From 75b1ce3a319244901e235d2230cf55d9c09109a2 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 29 Apr 2026 11:38:06 -0400 Subject: [PATCH] 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 - 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..service_config_changed on the bus. --- decnet/bus/topics.py | 6 + decnet/engine/services_live.py | 127 ++++++++++++++ decnet/web/db/models/__init__.py | 8 + decnet/web/db/models/decky.py | 44 ++++- decnet/web/router/deckies/api_services.py | 144 ++++++++++++++++ decnet/web/router/topology/api_catalog.py | 36 ++++ tests/api/deckies/test_service_config_api.py | 168 +++++++++++++++++++ tests/engine/test_service_config_live.py | 161 ++++++++++++++++++ 8 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 tests/api/deckies/test_service_config_api.py create mode 100644 tests/engine/test_service_config_live.py diff --git a/decnet/bus/topics.py b/decnet/bus/topics.py index d3002e73..5afbc363 100644 --- a/decnet/bus/topics.py +++ b/decnet/bus/topics.py @@ -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 diff --git a/decnet/engine/services_live.py b/decnet/engine/services_live.py index c46f0ec5..1234e845 100644 --- a/decnet/engine/services_live.py +++ b/decnet/engine/services_live.py @@ -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 + ``-`` 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, *, diff --git a/decnet/web/db/models/__init__.py b/decnet/web/db/models/__init__.py index 9e1a3a17..2aebf70f 100644 --- a/decnet/web/db/models/__init__.py +++ b/decnet/web/db/models/__init__.py @@ -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", diff --git a/decnet/web/db/models/decky.py b/decnet/web/db/models/decky.py index 252ff4f2..2df58cd7 100644 --- a/decnet/web/db/models/decky.py +++ b/decnet/web/db/models/decky.py @@ -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) diff --git a/decnet/web/router/deckies/api_services.py b/decnet/web/router/deckies/api_services.py index 7cfd58ae..80ea47cb 100644 --- a/decnet/web/router/deckies/api_services.py +++ b/decnet/web/router/deckies/api_services.py @@ -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, diff --git a/decnet/web/router/topology/api_catalog.py b/decnet/web/router/topology/api_catalog.py index 674bcd33..cd68f4aa 100644 --- a/decnet/web/router/topology/api_catalog.py +++ b/decnet/web/router/topology/api_catalog.py @@ -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"], diff --git a/tests/api/deckies/test_service_config_api.py b/tests/api/deckies/test_service_config_api.py new file mode 100644 index 00000000..349b42e6 --- /dev/null +++ b/tests/api/deckies/test_service_config_api.py @@ -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" diff --git a/tests/engine/test_service_config_live.py b/tests/engine/test_service_config_live.py new file mode 100644 index 00000000..c4c897eb --- /dev/null +++ b/tests/engine/test_service_config_live.py @@ -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 -. + 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, + )