Files
cc-ci/runner/harness/settings.py
autonomic-bot cd19c1b172
Some checks failed
continuous-integration/drone/push Build is failing
feat(settings): server settings.toml loader + SKIP_CANONICALS_FOR_UPGRADE + release-tag-first no-canonical fallback
- harness/settings.py: stdlib tomllib loader, [upgrade].skip_canonicals_for_upgrade
  (bool, default false), _SCHEMA single-source defaults+validation; graceful on
  absent/malformed (WARN+defaults), warn-and-ignore unknown keys/tables, TypeError on
  wrong type. Path $CCCI_SETTINGS / /etc/cc-ci/settings.toml. + tracked settings.toml.example.
- resolve_upgrade_base: flag true bypasses the canonical lookup -> no-canonical fallback;
  canonical-present path (incl. samever step-back) unchanged when false.
- _no_canonical_base (always-on, §2.C): newest release tag < head (reuse
  warm_reconcile.newest_older_version) -> main-tip -> skip; replaces jump-to-main-tip.
- unit: full resolution matrix + loader tests; 315 unit pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:55:22 +00:00

119 lines
4.8 KiB
Python

"""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