Files
archy/tests/regression/c01-history-migration.py

150 lines
10 KiB
Python

#!/usr/bin/python3
"""Nonempty synthetic legacy import and retained-store qualification in PID1 realm.
No installed keys/data are copied. Existing native storage/fence code, real
access retirement, mapped backing views and rootless Podman are exercised.
"""
import hashlib
import json
import os
from pathlib import Path
class MigrationFixture:
def __init__(self, context):
self.c = context
def seed(self):
c=self.c
assert os.geteuid()==0 and Path('/run/.containerenv').exists()
assert os.uname().nodename=='c01-prerequisite-qualification'
launch,_=c['policy'].json_read(c['policy'].LAUNCH)
runtime,_=c['policy'].json_read(c['policy'].RUNTIME)
self.launch=launch;self.runtime=runtime
manager=Path(launch['runtime_data_dir'] if os.environ.get('C01_LAYOUT_MIGRATION')=='1' else launch['manager_backing'])
self.source=manager/'federation/pending_requests.json'
assert not self.source.exists()
rows=[]
appointment=c['PACKAGE']/'node-cutover-fixture.json'
self.node_appointment=json.loads(appointment.read_text()) if appointment.exists() else None
row_count=self.node_appointment['legacy_rows'] if self.node_appointment else 2
for index in range(row_count):
state='pending' if index%2==0 else 'rejected'
rows.append(dict(id='retained-legacy-'+str(index),from_nostr_pubkey=('%02x'%(index+2))*32,
from_nostr_npub='synthetic-legacy-display-'+str(index),from_did='did:fixture:peer-'+str(index),
from_name='retained peer',message='retained migration history',
received_at='2026-09-30T00:00:00Z',state=state,outbound=False))
c['dump'](self.source,{'requests':rows})
public=manager/'identity/nostr_pubkey'
assert not public.exists()
c['write'](public,bytes(launch['binding']['local_identity']).hex(),0o644)
for p in (self.source,self.source.parent,public):os.chown(p,1000,1000)
self.original=self.source.read_bytes();self.source_meta=self.source.stat()
declaration_stops=[]
if self.node_appointment and self.node_appointment['node']=='yaya':
import runpy
declaration_stops=runpy.run_path(str(c['PACKAGE']/'tests/regression/yaya-container-declarations.py'))['seed'](c,self.node_appointment,runtime)
stopped_ids=(self.node_appointment['stopped'] if self.node_appointment else [])+['fixture-stopped']+declaration_stops
uninstalled_ids=(self.node_appointment['uninstalled'] if self.node_appointment else [])+['fixture-removed']
for name,ids in (('user-stopped.json',stopped_ids),('user-uninstalled.json',uninstalled_ids)):
target=manager/name;assert not target.exists();c['dump'](target,ids);os.chown(target,1000,1000)
for base in (manager/'wallet',Path(runtime['runtime_data_dir'])/'lnd',
Path(runtime['runtime_data_dir'])/'fixture-removed'):
base.mkdir(mode=0o700,parents=True,exist_ok=True);os.chown(base,1000,1000)
marker=base/'migration-preserved.bin';assert not marker.exists()
c['write'](marker,b'SYNTHETIC OPAQUE METADATA - NOT A WALLET\n'*64,0o600);os.chown(marker,1000,1000)
subordinate=Path(runtime['runtime_data_dir'])/'subuid-preserved.bin'
c['write'](subordinate,b'preserve original subordinate ownership\n',0o600)
os.chown(subordinate,runtime['subuids'][0][0],runtime['subgids'][0][0])
for app in ('fixture-stopped','fixture-removed'):
manifest={'app':{'id':app,'name':app,'version':'1.0.0','description':'Disposable migration fixture',
'container':{'image':'localhost/c01-prerequisite:fixture','pull_policy':'if-not-present',
'network':'none','user':1000},
'resources':{'cpu_limit':1,'memory_limit':'64Mi','disk_limit':'128Mi'},
'security':{'capabilities':[],'readonly_root':True,'network_policy':'isolated'},'ports':[],
'health_check':{'type':'tcp','endpoint':'localhost:1','interval':'30s','timeout':'5s','retries':3}}}
destination=Path('/opt/archipelago/apps')/app/'manifest.yml'
assert not destination.exists();c['write'](destination,json.dumps(manifest),0o644)
# Execute inside the existing old user-manager hierarchy, before access
# retirement. Never inject a UID1000 diagnostic process after admission.
stopped=c['run']('runuser','-u','archipelago','--','env','XDG_RUNTIME_DIR=/run/user/1000',
'systemd-run','--user','--wait','--pipe','--collect','--unit=c01-preserved-stopped',
'/usr/bin/podman','create','--name','fixture-stopped','--network=none',
'localhost/c01-prerequisite:fixture','/bin/true').stdout.decode().strip()
assert len(stopped)==64 and all(x in '0123456789abcdef' for x in stopped)
c['dump'](c['EVIDENCE']/'migration-seed.json',{'scope':'synthetic nonempty history; no node private keys',
'legacy_sha256':hashlib.sha256(self.original).hexdigest(),'legacy_rows':len(rows),
'source_inode':self.source_meta.st_ino,'source_uid':self.source_meta.st_uid,
'stopped_container_id':stopped,'subuid':subordinate.stat().st_uid,'subgid':subordinate.stat().st_gid})
return {'legacy_rows':len(rows),'stopped_container_id':stopped,'financial_operations':False}
def layout_plan(self):
import c01_migration_layout as layout
c=self.c;launch,_=c['policy'].json_read(c['policy'].LAUNCH)
runtime,_=c['policy'].json_read(c['policy'].RUNTIME)
source=Path(runtime['runtime_data_dir']);target=Path(launch['manager_backing'])
private={'identity','federation','wallet','user.json','user-stopped.json','user-uninstalled.json'}
entries=[]
for path in sorted(source.iterdir()):
if path.name=='.c01-private':continue
disposition='private' if path.name in private else 'runtime'
entries.append({'name':path.name,'disposition':disposition,'identity':layout.identity(path.lstat()),
'mode_after':0o700 if path.name=='identity' else path.lstat().st_mode & 0o7777,
'tree_metadata_sha256':layout.private_metadata(path) if disposition=='private' else None})
assert private<={entry['name'] for entry in entries}
plan={'version':1,'source_root':str(source),'source_identity':layout.identity(source.stat()),
'target_root':str(target),'target_identity':layout.identity(target.stat()),
'private_parent':{'path':str(source/'.c01-private'),'identity':layout.identity((source/'.c01-private').stat())},
'entries':entries,'quarantine_parents':[str(c['policy'].STATE/'quarantine')]}
layout.validate(plan,launch,runtime)
c['dump'](c['EVIDENCE']/'migration-layout-plan.json',plan)
return plan
def import_retired(self):
c=self.c;policy=c['policy'];launch,launch_hash=policy.json_read(policy.LAUNCH)
access_value,access_hash=policy.json_read(policy.CONFIG)
if os.environ.get('C01_LAYOUT_MIGRATION')=='1':
old=self.source
self.source=Path(launch['manager_backing'])/'federation/pending_requests.json'
assert not old.exists() and self.source.stat().st_ino==self.source_meta.st_ino
m=self.source.stat()
assert self.source.read_bytes()==self.original and m.st_ino==self.source_meta.st_ino
approval={'version':1,'operation':'import-legacy-requests-v1',
'authorization':access_value['authorization'],
'launch_sha256':launch_hash,'access_sha256':access_hash,'binding':launch['binding'],
'source_public_sha256':c['sha'](Path(launch['manager_backing'])/'identity/nostr_pubkey'),
# Synthetic explicit first-C01 floor; not an authenticated UTC grant.
'approved_watermark':1790000000,
'source':{'path':str(self.source),'device':m.st_dev,'inode':m.st_ino,'uid':m.st_uid,
'gid':m.st_gid,'bytes':m.st_size,'sha256':hashlib.sha256(self.original).hexdigest()}}
raw=(json.dumps(approval,sort_keys=True,indent=2)+'\n').encode();digest=hashlib.sha256(raw).hexdigest()
c['write']('/etc/archipelago-c01/migrations/'+digest+'.json',raw,0o600)
def execute(name,*args):
p=c['run']('systemd-run','--wait','--pipe','--unit=c01-history-'+name,
'-p','Delegate=yes','-p','DelegateSubgroup=supervisor','-p','NoNewPrivileges=yes',
'-p','NetworkNamespacePath=/run/netns/archipelago-managed',
'/usr/bin/python3','-B','/usr/lib/archipelago/provision-c01-history.py',*args,check=False)
c['dump'](c['EVIDENCE']/('history-'+name+'.json'),{'exit':p.returncode,'stdout':p.stdout.decode(),'stderr':p.stderr.decode()})
return p
imported=execute('import','import-legacy','--approval',digest)
assert imported.returncode==0, 'actual capability-bound legacy import failed'
receipt=json.loads(imported.stdout)
assert receipt['anchor']['watermark']==approval['approved_watermark'] and receipt['anchor']['revision']==1
assert receipt['anchor']['incarnation']==launch['binding']['incarnation'] and not receipt['clock_authorized']
assert self.source.read_bytes()==self.original and self.source.stat().st_ino==m.st_ino
def inventory():
return {str(p):[p.stat().st_dev,p.stat().st_ino,p.stat().st_uid,p.stat().st_gid,c['sha'](p)]
for role in ('snapshot','authority') for p in Path(launch[role+'_backing']).iterdir() if p.is_file()}
preserved=inventory()
inspected=execute('inspect','inspect');assert inspected.returncode==0
assert json.loads(inspected.stdout)['anchor']==receipt['anchor'] and inventory()==preserved
repeated=execute('reimport-refusal','import-legacy','--approval',digest)
assert repeated.returncode!=0 and inventory()==preserved
c['dump'](c['EVIDENCE']/'history-import-preservation.json',{'anchor':receipt['anchor'],
'encrypted_store_identity':preserved,'source_sha256':hashlib.sha256(self.original).hexdigest(),
'source_inode':m.st_ino,'existing_store_reimport_refused':True,'clock_authorized':False})
# Activate only the durable epoch mechanism here; actual application time
# still requires the normal fresh authenticated post-barrier observation.
result=c['epoch_owner']()
c['dump'](c['EVIDENCE']/'history-epoch.json',result)