From 39a298f6857bf150e1ec0c60fc94ac5527d3f24d Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 2 May 2026 19:33:53 -0400 Subject: [PATCH] feat(init): persist DECNET-service api-user/api-group to decnet.ini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEBT-035 step 1. The composer needs to know which uid/gid to inject into each compose fragment's `user:` directive at deploy time. Today the resolved `--user` / `--group` values reach systemd unit rendering (init.py:349–354) but are not persisted anywhere the composer can read them. Persist as **names** (not numeric ids) under `[decnet] api-user` / `api-group` in the rendered decnet.ini placeholder. Resolution to uid/gid happens at deploy time on whichever host runs the deploy, via `pwd.getpwnam(...)` / `grp.getgrnam(...)` — so the same user name can have different uids on master vs agents (heterogeneous /etc/passwd) without breaking artifact ownership. The existing config_ini auto-translates kebab→DECNET_API_USER / DECNET_API_GROUP at load time; no domain-map changes needed. Two new tests: one asserting the rendered ini carries the `api-user` / `api-group` keys for the values passed to `--user` / `--group`; one round-tripping through `load_ini_config` to confirm the env vars land in `os.environ` for the composer to pick up. --- decnet/cli/init.py | 18 ++++++++++++--- tests/cli/test_init.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) 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,