diff --git a/decnet/config_ini.py b/decnet/config_ini.py new file mode 100644 index 0000000..b7c75d2 --- /dev/null +++ b/decnet/config_ini.py @@ -0,0 +1,90 @@ +"""Parse /etc/decnet/decnet.ini and seed os.environ defaults. + +The INI file is a convenience layer on top of the existing DECNET_* env +vars. It never overrides an explicit environment variable (uses +os.environ.setdefault). Call load_ini_config() once, very early, before +any decnet.env import, so env.py picks up the seeded values as if they +had been exported by the shell. + +Shape:: + + [decnet] + mode = agent # or "master" + log-file-path = /var/log/decnet/decnet.log + disallow-master = true + + [agent] + master-host = 192.168.1.50 + master-port = 8770 + agent-port = 8765 + agent-dir = /home/anti/.decnet/agent + ... + + [master] + api-host = 0.0.0.0 + swarmctl-port = 8770 + listener-port = 6514 + ... + +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``. +""" +from __future__ import annotations + +import configparser +import os +from pathlib import Path +from typing import Optional + + +DEFAULT_CONFIG_PATH = Path("/etc/decnet/decnet.ini") + +# The [decnet] section keys are role-agnostic and always exported. +_COMMON_KEYS = frozenset({"mode", "disallow-master", "log-file-path"}) + + +def _key_to_env(key: str) -> str: + return "DECNET_" + key.replace("-", "_").upper() + + +def load_ini_config(path: Optional[Path] = None) -> Optional[Path]: + """Seed os.environ defaults from the DECNET INI file. + + Returns the path that was actually loaded (so callers can log it), or + None if no file was read. Missing file is a no-op — callers fall back + to env vars / CLI flags / hardcoded defaults. + + Precedence: real os.environ > INI > defaults. Real env vars are never + overwritten because we use setdefault(). + """ + if path is None: + override = os.environ.get("DECNET_CONFIG") + path = Path(override) if override else DEFAULT_CONFIG_PATH + + if not path.is_file(): + return None + + parser = configparser.ConfigParser() + parser.read(path) + + # [decnet] first — mode/disallow-master/log-file-path. These seed the + # mode decision for the section selection below. + if parser.has_section("decnet"): + for key, value in parser.items("decnet"): + os.environ.setdefault(_key_to_env(key), value) + + mode = os.environ.get("DECNET_MODE", "master").lower() + if mode not in ("agent", "master"): + raise ValueError( + f"decnet.ini: [decnet] mode must be 'agent' or 'master', got '{mode}'" + ) + + # Role-specific section. + section = mode + if parser.has_section(section): + for key, value in parser.items(section): + os.environ.setdefault(_key_to_env(key), value) + + return path diff --git a/tests/test_config_ini.py b/tests/test_config_ini.py new file mode 100644 index 0000000..d283d4f --- /dev/null +++ b/tests/test_config_ini.py @@ -0,0 +1,134 @@ +"""decnet.config_ini — INI file loader, precedence, section routing.""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from decnet.config_ini import load_ini_config + + +def _write_ini(tmp_path: Path, body: str) -> Path: + p = tmp_path / "decnet.ini" + p.write_text(body) + return p + + +def _scrub(monkeypatch: pytest.MonkeyPatch, *names: str) -> None: + for n in names: + monkeypatch.delenv(n, raising=False) + + +def test_missing_file_is_noop(monkeypatch, tmp_path): + _scrub(monkeypatch, "DECNET_MODE", "DECNET_AGENT_PORT") + result = load_ini_config(tmp_path / "does-not-exist.ini") + assert result is None + assert "DECNET_AGENT_PORT" not in os.environ + + +def test_agent_section_only_loaded_when_mode_agent(monkeypatch, tmp_path): + _scrub( + monkeypatch, + "DECNET_MODE", "DECNET_DISALLOW_MASTER", + "DECNET_AGENT_PORT", "DECNET_MASTER_HOST", + "DECNET_API_PORT", "DECNET_SWARMCTL_PORT", + ) + ini = _write_ini(tmp_path, """ +[decnet] +mode = agent + +[agent] +agent-port = 8765 +master-host = 192.168.1.50 + +[master] +api-port = 9999 +swarmctl-port = 8770 +""") + load_ini_config(ini) + assert os.environ["DECNET_MODE"] == "agent" + assert os.environ["DECNET_AGENT_PORT"] == "8765" + assert os.environ["DECNET_MASTER_HOST"] == "192.168.1.50" + # [master] section values must NOT leak into an agent host's env + assert "DECNET_API_PORT" not in os.environ + assert "DECNET_SWARMCTL_PORT" not in os.environ + + +def test_master_section_loaded_when_mode_master(monkeypatch, tmp_path): + _scrub( + monkeypatch, + "DECNET_MODE", "DECNET_API_PORT", + "DECNET_SWARMCTL_PORT", "DECNET_AGENT_PORT", + ) + ini = _write_ini(tmp_path, """ +[decnet] +mode = master + +[agent] +agent-port = 8765 + +[master] +api-port = 8000 +swarmctl-port = 8770 +""") + load_ini_config(ini) + assert os.environ["DECNET_MODE"] == "master" + assert os.environ["DECNET_API_PORT"] == "8000" + assert os.environ["DECNET_SWARMCTL_PORT"] == "8770" + assert "DECNET_AGENT_PORT" not in os.environ + + +def test_env_wins_over_ini(monkeypatch, tmp_path): + _scrub(monkeypatch, "DECNET_MODE") + monkeypatch.setenv("DECNET_AGENT_PORT", "7777") + ini = _write_ini(tmp_path, """ +[decnet] +mode = agent + +[agent] +agent-port = 8765 +""") + load_ini_config(ini) + # Real env var must beat INI value + assert os.environ["DECNET_AGENT_PORT"] == "7777" + + +def test_common_keys_always_exported(monkeypatch, tmp_path): + _scrub(monkeypatch, "DECNET_MODE", "DECNET_DISALLOW_MASTER", "DECNET_LOG_FILE_PATH") + ini = _write_ini(tmp_path, """ +[decnet] +mode = agent +disallow-master = true +log-file-path = /var/log/decnet/decnet.log +""") + load_ini_config(ini) + assert os.environ["DECNET_MODE"] == "agent" + assert os.environ["DECNET_DISALLOW_MASTER"] == "true" + assert os.environ["DECNET_LOG_FILE_PATH"] == "/var/log/decnet/decnet.log" + + +def test_invalid_mode_raises(monkeypatch, tmp_path): + _scrub(monkeypatch, "DECNET_MODE") + ini = _write_ini(tmp_path, """ +[decnet] +mode = supervisor +""") + with pytest.raises(ValueError, match="mode must be"): + load_ini_config(ini) + + +def test_decnet_config_env_var_overrides_default_path(monkeypatch, tmp_path): + _scrub(monkeypatch, "DECNET_MODE", "DECNET_API_PORT") + ini = _write_ini(tmp_path, """ +[decnet] +mode = master + +[master] +api-port = 9001 +""") + monkeypatch.setenv("DECNET_CONFIG", str(ini)) + # Call with no explicit path — loader reads $DECNET_CONFIG + loaded = load_ini_config() + assert loaded == ini + assert os.environ["DECNET_API_PORT"] == "9001"