feat(config): promote /etc/decnet/decnet.ini to real config with domain sections

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).
This commit is contained in:
2026-04-23 18:21:00 -04:00
parent 1753eca198
commit 07bf3dc8cb
4 changed files with 375 additions and 24 deletions

View File

@@ -166,6 +166,32 @@ def test_unit_files_are_installed_then_idempotent(
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,
@@ -328,6 +354,8 @@ def _seed_installed_state(prefix: Path) -> None:
(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)

View File

@@ -132,3 +132,155 @@ api-port = 9001
loaded = load_ini_config()
assert loaded == ini
assert os.environ["DECNET_API_PORT"] == "9001"
# ─── Domain sections ────────────────────────────────────────────────────────
def test_domain_sections_load_regardless_of_mode(monkeypatch, tmp_path):
"""[api], [web], [database], etc. load on both master and agent —
setdefault makes unused keys harmless on the other role."""
_scrub(
monkeypatch,
"DECNET_MODE", "DECNET_API_HOST", "DECNET_API_PORT",
"DECNET_WEB_PORT", "DECNET_ADMIN_USER",
"DECNET_DB_TYPE", "DECNET_DB_URL",
"DECNET_BUS_ENABLED", "DECNET_BUS_GROUP",
"DECNET_SWARM_SYSLOG_PORT",
"DECNET_SYSTEM_LOGS", "DECNET_INGEST_LOG_FILE",
"DECNET_BATCH_SIZE", "DECNET_BATCH_MAX_WAIT_MS",
"DECNET_DEVELOPER_TRACING", "DECNET_OTEL_ENDPOINT",
)
ini = _write_ini(tmp_path, """
[decnet]
mode = agent
[api]
host = 0.0.0.0
port = 8001
[web]
port = 9090
admin-user = superman
cors-origins = https://dash.example.com
[database]
type = mysql
url = mysql+asyncmy://decnet@db/decnet
[bus]
enabled = false
group = custom
[swarm]
syslog-port = 7514
[logging]
system-log = /tmp/decnet.log
ingest-log = /tmp/decnet.ingest.log
[ingester]
batch-size = 500
batch-max-wait-ms = 1000
[tracing]
enabled = true
otel-endpoint = http://otel.internal:4317
""")
load_ini_config(ini)
assert os.environ["DECNET_API_HOST"] == "0.0.0.0"
assert os.environ["DECNET_API_PORT"] == "8001"
assert os.environ["DECNET_WEB_PORT"] == "9090"
assert os.environ["DECNET_ADMIN_USER"] == "superman"
assert os.environ["DECNET_CORS_ORIGINS"] == "https://dash.example.com"
assert os.environ["DECNET_DB_TYPE"] == "mysql"
assert os.environ["DECNET_DB_URL"] == "mysql+asyncmy://decnet@db/decnet"
assert os.environ["DECNET_BUS_ENABLED"] == "false"
assert os.environ["DECNET_BUS_GROUP"] == "custom"
assert os.environ["DECNET_SWARM_SYSLOG_PORT"] == "7514"
assert os.environ["DECNET_SYSTEM_LOGS"] == "/tmp/decnet.log"
assert os.environ["DECNET_INGEST_LOG_FILE"] == "/tmp/decnet.ingest.log"
assert os.environ["DECNET_BATCH_SIZE"] == "500"
assert os.environ["DECNET_BATCH_MAX_WAIT_MS"] == "1000"
assert os.environ["DECNET_DEVELOPER_TRACING"] == "true"
assert os.environ["DECNET_OTEL_ENDPOINT"] == "http://otel.internal:4317"
def test_domain_section_env_wins_over_ini(monkeypatch, tmp_path):
"""Real env var beats the INI for a domain-section key, same as
the role-specific section contract."""
_scrub(monkeypatch, "DECNET_MODE")
monkeypatch.setenv("DECNET_API_PORT", "5555")
ini = _write_ini(tmp_path, """
[decnet]
mode = master
[api]
port = 8000
""")
load_ini_config(ini)
assert os.environ["DECNET_API_PORT"] == "5555"
def test_domain_unknown_key_logs_warning(monkeypatch, tmp_path, caplog):
"""Typos in a domain section should be visible to the operator —
a silent drop is how you spend an afternoon debugging 'why isn't
my setting taking effect'."""
_scrub(monkeypatch, "DECNET_MODE")
ini = _write_ini(tmp_path, """
[decnet]
mode = master
[api]
host = 127.0.0.1
# typo: hostt instead of host
hostt = 0.0.0.0
""")
import logging as _logging
with caplog.at_level(_logging.WARNING, logger="decnet.config_ini"):
load_ini_config(ini)
assert any(
"unknown key [api] hostt" in rec.getMessage()
for rec in caplog.records
), f"expected warning about unknown key, got: {[r.getMessage() for r in caplog.records]}"
def test_domain_absent_section_is_noop(monkeypatch, tmp_path):
"""INI with only [decnet] present doesn't touch any domain env var."""
_scrub(
monkeypatch,
"DECNET_MODE", "DECNET_API_PORT", "DECNET_WEB_PORT",
"DECNET_DB_TYPE", "DECNET_BUS_ENABLED",
)
ini = _write_ini(tmp_path, """
[decnet]
mode = master
""")
load_ini_config(ini)
assert os.environ["DECNET_MODE"] == "master"
assert "DECNET_API_PORT" not in os.environ
assert "DECNET_WEB_PORT" not in os.environ
assert "DECNET_DB_TYPE" not in os.environ
assert "DECNET_BUS_ENABLED" not in os.environ
def test_domain_section_does_not_override_role_section(monkeypatch, tmp_path):
"""If both [master] (role) and [swarm] (domain) define swarmctl-port,
whichever the loader applies first wins via setdefault — and the role
section runs first, so the [swarm] value is dropped silently.
This locks in the precedence order as part of the contract."""
_scrub(monkeypatch, "DECNET_MODE", "DECNET_SWARMCTL_PORT")
ini = _write_ini(tmp_path, """
[decnet]
mode = master
[master]
swarmctl-port = 9001
[swarm]
swarmctl-port = 9999
""")
load_ini_config(ini)
# [master] loaded first, [swarm] lost via setdefault
assert os.environ["DECNET_SWARMCTL_PORT"] == "9001"