vm-bench/frontend/app.py
Claus Lohmar c887ccd5a3 fix: uniform /api/v1/ prefix, real EFI detection, spec alignment
Backend:
- All routes now consistently under /api/v1/ (health, analyze, jobs)
- Replace dummy EFI heuristic with actual guestfish-based detect_efi()
- Add path traversal validation on analyze source_filename
- Use typed response models (HealthResponse, CleanupResponse) everywhere
- Clean up unused imports (Path, ErrorResponse)

Models:
- Align AnalyzeResponse with open-api.yaml (add vmid, remove bootable/error,
  make efi_detectable nullable, deduplicate CleanupResponse)

Converter:
- Add detect_efi(disk_path) using guestfish list-filesystems

Frontend:
- Update api_client paths to /api/v1/health and /api/v1/analyze
- Remove unsupported 'network' field from job payload

Spec:
- Promote inline HealthResponse/CleanupResponse to named schemas
- Move /analyze and /health under /api/v1/ prefix

Docs:
- Add README.md with architecture, install, API reference, usage flow
2026-07-21 17:40:08 +00:00

210 lines
7 KiB
Python

"""
VM Bench — Proxmox Image Conversion Frontend
FastAPI web application serving as the user GUI.
Runs in the vm-bench LXC on port 5000.
Communicates with the backend at http://10.2.0.2:9000.
Routes:
GET / New session form
POST /session/start Upload + analyse source image
POST /session/confirm Submit conversion job
GET /session/status/{id} Poll job status (JSON)
POST /session/cleanup/{id} Clean up / reuse staging files
"""
from __future__ import annotations
import os
import shutil
import subprocess
import uuid
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Form, Request, UploadFile, File
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader
from api_client import ApiClient, ApiError, BACKEND_URL
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
app = FastAPI(title="VM Bench — Frontend", version="1.0.0")
BASE = Path(__file__).parent
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
_jinja = Environment(loader=FileSystemLoader(str(BASE / "templates")), autoescape=True)
STAGING = Path("/mnt/converter/in")
api = ApiClient()
# Config
VM_ID_MIN = 21000
VM_ID_MAX = 21100
DEFAULT_STORAGE = "local-lvm"
def _validate_vmid(vmid: int) -> Optional[str]:
if not (VM_ID_MIN <= vmid <= VM_ID_MAX):
return f"VM ID must be between {VM_ID_MIN} and {VM_ID_MAX}."
return None
def render(name: str, status: int = 200, **ctx) -> HTMLResponse:
tpl = _jinja.get_template(name)
return HTMLResponse(tpl.render(**ctx), status_code=status)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index(request: Request, vmid: Optional[int] = None, source: Optional[str] = None):
"""Landing page — new session form. Optionally pre-fills vmid + source for reuse."""
return render("index.html", request=request, backend_url=BACKEND_URL,
prefill_vmid=vmid or "", prefill_source=source or "")
@app.post("/session/start", response_class=HTMLResponse)
async def start_session(
request: Request,
vmid: int = Form(...),
source_type: str = Form("upload"),
source_file: Optional[UploadFile] = File(None),
source_url: Optional[str] = Form(None),
):
"""Upload or download source file, then call backend /analyze."""
filename = None
error = None
# Validate VM ID range
err = _validate_vmid(vmid)
if err:
return render("_analysis.html", request=request, error=err)
# --- Get the file into /mnt/converter/in/ ---
if source_type == "upload":
if not source_file or not source_file.filename:
error = "No file uploaded."
else:
filename = source_file.filename
dest = STAGING / filename
try:
with dest.open("wb") as f:
shutil.copyfileobj(source_file.file, f)
except Exception as exc:
error = f"Upload failed: {exc}"
else:
url = (source_url or "").strip()
if not url:
error = "No URL provided."
else:
filename = Path(url).name or f"download_{uuid.uuid4().hex[:8]}"
dest = STAGING / filename
try:
result = subprocess.run(
["wget", "-q", "--show-progress", "-O", str(dest), url],
capture_output=True, text=True, timeout=1800,
)
if result.returncode != 0:
error = f"Download failed: {result.stderr.strip()[:300]}"
except subprocess.TimeoutExpired:
error = "Download timed out (30 min limit)."
except Exception as exc:
error = f"Download failed: {exc}"
if error:
return render("_analysis.html", request=request, error=error)
# --- Call backend /analyze ---
try:
analysis = api.analyze(vmid=vmid, filename=filename)
except ApiError as exc:
return render("_analysis.html", request=request,
error=f"Backend analysis failed: {exc.detail}")
vm_name = (analysis.get("os_type") or "vm") + f"-{vmid}"
return render("_analysis.html", request=request,
vmid=vmid, source_filename=filename,
vm_name=vm_name, analysis=analysis)
@app.post("/session/confirm", response_class=HTMLResponse)
async def confirm_session(
request: Request,
vmid: int = Form(...),
source_filename: str = Form(...),
disk_format: str = Form(...),
vm_name: str = Form(...),
cpu_cores: int = Form(2),
ram_mb: int = Form(4096),
target_storage: str = Form("local-lvm"),
target_disk_size_gb: Optional[int] = Form(None),
auto_detect_boot: str = Form("true"),
boot_type: str = Form("uefi"),
):
"""Build the job payload and submit to the backend."""
# Validate VM ID range
err = _validate_vmid(vmid)
if err:
return render("_analysis.html", request=request, error=err)
payload = {
"vmid": vmid,
"vm_name": vm_name,
"cpu_cores": cpu_cores,
"ram_mb": ram_mb,
"target_storage": target_storage,
"auto_detect_boot": auto_detect_boot == "true",
"boot_type": boot_type,
"boot_disk": {
"disk_type": "image_file",
"source_filename": source_filename,
"format": disk_format,
},
}
if target_disk_size_gb is not None:
payload["target_disk_size_gb"] = target_disk_size_gb
try:
job = api.create_job(payload)
except ApiError as exc:
return render("_analysis.html", request=request,
error=f"Job submission failed: {exc.detail}")
return render("polling.html", request=request,
job_id=job["job_id"], vmid=vmid,
vm_name=vm_name, source_filename=source_filename)
@app.get("/session/status/{job_id}")
async def session_status(job_id: str):
"""Poll backend for job status (returns JSON for AJAX polling)."""
try:
return api.get_job(job_id)
except ApiError as exc:
return JSONResponse({"error": exc.detail, "status": "error"}, status_code=502)
@app.post("/session/cleanup/{job_id}")
async def session_cleanup(job_id: str, request: Request):
"""Forward cleanup request to backend."""
try:
body = await request.json()
delete = body.get("delete_staging_files", False)
return api.cleanup_job(job_id, delete)
except ApiError as exc:
return JSONResponse({"error": exc.detail}, status_code=502)
# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=5000)