Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e241-8285-7a63-8c0c-51c9cb824dc3
218 lines
11 KiB
Python
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()
|