320 lines
16 KiB
Python
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.')
|