the-custodian/docs/changes/CUST-WP-0080/activate_forge_template.py

320 lines
16 KiB
Python

#!/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.')