Some checks failed
continuous-integration/drone/push Build is failing
- 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>
119 lines
4.8 KiB
Python
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
|