diff --git a/decnet/cli/init.py b/decnet/cli/init.py index f1fc0f5a..344dda13 100644 --- a/decnet/cli/init.py +++ b/decnet/cli/init.py @@ -44,6 +44,12 @@ _CONFIG_PLACEHOLDER = """\ # EnvironmentFile= — never in a group-readable INI. [decnet] +# DECNET-service user/group as configured at `decnet init` time. +# Resolved to a uid/gid on each host at deploy time via pwd.getpwnam, +# so the same user name can have different numeric uids on master vs +# agents without breaking artifact ownership. +api-user = {api_user} +api-group = {api_group} # mode = master # or "agent" # [api] @@ -198,14 +204,17 @@ def _ensure_dir( return f"skip: {path} already present" if existed else "ok" -def _ensure_config(path: Path, group: str, *, dry_run: bool) -> str: +def _ensure_config( + path: Path, group: str, *, user: str, dry_run: bool, +) -> str: if path.exists(): return f"skip: {path} already present" if dry_run: console.print(f" [dim]would write:[/] {path}") return "ok" path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(_CONFIG_PLACEHOLDER) + rendered = _CONFIG_PLACEHOLDER.format(api_user=user, api_group=group) + path.write_text(rendered) try: os.chmod(path, 0o640) gid = grp.getgrnam(group).gr_gid @@ -781,7 +790,10 @@ def register(app: typer.Typer) -> None: ) _step( f"write {etc_decnet / 'decnet.ini'}", - lambda: _ensure_config(etc_decnet / "decnet.ini", group, dry_run=dry_run), + lambda: _ensure_config( + etc_decnet / "decnet.ini", group, + user=user, dry_run=dry_run, + ), ) _step( "install systemd units", diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index f3f3803e..63fdacaf 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -7,6 +7,7 @@ into a pytest ``tmp_path``. """ from __future__ import annotations +import os from pathlib import Path from typing import Any, List @@ -217,6 +218,55 @@ def test_init_writes_decnet_ini_not_config_ini( assert header in body, f"placeholder missing {header} example" +def test_init_persists_api_user_group_to_decnet_ini( + monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]], + no_missing_tools: None, missing_user_and_group: None, +) -> None: + """DEBT-035: `decnet init --user X --group Y` must persist the + DECNET-service user/group **as names** to decnet.ini under + `[decnet] api-user` / `api-group`. Resolution to numeric uid/gid + happens at deploy time on whichever host runs the deploy — names + survive across master/agent uid namespaces while raw numbers + would mismatch.""" + _seed_deploy(monkeypatch, tmp_path) + prefix = tmp_path / "root" + r = runner.invoke(app, [ + "init", "--no-start", "--prefix", str(prefix), + "--user", "decoyman", "--group", "decoygrp", + ]) + assert r.exit_code == 0, r.output + + body = (prefix / "etc/decnet/decnet.ini").read_text() + assert "api-user = decoyman" in body + assert "api-group = decoygrp" in body + + +def test_init_decnet_ini_loads_via_config_ini( + monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]], + no_missing_tools: None, missing_user_and_group: None, +) -> None: + """Defence-in-depth: round-trip the rendered ini through + `decnet.config_ini.load_ini_config` and confirm that + DECNET_API_USER / DECNET_API_GROUP appear in os.environ. The + composer reads those env vars at deploy time.""" + from decnet.config_ini import load_ini_config + + _seed_deploy(monkeypatch, tmp_path) + prefix = tmp_path / "root" + r = runner.invoke(app, [ + "init", "--no-start", "--prefix", str(prefix), + "--user", "deckyowner", "--group", "deckygroup", + ]) + assert r.exit_code == 0, r.output + + ini = prefix / "etc/decnet/decnet.ini" + monkeypatch.delenv("DECNET_API_USER", raising=False) + monkeypatch.delenv("DECNET_API_GROUP", raising=False) + load_ini_config(ini) + assert os.environ.get("DECNET_API_USER") == "deckyowner" + assert os.environ.get("DECNET_API_GROUP") == "deckygroup" + + 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,