feat: async analysis with polling + nested archive extraction

- Backend /api/v1/analyze now returns 202 with analysis_id immediately
- New GET /api/v1/analyze/{id} for polling analysis status
- Background thread handles extraction, disk discovery, OS/EFI detection
- Nested archive extraction: handles chained zips and split zips (.z01-.zNN)
- Frontend polls /session/analyze/status/{id} every 2s until complete
- SCP page now has complete confirm form flow with job polling
This commit is contained in:
Claus Lohmar 2026-07-26 00:13:14 +00:00
parent 1e14d96052
commit 1ecaefe1d8
7 changed files with 380 additions and 52 deletions

View file

@ -22,11 +22,12 @@ from models import (
CleanupResponse, CleanupResponse,
AnalyzeRequest, AnalyzeRequest,
AnalyzeResponse, AnalyzeResponse,
AnalyzeStatusResponse,
CloneRequest, CloneRequest,
HealthResponse, HealthResponse,
ErrorResponse, ErrorResponse,
) )
from converter import extract_if_needed, discover_disk, detect_os, detect_efi from converter import extract_if_needed, discover_disk, detect_os, detect_efi, start_analysis, get_analysis_status
from provisioner import submit_job, get_job_status, cleanup_staging, clone_vm from provisioner import submit_job, get_job_status, cleanup_staging, clone_vm
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -93,39 +94,33 @@ def health() -> HealthResponse:
return HealthResponse(status="ok") return HealthResponse(status="ok")
@app.post(f"{API_PREFIX}/analyze", response_model=AnalyzeResponse) @app.post(f"{API_PREFIX}/analyze", response_model=AnalyzeStatusResponse, status_code=202)
def analyze(req: AnalyzeRequest) -> AnalyzeResponse: def analyze(req: AnalyzeRequest) -> AnalyzeStatusResponse:
"""Analyze a source file: extract if archive, find disk, detect OS + EFI.""" """Analyze a source file asynchronously: extraction + disk probing + OS detection."""
_validate_filename(req.source_filename) _validate_filename(req.source_filename)
logger.info("Analyze request: vmid=%d file=%s", req.vmid, req.source_filename) logger.info("Analyze request: vmid=%d file=%s", req.vmid, req.source_filename)
try: try:
source = extract_if_needed(req.source_filename) result = start_analysis(req.vmid, req.source_filename)
logger.info("Source ready: %s", source) return AnalyzeStatusResponse(
analysis_id=result["analysis_id"],
vmid=req.vmid,
status=result["status"],
message=result["message"],
)
except Exception as exc: except Exception as exc:
logger.error("Extraction failed: %s", exc) logger.error("Analysis start failed: %s", exc)
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
@app.get(f"{API_PREFIX}/analyze/{{analysis_id}}", response_model=AnalyzeStatusResponse)
def analyze_status(analysis_id: str) -> AnalyzeStatusResponse:
"""Poll async analysis status."""
try: try:
disk = discover_disk(source) result = get_analysis_status(analysis_id)
logger.info("Disk found: %s (format=%s, size=%.1f GiB)", disk.path, disk.format, disk.size_gb) return AnalyzeStatusResponse(**result)
except Exception as exc: except KeyError:
logger.error("Disk discovery failed: %s", exc) logger.warning("Analysis not found: %s", analysis_id)
raise HTTPException(status_code=400, detail=f"Disk discovery failed: {exc}") raise HTTPException(status_code=404, detail=f"Analysis not found: {analysis_id}")
extract_dir = source if source.is_dir() else source.parent
os_type = detect_os(disk.path, extract_dir)
efi = detect_efi(disk.path)
logger.info("Analysis result: os=%s efi=%s", os_type, efi)
return AnalyzeResponse(
vmid=req.vmid,
filename=disk.path.name,
disk_format=disk.format,
disk_size_gb=disk.size_gb,
os_type=os_type,
efi_detectable=efi,
)
@app.post(f"{API_PREFIX}/jobs", response_model=JobStatusResponse, status_code=202) @app.post(f"{API_PREFIX}/jobs", response_model=JobStatusResponse, status_code=202)

View file

@ -11,6 +11,8 @@ import os
import re import re
import shutil import shutil
import subprocess import subprocess
import threading
import time
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@ -320,9 +322,166 @@ def detect_efi(disk_path: Path) -> Optional[bool]:
) )
if result.returncode != 0: if result.returncode != 0:
return None return None
# EFI System Partition is typically VFAT; look for it
if "vfat" in result.stdout.lower(): if "vfat" in result.stdout.lower():
return True return True
return False return False
except Exception: except Exception:
return None return None
# ---------------------------------------------------------------------------
# Async analysis — background extraction + probing
# ---------------------------------------------------------------------------
_analyses: dict[str, dict] = {}
_analyses_lock = threading.Lock()
def start_analysis(vmid: int, source_filename: str) -> dict:
analysis_id = f"analysis_{vmid}_{int(time.time())}"
with _analyses_lock:
_analyses[analysis_id] = {
"vmid": vmid,
"status": "queued",
"message": "Queued",
"result": None,
"error": None,
}
thread = threading.Thread(target=_process_analysis, args=(analysis_id, vmid, source_filename), daemon=True)
thread.start()
return {"analysis_id": analysis_id, "status": "queued", "message": "Analysis queued"}
def get_analysis_status(analysis_id: str) -> dict:
with _analyses_lock:
a = _analyses.get(analysis_id)
if not a:
raise KeyError(analysis_id)
return {
"analysis_id": analysis_id,
"vmid": a["vmid"],
"status": a["status"],
"message": a.get("message", ""),
"result": a.get("result"),
"error_details": a.get("error"),
}
def _update_analysis(analysis_id: str, status: str, message: str = "", result=None, error: str = None):
with _analyses_lock:
a = _analyses.get(analysis_id)
if not a:
return
a["status"] = status
if message:
a["message"] = message
if result is not None:
a["result"] = result
if error is not None:
a["error"] = error
def _process_analysis(analysis_id: str, vmid: int, source_filename: str):
try:
_update_analysis(analysis_id, "processing", "Extracting archive...")
source = extract_if_needed(source_filename)
logger.info("Extraction done: %s", source)
_update_analysis(analysis_id, "processing", "Extracting nested archives...")
source = _extract_nested(source)
_update_analysis(analysis_id, "processing", "Discovering disk image...")
disk = discover_disk(source)
logger.info("Disk found: %s (format=%s, size=%.1f GiB)", disk.path, disk.format, disk.size_gb)
_update_analysis(analysis_id, "processing", "Detecting OS and boot type...")
extract_dir = source if source.is_dir() else source.parent
os_type = detect_os(disk.path, extract_dir)
efi = detect_efi(disk.path)
logger.info("Analysis done: os=%s efi=%s", os_type, efi)
from models import AnalyzeResponse
result = AnalyzeResponse(
vmid=vmid,
filename=disk.path.name,
disk_format=disk.format,
disk_size_gb=disk.size_gb,
os_type=os_type,
efi_detectable=efi,
)
_update_analysis(analysis_id, "completed", "Analysis complete", result=result.model_dump())
except Exception as exc:
logger.exception("Analysis %s failed", analysis_id)
_update_analysis(analysis_id, "failed", "Analysis failed", error=str(exc))
# ---------------------------------------------------------------------------
# Nested archive extraction — handles split zips, chained archives
# ---------------------------------------------------------------------------
def _extract_nested(base: Path) -> Path:
"""Recursively extract nested archives until disk images are found
or no more archives remain. Handles split zips (.z01-.zNN + .zip)."""
for _ in range(5):
if not base.is_dir():
return base
cand = _find_nested_archive(base)
if cand is None:
break
logger.info("Extracting nested archive: %s", cand)
_extract_one_archive(cand, base)
try:
cand.unlink()
except Exception:
pass
base = _flatten_nested(base)
return base
def _find_nested_archive(base: Path) -> Optional[Path]:
"""Find the next archive to extract inside a directory. Prioritises
split zip masters if .z01 segments are present."""
files = sorted([f for f in base.iterdir() if f.is_file()], key=lambda f: f.name)
split_master = None
has_segments = False
for f in files:
name = f.name.lower()
if re.search(r'\.z\d{2,3}$', name):
has_segments = True
if name.endswith(".zip") and not re.search(r'\.z\d{2,3}$', name):
split_master = f
if has_segments and split_master:
return split_master
for f in files:
if _detect_archive_ext(f.name.lower()) is not None:
return f
return None
def _extract_one_archive(archive_path: Path, target_dir: Path):
name = archive_path.name.lower()
ext = _detect_archive_ext(name)
tool_map = {
".7z": ("7z", ["7z", "x", "-y", str(archive_path)]),
".zip": ("7z", ["7z", "x", "-y", str(archive_path)]),
".rar": ("unrar", ["unrar", "x", "-y", str(archive_path)]),
".tar.gz": ("tar", ["tar", "-xzf", str(archive_path)]),
".tgz": ("tar", ["tar", "-xzf", str(archive_path)]),
".tar": ("tar", ["tar", "-xf", str(archive_path)]),
".gz": ("gunzip", ["gunzip", "-f", str(archive_path)]),
".bz2": ("bunzip2", ["bunzip2", "-f", str(archive_path)]),
".xz": ("xz", ["xz", "-d", str(archive_path)]),
}
tool, cmd = tool_map[ext]
if not shutil.which(tool):
raise RuntimeError(f"Extraction tool '{tool}' not found on host")
result = subprocess.run(cmd, cwd=str(target_dir), capture_output=True, text=True, timeout=900)
if result.returncode != 0:
logger.warning("Nested extraction %s failed: %s", archive_path.name, result.stderr.strip()[:200])

View file

@ -35,6 +35,13 @@ class JobStatus(str, Enum):
FAILED = "failed" FAILED = "failed"
class AnalyzeStatus(str, Enum):
QUEUED = "queued"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Request schemas # Request schemas
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -117,3 +124,12 @@ class CloneRequest(BaseModel):
source_vmid: int source_vmid: int
target_vmid: int target_vmid: int
target_name: str target_name: str
class AnalyzeStatusResponse(BaseModel):
analysis_id: str
vmid: int
status: AnalyzeStatus
message: str = ""
result: Optional[AnalyzeResponse] = None
error_details: Optional[str] = None

View file

@ -15,7 +15,6 @@ from typing import Optional
BACKEND_URL = os.getenv("BACKEND_URL", "http://10.2.0.2:9000") BACKEND_URL = os.getenv("BACKEND_URL", "http://10.2.0.2:9000")
TIMEOUT = 30 TIMEOUT = 30
ANALYZE_TIMEOUT = 300 # guestfish needs time to probe large disk images
class ApiError(Exception): class ApiError(Exception):
@ -44,12 +43,16 @@ class ApiClient:
return self._get("/api/v1/health") return self._get("/api/v1/health")
def analyze(self, vmid: int, filename: str, source_type: str = "upload") -> dict: def analyze(self, vmid: int, filename: str, source_type: str = "upload") -> dict:
"""POST /api/v1/analyze — probe source file for OS, size, format.""" """POST /api/v1/analyze — start async analysis, returns analysis_id."""
return self._post("/api/v1/analyze", { return self._post("/api/v1/analyze", {
"vmid": vmid, "vmid": vmid,
"source_filename": filename, "source_filename": filename,
"source_type": source_type, "source_type": source_type,
}, timeout=ANALYZE_TIMEOUT) })
def get_analysis(self, analysis_id: str) -> dict:
"""GET /api/v1/analyze/{analysis_id} — poll analysis status."""
return self._get(f"/api/v1/analyze/{analysis_id}")
def create_job(self, payload: dict) -> dict: def create_job(self, payload: dict) -> dict:
"""POST /api/v1/jobs — submit conversion job.""" """POST /api/v1/jobs — submit conversion job."""

View file

@ -299,7 +299,7 @@ async def session_progress(filename: str):
}) })
@app.post("/session/analyze", response_class=HTMLResponse) @app.post("/session/analyze")
async def session_analyze( async def session_analyze(
request: Request, request: Request,
vmid: int = Form(...), vmid: int = Form(...),
@ -307,28 +307,63 @@ async def session_analyze(
vm_name: str = Form(""), vm_name: str = Form(""),
session_id: str = Form(""), session_id: str = Form(""),
): ):
"""Phase 2 — call the backend /analyze endpoint and render the result.""" """Phase 2 — start async backend analysis, return analysis_id for polling."""
err = _validate_vmid(vmid) err = _validate_vmid(vmid)
if err: if err:
return render("_analysis.html", request=request, error=err) return JSONResponse({"phase": "error", "error": err}, status_code=400)
# Prepend session subdirectory to the filename for the backend
# Files are in tmp/{guid}/in/, so the backend needs {guid}/in/filename
backend_filename = f"{session_id}/in/{filename}" if session_id else filename backend_filename = f"{session_id}/in/{filename}" if session_id else filename
try: try:
analysis = api.analyze(vmid=vmid, filename=backend_filename) analysis = api.analyze(vmid=vmid, filename=backend_filename)
except ApiError as exc: except ApiError as exc:
return render("_analysis.html", request=request, return JSONResponse({"phase": "error", "error": f"Backend analysis failed: {exc.detail}"}, status_code=502)
error=f"Backend analysis failed: {exc.detail}")
vm_name = vm_name.strip() return JSONResponse({
if not vm_name: "phase": "analyzing",
vm_name = (analysis.get("os_type") or "vm") + f"-{vmid}" "analysis_id": analysis.get("analysis_id"),
"vmid": vmid,
"filename": filename,
"vm_name": vm_name.strip(),
})
@app.get("/session/analyze/status/{analysis_id}")
async def session_analyze_status(analysis_id: str):
"""Poll backend for async analysis status."""
try:
return api.get_analysis(analysis_id)
except ApiError as exc:
return JSONResponse({"error": exc.detail}, status_code=502)
@app.get("/session/analyze/result/{analysis_id}", response_class=HTMLResponse)
async def session_analyze_result(
request: Request,
analysis_id: str,
vmid: str = "",
source_filename: str = "",
vm_name: str = "",
):
"""Render the analysis result after polling completes."""
try:
status = api.get_analysis(analysis_id)
result = status.get("result")
if not result:
return render("_analysis.html", request=request,
error="Analysis result not ready.")
except ApiError as exc:
return render("_analysis.html", request=request,
error=f"Failed to fetch analysis: {exc.detail}")
analysis = result
name = vm_name.strip()
if not name:
name = (analysis.get("os_type") or "vm") + f"-{vmid}"
return render("_analysis.html", request=request, return render("_analysis.html", request=request,
vmid=vmid, source_filename=filename, vmid=vmid, source_filename=source_filename,
vm_name=vm_name, analysis=analysis) vm_name=name, analysis=analysis)
@app.post("/session/confirm", response_class=HTMLResponse) @app.post("/session/confirm", response_class=HTMLResponse)

View file

@ -147,10 +147,41 @@ async function runAnalysis(vmid, filename, vmName) {
fd.append('vmid', vmid); fd.append('filename', filename); fd.append('vm_name', vmName); fd.append('vmid', vmid); fd.append('filename', filename); fd.append('vm_name', vmName);
fd.append('session_id', window.VM_BENCH_SID || ''); fd.append('session_id', window.VM_BENCH_SID || '');
const resp = await fetch('/session/analyze', { method: 'POST', body: fd }); const resp = await fetch('/session/analyze', { method: 'POST', body: fd });
const html = await resp.text(); const data = await resp.json();
document.getElementById('analysis-section').innerHTML = html;
document.getElementById('session-status').classList.add('pve-hidden'); if (data.phase === 'error') {
initConfirmForm(); setPhase('Analysis failed', 0, false);
showError(data.error);
return;
}
var analysisId = data.analysis_id;
var startTime = Date.now();
for (;;) {
await sleep(2000);
try {
var pr = await fetch('/session/analyze/status/' + analysisId);
var sdata = await pr.json();
var elapsed = Math.round((Date.now() - startTime) / 1000);
setPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
if (sdata.status === 'completed') {
await sleep(500);
var rr = await fetch('/session/analyze/result/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid);
var html = await rr.text();
document.getElementById('analysis-section').innerHTML = html;
document.getElementById('session-status').classList.add('pve-hidden');
initConfirmForm();
return;
}
if (sdata.status === 'failed') {
setPhase('Analysis failed', 0, false);
showError(sdata.error_details || 'Analysis failed');
return;
}
} catch (_) {}
}
} }
function initConfirmForm() { function initConfirmForm() {

View file

@ -106,15 +106,42 @@ async function startScpPull(e) {
setScpPhase('Pull complete (' + (pdata.file_size_gb || 0) + ' GiB)', 100, false); setScpPhase('Pull complete (' + (pdata.file_size_gb || 0) + ' GiB)', 100, false);
await new Promise(r => setTimeout(r, 500)); await new Promise(r => setTimeout(r, 500));
setScpPhase('Step 2/2: Analysing source image...', 0, true); setScpPhase('Step 2/2: Analysing source image...', 0, true);
const fd = new FormData(); var fd = new FormData();
fd.append('vmid', document.getElementById('scp-vmid').value); fd.append('vmid', document.getElementById('scp-vmid').value);
fd.append('filename', filename); fd.append('filename', filename);
fd.append('vm_name', document.getElementById('scp-vm-name').value.trim()); fd.append('vm_name', document.getElementById('scp-vm-name').value.trim());
fd.append('session_id', SESSION_ID); fd.append('session_id', SESSION_ID);
const ar = await fetch('/session/analyze', { method: 'POST', body: fd }); var ar = await fetch('/session/analyze', { method: 'POST', body: fd });
document.getElementById('scp-analysis-section').innerHTML = await ar.text(); var adata = await ar.json();
document.getElementById('scp-status').classList.add('pve-hidden'); if (adata.phase === 'error') {
btn.disabled = false; return; setScpPhase('Analysis failed', 0, false);
showScpError(adata.error);
btn.disabled = false; return;
}
var analysisId = adata.analysis_id;
var vmid = document.getElementById('scp-vmid').value;
var vmName = document.getElementById('scp-vm-name').value.trim();
var astart = Date.now();
for (;;) {
await new Promise(r => setTimeout(r, 2000));
var sr = await fetch('/session/analyze/status/' + analysisId);
var sdata = await sr.json();
var elapsed = Math.round((Date.now() - astart) / 1000);
setScpPhase('Analysing... ' + (sdata.message || '') + ' (' + elapsed + 's)', sdata.status === 'completed' ? 100 : 50, sdata.status !== 'completed');
if (sdata.status === 'completed') {
await new Promise(r => setTimeout(r, 500));
var rr = await fetch('/session/analyze/result/' + analysisId + '?source_filename=' + encodeURIComponent(filename) + '&vm_name=' + encodeURIComponent(vmName) + '&vmid=' + vmid);
document.getElementById('scp-analysis-section').innerHTML = await rr.text();
document.getElementById('scp-status').classList.add('pve-hidden');
scpInitConfirmForm();
btn.disabled = false; return;
}
if (sdata.status === 'failed') {
setScpPhase('Analysis failed', 0, false);
showScpError(sdata.error_details || 'Analysis failed');
btn.disabled = false; return;
}
}
} }
if (pdata.phase === 'error') { if (pdata.phase === 'error') {
setScpPhase('SCP pull failed', 0, false); setScpPhase('SCP pull failed', 0, false);
@ -147,6 +174,68 @@ function showScpError(msg) {
const el = document.getElementById('scp-error'); const el = document.getElementById('scp-error');
if (el) el.innerHTML += '<div class="pve-alert pve-alert-error" style="margin-top:0.5rem;">' + msg + '</div>'; if (el) el.innerHTML += '<div class="pve-alert pve-alert-error" style="margin-top:0.5rem;">' + msg + '</div>';
} }
function scpInitConfirmForm() {
const form = document.getElementById('confirm-form');
if (!form) return;
form.addEventListener('submit', scpSubmitJob);
const bootSel = form.querySelector('select[name="auto_detect_boot"]');
if (bootSel) {
bootSel.addEventListener('change', function() {
document.getElementById('boot-type-group').style.display =
this.value === 'false' ? 'block' : 'none';
});
}
}
async function scpSubmitJob(e) {
e.preventDefault();
const form = e.target;
const btn = form.querySelector('#submit-btn');
btn.disabled = true;
const formData = new FormData(form);
formData.append('session_id', SESSION_ID);
try {
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
const html = await resp.text();
document.getElementById('scp-form-panel').style.display = 'none';
document.getElementById('scp-analysis-section').innerHTML = '';
document.getElementById('scp-analysis-section').innerHTML = html;
scpInitPolling();
} catch (err) {
btn.disabled = false;
}
}
var _scpPollTimer = null;
function scpInitPolling() {
const container = document.getElementById('polling-container');
if (!container) return;
const jobId = container.dataset.jobId;
const startTime = Date.now();
var completed = false;
async function poll() {
try {
const resp = await fetch('/session/status/' + jobId);
const data = await resp.json();
const pct = data.progress_percentage || 0;
var fill = document.getElementById('pve-progress-bar');
if (fill) { fill.style.width = pct + '%'; fill.textContent = pct + '%'; }
var elMsg = document.getElementById('status-message');
if (elMsg) elMsg.textContent = data.message || '';
var badge = document.getElementById('status-badge');
if (badge) {
if (data.status === 'completed') { badge.className = 'pve-badge pve-badge-completed'; badge.textContent = 'Completed'; }
else if (data.status === 'failed') { badge.className = 'pve-badge pve-badge-failed'; badge.textContent = 'Failed'; }
else { badge.className = 'pve-badge pve-badge-running'; badge.innerHTML = '<span class="pve-spinner"></span> Processing'; }
}
if (data.status === 'completed' || data.status === 'failed') { completed = true; return; }
} catch (err) {}
if (!completed) _scpPollTimer = setTimeout(poll, 2000);
}
_scpPollTimer = setTimeout(poll, 3000);
}
</script> </script>
{% endblock %} {% endblock %}