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.
This commit is contained in:
@@ -21,9 +21,10 @@ mode = agent
|
|||||||
; Set to false for hybrid dev hosts that legitimately run both roles.
|
; Set to false for hybrid dev hosts that legitimately run both roles.
|
||||||
disallow-master = true
|
disallow-master = true
|
||||||
|
|
||||||
; log-file-path — where the local RFC 5424 event sink writes. The forwarder
|
; log-directory — root for DECNET's per-component logs. Systemd units set
|
||||||
; tails this file and ships it to the master.
|
; DECNET_SYSTEM_LOGS=<log-directory>/decnet.<component>.log so agent, forwarder,
|
||||||
log-file-path = /var/log/decnet/decnet.log
|
; and engine each get their own file. The forwarder tails decnet.log.
|
||||||
|
log-directory = /var/log/decnet
|
||||||
|
|
||||||
|
|
||||||
; ─── Agent-only settings (read when mode=agent) ───────────────────────────
|
; ─── Agent-only settings (read when mode=agent) ───────────────────────────
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Shape::
|
|||||||
|
|
||||||
[decnet]
|
[decnet]
|
||||||
mode = agent # or "master"
|
mode = agent # or "master"
|
||||||
log-file-path = /var/log/decnet/decnet.log
|
log-directory = /var/log/decnet
|
||||||
disallow-master = true
|
disallow-master = true
|
||||||
|
|
||||||
[agent]
|
[agent]
|
||||||
@@ -42,7 +42,7 @@ from typing import Optional
|
|||||||
DEFAULT_CONFIG_PATH = Path("/etc/decnet/decnet.ini")
|
DEFAULT_CONFIG_PATH = Path("/etc/decnet/decnet.ini")
|
||||||
|
|
||||||
# The [decnet] section keys are role-agnostic and always exported.
|
# 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:
|
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 = configparser.ConfigParser()
|
||||||
parser.read(path)
|
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.
|
# mode decision for the section selection below.
|
||||||
if parser.has_section("decnet"):
|
if parser.has_section("decnet"):
|
||||||
for key, value in parser.items("decnet"):
|
for key, value in parser.items("decnet"):
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ def _render_decnet_ini(master_host: str) -> bytes:
|
|||||||
"[decnet]\n"
|
"[decnet]\n"
|
||||||
"mode = agent\n"
|
"mode = agent\n"
|
||||||
"disallow-master = true\n"
|
"disallow-master = true\n"
|
||||||
"log-file-path = /var/log/decnet/decnet.log\n"
|
"log-directory = /var/log/decnet\n"
|
||||||
"\n"
|
"\n"
|
||||||
"[agent]\n"
|
"[agent]\n"
|
||||||
f"master-host = {master_host}\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(
|
def _build_tarball(
|
||||||
master_host: str,
|
master_host: str,
|
||||||
|
agent_name: str,
|
||||||
issued: pki.IssuedCert,
|
issued: pki.IssuedCert,
|
||||||
services_ini: Optional[str],
|
services_ini: Optional[str],
|
||||||
updater_issued: Optional[pki.IssuedCert] = None,
|
updater_issued: Optional[pki.IssuedCert] = None,
|
||||||
@@ -216,6 +217,12 @@ def _build_tarball(
|
|||||||
tar.add(path, arcname=rel, recursive=False)
|
tar.add(path, arcname=rel, recursive=False)
|
||||||
|
|
||||||
_add_bytes(tar, "etc/decnet/decnet.ini", _render_decnet_ini(master_host))
|
_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/ca.crt", issued.ca_cert_pem)
|
||||||
_add_bytes(tar, "home/.decnet/agent/worker.crt", issued.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)
|
_add_bytes(tar, "home/.decnet/agent/worker.key", issued.key_pem, mode=0o600)
|
||||||
@@ -231,6 +238,18 @@ def _build_tarball(
|
|||||||
return buf.getvalue()
|
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(
|
def _render_bootstrap(
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
master_host: str,
|
master_host: str,
|
||||||
@@ -314,7 +333,7 @@ async def create_enroll_bundle(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 3. Render payload + bootstrap.
|
# 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)
|
token = secrets.token_urlsafe(24)
|
||||||
expires_at = datetime.now(timezone.utc) + BUNDLE_TTL
|
expires_at = datetime.now(timezone.utc) + BUNDLE_TTL
|
||||||
|
|
||||||
|
|||||||
17
decnet/web/templates/decnet-agent.service.j2
Normal file
17
decnet/web/templates/decnet-agent.service.j2
Normal file
@@ -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
|
||||||
16
decnet/web/templates/decnet-engine.service.j2
Normal file
16
decnet/web/templates/decnet-engine.service.j2
Normal file
@@ -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
|
||||||
18
decnet/web/templates/decnet-forwarder.service.j2
Normal file
18
decnet/web/templates/decnet-forwarder.service.j2
Normal file
@@ -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
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
[[ $EUID -eq 0 ]] || { echo "decnet-install: must run as root (use sudo)"; exit 1; }
|
[[ $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; }
|
command -v "$bin" >/dev/null || { echo "decnet-install: $bin required"; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -53,8 +53,15 @@ fi
|
|||||||
# combos drop it with mode 0644) and expose it on PATH.
|
# combos drop it with mode 0644) and expose it on PATH.
|
||||||
chmod 0755 "$INSTALL_DIR/.venv/bin/decnet"
|
chmod 0755 "$INSTALL_DIR/.venv/bin/decnet"
|
||||||
ln -sf "$INSTALL_DIR/.venv/bin/decnet" /usr/local/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
|
if [[ "$WITH_UPDATER" == "true" ]]; then
|
||||||
/usr/local/bin/decnet updater --daemon
|
/usr/local/bin/decnet updater --daemon
|
||||||
fi
|
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."
|
||||||
|
|||||||
@@ -136,6 +136,38 @@ async def test_updater_opt_out_excludes_updater_artifacts(client, auth_token):
|
|||||||
assert 'WITH_UPDATER="false"' in sh
|
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
|
@pytest.mark.anyio
|
||||||
async def test_updater_opt_in_ships_cert_and_starts_daemon(client, auth_token):
|
async def test_updater_opt_in_ships_cert_and_starts_daemon(client, auth_token):
|
||||||
import io, tarfile
|
import io, tarfile
|
||||||
|
|||||||
@@ -95,17 +95,17 @@ agent-port = 8765
|
|||||||
|
|
||||||
|
|
||||||
def test_common_keys_always_exported(monkeypatch, tmp_path):
|
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, """
|
ini = _write_ini(tmp_path, """
|
||||||
[decnet]
|
[decnet]
|
||||||
mode = agent
|
mode = agent
|
||||||
disallow-master = true
|
disallow-master = true
|
||||||
log-file-path = /var/log/decnet/decnet.log
|
log-directory = /var/log/decnet
|
||||||
""")
|
""")
|
||||||
load_ini_config(ini)
|
load_ini_config(ini)
|
||||||
assert os.environ["DECNET_MODE"] == "agent"
|
assert os.environ["DECNET_MODE"] == "agent"
|
||||||
assert os.environ["DECNET_DISALLOW_MASTER"] == "true"
|
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):
|
def test_invalid_mode_raises(monkeypatch, tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user