diff --git a/decnet/engine/deployer.py b/decnet/engine/deployer.py index fd0c91eb..db4ad21e 100644 --- a/decnet/engine/deployer.py +++ b/decnet/engine/deployer.py @@ -472,6 +472,7 @@ def _mirror_fleet_deploy_to_db(config: DecnetConfig) -> None: repo = get_repository() async def _go() -> None: + from decnet.canary import planter as _canary_planter for d in config.deckies: await repo.upsert_fleet_decky({ "host_uuid": d.host_uuid or LOCAL_HOST_SENTINEL, @@ -481,6 +482,24 @@ def _mirror_fleet_deploy_to_db(config: DecnetConfig) -> None: "decky_ip": d.ip, "state": "running", }) + # Best-effort canary baseline seed. A failure here is + # logged inside the planter and surfaces as state=failed + # rows in the UI; it must NOT abort the deploy (per the + # resilience principle in CLAUDE.md). + try: + persona = "linux" + cfg = d.model_dump(mode="json") + nmap_os = cfg.get("nmap_os") or cfg.get("archetype_os") + if isinstance(nmap_os, str) and nmap_os.lower().startswith("win"): + persona = "windows" + await _canary_planter.seed_baseline( + d.name, repo, persona=persona, + ) + except Exception as exc: # noqa: BLE001 + log.warning( + "canary baseline seed failed (best-effort) decky=%s err=%s", + d.name, exc, + ) _run_async(_go) except Exception as exc: # noqa: BLE001 diff --git a/tests/canary/test_deploy_hook.py b/tests/canary/test_deploy_hook.py new file mode 100644 index 00000000..3b0c05d0 --- /dev/null +++ b/tests/canary/test_deploy_hook.py @@ -0,0 +1,80 @@ +"""Smoke coverage for the deploy-time canary baseline seed. + +The deployer hook calls ``decnet.canary.planter.seed_baseline`` for +every running decky. Two properties matter: + +* a baseline seed runs, producing one token row per configured + generator; and +* failures in seed_baseline must never abort the surrounding + deploy flow (resilience principle). + +We don't drive the full ``deploy()`` here — that pulls in docker, +network helpers, etc. Instead we exercise ``seed_baseline`` +directly with the planter's docker-exec patched, then assert the +hook's wiring via static inspection. +""" +from __future__ import annotations + +import asyncio +from typing import AsyncIterator +from unittest.mock import patch + +import pytest +import pytest_asyncio + +from decnet.canary import planter +from decnet.web.db.sqlite.repository import SQLiteRepository +import decnet.web.db.models # noqa: F401 + + +class _FakeProc: + def __init__(self, rc: int = 0, stderr: bytes = b"") -> None: + self.returncode = rc + self._stderr = stderr + + async def communicate(self) -> tuple[bytes, bytes]: + return b"", self._stderr + + +def _patch(rc: int = 0, stderr: bytes = b""): + async def _fake(*argv, **kw): # noqa: ANN001 + return _FakeProc(rc, stderr) + return patch.object(asyncio, "create_subprocess_exec", _fake) + + +@pytest_asyncio.fixture +async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]: + r = SQLiteRepository(str(tmp_path / "h.db")) + await r.initialize() + yield r + + +@pytest.mark.asyncio +async def test_baseline_creates_tokens_per_decky( + repo: SQLiteRepository, monkeypatch +) -> None: + monkeypatch.setenv("DECNET_CANARY_BASELINE", "git_config,env_file,aws_creds") + monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test") + with _patch(rc=0): + await planter.seed_baseline("web1", repo) + await planter.seed_baseline("web2", repo) + web1 = await repo.list_canary_tokens(decky_name="web1") + web2 = await repo.list_canary_tokens(decky_name="web2") + assert len(web1) == 3 and len(web2) == 3 + assert {t["generator"] for t in web1} == {"git_config", "env_file", "aws_creds"} + + +def test_deploy_hook_is_wired_into_deployer() -> None: + """Static check: deployer's _mirror_fleet_to_db calls seed_baseline. + + We grep the source rather than driving the full deploy() because + that pulls in docker + networking helpers and we don't want a + second test environment for this one assertion. + """ + import inspect + from decnet.engine import deployer + source = inspect.getsource(deployer) + assert "seed_baseline" in source, "deployer must call canary.planter.seed_baseline" + # And the call must be wrapped in try/except so a failure doesn't + # abort the deploy. + assert "canary baseline seed failed (best-effort)" in source