""" 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)