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:
@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
ABRA = "abra"
|
ABRA = "abra"
|
||||||
@ -34,6 +35,29 @@ def recipe_dir(recipe: str) -> str:
|
|||||||
return os.path.join(abra_dir(), "recipes", recipe)
|
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.<stack>.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(
|
def _run_pty(
|
||||||
args: list[str], timeout: int = 900, check: bool = True
|
args: list[str], timeout: int = 900, check: bool = True
|
||||||
) -> subprocess.CompletedProcess:
|
) -> subprocess.CompletedProcess:
|
||||||
|
|||||||
@ -42,6 +42,7 @@ from typing import NamedTuple
|
|||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
sys.path.insert(0, os.path.join(ROOT, "runner"))
|
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
|
from harness import ( # noqa: E402
|
||||||
abra,
|
abra,
|
||||||
canonical,
|
canonical,
|
||||||
@ -108,11 +109,21 @@ class BasePlan(NamedTuple):
|
|||||||
return self.kind in ("version", "ref")
|
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]`
|
"""Dynamic upgrade-base resolution (phase prevb, replaces the static `recipe_versions[-2]`
|
||||||
default). Order: explicit override → last-green (warm canonical) → target-branch (main) tip →
|
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.
|
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.<stack>.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
|
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
|
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
|
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")
|
return BasePlan("version", override, None, "explicit UPGRADE_BASE_VERSION override")
|
||||||
rec = canonical.read_registry(recipe)
|
rec = canonical.read_registry(recipe)
|
||||||
if rec and rec.get("version"):
|
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(
|
return BasePlan(
|
||||||
"version",
|
"skip",
|
||||||
rec["version"],
|
|
||||||
None,
|
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")
|
main_tip = lifecycle.recipe_branch_commit(recipe, "main")
|
||||||
if main_tip and main_tip != head_ref:
|
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)
|
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
|
prev = base_plan.runs # gates the upgrade tier
|
||||||
# base deploy target: a pinned published version (kind=version) or main-tip commit (kind=ref);
|
# 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).
|
# on skip fall back to the run's VERSION/head (target=None → chaos head deploy, as before).
|
||||||
|
|||||||
@ -145,14 +145,31 @@ def is_version_tag(tag: str) -> bool:
|
|||||||
return bool(_VER_RE.match(tag.strip()))
|
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]:
|
def sort_versions(tags) -> list[str]:
|
||||||
"""Sort coop-cloud version tags ascending by (recipe-semver tuple, app-version tuple)."""
|
"""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:
|
def _numtuple(s: str) -> tuple:
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||||
import run_recipe_ci # noqa: E402
|
import run_recipe_ci # noqa: E402
|
||||||
|
import warm_reconcile # noqa: E402
|
||||||
from harness import canonical, lifecycle # noqa: E402
|
from harness import canonical, lifecycle # noqa: E402
|
||||||
|
|
||||||
ALL = {"install", "upgrade", "backup", "restore", "custom"}
|
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
|
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):
|
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 —
|
# 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).
|
# resolution proceeds to last-green/main-tip (custom-html-tiny shape).
|
||||||
|
|||||||
Reference in New Issue
Block a user