Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <tenant> <app> [--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

Expand Down
30 changes: 30 additions & 0 deletions capd/instant-rollback.mesh.capd.json
Original file line number Diff line number Diff line change
@@ -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
}
}
27 changes: 27 additions & 0 deletions docs/BUILD_DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tenant> <app>"` (or `--to-digest <D>` / `--steps N`); `history` and
`current` read the ledger. That completes the deploy ergonomic: **push → preview → promote → rollback**,
every step governed, sealed, and sovereign.
21 changes: 16 additions & 5 deletions tools/push_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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...


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) -------------------------------------------------------------
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading