feat(samever): step back to older base when last-green canonical == head version

resolve_upgrade_base now reads the head's published version (abra.head_compose_version,
the coop-cloud.<stack>.version label) and, when the last-green warm-canonical version
equals it, steps back to the newest published version strictly older than head instead
of deploying a same-version no-op. warm_reconcile gains version_key + newest_older_version
(single coop-cloud ordering source; sort_versions refactored onto version_key, no behavior
change). Skip only when no older published predecessor exists. Step-back returns kind=version
so it inherits F1d-2 pinned-tag checkout. Extends tests/unit/test_upgrade_base.py (13 pass).
This commit is contained in:
autonomic-bot
2026-06-17 04:24:14 +00:00
parent 279d84d229
commit b29bb3f804
4 changed files with 175 additions and 9 deletions

View File

@ -14,6 +14,7 @@ from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
import run_recipe_ci # noqa: E402
import warm_reconcile # noqa: E402
from harness import canonical, lifecycle # noqa: E402
ALL = {"install", "upgrade", "backup", "restore", "custom"}
@ -105,6 +106,90 @@ def test_no_canonical_no_main_skip(monkeypatch):
assert plan.kind == "skip" and "no predecessor" in plan.reason
# --- phase samever: step back to an older base when canonical == head version ---
# A realistic keycloak tag list (coop-cloud "<recipe-semver>+<app-version>"), out of order on purpose
# so the test exercises the ordering, not list position.
KC_TAGS = ["10.6.0+26.5.0", "10.8.0+26.6.3", "10.7.1+26.6.2", "10.7.0+26.6.0", "not-a-version"]
def test_canonical_equals_head_steps_back_to_newest_older(monkeypatch):
# nightly steady state: a green cold-on-latest run promoted canonical→latest, so the next night
# finds canonical == head version. The resolver must NOT deploy the same version — it steps back
# to the newest published version STRICTLY OLDER than the head.
head_v = "10.8.0+26.6.3" # == canonical == latest tag
monkeypatch.setattr(canonical, "read_registry", lambda r: {"version": head_v, "status": "warm"})
monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: KC_TAGS)
# main must never be consulted on the step-back (canonical path owns the decision)
monkeypatch.setattr(
lifecycle,
"recipe_branch_commit",
lambda r, b="main": (_ for _ in ()).throw(AssertionError("not consulted")),
)
plan = run_recipe_ci.resolve_upgrade_base(
ALL, _meta(), "keycloak", head_ref=HEAD, head_version=head_v
)
assert plan.kind == "version" and plan.runs
assert plan.version == "10.7.1+26.6.2" # newest tag strictly older than 10.8.0+26.6.3
# strictly older than head (the load-bearing invariant)
assert warm_reconcile.version_key(plan.version) < warm_reconcile.version_key(head_v)
assert "step-back" in plan.reason
def test_canonical_differs_from_head_uses_canonical_unchanged(monkeypatch):
# the common version-bump case: canonical (older) ≠ head version → primary base unchanged; the
# step-back never triggers, recipe_tags never consulted.
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("step-back not taken")),
)
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"
assert "last-green" in plan.reason
def test_canonical_equals_head_no_older_published_skips(monkeypatch):
# canonical == head and it is the ONLY (oldest) published version → genuinely no predecessor →
# declared skip with the samever reason (never a same-version no-op).
head_v = "1.0.0+3.5.3"
monkeypatch.setattr(canonical, "read_registry", lambda r: {"version": head_v, "status": "warm"})
monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: [head_v]) # only the head version
plan = run_recipe_ci.resolve_upgrade_base(
ALL, _meta(), "discourse", head_ref=HEAD, head_version=head_v
)
assert plan.kind == "skip" and not plan.runs
assert "no older published predecessor" in plan.reason
def test_no_head_version_preserves_canonical_primary(monkeypatch):
# head_version unreadable (None) → cannot compare → preserve prevb behavior: canonical is primary,
# never a step-back (no regression for callers that don't pass head_version).
monkeypatch.setattr(
canonical, "read_registry", lambda r: {"version": "10.8.0+26.6.3", "status": "warm"}
)
monkeypatch.setattr(
warm_reconcile,
"recipe_tags",
lambda r: (_ for _ in ()).throw(AssertionError("step-back not taken")),
)
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "keycloak", head_ref=HEAD)
assert plan.kind == "version" and plan.version == "10.8.0+26.6.3"
def test_newest_older_version_ordering():
# the ordering helper picks the correct strictly-older tag and excludes the equal one.
assert warm_reconcile.newest_older_version(KC_TAGS, "10.8.0+26.6.3") == "10.7.1+26.6.2"
assert warm_reconcile.newest_older_version(KC_TAGS, "10.7.0+26.6.0") == "10.6.0+26.5.0"
assert warm_reconcile.newest_older_version(KC_TAGS, "10.6.0+26.5.0") is None # oldest → none
assert warm_reconcile.newest_older_version(KC_TAGS, None) is None
def test_expected_na_other_rung_does_not_suppress_upgrade(monkeypatch):
# an EXPECTED_NA for a DIFFERENT rung (backup_restore) must NOT short-circuit the upgrade base —
# resolution proceeds to last-green/main-tip (custom-html-tiny shape).