Implement signed release admission and durable rollback coordinator
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e241-8285-7a63-8c0c-51c9cb824dc3
This commit is contained in:
parent
a122b1a5a4
commit
22935955cc
10 changed files with 810 additions and 59 deletions
218
src/activity_core/release_broker.py
Normal file
218
src/activity_core/release_broker.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""Durable image-release coordinator; production adapters are deliberately separate.
|
||||
|
||||
Trust keys and role bindings come from operator-owned configuration, never requests.
|
||||
Adapters must implement the fixed Git/ArgoCD contract documented in release admission.
|
||||
No credential loading, generic command execution, HTTP endpoint or live adapter here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from .gitops_policy import validate
|
||||
|
||||
ROLES = {"build", "review", "health", "retention"}
|
||||
AUTHORITY = "ACTIVITY-WP-0041-image-only-v1"
|
||||
TERMINAL = {"complete", "rolled_back", "cancelled"}
|
||||
REQUIRED_CHECKS = {"CI Smoke / host-smoke (push)", "CI Smoke / container-smoke (push)",
|
||||
"Build and Publish Container Image / build-and-push (push)"}
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
|
||||
|
||||
def fingerprint(value):
|
||||
return hashlib.sha256(canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def timestamp(value):
|
||||
result = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if result.tzinfo is None:
|
||||
raise ValueError("timezone required")
|
||||
return result
|
||||
|
||||
|
||||
class Receipts:
|
||||
"""Verify Ed25519 envelopes against locally bound principals and roles."""
|
||||
|
||||
def __init__(self, trusted):
|
||||
# key id -> {public_key: raw 32 bytes, principal: str, roles: set[str]}
|
||||
self.trusted = trusted
|
||||
|
||||
def verify(self, envelope, role, binding, now):
|
||||
if set(envelope) != {"key_id", "payload", "signature"}:
|
||||
raise ValueError("unexpected envelope fields")
|
||||
trust = self.trusted.get(envelope["key_id"])
|
||||
if trust is None or role not in trust["roles"]:
|
||||
raise ValueError("untrusted receipt role")
|
||||
payload = envelope["payload"]
|
||||
signature = base64.b64decode(envelope["signature"], validate=True)
|
||||
Ed25519PublicKey.from_public_bytes(trust["public_key"]).verify(signature, canonical(payload))
|
||||
if (payload.get("role") != role or payload.get("schema") != 1
|
||||
or payload.get("authority") != AUTHORITY or payload.get("binding") != binding
|
||||
or payload.get("result") != "pass"):
|
||||
raise ValueError("receipt scope/result mismatch")
|
||||
issued, expires = timestamp(payload["issued_at"]), timestamp(payload["expires_at"])
|
||||
if not now - timedelta(minutes=5) <= issued <= now < expires <= issued + timedelta(minutes=5):
|
||||
raise ValueError("stale or future receipt")
|
||||
return trust["principal"], payload
|
||||
|
||||
|
||||
class Backend(Protocol):
|
||||
"""Trusted adapter, never implemented from caller-provided URLs or commands.
|
||||
|
||||
publish_revision is a CAS on the single platform child targetRevision field;
|
||||
already-at-target is success, any third revision is a conflict. It must retain
|
||||
source signatures/checks, serialize with other writers and return only after
|
||||
durable Git publication. sync_revision selects ONLY the activity-core child in
|
||||
the root, then ONLY the child application, with prune false and no overrides.
|
||||
Both methods must be idempotent after a lost response or broker restart.
|
||||
healthy includes exact revision, deployment health, report/schedule invariants.
|
||||
"""
|
||||
def publish_revision(self, expected: str, target: str) -> None: ...
|
||||
def sync_revision(self, target: str) -> None: ...
|
||||
def healthy(self, target: str) -> bool: ...
|
||||
|
||||
|
||||
class Broker:
|
||||
def __init__(self, database: Path, receipts: Receipts, *, admitted=False):
|
||||
self.database, self.receipts, self.admitted = database, receipts, admitted
|
||||
with self.connect() as db:
|
||||
db.execute("CREATE TABLE IF NOT EXISTS releases (id TEXT PRIMARY KEY, plan TEXT NOT NULL, phase TEXT NOT NULL, active INTEGER UNIQUE, deadline TEXT NOT NULL, error TEXT)")
|
||||
db.execute("CREATE TABLE IF NOT EXISTS transitions (sequence INTEGER PRIMARY KEY, release_id TEXT NOT NULL, phase TEXT NOT NULL, observed_at TEXT NOT NULL)")
|
||||
|
||||
def connect(self):
|
||||
db = sqlite3.connect(self.database, timeout=5, isolation_level=None)
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
return db
|
||||
|
||||
def admit(self, before, after, candidate, rollback, envelopes, now=None):
|
||||
if not self.admitted:
|
||||
raise ValueError("broker identity not admitted")
|
||||
now = now or datetime.now(timezone.utc)
|
||||
binding = {"repository": "coulomb/activity-core", "application": "activity-core",
|
||||
"candidate": candidate, "rollback": rollback,
|
||||
"before_sha256": fingerprint(before), "after_sha256": fingerprint(after)}
|
||||
if set(envelopes) != ROLES:
|
||||
raise ValueError("all independent receipt roles required")
|
||||
verified = {r: self.receipts.verify(envelopes[r], r, binding, now) for r in ROLES}
|
||||
if (verified["build"][0] == verified["review"][0]
|
||||
or self.receipts.trusted[envelopes["build"]["key_id"]]["public_key"]
|
||||
== self.receipts.trusted[envelopes["review"]["key_id"]]["public_key"]):
|
||||
raise ValueError("reviewer must be independent of producer")
|
||||
health = verified["health"][1]
|
||||
# A signed continuous observation attestation must name the prior revision.
|
||||
if health.get("revision") != rollback or health.get("continuous") is not True:
|
||||
raise ValueError("continuous prior-revision observation required")
|
||||
if health.get("synced") is not True or health.get("healthy") is not True:
|
||||
raise ValueError("prior revision unhealthy")
|
||||
evidence = dict(schema_version=1, identity_admitted=True, authority=AUTHORITY,
|
||||
checks="pass", review_result="pass", producer=verified["build"][0],
|
||||
reviewer=verified["review"][0], candidate_commit=candidate,
|
||||
rollback_commit=rollback, healthy_since=health["healthy_since"],
|
||||
observed_at=health["issued_at"], argo_synced=True, argo_healthy=True)
|
||||
validate(before, after, evidence, now)
|
||||
required = sorted({c["image"] for docs in (before, after) for d in docs
|
||||
if d["kind"] == "Deployment"
|
||||
for c in d["spec"]["template"]["spec"]["containers"]})
|
||||
if sorted(verified["retention"][1].get("images", [])) != required:
|
||||
raise ValueError("live and rollback image retention coverage required")
|
||||
checks = verified["build"][1].get("checks", {})
|
||||
if not isinstance(checks, dict) or any(checks.get(name) != "success" for name in REQUIRED_CHECKS):
|
||||
raise ValueError("required build and smoke checks missing")
|
||||
# Signed build receipt binds each exact candidate digest, not just a green status.
|
||||
candidate_images = sorted({c["image"] for d in after if d["kind"] == "Deployment"
|
||||
for c in d["spec"]["template"]["spec"]["containers"]})
|
||||
if sorted(verified["build"][1].get("images", [])) != candidate_images:
|
||||
raise ValueError("build digest binding missing")
|
||||
plan = {**binding, "receipts": envelopes, "receipt_hashes": {r: fingerprint(envelopes[r]) for r in ROLES}}
|
||||
release_id = fingerprint(binding)
|
||||
db = self.connect()
|
||||
try:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
existing = db.execute("SELECT id FROM releases WHERE id=?", (release_id,)).fetchone()
|
||||
if existing:
|
||||
db.rollback()
|
||||
return release_id
|
||||
db.execute("INSERT INTO releases VALUES (?, ?, 'planned', 1, ?, NULL)",
|
||||
(release_id, canonical(plan).decode(), min(timestamp(verified[r][1]["expires_at"]) for r in ROLES).isoformat()))
|
||||
db.execute("INSERT INTO transitions(release_id,phase,observed_at) VALUES (?, 'planned', ?)", (release_id, now.isoformat()))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return release_id
|
||||
|
||||
def advance(self, release_id, backend: Backend, now=None):
|
||||
"""One durable step. Adapter exceptions preserve intent for safe retry.
|
||||
|
||||
Failure/timeout after publication moves to rollback; rollback failure keeps
|
||||
the unique active slot, halting all new releases until recovery succeeds.
|
||||
A database write lock serializes adapter calls across broker processes.
|
||||
"""
|
||||
if not self.admitted:
|
||||
raise ValueError("broker identity not admitted")
|
||||
now = now or datetime.now(timezone.utc)
|
||||
db = self.connect()
|
||||
try:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row = db.execute("SELECT plan, phase, deadline FROM releases WHERE id=?", (release_id,)).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("unknown release")
|
||||
plan, phase, deadline = json.loads(row[0]), row[1], timestamp(row[2])
|
||||
candidate, rollback = plan["candidate"], plan["rollback"]
|
||||
if phase in TERMINAL:
|
||||
db.rollback()
|
||||
return phase
|
||||
next_phase = phase
|
||||
if phase == "planned":
|
||||
if now >= deadline:
|
||||
# No publication attempted; safe terminal cancellation.
|
||||
next_phase = "cancelled"
|
||||
else:
|
||||
# Persist intent BEFORE the external CAS; a lost response
|
||||
# must never be mistaken for an unpublished cancellation.
|
||||
next_phase = "publish_pending"
|
||||
elif phase == "publish_pending":
|
||||
if now >= deadline:
|
||||
next_phase = "rollback_planned"
|
||||
else:
|
||||
backend.publish_revision(rollback, candidate)
|
||||
next_phase = "published"
|
||||
elif phase == "published":
|
||||
if now >= deadline:
|
||||
next_phase = "rollback_planned"
|
||||
else:
|
||||
backend.sync_revision(candidate)
|
||||
next_phase = "synced"
|
||||
elif phase == "synced":
|
||||
next_phase = "complete" if now < deadline and backend.healthy(candidate) is True else "rollback_planned"
|
||||
elif phase == "rollback_planned":
|
||||
backend.publish_revision(candidate, rollback)
|
||||
next_phase = "rollback_published"
|
||||
elif phase == "rollback_published":
|
||||
backend.sync_revision(rollback)
|
||||
next_phase = "rollback_synced"
|
||||
elif phase == "rollback_synced":
|
||||
if backend.healthy(rollback) is True:
|
||||
next_phase = "rolled_back"
|
||||
else:
|
||||
raise ValueError("unknown durable phase")
|
||||
db.execute("UPDATE releases SET phase=?, active=? WHERE id=?",
|
||||
(next_phase, None if next_phase in TERMINAL else 1, release_id))
|
||||
if next_phase != phase:
|
||||
db.execute("INSERT INTO transitions(release_id,phase,observed_at) VALUES (?, ?, ?)", (release_id, next_phase, now.isoformat()))
|
||||
db.commit()
|
||||
return next_phase
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
Loading…
Add table
Add a link
Reference in a new issue