feat(deploy): seed canary baseline at deploy time + tests
Hooks decnet.canary.planter.seed_baseline into the deploy() flow's fleet-mirror step. After upserting a FleetDecky as 'running' we seed the configured baseline canary set on the freshly-deployed decky. Persona detection: read d.nmap_os (Windows -> windows path-mapping, otherwise linux). Failures are logged and surface as state=failed rows in the UI; the deploy itself MUST NOT abort (resilience principle in CLAUDE.md). Tests confirm: - seed_baseline produces one row per configured generator per decky; - the deployer source wires seed_baseline inside a try/except so a failure can't abort the deploy.
This commit is contained in:
@@ -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
|
||||
|
||||
80
tests/canary/test_deploy_hook.py
Normal file
80
tests/canary/test_deploy_hook.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user