diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..40ae52e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +__pycache__ +*.pyc +*.egg-info +.venv +venv +.pytest_cache +.mypy_cache +data diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79de454 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +# ova2vzdump — Debian bookworm + Proxmox no-subscription repo for the +# `vma` binary, plus qemu-utils for VMDK->raw conversion. +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg lsb-release \ + && install -d /etc/apt/keyrings \ + && curl -fsSL https://enterprise.proxmox.com/debian/proxmox-release-bookworm.gpg \ + -o /etc/apt/keyrings/proxmox-release-bookworm.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/proxmox-release-bookworm.gpg] \ + http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \ + > /etc/apt/sources.list.d/pve.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + pve-qemu-kvm \ + zstd \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +# pve-qemu-kvm provides its own qemu-img and the vma binary; confirm both +# are on PATH so the later conversion steps can find them. +RUN command -v qemu-img && command -v vma + +# Pre-cache a small bootable Alpine raw image so `create-test-ova --bootable` +# can produce a real OVA in seconds without re-downloading. Pinned version + +# checksum keeps builds reproducible. +ARG ALPINE_VERSION=3.22.4 +ARG ALPINE_FILE=gcp_alpine-3.22.4-x86_64-bios-tiny-r0.raw.tar.gz +ARG ALPINE_URL=https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud +RUN set -eux \ + && mkdir -p /app/fixtures /tmp/alpine-extract \ + && cd /tmp \ + && curl -fsSL -o "${ALPINE_FILE}" "${ALPINE_URL}/${ALPINE_FILE}" \ + && curl -fsSL -o "${ALPINE_FILE}.hash" "${ALPINE_URL}/${ALPINE_FILE}.sha512" \ + # Alpine publishes bare hex hashes (no filename). Build a proper + # sha512sum-compatible line so we can verify with `-c`. + && printf '%s %s\n' "$(tr -d '[:space:]' < "${ALPINE_FILE}.hash")" "${ALPINE_FILE}" \ + > "${ALPINE_FILE}.sha512" \ + && sha512sum -c "${ALPINE_FILE}.sha512" \ + && tar -xzf "${ALPINE_FILE}" -C /tmp/alpine-extract \ + && raw="$(find /tmp/alpine-extract -maxdepth 2 -name '*.raw' -print -quit)" \ + && test -n "$raw" || (echo "no .raw file inside tarball" && exit 1) \ + && mv "$raw" /app/fixtures/alpine-base.raw \ + && rm -rf /tmp/alpine-extract "${ALPINE_FILE}" "${ALPINE_FILE}.hash" "${ALPINE_FILE}.sha512" \ + && ls -la /app/fixtures/ + +WORKDIR /app +COPY pyproject.toml requirements.txt README.md ./ +COPY src ./src +COPY scripts ./scripts + +RUN pip install --no-cache-dir --break-system-packages . \ + && mkdir -p /data/uploads /data/output + +VOLUME ["/data"] + +EXPOSE 8080 + +ENV OVA2VZDUMP_ALPINE_BASE=/app/fixtures/alpine-base.raw + +ENTRYPOINT ["ova2vzdump"] +CMD ["gui", "--upload-dir", "/data/uploads", "--output-dir", "/data/output"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..48b2f45 --- /dev/null +++ b/README.md @@ -0,0 +1,313 @@ +# ova2vzdump + +*[English](#english) · [Deutsch](#deutsch)* + + + +Convert OVA appliances (VirtualBox / VMware export) into **Proxmox +vzdump** `.vma.zst` backup files that can be restored with `qmrestore`. + +The tricky part is `vma`, the proprietary container format Proxmox uses +for QEMU backups. The `vma` binary only ships with Proxmox, so this tool +runs inside a Docker image that installs `pve-qemu-kvm` from the Proxmox +no-subscription apt repo. That way you can convert appliances on any +machine with Docker — no PVE host required. + +## Pipeline + +1. Extract OVA tar → OVF descriptor + VMDK disk(s) +2. Parse OVF XML → CPU, memory, disks, NICs +3. `qemu-img convert` each VMDK → raw +4. Synthesize a `qemu-server.conf` +5. `vma create` packs raws + conf → `.vma` +6. `zstd` compresses → `vzdump-qemu--.vma.zst` + +## Usage + +### Build the image + +```bash +docker compose build +``` + +### Web UI + +```bash +docker compose up +# browse http://localhost:8880 +``` + +Uploads land in `./data/uploads`, results in `./data/output`. + +### Getting a test OVA + +ova2vzdump ships a built-in test OVA generator — both the CLI and the +web UI have a dedicated entry point. Two modes: + +- **stub** (default, ~1 KB): valid OVA with an empty VMDK. Fast, exercises + the full `extract → parse → qemu-img → vma → zstd` pipeline. Not bootable. +- **bootable** (~80 MB): packages a pre-cached **Alpine Linux** raw image + (downloaded during `docker build`, sha512-verified). After `qmrestore` + on Proxmox the VM actually boots to an Alpine login prompt — this is + what you want for a real end-to-end test. + +Existing test OVAs are reused by default; pass `--force` to rebuild. + +#### CLI + +```bash +# stub: +docker compose run --rm ova2vzdump create-test-ova \ + /data/uploads/test.ova --name demo + +# bootable Alpine: +docker compose run --rm ova2vzdump create-test-ova \ + /data/uploads/alpine.ova --name alpine --bootable + +# rebuild: +docker compose run --rm ova2vzdump create-test-ova \ + /data/uploads/test.ova --force +``` + +#### Web UI + +Click the **Create test OVA** tab, pick a name, tick **Bootable** if you +want real Alpine, hit Create. The list at the bottom shows what's already +in the upload directory and offers direct download. The generated OVA +also appears as an available input on the **Convert** tab. + +If you'd rather grab a real-world appliance from elsewhere: + +- **TurnKey Linux Core** (~250 MB): + [turnkeylinux.org/core](https://www.turnkeylinux.org/core) → VM build. +- **DIY**: export any small VM from VirtualBox via + `File → Export Appliance…` → `.ova`. + +### CLI + +```bash +docker run --rm \ + -v "$PWD/data/uploads:/in" \ + -v "$PWD/data/output:/out" \ + ova2vzdump:latest convert /in/appliance.ova /out \ + --vmid 123 --storage local-lvm +``` + +Output filename looks like `vzdump-qemu-123-2026_04_21-12_30_00.vma.zst`. + +### Restore on Proxmox + +Copy the `.vma.zst` onto a PVE node into a backup-capable storage (for +example `/var/lib/vz/dump/`), then: + +```bash +qmrestore vzdump-qemu-123-2026_04_21-12_30_00.vma.zst 123 --storage local-lvm +``` + +## Options + +| Flag | Meaning | Default | +| ---- | ------- | ------- | +| `--vmid` | **Placeholder** VMID — overridden at restore time | `100` | +| `--storage` | **Placeholder** storage name in qemu-server.conf — overridden by `qmrestore --storage` | `local-lvm` | +| `--keep-workdir` | Keep intermediate raw disks + vma for debugging | off | + +> `qmrestore [--storage ]` always decides the +> real VMID and storage. The values baked into the archive are only +> placeholders; you can leave the defaults for most use cases and only +> tweak them if you want a meaningful filename like +> `vzdump-qemu--*.vma.zst`. + +## Config mapping + +| OVF | qemu-server.conf | +| --- | ---------------- | +| `VirtualHardwareSection` CPU quantity | `cores` | +| `VirtualHardwareSection` memory | `memory` (MiB) | +| OS description heuristic (`linux`/`windows`/…) | `ostype` (`l26` / `win10` / `other`) | +| Disks w/ SCSI controller | `scsiN` + `scsihw: virtio-scsi-pci` | +| Disks w/ SATA controller | `sataN` | +| Disks w/ IDE controller | `ideN` | +| Ethernet subtype | `netN: =auto,bridge=vmbr0` | + +Heuristics are conservative; review the generated config after restore +before booting production workloads. + +## Limitations + +- OVF 1.x / 2.x only (DMTF envelope namespace). +- `manifest`, `cert`, and signed OVAs are not verified. +- Firmware (BIOS vs. UEFI) is not read from OVF — defaults to SeaBIOS. + Edit the config after restore if your appliance needs OVMF. +- MAC addresses are not preserved (Proxmox assigns new ones on import). + +## Local development (without Docker) + +You need `qemu-img`, `vma`, and `zstd` on your PATH — i.e. you must be on +a Proxmox host. Then: + +```bash +pip install -e . +ova2vzdump convert appliance.ova ./out +``` + +--- + + + +# ova2vzdump (Deutsch) + +Konvertiert OVA-Appliances (Export aus VirtualBox / VMware) in **Proxmox +vzdump** `.vma.zst`-Backup-Dateien, die sich mit `qmrestore` direkt +zurückspielen lassen. + +Der knifflige Teil ist `vma` — das proprietäre Container-Format, in dem +Proxmox seine QEMU-Backups speichert. Die `vma`-Binary gibt es nur mit +Proxmox, deshalb läuft dieses Tool in einem Docker-Image, das +`pve-qemu-kvm` aus dem Proxmox-No-Subscription-Apt-Repo installiert. +Dadurch kannst du Appliances auf jeder Maschine mit Docker konvertieren — +ganz ohne PVE-Host. + +## Pipeline + +1. OVA-Tar entpacken → OVF-Descriptor + VMDK-Disk(s) +2. OVF-XML parsen → CPU, RAM, Disks, NICs +3. Jede VMDK mit `qemu-img convert` nach raw konvertieren +4. Eine `qemu-server.conf` generieren +5. `vma create` packt raws + config → `.vma` +6. `zstd` komprimiert → `vzdump-qemu--.vma.zst` + +## Benutzung + +### Image bauen + +```bash +docker compose build +``` + +### Web-UI + +```bash +docker compose up +# dann http://localhost:8880 öffnen +``` + +Uploads landen in `./data/uploads`, Ergebnisse in `./data/output`. + +### Test-OVA bekommen + +ova2vzdump bringt einen eingebauten Test-OVA-Generator mit — sowohl die +CLI als auch die Web-UI haben einen eigenen Einstiegspunkt. Zwei Modi: + +- **stub** (Default, ~1 KB): valide OVA mit leerer VMDK. Schnell, testet + die komplette Pipeline `extract → parse → qemu-img → vma → zstd`. + Nicht bootbar. +- **bootable** (~80 MB): verpackt ein vorgecachtes **Alpine-Linux**-Raw- + Image (wird beim `docker build` heruntergeladen, sha512-verifiziert). + Nach `qmrestore` auf Proxmox bootet die VM tatsächlich in einen + Alpine-Login — das ist der echte End-to-End-Test. + +Vorhandene Test-OVAs werden standardmäßig wiederverwendet; `--force` baut +neu. + +#### CLI + +```bash +# Stub: +docker compose run --rm ova2vzdump create-test-ova \ + /data/uploads/test.ova --name demo + +# Bootbares Alpine: +docker compose run --rm ova2vzdump create-test-ova \ + /data/uploads/alpine.ova --name alpine --bootable + +# Neu bauen: +docker compose run --rm ova2vzdump create-test-ova \ + /data/uploads/test.ova --force +``` + +#### Web-UI + +Auf den Tab **Create test OVA** klicken, Name wählen, Häkchen bei +**Bootable** setzen wenn du echtes Alpine willst, auf Create klicken. Die +Liste unten zeigt, was im Upload-Verzeichnis liegt, mit direkten +Download-Buttons. Die erzeugte OVA erscheint auch im **Convert**-Tab als +auswählbare Eingabe. + +Lieber eine echte Appliance von woanders? + +- **TurnKey Linux Core** (~250 MB): + [turnkeylinux.org/core](https://www.turnkeylinux.org/core) → VM-Build. +- **DIY**: irgendeine kleine VM aus VirtualBox exportieren via + `Datei → Appliance exportieren…` → `.ova`. + +### CLI (Konvertierung) + +```bash +docker run --rm \ + -v "$PWD/data/uploads:/in" \ + -v "$PWD/data/output:/out" \ + ova2vzdump:latest convert /in/appliance.ova /out \ + --vmid 123 --storage local-lvm +``` + +Der Ausgabedateiname sieht etwa so aus: +`vzdump-qemu-123-2026_04_21-12_30_00.vma.zst`. + +### Restore auf Proxmox + +Die `.vma.zst` auf einen PVE-Node in ein backup-fähiges Storage kopieren +(z.B. `/var/lib/vz/dump/`), dann: + +```bash +qmrestore vzdump-qemu-123-2026_04_21-12_30_00.vma.zst 123 --storage local-lvm +``` + +## Optionen + +| Flag | Bedeutung | Default | +| ---- | --------- | ------- | +| `--vmid` | **Platzhalter**-VMID — wird beim Restore überschrieben | `100` | +| `--storage` | **Platzhalter**-Storage-Name in `qemu-server.conf` — wird durch `qmrestore --storage` überschrieben | `local-lvm` | +| `--keep-workdir` | Zwischenergebnisse (raw-Disks, vma) behalten, zum Debuggen | aus | + +> `qmrestore [--storage ]` entscheidet immer die +> echte VMID und das Storage. Die Werte im Archiv sind nur Platzhalter — +> Defaults reichen meist. Du passt sie höchstens an, wenn du einen +> sprechenden Dateinamen haben willst, z.B. +> `vzdump-qemu--*.vma.zst`. + +## Config-Mapping + +| OVF | qemu-server.conf | +| --- | ---------------- | +| `VirtualHardwareSection` CPU-Anzahl | `cores` | +| `VirtualHardwareSection` RAM | `memory` (MiB) | +| OS-Description-Heuristik (`linux`/`windows`/…) | `ostype` (`l26` / `win10` / `other`) | +| Disks am SCSI-Controller | `scsiN` + `scsihw: virtio-scsi-pci` | +| Disks am SATA-Controller | `sataN` | +| Disks am IDE-Controller | `ideN` | +| Ethernet-Subtyp | `netN: =auto,bridge=vmbr0` | + +Die Heuristiken sind konservativ; bitte die generierte Config nach dem +Restore einmal prüfen, bevor du produktive Workloads startest. + +## Limitierungen + +- Nur OVF 1.x / 2.x (DMTF-Envelope-Namespace). +- `manifest`, `cert` und signierte OVAs werden nicht verifiziert. +- Firmware (BIOS vs. UEFI) wird nicht aus der OVF gelesen — Default ist + SeaBIOS. Config nach dem Restore manuell anpassen, falls deine + Appliance OVMF braucht. +- MAC-Adressen werden nicht übernommen (Proxmox vergibt beim Import + neue). + +## Lokale Entwicklung (ohne Docker) + +Du brauchst `qemu-img`, `vma` und `zstd` im PATH — also de facto einen +Proxmox-Host. Dann: + +```bash +pip install -e . +ova2vzdump convert appliance.ova ./out +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8b59b2c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + ova2vzdump: + build: . + image: ova2vzdump:latest + container_name: ova2vzdump + ports: + - "8889:8080" + volumes: + - ./data/uploads:/data/uploads + - ./data/output:/data/output + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..836abf8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ova2vzdump" +version = "0.1.0" +description = "Convert OVA (VirtualBox/VMware) templates into Proxmox vzdump (.vma.zst) backups" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Stefan Hacker", email = "stefanhacker@gmx.de" }] +license = { text = "MIT" } +dependencies = [ + "click>=8.1", + "flask>=3.0", + "lxml>=5.1", +] + +[project.scripts] +ova2vzdump = "ova2vzdump.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +ova2vzdump = ["templates/*.html", "static/*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..467bed1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +click>=8.1 +flask>=3.0 +lxml>=5.1 diff --git a/scripts/make_test_ova.py b/scripts/make_test_ova.py new file mode 100644 index 0000000..791e800 --- /dev/null +++ b/scripts/make_test_ova.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Thin CLI wrapper so folks can run the test-OVA builder without having +the package installed in their active env. Prefer `ova2vzdump create-test-ova` +when possible.""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from ova2vzdump.test_ova import create_test_ova # noqa: E402 + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("output", type=Path) + ap.add_argument("--name", default="test-vm") + ap.add_argument("--size-mib", type=int, default=64) + ap.add_argument("--bootable", action="store_true", + help="Package pre-cached bootable Alpine image") + ap.add_argument("--force", action="store_true", + help="Overwrite existing output file") + args = ap.parse_args() + + r = create_test_ova( + output=args.output, vm_name=args.name, size_mib=args.size_mib, + bootable=args.bootable, force=args.force, + ) + if r.reused: + print(f"reused existing {r.path} ({r.bytes} bytes) " + "(pass --force to rebuild)") + else: + kind = "bootable Alpine" if r.bootable else "empty stub" + print(f"wrote {r.path} ({r.bytes} bytes, {kind})") + + +if __name__ == "__main__": + main() diff --git a/src/ova2vzdump/__init__.py b/src/ova2vzdump/__init__.py new file mode 100644 index 0000000..ff2e10f --- /dev/null +++ b/src/ova2vzdump/__init__.py @@ -0,0 +1,3 @@ +"""ova2vzdump — convert OVA templates to Proxmox vzdump (.vma.zst) backups.""" + +__version__ = "0.1.0" diff --git a/src/ova2vzdump/__main__.py b/src/ova2vzdump/__main__.py new file mode 100644 index 0000000..77efed4 --- /dev/null +++ b/src/ova2vzdump/__main__.py @@ -0,0 +1,4 @@ +from ova2vzdump.cli import main + +if __name__ == "__main__": + main() diff --git a/src/ova2vzdump/cli.py b/src/ova2vzdump/cli.py new file mode 100644 index 0000000..3365fcb --- /dev/null +++ b/src/ova2vzdump/cli.py @@ -0,0 +1,112 @@ +"""Command-line entry point for ova2vzdump.""" +from __future__ import annotations + +import logging +import sys +from pathlib import Path + +import click + +from .converter import convert + + +@click.group() +@click.option("-v", "--verbose", count=True, help="-v info, -vv debug") +def main(verbose: int) -> None: + level = logging.WARNING + if verbose == 1: + level = logging.INFO + elif verbose >= 2: + level = logging.DEBUG + logging.basicConfig( + level=level, format="%(asctime)s %(levelname)s %(name)s: %(message)s" + ) + + +@main.command("convert") +@click.argument("ova", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.argument("output_dir", type=click.Path(file_okay=False, path_type=Path)) +@click.option("--vmid", type=int, default=100, show_default=True, + help="Placeholder VMID in filename/config. Overridden at " + "restore time by `qmrestore `.") +@click.option("--storage", default="local-lvm", show_default=True, + help="Placeholder storage name in qemu-server.conf. " + "Override at restore time with `qmrestore --storage`.") +@click.option("--keep-workdir", is_flag=True, + help="Keep intermediate raw disks and vma file for debugging") +def convert_cmd(ova: Path, output_dir: Path, vmid: int, storage: str, + keep_workdir: bool) -> None: + """Convert an OVA appliance into a vzdump .vma.zst backup.""" + def progress(stage: str, pct: float) -> None: + click.echo(f" [{stage}] {pct:5.1f}%", err=True) + + try: + result = convert( + ova_path=ova, output_dir=output_dir, vmid=vmid, storage=storage, + progress=progress, keep_workdir=keep_workdir, + ) + except Exception as exc: + click.echo(f"error: {exc}", err=True) + sys.exit(1) + + click.echo(f"OK: wrote {result.output_path}") + click.echo(f" VM '{result.vm_name}', vmid={result.vmid}, " + f"{result.disk_count} disk(s), " + f"{result.total_bytes / 1024**3:.2f} GiB raw total") + click.echo("Restore on Proxmox:") + click.echo(f" qmrestore {result.output_path.name} {result.vmid} " + f"--storage {storage}") + + +@main.command("gui") +@click.option("--host", default="0.0.0.0", show_default=True) +@click.option("--port", type=int, default=8080, show_default=True) +@click.option("--upload-dir", type=click.Path(path_type=Path), + default=Path("/data/uploads"), show_default=True) +@click.option("--output-dir", type=click.Path(path_type=Path), + default=Path("/data/output"), show_default=True) +def gui_cmd(host: str, port: int, upload_dir: Path, output_dir: Path) -> None: + """Launch the Flask web UI.""" + from .web import create_app + upload_dir.mkdir(parents=True, exist_ok=True) + output_dir.mkdir(parents=True, exist_ok=True) + app = create_app(upload_dir=upload_dir, output_dir=output_dir) + app.run(host=host, port=port, threaded=True) + + +@main.command("create-test-ova") +@click.argument("output", type=click.Path(path_type=Path)) +@click.option("--name", default="test-vm", show_default=True, + help="VM name embedded in the OVF") +@click.option("--size-mib", type=int, default=64, show_default=True, + help="Disk capacity (ignored when --bootable)") +@click.option("--bootable", is_flag=True, + help="Package a real bootable Alpine image " + "(cached during docker build)") +@click.option("--force", is_flag=True, + help="Overwrite an existing file instead of reusing it") +def create_test_ova_cmd(output: Path, name: str, size_mib: int, + bootable: bool, force: bool) -> None: + """Build a small test OVA for smoke-testing the converter.""" + from .test_ova import create_test_ova + try: + r = create_test_ova( + output=output, vm_name=name, size_mib=size_mib, + bootable=bootable, force=force, + ) + except Exception as exc: + click.echo(f"error: {exc}", err=True) + sys.exit(1) + + if r.reused: + click.echo( + f"reused existing {r.path} ({r.bytes / 1024:.1f} KiB) " + "— pass --force to rebuild" + ) + else: + kind = "bootable Alpine" if r.bootable else "empty stub" + click.echo(f"wrote {r.path} ({r.bytes / 1024:.1f} KiB, {kind})") + + +if __name__ == "__main__": + main() diff --git a/src/ova2vzdump/config_mapper.py b/src/ova2vzdump/config_mapper.py new file mode 100644 index 0000000..aa0c5da --- /dev/null +++ b/src/ova2vzdump/config_mapper.py @@ -0,0 +1,97 @@ +"""Map an OvfVm into a Proxmox qemu-server.conf string. + +Proxmox's config format is a simple KEY: VALUE text file with disk entries +like `scsi0: :vm--disk-0,size=8G`. For a VMA archive we +also need special `#qmdump#map:VIRTDEV:DEVNAME:STOREID:FORMAT:` comment +lines — without them, `qmrestore` aborts with "found no device mapping +information for device 'drive-XXX'". +""" +from __future__ import annotations + +from .ovf_parser import OvfDisk, OvfVm + +OS_HINT_MAP = [ + ("windows", "win10"), + ("win", "win10"), + ("debian", "l26"), + ("ubuntu", "l26"), + ("centos", "l26"), + ("redhat", "l26"), + ("suse", "l26"), + ("fedora", "l26"), + ("linux", "l26"), + ("bsd", "other"), +] + + +def _guess_ostype(os_desc: str | None) -> str: + if not os_desc: + return "l26" + s = os_desc.lower() + for needle, ostype in OS_HINT_MAP: + if needle in s: + return ostype + return "other" + + +def _bytes_to_gib(b: int) -> int: + gib = b // (1024**3) + return max(1, gib) + + +def assign_device_names(disks: list[OvfDisk]) -> list[tuple[str, str]]: + """Return (virtdev, devname) per disk, e.g. ('scsi0', 'drive-scsi0').""" + scsi_idx = sata_idx = ide_idx = 0 + result: list[tuple[str, str]] = [] + for disk in disks: + if disk.controller == "sata": + virtdev = f"sata{sata_idx}"; sata_idx += 1 + elif disk.controller == "ide": + virtdev = f"ide{ide_idx}"; ide_idx += 1 + else: + virtdev = f"scsi{scsi_idx}"; scsi_idx += 1 + result.append((virtdev, f"drive-{virtdev}")) + return result + + +def build_qemu_conf(vm: OvfVm, vmid: int, storage: str = "local-lvm") -> str: + devices = assign_device_names(vm.disks) + + lines: list[str] = [] + lines.append(f"#ova2vzdump import of {vm.name}") + if vm.os_type: + lines.append(f"#source os: {vm.os_type}") + + # qmdump map lines — REQUIRED for `qmrestore`, otherwise vma extract + # errors out with "found no device mapping information for device 'drive-XXX'". + for (virtdev, devname), _disk in zip(devices, vm.disks): + lines.append(f"#qmdump#map:{virtdev}:{devname}:{storage}:raw:") + + lines.append(f"name: {_sanitize_name(vm.name)}") + lines.append(f"cores: {vm.cpus}") + lines.append(f"memory: {vm.memory_mb}") + lines.append(f"ostype: {_guess_ostype(vm.os_type)}") + boot_dev = devices[0][0] if devices else "scsi0" + lines.append(f"boot: order={boot_dev};net0") + lines.append("scsihw: virtio-scsi-pci") + lines.append("agent: 1") + + for i, ((virtdev, _), disk) in enumerate(zip(devices, vm.disks)): + size_gib = _bytes_to_gib(disk.capacity_bytes) + volume = f"{storage}:vm-{vmid}-disk-{i}" + lines.append(f"{virtdev}: {volume},size={size_gib}G") + + for i, nic in enumerate(vm.nics): + # Format is `[=]` — omit the `=` part to let + # Proxmox auto-assign. `=auto` is NOT a valid placeholder; it + # would be parsed as a MAC literal and rejected. + lines.append(f"net{i}: {nic.model},bridge=vmbr0,firewall=1") + + lines.append("") + return "\n".join(lines) + + +def _sanitize_name(name: str) -> str: + safe = "".join(c if c.isalnum() or c in "-_" else "-" for c in name) + safe = safe.strip("-") or "imported-vm" + return safe[:63] diff --git a/src/ova2vzdump/converter.py b/src/ova2vzdump/converter.py new file mode 100644 index 0000000..c7812c2 --- /dev/null +++ b/src/ova2vzdump/converter.py @@ -0,0 +1,155 @@ +"""Orchestrate the full OVA -> vzdump pipeline. + +Pipeline: + 1. Extract OVA tar into workdir + 2. Parse OVF + 3. Convert each VMDK to raw (vma requires raw inputs) + 4. Write qemu-server.conf + 5. Call `vma create` with raw disks + config -> vma file + 6. Compress with zstd -> vzdump-qemu--.vma.zst +""" +from __future__ import annotations + +import logging +import shutil +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from .config_mapper import assign_device_names, build_qemu_conf +from .ova_extractor import extract_ova +from .ovf_parser import parse_ovf + +log = logging.getLogger(__name__) + +ProgressFn = Callable[[str, float], None] + + +@dataclass +class ConversionResult: + output_path: Path + vm_name: str + vmid: int + disk_count: int + total_bytes: int + + +def _noop_progress(stage: str, pct: float) -> None: + log.info("[%s] %.1f%%", stage, pct) + + +def _run(cmd: list[str], **kwargs) -> None: + """Run a subprocess and re-raise with captured stderr on failure.""" + log.info("run: %s", " ".join(str(c) for c in cmd)) + try: + subprocess.run(cmd, check=True, **kwargs) + except subprocess.CalledProcessError as exc: + tail = "" + if exc.stderr: + tail_bytes = exc.stderr if isinstance(exc.stderr, bytes) else exc.stderr.encode() + tail = "\n" + tail_bytes.decode(errors="replace").strip() + raise RuntimeError( + f"{cmd[0]} exited {exc.returncode}: " + f"{' '.join(str(c) for c in cmd)}{tail}" + ) from exc + + +def _require_tool(name: str) -> str: + path = shutil.which(name) + if not path: + raise RuntimeError( + f"Required tool '{name}' not found in PATH. " + "Run inside the ova2vzdump Docker image, which ships it." + ) + return path + + +def convert( + ova_path: Path, + output_dir: Path, + vmid: int = 100, + storage: str = "local-lvm", + workdir: Optional[Path] = None, + progress: ProgressFn = _noop_progress, + keep_workdir: bool = False, +) -> ConversionResult: + _require_tool("qemu-img") + _require_tool("vma") + _require_tool("zstd") + + ova_path = Path(ova_path).resolve() + output_dir = Path(output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + wd = Path(workdir) if workdir else output_dir / f".work-{int(time.time())}" + wd.mkdir(parents=True, exist_ok=True) + + try: + progress("extract", 0.0) + extracted = extract_ova(ova_path, wd / "ova") + progress("extract", 100.0) + + progress("parse", 0.0) + vm = parse_ovf(extracted.ovf_path) + progress("parse", 100.0) + log.info( + "Parsed VM '%s': %d CPU, %d MiB RAM, %d disks, %d NICs", + vm.name, vm.cpus, vm.memory_mb, len(vm.disks), len(vm.nics), + ) + + raw_dir = wd / "raw" + raw_dir.mkdir(exist_ok=True) + raw_disks: list[Path] = [] + total_bytes = 0 + for idx, disk in enumerate(vm.disks): + src_file = extracted.files.get(disk.file_href) + if not src_file or not src_file.exists(): + raise ValueError( + f"Disk file '{disk.file_href}' missing from OVA" + ) + raw_path = raw_dir / f"disk-{idx}.raw" + progress(f"convert-disk-{idx}", 0.0) + _run([ + "qemu-img", "convert", "-p", "-O", "raw", + str(src_file), str(raw_path), + ]) + total_bytes += raw_path.stat().st_size + raw_disks.append(raw_path) + progress(f"convert-disk-{idx}", 100.0) + + conf_path = wd / "qemu-server.conf" + conf_path.write_text(build_qemu_conf(vm, vmid=vmid, storage=storage)) + + progress("vma-create", 0.0) + vma_path = wd / f"vzdump-qemu-{vmid}.vma" + # `-c ` — vma derives the archive entry name from basename, + # so the conf file must already be named `qemu-server.conf`. + # Drives are positional `=`, e.g. + # `drive-scsi0=/tmp/disk-0.raw` (no `.raw` in the device name!). + devices = assign_device_names(vm.disks) + vma_cmd = ["vma", "create", "-v", str(vma_path), + "-c", str(conf_path)] + for (_, devname), raw_path in zip(devices, raw_disks): + vma_cmd.append(f"{devname}={raw_path}") + _run(vma_cmd, capture_output=True) + progress("vma-create", 100.0) + + progress("compress", 0.0) + ts = time.strftime("%Y_%m_%d-%H_%M_%S") + out_name = f"vzdump-qemu-{vmid}-{ts}.vma.zst" + out_path = output_dir / out_name + _run(["zstd", "-T0", "--rm", "-o", str(out_path), str(vma_path)]) + progress("compress", 100.0) + + return ConversionResult( + output_path=out_path, + vm_name=vm.name, + vmid=vmid, + disk_count=len(raw_disks), + total_bytes=total_bytes, + ) + finally: + if not keep_workdir and wd.exists(): + shutil.rmtree(wd, ignore_errors=True) diff --git a/src/ova2vzdump/ova_extractor.py b/src/ova2vzdump/ova_extractor.py new file mode 100644 index 0000000..f0268cc --- /dev/null +++ b/src/ova2vzdump/ova_extractor.py @@ -0,0 +1,52 @@ +"""Extract an OVA tarball into a working directory. + +An OVA is a plain (uncompressed) tar. We stream-extract it so memory stays +flat even for 50-GB appliances. Returns paths to the OVF descriptor and a +map of file href -> extracted path. +""" +from __future__ import annotations + +import tarfile +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class ExtractedOva: + ovf_path: Path + files: dict[str, Path] + workdir: Path + + +def extract_ova(ova_path: Path, workdir: Path) -> ExtractedOva: + workdir.mkdir(parents=True, exist_ok=True) + ovf_path: Path | None = None + files: dict[str, Path] = {} + + with tarfile.open(ova_path, mode="r|*") as tar: + for member in tar: + if not member.isfile(): + continue + name = Path(member.name).name + target = workdir / name + if _is_unsafe(target, workdir): + raise ValueError(f"Refusing unsafe tar entry: {member.name}") + with tar.extractfile(member) as src, open(target, "wb") as dst: + while chunk := src.read(1024 * 1024): + dst.write(chunk) + files[name] = target + if target.suffix.lower() == ".ovf": + ovf_path = target + + if ovf_path is None: + raise ValueError(f"No .ovf descriptor found inside {ova_path}") + + return ExtractedOva(ovf_path=ovf_path, files=files, workdir=workdir) + + +def _is_unsafe(target: Path, base: Path) -> bool: + try: + target.resolve().relative_to(base.resolve()) + return False + except ValueError: + return True diff --git a/src/ova2vzdump/ovf_parser.py b/src/ova2vzdump/ovf_parser.py new file mode 100644 index 0000000..af388a4 --- /dev/null +++ b/src/ova2vzdump/ovf_parser.py @@ -0,0 +1,169 @@ +"""Parse OVF 1.x / 2.x XML descriptors into a simple VM dataclass. + +Covers the subset of the DMTF OVF spec that VirtualBox and VMware actually +emit: System name, CPU/memory, disks (Disk + File refs), and network +adapters. Unknown resources are ignored rather than rejected — real-world +OVFs contain a lot of optional cruft. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from lxml import etree + +NS = { + "ovf": "http://schemas.dmtf.org/ovf/envelope/1", + "rasd": "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData", + "vssd": "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", +} + +RESOURCE_TYPE_CPU = "3" +RESOURCE_TYPE_MEMORY = "4" +RESOURCE_TYPE_ETHERNET = "10" +RESOURCE_TYPE_DISK = "17" + + +@dataclass +class OvfDisk: + disk_id: str + file_ref: str + file_href: str + capacity_bytes: int + controller: str = "scsi" + + +@dataclass +class OvfNic: + name: str + network: str + model: str = "virtio" + + +@dataclass +class OvfVm: + name: str + os_type: Optional[str] = None + cpus: int = 1 + memory_mb: int = 512 + disks: list[OvfDisk] = field(default_factory=list) + nics: list[OvfNic] = field(default_factory=list) + + +def _text(elem, xpath: str) -> Optional[str]: + result = elem.xpath(xpath, namespaces=NS) + if not result: + return None + if isinstance(result[0], str): + return result[0].strip() or None + return (result[0].text or "").strip() or None + + +def _parse_capacity(disk_elem) -> int: + capacity = disk_elem.get(f"{{{NS['ovf']}}}capacity") or "0" + units = disk_elem.get(f"{{{NS['ovf']}}}capacityAllocationUnits") or "byte" + multiplier = 1 + u = units.lower().replace(" ", "") + if "byte*2^10" in u or u == "kb" or u == "kib": + multiplier = 1024 + elif "byte*2^20" in u or u == "mb" or u == "mib": + multiplier = 1024**2 + elif "byte*2^30" in u or u == "gb" or u == "gib": + multiplier = 1024**3 + elif "byte*2^40" in u or u == "tb" or u == "tib": + multiplier = 1024**4 + return int(capacity) * multiplier + + +def parse_ovf(ovf_path: Path) -> OvfVm: + tree = etree.parse(str(ovf_path)) + root = tree.getroot() + + files = { + f.get(f"{{{NS['ovf']}}}id"): f.get(f"{{{NS['ovf']}}}href") + for f in root.xpath(".//ovf:References/ovf:File", namespaces=NS) + } + + disks_by_id: dict[str, OvfDisk] = {} + for d in root.xpath(".//ovf:DiskSection/ovf:Disk", namespaces=NS): + disk_id = d.get(f"{{{NS['ovf']}}}diskId") + file_ref = d.get(f"{{{NS['ovf']}}}fileRef") + capacity = _parse_capacity(d) + disks_by_id[disk_id] = OvfDisk( + disk_id=disk_id, + file_ref=file_ref, + file_href=files.get(file_ref, ""), + capacity_bytes=capacity, + ) + + vsys = root.xpath(".//ovf:VirtualSystem", namespaces=NS) + if not vsys: + raise ValueError(f"No VirtualSystem in {ovf_path}") + vsys = vsys[0] + + name = vsys.get(f"{{{NS['ovf']}}}id") or "imported-vm" + name = _text(vsys, "./ovf:Name/text()") or name + + os_type = None + os_section = vsys.xpath("./ovf:OperatingSystemSection", namespaces=NS) + if os_section: + os_type = _text(os_section[0], "./ovf:Description/text()") + + vm = OvfVm(name=name, os_type=os_type) + + controllers: dict[str, str] = {} + items = vsys.xpath( + "./ovf:VirtualHardwareSection/ovf:Item", namespaces=NS + ) + for item in items: + rtype = _text(item, "./rasd:ResourceType/text()") + instance_id = _text(item, "./rasd:InstanceID/text()") + subtype = (_text(item, "./rasd:ResourceSubType/text()") or "").lower() + if rtype in ("5", "6", "20"): + if "sata" in subtype: + controllers[instance_id] = "sata" + elif "ide" in subtype: + controllers[instance_id] = "ide" + else: + controllers[instance_id] = "scsi" + + for item in items: + rtype = _text(item, "./rasd:ResourceType/text()") + if rtype == RESOURCE_TYPE_CPU: + vm.cpus = int(_text(item, "./rasd:VirtualQuantity/text()") or "1") + elif rtype == RESOURCE_TYPE_MEMORY: + vm.memory_mb = int( + _text(item, "./rasd:VirtualQuantity/text()") or "512" + ) + elif rtype == RESOURCE_TYPE_ETHERNET: + nic_name = _text(item, "./rasd:ElementName/text()") or "net0" + network = _text(item, "./rasd:Connection/text()") or "default" + subtype = _text(item, "./rasd:ResourceSubType/text()") or "" + model = _map_nic_model(subtype) + vm.nics.append(OvfNic(name=nic_name, network=network, model=model)) + elif rtype == RESOURCE_TYPE_DISK: + host_resource = _text(item, "./rasd:HostResource/text()") or "" + disk_key = host_resource.rsplit("/", 1)[-1] + if disk_key in disks_by_id: + disk = disks_by_id[disk_key] + parent = _text(item, "./rasd:Parent/text()") + if parent and parent in controllers: + disk.controller = controllers[parent] + vm.disks.append(disk) + + return vm + + +def _map_nic_model(subtype: str) -> str: + s = subtype.lower() + if "virtio" in s: + return "virtio" + if "e1000" in s: + return "e1000" + if "vmxnet" in s: + return "vmxnet3" + if "pcnet" in s: + return "rtl8139" + return "virtio" diff --git a/src/ova2vzdump/templates/index.html b/src/ova2vzdump/templates/index.html new file mode 100644 index 0000000..395503b --- /dev/null +++ b/src/ova2vzdump/templates/index.html @@ -0,0 +1,274 @@ + + + + + ova2vzdump + + + +

ova2vzdump

+

OVA → Proxmox vzdump (.vma.zst) converter

+ + + + +
+
+ +
+ + +
+

Both fields are just placeholders in the generated + config — the real values are picked at restore time via + qmrestore <file> <target-vmid> --storage <name>. + Defaults are fine in most cases.

+ +
+ +
+
Stage:
+ +
+
+
+
+ + + + + + + diff --git a/src/ova2vzdump/test_ova.py b/src/ova2vzdump/test_ova.py new file mode 100644 index 0000000..692f1fb --- /dev/null +++ b/src/ova2vzdump/test_ova.py @@ -0,0 +1,231 @@ +"""Build a small test OVA, either a hollow stub or a real bootable Alpine. + +Two modes: + - stub (default): ~1 KB metadata + empty VMDK. Exercises parser + + converter pipeline. Not bootable. + - bootable: packages a pre-cached Alpine tiny raw image (downloaded at + docker build time) as a stream-optimized VMDK inside the OVA. After + restore on Proxmox, the VM boots to an Alpine login prompt. +""" +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import tarfile +import tempfile +from dataclasses import dataclass +from pathlib import Path + +log = logging.getLogger(__name__) + +ALPINE_BASE_ENV = "OVA2VZDUMP_ALPINE_BASE" + + +OVF_TEMPLATE = """ + + + + + + Virtual disks + + + + Logical networks + + Default bridged network + + + + A virtual machine + {vm_name} + + Guest Operating System + {os_description} + + + Virtual hardware requirements + + Virtual Hardware Family + 0 + {vm_name} + vmx-10 + + + hertz * 10^6 + {cpus} virtual CPU(s) + 1 + 3 + {cpus} + + + byte * 2^20 + {memory_mb} MB of memory + 2 + 4 + {memory_mb} + + + 0 + SCSIController + 3 + VirtualSCSI + 6 + + + 0 + Hard Disk 1 + ovf:/disk/disk1 + 4 + 3 + 17 + + + 7 + true + VM Network + Network adapter 1 + 5 + E1000 + 10 + + + + +""" + + +@dataclass +class TestOvaResult: + path: Path + bytes: int + bootable: bool + reused: bool + + +def create_test_ova( + output: Path, + vm_name: str = "test-vm", + size_mib: int = 64, + bootable: bool = False, + force: bool = False, + alpine_base: Path | None = None, +) -> TestOvaResult: + """Create (or reuse) a test OVA. + + Returns reused=True when `output` already existed and force=False, so + callers can surface that to the user instead of silently regenerating. + """ + output = Path(output) + if output.exists() and not force: + return TestOvaResult( + path=output, bytes=output.stat().st_size, + bootable=bootable, reused=True, + ) + + if not _have("qemu-img"): + raise RuntimeError( + "qemu-img not found; run inside the ova2vzdump Docker image." + ) + + output.parent.mkdir(parents=True, exist_ok=True) + + if bootable: + base = alpine_base or Path(os.environ.get( + ALPINE_BASE_ENV, "/app/fixtures/alpine-base.raw" + )) + if not base.exists(): + raise RuntimeError( + f"Bootable Alpine base image not found at {base}. " + "Rebuild the Docker image or set " + f"${ALPINE_BASE_ENV}." + ) + _build_bootable(output, vm_name, base) + else: + _build_stub(output, vm_name, size_mib) + + return TestOvaResult( + path=output, bytes=output.stat().st_size, + bootable=bootable, reused=False, + ) + + +def _build_stub(output: Path, vm_name: str, size_mib: int) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmpdir = Path(tmp) + disk_name = f"{vm_name}-disk-0.vmdk" + disk_path = tmpdir / disk_name + capacity_bytes = size_mib * 1024 * 1024 + + subprocess.run( + ["qemu-img", "create", "-f", "vmdk", + "-o", "subformat=streamOptimized", + str(disk_path), f"{size_mib}M"], + check=True, capture_output=True, + ) + + _write_ovf_and_tar( + tmpdir, output, vm_name, disk_name, disk_path, + capacity_bytes=capacity_bytes, + os_description="Debian GNU/Linux 12 (64-bit)", + ) + + +def _build_bootable(output: Path, vm_name: str, base_raw: Path) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmpdir = Path(tmp) + disk_name = f"{vm_name}-disk-0.vmdk" + disk_path = tmpdir / disk_name + + subprocess.run( + ["qemu-img", "convert", "-O", "vmdk", + "-o", "subformat=streamOptimized", + str(base_raw), str(disk_path)], + check=True, capture_output=True, + ) + + # Capacity = virtual size of the raw image (not compressed VMDK size). + info = subprocess.run( + ["qemu-img", "info", "--output=json", str(base_raw)], + check=True, capture_output=True, text=True, + ) + import json + capacity_bytes = int(json.loads(info.stdout)["virtual-size"]) + + _write_ovf_and_tar( + tmpdir, output, vm_name, disk_name, disk_path, + capacity_bytes=capacity_bytes, + os_description="Alpine Linux 3.22 (64-bit)", + ) + + +def _write_ovf_and_tar(tmpdir: Path, output: Path, vm_name: str, + disk_name: str, disk_path: Path, + capacity_bytes: int, os_description: str, + cpus: int = 2, memory_mb: int = 1024) -> None: + disk_size = disk_path.stat().st_size + ovf_path = tmpdir / f"{vm_name}.ovf" + ovf_path.write_text(OVF_TEMPLATE.format( + vm_name=vm_name, disk_name=disk_name, disk_size=disk_size, + capacity_bytes=capacity_bytes, os_description=os_description, + cpus=cpus, memory_mb=memory_mb, + )) + # USTAR, not PAX. VirtualBox's OVA importer (VERR_TAR_UNSUPPORTED_PAX_TYPE) + # and many other tools reject Python's default PAX extended headers. + with tarfile.open(output, "w", format=tarfile.USTAR_FORMAT) as tar: + tar.add(ovf_path, arcname=ovf_path.name) + tar.add(disk_path, arcname=disk_name) + + +def _have(tool: str) -> bool: + return shutil.which(tool) is not None diff --git a/src/ova2vzdump/web.py b/src/ova2vzdump/web.py new file mode 100644 index 0000000..34865cb --- /dev/null +++ b/src/ova2vzdump/web.py @@ -0,0 +1,195 @@ +"""Flask web UI for ova2vzdump. + +Single-page app: upload an OVA, watch the conversion progress via Server- +Sent Events, download the resulting .vma.zst. Jobs run in background +threads; state is kept in memory (process-local). +""" +from __future__ import annotations + +import json +import logging +import threading +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from queue import Queue, Empty +from typing import Optional + +from flask import ( + Flask, Response, jsonify, render_template, request, send_from_directory, + stream_with_context, +) +from werkzeug.utils import secure_filename + +from .converter import convert +from .test_ova import create_test_ova + +log = logging.getLogger(__name__) + + +@dataclass +class Job: + id: str + ova_name: str + vmid: int + storage: str + state: str = "pending" + stage: str = "init" + progress: float = 0.0 + message: str = "" + output_name: Optional[str] = None + events: Queue = field(default_factory=Queue) + + def push(self, payload: dict) -> None: + self.events.put(payload) + + +_JOBS: dict[str, Job] = {} +_JOBS_LOCK = threading.Lock() + + +def _run_job(job: Job, ova_path: Path, output_dir: Path) -> None: + def progress(stage: str, pct: float) -> None: + job.stage = stage + job.progress = pct + job.push({"stage": stage, "progress": pct, "state": "running"}) + + job.state = "running" + job.push({"state": "running", "stage": "starting", "progress": 0.0}) + try: + result = convert( + ova_path=ova_path, output_dir=output_dir, + vmid=job.vmid, storage=job.storage, + progress=progress, + ) + job.state = "done" + job.output_name = result.output_path.name + job.message = ( + f"{result.vm_name}: {result.disk_count} disk(s), " + f"{result.total_bytes / 1024**3:.2f} GiB" + ) + job.push({ + "state": "done", "stage": "finished", "progress": 100.0, + "output": result.output_path.name, "message": job.message, + }) + except Exception as exc: + log.exception("job %s failed", job.id) + job.state = "error" + job.message = str(exc) + job.push({"state": "error", "message": str(exc)}) + finally: + try: + ova_path.unlink(missing_ok=True) + except OSError: + pass + + +def create_app(upload_dir: Path, output_dir: Path) -> Flask: + app = Flask(__name__) + app.config["MAX_CONTENT_LENGTH"] = 500 * 1024**3 + + @app.get("/") + def index() -> str: + return render_template("index.html") + + @app.post("/api/jobs") + def create_job(): + if "ova" not in request.files: + return jsonify(error="no file 'ova' in multipart form"), 400 + f = request.files["ova"] + if not f.filename: + return jsonify(error="empty filename"), 400 + vmid = int(request.form.get("vmid", "100")) + storage = request.form.get("storage", "local-lvm") + filename = secure_filename(f.filename) or "input.ova" + job_id = uuid.uuid4().hex + ova_path = upload_dir / f"{job_id}-{filename}" + f.save(ova_path) + + job = Job(id=job_id, ova_name=filename, vmid=vmid, storage=storage) + with _JOBS_LOCK: + _JOBS[job_id] = job + t = threading.Thread( + target=_run_job, args=(job, ova_path, output_dir), daemon=True, + ) + t.start() + return jsonify(id=job_id) + + @app.get("/api/jobs/") + def job_status(job_id: str): + job = _JOBS.get(job_id) + if not job: + return jsonify(error="not found"), 404 + return jsonify( + id=job.id, state=job.state, stage=job.stage, + progress=job.progress, message=job.message, + output=job.output_name, + ) + + @app.get("/api/jobs//events") + def job_events(job_id: str): + job = _JOBS.get(job_id) + if not job: + return jsonify(error="not found"), 404 + + @stream_with_context + def stream(): + yield f"data: {json.dumps({'state': job.state, 'stage': job.stage, 'progress': job.progress})}\n\n" + while True: + try: + evt = job.events.get(timeout=15) + except Empty: + yield ": keepalive\n\n" + continue + yield f"data: {json.dumps(evt)}\n\n" + if evt.get("state") in ("done", "error"): + return + + return Response(stream(), mimetype="text/event-stream") + + @app.get("/download/") + def download(name: str): + return send_from_directory(output_dir, name, as_attachment=True) + + @app.get("/api/test-ovas") + def list_test_ovas(): + items = [] + for p in sorted(upload_dir.glob("test-*.ova")): + items.append({"name": p.name, "bytes": p.stat().st_size}) + return jsonify(items=items) + + @app.post("/api/test-ovas") + def make_test_ova(): + data = request.get_json(silent=True) or request.form + name = secure_filename(data.get("name", "test-vm")) or "test-vm" + bootable = str(data.get("bootable", "")).lower() in ("1", "true", "on") + try: + size_mib = int(data.get("size_mib", 64)) + except (TypeError, ValueError): + return jsonify(error="size_mib must be an integer"), 400 + force = str(data.get("force", "")).lower() in ("1", "true", "on") + + suffix = "bootable-alpine" if bootable else f"stub-{size_mib}mib" + filename = f"test-{name}-{suffix}.ova" + out_path = upload_dir / filename + + try: + r = create_test_ova( + output=out_path, vm_name=name, size_mib=size_mib, + bootable=bootable, force=force, + ) + except Exception as exc: + log.exception("test OVA creation failed") + return jsonify(error=str(exc)), 500 + + return jsonify( + name=filename, bytes=r.bytes, bootable=r.bootable, + reused=r.reused, + ) + + @app.get("/api/test-ovas/") + def download_test_ova(name: str): + return send_from_directory(upload_dir, name, as_attachment=True) + + return app