feat: initial orchestrator service with FastAPI backend and premium GUI
- FastAPI backend with full Proxmox VE API integration - ISO builder using proxmox-auto-install-assistant - Premium dark-mode SPA frontend with glassmorphism design - VM lifecycle management (create, start, stop, destroy) - Build pipeline tracking with real-time logs - Deployment automation for custom auto-installer ISOs - Production deployment script (setup.sh + systemd) - Comprehensive README with API documentation
This commit is contained in:
+403
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Sovereign Orchestrator - FastAPI Application
|
||||
|
||||
Main entrypoint that wires up all API routes, middleware, and static file
|
||||
serving. Run with:
|
||||
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8888
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import settings
|
||||
from app.iso_builder import (
|
||||
build_iso,
|
||||
get_all_builds,
|
||||
get_build,
|
||||
get_build_iso_path,
|
||||
get_active_builds,
|
||||
get_latest_build,
|
||||
)
|
||||
from app.models import (
|
||||
BuildState,
|
||||
BuildStatus,
|
||||
DeployConfig,
|
||||
DeployResult,
|
||||
ISOConfig,
|
||||
ISOInfo,
|
||||
NodeInfo,
|
||||
SystemStatus,
|
||||
VMStatus,
|
||||
)
|
||||
from app.proxmox_client import ProxmoxError, proxmox
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(
|
||||
title="Sovereign Orchestrator",
|
||||
description=(
|
||||
"API for building Proxmox auto-install ISOs and deploying "
|
||||
"VMs on a Proxmox VE cluster."
|
||||
),
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static files – mount the frontend at / (must be last, after API routes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
_static_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health / status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/status", response_model=SystemStatus, tags=["Status"])
|
||||
async def get_system_status() -> SystemStatus:
|
||||
"""Return an aggregate system status dashboard.
|
||||
|
||||
Includes Proxmox node info, test VM state, available ISOs, and the
|
||||
active build queue. Proxmox errors are captured gracefully so the
|
||||
endpoint always returns *something*.
|
||||
"""
|
||||
status = SystemStatus()
|
||||
|
||||
try:
|
||||
# Nodes
|
||||
raw_nodes = await proxmox.get_nodes()
|
||||
status.nodes = [
|
||||
NodeInfo(
|
||||
node=n.get("node", "unknown"),
|
||||
status=n.get("status", "unknown"),
|
||||
cpu=n.get("cpu"),
|
||||
maxcpu=n.get("maxcpu"),
|
||||
mem=n.get("mem"),
|
||||
maxmem=n.get("maxmem"),
|
||||
uptime=n.get("uptime"),
|
||||
)
|
||||
for n in raw_nodes
|
||||
]
|
||||
|
||||
# Test VM
|
||||
try:
|
||||
vm_data = await proxmox.get_vm_status()
|
||||
status.test_vm = VMStatus(
|
||||
vmid=settings.default_vmid,
|
||||
name=vm_data.get("name"),
|
||||
status=vm_data.get("status", "unknown"),
|
||||
node=settings.default_node,
|
||||
cpus=vm_data.get("cpus"),
|
||||
maxmem=vm_data.get("maxmem"),
|
||||
maxdisk=vm_data.get("maxdisk"),
|
||||
uptime=vm_data.get("uptime"),
|
||||
pid=vm_data.get("pid"),
|
||||
)
|
||||
except ProxmoxError:
|
||||
logger.debug("Test VM %d not found (may not exist yet)", settings.default_vmid)
|
||||
|
||||
# ISOs
|
||||
try:
|
||||
raw_isos = await proxmox.get_isos()
|
||||
status.available_isos = [
|
||||
ISOInfo(
|
||||
volid=iso.get("volid", ""),
|
||||
filename=iso.get("volid", "").split("/")[-1],
|
||||
size=iso.get("size"),
|
||||
)
|
||||
for iso in raw_isos
|
||||
]
|
||||
except ProxmoxError:
|
||||
logger.debug("Could not list ISOs")
|
||||
|
||||
status.proxmox_connected = True
|
||||
|
||||
except ProxmoxError as exc:
|
||||
status.error = str(exc)
|
||||
logger.warning("Proxmox status check failed: %s", exc)
|
||||
except Exception as exc:
|
||||
status.error = f"Unexpected error: {exc}"
|
||||
logger.exception("Status check failed")
|
||||
|
||||
# Build queue (always available even if Proxmox is down)
|
||||
status.active_builds = get_active_builds()
|
||||
|
||||
return status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ISO generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/generate-iso", response_model=BuildStatus, tags=["ISO"])
|
||||
async def generate_iso(config: ISOConfig) -> BuildStatus:
|
||||
"""Generate a custom Proxmox auto-install ISO.
|
||||
|
||||
Accepts the installation configuration, writes an ``answers.toml``,
|
||||
and asynchronously runs ``proxmox-auto-install-assistant prepare-iso``.
|
||||
Returns the build status immediately; poll ``/api/builds/{id}`` for
|
||||
progress.
|
||||
"""
|
||||
logger.info("ISO generation requested for %s", config.fqdn)
|
||||
build = await build_iso(config)
|
||||
return build
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/builds", response_model=list[BuildStatus], tags=["Builds"])
|
||||
async def list_builds() -> list[BuildStatus]:
|
||||
"""List all ISO builds, newest first."""
|
||||
return get_all_builds()
|
||||
|
||||
|
||||
@app.get("/api/builds/{build_id}", response_model=BuildStatus, tags=["Builds"])
|
||||
async def get_build_detail(build_id: str) -> BuildStatus:
|
||||
"""Get detailed status and logs for a specific build."""
|
||||
build = get_build(build_id)
|
||||
if build is None:
|
||||
raise HTTPException(status_code=404, detail=f"Build {build_id} not found")
|
||||
return build
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deploy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/deploy", response_model=DeployResult, tags=["Deploy"])
|
||||
async def deploy_vm(config: DeployConfig) -> DeployResult:
|
||||
"""Deploy a VM using the custom auto-install ISO.
|
||||
|
||||
This will:
|
||||
1. Resolve which ISO to use (from *build_id* or the latest build)
|
||||
2. Upload the ISO to Proxmox storage if needed
|
||||
3. Stop and destroy the existing VM (if any)
|
||||
4. Create a new VM with the ISO mounted
|
||||
5. Start the VM
|
||||
|
||||
The entire flow runs synchronously so the caller gets a final status.
|
||||
"""
|
||||
node = config.node
|
||||
vmid = config.vmid
|
||||
|
||||
# --- Resolve the ISO to deploy ---
|
||||
build_id = config.build_id
|
||||
if build_id is None:
|
||||
latest = get_latest_build()
|
||||
if latest is None or latest.state != BuildState.COMPLETED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No completed build available. Run /api/generate-iso first.",
|
||||
)
|
||||
build_id = latest.id
|
||||
|
||||
build = get_build(build_id)
|
||||
if build is None or build.state != BuildState.COMPLETED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Build {build_id} is not in a completed state.",
|
||||
)
|
||||
|
||||
iso_path = get_build_iso_path(build_id)
|
||||
if iso_path is None or not iso_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"ISO file for build {build_id} not found on disk.",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Deploying VM %d on %s from build %s (%s)",
|
||||
vmid, node, build_id, iso_path.name,
|
||||
)
|
||||
|
||||
try:
|
||||
# --- Upload ISO to Proxmox ---
|
||||
iso_volid = f"{config.iso_storage}:iso/{iso_path.name}"
|
||||
try:
|
||||
existing_isos = await proxmox.get_isos(node, config.iso_storage)
|
||||
already_uploaded = any(
|
||||
i.get("volid") == iso_volid for i in existing_isos
|
||||
)
|
||||
except ProxmoxError:
|
||||
already_uploaded = False
|
||||
|
||||
if not already_uploaded:
|
||||
logger.info("Uploading ISO %s to Proxmox storage", iso_path.name)
|
||||
upload_upid = await proxmox.upload_iso(
|
||||
str(iso_path), node, config.iso_storage
|
||||
)
|
||||
await proxmox.wait_for_task(node, upload_upid, max_wait=300)
|
||||
logger.info("ISO upload completed")
|
||||
|
||||
# --- Tear down existing VM ---
|
||||
try:
|
||||
vm_status = await proxmox.get_vm_status(node, vmid)
|
||||
current_state = vm_status.get("status", "")
|
||||
|
||||
if current_state == "running":
|
||||
logger.info("Stopping existing VM %d", vmid)
|
||||
stop_upid = await proxmox.stop_vm(node, vmid)
|
||||
await proxmox.wait_for_task(node, stop_upid, max_wait=60)
|
||||
# Brief pause for lock release
|
||||
await asyncio.sleep(3)
|
||||
|
||||
logger.info("Destroying existing VM %d", vmid)
|
||||
destroy_upid = await proxmox.destroy_vm(node, vmid)
|
||||
await proxmox.wait_for_task(node, destroy_upid, max_wait=60)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except ProxmoxError as exc:
|
||||
if exc.status_code == 500 or exc.status_code == 404:
|
||||
logger.info("VM %d does not exist, skipping teardown", vmid)
|
||||
else:
|
||||
raise
|
||||
|
||||
# --- Create new VM ---
|
||||
vm_config: dict[str, Any] = {
|
||||
"vmid": vmid,
|
||||
"name": build.iso_config.fqdn.split(".")[0] if build.iso_config else f"vm-{vmid}",
|
||||
"cores": config.cores,
|
||||
"memory": config.memory,
|
||||
"ostype": "l26",
|
||||
"scsihw": "virtio-scsi-single",
|
||||
"scsi0": f"{config.storage}:{config.disk_size.rstrip('G')},iothread=1",
|
||||
"ide2": f"{iso_volid},media=cdrom",
|
||||
"boot": "order=ide2;scsi0",
|
||||
"net0": "virtio,bridge=vmbr0",
|
||||
"serial0": "socket",
|
||||
"vga": "serial0",
|
||||
}
|
||||
|
||||
create_upid = await proxmox.create_vm(node, vmid, vm_config)
|
||||
if isinstance(create_upid, str) and create_upid.startswith("UPID"):
|
||||
await proxmox.wait_for_task(node, create_upid, max_wait=30)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# --- Start the VM ---
|
||||
start_upid = await proxmox.start_vm(node, vmid)
|
||||
|
||||
return DeployResult(
|
||||
vmid=vmid,
|
||||
node=node,
|
||||
status="started",
|
||||
message=f"VM {vmid} created and started with ISO {iso_path.name}",
|
||||
task_id=start_upid if isinstance(start_upid, str) else None,
|
||||
)
|
||||
|
||||
except ProxmoxError as exc:
|
||||
logger.error("Deploy failed: %s", exc)
|
||||
raise HTTPException(status_code=502, detail=str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected deploy error")
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VM management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/vm/{vmid}/status", response_model=VMStatus, tags=["VM"])
|
||||
async def vm_status(vmid: int, node: str | None = None) -> VMStatus:
|
||||
"""Get the current status of a VM."""
|
||||
node = node or settings.default_node
|
||||
try:
|
||||
data = await proxmox.get_vm_status(node, vmid)
|
||||
return VMStatus(
|
||||
vmid=vmid,
|
||||
name=data.get("name"),
|
||||
status=data.get("status", "unknown"),
|
||||
node=node,
|
||||
cpus=data.get("cpus"),
|
||||
maxmem=data.get("maxmem"),
|
||||
maxdisk=data.get("maxdisk"),
|
||||
uptime=data.get("uptime"),
|
||||
pid=data.get("pid"),
|
||||
)
|
||||
except ProxmoxError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc))
|
||||
|
||||
|
||||
@app.post("/api/vm/{vmid}/start", tags=["VM"])
|
||||
async def vm_start(vmid: int, node: str | None = None) -> dict[str, str]:
|
||||
"""Start a VM."""
|
||||
node = node or settings.default_node
|
||||
try:
|
||||
task_id = await proxmox.start_vm(node, vmid)
|
||||
return {"status": "starting", "vmid": str(vmid), "task_id": str(task_id)}
|
||||
except ProxmoxError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc))
|
||||
|
||||
|
||||
@app.post("/api/vm/{vmid}/stop", tags=["VM"])
|
||||
async def vm_stop(vmid: int, node: str | None = None) -> dict[str, str]:
|
||||
"""Stop a VM."""
|
||||
node = node or settings.default_node
|
||||
try:
|
||||
task_id = await proxmox.stop_vm(node, vmid)
|
||||
return {"status": "stopping", "vmid": str(vmid), "task_id": str(task_id)}
|
||||
except ProxmoxError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ISOs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/isos", response_model=list[ISOInfo], tags=["ISO"])
|
||||
async def list_isos(
|
||||
node: str | None = None, storage: str | None = None
|
||||
) -> list[ISOInfo]:
|
||||
"""List available ISO images on Proxmox storage."""
|
||||
try:
|
||||
raw = await proxmox.get_isos(node, storage)
|
||||
return [
|
||||
ISOInfo(
|
||||
volid=iso.get("volid", ""),
|
||||
filename=iso.get("volid", "").split("/")[-1],
|
||||
size=iso.get("size"),
|
||||
)
|
||||
for iso in raw
|
||||
]
|
||||
except ProxmoxError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static files — mounted last so API routes take precedence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app.mount("/", StaticFiles(directory=str(_static_dir), html=True), name="static")
|
||||
Reference in New Issue
Block a user