diff --git a/runner/harness/abra.py b/runner/harness/abra.py index a45e45d..035cd06 100644 --- a/runner/harness/abra.py +++ b/runner/harness/abra.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import os +import re import subprocess ABRA = "abra" @@ -34,6 +35,29 @@ def recipe_dir(recipe: str) -> str: return os.path.join(abra_dir(), "recipes", recipe) +_VERSION_LABEL_RE = re.compile(r"coop-cloud\.[^.\s]*\.version=([^\s\"']+)") + + +def head_compose_version(recipe: str) -> str | None: + """The published version of the recipe's on-disk compose.yml (the head checkout under test): + the value of the `coop-cloud..version` label, e.g. "1.0.0+3.5.3". None if the file is + unreadable or carries no version label. + + Used by the upgrade-base resolver (phase samever) to compare the head's declared version against + the last-green warm-canonical version: when they are equal, deploying the canonical as the base + would be a vacuous same-version no-op, so the resolver steps back to an older published version. + `${STACK_NAME}` is a literal in the file (abra interpolates it at deploy time, not on disk), so + the regex matches it as the stack segment without needing interpolation.""" + path = os.path.join(recipe_dir(recipe), "compose.yml") + try: + with open(path) as f: + text = f.read() + except OSError: + return None + m = _VERSION_LABEL_RE.search(text) + return m.group(1).strip() if m else None + + def _run_pty( args: list[str], timeout: int = 900, check: bool = True ) -> subprocess.CompletedProcess: diff --git a/runner/run_recipe_ci.py b/runner/run_recipe_ci.py index b37f57c..a70d43e 100644 --- a/runner/run_recipe_ci.py +++ b/runner/run_recipe_ci.py @@ -42,6 +42,7 @@ from typing import NamedTuple ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(ROOT, "runner")) +import warm_reconcile # noqa: E402 (runner/ is on sys.path; owns coop-cloud version ordering) from harness import ( # noqa: E402 abra, canonical, @@ -108,11 +109,21 @@ class BasePlan(NamedTuple): return self.kind in ("version", "ref") -def resolve_upgrade_base(stages, meta, recipe: str, head_ref: str | None = None) -> BasePlan: +def resolve_upgrade_base( + stages, meta, recipe: str, head_ref: str | None = None, head_version: str | None = None +) -> BasePlan: """Dynamic upgrade-base resolution (phase prevb, replaces the static `recipe_versions[-2]` default). Order: explicit override → last-green (warm canonical) → target-branch (main) tip → skip. EXPECTED_NA[upgrade] / upgrade∉stages short-circuit to a declared skip first. + `head_version` is the head checkout's published version (the `coop-cloud..version` label; + see abra.head_compose_version). When the last-green warm-canonical version EQUALS it, deploying + the canonical as the base would be a vacuous same-version no-op, so the resolver STEPS BACK to the + newest published version strictly older than the head (phase samever) — the upgrade tier always + crosses a real version delta. This is the nightly STEADY STATE: a green cold-on-latest run promotes + canonical→latest, so the next night finds canonical == head and must step back. Skip only when no + older published predecessor exists. + last-green is the PRIMARY base — the version cc-ci last recorded green for this recipe (the warm-canonical registry record). main-tip is the FALLBACK: the recipe repo's `main` HEAD, the real predecessor the PR merges on top of, used when there is no last-green. Else the tier is @@ -135,11 +146,37 @@ def resolve_upgrade_base(stages, meta, recipe: str, head_ref: str | None = None) return BasePlan("version", override, None, "explicit UPGRADE_BASE_VERSION override") rec = canonical.read_registry(recipe) if rec and rec.get("version"): + canon = rec["version"] + same = head_version is not None and warm_reconcile.version_key( + canon + ) == warm_reconcile.version_key(head_version) + if not same: + # canonical ≠ head version (the common version-bump PR / nightly-with-new-version case): + # the green-verified primary base, unchanged from prevb. + return BasePlan( + "version", + canon, + None, + f"last-green (warm canonical, status={rec.get('status')})", + ) + # canonical == head version → deploying it would be a same-version no-op. Step back to the + # newest published version strictly older than the head (phase samever). + older = warm_reconcile.newest_older_version( + warm_reconcile.recipe_tags(recipe), head_version + ) + if older: + return BasePlan( + "version", + older, + None, + f"step-back: last-green canonical ({canon}) == head version {head_version}; " + f"newest older published base", + ) return BasePlan( - "version", - rec["version"], + "skip", None, - f"last-green (warm canonical, status={rec.get('status')})", + None, + f"base == head ({head_version}) and no older published predecessor", ) main_tip = lifecycle.recipe_branch_commit(recipe, "main") if main_tip and main_tip != head_ref: @@ -983,7 +1020,10 @@ def main() -> int: domain = naming.app_domain(recipe, os.environ.get("PR", "0"), ref) - base_plan = resolve_upgrade_base(stages, meta, recipe, head_ref=head_ref) + head_version = abra.head_compose_version(recipe) + base_plan = resolve_upgrade_base( + stages, meta, recipe, head_ref=head_ref, head_version=head_version + ) prev = base_plan.runs # gates the upgrade tier # base deploy target: a pinned published version (kind=version) or main-tip commit (kind=ref); # on skip fall back to the run's VERSION/head (target=None → chaos head deploy, as before). diff --git a/runner/warm_reconcile.py b/runner/warm_reconcile.py index 9a8e22e..ae5d67d 100644 --- a/runner/warm_reconcile.py +++ b/runner/warm_reconcile.py @@ -145,14 +145,31 @@ def is_version_tag(tag: str) -> bool: return bool(_VER_RE.match(tag.strip())) +def version_key(tag: str): + """Ordering key for a coop-cloud version tag: (recipe-semver tuple, app-version tuple). The single + source of version ordering — sort_versions / newest_older_version both key on this so the + upgrade-base step-back (phase samever) never hand-rolls semver comparison.""" + recipe, _, app = tag.partition("+") + return (_numtuple(recipe), _numtuple(app)) + + def sort_versions(tags) -> list[str]: """Sort coop-cloud version tags ascending by (recipe-semver tuple, app-version tuple).""" + return sorted([t for t in tags if is_version_tag(t)], key=version_key) - def key(t: str): - recipe, _, app = t.partition("+") - return (_numtuple(recipe), _numtuple(app)) - return sorted([t for t in tags if is_version_tag(t)], key=key) +def newest_older_version(tags, version: str | None) -> str | None: + """The newest published version tag STRICTLY OLDER than `version` (coop-cloud ordering), or None + when no such tag exists. Used by the upgrade-base resolver (phase samever): when the last-green + warm-canonical version equals the head version, the resolver steps back to this older base so the + upgrade tier always crosses a real version delta instead of deploying a same-version no-op. + "Strictly older" excludes any tag whose ordering key equals the head's (so a re-published or + differently-formatted equal version is never chosen).""" + if not version: + return None + target = version_key(version) + older = [t for t in sort_versions(tags) if version_key(t) < target] + return older[-1] if older else None def _numtuple(s: str) -> tuple: diff --git a/tests/unit/test_upgrade_base.py b/tests/unit/test_upgrade_base.py index 501ed10..18ad4c7 100644 --- a/tests/unit/test_upgrade_base.py +++ b/tests/unit/test_upgrade_base.py @@ -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 "+"), 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).