feat(prevb): dynamic upgrade base (last-green→main→skip) + per-recipe previous/ overlay; migrate discourse off static base + leaky overlay
All checks were successful
continuous-integration/drone/push Build is passing

- resolve_upgrade_base: BasePlan(kind=version|ref|skip); last-green (warm canonical) primary,
  main-tip fallback, declared skip else. UPGRADE_BASE_VERSION retained as optional override.
- deploy_app: base_ref path (chaos-deploy a main-tip/last-green commit) + apply_previous wiring.
- lifecycle: previous/ surface (has_previous, previous_target_version, previous_status decision,
  provide/remove overlay, compose_file add/remove, recipe_branch_commit, stack_service_names).
- generic.perform_upgrade: strip previous/ overlay + COMPOSE_FILE entry before head redeploy.
- discourse: compose.ccci.yml now environmental-only (order: stop-first); removed bitnamilegacy
  pins + sidekiq + UPGRADE_BASE_VERSION; test_upgrade.py asserts head image == official 3.5.3 + no sidekiq.
- unit tests: resolve_upgrade_base matrix + previous/ apply/skip/stale + COMPOSE_FILE layering.
This commit is contained in:
autonomic-bot
2026-06-17 00:14:53 +00:00
parent 1090abb97a
commit bb2e3c6b2c
8 changed files with 532 additions and 137 deletions

118
tests/unit/test_previous.py Normal file
View File

@ -0,0 +1,118 @@
"""Unit tests for the phase-prevb `previous/` overlay surface + COMPOSE_FILE layering (lifecycle).
Covers: the version-guarded apply/skip/stale decision (`previous_status`), the VERSION-marker reader
(`previous_target_version`), folder discovery (`has_previous`), and the pure COMPOSE_FILE add/remove
helpers used to layer compose.previous.yml onto the base deploy and strip it before the head redeploy.
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import lifecycle # noqa: E402
from harness import meta as meta_mod # noqa: E402
# ---- pure COMPOSE_FILE helpers --------------------------------------------------------------------
def test_compose_file_add_appends_when_absent():
assert (
lifecycle.compose_file_add("compose.yml:compose.ccci.yml", "compose.previous.yml")
== "compose.yml:compose.ccci.yml:compose.previous.yml"
)
def test_compose_file_add_idempotent():
cf = "compose.yml:compose.previous.yml"
assert lifecycle.compose_file_add(cf, "compose.previous.yml") == cf
def test_compose_file_add_defaults_missing_base():
assert (
lifecycle.compose_file_add("", "compose.previous.yml") == "compose.yml:compose.previous.yml"
)
def test_compose_file_remove_strips_only_overlay():
assert (
lifecycle.compose_file_remove(
"compose.yml:compose.ccci.yml:compose.previous.yml", "compose.previous.yml"
)
== "compose.yml:compose.ccci.yml"
)
def test_compose_file_remove_keeps_compose_yml_when_emptied():
assert (
lifecycle.compose_file_remove("compose.previous.yml", "compose.previous.yml")
== "compose.yml"
)
# ---- marker reader + discovery (filesystem, via a temp TESTS_DIR) ---------------------------------
def _stage(monkeypatch, tmp_path, recipe, *, compose=True, marker=None):
monkeypatch.setattr(meta_mod, "TESTS_DIR", str(tmp_path))
prev = tmp_path / recipe / "previous"
prev.mkdir(parents=True)
if compose:
(prev / "compose.previous.yml").write_text("services: {}\n")
if marker is not None:
(prev / "VERSION").write_text(marker)
return prev
def test_has_previous_true_only_with_compose(monkeypatch, tmp_path):
_stage(monkeypatch, tmp_path, "rx", compose=True)
assert lifecycle.has_previous("rx") is True
assert lifecycle.has_previous("other") is False
def test_previous_target_version_reads_first_real_line(monkeypatch, tmp_path):
_stage(monkeypatch, tmp_path, "rx", marker="# the previous published version\n\n0.7.0+3.3.1\n")
assert lifecycle.previous_target_version("rx") == "0.7.0+3.3.1"
def test_previous_target_version_none_without_marker(monkeypatch, tmp_path):
_stage(monkeypatch, tmp_path, "rx", marker=None)
assert lifecycle.previous_target_version("rx") is None
# ---- the apply/skip/stale decision matrix ---------------------------------------------------------
def test_previous_status_no_folder(monkeypatch):
monkeypatch.setattr(lifecycle, "has_previous", lambda r: False)
st = lifecycle.previous_status("rx", "version", "0.7.0+3.3.1")
assert st == {"apply": False, "stale": False, "reason": ""}
def test_previous_status_applies_on_version_match(monkeypatch):
monkeypatch.setattr(lifecycle, "has_previous", lambda r: True)
monkeypatch.setattr(lifecycle, "previous_target_version", lambda r: "0.7.0+3.3.1")
st = lifecycle.previous_status("rx", "version", "0.7.0+3.3.1")
assert st["apply"] is True and st["stale"] is False
def test_previous_status_stale_on_version_mismatch(monkeypatch):
monkeypatch.setattr(lifecycle, "has_previous", lambda r: True)
monkeypatch.setattr(lifecycle, "previous_target_version", lambda r: "0.6.0+3.1.1")
st = lifecycle.previous_status("rx", "version", "0.7.0+3.3.1")
assert st["apply"] is False and st["stale"] is True and "stale" in st["reason"]
def test_previous_status_stale_on_main_tip_base(monkeypatch):
# previous/ can only repair a pinned published base; a main-tip (ref) base flags it for review.
monkeypatch.setattr(lifecycle, "has_previous", lambda r: True)
st = lifecycle.previous_status("rx", "ref", None)
assert st["apply"] is False and st["stale"] is True
def test_previous_status_stale_without_marker(monkeypatch):
monkeypatch.setattr(lifecycle, "has_previous", lambda r: True)
monkeypatch.setattr(lifecycle, "previous_target_version", lambda r: None)
st = lifecycle.previous_status("rx", "version", "0.7.0+3.3.1")
assert st["apply"] is False and st["stale"] is True and "marker" in st["reason"]

View File

@ -1,12 +1,9 @@
"""Unit tests for `run_recipe_ci.upgrade_base` — the deploy-once base-version decision.
"""Unit tests for `run_recipe_ci.resolve_upgrade_base` — the DYNAMIC upgrade-base decision (phase prevb).
Phase bsky: a recipe whose published versions ALL pin a moving image tag that upstream
republished with incompatible builds (bluesky-pds) has no deployable upgrade base — deploying
one fails the INSTALL tier before the PR head is ever exercised. The sanctioned escape hatch is
the EXISTING declared-intentional-skip mechanism: EXPECTED_NA["upgrade"] now also suppresses
the base deploy (single deploy = PR head; the tier records "skip"; derive_rungs classifies it
intentional with the declared reason). These tests lock the decision matrix; derive_rungs'
classification of the resulting skip is already covered in test_results.py.
Resolution order: upgrade∉stages / EXPECTED_NA[upgrade] (declared skip) → explicit UPGRADE_BASE_VERSION
override → last-green (warm canonical) → target-branch (`main`) tip → skip (no predecessor). The result
is a `BasePlan(kind, version, ref, reason)`: kind ∈ {"version", "ref", "skip"}; `.runs` is True for
version/ref (the upgrade tier runs). Replaces the old static `recipe_versions[-2]` default.
"""
from __future__ import annotations
@ -17,63 +14,92 @@ from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
import run_recipe_ci # noqa: E402
from harness import lifecycle # noqa: E402
from harness import canonical, lifecycle # noqa: E402
ALL = {"install", "upgrade", "backup", "restore", "custom"}
HEAD = "aaaa1111head"
MAIN = "bbbb2222main"
def _meta(expected_na=None, upgrade_base_version=None):
return SimpleNamespace(EXPECTED_NA=expected_na, UPGRADE_BASE_VERSION=upgrade_base_version)
def test_default_prev_published(monkeypatch):
# upgrade in stages, ≥2 published versions, nothing declared → recipe_versions[-2]
monkeypatch.setattr(lifecycle, "previous_version", lambda r: "0.1.0+v1")
assert run_recipe_ci.upgrade_base(ALL, _meta(), "somerecipe") == "0.1.0+v1"
def _no_canonical(monkeypatch):
monkeypatch.setattr(canonical, "read_registry", lambda r: None)
def test_single_published_version_no_base(monkeypatch):
monkeypatch.setattr(lifecycle, "previous_version", lambda r: None)
assert run_recipe_ci.upgrade_base(ALL, _meta(), "somerecipe") is None
def _no_main(monkeypatch):
monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": None)
def test_upgrade_not_in_stages_no_base(monkeypatch):
def test_upgrade_not_in_stages_skip(monkeypatch):
# never consults canonical/main when upgrade isn't requested
monkeypatch.setattr(
lifecycle,
"previous_version",
lambda r: (_ for _ in ()).throw(AssertionError("not consulted")),
canonical, "read_registry", lambda r: (_ for _ in ()).throw(AssertionError("not consulted"))
)
assert run_recipe_ci.upgrade_base(ALL - {"upgrade"}, _meta(), "somerecipe") is None
plan = run_recipe_ci.resolve_upgrade_base(ALL - {"upgrade"}, _meta(), "somerecipe")
assert plan.kind == "skip" and not plan.runs
def test_upgrade_base_version_override_wins(monkeypatch):
def test_expected_na_upgrade_skip_even_with_canonical_and_override(monkeypatch):
# EXPECTED_NA[upgrade] is the strongest, declared fact — short-circuits before override/canonical.
monkeypatch.setattr(
lifecycle,
"previous_version",
lambda r: (_ for _ in ()).throw(AssertionError("not consulted")),
canonical, "read_registry", lambda r: {"version": "9.9.9", "status": "warm"}
)
meta = _meta(upgrade_base_version="0.7.0+3.3.1")
assert run_recipe_ci.upgrade_base(ALL, meta, "discourse") == "0.7.0+3.3.1"
def test_expected_na_upgrade_suppresses_base(monkeypatch):
# bluesky-pds shape: published versions exist but are undeployable — declared EXPECTED_NA
# upgrade → NO base (single deploy is the PR head), even though previous_version would
# return one and even if UPGRADE_BASE_VERSION is set (the declaration is the stronger,
# documented fact).
monkeypatch.setattr(lifecycle, "previous_version", lambda r: "0.1.1+v0.4")
declared = {"upgrade": "no deployable upgrade base (moving tag republished)"}
assert run_recipe_ci.upgrade_base(ALL, _meta(expected_na=declared), "bluesky-pds") is None
assert (
run_recipe_ci.upgrade_base(
ALL, _meta(expected_na=declared, upgrade_base_version="0.2.0+v0.4"), "bluesky-pds"
)
is None
plan = run_recipe_ci.resolve_upgrade_base(
ALL, _meta(expected_na=declared, upgrade_base_version="0.2.0+v0.4"), "bluesky-pds"
)
assert plan.kind == "skip" and "EXPECTED_NA" in plan.reason
def test_expected_na_other_rung_does_not_suppress(monkeypatch):
# an EXPECTED_NA for backup_restore (custom-html-tiny shape) must NOT touch the upgrade base
monkeypatch.setattr(lifecycle, "previous_version", lambda r: "0.1.0+v1")
meta = _meta(expected_na={"backup_restore": "stateless"})
assert run_recipe_ci.upgrade_base(ALL, meta, "custom-html-tiny") == "0.1.0+v1"
def test_explicit_override_wins_over_canonical(monkeypatch):
monkeypatch.setattr(
canonical, "read_registry", lambda r: {"version": "9.9.9", "status": "warm"}
)
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(upgrade_base_version="0.7.0+3.3.1"), "discourse"
)
assert plan.kind == "version" and plan.version == "0.7.0+3.3.1" and plan.runs
def test_last_green_warm_canonical_is_primary(monkeypatch):
# no override → last-green (warm canonical version) is the PRIMARY base; main is not consulted.
monkeypatch.setattr(
canonical, "read_registry", lambda r: {"version": "0.6.0+3.1.1", "status": "idle"}
)
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)
assert plan.kind == "version" and plan.version == "0.6.0+3.1.1" and "last-green" in plan.reason
def test_main_tip_fallback_when_no_last_green(monkeypatch):
_no_canonical(monkeypatch)
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 and plan.version is None and plan.runs
def test_head_equals_main_tip_skip(monkeypatch):
# the PR head IS the main tip (empty PR / LATEST run) → no real predecessor → declared skip.
_no_canonical(monkeypatch)
monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": HEAD)
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "discourse", head_ref=HEAD)
assert plan.kind == "skip" and "main tip" in plan.reason and not plan.runs
def test_no_canonical_no_main_skip(monkeypatch):
_no_canonical(monkeypatch)
_no_main(monkeypatch)
plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "brandnew", head_ref=HEAD)
assert plan.kind == "skip" and "no predecessor" in plan.reason