feat(cli): auto-spawn listener as detached sibling from decnet swarmctl

Mirrors the agent→forwarder pattern: `decnet swarmctl` now fires the
syslog-TLS listener as a detached Popen sibling so a single master
invocation brings the full receive pipeline online. --no-listener opts
out for operators who want to run the listener on a different host (or
under their own systemd unit).

Listener bind host / port come from DECNET_LISTENER_HOST and
DECNET_SWARM_SYSLOG_PORT — both seedable from /etc/decnet/decnet.ini.
PID at $(pid_dir)/listener.pid so operators can kill/restart manually.

decnet.ini.example ships alongside env.config.example as the
documented surface for the new role-scoped config. Mode, forwarder
targets, listener bind, and master ports all live there — no more
memorizing flag trees.

Extends tests/test_auto_spawn.py with two swarmctl cases: listener is
spawned with the expected argv + PID file, and --no-listener suppresses.
This commit is contained in:
2026-04-19 03:25:40 -04:00
parent 43f140a87a
commit 37b22b76a5
3 changed files with 160 additions and 1 deletions

63
decnet.ini.example Normal file
View File

@@ -0,0 +1,63 @@
; /etc/decnet/decnet.ini — DECNET host configuration
;
; Copy to /etc/decnet/decnet.ini and edit. Values here seed os.environ at
; CLI startup via setdefault() — real env vars still win, so you can
; override any value on the shell without editing this file.
;
; A missing file is fine; every daemon has sensible defaults. The main
; reason to use this file is to skip typing the same flags on every
; `decnet` invocation and to pin a host's role via `mode`.
[decnet]
; mode = agent | master
; agent — worker host (runs `decnet agent`, `decnet forwarder`, `decnet updater`).
; Master-only commands (api, swarmctl, swarm, deploy, teardown, ...)
; are hidden from `decnet --help` and refuse to run.
; master — central server (runs `decnet api`, `decnet web`, `decnet swarmctl`,
; `decnet listener`). All commands visible.
mode = agent
; disallow-master = true (default when mode=agent)
; Set to false for hybrid dev hosts that legitimately run both roles.
disallow-master = true
; log-file-path — where the local RFC 5424 event sink writes. The forwarder
; tails this file and ships it to the master.
log-file-path = /var/log/decnet/decnet.log
; ─── Agent-only settings (read when mode=agent) ───────────────────────────
[agent]
; Where the master's syslog-TLS listener lives. DECNET_SWARM_MASTER_HOST.
master-host = 192.168.1.50
; Master listener port (RFC 5425 default 6514). DECNET_SWARM_SYSLOG_PORT.
swarm-syslog-port = 6514
; Bind address/port for this worker's agent API (mTLS).
agent-port = 8765
; Cert bundle dir — must contain ca.crt, worker.crt, worker.key from enroll.
; DECNET_AGENT_DIR — honored by the forwarder child as well.
agent-dir = /home/anti/.decnet/agent
; Updater cert bundle (required for `decnet updater`).
updater-dir = /home/anti/.decnet/updater
; ─── Master-only settings (read when mode=master) ─────────────────────────
[master]
; Main API (REST for the React dashboard). DECNET_API_HOST / _PORT.
api-host = 0.0.0.0
api-port = 8000
; React dev-server dashboard (`decnet web`). DECNET_WEB_HOST / _PORT.
web-host = 0.0.0.0
web-port = 8080
; Swarm controller (master-internal). DECNET_SWARMCTL_HOST isn't exposed
; under that name today — this block is the forward-compatible spelling.
; swarmctl-host = 127.0.0.1
; swarmctl-port = 8770
; Syslog-over-TLS listener bind address and port. DECNET_LISTENER_HOST and
; DECNET_SWARM_SYSLOG_PORT. The listener is auto-spawned by `decnet swarmctl`.
listener-host = 0.0.0.0
swarm-syslog-port = 6514
; Master CA dir (for enroll / swarm cert issuance).
; ca-dir = /home/anti/.decnet/ca
; JWT secret for the web API. MUST be set; 32+ bytes. Keep out of git.
; jwt-secret = REPLACE_ME_WITH_A_32_BYTE_SECRET

View File

@@ -176,8 +176,17 @@ def swarmctl(
port: int = typer.Option(8770, "--port", help="Port for the swarm controller"), port: int = typer.Option(8770, "--port", help="Port for the swarm controller"),
host: str = typer.Option("127.0.0.1", "--host", help="Bind address for the swarm controller"), host: str = typer.Option("127.0.0.1", "--host", help="Bind address for the swarm controller"),
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"), daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
no_listener: bool = typer.Option(False, "--no-listener", help="Do not auto-spawn the syslog-TLS listener alongside swarmctl"),
) -> None: ) -> None:
"""Run the DECNET SWARM controller (master-side, separate process from `decnet api`).""" """Run the DECNET SWARM controller (master-side, separate process from `decnet api`).
By default, `decnet swarmctl` auto-spawns `decnet listener` as a fully-
detached sibling process so the master starts accepting forwarder
connections on 6514 without a second manual invocation. The listener
survives swarmctl restarts and crashes — if it dies on its own,
restart it manually with `decnet listener --daemon …`. Pass
--no-listener to skip.
"""
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
import os import os
@@ -188,6 +197,24 @@ def swarmctl(
log.info("swarmctl daemonizing host=%s port=%d", host, port) log.info("swarmctl daemonizing host=%s port=%d", host, port)
_daemonize() _daemonize()
if not no_listener:
listener_host = os.environ.get("DECNET_LISTENER_HOST", "0.0.0.0") # nosec B104
listener_port = int(os.environ.get("DECNET_SWARM_SYSLOG_PORT", "6514"))
lst_argv = [
sys.executable, "-m", "decnet", "listener",
"--host", listener_host,
"--port", str(listener_port),
"--daemon",
]
try:
pid = _spawn_detached(lst_argv, _pid_dir() / "listener.pid")
log.info("swarmctl auto-spawned listener pid=%d bind=%s:%d",
pid, listener_host, listener_port)
console.print(f"[dim]Auto-spawned listener (pid {pid}) on {listener_host}:{listener_port}.[/]")
except Exception as e: # noqa: BLE001
log.warning("swarmctl could not auto-spawn listener: %s", e)
console.print(f"[yellow]listener auto-spawn skipped: {e}[/]")
log.info("swarmctl command invoked host=%s port=%d", host, port) log.info("swarmctl command invoked host=%s port=%d", host, port)
console.print(f"[green]Starting DECNET SWARM controller on {host}:{port}...[/]") console.print(f"[green]Starting DECNET SWARM controller on {host}:{port}...[/]")
_cmd = [sys.executable, "-m", "uvicorn", "decnet.web.swarm_api:app", _cmd = [sys.executable, "-m", "uvicorn", "decnet.web.swarm_api:app",

View File

@@ -133,3 +133,72 @@ def test_agent_skips_forwarder_when_master_unset(fake_popen, monkeypatch, tmp_pa
result = runner.invoke(fake_popen.app, ["agent"]) result = runner.invoke(fake_popen.app, ["agent"])
assert result.exit_code == 0 assert result.exit_code == 0
assert _FakePopen.last_instance is None assert _FakePopen.last_instance is None
# ───────────────────────────────────────────────────────────────────────────
# swarmctl → listener auto-spawn
# ───────────────────────────────────────────────────────────────────────────
class _FakeUvicornPopen:
"""Stub for the uvicorn subprocess inside swarmctl — returns immediately
so the Typer command body doesn't block on proc.wait()."""
def __init__(self, *a, **kw) -> None:
self.pid = 999999
def wait(self, *a, **kw) -> int:
return 0
@pytest.fixture
def fake_swarmctl_popen(monkeypatch):
"""For swarmctl: record the detached listener spawn via _FakePopen
AND stub uvicorn's Popen so swarmctl's body returns immediately."""
import decnet.cli as cli_mod
import subprocess as _subp
calls: list[_FakePopen] = []
def _router(argv, **kwargs):
# Only the listener auto-spawn uses start_new_session + DEVNULL stdio.
if kwargs.get("start_new_session") and "stdin" in kwargs:
inst = _FakePopen(argv, **kwargs)
calls.append(inst)
return inst
# Anything else (the uvicorn child swarmctl blocks on) → cheap stub.
return _FakeUvicornPopen()
monkeypatch.setattr(_subp, "Popen", _router)
_FakePopen.last_instance = None
return cli_mod, calls
def test_swarmctl_autospawns_listener(fake_swarmctl_popen, monkeypatch, tmp_path):
cli_mod, calls = fake_swarmctl_popen
monkeypatch.setattr(cli_mod, "_pid_dir", lambda: tmp_path)
monkeypatch.setenv("DECNET_LISTENER_HOST", "0.0.0.0")
monkeypatch.setenv("DECNET_SWARM_SYSLOG_PORT", "6514")
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli_mod.app, ["swarmctl", "--port", "8770"])
assert result.exit_code == 0, result.stdout
assert len(calls) == 1, f"expected one detached spawn, got {len(calls)}"
argv = calls[0].argv
assert "listener" in argv
assert "--daemon" in argv
assert "--port" in argv and "6514" in argv
# PID file written.
pid_path = tmp_path / "listener.pid"
assert pid_path.exists()
assert int(pid_path.read_text().strip()) > 0
def test_swarmctl_no_listener_flag_suppresses_spawn(fake_swarmctl_popen, monkeypatch, tmp_path):
cli_mod, calls = fake_swarmctl_popen
monkeypatch.setattr(cli_mod, "_pid_dir", lambda: tmp_path)
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli_mod.app, ["swarmctl", "--no-listener"])
assert result.exit_code == 0, result.stdout
assert calls == [], "listener should NOT have been spawned"
assert not (tmp_path / "listener.pid").exists()