Files
2026-09-04 20:56:18 +00:00

128 lines
4.3 KiB
Python

"""FastAPI application factory and Uvicorn server launcher for uvm."""
from __future__ import annotations
from ipaddress import ip_address
from pathlib import Path
import ssl
from typing import Any
from .app import Application, build_application
from .errors import ConfigurationError, UvmError, ValidationError
def create_api(application: Application | None = None, *, host: str | None = None) -> Any:
"""Build the HTTP API after validating its intended bind address."""
if host is None:
raise UvmError("create_api requires an explicit host. Start the API with uvm --serve.")
resolved_application = application or build_application(emit=None)
_validate_server_settings(
resolved_application.settings.api_token,
resolved_application.settings.api_tls_cert,
resolved_application.settings.api_tls_key,
host,
)
try:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
except ModuleNotFoundError as error:
raise UvmError(
"FastAPI server support is not installed. Install this project with its dependencies."
) from error
from .routers.health import router as health_router
from .routers.installation import router as installation_router
from .routers.vms import router as vms_router
api = FastAPI(
title="uvm",
version="0.1.0",
description="Local Firecracker microVM management API.",
)
api.state.uvm_application = resolved_application
@api.exception_handler(UvmError)
async def handle_uvm_error(_request: Any, error: UvmError) -> Any:
return JSONResponse(
status_code=_http_status_for(error),
content={"error": {"message": str(error)}},
)
api.include_router(health_router)
api.include_router(installation_router)
api.include_router(vms_router)
return api
def run_server(application: Application, *, host: str, port: int) -> None:
"""Run Uvicorn after enforcing the local-management security boundary."""
settings = application.settings
_validate_server_settings(
settings.api_token,
settings.api_tls_cert,
settings.api_tls_key,
host,
)
try:
import uvicorn
except ModuleNotFoundError as error:
raise UvmError(
"Uvicorn server support is not installed. Install this project with its dependencies."
) from error
options: dict[str, Any] = {"host": host, "port": port}
if settings.api_tls_cert is not None:
options["ssl_certfile"] = str(settings.api_tls_cert)
options["ssl_keyfile"] = str(settings.api_tls_key)
uvicorn.run(create_api(application, host=host), **options)
def _is_loopback_host(host: str) -> bool:
try:
return ip_address(host).is_loopback
except ValueError:
return False
def _validate_server_settings(
api_token: str | None,
cert: Path | None,
key: Path | None,
host: str,
) -> None:
if not api_token:
raise UvmError("UVM_API_TOKEN is required before starting the management API")
if (cert is None) != (key is None):
raise UvmError("UVM_API_TLS_CERT and UVM_API_TLS_KEY must be configured together")
if cert is not None:
assert key is not None
if not cert.is_file() or not key.is_file():
raise UvmError("configured API TLS certificate or key does not exist")
try:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile=str(cert), keyfile=str(key))
except (OSError, ssl.SSLError) as error:
raise UvmError(f"configured API TLS certificate or key is invalid: {error}") from error
if not _is_loopback_host(host) and cert is None:
raise UvmError(
"refusing to bind the API to a non-loopback host without TLS. "
"Set UVM_API_TLS_CERT and UVM_API_TLS_KEY or bind behind a TLS reverse proxy."
)
def _http_status_for(error: UvmError) -> int:
if isinstance(error, (ConfigurationError, ValidationError)):
return 422
message = str(error)
if message.startswith("VM not found:"):
return 404
if "operation is already in progress" in message:
return 409
if "needs root" in message:
return 403
return 500