From 899ea559d97fb4eeeee140eb1301214cfea3645d Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 19 Apr 2026 05:46:08 -0400 Subject: [PATCH] feat(enroll): systemd units for agent/forwarder/engine + log-directory INI key Rename log-file-path -> log-directory (maps to DECNET_LOG_DIRECTORY). Bundle now ships three systemd units rendered with agent_name/master_host and installs them into /etc/systemd/system/. Bootstrap replaces direct 'decnet X --daemon' calls with systemctl enable --now. Each unit pins DECNET_SYSTEM_LOGS so agent, forwarder, and deckies logs land at decnet.{agent,forwarder}.log and decnet.log under /var/log/decnet. --- decnet.ini.example | 7 ++-- decnet/config_ini.py | 6 ++-- .../router/swarm_mgmt/api_enroll_bundle.py | 23 +++++++++++-- decnet/web/templates/decnet-agent.service.j2 | 17 ++++++++++ decnet/web/templates/decnet-engine.service.j2 | 16 ++++++++++ .../web/templates/decnet-forwarder.service.j2 | 18 +++++++++++ decnet/web/templates/enroll_bootstrap.sh.j2 | 13 ++++++-- tests/api/swarm_mgmt/test_enroll_bundle.py | 32 +++++++++++++++++++ tests/test_config_ini.py | 6 ++-- 9 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 decnet/web/templates/decnet-agent.service.j2 create mode 100644 decnet/web/templates/decnet-engine.service.j2 create mode 100644 decnet/web/templates/decnet-forwarder.service.j2 diff --git a/decnet.ini.example b/decnet.ini.example index 82071d7..2169896 100644 --- a/decnet.ini.example +++ b/decnet.ini.example @@ -21,9 +21,10 @@ 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 +; log-directory — root for DECNET's per-component logs. Systemd units set +; DECNET_SYSTEM_LOGS=/decnet..log so agent, forwarder, +; and engine each get their own file. The forwarder tails decnet.log. +log-directory = /var/log/decnet ; ─── Agent-only settings (read when mode=agent) ─────────────────────────── diff --git a/decnet/config_ini.py b/decnet/config_ini.py index b7c75d2..6a914e2 100644 --- a/decnet/config_ini.py +++ b/decnet/config_ini.py @@ -10,7 +10,7 @@ Shape:: [decnet] mode = agent # or "master" - log-file-path = /var/log/decnet/decnet.log + log-directory = /var/log/decnet disallow-master = true [agent] @@ -42,7 +42,7 @@ from typing import Optional DEFAULT_CONFIG_PATH = Path("/etc/decnet/decnet.ini") # The [decnet] section keys are role-agnostic and always exported. -_COMMON_KEYS = frozenset({"mode", "disallow-master", "log-file-path"}) +_COMMON_KEYS = frozenset({"mode", "disallow-master", "log-directory"}) def _key_to_env(key: str) -> str: @@ -69,7 +69,7 @@ def load_ini_config(path: Optional[Path] = None) -> Optional[Path]: parser = configparser.ConfigParser() parser.read(path) - # [decnet] first — mode/disallow-master/log-file-path. These seed the + # [decnet] first — mode/disallow-master/log-directory. These seed the # mode decision for the section selection below. if parser.has_section("decnet"): for key, value in parser.items("decnet"): diff --git a/decnet/web/router/swarm_mgmt/api_enroll_bundle.py b/decnet/web/router/swarm_mgmt/api_enroll_bundle.py index cca3df4..862f8ab 100644 --- a/decnet/web/router/swarm_mgmt/api_enroll_bundle.py +++ b/decnet/web/router/swarm_mgmt/api_enroll_bundle.py @@ -172,7 +172,7 @@ def _render_decnet_ini(master_host: str) -> bytes: "[decnet]\n" "mode = agent\n" "disallow-master = true\n" - "log-file-path = /var/log/decnet/decnet.log\n" + "log-directory = /var/log/decnet\n" "\n" "[agent]\n" f"master-host = {master_host}\n" @@ -193,6 +193,7 @@ def _add_bytes(tar: tarfile.TarFile, name: str, data: bytes, mode: int = 0o644) def _build_tarball( master_host: str, + agent_name: str, issued: pki.IssuedCert, services_ini: Optional[str], updater_issued: Optional[pki.IssuedCert] = None, @@ -216,6 +217,12 @@ def _build_tarball( tar.add(path, arcname=rel, recursive=False) _add_bytes(tar, "etc/decnet/decnet.ini", _render_decnet_ini(master_host)) + for unit in _SYSTEMD_UNITS: + _add_bytes( + tar, + f"etc/systemd/system/{unit}.service", + _render_systemd_unit(unit, agent_name, master_host), + ) _add_bytes(tar, "home/.decnet/agent/ca.crt", issued.ca_cert_pem) _add_bytes(tar, "home/.decnet/agent/worker.crt", issued.cert_pem) _add_bytes(tar, "home/.decnet/agent/worker.key", issued.key_pem, mode=0o600) @@ -231,6 +238,18 @@ def _build_tarball( return buf.getvalue() +_SYSTEMD_UNITS = ("decnet-agent", "decnet-forwarder", "decnet-engine") + + +def _render_systemd_unit(name: str, agent_name: str, master_host: str) -> bytes: + tpl_path = pathlib.Path(__file__).resolve().parents[1].parent / "templates" / f"{name}.service.j2" + tpl = tpl_path.read_text() + return ( + tpl.replace("{{ agent_name }}", agent_name) + .replace("{{ master_host }}", master_host) + ).encode() + + def _render_bootstrap( agent_name: str, master_host: str, @@ -314,7 +333,7 @@ async def create_enroll_bundle( ) # 3. Render payload + bootstrap. - tarball = _build_tarball(req.master_host, issued, req.services_ini, updater_issued) + tarball = _build_tarball(req.master_host, req.agent_name, issued, req.services_ini, updater_issued) token = secrets.token_urlsafe(24) expires_at = datetime.now(timezone.utc) + BUNDLE_TTL diff --git a/decnet/web/templates/decnet-agent.service.j2 b/decnet/web/templates/decnet-agent.service.j2 new file mode 100644 index 0000000..26d9612 --- /dev/null +++ b/decnet/web/templates/decnet-agent.service.j2 @@ -0,0 +1,17 @@ +[Unit] +Description=DECNET worker agent (mTLS control plane) — {{ agent_name }} +Documentation=https://github.com/anti/DECNET +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +Environment=DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.agent.log +ExecStart=/usr/local/bin/decnet agent --no-forwarder +Restart=on-failure +RestartSec=5 +StandardOutput=append:/var/log/decnet/decnet.agent.log +StandardError=append:/var/log/decnet/decnet.agent.log + +[Install] +WantedBy=multi-user.target diff --git a/decnet/web/templates/decnet-engine.service.j2 b/decnet/web/templates/decnet-engine.service.j2 new file mode 100644 index 0000000..8d5cf14 --- /dev/null +++ b/decnet/web/templates/decnet-engine.service.j2 @@ -0,0 +1,16 @@ +[Unit] +Description=DECNET deckie orchestrator (decnet deploy) — {{ agent_name }} +Documentation=https://github.com/anti/DECNET +After=network-online.target decnet-agent.service +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +Environment=DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.log +ExecStart=/usr/local/bin/decnet deploy +StandardOutput=append:/var/log/decnet/decnet.log +StandardError=append:/var/log/decnet/decnet.log + +[Install] +WantedBy=multi-user.target diff --git a/decnet/web/templates/decnet-forwarder.service.j2 b/decnet/web/templates/decnet-forwarder.service.j2 new file mode 100644 index 0000000..0f6b974 --- /dev/null +++ b/decnet/web/templates/decnet-forwarder.service.j2 @@ -0,0 +1,18 @@ +[Unit] +Description=DECNET log forwarder (syslog-over-TLS → master) — {{ agent_name }} +Documentation=https://github.com/anti/DECNET +After=network-online.target +Wants=network-online.target +PartOf=decnet-agent.service + +[Service] +Type=simple +Environment=DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.forwarder.log +ExecStart=/usr/local/bin/decnet forwarder --master-host {{ master_host }} --master-port 6514 --agent-dir /etc/decnet/agent --log-file /var/log/decnet/decnet.log +Restart=on-failure +RestartSec=5 +StandardOutput=append:/var/log/decnet/decnet.forwarder.log +StandardError=append:/var/log/decnet/decnet.forwarder.log + +[Install] +WantedBy=multi-user.target diff --git a/decnet/web/templates/enroll_bootstrap.sh.j2 b/decnet/web/templates/enroll_bootstrap.sh.j2 index aba010c..526f04a 100644 --- a/decnet/web/templates/enroll_bootstrap.sh.j2 +++ b/decnet/web/templates/enroll_bootstrap.sh.j2 @@ -5,7 +5,7 @@ set -euo pipefail [[ $EUID -eq 0 ]] || { echo "decnet-install: must run as root (use sudo)"; exit 1; } -for bin in python3 curl tar; do +for bin in python3 curl tar systemctl; do command -v "$bin" >/dev/null || { echo "decnet-install: $bin required"; exit 1; } done @@ -53,8 +53,15 @@ fi # combos drop it with mode 0644) and expose it on PATH. chmod 0755 "$INSTALL_DIR/.venv/bin/decnet" ln -sf "$INSTALL_DIR/.venv/bin/decnet" /usr/local/bin/decnet -/usr/local/bin/decnet agent --daemon + +echo "[DECNET] installing systemd units..." +install -Dm0644 etc/systemd/system/decnet-agent.service /etc/systemd/system/decnet-agent.service +install -Dm0644 etc/systemd/system/decnet-forwarder.service /etc/systemd/system/decnet-forwarder.service +install -Dm0644 etc/systemd/system/decnet-engine.service /etc/systemd/system/decnet-engine.service +systemctl daemon-reload +systemctl enable --now decnet-agent.service decnet-forwarder.service + if [[ "$WITH_UPDATER" == "true" ]]; then /usr/local/bin/decnet updater --daemon fi -echo "[DECNET] agent {{ agent_name }} enrolled -> {{ master_host }}. Forwarder auto-spawned." +echo "[DECNET] agent {{ agent_name }} enrolled -> {{ master_host }}. Units: decnet-agent, decnet-forwarder active." diff --git a/tests/api/swarm_mgmt/test_enroll_bundle.py b/tests/api/swarm_mgmt/test_enroll_bundle.py index 032ebe0..08ff10b 100644 --- a/tests/api/swarm_mgmt/test_enroll_bundle.py +++ b/tests/api/swarm_mgmt/test_enroll_bundle.py @@ -136,6 +136,38 @@ async def test_updater_opt_out_excludes_updater_artifacts(client, auth_token): assert 'WITH_UPDATER="false"' in sh +@pytest.mark.anyio +async def test_systemd_units_shipped_and_installed(client, auth_token): + import io, tarfile + post = await _post(client, auth_token, agent_name="svc-test", master_host="10.9.8.7") + token = post.json()["token"] + resp = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz") + assert resp.status_code == 200 + tf = tarfile.open(fileobj=io.BytesIO(resp.content), mode="r:gz") + names = set(tf.getnames()) + assert "etc/systemd/system/decnet-agent.service" in names + assert "etc/systemd/system/decnet-forwarder.service" in names + assert "etc/systemd/system/decnet-engine.service" in names + + fwd = tf.extractfile("etc/systemd/system/decnet-forwarder.service").read().decode() + assert "--master-host 10.9.8.7" in fwd + assert "DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.forwarder.log" in fwd + + agent_unit = tf.extractfile("etc/systemd/system/decnet-agent.service").read().decode() + assert "--no-forwarder" in agent_unit + assert "DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.agent.log" in agent_unit + + sh_token = (await _post(client, auth_token, agent_name="svc-test2", + master_host="10.9.8.7")).json()["token"] + sh = (await client.get(f"/api/v1/swarm/enroll-bundle/{sh_token}.sh")).text + assert "systemctl daemon-reload" in sh + assert "systemctl enable --now decnet-agent.service decnet-forwarder.service" in sh + + ini = tf.extractfile("etc/decnet/decnet.ini").read().decode() + assert "log-directory = /var/log/decnet" in ini + assert "log-file-path" not in ini + + @pytest.mark.anyio async def test_updater_opt_in_ships_cert_and_starts_daemon(client, auth_token): import io, tarfile diff --git a/tests/test_config_ini.py b/tests/test_config_ini.py index d283d4f..29eb84b 100644 --- a/tests/test_config_ini.py +++ b/tests/test_config_ini.py @@ -95,17 +95,17 @@ agent-port = 8765 def test_common_keys_always_exported(monkeypatch, tmp_path): - _scrub(monkeypatch, "DECNET_MODE", "DECNET_DISALLOW_MASTER", "DECNET_LOG_FILE_PATH") + _scrub(monkeypatch, "DECNET_MODE", "DECNET_DISALLOW_MASTER", "DECNET_LOG_DIRECTORY") ini = _write_ini(tmp_path, """ [decnet] mode = agent disallow-master = true -log-file-path = /var/log/decnet/decnet.log +log-directory = /var/log/decnet """) load_ini_config(ini) assert os.environ["DECNET_MODE"] == "agent" assert os.environ["DECNET_DISALLOW_MASTER"] == "true" - assert os.environ["DECNET_LOG_FILE_PATH"] == "/var/log/decnet/decnet.log" + assert os.environ["DECNET_LOG_DIRECTORY"] == "/var/log/decnet" def test_invalid_mode_raises(monkeypatch, tmp_path):