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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user