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:
2026-06-21 22:57:32 -04:00
parent f2935fa1e1
commit 70c71161f3
4464 changed files with 825937 additions and 2 deletions
+287
View File
@@ -0,0 +1,287 @@
"""
Sovereign Orchestrator - Proxmox API Client
Async HTTP client for the Proxmox VE REST API using token-based
authentication. All methods return parsed JSON dicts or raise on error.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class ProxmoxError(Exception):
"""Raised when a Proxmox API call fails."""
def __init__(self, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
class ProxmoxClient:
"""Async Proxmox VE API client with token authentication."""
def __init__(
self,
host: str | None = None,
token_id: str | None = None,
token_secret: str | None = None,
verify_ssl: bool = False,
timeout: float = 30.0,
) -> None:
self.host = (host or settings.proxmox_host).rstrip("/")
self.token_id = token_id or settings.proxmox_token_id
self.token_secret = token_secret or settings.proxmox_token_secret
self.verify_ssl = verify_ssl
self.timeout = timeout
if not self.token_id or not self.token_secret:
logger.warning(
"Proxmox token credentials are not configured. "
"API calls will fail."
)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _auth_header(self) -> dict[str, str]:
"""Build the PVEAPIToken authorization header."""
return {
"Authorization": f"PVEAPIToken={self.token_id}={self.token_secret}"
}
def _url(self, path: str) -> str:
"""Build the full API URL for *path* (should start with /)."""
return f"{self.host}/api2/json{path}"
async def _request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> Any:
"""Execute an API request and return the ``data`` envelope."""
url = self._url(path)
headers = self._auth_header()
logger.debug("%s %s params=%s", method, url, params)
async with httpx.AsyncClient(
verify=self.verify_ssl, timeout=self.timeout
) as client:
response = await client.request(
method,
url,
headers=headers,
params=params,
data=data,
json=json_body,
)
if response.status_code >= 400:
body = response.text[:500]
logger.error(
"Proxmox API error: %s %s -> %d: %s",
method, path, response.status_code, body,
)
raise ProxmoxError(
f"Proxmox API {method} {path} returned {response.status_code}: {body}",
status_code=response.status_code,
)
payload = response.json()
return payload.get("data", payload)
# ------------------------------------------------------------------
# Node operations
# ------------------------------------------------------------------
async def get_nodes(self) -> list[dict[str, Any]]:
"""List all cluster nodes."""
return await self._request("GET", "/nodes")
# ------------------------------------------------------------------
# VM operations
# ------------------------------------------------------------------
async def get_vms(self, node: str | None = None) -> list[dict[str, Any]]:
"""List VMs on *node*."""
node = node or settings.default_node
return await self._request("GET", f"/nodes/{node}/qemu")
async def get_vm_status(
self, node: str | None = None, vmid: int | None = None
) -> dict[str, Any]:
"""Get current status of a single VM."""
node = node or settings.default_node
vmid = vmid if vmid is not None else settings.default_vmid
return await self._request(
"GET", f"/nodes/{node}/qemu/{vmid}/status/current"
)
async def start_vm(
self, node: str | None = None, vmid: int | None = None
) -> str:
"""Start a VM. Returns the Proxmox UPID task string."""
node = node or settings.default_node
vmid = vmid if vmid is not None else settings.default_vmid
logger.info("Starting VM %d on %s", vmid, node)
return await self._request(
"POST", f"/nodes/{node}/qemu/{vmid}/status/start"
)
async def stop_vm(
self, node: str | None = None, vmid: int | None = None
) -> str:
"""Stop (hard) a VM. Returns the Proxmox UPID task string."""
node = node or settings.default_node
vmid = vmid if vmid is not None else settings.default_vmid
logger.info("Stopping VM %d on %s", vmid, node)
return await self._request(
"POST", f"/nodes/{node}/qemu/{vmid}/status/stop"
)
async def destroy_vm(
self,
node: str | None = None,
vmid: int | None = None,
purge: bool = True,
destroy_unreferenced_disks: bool = True,
) -> str:
"""Delete a VM and optionally purge its disks."""
node = node or settings.default_node
vmid = vmid if vmid is not None else settings.default_vmid
logger.info("Destroying VM %d on %s (purge=%s)", vmid, node, purge)
params: dict[str, Any] = {}
if purge:
params["purge"] = 1
if destroy_unreferenced_disks:
params["destroy-unreferenced-disks"] = 1
return await self._request(
"DELETE", f"/nodes/{node}/qemu/{vmid}", params=params
)
async def create_vm(
self,
node: str | None = None,
vmid: int | None = None,
config: dict[str, Any] | None = None,
) -> str:
"""Create a new VM with the given configuration dict."""
node = node or settings.default_node
vmid = vmid if vmid is not None else settings.default_vmid
config = config or {}
config.setdefault("vmid", vmid)
logger.info("Creating VM %d on %s with config: %s", vmid, node, config)
return await self._request(
"POST", f"/nodes/{node}/qemu", data=config
)
# ------------------------------------------------------------------
# ISO / storage operations
# ------------------------------------------------------------------
async def get_isos(
self,
node: str | None = None,
storage: str | None = None,
) -> list[dict[str, Any]]:
"""List ISO images available on *storage*."""
node = node or settings.default_node
storage = storage or settings.default_storage
result = await self._request(
"GET",
f"/nodes/{node}/storage/{storage}/content",
params={"content": "iso"},
)
# The API returns all content types; filter to ISOs just in case
if isinstance(result, list):
return [
item for item in result
if item.get("content") == "iso"
or item.get("volid", "").endswith(".iso")
]
return result
async def upload_iso(
self,
filepath: str,
node: str | None = None,
storage: str | None = None,
) -> str:
"""Upload a local ISO file to Proxmox storage.
Uses the Proxmox upload endpoint which expects multipart form data.
"""
node = node or settings.default_node
storage = storage or settings.default_storage
url = self._url(f"/nodes/{node}/storage/{storage}/upload")
headers = self._auth_header()
logger.info("Uploading ISO %s to %s:%s", filepath, node, storage)
async with httpx.AsyncClient(
verify=self.verify_ssl, timeout=300.0
) as client:
with open(filepath, "rb") as fh:
files = {"filename": (filepath.split("/")[-1], fh, "application/octet-stream")}
data = {"content": "iso"}
response = await client.post(
url, headers=headers, data=data, files=files
)
if response.status_code >= 400:
body = response.text[:500]
raise ProxmoxError(
f"ISO upload failed ({response.status_code}): {body}",
status_code=response.status_code,
)
payload = response.json()
task_id = payload.get("data", "")
logger.info("Upload task started: %s", task_id)
return task_id
# ------------------------------------------------------------------
# Task helpers
# ------------------------------------------------------------------
async def wait_for_task(
self,
node: str,
upid: str,
*,
poll_interval: float = 2.0,
max_wait: float = 120.0,
) -> dict[str, Any]:
"""Poll a Proxmox task until it completes or times out."""
import asyncio
elapsed = 0.0
while elapsed < max_wait:
status = await self._request(
"GET", f"/nodes/{node}/tasks/{upid}/status"
)
if status.get("status") == "stopped":
return status
await asyncio.sleep(poll_interval)
elapsed += poll_interval
raise ProxmoxError(
f"Task {upid} did not complete within {max_wait}s"
)
# Module-level singleton for convenience
proxmox = ProxmoxClient()