59 lines
5.2 KiB
Python
59 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Read-only Fleet artifact/live-state preflight; never deploys or restarts."""
|
|
import argparse,hashlib,json,os,pathlib,subprocess,tarfile,urllib.request,datetime
|
|
P=pathlib.Path
|
|
ap=argparse.ArgumentParser();ap.add_argument('--host',required=True,choices=['archi-dev-box','yaya-server']);ap.add_argument('--artifact',required=True,type=P);ap.add_argument('--out',required=True,type=P);ap.add_argument('--previous',type=P);a=ap.parse_args()
|
|
assert subprocess.check_output(['hostname'],text=True).strip()==a.host
|
|
assert not a.out.exists(),'Refuse to overwrite earlier preflight evidence'
|
|
def digest(p):
|
|
with open(p,'rb') as f:return hashlib.file_digest(f,'sha256').hexdigest()
|
|
def property(name):return subprocess.check_output(['systemctl','show','archipelago','--value','-p',name],text=True).strip()
|
|
q=json.loads((a.artifact/'qualification-final.json').read_text());manifest=json.loads((a.artifact/'dist-inputs.json').read_text());archive=a.artifact/'qualified-ui.tar.gz'
|
|
assert q['production_build_passed'] and q['source_inputs_unchanged']
|
|
assert q['baseline_tests_passed']==1459 and q['baseline_test_files_passed']==177
|
|
assert q['delta_focused_tests_passed']==31 and q['delta_app_typecheck_passed']
|
|
assert digest(archive)==q['archive_sha256']=='063752912fdcae2d1abf2abaccd53bfad39acce23c9a5d896ce52ba1e1996080'
|
|
assert digest(a.artifact/'dist-inputs.json')==q['dist_manifest_sha256']
|
|
assert digest(a.artifact/'dist/index.html')==q['index_sha256']
|
|
seen=set()
|
|
with tarfile.open(archive) as tar:
|
|
for member in tar:
|
|
path=P(member.name)
|
|
assert not path.is_absolute() and '..' not in path.parts and path.parts[0]=='dist'
|
|
assert member.isdir() or member.isfile(),'Non-regular archive member'
|
|
if member.isfile():
|
|
rel=str(path.relative_to('dist'));assert rel in manifest and rel not in seen
|
|
with tar.extractfile(member) as f:assert hashlib.file_digest(f,'sha256').hexdigest()==manifest[rel]
|
|
seen.add(rel)
|
|
assert seen==set(manifest)
|
|
web=P('/opt/archipelago/web-ui');data=P('/var/lib/archipelago')
|
|
files={data/x for x in ['remember_secret','user-stopped.json','user-uninstalled.json','installed-apps.json','app-catalog.json','node-app-catalog.json']}
|
|
for root in (data,web,data/'catalogs',data/'content',data/'qualification'):
|
|
if root.is_dir():files.update(p for p in root.glob('*catalog*') if p.is_file())
|
|
for root in (web/'packages',web/'aiui',web/'catalogs',data/'identity'):
|
|
if root.is_dir():files.update(p for p in root.rglob('*') if p.is_file())
|
|
preserved={str(p):digest(p) if p.is_file() else None for p in sorted(files)}
|
|
ids=subprocess.check_output(['podman','ps','-aq'],text=True).split()
|
|
rows=json.loads(subprocess.check_output(['podman','inspect',*ids])) if ids else []
|
|
containers={c['Name']:{'id':c['Id'],'started_at':c['State']['StartedAt'],'status':c['State']['Status'],'running':c['State']['Running']} for c in rows}
|
|
pid=property('MainPID');assert pid.isdigit() and int(pid)>0
|
|
exe=P('/proc')/pid/'exe'
|
|
try:running=digest(exe)
|
|
except PermissionError:running=subprocess.check_output(['sudo','-n','sha256sum',str(exe)],text=True).split()[0]
|
|
with urllib.request.urlopen('http://127.0.0.1/',timeout=20) as r:served=hashlib.file_digest(r,'sha256').hexdigest()
|
|
with urllib.request.urlopen('http://127.0.0.1:5678/health',timeout=20) as r:health=r.status
|
|
record={'captured_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),'host':a.host,'read_only':True,'index_sha256':digest(web/'index.html'),'served_index_sha256':served,'backend_sha256':digest('/usr/local/bin/archipelago'),'running_backend_sha256':running,'manager':{'pid':pid,'active_state':property('ActiveState'),'sub_state':property('SubState'),'started_at':property('ExecMainStartTimestamp')},'health_http':health,'preserved':preserved,'containers':containers,'candidate_archive_sha256':q['archive_sha256'],'candidate_index_sha256':q['index_sha256'],'artifact_members_verified':len(seen),'backend_qualified':False,'deploy_ready':False}
|
|
assert served==record['index_sha256'],'Served UI differs from disk'
|
|
assert running==record['backend_sha256'],'Running backend differs from installed binary'
|
|
assert health==200
|
|
fd=os.open(a.out,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
|
with os.fdopen(fd,'w') as f:json.dump(record,f,indent=2);f.write('\n')
|
|
summary={k:record[k] for k in ['captured_at','host','read_only','index_sha256','backend_sha256','running_backend_sha256','health_http','artifact_members_verified','backend_qualified','deploy_ready']};summary.update(preservation_paths=len(preserved),containers=len(containers))
|
|
if a.previous:
|
|
old=json.loads(a.previous.read_text());summary['drift_from_previous']={
|
|
'ui_changed':old.get('index_sha256')!=record['index_sha256'],
|
|
'backend_changed':old.get('backend_sha256')!=record['backend_sha256'],
|
|
'preservation_paths_changed':len([p for p in set(old.get('preserved',{}))|set(preserved) if old.get('preserved',{}).get(p)!=preserved.get(p)]),
|
|
'container_id_or_start_changes':len([n for n in set(old.get('containers',{}))|set(containers) if (old.get('containers',{}).get(n) if isinstance(old.get('containers',{}).get(n),list) else [old.get('containers',{}).get(n,{}).get('id'),old.get('containers',{}).get(n,{}).get('started_at')])!=([containers[n]['id'],containers[n]['started_at']] if n in containers else None)])}
|
|
print(json.dumps(summary))
|