Add ova2vzdump: OVA -> Proxmox vzdump (.vma.zst) converter

Dockerized converter that turns VirtualBox/VMware OVA appliances into
native Proxmox vzdump backups restorable via qmrestore, without needing
a PVE host.

- OVF parser (CPU/RAM/disks/NICs, SCSI/SATA/IDE controllers)
- Streaming OVA extractor
- qemu-server.conf generator with required #qmdump#map lines
- Orchestrator: qemu-img convert -> vma create -> zstd
- Click CLI: convert, create-test-ova, gui subcommands
- Flask web UI with Convert + Create-test-OVA tabs, SSE progress
- Dockerfile: Debian bookworm + pve-no-subscription for vma/qemu-img,
  pre-caches Alpine tiny raw image (sha512-verified) for bootable
  test-OVA generation
- End-to-end verified: generated OVA imports into VirtualBox, converted
  vzdump restores on Proxmox and the VM boots

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-21 12:50:04 +02:00
parent 5fe65d6b44
commit dcbc957334
17 changed files with 1761 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.git
.gitignore
__pycache__
*.pyc
*.egg-info
.venv
venv
.pytest_cache
.mypy_cache
data

66
Dockerfile Normal file
View File

@ -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"]

313
README.md Normal file
View File

@ -0,0 +1,313 @@
# ova2vzdump
*[English](#english) · [Deutsch](#deutsch)*
<a id="english"></a>
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-<vmid>-<ts>.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 <file> <target-vmid> [--storage <name>]` 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-<originally-intended-vmid>-*.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: <model>=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
```
---
<a id="deutsch"></a>
# 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-<vmid>-<ts>.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 <file> <ziel-vmid> [--storage <name>]` 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-<ursprüngliche-vmid>-*.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: <model>=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
```

11
docker-compose.yml Normal file
View File

@ -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

26
pyproject.toml Normal file
View File

@ -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/*"]

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
click>=8.1
flask>=3.0
lxml>=5.1

40
scripts/make_test_ova.py Normal file
View File

@ -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()

View File

@ -0,0 +1,3 @@
"""ova2vzdump — convert OVA templates to Proxmox vzdump (.vma.zst) backups."""
__version__ = "0.1.0"

View File

@ -0,0 +1,4 @@
from ova2vzdump.cli import main
if __name__ == "__main__":
main()

112
src/ova2vzdump/cli.py Normal file
View File

@ -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 <file> <target-vmid>`.")
@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()

View File

@ -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: <storage>:vm-<vmid>-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 `<model>[=<mac>]` — omit the `=<mac>` 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]

155
src/ova2vzdump/converter.py Normal file
View File

@ -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-<vmid>-<ts>.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 <path>` — vma derives the archive entry name from basename,
# so the conf file must already be named `qemu-server.conf`.
# Drives are positional `<bus-device>=<raw-path>`, 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)

View File

@ -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

View File

@ -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"

View File

@ -0,0 +1,274 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ova2vzdump</title>
<style>
:root { color-scheme: dark light; }
body { font-family: system-ui, sans-serif; max-width: 760px;
margin: 2rem auto; padding: 0 1rem; }
h1 { margin-bottom: 0.25rem; }
h2 { margin-top: 2rem; margin-bottom: 0.5rem; font-size: 1.1rem; }
.sub { color: #888; margin-top: 0; margin-bottom: 1.5rem; }
nav { display: flex; gap: 0.5rem; margin-bottom: 1rem;
border-bottom: 1px solid #8884; }
nav button { background: none; color: inherit; border: none;
padding: 0.5rem 1rem; cursor: pointer; font: inherit;
border-bottom: 2px solid transparent; }
nav button.active { border-bottom-color: #2a6; font-weight: 600; }
form { display: grid; gap: 0.75rem; padding: 1rem;
border: 1px solid #8884; border-radius: 8px; }
label { display: grid; gap: 0.25rem; font-size: 0.9rem; }
label.row-inline { grid-auto-flow: column; justify-content: start;
align-items: center; gap: 0.5rem; }
input, button, select { font: inherit; padding: 0.5rem;
border-radius: 6px; border: 1px solid #8884;
background: transparent; color: inherit; }
button[type="submit"] { cursor: pointer; background: #2a6; color: #fff;
border: none; font-weight: 600; }
button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.panel { margin-top: 1.5rem; padding: 1rem; border: 1px solid #8884;
border-radius: 8px; display: none; }
.panel.visible { display: block; }
progress { width: 100%; height: 1.5rem; }
.stage { font-family: ui-monospace, monospace; color: #888; }
.err { color: #e44; white-space: pre-wrap; }
.ok { color: #2a6; }
.reused { color: #e90; }
a.dl { display: inline-block; margin-top: 0.75rem; padding: 0.5rem 1rem;
background: #2a6; color: #fff; border-radius: 6px;
text-decoration: none; font-weight: 600; }
ul.ovalist { list-style: none; padding: 0; margin: 0.5rem 0 0; }
ul.ovalist li { padding: 0.4rem 0; border-top: 1px solid #8882;
display: flex; justify-content: space-between;
gap: 0.5rem; }
ul.ovalist code { color: #888; font-size: 0.85rem; }
.hidden { display: none; }
.hint { font-size: 0.85rem; color: #888; margin: -0.25rem 0 0; }
.hint code { background: #8882; padding: 0.1rem 0.3rem;
border-radius: 3px; }
</style>
</head>
<body>
<h1>ova2vzdump</h1>
<p class="sub">OVA → Proxmox vzdump (.vma.zst) converter</p>
<nav>
<button type="button" data-tab="convert" class="active">Convert</button>
<button type="button" data-tab="create">Create test OVA</button>
</nav>
<!-- ========== CONVERT TAB ========== -->
<section id="tab-convert">
<form id="convertForm">
<label>OVA file
<input type="file" name="ova" accept=".ova,.tar" required>
</label>
<div class="row">
<label>VMID (placeholder)
<input type="number" name="vmid" value="100" min="100"
max="999999999" required>
</label>
<label>Storage (placeholder)
<input type="text" name="storage" value="local-lvm" required>
</label>
</div>
<p class="hint">Both fields are just placeholders in the generated
config — the real values are picked at restore time via
<code>qmrestore &lt;file&gt; &lt;target-vmid&gt; --storage &lt;name&gt;</code>.
Defaults are fine in most cases.</p>
<button type="submit" id="goConvert">Convert</button>
</form>
<section class="panel" id="convertStatus">
<div><strong>Stage:</strong> <span class="stage" id="stage"></span></div>
<progress id="bar" value="0" max="100"></progress>
<div id="msg"></div>
<div id="result"></div>
</section>
</section>
<!-- ========== CREATE TEST OVA TAB ========== -->
<section id="tab-create" class="hidden">
<form id="createForm">
<label>VM name
<input type="text" name="name" value="test-vm" required>
</label>
<label class="row-inline">
<input type="checkbox" name="bootable" id="bootable">
<span>Bootable (real Alpine Linux, ~80 MB) — otherwise: empty stub disk</span>
</label>
<label>Disk size (MiB) — stub only
<input type="number" name="size_mib" value="64" min="1" max="102400">
</label>
<label class="row-inline">
<input type="checkbox" name="force" id="force">
<span>Rebuild if a matching test OVA already exists</span>
</label>
<button type="submit" id="goCreate">Create</button>
</form>
<section class="panel" id="createStatus">
<div id="createMsg"></div>
<div id="createResult"></div>
</section>
<h2>Existing test OVAs</h2>
<ul class="ovalist" id="ovalist"><li><em>loading…</em></li></ul>
</section>
<script>
// ---------- tab switching ----------
const tabs = document.querySelectorAll('nav button');
tabs.forEach(btn => btn.addEventListener('click', () => {
tabs.forEach(b => b.classList.toggle('active', b === btn));
document.getElementById('tab-convert').classList.toggle(
'hidden', btn.dataset.tab !== 'convert');
document.getElementById('tab-create').classList.toggle(
'hidden', btn.dataset.tab !== 'create');
if (btn.dataset.tab === 'create') refreshList();
}));
// ---------- convert flow ----------
const convertForm = document.getElementById('convertForm');
const goConvert = document.getElementById('goConvert');
const convertStatus = document.getElementById('convertStatus');
const stageEl = document.getElementById('stage');
const bar = document.getElementById('bar');
const msg = document.getElementById('msg');
const result = document.getElementById('result');
convertForm.addEventListener('submit', async (e) => {
e.preventDefault();
goConvert.disabled = true;
convertStatus.classList.add('visible');
stageEl.textContent = 'uploading';
bar.value = 0;
msg.textContent = '';
result.innerHTML = '';
const fd = new FormData(convertForm);
let resp;
try { resp = await fetch('/api/jobs', { method: 'POST', body: fd }); }
catch (err) {
msg.innerHTML = '<span class="err">upload failed: ' + err + '</span>';
goConvert.disabled = false;
return;
}
if (!resp.ok) {
const body = await resp.text();
msg.innerHTML = '<span class="err">upload failed: ' + body + '</span>';
goConvert.disabled = false;
return;
}
const { id } = await resp.json();
const es = new EventSource('/api/jobs/' + id + '/events');
es.onmessage = (ev) => {
const d = JSON.parse(ev.data);
if (d.stage) stageEl.textContent = d.stage;
if (typeof d.progress === 'number') bar.value = d.progress;
if (d.state === 'error') {
msg.innerHTML = '<span class="err">error: ' +
(d.message || 'unknown') + '</span>';
es.close(); goConvert.disabled = false;
}
if (d.state === 'done') {
msg.innerHTML = '<span class="ok">done — ' + (d.message || '') + '</span>';
result.innerHTML = '<a class="dl" href="/download/' +
encodeURIComponent(d.output) + '">Download ' + d.output + '</a>';
es.close(); goConvert.disabled = false;
}
};
es.onerror = () => { es.close(); goConvert.disabled = false; };
});
// ---------- create test OVA flow ----------
const createForm = document.getElementById('createForm');
const goCreate = document.getElementById('goCreate');
const createStatus = document.getElementById('createStatus');
const createMsg = document.getElementById('createMsg');
const createResult = document.getElementById('createResult');
const ovalist = document.getElementById('ovalist');
function formatBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1024**2) return (b / 1024).toFixed(1) + ' KiB';
if (b < 1024**3) return (b / 1024**2).toFixed(1) + ' MiB';
return (b / 1024**3).toFixed(2) + ' GiB';
}
async function refreshList() {
ovalist.innerHTML = '<li><em>loading…</em></li>';
try {
const r = await fetch('/api/test-ovas');
const j = await r.json();
if (!j.items || j.items.length === 0) {
ovalist.innerHTML = '<li><em>no test OVAs yet</em></li>';
return;
}
ovalist.innerHTML = j.items.map(it =>
'<li><span><strong>' + it.name + '</strong> ' +
'<code>' + formatBytes(it.bytes) + '</code></span>' +
'<a class="dl" href="/api/test-ovas/' +
encodeURIComponent(it.name) + '">Download</a></li>'
).join('');
} catch (e) {
ovalist.innerHTML = '<li class="err">failed to list: ' + e + '</li>';
}
}
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
goCreate.disabled = true;
createStatus.classList.add('visible');
createMsg.innerHTML = '<em>building…</em>';
createResult.innerHTML = '';
const fd = new FormData(createForm);
const payload = {
name: fd.get('name'),
bootable: fd.get('bootable') ? true : false,
size_mib: fd.get('size_mib'),
force: fd.get('force') ? true : false,
};
let resp, body;
try {
resp = await fetch('/api/test-ovas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
body = await resp.json();
} catch (err) {
createMsg.innerHTML = '<span class="err">request failed: ' + err + '</span>';
goCreate.disabled = false;
return;
}
if (!resp.ok) {
createMsg.innerHTML = '<span class="err">error: ' +
(body.error || resp.statusText) + '</span>';
goCreate.disabled = false;
return;
}
const kind = body.bootable ? 'bootable Alpine' : 'empty stub';
const status = body.reused
? '<span class="reused">reused existing ' + kind + '</span>'
: '<span class="ok">built ' + kind + '</span>';
createMsg.innerHTML = status + ' — ' + body.name +
' (' + formatBytes(body.bytes) + ')';
createResult.innerHTML = '<a class="dl" href="/api/test-ovas/' +
encodeURIComponent(body.name) + '">Download ' + body.name + '</a>';
goCreate.disabled = false;
refreshList();
});
// initial load when Create tab is active
if (!document.getElementById('tab-create').classList.contains('hidden')) {
refreshList();
}
</script>
</body>
</html>

231
src/ova2vzdump/test_ova.py Normal file
View File

@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1"
xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1"
xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData"
xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xml:lang="en-US">
<References>
<File ovf:id="file1" ovf:href="{disk_name}" ovf:size="{disk_size}"/>
</References>
<DiskSection>
<Info>Virtual disks</Info>
<Disk ovf:diskId="disk1" ovf:fileRef="file1"
ovf:capacity="{capacity_bytes}"
ovf:capacityAllocationUnits="byte"
ovf:format="http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized"/>
</DiskSection>
<NetworkSection>
<Info>Logical networks</Info>
<Network ovf:name="VM Network">
<Description>Default bridged network</Description>
</Network>
</NetworkSection>
<VirtualSystem ovf:id="{vm_name}">
<Info>A virtual machine</Info>
<Name>{vm_name}</Name>
<OperatingSystemSection ovf:id="96" ovf:version="1">
<Info>Guest Operating System</Info>
<Description>{os_description}</Description>
</OperatingSystemSection>
<VirtualHardwareSection>
<Info>Virtual hardware requirements</Info>
<System>
<vssd:ElementName>Virtual Hardware Family</vssd:ElementName>
<vssd:InstanceID>0</vssd:InstanceID>
<vssd:VirtualSystemIdentifier>{vm_name}</vssd:VirtualSystemIdentifier>
<vssd:VirtualSystemType>vmx-10</vssd:VirtualSystemType>
</System>
<Item>
<rasd:AllocationUnits>hertz * 10^6</rasd:AllocationUnits>
<rasd:ElementName>{cpus} virtual CPU(s)</rasd:ElementName>
<rasd:InstanceID>1</rasd:InstanceID>
<rasd:ResourceType>3</rasd:ResourceType>
<rasd:VirtualQuantity>{cpus}</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:AllocationUnits>byte * 2^20</rasd:AllocationUnits>
<rasd:ElementName>{memory_mb} MB of memory</rasd:ElementName>
<rasd:InstanceID>2</rasd:InstanceID>
<rasd:ResourceType>4</rasd:ResourceType>
<rasd:VirtualQuantity>{memory_mb}</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:Address>0</rasd:Address>
<rasd:ElementName>SCSIController</rasd:ElementName>
<rasd:InstanceID>3</rasd:InstanceID>
<rasd:ResourceSubType>VirtualSCSI</rasd:ResourceSubType>
<rasd:ResourceType>6</rasd:ResourceType>
</Item>
<Item>
<rasd:AddressOnParent>0</rasd:AddressOnParent>
<rasd:ElementName>Hard Disk 1</rasd:ElementName>
<rasd:HostResource>ovf:/disk/disk1</rasd:HostResource>
<rasd:InstanceID>4</rasd:InstanceID>
<rasd:Parent>3</rasd:Parent>
<rasd:ResourceType>17</rasd:ResourceType>
</Item>
<Item>
<rasd:AddressOnParent>7</rasd:AddressOnParent>
<rasd:AutomaticAllocation>true</rasd:AutomaticAllocation>
<rasd:Connection>VM Network</rasd:Connection>
<rasd:ElementName>Network adapter 1</rasd:ElementName>
<rasd:InstanceID>5</rasd:InstanceID>
<rasd:ResourceSubType>E1000</rasd:ResourceSubType>
<rasd:ResourceType>10</rasd:ResourceType>
</Item>
</VirtualHardwareSection>
</VirtualSystem>
</Envelope>
"""
@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

195
src/ova2vzdump/web.py Normal file
View File

@ -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/<job_id>")
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/<job_id>/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/<path:name>")
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/<path:name>")
def download_test_ova(name: str):
return send_from_directory(upload_dir, name, as_attachment=True)
return app