#!/usr/bin/env python3 """Node-side reviewed ReichelAg cohort import. Default is preflight only. Requires a private staged corpus manifest and matching source-package digest. Preserves deployment/Secrets/image, pauses writers for actual import, never replaces existing content, and resumes the app in a finally block. Use a fresh paired pre-import snapshot and isolated rehearsal before --execute. """ from __future__ import annotations import argparse import copy import hashlib import importlib.util import json import os import subprocess import sys import tarfile import time from pathlib import Path IMAGE = ('forgejo.coulomb.social/coulomb/coulomb-social@sha256:' 'bf975939e22b9b0a5d9ac05584ae6265ba201169df6dc48e506ec684b30a9c58') NS = 'coulomb-social' CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' def k(*args: str, payload: bytes | None = None) -> bytes: p = subprocess.run(['kubectl', *args], input=payload, capture_output=True, timeout=180) if p.returncode: raise RuntimeError('command_failed') return p.stdout def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--execute', action='store_true') parser.add_argument('--package', type=Path, required=True) parser.add_argument('--package-sha256', required=True) parser.add_argument('--staged-source', type=Path, required=True) parser.add_argument('--snapshot', type=Path, required=True) args = parser.parse_args() os.environ['KUBECONFIG'] = '/etc/rancher/k3s/k3s.yaml' cluster_identity = json.loads(k('get', 'namespace', 'kube-system', '-o', 'json')) assert cluster_identity['metadata']['uid'] == CLUSTER_UID assert hashlib.sha256(args.package.read_bytes()).hexdigest() == args.package_sha256 manifest = json.loads((args.staged_source / 'manifest.json').read_text()) assert manifest['version'] == 1 and len(manifest['spaces']) == 1 entry = manifest['spaces'][0] assert entry['slug'] == 'reichelag' and entry['tenant'] == 'tenant:platform' assert entry['tree'] == 'trees/reichelag' and len(entry['memberships']) == 1 assert entry['memberships'][0]['role'] == 'owner' source = args.staged_source / 'trees/reichelag' assert len(list((source / 'pages').glob('*.md'))) == 37 assert sum(p.is_file() for p in (source / 'assets').rglob('*')) == 36 assert all(not p.is_symlink() for p in args.staged_source.rglob('*')) paired = json.loads((args.snapshot / 'receipt.json').read_text()) assert paired['status'] == 'captured' and paired['writers_quiesced'] and paired['app_resumed'] assert paired['database'] == 'coulomb_social_db' and paired['image'] == IMAGE for name, key in [('database.dump', 'database_sha256'), ('content.tar.gz', 'content_sha256')]: assert hashlib.sha256((args.snapshot / name).read_bytes()).hexdigest() == paired[key] dep = json.loads(k('get', 'deployment', 'coulomb-social', '-n', NS, '-o', 'json')) assert dep['spec']['replicas'] == 1 spec = copy.deepcopy(dep['spec']['template']['spec']) assert spec['containers'][0]['image'] == IMAGE and len(spec['containers']) == 1 c = spec['containers'][0] c.pop('livenessProbe', None) c.pop('readinessProbe', None) c['command'] = ['python', '-c', 'import time;time.sleep(1800)'] c.pop('args', None) spec['restartPolicy'] = 'Never' spec['automountServiceAccountToken'] = False name = 'csoc-approved-reichelag-' + time.strftime('%Y%m%d%H%M%S', time.gmtime()) pod = {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': name, 'namespace': NS, 'labels': {'csoc-import': name}}, 'spec': spec} payload = json.dumps(pod).encode() k('create', '--dry-run=server', '-f', '-', payload=payload) if not args.execute: print(json.dumps({'status': 'reviewed', 'server_dry_run': True, 'source_package_matches': True, 'paired_snapshot_matches': True, 'pages': 37, 'assets': 36, 'owner_grants': 1, 'replace': False})) return 0 paused = created = False started = time.monotonic() try: paused = True k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=0') k('wait', '--for=delete', 'pod', '-n', NS, '-l', 'app.kubernetes.io/instance=coulomb-social', '--timeout=120s') created = True k('create', '-f', '-', payload=payload) k('wait', '--for=condition=Ready', 'pod/' + name, '-n', NS, '--timeout=120s') # Refuse if any writer changed the metadata/content after the paired point. module_spec = importlib.util.spec_from_file_location( 'restore_guard', Path(__file__).with_name('restore-paired-snapshot.py')) restore_guard = importlib.util.module_from_spec(module_spec) module_spec.loader.exec_module(restore_guard) primary = json.loads(k('get', 'cluster', 'apps-pg', '-n', 'databases', '-o', 'json'))['status']['currentPrimary'] original_sql = k('exec', '-i', '-n', 'databases', primary, '-c', 'postgres', '--', 'pg_restore', '--data-only', '-f', '-', payload=(args.snapshot / 'database.dump').read_bytes()) current_sql = k('exec', '-n', 'databases', primary, '-c', 'postgres', '--', 'pg_dump', '-U', 'postgres', '-d', 'coulomb_social_db', '--data-only', '--no-owner', '--no-acl') baseline_data = restore_guard.data_fingerprint(original_sql) assert baseline_data == restore_guard.data_fingerprint(current_sql) prior_files = {} with tarfile.open(args.snapshot / 'content.tar.gz', 'r:gz') as archive: for item in archive.getmembers(): if item.isfile(): prior_files[str(Path(item.name).relative_to('content'))] = hashlib.sha256( archive.extractfile(item).read()).hexdigest() prior_sha = hashlib.sha256(json.dumps(prior_files, sort_keys=True).encode()).hexdigest() fingerprint_code = ('import os,json,hashlib;from pathlib import Path; ' 'root=Path(os.environ["CONTENT_ROOT"]); ' 'rows={str(p.relative_to(root)): ' 'hashlib.sha256(p.read_bytes()).hexdigest() ' 'for p in root.rglob("*") if p.is_file()}; ' 'print(hashlib.sha256(json.dumps(rows,sort_keys=True).encode()).hexdigest())') current_sha = k('exec', '-n', NS, name, '--', 'python', '-c', fingerprint_code).decode().strip() assert prior_sha == current_sha k('cp', '--no-preserve', '-n', NS, str(args.staged_source), name + ':/tmp/approved-source') code = '''import io,json,hashlib from pathlib import Path from django.core.management import call_command from coulomb_social.apps.spaces.models import Space,SpaceMembership from coulomb_social.apps.spaces import pageops from coulomb_social.apps.spaces.content import load_space_page from coulomb_social.apps.spaces.services import spaces_for_member source=Path('/tmp/approved-source') manifest=json.loads((source/'manifest.json').read_text()); entry=manifest['spaces'][0] assert not Space.objects.filter(slug='reichelag').exists() assert not pageops.space_dir('reichelag').exists() call_command('import_content_corpus',str(source/'manifest.json'),dry_run=True,stdout=io.StringIO()) call_command('import_content_corpus',str(source/'manifest.json'),stdout=io.StringIO()) s=Space.objects.get(slug='reichelag'); grants=SpaceMembership.objects.filter(space=s) assert grants.count()==1 and grants.get().role=='owner' m=grants.get().member assert m.tenant_id==s.tenant_id=='tenant:platform' assert m.issuer==entry['memberships'][0]['issuer'] and m.subject==entry['memberships'][0]['subject'] assert spaces_for_member(m).filter(pk=s.pk).exists() for p in (source/'trees/reichelag').rglob('*'): if p.is_file(): dest=pageops.space_dir('reichelag')/p.relative_to(source/'trees/reichelag') assert hashlib.sha256(dest.read_bytes()).digest()==hashlib.sha256(p.read_bytes()).digest() for p in (source/'trees/reichelag/pages').glob('*.md'): rendered=load_space_page(s,p.stem) assert rendered.source=='content-plane' and rendered.html print(json.dumps({'status':'imported_verified','pages':37,'assets':36,'owner_grants':1, 'source_file_hashes_match':True,'all_pages_render':True,'member_can_list_space':True})) ''' result = k('exec', '-n', NS, name, '--', 'python', 'manage.py', 'shell', '-c', code) rows = [json.loads(line) for line in result.decode().splitlines() if line.startswith('{')] assert len(rows) == 1 and rows[0]['status'] == 'imported_verified' receipt = rows[0] finally: try: if created: k('delete', 'pod', name, '-n', NS, '--ignore-not-found', '--wait=true', '--timeout=120s') finally: if paused: k('scale', 'deployment/coulomb-social', '-n', NS, '--replicas=1') k('rollout', 'status', 'deployment/coulomb-social', '-n', NS, '--timeout=180s') receipt['app_resumed'] = True receipt['pre_import_db_and_content_match_paired_snapshot'] = True receipt['elapsed_seconds'] = round(time.monotonic() - started, 2) print(json.dumps(receipt)) return 0 if __name__ == '__main__': try: sys.exit(main()) except Exception as exc: print(json.dumps({'status': 'failed', 'failure_type': type(exc).__name__})) sys.exit(1)