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

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"])
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()