Files
sovereign-orchestrator/app/main.py
T
wmantly 70c71161f3 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
2026-06-21 22:57:32 -04:00

404 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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")