128 lines
4.3 KiB
Python
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
|