From 9510de60db9a0369135435f1bd748142f604a718 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:07:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(frontdoor):=20instant=20rollback=20?= =?UTF-8?q?=E2=80=94=20re-point=20to=20a=20prior=20release's=20immutable?= =?UTF-8?q?=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Vercel/Heroku deploy ergonomic trio (push -> preview -> promote -> ROLLBACK). Rollback was only implied by the promotion gate; now it is real, and better than a plain PaaS's, because every deploy is a content-addressed immutable image (a data sphere): tools/release_ledger.py — a sealed release ledger + instant rollback: - a release binds (tenant, app, branch, image_digest, workload); a successful deploy records one. - rollback re-points to a PRIOR release's already-built digest: NO rebuild (instant) and reproducible (the digest guarantees you get back exactly the bits that ran — not "rebuild an old ref and hope"). - FAIL-CLOSED: you can only roll back to a release that actually ran here; a target image that was never built+deployed is blocked and never served. Rolling forward still goes through the promotion gate; rollback to a known-prior-good release is the fast, sealed, audited escape hatch. - a rollback is itself a new auditable head release (links from->to), so you can roll again either way. - history filenames carry a monotonic sequence so ordering is deterministic regardless of clock resolution (two releases in the same microsecond must not sort by their hash-based id). - real CLI: `release_ledger.py history|current|rollback [--to-digest|--steps]`. push_webhook.handle_push now records a release on every successful deploy (optional ledger_dir; the HTTP receiver passes artifacts/releases) — so real pushes accrue the history rollback operates on. Wired: capd/instant-rollback.mesh.capd.json (caps.dev.instant-rollback, composes_with git-push-deploy + git-push-webhook); validate.py; Makefile `rollback`; docs/BUILD_DEPLOY.md documents the full trio. Tests: +12 (10 ledger + 2 webhook-integration) = 209 tools tests green. --- Makefile | 5 +- capd/instant-rollback.mesh.capd.json | 30 ++++ docs/BUILD_DEPLOY.md | 27 ++++ tools/push_webhook.py | 21 ++- tools/release_ledger.py | 230 +++++++++++++++++++++++++++ tools/test_push_webhook.py | 20 +++ tools/test_release_ledger.py | 110 +++++++++++++ tools/validate.py | 3 + 8 files changed, 440 insertions(+), 6 deletions(-) create mode 100644 capd/instant-rollback.mesh.capd.json create mode 100644 tools/release_ledger.py create mode 100644 tools/test_release_ledger.py diff --git a/Makefile b/Makefile index ce4e734..3aadf4c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # SourceOS Continuum — lifecycle entry points. # Control-plane targets delegate to Makefile.porter (the rehomed Porter control plane). -.PHONY: validate onboard dev-up dev-down shim-test test tools-test rollout promotion-gate portal compute mesh-demo grant commons mcp spine run loop verify lease sphere push push-webhook edge login provision deploy inference availability +.PHONY: validate onboard dev-up dev-down shim-test test tools-test rollout promotion-gate portal compute mesh-demo grant commons mcp spine run loop verify lease sphere push push-webhook rollback edge login provision deploy inference availability validate: ## repo hygiene + CapD validity python3 tools/validate.py @@ -51,6 +51,9 @@ push: ## git-push deploy flow demo: build -> deploy -> per-branch preview push-webhook: ## git-push webhook demo: signed push -> deploy; forged push -> rejected (fail-closed door). Run a receiver with ARGS="serve 8099" cd tools && python3 push_webhook.py $(ARGS) +rollback: ## instant rollback: re-point an app to a prior release's immutable image (no rebuild, fail-closed). Demo with no ARGS, or ARGS="rollback [--to-digest D|--steps N]" + cd tools && python3 release_ledger.py $(ARGS) + edge: ## edge-worker demo: register the agent-machine into a cloud pool + evolve topology (reversed Giant Swarm) cd tools && python3 edge_worker.py diff --git a/capd/instant-rollback.mesh.capd.json b/capd/instant-rollback.mesh.capd.json new file mode 100644 index 0000000..1965e2f --- /dev/null +++ b/capd/instant-rollback.mesh.capd.json @@ -0,0 +1,30 @@ +{ + "capability_id": "caps.dev.instant-rollback@0.1.0", + "kind": "dev.release-rollback", + "status": "experimental", + "name": "Instant rollback — re-point to a prior release's immutable image, sovereign", + "description": "The last of the Vercel/Heroku deploy ergonomics: one action and prod serves the previous release again. It is almost free here because every deploy produces a CONTENT-ADDRESSED, immutable image (a data sphere) — so a release is a sealed record binding (tenant, app, branch, image_digest, workload), and rolling back is re-pointing to a PRIOR release's already-built digest: no rebuild (instant), and reproducible (the digest guarantees you get back exactly the bits that ran). It is fail-closed: you can only roll back to a release that actually ran here; a target image that was never built+deployed is blocked and never served. Rolling forward to a new version still goes through the promotion gate; rollback to a known-prior-good release is the fast, sealed, audited escape hatch. Each rollback is itself a new auditable head release, so you can roll again either way.", + "links": { + "engine": "tools/release_ledger.py", + "deploy": "tools/deploy_flow.py", + "trigger": "tools/push_webhook.py", + "image_as_data_sphere": "tools/data_sphere.py", + "promotion": "tools/promotion_gate.py", + "spec_witness": "docs/BUILD_DEPLOY.md", + "reference_pattern": "Vercel/Heroku instant rollback (one-click revert to a prior immutable release) — met sovereign: content-addressed reproducible image, fail-closed to releases that ran, sealed+audited, rollback is itself a release" + }, + "composes_with": { + "deploy": "caps.dev.git-push-deploy@0.1.0", + "trigger": "caps.dev.git-push-webhook@0.1.0", + "control_plane": "caps.infra.paas.continuum-local@0.1.0", + "scales_up_to": "caps.infra.cluster-scaleup.hyperswarm@0.1.0" + }, + "policy": { + "availability": "needs-work", + "instant_no_rebuild": true, + "content_addressed_reproducible": true, + "fail_closed_to_prior_releases": true, + "rollback_is_audited_release": true, + "evidence_emitting": true + } +} diff --git a/docs/BUILD_DEPLOY.md b/docs/BUILD_DEPLOY.md index 21042ec..47ed274 100644 --- a/docs/BUILD_DEPLOY.md +++ b/docs/BUILD_DEPLOY.md @@ -68,3 +68,30 @@ we verify against the project's per-tenant secret **before any work starts**. A validly signed is rejected with a sealed receipt and **no build is ever started** — the same fail-closed posture as the rest of the stack. So `git push` now literally deploys: the Vercel/Heroku ergonomic, sovereign and governed, with the trigger itself a zero-trust gate rather than an open hook. + +## Instant rollback (`release_ledger.py`) + +The other half of the Vercel/Heroku ergonomic is **instant rollback** — and it is almost free here, +because of a property the stack already guarantees: **every deploy is a content-addressed, immutable +image** (a data sphere). So a *release* is a sealed record binding `(tenant, app, branch, +image_digest, workload)`, a successful push records one, and rolling back is just re-pointing to a +**prior release's already-built digest**: + +``` +rollback(app) ──> find a PRIOR release that ran ← target never ran here ⇒ BLOCKED, nothing served + ──> re-point to its immutable image_digest ← NO REBUILD (instant), reproducible bits + ──> record a new head release (kind=rollback, links from→to) ← itself auditable + ──> a SEALED decision +``` + +Two things this buys that a plain PaaS rollback doesn't: + +- **Reproducible, not "rebuild-and-hope."** The digest *is* the guarantee — you get back exactly the + bytes that ran, not a fresh build of an old ref. +- **Fail-closed.** You can only roll back to a release that actually ran here; an image that was never + built and deployed is never served. Rolling *forward* to a new version still goes through the + promotion gate — rollback to a known-prior-good release is the fast, sealed, audited escape hatch. + +`make rollback ARGS="rollback "` (or `--to-digest ` / `--steps N`); `history` and +`current` read the ledger. That completes the deploy ergonomic: **push → preview → promote → rollback**, +every step governed, sealed, and sovereign. diff --git a/tools/push_webhook.py b/tools/push_webhook.py index 541ec62..7a5b1a0 100644 --- a/tools/push_webhook.py +++ b/tools/push_webhook.py @@ -32,9 +32,11 @@ from pathlib import Path import deploy_flow as df +import release_ledger as rl _ROOT = Path(__file__).resolve().parent.parent _RECEIPTS = _ROOT / "artifacts" / "webhook-receipts" +_RELEASES = _ROOT / "artifacts" / "releases" _ZERO_SHA = "0" * 40 # git's null object — a branch delete pushes "after": 0000... @@ -102,13 +104,15 @@ def _finish(decision: dict, receipts_dir) -> dict: def handle_push(*, secret: str, sig_header: str, raw_body: bytes, project: dict, - resolve_files=None, receipts_dir=None) -> dict: + resolve_files=None, receipts_dir=None, ledger_dir=None) -> dict: """The governed decision for one webhook delivery. Fail-closed: verify the signature FIRST; an unsigned/mis-signed push returns `rejected` and NO build is started. project: {tenant, user, app?, sensitivity?} — resolved per-repo by the caller. resolve_files(repo, ref, after) -> [paths] — the full source manifest at the pushed commit (production: a checkout of `after`). Optional. + ledger_dir — if given, a successful deploy is recorded as a + release so it can be rolled back to. Optional. Returns a sealed decision; status in {rejected, ignored, deployed, build-failed}. """ decision = {"surface": "sourceos-continuum.push_webhook.v1", @@ -155,9 +159,16 @@ def handle_push(*, secret: str, sig_header: str, raw_body: bytes, project: dict, result = df.on_push(tenant=project["tenant"], user=project["user"], repo=ev["repo"], branch=ev["branch"], source_files=source_files, sensitivity=project.get("sensitivity", "normal"), app=project.get("app")) - return _finish({**decision, "status": result["status"], - "accepted": result["status"] != "build-failed", - "files_source": files_source, "deploy": result}, receipts_dir) + out = {**decision, "status": result["status"], + "accepted": result["status"] != "build-failed", + "files_source": files_source, "deploy": result} + # a successful deploy becomes a rollback-able release (fail-closed deploys are not releases). + if ledger_dir is not None and result.get("status") == "deployed": + rel = rl.record_deploy(ledger_dir, tenant=project["tenant"], + app=project.get("app") or "default", deploy_result=result) + if rel is not None: + out["release_id"] = rel["release_id"] + return _finish(out, receipts_dir) # --- thin HTTP wrapper (stdlib only) ------------------------------------------------------------- @@ -197,7 +208,7 @@ def do_POST(self): or self.headers.get("X-SourceOS-Signature") or "") proj = project_for_path(self.path) decision = handle_push(secret=proj["secret"], sig_header=sig, raw_body=raw, - project=proj, receipts_dir=_RECEIPTS) + project=proj, receipts_dir=_RECEIPTS, ledger_dir=_RELEASES) body = json.dumps(decision, indent=2, sort_keys=True).encode("utf-8") self.send_response(_STATUS_CODE.get(decision["status"], 200)) self.send_header("Content-Type", "application/json") diff --git a/tools/release_ledger.py b/tools/release_ledger.py new file mode 100644 index 0000000..4b088e8 --- /dev/null +++ b/tools/release_ledger.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Release ledger + instant rollback — the last of the Vercel/Heroku deploy ergonomics, sovereign. + +Vercel and Heroku sell "instant rollback": one click and prod is serving the previous release again. +In our model that is almost free, and *better*, because of one property the rest of the stack already +guarantees: + + every deploy produces a CONTENT-ADDRESSED, immutable image (a data sphere). + +So a "release" is just a sealed record binding (tenant, app, branch, image_digest, workload). Rolling +back is re-pointing to a PRIOR release's already-built image digest — **no rebuild**, and because the +digest is content-addressed you get back *exactly* the bits that ran before (reproducible rollback, +not "rebuild the old ref and hope"). It is instant for the same reason it is safe. + +It is fail-closed: you can only roll back to a release that ACTUALLY RAN. A rollback target that isn't +in the history — an image that was never built and deployed here — is blocked, never served. Rolling +*forward* to a new version still goes through the promotion gate; rollback to a known-prior-good +release is the fast escape hatch, sealed and audited but ungated (that is the point of a rollback). + +`make_release` / `history` / `current` / `rollback` are a pure, file-backed core (unit-tested). +""" +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_RELEASES = _ROOT / "artifacts" / "releases" # where the webhook records real deploys + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _seal(body: dict) -> str: + return "sha256:" + hashlib.sha256( + json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + + +def _appdir(ledger_dir, tenant: str, app: str) -> Path: + return Path(ledger_dir) / tenant / app + + +def make_release(*, tenant: str, app: str, branch: str, image: str, image_digest: str, + workload_name: str, status: str = "deployed", kind: str = "deploy", + rolled_back_from: str | None = None, rolled_back_to: str | None = None) -> dict: + """A sealed release record. `release_id` is content+time addressed. `kind` is deploy|rollback; + `status` is deployed|rolled-back. A rollback record carries the linkage to what it replaced.""" + created_at = _now() + core = {"tenant": tenant, "app": app, "branch": branch, "image": image, + "image_digest": image_digest, "workload_name": workload_name, "status": status, + "kind": kind, "created_at": created_at, + "rolled_back_from": rolled_back_from, "rolled_back_to": rolled_back_to} + core["release_id"] = "rel-" + hashlib.sha256( + json.dumps({"t": tenant, "a": app, "d": image_digest, "ts": created_at}, + sort_keys=True).encode()).hexdigest()[:12] + core["receipt_digest"] = _seal({k: v for k, v in core.items()}) + return core + + +def record(ledger_dir, release: dict) -> dict: + """Append a sealed release to the app's ledger. A zero-padded monotonic sequence prefixes the + filename so history order is deterministic (append order) regardless of wall-clock resolution — + two releases in the same microsecond must not sort by their (hash-based) release_id.""" + d = _appdir(ledger_dir, release["tenant"], release["app"]) + d.mkdir(parents=True, exist_ok=True) + seq = len(list(d.glob("*.json"))) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + (d / f"{seq:08d}-{stamp}-{release['release_id']}.json").write_text( + json.dumps(release, indent=2, sort_keys=True)) + return release + + +def record_deploy(ledger_dir, *, tenant: str, app: str, deploy_result: dict) -> dict | None: + """Turn a successful deploy_flow.on_push result into a recorded release. Failed builds are NOT + releases (you can't roll back to something that never ran), so they are not recorded.""" + if deploy_result.get("status") != "deployed": + return None + return record(ledger_dir, make_release( + tenant=tenant, app=app, branch=deploy_result.get("branch"), + image=deploy_result.get("image"), image_digest=deploy_result.get("build_digest"), + workload_name=(deploy_result.get("workload") or {}).get("name"))) + + +def history(ledger_dir, tenant: str, app: str) -> list: + """All releases for an app, NEWEST FIRST.""" + d = _appdir(ledger_dir, tenant, app) + if not d.is_dir(): + return [] + out = [] + for f in sorted(d.glob("*.json"), reverse=True): + try: + out.append(json.loads(f.read_text())) + except (OSError, json.JSONDecodeError): + continue + return out + + +def current(ledger_dir, tenant: str, app: str) -> dict | None: + """What is serving now — the head of history.""" + h = history(ledger_dir, tenant, app) + return h[0] if h else None + + +def _resolve_target(hist: list, *, to_release_id, to_digest, steps: int): + """Pick the rollback target from history (hist is newest-first, hist[0] = current).""" + if to_release_id is not None: + return next((r for r in hist if r.get("release_id") == to_release_id), None) + if to_digest is not None: + # newest prior release that ran this exact (content-addressed) image + return next((r for r in hist[1:] if r.get("image_digest") == to_digest), None) + # default: `steps` releases back from current + return hist[steps] if len(hist) > steps else None + + +def rollback(ledger_dir, *, tenant: str, app: str, to_release_id: str | None = None, + to_digest: str | None = None, steps: int = 1) -> dict: + """Instantly re-point an app to a PRIOR release's immutable image. Fail-closed: the target must be + a real prior release that actually ran (has a deployed/rolled-back status and an image digest), + else the rollback is blocked and nothing is served. On success a new head release (kind=rollback) + is recorded so you can roll again (forward or back).""" + hist = history(ledger_dir, tenant, app) + decision = {"surface": "sourceos-continuum.release_ledger.rollback.v1", + "tenant": tenant, "app": app, "decided_at": _now()} + + if not hist: + decision.update({"ok": False, "placement": "blocked", + "reason": "no release history for this app — nothing to roll back to"}) + decision["receipt_digest"] = _seal(decision) + return decision + + cur = hist[0] + target = _resolve_target(hist, to_release_id=to_release_id, to_digest=to_digest, steps=steps) + + # fail-closed: only roll to a release that actually ran and carries a content-addressed image. + ran = target is not None and target.get("status") in ("deployed", "rolled-back") \ + and bool(target.get("image_digest")) + if not ran: + decision.update({"ok": False, "placement": "blocked", + "reason": "rollback target is not a prior release that ran here — an image " + "that was never built+deployed is never served (fail-closed)", + "current": {"release_id": cur.get("release_id"), "image_digest": cur.get("image_digest")}}) + decision["receipt_digest"] = _seal(decision) + return decision + + if target.get("image_digest") == cur.get("image_digest"): + decision.update({"ok": True, "placement": "no-op", "instant": True, "no_rebuild": True, + "reason": "target image is already serving — nothing to do", + "current": {"release_id": cur.get("release_id"), "image_digest": cur.get("image_digest")}}) + decision["receipt_digest"] = _seal(decision) + return decision + + new_head = record(ledger_dir, make_release( + tenant=tenant, app=app, branch=target.get("branch"), image=target.get("image"), + image_digest=target.get("image_digest"), workload_name=target.get("workload_name"), + status="rolled-back", kind="rollback", + rolled_back_from=cur.get("release_id"), rolled_back_to=target.get("release_id"))) + + decision.update({ + "ok": True, "placement": "rolled-back", "instant": True, "no_rebuild": True, + "reason": "re-pointed to a prior release's content-addressed image — no rebuild, reproducible", + "from": {"release_id": cur.get("release_id"), "image": cur.get("image"), + "image_digest": cur.get("image_digest")}, + "to": {"release_id": target.get("release_id"), "image": target.get("image"), + "image_digest": target.get("image_digest")}, + "new_head": {"release_id": new_head["release_id"], "kind": new_head["kind"], + "receipt_digest": new_head["receipt_digest"]}, + }) + decision["receipt_digest"] = _seal({k: v for k, v in decision.items() if k != "receipt_digest"}) + return decision + + +def _cli(argv: list) -> int: + """history|current|rollback [--to-digest D | --to-release R | --steps N]. + Operates on the real ledger (artifacts/releases). Returns a process exit code.""" + cmd, rest = argv[0], argv[1:] + pos, opts, it = [], {}, iter(rest) + for a in it: + if a.startswith("--"): + opts[a[2:]] = next(it, None) + else: + pos.append(a) + tenant = pos[0] if pos else "you" + app = pos[1] if len(pos) > 1 else "default" + if cmd == "history": + print(json.dumps([{"release_id": r["release_id"], "kind": r["kind"], "status": r["status"], + "image_digest": r["image_digest"], "created_at": r["created_at"]} + for r in history(_RELEASES, tenant, app)], indent=2)) + return 0 + if cmd == "current": + print(json.dumps(current(_RELEASES, tenant, app), indent=2)) + return 0 + d = rollback(_RELEASES, tenant=tenant, app=app, to_release_id=opts.get("to-release"), + to_digest=opts.get("to-digest"), steps=int(opts.get("steps") or 1)) + print(json.dumps(d, indent=2)) + return 0 if d.get("ok") else 1 + + +if __name__ == "__main__": + import sys + import tempfile + + if len(sys.argv) > 1 and sys.argv[1] in ("history", "current", "rollback"): + raise SystemExit(_cli(sys.argv[1:])) + + # no subcommand: an in-memory demo of the full story. + with tempfile.TemporaryDirectory() as td: + t, a = "acme", "shop" + # three deploys land, oldest -> newest (v1, v2, v3 have distinct content-addressed images). + for i, dig in enumerate(("aa" * 32, "bb" * 32, "cc" * 32), start=1): + record(td, make_release(tenant=t, app=a, branch="main", image=f"shop@sha256:{dig[:12]}", + image_digest="sha256:" + dig, workload_name="shop")) + before = current(td, t, a)["image_digest"] + + one_back = rollback(td, tenant=t, app=a) # v3 -> v2 + to_v1 = rollback(td, tenant=t, app=a, to_digest="sha256:" + "aa" * 32) # -> v1 exactly + forged = rollback(td, tenant=t, app=a, to_digest="sha256:" + "99" * 32) # never ran -> blocked + + print(json.dumps({ + "history_depth": len(history(td, t, a)), + "was_serving": before[:19] + "…", + "rollback_1_step": {"ok": one_back["ok"], "to": one_back["to"]["image_digest"][:19] + "…", + "instant": one_back["instant"], "no_rebuild": one_back["no_rebuild"]}, + "rollback_to_v1_digest": {"ok": to_v1["ok"], "to": to_v1["to"]["image_digest"][:19] + "…"}, + "rollback_to_never_built": {"ok": forged["ok"], "placement": forged["placement"], + "reason": forged["reason"][:60] + "…"}, + "now_serving": current(td, t, a)["image_digest"][:19] + "…", + }, indent=2)) diff --git a/tools/test_push_webhook.py b/tools/test_push_webhook.py index aef49a3..5bb6bb9 100644 --- a/tools/test_push_webhook.py +++ b/tools/test_push_webhook.py @@ -6,6 +6,7 @@ import tempfile import push_webhook as pw +import release_ledger as rl _SECRET = "s3cr3t-per-tenant" _PROJECT = {"tenant": "acme", "user": "alice", "app": "shop", "sensitivity": "normal"} @@ -116,6 +117,25 @@ def test_receipt_is_sealed_and_persisted(): assert written["body_digest"] == pw._digest(raw) +def test_deployed_push_records_a_rollbackable_release(): + raw = _raw(_payload()) + with tempfile.TemporaryDirectory() as td: + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT, + ledger_dir=td) + assert d["status"] == "deployed" and "release_id" in d + hist = rl.history(td, "acme", "shop") + assert len(hist) == 1 and hist[0]["release_id"] == d["release_id"] + + +def test_build_failed_push_records_no_release(): + raw = _raw(_payload(added=("README.md",), modified=("LICENSE",))) # no buildpack match + with tempfile.TemporaryDirectory() as td: + d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT, + ledger_dir=td) + assert d["status"] == "build-failed" and "release_id" not in d + assert rl.history(td, "acme", "shop") == [] + + def test_secret_never_appears_in_decision_or_receipt(): raw = _raw(_payload()) d = pw.handle_push(secret=_SECRET, sig_header=_sign(raw), raw_body=raw, project=_PROJECT) diff --git a/tools/test_release_ledger.py b/tools/test_release_ledger.py new file mode 100644 index 0000000..78d0b74 --- /dev/null +++ b/tools/test_release_ledger.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Tests for the release ledger + instant rollback.""" +import tempfile + +import release_ledger as rl + + +def _seed(td, digests, tenant="acme", app="shop"): + for dig in digests: + rl.record(td, rl.make_release(tenant=tenant, app=app, branch="main", + image=f"{app}@sha256:{dig[:12]}", + image_digest="sha256:" + dig, workload_name=app)) + + +def test_history_is_newest_first_and_current_is_the_head(): + with tempfile.TemporaryDirectory() as td: + _seed(td, ["aa" * 32, "bb" * 32, "cc" * 32]) + hist = rl.history(td, "acme", "shop") + assert [r["image_digest"][-2:] for r in hist] == ["cc"[-2:], "bb"[-2:], "aa"[-2:]] + assert rl.current(td, "acme", "shop")["image_digest"] == "sha256:" + "cc" * 32 + + +def test_rollback_one_step_repoints_to_previous_image_no_rebuild(): + with tempfile.TemporaryDirectory() as td: + _seed(td, ["aa" * 32, "bb" * 32, "cc" * 32]) + d = rl.rollback(td, tenant="acme", app="shop") # cc -> bb + assert d["ok"] and d["placement"] == "rolled-back" + assert d["instant"] is True and d["no_rebuild"] is True + assert d["to"]["image_digest"] == "sha256:" + "bb" * 32 + assert rl.current(td, "acme", "shop")["image_digest"] == "sha256:" + "bb" * 32 + + +def test_rollback_to_a_specific_prior_digest(): + with tempfile.TemporaryDirectory() as td: + _seed(td, ["aa" * 32, "bb" * 32, "cc" * 32]) + d = rl.rollback(td, tenant="acme", app="shop", to_digest="sha256:" + "aa" * 32) + assert d["ok"] and d["to"]["image_digest"] == "sha256:" + "aa" * 32 + assert rl.current(td, "acme", "shop")["image_digest"] == "sha256:" + "aa" * 32 + + +def test_rollback_creates_a_new_auditable_head_linking_from_and_to(): + with tempfile.TemporaryDirectory() as td: + _seed(td, ["aa" * 32, "bb" * 32]) + before = rl.history(td, "acme", "shop") + d = rl.rollback(td, tenant="acme", app="shop") + head = rl.current(td, "acme", "shop") + assert len(rl.history(td, "acme", "shop")) == len(before) + 1 # rollback is itself a release + assert head["kind"] == "rollback" and head["status"] == "rolled-back" + assert head["rolled_back_from"] == d["from"]["release_id"] + assert head["rolled_back_to"] == d["to"]["release_id"] + + +def test_rollback_to_an_image_that_never_ran_is_blocked_failclosed(): + with tempfile.TemporaryDirectory() as td: + _seed(td, ["aa" * 32, "bb" * 32]) + d = rl.rollback(td, tenant="acme", app="shop", to_digest="sha256:" + "99" * 32) + assert d["ok"] is False and d["placement"] == "blocked" + # nothing served: current is unchanged + assert rl.current(td, "acme", "shop")["image_digest"] == "sha256:" + "bb" * 32 + + +def test_rollback_with_no_history_is_blocked(): + with tempfile.TemporaryDirectory() as td: + d = rl.rollback(td, tenant="acme", app="shop") + assert d["ok"] is False and d["placement"] == "blocked" + + +def test_rollback_to_the_current_release_is_a_noop(): + with tempfile.TemporaryDirectory() as td: + _seed(td, ["aa" * 32, "bb" * 32]) + head_id = rl.current(td, "acme", "shop")["release_id"] # bb is serving + d = rl.rollback(td, tenant="acme", app="shop", to_release_id=head_id) + assert d["ok"] and d["placement"] == "no-op" + # a no-op does not append a new head + assert len(rl.history(td, "acme", "shop")) == 2 + + +def test_rollback_to_currently_serving_digest_finds_no_prior_release(): + # to_digest looks only at PRIOR releases; the serving digest isn't a prior one, so it's blocked. + with tempfile.TemporaryDirectory() as td: + _seed(td, ["aa" * 32, "bb" * 32]) + d = rl.rollback(td, tenant="acme", app="shop", to_digest="sha256:" + "bb" * 32) + assert d["ok"] is False and d["placement"] == "blocked" + + +def test_record_deploy_records_only_successful_deploys(): + with tempfile.TemporaryDirectory() as td: + ok = {"status": "deployed", "branch": "pr-1", "image": "shop@sha256:abc", + "build_digest": "sha256:" + "ab" * 32, "workload": {"name": "shop"}} + failed = {"status": "build-failed", "reason": "no buildpack"} + assert rl.record_deploy(td, tenant="acme", app="shop", deploy_result=ok) is not None + assert rl.record_deploy(td, tenant="acme", app="shop", deploy_result=failed) is None + assert len(rl.history(td, "acme", "shop")) == 1 # only the successful one is a release + + +def test_release_and_rollback_records_are_sealed(): + with tempfile.TemporaryDirectory() as td: + _seed(td, ["aa" * 32, "bb" * 32]) + assert rl.current(td, "acme", "shop")["receipt_digest"].startswith("sha256:") + d = rl.rollback(td, tenant="acme", app="shop") + assert d["receipt_digest"].startswith("sha256:") + + +if __name__ == "__main__": + import sys + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + for fn in fns: + fn() + print(f"ok: {len(fns)} release-ledger tests passed") + sys.exit(0) diff --git a/tools/validate.py b/tools/validate.py index e8b0304..1c56a6b 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -28,6 +28,7 @@ "capd/sovereign-inference.mesh.capd.json", "capd/git-push-deploy.mesh.capd.json", "capd/git-push-webhook.mesh.capd.json", + "capd/instant-rollback.mesh.capd.json", "capd/provisioning-plane.mesh.capd.json", "tools/promotion_gate.py", "tools/portal_server.py", @@ -51,6 +52,7 @@ "tools/provisioning.py", "tools/deploy_flow.py", "tools/push_webhook.py", + "tools/release_ledger.py", "tools/login.py", "tools/edge_worker.py", ] @@ -67,6 +69,7 @@ "capd/sovereign-inference.mesh.capd.json": "caps.inference.sovereign", "capd/git-push-deploy.mesh.capd.json": "caps.dev.git-push-deploy", "capd/git-push-webhook.mesh.capd.json": "caps.dev.git-push-webhook", + "capd/instant-rollback.mesh.capd.json": "caps.dev.instant-rollback", "capd/provisioning-plane.mesh.capd.json": "caps.dev.provisioning", }