From 9a6d2df78b6695f6359dc359655afa7bc971f67d Mon Sep 17 00:00:00 2001 From: Claus Lohmar Date: Tue, 21 Jul 2026 19:14:32 +0000 Subject: [PATCH] fix: real-time progress during qemu-img convert, smoother percentages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qemu-img convert is the longest step but previously had no progress updates — bar stuck at 10% until conversion completed. Now _convert_with_progress() runs qemu-img in background and polls output file size every 2s, updating progress with GiB done/total. Progress flow for single disk: 5% queued → 10% starting → 10-55% converting (real-time) → 60% shrinking → 70% creating VM → 75-85% importing → 95% configuring → 100% done --- backend/provisioner.py | 52 +++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/backend/provisioner.py b/backend/provisioner.py index fd6217d..c11fb8a 100644 --- a/backend/provisioner.py +++ b/backend/provisioner.py @@ -145,6 +145,43 @@ def _run(cmd: list[str], check: bool = True, timeout: int = 3600) -> subprocess. return result +def _convert_with_progress( + job_id: str, source_path: Path, output_path: Path, format: str, + start_pct: int, end_pct: int, +): + """Run qemu-img convert in background and update job progress + by polling the output file size against the source virtual size.""" + # Get source virtual size for percentage estimation + info = _run(["qemu-img", "info", "--output=json", str(source_path)]) + source_size = json.loads(info.stdout).get("virtual-size", 0) + if source_size == 0: + source_size = source_path.stat().st_size + + proc = subprocess.Popen( + ["qemu-img", "convert", "-f", format, "-O", "qcow2", + str(source_path), str(output_path)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + # Poll output file size and update progress + while proc.poll() is None: + time.sleep(2) + if output_path.exists(): + current = output_path.stat().st_size + if source_size > 0: + ratio = min(current / source_size, 1.0) + pct = start_pct + int(ratio * (end_pct - start_pct)) + gb_done = current / (1024**3) + gb_total = source_size / (1024**3) + _update_job(job_id, progress=pct, + message=f"Converting disk... {gb_done:.1f} / {gb_total:.1f} GiB") + + if proc.returncode != 0: + raise RuntimeError(f"qemu-img convert failed (rc={proc.returncode})") + + _update_job(job_id, progress=end_pct, message="Disk converted.") + + def _process_job(job_id: str, req: JobSubmissionRequest): try: vmid = req.vmid @@ -171,13 +208,11 @@ def _process_job(job_id: str, req: JobSubmissionRequest): logger.info("Disk %d: source=%s format=%s size=%.1f GiB", idx, source_path, disk.format, disk.size_gb) - _run(["qemu-img", "convert", - "-f", disk.format, - "-O", "qcow2", - str(source_path), str(output_path)], - timeout=7200) - _update_job(job_id, progress=20 + (idx + 1) * 15, - message=f"Converted disk {idx + 1}/{len(disk_specs)}") + # Convert with real-time progress + _convert_with_progress( + job_id, source_path, output_path, disk.format, + start_pct=10 + idx * 10, end_pct=55 + idx * 10, + ) else: size_gb = spec.size_gb or 32 logger.info("Creating empty disk %d: %d GiB", idx, size_gb) @@ -204,7 +239,7 @@ def _process_job(job_id: str, req: JobSubmissionRequest): target_gb = int(round(current_gb)) + 1 if target_gb < current_gb: - _update_job(job_id, progress=65, + _update_job(job_id, progress=60, message=f"Shrinking disk to {target_gb} GiB...") logger.info("Shrinking disk: %.1f GiB → %d GiB via virt-resize", current_gb, target_gb) try: @@ -276,6 +311,7 @@ def _process_job(job_id: str, req: JobSubmissionRequest): f"{req.target_storage}:0,format=raw,size=4M"]) # ── Finalise ─────────────────────────────────────────────── + _update_job(job_id, progress=95, message="Configuring VM...") _run(["qm", "set", str(vmid), "--boot", "order=scsi0"]) _run(["qm", "set", str(vmid), "--serial0", "socket"])