From 37b22b76a5336c441fa185aef106b2dd336804f9 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 19 Apr 2026 03:25:40 -0400 Subject: [PATCH] feat(cli): auto-spawn listener as detached sibling from decnet swarmctl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- decnet.ini.example | 63 ++++++++++++++++++++++++++++++++++++ decnet/cli.py | 29 ++++++++++++++++- tests/test_auto_spawn.py | 69 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 decnet.ini.example diff --git a/decnet.ini.example b/decnet.ini.example new file mode 100644 index 0000000..82071d7 --- /dev/null +++ b/decnet.ini.example @@ -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 diff --git a/decnet/cli.py b/decnet/cli.py index f7d784d..a3dee5a 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -176,8 +176,17 @@ def swarmctl( 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"), 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: - """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 sys import os @@ -188,6 +197,24 @@ def swarmctl( log.info("swarmctl daemonizing host=%s port=%d", host, port) _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) console.print(f"[green]Starting DECNET SWARM controller on {host}:{port}...[/]") _cmd = [sys.executable, "-m", "uvicorn", "decnet.web.swarm_api:app", diff --git a/tests/test_auto_spawn.py b/tests/test_auto_spawn.py index f6ff675..69b6e5d 100644 --- a/tests/test_auto_spawn.py +++ b/tests/test_auto_spawn.py @@ -133,3 +133,72 @@ def test_agent_skips_forwarder_when_master_unset(fake_popen, monkeypatch, tmp_pa result = runner.invoke(fake_popen.app, ["agent"]) assert result.exit_code == 0 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()