commit a8099f504c109305fb75671b567cc8b3cf63eaa0 Author: Claus Lohmar Date: Tue Jul 21 14:53:29 2026 +0000 chore: initial commit — vm-bench frontend + backend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99e3b92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Staging data — not code +/in/ +/out/ + +# Python +__pycache__/ +*.pyc diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..3e03fe1 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,114 @@ +""" +Backend API — Proxmox Image Conversion Engine + +Runs on the Proxmox host (srv2) at http://10.2.0.2:9000/api/v1 +Matches open-api.yaml v1.1.0 spec exactly. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +from models import ( + JobSubmissionRequest, + JobStatusResponse, + CleanupRequest, + AnalyzeRequest, + AnalyzeResponse, + ErrorResponse, +) +from converter import extract_if_needed, discover_disk, detect_os +from provisioner import submit_job, get_job_status, cleanup_staging + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- +app = FastAPI( + title="Proxmox Image Conversion Engine", + version="1.1.0", + docs_url="/docs", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# --------------------------------------------------------------------------- +# Routes — matching open-api.yaml +# --------------------------------------------------------------------------- + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} + + +@app.post("/analyze", response_model=AnalyzeResponse) +def analyze(req: AnalyzeRequest) -> AnalyzeResponse: + """Analyze a source file: extract if archive, find disk, detect OS.""" + try: + source = extract_if_needed(req.source_filename) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + try: + disk = discover_disk(source) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Disk discovery failed: {exc}") + + # Determine extract_dir for OS detection + extract_dir = source if source.is_dir() else source.parent + + os_type = detect_os(disk.path, extract_dir) + efi = disk.format in ("raw", "qcow2") # qemu-img can probe EFI partition + + return AnalyzeResponse( + os_type=os_type, + disk_size_gb=disk.size_gb, + disk_format=disk.format, + bootable=True, + efi_detectable=efi, + filename=disk.path.name, + ) + + +@app.post("/api/v1/jobs", response_model=JobStatusResponse, status_code=202) +def create_job(req: JobSubmissionRequest) -> JobStatusResponse: + """Submit a conversion + provisioning job.""" + try: + return submit_job(req) + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@app.get("/api/v1/jobs/{job_id}", response_model=JobStatusResponse) +def job_status(job_id: str) -> JobStatusResponse: + """Get current status of a conversion job.""" + try: + return get_job_status(job_id) + except Exception: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + + +@app.post("/api/v1/jobs/{job_id}/cleanup") +def cleanup_job(job_id: str, req: CleanupRequest) -> dict: + """Delete or preserve staging files for a job.""" + # Extract vmid from job_id (format: job_{vmid}_{timestamp}) + try: + vmid = int(job_id.split("_")[1]) + except (IndexError, ValueError): + vmid = 0 + return cleanup_staging(vmid, req.delete_staging_files) + + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=9000) diff --git a/backend/converter.py b/backend/converter.py new file mode 100644 index 0000000..6ac4611 --- /dev/null +++ b/backend/converter.py @@ -0,0 +1,240 @@ +""" +Archive extraction, disk probing, and OS detection. + +Runs on the Proxmox host (srv2) where qemu-img, 7z, unzip, tar are available. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path +from dataclasses import dataclass +from typing import Optional + +STAGING_IN = Path("/mnt/converter/in") +STAGING_OUT = Path("/mnt/converter/out") + +DISK_EXTENSIONS = {".vmdk", ".qcow2", ".qcow", ".img", ".raw", ".vhd", ".vhdx"} + +ARCHIVE_EXTENSIONS = {".7z", ".zip", ".tar.gz", ".tgz", ".tar", ".gz", ".bz2", ".xz"} + +MIN_DISK_BYTES = 1024 + + +@dataclass +class DiskInfo: + path: Path + format: str # from qemu-img + size_bytes: int + size_gb: float + os_type: Optional[str] = None + bootable: bool = False + efi_detectable: bool = False + + +# --------------------------------------------------------------------------- +# Extraction +# --------------------------------------------------------------------------- + +def extract_if_needed(filename: str) -> Path: + """Extract an archive into STAGING_IN if it's compressed. Returns the path + to the extracted directory (or the original file if not compressed).""" + filepath = STAGING_IN / filename + if not filepath.exists(): + raise FileNotFoundError(f"Source not found: {filepath}") + + # Detect archive type + name = filepath.name.lower() + ext = _detect_archive_ext(name) + + if ext is None: + return filepath # not an archive, return as-is + + # Determine extraction tool + tool_map = { + ".7z": ("7z", ["7z", "x", "-y", str(filepath)]), + ".zip": ("unzip", ["unzip", "-o", str(filepath)]), + ".tar.gz": ("tar", ["tar", "-xzf", str(filepath)]), + ".tgz": ("tar", ["tar", "-xzf", str(filepath)]), + ".tar": ("tar", ["tar", "-xf", str(filepath)]), + ".gz": ("gunzip", ["gunzip", "-f", str(filepath)]), + ".bz2": ("bunzip2", ["bunzip2", "-f", str(filepath)]), + ".xz": ("xz", ["xz", "-d", str(filepath)]), + } + tool, cmd = tool_map[ext] + + if not shutil.which(tool): + raise RuntimeError(f"Extraction tool '{tool}' not found on host") + + # Directory stem: strip extension(s) + stem = name + for e in sorted(tool_map, key=len, reverse=True): + if stem.endswith(e): + stem = stem[:-len(e)] + break + out_dir = STAGING_IN / stem + out_dir.mkdir(parents=True, exist_ok=True) + + result = subprocess.run(cmd, cwd=str(out_dir), capture_output=True, text=True, timeout=600) + if result.returncode != 0: + raise RuntimeError(f"{tool} failed: {result.stderr.strip()[:500]}") + + return out_dir + + +def _detect_archive_ext(name: str) -> Optional[str]: + for ext in sorted(ARCHIVE_EXTENSIONS, key=len, reverse=True): + if name.endswith(ext): + return ext + return None + + +# --------------------------------------------------------------------------- +# Disk discovery +# --------------------------------------------------------------------------- + +def discover_disk(source: Path) -> DiskInfo: + """Walk the source directory and find the primary disk image.""" + if source.is_file(): + return _probe(source) + + candidates = [] + for dirpath, _, filenames in os.walk(source): + for fname in filenames: + fpath = Path(dirpath) / fname + if fpath.suffix.lower() in DISK_EXTENSIONS and fpath.stat().st_size >= MIN_DISK_BYTES: + candidates.append(fpath) + + if not candidates: + raise FileNotFoundError(f"No disk image found in {source}") + + # Pick largest as primary + candidates.sort(key=lambda p: p.stat().st_size, reverse=True) + return _probe(candidates[0]) + + +def _probe(disk_path: Path) -> DiskInfo: + """Run qemu-img info and extract format + virtual size.""" + result = subprocess.run( + ["qemu-img", "info", "--output=json", str(disk_path)], + capture_output=True, text=True, timeout=30, + ) + + fmt = disk_path.suffix.lstrip(".").lower() + virtual_size = disk_path.stat().st_size + + if result.returncode == 0: + import json + data = json.loads(result.stdout) + fmt = data.get("format", fmt) + virtual_size = data.get("virtual-size", virtual_size) + + size_gb = round(virtual_size / (1024 ** 3), 1) + + return DiskInfo( + path=disk_path, + format=fmt, + size_bytes=virtual_size, + size_gb=size_gb, + ) + + +# --------------------------------------------------------------------------- +# OS detection +# --------------------------------------------------------------------------- + +def detect_os(disk_path: Path, extract_dir: Path) -> Optional[str]: + """Try multiple strategies to identify the guest OS. + + 1. Parse .vmx sidecar file for guestOS field + 2. Read /etc/os-release from disk via libguestfs if available + 3. Heuristic from filename / directory name + """ + # Strategy 1: .vmx sidecar + vmx = _find_sidecar(extract_dir, ".vmx") + if vmx: + os_type = _parse_vmx_guest_os(vmx) + if os_type: + return os_type + + # Strategy 2: libguestfs (if available) + if shutil.which("virt-inspector"): + try: + result = subprocess.run( + ["virt-inspector", str(disk_path)], + capture_output=True, text=True, timeout=60, + ) + if result.returncode == 0: + import xml.etree.ElementTree as ET + root = ET.fromstring(result.stdout) + os_elem = root.find(".//operatingsystem") + if os_elem is not None and os_elem.text: + return f"{os_elem.text}" + except Exception: + pass + + # Strategy 3: filename heuristics + name = disk_path.name.lower() + os_hints = { + "debian": "Debian", + "ubuntu": "Ubuntu", + "centos": "CentOS", + "rhel": "RHEL", + "fedora": "Fedora", + "windows": "Windows", + "win10": "Windows 10", + "win11": "Windows 11", + "alpine": "Alpine", + "arch": "Arch Linux", + "opensuse": "openSUSE", + } + for keyword, label in os_hints.items(): + if keyword in name: + return label + + # Also check parent directory name + parent = str(extract_dir).lower() + for keyword, label in os_hints.items(): + if keyword in parent: + return label + + return None + + +def _find_sidecar(base: Path, ext: str) -> Optional[Path]: + """Find first file with given extension in the directory tree.""" + if not base.is_dir(): + return None + for dirpath, _, filenames in os.walk(base): + for fname in filenames: + if fname.lower().endswith(ext): + return Path(dirpath) / fname + return None + + +def _parse_vmx_guest_os(vmx_path: Path) -> Optional[str]: + """Parse VMware .vmx file for guestOS field.""" + try: + text = vmx_path.read_text(errors="ignore") + except OSError: + return None + m = re.search(r'guestOS\s*=\s*"([^"]+)"', text, re.IGNORECASE) + if not m: + return None + raw = m.group(1) + # Map common VMware guestOS values + mapping = { + "debian": "Debian", "ubuntu": "Ubuntu", "centos": "CentOS", + "rhel": "RHEL", "fedora": "Fedora", "windows": "Windows", + "other": "Other Linux", "other-64": "Other Linux (64-bit)", + "other26xlinux": "Linux 2.6.x", "other3xlinux": "Linux 3.x+", + "other4xlinux": "Linux 4.x+", "other5xlinux": "Linux 5.x+", + } + raw_lower = raw.lower().replace("_", "").replace("-", "").replace(" ", "") + for k, v in mapping.items(): + if k in raw_lower: + return v + return raw.replace("-", " ").replace("_", " ").title() diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..54be64c --- /dev/null +++ b/backend/models.py @@ -0,0 +1,110 @@ +""" +Pydantic models matching open-api.yaml v1.1.0 exactly. + +Single source of truth: /mnt/converter/open-api.yaml +""" + +from __future__ import annotations + +from enum import Enum +from typing import Optional, List +from pydantic import BaseModel, Field + + +class DiskType(str, Enum): + IMAGE_FILE = "image_file" + EMPTY_DISK = "empty_disk" + + +class DiskFormat(str, Enum): + QCOW2 = "qcow2" + RAW = "raw" + VMDK = "vmdk" + + +class BootType(str, Enum): + UEFI = "uefi" + LEGACY = "legacy" + + +class JobStatus(str, Enum): + QUEUED = "queued" + PROCESSING_CONVERSION = "processing_conversion" + IMPORTING_STORAGE = "importing_storage" + COMPLETED = "completed" + FAILED = "failed" + + +# --------------------------------------------------------------------------- +# Request schemas +# --------------------------------------------------------------------------- + +class DiskSpec(BaseModel): + disk_type: DiskType = DiskType.IMAGE_FILE + source_filename: Optional[str] = None + size_gb: Optional[int] = None + format: DiskFormat = DiskFormat.QCOW2 + + +class JobSubmissionRequest(BaseModel): + vmid: int + vm_name: str + boot_disk: DiskSpec + boot_type: BootType = BootType.UEFI + auto_detect_boot: bool = True + cpu_cores: int = Field(default=4, ge=1) + ram_mb: int = Field(default=8192, ge=512) + target_storage: str = "local-lvm" + additional_disks: List[DiskSpec] = [] + target_disk_size_gb: Optional[int] = None + + +class CleanupRequest(BaseModel): + delete_staging_files: bool + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +class JobStatusResponse(BaseModel): + job_id: str + vmid: int + status: JobStatus + progress_percentage: int = Field(default=0, ge=0, le=100) + message: str = "" + error_details: Optional[str] = None + + +class CleanupResponse(BaseModel): + job_id: str + action_taken: str # "purged" | "retained" + message: str + + +class ErrorResponse(BaseModel): + detail: str + + +# --------------------------------------------------------------------------- +# New: /analyze endpoint +# --------------------------------------------------------------------------- + +class AnalyzeRequest(BaseModel): + vmid: int + source_filename: str + source_type: str = "upload" # "upload" | "url" + + +class AnalyzeResponse(BaseModel): + os_type: Optional[str] = None + disk_size_gb: float = 0 + disk_format: str = "" + bootable: bool = False + efi_detectable: bool = False + filename: str = "" + error: Optional[str] = None + + +class HealthResponse(BaseModel): + status: str = "ok" diff --git a/backend/provisioner.py b/backend/provisioner.py new file mode 100644 index 0000000..c6ccb4c --- /dev/null +++ b/backend/provisioner.py @@ -0,0 +1,116 @@ +""" +VM provisioning on the Proxmox host. + +This module is a stub — the real Proxmox integration (qm create, qm importdisk, +qm set) runs on the backend host (srv2). Fill in the actual Proxmox commands +when you deploy the backend to the host. +""" + +from __future__ import annotations + +import subprocess +import uuid +import time +from pathlib import Path +from typing import Optional + +from models import ( + JobSubmissionRequest, + JobStatusResponse, + JobStatus, +) + +STAGING_OUT = Path("/mnt/converter/out") + + +def submit_job(req: JobSubmissionRequest) -> JobStatusResponse: + """Submit a conversion + provisioning job. Returns initial status.""" + job_id = f"job_{req.vmid}_{int(time.time())}" + + # Create output staging directory + out_dir = STAGING_OUT / str(req.vmid) + out_dir.mkdir(parents=True, exist_ok=True) + + # In the real backend, this would: + # 1. Convert the source image to QCOW2 via qemu-img convert + # 2. Resize if target_disk_size_gb is set + # 3. Create the VM with qm create + # 4. Import the disk: qm importdisk + # 5. Configure boot: qm set --boot order=scsi0 --bios ovmf + # 6. Auto-start if configured: qm set --onboot 1 + + # Stub: simulate a job being queued + return JobStatusResponse( + job_id=job_id, + vmid=req.vmid, + status=JobStatus.QUEUED, + progress_percentage=0, + message="Job queued for processing", + error_details=None, + ) + + +def get_job_status(job_id: str) -> JobStatusResponse: + """Query the current status of a job. + + In the real backend, this would check the actual conversion process + (qemu-img progress, qm task status, etc.). + + For now, returns a simulated progressing status to demonstrate the flow. + """ + # Stub: simulate progress advancing + parts = job_id.split("_") + vmid = int(parts[1]) if len(parts) >= 2 else 0 + ts = int(parts[2]) if len(parts) >= 3 else 0 + elapsed = int(time.time()) - ts + + if elapsed < 5: + progress = min(int(elapsed * 10), 40) + status = JobStatus.PROCESSING_CONVERSION + msg = "Converting disk image..." + elif elapsed < 10: + progress = 40 + min(int((elapsed - 5) * 10), 50) + status = JobStatus.IMPORTING_STORAGE + msg = "Importing disk to Proxmox storage..." + else: + progress = 100 + status = JobStatus.COMPLETED + msg = "VM created successfully" + + return JobStatusResponse( + job_id=job_id, + vmid=vmid, + status=status, + progress_percentage=progress, + message=msg, + error_details=None, + ) + + +def cleanup_staging(vmid: int, delete: bool) -> dict: + """Remove staging files for a VM ID. Returns action details.""" + in_dir = Path("/mnt/converter/in") + out_dir = STAGING_OUT / str(vmid) + + if not delete: + return { + "job_id": f"job_{vmid}", + "action_taken": "retained", + "message": f"Staging files preserved for VM {vmid}", + } + + # Remove extracted source files in /mnt/converter/in for this vmid + # (In production, you'd track which files belong to which job) + cleaned_in = False + cleaned_out = False + + if out_dir.exists(): + import shutil + shutil.rmtree(out_dir, ignore_errors=True) + cleaned_out = True + + return { + "job_id": f"job_{vmid}", + "action_taken": "purged", + "message": f"Staging files deleted for VM {vmid}", + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c9a54a8 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.110.0 +uvicorn>=0.29.0 +pydantic>=2.0.0 diff --git a/frontend/api_client.py b/frontend/api_client.py new file mode 100644 index 0000000..c38ed3b --- /dev/null +++ b/frontend/api_client.py @@ -0,0 +1,87 @@ +""" +Typed REST client for the Proxmox Image Conversion Backend API. + +Connects to the backend at http://10.2.0.2:9000/api/v1. +Derived from open-api.yaml — do not change models without updating the spec. +""" + +from __future__ import annotations + +import os +import requests +from urllib.parse import urljoin +from typing import Optional + +BACKEND_URL = os.getenv("BACKEND_URL", "http://10.2.0.2:9000") +TIMEOUT = 30 + + +class ApiError(Exception): + def __init__(self, status: int, detail: str): + self.status = status + self.detail = detail + super().__init__(f"HTTP {status}: {detail}") + + +class ApiClient: + """Synchronous HTTP client for the backend API.""" + + def __init__(self, base_url: str = BACKEND_URL): + self.base_url = base_url.rstrip("/") + self._s = requests.Session() + self._s.headers.update({ + "Accept": "application/json", + "Content-Type": "application/json", + }) + + # ------------------------------------------------------------------ + # Public API — matches open-api.yaml + # ------------------------------------------------------------------ + + def health(self) -> dict: + return self._get("/health") + + def analyze(self, vmid: int, filename: str, source_type: str = "upload") -> dict: + """POST /analyze — probe source file for OS, size, format.""" + return self._post("/analyze", { + "vmid": vmid, + "source_filename": filename, + "source_type": source_type, + }) + + def create_job(self, payload: dict) -> dict: + """POST /api/v1/jobs — submit conversion job.""" + return self._post("/api/v1/jobs", payload) + + def get_job(self, job_id: str) -> dict: + """GET /api/v1/jobs/{job_id} — poll job status.""" + return self._get(f"/api/v1/jobs/{job_id}") + + def cleanup_job(self, job_id: str, delete: bool) -> dict: + """POST /api/v1/jobs/{job_id}/cleanup — remove or keep staging files.""" + return self._post(f"/api/v1/jobs/{job_id}/cleanup", {"delete_staging_files": delete}) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _get(self, path: str) -> dict: + return self._request("GET", path) + + def _post(self, path: str, data: dict) -> dict: + return self._request("POST", path, data) + + def _request(self, method: str, path: str, data: Optional[dict] = None) -> dict: + url = urljoin(self.base_url + "/", path.lstrip("/")) + try: + resp = self._s.request(method, url, json=data, timeout=TIMEOUT) + if resp.ok: + return resp.json() + detail = resp.text + try: + detail = resp.json().get("detail", detail) + except Exception: + pass + raise ApiError(resp.status_code, detail) + except requests.RequestException as exc: + raise ApiError(0, f"Connection failed: {exc}") from exc diff --git a/frontend/app.py b/frontend/app.py new file mode 100644 index 0000000..900d184 --- /dev/null +++ b/frontend/app.py @@ -0,0 +1,190 @@ +""" +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() + +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 + + # --- 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.""" + 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) diff --git a/frontend/requirements.txt b/frontend/requirements.txt new file mode 100644 index 0000000..cc9ff8d --- /dev/null +++ b/frontend/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.110.0 +uvicorn>=0.29.0 +jinja2>=3.1.0 +python-multipart>=0.0.9 +requests>=2.28.0 diff --git a/frontend/static/proxmox.css b/frontend/static/proxmox.css new file mode 100644 index 0000000..5a313b8 --- /dev/null +++ b/frontend/static/proxmox.css @@ -0,0 +1,117 @@ +/* Proxmox VE-inspired dark theme for VM Bench */ +:root { + --bg: #161616; + --panel: #1e1e1e; + --border: #3a3a3a; + --text: #e0e0e0; + --muted: #999; + --accent: #f48024; /* Proxmox orange */ + --accent-alt: #3892d5; /* blue accent */ + --green: #54b948; + --red: #e8524a; + --yellow: #e5a72e; + --radius: 4px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + background: var(--bg); color: var(--text); font-family: var(--font); + font-size: 13px; line-height: 1.5; min-height: 100vh; + display: flex; flex-direction: column; +} +header { + background: var(--panel); border-bottom: 2px solid var(--accent); + padding: 0.75rem 1.5rem; display: flex; align-items: baseline; gap: 1rem; +} +header .logo { font-size: 1.2rem; font-weight: 700; color: var(--accent); } +header .sub { font-size: 0.8rem; color: var(--muted); } +footer { + margin-top: auto; padding: 0.5rem 1.5rem; font-size: 0.75rem; + color: var(--muted); display: flex; justify-content: space-between; + border-top: 1px solid var(--border); background: var(--panel); +} +.container { max-width: 760px; margin: 0 auto; padding: 2rem 1rem; width: 100%; } + +/* Panels */ +.panel { + background: var(--panel); border: 1px solid var(--border); + border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.5rem; +} +.panel h2 { + font-size: 1rem; color: var(--accent); margin-bottom: 1rem; + padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); +} +.poll-panel { text-align: center; } + +/* Forms */ +.form-group { margin-bottom: 1rem; } +label { display: block; margin-bottom: 0.25rem; font-size: 0.8rem; color: var(--muted); } +input, select, button, textarea { + width: 100%; padding: 0.5rem 0.75rem; background: var(--bg); + border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); font-family: inherit; font-size: 0.85rem; + transition: border-color 0.15s; +} +input:focus, select:focus { border-color: var(--accent); outline: none; } +.hint { display: block; font-size: 0.7rem; color: var(--muted); margin-top: 0.25rem; } +.hidden { display: none !important; } +.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +@media (max-width: 520px) { .row { grid-template-columns: 1fr; } } + +/* Buttons */ +button, .btn { + display: inline-block; padding: 0.5rem 1.25rem; font-weight: 600; + font-size: 0.85rem; cursor: pointer; text-decoration: none; + border-radius: var(--radius); transition: opacity 0.15s; +} +button, .btn { background: var(--accent); color: #fff; border: none; } +button:hover, .btn:hover { opacity: 0.85; } +.btn-secondary, button.btn-secondary { background: var(--border); color: var(--text); } +.btn-row { display: flex; gap: 0.75rem; margin-top: 1rem; } + +/* Tables */ +table { width: 100%; border-collapse: collapse; margin: 0.75rem 0; } +td { padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); font-size: 0.85rem; } +td:first-child { color: var(--muted); width: 35%; } +td:last-child { font-weight: 500; } + +/* Progress bar */ +.progress-bar { + height: 1.25rem; background: var(--bg); border-radius: var(--radius); + overflow: hidden; margin: 1rem 0; +} +.progress-fill { + height: 100%; background: var(--accent); transition: width 0.4s ease; + display: flex; align-items: center; justify-content: center; + font-size: 0.7rem; font-weight: 700; color: #fff; min-width: 2.5rem; +} + +/* Badges */ +.badge { + display: inline-block; padding: 0.2rem 0.6rem; border-radius: 3px; + font-size: 0.75rem; font-weight: 600; margin-bottom: 0.75rem; +} +.badge-queued { background: #3b3410; color: var(--yellow); } +.badge-running { background: #1e3a5f; color: var(--accent-alt); } +.badge-completed { background: #1a3a1a; color: var(--green); } +.badge-failed { background: #3a1a1a; color: var(--red); } + +/* Spinner */ +.spinner { + display: inline-block; width: 0.85rem; height: 0.85rem; + border: 2px solid var(--border); border-top-color: currentColor; + border-radius: 50%; animation: spin 0.6s linear infinite; vertical-align: middle; margin-right: 0.25rem; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* Alerts */ +.error { background: #3a1a1a; border: 1px solid var(--red); color: var(--red); + padding: 0.6rem 0.75rem; border-radius: var(--radius); font-size: 0.85rem; } +.success { background: #1a3a1a; border: 1px solid var(--green); color: var(--green); + padding: 0.6rem 0.75rem; border-radius: var(--radius); } +.error-panel h2 { color: var(--red); } + +/* Misc */ +.dim { color: var(--muted); } +.confirm-text { color: var(--muted); font-size: 0.85rem; margin: 0.75rem 0; } +code { background: var(--bg); padding: 0.1rem 0.4rem; border-radius: 2px; font-size: 0.8rem; } diff --git a/frontend/templates/_analysis.html b/frontend/templates/_analysis.html new file mode 100644 index 0000000..de15f86 --- /dev/null +++ b/frontend/templates/_analysis.html @@ -0,0 +1,105 @@ +{# Fragment returned by POST /session/start — analysis result + confirm form #} + +{% if error %} +
+

Analysis Failed

+
{{ error }}
+ Try Again +
+{% else %} +
+

Analysis Result

+ + + + + + + +
File{{ analysis.filename }}
Disk Format{{ analysis.disk_format }}
Disk Size{{ analysis.disk_size_gb }} GB
Operating System{{ analysis.os_type or 'Unknown' }}
EFI Bootable{{ 'Yes' if analysis.efi_detectable else 'Unknown' }}
+ +

Is this correct? Configure the VM below and submit the conversion job.

+ +
+ + + + +
+
+ + +
+
+ + + {{ analysis.disk_size_gb }} GB detected. Omit to auto-shrink to 30 GB. +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + +
+ + +
+{% endif %} + + diff --git a/frontend/templates/base.html b/frontend/templates/base.html new file mode 100644 index 0000000..bc266ae --- /dev/null +++ b/frontend/templates/base.html @@ -0,0 +1,26 @@ + + + + + +VM Bench — Proxmox Image Converter + + + + +
+ +
Proxmox Image Conversion
+
+ +
+ {% block content %}{% endblock %} +
+ +
+ vm-bench frontend + Backend: {{ backend_url or "10.2.0.2:9000" }} +
+ + + diff --git a/frontend/templates/index.html b/frontend/templates/index.html new file mode 100644 index 0000000..c0e78f1 --- /dev/null +++ b/frontend/templates/index.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block content %} + + +
+

New Conversion Session

+ +
+
+ + + Must be unique on the Proxmox host +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+ + +
+ + + +{% endblock %} diff --git a/frontend/templates/polling.html b/frontend/templates/polling.html new file mode 100644 index 0000000..bd1361a --- /dev/null +++ b/frontend/templates/polling.html @@ -0,0 +1,125 @@ + + + + + +Converting VM — VM Bench + + + + +
+ +
Proxmox Image Conversion
+
+ +
+
+

Converting VM {{ vmid }}

+

Job: {{ job_id }}  |  {{ vm_name }}

+ +
+ Queued +
+ +
+
0%
+
+ +

Waiting for backend...

+

Elapsed: 0s

+ +
+ + + +
+ +
+ vm-bench frontend + Job: {{ job_id }} +
+ + + + diff --git a/open-api.yaml b/open-api.yaml new file mode 100644 index 0000000..8c2bf6c --- /dev/null +++ b/open-api.yaml @@ -0,0 +1,245 @@ +openapi: 3.0.3 +info: + title: Proxmox Image Conversion Engine + description: | + Backend API that runs on the Proxmox host (`srv2`) to convert VMware images + (VMDK, VHD, etc.) to Proxmox‑compatible QCOW2, optionally shrink the disk, + and provision a VM with automatic boot‑type detection. + version: 1.1.0 + contact: + name: Support + url: https://github.com/your-org/converter + +servers: + - url: http://10.2.0.2:9000/api/v1 + description: Internal Proxmox host (srv2) + +paths: + /jobs: + post: + summary: Submit a new VM conversion and creation job + operationId: createJob + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JobSubmissionRequest' + responses: + '202': + description: Job accepted and queued for processing + content: + application/json: + schema: + $ref: '#/components/schemas/JobStatusResponse' + '400': + description: Invalid request or source file not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /jobs/{job_id}: + get: + summary: Get the current status of a conversion job + operationId: getJobStatus + parameters: + - name: job_id + in: path + required: true + schema: + type: string + example: "job_10007_1784637888" + responses: + '200': + description: Job status details + content: + application/json: + schema: + $ref: '#/components/schemas/JobStatusResponse' + '404': + description: Job not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /jobs/{job_id}/cleanup: + post: + summary: Delete staging files or preserve them for reuse + operationId: cleanupJob + parameters: + - name: job_id + in: path + required: true + schema: + type: string + example: "job_10007_1784637888" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CleanupRequest' + responses: + '200': + description: Cleanup action performed + content: + application/json: + schema: + type: object + properties: + job_id: + type: string + action_taken: + type: string + enum: [purged, retained] + message: + type: string + + /health: + get: + summary: Health check endpoint + operationId: healthCheck + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + +components: + schemas: + DiskSpec: + type: object + required: + - disk_type + properties: + disk_type: + type: string + enum: [image_file, empty_disk] + description: | + `image_file` – convert an existing file from `source_filename`. + `empty_disk` – create a blank disk of the given `size_gb`. + source_filename: + type: string + nullable: true + description: Path relative to `/mnt/converter/in` (required if `disk_type=image_file`) + example: "64bit/Debian 12.11.0 (64bit).vmdk" + size_gb: + type: integer + nullable: true + description: Size in GiB (required if `disk_type=empty_disk`) + example: 50 + format: + type: string + enum: [qcow2, raw, vmdk] + default: qcow2 + description: Source image format (for `image_file`) + + JobSubmissionRequest: + type: object + required: + - vmid + - vm_name + - boot_disk + properties: + vmid: + type: integer + description: Proxmox VM ID (must be unique on host) + example: 10007 + vm_name: + type: string + description: Display name for the VM + example: "debian-default-shrink" + boot_type: + type: string + enum: [uefi, legacy] + default: uefi + description: Boot firmware type (ignored if `auto_detect_boot=true`) + auto_detect_boot: + type: boolean + default: true + description: | + If `true`, automatically detects whether the disk is EFI‑bootable and + overrides `boot_type`. Falls back to `legacy` if detection fails. + cpu_cores: + type: integer + default: 4 + minimum: 1 + example: 2 + ram_mb: + type: integer + default: 8192 + minimum: 512 + example: 4096 + target_storage: + type: string + default: "local-lvm" + description: Proxmox storage pool for the VM disks + example: "local-lvm" + boot_disk: + $ref: '#/components/schemas/DiskSpec' + additional_disks: + type: array + items: + $ref: '#/components/schemas/DiskSpec' + description: Additional data disks (converted or empty) + target_disk_size_gb: + type: integer + nullable: true + description: | + If provided, the boot disk (and only the boot disk) is resized to this + size (GiB). If omitted and the boot disk is larger than 30 GiB, it is + automatically shrunk to 30 GiB. Only shrinks when disk_type=image_file. + example: 40 + + JobStatusResponse: + type: object + properties: + job_id: + type: string + example: "job_10007_1784637888" + vmid: + type: integer + example: 10007 + status: + type: string + enum: [queued, processing_conversion, importing_storage, completed, failed] + description: Current job phase + progress_percentage: + type: integer + minimum: 0 + maximum: 100 + example: 75 + message: + type: string + description: Human‑readable status message + example: "Creating VM and importing disks..." + error_details: + type: string + nullable: true + description: If status is `failed`, contains the error reason + + CleanupRequest: + type: object + required: + - delete_staging_files + properties: + delete_staging_files: + type: boolean + description: | + `true` – delete extracted source files and converted disk images. + `false` – keep files so another VM can be created from the same source. + + ErrorResponse: + type: object + properties: + detail: + type: string + example: "Source file not found: 64bit/Debian 12.11.0 (64bit).vmdk"