feat(cli): register decnet canary subcommand + tests

decnet canary launches the HTTP + DNS callback receiver via
decnet.canary.worker.run. Mirrors the shape of decnet webhook
(typer command with --daemon flag, asyncio.run in the foreground).

Deliberately NOT added to MASTER_ONLY_COMMANDS — every host that
hosts deckies runs its own canary worker, and the bus events stay
local to that host (per-host webhook fanout handles SIEM egress).
This commit is contained in:
2026-04-27 13:13:23 -04:00
parent fae3e0caa3
commit f9513bb7dd
3 changed files with 68 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ from . import (
agent,
api,
bus,
canary,
db,
deploy,
emailgen,
@@ -58,7 +59,7 @@ for _mod in (
swarm,
deploy, lifecycle, workers, inventory,
web, profiler, orchestrator, emailgen, reconciler, sniffer, db,
topology, bus, geoip, init, webhook,
topology, bus, geoip, init, webhook, canary,
):
_mod.register(app)

42
decnet/cli/canary.py Normal file
View File

@@ -0,0 +1,42 @@
"""``decnet canary`` — HTTP + DNS callback receiver for canary tokens.
Worker process. Mirrors the shape of :mod:`decnet.cli.webhook`: a
``@app.command(name="canary")`` Typer entry point that delegates to
:func:`decnet.canary.worker.run`.
Not master-only — any host that hosts deckies can run its own
canary worker (the bus events stay local; the webhook worker on
each host fans them out to SIEMs independently per the design
in ``development/let-s-move-to-the-enumerated-pike.md``).
"""
from __future__ import annotations
import typer
from . import utils as _utils
from .utils import console, log
def register(app: typer.Typer) -> None:
@app.command(name="canary")
def canary_cmd(
daemon: bool = typer.Option(
False, "--daemon", "-d", help="Detach to background as a daemon process",
),
) -> None:
"""Run the canary HTTP + DNS callback receiver."""
import asyncio
from decnet.canary.worker import run
if daemon:
log.info("canary daemonizing")
_utils._daemonize()
log.info("canary starting")
console.print("[bold cyan]Canary callback receiver starting[/]")
try:
asyncio.run(run())
except KeyboardInterrupt:
console.print("\n[yellow]Canary worker stopped.[/]")

24
tests/canary/test_cli.py Normal file
View File

@@ -0,0 +1,24 @@
"""Smoke coverage for the ``decnet canary`` CLI subcommand.
We don't run the worker (it would block on HTTP/DNS sockets) — we
just confirm the command is registered and not master-gated, so an
agent host can run ``decnet canary`` without the gate hiding it.
"""
from __future__ import annotations
from typer.testing import CliRunner
from decnet.cli import app
from decnet.cli.gating import MASTER_ONLY_COMMANDS
def test_canary_command_registered() -> None:
runner = CliRunner()
result = runner.invoke(app, ["canary", "--help"])
assert result.exit_code == 0
assert "Run the canary HTTP + DNS callback receiver" in result.output
def test_canary_is_not_master_only() -> None:
# Agents must be able to run their own canary worker.
assert "canary" not in MASTER_ONLY_COMMANDS