139 lines
8 KiB
Python
139 lines
8 KiB
Python
#!/usr/bin/env python3
|
|
"""Read-only Git+Hub task binding verifier; never syncs or changes source/index."""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import urllib.request
|
|
|
|
HERE=Path(__file__).resolve().parent
|
|
OWNERS=[{'repo':'railiance-cluster','commit':'c562d7134c38679952a8ffd17a8a893a900aef0d','path':'workplans/RCLUSTER-WP-0018-keycape-original-tls-recovery.md','id':'RCLUSTER-WP-0018','tasks':5}]
|
|
API=os.environ.get('API_BASE','http://127.0.0.1:8000').rstrip('/')
|
|
EXTERNAL={'STATE-WP-0095-T03':'e8566341-cf6e-5b9c-ad80-8564d14cd52a','STATE-WP-0095-T06':'6af973b4-1964-5d4d-a2f8-8dbd4c01fda4','NK-WP-0049-T33':'56c8d469-7a10-5d50-a30e-95cbf3cd4756'}
|
|
|
|
|
|
class Refused(Exception):pass
|
|
|
|
|
|
def git(root,*args):
|
|
result=subprocess.run(['git','--no-optional-locks','-C',str(root),*args],
|
|
capture_output=True,timeout=30,check=False)
|
|
if result.returncode:raise Refused('read-only-git-failed')
|
|
return result.stdout
|
|
|
|
|
|
def field(text,key):
|
|
values=re.findall(r'^'+re.escape(key)+r':\s*(.*?)\s*$',text,re.M)
|
|
if len(values)!=1:raise Refused('source-field-ambiguous')
|
|
return values[0].strip('"\'')
|
|
|
|
|
|
def source(text):
|
|
header=text.split('---',2)[1]
|
|
tasks=[]
|
|
for block in re.findall(r'```task\n(.*?)\n```',text,re.S):
|
|
deps=re.findall(r'^depends_on:\s*\[(.*?)\]',block,re.M)
|
|
tasks.append({'record_id':field(block,'id'),'id':field(block,'state_hub_task_id'),
|
|
'status':field(block,'status'),'dependencies':[x.strip() for x in deps[0].split(',') if x.strip()] if deps else []})
|
|
return {'id':field(header,'state_hub_workstream_id'),'source_record_id':field(header,'id'),
|
|
'slug':field(header,'id').lower(),
|
|
'status':field(header,'status'),'tasks':tasks}
|
|
|
|
|
|
def get(path):
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self,*args,**kwargs):raise Refused('hub-redirect-refused')
|
|
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect)
|
|
with opener.open(urllib.request.Request(API+path,method='GET'),timeout=20) as response:
|
|
data=response.read(8*1024**2+1)
|
|
if len(data)>8*1024**2:raise Refused('hub-read-limit')
|
|
return json.loads(data)
|
|
|
|
|
|
def index_stat(root):
|
|
raw=git(root,'rev-parse','--git-path','index').decode().strip();path=Path(raw)
|
|
if not path.is_absolute():path=root/path
|
|
try:item=path.lstat()
|
|
except FileNotFoundError:return None
|
|
return [item.st_dev,item.st_ino,item.st_size,item.st_mtime_ns,item.st_mode]
|
|
|
|
|
|
def remote(root,expected,repo):
|
|
if git(root,'remote','get-url','origin').decode().strip()!='forgejo-remote:coulomb/'+repo+'.git':
|
|
raise Refused('remote-route-drift')
|
|
lines=git(root,'ls-remote','--symref','origin','HEAD','refs/heads/main').decode().splitlines()
|
|
if set(lines)!={'ref: refs/heads/main\tHEAD',expected+'\tHEAD',expected+'\trefs/heads/main'}:
|
|
raise Refused('remote-head-mismatch')
|
|
return {'HEAD':expected,'refs/heads/main':expected,'symbolic_HEAD':'refs/heads/main'}
|
|
|
|
|
|
def verify():
|
|
result={'status':'unverified','scope':'Cluster WP18 owner publication; read-only Git remote and Hub snapshots',
|
|
'writes_to_source_index_refs_or_Hub':False,'rows':[]}
|
|
try:
|
|
if API not in {'http://127.0.0.1:8000','http://127.0.0.1:18080','http://10.43.68.154:8000'}:
|
|
raise Refused('unrecognized-hub-endpoint')
|
|
health=get('/state/health')
|
|
result['hub_instance']={key:health.get(key) for key in ('status','instance_role','instance_label')}
|
|
if result['hub_instance']!={'status':'ok','instance_role':'primary','instance_label':'railiance01'}:
|
|
raise Refused('hub-identity-mismatch')
|
|
for owner in OWNERS:
|
|
root=Path('/home/worsch')/owner['repo'];before_index=index_stat(root)
|
|
if git(root,'rev-parse','HEAD').decode().strip()!=owner['commit']:
|
|
raise Refused('local-head-drift')
|
|
committed=git(root,'show',owner['commit']+':'+owner['path'])
|
|
local=(root/owner['path']).read_bytes()
|
|
if local!=committed:raise Refused('published-workplan-local-drift')
|
|
expected=source(committed.decode())
|
|
if expected['source_record_id']!=owner['id'] or len(expected['tasks'])!=owner['tasks']:
|
|
raise Refused('source-task-denominator-mismatch')
|
|
snapshot=get('/repos/'+owner['repo']+'/work-record-projection/snapshot')
|
|
plans=[row for row in snapshot['workplans'] if row['id']==expected['id']]
|
|
if len(plans)!=1 or any(plans[0].get(key)!=expected[key] for key in ('id','slug','status')):
|
|
raise Refused('workplan-binding-mismatch')
|
|
projected=[row for row in snapshot['tasks']
|
|
if row.get('workplan_id',row.get('workstream_id'))==expected['id']]
|
|
if len(projected)!=len(expected['tasks']):raise Refused('projected-task-denominator-mismatch')
|
|
byid={row['id']:row for row in projected}
|
|
if len(byid)!=len(projected):raise Refused('duplicate-projected-uuid')
|
|
observed=[]
|
|
for task in expected['tasks']:
|
|
row=byid.get(task['id'],{})
|
|
if any(row.get(key)!=task[key] for key in ('id','record_id','status')):
|
|
raise Refused('task-id-uuid-status-mismatch')
|
|
observed.append({key:row[key] for key in ('id','record_id','status')})
|
|
identifiers={task['record_id']:task['id'] for task in expected['tasks']}
|
|
edges={(task['id'],identifiers[dep],'starts_after') for task in expected['tasks'] for dep in task['dependencies'] if dep in identifiers}
|
|
actual=[edge for edge in snapshot['dependencies'] if edge['from_workplan_id']==expected['id']]
|
|
projected_edges={(edge.get('from_task_id'),edge.get('to_task_id'),edge['relationship_type']) for edge in actual}
|
|
if len(actual)!=len(edges) or projected_edges!=edges:
|
|
raise Refused('dependency-binding-mismatch')
|
|
if any(edge.get('source_commit') not in (None,owner['commit']) for edge in actual):
|
|
raise Refused('dependency-source-commit-mismatch')
|
|
for task in expected['tasks']:
|
|
for dep in task['dependencies']:
|
|
if dep in EXTERNAL:
|
|
target=get('/tasks/'+EXTERNAL[dep])
|
|
if target.get('id')!=EXTERNAL[dep] or target.get('record_id')!=dep:
|
|
raise Refused('external-dependency-target-mismatch')
|
|
remote_readback=remote(root,owner['commit'],owner['repo'])
|
|
if index_stat(root)!=before_index or (root/owner['path']).read_bytes()!=local:
|
|
raise Refused('source-or-index-stat-changed')
|
|
result['rows'].append({'repo':owner['repo'],'published_commit':owner['commit'],
|
|
'remote':remote_readback,'workplan':{key:plans[0][key] for key in ('id','slug','status')},
|
|
'source_record_id':expected['source_record_id'],
|
|
'tasks':observed,'external_source_declarations_not_projected':[{'from_task_id':task['id'],'target_record_id':dep,'target_uuid':EXTERNAL[dep]} for task in expected['tasks'] for dep in task['dependencies'] if dep in EXTERNAL],'dependencies':[{'from_task_id':a,'to_task_id':b,'relationship_type':c} for a,b,c in sorted(edges)],
|
|
'source_file_sha256':hashlib.sha256(local).hexdigest(),'source_file_unchanged':True,'dependency_scope':'internal edges verified; external source target identities checked without claiming projected external edges',
|
|
'index_stat_unchanged':True})
|
|
result['status']='verified'
|
|
except Exception as error:
|
|
result['reason']=error.args[0] if isinstance(error,Refused) else 'readback-unavailable'
|
|
result['verifier_sha256']=hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
|
(HERE/'cluster-publication-readback.json').write_text(json.dumps(result,indent=2)+'\n')
|
|
print(json.dumps({'status':result['status'],'owners':len(result['rows'])}))
|
|
return 0 if result['status']=='verified' else 2
|
|
|
|
|
|
if __name__=='__main__':raise SystemExit(verify())
|