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
202
tests/test_release_broker.py
Normal file
202
tests/test_release_broker.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""Isolated failure proofs: generated test keys and an in-memory deployment adapter."""
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
from activity_core.release_broker import AUTHORITY, REQUIRED_CHECKS, Broker, Receipts, canonical, fingerprint
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NOW = datetime(2026, 9, 28, 16, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundle(tmp_path):
|
||||
before = list(yaml.safe_load_all((ROOT / 'k8s/gitops/runtime.yaml').read_text()))
|
||||
after = copy.deepcopy(before)
|
||||
next(d for d in after if d['metadata']['name'] == 'actcore-worker')['spec']['template']['spec']['containers'][0]['image'] = 'forgejo.coulomb.social/coulomb/activity-core@sha256:'+'f'*64
|
||||
keys = {r: Ed25519PrivateKey.generate() for r in ['build','review','health','retention']}
|
||||
trusted = {r: {'public_key': k.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw),
|
||||
'principal':r, 'roles':{r}} for r,k in keys.items()}
|
||||
broker = Broker(tmp_path/'release.sqlite', Receipts(trusted), admitted=True)
|
||||
return broker, before, after, keys
|
||||
|
||||
|
||||
def images(docs):
|
||||
return {c['image'] for d in docs if d['kind']=='Deployment' for c in d['spec']['template']['spec']['containers']}
|
||||
|
||||
|
||||
def envelopes(before, after, keys, candidate='a'*40, rollback='b'*40):
|
||||
binding = dict(repository='coulomb/activity-core',application='activity-core',
|
||||
candidate=candidate,rollback=rollback,before_sha256=fingerprint(before),after_sha256=fingerprint(after))
|
||||
out={}
|
||||
for role,key in keys.items():
|
||||
payload=dict(schema=1,role=role,authority=AUTHORITY,binding=binding,result='pass',
|
||||
issued_at=NOW.isoformat(),expires_at=(NOW+timedelta(minutes=5)).isoformat())
|
||||
if role=='health':payload.update(revision=rollback,continuous=True,healthy=True,synced=True,healthy_since=(NOW-timedelta(hours=25)).isoformat())
|
||||
if role=='build':
|
||||
payload['images']=sorted(images(after))
|
||||
payload['checks']={name:'success' for name in REQUIRED_CHECKS}
|
||||
if role=='retention':payload['images']=sorted(images(before)|images(after))
|
||||
out[role]={'key_id':role,'payload':payload,'signature':base64.b64encode(key.sign(canonical(payload))).decode()}
|
||||
return out
|
||||
|
||||
|
||||
def resign(receipt,key):
|
||||
receipt['signature']=base64.b64encode(key.sign(canonical(receipt['payload']))).decode()
|
||||
|
||||
|
||||
def admit(bundle, **kwargs):
|
||||
broker,before,after,keys=bundle
|
||||
return broker.admit(before,after,'a'*40,'b'*40,envelopes(before,after,keys),now=NOW,**kwargs)
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
def __init__(self):
|
||||
self.revision='b'*40;self.calls=[];self.fail_health=False;self.fail_rollback=False;self.lose_response=False
|
||||
def publish_revision(self,expected,target):
|
||||
if self.revision not in {expected,target}:raise ValueError('CAS conflict')
|
||||
self.revision=target;self.calls.append(('publish',target))
|
||||
if self.lose_response:
|
||||
self.lose_response=False
|
||||
raise ConnectionError('lost response after durable publication')
|
||||
def sync_revision(self,target):
|
||||
assert self.revision==target
|
||||
self.calls.append(('sync',target))
|
||||
def healthy(self,target):
|
||||
assert self.revision==target
|
||||
return not (self.fail_health if target=='a'*40 else self.fail_rollback)
|
||||
|
||||
|
||||
def drive(broker, rid, backend, now=NOW):
|
||||
phases=[]
|
||||
for _ in range(9):
|
||||
phase=broker.advance(rid,backend,now);phases.append(phase)
|
||||
if phase in {'complete','rolled_back','cancelled'}:break
|
||||
return phases
|
||||
|
||||
|
||||
def test_normal_release_restart_and_idempotent_delivery(bundle):
|
||||
broker,*_=bundle;rid=admit(bundle);backend=FakeBackend()
|
||||
assert broker.advance(rid,backend,NOW)=='publish_pending'
|
||||
assert not backend.calls
|
||||
restarted=Broker(broker.database,broker.receipts,admitted=True)
|
||||
assert drive(restarted,rid,backend)==['published','synced','complete']
|
||||
assert admit(bundle)==rid
|
||||
previous=list(backend.calls)
|
||||
assert restarted.advance(rid,backend,NOW)=='complete'
|
||||
assert backend.calls==previous
|
||||
with broker.connect() as db:
|
||||
assert [r[0] for r in db.execute('SELECT phase FROM transitions ORDER BY sequence')]==['planned','publish_pending','published','synced','complete']
|
||||
|
||||
|
||||
def test_health_failure_rolls_back_through_same_adapter(bundle):
|
||||
broker,*_=bundle;rid=admit(bundle);backend=FakeBackend();backend.fail_health=True
|
||||
assert drive(broker,rid,backend)[-1]=='rolled_back'
|
||||
assert backend.calls==[('publish','a'*40),('sync','a'*40),('publish','b'*40),('sync','b'*40)]
|
||||
|
||||
|
||||
def test_failed_rollback_holds_exclusive_slot(bundle):
|
||||
broker,before,after,keys=bundle;rid=admit(bundle);backend=FakeBackend();backend.fail_health=True;backend.fail_rollback=True
|
||||
assert drive(broker,rid,backend)[-1]=='rollback_synced'
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
broker.admit(before,after,'c'*40,'b'*40,envelopes(before,after,keys,candidate='c'*40),now=NOW)
|
||||
backend.fail_rollback=False
|
||||
assert broker.advance(rid,backend,NOW)=='rolled_back'
|
||||
|
||||
|
||||
def test_lost_publish_response_and_expiry_recovers_prior_revision(bundle):
|
||||
broker,*_=bundle;rid=admit(bundle);backend=FakeBackend();backend.lose_response=True
|
||||
assert broker.advance(rid,backend,NOW)=='publish_pending'
|
||||
with pytest.raises(ConnectionError):broker.advance(rid,backend,NOW)
|
||||
assert backend.revision=='a'*40
|
||||
restarted=Broker(broker.database,broker.receipts,admitted=True)
|
||||
assert drive(restarted,rid,backend,NOW+timedelta(minutes=6))[-1]=='rolled_back'
|
||||
assert backend.revision=='b'*40
|
||||
|
||||
|
||||
def test_expired_plan_never_publishes(bundle):
|
||||
broker,*_=bundle;rid=admit(bundle);backend=FakeBackend()
|
||||
assert broker.advance(rid,backend,NOW+timedelta(minutes=6))=='cancelled'
|
||||
assert backend.calls==[]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind',['signature','commit','stale','future','key','role','scope','retention','build','independence','health','checks'])
|
||||
def test_rejects_untrusted_or_incomplete_evidence(bundle,kind):
|
||||
broker,before,after,keys=bundle;e=envelopes(before,after,keys);r=e['review']
|
||||
if kind=='signature':r['payload']['result']='fail'
|
||||
elif kind=='commit':r['payload']['binding']['candidate']='c'*40;resign(r,keys['review'])
|
||||
elif kind=='stale':r['payload']['issued_at']=(NOW-timedelta(minutes=6)).isoformat();resign(r,keys['review'])
|
||||
elif kind=='future':r['payload']['issued_at']=(NOW+timedelta(seconds=1)).isoformat();resign(r,keys['review'])
|
||||
elif kind=='key':r['key_id']='caller-provided'
|
||||
elif kind=='role':r['key_id']='build'
|
||||
elif kind=='scope':r['payload']['authority']='admin';resign(r,keys['review'])
|
||||
elif kind=='retention':e['retention']['payload']['images']=[];resign(e['retention'],keys['retention'])
|
||||
elif kind=='build':e['build']['payload']['images']=[];resign(e['build'],keys['build'])
|
||||
elif kind=='independence':broker.receipts.trusted['review']['principal']='build'
|
||||
elif kind=='checks':e['build']['payload']['checks']={};resign(e['build'],keys['build'])
|
||||
elif kind=='health':e['health']['payload']['continuous']=False;resign(e['health'],keys['health'])
|
||||
with pytest.raises((ValueError,InvalidSignature)):
|
||||
broker.admit(before,after,'a'*40,'b'*40,e,now=NOW)
|
||||
with broker.connect() as db:assert db.execute('SELECT count(*) FROM releases').fetchone()[0]==0
|
||||
|
||||
|
||||
def test_signed_out_of_scope_manifest_still_refused(bundle):
|
||||
broker,before,after,keys=bundle
|
||||
next(d for d in after if d['kind']=='Deployment')['spec']['replicas']=99
|
||||
with pytest.raises(ValueError,match='non-image'):
|
||||
broker.admit(before,after,'a'*40,'b'*40,envelopes(before,after,keys),now=NOW)
|
||||
|
||||
|
||||
def test_unadmitted_identity_cannot_start_or_resume(bundle):
|
||||
broker,*_=bundle;rid=admit(bundle);broker.admitted=False
|
||||
with pytest.raises(ValueError,match='not admitted'):admit(bundle)
|
||||
with pytest.raises(ValueError,match='not admitted'):broker.advance(rid,FakeBackend(),NOW)
|
||||
|
||||
|
||||
def test_fixed_git_and_argo_mutation_shapes():
|
||||
from activity_core.release_operations import update_child, sync_operations
|
||||
declaration = '''apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: activity-core
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: activity-core
|
||||
source:
|
||||
repoURL: https://forgejo.coulomb.social/coulomb/activity-core.git
|
||||
targetRevision: BBBB
|
||||
path: k8s/gitops
|
||||
destination:
|
||||
namespace: activity-core
|
||||
server: https://kubernetes.default.svc
|
||||
syncPolicy:
|
||||
syncOptions: [CreateNamespace=false, ApplyOutOfSyncOnly=true, PruneLast=true, FailOnSharedResource=true]
|
||||
'''.replace('BBBB','b'*40)
|
||||
updated=update_child(declaration,'b'*40,'a'*40)
|
||||
assert updated==declaration.replace('b'*40,'a'*40)
|
||||
assert update_child(updated,'b'*40,'a'*40)==updated
|
||||
with pytest.raises(ValueError,match='CAS'):update_child(declaration,'c'*40,'a'*40)
|
||||
with pytest.raises(ValueError):update_child(declaration.replace('namespace: activity-core','namespace: foreign'),'b'*40,'a'*40)
|
||||
with pytest.raises(ValueError):update_child(declaration,'b'*40,'main')
|
||||
root,child=sync_operations('c'*40,'a'*40)
|
||||
assert root[0]=='railiance-apps-root' and child[0]=='activity-core'
|
||||
assert root[1]['operation']['sync']['resources']==[{'group':'argoproj.io','kind':'Application','name':'activity-core','namespace':'argocd'}]
|
||||
assert all(op['operation']['sync']['prune'] is False for _,op in [root,child])
|
||||
|
||||
|
||||
def test_different_principals_cannot_share_review_signing_key(bundle):
|
||||
broker,before,after,keys=bundle
|
||||
e=envelopes(before,after,keys)
|
||||
broker.receipts.trusted['review']['public_key']=broker.receipts.trusted['build']['public_key']
|
||||
resign(e['review'],keys['build'])
|
||||
with pytest.raises(ValueError,match='independent'):
|
||||
broker.admit(before,after,'a'*40,'b'*40,e,now=NOW)
|
||||
Loading…
Add table
Add a link
Reference in a new issue