feat(api): per-service config schema endpoint + PUT/POST update+apply for fleet & topology

- GET /topologies/services/{name}/schema serves the declared ServiceConfigField
  metadata so the Inspector can auto-render forms.
- PUT  /(topologies/{id}/)deckies/{decky}/services/{svc}/config persists the
  validated dict (DB + compose); container untouched (Save).
- POST /(topologies/{id}/)deckies/{decky}/services/{svc}/apply persists then
  force-recreates <decky>-<svc> so the new env takes effect (Apply, destructive).
- New engine helper update_service_config wires both fleet and topology paths
  through the existing _persist_fleet_change / _rerender_topology_compose
  machinery; emits decky.<name>.service_config_changed on the bus.
This commit is contained in:
2026-04-29 11:38:06 -04:00
parent 54b1fbed14
commit 75b1ce3a31
8 changed files with 693 additions and 1 deletions

View File

@@ -8,7 +8,7 @@ under ``decnet.web.db.models``.
"""
from __future__ import annotations
from typing import Optional
from typing import Any, Optional
from pydantic import BaseModel, Field as PydanticField, field_validator
@@ -65,6 +65,48 @@ class DeckyServicesResponse(BaseModel):
services: list[str]
class ServiceConfigFieldDTO(BaseModel):
"""Serialized form of ``decnet.services.base.ServiceConfigField``.
The Inspector form (Fleet + MazeNET) renders inputs from this metadata.
"""
key: str
label: str
type: str
default: Optional[Any] = None
secret: bool = False
help: Optional[str] = None
enum: Optional[list[str]] = None
placeholder: Optional[str] = None
class ServiceSchemaResponse(BaseModel):
"""Per-service config schema returned by GET /services/{name}/schema."""
name: str
ports: list[int]
fleet_singleton: bool = False
fields: list[ServiceConfigFieldDTO] = PydanticField(default_factory=list)
class DeckyServiceConfigRequest(BaseModel):
"""Body for PUT/POST per-service config endpoints.
The dict is validated against the service's ``config_schema``
server-side: unknown keys are silently dropped, declared keys are
coerced to their declared type, and out-of-range values raise 400.
"""
config: dict[str, Any] = PydanticField(default_factory=dict)
class DeckyServiceConfigResponse(BaseModel):
"""Post-validation config + apply state for the form to re-sync from."""
decky_name: str
service_name: str
topology_id: Optional[str] = None
config: dict[str, Any] = PydanticField(default_factory=dict)
recreated: bool = False
class DeckyFileDeleteRequest(BaseModel):
"""Best-effort ``rm -f`` of an absolute path inside a decky container."""
decky_name: str = PydanticField(..., min_length=1)