194 lines
11 KiB
Python
194 lines
11 KiB
Python
"""Founder-run r4: portable hook migration, exact mode restoration, existing CM.
|
|
|
|
Original r2/r3 code and failed receipts are preserved. Same scanner binary,
|
|
profile, deployment fields and six owner-source candidates. Acknowledgement
|
|
binds reviewed bytes; it is not a credential or runtime approval service.
|
|
"""
|
|
from pathlib import Path
|
|
import argparse
|
|
import base64
|
|
import copy
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
ORIGINAL = HERE.parent / 'native-r2'
|
|
OUTPUT = Path('/tmp/forge-scanner-rollout-r4-receipt.json')
|
|
R2 = 'd22b1f555af96c203b7d68efd25e80815ce18ca4e3bf0dd3ab96b6f699d50237'
|
|
|
|
|
|
def sha(raw): return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def ensure(ok, reason):
|
|
if not ok: raise RuntimeError(reason)
|
|
|
|
|
|
def support():
|
|
spec = importlib.util.spec_from_file_location('original_rollout', ORIGINAL / 'rollout.py')
|
|
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
|
ensure(m.verify_packet() == R2, 'original-reviewed-packet-drift')
|
|
m.RECEIPT = OUTPUT
|
|
return m
|
|
|
|
|
|
def verify_packet():
|
|
raw = (HERE / 'packet.json').read_bytes(); p = json.loads(raw)
|
|
for name, digest in p['files'].items():
|
|
ensure(Path(name).name == name and sha((HERE / name).read_bytes()) == digest, 'r4-packet-drift')
|
|
support()
|
|
return sha(raw)
|
|
|
|
|
|
def operation(args):
|
|
if 'exec' in args:
|
|
for source, label in [(HERE/'runtime_self_test.sh','runtime-tool-self-test'), (HERE/'hooks.sh','hook-migration'), (HERE/'restore_modes.sh','hook-mode-restoration'),
|
|
(ORIGINAL/'stage.sh','artifact-staging'), (ORIGINAL/'probe.sh','candidate-receive-probe'),
|
|
(ORIGINAL/'rollback-probe.sh','old-policy-receive-probe')]:
|
|
if source.read_text() in args: return label
|
|
if any(isinstance(a,str) and 'custodian-replace-' in a for a in args): return 'hook-migration'
|
|
if any(isinstance(a,str) and 'opened-inode' in a for a in args): return 'hook-mode-restoration'
|
|
if any(isinstance(a,str) and 'for repo in /data/git/gitea-repositories/' in a for a in args): return 'hook-inventory'
|
|
return 'public-artifact-or-template-check'
|
|
for verb in ('get','patch'):
|
|
if verb in args: return verb + ':' + args[args.index(verb)+1]
|
|
return 'reviewed-kubernetes-operation'
|
|
|
|
|
|
def make_runner(m):
|
|
class Rollout(m.Rollout):
|
|
def k(self, *args, data=None, timeout=60):
|
|
label = operation(args)
|
|
try: q = subprocess.run(['kubectl',*args], input=data, capture_output=True, timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
self.event('command-failed', operation=label, category='timeout')
|
|
raise RuntimeError('kubernetes-timeout:' + label)
|
|
if q.returncode:
|
|
category = 'remote-check-nonzero' if b'command terminated with exit code' in q.stderr else 'command-failed'
|
|
for needle,name in [(b'Forbidden','forbidden'),(b'Unauthorized','unauthorized'),(b'NotFound','not-found')]:
|
|
if needle in q.stderr: category=name
|
|
self.event('command-failed', operation=label, category=category)
|
|
raise RuntimeError('kubernetes-command-failed:' + label)
|
|
return q.stdout
|
|
|
|
def existing_cm(self):
|
|
cm = json.loads(self.k('-n','forgejo','get','configmap',self.c['new_configmap'],'-o','json'))
|
|
wanted = json.loads((ORIGINAL/'configmap.json').read_bytes())
|
|
ensure(cm.get('immutable') is True and cm.get('data') == wanted['data'] and
|
|
cm['metadata']['name'] == self.c['new_configmap'] and cm['metadata']['namespace'] == 'forgejo', 'existing-public-configmap-drift')
|
|
uid = cm['metadata']['uid']
|
|
ensure(not hasattr(self,'cm_uid') or self.cm_uid == uid, 'existing-configmap-identity-drift')
|
|
self.cm_uid = uid
|
|
|
|
def dry_run(self,d):
|
|
self.existing_cm()
|
|
patch = m.patch_for(d,self.c)
|
|
self.k('-n','forgejo','patch','deployment','forge','--type=json','--dry-run=server','-p',json.dumps(patch),'-o','name')
|
|
diff = subprocess.run(['kubectl','diff','-f','-'], input=(ORIGINAL/'configmap.json').read_bytes(), capture_output=True, timeout=60)
|
|
ensure(diff.returncode in (0,1),'client-diff-failed; raw diagnostics withheld')
|
|
self.event('server-dry-run-passed', reused_immutable_configmap=self.c['new_configmap'], configmap_uid=self.cm_uid,
|
|
client_configmap_diff_exit=diff.returncode, deployment_changes=2, strategy='Recreate', resources_unchanged=True)
|
|
|
|
def preflight(self):
|
|
result = super().preflight()
|
|
for files in self.receipt['before_hooks'].values():
|
|
for f in files.values():
|
|
ensure(f['uid'] == 1000 and f['gid'] == 1000 and f['mode'] in ('755','775'),'unsupported-hook-custody-or-mode')
|
|
return result
|
|
|
|
def migrate(self,pod,direction):
|
|
c=self.c
|
|
before=self.inventory(pod,{c['old_hook_sha256'],c['new_hook_sha256']})
|
|
baseline=self.receipt['before_hooks']
|
|
reference=copy.deepcopy(before)
|
|
records=[]
|
|
expected=c['old_hook_sha256'] if direction=='rollback' else c['new_hook_sha256']
|
|
for repo,files in baseline.items():
|
|
ensure(repo in before and set(before[repo]) == set(files),'hook-set-drift')
|
|
for name,f in files.items():
|
|
now=before[repo][name]
|
|
ensure((now['uid'],now['gid'])==(f['uid'],f['gid']),'hook-owner-drift')
|
|
ensure(now['mode']==f['mode'] or (f['mode']=='755' and now['mode']=='775'),'unexpected-hook-mode-drift')
|
|
if name!=m.HOOK: ensure(now['sha256']==f['sha256'],'protected-hook-content-drift')
|
|
reference[repo][name]=dict(f)
|
|
digest=expected if name==m.HOOK else f['sha256']
|
|
records.append(f['mode']+'\t'+digest+'\t'+repo+'/hooks/'+name+'\n')
|
|
paths=''.join(repo+'/hooks/'+m.HOOK+'\n' for repo,files in sorted(before.items())
|
|
if direction=='rollback' or files[m.HOOK]['sha256']==c['old_hook_sha256'])
|
|
# One private tmpfs control area; public hooks/paths/modes only.
|
|
wrapper='''set +vx
|
|
set -eu
|
|
umask 077
|
|
test "$(stat -f -c %t /dev/shm)" = 1021994
|
|
area=$(mktemp -d /dev/shm/forge-r4-hooks-XXXXXXXX)
|
|
cleanup() { code=$?; trap - EXIT; rm -f -- "$area/before" "$area/after" "$area/paths" "$area/modes"; rmdir "$area" || code=70; exit "$code"; }
|
|
trap cleanup EXIT
|
|
'''
|
|
for name in ('before','after'):
|
|
b64=base64.b64encode((ORIGINAL/(name+'-hook')).read_bytes()).decode()
|
|
wrapper+='printf %s '+shlex.quote(b64)+' | base64 -d > "$area/'+name+'"\n'
|
|
# Stream records through stdin; never put a large inventory in argv.
|
|
wrapper+=': > "$area/paths"\nwhile IFS= read -r line; do\n test "$line" = __FORGE_R4_MODE_RECORDS__ && break\n printf "%s\\n" "$line" >> "$area/paths"\ndone\ncat > "$area/modes"\n'
|
|
if paths:
|
|
wrapper+='sh -c '+shlex.quote((HERE/'hooks.sh').read_text())+' sh "$area" "$1" "$2" "$3"\n'
|
|
wrapper+='sh -c '+shlex.quote((HERE/'restore_modes.sh').read_text())+' sh "$area/modes"\n'
|
|
# shell output contains two JSON lines; only report vetted counters.
|
|
raw=self.shell(pod,wrapper,(direction,c['old_hook_sha256'],c['new_hook_sha256']),data=(paths+'__FORGE_R4_MODE_RECORDS__\n'+''.join(records)).encode(),timeout=180)
|
|
counters=[json.loads(line) for line in raw.splitlines() if line]
|
|
after=self.inventory(pod,{expected})
|
|
m.protected_equal(reference,after)
|
|
self.event('hooks-'+direction,repositories=len(after),portable_modes=True,baseline_modes_restored=True,
|
|
group_write_additions_reversed=sum(x.get('group_write_additions_reversed',0) for x in counters))
|
|
return after
|
|
|
|
def apply(self,binary):
|
|
d,pod,before=self.preflight()
|
|
raw=binary.read_bytes();ensure(sha(raw)==self.c['new_adapter_sha256'],'candidate-binary-drift')
|
|
tools=json.loads(self.shell(pod,(HERE/'runtime_self_test.sh').read_text()))
|
|
ensure(all(tools.get(k) for k in ('numeric_modes','descriptor_mode_restore','atomic_rename','temporary_test_removed')),'runtime-tool-self-test-failed')
|
|
self.event('runtime-tool-self-test-passed',checks=tools)
|
|
self.receipt['native_mutation']=True;self.event('staging-started')
|
|
try:
|
|
self.shell(pod,(ORIGINAL/'stage.sh').read_text(),(self.c['new_dir'],self.c['old_dir'],self.c['new_adapter_sha256'],self.c['scanner_sha256'],'SYNC-SCANNER-JSON-DIGEST-20261101-R2'),data=raw)
|
|
self.shell(pod,self.c['new_guard_command'][2],('check',))
|
|
self.probe(pod,self.c['new_dir'],before)
|
|
self.existing_cm()
|
|
self.migrate(pod,'apply')
|
|
patch=m.patch_for(self.deployment(),self.c)
|
|
self.k('-n','forgejo','patch','deployment','forge','--type=json','-p',json.dumps(patch),'-o','name')
|
|
self.event('deployment-patched')
|
|
pod=self.ready_pod(self.deployment())
|
|
after=self.migrate(pod,'apply')
|
|
m.protected_equal(before,after)
|
|
self.shell(pod,self.c['new_guard_command'][2],('check',))
|
|
mounted=self.shell(pod,'cat /opt/forge-secret-template/hooks/pre-receive.d/10-custodian-secret-scan')
|
|
ensure(sha(mounted)==self.c['new_hook_sha256'],'mounted-template-drift')
|
|
self.probe(pod,self.c['new_dir'],after)
|
|
self.event('activation-verified',repositories=len(after),hook_sha256=self.c['new_hook_sha256'],baseline_hook_metadata_preserved=True)
|
|
except Exception as exc:
|
|
self.event('activation-failed',reason=str(exc) if isinstance(exc,RuntimeError) else type(exc).__name__)
|
|
try:self.rollback(before)
|
|
except Exception as recovery:self.event('rollback-unverified',reason=str(recovery) if isinstance(recovery,RuntimeError) else type(recovery).__name__)
|
|
raise
|
|
return Rollout
|
|
|
|
|
|
def main():
|
|
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--approved-digest',required=True)
|
|
p.add_argument('--binary',type=Path,default=Path('/tmp/sync-scanner-candidate/receive-secret-scan-candidate'));args=p.parse_args()
|
|
digest=verify_packet();ensure(args.approved_digest==digest,'exact-founder-acknowledgement-required')
|
|
m=support();c=json.loads((ORIGINAL/'inputs.json').read_bytes());runner=make_runner(m)(c)
|
|
runner.receipt.update(revision=4,packet_sha256=digest,reused_r2_packet_sha256=R2,mode='apply')
|
|
with OUTPUT.open('x') as stream:stream.write(json.dumps(runner.receipt)+'\n')
|
|
runner.apply(args.binary)
|
|
|
|
|
|
if __name__=='__main__':
|
|
try:main()
|
|
except Exception as exc:
|
|
print(json.dumps({'result':'refused-or-unverified','reason':str(exc) if isinstance(exc,RuntimeError) else type(exc).__name__}))
|
|
raise SystemExit(1)
|