The config file `decnet init` dropped at /etc/decnet/config.ini was a stub with a single [decnet] header saying 'reserved for future structured settings.' Admins who wanted to tune DECNET_API_HOST, DECNET_DB_URL, DECNET_BATCH_SIZE, etc. had to hunt env.py for the exact variable name and drop it in .env.local. Changes: - decnet/config_ini.py — adds a _DOMAIN_MAP translation table covering [api], [web], [database], [bus], [swarm], [logging], [ingester], [tracing]. Loads regardless of mode; unknown keys inside a known section log a WARNING (operator typos shouldn't be silent). Explicit key map (not auto kebab-to-snake) so [web] admin-user lands in DECNET_ADMIN_USER without silently renaming the env-var contract consumers import from decnet.env. - decnet/cli/init.py — renames the placeholder target config.ini → decnet.ini (unifies with the name already used by load_ini_config and the enroll bundle's _render_decnet_ini). Placeholder body now shows every domain section as a commented example so admins learn the shape by reading. Deinit removes both decnet.ini and the legacy config.ini so upgrading hosts leave no orphan file. Precedence is unchanged: real env > INI > built-in default in env.py. os.environ.setdefault means systemd EnvironmentFile= and one-off DECNET_FOO=bar decnet ... invocations always win. Secrets explicitly NOT moved to the INI: - DECNET_JWT_SECRET - DECNET_ADMIN_PASSWORD - DECNET_DB_PASSWORD They stay in .env.local / EnvironmentFile= — never in a group-readable INI, never in a diff, never on the dashboard. Dev/profiling flags (DECNET_DEVELOPER, DECNET_EMBED_*, DECNET_PROFILE_*) also stay env-only per maintainer direction — dev knobs shouldn't be one 'I'll flip this for tonight' away. Tests: +5 in test_config_ini.py (domain sections load regardless of mode, env beats INI for domain keys, unknown key warns, absent section is no-op, role section beats domain section via setdefault precedence). +1 in test_init.py (placeholder writes decnet.ini with every section header present as commented guidance). 31 tests pass across the two files (was 26).
462 lines
16 KiB
Python
462 lines
16 KiB
Python
"""Orchestration tests for ``decnet init``.
|
|
|
|
The command is a thin orchestrator over privileged system calls. We
|
|
exercise every branch by monkeypatching subprocess + identity lookups
|
|
and using the hidden ``--prefix`` option to redirect filesystem writes
|
|
into a pytest ``tmp_path``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, List
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from decnet.cli import app
|
|
from decnet.cli import init as _init
|
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def subprocess_calls(monkeypatch: Any) -> List[List[str]]:
|
|
calls: List[List[str]] = []
|
|
|
|
def _fake_run(argv: List[str], *a: Any, **kw: Any) -> Any:
|
|
calls.append(list(argv))
|
|
|
|
class _Ok:
|
|
returncode = 0
|
|
return _Ok()
|
|
|
|
monkeypatch.setattr(_init.subprocess, "run", _fake_run)
|
|
return calls
|
|
|
|
|
|
@pytest.fixture
|
|
def no_missing_tools(monkeypatch: Any) -> None:
|
|
monkeypatch.setattr(_init.shutil, "which", lambda _: "/usr/bin/fake")
|
|
|
|
|
|
@pytest.fixture
|
|
def present_user_and_group(monkeypatch: Any) -> None:
|
|
class _Stub:
|
|
pw_uid = 1000
|
|
gr_gid = 1000
|
|
|
|
monkeypatch.setattr(_init.pwd, "getpwnam", lambda _: _Stub())
|
|
monkeypatch.setattr(_init.grp, "getgrnam", lambda _: _Stub())
|
|
|
|
|
|
@pytest.fixture
|
|
def missing_user_and_group(monkeypatch: Any) -> None:
|
|
def _raise(_: str) -> None:
|
|
raise KeyError
|
|
|
|
monkeypatch.setattr(_init.pwd, "getpwnam", _raise)
|
|
monkeypatch.setattr(_init.grp, "getgrnam", _raise)
|
|
|
|
|
|
def _seed_deploy(monkeypatch: Any, tmp_path: Path) -> 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.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")
|
|
monkeypatch.setattr(_init, "_deploy_root", lambda: deploy)
|
|
return deploy
|
|
|
|
|
|
def test_non_root_exits_one(monkeypatch: Any) -> None:
|
|
monkeypatch.setattr(_init.os, "geteuid", lambda: 1000)
|
|
result = runner.invoke(app, ["init"])
|
|
assert result.exit_code == 1
|
|
assert "must run as root" in result.output
|
|
|
|
|
|
def test_dry_run_issues_no_subprocess_calls(
|
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, missing_user_and_group: None,
|
|
) -> None:
|
|
_seed_deploy(monkeypatch, tmp_path)
|
|
result = runner.invoke(
|
|
app,
|
|
["init", "--dry-run", "--prefix", str(tmp_path / "root")],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert subprocess_calls == [], (
|
|
f"dry-run must not exec anything, got {subprocess_calls}"
|
|
)
|
|
assert "would run:" in result.output
|
|
# No real files created either.
|
|
assert not (tmp_path / "root" / "etc/systemd/system").exists()
|
|
|
|
|
|
def test_missing_user_and_group_triggers_useradd_groupadd(
|
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, missing_user_and_group: None,
|
|
) -> None:
|
|
_seed_deploy(monkeypatch, tmp_path)
|
|
result = runner.invoke(
|
|
app,
|
|
["init", "--no-start", "--prefix", str(tmp_path / "root")],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
groupadds = [c for c in subprocess_calls if c[:1] == ["groupadd"]]
|
|
useradds = [c for c in subprocess_calls if c[:1] == ["useradd"]]
|
|
assert groupadds == [["groupadd", "--system", "decnet"]]
|
|
assert useradds and useradds[0][:6] == [
|
|
"useradd", "--system", "--gid", "decnet", "--home-dir", "/opt/decnet",
|
|
]
|
|
assert useradds[0][-1] == "decnet"
|
|
|
|
|
|
def test_present_user_and_group_skipped(
|
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, present_user_and_group: None,
|
|
) -> None:
|
|
_seed_deploy(monkeypatch, tmp_path)
|
|
result = runner.invoke(
|
|
app,
|
|
["init", "--no-start", "--prefix", str(tmp_path / "root")],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert all(c[0] not in ("groupadd", "useradd") for c in subprocess_calls)
|
|
assert "[SKIP]" in result.output
|
|
|
|
|
|
def test_unit_files_are_installed_then_idempotent(
|
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, present_user_and_group: None,
|
|
) -> None:
|
|
_seed_deploy(monkeypatch, tmp_path)
|
|
prefix = tmp_path / "root"
|
|
# First run: installs.
|
|
r1 = runner.invoke(
|
|
app, ["init", "--no-start", "--prefix", str(prefix)],
|
|
)
|
|
assert r1.exit_code == 0, r1.output
|
|
target = prefix / "etc/systemd/system" / "decnet.target"
|
|
assert target.is_file()
|
|
assert target.read_text() == "# target\n"
|
|
|
|
# Second run: every copy should SKIP.
|
|
subprocess_calls.clear()
|
|
r2 = runner.invoke(
|
|
app, ["init", "--no-start", "--prefix", str(prefix)],
|
|
)
|
|
assert r2.exit_code == 0, r2.output
|
|
assert "unit files up to date" in r2.output
|
|
|
|
|
|
def test_init_writes_decnet_ini_not_config_ini(
|
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, missing_user_and_group: None,
|
|
) -> None:
|
|
"""Placeholder target is /etc/decnet/decnet.ini (new name) — matches
|
|
what decnet.config_ini.load_ini_config() actually reads. Guards
|
|
against regressing to the old `config.ini` name."""
|
|
_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
|
|
|
|
ini = prefix / "etc/decnet/decnet.ini"
|
|
legacy = prefix / "etc/decnet/config.ini"
|
|
assert ini.is_file(), "decnet.ini should be written"
|
|
assert not legacy.exists(), "legacy config.ini must not be written"
|
|
|
|
body = ini.read_text()
|
|
# Admin-facing sections are documented as commented examples so
|
|
# the placeholder teaches the file shape.
|
|
for header in ("[decnet]", "[api]", "[web]", "[database]",
|
|
"[bus]", "[swarm]", "[logging]", "[ingester]",
|
|
"[tracing]", "[agent]"):
|
|
assert header in body, f"placeholder missing {header} example"
|
|
|
|
|
|
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,
|
|
) -> None:
|
|
deploy = _seed_deploy(monkeypatch, tmp_path)
|
|
prefix = tmp_path / "root"
|
|
runner.invoke(app, ["init", "--no-start", "--prefix", str(prefix)])
|
|
# Mutate the installed copy so SHA-256 matches source, but we ask
|
|
# for --force anyway: source wins.
|
|
target = prefix / "etc/systemd/system" / "decnet.target"
|
|
target.write_text("# tampered\n")
|
|
r = runner.invoke(
|
|
app,
|
|
["init", "--no-start", "--force", "--prefix", str(prefix)],
|
|
)
|
|
assert r.exit_code == 0, r.output
|
|
assert target.read_text() == (deploy / "decnet.target").read_text()
|
|
|
|
|
|
def test_no_start_suppresses_target_start(
|
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, present_user_and_group: None,
|
|
) -> None:
|
|
_seed_deploy(monkeypatch, tmp_path)
|
|
runner.invoke(
|
|
app,
|
|
["init", "--no-start", "--prefix", str(tmp_path / "root")],
|
|
)
|
|
enables = [
|
|
c for c in subprocess_calls
|
|
if c[:2] == ["systemctl", "enable"]
|
|
]
|
|
assert enables == []
|
|
|
|
|
|
def test_default_invokes_target_start(
|
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, present_user_and_group: None,
|
|
) -> None:
|
|
_seed_deploy(monkeypatch, tmp_path)
|
|
result = runner.invoke(
|
|
app, ["init", "--prefix", str(tmp_path / "root")],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert ["systemctl", "enable", "--now", "decnet.target"] in subprocess_calls
|
|
assert ["systemctl", "daemon-reload"] in subprocess_calls
|
|
|
|
|
|
def _seed_installed_state(prefix: Path) -> None:
|
|
"""Create the files a prior `decnet init` would have installed."""
|
|
systemd = prefix / "etc/systemd/system"
|
|
systemd.mkdir(parents=True)
|
|
(systemd / "decnet-bus.service").write_text("# bus\n")
|
|
(systemd / "decnet-api.service").write_text("# api\n")
|
|
(systemd / "decnet.target").write_text("# target\n")
|
|
polkit = prefix / "etc/polkit-1/rules.d"
|
|
polkit.mkdir(parents=True)
|
|
(polkit / "50-decnet-workers.rules").write_text("// rule\n")
|
|
tmpfiles = prefix / "etc/tmpfiles.d"
|
|
tmpfiles.mkdir(parents=True)
|
|
(tmpfiles / "decnet.conf").write_text("d /run/decnet\n")
|
|
etc_decnet = prefix / "etc/decnet"
|
|
etc_decnet.mkdir(parents=True)
|
|
(etc_decnet / "decnet.ini").write_text("[decnet]\n")
|
|
# Also seed the legacy config.ini so we cover the legacy-cleanup path.
|
|
(etc_decnet / "config.ini").write_text("[decnet]\n")
|
|
(prefix / "opt/decnet").mkdir(parents=True)
|
|
(prefix / "run/decnet").mkdir(parents=True)
|
|
(prefix / "var/lib/decnet").mkdir(parents=True)
|
|
(prefix / "var/log/decnet").mkdir(parents=True)
|
|
(prefix / "var/log/decnet/events.jsonl").write_text("{}\n")
|
|
|
|
|
|
def test_deinit_removes_units_polkit_tmpfiles_and_preserves_data(
|
|
tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, present_user_and_group: None,
|
|
) -> None:
|
|
prefix = tmp_path / "root"
|
|
_seed_installed_state(prefix)
|
|
result = runner.invoke(
|
|
app, ["init", "--deinit", "--prefix", str(prefix)],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Units + polkit + tmpfiles.d gone.
|
|
assert not (prefix / "etc/systemd/system/decnet-bus.service").exists()
|
|
assert not (prefix / "etc/systemd/system/decnet.target").exists()
|
|
assert not (prefix / "etc/polkit-1/rules.d/50-decnet-workers.rules").exists()
|
|
assert not (prefix / "etc/tmpfiles.d/decnet.conf").exists()
|
|
assert not (prefix / "etc/decnet").exists()
|
|
assert not (prefix / "opt/decnet").exists()
|
|
|
|
# Data dirs preserved.
|
|
assert (prefix / "var/lib/decnet").exists()
|
|
assert (prefix / "var/log/decnet/events.jsonl").read_text() == "{}\n"
|
|
|
|
# systemctl disable + daemon-reload + userdel + groupdel were invoked.
|
|
assert ["systemctl", "disable", "--now", "decnet.target"] in subprocess_calls
|
|
assert ["systemctl", "daemon-reload"] in subprocess_calls
|
|
assert ["userdel", "decnet"] in subprocess_calls
|
|
assert ["groupdel", "decnet"] in subprocess_calls
|
|
|
|
|
|
def test_deinit_purge_wipes_data_dirs(
|
|
tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, present_user_and_group: None,
|
|
) -> None:
|
|
prefix = tmp_path / "root"
|
|
_seed_installed_state(prefix)
|
|
result = runner.invoke(
|
|
app, ["init", "--deinit", "--purge", "--prefix", str(prefix)],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert not (prefix / "var/lib/decnet").exists()
|
|
assert not (prefix / "var/log/decnet").exists()
|
|
|
|
|
|
def test_deinit_is_idempotent_on_clean_host(
|
|
tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, missing_user_and_group: None,
|
|
) -> None:
|
|
prefix = tmp_path / "root"
|
|
# Nothing seeded — everything should SKIP.
|
|
result = runner.invoke(
|
|
app, ["init", "--deinit", "--prefix", str(prefix)],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert result.output.count("[SKIP]") >= 5
|
|
# userdel / groupdel never invoked because user/group are absent.
|
|
assert ["userdel", "decnet"] not in subprocess_calls
|
|
assert ["groupdel", "decnet"] not in subprocess_calls
|
|
|
|
|
|
def test_deinit_dry_run_touches_nothing(
|
|
tmp_path: Path, subprocess_calls: List[List[str]],
|
|
no_missing_tools: None, present_user_and_group: None,
|
|
) -> None:
|
|
prefix = tmp_path / "root"
|
|
_seed_installed_state(prefix)
|
|
result = runner.invoke(
|
|
app,
|
|
["init", "--deinit", "--purge", "--dry-run", "--prefix", str(prefix)],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert subprocess_calls == []
|
|
assert (prefix / "etc/systemd/system/decnet.target").exists()
|
|
assert (prefix / "var/lib/decnet").exists()
|
|
|
|
|
|
def test_purge_without_deinit_errors(tmp_path: Path) -> None:
|
|
result = runner.invoke(
|
|
app, ["init", "--purge", "--prefix", str(tmp_path / "root")],
|
|
)
|
|
assert result.exit_code == 1
|
|
assert "--purge only applies with --deinit" in result.output
|
|
|
|
|
|
def test_missing_deploy_dir_errors_clearly(monkeypatch: Any, tmp_path: Path) -> None:
|
|
def _boom() -> Path:
|
|
raise RuntimeError("cannot locate deploy/ directory (looked at /nope)")
|
|
|
|
monkeypatch.setattr(_init, "_deploy_root", _boom)
|
|
monkeypatch.setattr(_init.shutil, "which", lambda _: "/bin/x")
|
|
result = runner.invoke(
|
|
app, ["init", "--prefix", str(tmp_path / "root")],
|
|
)
|
|
assert result.exit_code == 1
|
|
assert "cannot locate deploy/" in result.output
|