feat: OVA/OVF import via qm importovf — reads VM config from appliance descriptor

- Analysis detects .ovf file in extracted directory, stores ovf_path
- _import_ovf uses qm importovf + --format qcow2 (replaces manual disk-by-disk)
- Applies user overrides: cores, memory, name, bios, network via qm set
- Configure page shows OVF note, disables per-disk shrink for OVF
- ovf_path flows through analysis → configure → job submission
This commit is contained in:
Claus Lohmar 2026-07-27 10:27:32 +00:00
parent 8ee3c78449
commit 5c8d5ea4b4
5 changed files with 114 additions and 10 deletions

View file

@ -422,6 +422,17 @@ def _process_analysis(analysis_id: str, vmid: int, source_filename: str):
"size_bytes": d.size_bytes, "size_bytes": d.size_bytes,
} for d in disks] } 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( result = AnalyzeResponse(
vmid=vmid, vmid=vmid,
filename=disk.path.name, filename=disk.path.name,
@ -430,6 +441,7 @@ def _process_analysis(analysis_id: str, vmid: int, source_filename: str):
os_type=os_type, os_type=os_type,
efi_detectable=efi, efi_detectable=efi,
all_disks=all_disks_data, all_disks=all_disks_data,
ovf_path=ovf_path,
) )
_update_analysis(analysis_id, "completed", "Analysis complete", result=result.model_dump()) _update_analysis(analysis_id, "completed", "Analysis complete", result=result.model_dump())

View file

@ -65,6 +65,7 @@ class JobSubmissionRequest(BaseModel):
additional_disks: List[DiskSpec] = [] additional_disks: List[DiskSpec] = []
target_disk_size_gb: Optional[int] = None target_disk_size_gb: Optional[int] = None
session_id: str = "" session_id: str = ""
ovf_path: Optional[str] = None
class CleanupRequest(BaseModel): class CleanupRequest(BaseModel):
@ -107,6 +108,7 @@ class AnalyzeResponse(BaseModel):
os_type: Optional[str] = None os_type: Optional[str] = None
efi_detectable: Optional[bool] = None efi_detectable: Optional[bool] = None
all_disks: list = [] all_disks: list = []
ovf_path: Optional[str] = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -232,11 +232,79 @@ def _convert_with_progress(
_update_job(job_id, progress=end_pct, message="Disk converted.") _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): def _process_job(job_id: str, req: JobSubmissionRequest):
try: try:
vmid = req.vmid vmid = req.vmid
logger.info("=== Job %s started for VM %d ===", job_id, 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, _update_job(job_id, status=JobStatus.PROCESSING_CONVERSION, progress=10,
message="Converting disk image...") message="Converting disk image...")

View file

@ -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)}] disks = [{"filename": analysis.get("filename", ""), "format": analysis.get("disk_format", ""), "size_gb": analysis.get("disk_size_gb", 0)}]
suggested_vmid = vmid if vmid else "" suggested_vmid = vmid if vmid else ""
ovf_path = analysis.get("ovf_path")
return render("configure.html", request=request, return render("configure.html", request=request,
analysis_id=analysis_id, source_filename=source_filename, analysis_id=analysis_id, source_filename=source_filename,
session_id=session_id, vm_name=name, storage_pools=pools, 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") @app.post("/session/configure/submit")

View file

@ -4,6 +4,12 @@
<div class="pve-hero" style="padding-bottom:0.5rem;"> <div class="pve-hero" style="padding-bottom:0.5rem;">
<h1 style="font-size:1.1rem;">VM Configuration</h1> <h1 style="font-size:1.1rem;">VM Configuration</h1>
<p>Source: <span class="pve-code">{{ source_filename }}</span></p> <p>Source: <span class="pve-code">{{ source_filename }}</span></p>
{% if ovf_path %}
<div class="pve-alert pve-alert-info" style="margin-top:0.5rem;">
<strong>OVA/OVF appliance detected.</strong> qm importovf will read the VM configuration
from the appliance descriptor. Disks are imported together — individual shrink is not available.
</div>
{% endif %}
</div> </div>
<div class="pve-panel"> <div class="pve-panel">
@ -89,9 +95,13 @@
<td>{{ d.size_gb }} GB</td> <td>{{ d.size_gb }} GB</td>
<td> <td>
<label style="display:flex;align-items:center;gap:0.3rem;font-size:0.72rem;white-space:nowrap;"> <label style="display:flex;align-items:center;gap:0.3rem;font-size:0.72rem;white-space:nowrap;">
{% if ovf_path %}
<span class="pve-dim">handled by OVF</span>
{% else %}
<input type="checkbox" class="disk-shrink-cb" onchange="toggleShrink(this, {{ loop.index0 }})"> <input type="checkbox" class="disk-shrink-cb" onchange="toggleShrink(this, {{ loop.index0 }})">
to <input type="number" class="pve-input disk-shrink-size" id="shrink-{{ loop.index0 }}" to <input type="number" class="pve-input disk-shrink-size" id="shrink-{{ loop.index0 }}"
value="{{ d.size_gb }}" disabled style="width:70px;padding:0.2rem 0.3rem;font-size:0.72rem;"> GB value="{{ d.size_gb }}" disabled style="width:70px;padding:0.2rem 0.3rem;font-size:0.72rem;"> GB
{% endif %}
</label> </label>
</td> </td>
</tr> </tr>
@ -112,6 +122,7 @@
<script> <script>
var _analysisId = '{{ analysis_id }}'; var _analysisId = '{{ analysis_id }}';
var _sessionId = '{{ session_id }}'; var _sessionId = '{{ session_id }}';
var _ovfPath = '{{ ovf_path or "" }}';
function toggleShrink(cb, idx) { function toggleShrink(cb, idx) {
var inp = document.getElementById('shrink-' + idx); var inp = document.getElementById('shrink-' + idx);
@ -161,9 +172,17 @@ async function submitConfig() {
session_id: _sessionId, analysis_id: _analysisId, session_id: _sessionId, analysis_id: _analysisId,
boot_disk: null, additional_disks: [] boot_disk: null, additional_disks: []
}; };
if (_ovfPath) payload.ovf_path = _ovfPath;
var shrinkCbs = document.getElementsByClassName('disk-shrink-cb'); var shrinkCbs = document.getElementsByClassName('disk-shrink-cb');
var diskFiles = [{% for d in disks %}{{ d|tojson }},{% endfor %}]; var diskFiles = [{% for d in disks %}{{ d|tojson }},{% endfor %}];
if (shrinkCbs.length === 0 && diskFiles.length > 0) {
payload.boot_disk = { disk_type: 'image_file', source_filename: diskFiles[0].filename, format: diskFiles[0].format };
for (var k = 1; k < diskFiles.length; k++) {
payload.additional_disks.push({ disk_type: 'image_file', source_filename: diskFiles[k].filename, format: diskFiles[k].format });
}
} else {
for (var i = 0; i < shrinkCbs.length; i++) { for (var i = 0; i < shrinkCbs.length; i++) {
var d = diskFiles[i]; var d = diskFiles[i];
var spec = { disk_type: 'image_file', source_filename: d.filename, format: d.format }; var spec = { disk_type: 'image_file', source_filename: d.filename, format: d.format };
@ -175,6 +194,7 @@ async function submitConfig() {
if (i === bootIdx) payload.boot_disk = spec; if (i === bootIdx) payload.boot_disk = spec;
else payload.additional_disks.push(spec); else payload.additional_disks.push(spec);
} }
}
var btn = document.getElementById('config-submit-btn'); var btn = document.getElementById('config-submit-btn');
var status = document.getElementById('config-submit-status'); var status = document.getElementById('config-submit-status');