Prepare guarded content release after inactive backup installation
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
parent
75343cfec1
commit
9c74884641
16 changed files with 972 additions and 20 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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."
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
175
release/content-lifecycle-snapshot.py
Normal file
175
release/content-lifecycle-snapshot.py
Normal file
|
|
@ -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)
|
||||
50
release/content-lifecycle-values.yaml
Normal file
50
release/content-lifecycle-values.yaml
Normal file
|
|
@ -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
|
||||
69
release/coulomb-content-lifecycle-f306dbf.json
Normal file
69
release/coulomb-content-lifecycle-f306dbf.json
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue