chore: initial commit — vm-bench frontend + backend

This commit is contained in:
Claus Lohmar 2026-07-21 14:53:29 +00:00
commit a8099f504c
15 changed files with 1588 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Staging data — not code
/in/
/out/
# Python
__pycache__/
*.pyc

114
backend/app.py Normal file
View file

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

240
backend/converter.py Normal file
View file

@ -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()

110
backend/models.py Normal file
View file

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

116
backend/provisioner.py Normal file
View file

@ -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 <vmid> <qcow2> <storage>
# 5. Configure boot: qm set <vmid> --boot order=scsi0 --bios ovmf
# 6. Auto-start if configured: qm set <vmid> --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}",
}

3
backend/requirements.txt Normal file
View file

@ -0,0 +1,3 @@
fastapi>=0.110.0
uvicorn>=0.29.0
pydantic>=2.0.0

87
frontend/api_client.py Normal file
View file

@ -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

190
frontend/app.py Normal file
View file

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

View file

@ -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

117
frontend/static/proxmox.css Normal file
View file

@ -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; }

View file

@ -0,0 +1,105 @@
{# Fragment returned by POST /session/start — analysis result + confirm form #}
{% if error %}
<div class="panel error-panel">
<h2>Analysis Failed</h2>
<div class="error">{{ error }}</div>
<a href="/" class="btn">Try Again</a>
</div>
{% else %}
<div class="panel" id="step2">
<h2>Analysis Result</h2>
<table>
<tr><td>File</td><td>{{ analysis.filename }}</td></tr>
<tr><td>Disk Format</td><td>{{ analysis.disk_format }}</td></tr>
<tr><td>Disk Size</td><td>{{ analysis.disk_size_gb }} GB</td></tr>
<tr><td>Operating System</td><td>{{ analysis.os_type or 'Unknown' }}</td></tr>
<tr><td>EFI Bootable</td><td>{{ 'Yes' if analysis.efi_detectable else 'Unknown' }}</td></tr>
</table>
<p class="confirm-text">Is this correct? Configure the VM below and submit the conversion job.</p>
<form id="confirm-form" onsubmit="submitJob(event)">
<input type="hidden" name="vmid" value="{{ vmid }}">
<input type="hidden" name="source_filename" value="{{ source_filename }}">
<input type="hidden" name="disk_format" value="{{ analysis.disk_format }}">
<div class="row">
<div class="form-group">
<label>VM Name *</label>
<input type="text" name="vm_name" required value="{{ vm_name or '' }}"
placeholder="my-converted-vm">
</div>
<div class="form-group">
<label>Target Disk Size (GB)</label>
<input type="number" name="target_disk_size_gb"
value="{{ analysis.disk_size_gb }}"
placeholder="Leave blank for auto-shrink">
<span class="hint">{{ analysis.disk_size_gb }} GB detected. Omit to auto-shrink to 30 GB.</span>
</div>
</div>
<div class="row">
<div class="form-group">
<label>CPU Cores</label>
<input type="number" name="cpu_cores" value="2" min="1">
</div>
<div class="form-group">
<label>RAM (MB)</label>
<input type="number" name="ram_mb" value="4096" min="512">
</div>
</div>
<div class="row">
<div class="form-group">
<label>Storage Pool</label>
<input type="text" name="target_storage" value="local-lvm">
</div>
<div class="form-group">
<label>Boot Detection</label>
<select name="auto_detect_boot">
<option value="true" selected>Auto-detect (recommended)</option>
<option value="false">Manual</option>
</select>
</div>
</div>
<div class="form-group" id="boot-type-group" style="display:none;">
<label>Boot Type</label>
<select name="boot_type">
<option value="uefi">UEFI</option>
<option value="legacy">Legacy BIOS</option>
</select>
</div>
<button type="submit" id="submit-btn">Start Conversion</button>
</form>
<div id="submit-status" class="hidden" style="margin-top:1rem;"></div>
</div>
{% endif %}
<script>
document.querySelector('select[name="auto_detect_boot"]')?.addEventListener('change', function(e) {
document.getElementById('boot-type-group').style.display =
e.target.value === 'false' ? 'block' : 'none';
});
async function submitJob(e) {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const status = document.getElementById('submit-status');
btn.disabled = true;
status.classList.remove('hidden');
status.innerHTML = '<div class="spinner"></div> Submitting job...';
const formData = new FormData(e.target);
try {
const resp = await fetch('/session/confirm', { method: 'POST', body: formData });
const html = await resp.text();
document.body.innerHTML = html; // Replace page with polling view
} catch (err) {
status.innerHTML = `<div class="error">Failed: ${err.message}</div>`;
btn.disabled = false;
}
}
</script>

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VM Bench — Proxmox Image Converter</title>
<link rel="stylesheet" href="/static/proxmox.css">
</head>
<body>
<header>
<div class="logo">VM Bench</div>
<div class="sub">Proxmox Image Conversion</div>
</header>
<div class="container">
{% block content %}{% endblock %}
</div>
<footer>
<span>vm-bench frontend</span>
<span>Backend: {{ backend_url or "10.2.0.2:9000" }}</span>
</footer>
</body>
</html>

View file

@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block content %}
<!-- Step 1: Start New Session -->
<div class="panel" id="step1">
<h2>New Conversion Session</h2>
<form id="session-form" onsubmit="startSession(event)">
<div class="form-group">
<label for="vmid">VM ID *</label>
<input type="number" id="vmid" name="vmid" required
placeholder="e.g. 10010" min="100" max="999999">
<span class="hint">Must be unique on the Proxmox host</span>
</div>
<div class="form-group">
<label for="source-type">Source Type</label>
<select id="source-type" onchange="toggleSourceInput(event)">
<option value="upload">File Upload</option>
<option value="url">Download from URL</option>
</select>
</div>
<div class="form-group" id="upload-group">
<label for="source-file">Source File (.vmdk / .7z / .zip / .tar.gz)</label>
<input type="file" id="source-file" name="source_file"
accept=".vmdk,.vhd,.vhdx,.7z,.zip,.tar.gz,.tgz,.tar,.gz">
</div>
<div class="form-group hidden" id="url-group">
<label for="source-url">Download URL</label>
<input type="url" id="source-url" name="source_url"
placeholder="https://example.com/image.7z">
</div>
<button type="submit" id="start-btn">Analyse Source Image</button>
</form>
<div id="session-status" class="hidden" style="margin-top:1rem;"></div>
</div>
<!-- Step 2: Analysis Result (populated by JavaScript) -->
<div id="analysis-section"></div>
<script>
function toggleSourceInput(e) {
const type = e.target.value;
document.getElementById('upload-group').classList.toggle('hidden', type !== 'upload');
document.getElementById('url-group').classList.toggle('hidden', type !== 'url');
}
async function startSession(e) {
e.preventDefault();
const btn = document.getElementById('start-btn');
const status = document.getElementById('session-status');
btn.disabled = true;
status.classList.remove('hidden');
status.innerHTML = '<div class="spinner"></div> Analysing source image...';
const formData = new FormData();
formData.append('vmid', document.getElementById('vmid').value);
const sourceType = document.getElementById('source-type').value;
formData.append('source_type', sourceType);
if (sourceType === 'upload') {
const fileInput = document.getElementById('source-file');
if (fileInput.files.length > 0) {
formData.append('source_file', fileInput.files[0]);
} else {
status.innerHTML = '<div class="error">Please select a file.</div>';
btn.disabled = false;
return;
}
} else {
const url = document.getElementById('source-url').value.trim();
if (!url) {
status.innerHTML = '<div class="error">Please enter a URL.</div>';
btn.disabled = false;
return;
}
formData.append('source_url', url);
}
try {
const resp = await fetch('/session/start', { method: 'POST', body: formData });
const html = await resp.text();
document.getElementById('analysis-section').innerHTML = html;
status.classList.add('hidden');
} catch (err) {
status.innerHTML = `<div class="error">Failed: ${err.message}</div>`;
} finally {
btn.disabled = false;
}
}
</script>
{% endblock %}

View file

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Converting VM — VM Bench</title>
<link rel="stylesheet" href="/static/proxmox.css">
</head>
<body>
<header>
<div class="logo">VM Bench</div>
<div class="sub">Proxmox Image Conversion</div>
</header>
<div class="container">
<div class="panel poll-panel">
<h2>Converting VM {{ vmid }}</h2>
<p class="dim">Job: <code>{{ job_id }}</code> &nbsp;|&nbsp; {{ vm_name }}</p>
<div id="status-badge" class="badge badge-queued">
<span class="spinner"></span> Queued
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width:0%">0%</div>
</div>
<p id="status-message" class="dim">Waiting for backend...</p>
<p id="elapsed" class="dim">Elapsed: 0s</p>
<div id="error-block" class="error hidden"></div>
</div>
<!-- Reuse section (shown after completion) -->
<div id="reuse-section" class="panel hidden">
<h2>Conversion Complete</h2>
<p>Use the same source image to create another VM?</p>
<div class="btn-row">
<button onclick="reuseImage(true)" class="btn">Yes — Create Another VM</button>
<button onclick="reuseImage(false)" class="btn btn-secondary">No — Clean Up &amp; Finish</button>
</div>
</div>
</div>
<footer>
<span>vm-bench frontend</span>
<span>Job: {{ job_id }}</span>
</footer>
<script>
const jobId = "{{ job_id }}";
const vmid = "{{ vmid }}";
const startTime = Date.now();
let completed = false;
async function poll() {
try {
const resp = await fetch(`/session/status/${jobId}`);
const data = await resp.json();
const pct = data.progress_percentage || 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-fill').textContent = pct + '%';
const elapsed = Math.round((Date.now() - startTime) / 1000);
document.getElementById('elapsed').textContent = `Elapsed: ${elapsed}s`;
document.getElementById('status-message').textContent = data.message || '';
const badge = document.getElementById('status-badge');
const map = {
'queued': ['badge-queued', 'Queued'],
'processing_conversion': ['badge-running', 'Converting...'],
'importing_storage': ['badge-running', 'Importing...'],
'completed': ['badge-completed', 'Completed'],
'failed': ['badge-failed', 'Failed'],
};
const [cls, text] = map[data.status] || ['badge-queued', data.status];
badge.className = 'badge ' + cls;
badge.innerHTML = (data.status === 'processing_conversion' || data.status === 'importing_storage')
? '<span class="spinner"></span> ' + text : text;
if (data.error_details) {
document.getElementById('error-block').classList.remove('hidden');
document.getElementById('error-block').textContent = data.error_details;
}
if (data.status === 'completed' || data.status === 'failed') {
completed = true;
if (data.status === 'completed') {
document.getElementById('reuse-section').classList.remove('hidden');
document.getElementById('reuse-section').scrollIntoView({ behavior: 'smooth' });
}
return;
}
if (!completed) setTimeout(poll, 2000);
} catch (err) {
document.getElementById('error-block').classList.remove('hidden');
document.getElementById('error-block').textContent = 'Polling error: ' + err.message;
if (!completed) setTimeout(poll, 5000);
}
}
async function reuseImage(keep) {
try {
await fetch(`/session/cleanup/${jobId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_staging_files: !keep }),
});
if (keep) {
window.location.href = `/?vmid=${vmid}&source={{ source_filename }}`;
} else {
window.location.href = '/';
}
} catch (err) {
alert('Cleanup failed: ' + err.message);
}
}
// Wait 3 seconds before first poll (backend needs time to queue)
setTimeout(poll, 3000);
</script>
</body>
</html>

245
open-api.yaml Normal file
View file

@ -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 Proxmoxcompatible QCOW2, optionally shrink the disk,
and provision a VM with automatic boottype 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 EFIbootable 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: Humanreadable 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"