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:
@@ -24,6 +24,7 @@ from pathlib import Path
|
|||||||
from typing import Callable, List
|
from typing import Callable, List
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
||||||
|
|
||||||
import decnet as _decnet_pkg
|
import decnet as _decnet_pkg
|
||||||
from .gating import _require_master_mode
|
from .gating import _require_master_mode
|
||||||
@@ -99,7 +100,7 @@ def _ensure_group(group: str, *, dry_run: bool) -> str:
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
def _ensure_user(user: str, group: str, *, dry_run: bool) -> str:
|
def _ensure_user(user: str, group: str, install_dir: str, *, dry_run: bool) -> str:
|
||||||
try:
|
try:
|
||||||
pwd.getpwnam(user)
|
pwd.getpwnam(user)
|
||||||
return f"skip: user {user} already exists"
|
return f"skip: user {user} already exists"
|
||||||
@@ -108,7 +109,7 @@ def _ensure_user(user: str, group: str, *, dry_run: bool) -> str:
|
|||||||
[
|
[
|
||||||
"useradd", "--system",
|
"useradd", "--system",
|
||||||
"--gid", group,
|
"--gid", group,
|
||||||
"--home-dir", "/opt/decnet",
|
"--home-dir", install_dir,
|
||||||
"--shell", "/usr/sbin/nologin",
|
"--shell", "/usr/sbin/nologin",
|
||||||
"--comment", "DECNET honeypot",
|
"--comment", "DECNET honeypot",
|
||||||
user,
|
user,
|
||||||
@@ -177,19 +178,78 @@ def _copy_if_changed(
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
def _install_units(
|
def _render_template(src: Path, context: dict[str, str]) -> str:
|
||||||
deploy: Path, systemd_dir: Path, *, force: bool, dry_run: bool
|
"""Render a Jinja2 .j2 template with the given context.
|
||||||
|
|
||||||
|
StrictUndefined: a missing context variable is an error, not a
|
||||||
|
silent empty-string substitution — that way a typo in the template
|
||||||
|
fails loudly instead of shipping a broken systemd unit.
|
||||||
|
"""
|
||||||
|
env = Environment(
|
||||||
|
loader=FileSystemLoader(str(src.parent)),
|
||||||
|
undefined=StrictUndefined,
|
||||||
|
keep_trailing_newline=True,
|
||||||
|
autoescape=False, # nosec B701 — rendering systemd INI, not HTML
|
||||||
|
)
|
||||||
|
template = env.get_template(src.name)
|
||||||
|
return template.render(**context)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_rendered_if_changed(
|
||||||
|
src: Path, dst: Path, rendered: str, *, mode: int, force: bool, dry_run: bool
|
||||||
) -> str:
|
) -> str:
|
||||||
sources = sorted(deploy.glob("decnet-*.service")) + [deploy / "decnet.target"]
|
"""Write *rendered* content to *dst* only if it differs from what's there.
|
||||||
|
|
||||||
|
SHA compares rendered-output ↔ on-disk bytes (NOT source-template ↔
|
||||||
|
on-disk) so operators who customise their install_dir get idempotent
|
||||||
|
re-runs instead of every ``decnet init`` rewriting files.
|
||||||
|
"""
|
||||||
|
rendered_bytes = rendered.encode("utf-8")
|
||||||
|
if dst.exists() and not force:
|
||||||
|
if hashlib.sha256(dst.read_bytes()).hexdigest() == hashlib.sha256(rendered_bytes).hexdigest():
|
||||||
|
return f"skip: {dst} up to date"
|
||||||
|
if dry_run:
|
||||||
|
console.print(f" [dim]would render:[/] {src} -> {dst} (mode={oct(mode)})")
|
||||||
|
return "ok"
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dst.write_bytes(rendered_bytes)
|
||||||
|
try:
|
||||||
|
os.chmod(dst, mode)
|
||||||
|
os.chown(dst, 0, 0)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _install_units(
|
||||||
|
deploy: Path, systemd_dir: Path, *, install_dir: str, force: bool, dry_run: bool
|
||||||
|
) -> str:
|
||||||
|
"""Render decnet-*.service.j2 → systemd_dir/decnet-*.service, and copy
|
||||||
|
the static decnet.target (no templating needed — it has no install
|
||||||
|
path references)."""
|
||||||
|
context = {"install_dir": install_dir}
|
||||||
|
templates = sorted(deploy.glob("decnet-*.service.j2"))
|
||||||
|
static = [deploy / "decnet.target"]
|
||||||
|
|
||||||
touched = 0
|
touched = 0
|
||||||
for src in sources:
|
for src in templates:
|
||||||
|
rendered = _render_template(src, context)
|
||||||
|
# decnet-api.service.j2 → decnet-api.service
|
||||||
|
dst_name = src.name[: -len(".j2")]
|
||||||
|
result = _write_rendered_if_changed(
|
||||||
|
src, systemd_dir / dst_name, rendered,
|
||||||
|
mode=0o644, force=force, dry_run=dry_run,
|
||||||
|
)
|
||||||
|
if not result.startswith("skip:"):
|
||||||
|
touched += 1
|
||||||
|
for src in static:
|
||||||
result = _copy_if_changed(
|
result = _copy_if_changed(
|
||||||
src, systemd_dir / src.name,
|
src, systemd_dir / src.name,
|
||||||
mode=0o644, force=force, dry_run=dry_run,
|
mode=0o644, force=force, dry_run=dry_run,
|
||||||
)
|
)
|
||||||
if not result.startswith("skip:"):
|
if not result.startswith("skip:"):
|
||||||
touched += 1
|
touched += 1
|
||||||
total = len(sources)
|
total = len(templates) + len(static)
|
||||||
if touched == 0:
|
if touched == 0:
|
||||||
return f"skip: {total} unit files up to date"
|
return f"skip: {total} unit files up to date"
|
||||||
return f"ok ({touched}/{total} installed)"
|
return f"ok ({touched}/{total} installed)"
|
||||||
@@ -335,6 +395,14 @@ def register(app: typer.Typer) -> None:
|
|||||||
"decnet", "--group",
|
"decnet", "--group",
|
||||||
help="Primary group of the DECNET user.",
|
help="Primary group of the DECNET user.",
|
||||||
),
|
),
|
||||||
|
install_dir: str = typer.Option(
|
||||||
|
"/opt/decnet", "--install-dir",
|
||||||
|
help="Absolute path where DECNET is installed. Default "
|
||||||
|
"/opt/decnet; distros that reserve /opt can point this "
|
||||||
|
"at /srv/decnet, /usr/local/decnet, etc. Gets rendered "
|
||||||
|
"into every systemd unit via Jinja2 and used as the "
|
||||||
|
"decnet user's home directory.",
|
||||||
|
),
|
||||||
prefix: str = typer.Option(
|
prefix: str = typer.Option(
|
||||||
"", "--prefix", hidden=True,
|
"", "--prefix", hidden=True,
|
||||||
help="Filesystem prefix for tests (e.g. tmp_path). Empty = real root.",
|
help="Filesystem prefix for tests (e.g. tmp_path). Empty = real root.",
|
||||||
@@ -358,6 +426,15 @@ def register(app: typer.Typer) -> None:
|
|||||||
console.print(f"[red]decnet {verb}: must run as root (use sudo)[/]")
|
console.print(f"[red]decnet {verb}: must run as root (use sudo)[/]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if not install_dir.startswith("/"):
|
||||||
|
console.print(
|
||||||
|
f"[red]decnet init: --install-dir must be absolute, got {install_dir!r}[/]"
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
# Strip leading slash so pfx-joining works under --prefix test mode
|
||||||
|
# (Path("/"). / "/opt/decnet" == Path("/opt/decnet"), dropping pfx).
|
||||||
|
_install_rel = install_dir.lstrip("/")
|
||||||
|
|
||||||
required_tools = ("systemctl",) if deinit else (
|
required_tools = ("systemctl",) if deinit else (
|
||||||
"systemctl", "useradd", "groupadd", "systemd-tmpfiles",
|
"systemctl", "useradd", "groupadd", "systemd-tmpfiles",
|
||||||
)
|
)
|
||||||
@@ -424,9 +501,9 @@ def register(app: typer.Typer) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
_step(
|
_step(
|
||||||
f"remove {pfx / 'opt/decnet'}",
|
f"remove {pfx / _install_rel}",
|
||||||
lambda: _remove_dir_if_present(
|
lambda: _remove_dir_if_present(
|
||||||
pfx / "opt/decnet", dry_run=dry_run,
|
pfx / _install_rel, dry_run=dry_run,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if purge:
|
if purge:
|
||||||
@@ -468,7 +545,7 @@ def register(app: typer.Typer) -> None:
|
|||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
|
|
||||||
dirs = [
|
dirs = [
|
||||||
(pfx / "opt/decnet", 0o755, user, group),
|
(pfx / _install_rel, 0o755, user, group),
|
||||||
(pfx / "var/lib/decnet", 0o750, user, group),
|
(pfx / "var/lib/decnet", 0o750, user, group),
|
||||||
(pfx / "var/log/decnet", 0o750, user, group),
|
(pfx / "var/log/decnet", 0o750, user, group),
|
||||||
(etc_decnet, 0o755, "root", group),
|
(etc_decnet, 0o755, "root", group),
|
||||||
@@ -486,7 +563,7 @@ def register(app: typer.Typer) -> None:
|
|||||||
)
|
)
|
||||||
_step(
|
_step(
|
||||||
f"ensure user {user!r}",
|
f"ensure user {user!r}",
|
||||||
lambda: _ensure_user(user, group, dry_run=dry_run),
|
lambda: _ensure_user(user, group, install_dir, dry_run=dry_run),
|
||||||
)
|
)
|
||||||
for path, mode, d_owner, d_group in dirs:
|
for path, mode, d_owner, d_group in dirs:
|
||||||
_step(
|
_step(
|
||||||
@@ -501,7 +578,8 @@ def register(app: typer.Typer) -> None:
|
|||||||
_step(
|
_step(
|
||||||
"install systemd units",
|
"install systemd units",
|
||||||
lambda: _install_units(
|
lambda: _install_units(
|
||||||
deploy, systemd_dir, force=force, dry_run=dry_run,
|
deploy, systemd_dir,
|
||||||
|
install_dir=install_dir, force=force, dry_run=dry_run,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_step(
|
_step(
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ User=decnet
|
|||||||
Group=decnet
|
Group=decnet
|
||||||
# docker.sock is group-readable by 'docker'; the agent needs it for compose.
|
# docker.sock is group-readable by 'docker'; the agent needs it for compose.
|
||||||
SupplementaryGroups=docker
|
SupplementaryGroups=docker
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet agent --host 0.0.0.0 --port 8765 --agent-dir /etc/decnet/agent
|
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
|
# MACVLAN/IPVLAN management + scapy raw sockets. Granted via ambient caps so
|
||||||
# the process starts unprivileged and keeps only these two bits.
|
# the process starts unprivileged and keeps only these two bits.
|
||||||
@@ -30,8 +30,8 @@ ProtectKernelModules=yes
|
|||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
# /opt/decnet holds release slots + state; the agent reads them and writes its PID.
|
# {{ install_dir }} holds release slots + state; the agent reads them and writes its PID.
|
||||||
ReadWritePaths=/opt/decnet /var/log/decnet
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -11,9 +11,9 @@ User=decnet
|
|||||||
Group=decnet
|
Group=decnet
|
||||||
# docker.sock is group-readable by 'docker'; the API ingester tails container logs.
|
# docker.sock is group-readable by 'docker'; the API ingester tails container logs.
|
||||||
SupplementaryGroups=docker
|
SupplementaryGroups=docker
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet api
|
ExecStart={{ install_dir }}/venv/bin/decnet api
|
||||||
|
|
||||||
# MACVLAN/IPVLAN setup runs from the API lifespan when the embedded sniffer is on.
|
# MACVLAN/IPVLAN setup runs from the API lifespan when the embedded sniffer is on.
|
||||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
|
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
|
||||||
@@ -29,7 +29,7 @@ ProtectKernelModules=yes
|
|||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
ReadWritePaths=/opt/decnet /var/log/decnet
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -8,15 +8,15 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=decnet
|
User=decnet
|
||||||
Group=decnet
|
Group=decnet
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
# /run/decnet is created automatically with the RuntimeDirectory= directive
|
# /run/decnet is created automatically with the RuntimeDirectory= directive
|
||||||
# below (mode 0755, owned by User/Group) and cleaned up on stop. The bus
|
# 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
|
# socket is placed inside it with 0660 perms so only the decnet group can
|
||||||
# connect.
|
# connect.
|
||||||
RuntimeDirectory=decnet
|
RuntimeDirectory=decnet
|
||||||
RuntimeDirectoryMode=0755
|
RuntimeDirectoryMode=0755
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet bus \
|
ExecStart={{ install_dir }}/venv/bin/decnet bus \
|
||||||
--socket /run/decnet/bus.sock \
|
--socket /run/decnet/bus.sock \
|
||||||
--group decnet
|
--group decnet
|
||||||
|
|
||||||
@@ -11,9 +11,9 @@ User=decnet
|
|||||||
Group=decnet
|
Group=decnet
|
||||||
# docker.sock is group-readable by 'docker'; the collector tails container logs.
|
# docker.sock is group-readable by 'docker'; the collector tails container logs.
|
||||||
SupplementaryGroups=docker
|
SupplementaryGroups=docker
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet collect
|
ExecStart={{ install_dir }}/venv/bin/decnet collect
|
||||||
|
|
||||||
# No privileged network operations.
|
# No privileged network operations.
|
||||||
CapabilityBoundingSet=
|
CapabilityBoundingSet=
|
||||||
@@ -29,7 +29,7 @@ ProtectKernelModules=yes
|
|||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
ReadWritePaths=/opt/decnet /var/log/decnet
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -10,12 +10,12 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=decnet
|
User=decnet
|
||||||
Group=decnet
|
Group=decnet
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
# Replace <master-host> with the master's LAN address or hostname. The agent
|
# 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
|
# cert bundle at /etc/decnet/agent is reused — the forwarder presents the same
|
||||||
# worker identity when it connects to the master's listener.
|
# 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 \
|
--log-file /var/log/decnet/decnet.log \
|
||||||
--master-host ${DECNET_SWARM_MASTER_HOST} \
|
--master-host ${DECNET_SWARM_MASTER_HOST} \
|
||||||
--master-port 6514 \
|
--master-port 6514 \
|
||||||
@@ -8,11 +8,11 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=decnet
|
User=decnet
|
||||||
Group=decnet
|
Group=decnet
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
# Binds 0.0.0.0:6514 so workers across the LAN can connect. 6514 is not a
|
# 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.
|
# 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 \
|
--host 0.0.0.0 --port 6514 \
|
||||||
--ca-dir /etc/decnet/ca \
|
--ca-dir /etc/decnet/ca \
|
||||||
--log-path /var/log/decnet/master.log \
|
--log-path /var/log/decnet/master.log \
|
||||||
@@ -11,9 +11,9 @@ User=decnet
|
|||||||
Group=decnet
|
Group=decnet
|
||||||
# Mutator recomposes decky services via docker compose.
|
# Mutator recomposes decky services via docker compose.
|
||||||
SupplementaryGroups=docker
|
SupplementaryGroups=docker
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet mutate --watch
|
ExecStart={{ install_dir }}/venv/bin/decnet mutate --watch
|
||||||
|
|
||||||
CapabilityBoundingSet=
|
CapabilityBoundingSet=
|
||||||
AmbientCapabilities=
|
AmbientCapabilities=
|
||||||
@@ -28,7 +28,7 @@ ProtectKernelModules=yes
|
|||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
ReadWritePaths=/opt/decnet /var/log/decnet
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -8,9 +8,9 @@ Wants=network-online.target decnet-bus.service
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=decnet
|
User=decnet
|
||||||
Group=decnet
|
Group=decnet
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet probe
|
ExecStart={{ install_dir }}/venv/bin/decnet probe
|
||||||
|
|
||||||
# TCP connect probes only — no raw sockets required.
|
# TCP connect probes only — no raw sockets required.
|
||||||
CapabilityBoundingSet=
|
CapabilityBoundingSet=
|
||||||
@@ -26,7 +26,7 @@ ProtectKernelModules=yes
|
|||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
ReadWritePaths=/opt/decnet /var/log/decnet
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -8,9 +8,9 @@ Wants=network-online.target decnet-bus.service
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=decnet
|
User=decnet
|
||||||
Group=decnet
|
Group=decnet
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet profiler
|
ExecStart={{ install_dir }}/venv/bin/decnet profiler
|
||||||
|
|
||||||
CapabilityBoundingSet=
|
CapabilityBoundingSet=
|
||||||
AmbientCapabilities=
|
AmbientCapabilities=
|
||||||
@@ -25,7 +25,7 @@ ProtectKernelModules=yes
|
|||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
ReadWritePaths=/opt/decnet /var/log/decnet
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -8,9 +8,9 @@ Wants=network-online.target decnet-bus.service
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=decnet
|
User=decnet
|
||||||
Group=decnet
|
Group=decnet
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet sniffer
|
ExecStart={{ install_dir }}/venv/bin/decnet sniffer
|
||||||
|
|
||||||
# scapy needs raw packet access on the MACVLAN host interface.
|
# scapy needs raw packet access on the MACVLAN host interface.
|
||||||
CapabilityBoundingSet=CAP_NET_RAW
|
CapabilityBoundingSet=CAP_NET_RAW
|
||||||
@@ -26,7 +26,7 @@ ProtectKernelModules=yes
|
|||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
ReadWritePaths=/opt/decnet /var/log/decnet
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -8,11 +8,11 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=decnet
|
User=decnet
|
||||||
Group=decnet
|
Group=decnet
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
# Default bind is loopback — the controller is a master-local orchestrator
|
# Default bind is loopback — the controller is a master-local orchestrator
|
||||||
# reached by the CLI and the web dashboard, not by workers.
|
# 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
|
# No special capabilities — the controller issues mTLS certs and talks to
|
||||||
# workers over TCP on unprivileged ports.
|
# workers over TCP on unprivileged ports.
|
||||||
@@ -30,7 +30,7 @@ ProtectControlGroups=yes
|
|||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
# Reads/writes the CA bundle and the master DB.
|
# Reads/writes the CA bundle and the master DB.
|
||||||
ReadWritePaths=/opt/decnet /var/log/decnet
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
ReadOnlyPaths=/etc/decnet
|
ReadOnlyPaths=/etc/decnet
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
@@ -10,12 +10,12 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=decnet
|
User=decnet
|
||||||
Group=decnet
|
Group=decnet
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet updater \
|
ExecStart={{ install_dir }}/venv/bin/decnet updater \
|
||||||
--host 0.0.0.0 --port 8766 \
|
--host 0.0.0.0 --port 8766 \
|
||||||
--updater-dir /etc/decnet/updater \
|
--updater-dir /etc/decnet/updater \
|
||||||
--install-dir /opt/decnet \
|
--install-dir {{ install_dir }} \
|
||||||
--agent-dir /etc/decnet/agent
|
--agent-dir /etc/decnet/agent
|
||||||
|
|
||||||
# The updater SIGTERMs the agent and spawns a new one. Same User=decnet means
|
# The updater SIGTERMs the agent and spawns a new one. Same User=decnet means
|
||||||
@@ -37,7 +37,7 @@ ProtectControlGroups=yes
|
|||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
# Writes release slots, pip installs into venv, manages agent.pid.
|
# 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
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -8,9 +8,9 @@ Wants=network-online.target
|
|||||||
Type=simple
|
Type=simple
|
||||||
User=decnet
|
User=decnet
|
||||||
Group=decnet
|
Group=decnet
|
||||||
WorkingDirectory=/opt/decnet
|
WorkingDirectory={{ install_dir }}
|
||||||
EnvironmentFile=-/opt/decnet/.env.local
|
EnvironmentFile=-{{ install_dir }}/.env.local
|
||||||
ExecStart=/opt/decnet/venv/bin/decnet web
|
ExecStart={{ install_dir }}/venv/bin/decnet web
|
||||||
|
|
||||||
# Uncomment if you bind the dashboard to a privileged port (80/443):
|
# Uncomment if you bind the dashboard to a privileged port (80/443):
|
||||||
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
@@ -28,7 +28,7 @@ ProtectKernelModules=yes
|
|||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
RestrictSUIDSGID=yes
|
RestrictSUIDSGID=yes
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
ReadWritePaths=/opt/decnet /var/log/decnet
|
ReadWritePaths={{ install_dir }} /var/log/decnet
|
||||||
ReadOnlyPaths=/etc/decnet
|
ReadOnlyPaths=/etc/decnet
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
@@ -60,12 +60,22 @@ def missing_user_and_group(monkeypatch: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _seed_deploy(monkeypatch: Any, tmp_path: Path) -> Path:
|
def _seed_deploy(monkeypatch: Any, tmp_path: Path) -> Path:
|
||||||
"""Point `_deploy_root()` at a faked deploy tree under tmp_path."""
|
"""Point `_deploy_root()` at a faked deploy tree under tmp_path.
|
||||||
|
|
||||||
|
Services are Jinja2 templates keyed on ``{{ install_dir }}`` —
|
||||||
|
matching production layout since the refactor that made install
|
||||||
|
path configurable.
|
||||||
|
"""
|
||||||
deploy = tmp_path / "deploy"
|
deploy = tmp_path / "deploy"
|
||||||
(deploy / "polkit").mkdir(parents=True)
|
(deploy / "polkit").mkdir(parents=True)
|
||||||
(deploy / "tmpfiles.d").mkdir()
|
(deploy / "tmpfiles.d").mkdir()
|
||||||
(deploy / "decnet-bus.service").write_text("# bus unit\n")
|
(deploy / "decnet-bus.service.j2").write_text(
|
||||||
(deploy / "decnet-api.service").write_text("# api unit\n")
|
"[Service]\nExecStart={{ install_dir }}/venv/bin/decnet bus\n"
|
||||||
|
)
|
||||||
|
(deploy / "decnet-api.service.j2").write_text(
|
||||||
|
"[Service]\nWorkingDirectory={{ install_dir }}\n"
|
||||||
|
"ExecStart={{ install_dir }}/venv/bin/decnet api\n"
|
||||||
|
)
|
||||||
(deploy / "decnet.target").write_text("# target\n")
|
(deploy / "decnet.target").write_text("# target\n")
|
||||||
(deploy / "polkit" / "50-decnet-workers.rules").write_text("// rule\n")
|
(deploy / "polkit" / "50-decnet-workers.rules").write_text("// rule\n")
|
||||||
(deploy / "tmpfiles.d" / "decnet.conf").write_text("d /run/decnet\n")
|
(deploy / "tmpfiles.d" / "decnet.conf").write_text("d /run/decnet\n")
|
||||||
@@ -156,6 +166,105 @@ def test_unit_files_are_installed_then_idempotent(
|
|||||||
assert "unit files up to date" in r2.output
|
assert "unit files up to date" in r2.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_dir_renders_into_service_units(
|
||||||
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||||
|
no_missing_tools: None, missing_user_and_group: None,
|
||||||
|
) -> None:
|
||||||
|
"""`--install-dir /srv/decnet` must land in the rendered service
|
||||||
|
files. Regression guard for the Jinja2 templating refactor."""
|
||||||
|
_seed_deploy(monkeypatch, tmp_path)
|
||||||
|
prefix = tmp_path / "root"
|
||||||
|
r = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"init", "--no-start",
|
||||||
|
"--prefix", str(prefix),
|
||||||
|
"--install-dir", "/srv/decnet",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
|
||||||
|
api_unit = prefix / "etc/systemd/system" / "decnet-api.service"
|
||||||
|
bus_unit = prefix / "etc/systemd/system" / "decnet-bus.service"
|
||||||
|
assert api_unit.is_file()
|
||||||
|
api_text = api_unit.read_text()
|
||||||
|
assert "/srv/decnet" in api_text
|
||||||
|
assert "/opt/decnet" not in api_text
|
||||||
|
assert "{{" not in api_text, "unrendered Jinja tag leaked through"
|
||||||
|
assert "/srv/decnet" in bus_unit.read_text()
|
||||||
|
|
||||||
|
# useradd --home-dir must match the install_dir override too.
|
||||||
|
useradds = [c for c in subprocess_calls if c and c[0] == "useradd"]
|
||||||
|
assert useradds, "expected useradd call"
|
||||||
|
assert "/srv/decnet" in useradds[0]
|
||||||
|
assert "/opt/decnet" not in useradds[0]
|
||||||
|
|
||||||
|
# And /srv/decnet on disk should be the dir we created.
|
||||||
|
assert (prefix / "srv/decnet").is_dir()
|
||||||
|
assert not (prefix / "opt/decnet").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_dir_defaults_to_opt(
|
||||||
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||||
|
no_missing_tools: None, present_user_and_group: None,
|
||||||
|
) -> None:
|
||||||
|
"""Default --install-dir is /opt/decnet — existing installs remain
|
||||||
|
byte-identical with no explicit flag."""
|
||||||
|
_seed_deploy(monkeypatch, tmp_path)
|
||||||
|
prefix = tmp_path / "root"
|
||||||
|
r = runner.invoke(app, ["init", "--no-start", "--prefix", str(prefix)])
|
||||||
|
assert r.exit_code == 0, r.output
|
||||||
|
api_unit = prefix / "etc/systemd/system" / "decnet-api.service"
|
||||||
|
assert "/opt/decnet" in api_unit.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_dir_rejects_relative_path(
|
||||||
|
monkeypatch: Any, tmp_path: Path,
|
||||||
|
no_missing_tools: None, missing_user_and_group: None,
|
||||||
|
) -> None:
|
||||||
|
"""Relative install_dir would break every absolute path in a
|
||||||
|
rendered service. Reject at the CLI boundary with a clear message."""
|
||||||
|
_seed_deploy(monkeypatch, tmp_path)
|
||||||
|
r = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"init", "--no-start",
|
||||||
|
"--prefix", str(tmp_path / "root"),
|
||||||
|
"--install-dir", "relative/path",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert r.exit_code == 1
|
||||||
|
assert "must be absolute" in r.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_dir_custom_idempotent_second_run(
|
||||||
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||||
|
no_missing_tools: None, present_user_and_group: None,
|
||||||
|
) -> None:
|
||||||
|
"""Rendering the same templates twice with the same context must
|
||||||
|
produce byte-identical output — second run SKIPs, no churn."""
|
||||||
|
_seed_deploy(monkeypatch, tmp_path)
|
||||||
|
prefix = tmp_path / "root"
|
||||||
|
runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"init", "--no-start",
|
||||||
|
"--prefix", str(prefix),
|
||||||
|
"--install-dir", "/srv/decnet",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
r2 = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"init", "--no-start",
|
||||||
|
"--prefix", str(prefix),
|
||||||
|
"--install-dir", "/srv/decnet",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert r2.exit_code == 0, r2.output
|
||||||
|
assert "unit files up to date" in r2.output
|
||||||
|
|
||||||
|
|
||||||
def test_force_overwrites_existing_units(
|
def test_force_overwrites_existing_units(
|
||||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||||
no_missing_tools: None, present_user_and_group: None,
|
no_missing_tools: None, present_user_and_group: None,
|
||||||
|
|||||||
Reference in New Issue
Block a user