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

View File

@ -38,6 +38,7 @@ import subprocess
import sys
import tempfile
import time
from typing import NamedTuple
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "runner"))
@ -88,36 +89,66 @@ def sso_dep_unverified(declared, deps_ready: bool, requires_deps_skipped: int) -
return bool(declared) and not deps_ready and requires_deps_skipped > 0
def upgrade_base(stages, meta, recipe: str) -> str | None:
"""Deploy-once base version decision (pure given meta + the published-version lookup):
previous published version when the upgrade tier will run and one exists (so upgrade goes
previous→target in place), else None (the caller falls back to the target / PR head).
(DECISIONS.)
class BasePlan(NamedTuple):
"""Resolved upgrade-base decision (phase prevb). `kind`:
- "version" → deploy a pinned published version (`version`): an explicit UPGRADE_BASE_VERSION
override, or last-green (warm-canonical) version. `previous/` may apply (version-guarded).
- "ref" → deploy the target-branch (main) tip at commit `ref` (chaos): the true predecessor
the PR merges onto, used when there is no last-green. `previous/` never applies to a ref base.
- "skip" → no upgrade base; the single deploy is the PR head and the upgrade tier records a
declared skip with `reason` (upgrade∉stages / EXPECTED_NA / new recipe / head==main tip)."""
A recipe may override the base via recipe_meta UPGRADE_BASE_VERSION when the harness default
(recipe_versions[-2]) is NOT the PR's true predecessor — e.g. a PR that adds a version ABOVE the
newest published tag, where the correct base is [-1] (the newest published), not [-2]. The
override must be an exact published version tag (deployed as a pinned base). (Adversary §7.1.)
kind: str
version: str | None
ref: str | None
reason: str
A recipe that declares the upgrade rung in EXPECTED_NA gets NO base: published versions may
exist yet be genuinely undeployable — e.g. bluesky-pds, where every published tag pins the
moving image tag `:0.4` that upstream republished with incompatible main builds, so no
published version can come up as an upgrade base (phase bsky, DECISIONS). Deploying one would
fail the INSTALL tier before the PR-head code is ever exercised. With no base, the single
deploy is the PR head itself and the upgrade tier records "skip", which derive_rungs
classifies as the DECLARED intentional skip (reason from EXPECTED_NA — visible in
results.json `skips.intentional`, never reported as a pass)."""
@property
def runs(self) -> bool:
return self.kind in ("version", "ref")
def resolve_upgrade_base(stages, meta, recipe: str, head_ref: 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.
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
skipped with a recorded reason (structural, declared — not a silent pass).
`UPGRADE_BASE_VERSION` is RETAINED as an optional explicit override (wins when set) for the rare
PR-adds-a-version-above-the-newest-tag case; it is no longer the default (DECISIONS prevb)."""
if "upgrade" not in stages:
return None
if "upgrade" in (meta.EXPECTED_NA or {}):
return BasePlan("skip", None, None, "upgrade tier not in requested stages")
declared = (meta.EXPECTED_NA or {}).get("upgrade")
if declared:
print(
"== upgrade tier: declared EXPECTED_NA['upgrade'] — no upgrade base will be "
f"deployed; the single deploy is the target/PR head. Reason: "
f"{(meta.EXPECTED_NA or {}).get('upgrade')}",
f"== upgrade tier: declared EXPECTED_NA['upgrade'] — single deploy is the PR head. "
f"Reason: {declared}",
flush=True,
)
return None
return meta.UPGRADE_BASE_VERSION or lifecycle.previous_version(recipe)
return BasePlan("skip", None, None, f"declared EXPECTED_NA[upgrade]: {declared}")
override = getattr(meta, "UPGRADE_BASE_VERSION", None)
if override:
return BasePlan("version", override, None, "explicit UPGRADE_BASE_VERSION override")
rec = canonical.read_registry(recipe)
if rec and rec.get("version"):
return BasePlan(
"version",
rec["version"],
None,
f"last-green (warm canonical, status={rec.get('status')})",
)
main_tip = lifecycle.recipe_branch_commit(recipe, "main")
if main_tip and main_tip != head_ref:
return BasePlan("ref", None, main_tip, "target-branch (main) tip")
if main_tip and main_tip == head_ref:
return BasePlan("skip", None, None, "head == main tip (no predecessor delta)")
return BasePlan(
"skip", None, None, "no last-green and no main tip (new recipe / no predecessor)"
)
def _truthy(v: str | None) -> bool:
@ -952,8 +983,25 @@ def main() -> int:
domain = naming.app_domain(recipe, os.environ.get("PR", "0"), ref)
prev = upgrade_base(stages, meta, recipe)
base = prev or target
base_plan = resolve_upgrade_base(stages, meta, recipe, head_ref=head_ref)
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).
base = base_plan.version or target
base_ref = base_plan.ref
prev_status = lifecycle.previous_status(recipe, base_plan.kind, base_plan.version)
print(
f"== upgrade base: kind={base_plan.kind} "
f"{('version=' + base) if base_plan.kind == 'version' else ''}"
f"{('ref=' + (base_ref or '')[:12]) if base_plan.kind == 'ref' else ''}"
f"{(' SKIP: ' + base_plan.reason) if base_plan.kind == 'skip' else ''} "
f"({base_plan.reason if base_plan.kind != 'skip' else ''})",
flush=True,
)
if prev_status["stale"]:
print(f"!! previous/ STALE — {prev_status['reason']}", flush=True)
elif prev_status["apply"]:
print(f"== previous/ applies to the base deploy (targets {base})", flush=True)
backup_cap = generic.backup_capable(recipe, meta)
hook = discovery.install_steps(recipe, repo_local)
@ -1051,6 +1099,8 @@ def main() -> int:
recipe,
domain,
version=base,
base_ref=base_ref,
apply_previous=prev_status["apply"],
secrets=True,
install_steps_hook=hook,
deploy_timeout=int(meta.DEPLOY_TIMEOUT),
@ -1129,7 +1179,7 @@ def main() -> int:
junit_dir=junit_dir,
)
if prev
else "skip" # no upgrade base: single published version, or declared EXPECTED_NA
else "skip" # base_plan.kind == "skip": no predecessor / EXPECTED_NA / head==main
)
# ---- BACKUP + RESTORE tiers (backup-capable only; else clean N/A) ----
if "backup" in stages: