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:
parent
8ee3c78449
commit
5c8d5ea4b4
5 changed files with 114 additions and 10 deletions
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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...")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
<div class="pve-hero" style="padding-bottom:0.5rem;">
|
||||
<h1 style="font-size:1.1rem;">VM Configuration</h1>
|
||||
<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 class="pve-panel">
|
||||
|
|
@ -89,9 +95,13 @@
|
|||
<td>{{ d.size_gb }} GB</td>
|
||||
<td>
|
||||
<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 }})">
|
||||
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
|
||||
{% endif %}
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -112,6 +122,7 @@
|
|||
<script>
|
||||
var _analysisId = '{{ analysis_id }}';
|
||||
var _sessionId = '{{ session_id }}';
|
||||
var _ovfPath = '{{ ovf_path or "" }}';
|
||||
|
||||
function toggleShrink(cb, idx) {
|
||||
var inp = document.getElementById('shrink-' + idx);
|
||||
|
|
@ -161,9 +172,17 @@ async function submitConfig() {
|
|||
session_id: _sessionId, analysis_id: _analysisId,
|
||||
boot_disk: null, additional_disks: []
|
||||
};
|
||||
if (_ovfPath) payload.ovf_path = _ovfPath;
|
||||
|
||||
var shrinkCbs = document.getElementsByClassName('disk-shrink-cb');
|
||||
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++) {
|
||||
var d = diskFiles[i];
|
||||
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;
|
||||
else payload.additional_disks.push(spec);
|
||||
}
|
||||
}
|
||||
|
||||
var btn = document.getElementById('config-submit-btn');
|
||||
var status = document.getElementById('config-submit-status');
|
||||
|
|
|
|||
Loading…
Reference in a new issue