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:
63
decnet.ini.example
Normal file
63
decnet.ini.example
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user