diff --git a/decnet/cli/__init__.py b/decnet/cli/__init__.py index 39bfe3b7..26a16dcf 100644 --- a/decnet/cli/__init__.py +++ b/decnet/cli/__init__.py @@ -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) diff --git a/decnet/cli/canary.py b/decnet/cli/canary.py new file mode 100644 index 00000000..87af60ea --- /dev/null +++ b/decnet/cli/canary.py @@ -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.[/]") diff --git a/tests/canary/test_cli.py b/tests/canary/test_cli.py new file mode 100644 index 00000000..001de3f8 --- /dev/null +++ b/tests/canary/test_cli.py @@ -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