"""Server-level CI settings (phase settings). A minimal, extensible TOML config layer for the cc-ci server, read once per harness process. Stdlib-only (`tomllib`). Defaults are baked into this loader, so an ABSENT file — or an absent key within a present file — behaves EXACTLY as before (this server needs no file to behave as today). The live file is an operator-managed HOST OVERRIDE; it must carry NO secrets (config only — secrets stay in sops). See `settings.toml.example` for the documented keys. Layout (minimal + extensible — one table, one key now; shaped so future CI-server configs slot in without a redesign): [upgrade] skip_canonicals_for_upgrade = false # when true, resolve the upgrade BASE without canonicals Path resolution: `$CCCI_SETTINGS` if set, else `/etc/cc-ci/settings.toml` (co-located with the deployed checkout `$CCCI_REPO=/etc/cc-ci`, alongside the tracked `settings.toml.example`; both the Drone recipe-CI runner and the nightly sweep read this same absolute host path). Validation: - absent / unreadable / malformed-TOML file → WARN + all-defaults (a bad file can NEVER crash the harness — a red config should degrade to today's behavior, not take down CI). - unknown table / unknown key → WARN-and-ignore (forward/typo tolerant). - present known key of the WRONG TYPE → raise TypeError (a typo'd value is loud, not silently mis-parsed) — this is the one "errors clearly" case, distinct from a malformed file. """ from __future__ import annotations import os import sys import tomllib from dataclasses import dataclass DEFAULT_PATH = "/etc/cc-ci/settings.toml" # The full schema: table -> {key: (type, default)}. SINGLE SOURCE OF TRUTH for defaults + validation. # Add future settings here (a new key, or a new table); `Settings` field names mirror the keys. _SCHEMA: dict[str, dict[str, tuple[type, object]]] = { "upgrade": { "skip_canonicals_for_upgrade": (bool, False), }, } @dataclass(frozen=True) class Settings: # [upgrade].skip_canonicals_for_upgrade — when True, resolve_upgrade_base skips the canonical # lookup entirely and takes the no-canonical release-tag-first fallback (phase settings §2.B/§2.C). skip_canonicals_for_upgrade: bool = False def _warn(msg: str) -> None: print(f"[settings] WARNING: {msg}", file=sys.stderr, flush=True) def _resolve_path() -> str: return os.environ.get("CCCI_SETTINGS") or DEFAULT_PATH def _check_type(table: str, key: str, value: object, typ: type) -> object: # bool is a subclass of int — reject an int given for a bool key (and vice-versa) so a stray # `1`/`0` for a flag is a loud error, not a silent truthy coercion. ok = isinstance(value, typ) and (typ is bool or not isinstance(value, bool)) if not ok: raise TypeError( f"settings: [{table}].{key} must be {typ.__name__}, " f"got {type(value).__name__} ({value!r})" ) return value def load(path: str | None = None) -> Settings: """Load settings from `path` (default: `$CCCI_SETTINGS` / DEFAULT_PATH). Returns all-defaults on an absent / unreadable / malformed file (WARN, never raises). Raises TypeError only on a present known key whose value is the wrong type.""" p = path or _resolve_path() raw: dict = {} if os.path.exists(p): try: with open(p, "rb") as f: raw = tomllib.load(f) except (OSError, tomllib.TOMLDecodeError) as e: _warn(f"could not read {p} ({e}); using defaults") raw = {} values: dict[str, object] = {} for table, keys in _SCHEMA.items(): section = raw.get(table, {}) if not isinstance(section, dict): _warn(f"[{table}] is not a table ({type(section).__name__}); ignoring it") section = {} for key, (typ, default) in keys.items(): if key in section: values[key] = _check_type(table, key, section[key], typ) else: values[key] = default # Forward/typo tolerant: warn on anything not in the schema, but never fail on it. for table, section in raw.items(): if table not in _SCHEMA: _warn(f"unknown table [{table}] ignored") continue if isinstance(section, dict): for key in section: if key not in _SCHEMA[table]: _warn(f"unknown key [{table}].{key} ignored") return Settings(**values) # type: ignore[arg-type] _CACHE: Settings | None = None def get() -> Settings: """Process-wide cached settings (read the file once). The harness calls this; tests call `load()` with an explicit path (or monkeypatch this) to bypass the cache.""" global _CACHE if _CACHE is None: _CACHE = load() return _CACHE