vm-bench/frontend/app.py
Claus Lohmar 509eb97b57 fix: large-file support + user-defined VM name
Large-file handling:
- Set TMPDIR=/mnt/converter/in in service to spool uploads to
  shared storage instead of 24 GB LXC rootfs (critical for >24GB)
- Chunked upload streaming (8 MiB) with progress logging every 1 GiB
- Pre-flight disk space check via Content-Length header
- Clean up partial files on upload/download failure
- Download timeout extended to 7200s (2 hours) for 88 GB images
- Switched wget from --show-progress to --progress=dot:giga
  (compact output, won't fill memory on large transfers)
- uvicorn --timeout-keep-alive 300 on both frontend and backend

VM name:
- Added vm_name field to initial session form (step 1)
- Falls back to auto-generated 'os_type-vmid' if left blank
- Pre-filled & editable in confirm form (step 2)
2026-07-21 17:59:31 +00:00

282 lines
11 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 logging
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()
logger = logging.getLogger("vm-bench")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# Config
VM_ID_MIN = 21000
VM_ID_MAX = 21100
DEFAULT_STORAGE = "local-lvm"
MIN_FREE_DISK_GB = 2 # keep 2 GB headroom
DOWNLOAD_TIMEOUT = 7200 # 2 hours for very large files
UPLOAD_PROGRESS_INTERVAL = 1024**3 # log every 1 GiB during upload
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 _check_disk_space(path: Path, needed_gb: int) -> Optional[str]:
"""Return an error message if <path> has less than <needed_gb> + headroom free."""
usage = shutil.disk_usage(path.parent if path.is_file() or not path.exists() else path)
free_gb = usage.free / (1024**3)
required = needed_gb + MIN_FREE_DISK_GB
if free_gb < required:
return (
f"Insufficient disk space on {path.parent}: "
f"{free_gb:.1f} GB free, {required:.1f} GB needed "
f"({needed_gb} GB file + {MIN_FREE_DISK_GB} GB headroom). "
f"Free up space or use a smaller file."
)
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 "",
prefill_vmname="")
@app.post("/session/start", response_class=HTMLResponse)
async def start_session(
request: Request,
vmid: int = Form(...),
vm_name: str = 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
# Estimate size from Content-Length header (may be None)
content_length = request.headers.get("content-length")
if content_length:
estimated_gb = int(content_length) / (1024**3)
err = _check_disk_space(dest, estimated_gb)
if err:
return render("_analysis.html", request=request, error=err)
try:
logger.info("Receiving upload: %s (%s bytes)", filename, content_length or "unknown")
with dest.open("wb") as f:
written = 0
while True:
chunk = source_file.file.read(8 * 1024 * 1024) # 8 MiB chunks
if not chunk:
break
f.write(chunk)
written += len(chunk)
if written % UPLOAD_PROGRESS_INTERVAL < len(chunk):
logger.info("Upload progress: %s%.1f GiB written", filename, written / (1024**3))
logger.info("Upload complete: %s (%.1f GiB)", filename, written / (1024**3))
except OSError as exc:
# Clean up partial file on disk-full or other IO error
if dest.exists():
dest.unlink(missing_ok=True)
error = f"Upload failed (disk full?): {exc}"
except Exception as exc:
if dest.exists():
dest.unlink(missing_ok=True)
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
# Warn if staging is tight, but don't block (don't know file size)
usage = shutil.disk_usage(STAGING)
free_gb = usage.free / (1024**3)
if free_gb < 50:
logger.warning("Low disk space on %s: %.1f GB free — download may fail for large files",
STAGING, free_gb)
logger.info("Starting download: %s%s", url, dest)
try:
# --progress=dot:giga prints one dot per 64 KiB downloaded, minimal output
# Redirection to stderr keeps stdout clean for error capture
result = subprocess.run(
["wget", "--progress=dot:giga", "-O", str(dest), url],
capture_output=True, text=True, timeout=DOWNLOAD_TIMEOUT,
)
if result.returncode != 0:
error = f"Download failed: {result.stderr.strip()[:300]}"
else:
file_size = dest.stat().st_size / (1024**3) if dest.exists() else 0
logger.info("Download complete: %s (%.1f GiB)", filename, file_size)
except subprocess.TimeoutExpired:
if dest.exists():
dest.unlink(missing_ok=True)
error = (
f"Download timed out after {DOWNLOAD_TIMEOUT // 3600} hours. "
f"The file may be too large for your network speed."
)
except Exception as exc:
if dest.exists():
dest.unlink(missing_ok=True)
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 = vm_name.strip() if vm_name else ""
if not vm_name:
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)