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
55
src/activity_core/gitops_policy.py
Normal file
55
src/activity_core/gitops_policy.py
Normal 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']}
|
||||
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()
|
||||
63
src/activity_core/release_operations.py
Normal file
63
src/activity_core/release_operations.py
Normal 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)),
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue