From 07bf3dc8cbbe09dbe19be4b5564eddc8ea28c126 Mon Sep 17 00:00:00 2001 From: anti Date: Thu, 23 Apr 2026 18:21:00 -0400 Subject: [PATCH] feat(config): promote /etc/decnet/decnet.ini to real config with domain sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- decnet/cli/init.py | 72 +++++++++++++++++-- decnet/config_ini.py | 147 ++++++++++++++++++++++++++++++++----- tests/cli/test_init.py | 28 ++++++++ tests/test_config_ini.py | 152 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+), 24 deletions(-) diff --git a/decnet/cli/init.py b/decnet/cli/init.py index d9604a31..b128b9eb 100644 --- a/decnet/cli/init.py +++ b/decnet/cli/init.py @@ -32,10 +32,64 @@ from .utils import console, log _CONFIG_PLACEHOLDER = """\ -# /etc/decnet/config.ini — DECNET master-host config. -# Placeholder; reserved for future structured settings. -# Today, most knobs live in /opt/decnet/.env.local as env vars. +# /etc/decnet/decnet.ini — DECNET host config. +# +# Every key is OPTIONAL. Absent keys fall through to env-var defaults +# defined in decnet/env.py. Real env vars always win over this file +# (precedence: env > INI > default), so systemd EnvironmentFile= and +# one-off `DECNET_FOO=bar decnet ...` invocations always take effect. +# +# Secrets (JWT, admin password, DB password) intentionally DO NOT +# live here. Put them in /opt/decnet/.env.local or the systemd +# EnvironmentFile= — never in a group-readable INI. + [decnet] +# mode = master # or "agent" + +# [api] +# host = 127.0.0.1 +# port = 8000 + +# [web] +# host = 127.0.0.1 +# port = 8080 +# admin-user = admin +# cors-origins = http://localhost:8080 # comma-separated + +# [database] +# type = sqlite # or "mysql" +# url = mysql+asyncmy://user@host:3306/decnet # if set, wins over host/port/name/user +# host = localhost +# port = 3306 +# name = decnet +# user = decnet + +# [bus] +# enabled = true +# type = unix # or "fake" +# socket = /run/decnet/bus.sock +# group = decnet + +# [swarm] +# master-host = 10.0.0.1 +# syslog-port = 6514 +# swarmctl-port = 8770 + +# [logging] +# system-log = /var/log/decnet/decnet.system.log +# ingest-log = /var/log/decnet/decnet.log +# agent-log = /var/log/decnet/agent.log + +# [ingester] +# batch-size = 100 +# batch-max-wait-ms = 250 + +# [tracing] +# enabled = false +# otel-endpoint = http://localhost:4317 + +# [agent] +# Managed by the enroll bundle — do NOT edit by hand on an agent host. """ @@ -487,7 +541,13 @@ def register(app: typer.Typer) -> None: lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], ) _step( - f"remove {etc_decnet / 'config.ini'}", + f"remove {etc_decnet / 'decnet.ini'}", + lambda: _remove_file(etc_decnet / "decnet.ini", dry_run=dry_run), + ) + # Legacy name from pre-domain-sections placeholder era. + # Harmless if absent (the _remove_file step logs skip). + _step( + f"remove legacy {etc_decnet / 'config.ini'}", lambda: _remove_file(etc_decnet / "config.ini", dry_run=dry_run), ) _step( @@ -572,8 +632,8 @@ def register(app: typer.Typer) -> None: _ensure_dir(p, mode=m, owner=o, group=g, dry_run=dry_run), ) _step( - f"write {etc_decnet / 'config.ini'}", - lambda: _ensure_config(etc_decnet / "config.ini", group, dry_run=dry_run), + f"write {etc_decnet / 'decnet.ini'}", + lambda: _ensure_config(etc_decnet / "decnet.ini", group, dry_run=dry_run), ) _step( "install systemd units", diff --git a/decnet/config_ini.py b/decnet/config_ini.py index 6a914e28..4732f3f4 100644 --- a/decnet/config_ini.py +++ b/decnet/config_ini.py @@ -9,31 +9,71 @@ had been exported by the shell. Shape:: [decnet] - mode = agent # or "master" - log-directory = /var/log/decnet - disallow-master = true + mode = master # or "agent" + + [api] + host = 127.0.0.1 + port = 8000 + + [web] + host = 127.0.0.1 + port = 8080 + admin-user = admin + cors-origins = http://localhost:8080 + + [database] + type = sqlite # or "mysql" + url = mysql+asyncmy://user@host:3306/decnet # wins over host/port/name/user + host = localhost + port = 3306 + name = decnet + user = decnet + + [bus] + enabled = true + type = unix # or "fake" + socket = /run/decnet/bus.sock + group = decnet + + [swarm] + master-host = 10.0.0.1 # required on agents + syslog-port = 6514 + swarmctl-port = 8770 + + [logging] + system-log = /var/log/decnet/decnet.system.log + ingest-log = /var/log/decnet/decnet.log + agent-log = /var/log/decnet/agent.log + + [ingester] + batch-size = 100 + batch-max-wait-ms = 250 + + [tracing] + enabled = false + otel-endpoint = http://localhost:4317 [agent] - master-host = 192.168.1.50 - master-port = 8770 - agent-port = 8765 - agent-dir = /home/anti/.decnet/agent - ... + # Written by the enroll bundle on agent hosts — don't hand-edit. + host-uuid = ... + master-host = ... - [master] - api-host = 0.0.0.0 - swarmctl-port = 8770 - listener-port = 6514 - ... +The ``[decnet]`` and role-specific ``[agent]`` / ``[master]`` sections +use auto kebab-to-snake translation (``master-host`` → ``DECNET_MASTER_HOST``). +The domain sections (``[api]``, ``[web]``, etc.) use an explicit key map +so ``[web] admin-user`` resolves to ``DECNET_ADMIN_USER`` without silently +renaming the env-var contract consumers already import from ``decnet.env``. -Only the section matching `mode` is loaded. The other section is -ignored silently so an agent host never reads master secrets (and -vice versa). Keys are converted to SCREAMING_SNAKE_CASE and prefixed -with ``DECNET_`` — e.g. ``master-host`` → ``DECNET_MASTER_HOST``. +Secrets (``DECNET_JWT_SECRET``, ``DECNET_ADMIN_PASSWORD``, +``DECNET_DB_PASSWORD``) are deliberately NOT in the domain map. They +belong in ``.env.local`` / systemd ``EnvironmentFile=`` so they never +hit the dashboard, never end up in `config.ini`-style diffs, and never +get group-readable alongside tunables. """ from __future__ import annotations import configparser +import logging import os from pathlib import Path from typing import Optional @@ -41,10 +81,62 @@ from typing import Optional DEFAULT_CONFIG_PATH = Path("/etc/decnet/decnet.ini") +log = logging.getLogger(__name__) + # The [decnet] section keys are role-agnostic and always exported. _COMMON_KEYS = frozenset({"mode", "disallow-master", "log-directory"}) +# Explicit INI-key → env-var mapping for the domain sections. Kept +# separate from the role-specific [agent] / [master] loader so the +# admin-facing section layout ([web] admin-user) can diverge from the +# env-var name (DECNET_ADMIN_USER) without breaking any consumer. +_DOMAIN_MAP: dict[str, dict[str, str]] = { + "api": { + "host": "DECNET_API_HOST", + "port": "DECNET_API_PORT", + }, + "web": { + "host": "DECNET_WEB_HOST", + "port": "DECNET_WEB_PORT", + "admin-user": "DECNET_ADMIN_USER", + "cors-origins": "DECNET_CORS_ORIGINS", + }, + "database": { + "type": "DECNET_DB_TYPE", + "url": "DECNET_DB_URL", + "host": "DECNET_DB_HOST", + "port": "DECNET_DB_PORT", + "name": "DECNET_DB_NAME", + "user": "DECNET_DB_USER", + }, + "bus": { + "enabled": "DECNET_BUS_ENABLED", + "type": "DECNET_BUS_TYPE", + "socket": "DECNET_BUS_SOCKET", + "group": "DECNET_BUS_GROUP", + }, + "swarm": { + "master-host": "DECNET_SWARM_MASTER_HOST", + "syslog-port": "DECNET_SWARM_SYSLOG_PORT", + "swarmctl-port": "DECNET_SWARMCTL_PORT", + }, + "logging": { + "system-log": "DECNET_SYSTEM_LOGS", + "ingest-log": "DECNET_INGEST_LOG_FILE", + "agent-log": "DECNET_AGENT_LOG_FILE", + }, + "ingester": { + "batch-size": "DECNET_BATCH_SIZE", + "batch-max-wait-ms": "DECNET_BATCH_MAX_WAIT_MS", + }, + "tracing": { + "enabled": "DECNET_DEVELOPER_TRACING", + "otel-endpoint": "DECNET_OTEL_ENDPOINT", + }, +} + + def _key_to_env(key: str) -> str: return "DECNET_" + key.replace("-", "_").upper() @@ -81,10 +173,29 @@ def load_ini_config(path: Optional[Path] = None) -> Optional[Path]: f"decnet.ini: [decnet] mode must be 'agent' or 'master', got '{mode}'" ) - # Role-specific section. + # Role-specific section — kebab→SCREAMING_SNAKE auto-translation. + # Kept for backwards compatibility with the enroll-bundle [agent] + # writer (decnet/web/router/swarm_mgmt/api_enroll_bundle.py). section = mode if parser.has_section(section): for key, value in parser.items(section): os.environ.setdefault(_key_to_env(key), value) + # Domain sections — explicit key map; loaded regardless of mode. + # Unknown keys inside a known section log a WARNING so operator + # typos are visible; unknown sections are silently ignored (so the + # file format can grow without breaking older loaders). + for section_name, key_map in _DOMAIN_MAP.items(): + if not parser.has_section(section_name): + continue + for key, value in parser.items(section_name): + env_name = key_map.get(key) + if env_name is None: + log.warning( + "decnet.ini: unknown key [%s] %s — ignored", + section_name, key, + ) + continue + os.environ.setdefault(env_name, value) + return path diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index a1a94638..a093abef 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -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) diff --git a/tests/test_config_ini.py b/tests/test_config_ini.py index 29eb84b9..1e0fce5a 100644 --- a/tests/test_config_ini.py +++ b/tests/test_config_ini.py @@ -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"