activity-core/tests/test_release_broker.py

203 lines
9.8 KiB
Python
Raw Normal View History

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