212 lines
7.1 KiB
Python
212 lines
7.1 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"
|
|
DEFAULT_NETWORK = "vmbr0"
|
|
|
|
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,
|
|
"network": DEFAULT_NETWORK,
|
|
"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)
|