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:
@@ -32,10 +32,64 @@ from .utils import console, log
|
|||||||
|
|
||||||
|
|
||||||
_CONFIG_PLACEHOLDER = """\
|
_CONFIG_PLACEHOLDER = """\
|
||||||
# /etc/decnet/config.ini — DECNET master-host config.
|
# /etc/decnet/decnet.ini — DECNET host config.
|
||||||
# Placeholder; reserved for future structured settings.
|
#
|
||||||
# Today, most knobs live in /opt/decnet/.env.local as env vars.
|
# 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]
|
[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],
|
lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1],
|
||||||
)
|
)
|
||||||
_step(
|
_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),
|
lambda: _remove_file(etc_decnet / "config.ini", dry_run=dry_run),
|
||||||
)
|
)
|
||||||
_step(
|
_step(
|
||||||
@@ -572,8 +632,8 @@ def register(app: typer.Typer) -> None:
|
|||||||
_ensure_dir(p, mode=m, owner=o, group=g, dry_run=dry_run),
|
_ensure_dir(p, mode=m, owner=o, group=g, dry_run=dry_run),
|
||||||
)
|
)
|
||||||
_step(
|
_step(
|
||||||
f"write {etc_decnet / 'config.ini'}",
|
f"write {etc_decnet / 'decnet.ini'}",
|
||||||
lambda: _ensure_config(etc_decnet / "config.ini", group, dry_run=dry_run),
|
lambda: _ensure_config(etc_decnet / "decnet.ini", group, dry_run=dry_run),
|
||||||
)
|
)
|
||||||
_step(
|
_step(
|
||||||
"install systemd units",
|
"install systemd units",
|
||||||
|
|||||||
@@ -9,31 +9,71 @@ had been exported by the shell.
|
|||||||
Shape::
|
Shape::
|
||||||
|
|
||||||
[decnet]
|
[decnet]
|
||||||
mode = agent # or "master"
|
mode = master # or "agent"
|
||||||
log-directory = /var/log/decnet
|
|
||||||
disallow-master = true
|
[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]
|
[agent]
|
||||||
master-host = 192.168.1.50
|
# Written by the enroll bundle on agent hosts — don't hand-edit.
|
||||||
master-port = 8770
|
host-uuid = ...
|
||||||
agent-port = 8765
|
master-host = ...
|
||||||
agent-dir = /home/anti/.decnet/agent
|
|
||||||
...
|
|
||||||
|
|
||||||
[master]
|
The ``[decnet]`` and role-specific ``[agent]`` / ``[master]`` sections
|
||||||
api-host = 0.0.0.0
|
use auto kebab-to-snake translation (``master-host`` → ``DECNET_MASTER_HOST``).
|
||||||
swarmctl-port = 8770
|
The domain sections (``[api]``, ``[web]``, etc.) use an explicit key map
|
||||||
listener-port = 6514
|
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
|
Secrets (``DECNET_JWT_SECRET``, ``DECNET_ADMIN_PASSWORD``,
|
||||||
ignored silently so an agent host never reads master secrets (and
|
``DECNET_DB_PASSWORD``) are deliberately NOT in the domain map. They
|
||||||
vice versa). Keys are converted to SCREAMING_SNAKE_CASE and prefixed
|
belong in ``.env.local`` / systemd ``EnvironmentFile=`` so they never
|
||||||
with ``DECNET_`` — e.g. ``master-host`` → ``DECNET_MASTER_HOST``.
|
hit the dashboard, never end up in `config.ini`-style diffs, and never
|
||||||
|
get group-readable alongside tunables.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import configparser
|
import configparser
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -41,10 +81,62 @@ from typing import Optional
|
|||||||
|
|
||||||
DEFAULT_CONFIG_PATH = Path("/etc/decnet/decnet.ini")
|
DEFAULT_CONFIG_PATH = Path("/etc/decnet/decnet.ini")
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# The [decnet] section keys are role-agnostic and always exported.
|
# The [decnet] section keys are role-agnostic and always exported.
|
||||||
_COMMON_KEYS = frozenset({"mode", "disallow-master", "log-directory"})
|
_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:
|
def _key_to_env(key: str) -> str:
|
||||||
return "DECNET_" + key.replace("-", "_").upper()
|
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}'"
|
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
|
section = mode
|
||||||
if parser.has_section(section):
|
if parser.has_section(section):
|
||||||
for key, value in parser.items(section):
|
for key, value in parser.items(section):
|
||||||
os.environ.setdefault(_key_to_env(key), value)
|
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
|
return path
|
||||||
|
|||||||
@@ -166,6 +166,32 @@ def test_unit_files_are_installed_then_idempotent(
|
|||||||
assert "unit files up to date" in 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(
|
def test_install_dir_renders_into_service_units(
|
||||||
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
monkeypatch: Any, tmp_path: Path, subprocess_calls: List[List[str]],
|
||||||
no_missing_tools: None, missing_user_and_group: None,
|
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")
|
(tmpfiles / "decnet.conf").write_text("d /run/decnet\n")
|
||||||
etc_decnet = prefix / "etc/decnet"
|
etc_decnet = prefix / "etc/decnet"
|
||||||
etc_decnet.mkdir(parents=True)
|
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")
|
(etc_decnet / "config.ini").write_text("[decnet]\n")
|
||||||
(prefix / "opt/decnet").mkdir(parents=True)
|
(prefix / "opt/decnet").mkdir(parents=True)
|
||||||
(prefix / "run/decnet").mkdir(parents=True)
|
(prefix / "run/decnet").mkdir(parents=True)
|
||||||
|
|||||||
@@ -132,3 +132,155 @@ api-port = 9001
|
|||||||
loaded = load_ini_config()
|
loaded = load_ini_config()
|
||||||
assert loaded == ini
|
assert loaded == ini
|
||||||
assert os.environ["DECNET_API_PORT"] == "9001"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user