feat(webhook): worker + CLI + systemd unit

Introduces the `decnet webhook` long-running worker that consumes the
internal bus and POSTs matching events to configured subscriptions.

Design: one task per (subscription, pattern) pair. Each task opens
its own bus subscription, iterates events, and dispatches via the
shared deliver() client. No intermediate queue, no in-memory filter
matching — the bus's own pattern matcher is the filter. Reloads on
`system.webhook.subscriptions_changed` signals from the CRUD router,
with a 60s fallback timer in case a signal is lost.

Shutdown propagates via CancelledError on the outer task; all inner
subscription tasks are cancelled and awaited in a finally block.
Bus unavailable → worker stays up in idle mode per the DEBT-031
pattern, logging one warning.

Registered as a master-only CLI command (agents don't configure
webhooks — the subscription store lives on master). systemd unit
mirrors the profiler template; added to decnet.target Wants= list so
`systemctl start decnet.target` brings it up alongside everything
else. `decnet init` auto-picks up the new .service.j2 via its
existing `glob("decnet-*.service.j2")` sweep.
This commit is contained in:
2026-04-24 15:46:11 -04:00
parent b70845a85d
commit e6127a81a1
9 changed files with 583 additions and 3 deletions

View File

@@ -37,6 +37,7 @@ from . import (
topology,
updater,
web,
webhook,
workers,
)
from .gating import _gate_commands_by_mode
@@ -54,7 +55,7 @@ for _mod in (
swarm,
deploy, lifecycle, workers, inventory,
web, profiler, sniffer, db,
topology, bus, geoip, init,
topology, bus, geoip, init, webhook,
):
_mod.register(app)

View File

@@ -29,7 +29,7 @@ MASTER_ONLY_COMMANDS: frozenset[str] = frozenset({
"api", "swarmctl", "deploy", "redeploy", "teardown",
"mutate", "listener", "profiler",
"services", "distros", "correlate", "archetypes", "web",
"db-reset", "init",
"db-reset", "init", "webhook",
})
MASTER_ONLY_GROUPS: frozenset[str] = frozenset({"swarm", "topology", "geoip"})

35
decnet/cli/webhook.py Normal file
View File

@@ -0,0 +1,35 @@
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="webhook")
def webhook_cmd(
daemon: bool = typer.Option(
False, "--daemon", "-d", help="Detach to background as a daemon process"
),
) -> None:
"""Run the webhook dispatcher — bus consumer → external HTTP egress."""
import asyncio
from decnet.web.dependencies import repo
from decnet.webhook import webhook_worker
if daemon:
log.info("webhook daemonizing")
_utils._daemonize()
log.info("webhook starting")
console.print("[bold cyan]Webhook dispatcher starting[/]")
async def _run() -> None:
await repo.initialize()
await webhook_worker(repo)
try:
asyncio.run(_run())
except KeyboardInterrupt:
console.print("\n[yellow]Webhook worker stopped.[/]")