feat(settings): server settings.toml loader + SKIP_CANONICALS_FOR_UPGRADE + release-tag-first no-canonical fallback
Some checks failed
continuous-integration/drone/push Build is failing
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>
This commit is contained in:
111
tests/unit/test_settings.py
Normal file
111
tests/unit/test_settings.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Unit tests for `harness.settings` — the minimal, extensible server-level TOML config loader
|
||||
(phase settings). Stdlib `tomllib`; defaults baked in; absent/malformed file degrades to defaults
|
||||
(never crashes the harness); unknown keys warn-and-ignore; a present known key of the wrong type
|
||||
errors clearly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import settings # noqa: E402
|
||||
|
||||
|
||||
def _write(tmp_path, text: str) -> str:
|
||||
p = tmp_path / "settings.toml"
|
||||
p.write_text(text)
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_absent_file_yields_defaults(tmp_path):
|
||||
# a path that does not exist → all-defaults, no exception
|
||||
missing = str(tmp_path / "nope.toml")
|
||||
s = settings.load(missing)
|
||||
assert s.skip_canonicals_for_upgrade is False
|
||||
|
||||
|
||||
def test_absent_key_yields_default(tmp_path):
|
||||
# present file, present table, but the key omitted → default
|
||||
p = _write(tmp_path, "[upgrade]\n")
|
||||
assert settings.load(p).skip_canonicals_for_upgrade is False
|
||||
|
||||
|
||||
def test_empty_file_yields_defaults(tmp_path):
|
||||
p = _write(tmp_path, "")
|
||||
assert settings.load(p).skip_canonicals_for_upgrade is False
|
||||
|
||||
|
||||
def test_flag_true_read(tmp_path):
|
||||
p = _write(tmp_path, "[upgrade]\nskip_canonicals_for_upgrade = true\n")
|
||||
assert settings.load(p).skip_canonicals_for_upgrade is True
|
||||
|
||||
|
||||
def test_flag_false_read(tmp_path):
|
||||
p = _write(tmp_path, "[upgrade]\nskip_canonicals_for_upgrade = false\n")
|
||||
assert settings.load(p).skip_canonicals_for_upgrade is False
|
||||
|
||||
|
||||
def test_malformed_toml_degrades_to_defaults(tmp_path):
|
||||
# syntactically broken TOML must NOT crash the harness — WARN + defaults.
|
||||
p = _write(tmp_path, "[upgrade\nskip_canonicals_for_upgrade = tru")
|
||||
s = settings.load(p) # must not raise
|
||||
assert s.skip_canonicals_for_upgrade is False
|
||||
|
||||
|
||||
def test_wrong_type_errors_clearly(tmp_path):
|
||||
# a present key of the wrong type is a loud, actionable error (distinct from a malformed file).
|
||||
p = _write(tmp_path, '[upgrade]\nskip_canonicals_for_upgrade = "yes"\n')
|
||||
with pytest.raises(TypeError) as e:
|
||||
settings.load(p)
|
||||
assert "skip_canonicals_for_upgrade" in str(e.value)
|
||||
assert "bool" in str(e.value)
|
||||
|
||||
|
||||
def test_int_not_accepted_for_bool(tmp_path):
|
||||
# bool is an int subclass — a stray 1/0 must not silently coerce to a flag.
|
||||
p = _write(tmp_path, "[upgrade]\nskip_canonicals_for_upgrade = 1\n")
|
||||
with pytest.raises(TypeError):
|
||||
settings.load(p)
|
||||
|
||||
|
||||
def test_unknown_key_warns_and_ignored(tmp_path, capsys):
|
||||
p = _write(
|
||||
tmp_path,
|
||||
"[upgrade]\nskip_canonicals_for_upgrade = true\nfuture_knob = 7\n",
|
||||
)
|
||||
s = settings.load(p)
|
||||
assert s.skip_canonicals_for_upgrade is True # known key still honored
|
||||
err = capsys.readouterr().err
|
||||
assert "unknown key" in err and "future_knob" in err
|
||||
|
||||
|
||||
def test_unknown_table_warns_and_ignored(tmp_path, capsys):
|
||||
p = _write(tmp_path, "[future_section]\nx = 1\n")
|
||||
s = settings.load(p)
|
||||
assert s.skip_canonicals_for_upgrade is False
|
||||
err = capsys.readouterr().err
|
||||
assert "unknown table" in err and "future_section" in err
|
||||
|
||||
|
||||
def test_non_table_section_ignored(tmp_path, capsys):
|
||||
# a key named like a table but given a scalar — warn, ignore, fall back to defaults for that table.
|
||||
p = _write(tmp_path, "upgrade = 5\n")
|
||||
s = settings.load(p)
|
||||
assert s.skip_canonicals_for_upgrade is False
|
||||
assert "not a table" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_env_var_path_override(tmp_path, monkeypatch):
|
||||
p = _write(tmp_path, "[upgrade]\nskip_canonicals_for_upgrade = true\n")
|
||||
monkeypatch.setenv("CCCI_SETTINGS", p)
|
||||
assert settings.load().skip_canonicals_for_upgrade is True
|
||||
|
||||
|
||||
def test_default_path_is_absolute_host_path():
|
||||
# the live file is an absolute host override co-located with the deployed checkout.
|
||||
assert settings.DEFAULT_PATH == "/etc/cc-ci/settings.toml"
|
||||
assert os.path.isabs(settings.DEFAULT_PATH)
|
||||
@ -13,6 +13,8 @@ import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
import run_recipe_ci # noqa: E402
|
||||
import warm_reconcile # noqa: E402
|
||||
@ -23,6 +25,25 @@ HEAD = "aaaa1111head"
|
||||
MAIN = "bbbb2222main"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _default_flag_false(monkeypatch):
|
||||
# Hermetic: SKIP_CANONICALS_FOR_UPGRADE defaults to false regardless of any host settings.toml.
|
||||
# The few flag-true tests re-patch this within the test body.
|
||||
monkeypatch.setattr(
|
||||
run_recipe_ci.settings_mod,
|
||||
"get",
|
||||
lambda: SimpleNamespace(skip_canonicals_for_upgrade=False),
|
||||
)
|
||||
|
||||
|
||||
def _set_flag(monkeypatch, value: bool):
|
||||
monkeypatch.setattr(
|
||||
run_recipe_ci.settings_mod,
|
||||
"get",
|
||||
lambda: SimpleNamespace(skip_canonicals_for_upgrade=value),
|
||||
)
|
||||
|
||||
|
||||
def _meta(expected_na=None):
|
||||
return SimpleNamespace(EXPECTED_NA=expected_na)
|
||||
|
||||
@ -182,3 +203,119 @@ def test_expected_na_other_rung_does_not_suppress_upgrade(monkeypatch):
|
||||
meta = _meta(expected_na={"backup_restore": "stateless"})
|
||||
plan = run_recipe_ci.resolve_upgrade_base(ALL, meta, "custom-html-tiny", head_ref=HEAD)
|
||||
assert plan.kind == "ref" and plan.ref == MAIN
|
||||
|
||||
|
||||
# --- phase settings: improved no-canonical fallback (release tag before main-tip) + the flag ---
|
||||
|
||||
|
||||
def test_no_canonical_prefers_release_tag_over_main_tip(monkeypatch):
|
||||
# flag false + NO canonical → the base must be the newest release TAG strictly older than head
|
||||
# (a real published predecessor), NOT the raw main-tip. main must not even be consulted.
|
||||
_no_canonical(monkeypatch)
|
||||
monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: KC_TAGS)
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"recipe_branch_commit",
|
||||
lambda r, b="main": (_ for _ in ()).throw(AssertionError("main consulted before tag")),
|
||||
)
|
||||
plan = run_recipe_ci.resolve_upgrade_base(
|
||||
ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
|
||||
)
|
||||
assert plan.kind == "version" and plan.runs
|
||||
assert plan.version == "10.7.1+26.6.2" # newest tag strictly older than head
|
||||
assert warm_reconcile.version_key(plan.version) < warm_reconcile.version_key("10.8.0+26.6.3")
|
||||
assert "no-canonical fallback" in plan.reason and "release tag" in plan.reason
|
||||
|
||||
|
||||
def test_no_canonical_no_older_tag_falls_back_to_main_tip(monkeypatch):
|
||||
# flag false + no canonical + no release tag strictly older than head → raw main-tip is the
|
||||
# FURTHER fallback (a recipe whose only tag IS the head version, e.g. brand-new single release).
|
||||
_no_canonical(monkeypatch)
|
||||
monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: ["10.8.0+26.6.3"]) # == head only
|
||||
monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
|
||||
plan = run_recipe_ci.resolve_upgrade_base(
|
||||
ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
|
||||
)
|
||||
assert plan.kind == "ref" and plan.ref == MAIN and plan.version is None
|
||||
|
||||
|
||||
def test_no_canonical_no_tag_no_main_skips(monkeypatch):
|
||||
# no canonical, no release tag, no main → declared skip (new recipe / no predecessor).
|
||||
_no_canonical(monkeypatch)
|
||||
monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: [])
|
||||
_no_main(monkeypatch)
|
||||
plan = run_recipe_ci.resolve_upgrade_base(
|
||||
ALL, _meta(), "brandnew", head_ref=HEAD, head_version="1.0.0"
|
||||
)
|
||||
assert plan.kind == "skip" and not plan.runs and "no predecessor" in plan.reason
|
||||
|
||||
|
||||
def test_no_head_version_skips_tag_lookup_uses_main(monkeypatch):
|
||||
# no canonical AND no head_version (unreadable) → cannot compare versions, so recipe_tags is NOT
|
||||
# consulted; fall straight through to main-tip (preserves prevb behavior for that caller shape).
|
||||
_no_canonical(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
warm_reconcile,
|
||||
"recipe_tags",
|
||||
lambda r: (_ for _ in ()).throw(AssertionError("tags consulted without head_version")),
|
||||
)
|
||||
monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
|
||||
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "discourse", head_ref=HEAD)
|
||||
assert plan.kind == "ref" and plan.ref == MAIN
|
||||
|
||||
|
||||
def test_flag_true_bypasses_canonical_into_release_tag_fallback(monkeypatch):
|
||||
# SKIP_CANONICALS_FOR_UPGRADE=true: a canonical-bearing recipe (canonical ≠ head, would normally
|
||||
# resolve to the canonical) instead bypasses the canonical entirely and takes the release-tag
|
||||
# fallback → newest release tag < head.
|
||||
_set_flag(monkeypatch, True)
|
||||
monkeypatch.setattr(
|
||||
canonical, "read_registry", lambda r: {"version": "10.6.0+26.5.0", "status": "warm"}
|
||||
)
|
||||
monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: KC_TAGS)
|
||||
plan = run_recipe_ci.resolve_upgrade_base(
|
||||
ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
|
||||
)
|
||||
assert plan.kind == "version" and plan.runs
|
||||
assert plan.version == "10.7.1+26.6.2" # release tag, NOT the canonical 10.6.0+26.5.0
|
||||
assert "no-canonical fallback" in plan.reason
|
||||
|
||||
|
||||
def test_flag_true_canonical_present_no_older_tag_uses_main(monkeypatch):
|
||||
# flag true bypasses the canonical; with no older release tag, the further fallback is main-tip
|
||||
# (proves the flag genuinely routes through the full no-canonical chain, not just step 1).
|
||||
_set_flag(monkeypatch, True)
|
||||
monkeypatch.setattr(
|
||||
canonical, "read_registry", lambda r: {"version": "10.8.0+26.6.3", "status": "warm"}
|
||||
)
|
||||
monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: ["10.8.0+26.6.3"]) # == head only
|
||||
monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
|
||||
plan = run_recipe_ci.resolve_upgrade_base(
|
||||
ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
|
||||
)
|
||||
assert plan.kind == "ref" and plan.ref == MAIN
|
||||
|
||||
|
||||
def test_flag_false_canonical_present_unchanged(monkeypatch):
|
||||
# explicit guardrail check: flag false + canonical present (≠ head) → canonical, byte-for-byte the
|
||||
# prevb behavior; recipe_tags / main never consulted.
|
||||
_set_flag(monkeypatch, False)
|
||||
monkeypatch.setattr(
|
||||
canonical, "read_registry", lambda r: {"version": "10.7.1+26.6.2", "status": "warm"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
warm_reconcile,
|
||||
"recipe_tags",
|
||||
lambda r: (_ for _ in ()).throw(AssertionError("fallback taken with canonical present")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"recipe_branch_commit",
|
||||
lambda r, b="main": (_ for _ in ()).throw(AssertionError("main consulted")),
|
||||
)
|
||||
plan = run_recipe_ci.resolve_upgrade_base(
|
||||
ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
|
||||
)
|
||||
assert (
|
||||
plan.kind == "version" and plan.version == "10.7.1+26.6.2" and "last-green" in plan.reason
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user