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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
168
tests/api/deckies/test_service_config_api.py
Normal file
168
tests/api/deckies/test_service_config_api.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""API coverage for /services/{name}/schema + per-decky config PUT/POST.
|
||||
|
||||
Engine layer is patched so the tests don't touch docker; auth + routing
|
||||
+ schema-serialization + 4xx mapping run for real.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.engine import services_live
|
||||
from decnet.engine.services_live import ServiceMutationError
|
||||
from decnet.services.base import ConfigValidationError
|
||||
|
||||
_FLEET = "/api/v1/deckies"
|
||||
_TOPO = "/api/v1/topologies"
|
||||
|
||||
|
||||
def _hdr(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------- schema endpoint -----------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ssh_schema_returns_declared_fields(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
res = await client.get(
|
||||
f"{_TOPO}/services/ssh/schema", headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert body["name"] == "ssh"
|
||||
assert body["ports"] == [22]
|
||||
keys = {f["key"] for f in body["fields"]}
|
||||
assert keys == {"password", "hostname"}
|
||||
pw = next(f for f in body["fields"] if f["key"] == "password")
|
||||
assert pw["type"] == "password" and pw["secret"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unknown_service_schema_404(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
res = await client.get(
|
||||
f"{_TOPO}/services/no-such-svc/schema", headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ---------------- fleet PUT / POST apply ----------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fleet_put_config_persists_without_recreate(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
||||
) -> None:
|
||||
seen: dict = {}
|
||||
|
||||
async def _fake_update(repo, **kw):
|
||||
seen.update(kw)
|
||||
return {"password": "hunter2"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.deckies.api_services.update_service_config",
|
||||
_fake_update,
|
||||
)
|
||||
res = await client.put(
|
||||
f"{_FLEET}/web1/services/ssh/config",
|
||||
json={"config": {"password": "hunter2"}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert body["recreated"] is False
|
||||
assert body["config"] == {"password": "hunter2"}
|
||||
assert seen["apply"] is False and seen["decky_kind"] == "fleet"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fleet_apply_config_triggers_recreate(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
||||
) -> None:
|
||||
seen: dict = {}
|
||||
|
||||
async def _fake_update(repo, **kw):
|
||||
seen.update(kw)
|
||||
return kw["cfg"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.deckies.api_services.update_service_config",
|
||||
_fake_update,
|
||||
)
|
||||
res = await client.post(
|
||||
f"{_FLEET}/web1/services/ssh/apply",
|
||||
json={"config": {"password": "hunter2"}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["recreated"] is True
|
||||
assert seen["apply"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_config_400_on_validation_error(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
||||
) -> None:
|
||||
async def _fake(*a, **kw):
|
||||
raise ConfigValidationError("response_code: expected int, got 'oops'")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.deckies.api_services.update_service_config", _fake,
|
||||
)
|
||||
res = await client.put(
|
||||
f"{_FLEET}/web1/services/http/config",
|
||||
json={"config": {"response_code": "oops"}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 400
|
||||
assert "response_code" in res.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_config_409_when_service_not_on_decky(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
||||
) -> None:
|
||||
async def _fake(*a, **kw):
|
||||
raise ServiceMutationError("service 'ssh' not on decky 'web1'")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.deckies.api_services.update_service_config", _fake,
|
||||
)
|
||||
res = await client.put(
|
||||
f"{_FLEET}/web1/services/ssh/config",
|
||||
json={"config": {}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
# ---------------- topology scope ------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topology_put_config_passes_topology_id(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
||||
) -> None:
|
||||
seen: dict = {}
|
||||
|
||||
async def _fake(repo, **kw):
|
||||
seen.update(kw)
|
||||
return kw["cfg"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.deckies.api_services.update_service_config", _fake,
|
||||
)
|
||||
res = await client.put(
|
||||
f"{_TOPO}/topo-abc/deckies/web1/services/ssh/config",
|
||||
json={"config": {"hostname": "mail-01"}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert body["topology_id"] == "topo-abc"
|
||||
assert seen["topology_id"] == "topo-abc"
|
||||
assert seen["decky_kind"] == "topology"
|
||||
161
tests/engine/test_service_config_live.py
Normal file
161
tests/engine/test_service_config_live.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Engine-layer coverage for services_live.update_service_config.
|
||||
|
||||
Mirrors test_services_live.py — _compose patched to a recorder, real
|
||||
SQLite + topology hydrator under test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import AsyncIterator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.engine import services_live
|
||||
from decnet.engine.services_live import ServiceMutationError
|
||||
from decnet.services.base import ConfigValidationError
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
import decnet.web.db.models # noqa: F401 — register tables
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
|
||||
r = SQLiteRepository(str(tmp_path / "p.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def fake_bus(monkeypatch) -> AsyncIterator[FakeBus]:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
from decnet.bus import factory
|
||||
monkeypatch.setattr(factory, "get_bus", lambda: bus)
|
||||
yield bus
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def topology_with_ssh_decky(repo: SQLiteRepository) -> dict:
|
||||
topo_id = await repo.create_topology({"name": "topo", "description": ""})
|
||||
decky_uuid = await repo.add_topology_decky({
|
||||
"topology_id": topo_id,
|
||||
"name": "web1",
|
||||
"ip": "10.0.0.5",
|
||||
"decky_config": {"name": "web1", "ips_by_lan": {}},
|
||||
"services": ["ssh"],
|
||||
"state": "running",
|
||||
})
|
||||
return {"topology_id": topo_id, "decky_uuid": decky_uuid}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_persists_validated_cfg_no_recreate_on_save(
|
||||
repo: SQLiteRepository, topology_with_ssh_decky: dict, fake_bus: FakeBus,
|
||||
monkeypatch, tmp_path,
|
||||
) -> None:
|
||||
captured: list[tuple[str, ...]] = []
|
||||
monkeypatch.setattr(
|
||||
services_live, "_compose",
|
||||
lambda *a, **kw: captured.append(a),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
services_live, "_topology_compose_path",
|
||||
lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml",
|
||||
)
|
||||
|
||||
validated = await services_live.update_service_config(
|
||||
repo,
|
||||
decky_kind="topology",
|
||||
topology_id=topology_with_ssh_decky["topology_id"],
|
||||
decky_name="web1",
|
||||
service_name="ssh",
|
||||
cfg={"password": "hunter2", "wat": "drop me"},
|
||||
apply=False,
|
||||
)
|
||||
# Unknown key dropped.
|
||||
assert validated == {"password": "hunter2"}
|
||||
# Persisted into the decky_config blob.
|
||||
rows = await repo.list_topology_deckies(
|
||||
topology_with_ssh_decky["topology_id"]
|
||||
)
|
||||
row = next(r for r in rows if r["uuid"] == topology_with_ssh_decky["decky_uuid"])
|
||||
cfg_blob = row["decky_config"]
|
||||
if isinstance(cfg_blob, str):
|
||||
cfg_blob = json.loads(cfg_blob)
|
||||
assert cfg_blob["service_config"]["ssh"] == {"password": "hunter2"}
|
||||
# Save-only: no compose force-recreate ran.
|
||||
assert not any("--force-recreate" in a for a in captured)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_runs_force_recreate(
|
||||
repo: SQLiteRepository, topology_with_ssh_decky: dict, fake_bus: FakeBus,
|
||||
monkeypatch, tmp_path,
|
||||
) -> None:
|
||||
captured: list[tuple[str, ...]] = []
|
||||
monkeypatch.setattr(
|
||||
services_live, "_compose",
|
||||
lambda *a, **kw: captured.append(a),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
services_live, "_topology_compose_path",
|
||||
lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml",
|
||||
)
|
||||
await services_live.update_service_config(
|
||||
repo,
|
||||
decky_kind="topology",
|
||||
topology_id=topology_with_ssh_decky["topology_id"],
|
||||
decky_name="web1",
|
||||
service_name="ssh",
|
||||
cfg={"password": "hunter2"},
|
||||
apply=True,
|
||||
)
|
||||
# Apply path issued compose up --force-recreate <decky>-<svc>.
|
||||
assert any(
|
||||
"--force-recreate" in a and "web1-ssh" in a
|
||||
for a in captured
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rejects_service_not_on_decky(
|
||||
repo: SQLiteRepository, topology_with_ssh_decky: dict, fake_bus: FakeBus,
|
||||
) -> None:
|
||||
with pytest.raises(ServiceMutationError):
|
||||
await services_live.update_service_config(
|
||||
repo,
|
||||
decky_kind="topology",
|
||||
topology_id=topology_with_ssh_decky["topology_id"],
|
||||
decky_name="web1",
|
||||
service_name="http", # not on the decky
|
||||
cfg={},
|
||||
apply=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rejects_bad_value_via_validator(
|
||||
repo: SQLiteRepository, topology_with_ssh_decky: dict, fake_bus: FakeBus,
|
||||
monkeypatch, tmp_path,
|
||||
) -> None:
|
||||
# Add http to the decky so we can submit a bad response_code.
|
||||
monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(
|
||||
services_live, "_topology_compose_path",
|
||||
lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml",
|
||||
)
|
||||
await repo.update_topology_decky(
|
||||
topology_with_ssh_decky["decky_uuid"], {"services": ["ssh", "http"]},
|
||||
)
|
||||
with pytest.raises(ConfigValidationError):
|
||||
await services_live.update_service_config(
|
||||
repo,
|
||||
decky_kind="topology",
|
||||
topology_id=topology_with_ssh_decky["topology_id"],
|
||||
decky_name="web1",
|
||||
service_name="http",
|
||||
cfg={"response_code": "not-a-number"},
|
||||
apply=False,
|
||||
)
|
||||
Reference in New Issue
Block a user