""" 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", "sata0": f"{config.storage}:{config.disk_size.rstrip('G')}", "sata1": f"{config.storage}:1000", "sata2": f"{config.storage}:1000", "ide2": f"{iso_volid},media=cdrom", "boot": f"order=sata0;ide2", "net0": f"virtio,bridge={config.bridge}", "serial0": "socket", "vga": "std", "tpmstate0": f"{config.storage}:4", } 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")