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,
|
"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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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...")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,19 +172,28 @@ 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 %}];
|
||||||
for (var i = 0; i < shrinkCbs.length; i++) {
|
|
||||||
var d = diskFiles[i];
|
if (shrinkCbs.length === 0 && diskFiles.length > 0) {
|
||||||
var spec = { disk_type: 'image_file', source_filename: d.filename, format: d.format };
|
payload.boot_disk = { disk_type: 'image_file', source_filename: diskFiles[0].filename, format: diskFiles[0].format };
|
||||||
if (shrinkCbs[i].checked) {
|
for (var k = 1; k < diskFiles.length; k++) {
|
||||||
var si = document.getElementById('shrink-' + i);
|
payload.additional_disks.push({ disk_type: 'image_file', source_filename: diskFiles[k].filename, format: diskFiles[k].format });
|
||||||
var sz = si ? parseInt(si.value) || null : null;
|
}
|
||||||
if (sz) spec.target_disk_size_gb = sz;
|
} 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 };
|
||||||
|
if (shrinkCbs[i].checked) {
|
||||||
|
var si = document.getElementById('shrink-' + i);
|
||||||
|
var sz = si ? parseInt(si.value) || null : null;
|
||||||
|
if (sz) spec.target_disk_size_gb = sz;
|
||||||
|
}
|
||||||
|
if (i === bootIdx) payload.boot_disk = spec;
|
||||||
|
else payload.additional_disks.push(spec);
|
||||||
}
|
}
|
||||||
if (i === bootIdx) payload.boot_disk = spec;
|
|
||||||
else payload.additional_disks.push(spec);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var btn = document.getElementById('config-submit-btn');
|
var btn = document.getElementById('config-submit-btn');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue