#!/usr/bin/env python3 """Exact, additive Forgejo template deployment with value-free receipts. Default mode is read-only observation plus Kubernetes server dry-runs. No Secret objects or Helm values are requested. Reuses existing immutable scanner artifacts. """ import argparse import copy import datetime import hashlib import importlib.util import json from pathlib import Path import re import subprocess import time HERE = Path(__file__).parent NAME = 'forgejo-secret-template-b1d7cf8cd13f' VOLUME = 'secret-scan-template' SETTING = 'GITEA__GIT_0X2E_CONFIG__INIT_0X2E_TEMPLATEDIR' TEMPLATE = '/opt/forge-secret-template' DEFAULT = '/usr/share/git-core/templates' MOUNT = {'name': VOLUME, 'mountPath': TEMPLATE + '/hooks/pre-receive.d/10-custodian-secret-scan', 'subPath': 'hook', 'readOnly': True} VOL = {'name': VOLUME, 'configMap': {'name': NAME, 'defaultMode': 365}} def digest(value): return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() def module(name, path): spec = importlib.util.spec_from_file_location(name, path) obj = importlib.util.module_from_spec(spec) spec.loader.exec_module(obj) return obj native = module('template_native', HERE / 'verify_receive_native.py') activation = module('template_activation', HERE / 'activate_receive_hooks.py') def deployment(): return json.loads(native.command(['-n', 'forgejo', 'get', 'deployment', 'forgejo-gitea', '-o', 'json'])) def target_pod(d, original): pods = json.loads(native.command(['-n', 'forgejo', 'get', 'pods', '-l', 'app.kubernetes.io/instance=forgejo', '-o', 'json']))['items'] candidates = [p for p in pods if not p['metadata'].get('deletionTimestamp') and any(c['name'] == 'gitea' for c in p['spec']['containers']) and p.get('status', {}).get('phase') == 'Running' and any(c['type'] == 'Ready' and c['status'] == 'True' for c in p.get('status', {}).get('conditions', []))] if len(candidates) != 1: raise ValueError('no-unique-ready-pod') p = candidates[0] image = next(c['image'] for c in p['spec']['containers'] if c['name'] == 'gitea') image_id = next(c['imageID'] for c in p['status']['containerStatuses'] if c['name'] == 'gitea') if image != original['image'] or image_id != original['image_id']: raise ValueError('image-changed') if d['status'].get('observedGeneration', 0) < d['metadata']['generation'] or d['status'].get('availableReplicas') != 1: raise ValueError('deployment-not-ready') return dict(original, pod=p['metadata']['name'], pod_uid=p['metadata']['uid']) def patch_for(d, rollback=False): """Build a conditional patch; refuse conflicts and preserve all other fields.""" spec = d['spec']['template']['spec'] patch = [{'op': 'test', 'path': '/metadata/uid', 'value': d['metadata']['uid']}, {'op': 'test', 'path': '/metadata/resourceVersion', 'value': d['metadata']['resourceVersion']}] volumes = spec['volumes'] found = [i for i, v in enumerate(volumes) if v['name'] == VOLUME] if rollback: if len(found) != 1 or volumes[found[0]] != VOL: raise ValueError('rollback-volume-changed') patch.append({'op': 'remove', 'path': '/spec/template/spec/volumes/' + str(found[0])}) else: if found: raise ValueError('template-volume-preexists') patch.append({'op': 'add', 'path': '/spec/template/spec/volumes/-', 'value': VOL}) configured = 0 for kind in ['initContainers', 'containers']: for index, c in enumerate(spec[kind]): base = '/spec/template/spec/' + kind + '/' + str(index) mounts = c['volumeMounts'] matches = [i for i, v in enumerate(mounts) if v['name'] == VOLUME or v['mountPath'] == MOUNT['mountPath']] if rollback: if len(matches) != 1 or mounts[matches[0]] != MOUNT: raise ValueError('rollback-mount-changed') patch.append({'op': 'remove', 'path': base + '/volumeMounts/' + str(matches[0])}) else: if matches: raise ValueError('template-mount-preexists') patch.append({'op': 'add', 'path': base + '/volumeMounts/-', 'value': MOUNT}) if kind == 'initContainers' and c['name'] == 'init-app-ini': env = c['env'] found = [i for i, e in enumerate(env) if e['name'] == SETTING] if rollback: if len(found) != 1 or env[found[0]] != {'name': SETTING, 'value': TEMPLATE}: raise ValueError('rollback-setting-changed') patch.append({'op': 'replace', 'path': base + '/env/' + str(found[0]), 'value': {'name': SETTING, 'value': DEFAULT}}) else: if found: raise ValueError('template-setting-preexists') patch.append({'op': 'add', 'path': base + '/env/-', 'value': {'name': SETTING, 'value': TEMPLATE}}) configured += 1 if configured != 1: raise ValueError('unexpected-configuration-container') return patch def apply_locally(d, patch): result = copy.deepcopy(d) for op in patch: parts = op['path'].strip('/').split('/') obj = result for part in parts[:-1]: obj = obj[int(part)] if isinstance(obj, list) else obj[part] last = parts[-1] key = int(last) if isinstance(obj, list) and last != '-' else last if op['op'] == 'test': if obj[key] != op['value']: raise ValueError('precondition-changed') elif op['op'] == 'remove': del obj[key] elif op['op'] == 'replace': obj[key] = copy.deepcopy(op['value']) elif op['op'] == 'add': if last == '-': obj.append(copy.deepcopy(op['value'])) else: obj[key] = copy.deepcopy(op['value']) return result def patch_remote(patch, dry_run=False): args = ['-n', 'forgejo', 'patch', 'deployment', 'forgejo-gitea', '--type=json', '--patch-file=/dev/stdin', '-o', 'json'] if dry_run: args.append('--dry-run=server') return json.loads(native.command(args, payload=json.dumps(patch).encode())) def selected_template(pod): raw = native.execute(pod, 'sh', '-c', 'set +e; value=$(git config --file /data/home/.gitconfig --get-all init.templateDir 2>/dev/null); code=$?; ' 'case "$code" in 0) printf "%s" "$value";; 1) printf "ABSENT";; *) exit 2;; esac') value = raw.decode().strip() if value not in ('ABSENT', TEMPLATE, DEFAULT): raise ValueError('unknown-template-selection') return value def check_artifacts(pod): activation.inspect(native, pod, applied=True) for item in pod['artifacts'][:2]: path = pod['version_directory'] + '/' + item['name'] observed = native.execute(pod, 'sha256sum', path).decode().split()[0] if observed != item['sha256']: raise ValueError('installed-artifact-changed') native.execute(pod, 'test', '-x', path) native.execute(pod, 'wget', '-qO', '/dev/null', 'http://127.0.0.1:3000/api/healthz') def wait_ready(original, selection, timeout=240): end = time.monotonic() + timeout while time.monotonic() < end: try: d = deployment() pod = target_pod(d, original) if d['status'].get('updatedReplicas') != 1 or selected_template(pod) != selection: raise ValueError('replacement-not-ready') check_artifacts(pod) return d, pod except (ValueError, KeyError, subprocess.SubprocessError): time.sleep(3) raise ValueError('rollout-not-verified') def verify_inheritance(pod, proposal): directory = '/tmp/custodian-template-' + proposal['proposal_id'].lower() created = False try: native.execute(pod, 'sh', '-c', 'umask 077; test ! -e "$1" && mkdir "$1" && printf "%s" "$2" > "$1/.owner"', 'sh', directory, proposal['proposal_id']) created = True native.execute(pod, 'sh', '-c', 'test -f "$1" && test ! -L "$1" && test -x "$1"', 'sh', MOUNT['mountPath']) native.execute(pod, 'git', '-c', 'include.path=/data/home/.gitconfig', 'init', '--bare', '-q', directory + '/probe.git') hook = directory + '/probe.git/hooks/pre-receive.d/10-custodian-secret-scan' native.execute(pod, 'sh', '-c', 'test -f "$1" && test ! -L "$1" && test -x "$1"', 'sh', hook) observed = native.execute(pod, 'sha256sum', hook).decode().split()[0] if observed != pod['hook_sha256']: raise ValueError('inherited-hook-differs') raw = native.execute(pod, 'sh', '-c', 'cd "$1"; set +e; output=$(printf "malformed\\n" | hooks/pre-receive.d/10-custodian-secret-scan 2>/dev/null); ' 'code=$?; test "$code" = 2 || exit 1; printf "%s" "$output"', 'sh', directory + '/probe.git') if json.loads(raw)['status'] != 'unverified': raise ValueError('inherited-hook-not-executable') return {'mounted_hook_regular': True, 'inherited_hook_regular_executable': True, 'hook_sha256': observed, 'inherited_hook_malformed_input_refused': True, 'scope': 'Native Git init with actual Forgejo global configuration in an owned temporary bare repository; not production API repository creation.'} finally: if created: native.execute(pod, 'sh', '-c', 'test "$(cat "$1/.owner")" = "$2" && rm -rf -- "$1" && test ! -e "$1"', 'sh', directory, proposal['proposal_id']) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--proposal', type=Path, required=True) parser.add_argument('--receipt', type=Path, required=True) mode = parser.add_mutually_exclusive_group() mode.add_argument('--apply', action='store_true') mode.add_argument('--rollback', action='store_true') args = parser.parse_args() proposal = json.loads(args.proposal.read_text()) if not re.fullmatch(r'[A-Z0-9-]{1,100}', proposal['proposal_id']): raise ValueError('unsafe-proposal-id') for filename, expected in proposal['executor_hashes'].items(): if hashlib.sha256((HERE / filename).read_bytes()).hexdigest() != expected: raise ValueError('executor-revision-changed') if not args.rollback: for filename, expected in proposal['owner_source_hashes'].items(): if hashlib.sha256(Path(filename).read_bytes()).hexdigest() != expected: raise ValueError('owner-source-revision-changed') isolated = json.loads(Path(proposal['isolated_receipt']).read_text()) if isolated.get('result') != 'verified' or not isolated.get('isolated_container_and_volumes_removed'): raise ValueError('isolated-acceptance-incomplete') original = json.loads((HERE / 'receive-hook-proposal.json').read_text()) nodes = json.loads(native.command(['get', 'nodes', '-o', 'json']))['items'] if len(nodes) != 1 or not any(a['address'] == '92.205.62.239' for a in nodes[0]['status']['addresses']): raise ValueError('host-changed') apps = json.loads(native.command(['-n', 'argocd', 'get', 'applications', '-o', 'json']))['items'] if any(a['spec'].get('destination', {}).get('namespace') == 'forgejo' for a in apps): raise ValueError('gitops-owner-now-present') d = deployment() if d['metadata']['uid'] != proposal['deployment_uid'] or d['spec']['strategy']['type'] != 'Recreate' or d['spec']['replicas'] != 1: raise ValueError('deployment-target-changed') if not args.rollback and digest(d['spec']['template']) != proposal['original_template_sha256']: raise ValueError('deployment-template-changed') pod = target_pod(d, original) check_artifacts(pod) selection = selected_template(pod) if selection != (TEMPLATE if args.rollback else 'ABSENT'): raise ValueError('template-selection-changed') cm = proposal['configmap'] if (cm.get('apiVersion') != 'v1' or cm.get('kind') != 'ConfigMap' or set(cm.get('data', {})) != {'hook'} or cm['metadata']['name'] != NAME or cm['metadata']['namespace'] != 'forgejo' or not cm.get('immutable')): raise ValueError('invalid-configmap-target') if hashlib.sha256(cm['data']['hook'].encode()).hexdigest() != original['hook_sha256']: raise ValueError('template-hook-changed') found = native.command(['-n', 'forgejo', 'get', 'configmap', NAME, '--ignore-not-found', '-o', 'json']) if found.strip(): existing = json.loads(found) if existing.get('data') != cm['data'] or existing.get('immutable') is not True: raise ValueError('template-configmap-conflict') elif not args.rollback: native.command(['create', '--dry-run=server', '-f', '-', '-o', 'name'], payload=json.dumps(cm).encode()) patch = patch_for(d, rollback=args.rollback) simulated = apply_locally(d, patch) dry = patch_remote(patch, dry_run=True) if dry['spec']['template'] != simulated['spec']['template']: raise ValueError('server-dry-run-unexpected-change') receipt = {'proposal_id': proposal['proposal_id'], 'proposal_sha256': hashlib.sha256(args.proposal.read_bytes()).hexdigest(), 'observed_at': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'result': 'dry-run-verified', 'old_pod_uid': pod['pod_uid'], 'old_generation': d['metadata']['generation'], 'server_dry_run_exact_match': True, 'patch': patch, 'original_template_sha256': digest(d['spec']['template']), 'resulting_template_sha256': digest(simulated['spec']['template']), 'no_secret_objects_read': True, 'mode': 'rollback' if args.rollback else 'apply' if args.apply else 'preflight'} args.receipt.write_text(json.dumps(receipt, indent=2) + '\n') if not args.apply and not args.rollback: print('Exact additive patch and ConfigMap server dry-runs passed; no live mutation.') return submitted = False try: if not found.strip() and not args.rollback: native.command(['create', '-f', '-', '-o', 'name'], payload=json.dumps(cm).encode()) receipt['configmap_created'] = True submitted = True receipt['patch_submission_started'] = True changed = patch_remote(patch) receipt['applied_generation'] = changed['metadata']['generation'] expected = DEFAULT if args.rollback else TEMPLATE ready, newpod = wait_ready(original, expected) if selected_template(newpod) != expected: raise ValueError('runtime-selection-not-adopted') if not args.rollback: receipt['inheritance'] = verify_inheritance(newpod, proposal) receipt.update(result='verified', new_pod_uid=newpod['pod_uid'], generation=ready['metadata']['generation'], selected_template=expected, existing_22_profiles_preserved=True, binaries_unchanged=True, health_after=True) except (ValueError, KeyError, subprocess.SubprocessError): receipt['result'] = 'unverified' if submitted and not args.rollback: try: current = deployment() observed_digest = digest(current['spec']['template']) if observed_digest == receipt['original_template_sha256']: receipt['rollback'] = 'patch was not applied; original deployment preserved; any created ConfigMap remains inert' elif observed_digest == receipt['resulting_template_sha256']: recovery = patch_for(current, rollback=True) patch_remote(recovery) _, recovered = wait_ready(original, DEFAULT) receipt['rollback'] = 'default template selection restored; original 22 hooks preserved; ConfigMap retained inert' else: raise ValueError('concurrent-template-change-needs-owner-review') except (ValueError, KeyError, subprocess.SubprocessError): receipt['rollback'] = 'unverified; abort owner must inspect exact deployment and receipt' raise finally: args.receipt.write_text(json.dumps(receipt, indent=2) + '\n') print('Template ' + receipt['mode'] + ' verified; existing profiles and service health preserved.') if __name__ == '__main__': try: main() except (ValueError, OSError, KeyError, subprocess.SubprocessError): raise SystemExit('Template operation refused or incomplete; inspect value-free receipt. Raw diagnostics suppressed.')