diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 54ea2a7..dc3c219 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -41,6 +41,7 @@ | task | RAPPS-WP-0015-T02 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T05 | progress | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | diff --git a/docs/coulomb-image-release-2026-10-31.md b/docs/coulomb-image-release-2026-10-31.md new file mode 100644 index 0000000..2a16234 --- /dev/null +++ b/docs/coulomb-image-release-2026-10-31.md @@ -0,0 +1,38 @@ +# Prepared Coulomb identity/export image release + +The exact supervised proposal is +`sha256:9f1daf6c5fb56b2b4fc9817f28f5ad090c884b0295f7d6f4469e42d48fa8d59f`. +Open decision: `75b94bb6-4709-495f-91fe-43e53e2867ed`. +The full founder packet is +[coulomb-social release review](/home/worsch/coulomb-social/docs/release/identity-export-release-review-2026-10-31.md). + +`tools/coulomb-image-release.py` prepares the capsule by default. `--preflight` +checks the live target without capture or deployment. `--execute --expect-digest` +requires the founder's own interactive terminal and the exact typed proposal. +The node controller freezes verified source bytes into its private durable +journal before execution. An existing attempt refuses blind replay. + +This distinct controller adapts the previously reviewed storage controller +from owner source 45d6178. It requires migration 0003 and the global constraint +already present, adds exact migration-history equality, verifies installed +helpers during preflight, verifies the desired complete spec through an image-only +server-dry-run patch and verifies rollback readiness/specification. Unknown +helper changes refuse overwrite. Historical storage commands/capsules remain +unchanged and must not be replayed. + +Candidate values live in `release/identity-export-values.yaml`; the current +`helm/coulomb-social-values.yaml` remains pinned to the actual revision 18 image. +The new snapshot-helper bytes are separate review material until execution. +No unit/timer, sender, grant, apex or data-restore operation is included. + +Validation: 19 release safety cases pass; Ruff, Python compile and Helm lint pass. +The sealed native preflight is retained in +`docs/evidence/2026-10-31-coulomb-image-release-preflight.json`. +Four hash-bound candidate evidence files travel inside the capsule. The product +review names source 441 tests, actual packaged 25 tests, all 80 packaged source matches +and the retained isolated recovery/export/view checks with their limits. + +After founder execution, inspect the durable receipt and independent current +state before reconciling the live values and snapshot pin. The product then +mirrors/retrieves the newly captured point and verifies packaged export on that +coherent post-correction data. Source publication alone does not finish T05. diff --git a/docs/evidence/2026-10-31-coulomb-image-release-preflight.json b/docs/evidence/2026-10-31-coulomb-image-release-preflight.json new file mode 100644 index 0000000..0402fbf --- /dev/null +++ b/docs/evidence/2026-10-31-coulomb-image-release-preflight.json @@ -0,0 +1,25 @@ +{ + "schema": "coulomb.guarded-image-release.v1", + "proposal_digest": "sha256:9f1daf6c5fb56b2b4fc9817f28f5ad090c884b0295f7d6f4469e42d48fa8d59f", + "started_at": "2026-10-31T03:41:35.362323+00:00", + "status": "live_release_preflight_verified", + "production_data_restored": false, + "timers_activated": false, + "node_request_percent": { + "cpu": 68, + "memory": 76 + }, + "preflight": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "live_changes": false +} \ No newline at end of file diff --git a/release/coulomb-identity-export-23ffe2c.json b/release/coulomb-identity-export-23ffe2c.json new file mode 100644 index 0000000..c89ca3d --- /dev/null +++ b/release/coulomb-identity-export-23ffe2c.json @@ -0,0 +1,51 @@ +{ + "schema": "coulomb.reviewed-image-release.v1", + "proposal_id": "CSOC-IDENTITY-EXPORT-20261031", + "scope": "ADMINISTER @ realm:kubernetes/railiance01; supervised founder execution only", + "operation": "image_only_existing_migration_0003", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "baseline_revision": 18, + "baseline_helm_manifest_sha256": "1f74302e7be7211b3ae8728f58ef6b8ec243ffa342306b25bbf42dd6f27b5ef7", + "candidate_render_sha256": "98ffff162a17a9ff4b3922364d002ddbc571f9c79af95191c84d7c0b47c9a16f", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_spec_sha256": "013aca79ec910b52ed2c2df7a18050c436751005320d1dec616590962da30b8e", + "candidate_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "product_source": "23ffe2cf4b214666754a2420558943438e54251a", + "expected_data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "installed_helper_hashes": { + "/opt/coulomb-social-backup/paired-backup.py": "43e119acb6a4933b4e144c349a091e074e1a91ab61dbe15a65c8264c1d47661a", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "4ff81b791daf6c8b976a4f89ef152e52f385fc1656986e2ea28bf555b9bd0d8a", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d" + }, + "clipboard_static_sha256": "347eabe0c8d93bf439df02b5a08a7ea71004600e6ff4e8a42cecbee52a9f0413", + "fresh_backup_before_helm": true, + "requires_dataset_equality_before_and_after": true, + "node_request_percent_limit": 90, + "helm_atomic_timeout_seconds": 300, + "rollback": "Restore Helm revision18/image and verified snapshot pin; preserve all post-capture data. No production data restore or schema downgrade.", + "approval_status": "prepared_not_admitted", + "recovery_rehearsal_scope": "retained October30 point; fresh post-correction primary backup/readback occurs before Helm; independent retrieval/export of that new point remains subsequent consumer acceptance", + "rehearsal_evidence_sha256": { + "identity-export-candidate-build-2026-10-31.json": "12e8709529f27323274b40c68bd701c39e1cd2aa7428f42e061d8b5c2d410176", + "identity-export-candidate-provenance-2026-10-31.json": "71a8e1b2e60d0da6948fb74d9d1be8a8b892cef4be0d6dd0b98fa45d8983551c", + "identity-export-candidate-recovery-2026-10-31.json": "ccfe90ff68ecc80c02ef673a1d1efd21291e7c829c1dc17dc8bd0c902b9cb8b1", + "identity-export-candidate-tests-2026-10-31.json": "bd9d7535f42c7fe79752b46672f8da67492baf0dd06893b8a3288ac9eb3edaae" + }, + "new_resources_committed": 0, + "autopilot_or_eur_budget_claimed": false +} diff --git a/release/identity-export-evidence/identity-export-candidate-build-2026-10-31.json b/release/identity-export-evidence/identity-export-candidate-build-2026-10-31.json new file mode 100644 index 0000000..51aa01b --- /dev/null +++ b/release/identity-export-evidence/identity-export-candidate-build-2026-10-31.json @@ -0,0 +1,95 @@ +{ + "schema": "coulomb.identity-export-candidate-build.v1", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "tag": "forgejo.coulomb.social/coulomb/coulomb-social:23ffe2cf4b21", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "registry_digest_independently_verified": true, + "source_revision_label_verified": true, + "build_context": "git archive of exact source; Dockerfile, dockerignore, lock, project, manage.py, app and entrypoint only", + "build_inputs": { + "Dockerfile": "d0e197a18db8980eb3e51cca842d693e10ab0b4208627bba28691d10719d4a00", + ".dockerignore": "901c8b95d95549d0e78c1c3d6d0538bf0252c8a77e9409129e0cadfa55c7c56c", + "pyproject.toml": "5758c56fd1be89b973861ccc5ecbadb495e742ee13ebd7e19a221a0f119c54ee", + "uv.lock": "e8ebb6fc4b5abd1ac80f1c9012f1983642d0528a8a213cfcc394261606fad887", + "scripts/docker-entrypoint.sh": "70100256ca41ae6afe77078c810a8b6012472f0a741919f403f32088f98ab463" + }, + "runtime": { + "live": { + "python": "3.12.16", + "distributions": { + "anyio": "4.14.2", + "asgiref": "3.12.1", + "authlib": "1.7.2", + "bleach": "6.4.0", + "certifi": "2026.7.22", + "cffi": "2.1.1", + "cryptography": "50.0.0", + "dj-database-url": "3.1.2", + "django": "6.1", + "gunicorn": "26.0.0", + "h11": "0.16.0", + "httpcore": "1.0.9", + "httpx": "0.28.1", + "idna": "3.18", + "joserfc": "1.7.4", + "markdown": "3.10.3", + "packaging": "26.3", + "psycopg": "3.3.4", + "psycopg-binary": "3.3.4", + "pycparser": "3.0", + "python-decouple": "3.8", + "sqlparse": "0.5.5", + "typing_extensions": "4.16.0", + "webencodings": "0.5.1", + "whitenoise": "6.12.0" + } + }, + "candidate": { + "python": "3.12.16", + "distributions": { + "anyio": "4.14.2", + "asgiref": "3.12.1", + "authlib": "1.7.2", + "bleach": "6.4.0", + "certifi": "2026.7.22", + "cffi": "2.1.1", + "cryptography": "50.0.0", + "dj-database-url": "3.1.2", + "django": "6.1", + "gunicorn": "26.0.0", + "h11": "0.16.0", + "httpcore": "1.0.9", + "httpx": "0.28.1", + "idna": "3.18", + "joserfc": "1.7.4", + "markdown": "3.10.3", + "packaging": "26.3", + "psycopg": "3.3.4", + "psycopg-binary": "3.3.4", + "pycparser": "3.0", + "python-decouple": "3.8", + "sqlparse": "0.5.5", + "typing_extensions": "4.16.0", + "webencodings": "0.5.1", + "whitenoise": "6.12.0" + } + }, + "runtime_packages_equal": true, + "python_versions_equal": true + }, + "production_deployed": false, + "live_release_pins_modified": false, + "new_database_migrations": false, + "not_an_offboarding_or_transfer_acceptance": true, + "source_manifest_sha256": "a90b0217c6424a89e166a7df514fe4a86c9643b502ecdc9ebf0b64b76c06b739", + "packaged_django_check": "passed", + "packaged_migration_drift_check": "passed_no_changes_detected", + "source_gate": { + "command": "make check", + "tests": 441, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "1e5b3c63d85a23f176420f2004831b59406d294c1528b3be80d49669edbb116f" + } +} diff --git a/release/identity-export-evidence/identity-export-candidate-provenance-2026-10-31.json b/release/identity-export-evidence/identity-export-candidate-provenance-2026-10-31.json new file mode 100644 index 0000000..2b769b0 --- /dev/null +++ b/release/identity-export-evidence/identity-export-candidate-provenance-2026-10-31.json @@ -0,0 +1,20 @@ +{ + "schema": "coulomb.packaged-source-provenance.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "network": "none", + "read_only_root": true, + "entrypoint_bypassed": true, + "database_contact": false, + "production_changes": false, + "comparisons": [ + { + "commit": "23ffe2cf4b214666754a2420558943438e54251a", + "packaged_file_count": 80, + "source_file_count": 80, + "different_files": 0, + "all_packaged_source_bytes_match": true + } + ], + "actual_packaged_manifest_sha256": "6b5a7b339d88f3c5eafb70a33aa28a7d3972988645b246ae1a92a3f159d72528", + "dependency_or_build_reproducibility_proved": false +} diff --git a/release/identity-export-evidence/identity-export-candidate-recovery-2026-10-31.json b/release/identity-export-evidence/identity-export-candidate-recovery-2026-10-31.json new file mode 100644 index 0000000..6714caf --- /dev/null +++ b/release/identity-export-evidence/identity-export-candidate-recovery-2026-10-31.json @@ -0,0 +1,63 @@ +{ + "schema": "coulomb.packaged-identity-export-recovery.v1", + "status": "packaged_recovered_client_export_and_views_verified", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "image_id": "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "restore_receipt_sha256": "b648348780f87f81094b40876a6b0ebabaefff7986cf20fb426c3d7456a0bc84", + "client_export": { + "status": "recovered_client_export_verified", + "schema": "coulomb.client-dataset-export.v1", + "content_files": 85, + "records": { + "members.user": 1, + "members.member": 1, + "spaces.space": 1, + "spaces.spacemembership": 1 + }, + "admin_events": 0, + "archive_sha256": "8efa82e7c29c95840d61d1290e20cac4b8acf5ae33d2c5b632382a7f6b779c37", + "offboarding_complete": false, + "all_source_tables_compared": 13, + "all_source_files_compared": 86, + "archive_member_hashes_verified": true, + "selected_client_content_complete_locally": true, + "source_sql_and_content_unchanged": true, + "external_bindings_require_review": 0, + "sessions_and_credentials_excluded": true + }, + "candidate_application": { + "status": "isolated_application_recovery_verified", + "recovered_spaces_with_content": 1, + "actual_pages_rendered_through_view": 40, + "restored_owner_access_verified": true, + "synthetic_viewer_reads": 1, + "synthetic_viewer_mutation_denials": 1, + "same_tenant_ungranted_page_denials": 40, + "fixture_transaction_rolled_back": true, + "production_access": false, + "external_identity_provider_used": false, + "real_browser_journey_verified": false + }, + "previous_image_application": { + "status": "isolated_application_recovery_verified", + "recovered_spaces_with_content": 1, + "actual_pages_rendered_through_view": 40, + "restored_owner_access_verified": true, + "synthetic_viewer_reads": 1, + "synthetic_viewer_mutation_denials": 1, + "same_tenant_ungranted_page_denials": 40, + "fixture_transaction_rolled_back": true, + "production_access": false, + "external_identity_provider_used": false, + "real_browser_journey_verified": false + }, + "source_content_files_unchanged": 86, + "application_source_overlay": false, + "export_archive_removed": true, + "network_internal": true, + "published_ports": 0, + "production_access": false, + "production_restore": false, + "post_date_correction_data_claimed": false, + "driver_sha256": "9c370b6e7e1acb442cb660cc3709e3ffd3676c87419a217a7656174c8a347f95" +} diff --git a/release/identity-export-evidence/identity-export-candidate-tests-2026-10-31.json b/release/identity-export-evidence/identity-export-candidate-tests-2026-10-31.json new file mode 100644 index 0000000..bda3298 --- /dev/null +++ b/release/identity-export-evidence/identity-export-candidate-tests-2026-10-31.json @@ -0,0 +1,17 @@ +{ + "schema": "coulomb.packaged-identity-export-tests.v1", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "image_id": "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "tests": 25, + "failures": 0, + "errors": 0, + "skipped": 0, + "network": "none", + "read_only_root": true, + "application_source_overlay": false, + "test_tooling_injected": true, + "application_modules_from_image_verified": true, + "production_access": false, + "junit_sha256": "85aac22d33a101d68e478f41f61bc3c7e7adb4f3d7f893a2810f7eb25fce5a39", + "driver_sha256": "a19f94f3dbc71dfd80a2d380f03e5f737c7453b64df022b6e5c2ede8141976b3" +} diff --git a/release/identity-export-snapshot.py b/release/identity-export-snapshot.py new file mode 100644 index 0000000..a928c1b --- /dev/null +++ b/release/identity-export-snapshot.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Node-side quiesced coulomb-social snapshot. Binary output stays private. + +Run on railiance01 after reviewed live approval. Does not restore production. +Default is server dry-run; --execute briefly pauses the app and captures a +paired pg_dump/content archive. A separate isolated restore must validate it. +""" +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import os +import signal +import stat +import subprocess +import sys +import time +from pathlib import Path + +NS = 'coulomb-social' +IMAGE = ('forgejo.coulomb.social/coulomb/coulomb-social@sha256:' + '522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed') +CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' +LOCK_PATH = Path('/var/lib/coulomb-social-backup/snapshot.lock') + + +class Refused(RuntimeError): + pass + + +def run(args: list[str], *, payload: bytes | None = None, timeout: int = 180) -> bytes: + p = subprocess.run(args, input=payload, capture_output=True, timeout=timeout) + if p.returncode: + raise Refused('command_failed') + return p.stdout + + +def k(*args: str, payload: bytes | None = None) -> bytes: + return run(['kubectl', *args], payload=payload) + + +def document(*args: str) -> dict: + return json.loads(k(*args, '-o', 'json')) + + +def private_write(path: Path, value: bytes) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as f: + f.write(value) + + +def snapshot() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--execute', action='store_true') + parser.add_argument('--destination', type=Path, required=True) + args = parser.parse_args() + os.environ['KUBECONFIG'] = '/etc/rancher/k3s/k3s.yaml' + started = time.monotonic() + if document('get', 'namespace', 'kube-system')['metadata']['uid'] != CLUSTER_UID: + raise Refused('wrong_cluster') + dep = document('get', 'deployment', 'coulomb-social', '-n', NS) + spec = dep['spec']['template']['spec'] + if dep['spec']['replicas'] != 1 or spec['containers'][0]['image'] != IMAGE: + raise Refused('unexpected_release') + if document('get', 'pvc', 'coulomb-social-content', '-n', NS)['status']['phase'] != 'Bound': + raise Refused('pvc_not_bound') + cluster = document('get', 'cluster', 'apps-pg', '-n', 'databases') + primary = cluster['status']['currentPrimary'] + name = 'csoc-paired-snapshot-' + time.strftime('%Y%m%d%H%M%S', time.gmtime()) + pod = { + 'apiVersion': 'v1', 'kind': 'Pod', + 'metadata': {'name': name, 'namespace': NS, 'labels': {'csoc-recovery': name}}, + 'spec': { + 'restartPolicy': 'Never', 'automountServiceAccountToken': False, + 'securityContext': {'runAsNonRoot': True, 'runAsUser': 10001, + 'runAsGroup': 10001, 'fsGroup': 10001, + 'fsGroupChangePolicy': 'OnRootMismatch'}, + 'containers': [{'name': 'snapshot', 'image': IMAGE, + 'command': ['python', '-c', 'import time;time.sleep(1800)'], + 'resources': {'requests': {'cpu': '10m', 'memory': '64Mi'}, + 'limits': {'cpu': '100m', 'memory': '256Mi'}}, + 'securityContext': {'allowPrivilegeEscalation': False, + 'readOnlyRootFilesystem': True, + 'capabilities': {'drop': ['ALL']}}, + 'volumeMounts': [{'name': 'content', 'mountPath': '/snapshot', 'readOnly': True}]}], + 'volumes': [{'name': 'content', 'persistentVolumeClaim': + {'claimName': 'coulomb-social-content', 'readOnly': True}}], + }, + } + body = json.dumps(pod).encode() + k('create', '--dry-run=server', '-f', '-', payload=body) + if not args.execute: + print(json.dumps({'status': 'reviewed', 'server_dry_run': True, + 'image_pinned': True, 'pvc_read_only': True, + 'db_scope': 'coulomb_social_db', 'replicas_restored_to': 1})) + return 0 + dest = args.destination + if not dest.is_absolute() or dest.exists(): + raise Refused('destination_must_be_new_absolute_directory') + dest.mkdir(mode=0o700) + paused = False + created = False + receipt = {'status': 'incomplete', 'app': 'coulomb-social', + 'database': 'coulomb_social_db', 'image': IMAGE, + 'started_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + 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=body) + k('wait', '--for=condition=Ready', 'pod/' + name, '-n', NS, '--timeout=120s') + dump = k('exec', '-n', 'databases', primary, '-c', 'postgres', '--', + 'pg_dump', '-U', 'postgres', '-d', 'coulomb_social_db', '-Fc') + if not dump.startswith(b'PGDMP'): + raise Refused('invalid_pg_dump') + private_write(dest / 'database.dump', dump) + archive_code = ('import io,sys,tarfile; b=io.BytesIO(); ' + 't=tarfile.open(fileobj=b,mode="w:gz"); ' + 't.add("/snapshot",arcname="content"); ' + 't.close();sys.stdout.buffer.write(b.getvalue())') + content = k('exec', '-n', NS, name, '--', 'python', '-c', archive_code) + private_write(dest / 'content.tar.gz', content) + receipt.update(status='captured', writers_quiesced=True, + database_sha256=hashlib.sha256(dump).hexdigest(), + content_sha256=hashlib.sha256(content).hexdigest(), + database_bytes=len(dump), content_bytes=len(content)) + finally: + try: + if created: + k('delete', 'pod', name, '-n', NS, '--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['elapsed_seconds'] = round(time.monotonic() - started, 2) + private_write(dest / 'receipt.json', json.dumps(receipt, indent=2).encode()) + print(json.dumps(receipt)) + return 0 + + +def main() -> int: + if '--execute' not in sys.argv: + return snapshot() + LOCK_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + parent = LOCK_PATH.parent.lstat() + if (not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() + or parent.st_mode & 0o077): + raise Refused('lock_directory_not_private') + fd = os.open(LOCK_PATH, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise Refused('snapshot_already_running') from None + def terminate(signum, frame): + raise Refused('snapshot_interrupted') + previous = signal.signal(signal.SIGTERM, terminate) + try: + return snapshot() + finally: + signal.signal(signal.SIGTERM, previous) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except Exception as exc: + # Never expose child output, kubectl objects, credentials or raw exception messages. + print(json.dumps({'status': 'failed', 'failure_type': type(exc).__name__})) + sys.exit(1) diff --git a/release/identity-export-values.yaml b/release/identity-export-values.yaml new file mode 100644 index 0000000..56d4ded --- /dev/null +++ b/release/identity-export-values.yaml @@ -0,0 +1,50 @@ +# Production overrides for coulomb-social. +# Non-secret values only. Secrets live in K8s Secret coulomb-social-env. + +image: + repository: forgejo.coulomb.social/coulomb/coulomb-social + tag: "23ffe2cf4b21" + digest: "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed" + +# Public host: app.coulomb.social (parallel to Bubble on apex coulomb.social). +# Apex cutover is deferred until data + self-registration are ready. +env: + DJANGO_SETTINGS_MODULE: coulomb_social.settings.prod + ALLOWED_HOSTS: app.coulomb.social,localhost + CSRF_TRUSTED_ORIGINS: https://app.coulomb.social + CONTENT_ROOT: /app/var/content + DEFAULT_TENANT_ID: "tenant:coulomb" + OIDC_ENABLED: "true" + OIDC_ISSUER: https://kc.coulomb.social + OIDC_CLIENT_ID: coulomb-social + OIDC_REDIRECT_URI: https://app.coulomb.social/auth/callback/ + OIDC_SCOPES: "openid profile email groups" + USER_ENGINE_BASE_URL: https://users.coulomb.social + USER_ENGINE_APPLICATION_ID: coulomb-social + USER_ENGINE_EXPECTED_AUDIENCE: user-engine-portal + NETKINGDOM_REGISTRATION_URL: https://users.92-205-62-239.nip.io/register + +probes: + hostHeader: app.coulomb.social + path: /healthz + +# One writable local-path volume; Recreate prevents concurrent old/new writers. +replicaCount: 1 +deploymentStrategy: Recreate +persistence: + content: + enabled: true + storageClass: local-path + size: 2Gi + mountPath: /app/var/content + retain: true + +# Wave-2 right-sizing (CSOC-WP-0017-T01, 2026-10-21 node budget): 7d p95 +# 0.4m CPU / 146Mi. Limits stay at the chart defaults (1000m/1Gi). +resources: + requests: + cpu: 15m + memory: 176Mi + limits: + cpu: 1000m + memory: 1Gi diff --git a/tests/test_coulomb_image_release.py b/tests/test_coulomb_image_release.py new file mode 100644 index 0000000..e733c02 --- /dev/null +++ b/tests/test_coulomb_image_release.py @@ -0,0 +1,194 @@ +"""Synthetic release safety cases; no cluster, credentials or real content.""" +import copy +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +SOURCE = Path(__file__).resolve().parents[1] / "tools/coulomb_image_release_node.py" +spec = importlib.util.spec_from_file_location("storage_release", SOURCE) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + +launcher_spec = importlib.util.spec_from_file_location("storage_launcher", SOURCE.with_name("coulomb-image-release.py")) +launcher = importlib.util.module_from_spec(launcher_spec) +launcher_spec.loader.exec_module(launcher) + + +def capsule(tmp_path): + root = tmp_path / "capsule" + root.mkdir() + (root / "probe.py").write_bytes(b"synthetic read-only probe") + plan = {"files": {"probe.py": release.sha((root / "probe.py").read_bytes())}} + (root / "manifest.json").write_text(json.dumps(plan)) + return root, "sha256:" + release.sha(release.canonical(plan)) + + +def test_capsule_tampering_refuses_before_privileged_work(tmp_path, monkeypatch): + root, digest = capsule(tmp_path) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", tmp_path / "journal") + (root / "probe.py").write_bytes(b"changed") + with pytest.raises(release.Refused, match="capsule_file_differs"): + release.Release(root, digest) + assert not (tmp_path / "journal").exists() + + +def test_preflight_never_creates_an_execution_journal(tmp_path, monkeypatch): + root, digest = capsule(tmp_path) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", tmp_path / "journal") + instance = release.Release(root, digest, read_only=True) + instance.save() + assert not (tmp_path / "journal").exists() + + +def test_prior_attempt_is_not_blindly_replayed(tmp_path, monkeypatch): + root, digest = capsule(tmp_path) + journal = tmp_path / "journal" + (journal / digest.removeprefix("sha256:")).mkdir(parents=True) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", journal) + with pytest.raises(release.Refused, match="prior_execution_requires_review"): + release.Release(root, digest) + + +def test_mutable_staging_is_frozen_before_live_work(tmp_path, monkeypatch): + root, digest = capsule(tmp_path) + monkeypatch.setattr(release.os, "geteuid", lambda: 0) + monkeypatch.setattr(release, "JOURNAL_ROOT", tmp_path / "journal") + instance = release.Release(root, digest) + (root / "probe.py").write_bytes(b"changed after verification") + assert (instance.root / "probe.py").read_bytes() == b"synthetic read-only probe" + assert (instance.journal / "receipt.json").is_file() + + +@pytest.mark.parametrize("field", ["business_rows_sha256", "content_files_sha256", "migration_rows_sha256"]) +def test_dataset_drift_is_not_accepted(field): + instance = object.__new__(release.Release) + baseline = {"business_tables": 2, "business_rows": 3, "business_rows_sha256": "synthetic-a", + "content_files": 4, "content_files_sha256": "synthetic-b", + "migration_rows_sha256": "synthetic-migrations"} + instance.plan = {"expected_data": baseline} + changed = dict(baseline, duplicate_slug_groups=0, tenant_slug_constraint=True) + changed[field] = "different" + with pytest.raises(release.Refused, match="dataset_drifted"): + instance.same_data(changed) + + +def test_failed_preflight_never_captures_or_upgrades(): + instance = object.__new__(release.Release) + instance.preflight = Mock(side_effect=release.Refused("guard stopped")) + instance.capture = Mock() + instance.command = Mock() + with pytest.raises(release.Refused): + instance.execute() + instance.capture.assert_not_called() + instance.command.assert_not_called() + + +@pytest.mark.parametrize("rollback_spec,rollback_ready,verified", [ + ({"synthetic": "old"}, 1, True), + ({"synthetic": "candidate"}, 1, False), + ({"synthetic": "old"}, 0, False), +]) +def test_postflight_failure_rolls_back_image_without_restoring_data( + tmp_path, monkeypatch, rollback_spec, rollback_ready, verified): + pin = tmp_path / "snapshot.py" + pin.write_bytes(b"old pin") + monkeypatch.setattr(release, "SNAPSHOT", pin) + instance = object.__new__(release.Release) + instance.receipt = {} + instance.root = tmp_path + spec = {"synthetic": "candidate"} + baseline = {"business_tables": 2, "business_rows": 3, "business_rows_sha256": "synthetic-a", + "content_files": 4, "content_files_sha256": "synthetic-b", + "migration_rows_sha256": "synthetic-migrations"} + instance.plan = {"candidate_spec_sha256": release.sha(release.canonical(spec)), "baseline_revision": 18, + "baseline_spec_sha256": release.sha(release.canonical({"synthetic": "old"})), + "expected_data": baseline} + instance.preflight = Mock() + instance.capture = Mock() + instance.save = Mock() + instance.deployment = Mock(side_effect=[ + {"spec": copy.deepcopy(spec), "status": {"readyReplicas": 1}}, + {"spec": rollback_spec, "status": {"readyReplicas": rollback_ready}}, + ]) + instance.probe = lambda mode: dict(baseline, business_rows_sha256="changed") + instance.command = Mock(return_value=SimpleNamespace(stdout=b"", returncode=0)) + instance.restore_pin = Mock() + with pytest.raises(release.Refused, match="dataset_drifted"): + instance.execute() + commands = [call.args[0] for call in instance.command.call_args_list] + assert commands[0][:2] == ["helm", "upgrade"] and "--atomic" in commands[0] + assert commands[1][:4] == ["helm", "rollback", "coulomb-social", "18"] + assert not any("pg_restore" in command for command in commands) + instance.restore_pin.assert_called_once_with(b"old pin") + assert instance.receipt["image_rollback_completed"] is verified + + +def test_live_launcher_rejects_unattended_execution_before_ssh(monkeypatch): + _, digest, _ = launcher.prepare() + monkeypatch.setattr(launcher.sys, "argv", ["release", "--execute", "--expect-digest", digest]) + monkeypatch.setattr(launcher.sys, "stdin", SimpleNamespace(isatty=lambda: False)) + command = Mock() + monkeypatch.setattr(launcher.subprocess, "run", command) + with pytest.raises(SystemExit, match="own terminal"): + launcher.main() + command.assert_not_called() + + +def test_timeout_gives_the_existing_capture_resume_window(monkeypatch): + instance = object.__new__(release.Release) + instance.env = {} + process = Mock() + process.communicate.side_effect = release.subprocess.TimeoutExpired(["synthetic"], 1) + monkeypatch.setattr(release.subprocess, "Popen", Mock(return_value=process)) + with pytest.raises(release.Refused, match="cleanup_window"): + instance.command(["synthetic"], timeout=1) + process.terminate.assert_called_once() + process.wait.assert_called_once_with(timeout=450) + process.kill.assert_not_called() + + +@pytest.mark.parametrize("field", ["global_slug_constraint", "migration_0003_applied"]) +@pytest.mark.parametrize("value", [False, None]) +def test_image_only_release_requires_existing_migration_and_constraint(field, value): + instance = object.__new__(release.Release) + baseline = {"business_tables": 2, "business_rows": 3, + "business_rows_sha256": "rows", "content_files": 4, + "content_files_sha256": "files", "migration_rows_sha256": "migrations"} + instance.plan = {"expected_data": baseline} + observed = dict(baseline, duplicate_slug_groups=0, tenant_slug_constraint=True, + global_slug_constraint=True, migration_0003_applied=True) + instance.same_data(observed) + observed[field] = value + with pytest.raises(release.Refused, match="already_migrated_baseline_required"): + instance.same_data(observed) + + +def test_rollback_does_not_overwrite_unrecognized_snapshot_helper(tmp_path, monkeypatch): + pin = tmp_path / "snapshot.py" + pin.write_bytes(b"concurrent owner change") + monkeypatch.setattr(release, "SNAPSHOT", pin) + instance = object.__new__(release.Release) + instance.plan = {"files": {"release/identity-export-snapshot.py": release.sha(b"new pin")}} + with pytest.raises(release.Refused, match="snapshot_pin_changed_during_rollback"): + instance.restore_pin(b"old pin") + assert pin.read_bytes() == b"concurrent owner change" + + +def test_known_candidate_helper_can_be_rolled_back(tmp_path, monkeypatch): + pin = tmp_path / "snapshot.py" + pin.write_bytes(b"new pin") + monkeypatch.setattr(release, "SNAPSHOT", pin) + instance = object.__new__(release.Release) + instance.plan = {"files": {"release/identity-export-snapshot.py": release.sha(b"new pin")}} + instance.restore_pin(b"old pin") + assert pin.read_bytes() == b"old pin" + # A completed inverse can be checked again without changing it. + instance.restore_pin(b"old pin") + assert pin.read_bytes() == b"old pin" diff --git a/tools/coulomb-image-release.py b/tools/coulomb-image-release.py new file mode 100644 index 0000000..1b2efc7 --- /dev/null +++ b/tools/coulomb-image-release.py @@ -0,0 +1,185 @@ +"""Prepare/read-only-check a sealed release; live mode is attended founder work. + +This launcher does not mint an approval or bypass gated-executor. The existing +gated-executor profile covers Kubernetes, not the root-owned backup helper refresh. +Only the founder runs --execute in their own terminal after reviewing the packet. +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import shlex +import subprocess +import sys +import tarfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PLAN = ROOT / "release/coulomb-identity-export-23ffe2c.json" + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def prepare(): + plan = json.loads(PLAN.read_text()) + paths = [ + ROOT / name + for name in ( + "tools/coulomb-image-release.py", + "tools/coulomb_image_release_node.py", + "tools/coulomb_image_probe.py", + "release/identity-export-values.yaml", + "release/identity-export-snapshot.py", + ) + ] + paths += sorted((ROOT / "charts/coulomb-social").rglob("*")) + paths += sorted((ROOT / "release/identity-export-evidence").glob("*.json")) + paths = [p for p in paths if p.is_file()] + if any(p.is_symlink() or not p.resolve().is_relative_to(ROOT) for p in paths): + raise RuntimeError("regular_reviewed_files_required") + content = {str(p.relative_to(ROOT)): p.read_bytes() for p in paths} + plan["files"] = { + name: hashlib.sha256(data).hexdigest() for name, data in content.items() + } + for name, expected in plan["rehearsal_evidence_sha256"].items(): + if plan["files"].get("release/identity-export-evidence/" + name) != expected: + raise RuntimeError("reviewed_candidate_evidence_changed") + digest = "sha256:" + hashlib.sha256(canonical(plan)).hexdigest() + content["manifest.json"] = canonical(plan) + return plan, digest, content + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--preflight", action="store_true") + parser.add_argument("--execute", action="store_true") + parser.add_argument("--expect-digest") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if args.preflight and args.execute: + raise SystemExit("choose read-only preflight or attended execution") + plan, digest, content = prepare() + if args.expect_digest and args.expect_digest != digest: + raise SystemExit("reviewed proposal changed; do not execute") + if not args.preflight and not args.execute: + print( + json.dumps( + { + "status": "prepared", + "proposal_digest": digest, + "file_count": len(content), + "candidate": plan["candidate_image"], + "live_changes": False, + } + ) + ) + return + if args.execute: + if not args.expect_digest or not sys.stdin.isatty(): + raise SystemExit( + "founder must review and run the exact digest in their own terminal" + ) + print("Reviewed proposal: " + digest) + print( + "Coulomb-only image update; existing database schema retained, fresh paired backup, postflight and backup image-pin refresh." + ) + if ( + input("Type DEPLOY followed by the full digest to proceed: ").strip() + != "DEPLOY " + digest + ): + raise SystemExit("not approved; no live change") + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:") as archive: + for name, data in content.items(): + entry = tarfile.TarInfo(name) + entry.size, entry.mode = len(data), 0o600 + archive.addfile(entry, io.BytesIO(data)) + remote = "/tmp/csoc-reviewed-image-" + digest.removeprefix("sha256:") + receiver = r""" +import hashlib,io,json,os,pathlib,sys,tarfile +root=pathlib.Path(sys.argv[1]);expected=sys.argv[2] +raw=sys.stdin.buffer.read(2*1024**2+1);assert len(raw)<=2*1024**2 +with tarfile.open(fileobj=io.BytesIO(raw),mode='r:') as archive: + entries=archive.getmembers();assert len({e.name for e in entries})==len(entries) + assert all(e.isfile() and not pathlib.Path(e.name).is_absolute() and '..' not in pathlib.Path(e.name).parts for e in entries) + data={e.name:archive.extractfile(e).read() for e in entries} + plan=json.loads(data['manifest.json']) + canonical=json.dumps(plan,sort_keys=True,separators=(',',':')).encode() + assert 'sha256:'+hashlib.sha256(canonical).hexdigest()==expected + assert set(data)==set(plan['files'])|{'manifest.json'} + for name,digest in plan['files'].items():assert hashlib.sha256(data[name]).hexdigest()==digest + root.mkdir(mode=0o700,exist_ok=True) + assert root.lstat().st_uid==os.getuid() and root.lstat().st_mode&0o077==0 + for name,value in data.items(): + target=root/name;target.parent.mkdir(parents=True,exist_ok=True,mode=0o700) + if target.exists():assert not target.is_symlink() and target.read_bytes()==value;continue + fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as stream:stream.write(value) +""" + staged = subprocess.run( + [ + "ssh", + "railiance01", + "python3 -c " + + shlex.quote(receiver) + + " " + + shlex.quote(remote) + + " " + + shlex.quote(digest), + ], + input=buffer.getvalue(), + capture_output=True, + timeout=60, + check=False, + ) + if staged.returncode: + raise SystemExit("reviewed capsule staging refused; no live change") + node_hash = plan["files"]["tools/coulomb_image_release_node.py"] + guard = ( + "import hashlib,pathlib,sys;p=pathlib.Path(" + + repr(remote + "/tools/coulomb_image_release_node.py") + + ");" + "b=p.read_bytes();assert hashlib.sha256(b).hexdigest()==" + + repr(node_hash) + + ";" + "sys.argv=['reviewed-release','--capsule'," + + repr(remote) + + ",'--expect-digest'," + + repr(digest) + + "," + + repr("--execute" if args.execute else "--preflight") + + "];exec(compile(b,str(p),'exec'),{'__name__':'__main__'})" + ) + result = subprocess.run( + ["ssh", "railiance01", "sudo -n python3 -c " + shlex.quote(guard)], + capture_output=True, + timeout=3600, + check=False, + ) + try: + receipt = json.loads(result.stdout) + except (ValueError, TypeError): + receipt = { + "status": "remote_execution_unconfirmed", + "proposal_digest": digest, + "journal": "/var/lib/coulomb-social-backup/releases/" + + digest.removeprefix("sha256:") + + "/receipt.json", + } + if args.output: + fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as stream: + json.dump(receipt, stream, indent=2) + print(json.dumps(receipt)) + if result.returncode: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/coulomb_image_probe.py b/tools/coulomb_image_probe.py new file mode 100644 index 0000000..8d7b028 --- /dev/null +++ b/tools/coulomb_image_probe.py @@ -0,0 +1,134 @@ +"""Read-only data/schema and view probe; runs inside the existing app container.""" + +import hashlib +import json +import sys +import uuid +from pathlib import Path + +import django + +django.setup() +from coulomb_social.apps.members.models import Member, User +from coulomb_social.apps.spaces.models import Space +from django.conf import settings +from django.db import connection +from django.db.models import Count + +assert connection.vendor == "postgresql" +assert connection.settings_dict["NAME"] in { + "coulomb_social_db", + "coulomb_social_restore", +} +tables = {} +with connection.cursor() as cursor: + cursor.execute( + "SELECT tablename FROM pg_tables WHERE schemaname='public' " + "AND tablename!='django_migrations' ORDER BY tablename" + ) + names = [row[0] for row in cursor.fetchall()] + for table in names: + cursor.execute("SELECT * FROM " + connection.ops.quote_name(table)) + rows = sorted( + json.dumps(row, default=str, separators=(",", ":")) + for row in cursor.fetchall() + ) + tables[table] = { + "rows": len(rows), + "sha256": hashlib.sha256("\n".join(rows).encode()).hexdigest(), + } + cursor.execute( + "SELECT conname FROM pg_constraint WHERE conrelid='spaces_space'::regclass" + ) + constraints = {row[0] for row in cursor.fetchall()} + cursor.execute( + "SELECT COUNT(*) FROM django_migrations WHERE app='spaces' AND name='0003_space_slug_global_uniq'" + ) + applied = cursor.fetchone()[0] == 1 + cursor.execute("SELECT * FROM django_migrations") + migration_rows = sorted(json.dumps(row, default=str, separators=(",", ":")) + for row in cursor.fetchall()) +root = Path(settings.CONTENT_ROOT) +files = { + str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in root.rglob("*") + if p.is_file() +} +result = { + "business_tables": len(tables), + "migration_rows_sha256": hashlib.sha256("\n".join(migration_rows).encode()).hexdigest(), + "business_rows": sum(t["rows"] for t in tables.values()), + "business_rows_sha256": hashlib.sha256( + json.dumps(tables, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + "content_files": len(files), + "content_files_sha256": hashlib.sha256( + json.dumps(files, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + "global_slug_constraint": "spaces_space_slug_global_uniq" in constraints, + "tenant_slug_constraint": "spaces_space_tenant_slug_uniq" in constraints, + "migration_0003_applied": applied, + "duplicate_slug_groups": Space.objects.values("slug") + .annotate(n=Count("pk")) + .filter(n__gt=1) + .count(), +} +if len(sys.argv) > 1 and sys.argv[1] == "views": + from coulomb_social.apps.spaces import views + from coulomb_social.apps.spaces.content import load_space_page + from django.test import RequestFactory + from django.urls import reverse + + assert not settings.FLEX_AUTH_BASE_URL + factory = RequestFactory() + rendered_count, denied_count = 0, 0 + for space in Space.objects.filter(is_active=True): + paths = sorted((root / "spaces" / space.slug / "pages").glob("*.md")) + if not paths: + continue + owner = space.created_by + if owner is None: + owner = ( + space.memberships.filter(role="owner") + .select_related("member__user") + .first() + .member + ) + token = "unsaved-release-check-" + uuid.uuid4().hex + outsider = Member( + user=User(username=token), + tenant_id=space.tenant_id, + subject=token, + issuer="https://release-check.invalid", + user_engine_user_id=token, + ) + for path in paths: + page = path.stem + loaded = load_space_page(space, page) + assert loaded.error is None + + def request(user, slug=space.slug, page_slug=page): + req = factory.get( + reverse("spaces:detail", kwargs={"slug": slug}), + {"page": page_slug}, + ) + req.user, req.session = user, {} + return req + + response = views.space_detail(request(owner.user), slug=space.slug) + assert ( + response.status_code == 200 and loaded.html.encode() in response.content + ) + rendered_count += 1 + denied = views.space_detail(request(outsider.user), slug=space.slug) + assert denied.status_code == 404 + if loaded.html: + assert loaded.html.encode() not in denied.content + denied_count += 1 + assert rendered_count > 0 and denied_count == rendered_count + result.update( + actual_owner_pages_rendered=rendered_count, + unsaved_outsider_page_denials=denied_count, + production_identity_or_grant_created=False, + ) +print(json.dumps(result)) diff --git a/tools/coulomb_image_release_node.py b/tools/coulomb_image_release_node.py new file mode 100644 index 0000000..d84b525 --- /dev/null +++ b/tools/coulomb_image_release_node.py @@ -0,0 +1,525 @@ +"""Attended founder-only node release; argv/data are sealed by the reviewed capsule. + +This is a supervisor command, not a new agent credential or approval boundary. +No Secret object/output is fetched. Existing approved backup custody stays local. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import subprocess +import tempfile +import time +import urllib.request +from datetime import UTC, datetime +from pathlib import Path + +JOURNAL_ROOT = Path("/var/lib/coulomb-social-backup/releases") +SNAPSHOT = Path("/opt/coulomb-social-backup/snapshot-content-and-db.py") + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +class Refused(RuntimeError): + pass + + +class Release: + def __init__(self, capsule, expected, read_only=False): + self.root = capsule + self.plan = json.loads((capsule / "manifest.json").read_text()) + frozen = {} + if "sha256:" + sha(canonical(self.plan)) != expected: + raise Refused("proposal_digest_differs") + for name, digest in self.plan["files"].items(): + path = capsule / name + if ( + not path.resolve().is_relative_to(capsule.resolve()) + or Path(name).is_absolute() + or ".." in Path(name).parts + or path.is_symlink() + or not path.is_file() + or sha(path.read_bytes()) != digest + ): + raise Refused("capsule_file_differs") + frozen[name] = path.read_bytes() + if sha(frozen[name]) != digest: + raise Refused("capsule_changed_during_verification") + if os.geteuid() != 0: + raise Refused("attended_founder_root_execution_required") + self.env = dict(os.environ, KUBECONFIG="/etc/rancher/k3s/k3s.yaml") + self.read_only = read_only + self.receipt = { + "schema": "coulomb.guarded-image-release.v1", + "proposal_digest": expected, + "started_at": datetime.now(UTC).isoformat(), + "status": "preflight", + "production_data_restored": False, + "timers_activated": False, + } + self.journal = JOURNAL_ROOT / expected.removeprefix("sha256:") + if read_only: + return + if self.journal.exists(): + raise Refused("prior_execution_requires_review") + self.journal.mkdir(parents=True, mode=0o700) + self.root = self.journal / "capsule" + self.root.mkdir(mode=0o700) + for name, data in frozen.items(): + target = self.root / name + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(data) + self.receipt["pid"] = os.getpid() + self.save() + + def save(self): + if self.read_only: + return + fd, name = tempfile.mkstemp(prefix=".receipt-", dir=self.journal) + with os.fdopen(fd, "w") as stream: + json.dump(self.receipt, stream, sort_keys=True, indent=2) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, self.journal / "receipt.json") + directory = os.open(self.journal, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + + def command(self, arguments, payload=None, env=None, timeout=180, allowed=(0,)): + # In particular, give the paired capture its existing app-resume window + # on timeout; subprocess.run would SIGKILL it before that cleanup. + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + process = subprocess.Popen( + arguments, + env=env or self.env, + stdin=subprocess.PIPE if payload is not None else subprocess.DEVNULL, + stdout=stdout, + stderr=stderr, + ) + try: + process.communicate(payload, timeout=timeout) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.wait(timeout=450) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise Refused("private_child_timed_out_after_cleanup_window") from None + stdout.seek(0) + body = stdout.read(8 * 1024**2 + 1) + if len(body) > 8 * 1024**2: + raise Refused("bounded_child_output_required") + result = subprocess.CompletedProcess(arguments, process.returncode, body) + if result.returncode not in allowed: + raise Refused("private_child_refused") + return result + + def kdoc(self, *arguments): + return json.loads(self.command(["kubectl", *arguments, "-o", "json"]).stdout) + + def history(self): + return json.loads( + self.command( + [ + "helm", + "history", + "coulomb-social", + "-n", + "coulomb-social", + "-o", + "json", + ] + ).stdout + ) + + def deployment(self): + return self.kdoc("get", "deployment", "coulomb-social", "-n", "coulomb-social") + + def probe(self, mode="data"): + source = (self.root / "tools/coulomb_image_probe.py").read_text() + result = self.command( + [ + "kubectl", + "exec", + "-n", + "coulomb-social", + "deploy/coulomb-social", + "--", + "python", + "-c", + source, + mode, + ] + ) + return json.loads(result.stdout) + + def same_data(self, actual): + for name in ( + "business_tables", + "business_rows", + "business_rows_sha256", + "content_files", + "content_files_sha256", + "migration_rows_sha256", + ): + if actual[name] != self.plan["expected_data"][name]: + raise Refused("approved_recovered_dataset_drifted") + if actual["duplicate_slug_groups"] != 0 or actual["tenant_slug_constraint"] is not True: + raise Refused("storage_invariant_refused") + if (actual.get("global_slug_constraint") is not True + or actual.get("migration_0003_applied") is not True): + raise Refused("already_migrated_baseline_required") + + def preflight(self): + self.verify_installed_helpers() + if ( + self.kdoc("get", "namespace", "kube-system")["metadata"]["uid"] + != self.plan["cluster_uid"] + ): + raise Refused("wrong_cluster") + node = self.kdoc("get", "nodes")["items"] + if len(node) != 1 or not any( + a["address"] == "92.205.62.239" for a in node[0]["status"]["addresses"] + ): + raise Refused("wrong_node") + conditions = {v["type"]: v["status"] for v in node[0]["status"]["conditions"]} + if conditions.get("Ready") != "True" or any( + conditions.get(k) != "False" + for k in ("MemoryPressure", "DiskPressure", "PIDPressure") + ): + raise Refused("node_pressure") + allocated = self.command( + ["kubectl", "describe", "node", node[0]["metadata"]["name"]] + ).stdout.decode() + requests = {} + for resource in ("cpu", "memory"): + found = re.search( + r"(?m)^\s+" + resource + r"\s+\S+\s+\((\d+)%\)", allocated + ) + if not found or int(found[1]) > 90: + raise Refused("node_request_headroom_refused") + requests[resource] = int(found[1]) + self.receipt["node_request_percent"] = requests + history = self.history() + if ( + history[-1]["revision"] != self.plan["baseline_revision"] + or history[-1]["status"] != "deployed" + ): + raise Refused("helm_baseline_changed") + manifest = self.command( + ["helm", "get", "manifest", "coulomb-social", "-n", "coulomb-social"] + ).stdout + if sha(manifest) != self.plan["baseline_helm_manifest_sha256"]: + raise Refused("helm_manifest_changed") + deployment = self.deployment() + if ( + deployment["metadata"]["uid"] != self.plan["deployment_uid"] + or sha(canonical(deployment["spec"])) != self.plan["baseline_spec_sha256"] + ): + raise Refused("live_spec_changed") + if ( + deployment["status"].get("readyReplicas") != 1 + or deployment["spec"]["strategy"]["type"] != "Recreate" + ): + raise Refused("single_ready_writer_required") + # Helm's three-way merge preserves unmanaged pod metadata. Bind the + # complete desired live spec rather than treating kubectl apply's + # last-applied projection as Helm's result. + image_patch = [ + {"op": "test", "path": "/spec", "value": deployment["spec"]}, + {"op": "replace", "path": "/spec/template/spec/containers/0/image", + "value": self.plan["candidate_image"]}, + ] + preview = self.kdoc( + "patch", "deployment", "coulomb-social", "-n", "coulomb-social", + "--type=json", "--dry-run=server", "-p", json.dumps(image_patch), + ) + if sha(canonical(preview["spec"])) != self.plan["candidate_spec_sha256"]: + raise Refused("image_only_server_preview_differs") + pvc = self.kdoc("get", "pvc", "coulomb-social-content", "-n", "coulomb-social") + if ( + pvc["metadata"]["uid"] != self.plan["pvc_uid"] + or pvc["status"]["phase"] != "Bound" + ): + raise Refused("pvc_changed") + apps = self.kdoc("get", "applications.argoproj.io", "-A")["items"] + if any( + a.get("spec", {}).get("destination", {}).get("namespace") + == "coulomb-social" + for a in apps + ): + raise Refused("gitops_adoption_requires_owner_path") + rendered = self.command( + [ + "helm", + "template", + "coulomb-social", + str(self.root / "charts/coulomb-social"), + "-f", + str(self.root / "release/identity-export-values.yaml"), + "-n", + "coulomb-social", + ] + ).stdout + if sha(rendered) != self.plan["candidate_render_sha256"]: + raise Refused("candidate_render_changed") + self.command(["kubectl", "diff", "-f", "-"], payload=rendered, allowed=(0, 1)) + self.command( + ["kubectl", "apply", "--dry-run=server", "-f", "-"], payload=rendered + ) + self.command( + [ + "helm", + "upgrade", + "coulomb-social", + str(self.root / "charts/coulomb-social"), + "-f", + str(self.root / "release/identity-export-values.yaml"), + "-n", + "coulomb-social", + "--dry-run=server", + ] + ) + before = self.probe() + self.same_data(before) + self.receipt["preflight"] = before + self.save() + + def verify_installed_helpers(self): + for path, expected in self.plan["installed_helper_hashes"].items(): + metadata = Path(path).lstat() + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != 0 + or metadata.st_mode & 0o022 + or sha(Path(path).read_bytes()) != expected + ): + raise Refused("installed_backup_helper_changed") + def capture(self): + self.verify_installed_helpers() + environment = dict(self.env) + for name in ("primary.env", "recipient.env"): + path = Path("/etc/coulomb-social-backup") / name + metadata = path.lstat() + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != 0 + or metadata.st_mode & 0o077 + ): + raise Refused("private_own_backup_configuration_required") + environment.update( + dict( + line.split("=", 1) + for line in path.read_text().splitlines() + if line and not line.startswith("#") + ) + ) + output = self.command( + ["python3", "/opt/coulomb-social-backup/paired-backup.py", "--execute"], + env=environment, + timeout=1200, + ) + point = json.loads(output.stdout) + if ( + point.get("status") != "offsite_fetched_verified" + or not point.get("remote_retrieval_verified") + or not point.get("version_id") + or point.get("version_id") == "null" + or point.get("image") != self.plan["old_image"] + or point.get("age_recipient") != self.plan["public_recipient"] + or not point.get("immutable_create") + or not point.get("destination", "").startswith( + "s3://railiance-platform-pg-backup/platform-pg/application-archives/coulomb-social/" + ) + ): + raise Refused("fresh_paired_primary_readback_required") + self.receipt["fresh_paired_point"] = point + self.save() + self.same_data(self.probe()) + + def restore_pin(self, old): + path = SNAPSHOT + if ( + sha(path.read_bytes()) + == self.plan["files"]["release/identity-export-snapshot.py"] + ): + self.replace_pin(path, old) + elif sha(path.read_bytes()) != sha(old): + raise Refused("snapshot_pin_changed_during_rollback") + + def replace_pin(self, path, value): + fd, name = tempfile.mkstemp(prefix=".reviewed-snapshot-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(value) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(name): + os.unlink(name) + + def execute(self): + began = time.monotonic() + self.preflight() + self.capture() + old_pin = SNAPSHOT.read_bytes() + self.receipt["status"] = "upgrade_started" + self.save() + try: + self.command( + [ + "helm", + "upgrade", + "coulomb-social", + str(self.root / "charts/coulomb-social"), + "-f", + str(self.root / "release/identity-export-values.yaml"), + "-n", + "coulomb-social", + "--atomic", + "--wait", + "--timeout", + "5m", + ], + timeout=660, + ) + after = self.deployment() + if ( + sha(canonical(after["spec"])) != self.plan["candidate_spec_sha256"] + or after["status"].get("readyReplicas") != 1 + ): + raise Refused("candidate_spec_or_readiness_differs") + probe = self.probe("views") + self.same_data(probe) + for suffix in ("/healthz", "/", "/static/spaces/copy-content.js"): + with urllib.request.urlopen( + "https://app.coulomb.social" + suffix, timeout=20 + ) as response: + body = response.read(1024**2) + if response.status != 200: + raise Refused("public_http_postflight_refused") + if ( + suffix.endswith("copy-content.js") + and sha(body) != self.plan["clipboard_static_sha256"] + ): + raise Refused("served_static_differs") + path = SNAPSHOT + if ( + sha(path.read_bytes()) + != self.plan["installed_helper_hashes"][str(path)] + ): + raise Refused("snapshot_pin_drifted") + self.replace_pin( + path, (self.root / "release/identity-export-snapshot.py").read_bytes() + ) + self.command( + [ + "python3", + str(path), + "--destination", + "/tmp/csoc-post-image-server-review", + ] + ) + self.receipt.update( + status="image_candidate_deployed_verified", + postflight=probe, + helm_revision=self.history()[-1]["revision"], + snapshot_pin_refreshed=True, + public_health_landing_static_verified=True, + existing_global_constraint_preserved=True, + ) + except Exception: + self.receipt["rollback_requested"] = True + try: + self.command( + [ + "helm", + "rollback", + "coulomb-social", + str(self.plan["baseline_revision"]), + "-n", + "coulomb-social", + "--wait", + "--timeout", + "5m", + ], + timeout=360, + ) + self.restore_pin(old_pin) + restored = self.deployment() + if (sha(canonical(restored["spec"])) != self.plan["baseline_spec_sha256"] + or restored["status"].get("readyReplicas") != 1): + raise Refused("rollback_spec_or_readiness_differs") + self.receipt["image_rollback_completed"] = True + except Exception: # noqa: BLE001 -- retain explicit unconfirmed rollback outcome + self.receipt["image_rollback_completed"] = False + raise + self.receipt.update( + elapsed_seconds=round(time.monotonic() - began, 3), + completed_at=datetime.now(UTC).isoformat(), + private_values_printed=False, + ) + self.save() + return self.receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--capsule", type=Path, required=True) + parser.add_argument("--expect-digest", required=True) + parser.add_argument("--execute", action="store_true") + parser.add_argument("--preflight", action="store_true") + args = parser.parse_args() + if not args.execute and not args.preflight: + print(json.dumps({"status": "prepared", "live_changes": False})) + return + if args.execute and args.preflight: + raise SystemExit("choose read-only preflight or attended execution") + release = Release(args.capsule, args.expect_digest, read_only=args.preflight) + try: + if args.preflight: + release.preflight() + print( + json.dumps( + dict( + release.receipt, + status="live_release_preflight_verified", + live_changes=False, + ) + ) + ) + return + print(json.dumps(release.execute())) + except Exception as error: # noqa: BLE001 -- private child diagnostics are suppressed + release.receipt.update( + status="release_refused_or_failed", failure_type=type(error).__name__ + ) + release.save() + print(json.dumps(release.receipt)) + raise SystemExit(1) from None + + +if __name__ == "__main__": + main() diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index fd4cac8..b08998c 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: railiance-apps created: "2026-10-06" -updated: "2026-10-08" +updated: "2026-10-31" related: - CSOC-WP-0008 state_hub_workstream_id: "79abacf8-361e-51ab-829d-40dec8e142d1" @@ -340,3 +340,45 @@ server dry-run pass. No Secret, issuer, mail, caller, PVC, offsite schedule or Bubble/apex change. T04 remains wait for productive complete user acceptance; product WP-0008/WP-0010 retain their broader open tasks. Product receipt: docs/release/sharing-portability-review-2026-10-08.md. + +## T05 — Deliver the identity-binding and operator-export image + +```task +id: RAPPS-WP-0015-T05 +status: progress +priority: high +needs_human: true +decision_id: "75b94bb6-4709-495f-91fe-43e53e2867ed" +intervention_note: "Exact image-only proposal9f1daf6c passes native read-only preflight. Founder terminal execution remains required; then verify durable receipt, current source/values/helper pins and consumer recovery/export follow-through." +``` + +Owner execution dependency for CSOC-WP-0012-T03 and CSOC-WP-0015-T04. This is +separate from completed storage release 5fd7ccd8 and date correction 47829c7a. +Candidate source 23ffe2c/image 522bcd7f is published and packaged-source verified; +441 source tests and 25 packaged identity/export tests pass. The retained isolated +recovery verifies 85 client files and 40 owner/40 outsider views without app overlays. +Current production remains revision 18/image 51281946; default live Helm values are +unchanged until the verified execution return. + +Prepared separate image-only launcher/node/probe, candidate values and snapshot +pin. The controller requires both existing slug constraints and migration 0003, +compares complete business/migration-history/content fingerprints, makes a fresh +admitted primary backup/readback, then performs atomic Helm upgrade and postflight. +Rollback verifies the previous live specification, Ready replica and recognized +helper; no data restore/schema downgrade or automatic replay. The old storage +launcher is not reused. 19 synthetic safety tests, Ruff, compile and Helm lint pass. + +Sealed read-only preflight 9f1daf6c passes at 2026-10-31T03:41:35Z: revision 18, +12 business tables/92 rows/86 files, exact migration history, unchanged helper pins, +CPU 68%/memory 76%, image-only chart/server-preview checks and no Argo adoption. +The expected live spec preserves the unmanaged restart annotation; raw kubectl +apply remains dry-run only. Receipt: docs/evidence/2026-10-31-coulomb-image-release-preflight.json. +Founder packet: coulomb-social/docs/release/identity-export-release-review-2026-10-31.md. +The new execution has not occurred. No node helper, Helm release, timer or data +changed. Existing recovery rehearsal predates the date correction; independent +retrieval/restore/export of the new execution point remains consumer follow-through. + +Done when founder execution and independent postflight are verified, the native +receipt and actual image/helper pins are reconciled into owner/product source, +and the product records reference that exact release. This does not accept the +remaining productive journeys or service/offboarding obligations for the consumer.