diff --git a/decnet/cli/init.py b/decnet/cli/init.py index 66fc7795..d9604a31 100644 --- a/decnet/cli/init.py +++ b/decnet/cli/init.py @@ -24,6 +24,7 @@ from pathlib import Path from typing import Callable, List import typer +from jinja2 import Environment, FileSystemLoader, StrictUndefined import decnet as _decnet_pkg from .gating import _require_master_mode @@ -99,7 +100,7 @@ def _ensure_group(group: str, *, dry_run: bool) -> str: 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: pwd.getpwnam(user) return f"skip: user {user} already exists" @@ -108,7 +109,7 @@ def _ensure_user(user: str, group: str, *, dry_run: bool) -> str: [ "useradd", "--system", "--gid", group, - "--home-dir", "/opt/decnet", + "--home-dir", install_dir, "--shell", "/usr/sbin/nologin", "--comment", "DECNET honeypot", user, @@ -177,19 +178,78 @@ def _copy_if_changed( return "ok" -def _install_units( - deploy: Path, systemd_dir: Path, *, force: bool, dry_run: bool +def _render_template(src: Path, context: dict[str, str]) -> str: + """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: - 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 - 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( src, systemd_dir / src.name, mode=0o644, force=force, dry_run=dry_run, ) if not result.startswith("skip:"): touched += 1 - total = len(sources) + total = len(templates) + len(static) if touched == 0: return f"skip: {total} unit files up to date" return f"ok ({touched}/{total} installed)" @@ -335,6 +395,14 @@ def register(app: typer.Typer) -> None: "decnet", "--group", 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", hidden=True, 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)[/]") 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 ( "systemctl", "useradd", "groupadd", "systemd-tmpfiles", ) @@ -424,9 +501,9 @@ def register(app: typer.Typer) -> None: ), ) _step( - f"remove {pfx / 'opt/decnet'}", + f"remove {pfx / _install_rel}", lambda: _remove_dir_if_present( - pfx / "opt/decnet", dry_run=dry_run, + pfx / _install_rel, dry_run=dry_run, ), ) if purge: @@ -468,7 +545,7 @@ def register(app: typer.Typer) -> None: raise typer.Exit(1) from exc dirs = [ - (pfx / "opt/decnet", 0o755, user, group), + (pfx / _install_rel, 0o755, user, group), (pfx / "var/lib/decnet", 0o750, user, group), (pfx / "var/log/decnet", 0o750, user, group), (etc_decnet, 0o755, "root", group), @@ -486,7 +563,7 @@ def register(app: typer.Typer) -> None: ) _step( 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: _step( @@ -501,7 +578,8 @@ def register(app: typer.Typer) -> None: _step( "install systemd 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( diff --git a/deploy/decnet-agent.service b/deploy/decnet-agent.service.j2 similarity index 73% rename from deploy/decnet-agent.service rename to deploy/decnet-agent.service.j2 index 20e152f2..8335d38e 100644 --- a/deploy/decnet-agent.service +++ b/deploy/decnet-agent.service.j2 @@ -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 diff --git a/deploy/decnet-api.service b/deploy/decnet-api.service.j2 similarity index 82% rename from deploy/decnet-api.service rename to deploy/decnet-api.service.j2 index a8e6dfab..4b324ed6 100644 --- a/deploy/decnet-api.service +++ b/deploy/decnet-api.service.j2 @@ -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 diff --git a/deploy/decnet-bus.service b/deploy/decnet-bus.service.j2 similarity index 88% rename from deploy/decnet-bus.service rename to deploy/decnet-bus.service.j2 index a324f6d6..9222eda1 100644 --- a/deploy/decnet-bus.service +++ b/deploy/decnet-bus.service.j2 @@ -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 diff --git a/deploy/decnet-collector.service b/deploy/decnet-collector.service.j2 similarity index 81% rename from deploy/decnet-collector.service rename to deploy/decnet-collector.service.j2 index 4f2a47b9..ef59bea2 100644 --- a/deploy/decnet-collector.service +++ b/deploy/decnet-collector.service.j2 @@ -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 diff --git a/deploy/decnet-forwarder.service b/deploy/decnet-forwarder.service.j2 similarity index 90% rename from deploy/decnet-forwarder.service rename to deploy/decnet-forwarder.service.j2 index 2537eb44..2d4fd29a 100644 --- a/deploy/decnet-forwarder.service +++ b/deploy/decnet-forwarder.service.j2 @@ -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 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 \ diff --git a/deploy/decnet-listener.service b/deploy/decnet-listener.service.j2 similarity index 88% rename from deploy/decnet-listener.service rename to deploy/decnet-listener.service.j2 index add9fc5e..ff615390 100644 --- a/deploy/decnet-listener.service +++ b/deploy/decnet-listener.service.j2 @@ -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 \ diff --git a/deploy/decnet-mutator.service b/deploy/decnet-mutator.service.j2 similarity index 80% rename from deploy/decnet-mutator.service rename to deploy/decnet-mutator.service.j2 index b2c872a4..b4227ddb 100644 --- a/deploy/decnet-mutator.service +++ b/deploy/decnet-mutator.service.j2 @@ -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 diff --git a/deploy/decnet-prober.service b/deploy/decnet-prober.service.j2 similarity index 79% rename from deploy/decnet-prober.service rename to deploy/decnet-prober.service.j2 index a862730e..cbeab44a 100644 --- a/deploy/decnet-prober.service +++ b/deploy/decnet-prober.service.j2 @@ -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 diff --git a/deploy/decnet-profiler.service b/deploy/decnet-profiler.service.j2 similarity index 77% rename from deploy/decnet-profiler.service rename to deploy/decnet-profiler.service.j2 index 4ff9ee82..d4a7ddba 100644 --- a/deploy/decnet-profiler.service +++ b/deploy/decnet-profiler.service.j2 @@ -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 diff --git a/deploy/decnet-sniffer.service b/deploy/decnet-sniffer.service.j2 similarity index 79% rename from deploy/decnet-sniffer.service rename to deploy/decnet-sniffer.service.j2 index 7320cf0d..796863fe 100644 --- a/deploy/decnet-sniffer.service +++ b/deploy/decnet-sniffer.service.j2 @@ -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 diff --git a/deploy/decnet-swarmctl.service b/deploy/decnet-swarmctl.service.j2 similarity index 81% rename from deploy/decnet-swarmctl.service rename to deploy/decnet-swarmctl.service.j2 index 64455281..dcfdd259 100644 --- a/deploy/decnet-swarmctl.service +++ b/deploy/decnet-swarmctl.service.j2 @@ -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 diff --git a/deploy/decnet-updater.service b/deploy/decnet-updater.service.j2 similarity index 86% rename from deploy/decnet-updater.service rename to deploy/decnet-updater.service.j2 index db2b8dd3..1221bef6 100644 --- a/deploy/decnet-updater.service +++ b/deploy/decnet-updater.service.j2 @@ -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 diff --git a/deploy/decnet-web.service b/deploy/decnet-web.service.j2 similarity index 80% rename from deploy/decnet-web.service rename to deploy/decnet-web.service.j2 index eb119eff..313d9e25 100644 --- a/deploy/decnet-web.service +++ b/deploy/decnet-web.service.j2 @@ -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 diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index 3f2fdb98..a1a94638 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -60,12 +60,22 @@ def missing_user_and_group(monkeypatch: Any) -> None: 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 / "polkit").mkdir(parents=True) (deploy / "tmpfiles.d").mkdir() - (deploy / "decnet-bus.service").write_text("# bus unit\n") - (deploy / "decnet-api.service").write_text("# api unit\n") + (deploy / "decnet-bus.service.j2").write_text( + "[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 / "polkit" / "50-decnet-workers.rules").write_text("// rule\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 +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( monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]], no_missing_tools: None, present_user_and_group: None,