merge testing->tomerge/main #7
90
decnet/config_ini.py
Normal file
90
decnet/config_ini.py
Normal file
@@ -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
|
||||
134
tests/test_config_ini.py
Normal file
134
tests/test_config_ini.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user