feat(deploy): templatize systemd services on install_dir via Jinja2

Distros reserve /opt for different things (some package managers own it
outright), and a DECNET install that wants to live at /srv/decnet or
/usr/local/decnet had to hand-edit 13 service files post-install.

Converts every deploy/decnet-*.service to a .j2 template keyed on
{{ install_dir }}, rendered by `decnet init` at install time. All other
paths (log_dir, state_dir, runtime_dir, user, group) stay standard —
only install_dir varies.

Changes:
- deploy/decnet-*.service → deploy/decnet-*.service.j2 (13 files).
- decnet init gains --install-dir (default /opt/decnet, preserves
  existing behaviour byte-for-byte). Validates absolute-path at the
  CLI boundary. Threads through useradd --home-dir and the dir-creation
  list so the filesystem layout matches the rendered templates.
- _install_units renders via Jinja2 with StrictUndefined (typo → loud
  error, not a silent broken unit). SHA over rendered output so
  operators with a custom install_dir get idempotent re-runs.
- decnet.target, tmpfiles.d, polkit rule stay static — they don't
  reference install paths.
- 4 new tests: custom install_dir renders into units, default remains
  /opt/decnet, relative paths rejected, second run with same custom
  dir is idempotent.
This commit is contained in:
2026-04-23 18:08:26 -04:00
parent 4418608a54
commit 1753eca198
15 changed files with 253 additions and 66 deletions

View File

@@ -11,9 +11,9 @@ User=decnet
Group=decnet
# docker.sock is group-readable by 'docker'; the agent needs it for compose.
SupplementaryGroups=docker
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
ExecStart=/opt/decnet/venv/bin/decnet agent --host 0.0.0.0 --port 8765 --agent-dir /etc/decnet/agent
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
ExecStart={{ install_dir }}/venv/bin/decnet agent --host 0.0.0.0 --port 8765 --agent-dir /etc/decnet/agent
# MACVLAN/IPVLAN management + scapy raw sockets. Granted via ambient caps so
# the process starts unprivileged and keeps only these two bits.
@@ -30,8 +30,8 @@ ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
# /opt/decnet holds release slots + state; the agent reads them and writes its PID.
ReadWritePaths=/opt/decnet /var/log/decnet
# {{ install_dir }} holds release slots + state; the agent reads them and writes its PID.
ReadWritePaths={{ install_dir }} /var/log/decnet
Restart=on-failure
RestartSec=5

View File

@@ -11,9 +11,9 @@ User=decnet
Group=decnet
# docker.sock is group-readable by 'docker'; the API ingester tails container logs.
SupplementaryGroups=docker
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
ExecStart=/opt/decnet/venv/bin/decnet api
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
ExecStart={{ install_dir }}/venv/bin/decnet api
# MACVLAN/IPVLAN setup runs from the API lifespan when the embedded sniffer is on.
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
@@ -29,7 +29,7 @@ ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
ReadWritePaths=/opt/decnet /var/log/decnet
ReadWritePaths={{ install_dir }} /var/log/decnet
Restart=on-failure
RestartSec=5

View File

@@ -8,15 +8,15 @@ Wants=network-online.target
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
# /run/decnet is created automatically with the RuntimeDirectory= directive
# below (mode 0755, owned by User/Group) and cleaned up on stop. The bus
# socket is placed inside it with 0660 perms so only the decnet group can
# connect.
RuntimeDirectory=decnet
RuntimeDirectoryMode=0755
ExecStart=/opt/decnet/venv/bin/decnet bus \
ExecStart={{ install_dir }}/venv/bin/decnet bus \
--socket /run/decnet/bus.sock \
--group decnet

View File

@@ -11,9 +11,9 @@ User=decnet
Group=decnet
# docker.sock is group-readable by 'docker'; the collector tails container logs.
SupplementaryGroups=docker
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
ExecStart=/opt/decnet/venv/bin/decnet collect
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
ExecStart={{ install_dir }}/venv/bin/decnet collect
# No privileged network operations.
CapabilityBoundingSet=
@@ -29,7 +29,7 @@ ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
ReadWritePaths=/opt/decnet /var/log/decnet
ReadWritePaths={{ install_dir }} /var/log/decnet
Restart=on-failure
RestartSec=5

View File

@@ -10,12 +10,12 @@ Wants=network-online.target
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
# Replace <master-host> with the master's LAN address or hostname. The agent
# cert bundle at /etc/decnet/agent is reused — the forwarder presents the same
# worker identity when it connects to the master's listener.
ExecStart=/opt/decnet/venv/bin/decnet forwarder \
ExecStart={{ install_dir }}/venv/bin/decnet forwarder \
--log-file /var/log/decnet/decnet.log \
--master-host ${DECNET_SWARM_MASTER_HOST} \
--master-port 6514 \

View File

@@ -8,11 +8,11 @@ Wants=network-online.target
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
# Binds 0.0.0.0:6514 so workers across the LAN can connect. 6514 is not a
# privileged port (≥1024), so no CAP_NET_BIND_SERVICE is required.
ExecStart=/opt/decnet/venv/bin/decnet listener \
ExecStart={{ install_dir }}/venv/bin/decnet listener \
--host 0.0.0.0 --port 6514 \
--ca-dir /etc/decnet/ca \
--log-path /var/log/decnet/master.log \

View File

@@ -11,9 +11,9 @@ User=decnet
Group=decnet
# Mutator recomposes decky services via docker compose.
SupplementaryGroups=docker
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
ExecStart=/opt/decnet/venv/bin/decnet mutate --watch
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
ExecStart={{ install_dir }}/venv/bin/decnet mutate --watch
CapabilityBoundingSet=
AmbientCapabilities=
@@ -28,7 +28,7 @@ ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
ReadWritePaths=/opt/decnet /var/log/decnet
ReadWritePaths={{ install_dir }} /var/log/decnet
Restart=on-failure
RestartSec=5

View File

@@ -8,9 +8,9 @@ Wants=network-online.target decnet-bus.service
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
ExecStart=/opt/decnet/venv/bin/decnet probe
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
ExecStart={{ install_dir }}/venv/bin/decnet probe
# TCP connect probes only — no raw sockets required.
CapabilityBoundingSet=
@@ -26,7 +26,7 @@ ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
ReadWritePaths=/opt/decnet /var/log/decnet
ReadWritePaths={{ install_dir }} /var/log/decnet
Restart=on-failure
RestartSec=5

View File

@@ -8,9 +8,9 @@ Wants=network-online.target decnet-bus.service
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
ExecStart=/opt/decnet/venv/bin/decnet profiler
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
ExecStart={{ install_dir }}/venv/bin/decnet profiler
CapabilityBoundingSet=
AmbientCapabilities=
@@ -25,7 +25,7 @@ ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
ReadWritePaths=/opt/decnet /var/log/decnet
ReadWritePaths={{ install_dir }} /var/log/decnet
Restart=on-failure
RestartSec=5

View File

@@ -8,9 +8,9 @@ Wants=network-online.target decnet-bus.service
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
ExecStart=/opt/decnet/venv/bin/decnet sniffer
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
ExecStart={{ install_dir }}/venv/bin/decnet sniffer
# scapy needs raw packet access on the MACVLAN host interface.
CapabilityBoundingSet=CAP_NET_RAW
@@ -26,7 +26,7 @@ ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
ReadWritePaths=/opt/decnet /var/log/decnet
ReadWritePaths={{ install_dir }} /var/log/decnet
Restart=on-failure
RestartSec=5

View File

@@ -8,11 +8,11 @@ Wants=network-online.target
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
# Default bind is loopback — the controller is a master-local orchestrator
# reached by the CLI and the web dashboard, not by workers.
ExecStart=/opt/decnet/venv/bin/decnet swarmctl --host 127.0.0.1 --port 8770
ExecStart={{ install_dir }}/venv/bin/decnet swarmctl --host 127.0.0.1 --port 8770
# No special capabilities — the controller issues mTLS certs and talks to
# workers over TCP on unprivileged ports.
@@ -30,7 +30,7 @@ ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
# Reads/writes the CA bundle and the master DB.
ReadWritePaths=/opt/decnet /var/log/decnet
ReadWritePaths={{ install_dir }} /var/log/decnet
ReadOnlyPaths=/etc/decnet
Restart=on-failure

View File

@@ -10,12 +10,12 @@ Wants=network-online.target
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
ExecStart=/opt/decnet/venv/bin/decnet updater \
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
ExecStart={{ install_dir }}/venv/bin/decnet updater \
--host 0.0.0.0 --port 8766 \
--updater-dir /etc/decnet/updater \
--install-dir /opt/decnet \
--install-dir {{ install_dir }} \
--agent-dir /etc/decnet/agent
# The updater SIGTERMs the agent and spawns a new one. Same User=decnet means
@@ -37,7 +37,7 @@ ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
# Writes release slots, pip installs into venv, manages agent.pid.
ReadWritePaths=/opt/decnet /var/log/decnet
ReadWritePaths={{ install_dir }} /var/log/decnet
Restart=on-failure
RestartSec=5

View File

@@ -8,9 +8,9 @@ Wants=network-online.target
Type=simple
User=decnet
Group=decnet
WorkingDirectory=/opt/decnet
EnvironmentFile=-/opt/decnet/.env.local
ExecStart=/opt/decnet/venv/bin/decnet web
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
ExecStart={{ install_dir }}/venv/bin/decnet web
# Uncomment if you bind the dashboard to a privileged port (80/443):
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
@@ -28,7 +28,7 @@ ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
ReadWritePaths=/opt/decnet /var/log/decnet
ReadWritePaths={{ install_dir }} /var/log/decnet
ReadOnlyPaths=/etc/decnet
Restart=on-failure