activity-core/src/activity_core/release_broker.py
tegwick 22935955cc
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s
Build and Publish Container Image / build-and-push (push) Successful in 1m32s
Implement signed release admission and durable rollback coordinator
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e241-8285-7a63-8c0c-51c9cb824dc3
2026-09-27 16:39:29 +02:00

218 lines
11 KiB
Python

"""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()