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