Files
nest/uvm/api_models.py

293 lines
8.4 KiB
Python

"""Pydantic request and response models for the UVM HTTP API."""
from __future__ import annotations
from ipaddress import IPv4Address
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
from .domain import VmRecord
from .errors import ValidationError
from .guest_agent import DEFAULT_TIMEOUT_SECONDS, MAX_COMMAND_BYTES, MAX_TIMEOUT_SECONDS
from .guest_exec import validate_exec
from .tool_catalog import MAX_SELECTIONS, ToolSelection
class HealthResponse(BaseModel):
status: str = "ok"
class ServerExitResponse(BaseModel):
status: str = "stopping"
class InstallRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
force: bool = False
class InstallResponse(BaseModel):
firecracker: str
kernel: str
rootfs: str
class PortRuleModel(BaseModel):
"""A private guest TCP port; HTTP and HTTPS are descriptive labels only."""
model_config = ConfigDict(extra="forbid")
port: int = Field(strict=True, ge=1, le=65535)
protocol: Literal["TCP", "HTTP", "HTTPS"] = "TCP"
class ToolSelectionModel(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(strict=True, min_length=1, max_length=32)
version: str | None = Field(default=None, strict=True, max_length=16,
description="Required for Python: 3.11, 3.12, or 3.13. Omit for other tools.")
@model_validator(mode="after")
def validate_selection(self):
ToolSelection(self.id, self.version)
return self
class VmCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
cpu: float = Field(default=1, gt=0)
ram: str | int | float = "512"
storage: str | int | float = Field(default="30G", description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
guest_ip: IPv4Address | None = None
username: str = "root"
password: SecretStr = SecretStr("root")
name: str | None = None
ports: list[PortRuleModel] | None = None
no_default_ports: bool = Field(default=False, strict=True)
tools: list[ToolSelectionModel] = Field(default_factory=list, max_length=MAX_SELECTIONS,
description="Optional guest tools to install asynchronously after boot")
class VmResizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
cpu: float | None = Field(default=None, gt=0, allow_inf_nan=False, strict=True)
ram: str | int | float | None = None
storage: str | int | float | None = Field(default=None, description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
class VmResourcesModel(BaseModel):
cpu: float
ram_mib: int
storage_mib: int
class VmResponse(BaseModel):
id: str
name: str | None
cpu: float
ram_mib: int
storage_mib: int | None
pending_resize: VmResourcesModel | None
guest_ip: str
gateway: str
mac: str
username: str
status: str
observed_status: str
ports: list[PortRuleModel] | None
policy_status: str
operation: str | None
pid: int | None
created_at: int
updated_at: int
last_error: str | None
class DestroyResponse(BaseModel):
id: str
status: str = "terminated"
class PortUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
mode: Literal["set", "open", "close"] = "set"
ports: list[PortRuleModel] = Field(default_factory=list)
class PortSyncResponse(BaseModel):
id: str
policy_status: str
status: str
class VmLogResponse(BaseModel):
content: str
truncated: bool
class VmExecRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
command: str = Field(strict=True, min_length=1, max_length=MAX_COMMAND_BYTES, repr=False,
description="Command or multiline script interpreted by /bin/sh inside the guest; at most 64 KiB UTF-8")
sudo: bool = Field(default=False, strict=True, description="Run as guest root instead of the VM's configured login user")
timeout_seconds: float = Field(default=DEFAULT_TIMEOUT_SECONDS, strict=True, gt=0,
le=MAX_TIMEOUT_SECONDS, allow_inf_nan=False)
@model_validator(mode="before")
@classmethod
def validate_execution(cls, value: object) -> object:
# Use the domain error envelope: echoing NaN or unpaired surrogates in
# FastAPI's validation-error input can itself fail JSON serialization.
if not isinstance(value, dict) or set(value) - {"command", "sudo", "timeout_seconds"}:
raise ValidationError("execution requires an object with command, sudo, and timeout_seconds fields only")
validate_exec(value.get("command"), value.get("sudo", False), value.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS))
return value
class VmExecResponse(BaseModel):
exit_code: int | None
stdout: str
stderr: str
timed_out: bool
truncated: bool
class VmAgentInstallResponse(BaseModel):
id: str
status: Literal["installed"] = "installed"
restart_required: bool = True
class ToolCatalogResponse(BaseModel):
id: str
name: str
description: str
version_label: str
versions: list[str]
source: str
class ToolInstallRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
tools: list[ToolSelectionModel] = Field(min_length=1, max_length=MAX_SELECTIONS)
start_if_stopped: bool = Field(default=False, strict=True)
restart: bool = Field(default=False, strict=True, description="Restart first to refresh the guest agent")
request_id: str | None = Field(default=None, strict=True, max_length=80, pattern=r"^[A-Za-z0-9_-]{1,80}$",
description="Optional idempotency key, retained with the most recent 10 jobs")
class ToolInventoryResponse(BaseModel):
key: str
id: str
version: str | None
installed: bool
detected_version: str | None
error: str | None
usage: str
class ToolJobItemResponse(BaseModel):
key: str
id: str
version: str | None
status: Literal["queued", "installing", "installed", "already_installed", "failed", "interrupted", "skipped"]
detected_version: str | None
error: str | None
output: str
truncated: bool
class ToolJobSummaryResponse(BaseModel):
id: str
status: Literal["queued", "starting", "waiting", "installing", "succeeded", "failed", "interrupted"]
created_at: float
completed_at: float | None
class ToolJobResponse(ToolJobSummaryResponse):
vm_id: str
source: Literal["create", "user"]
updated_at: float
start_if_stopped: bool
restart: bool
request_id: str | None
error: str | None
outcome_unknown: bool
items: list[ToolJobItemResponse]
class VmToolsResponse(BaseModel):
vm_id: str
inventory: list[ToolInventoryResponse]
checked_at: float | None
agent_ready: bool
guest_busy: bool
supported: bool | None
error: str | None
stale: bool
latest_job: ToolJobResponse | None
jobs: list[ToolJobSummaryResponse]
class DiagnosticResponse(BaseModel):
name: str
ok: bool
message: str
class VmBulkActionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["start", "stop", "restart", "delete", "ports-sync"]
identifiers: list[str] = Field(default_factory=list)
all_vms: bool = Field(default=False, strict=True)
timeout: float | None = None
class VmActionResultResponse(BaseModel):
id: str
action: str
outcome: str
error: str | None = None
def vm_response(
vm: VmRecord,
*,
observed_status: str | None = None,
policy_status: str = "unknown",
) -> VmResponse:
return VmResponse(
id=vm.id,
name=vm.name,
cpu=vm.cpu,
ram_mib=vm.ram_mib,
storage_mib=vm.storage_mib,
pending_resize=VmResourcesModel(**vm.pending_resize.to_dict()) if vm.pending_resize is not None else None,
guest_ip=vm.guest_ip,
gateway=vm.gateway,
mac=vm.mac,
username=vm.username,
status=vm.status,
observed_status=observed_status if observed_status is not None else vm.status,
ports=(
[PortRuleModel(port=rule.port, protocol=rule.protocol) for rule in vm.ports]
if vm.ports is not None
else None
),
policy_status=policy_status,
operation=vm.operation,
pid=vm.pid,
created_at=vm.created_at,
updated_at=vm.updated_at,
last_error=vm.last_error,
)