chore: initial commit — vm-bench frontend + backend
This commit is contained in:
commit
a8099f504c
15 changed files with 1588 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Staging data — not code
|
||||
/in/
|
||||
/out/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
114
backend/app.py
Normal file
114
backend/app.py
Normal 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
240
backend/converter.py
Normal 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
110
backend/models.py
Normal 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
116
backend/provisioner.py
Normal 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
3
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fastapi>=0.110.0
|
||||
uvicorn>=0.29.0
|
||||
pydantic>=2.0.0
|
||||
87
frontend/api_client.py
Normal file
87
frontend/api_client.py
Normal 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
190
frontend/app.py
Normal 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)
|
||||
5
frontend/requirements.txt
Normal file
5
frontend/requirements.txt
Normal 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
117
frontend/static/proxmox.css
Normal 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; }
|
||||
105
frontend/templates/_analysis.html
Normal file
105
frontend/templates/_analysis.html
Normal 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>
|
||||
26
frontend/templates/base.html
Normal file
26
frontend/templates/base.html
Normal 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>
|
||||
98
frontend/templates/index.html
Normal file
98
frontend/templates/index.html
Normal 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 %}
|
||||
125
frontend/templates/polling.html
Normal file
125
frontend/templates/polling.html
Normal 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> | {{ 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 & 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
245
open-api.yaml
Normal 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 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"
|
||||
Loading…
Reference in a new issue