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