diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 00cca4b..af8ac76 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -43,7 +43,7 @@ | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T06 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | -| task | RAPPS-WP-0015-T07 | todo | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | +| task | RAPPS-WP-0015-T07 | wait | — | 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/release/content-lifecycle-evidence/content-lifecycle-baseline-tests-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-baseline-tests-2026-10-31.json new file mode 100644 index 0000000..6b3e676 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-baseline-tests-2026-10-31.json @@ -0,0 +1,25 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-tests.v1", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "image_id": "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "tests": 29, + "failures": 4, + "expected_baseline_regressions": true, + "failed_nodes": [ + "test_process_death_during_copy_or_move_is_recoverable[copy_page-during_first_asset]", + "test_process_death_during_copy_or_move_is_recoverable[copy_page-during_second_asset]", + "test_process_death_during_copy_or_move_is_recoverable[transfer_page-during_first_asset]", + "test_process_death_during_copy_or_move_is_recoverable[transfer_page-during_second_asset]" + ], + "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": "edc52bd3a321d028d255b3d478020b3383d94747957ffe2985583b1f0741cb85", + "driver_sha256": "85b65114997c7e7c5323cb77a0043e495ec132506eef07dd394c6a6361380d36", + "test_source_sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-candidate-build-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-candidate-build-2026-10-31.json new file mode 100644 index 0000000..07207bc --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-candidate-build-2026-10-31.json @@ -0,0 +1,119 @@ +{ + "schema": "coulomb.content-lifecycle-candidate-build.v1", + "completed_at": "2026-10-31T11:58:26.697591+00:00", + "source": "f306dbfa2604e4815523667740be2925c333b84e", + "tag": "forgejo.coulomb.social/coulomb/coulomb-social:f306dbfa2604", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "registry_digest_independently_verified": true, + "source_revision_label_verified": true, + "build_context": "Clean git archive restricted to Dockerfile, dockerignore, lock, project, manage.py, app and entrypoint.", + "build_inputs": [ + { + "path": "Dockerfile", + "sha256": "d0e197a18db8980eb3e51cca842d693e10ab0b4208627bba28691d10719d4a00" + }, + { + "path": ".dockerignore", + "sha256": "901c8b95d95549d0e78c1c3d6d0538bf0252c8a77e9409129e0cadfa55c7c56c" + }, + { + "path": "pyproject.toml", + "sha256": "5758c56fd1be89b973861ccc5ecbadb495e742ee13ebd7e19a221a0f119c54ee" + }, + { + "path": "uv.lock", + "sha256": "e8ebb6fc4b5abd1ac80f1c9012f1983642d0528a8a213cfcc394261606fad887" + }, + { + "path": "scripts/docker-entrypoint.sh", + "sha256": "70100256ca41ae6afe77078c810a8b6012472f0a741919f403f32088f98ab463" + } + ], + "runtime": { + "previous": { + "python": "3.12.16", + "distributions": { + "h11": "0.16.0", + "httpcore": "1.0.9", + "psycopg": "3.3.4", + "joserfc": "1.7.4", + "anyio": "4.14.2", + "cryptography": "50.0.0", + "idna": "3.18", + "certifi": "2026.7.22", + "packaging": "26.3", + "python-decouple": "3.8", + "psycopg-binary": "3.3.4", + "sqlparse": "0.5.5", + "whitenoise": "6.12.0", + "cffi": "2.1.1", + "markdown": "3.10.3", + "webencodings": "0.5.1", + "pycparser": "3.0", + "bleach": "6.4.0", + "authlib": "1.7.2", + "django": "6.1", + "typing_extensions": "4.16.0", + "gunicorn": "26.0.0", + "asgiref": "3.12.1", + "httpx": "0.28.1", + "dj-database-url": "3.1.2" + } + }, + "candidate": { + "python": "3.12.16", + "distributions": { + "h11": "0.16.0", + "httpcore": "1.0.9", + "psycopg": "3.3.4", + "joserfc": "1.7.4", + "anyio": "4.14.2", + "cryptography": "50.0.0", + "idna": "3.18", + "certifi": "2026.7.22", + "packaging": "26.3", + "python-decouple": "3.8", + "psycopg-binary": "3.3.4", + "sqlparse": "0.5.5", + "whitenoise": "6.12.0", + "cffi": "2.1.1", + "markdown": "3.10.3", + "webencodings": "0.5.1", + "pycparser": "3.0", + "bleach": "6.4.0", + "authlib": "1.7.2", + "django": "6.1", + "typing_extensions": "4.16.0", + "gunicorn": "26.0.0", + "asgiref": "3.12.1", + "httpx": "0.28.1", + "dj-database-url": "3.1.2" + } + }, + "runtime_packages_equal": true, + "python_versions_equal": true, + "django_check": { + "returncode": 0, + "output": "System check identified no issues (0 silenced)." + }, + "migration_drift": { + "returncode": 0, + "output": "No changes detected" + } + }, + "source_delta_from_revision_19": [ + "coulomb_social/apps/spaces/pageops.py" + ], + "new_database_migrations": false, + "production_deployed": false, + "live_release_pins_modified": false, + "source_full_gate_tests": 546, + "packaged_lifecycle_tests": 29, + "previous_image_expected_lifecycle_failures": 4, + "owner_release_task": "RAPPS-WP-0015-T07", + "remaining": [ + "Exact current-target release packet, paired rollback evidence and candidate/previous-image restored-store checks.", + "Attended image release and independent native postflight.", + "Productive lifecycle verdict; retention policy already accepted under c3a191f2-ce06-483f-82d7-ef8c54ea0d5e." + ] +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-candidate-provenance-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-candidate-provenance-2026-10-31.json new file mode 100644 index 0000000..60aefca --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-candidate-provenance-2026-10-31.json @@ -0,0 +1,20 @@ +{ + "schema": "coulomb.packaged-source-provenance.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "network": "none", + "read_only_root": true, + "entrypoint_bypassed": true, + "database_contact": false, + "production_changes": false, + "comparisons": [ + { + "commit": "f306dbfa2604e4815523667740be2925c333b84e", + "packaged_file_count": 80, + "source_file_count": 80, + "different_files": 0, + "all_packaged_source_bytes_match": true + } + ], + "actual_packaged_manifest_sha256": "b5c8a16b4b3d30cc9d8a7a9f3ed61bd8cb0e182589aceb5e4127fff27a8ae482", + "dependency_or_build_reproducibility_proved": false +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-candidate-recovery-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-candidate-recovery-2026-10-31.json new file mode 100644 index 0000000..72b4365 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-candidate-recovery-2026-10-31.json @@ -0,0 +1,65 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-recovery.v1", + "status": "packaged_recovered_client_export_and_views_verified", + "source": "f306dbfa2604e4815523667740be2925c333b84e", + "image_id": "sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "restore_receipt_sha256": "966d6640e42e2f4940c66d1cd5c8a802cd950de0305572f3c8b720ee3d81ae2b", + "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": "e364a61570e0083e729a3f314eb707c57347200438bd98170c77b8f7fcc2b337", + "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, + "all_tables_unchanged_after_both_view_checks": true, + "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": true, + "retained_point_captured_at": "2026-10-31T07:13:52Z", + "driver_sha256": "92b66225e5f9bdf03d0a0999b613370c42341a2ceef468f1743fb4301884cb25" +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-candidate-tests-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-candidate-tests-2026-10-31.json new file mode 100644 index 0000000..a0748e4 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-candidate-tests-2026-10-31.json @@ -0,0 +1,20 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-tests.v1", + "source": "f306dbfa2604e4815523667740be2925c333b84e", + "image_id": "sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "tests": 29, + "failures": 0, + "expected_baseline_regressions": false, + "failed_nodes": [], + "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": "f2968ef1048ec9af68a187b967d558db0f6ee76e765a8106fb7fab284953511f", + "driver_sha256": "85b65114997c7e7c5323cb77a0043e495ec132506eef07dd394c6a6361380d36", + "test_source_sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-release-row-drift-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-release-row-drift-2026-10-31.json new file mode 100644 index 0000000..8c8aeec --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-release-row-drift-2026-10-31.json @@ -0,0 +1,61 @@ +{ + "changed_columns": { + "django_session": [ + "session_key", + "session_data", + "expire_date" + ], + "members_member": [ + "updated_at", + "last_login_at" + ], + "members_user": [ + "last_login" + ] + }, + "file_changes": [], + "retained_fingerprint": { + "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 + }, + "schema": "coulomb.content-release-row-drift.v1", + "changed_tables": [ + { + "table": "django_session", + "retained_rows": 42, + "current_rows": 43, + "retained_sha256": "c958e33ec782f9f2fd1b3cceba4a64da0038fde51050f25905ae42de91ca6dde", + "current_sha256": "594e246ef6297d9be9b6e8d9eda076a5b2ab36afb9263606b719f64f51293ba6" + }, + { + "table": "members_member", + "retained_rows": 1, + "current_rows": 1, + "retained_sha256": "4d465d2b9d1e6311e505f861f2ed4b67763a545a965f9a7d0c586a3d07e3e9a3", + "current_sha256": "ab82b3f2c5e49f8634c598c9642894747f4b6fcff0f5230ac27f41939feb8640" + }, + { + "table": "members_user", + "retained_rows": 1, + "current_rows": 1, + "retained_sha256": "81ccb3a40191e910a68c101896df518a588c00231728934c4c900577f8ae4f12", + "current_sha256": "0fc7a71b4b80a71fbedce527bbd2cae3858cf6a062e5714fc231be13ff987256" + } + ], + "all_other_business_tables_equal": true, + "migration_history_equal": true, + "content_equal": true, + "retained_point": "2026-10-31T07:13:52Z", + "current_business_rows": 93, + "current_business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "private_row_values_printed": false, + "production_changes": false +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-release-target-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-release-target-2026-10-31.json new file mode 100644 index 0000000..a3e6e05 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-release-target-2026-10-31.json @@ -0,0 +1,62 @@ +{ + "schema": "coulomb.content-release-target-observation.v1", + "observed_at": "2026-10-31T12:07:24.322343+00:00", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_revision": 19, + "baseline_helm_manifest_sha256": "93cb277abd1aea0657be482e458dcfb268fe0bfbd45d90aa1faedcc7f5bce499", + "baseline_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "candidate_spec_sha256": "c65cb691bab7054ae25de58cdb75933d383621ec4e049d2daa1b1bf52dce0bba", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "sole_ready_pvc_writer": true, + "existing_unmanaged_metadata_preserved": true, + "candidate_image_only_server_preview_verified": true, + "data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0, + "actual_owner_pages_rendered": 40, + "unsaved_outsider_page_denials": 40, + "production_identity_or_grant_created": false + }, + "matches_verified_revision19_data": false, + "installed_helper_sha256": { + "/opt/coulomb-social-backup/paired-backup.py": "43e119acb6a4933b4e144c349a091e074e1a91ab61dbe15a65c8264c1d47661a", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d" + }, + "timers": { + "coulomb-social-paired-backup.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "UnitFileState": "" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "UnitFileState": "" + } + }, + "prerequisites": { + "custody": { + "exists": false + }, + "installation": { + "exists": false + }, + "route": { + "exists": false + } + }, + "production_data_modified": false, + "release_executed": false +} diff --git a/release/content-lifecycle-evidence/content-lifecycle-validation-2026-10-31.json b/release/content-lifecycle-evidence/content-lifecycle-validation-2026-10-31.json new file mode 100644 index 0000000..f426e59 --- /dev/null +++ b/release/content-lifecycle-evidence/content-lifecycle-validation-2026-10-31.json @@ -0,0 +1,58 @@ +{ + "schema": "coulomb.content-lifecycle-validation.v1", + "recorded_at": "2026-10-31T11:54:24.215512+00:00", + "status": "source_fix_verified", + "workplan_task": "CSOC-WP-0013-T05", + "bug": "A process exit during attachment creation left bytes absent from the copy/move cleanup journal; the orphan blocked retry.", + "regression_observed_before_fix": { + "baseline": "f70708839cd5ca43310f6891c8d94aaff915139c", + "operations": [ + "copy_page", + "transfer_page" + ], + "interruption": "partial first attachment flushed, immediate os._exit(86)", + "result": "both failed because the partial destination survived recovery" + }, + "fix": [ + "Refuse existing attachment entries before recording cleanup authority.", + "Persist intended new attachment name before exclusive creation under the content writer lock.", + "Flush recovery and attachment directory entries before destination publication." + ], + "focused_cases": { + "total": 29, + "abrupt_child_process_exit": 14, + "existing_destination_collision": 8, + "prior_retention_and_recovery": 7 + }, + "full_gate": { + "command": "make check", + "tests": 546, + "failures": 0, + "errors": 0, + "skipped": 0, + "warnings": 69, + "junit_sha256": "dd3d0433c8109f00f6dddd119cfdf8281915f94da2da1f5e6009c31cfe76c65b", + "log_sha256": "eb59a394c95c5307c17069874640d1655ef61c5aa7bb426ef6d21db4929f748e" + }, + "files": [ + { + "path": "coulomb_social/apps/spaces/pageops.py", + "sha256": "055ef25b5efc75798cf17ffa68739de4d4099d2cab4d041e7c7eacdfaf43803e" + }, + { + "path": "tests/test_content_recovery.py", + "sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" + } + ], + "policy": "User accepted retain-all default and separately reviewed manual cutoff; keep live page and all history of deleted pages.", + "live_purge_authorized": false, + "live_purge_executed": false, + "production_deployed": false, + "productive_acceptance": false, + "limits": [ + "Child process termination on synthetic local files is proved; physical power-loss durability is not experimentally proved.", + "Cooperating application/import writers must use the content writer lock.", + "Release delivery and productive copy/move review remain open." + ], + "policy_decision_id": "c3a191f2-ce06-483f-82d7-ef8c54ea0d5e" +} diff --git a/release/content-lifecycle-snapshot.py b/release/content-lifecycle-snapshot.py new file mode 100644 index 0000000..de340de --- /dev/null +++ b/release/content-lifecycle-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:' + 'd3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec') +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/content-lifecycle-values.yaml b/release/content-lifecycle-values.yaml new file mode 100644 index 0000000..3c0e2f6 --- /dev/null +++ b/release/content-lifecycle-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: "f306dbfa2604" + digest: "sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec" + +# 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/release/coulomb-content-lifecycle-f306dbf.json b/release/coulomb-content-lifecycle-f306dbf.json new file mode 100644 index 0000000..9937810 --- /dev/null +++ b/release/coulomb-content-lifecycle-f306dbf.json @@ -0,0 +1,69 @@ +{ + "schema": "coulomb.reviewed-image-release.v1", + "proposal_id": "CSOC-CONTENT-LIFECYCLE-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": 19, + "baseline_helm_manifest_sha256": "93cb277abd1aea0657be482e458dcfb268fe0bfbd45d90aa1faedcc7f5bce499", + "candidate_render_sha256": "1606725273089b83689850ad1c25d1834faa15e00c8cf8aa24e1053273af71bc", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "candidate_spec_sha256": "c65cb691bab7054ae25de58cdb75933d383621ec4e049d2daa1b1bf52dce0bba", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "product_source": "f306dbfa2604e4815523667740be2925c333b84e", + "expected_data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "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": "d161c742d1a6a182460a57d0a1719591ab58fc94006f55169ad3d74442a93630", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "/opt/coulomb-social-backup/mirror-paired-backup.py": "9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c", + "/opt/coulomb-social-backup/check-paired-backup-health.py": "2d40ec6e4c59ea7fe1d46065d197b9a87ca6811f023cc6f3e20e4184a3490566", + "/opt/coulomb-social-backup/backup_capacity.py": "cb699a79e67b129239b5aa5d6f3b0d4ffaa9773a8b073227ad17fdecb5b9d893", + "/opt/railiance-platform/scripts/application_recovery_identities.py": "20f56255ed6eeec4aafe33f142bc3ee03f623d4957a7b0cabb741d340f15f79d", + "/opt/railiance-platform/scripts/application_recovery_mirror.py": "d48ab95e89b9eeae36269d853ebe4c44c22e1f57d4e14224f03d0b9aeeb34178", + "/opt/railiance-platform/scripts/application_recovery_resources.py": "ab70ec341e738272730eb0daf76840d0bca807caeb3c4e4c2357f162fbed4498", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d", + "/opt/coulomb-social-backup/backup_alerts.py": "07522437d4276b92f0a44149adc6c9e81bb18dbe59ce0f1f9e58a48347700d0e", + "/opt/coulomb-social-backup/notify-backup-alert.py": "0bd53bc5ad22d8bcb0326fb18a17737744b06416fe785dad077308e45f051d5a", + "/opt/coulomb-social-backup/observe-backup-alerts.py": "17caf4fa65283242f618b039ff1d018801c9890ea2a9178912cbdd4b50ee2664" + }, + "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 revision19/image522bcd7f 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 independently recovered 2026-10-31T07:13:52Z point; candidate and previous image preserve all 13 source tables and 86 files. Live observation now has93 business rows versus92 in that point; exact fresh paired backup/readback is required before Helm. Independent restore of the new point remains consumer follow-through. Read-only drift comparison confines differences to sessions and user/member login timestamps; all content and migration history match the retained point.", + "rehearsal_evidence_sha256": { + "content-lifecycle-candidate-build-2026-10-31.json": "83ad2ec41c85f8bb5087a250fdb91126ed92f12351cdc72b353c6f0e1a26452b", + "content-lifecycle-candidate-provenance-2026-10-31.json": "35ed909e0a2fa5c57cea1fcc4fbebb877eae559ca9dc753c3da00f84806d7b73", + "content-lifecycle-candidate-tests-2026-10-31.json": "27a75cfee6bbda41f377b73d6a6c7ecde0c54806c113f1be5223dd2f2cf39b3b", + "content-lifecycle-baseline-tests-2026-10-31.json": "de14e4e10337990ccec1c483f7d943bdc7e11b03aa813aff47d00a9b38a07b65", + "content-lifecycle-candidate-recovery-2026-10-31.json": "8d15172e5e330d01f153336e67f6bffb4363d571530c31ea05db872ca10807a9", + "content-lifecycle-validation-2026-10-31.json": "cbb093af470588ca6e8eb3ec5bc1ccee0be537ab37a46625b6ac267c17343d99", + "content-lifecycle-release-target-2026-10-31.json": "e9b50be3c8284c5a9ae96d75b800cbc4090580fa811c2ecd098d73ce8cbee74e", + "content-lifecycle-release-row-drift-2026-10-31.json": "b10f65bf55e900199ae6037767f32e8901aaccb6f7f3a86461f13dd02ec8efce" + }, + "new_resources_committed": 0, + "autopilot_or_eur_budget_claimed": false, + "values_file": "release/content-lifecycle-values.yaml", + "snapshot_file": "release/content-lifecycle-snapshot.py", + "evidence_directory": "release/content-lifecycle-evidence", + "prerequisite_installation_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "requires_inactive_backup_units": true +} diff --git a/tests/test_coulomb_image_release.py b/tests/test_coulomb_image_release.py index 703326e..d4e5fae 100644 --- a/tests/test_coulomb_image_release.py +++ b/tests/test_coulomb_image_release.py @@ -236,3 +236,129 @@ def test_known_candidate_helper_can_be_rolled_back(tmp_path, monkeypatch): # A completed inverse can be checked again without changing it. instance.restore_pin(b"old pin") assert pin.read_bytes() == b"old pin" + + +def test_missing_installation_refuses_before_execution_journal(tmp_path, monkeypatch): + root, _ = capsule(tmp_path) + plan = json.loads((root / 'manifest.json').read_text()) + plan['prerequisite_installation_digest'] = 'sha256:' + 'a' * 64 + (root / 'manifest.json').write_text(json.dumps(plan)) + digest = 'sha256:' + release.sha(release.canonical(plan)) + monkeypatch.setattr(release.os, 'geteuid', lambda: 0) + monkeypatch.setattr(release, 'JOURNAL_ROOT', tmp_path / 'journal') + monkeypatch.setattr(release, 'INSTALL_RECEIPT', tmp_path / 'missing.json') + with pytest.raises(release.Refused, match='installation_required_before_image'): + release.Release(root, digest) + assert not (tmp_path / 'journal').exists() + + +@pytest.fixture +def installed(monkeypatch): + instance = object.__new__(release.Release) + digest = 'sha256:' + 'a' * 64 + instance.plan = {'prerequisite_installation_digest': digest} + receipt = {'schema': 'coulomb.backup-source-supervised-installation.v1', + 'proposal_digest': digest, 'status': 'sources_installed_units_inactive_verified', + 'verified_targets': 19, 'timers_activated': False} + path = SimpleNamespace(parents=[], lstat=lambda: SimpleNamespace( + st_mode=stat.S_IFREG | 0o600, st_uid=0, st_nlink=1), + read_bytes=lambda: json.dumps(receipt).encode()) + monkeypatch.setattr(release, 'INSTALL_RECEIPT', path) + state = {'active': 'inactive', 'enabled': 'disabled', 'jobs': b''} + def command(arguments): + if arguments[1] == 'list-jobs': + return SimpleNamespace(stdout=state['jobs']) + return SimpleNamespace(stdout=(f"LoadState=loaded\nActiveState={state['active']}\n" + f"UnitFileState={state['enabled']}\n").encode()) + instance.command = Mock(side_effect=command) + return instance, receipt, state + + +def test_completed_installer_and_inactive_units_admit_release_prerequisite(installed): + instance, _, _ = installed + instance.verify_installation_prerequisite() + assert instance.command.call_count == 5 + + +@pytest.mark.parametrize('field,value', [ + ('status', 'installation_requires_inspection'), ('verified_targets', 18), + ('proposal_digest', 'sha256:' + 'b' * 64), ('timers_activated', True), +]) +def test_wrong_or_incomplete_installation_never_checks_or_changes_units(installed, field, value): + instance, receipt, _ = installed + receipt[field] = value + with pytest.raises(release.Refused, match='verified_inactive_backup_installation'): + instance.verify_installation_prerequisite() + instance.command.assert_not_called() + + +@pytest.mark.parametrize('field,value', [('active', 'active'), ('enabled', 'enabled'), + ('jobs', b'17 coulomb-social-paired-backup.service start waiting\n'), + ('jobs', b'unrecognized job format\n')]) +def test_active_enabled_or_queued_backup_refuses_image_release(installed, field, value): + instance, _, state = installed + state[field] = value + with pytest.raises(release.Refused): + instance.verify_installation_prerequisite() + assert all(c.args[0][1] in {'show', 'list-jobs'} for c in instance.command.call_args_list) + + +def test_an_explicit_release_plan_seals_its_own_artifacts(tmp_path, monkeypatch): + monkeypatch.setattr(launcher, 'ROOT', tmp_path) + paths = ['tools/coulomb-image-release.py', 'tools/coulomb_image_release_node.py', + 'tools/coulomb_image_probe.py', 'release/next-values.yaml', + 'release/next-snapshot.py', 'charts/coulomb-social/Chart.yaml', + 'release/next-evidence/proof.json'] + for name in paths: + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(('synthetic ' + name).encode()) + plan = {'values_file': paths[3], 'snapshot_file': paths[4], + 'evidence_directory': 'release/next-evidence', + 'rehearsal_evidence_sha256': {'proof.json': release.sha((tmp_path / paths[-1]).read_bytes())}} + path = tmp_path / 'release/next.json' + path.write_text(json.dumps(plan)) + prepared, digest, content = launcher.prepare(path) + assert set(content) == set(paths) | {'manifest.json'} + assert digest == 'sha256:' + release.sha(release.canonical(prepared)) + plan['values_file'] = '../outside.yaml' + path.write_text(json.dumps(plan)) + with pytest.raises(RuntimeError, match='release_paths'): + launcher.prepare(path) + + +@pytest.mark.parametrize('name', ['release/missing.yaml', '../outside.yaml', '/outside.yaml']) +def test_node_refuses_unsealed_artifact_reference_before_journal(tmp_path, monkeypatch, name): + root, _ = capsule(tmp_path) + plan = json.loads((root / 'manifest.json').read_text()) + plan['values_file'] = name + (root / 'manifest.json').write_text(json.dumps(plan)) + monkeypatch.setattr(release.os, 'geteuid', lambda: 0) + monkeypatch.setattr(release, 'JOURNAL_ROOT', tmp_path / 'journal') + with pytest.raises(release.Refused, match='reviewed_release_artifact'): + release.Release(root, 'sha256:' + release.sha(release.canonical(plan))) + assert not (tmp_path / 'journal').exists() + + +def test_initializer_refusal_is_structured_and_never_calls_execute(monkeypatch, capsys): + monkeypatch.setattr(release, 'Release', Mock(side_effect=release.Refused( + 'inactive_backup_installation_required_before_image_release'))) + monkeypatch.setattr(release.argparse.ArgumentParser, 'parse_args', lambda _: SimpleNamespace( + capsule=Path('/synthetic'), expect_digest='sha256:' + 'a' * 64, + execute=False, preflight=True)) + with pytest.raises(SystemExit): + release.main() + result = json.loads(capsys.readouterr().out) + assert result['execution_started'] is False + assert result['failure_reason'] == 'inactive_backup_installation_required_before_image_release' + + +def test_custom_snapshot_artifact_is_used_for_recognized_pin_rollback(tmp_path, monkeypatch): + path = tmp_path / 'snapshot.py' + path.write_bytes(b'new reviewed pin') + monkeypatch.setattr(release, 'SNAPSHOT', path) + instance = object.__new__(release.Release) + instance.plan = {'snapshot_file': 'release/next-snapshot.py', + 'files': {'release/next-snapshot.py': release.sha(path.read_bytes())}} + instance.restore_pin(b'previous pin') + assert path.read_bytes() == b'previous pin' diff --git a/tools/coulomb-image-release.py b/tools/coulomb-image-release.py index 1b2efc7..308adb0 100644 --- a/tools/coulomb-image-release.py +++ b/tools/coulomb-image-release.py @@ -26,20 +26,32 @@ def canonical(value): return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() -def prepare(): - plan = json.loads(PLAN.read_text()) +def prepare(plan_path=None): + plan_path = plan_path or PLAN + if plan_path.is_symlink() or not plan_path.resolve().is_relative_to(ROOT.resolve()): + raise RuntimeError("reviewed_plan_inside_owner_repository_required") + plan = json.loads(plan_path.read_text()) + values = plan.get("values_file", "release/identity-export-values.yaml") + snapshot = plan.get("snapshot_file", "release/identity-export-snapshot.py") + evidence = plan.get("evidence_directory", "release/identity-export-evidence") + for name in (values, snapshot, evidence): + if (not isinstance(name, str) or Path(name).is_absolute() + or ".." in Path(name).parts or not name.startswith("release/")): + raise RuntimeError("reviewed_release_paths_required") + if not all((ROOT / name).is_file() for name in (values, snapshot)): + raise RuntimeError("reviewed_release_artifacts_missing") 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", + values, + snapshot, ) ] paths += sorted((ROOT / "charts/coulomb-social").rglob("*")) - paths += sorted((ROOT / "release/identity-export-evidence").glob("*.json")) + paths += sorted((ROOT / 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") @@ -48,7 +60,7 @@ def prepare(): 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: + if plan["files"].get(evidence + "/" + name) != expected: raise RuntimeError("reviewed_candidate_evidence_changed") digest = "sha256:" + hashlib.sha256(canonical(plan)).hexdigest() content["manifest.json"] = canonical(plan) @@ -61,10 +73,11 @@ def main(): parser.add_argument("--execute", action="store_true") parser.add_argument("--expect-digest") parser.add_argument("--output", type=Path) + parser.add_argument("--plan", type=Path, default=PLAN) args = parser.parse_args() if args.preflight and args.execute: raise SystemExit("choose read-only preflight or attended execution") - plan, digest, content = prepare() + plan, digest, content = prepare(args.plan) 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: diff --git a/tools/coulomb_image_release_node.py b/tools/coulomb_image_release_node.py index 10381ea..df49bc9 100644 --- a/tools/coulomb_image_release_node.py +++ b/tools/coulomb_image_release_node.py @@ -22,6 +22,9 @@ from pathlib import Path JOURNAL_ROOT = Path("/var/lib/coulomb-social-backup/releases") SNAPSHOT = Path("/opt/coulomb-social-backup/snapshot-content-and-db.py") +INSTALL_RECEIPT = Path( + "/var/lib/coulomb-social-backup-installations/20261031bacc0001/receipt.json" +) def canonical(value): @@ -88,6 +91,12 @@ class Release: raise Refused("attended_founder_root_execution_required") self.env = dict(os.environ, KUBECONFIG="/etc/rancher/k3s/k3s.yaml") self.read_only = read_only + for field in ("values_file", "snapshot_file"): + if field in self.plan: + name = self.plan[field] + if (not isinstance(name, str) or not name.startswith("release/") + or ".." in Path(name).parts or name not in frozen): + raise Refused("reviewed_release_artifact_required") self.receipt = { "schema": "coulomb.guarded-image-release.v1", "proposal_digest": expected, @@ -97,6 +106,7 @@ class Release: "timers_activated": False, } self.journal = JOURNAL_ROOT / expected.removeprefix("sha256:") + self.verify_installation_prerequisite() if read_only: return if self.journal.exists(): @@ -215,7 +225,52 @@ class Release: or actual.get("migration_0003_applied") is not True): raise Refused("already_migrated_baseline_required") + def artifact_name(self, field): + return self.plan.get(field, { + "values_file": "release/identity-export-values.yaml", + "snapshot_file": "release/identity-export-snapshot.py", + }[field]) + + def verify_installation_prerequisite(self): + expected = self.plan.get("prerequisite_installation_digest") + if expected is None: + return + if not re.fullmatch(r"sha256:[a-f0-9]{64}", expected): + raise Refused("reviewed_installation_digest_required") + try: + info = INSTALL_RECEIPT.lstat() + except FileNotFoundError: + raise Refused("inactive_backup_installation_required_before_image_release") from None + if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 + or info.st_mode & 0o077 or info.st_nlink != 1 + or any(p.is_symlink() for p in INSTALL_RECEIPT.parents)): + raise Refused("private_installation_receipt_required") + receipt = json.loads(INSTALL_RECEIPT.read_bytes()) + if (receipt.get("schema") != "coulomb.backup-source-supervised-installation.v1" + or receipt.get("proposal_digest") != expected + or receipt.get("status") != "sources_installed_units_inactive_verified" + or receipt.get("verified_targets") != 19 + or receipt.get("timers_activated") is not False): + raise Refused("verified_inactive_backup_installation_required") + for name in ("paired-backup", "backup-freshness"): + for suffix in ("service", "timer"): + unit = f"coulomb-social-{name}.{suffix}" + raw = self.command(["systemctl", "show", unit, + "--property=LoadState,ActiveState,UnitFileState"]).stdout + state = dict(line.split("=", 1) for line in raw.decode().splitlines()) + if (state.get("LoadState") != "loaded" or state.get("ActiveState") != "inactive" + or state.get("UnitFileState") not in {"disabled", "static"}): + raise Refused("backup_units_must_remain_inactive_for_image_release") + jobs = self.command(["systemctl", "list-jobs", "--no-legend", "--no-pager", + "--plain"]).stdout.decode().splitlines() + for line in jobs: + fields = line.split() + if (len(fields) != 4 or not fields[0].isdecimal() + or fields[1].startswith("coulomb-social-")): + raise Refused("queued_backup_job_requires_review") + def preflight(self): + self.verify_installation_prerequisite() self.verify_installed_helpers() if ( self.kdoc("get", "namespace", "kube-system")["metadata"]["uid"] @@ -301,7 +356,7 @@ class Release: "coulomb-social", str(self.root / "charts/coulomb-social"), "-f", - str(self.root / "release/identity-export-values.yaml"), + str(self.root / self.artifact_name("values_file")), "-n", "coulomb-social", ] @@ -319,7 +374,7 @@ class Release: "coulomb-social", str(self.root / "charts/coulomb-social"), "-f", - str(self.root / "release/identity-export-values.yaml"), + str(self.root / self.artifact_name("values_file")), "-n", "coulomb-social", "--dry-run=server", @@ -386,7 +441,7 @@ class Release: path = SNAPSHOT if ( sha(path.read_bytes()) - == self.plan["files"]["release/identity-export-snapshot.py"] + == self.plan["files"][self.artifact_name("snapshot_file")] ): self.replace_pin(path, old) elif sha(path.read_bytes()) != sha(old): @@ -424,7 +479,7 @@ class Release: "coulomb-social", str(self.root / "charts/coulomb-social"), "-f", - str(self.root / "release/identity-export-values.yaml"), + str(self.root / self.artifact_name("values_file")), "-n", "coulomb-social", "--atomic", @@ -461,7 +516,7 @@ class Release: ): raise Refused("snapshot_pin_drifted") self.replace_pin( - path, (self.root / "release/identity-export-snapshot.py").read_bytes() + path, (self.root / self.artifact_name("snapshot_file")).read_bytes() ) self.command( [ @@ -526,8 +581,9 @@ def main(): 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) + release = None try: + release = Release(args.capsule, args.expect_digest, read_only=args.preflight) if args.preflight: release.preflight() print( @@ -542,11 +598,15 @@ def main(): 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)) + receipt = release.receipt if release is not None else { + "proposal_digest": args.expect_digest, "execution_started": False, + } + receipt.update(status="release_refused_or_failed", failure_type=type(error).__name__) + if isinstance(error, Refused): + receipt["failure_reason"] = str(error) + if release is not None: + release.save() + print(json.dumps(receipt)) raise SystemExit(1) from None diff --git a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md index c8e5e2b..472fc6f 100644 --- a/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md +++ b/workplans/RAPPS-WP-0015-coulomb-personal-content-release.md @@ -428,9 +428,13 @@ T04/T09. No cluster operation occurred; future release proposals bind the new so ```task id: RAPPS-WP-0015-T07 -status: todo +status: wait priority: high needs_human: false +wait_kind: external +blocking_reason: "Prepared image packet c9c32316 waits for CSOC-WP-0012-T08 inactive installation8f442bf0; native preflight confirms its receipt is absent." +depends_on: +- CSOC-WP-0012-T08 state_hub_task_id: "8f448934-bdfe-539f-8cab-a739ec752491" ``` @@ -453,3 +457,28 @@ its durable return. Current verified revision19 remains authoritative until that return; no image deploy is implied by publication. Productive review stays with CSOC-WP-0013-T05/T03. Retention policy c3a191f2 is accepted; no cutoff or purge was authorized. Do not replay the completed identity image release. + +2026-10-31 preparation return: exact20-member proposal +`sha256:c9c3231620721ef3ea9b2bf22fc6c62fe3ed8efb91386cf7ec96acc6959dae08` +is prepared with a separate plan/values/snapshot/evidence set. The existing launcher +accepts an explicit reviewed plan; historical frozen capsules stay intact.40 +controller tests, Ruff and Helm lint pass. Native image-only server preview preserves +the complete current specification and unmanaged metadata. Candidate/previous-image +checks on the independent07:13 recovery point render40owner pages, deny40outsiders, +verify85export files, and preserve all13tables/86files. Current target remains +revision19;93business rows reflect a new session plus login timestamp changes. +Content and migration history match the retained point; private values are not exposed. + +Ordering is material: the pending installer8f442bf0 pins snapshotdcc8dd2a. Deploying +this image first would stale that reviewed packet. The new controller therefore +requires that exact completed installation receipt and currently inactive/disabled +backup units before creating its release journal, then pins all12 installed Python +helpers. Native full preflight correctly refuses the absent installation receipt; +it is not a pass and no image execution occurred. Historical custodyfa2763d1, +route4d848106 and installer8f442bf0 packets are unchanged. After their returns, +re-run the full image preflight before admitting execution; finish native service +proof/schedule activation afterward. Consumer review commands and exact scope: +coulomb-social/docs/usecases/sharing-portability-review.md, lifecycle release section. +Evidence: content-lifecycle-release-{target,row-drift,preflight,proposal,preparation} +JSON files in the consumer docs/release directory. T07 is now external wait for T08; +product policy is settled, and productive lifecycle acceptance remains open.