diff --git a/backend/converter.py b/backend/converter.py index 508b8d2..8248d1b 100644 --- a/backend/converter.py +++ b/backend/converter.py @@ -422,6 +422,17 @@ def _process_analysis(analysis_id: str, vmid: int, source_filename: str): "size_bytes": d.size_bytes, } for d in disks] + # Detect OVF for qm importovf + ovf_path = None + for dirpath, _, filenames in os.walk(source if source.is_dir() else source.parent): + for fname in filenames: + if fname.lower().endswith(".ovf"): + ovf_path = str(Path(dirpath) / fname) + logger.info("OVF descriptor found: %s", ovf_path) + break + if ovf_path: + break + result = AnalyzeResponse( vmid=vmid, filename=disk.path.name, @@ -430,6 +441,7 @@ def _process_analysis(analysis_id: str, vmid: int, source_filename: str): os_type=os_type, efi_detectable=efi, all_disks=all_disks_data, + ovf_path=ovf_path, ) _update_analysis(analysis_id, "completed", "Analysis complete", result=result.model_dump()) diff --git a/backend/models.py b/backend/models.py index 809eaec..f50b658 100644 --- a/backend/models.py +++ b/backend/models.py @@ -65,6 +65,7 @@ class JobSubmissionRequest(BaseModel): additional_disks: List[DiskSpec] = [] target_disk_size_gb: Optional[int] = None session_id: str = "" + ovf_path: Optional[str] = None class CleanupRequest(BaseModel): @@ -107,6 +108,7 @@ class AnalyzeResponse(BaseModel): os_type: Optional[str] = None efi_detectable: Optional[bool] = None all_disks: list = [] + ovf_path: Optional[str] = None # --------------------------------------------------------------------------- diff --git a/backend/provisioner.py b/backend/provisioner.py index a9a1c44..4f69e4c 100644 --- a/backend/provisioner.py +++ b/backend/provisioner.py @@ -232,11 +232,79 @@ def _convert_with_progress( _update_job(job_id, progress=end_pct, message="Disk converted.") +def _import_ovf(job_id: str, req: JobSubmissionRequest): + """Import an OVA/OVF appliance using qm importovf.""" + vmid = req.vmid + ovf_path = req.ovf_path + logger.info("OVF import: vmid=%d ovf=%s storage=%s", vmid, ovf_path, req.target_storage) + + _update_job(job_id, status=JobStatus.PROCESSING_CONVERSION, progress=20, + message="Importing OVF appliance...") + + safe_name = re.sub(r'[^a-zA-Z0-9-]', '-', req.vm_name).strip('-').lower() + if not safe_name: + safe_name = f"vm-{vmid}" + + existing = subprocess.run(["qm", "status", str(vmid)], capture_output=True, text=True) + if "does not exist" not in existing.stderr and "does not exist" not in existing.stdout: + logger.warning("VM %d already exists — destroying", vmid) + subprocess.run(["qm", "stop", str(vmid)], capture_output=True) + subprocess.run(["qm", "destroy", str(vmid), "--purge"], capture_output=True) + time.sleep(2) + + _run(["qm", "importovf", str(vmid), ovf_path, req.target_storage, "--format", "qcow2"], + timeout=3600) + + _update_job(job_id, status=JobStatus.IMPORTING_STORAGE, progress=80, + message="Applying configuration...") + + if req.cpu_cores and req.cpu_cores != 4: + _run(["qm", "set", str(vmid), "--cores", str(req.cpu_cores)]) + if req.ram_mb and req.ram_mb != 8192: + _run(["qm", "set", str(vmid), "--memory", str(req.ram_mb)]) + if safe_name: + _run(["qm", "set", str(vmid), "--name", safe_name]) + + final_boot = req.boot_type + if req.auto_detect_boot: + ovf_dir = Path(ovf_path).parent + efi_disk = None + for f in ovf_dir.iterdir(): + if f.is_file() and f.suffix.lower() in {".vmdk", ".qcow2", ".img", ".raw", ".vhd", ".vhdx", ".vdi"}: + efi_disk = f + break + if efi_disk: + efi = detect_efi(efi_disk) + if efi is True: + final_boot = BootType.UEFI + elif efi is False: + final_boot = BootType.LEGACY + + bios = "ovmf" if final_boot.value == "uefi" else "seabios" + _run(["qm", "set", str(vmid), "--bios", bios]) + + has_net = subprocess.run(["qm", "config", str(vmid)], capture_output=True, text=True) + if "net0" not in has_net.stdout: + _run(["qm", "set", str(vmid), "--net0", "virtio,bridge=vmbr0"]) + + _run(["qm", "set", str(vmid), "--serial0", "socket"]) + + config = _run(["qm", "config", str(vmid)], check=False) + logger.info("OVF import complete:\n%s", config.stdout.strip()) + + _update_job(job_id, status=JobStatus.COMPLETED, progress=100, + message=f"VM {vmid} imported and ready.") + + def _process_job(job_id: str, req: JobSubmissionRequest): try: vmid = req.vmid logger.info("=== Job %s started for VM %d ===", job_id, vmid) + if req.ovf_path: + _import_ovf(job_id, req) + return + _update_job(job_id, status=JobStatus.PROCESSING_CONVERSION, progress=10, message="Converting disk image...") diff --git a/frontend/app.py b/frontend/app.py index 4cd7bda..c7d73c9 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -385,11 +385,13 @@ async def session_configure( disks = [{"filename": analysis.get("filename", ""), "format": analysis.get("disk_format", ""), "size_gb": analysis.get("disk_size_gb", 0)}] suggested_vmid = vmid if vmid else "" + ovf_path = analysis.get("ovf_path") return render("configure.html", request=request, analysis_id=analysis_id, source_filename=source_filename, session_id=session_id, vm_name=name, storage_pools=pools, - disks=disks, suggested_vmid=suggested_vmid) + disks=disks, suggested_vmid=suggested_vmid, + ovf_path=ovf_path) @app.post("/session/configure/submit") diff --git a/frontend/templates/configure.html b/frontend/templates/configure.html index 84a565e..373e372 100644 --- a/frontend/templates/configure.html +++ b/frontend/templates/configure.html @@ -4,6 +4,12 @@

VM Configuration

Source: {{ source_filename }}

+ {% if ovf_path %} +
+ OVA/OVF appliance detected. qm importovf will read the VM configuration + from the appliance descriptor. Disks are imported together — individual shrink is not available. +
+ {% endif %}
@@ -89,9 +95,13 @@ {{ d.size_gb }} GB @@ -112,6 +122,7 @@