Implement signed release admission and durable rollback coordinator
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

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e241-8285-7a63-8c0c-51c9cb824dc3
This commit is contained in:
tegwick 2026-09-27 16:39:29 +02:00
parent a122b1a5a4
commit 22935955cc
10 changed files with 810 additions and 59 deletions

View file

@ -0,0 +1,55 @@
"""Fail-closed admission check for a candidate image-only GitOps release.
This is a validator, not an authority issuer or a cluster/Git credential broker.
Evidence must be supplied by the separately admitted release identity.
"""
import copy
from datetime import datetime, timedelta, timezone
import re
import yaml
IMAGE=re.compile(r'forgejo\.coulomb\.social/coulomb/activity-core@sha256:[0-9a-f]{64}')
SHA=re.compile(r'[0-9a-f]{40}')
DEPLOYS={'actcore-api','actcore-worker','actcore-event-router'}
def require(condition,message):
if not condition: raise ValueError(message)
def indexed(docs):
out={}
for d in docs:
require(isinstance(d,dict),'invalid resource')
key=(d['kind'],d['metadata']['name'])
require(key not in out and d['metadata'].get('namespace')=='activity-core','duplicate or foreign resource')
out[key]=copy.deepcopy(d)
return out
def validate(before,after,evidence,now=None):
now=now or datetime.now(timezone.utc)
require(evidence.get('schema_version')==1,'unknown evidence schema')
require(evidence.get('identity_admitted') is True,'release identity not admitted')
require(evidence.get('authority')=='ACTIVITY-WP-0041-image-only-v1','unknown authority')
require(evidence.get('review_result')=='pass' and evidence.get('checks')=='pass','checks/review not passed')
require(evidence.get('reviewer') and evidence.get('reviewer')!=evidence.get('producer'),'independent review required')
require(bool(SHA.fullmatch(evidence.get('candidate_commit',''))),'full candidate revision required')
require(bool(SHA.fullmatch(evidence.get('rollback_commit',''))),'rollback revision required')
require(evidence['candidate_commit']!=evidence['rollback_commit'],'rollback must name prior revision')
observed=datetime.fromisoformat(evidence['healthy_since'].replace('Z','+00:00'))
measured=datetime.fromisoformat(evidence['observed_at'].replace('Z','+00:00'))
require(observed.tzinfo is not None and measured.tzinfo is not None,'timestamps need timezone')
require(now-timedelta(minutes=5)<=measured<=now,'health evidence stale or future')
require(measured-observed>=timedelta(hours=24),'24-hour healthy observation period incomplete')
require(evidence.get('argo_synced') is True and evidence.get('argo_healthy') is True,'Argo not healthy/synced')
left,right=indexed(before),indexed(after)
require(left.keys()==right.keys(),'resource inventory change')
changed=[]
for key,a in left.items():
b=right[key]
if a==b: continue
require(key[0]=='Deployment' and key[1] in DEPLOYS,'only runtime images may change')
ac=a['spec']['template']['spec']['containers'];bc=b['spec']['template']['spec']['containers']
require(len(ac)==len(bc)==1,'container inventory change')
require(bool(IMAGE.fullmatch(bc[0].get('image',''))),'registry digest required')
require(bc[0].get('imagePullPolicy')=='IfNotPresent','pull policy must support registry recovery')
ac[0]['image']=bc[0]['image'];ac[0]['imagePullPolicy']=bc[0]['imagePullPolicy']
require(a==b,'non-image deployment change')
changed.append(key[1])
require(bool(changed),'no release change')
return {'admitted':True,'deployments':changed,'candidate_commit':evidence['candidate_commit'],'rollback_commit':evidence['rollback_commit']}

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

View file

@ -0,0 +1,63 @@
"""Fixed mutation shapes for the trusted Git/ArgoCD release adapter.
These builders confer no authority. The admitted adapter must authenticate its
transport, enforce compare-and-swap, and never accept arbitrary path/body inputs.
"""
import copy
import re
import yaml
REVISION = re.compile(r'[0-9a-f]{40}')
APPLICATION_PATH = 'argocd/railiance01/applications/activity-core.application.yaml'
ROOT_APPLICATION = 'railiance-apps-root'
APPLICATION = 'activity-core'
NAMESPACE = 'argocd'
def revision(value):
if not isinstance(value,str) or REVISION.fullmatch(value) is None:
raise ValueError('full commit revision required')
return value
def update_child(document, expected, target):
"""Change only targetRevision in the one validated child Application."""
revision(expected);revision(target)
before = yaml.safe_load(document)
if not isinstance(before,dict):raise ValueError('invalid child declaration')
spec=before.get('spec',{});source=spec.get('source',{})
if (before.get('apiVersion')!='argoproj.io/v1alpha1' or before.get('kind')!='Application'
or before.get('metadata',{}).get('name')!=APPLICATION
or before.get('metadata',{}).get('namespace')!=NAMESPACE
or spec.get('project')!='activity-core'
or source.get('repoURL')!='https://forgejo.coulomb.social/coulomb/activity-core.git'
or source.get('path')!='k8s/gitops'
or spec.get('destination')!={'namespace':'activity-core','server':'https://kubernetes.default.svc'}
or spec.get('syncPolicy',{}).get('automated') is not None
or set(spec.get('syncPolicy',{}).get('syncOptions',[])) != {'CreateNamespace=false','ApplyOutOfSyncOnly=true','PruneLast=true','FailOnSharedResource=true'}
or 'sources' in spec or before.get('metadata',{}).get('finalizers')):
raise ValueError('child outside release contract')
if source.get('targetRevision') not in {expected,target}:
raise ValueError('child revision CAS conflict')
if source['targetRevision']==target:return document
# Preserve comments and all other bytes; ambiguous duplicate YAML keys cannot
# silently widen this one-field text patch.
pattern=re.compile(r'(?m)^ targetRevision: '+re.escape(expected)+r'[ \t]*$')
if len(pattern.findall(document))!=1:raise ValueError('ambiguous child revision field')
updated=pattern.sub(' targetRevision: '+target,document)
wanted=copy.deepcopy(before);wanted['spec']['source']['targetRevision']=target
if yaml.safe_load(updated)!=wanted:raise ValueError('unexpected child mutation')
return updated
def sync_operations(platform_revision, activity_revision):
"""Return fixed root-selective and child sync bodies, never prune/overrides."""
revision(platform_revision);revision(activity_revision)
def body(value,resources=None):
sync={'revision':value,'prune':False,'syncStrategy':{'apply':{}}}
if resources is not None:sync['resources']=resources
return {'operation':{'initiatedBy':{'username':'activity-core-release-broker'},'sync':sync}}
return [
(ROOT_APPLICATION,body(platform_revision,[{'group':'argoproj.io','kind':'Application','name':APPLICATION,'namespace':NAMESPACE}])),
(APPLICATION,body(activity_revision)),
]