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

@@ -90,6 +90,12 @@ DECKY_MUTATION = "mutation"
# off these without waiting for the next decnet-state.json snapshot.
DECKY_SERVICE_ADDED = "service_added"
DECKY_SERVICE_REMOVED = "service_removed"
# Per-service config change (the schema-driven Inspector form). Payload
# carries ``decky_name``, ``service_name``, optional ``topology_id``,
# ``service_config`` (the new validated dict), and ``recreated`` — true
# when the operator hit Apply (container was force-recreated to pick up
# the new env), false when they only hit Save (DB-only).
DECKY_SERVICE_CONFIG_CHANGED = "service_config_changed"
# Attacker event types (second token under the ``attacker`` root). First
# sighting, session boundary transitions, and score-threshold crossings

View File

@@ -353,6 +353,133 @@ async def add_service(
return services
async def update_service_config(
repo: BaseRepository,
*,
decky_kind: DeckyKind,
decky_name: str,
service_name: str,
cfg: dict,
apply: bool = False,
topology_id: Optional[str] = None,
) -> dict:
"""Persist ``cfg`` as the new ``service_config[service_name]`` for a decky.
The submitted dict is validated against the service's
``config_schema`` (unknown keys dropped, types coerced) BEFORE any
DB write, so a 400-class failure leaves zero side-effects.
``apply=False`` (Save): only the DB row + compose file are updated.
The running container keeps its old env.
``apply=True`` (Apply): same persistence, then a force-recreate of
``<decky>-<service>`` so the container picks
up the new env. Destructive: drops any
in-container session state on that service.
Returns the post-mutation validated cfg.
"""
svc = _validate_service_for_per_decky(service_name)
validated = svc.validate_cfg(cfg)
if decky_kind == "topology":
if not topology_id:
raise ServiceMutationError(
"decky_kind=topology requires topology_id",
)
await _update_topology_service_config(
repo, topology_id, decky_name, service_name, validated, apply=apply,
)
elif decky_kind == "fleet":
await _update_fleet_service_config(
repo, decky_name, service_name, validated, apply=apply,
)
else: # pragma: no cover
raise ServiceMutationError(f"unknown decky_kind {decky_kind!r}")
await _publish(
topics.decky(decky_name, topics.DECKY_SERVICE_CONFIG_CHANGED),
{
"decky_name": decky_name,
"service_name": service_name,
"topology_id": topology_id,
"service_config": validated,
"recreated": bool(apply),
},
)
log.info(
"services_live.update_config decky=%s topology=%s service=%s apply=%s",
decky_name, topology_id, service_name, apply,
)
return validated
async def _update_topology_service_config(
repo: BaseRepository,
topology_id: str,
decky_name: str,
service_name: str,
validated: dict,
*,
apply: bool,
) -> None:
decky = await _topology_decky(repo, topology_id, decky_name)
if service_name not in (decky.get("services") or []):
raise ServiceMutationError(
f"service {service_name!r} not on decky {decky_name!r}"
)
cfg_blob = dict(decky.get("decky_config") or {})
sc = dict(cfg_blob.get("service_config") or {})
sc[service_name] = validated
cfg_blob["service_config"] = sc
await repo.update_topology_decky(decky["uuid"], {"decky_config": cfg_blob})
compose_path = await _rerender_topology_compose(repo, topology_id)
if apply:
target = f"{decky_name}-{service_name}"
await anyio.to_thread.run_sync(
lambda: _compose(
"up", "-d", "--no-deps", "--force-recreate", "--build", target,
compose_file=compose_path,
),
)
async def _update_fleet_service_config(
repo: BaseRepository,
decky_name: str,
service_name: str,
validated: dict,
*,
apply: bool,
) -> None:
config, compose_path = _fleet_state_or_raise()
decky = _fleet_find_decky(config, decky_name)
if service_name not in (decky.services or []):
raise ServiceMutationError(
f"service {service_name!r} not on decky {decky_name!r}"
)
sc = dict(getattr(decky, "service_config", None) or {})
sc[service_name] = validated
decky.service_config = sc
_save_state(config, compose_path)
_write_compose(config, compose_path)
from decnet.web.db.models import LOCAL_HOST_SENTINEL
await repo.upsert_fleet_decky({
"host_uuid": getattr(decky, "host_uuid", None) or LOCAL_HOST_SENTINEL,
"name": decky.name,
"services": list(decky.services or []),
"decky_config": decky.model_dump(mode="json"),
"decky_ip": decky.ip,
"state": "running",
})
if apply:
target = f"{decky_name}-{service_name}"
await anyio.to_thread.run_sync(
lambda: _compose(
"up", "-d", "--no-deps", "--force-recreate", "--build", target,
compose_file=compose_path,
),
)
async def remove_service(
repo: BaseRepository,
*,

View File

@@ -67,7 +67,11 @@ from .decky import (
DeckyFileDeleteRequest,
DeckyFileDropRequest,
DeckyServiceAddRequest,
DeckyServiceConfigRequest,
DeckyServiceConfigResponse,
DeckyServicesResponse,
ServiceConfigFieldDTO,
ServiceSchemaResponse,
)
from .fleet import (
LOCAL_HOST_SENTINEL,
@@ -231,8 +235,12 @@ __all__ = [
"DeckyFileDeleteRequest",
"DeckyFileDropRequest",
"DeckyServiceAddRequest",
"DeckyServiceConfigRequest",
"DeckyServiceConfigResponse",
"DeckyServicesResponse",
"FleetDecky",
"ServiceConfigFieldDTO",
"ServiceSchemaResponse",
# health
"ComponentHealth",
"HealthResponse",

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)

View File

@@ -19,10 +19,14 @@ from decnet.engine.services_live import (
ServiceMutationError,
add_service,
remove_service,
update_service_config,
)
from decnet.logging import get_logger
from decnet.services.base import ConfigValidationError
from decnet.web.db.models import (
DeckyServiceAddRequest,
DeckyServiceConfigRequest,
DeckyServiceConfigResponse,
DeckyServicesResponse,
)
from decnet.web.dependencies import repo, require_admin
@@ -80,6 +84,88 @@ async def api_fleet_add_service(
return DeckyServicesResponse(decky_name=decky_name, services=services)
async def _do_update_config(
*, decky_kind, decky_name, service_name, cfg, apply, topology_id=None,
) -> DeckyServiceConfigResponse:
try:
validated = await update_service_config(
repo,
decky_kind=decky_kind,
decky_name=decky_name,
service_name=service_name,
cfg=cfg,
apply=apply,
topology_id=topology_id,
)
except ConfigValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except ServiceMutationError as exc:
raise _map_mutation_error(exc) from exc
return DeckyServiceConfigResponse(
decky_name=decky_name,
service_name=service_name,
topology_id=topology_id,
config=validated,
recreated=apply,
)
@fleet_services_router.put(
"/deckies/{decky_name}/services/{service_name}/config",
response_model=DeckyServiceConfigResponse,
responses={
400: {"description": "Config rejected by service schema"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Decky not found"},
409: {"description": "Service not on decky"},
422: {"description": "Unknown service"},
},
)
async def api_fleet_put_service_config(
req: DeckyServiceConfigRequest,
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
service_name: str = Path(..., pattern=r"^[a-z0-9_\-]{1,64}$"),
admin: dict = Depends(require_admin),
) -> DeckyServiceConfigResponse:
"""Persist new service_config (DB + compose); container untouched."""
return await _do_update_config(
decky_kind="fleet",
decky_name=decky_name,
service_name=service_name,
cfg=req.config,
apply=False,
)
@fleet_services_router.post(
"/deckies/{decky_name}/services/{service_name}/apply",
response_model=DeckyServiceConfigResponse,
responses={
400: {"description": "Config rejected by service schema"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Decky not found"},
409: {"description": "Service not on decky"},
422: {"description": "Unknown service"},
},
)
async def api_fleet_apply_service_config(
req: DeckyServiceConfigRequest,
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
service_name: str = Path(..., pattern=r"^[a-z0-9_\-]{1,64}$"),
admin: dict = Depends(require_admin),
) -> DeckyServiceConfigResponse:
"""Persist + force-recreate that one service container. Destructive."""
return await _do_update_config(
decky_kind="fleet",
decky_name=decky_name,
service_name=service_name,
cfg=req.config,
apply=True,
)
@fleet_services_router.delete(
"/deckies/{decky_name}/services/{service_name}",
response_model=DeckyServicesResponse,
@@ -137,6 +223,64 @@ async def api_topology_add_service(
)
@topology_services_router.put(
"/{topology_id}/deckies/{decky_name}/services/{service_name}/config",
response_model=DeckyServiceConfigResponse,
responses={
400: {"description": "Config rejected by service schema"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology or decky not found"},
409: {"description": "Service not on decky"},
422: {"description": "Unknown service"},
},
)
async def api_topology_put_service_config(
req: DeckyServiceConfigRequest,
topology_id: str = Path(...),
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
service_name: str = Path(..., pattern=r"^[a-z0-9_\-]{1,64}$"),
admin: dict = Depends(require_admin),
) -> DeckyServiceConfigResponse:
return await _do_update_config(
decky_kind="topology",
topology_id=topology_id,
decky_name=decky_name,
service_name=service_name,
cfg=req.config,
apply=False,
)
@topology_services_router.post(
"/{topology_id}/deckies/{decky_name}/services/{service_name}/apply",
response_model=DeckyServiceConfigResponse,
responses={
400: {"description": "Config rejected by service schema"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Topology or decky not found"},
409: {"description": "Service not on decky"},
422: {"description": "Unknown service"},
},
)
async def api_topology_apply_service_config(
req: DeckyServiceConfigRequest,
topology_id: str = Path(...),
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
service_name: str = Path(..., pattern=r"^[a-z0-9_\-]{1,64}$"),
admin: dict = Depends(require_admin),
) -> DeckyServiceConfigResponse:
return await _do_update_config(
decky_kind="topology",
topology_id=topology_id,
decky_name=decky_name,
service_name=service_name,
cfg=req.config,
apply=True,
)
@topology_services_router.delete(
"/{topology_id}/deckies/{decky_name}/services/{service_name}",
response_model=DeckyServicesResponse,

View File

@@ -22,6 +22,8 @@ from decnet.web.db.models import (
NextIPResponse,
NextSubnetResponse,
ServiceCatalogResponse,
ServiceConfigFieldDTO,
ServiceSchemaResponse,
)
from decnet.web.dependencies import repo, require_viewer
@@ -52,6 +54,40 @@ async def api_list_services(
)
@router.get(
"/services/{service_name}/schema",
tags=["MazeNET Topologies"],
response_model=ServiceSchemaResponse,
responses={
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Unknown service"},
},
)
@_traced("api.topology.catalog.service_schema")
async def api_service_schema(
service_name: str,
_viewer: dict = Depends(require_viewer),
) -> ServiceSchemaResponse:
"""Return the declarative config schema for one service.
Drives the schema-driven Inspector form on both Fleet and MazeNET.
Empty ``fields`` means the service has no customizable knobs yet —
the form renders a "No customizable fields" placeholder.
"""
from decnet.services.registry import get_service
try:
svc = get_service(service_name)
except KeyError:
raise HTTPException(status_code=404, detail=f"Unknown service: {service_name!r}")
return ServiceSchemaResponse(
name=svc.name,
ports=list(svc.ports),
fleet_singleton=bool(svc.fleet_singleton),
fields=[ServiceConfigFieldDTO(**f.to_json()) for f in svc.config_schema],
)
@router.get(
"/archetypes",
tags=["MazeNET Topologies"],