activity-core/tests/test_release_transport.py

192 lines
11 KiB
Python
Raw Normal View History

"""Real disposable Git repositories; simulated Kubernetes boundary."""
import copy
import json
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import yaml
from activity_core.release_broker import fingerprint
from activity_core.release_observer import HealthObserver
from activity_core.release_operations import APPLICATION_PATH
from activity_core.release_transport import ACTIVITY_URL, PLATFORM_URL, GitArgoBackend, TransportError, run
NOW=datetime(2026,9,28,16,tzinfo=timezone.utc)
ROOT=Path(__file__).resolve().parents[1]
def git(path,*args):
return subprocess.check_output(['git','-C',str(path),*args],text=True,stderr=subprocess.DEVNULL).strip()
def init(path):
path.mkdir();git(path,'init','-b','main');git(path,'config','user.name','Fixture');git(path,'config','user.email','fixture@example.invalid')
def commit(path):
git(path,'add','.');git(path,'commit','-m','fixture');return git(path,'rev-parse','HEAD')
@pytest.fixture
def transport(tmp_path):
activity=tmp_path/'activity';init(activity)
k=activity/'k8s/gitops';k.mkdir(parents=True)
before=list(yaml.safe_load_all((ROOT/'k8s/gitops/runtime.yaml').read_text()))
(k/'runtime.yaml').write_text(yaml.safe_dump_all(before));(k/'kustomization.yaml').write_text((ROOT/'k8s/gitops/kustomization.yaml').read_text())
rollback=commit(activity)
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
(k/'runtime.yaml').write_text(yaml.safe_dump_all(after));candidate=commit(activity)
seed=tmp_path/'seed';init(seed);p=seed/APPLICATION_PATH;p.parent.mkdir(parents=True)
child={'apiVersion':'argoproj.io/v1alpha1','kind':'Application','metadata':{'name':'activity-core','namespace':'argocd'},'spec':{'project':'activity-core','source':{'repoURL':ACTIVITY_URL,'path':'k8s/gitops','targetRevision':rollback},'destination':{'namespace':'activity-core','server':'https://kubernetes.default.svc'},'syncPolicy':{'syncOptions':['CreateNamespace=false','ApplyOutOfSyncOnly=true','PruneLast=true','FailOnSharedResource=true']}}}
p.write_text(yaml.safe_dump(child));commit(seed)
platform=tmp_path/'platform.git';git(tmp_path,'clone','--bare',str(seed),str(platform))
plan={'repository':'coulomb/activity-core','application':'activity-core','candidate':candidate,'rollback':rollback,'before_sha256':fingerprint(before),'after_sha256':fingerprint(after)}
state={'tick':0,'calls':[],'lose_push':False,'fail_health':False}
root={'metadata':{'name':'railiance-apps-root','namespace':'argocd','resourceVersion':'1'},'spec':{'source':{'repoURL':PLATFORM_URL,'path':'argocd/railiance01/applications','targetRevision':'main'}}}
child['metadata']['resourceVersion']='1';apps={'railiance-apps-root':root,'activity-core':child}
def runner(argv,**kwargs):
if argv[0]=='git':
argv=[str(activity) if a==ACTIVITY_URL else str(platform) if a==PLATFORM_URL else 'protocol.file.allow=always' if a=='protocol.file.allow=never' else a for a in argv]
result=run(argv,**kwargs)
if 'push' in argv and state['lose_push']:
state['lose_push']=False;raise TransportError('fixture lost push response')
return result
state['calls'].append(argv)
if 'get' in argv:
i=argv.index('get');kind,name=argv[i+1:i+3]
if kind=='namespace':return 'fixture-cluster'
if kind=='application':return json.dumps(apps[name])
return json.dumps({'metadata':{'generation':1},'spec':{'replicas':1},'status':{'observedGeneration':1,'readyReplicas':1,'updatedReplicas':1,'availableReplicas':1}})
i=argv.index('patch');name=argv[i+2];patch=json.loads(kwargs['input']);obj=apps[name]
assert patch[0]=={'op':'test','path':'/metadata/resourceVersion','value':obj['metadata']['resourceVersion']}
assert patch[1]=={'op':'test','path':'/spec','value':obj['spec']}
operation=patch[2]['value'];sync=operation['sync'];assert sync['prune'] is False
if name=='railiance-apps-root':
assert sync['resources']==[{'group':'argoproj.io','kind':'Application','name':'activity-core','namespace':'argocd'}]
source=yaml.safe_load(git(platform,'show',sync['revision']+':'+APPLICATION_PATH))
apps['activity-core']['spec']=source['spec']
obj['status']={'operationState':{'phase':'Succeeded','syncResult':{'revision':sync['revision']}},'sync':{'status':'Synced','revision':sync['revision']},'health':{'status':'Healthy'}}
return '{}'
def pause(seconds):state['tick']+=seconds
backend=GitArgoBackend(plan,tmp_path/'broker',lambda target:not(state['fail_health'] and target==candidate),cluster_uid='fixture-cluster',admitted=True,runner=runner,clock=lambda:state['tick'],pause=pause,wait_seconds=2)
return backend,plan,state,platform,apps,before,after
def test_real_git_publish_recover_sync_and_rollback(transport):
b,p,state,platform,apps,*_=transport
before=git(platform,'rev-parse','main');state['lose_push']=True
with pytest.raises(TransportError):b.publish_revision(p['rollback'],p['candidate'])
published=git(platform,'rev-parse','main');assert published!=before
b.publish_revision(p['rollback'],p['candidate'])
assert git(platform,'rev-parse','main')==published
assert git(platform,'diff-tree','--no-commit-id','--name-only','-r',published)==APPLICATION_PATH
b.sync_revision(p['candidate']);assert b.healthy(p['candidate'])
state['fail_health']=True;assert not b.healthy(p['candidate'])
b.publish_revision(p['candidate'],p['rollback']);b.sync_revision(p['rollback']);assert b.healthy(p['rollback'])
assert apps['activity-core']['spec']['source']['targetRevision']==p['rollback']
def test_manifest_tampering_refused_before_push(transport):
b,p,state,platform,*_=transport;before=git(platform,'rev-parse','main');b.plan['after_sha256']='0'*64
with pytest.raises(ValueError,match='manifest binding'):b.publish_revision(p['rollback'],p['candidate'])
assert git(platform,'rev-parse','main')==before
assert not any('patch' in c for c in state['calls'])
def test_no_unbound_target_or_unadmitted_transport(transport,tmp_path):
b,p,*_=transport
with pytest.raises(ValueError):b.publish_revision(p['rollback'],'c'*40)
with pytest.raises(ValueError):b.sync_revision('c'*40)
with pytest.raises(ValueError):GitArgoBackend(p,tmp_path/'denied',lambda _:True,cluster_uid='fixture-cluster')
def test_active_foreign_operation_not_overwritten(transport):
b,p,state,platform,apps,*_=transport
b.publish_revision(p['rollback'],p['candidate'])
apps['railiance-apps-root']['operation']={'sync':{'revision':'d'*40,'prune':True}}
with pytest.raises(ValueError,match='active'):b.sync_revision(p['candidate'])
assert not any('patch' in c for c in state['calls'])
def test_health_observer_requires_samples_and_survives_restart(tmp_path):
o=HealthObserver(tmp_path/'health.sqlite');sha='a'*40
o.observe(sha,True,NOW)
o.observe(sha,True,NOW+timedelta(hours=24))
with pytest.raises(ValueError,match='incomplete'):o.attestation(sha,NOW+timedelta(hours=24))
start=NOW+timedelta(hours=24)
for n in range(1,1441):o.observe(sha,True,start+timedelta(minutes=n))
restarted=HealthObserver(tmp_path/'health.sqlite')
result=restarted.attestation(sha,start+timedelta(hours=24))
assert result['healthy_since']==start.isoformat()
with pytest.raises(ValueError,match='stale'):restarted.attestation(sha,start+timedelta(hours=24,minutes=2))
restarted.observe(sha,False,start+timedelta(hours=24,minutes=1))
with pytest.raises(ValueError,match='no healthy'):restarted.attestation(sha,start+timedelta(hours=24,minutes=1))
def test_observer_revision_change_and_clock_regression(tmp_path):
o=HealthObserver(tmp_path/'health.sqlite');o.observe('a'*40,True,NOW)
with pytest.raises(ValueError,match='non-increasing'):o.observe('a'*40,True,NOW)
t=NOW+timedelta(seconds=60)
assert o.observe('b'*40,True,t)==t.isoformat()
with pytest.raises(ValueError):o.attestation('a'*40,t)
def test_concurrent_git_writer_is_not_overwritten(transport):
b,p,state,platform,apps,*_=transport
original=b.runner
def racing(argv,**kwargs):
if argv[0]=='git' and 'push' in argv:
# Inject another committed platform update after broker's CAS read.
tree=git(platform,'rev-parse','main^{tree}')
parent=git(platform,'rev-parse','main')
env=dict(b.env)
new=run(['git','-C',str(platform),'commit-tree',tree,'-p',parent],input='concurrent writer',env=env).strip()
git(platform,'update-ref','refs/heads/main',new,parent)
return original(argv,**kwargs)
b.runner=racing
with pytest.raises(TransportError):b.publish_revision(p['rollback'],p['candidate'])
current=yaml.safe_load(git(platform,'show','main:'+APPLICATION_PATH))
assert current['spec']['source']['targetRevision']==p['rollback']
def test_broker_rolls_back_using_real_git_transport(transport,tmp_path):
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding,PublicFormat
from activity_core.release_broker import Broker,Receipts,ROLES
from tests.test_release_broker import envelopes
b,p,state,platform,apps,before,after=transport
keys={role:Ed25519PrivateKey.generate() for role in ROLES}
trust={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/'coordinator.sqlite',Receipts(trust),admitted=True)
signed=envelopes(before,after,keys,candidate=p['candidate'],rollback=p['rollback'])
rid=broker.admit(before,after,p['candidate'],p['rollback'],signed,NOW)
state['fail_health']=True
phases=[]
for _ in range(10):
phases.append(broker.advance(rid,b,NOW))
if phases[-1]=='rolled_back':break
assert phases==['publish_pending','published','synced','rollback_planned','rollback_published','rollback_synced','rolled_back']
assert apps['activity-core']['spec']['source']['targetRevision']==p['rollback']
assert yaml.safe_load(git(platform,'show','main:'+APPLICATION_PATH))['spec']['source']['targetRevision']==p['rollback']
def test_clock_regression_invalidates_interval_until_new_sample(tmp_path):
o=HealthObserver(tmp_path/'health.sqlite');sha='a'*40
o.observe(sha,True,NOW)
with pytest.raises(ValueError):o.observe(sha,True,NOW-timedelta(seconds=1))
with pytest.raises(ValueError,match='invalidated'):HealthObserver(tmp_path/'health.sqlite').attestation(sha,NOW)
later=NOW+timedelta(seconds=30)
assert o.observe(sha,True,later)==later.isoformat()
def test_wrong_cluster_refused_before_git_publication(transport):
b,p,state,platform,*_=transport
before=git(platform,'rev-parse','main');b.cluster_uid='wrong-cluster'
with pytest.raises(ValueError,match='cluster identity'):
b.publish_revision(p['rollback'],p['candidate'])
assert git(platform,'rev-parse','main')==before
assert not any('patch' in c for c in state['calls'])