From 8dd4b40252bf235711837e43e88140382aecd88b Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Wed, 7 Oct 2026 01:56:22 +0000 Subject: [PATCH] Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8 --- .gitleaks.toml | 15 + PUBLIC_SOURCE_EXPORT.md | 2 +- PUBLIC_SOURCE_MANIFEST.json | 220 ++- backend/app/api/routes_deployments.py | 88 ++ backend/app/connectors/nginx_proxy_manager.py | 404 +++++- backend/app/connectors/portainer.py | 195 +++ backend/app/core/config.py | 4 +- backend/app/db/init_db.py | 1 + backend/app/mcp_server.py | 118 +- backend/app/schemas/forge_route_stage.py | 48 + backend/app/services/action_execution.py | 59 +- backend/app/services/action_registry.py | 4 + .../controller_artifact_helper_spec.py | 59 +- .../services/controller_artifact_promoter.py | 167 ++- backend/app/services/deployment_broker.py | 31 +- .../services/deployment_target_registry.py | 103 +- backend/app/services/enrollment_plane.py | 208 ++- backend/app/services/forge_route_stage.py | 835 ++++++++++++ .../gitea_project_auto_registration.py | 20 +- .../mcp_hub_deployment_reconciliation.py | 32 +- backend/app/services/policy_engine.py | 23 + backend/tests/test_authentik_connector.py | 2 +- .../test_controller_artifact_bootstrap.py | 482 ++++++- ...rtifact_bootstrap_failed_child_recovery.py | 314 +++++ ...ntroller_artifact_promotion_diagnostics.py | 354 +++++ .../test_controller_hub_release_volume.py | 675 +++++++++- .../test_controller_runtime_capabilities.py | 140 ++ backend/tests/test_deploy_broker.py | 224 +++- backend/tests/test_enrollment_plane.py | 353 ++++- ...isting_registered_target_reconciliation.py | 305 +++++ backend/tests/test_forge_route_stage.py | 761 +++++++++++ backend/tests/test_forge_route_stage_api.py | 246 ++++ .../tests/test_forge_route_stage_service.py | 904 +++++++++++++ .../test_gitea_project_auto_registration.py | 94 +- backend/tests/test_hub_candidate_import.py | 375 +++++- backend/tests/test_hub_component_transport.py | 177 +++ .../tests/test_immutable_service_promotion.py | 667 ++++++++++ .../tests/test_managed_validation_parallel.py | 45 + .../test_mcp_hub_candidate_preparation.py | 267 +++- .../test_mcp_hub_deployment_reconciliation.py | 93 +- .../tests/test_mcp_hub_governed_topology.py | 48 +- .../tests/test_mcp_hub_release_candidates.py | 82 +- .../test_nginx_proxy_manager_connector.py | 2 +- backend/tests/test_npm_secure_transport.py | 503 +++++++ .../tests/test_registered_build_context.py | 366 +++++ .../tests/test_registered_build_metadata.py | 174 +++ .../tests/test_registered_legacy_adoption.py | 389 ++++++ backend/tests/test_registered_legacy_http.py | 154 +++ .../tests/test_registered_legacy_runtime.py | 1184 +++++++++++++++++ .../tests/test_registered_legacy_transport.py | 59 + src/api/liveClient.ts | 15 +- src/types/appops.ts | 12 + 52 files changed, 11814 insertions(+), 288 deletions(-) create mode 100644 backend/app/schemas/forge_route_stage.py create mode 100644 backend/app/services/forge_route_stage.py create mode 100644 backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py create mode 100644 backend/tests/test_controller_artifact_promotion_diagnostics.py create mode 100644 backend/tests/test_controller_runtime_capabilities.py create mode 100644 backend/tests/test_existing_registered_target_reconciliation.py create mode 100644 backend/tests/test_forge_route_stage.py create mode 100644 backend/tests/test_forge_route_stage_api.py create mode 100644 backend/tests/test_forge_route_stage_service.py create mode 100644 backend/tests/test_hub_component_transport.py create mode 100644 backend/tests/test_immutable_service_promotion.py create mode 100644 backend/tests/test_npm_secure_transport.py create mode 100644 backend/tests/test_registered_build_context.py create mode 100644 backend/tests/test_registered_build_metadata.py create mode 100644 backend/tests/test_registered_legacy_adoption.py create mode 100644 backend/tests/test_registered_legacy_http.py create mode 100644 backend/tests/test_registered_legacy_runtime.py create mode 100644 backend/tests/test_registered_legacy_transport.py diff --git a/.gitleaks.toml b/.gitleaks.toml index 56b8d56..6d08e21 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -43,3 +43,18 @@ regexTarget = "line" regexes = ['^\s*\{"plan": profile, "idempotencyKey": "forge-mcp-apply-0001"\},\r?$'] condition = "AND" targetRules = ["generic-api-key"] +[[allowlists]] +description = "Exact synthetic Forge route-stage API idempotency values" +paths = ['backend[/\\]+tests[/\\]+test_forge_route_stage_api\.py$'] +regexTarget = "secret" +regexes = ['^forge-route-api-0001$', '^forge-route-api-0002$', '^forge-route-api-duplicate-01$', '^forge-route-api-duplicate-02$'] +condition = "AND" +targetRules = ["generic-api-key"] + +[[allowlists]] +description = "Exact synthetic Forge route-stage service idempotency values" +paths = ['backend[/\\]+tests[/\\]+test_forge_route_stage_service\.py$'] +regexTarget = "secret" +regexes = ['^route-stage-key-0001$', '^route-stage-key-0002$', '^route-stage-key-0003$', '^route-stage-key-0004$', '^route-stage-key-0005$', '^route-stage-key-0006$'] +condition = "AND" +targetRules = ["generic-api-key"] diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index d338919..4d4a399 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `a480f280f26f4aa48c61394fa841d2233067c208`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `ba66029223865607deddc234e283c11127324eb8`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 434f457..1c9faf9 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "a480f280f26f4aa48c61394fa841d2233067c208", + "sourceRevision": "ba66029223865607deddc234e283c11127324eb8", "files": [ { "path": ".dockerignore", @@ -24,8 +24,8 @@ }, { "path": ".gitleaks.toml", - "sha256": "e631ebe31e0b4fc034379618fc0b16c2a239015b087eab4d3a57a81f74c384de", - "bytes": 2107 + "sha256": "33080cc7e9f83d2a2bbc54f754127a68243c6b0ad35c1655f8aa0cb1490ef55c", + "bytes": 2876 }, { "path": "CHANGELOG.md", @@ -124,8 +124,8 @@ }, { "path": "backend/app/api/routes_deployments.py", - "sha256": "ff14b4e40e6e14d0d7f9f8fd74faf5001010a3ba5c0b279253c61f17199f350f", - "bytes": 8604 + "sha256": "df617522c2636628a220800cfa970233de85df858bef574c9ea7a4c37f13274c", + "bytes": 12171 }, { "path": "backend/app/api/routes_enrollments.py", @@ -304,8 +304,8 @@ }, { "path": "backend/app/connectors/nginx_proxy_manager.py", - "sha256": "36bbd6b853e4969817d31ca74b7a170cd7c4ca197676f46554f1e5e499e96212", - "bytes": 32804 + "sha256": "42a47f48bdb4016b452f80a2a5efeff980d958bc67453a52f24256c6a030ca1e", + "bytes": 45381 }, { "path": "backend/app/connectors/plex.py", @@ -314,8 +314,8 @@ }, { "path": "backend/app/connectors/portainer.py", - "sha256": "dcbfb3926929b10e08190944acf0217a71d9696f0c8e91b24fe832bc71034f8f", - "bytes": 19655 + "sha256": "375141e4e4d01c70e645075395a6d6bf0d2240c178f13b01fcb62f2440cc3d79", + "bytes": 27899 }, { "path": "backend/app/connectors/prowlarr.py", @@ -354,8 +354,8 @@ }, { "path": "backend/app/core/config.py", - "sha256": "9a839001e681edb19926a45e859c5103b398cc97922d20042fa739cfcbf84126", - "bytes": 27341 + "sha256": "56ea46b05cad49d1d7f19b6af06ce80036723c0f75317a33af42a50b9521a289", + "bytes": 27330 }, { "path": "backend/app/core/network_security.py", @@ -384,8 +384,8 @@ }, { "path": "backend/app/db/init_db.py", - "sha256": "450abf3cdf8c8897938cffeef193d69f7fc0fb1d4cd7a713597db1ca5bb811a7", - "bytes": 70586 + "sha256": "6ddf18c82d29f979b97aa2dde6e2959526a4ce27a0ce805fae979966b6371cc9", + "bytes": 70832 }, { "path": "backend/app/db/models.py", @@ -404,8 +404,8 @@ }, { "path": "backend/app/mcp_server.py", - "sha256": "aa1246a23ba269a0e52cb5f95d0cd3bde8e4e5aa3943872a0a4059dbaa441f18", - "bytes": 38109 + "sha256": "9d9e6b881cc293799bde6dc244bfb04442ad3c30f3757bad99d824f12fb30d0a", + "bytes": 44777 }, { "path": "backend/app/runner_statistics_mcp.py", @@ -452,6 +452,11 @@ "sha256": "8d55f7a8263191f07813d1689eb0697dabec99a1bbd5aff5a704e66e6bf86c47", "bytes": 6045 }, + { + "path": "backend/app/schemas/forge_route_stage.py", + "sha256": "a84f15e2d8421757e633895fff5ea0aac075a9eca2c5f7bceec322dfbee682ab", + "bytes": 1538 + }, { "path": "backend/app/schemas/forge_runtime_readiness.py", "sha256": "4571fd2c7b624fe4f12c219a1f494b03a98ec1745a9512197cf7c49287ce47b5", @@ -469,13 +474,13 @@ }, { "path": "backend/app/services/action_execution.py", - "sha256": "3ba0a33cd6947dee1d7961e63e6e9a4b247eda3ea8253537b9f879a57cf063ac", - "bytes": 99269 + "sha256": "4c4099328c3931ef3811f7d9043d7ad69c2bd27c1e7b6b1bdc139fbb5ba3c29a", + "bytes": 100242 }, { "path": "backend/app/services/action_registry.py", - "sha256": "a5c575ee262ca721906a65156383ae2d1c2b08c52b2a5f9145c841894496c31f", - "bytes": 21316 + "sha256": "3e0f83e62f6d8dfdf0bb4bc6c09b58d612bb6ff2a4b5fcf619dba5aa8d009fd1", + "bytes": 21446 }, { "path": "backend/app/services/ai_remediation_planner.py", @@ -519,13 +524,13 @@ }, { "path": "backend/app/services/controller_artifact_helper_spec.py", - "sha256": "1da47a3f061ef0b89158c9f646bb7018ce88b81c0687d8b27abe65b74df67fea", - "bytes": 4948 + "sha256": "dc456d0005b7181098d99028fa8ea110fb53b037aac1c031c98a9101a61183bd", + "bytes": 6029 }, { "path": "backend/app/services/controller_artifact_promoter.py", - "sha256": "bc06a28941c111b5a0f6eed96e407259626c0b9fc1ba5ecb8953befc83b66304", - "bytes": 13602 + "sha256": "613dbe8dd8b05553f947e376ccb82958836bb55cdfe683e28d5a6b981720855b", + "bytes": 18179 }, { "path": "backend/app/services/controller_bootstrap_import.py", @@ -534,8 +539,8 @@ }, { "path": "backend/app/services/deployment_broker.py", - "sha256": "e23b2a0669c846cf37c44766700d29e5d2dfa22b6b6d0d546fe5c94bb86f4c6b", - "bytes": 37749 + "sha256": "693e0a46920a7a35d129f89b31d31a2fba026a704b899eb89e34023b0a0c52c1", + "bytes": 39197 }, { "path": "backend/app/services/deployment_control_plane.py", @@ -544,8 +549,8 @@ }, { "path": "backend/app/services/deployment_target_registry.py", - "sha256": "2266540e1820997df473e9d65922b07171becbf0c300f527e939718f8317b4a6", - "bytes": 23483 + "sha256": "9ada1454f5e63e7557db06f811c28bc6501d29fd02c0474233dda8e12295ef4a", + "bytes": 29274 }, { "path": "backend/app/services/discovery_scheduler.py", @@ -554,8 +559,8 @@ }, { "path": "backend/app/services/enrollment_plane.py", - "sha256": "079163e8634996f572e573d657eae1ec1a72fa7ac739e86e47510a60f248b3f4", - "bytes": 57487 + "sha256": "d04fd7a4399c698705719e9748130e450702f541c3e69b003480b4e5621a256c", + "bytes": 63491 }, { "path": "backend/app/services/enrollment_profiles.py", @@ -587,6 +592,11 @@ "sha256": "25d32b9f1acfdf2a2f4032b242f8843ba0e8a48ab1c2c0cf4f048181906c0af1", "bytes": 31091 }, + { + "path": "backend/app/services/forge_route_stage.py", + "sha256": "8c748366fa972b864c7b9790d3327da5699fa008136334c7f73d1aad1231fc9a", + "bytes": 35587 + }, { "path": "backend/app/services/forge_secret_delivery.py", "sha256": "a0f16bd7745f30d54781c10808ff076b45a9db0c432eeaa36ea6947bf654c17e", @@ -594,8 +604,8 @@ }, { "path": "backend/app/services/gitea_project_auto_registration.py", - "sha256": "0da4469d2b75d1d6c74704b9c402348ce246f3325c1726bd50dcf13b378e060b", - "bytes": 25143 + "sha256": "6055747c802d398cca6d80b7a73ec0dbd49108d1a095b4845218af819e8c3e10", + "bytes": 25796 }, { "path": "backend/app/services/healthcheck_service.py", @@ -639,8 +649,8 @@ }, { "path": "backend/app/services/mcp_hub_deployment_reconciliation.py", - "sha256": "2367cf52e39091bd941b19e12aeccf72f65842cdf5ec74e5eb17bd0f636f48bf", - "bytes": 44919 + "sha256": "caf531c4b11e48984318281bed28160be8d8b8af277600b5471ada622c314966", + "bytes": 45506 }, { "path": "backend/app/services/mcp_operations.py", @@ -689,8 +699,8 @@ }, { "path": "backend/app/services/policy_engine.py", - "sha256": "d00f8d1be911d49519791a5c517d261a666a9ff04bb037954501a7f79a1b4dcf", - "bytes": 12023 + "sha256": "23ede379cf2078de600e0cecd0d54ec5f921e331ca3996d64b5a068cc7ecda1c", + "bytes": 13119 }, { "path": "backend/app/services/portfolio_deployment_enrollment.py", @@ -879,8 +889,8 @@ }, { "path": "backend/tests/test_authentik_connector.py", - "sha256": "1ea6eaea54f0ce6f5a1e92a6c338edb880eff4c6bc43f09a32ea7a0e4a537870", - "bytes": 28252 + "sha256": "ac1056893dfa791e074f9962fb9752cd65944af16c41437a8d1824c1b144dd85", + "bytes": 28251 }, { "path": "backend/tests/test_ci_job_docker.py", @@ -894,14 +904,24 @@ }, { "path": "backend/tests/test_controller_artifact_bootstrap.py", - "sha256": "da2ff9e9ec07c15f2fed076d0d7c76017496252c5584a8383d6c3132bdbaf25f", - "bytes": 62434 + "sha256": "3389f7cc8225ff2f03b46aa79a3a70dae7a6198d1ab52fd0879638bcf4c92f83", + "bytes": 80172 + }, + { + "path": "backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py", + "sha256": "394bbd68f8ac513a9b214317fefcc7cb5a547ca3baced8453a15ef9f9df1e7ce", + "bytes": 10598 }, { "path": "backend/tests/test_controller_artifact_promoter.py", "sha256": "8eee2bdaf60422824f605637b1820c431fdacea305e1b68b3618fffa0dc000ee", "bytes": 7570 }, + { + "path": "backend/tests/test_controller_artifact_promotion_diagnostics.py", + "sha256": "07341103e2ba10a3ab9a2dc4d83a08b4dd7f7d06e0fe082b9a36230077884371", + "bytes": 12438 + }, { "path": "backend/tests/test_controller_bootstrap_import_host.py", "sha256": "58c153b8e3d1b016006b0bed5c98f9aa069d58e670d65edac368833ae38dd6f1", @@ -919,14 +939,19 @@ }, { "path": "backend/tests/test_controller_hub_release_volume.py", - "sha256": "c6f8304f8f3fbbf42900c07a5b01fb40edeb3f6657849b55f9bf4fd59cdb79f6", - "bytes": 84733 + "sha256": "c0ea7e949d1e1c258eccfed815750f885009337dd56db7be5a9cb598e558fa24", + "bytes": 115271 }, { "path": "backend/tests/test_controller_root_procedure_contract.py", "sha256": "964b3e21828a9d815d05ffb0e51acf6e9e45efe56af9fa3b70f39200d3a5c336", "bytes": 5125 }, + { + "path": "backend/tests/test_controller_runtime_capabilities.py", + "sha256": "2f45e82c224f858ab9a4b0e0a9ddcc8a6c96a16942eee5595f5a2d8f553415bd", + "bytes": 6701 + }, { "path": "backend/tests/test_controller_state_reconciliation.py", "sha256": "d1d90f6d4fe35e434797dccd2c0ec10357fc347900e6e148c0f85face9d12598", @@ -939,8 +964,8 @@ }, { "path": "backend/tests/test_deploy_broker.py", - "sha256": "7e32918a89dcb3d02cc75674fcd9313b60cf96ed3651a5016bbfb3fc751c3ec3", - "bytes": 406062 + "sha256": "8d92404b6719159d1ce2e5b7df73f925ba37ce2fff6f7bbaf9bcb780c46a789c", + "bytes": 415499 }, { "path": "backend/tests/test_deployment_auto_rollback.py", @@ -974,8 +999,13 @@ }, { "path": "backend/tests/test_enrollment_plane.py", - "sha256": "5979d8922fa03c0954b0c928b263278c1123c48a2f5dda701d543ff4eb1ccffa", - "bytes": 26847 + "sha256": "d886402e194b2e76d1eac67761e9eb801af525580261d16cca79ed6273bf01d9", + "bytes": 41160 + }, + { + "path": "backend/tests/test_existing_registered_target_reconciliation.py", + "sha256": "0c493282fecf31c5d2606cce0dac869658dbb0e032cce00b7fa96e82c065f3cc", + "bytes": 14764 }, { "path": "backend/tests/test_exposure_preferences.py", @@ -1037,6 +1067,21 @@ "sha256": "dc4e9d176a9b4c33fd24e27b2cb83919040babe412d021109a22de41c54edb8b", "bytes": 6424 }, + { + "path": "backend/tests/test_forge_route_stage.py", + "sha256": "edc7e35c9c2303598f97de65fdea22fae5ea9fe6fe6256e4ee6f446f43a661d5", + "bytes": 26605 + }, + { + "path": "backend/tests/test_forge_route_stage_api.py", + "sha256": "697af2546eb1b067d47e0f17375f7ebe53865e3e0512fdbd8f55af0f35a243fa", + "bytes": 7807 + }, + { + "path": "backend/tests/test_forge_route_stage_service.py", + "sha256": "418ea7c31f57ecdf0540c9181de93d3ffd9468e880ed2feb4424f78e98b4bdcb", + "bytes": 30801 + }, { "path": "backend/tests/test_forge_runtime_readiness.py", "sha256": "0f70e327225eceae2ad161e565c9f601c191e3b086a989c7383ac7327434926d", @@ -1069,8 +1114,8 @@ }, { "path": "backend/tests/test_gitea_project_auto_registration.py", - "sha256": "d34b53f338e53e0bb2d8a6fccac88384b625e63d041766b4da3f1665f6020d56", - "bytes": 13248 + "sha256": "507b62e5c0a43355c03a8bd196c28d118e612ac5920ae3b139a96c0e6f960744", + "bytes": 16425 }, { "path": "backend/tests/test_gpu_metrics.py", @@ -1094,19 +1139,29 @@ }, { "path": "backend/tests/test_hub_candidate_import.py", - "sha256": "178a07b0dc9489545723c8ff5ecbfd7060aa0c1491ba6c1ced50edd7f3481e68", - "bytes": 30015 + "sha256": "7ffc9b4f10d589f092c16bd23cf2cafabac1a6a26fb1a3fb19dbbd5ccf2f04bc", + "bytes": 42194 }, { "path": "backend/tests/test_hub_candidate_transport.py", "sha256": "da0b302e0bf45d87f0b3339b2c1392aec22e650f45468ab236fc37f448d9f9ea", "bytes": 9353 }, + { + "path": "backend/tests/test_hub_component_transport.py", + "sha256": "7fbce0be7f6cc12b68f5b05eb3f7c9eb00478207f1c49ab36ef88915ee53eb82", + "bytes": 9756 + }, { "path": "backend/tests/test_hub_permission_preparation.py", "sha256": "3ba8efe338a21b100e4bc20e4fbffe043eb57c411a4143c00332fdb368db5011", "bytes": 28482 }, + { + "path": "backend/tests/test_immutable_service_promotion.py", + "sha256": "8220a58307a0dd3a2ab8c885111b00ae57658bbc246e9e826fb46fcfddf76ef2", + "bytes": 22197 + }, { "path": "backend/tests/test_inventory_discovery.py", "sha256": "db7a7fbe23ef01f2a4e2e58ca72514889b9740a04374851b4a414118908bc594", @@ -1139,23 +1194,23 @@ }, { "path": "backend/tests/test_managed_validation_parallel.py", - "sha256": "d704cd4ba299ce2fc51d6f4a8a1b26ecdc2dea0e734e63dc6a65e1ba4c3ed37f", - "bytes": 692 + "sha256": "5654211d4ab1ab2749c269c148497f9448b0257c70779ebac063ef55780e3c11", + "bytes": 2355 }, { "path": "backend/tests/test_mcp_hub_candidate_preparation.py", - "sha256": "ed0d60e8f0e4371622405dc200e80ecfa3c4574ad2a9439373032f997a1af414", - "bytes": 27390 + "sha256": "b5614f0b4828adb464d567f9a113116d47d02ea0b625475f37609ce9ac716ad9", + "bytes": 37965 }, { "path": "backend/tests/test_mcp_hub_deployment_reconciliation.py", - "sha256": "476d02f8c5261e55788b16f8ee490728291b2379395890bbedc3a9be56ae5f58", - "bytes": 28475 + "sha256": "5459baadd13180b69df7106615a977647c2afa63b0aa5df93af54484a09bb6b6", + "bytes": 31726 }, { "path": "backend/tests/test_mcp_hub_governed_topology.py", - "sha256": "9e306fbce479f15610a87ad666023de7deae588dc01f69be6024140d5df2158d", - "bytes": 125396 + "sha256": "59872024ae0a2e552aafe459d42d90098e022877648c3d77d33ce102c595d234", + "bytes": 127375 }, { "path": "backend/tests/test_mcp_hub_legacy_registry_compatibility.py", @@ -1169,8 +1224,8 @@ }, { "path": "backend/tests/test_mcp_hub_release_candidates.py", - "sha256": "f2add61e18641e4a5075948dc0b3b0e36b2b4bfe389d608078a7c5cf506fad54", - "bytes": 34190 + "sha256": "43075075c0427cb76923f0fe98f0aec4ae864e0224dca45b53f470f4e649c4bd", + "bytes": 37316 }, { "path": "backend/tests/test_mcp_hub_release_store.py", @@ -1214,14 +1269,19 @@ }, { "path": "backend/tests/test_nginx_proxy_manager_connector.py", - "sha256": "65e5b2be9dc013e872b5600d7374eb541950871c56d8c246e87a503e3bb25b31", - "bytes": 33978 + "sha256": "4e4fafd2369bb6f86629f4e86f1a87d5e1110a99b1cc3b8c25631be84e88dc0d", + "bytes": 33977 }, { "path": "backend/tests/test_notifications.py", "sha256": "52cb0ed1e82101cbd3439502f6aec131c1a6a85ba9b53cc47f2f4aad72900800", "bytes": 46292 }, + { + "path": "backend/tests/test_npm_secure_transport.py", + "sha256": "18661491628f4a6e5be7dc7b5d1e92054d6fd513219fec2967a14f6d17ff2b1d", + "bytes": 16114 + }, { "path": "backend/tests/test_oidc_login.py", "sha256": "ae40a20e395155eda7d3de61c08c74db51095632be168bce7c453fb320a86e29", @@ -1302,6 +1362,36 @@ "sha256": "da2a5b9421ced902cf75c7922f15431be0e5db06ef2e350ed5ab2d2a33f8749b", "bytes": 12332 }, + { + "path": "backend/tests/test_registered_build_context.py", + "sha256": "d51be0e2fec9fafae9616e624dbd87090d15d03dc6d8b8c55e910942ef352133", + "bytes": 13474 + }, + { + "path": "backend/tests/test_registered_build_metadata.py", + "sha256": "696fe58dd7df47924d96b870f1467eb2f1ebdb4b640465902c01844784e6b96a", + "bytes": 10303 + }, + { + "path": "backend/tests/test_registered_legacy_adoption.py", + "sha256": "d67ca2425008cc84ac9c84122a43ef774aff311719f32040283bb1692fe12502", + "bytes": 17614 + }, + { + "path": "backend/tests/test_registered_legacy_http.py", + "sha256": "db2ac891871416d02b40634193aeb4491c006b655bd2dd0f01315ac1d69c5ab6", + "bytes": 8419 + }, + { + "path": "backend/tests/test_registered_legacy_runtime.py", + "sha256": "dd8eb3e3293b94f7fb2ce716cf2a55ba561f9c6e91f624f900be6fe141c84b1a", + "bytes": 38314 + }, + { + "path": "backend/tests/test_registered_legacy_transport.py", + "sha256": "cf4315ea6856adafc41b01a5c010b0e7f763db26687e1a61413f503af6bc1db0", + "bytes": 2549 + }, { "path": "backend/tests/test_repopulse_adoption_cli.py", "sha256": "31799892efbd70e7cbbe686844972ed624a0cd4ee6c8d65416ccfd916ee18fd5", @@ -1659,8 +1749,8 @@ }, { "path": "src/api/liveClient.ts", - "sha256": "12536177fde01cc870bc6aa24d31d922db9341ef2842c4ef244fe0cee4de98c3", - "bytes": 62080 + "sha256": "f2cf782e3bbdcbe737dee523d47ba6f0d655048a8253a117525872486f8f1553", + "bytes": 62680 }, { "path": "src/api/userSession.test.ts", @@ -3054,8 +3144,8 @@ }, { "path": "src/types/appops.ts", - "sha256": "5ca792943c3d25d8c6180911c46f1ed8e3ea5e3c1993d7542d01690e81030036", - "bytes": 73737 + "sha256": "cffa840d76327eadf663ee7c896a366ad56d6cf860a0e0e9687ff7b3e14e2f3b", + "bytes": 74110 }, { "path": "src/utils/toast.ts", diff --git a/backend/app/api/routes_deployments.py b/backend/app/api/routes_deployments.py index 9253401..881fceb 100644 --- a/backend/app/api/routes_deployments.py +++ b/backend/app/api/routes_deployments.py @@ -6,11 +6,13 @@ for command execution, Compose upload, arbitrary host paths or raw secrets. from __future__ import annotations +import re from typing import Annotated, Literal from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, Field from sqlmodel import Session +from starlette.concurrency import run_in_threadpool from ..core.security import require_operator_request from ..db.session import get_session @@ -22,6 +24,7 @@ from ..schemas.forge_secret_delivery import ( ForgeSecretDeliveryResultV1, ) from ..schemas.forge_runtime_readiness import ForgeRuntimeReadinessResultV1 +from ..schemas.forge_route_stage import ForgeRouteStageResultV1 from ..services.deployment_broker import DeploymentBrokerClient from ..services.deployment_control_plane import ( DeploymentControlError, @@ -56,6 +59,27 @@ class WorkMeshPromotionPreflightRequest(BaseModel): DeploymentSession = Annotated[Session, Depends(get_session)] DeploymentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")] DeploymentLimit = Annotated[int, Query(ge=1, le=250)] +_FORGE_ROUTE_JOB_ID = re.compile(r"^forge-install-[0-9a-f]{64}$") +_FORGE_ROUTE_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") + + +async def _require_empty_forge_route_request(request: Request) -> None: + lengths = request.headers.getlist("content-length") + if ( + request.query_params + or len(lengths) > 1 + or (lengths and lengths[0] != "0") + or request.headers.getlist("transfer-encoding") + ): + raise HTTPException(status_code=400, detail="invalid_forge_route_request") + try: + async for chunk in request.stream(): + if chunk: + raise HTTPException(status_code=400, detail="invalid_forge_route_request") + except HTTPException: + raise + except Exception: # noqa: BLE001 - transport detail is not part of this contract. + raise HTTPException(status_code=400, detail="invalid_forge_route_request") from None def _idempotency_header(value: str | None) -> str: @@ -232,6 +256,70 @@ def get_forge_first_install_readiness( return ForgeBootstrapApplyService().current_readiness(session, job_id) +@router.post( + "/forge-bootstrap/first-install/{job_id}/route", + response_model=ForgeRouteStageResultV1, + description=( + "Stages only the fixed Forge NPM host in a disabled state after fresh runtime and network checks. " + "The request body must be empty; this operation does not issue certificates or activate routing." + ), +) +async def stage_forge_first_install_route( + job_id: str, + request: Request, + actor: Annotated[str, Depends(_require_deployment_operator)], + session: DeploymentSession, +): + if _FORGE_ROUTE_JOB_ID.fullmatch(job_id) is None: + raise HTTPException(status_code=404, detail="Forge first-install request not found.") + await _require_empty_forge_route_request(request) + keys = request.headers.getlist("idempotency-key") + if len(keys) != 1 or _FORGE_ROUTE_IDEMPOTENCY_KEY.fullmatch(keys[0]) is None: + raise HTTPException(status_code=400, detail="invalid_forge_route_idempotency_key") + from ..services.forge_route_stage import stage_forge_route + + try: + return await run_in_threadpool( + stage_forge_route, + session, + job_id=job_id, + idempotency_key=keys[0], + actor_identity=actor, + ) + except Exception: # noqa: BLE001 - no controller/NPM details may reach the caller. + raise HTTPException(status_code=503, detail="forge_route_operation_unavailable") from None + + +@router.get( + "/forge-bootstrap/first-install/{job_id}/route", + response_model=ForgeRouteStageResultV1, + description=( + "Reads and reconciles the fixed disabled Forge host using NPM GET requests only. " + "It never retries a host creation." + ), +) +async def get_forge_first_install_route( + job_id: str, + request: Request, + actor_identity: Annotated[str, Depends(_require_deployment_operator)], + session: DeploymentSession, +): + if _FORGE_ROUTE_JOB_ID.fullmatch(job_id) is None: + raise HTTPException(status_code=404, detail="Forge first-install request not found.") + await _require_empty_forge_route_request(request) + from ..services.forge_route_stage import reconcile_forge_route + + try: + return await run_in_threadpool( + reconcile_forge_route, + session, + job_id=job_id, + actor_identity=actor_identity, + ) + except Exception: # noqa: BLE001 - no controller/NPM details may reach the caller. + raise HTTPException(status_code=503, detail="forge_route_reconcile_unavailable") from None + + @router.get("") def list_deployments(session: DeploymentSession, limit: DeploymentLimit = 100): return {"items": DeploymentControlPlane().list_jobs(session, limit=limit)} diff --git a/backend/app/connectors/nginx_proxy_manager.py b/backend/app/connectors/nginx_proxy_manager.py index fc53bb7..35d91fe 100644 --- a/backend/app/connectors/nginx_proxy_manager.py +++ b/backend/app/connectors/nginx_proxy_manager.py @@ -1,10 +1,12 @@ from __future__ import annotations +import hashlib +import ipaddress import re from dataclasses import dataclass from datetime import UTC, datetime from typing import Any -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse import httpx @@ -23,12 +25,172 @@ class _NpmConfig: timeout_seconds: float -def _as_bool(value: Any, default: bool = False) -> bool: - if isinstance(value, bool): - return value - if value is None: - return default - return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} +class _NpmForgeRoutePermissionDenied(RuntimeError): + """Fixed marker for a failed route-only permission preflight.""" + + def __init__(self) -> None: + super().__init__("npm_forge_route_permission_denied") + + +class _ForgeRouteNpmSession: + """Route-only transport pinned to one verified NPM endpoint/config snapshot.""" + + def __init__(self, connector: NginxProxyManagerConnector, config: _NpmConfig): + self._connector = connector + self._config_snapshot = config + self._token: str | None = None + self.verified_tls = config.verify_tls is True + self.endpoint_sha256 = _npm_endpoint_sha256(config.url) + + def _assert_current(self) -> None: + try: + current = self._connector._config() + except Exception: # noqa: BLE001 - never surface config diagnostics. + current = None + if current != self._config_snapshot: + raise RuntimeError("npm_configuration_changed") from None + + def _login(self) -> str: + if self._token: + return self._token + self._assert_current() + config = self._config_snapshot + try: + with httpx.Client( + timeout=config.timeout_seconds, + verify=True, + follow_redirects=False, + ) as client: + response = client.post( + f"{config.url}/api/tokens", + json={"identity": config.identity, "secret": config.secret}, + headers={"Accept": "application/json"}, + follow_redirects=False, + ) + except (httpx.RequestError, ValueError): + raise RuntimeError("npm_request_failed") from None + if type(response.status_code) is not int or response.status_code != 200: + raise RuntimeError("npm_login_failed") from None + try: + payload = response.json() + except (ValueError, TypeError): + raise RuntimeError("npm_invalid_response") from None + token = payload.get("token") if isinstance(payload, dict) else None + if not isinstance(token, str) or not token: + raise RuntimeError("npm_login_failed") from None + self._assert_current() + self._token = token + return token + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + if (method, path) not in { + ("GET", "/users/me"), + ("GET", "/nginx/proxy-hosts"), + ("POST", "/nginx/proxy-hosts"), + }: + raise RuntimeError("npm_forge_route_request_invalid") from None + if path == "/users/me": + if kwargs != {"params": {"expand": "permissions"}}: + raise RuntimeError("npm_forge_route_request_invalid") from None + elif method == "GET": + if kwargs: + raise RuntimeError("npm_forge_route_request_invalid") from None + elif set(kwargs) != {"json"} or type(kwargs.get("json")) is not dict: + raise RuntimeError("npm_forge_route_request_invalid") from None + self._assert_current() + token = self._login() + self._assert_current() + config = self._config_snapshot + try: + with httpx.Client( + timeout=config.timeout_seconds, + verify=True, + follow_redirects=False, + ) as client: + response = client.request( + method, + f"{config.url}/api{path}", + headers={"Accept": "application/json", "Authorization": f"Bearer {token}"}, + follow_redirects=False, + **kwargs, + ) + except (httpx.RequestError, ValueError): + raise RuntimeError("npm_request_failed") from None + if type(response.status_code) is not int or not ( + response.status_code == 200 if method == "GET" else response.status_code in {200, 201} + ): + raise RuntimeError("npm_api_error") from None + try: + return response.json() + except (ValueError, TypeError): + raise RuntimeError("npm_invalid_response") from None + + def _has_forge_route_permissions(self) -> bool: + try: + user = self._request("GET", "/users/me", params={"expand": "permissions"}) + except RuntimeError as exc: + if str(exc) == "npm_configuration_changed": + raise + return False + except Exception: # noqa: BLE001 - permission response details stay private. + return False + if type(user) is not dict: + return False + user_id = user.get("id") + disabled = user.get("is_disabled") + permissions = user.get("permissions") + return bool( + type(user_id) is int + and user_id > 0 + and type(disabled) is bool + and disabled is False + and type(permissions) is dict + and type(permissions.get("visibility")) is str + and permissions["visibility"] == "all" + and type(permissions.get("proxy_hosts")) is str + and permissions["proxy_hosts"] == "manage" + ) + + def _require_forge_route_permissions(self) -> None: + if not self._has_forge_route_permissions(): + raise _NpmForgeRoutePermissionDenied() from None + + def list_forge_route_hosts(self) -> list[dict[str, Any]]: + self._require_forge_route_permissions() + payload = self._request("GET", "/nginx/proxy-hosts") + return self._connector._parse_forge_route_hosts(payload) + + def create_forge_disabled_proxy_host(self, ownership_id: str) -> dict[str, Any]: + if not re.fullmatch(r"froute-[0-9a-f]{32}", ownership_id): + raise RuntimeError("npm_forge_route_ownership_invalid") from None + self._require_forge_route_permissions() + payload = self._request( + "POST", + "/nginx/proxy-hosts", + json={ + "domain_names": ["forge.example.com"], + "forward_scheme": "http", + "forward_host": "itworx-forge", + "forward_port": 8080, + "access_list_id": 0, + "certificate_id": 0, + "enabled": False, + "ssl_forced": False, + "http2_support": False, + "hsts_enabled": False, + "hsts_subdomains": False, + "caching_enabled": False, + "block_exploits": True, + "allow_websocket_upgrade": False, + "trust_forwarded_proto": False, + "locations": [], + "meta": {"appops_forge_route_id": ownership_id}, + "advanced_config": "", + }, + ) + if not isinstance(payload, dict): + raise RuntimeError("npm_invalid_response") from None + return payload def _as_float(value: Any, default: float) -> float: @@ -54,15 +216,95 @@ def _as_list(value: Any) -> list[Any]: def _safe_url(value: Any = None) -> str: - raw = str(value or settings.apis.get('NPM_URL') or '').strip().rstrip('/') - if not raw: + raw = value if value is not None else settings.apis.get('NPM_URL') + try: + url = _validated_npm_url(raw) + _verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS')) + return url + except RuntimeError: return '' - parsed = urlparse(raw) - if not parsed.username and not parsed.password: - return raw - host = parsed.hostname or '' - port = f':{parsed.port}' if parsed.port else '' - return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/') + + +def _validated_npm_url(value: Any) -> str: + if not isinstance(value, str) or not value or len(value) > 2048: + raise RuntimeError('npm_url_invalid') + if value != value.strip() or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value): + raise RuntimeError('npm_url_invalid') + if '\\' in value or '?' in value or '#' in value: + raise RuntimeError('npm_url_invalid') + try: + parsed = urlparse(value) + port = parsed.port + except ValueError: + raise RuntimeError('npm_url_invalid') from None + if parsed.scheme.lower() == 'http': + raise RuntimeError('npm_https_required') + if parsed.scheme.lower() != 'https' or not parsed.netloc: + raise RuntimeError('npm_url_invalid') + if '@' in parsed.netloc or '%' in parsed.netloc or parsed.username is not None or parsed.password is not None: + raise RuntimeError('npm_url_invalid') + if parsed.netloc.endswith(':'): + raise RuntimeError('npm_url_invalid') + + hostname = parsed.hostname + if not hostname or hostname.endswith('.') or (port is not None and not 1 <= port <= 65535): + raise RuntimeError('npm_url_invalid') + try: + host_ascii = hostname.encode('idna').decode('ascii').lower() + except UnicodeError: + raise RuntimeError('npm_url_invalid') from None + if ':' in host_ascii: + try: + ipaddress.IPv6Address(host_ascii) + except ValueError: + raise RuntimeError('npm_url_invalid') from None + authority_host = f'[{host_ascii}]' + else: + if len(host_ascii) > 253 or any( + not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label) + for label in host_ascii.split('.') + ): + raise RuntimeError('npm_url_invalid') + authority_host = host_ascii + authority = authority_host + (f':{port}' if port is not None else '') + + path = parsed.path + if path and ( + not path.startswith('/') + or not re.fullmatch(r"/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*(?:/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*)*", path) + or re.search(r'%(?![0-9A-Fa-f]{2})', path) + ): + raise RuntimeError('npm_url_invalid') + decoded_path = unquote(path) + if '\\' in decoded_path or any(segment in {'.', '..'} for segment in decoded_path.split('/')): + raise RuntimeError('npm_url_invalid') + return f'https://{authority}{path}'.rstrip('/') + + +def _npm_endpoint_sha256(value: str) -> str: + """Hash the normalized HTTPS API base without persisting its URL.""" + normalized = _validated_npm_url(value) + return hashlib.sha256(normalized.encode("ascii")).hexdigest() + + +def _verified_tls_setting(value: Any) -> bool: + if value is None: + return True + if isinstance(value, bool): + enabled = value + elif isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'1', 'true', 'yes', 'on'}: + enabled = True + elif normalized in {'0', 'false', 'no', 'off'}: + enabled = False + else: + raise RuntimeError('npm_config_invalid') + else: + raise RuntimeError('npm_config_invalid') # noqa: TRY004 - use a fixed public connector reason + if not enabled: + raise RuntimeError('npm_tls_verification_required') + return True def _parse_date(value: Any) -> datetime | None: @@ -111,57 +353,107 @@ class NginxProxyManagerConnector(BaseConnector): super().__init__() self._runtime_token: str | None = None self._read_cache: dict[str, dict[str, Any]] = {} + self._credential_binding: tuple[str, str, str, bool] | None = None + + def _clear_runtime_context(self) -> None: + self._runtime_token = None + self._credential_binding = None + self._read_cache.clear() def _memo(self, key: str, factory: Any) -> dict[str, Any]: + try: + config = self._config() + except RuntimeError as exc: + return self._error_payload(exc, items=[], total=0) + if config is None: + return factory() if key not in self._read_cache: self._read_cache[key] = factory() return self._read_cache[key] def is_configured(self) -> bool: - return bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET')) + configured = bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET')) + if not configured: + self._clear_runtime_context() + return configured def _config(self) -> _NpmConfig | None: if not self.is_configured(): + self._clear_runtime_context() + return None + try: + url = _validated_npm_url(settings.apis.get('NPM_URL')) + verify_tls = _verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS')) + identity = settings.apis.get('NPM_IDENTITY') + secret = settings.apis.get('NPM_SECRET') + if not isinstance(identity, str) or not identity.strip() or not isinstance(secret, str) or not secret: + raise RuntimeError('missing_config') + config = _NpmConfig( + url=url, + identity=identity, + secret=secret, + verify_tls=verify_tls, + timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0), + ) + except RuntimeError: + self._clear_runtime_context() + raise + + binding = (config.url, config.identity, config.secret, config.verify_tls) + if binding != self._credential_binding: + self._runtime_token = None + self._read_cache.clear() + self._credential_binding = binding + return config + + def forge_route_session(self) -> _ForgeRouteNpmSession | None: + """Capture one strict endpoint/config for the entire Forge route operation.""" + try: + config = self._config() + if config is None or config.verify_tls is not True: + return None + if _validated_npm_url(config.url) != config.url: + return None + return _ForgeRouteNpmSession(self, config) + except Exception: # noqa: BLE001 - never expose connector settings or diagnostics. return None - return _NpmConfig( - url=str(settings.apis['NPM_URL']).rstrip('/'), - identity=str(settings.apis['NPM_IDENTITY']), - secret=str(settings.apis['NPM_SECRET']), - verify_tls=_as_bool(settings.apis.get('NPM_VERIFY_TLS'), False), - timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0), - ) def _login(self) -> str: - if self._runtime_token: - return self._runtime_token config = self._config() if not config: raise RuntimeError('missing_config') + if self._runtime_token: + return self._runtime_token try: self.clear_error() - with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + with httpx.Client( + timeout=config.timeout_seconds, + verify=config.verify_tls, + follow_redirects=False, + ) as client: response = client.post( f'{config.url}/api/tokens', json={'identity': config.identity, 'secret': config.secret}, headers={'Accept': 'application/json'}, + follow_redirects=False, ) - except httpx.RequestError as exc: - message = redact_message(str(exc)) - self.set_error(message) - raise RuntimeError(message) + except httpx.RequestError: + self.set_error('npm_request_failed') + raise RuntimeError('npm_request_failed') from None + if 300 <= response.status_code < 400: + self.set_error('npm_redirect_denied') + raise RuntimeError('npm_redirect_denied') if response.status_code in {400, 401, 403}: self.set_error('auth_failed') raise RuntimeError('auth_failed') if response.status_code >= 400: - message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}') - self.set_error(message) - raise RuntimeError(message) + self.set_error('npm_api_error') + raise RuntimeError('npm_api_error') try: payload = response.json() - except ValueError as exc: - message = redact_message(str(exc)) - self.set_error(message) - raise RuntimeError(message) + except ValueError: + self.set_error('npm_invalid_response') + raise RuntimeError('npm_invalid_response') from None token = payload.get('token') if isinstance(payload, dict) else None if not token: self.set_error('auth_failed') @@ -174,31 +466,39 @@ class NginxProxyManagerConnector(BaseConnector): if not config: raise RuntimeError('missing_config') token = self._login() + if self._credential_binding != (config.url, config.identity, config.secret, config.verify_tls): + self.set_error('npm_configuration_changed') + raise RuntimeError('npm_configuration_changed') try: - with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + with httpx.Client( + timeout=config.timeout_seconds, + verify=config.verify_tls, + follow_redirects=False, + ) as client: + kwargs['follow_redirects'] = False response = client.request( method, f'{config.url}/api{path}', headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'}, **kwargs, ) - except httpx.RequestError as exc: - message = redact_message(str(exc)) - self.set_error(message) - raise RuntimeError(message) + except httpx.RequestError: + self.set_error('npm_request_failed') + raise RuntimeError('npm_request_failed') from None + if 300 <= response.status_code < 400: + self.set_error('npm_redirect_denied') + raise RuntimeError('npm_redirect_denied') if response.status_code in {401, 403}: self.set_error('auth_failed') raise RuntimeError('auth_failed') if response.status_code >= 400: - message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}') - self.set_error(message) - raise RuntimeError(message) + self.set_error('npm_api_error') + raise RuntimeError('npm_api_error') try: return response.json() - except ValueError as exc: - message = redact_message(str(exc)) - self.set_error(message) - raise RuntimeError(message) + except ValueError: + self.set_error('npm_invalid_response') + raise RuntimeError('npm_invalid_response') from None def test_connection(self) -> dict[str, Any]: if not self.is_configured(): @@ -215,6 +515,12 @@ class NginxProxyManagerConnector(BaseConnector): return {'status': 'unreachable', 'reason': reason} return {'status': 'connected', 'proxyHostCount': len(hosts)} + @staticmethod + def _parse_forge_route_hosts(payload: Any) -> list[dict[str, Any]]: + if type(payload) is not list or len(payload) > 1000 or any(type(item) is not dict for item in payload): + raise RuntimeError('npm_forge_route_inventory_invalid') from None + return payload + def health(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(proxyHostCount=0, certificateCount=0) diff --git a/backend/app/connectors/portainer.py b/backend/app/connectors/portainer.py index 94abe39..aaa6916 100644 --- a/backend/app/connectors/portainer.py +++ b/backend/app/connectors/portainer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from typing import Any @@ -136,6 +137,200 @@ class PortainerConnector(BaseConnector): return {'status': 'ok', 'containers': containers} + def forge_route_network_readiness(self) -> dict[str, Any]: + """Check only the fixed NPM and Forge frontdoor network membership.""" + try: + config = self._config() + except Exception: # noqa: BLE001 - fixed projection never returns config diagnostics. + config = None + try: + parsed_url = urlsplit(config.url) if config else None + parsed_port = parsed_url.port if parsed_url else None + base_url = _safe_portainer_base_url(config.url) if config else None + except (TypeError, ValueError): + parsed_url = None + parsed_port = None + base_url = None + if ( + not config + or not isinstance(config.url, str) + or len(config.url) > 2048 + or config.url != config.url.strip() + or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in config.url) + or "\\" in config.url + or "?" in config.url + or "#" in config.url + or settings.apis.get('PORTAINER_VERIFY_TLS', settings.verify_tls) is not True + or config.verify_tls is not True + or parsed_url is None + or parsed_url.scheme != 'https' + or not parsed_url.hostname + or (parsed_port is not None and not 1 <= parsed_port <= 65535) + or parsed_url.username is not None + or parsed_url.password is not None + or parsed_url.query + or parsed_url.fragment + or '@' in parsed_url.netloc + or '%' in parsed_url.netloc + or any(segment in {".", ".."} for segment in parsed_url.path.split("/")) + or base_url != config.url + or not isinstance(config.api_key, str) + or not config.api_key + or re.fullmatch(r'[1-9][0-9]{0,9}', config.endpoint_id) is None + ): + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + try: + with httpx.Client( + timeout=_PORTAINER_DEFAULT_TIMEOUT_SECONDS, + verify=True, + follow_redirects=False, + ) as client: + response = client.get( + f'{config.url}/api/endpoints/{config.endpoint_id}/docker/containers/json?all=1', + headers={'Accept': 'application/json', 'X-API-Key': config.api_key}, + follow_redirects=False, + ) + if type(getattr(response, 'status_code', None)) is not int or response.status_code != 200: + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + payload = response.json() + except Exception: # noqa: BLE001 - never expose a Docker/API credential or response. + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + if not isinstance(payload, list): + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + + npm = [] + frontdoor = [] + for raw in payload: + if not isinstance(raw, dict): + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + names = raw.get('Names') + if not isinstance(names, list) or any(not isinstance(name, str) for name in names): + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + name_matches = any(name.lstrip('/') == 'NginxProxyManager' for name in names) + image = raw.get('Image') + if not isinstance(image, str) or not image: + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + image_repository = image.rsplit('@', 1)[0] + last_component = image_repository.rsplit('/', 1)[-1] + if ':' in last_component: + image_repository = image_repository.rsplit(':', 1)[0] + image_repository = image_repository.lower() + if name_matches: + if image_repository != 'jlesage/nginx-proxy-manager': + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + npm.append(raw) + labels = raw.get('Labels') + if ( + isinstance(labels, dict) + and labels.get('com.docker.compose.project') == 'itworx-forge' + and labels.get('com.docker.compose.service') == 'itworx-forge' + ): + frontdoor.append(raw) + if len(npm) != 1 or len(frontdoor) != 1: + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + npm_id = npm[0].get('Id') + frontdoor_id = frontdoor[0].get('Id') + if ( + not isinstance(npm_id, str) + or not re.fullmatch(r'[0-9a-f]{64}', npm_id) + or not isinstance(frontdoor_id, str) + or not re.fullmatch(r'[0-9a-f]{64}', frontdoor_id) + or npm_id == frontdoor_id + ): + return { + 'status': 'unknown', + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': None, + 'frontdoorAttached': None, + } + + def _attached(raw: dict[str, Any]) -> tuple[str, str] | bool | None: + if raw.get('State') != 'running': + return False + network_settings = raw.get('NetworkSettings') + networks = network_settings.get('Networks') if isinstance(network_settings, dict) else None + if not isinstance(networks, dict): + return None + if 'itworx-mcp-hub-npm' not in networks: + return False + network = networks.get('itworx-mcp-hub-npm') + if not isinstance(network, dict): + return None + network_id = network.get('NetworkID') + endpoint_id = network.get('EndpointID') + if ( + not isinstance(network_id, str) + or re.fullmatch(r'[0-9a-f]{64}', network_id) is None + or not isinstance(endpoint_id, str) + or re.fullmatch(r'[0-9a-f]{64}', endpoint_id) is None + ): + return None + return network_id, endpoint_id + + npm_membership = _attached(npm[0]) + frontdoor_membership = _attached(frontdoor[0]) + if npm_membership is None or frontdoor_membership is None: + status = 'unknown' + elif npm_membership is False or frontdoor_membership is False: + status = 'not_ready' + elif npm_membership[0] != frontdoor_membership[0]: + status = 'unknown' + else: + status = 'ready' + npm_attached = None if npm_membership is None else npm_membership is not False + frontdoor_attached = None if frontdoor_membership is None else frontdoor_membership is not False + return { + 'status': status, + 'network': 'itworx-mcp-hub-npm', + 'npmAttached': npm_attached, + 'frontdoorAttached': frontdoor_attached, + } + def _attach_container_stats(self, containers: list[dict[str, Any]], stats_limit: int) -> None: candidates = [ container diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 621cd30..0025dbf 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,9 +1,9 @@ from __future__ import annotations import os +import stat from dataclasses import dataclass from pathlib import Path -import stat from typing import Any @@ -272,7 +272,7 @@ class Settings: "NPM_URL": _env_value("NPM_URL", None), "NPM_IDENTITY": _env_value("NPM_IDENTITY", None), "NPM_SECRET": _env_value("NPM_SECRET", None), - "NPM_VERIFY_TLS": _env_bool("NPM_VERIFY_TLS", self.verify_tls), + "NPM_VERIFY_TLS": _env_bool("NPM_VERIFY_TLS", True), "NPM_TIMEOUT_SECONDS": _env_value("NPM_TIMEOUT_SECONDS", "10"), "NPM_LETSENCRYPT_EMAIL": _env_value("NPM_LETSENCRYPT_EMAIL", None), "AUTHENTIK_URL": _env_value("AUTHENTIK_URL", None), diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 37e42b6..be9ab1b 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -950,4 +950,5 @@ def _seed_policies() -> list[Policy]: Policy(id="policy-authentik-unknown-mutation", action="authentik.unknown_mutation", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Unknown Authentik mutations fail closed."), Policy(id="policy-forge-oidc-provision", action="forge.oidc.provision", target="itworx-forge", voice="blocked", manual="allowed", risk="critical", reason="The fixed Forge OIDC client may be provisioned only by an authenticated manual operator route; voice and generic Authentik mutations remain blocked."), Policy(id="policy-forge-secret-prepare", action="forge.secrets.prepare", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Preparing the fixed Forge bootstrap secret bundle is default-denied and requires an explicit exact-target manual operator policy; voice remains blocked."), + Policy(id="policy-forge-route-stage", action="forge.route.stage", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Staging the fixed disabled Forge proxy host is default-denied; voice remains blocked."), ] diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 4edbee4..3063b37 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -46,8 +46,8 @@ from .services.policy_engine import evaluate_policy from .services.privatemuse_artifact_installer import ArtifactInstallError from .services.privatemuse_artifact_installer import install as install_artifact_file from .services.privatemuse_artifact_installer import preflight as artifact_preflight -from .services.runtime_service import runtime_info from .services.runner_statistics import RunnerStatisticsService +from .services.runtime_service import runtime_info _SESSION_RUNNING = False _SESSION_VERIFIED_AT: str | None = None @@ -623,6 +623,122 @@ def appops_self_upgrade_status(operation_id: str) -> dict[str, Any]: return _json(DeploymentBrokerClient().appops_self_upgrade_status(operation_id)) +@appops_mcp.tool(name="appops.hub_auto_apply.preflight", annotations=_READ_ONLY_TOOL) +@_tracked("appops.hub_auto_apply.preflight") +def appops_hub_auto_apply_preflight(repository: str) -> dict[str, Any]: + """Read the fixed registered Hub policy fingerprint without secret values.""" + return _json(DeploymentBrokerClient().hub_auto_apply({"repository": repository}, preflight=True)) + + +@appops_mcp.tool(name="appops.hub_auto_apply.set", annotations=_OPERATION_RUN_TOOL) +@_tracked("appops.hub_auto_apply.set") +def appops_hub_auto_apply_set(repository: str, held: bool, expected_registry_sha256: str, + reason: str, request_id: str) -> dict[str, Any]: + """CAS-hold the Hub full-stack auto-rollout while retaining every validation gate.""" + return _json(DeploymentBrokerClient().hub_auto_apply({"repository": repository, "held": held, + "expectedRegistrySha256": expected_registry_sha256, "reason": reason, "requestId": request_id})) + + +@appops_mcp.tool(name="appops.hub_component.preflight", annotations=_READ_ONLY_TOOL) +@_tracked("appops.hub_component.preflight") +def appops_hub_component_preflight(repository: str, service: str, revision: str, + expected_baseline: str, reason: str, request_id: str) -> dict[str, Any]: + """Prepare one immutable protected-main connector promotion and fingerprint its rollback baseline.""" + return _json(DeploymentBrokerClient().hub_component_operation("preflight", { + "repository": repository, "service": service, "revision": revision, + "expectedBaseline": expected_baseline, "reason": reason, "requestId": request_id, + })) + + +@appops_mcp.tool(name="appops.hub_component.baseline", annotations=_READ_ONLY_TOOL) +@_tracked("appops.hub_component.baseline") +def appops_hub_component_baseline(repository: str, service: str) -> dict[str, Any]: + """Read the closed connector baseline and unrelated container identity fingerprint.""" + return _json(DeploymentBrokerClient().hub_component_operation("baseline", { + "repository": repository, "service": service, + })) + + +@appops_mcp.tool(name="appops.hub_component.apply", annotations=_OPERATION_RUN_TOOL) +@_tracked("appops.hub_component.apply") +def appops_hub_component_apply(repository: str, service: str, revision: str, + expected_baseline: str, reason: str, request_id: str, + plan_id: str, plan_digest: str) -> dict[str, Any]: + """Queue the exact preflighted closed connector target; caller-selected images and paths are forbidden.""" + return _json(DeploymentBrokerClient().hub_component_operation("apply", { + "repository": repository, "service": service, "revision": revision, + "expectedBaseline": expected_baseline, "reason": reason, "requestId": request_id, + "planId": plan_id, "planDigest": plan_digest, + })) + + +@appops_mcp.tool(name="appops.hub_component.status", annotations=_READ_ONLY_TOOL) +@_tracked("appops.hub_component.status") +def appops_hub_component_status(plan_id: str) -> dict[str, Any]: + """Read one durable component receipt without advancing the full Hub revision.""" + return _json(DeploymentBrokerClient().hub_component_operation("status", {"planId": plan_id})) + + +@appops_mcp.tool(name="appops.registered_legacy.baseline", annotations=_READ_ONLY_TOOL) +@_tracked("appops.registered_legacy.baseline") +def appops_registered_legacy_baseline(repository: str, request_id: str) -> dict[str, Any]: + """Observe an existing registered runtime whose previous source revision is unknown.""" + return _json(DeploymentBrokerClient().registered_legacy_operation("baseline", { + "repository": repository, "requestId": request_id, + })) + + +@appops_mcp.tool(name="appops.registered_legacy.preflight", annotations=_READ_ONLY_TOOL) +@_tracked("appops.registered_legacy.preflight") +def appops_registered_legacy_preflight(repository: str, revision: str, request_id: str, + expected_state_sha256: str) -> dict[str, Any]: + """Validate exact protected-main push evidence and the actual rollback baseline.""" + return _json(DeploymentBrokerClient().registered_legacy_operation("preflight", { + "repository": repository, "revision": revision, "requestId": request_id, + "expectedStateSha256": expected_state_sha256, + })) + + +@appops_mcp.tool(name="appops.registered_legacy.adopt", annotations=_OPERATION_RUN_TOOL) +@_tracked("appops.registered_legacy.adopt") +def appops_registered_legacy_adopt(repository: str, revision: str, request_id: str, + expected_state_sha256: str, preflight_sha256: str) -> dict[str, Any]: + """Authorize one CAS-bound unknown-image rollback snapshot without changing the runtime.""" + return _json(DeploymentBrokerClient().registered_legacy_operation("adopt", { + "repository": repository, "revision": revision, "requestId": request_id, + "expectedStateSha256": expected_state_sha256, "preflightSha256": preflight_sha256, + })) + + +@appops_mcp.tool(name="appops.registered_legacy.status", annotations=_READ_ONLY_TOOL) +@_tracked("appops.registered_legacy.status") +def appops_registered_legacy_status(repository: str, request_id: str) -> dict[str, Any]: + """Read safe signed adoption metadata; credentials and runtime configuration stay private.""" + return _json(DeploymentBrokerClient().registered_legacy_operation("status", { + "repository": repository, "requestId": request_id, + })) + + +@appops_mcp.tool(name="appops.registered_legacy.rollback", annotations=_OPERATION_RUN_TOOL) +@_tracked("appops.registered_legacy.rollback") +def appops_registered_legacy_rollback(repository: str, revision: str, request_id: str, + expected_state_sha256: str) -> dict[str, Any]: + """Restore the signed previous image/configuration only if the verified current target matches.""" + return _json(DeploymentBrokerClient().registered_legacy_operation("rollback", { + "repository": repository, "revision": revision, "requestId": request_id, + "expectedStateSha256": expected_state_sha256, + })) + + +@appops_mcp.tool(name="appops.registered_legacy.delivery", annotations=_OPERATION_RUN_TOOL) +@_tracked("appops.registered_legacy.delivery") +def appops_registered_legacy_delivery(repository: str, revision: str, request_id: str) -> dict[str, Any]: + """Revalidate one original signed delivery after its registered baseline has been adopted.""" + return _json(DeploymentBrokerClient().registered_legacy_operation("delivery", { + "repository": repository, "revision": revision, "requestId": request_id, + })) + + @appops_mcp.tool(name="server.operation.execute", annotations=_LEGACY_APPROVAL_EXECUTION_TOOL) @_tracked("server.operation.execute") def execute_operation( diff --git a/backend/app/schemas/forge_route_stage.py b/backend/app/schemas/forge_route_stage.py new file mode 100644 index 0000000..3ffb1ca --- /dev/null +++ b/backend/app/schemas/forge_route_stage.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import re +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, model_validator + +_JOB = re.compile(r"^forge-install-[0-9a-f]{64}$") +_SAFE_CODE = re.compile(r"^[a-z][a-z0-9_]{0,95}$") + + +class ForgeRouteStageResultV1(BaseModel): + """Safe public projection for the disabled-only Forge route staging operation.""" + + model_config = ConfigDict( + extra="forbid", + frozen=True, + populate_by_name=True, + strict=True, + ) + + success: StrictBool + status: Literal[ + "host_staged_disabled", + "blocked", + "conflict", + "failed", + "outcome_unknown", + "not_found", + ] + message: StrictStr = Field(pattern=_SAFE_CODE.pattern) + target: Literal["itworx-forge"] + job_id: StrictStr = Field(alias="jobId", pattern=_JOB.pattern) + hostname: Literal["forge.example.com"] + upstream: Literal["http://itworx-forge:8080"] + route_state: Literal["host_staged_disabled", "not_routed"] = Field(alias="routeState") + routed: StrictBool + + @model_validator(mode="after") + def state_is_disabled_only(self) -> ForgeRouteStageResultV1: + staged = self.status == "host_staged_disabled" + if ( + self.success is not staged + or self.route_state != ("host_staged_disabled" if staged else "not_routed") + or self.routed is not False + ): + raise ValueError("Forge route stage results never activate a route") + return self diff --git a/backend/app/services/action_execution.py b/backend/app/services/action_execution.py index 246568f..5e48ee1 100644 --- a/backend/app/services/action_execution.py +++ b/backend/app/services/action_execution.py @@ -1555,7 +1555,11 @@ def _execute_controller_artifact_promote( reason: str, original_policy_decision: str, ) -> dict[str, Any]: - from .controller_artifact_promoter import ControllerArtifactPromoter + from .controller_artifact_promoter import ( + ControllerArtifactPromoter, + helper_failure_diagnostics, + safe_helper_diagnostics, + ) action = "controller.artifact.promote" target = "tower/deployment-controller" @@ -1595,12 +1599,43 @@ def _execute_controller_artifact_promote( success = False message = f"{type(exc).__name__}: controller artifact promotion failed" + diagnostics = None + if status == "failed" and result.get("reason") == "controller_promoter_helper_failed": + diagnostics = safe_helper_diagnostics(result.get("diagnostics")) + if diagnostics is None: + diagnostics = helper_failure_diagnostics(result.get("exitCode")) + exit_code = diagnostics["exitCode"] + exit_code_summary = str(exit_code) if exit_code is not None else "unknown" + message = ( + f"Controller promotion helper failed: {diagnostics['code']}; " + f"phase={diagnostics['phase']}; exitCode={exit_code_summary}." + ) + completed = complete_job( session, job, status="succeeded" if success else "failed", result_summary=message, ) + after_state = { + "status": status, + "revision": result.get("revision"), + "controllerVersion": result.get("controllerVersion"), + "artifactSha256": result.get("artifactSha256"), + } + redacted_metadata = { + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + "callerRevisionSelectable": False, + "callerCommandSelectable": False, + "callerMountSelectable": False, + "helperTargetSelectable": False, + } + if diagnostics is not None: + after_state["diagnostics"] = diagnostics + redacted_metadata["diagnostics"] = diagnostics + audit = log_event( session, source="action", @@ -1613,23 +1648,10 @@ def _execute_controller_artifact_promote( reason=reason, result="success" if success else "failed", execution_plan={"steps": steps, "jobId": completed.id}, - after_state={ - "status": status, - "revision": result.get("revision"), - "controllerVersion": result.get("controllerVersion"), - "artifactSha256": result.get("artifactSha256"), - }, - redacted_metadata={ - "executor": action, - "integration": "mcp" if source == "chatgpt-mcp" else "operator", - "originalPolicyDecision": original_policy_decision, - "callerRevisionSelectable": False, - "callerCommandSelectable": False, - "callerMountSelectable": False, - "helperTargetSelectable": False, - }, + after_state=after_state, + redacted_metadata=redacted_metadata, ) - return { + operation_result = { "success": success, "status": "success" if success else status, "result": "success" if success else "failed", @@ -1642,6 +1664,9 @@ def _execute_controller_artifact_promote( "controllerVersion": result.get("controllerVersion"), "artifactSha256": result.get("artifactSha256"), } + if diagnostics is not None: + operation_result["diagnostics"] = diagnostics + return operation_result def _execute_runner_fleet_upgrade_v2( diff --git a/backend/app/services/action_registry.py b/backend/app/services/action_registry.py index f436b27..c2c3357 100644 --- a/backend/app/services/action_registry.py +++ b/backend/app/services/action_registry.py @@ -178,6 +178,7 @@ ACTIONS = [ 'authentik.unknown_mutation', 'forge.oidc.provision', 'forge.secrets.prepare', + 'forge.route.stage', 'tautulli.terminate_session', 'tautulli.delete_newsletter', 'tautulli.delete_notification_log', @@ -390,6 +391,7 @@ ACTION_RISK = { 'authentik.unknown_mutation': 'critical', 'forge.oidc.provision': 'critical', 'forge.secrets.prepare': 'critical', + 'forge.route.stage': 'critical', 'tautulli.terminate_session': 'high', 'tautulli.delete_newsletter': 'critical', 'tautulli.delete_notification_log': 'critical', @@ -539,6 +541,8 @@ def action_default_target(action: str) -> str: return 'itworx-forge' if action == 'forge.secrets.prepare': return 'itworx-forge' + if action == 'forge.route.stage': + return 'itworx-forge' if action == 'controller.artifact.promote': return 'tower/deployment-controller' if action == 'controller.managed-storage.initialize': diff --git a/backend/app/services/controller_artifact_helper_spec.py b/backend/app/services/controller_artifact_helper_spec.py index ea491d1..e954d58 100644 --- a/backend/app/services/controller_artifact_helper_spec.py +++ b/backend/app/services/controller_artifact_helper_spec.py @@ -43,24 +43,68 @@ def helper_container_spec( raise ValueError("controller_promoter_bootstrap_binding_invalid") command = f"""set -Eeuo pipefail +phase=shell_setup +work='' +diagnostic_emitted=0 +emit_failure() {{ + local exit_code="$1" + if (( diagnostic_emitted == 0 )); then + printf 'APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=%s exitCode=%s\\n' "$phase" "$exit_code" >&2 + diagnostic_emitted=1 + fi +}} +on_error() {{ + local exit_code=$? + trap - ERR + emit_failure "$exit_code" + exit "$exit_code" +}} +cleanup() {{ + local exit_code=$? + local cleanup_exit_code=0 + trap - EXIT + phase=cleanup + if [[ -n "$work" ]]; then + if rm -rf -- "$work"; then + : + else + cleanup_exit_code=$? + emit_failure "$cleanup_exit_code" + if (( exit_code == 0 )); then exit_code=$cleanup_exit_code; fi + fi + fi + exit "$exit_code" +}} +trap on_error ERR umask 077 revision='{revision}' version='{CONTROLLER_VERSION}' image_id='{image_id}' repo='/gitea-repositories/jens/unraid-appops-gateway.git' -[[ \"$(stat -c '%a:%u:%g' /tmp)\" == '1777:0:0' ]] -work=\"$(mktemp -d -p /tmp itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX)\" +phase=tmp_root +[[ \"$(trap - ERR; stat -c '%a:%u:%g' /tmp)\" == '1777:0:0' ]] +phase=workspace_create +work=\"$(trap - ERR; mktemp -d -p /tmp itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX)\" +phase=workspace_validate [[ -d \"$work\" && ! -L \"$work\" && \"$(stat -c '%u:%a' \"$work\")\" == '0:700' ]] -cleanup() {{ rm -rf -- \"$work\"; }} trap cleanup EXIT +phase=tool_git command -v git >/dev/null +phase=tool_docker command -v docker >/dev/null +phase=tool_compose docker compose version >/dev/null +phase=tool_python command -v python3 >/dev/null +phase=tool_tar command -v tar >/dev/null +phase=tool_curl command -v curl >/dev/null +phase=source_commit git --git-dir=\"$repo\" cat-file -e \"$revision^{{commit}}\" -tree=\"$(git --git-dir=\"$repo\" rev-parse \"$revision^{{tree}}\")\" +phase=source_tree +tree=\"$(trap - ERR; git --git-dir=\"$repo\" rev-parse \"$revision^{{tree}}\")\" +phase=source_archive git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\ ops/gitea-deploy-broker \\ scripts/prepare_controller_artifact.py \\ @@ -69,10 +113,15 @@ git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\ scripts/vendor \\ scripts/run_repopulse_adoption.py \ scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\" +phase=archive_permissions chmod 0600 \"$work/controller.tar\" -archive_sha=\"$(sha256sum \"$work/controller.tar\" | cut -d ' ' -f 1)\" +phase=archive_digest +archive_sha=\"$(trap - ERR; sha256sum \"$work/controller.tar\" | cut -d ' ' -f 1)\" +phase=upgrade_script git --git-dir=\"$repo\" show \"$revision:scripts/upgrade_deployment_controller.sh\" > \"$work/upgrade.sh\" +phase=upgrade_permissions chmod 0700 \"$work/upgrade.sh\" +phase=upgrade bash \"$work/upgrade.sh\" \\ --archive \"$work/controller.tar\" \\ --archive-sha256 \"$archive_sha\" \\ diff --git a/backend/app/services/controller_artifact_promoter.py b/backend/app/services/controller_artifact_promoter.py index 92d9eb7..ff9a3d7 100644 --- a/backend/app/services/controller_artifact_promoter.py +++ b/backend/app/services/controller_artifact_promoter.py @@ -10,16 +10,17 @@ import httpx from ..connectors.portainer import PortainerConnector from ..core.config import settings -from ..core.redaction import redact_message from .controller_artifact_helper_spec import ( + _IMAGE_ID, + _REVISION, CONTROLLER_VERSION, HELPER_LABEL, HELPER_NAME, - SOURCE_REPOSITORY as SOURCE_REPOSITORY, - _IMAGE_ID, - _REVISION, helper_container_spec, ) +from .controller_artifact_helper_spec import ( + SOURCE_REPOSITORY as SOURCE_REPOSITORY, +) from .deployment_broker import DeploymentBrokerClient GITEA_OWNER = "Jens" @@ -28,6 +29,132 @@ REQUIRED_PUSH_CONTEXT = "Managed validation / full (push)" CONTROLLER_CONTAINER = "gitea-deploy-control" _HELPER_TIMEOUT_SECONDS = 240 _TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$") +_MAX_HELPER_LOG_CHARS = 4000 +_MAX_HELPER_LOG_LINES = 200 +_MAX_HELPER_LOG_WIRE_BYTES = _MAX_HELPER_LOG_CHARS + (8 * _MAX_HELPER_LOG_LINES) +_HELPER_DIAGNOSTIC_PREFIX = "APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC" +_HELPER_DIAGNOSTIC_CODES = frozenset({"helper_failed"}) +_HELPER_DIAGNOSTIC_PHASES = frozenset( + { + "shell_setup", + "tmp_root", + "workspace_create", + "workspace_validate", + "tool_git", + "tool_docker", + "tool_compose", + "tool_python", + "tool_tar", + "tool_curl", + "source_commit", + "source_tree", + "source_archive", + "archive_permissions", + "archive_digest", + "upgrade_script", + "upgrade_permissions", + "upgrade", + "cleanup", + } +) +_HELPER_DIAGNOSTIC_LINE = re.compile( + rf"^{_HELPER_DIAGNOSTIC_PREFIX} code=([a-z_]+) phase=([a-z_]+) " + r"exitCode=(0|[1-9][0-9]{0,2})$" +) + + +def helper_failure_diagnostics(exit_code: Any) -> dict[str, Any]: + safe_exit_code = exit_code if type(exit_code) is int and 0 <= exit_code <= 255 else None + return {"code": "helper_failed", "phase": "unknown", "exitCode": safe_exit_code} + + +def safe_helper_diagnostics(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict) or set(value) != {"code", "phase", "exitCode"}: + return None + code = value.get("code") + phase = value.get("phase") + exit_code = value.get("exitCode") + if not isinstance(code, str) or code not in _HELPER_DIAGNOSTIC_CODES: + return None + if not isinstance(phase, str) or ( + phase != "unknown" and phase not in _HELPER_DIAGNOSTIC_PHASES + ): + return None + if exit_code is not None and ( + type(exit_code) is not int or not 0 <= exit_code <= 255 + ): + return None + return {"code": code, "phase": phase, "exitCode": exit_code} + + +def parse_helper_diagnostics(log_text: Any, exit_code: Any) -> dict[str, Any]: + fallback = helper_failure_diagnostics(exit_code) + if ( + fallback["exitCode"] is None + or not isinstance(log_text, str) + or len(log_text) > _MAX_HELPER_LOG_CHARS + ): + return fallback + + candidates = [ + line + for line in log_text.splitlines() + if line.startswith(_HELPER_DIAGNOSTIC_PREFIX) + ] + if len(candidates) != 1: + return fallback + match = _HELPER_DIAGNOSTIC_LINE.fullmatch(candidates[0]) + if match is None: + return fallback + code, phase, marker_exit_code = match.groups() + parsed_exit_code = int(marker_exit_code) + if ( + code not in _HELPER_DIAGNOSTIC_CODES + or phase not in _HELPER_DIAGNOSTIC_PHASES + or parsed_exit_code != fallback["exitCode"] + ): + return fallback + return {"code": code, "phase": phase, "exitCode": parsed_exit_code} + + +def _decode_docker_log_stream(content: Any) -> str | None: + if ( + not isinstance(content, bytes) + or not content + or len(content) > _MAX_HELPER_LOG_WIRE_BYTES + ): + return None + + offset = 0 + frame_count = 0 + payload_size = 0 + payloads: list[bytes] = [] + while offset < len(content): + if frame_count >= _MAX_HELPER_LOG_LINES or len(content) - offset < 8: + return None + + header = content[offset : offset + 8] + stream_type = header[0] + if stream_type not in {1, 2} or header[1:4] != b"\x00\x00\x00": + return None + frame_size = int.from_bytes(header[4:8], "big") + if frame_size == 0 or frame_size > _MAX_HELPER_LOG_CHARS - payload_size: + return None + + payload_start = offset + 8 + payload_end = payload_start + frame_size + if payload_end > len(content): + return None + + payloads.append(content[payload_start:payload_end]) + payload_size += frame_size + frame_count += 1 + offset = payload_end + + try: + return b"".join(payloads).decode("utf-8") + except UnicodeDecodeError: + return None class ControllerArtifactPromotionError(RuntimeError): @@ -257,10 +384,29 @@ class ControllerArtifactPromoter: } exit_code = state.get("ExitCode") - logs = self.portainer.get_container_logs(helper_id, tail=200) - log_lines = logs.get("logs") if isinstance(logs, dict) and isinstance(logs.get("logs"), list) else [] - tail = "\n".join(str(line) for line in log_lines[-40:])[-4000:] - if exit_code != 0: + if type(exit_code) is not int or exit_code != 0: + try: + logs_response = self.portainer._request( + "GET", + f"/api/endpoints/{endpoint}/docker/containers/{quote(helper_id, safe='')}/logs", + params={ + "stdout": 1, + "stderr": 1, + "tail": _MAX_HELPER_LOG_LINES, + }, + ) + log_text = ( + _decode_docker_log_stream(logs_response.content) + if isinstance(logs_response, httpx.Response) + and logs_response.status_code == 200 + else None + ) + except (httpx.HTTPError, RuntimeError, ValueError): + log_text = None + diagnostics = parse_helper_diagnostics( + log_text, + exit_code, + ) try: self._remove_helper(helper_id) except RuntimeError: @@ -269,15 +415,14 @@ class ControllerArtifactPromoter: "status": "failed", "revision": revision, "reason": "controller_promoter_helper_failed", - "exitCode": exit_code, - "logTail": redact_message(tail), + "diagnostics": diagnostics, } try: self._remove_helper(helper_id) except RuntimeError: pass - return {"status": "promoted", "revision": revision, "logTail": redact_message(tail)} + return {"status": "promoted", "revision": revision} def run(self) -> dict[str, Any]: revision = self._validated_main_revision() diff --git a/backend/app/services/deployment_broker.py b/backend/app/services/deployment_broker.py index 56b775c..36ec092 100644 --- a/backend/app/services/deployment_broker.py +++ b/backend/app/services/deployment_broker.py @@ -214,9 +214,7 @@ class DeploymentBrokerClient: } for field in error_response_fields: value = decoded.get(field) - if isinstance(value, str) and len(value) <= 256: - rejected[field] = value - elif field == "noMutation" and isinstance(value, bool): + if isinstance(value, str) and len(value) <= 256 or field == "noMutation" and isinstance(value, bool): rejected[field] = value return rejected return {"status": str(decoded.get("status") or "ok"), **decoded} @@ -279,6 +277,33 @@ class DeploymentBrokerClient: "reason": reason, "expectedCurrentRevision": expected_current_revision}, ) + def hub_component_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]: + """Only closed, signed component-promotion endpoints; never caller commands.""" + if stage not in {"baseline", "preflight", "apply", "status"}: + raise ValueError("component promotion stage is invalid") + return self._request( + "POST", f"/v1/appops/hub-components/{stage}", + secret=settings.mcp_token or "", payload=payload, + error_response_fields=frozenset({"planId", "planDigest", "noMutation"}), + ) + + def hub_auto_apply(self, payload: dict[str, Any], *, preflight: bool = False) -> dict[str, Any]: + return self._request( + "POST", "/v1/appops/registered-targets/auto-apply" + ("/preflight" if preflight else ""), + secret=settings.mcp_token or "", payload=payload, + error_response_fields=frozenset({"noMutation"}), + ) + + def registered_legacy_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]: + """Signed fixed-stage metadata adoption/recovery for existing registered targets.""" + if stage not in {"baseline", "preflight", "adopt", "status", "rollback", "delivery"}: + raise ValueError("registered legacy stage is invalid") + return self._request( + "POST", f"/v1/appops/registered-legacy/{stage}", + secret=settings.mcp_token or "", payload=payload, + error_response_fields=frozenset({"noMutation"}), + ) + def appops_self_upgrade_apply( self, revision: str, reason: str, expected_current_revision: str | None, preflight_sha256: str, idempotency_key: str, diff --git a/backend/app/services/deployment_target_registry.py b/backend/app/services/deployment_target_registry.py index 3428afd..09bada4 100644 --- a/backend/app/services/deployment_target_registry.py +++ b/backend/app/services/deployment_target_registry.py @@ -26,6 +26,7 @@ import fcntl import json import os from pathlib import Path +import re import stat import subprocess import sys @@ -36,6 +37,8 @@ host_path = Path("/controller-hub-releases/controller-config/projects.json") lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") repository, revision, branch, raw_project = sys.argv[1:5] project = json.loads(raw_project) +requested_project = project +strengthening_deadline = time.monotonic() + 20 def _validation_compatible(repository, validation): expected_validation = { @@ -58,6 +61,52 @@ def _validation_compatible(repository, validation): "contexts": ["CI / gates (push)", "Managed validation / full (push)"], } +def _source_verified_strengthening(): + # Only an exact protected-main descriptor can strengthen an existing target. + # The controller reader uses fixed credential files and private stdin. + if branch != "main" or time.monotonic() >= strengthening_deadline: + return False + try: + fresh = git_run("rev-parse", "refs/heads/main") + if fresh.returncode or fresh.stdout.strip() != revision: + return False + result = git_run("show", f"{revision}:.itworx/deployment-target.json") + if result.returncode or len(result.stdout.encode("utf-8")) > 16384: + return False + def unique(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate descriptor field") + value[key] = item + return value + descriptor = json.loads(result.stdout, object_pairs_hook=unique) + allowed = {"apiVersion", "applicationId", "composeFile", "composeProject", + "healthTimeout", "primaryService", "verifyOciRevision"} + if (not isinstance(descriptor, dict) or set(descriptor) - allowed + or descriptor.get("apiVersion") != "itworx.deployment-target/v1" + or descriptor.get("verifyOciRevision") is not True + or descriptor.get("composeFile") != compose_file + or descriptor.get("composeProject", descriptor.get("applicationId")) != requested_project["compose_project"] + or type(descriptor.get("healthTimeout", 180)) is not int + or descriptor.get("healthTimeout", 180) != requested_project["health_timeout"] + or not isinstance(descriptor.get("applicationId"), str) + or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", descriptor["applicationId"]) + or not isinstance(descriptor.get("primaryService", descriptor["applicationId"]), str) + or not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", descriptor.get("primaryService", descriptor["applicationId"]))): + return False + sys.path.insert(0, "/opt/gitea-deploy") + from status_server import _registered_legacy_api_reader + protected = _registered_legacy_api_reader(repository)( + f"/repos/{repository}/branches/main", query={}, + ) + return (time.monotonic() < strengthening_deadline + and isinstance(protected, dict) and protected.get("protected") is True + and isinstance(protected.get("commit"), dict) + and protected["commit"].get("id") == revision) + except Exception: + return False + for directory in (path.parent, host_path.parent, host_path.parent.parent): info = directory.lstat() if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: @@ -89,8 +138,16 @@ if not bare_repo.is_dir(): raise SystemExit(24) def git_run(*args): + # Trust only this validated controller-owned mount, preserving Git's normal + # checks elsewhere. Always read original objects rather than replacement refs. + for directory in (bare_repo.parent.parent, bare_repo.parent, bare_repo): + info = directory.lstat() + if (not stat.S_ISDIR(info.st_mode) or info.st_uid not in {0, 1000} + or info.st_mode & 0o022): + raise ValueError("unsafe mounted repository") return subprocess.run( - ["git", f"--git-dir={bare_repo}", *args], + ["git", "--no-replace-objects", "-c", f"safe.directory={bare_repo}", + f"--git-dir={bare_repo}", *args], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, @@ -168,12 +225,34 @@ with lock_handle as lock: "gitea_repository_id", "gitea_webhook_secret_file", "validation", + "legacy_containers", } extra_keys = set(current) - set(project) if not extra_keys.issubset(allowed_extra_keys): raise SystemExit(23) - canonical_current = {key: current.get(key) for key in project} - if canonical_current != project: + immutable_keys = set(project) - {"source_dir", "exact_source", "verify_oci_revision", "health_timeout"} + if any(current.get(key) != project[key] for key in immutable_keys): + raise SystemExit(23) + # Preserve the registered source mode and fixed historical appdata path. + # Registration must never silently migrate an existing runtime's source. + source = current.get("source_dir") + known_sources = {project["source_dir"], f"/srv/example/appdata/{project['slug']}/source"} + exact_source = current.get("exact_source", False) + verification = current.get("verify_oci_revision", False) + health_timeout = current.get("health_timeout", 180) + if (not isinstance(source, str) or source not in known_sources or type(exact_source) is not bool + or type(verification) is not bool or type(project["verify_oci_revision"]) is not bool + or type(health_timeout) is not int or health_timeout != project["health_timeout"] + or verification and project["verify_oci_revision"] is not True): + raise SystemExit(23) + legacy_containers = current.get("legacy_containers") + if legacy_containers is not None and ( + not isinstance(legacy_containers, list) or len(legacy_containers) > 32 + or any(not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", name) + for name in legacy_containers) + or len(set(legacy_containers)) != len(legacy_containers) + or set(legacy_containers).intersection(project["containers"]) + ): raise SystemExit(23) env_source = current.get("env_source") if env_source is not None: @@ -222,6 +301,15 @@ with lock_handle as lock: if not _validation_compatible(repository, validation): raise SystemExit(23) + strengthened = not verification and project["verify_oci_revision"] is True + if strengthened and not _source_verified_strengthening(): + raise SystemExit(39) + reconciled = dict(current) + if strengthened: + reconciled["verify_oci_revision"] = True + changed = reconciled != current + project = reconciled + if changed: try: backup_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state/registry-backups") @@ -266,11 +354,17 @@ with lock_handle as lock: raise SystemExit(33) try: + if current is not None and strengthened and not _source_verified_strengthening(): + temporary.unlink(missing_ok=True) + raise SystemExit(39) latest = mutation_path.lstat() latest_identity = (latest.st_dev, latest.st_ino, latest.st_mode, latest.st_uid, latest.st_gid, latest.st_mtime_ns, latest.st_ctime_ns, latest.st_size) if latest_identity != registry_identity or mutation_path.read_bytes() != original or not os.path.samefile(path, mutation_path): raise SystemExit(34) + if current is not None and strengthened and time.monotonic() >= strengthening_deadline: + temporary.unlink(missing_ok=True) + raise SystemExit(39) os.replace(temporary, mutation_path) except Exception: try: @@ -429,6 +523,8 @@ class DeploymentTargetRegistry: verify_oci_revision: bool = False, ) -> dict[str, Any]: try: + if type(verify_oci_revision) is not bool: + raise ValueError("OCI revision verification must be a boolean") ( repository, revision, @@ -561,6 +657,7 @@ class DeploymentTargetRegistry: 36: "deployment projects registry host alias is unavailable or does not match the controller registry", 37: "controller project branch does not match the requested default branch", 38: "controller project Compose contract is invalid", + 39: "existing target verification strengthening requires its exact protected-main source descriptor", } if exit_code in preflight_reasons: return { diff --git a/backend/app/services/enrollment_plane.py b/backend/app/services/enrollment_plane.py index 331a245..49b6b41 100644 --- a/backend/app/services/enrollment_plane.py +++ b/backend/app/services/enrollment_plane.py @@ -15,6 +15,7 @@ import secrets import uuid from typing import Any, Literal +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select from ..connectors.authentik import AuthentikConnector @@ -46,6 +47,20 @@ _CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "ap _PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"} _OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"} _APPDATA_ROOT_STATUSES = {"ready", "needs_initialization", "unavailable"} +_CAPABILITY_REASON = re.compile(r"^[a-z][a-z0-9_]{0,79}$") +_RUNTIME_SECURITY_FEATURES = ("exactTargetPolicy", "hmacV2", "replayProtection") + + +def _safe_capability_reason(value: object) -> str: + reason = str(value or "") + return reason if _CAPABILITY_REASON.fullmatch(reason) else "" + + +def _features_ready(features: object, names: tuple[str, ...]) -> bool: + return bool( + isinstance(features, dict) + and all(features.get(name) is True for name in names) + ) def _trusted_appdata_roots_readiness( @@ -57,7 +72,7 @@ def _trusted_appdata_roots_readiness( if isinstance(reported, list): if len(reported) != len(roots): return [{"root": root, "status": unavailable[root]} for root in roots] - statuses: dict[str, str] = {} + statuses: dict[str, dict[str, str]] = {} for item in reported: if not isinstance(item, dict): return [{"root": root, "status": unavailable[root]} for root in roots] @@ -71,10 +86,14 @@ def _trusted_appdata_roots_readiness( or status not in _APPDATA_ROOT_STATUSES ): return [{"root": expected, "status": unavailable[expected]} for expected in roots] - statuses[root] = status + value = {"root": root, "status": status} + reason = _safe_capability_reason(item.get("reason")) + if reason: + value["reason"] = reason + statuses[root] = value if set(statuses) != set(roots): return [{"root": root, "status": unavailable[root]} for root in roots] - return [{"root": root, "status": statuses[root]} for root in roots] + return [statuses[root] for root in roots] # A legacy V1 controller reports only its configured root. It cannot attest # independent managed-volume readiness, so only its exact legacy root can @@ -224,11 +243,40 @@ class EnrollmentPlane: controller_root_matches = bool(selected_root and selected_root.get("status") == "ready") else: controller_root_matches = False - broker_ready = bool( + controller_core_ready = bool( bootstrap and self.broker.is_configured() and controller_capability.get("status") == "ready" + ) + features = controller_capability.get("features") + runtime_security_ready = _features_ready(features, _RUNTIME_SECURITY_FEATURES) + apply_ready = bool( + controller_core_ready + and runtime_security_ready + and isinstance(features, dict) + and features.get("deploymentApply") is True + ) + verify_ready = bool( + controller_core_ready + and runtime_security_ready + and isinstance(features, dict) + and features.get("deploymentVerify") is True + ) + rollback_ready = bool( + controller_core_ready + and runtime_security_ready + and isinstance(features, dict) + and features.get("rollback") is True + ) + runtime_deployment_ready = apply_ready and verify_ready + enrollment_features_ready = _features_ready( + features, + ("enrollment", "targetRegistration", "runtimeSecretSealing"), + ) + target_enrollment_ready = bool( + controller_core_ready and controller_root_matches + and enrollment_features_ready ) controller_managed_storage = controller_capability.get("managedStorage") if not isinstance(controller_managed_storage, dict): @@ -248,6 +296,41 @@ class EnrollmentPlane: else: managed_storage_status = "unavailable" managed_storage_blocker = "managed_appdata_volume_unavailable" + controller_roots_by_path = { + str(item.get("root") or ""): item + for item in available_roots + if isinstance(item, dict) + } + selected_root_readiness = ( + controller_roots_by_path.get(trust.trusted_appdata_root) + if trust is not None + else None + ) + controller_enrollment = controller_capability.get("enrollmentReadiness") + if not isinstance(controller_enrollment, dict): + controller_enrollment = {} + root_reason = _safe_capability_reason( + selected_root_readiness.get("reason") + if isinstance(selected_root_readiness, dict) + else None + ) + if not root_reason and trust is not None and trust.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT: + root_reason = _safe_capability_reason(controller_managed_storage.get("reason")) + enrollment_reason = ( + root_reason + or _safe_capability_reason(controller_enrollment.get("reason")) + or ( + "enrollment_trust_not_configured" + if trust is None + else "trusted_appdata_root_unavailable" + if not controller_root_matches + else "controller_enrollment_feature_unavailable" + if not enrollment_features_ready + else "deployment_executor_unavailable" + ) + ) + if target_enrollment_ready: + enrollment_reason = "" npm_ready = self.npm.is_configured() oidc_configured = bool( trust @@ -262,18 +345,44 @@ class EnrollmentPlane: def item(ready: bool, code: str) -> dict[str, str]: return {"status": "ready" if ready else "unavailable", "blockerCode": "" if ready else code} + if not controller_core_ready: + enrollment_blocker = "deployment_executor_unavailable" + elif not controller_root_matches: + enrollment_blocker = "trusted_appdata_root_unavailable" + else: + enrollment_blocker = "controller_enrollment_feature_unavailable" + registry_report = controller_capability.get("registry") + registry_projection: dict[str, Any] = {"status": "unavailable"} + if isinstance(registry_report, dict): + registry_status = registry_report.get("status") + if registry_status in {"ready", "unavailable"}: + registry_projection["status"] = registry_status + count = registry_report.get("registeredProjectCount") + if ( + registry_status == "ready" + and type(count) is int + and 0 <= count <= 500 + ): + registry_projection["registeredProjectCount"] = count + controller_reason = ( + "" + if controller_core_ready + else _safe_capability_reason(controller_capability.get("reason")) + or "deployment_executor_unavailable" + ) + return { "apiVersion": "itworx.enrollment-capabilities/v1", "trust": item(configured, "enrollment_trust_not_configured"), - "targetEnrollment": item(broker_ready, "deployment_executor_unavailable"), + "targetEnrollment": item(target_enrollment_ready, enrollment_blocker), "secretBootstrap": item(_enrollment_secret_store_ready(storage), "secret_store_not_production_ready"), - "runtimeDeployment": item(broker_ready, "deployment_executor_unavailable"), + "runtimeDeployment": item(runtime_deployment_ready, "deployment_executor_unavailable"), "reverseProxyProvisioning": item(npm_ready, "npm_credentials_missing"), "tlsProvisioning": item(certificate_ready, "certificate_provisioning_unavailable"), "oidcProvisioning": item(oidc_configured, "oidc_profile_unavailable"), "policyProvisioning": item(configured, "policy_template_unavailable"), - "rollback": item(broker_ready, "deployment_executor_unavailable"), - "verification": item(broker_ready, "deployment_executor_unavailable"), + "rollback": item(rollback_ready, "deployment_executor_unavailable"), + "verification": item(verify_ready, "deployment_executor_unavailable"), "availableTrustedAppdataRoots": available_roots, "managedStorage": { "status": managed_storage_status, @@ -283,19 +392,23 @@ class EnrollmentPlane: "backupAcknowledged": bool( trust and trust.managed_appdata_backup_acknowledged ), - }, - "controller": { - "status": "ready" if broker_ready else "unavailable", - "reason": ( - "" - if broker_ready - else str(controller_capability.get("reason") or ( - "trusted_appdata_root_mismatch" - if controller_capability.get("status") == "ready" and not controller_root_matches - else "deployment_executor_unavailable" - )) + **( + {"reason": _safe_capability_reason(controller_managed_storage.get("reason"))} + if trust is not None + and trust.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT + and _safe_capability_reason(controller_managed_storage.get("reason")) + else {} ), }, + "enrollmentReadiness": { + "status": "ready" if target_enrollment_ready else "unavailable", + "reason": enrollment_reason, + }, + "registry": registry_projection, + "controller": { + "status": "ready" if controller_core_ready else "unavailable", + "reason": controller_reason, + }, "profiles": [profile_summary(name) for name in ("internal-webapp", "internal-webapp-oidc", "internal-service", "repopulse-authenticated")], } @@ -433,6 +546,18 @@ class EnrollmentPlane: if by_repository and (not by_application or by_repository.id != by_application.id): checks.append(_check("target_already_exists", "blocked", "Repository is already bound to a different managed application.")) + policy_ids = ( + f"enrollment-{plan.application_id}-deployment-apply", + f"enrollment-{plan.application_id}-deployment-rollback", + ) + policy_collisions = [policy_id for policy_id in policy_ids if session.get(Policy, policy_id) is not None] + checks.append(_check( + "exact_policy_id_collision", + "blocked" if policy_collisions else "passed", + "An enrollment-generated exact policy identifier is already owned and cannot be adopted." + if policy_collisions else "Both enrollment-generated exact policy identifiers are available.", + )) + containers = self.portainer.list_containers() if containers.get("status") != "ok": checks.append(_check("container_inventory_unavailable", "blocked", "Portainer inventory is unavailable for collision protection.")) @@ -676,17 +801,25 @@ class EnrollmentPlane: "sourceRevision": plan.revision, "createdAt": utc_now().isoformat(), } - identifiers: list[str] = [] - for action, suffix in (("deployment.apply", "deployment-apply"), ("deployment.rollback", "deployment-rollback")): - policy_id = f"enrollment-{plan.application_id}-{suffix}" - existing = session.get(Policy, policy_id) - if existing is not None: - # Enrollment never adopts or repurposes a pre-existing policy, - # even where its visible action/target happen to match. A - # replay is handled before this method; this branch is a - # collision, not a safe recovery path. - raise EnrollmentError("exact policy identifier is already owned and cannot be adopted") - existing = Policy( + policy_specs = ( + ( + "deployment.apply", + f"enrollment-{plan.application_id}-deployment-apply", + ), + ( + "deployment.rollback", + f"enrollment-{plan.application_id}-deployment-rollback", + ), + ) + identifiers = [policy_id for _action, policy_id in policy_specs] + # Check the complete pair before adding either row. The database flush + # below is one transaction so a concurrent uniqueness collision cannot + # leave the first insert committed by _fail(). + if any(session.get(Policy, policy_id) is not None for policy_id in identifiers): + raise EnrollmentError("exact policy identifier is already owned and cannot be adopted") + + for action, policy_id in policy_specs: + policy = Policy( id=policy_id, action=action, target=plan.application_id, @@ -695,10 +828,17 @@ class EnrollmentPlane: risk="high", reason="Enrollment-generated exact-target deployment policy.", ) - existing.provenance_json = provenance - session.add(existing) - identifiers.append(policy_id) - session.commit() + policy.provenance_json = provenance + session.add(policy) + try: + # Force uniqueness errors inside this boundary; on failure discard + # the uncommitted unit of work before _fail() persists its outcome. + session.flush() + except IntegrityError as exc: + session.rollback() + raise EnrollmentError("exact policy identifier is already owned and cannot be adopted") from exc + # Deliberately do not commit here: the caller commits these rows together + # with the enrollment/job ready state after policy installation succeeds. return identifiers def _compensate( diff --git a/backend/app/services/forge_route_stage.py b/backend/app/services/forge_route_stage.py new file mode 100644 index 0000000..36c83a2 --- /dev/null +++ b/backend/app/services/forge_route_stage.py @@ -0,0 +1,835 @@ +from __future__ import annotations + +import hashlib +import json +import re +import time +from typing import Any +from uuid import uuid4 + +from sqlmodel import Session, select + +from ..connectors.nginx_proxy_manager import ( + NginxProxyManagerConnector, + _NpmForgeRoutePermissionDenied, +) +from ..connectors.portainer import PortainerConnector +from ..db.models import DeploymentJob, Policy, Secret +from ..schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1 +from ..schemas.forge_route_stage import ForgeRouteStageResultV1 +from ..schemas.forge_runtime_readiness import ForgeRuntimeReadinessResultV1 +from .audit_logger import log_event +from .forge_bootstrap_apply import ( + ForgeBootstrapApplyService, + _classify_controller_result, + _sha256, +) +from .forge_oidc_provisioning import _forge_oidc_process_lock +from .policy_engine import evaluate_policy +from .secret_store import get_secret, secret_storage_status, set_secret + +_ACTION = "forge.route.stage" +_TARGET = "itworx-forge" +_REPOSITORY = "jens/itworx-forge" +_JOB_ID = re.compile(r"^forge-install-[0-9a-f]{64}$") +_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") +_OWNERSHIP_ID = re.compile(r"^froute-[0-9a-f]{32}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +_DOMAIN_LABEL = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") +_ROUTE_HOST = "forge.example.com" +_ROUTE_NETWORK = "itworx-mcp-hub-npm" +_ROUTE_UPSTREAM = "http://itworx-forge:8080" +_INTENT_VERSION = "itworx.forge-route-stage-intent/v1" +_INTENT_PHASES = {"pending", "outcome_unknown", "host_staged_disabled", "aborted_before_post"} +_READINESS_SERVICES = ("api", "dashboard", "itworx-forge", "postgres", "worker") + + +def _canonical_json(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate journal key") + value[key] = item + return value + + +def _reject_json_constant(_constant: str) -> Any: + raise ValueError("invalid journal number") + + +def _readiness_is_fresh(identity: dict[str, Any]) -> bool: + observed_at = identity.get("observedAt") + if type(observed_at) is not int: + return False + now = int(time.time()) + return now - 30 <= observed_at <= now + 5 + + +def _journal_secret_ref(job_id: str) -> str: + # set_secret normalizes reference names to uppercase before persistence. + return ("FORGE_ROUTE_STAGE_" + hashlib.sha256(job_id.encode("ascii")).hexdigest()).upper() + + +def _secret_store_ready() -> bool: + try: + status = secret_storage_status() + except Exception: # noqa: BLE001 - secret-store diagnostics never enter route results. + return False + return isinstance(status, dict) and status.get("productionReady") is True + + +def _exact_route_policy_allowed(session: Session) -> str | None: + rows = session.exec(select(Policy).where(Policy.action == _ACTION)).all() + if len(rows) != 1: + return None + policy = rows[0] + if ( + policy.target != _TARGET + or policy.manual != "allowed" + or policy.voice != "blocked" + or policy.risk != "critical" + ): + return None + decision = evaluate_policy(session, _ACTION, _TARGET, "manual") + if ( + decision.get("decision") != "allowed" + or decision.get("scope_decision") != "allowed" + or decision.get("policy_id") != policy.id + ): + return None + return policy.id + + +def _result(job_id: str, status: str, message: str) -> dict[str, Any]: + staged = status == "host_staged_disabled" + return ForgeRouteStageResultV1.model_validate( + { + "success": staged, + "status": status, + "message": message, + "target": _TARGET, + "jobId": job_id, + "hostname": _ROUTE_HOST, + "upstream": _ROUTE_UPSTREAM, + "routeState": "host_staged_disabled" if staged else "not_routed", + "routed": False, + } + ).model_dump(mode="json", by_alias=True) + + +def _audit( + session: Session, + *, + actor_identity: str, + decision: str, + policy_id: str | None, + result: dict[str, Any], + idempotency_hash: str, +) -> bool: + try: + log_event( + session, + source="manual", + identity=actor_identity, + action_id=_ACTION, + target=_TARGET, + target_type="forge_route_stage", + risk="critical", + policy_decision=decision, + reason=result["message"], + result=result["status"], + after_state={ + "status": result["status"], + "routeState": result["routeState"], + "routed": False, + }, + redacted_metadata={ + "policyId": policy_id, + "idempotencyKeySha256": idempotency_hash, + }, + ) + except Exception: # noqa: BLE001 - audit/DB failures never expose held connector data. + try: + session.rollback() + except Exception: # noqa: BLE001 - keep DB details out of the response. + pass + return False + return True + + +def _verified_job(session: Session, job_id: str) -> tuple[DeploymentJob, ForgeBootstrapApplyV1] | None: + if _JOB_ID.fullmatch(job_id) is None: + return None + try: + job = session.get(DeploymentJob, job_id) + if ( + job is None + or job.application_id != _TARGET + or job.repository != _REPOSITORY + or job.status != "deployed" + or job.stage not in {"controller_deployed", "resume_controller_deployed"} + or job.completed_at is None + or not isinstance(job.broker_request_id, str) + or _SHA256.fullmatch(job.broker_request_id) is None + or not isinstance(job.plan_json, dict) + or _sha256(job.plan_json) != job.plan_sha256 + ): + return None + profile = ForgeBootstrapApplyV1.model_validate(job.plan_json) + preflight_receipt = job.preflight_json + if ( + job.revision != profile.revision + or not isinstance(preflight_receipt, dict) + or set(preflight_receipt) != {"apiVersion", "jobId", "sha256"} + or preflight_receipt + != { + "apiVersion": "itworx.forge-bootstrap-preflight/v1", + "jobId": profile.preflight_job_id, + "sha256": profile.preflight_sha256, + } + ): + return None + evidence = job.evidence_json + if ( + not isinstance(evidence, dict) + or set(evidence) != {"profile", "controller"} + or evidence.get("profile") != "forge-first-install" + ): + return None + historical_status, _stage, _summary, _safe_evidence = _classify_controller_result( + evidence.get("controller"), + request_id=job.broker_request_id, + profile=profile, + ) + if historical_status != "deployed": + return None + return job, profile + except Exception: # noqa: BLE001 - invalid stored job data is a fixed denial. + return None + + +def _readiness_identity( + value: object, + *, + job: DeploymentJob, + profile: ForgeBootstrapApplyV1, +) -> dict[str, Any] | None: + try: + readiness = ForgeRuntimeReadinessResultV1.model_validate(value) + except Exception: # noqa: BLE001 - the current readiness projection is untrusted input. + return None + now = int(time.time()) + artifact = readiness.controller_artifact + if ( + readiness.status != "ready" + or readiness.target != _TARGET + or readiness.job_id != job.id + or readiness.revision != profile.revision + or readiness.workspace_image_digest != profile.workspace_image_digest + or readiness.frontdoor_image_digest != profile.frontdoor_image_digest + or type(readiness.observed_at) is not int + or readiness.observed_at < now - 30 + or readiness.observed_at > now + 5 + or readiness.migration_completed is not True + or readiness.health.status != "healthy" + or tuple(item.name for item in readiness.health.services) != _READINESS_SERVICES + or any(item.status != "healthy" for item in readiness.health.services) + or artifact is None + or artifact.source_repository != "jens/unraid-appops-gateway" + or not re.fullmatch(r"[0-9a-f]{40}", artifact.source_revision) + or not re.fullmatch(r"[0-9a-f]{40}", artifact.source_tree) + or not _SHA256.fullmatch(artifact.artifact_sha256) + ): + return None + identity = { + "jobId": job.id, + "observedAt": readiness.observed_at, + "revision": profile.revision, + "workspaceImageDigest": profile.workspace_image_digest, + "frontdoorImageDigest": profile.frontdoor_image_digest, + "migrationCompleted": True, + "healthServices": list(_READINESS_SERVICES), + "controllerArtifact": { + "sourceRepository": artifact.source_repository, + "sourceRevision": artifact.source_revision, + "sourceTree": artifact.source_tree, + "artifactSha256": artifact.artifact_sha256, + }, + } + return {"sha256": hashlib.sha256(_canonical_json(identity)).hexdigest(), **identity} + + +def _network_ready(value: object) -> bool: + return bool( + isinstance(value, dict) + and set(value) == {"status", "network", "npmAttached", "frontdoorAttached"} + and value.get("status") == "ready" + and value.get("network") == _ROUTE_NETWORK + and value.get("npmAttached") is True + and value.get("frontdoorAttached") is True + ) + + +def _forge_route_npm_session(npm: NginxProxyManagerConnector) -> Any | None: + """Require the connector's fixed route-only HTTPS transport contract.""" + factory = getattr(npm, "forge_route_session", None) + if not callable(factory): + return None + try: + route_session = factory() + except Exception: # noqa: BLE001 - connector configuration is private. + return None + endpoint_sha256 = getattr(route_session, "endpoint_sha256", None) + if ( + getattr(route_session, "verified_tls", None) is not True + or not isinstance(endpoint_sha256, str) + or _SHA256.fullmatch(endpoint_sha256) is None + ): + return None + return route_session + + +def _desired_state(ownership_id: str) -> dict[str, Any]: + return { + "domain_names": [_ROUTE_HOST], + "forward_scheme": "http", + "forward_host": "itworx-forge", + "forward_port": 8080, + "access_list_id": 0, + "certificate_id": 0, + "enabled": False, + "ssl_forced": False, + "http2_support": False, + "hsts_enabled": False, + "hsts_subdomains": False, + "caching_enabled": False, + "block_exploits": True, + "allow_websocket_upgrade": False, + "trust_forwarded_proto": False, + "locations": [], + "meta": {"appops_forge_route_id": ownership_id}, + "advanced_config": "", + } + + +def _build_record( + *, + job: DeploymentJob, + profile: ForgeBootstrapApplyV1, + preflight_receipt: dict[str, Any], + idempotency_hash: str, + readiness: dict[str, Any], + ownership_id: str, + npm_endpoint_sha256: str, +) -> dict[str, Any]: + return { + "apiVersion": _INTENT_VERSION, + "jobId": job.id, + "idempotencyKeySha256": idempotency_hash, + "npmEndpointSha256": npm_endpoint_sha256, + "ownershipId": ownership_id, + "phase": "pending", + "target": _TARGET, + "repository": _REPOSITORY, + "revision": profile.revision, + "workspaceImageDigest": profile.workspace_image_digest, + "frontdoorImageDigest": profile.frontdoor_image_digest, + "preflightReceiptSha256": preflight_receipt["sha256"], + "readinessIdentity": readiness, + "desired": _desired_state(ownership_id), + } + + +def _record_valid(value: object, job_id: str) -> bool: + if not isinstance(value, dict) or set(value) != { + "apiVersion", + "jobId", + "idempotencyKeySha256", + "npmEndpointSha256", + "ownershipId", + "phase", + "target", + "repository", + "revision", + "workspaceImageDigest", + "frontdoorImageDigest", + "preflightReceiptSha256", + "readinessIdentity", + "desired", + }: + return False + owner = value.get("ownershipId") + identity = value.get("readinessIdentity") + if ( + value.get("apiVersion") != _INTENT_VERSION + or value.get("jobId") != job_id + or not isinstance(value.get("idempotencyKeySha256"), str) + or _SHA256.fullmatch(value["idempotencyKeySha256"]) is None + or not isinstance(value.get("npmEndpointSha256"), str) + or _SHA256.fullmatch(value["npmEndpointSha256"]) is None + or not isinstance(owner, str) + or _OWNERSHIP_ID.fullmatch(owner) is None + or value.get("phase") not in _INTENT_PHASES + or value.get("target") != _TARGET + or value.get("repository") != _REPOSITORY + or not isinstance(value.get("revision"), str) + or re.fullmatch(r"[0-9a-f]{40}", value["revision"]) is None + or not isinstance(value.get("workspaceImageDigest"), str) + or _DIGEST.fullmatch(value["workspaceImageDigest"]) is None + or not isinstance(value.get("frontdoorImageDigest"), str) + or _DIGEST.fullmatch(value["frontdoorImageDigest"]) is None + or not isinstance(value.get("preflightReceiptSha256"), str) + or _SHA256.fullmatch(value["preflightReceiptSha256"]) is None + or not isinstance(identity, dict) + or set(identity) + != { + "sha256", + "jobId", + "observedAt", + "revision", + "workspaceImageDigest", + "frontdoorImageDigest", + "migrationCompleted", + "healthServices", + "controllerArtifact", + } + or not isinstance(identity.get("sha256"), str) + or _SHA256.fullmatch(identity["sha256"]) is None + or identity.get("jobId") != job_id + or type(identity.get("observedAt")) is not int + or identity.get("revision") != value.get("revision") + or identity.get("workspaceImageDigest") != value.get("workspaceImageDigest") + or identity.get("frontdoorImageDigest") != value.get("frontdoorImageDigest") + or identity.get("migrationCompleted") is not True + or identity.get("healthServices") != list(_READINESS_SERVICES) + or not isinstance(identity.get("controllerArtifact"), dict) + or not isinstance(value.get("desired"), dict) + or value["desired"] != _desired_state(owner) + ): + return False + if hashlib.sha256( + _canonical_json({key: item for key, item in identity.items() if key != "sha256"}) + ).hexdigest() != identity["sha256"]: + return False + artifact = identity["controllerArtifact"] + return bool( + set(artifact) + == {"sourceRepository", "sourceRevision", "sourceTree", "artifactSha256"} + and artifact.get("sourceRepository") == "jens/unraid-appops-gateway" + and isinstance(artifact.get("sourceRevision"), str) + and re.fullmatch(r"[0-9a-f]{40}", artifact["sourceRevision"]) is not None + and isinstance(artifact.get("sourceTree"), str) + and re.fullmatch(r"[0-9a-f]{40}", artifact["sourceTree"]) + and isinstance(artifact.get("artifactSha256"), str) + and _SHA256.fullmatch(artifact["artifactSha256"]) is not None + ) + + +def _load_record(session: Session, job_id: str) -> tuple[str, dict[str, Any] | None]: + secret_ref = _journal_secret_ref(job_id) + row = session.get(Secret, secret_ref) + if row is None: + return "missing", None + raw = get_secret(session, secret_ref, touch=False) + if not isinstance(raw, str) or len(raw) > 16_384: + return "invalid", None + try: + value = json.loads( + raw, + object_pairs_hook=_reject_duplicate_json_keys, + parse_constant=_reject_json_constant, + ) + except (TypeError, ValueError): + return "invalid", None + if ( + not isinstance(value, dict) + or _canonical_json(value).decode("utf-8") != raw + or not _record_valid(value, job_id) + ): + return "invalid", None + return "present", value + + +def _save_record(session: Session, record: dict[str, Any], *, commit: bool = True) -> bool: + try: + if not _record_valid(record, record.get("jobId", "")): + return False + set_secret( + session, + _journal_secret_ref(record["jobId"]), + _canonical_json(record).decode("utf-8"), + used_by=[_ACTION], + metadata={"kind": "encrypted_forge_route_stage_intent"}, + commit=commit, + ) + return True + except Exception: # noqa: BLE001 - secret-store/database errors remain fixed failures. + try: + session.rollback() + except Exception: # noqa: BLE001 + pass + return False + + +def _normalized_domain(value: object) -> str | None: + if ( + not isinstance(value, str) + or not value + or len(value) > 254 + or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value) + ): + return None + domain = value[:-1] if value.endswith(".") else value + if not domain or domain.endswith("."): + return None + wildcard = domain.startswith("*.") + if "*" in domain and not wildcard: + return None + suffix = domain[2:] if wildcard else domain + try: + ascii_domain = suffix.encode("idna").decode("ascii").lower() + except UnicodeError: + return None + labels = ascii_domain.split(".") + if ( + not ascii_domain + or len(ascii_domain) > 253 + or any(not label or len(label) > 63 or _DOMAIN_LABEL.fullmatch(label) is None for label in labels) + ): + return None + return f"*.{ascii_domain}" if wildcard else ascii_domain + + +def _domain_matches(value: str) -> bool: + normalized = _normalized_domain(value) + if normalized == _ROUTE_HOST: + return True + if normalized is not None and normalized.startswith("*."): + suffix = normalized[2:] + # Treat any ancestor wildcard as a possible NPM match, including broad + # patterns such as *.tech, so route creation fails closed on overlap. + return _ROUTE_HOST.endswith(f".{suffix}") + return False + + +def _inventory_matches(value: object) -> tuple[str, list[dict[str, Any]]]: + if not isinstance(value, list): + return "unknown", [] + matches: list[dict[str, Any]] = [] + for host in value: + if not isinstance(host, dict): + return "unknown", [] + domains = host.get("domain_names") + if not isinstance(domains, list) or any(_normalized_domain(domain) is None for domain in domains): + return "unknown", [] + if any(_domain_matches(domain) for domain in domains): + matches.append(host) + return "ok", matches + + +def _host_is_exact_disabled(host: dict[str, Any], record: dict[str, Any]) -> bool: + expected = record["desired"] + for field in ( + "forward_scheme", + "forward_host", + "advanced_config", + ): + if type(host.get(field)) is not str or host[field] != expected[field]: + return False + for field in ( + "forward_port", + "access_list_id", + "certificate_id", + ): + if type(host.get(field)) is not int or host[field] != expected[field]: + return False + for field in ( + "enabled", + "ssl_forced", + "http2_support", + "hsts_enabled", + "hsts_subdomains", + "caching_enabled", + "block_exploits", + "allow_websocket_upgrade", + "trust_forwarded_proto", + ): + if type(host.get(field)) is not bool or host[field] is not expected[field]: + return False + host_id = host.get("id") + if type(host_id) is not int or host_id < 1: + return False + meta = host.get("meta") + return bool( + host.get("domain_names") == [_ROUTE_HOST] + and isinstance(meta, dict) + and meta.get("appops_forge_route_id") == record["ownershipId"] + and type(host.get("locations")) is list + and host["locations"] == [] + ) + + +def _inspect_inventory(value: object, record: dict[str, Any] | None) -> str: + status, matches = _inventory_matches(value) + if status != "ok": + return "unknown" + if len(matches) > 1: + return "conflict" + if not matches: + return "missing" + if record is None: + return "conflict" + host = matches[0] + meta = host.get("meta") + if not isinstance(meta, dict) or meta.get("appops_forge_route_id") != record["ownershipId"]: + return "conflict" + return "staged" if _host_is_exact_disabled(host, record) else "conflict" + + +def _reconcile_locked( + session: Session, + *, + job_id: str, + record: dict[str, Any], + npm: NginxProxyManagerConnector, + defer_phase_write: bool = False, +) -> dict[str, Any]: + endpoint_sha256 = getattr(npm, "endpoint_sha256", None) + if ( + getattr(npm, "verified_tls", None) is not True + or not isinstance(endpoint_sha256, str) + or _SHA256.fullmatch(endpoint_sha256) is None + ): + return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable") + if endpoint_sha256 != record.get("npmEndpointSha256"): + return _result(job_id, "conflict", "forge_route_endpoint_binding_changed") + try: + hosts = npm.list_forge_route_hosts() + except Exception: # noqa: BLE001 - do not expose NPM responses, URLs or credentials. + return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable") + state = _inspect_inventory(hosts, record) + if state == "staged": + if record.get("phase") != "host_staged_disabled": + record["phase"] = "host_staged_disabled" + if not defer_phase_write and not _save_record(session, record): + return _result(job_id, "outcome_unknown", "forge_route_journal_update_failed") + return _result(job_id, "host_staged_disabled", "forge_route_host_staged_disabled_not_routed") + if state == "conflict": + return _result(job_id, "conflict", "forge_route_ownership_or_state_conflict") + if state == "missing" and record.get("phase") == "aborted_before_post": + return _result(job_id, "blocked", "forge_route_policy_changed_before_mutation") + return _result(job_id, "outcome_unknown", "forge_route_host_state_unconfirmed") + + +def stage_forge_route( + session: Session, + *, + job_id: str, + idempotency_key: str, + actor_identity: str, + npm: NginxProxyManagerConnector | None = None, + portainer: PortainerConnector | None = None, +) -> dict[str, Any]: + """Stage one disabled, fixed Forge host after current readiness is proven.""" + normalized_key = idempotency_key.strip() if isinstance(idempotency_key, str) else "" + idempotency_hash = hashlib.sha256(normalized_key.encode("utf-8")).hexdigest() + actor = actor_identity.strip() if isinstance(actor_identity, str) else "" + connector = npm or NginxProxyManagerConnector() + network_reader = portainer or PortainerConnector() + policy_id: str | None = None + decision = "blocked" + mutation_attempted = False + outcome = _result(job_id, "blocked", "forge_route_stage_requirements_not_met") + try: + if _JOB_ID.fullmatch(job_id) is None or _IDEMPOTENCY.fullmatch(normalized_key) is None or not actor: + outcome = _result(job_id, "blocked", "forge_route_request_invalid") + else: + with _forge_oidc_process_lock(): + policy_id = _exact_route_policy_allowed(session) + if policy_id is None: + outcome = _result(job_id, "blocked", "forge_route_exact_manual_policy_required") + else: + decision = "allowed" + record_state, record = _load_record(session, job_id) + if record_state == "invalid": + outcome = _result(job_id, "conflict", "forge_route_journal_invalid") + elif record_state == "present" and record is not None: + if record["idempotencyKeySha256"] != idempotency_hash: + outcome = _result(job_id, "conflict", "forge_route_job_already_claimed") + else: + route_npm = _forge_route_npm_session(connector) + if route_npm is None: + outcome = _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable") + else: + outcome = _reconcile_locked( + session, job_id=job_id, record=record, npm=route_npm + ) + else: + verified = _verified_job(session, job_id) + if verified is None: + outcome = _result(job_id, "blocked", "forge_route_successful_job_required") + elif not _secret_store_ready(): + outcome = _result(job_id, "blocked", "forge_route_encrypted_journal_required") + else: + job, profile = verified + route_npm = _forge_route_npm_session(connector) + if route_npm is None: + outcome = _result(job_id, "blocked", "forge_route_verified_npm_tls_required") + else: + try: + initial_hosts = route_npm.list_forge_route_hosts() + except _NpmForgeRoutePermissionDenied: + outcome = _result( + job_id, + "blocked", + "forge_route_npm_permission_required", + ) + except Exception: # noqa: BLE001 - no connector diagnostics escape. + outcome = _result(job_id, "failed", "forge_route_npm_read_unavailable") + else: + inventory_state, matches = _inventory_matches(initial_hosts) + if inventory_state != "ok": + outcome = _result(job_id, "failed", "forge_route_npm_inventory_invalid") + elif matches: + outcome = _result(job_id, "conflict", "forge_route_existing_host_conflict") + elif not _network_ready(network_reader.forge_route_network_readiness()): + outcome = _result(job_id, "blocked", "forge_route_shared_network_not_ready") + else: + readiness_value = ForgeBootstrapApplyService().current_readiness( + session, job_id + ) + readiness = _readiness_identity( + readiness_value, job=job, profile=profile + ) + if readiness is None: + outcome = _result(job_id, "blocked", "forge_route_current_readiness_required") + else: + # Last authorization check occurs in the existing Forge lock, + # after fresh readiness and immediately before the journal/POST. + if _exact_route_policy_allowed(session) != policy_id: + outcome = _result(job_id, "blocked", "forge_route_exact_manual_policy_required") + else: + ownership_id = "froute-" + uuid4().hex + record = _build_record( + job=job, + profile=profile, + preflight_receipt=job.preflight_json, + idempotency_hash=idempotency_hash, + readiness=readiness, + ownership_id=ownership_id, + npm_endpoint_sha256=route_npm.endpoint_sha256, + ) + if not _save_record(session, record): + outcome = _result(job_id, "failed", "forge_route_journal_persist_failed") + elif _exact_route_policy_allowed(session) != policy_id: + record["phase"] = "aborted_before_post" + _save_record(session, record) + outcome = _result(job_id, "blocked", "forge_route_exact_manual_policy_required") + elif not _readiness_is_fresh(record["readinessIdentity"]): + record["phase"] = "aborted_before_post" + _save_record(session, record) + outcome = _result(job_id, "blocked", "forge_route_current_readiness_expired") + else: + mutation_attempted = True + try: + route_npm.create_forge_disabled_proxy_host(ownership_id) + except _NpmForgeRoutePermissionDenied: + mutation_attempted = False + record["phase"] = "aborted_before_post" + if _save_record(session, record): + outcome = _result( + job_id, + "blocked", + "forge_route_npm_permission_required", + ) + else: + outcome = _result( + job_id, + "outcome_unknown", + "forge_route_journal_update_failed", + ) + except Exception: # noqa: BLE001 - a failed POST is ambiguous. + record["phase"] = "outcome_unknown" + _save_record(session, record) + if mutation_attempted: + outcome = _reconcile_locked( + session, + job_id=job_id, + record=record, + npm=route_npm, + ) + except Exception: # noqa: BLE001 - no lock, connector, or DB detail reaches the caller. + outcome = _result( + job_id, + "outcome_unknown" if mutation_attempted else "failed", + "forge_route_operation_unavailable", + ) + + if not _audit( + session, + actor_identity=actor or "manual", + decision=decision, + policy_id=policy_id, + result=outcome, + idempotency_hash=idempotency_hash, + ): + return _result( + job_id, + "outcome_unknown" if mutation_attempted else "failed", + "forge_route_audit_persist_failed", + ) + return outcome + + +def reconcile_forge_route( + session: Session, + *, + job_id: str, + actor_identity: str, + npm: NginxProxyManagerConnector | None = None, +) -> dict[str, Any]: + """Read back the fixed host for an existing operation; this function never POSTs.""" + connector = npm or NginxProxyManagerConnector() + actor = actor_identity.strip() if isinstance(actor_identity, str) else "" + if _JOB_ID.fullmatch(job_id) is None: + return _result(job_id, "not_found", "forge_route_operation_not_found") + if not actor: + return _result(job_id, "failed", "forge_route_reconcile_unavailable") + try: + with _forge_oidc_process_lock(): + state, record = _load_record(session, job_id) + if state == "missing": + return _result(job_id, "not_found", "forge_route_operation_not_found") + if state != "present" or record is None: + return _result(job_id, "outcome_unknown", "forge_route_journal_invalid") + route_npm = _forge_route_npm_session(connector) + if route_npm is None: + return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable") + previous_phase = record["phase"] + result = _reconcile_locked( + session, + job_id=job_id, + record=record, + npm=route_npm, + defer_phase_write=True, + ) + if result["status"] == "host_staged_disabled" and previous_phase != record["phase"]: + if not _save_record(session, record, commit=False): + return _result(job_id, "outcome_unknown", "forge_route_journal_update_failed") + if not _audit( + session, + actor_identity=actor, + decision="allowed", + policy_id=None, + result=result, + idempotency_hash=record["idempotencyKeySha256"], + ): + return _result(job_id, "outcome_unknown", "forge_route_audit_persist_failed") + return result + except Exception: # noqa: BLE001 - status remains safe when stores/connectors fail. + return _result(job_id, "outcome_unknown", "forge_route_reconcile_unavailable") diff --git a/backend/app/services/gitea_project_auto_registration.py b/backend/app/services/gitea_project_auto_registration.py index 7c797b2..20071c7 100644 --- a/backend/app/services/gitea_project_auto_registration.py +++ b/backend/app/services/gitea_project_auto_registration.py @@ -28,6 +28,7 @@ _COMPOSE_CANDIDATES = ( ) # Must remain identical to DeploymentPlanV1.application_id. _APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$") +_COMPOSE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") _TRANSIENT_STATES = {"gitea_unavailable", "controller_unavailable", "registration_error"} _RETRY_STATES = _TRANSIENT_STATES | {"controller_conflict", "policy_conflict"} _DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback", "deployment.runnerless.provision") @@ -208,6 +209,7 @@ class GiteaProjectAutoRegistration: "composeFile", "composeProject", "healthTimeout", + "primaryService", "verifyOciRevision", } unknown = set(payload) - allowed @@ -223,14 +225,18 @@ class GiteaProjectAutoRegistration: raise ValueError("deployment descriptor applicationId is invalid") compose_file = str(payload.get("composeFile") or "").strip() compose_project = str(payload.get("composeProject") or application_id).strip().lower() - if compose_project != application_id: - raise ValueError("deployment descriptor composeProject must equal applicationId") + if not _COMPOSE_PROJECT.fullmatch(compose_project): + raise ValueError("deployment descriptor composeProject is invalid") + primary_service = str(payload.get("primaryService") or application_id).strip() + if not _APP_ID.fullmatch(primary_service): + raise ValueError("deployment descriptor primaryService is invalid") timeout = int(payload.get("healthTimeout") or 180) return { "applicationId": application_id, "composeFile": compose_file, "composeProject": compose_project, "healthTimeout": timeout, + "primaryService": primary_service, "verifyOciRevision": bool(payload.get("verifyOciRevision", False)), } @@ -303,8 +309,9 @@ class GiteaProjectAutoRegistration: if descriptor_present: application_id = str(descriptor["applicationId"]) - if application_id not in service_names: - raise ValueError("deployment descriptor applicationId must name a Compose service") + primary_service = str(descriptor["primaryService"]) + if primary_service not in service_names: + raise ValueError("deployment descriptor primaryService must name a Compose service") else: repository_id = self._application_id(repository_name) compose_name = str(compose.get("name") or "").strip().lower() if isinstance(compose, dict) else "" @@ -321,8 +328,9 @@ class GiteaProjectAutoRegistration: # explicit bounded descriptor to select its primary service. return None application_id = candidates[0] + primary_service = application_id - compose_project = application_id + compose_project = str(descriptor.get("composeProject") or application_id) containers: list[str] = [] for service_name, raw_service in services.items(): service = raw_service if isinstance(raw_service, dict) else {} @@ -334,6 +342,7 @@ class GiteaProjectAutoRegistration: "applicationId": application_id, "composeFile": compose_file, "composeProject": compose_project, + "primaryService": primary_service, "containers": containers, "serviceNames": service_names, "healthTimeout": int(descriptor.get("healthTimeout") or 180), @@ -510,6 +519,7 @@ class GiteaProjectAutoRegistration: "applicationId": application_id, "composeFile": contract["composeFile"], "composeProject": contract["composeProject"], + "primaryService": contract.get("primaryService", application_id), "containers": list(contract["containers"]), "serviceNames": list(contract["serviceNames"]), } diff --git a/backend/app/services/mcp_hub_deployment_reconciliation.py b/backend/app/services/mcp_hub_deployment_reconciliation.py index 82a6c35..6212a27 100644 --- a/backend/app/services/mcp_hub_deployment_reconciliation.py +++ b/backend/app/services/mcp_hub_deployment_reconciliation.py @@ -337,17 +337,33 @@ def fixed_image_reference(service_name, image_ref): ) ) if service_name == "postgres": - return image_ref == "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a" or ( - image_ref == "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04" - and main_declares_external_image(image_ref) - ) + if image_ref == "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a": + return True + if image_ref in { + "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04", + ( + "gitea.example.com/jens/itworx-mcp-hub/postgres:" + "wp400-gosu-v2-20261002-a94e0b4d2029@sha256:" + "a60d81e06987671ae903757f1b71cdfbbff4d481b42610e4de2b7dcfa32aad73" + ), + }: + return main_declares_external_image(image_ref) + return False if service_name == "ingestion-clamd": return image_ref == "clamav/clamav:1.4.6@sha256:a5f03c12a79dbe9f6d8a527b6bb1ea053fa8dd061d3738a26897f055ee2d9303" if service_name == "redis": - return image_ref == "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236" or ( - image_ref == "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48" - and main_declares_external_image(image_ref) - ) + if image_ref == "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236": + return True + if image_ref in { + "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48", + ( + "gitea.example.com/jens/itworx-mcp-hub/redis:" + "wp400-remediation-20261002-4e18f61c@sha256:" + "7d826b44f245ad32e8fe4bf40d42eae95855c3942c5f2e42784ee8fd3f9547a2" + ), + }: + return main_declares_external_image(image_ref) + return False if service_name == "contextforge": return main_declares_contextforge_compat_image( "itworx-mcp-hub-contextforge-1", image_ref diff --git a/backend/app/services/policy_engine.py b/backend/app/services/policy_engine.py index d693ee4..2f7e99f 100644 --- a/backend/app/services/policy_engine.py +++ b/backend/app/services/policy_engine.py @@ -187,6 +187,29 @@ def evaluate_policy( "scope_decision": "allowed" if allowed else None, } + if action == "forge.route.stage": + # This narrowly scoped external-route mutation never inherits the + # generic manual fallback or a wildcard grant. The row is seeded + # blocked and must be explicitly enabled for the one Forge target. + policies = session.exec(select(Policy).where(Policy.action == action)).all() + policy = policies[0] if len(policies) == 1 else None + allowed = bool( + source == "manual" + and target == "itworx-forge" + and policy is not None + and policy.target == "itworx-forge" + and policy.manual == "allowed" + and policy.voice == "blocked" + and policy.risk == "critical" + ) + return { + "decision": "allowed" if allowed else "blocked", + "reason": "Forge route staging requires one exact-target manual policy; voice is always blocked.", + "risk": "critical", + "policy_id": policy.id if allowed and policy is not None else None, + "scope_decision": "allowed" if allowed else None, + } + if action in _DEFAULT_DECISIONS: reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.') return { diff --git a/backend/tests/test_authentik_connector.py b/backend/tests/test_authentik_connector.py index e0c1f80..f7e6153 100644 --- a/backend/tests/test_authentik_connector.py +++ b/backend/tests/test_authentik_connector.py @@ -42,7 +42,7 @@ def _set_npm_config(): settings.apis["NPM_URL"] = "https://npm.example.test" settings.apis["NPM_IDENTITY"] = "admin@example.test" settings.apis["NPM_SECRET"] = "NPM_SECRET_VALUE" - settings.apis["NPM_VERIFY_TLS"] = False + settings.apis["NPM_VERIFY_TLS"] = True settings.apis["NPM_TIMEOUT_SECONDS"] = "10" diff --git a/backend/tests/test_controller_artifact_bootstrap.py b/backend/tests/test_controller_artifact_bootstrap.py index 482fde5..6d62b12 100644 --- a/backend/tests/test_controller_artifact_bootstrap.py +++ b/backend/tests/test_controller_artifact_bootstrap.py @@ -1,12 +1,15 @@ from __future__ import annotations import ast +import io import json import os import re import shutil import subprocess import sys +import tarfile +from dataclasses import replace from pathlib import Path from typing import Any @@ -24,6 +27,10 @@ CONTROLLER_IMAGE_ID = "sha256:c474d435a4f55ac5205570da0caa47e103f89a6c2ef96ea458 CONTROLLER_IMAGE_REF = "docker.gitea.com/runner-images:ubuntu-latest" CONTROLLER_REVISION = "034c9f844d2455850ba37b31379b7edaa786583e" CONTROLLER_TREE = "6b8e92942e6e5eca8a9aa9da0a1509b2019d7b7b" +MODERN_CONTROLLER_CONTAINER_ID = "9093c8e29d9064f9948918e5884f9adc5db6b740991e2934902f93c6ea813e01" +MODERN_CONTROLLER_REVISION = "e854334923cca574482d22b8424ba3ce46ec6aa1" +MODERN_CONTROLLER_TREE = "cf2a117f9222b952deb9902c646116612f3a7ea0" +MODERN_CONTROLLER_ARTIFACT_SHA256 = "e88739bf091c8bac77ea0db5b97300666a1baa8ba984bbf6ae91e3bf25719e08" REVISION = "a" * 40 TREE = "b" * 40 CALLER_ID = "c" * 64 @@ -247,15 +254,39 @@ def _modern_controller_mounts(source_revision: str) -> list[dict[str, Any]]: ] -def _modern_controller_inspect(source_revision: str) -> dict[str, Any]: +def _modern_controller_source_mounts(source_revision: str) -> list[dict[str, Any]]: + mounts = [dict(mount) for mount in _modern_controller_mounts(source_revision)] + mounts = [mount for mount in mounts if mount["Destination"] != "/forge-master-key-backup"] + for mount in mounts: + if mount["Destination"] in {"/opt/gitea-deploy", "/controller-config"}: + mount["Propagation"] = "rslave" + return mounts + + +def _modern_controller_inspect( + source_revision: str, + *, + identifier: str = MODERN_CONTROLLER_CONTAINER_ID, +) -> dict[str, Any]: controller = _controller_inspect() - controller.update({"Id": "e" * 64, "Image": CONTROLLER_IMAGE_ID}) + controller.update({"Id": identifier, "Image": CONTROLLER_IMAGE_ID}) + controller["State"].update({"Dead": False, "Paused": False, "Restarting": False}) controller["Config"]["Image"] = CONTROLLER_IMAGE_ID controller["Config"]["Labels"] = _modern_controller_labels() controller["Mounts"] = _modern_controller_mounts(source_revision) return controller +def _modern_controller_source_inspect( + source_revision: str, + *, + identifier: str = MODERN_CONTROLLER_CONTAINER_ID, +) -> dict[str, Any]: + controller = _modern_controller_inspect(source_revision, identifier=identifier) + controller["Mounts"] = _modern_controller_source_mounts(source_revision) + return controller + + def _appops_inspect() -> dict[str, Any]: labels = _appops_labels() return { @@ -480,7 +511,9 @@ class FakeDocker: def start_container(self, identifier: str) -> None: self.started.append(identifier) source_revision = self.created[-1]["Labels"]["itworx.source.revision"] - self.containers["gitea-deploy-control"] = _modern_controller_inspect(source_revision) + self.containers["gitea-deploy-control"] = _modern_controller_inspect( + source_revision, identifier="e" * 64 + ) def remove_retired_failed_container(self, identifier: str) -> None: assert identifier == RETIRED_HELPER_ID @@ -505,6 +538,8 @@ class FakeProtectedGit: self.main_checks = 0 self.fail_current_main_at: int | None = None self.retired_tree_valid = True + self.controller_release_checks: list[bootstrap.ControllerBaseline] = [] + self.invalid_controller_release: bootstrap.ControllerBaseline | None = None def verify_current_main(self, revision: str, tree: str) -> None: self.main_checks += 1 @@ -517,6 +552,11 @@ class FakeProtectedGit: if (revision, tree) != (LEGACY_REVISION, LEGACY_TREE): raise bootstrap.BootstrapError("controller_promoter_legacy_provenance_invalid") + def verify_controller_release(self, baseline: bootstrap.ControllerBaseline) -> None: + self.controller_release_checks.append(baseline) + if baseline not in bootstrap.CONTROLLER_BASELINES or baseline == self.invalid_controller_release: + raise bootstrap.BootstrapError("controller_promoter_controller_provenance_invalid") + def verify_retired_modern_revision(self, revision: str, tree: str) -> None: if (revision, tree) != ( bootstrap.RETIRED_FAILED_MODERN_REVISION, @@ -570,6 +610,15 @@ def _health( } +def _modern_baseline_health() -> dict[str, Any]: + return _health( + revision=MODERN_CONTROLLER_REVISION, + tree=MODERN_CONTROLLER_TREE, + version="2.1.14", + artifact_sha256=MODERN_CONTROLLER_ARTIFACT_SHA256, + ) + + def _run_bootstrap( docker: FakeDocker, git: FakeProtectedGit, @@ -610,6 +659,176 @@ def _run_bootstrap( ) +def test_protected_git_reproduces_pinned_release_with_exact_handoff_crlf_conversion( + monkeypatch: pytest.MonkeyPatch, +): + git_executable = shutil.which("git") + if git_executable is None: + pytest.skip("git is required to validate the pinned source archives") + + probe = subprocess.run( + [git_executable, "cat-file", "-e", f"{CONTROLLER_REVISION}^{{commit}}"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + check=False, + ) + if probe.returncode != 0: + pytest.skip("the checkout does not expose the retained historical commits to Git") + + def local_git_output(arguments: list[str], *, limit: int = bootstrap.MAX_GIT_BLOB_BYTES) -> bytes: + result = subprocess.run( + [git_executable, "-c", f"safe.directory={ROOT}", *arguments], + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=15, + check=False, + ) + if result.returncode != 0: + raise bootstrap.BootstrapError("controller_promoter_protected_git_invalid") + if len(result.stdout) > limit: + raise bootstrap.BootstrapError("controller_promoter_protected_git_invalid") + return result.stdout + + monkeypatch.setattr(bootstrap, "_git_output", local_git_output) + repository = bootstrap.ProtectedGitRepository() + for baseline in ( + bootstrap.LEGACY_CONTROLLER_BASELINE, + bootstrap.MODERN_CONTROLLER_BASELINE, + ): + repository.verify_controller_release(baseline) + with pytest.raises( + bootstrap.BootstrapError, + match="controller_promoter_controller_provenance_invalid", + ): + repository.verify_controller_release( + replace(baseline, tree="f" * 40) + ) + archive_paths = [ + "scripts/run_repopulse_adoption.py" + if name == "run_repopulse_adoption.py" + else f"ops/gitea-deploy-broker/{name}" + for name in baseline.artifact_files + ] + archive = local_git_output( + ["archive", "--format=tar", baseline.revision, "--", *archive_paths], + limit=bootstrap.MAX_CONTROLLER_ARTIFACT_ARCHIVE_BYTES, + ) + assert bootstrap._controller_artifact_archive_digest( + archive, baseline + ) == baseline.artifact_sha256 + + with pytest.raises( + bootstrap.BootstrapError, + match="controller_promoter_controller_provenance_invalid", + ): + bootstrap._controller_artifact_source_bytes( + replace(baseline, revision="f" * 40), + bootstrap.CONTROLLER_CRLF_HANDOFF, + b"source", + ) + + corrupted_archive = _rewrite_controller_archive( + archive, + mutate_path="ops/gitea-deploy-broker/deploy.py", + mutate=lambda content: content + b"# changed\n", + ) + assert bootstrap._controller_artifact_archive_digest( + corrupted_archive, baseline + ) != baseline.artifact_sha256 + + renamed_archive = _rewrite_controller_archive( + archive, + rename_path="scripts/run_repopulse_adoption.py", + new_path="scripts/unreviewed.py", + ) + with pytest.raises( + bootstrap.BootstrapError, + match="controller_promoter_controller_provenance_invalid", + ): + bootstrap._controller_artifact_archive_digest(renamed_archive, baseline) + + handoff_path = "scripts/run_repopulse_adoption.py" + corrupted_handoff = _rewrite_controller_archive( + archive, + mutate_path=handoff_path, + mutate=lambda content: content + b"# changed\n", + ) + with pytest.raises( + bootstrap.BootstrapError, + match="controller_promoter_controller_provenance_invalid", + ): + bootstrap._controller_artifact_archive_digest(corrupted_handoff, baseline) + + crlf_source_archive = _rewrite_controller_archive( + archive, + mutate_path=handoff_path, + mutate=lambda content: content.replace(b"\n", b"\r\r\n"), + ) + with pytest.raises( + bootstrap.BootstrapError, + match="controller_promoter_controller_provenance_invalid", + ): + bootstrap._controller_artifact_archive_digest(crlf_source_archive, baseline) + + +def _rewrite_controller_archive( + archive: bytes, + *, + rename_path: str | None = None, + new_path: str | None = None, + mutate_path: str | None = None, + mutate: Any | None = None, +) -> bytes: + output = io.BytesIO() + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as original: + with tarfile.open(fileobj=output, mode="w:") as rewritten: + for member in original.getmembers(): + if member.isdir(): + rewritten.addfile(member) + continue + body = original.extractfile(member) + assert body is not None + content = body.read() + if member.name == rename_path: + assert new_path is not None + member.name = new_path + if member.name == mutate_path: + assert mutate is not None + content = mutate(content) + member.size = len(content) + rewritten.addfile(member, io.BytesIO(content)) + return output.getvalue() + + +def test_controller_source_provenance_failure_precedes_health_and_side_effects(): + helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( + encoding="utf-8" + ) + docker = FakeDocker(_legacy_helper_spec()) + git = FakeProtectedGit(helper_source) + git.invalid_controller_release = bootstrap.MODERN_CONTROLLER_BASELINE + + with pytest.raises( + bootstrap.BootstrapError, + match="controller_promoter_controller_provenance_invalid", + ): + _run_bootstrap( + docker, + git, + health_sequence=[_modern_baseline_health()], + ) + + assert git.controller_release_checks == [bootstrap.MODERN_CONTROLLER_BASELINE] + assert docker.removed == [] + assert docker.created == [] + assert docker.started == [] + + def _retired_failed_child(helper_source: str) -> dict[str, Any]: spec = bootstrap.render_modern_helper_spec( helper_source, @@ -678,6 +897,87 @@ def test_initial_controller_runtime_accepts_exact_current_compose_container(): ) +def test_modern_source_baseline_accepts_its_fixed_source_mount_profile(): + docker = FakeDocker(_legacy_helper_spec()) + docker.containers["gitea-deploy-control"] = _modern_controller_source_inspect( + MODERN_CONTROLLER_REVISION + ) + base_image_labels = bootstrap._inspect_controller_base_image(docker) + + assert ( + bootstrap._verify_controller_runtime( + docker, + health_payload=_modern_baseline_health(), + base_image_labels=base_image_labels, + ) + == base_image_labels + ) + + +def test_modern_source_mount_profile_is_distinct_from_postpromotion_topology(): + source_mounts = _modern_controller_source_mounts(MODERN_CONTROLLER_REVISION) + desired_mounts = _modern_controller_mounts(MODERN_CONTROLLER_REVISION) + + assert len(source_mounts) == 10 + assert len(desired_mounts) == 11 + assert bootstrap._modern_controller_source_mounts_match( + source_mounts, MODERN_CONTROLLER_REVISION + ) + assert not bootstrap._modern_controller_source_mounts_match( + desired_mounts, MODERN_CONTROLLER_REVISION + ) + assert not bootstrap._modern_controller_source_mounts_match( + source_mounts, "9" * 40 + ) + assert bootstrap._modern_controller_mounts_match( + desired_mounts, MODERN_CONTROLLER_REVISION + ) + + docker = FakeDocker(_legacy_helper_spec()) + docker.containers["gitea-deploy-control"] = _modern_controller_source_inspect( + MODERN_CONTROLLER_REVISION + ) + assert not bootstrap._verified_live_controller( + docker, source_revision=MODERN_CONTROLLER_REVISION + ) + + +@pytest.mark.parametrize( + "mutation", + [ + "missing_mount", + "extra_backup_mount", + "artifact_propagation", + "config_propagation", + "integer_rw", + "duplicate_mount", + ], +) +def test_modern_source_mount_profile_rejects_topology_drift(mutation: str): + mounts = _modern_controller_source_mounts(MODERN_CONTROLLER_REVISION) + if mutation == "missing_mount": + mounts.pop() + elif mutation == "extra_backup_mount": + backup = next( + mount + for mount in _modern_controller_mounts(MODERN_CONTROLLER_REVISION) + if mount["Destination"] == "/forge-master-key-backup" + ) + mounts.append(backup) + elif mutation == "artifact_propagation": + mounts[3]["Propagation"] = "rprivate" + elif mutation == "config_propagation": + mounts[9]["Propagation"] = "rprivate" + elif mutation == "integer_rw": + mounts[1]["RW"] = 0 + elif mutation == "duplicate_mount": + mounts.append(dict(mounts[0])) + + assert not bootstrap._modern_controller_source_mounts_match( + mounts, MODERN_CONTROLLER_REVISION + ) + + def test_immutable_legacy_helper_source_fixture_passes_bootstrap_identity_guard(): legacy_spec = _legacy_helper_spec() docker = FakeDocker(legacy_spec) @@ -1002,7 +1302,13 @@ def test_exact_failed_historical_child_is_removed_before_current_helper_launch() result = _run_bootstrap( docker, git, - health_sequence=[legacy_health, legacy_health, legacy_health, _health()], + health_sequence=[ + legacy_health, + legacy_health, + legacy_health, + legacy_health, + _health(), + ], ) assert result["status"] == "promoted" @@ -1015,6 +1321,174 @@ def test_exact_failed_historical_child_is_removed_before_current_helper_launch() ) == 1 +def test_current_214_baseline_is_verified_and_bound_to_new_helper(): + helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( + encoding="utf-8" + ) + docker = FakeDocker(_legacy_helper_spec()) + docker.containers["gitea-deploy-control"] = _modern_controller_source_inspect( + MODERN_CONTROLLER_REVISION + ) + git = FakeProtectedGit(helper_source) + + result = _run_bootstrap( + docker, + git, + health_sequence=[_modern_baseline_health(), _health(), _health()], + ) + + assert result["status"] == "promoted" + assert git.controller_release_checks == [bootstrap.MODERN_CONTROLLER_BASELINE] + assert docker.created[0]["Labels"] == { + "itworx.controller.promoter": "true", + "itworx.source.repository": "jens/unraid-appops-gateway", + "itworx.source.revision": REVISION, + "itworx.controller.version": "2.1.14", + "itworx.controller.bootstrap.target.id": MODERN_CONTROLLER_CONTAINER_ID, + "itworx.controller.bootstrap.target.image": CONTROLLER_IMAGE_ID, + "itworx.controller.bootstrap.target.revision": MODERN_CONTROLLER_REVISION, + "itworx.controller.bootstrap.target.tree": MODERN_CONTROLLER_TREE, + "itworx.controller.bootstrap.target.version": "2.1.14", + } + + +def test_retired_failed_child_is_removed_only_after_current_214_profile_rechecks(): + helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( + encoding="utf-8" + ) + failed_child = _retired_failed_child(helper_source) + docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child) + docker.containers["gitea-deploy-control"] = _modern_controller_source_inspect( + MODERN_CONTROLLER_REVISION + ) + git = FakeProtectedGit(helper_source) + + result = _run_bootstrap( + docker, + git, + health_sequence=[ + _modern_baseline_health(), + _modern_baseline_health(), + _modern_baseline_health(), + _modern_baseline_health(), + _health(), + _health(), + ], + ) + + assert result["status"] == "promoted" + assert docker.removed == [RETIRED_HELPER_ID] + assert docker.created[0]["Labels"]["itworx.controller.bootstrap.target.id"] == ( + MODERN_CONTROLLER_CONTAINER_ID + ) + + +@pytest.mark.parametrize( + "mutation", + [ + "container_id", + "image_id", + "source_revision", + "source_tree", + "artifact_digest", + "version", + "compose_label", + "extra_label", + "missing_label", + "extra_mount", + "missing_mount", + "writable_mount", + "integer_rw_mount", + "missing_rw_mount", + "malformed_mount_source", + "malformed_mount_name", + "mount_alias", + "dead_controller", + "paused_controller", + "restarting_controller", + "missing_state_flag", + "health_revision", + "health_tree", + "health_artifact", + "health_version", + ], +) +def test_retired_cleanup_refuses_any_current_214_identity_or_topology_drift(mutation: str): + helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( + encoding="utf-8" + ) + failed_child = _retired_failed_child(helper_source) + docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child) + controller = _modern_controller_source_inspect(MODERN_CONTROLLER_REVISION) + health = _modern_baseline_health() + if mutation == "container_id": + controller["Id"] = "9" * 64 + elif mutation == "image_id": + controller["Image"] = "sha256:" + "9" * 64 + elif mutation == "source_revision": + controller["Mounts"][3]["Source"] = ( + "/var/lib/docker/volumes/itworx-controller-hub-releases/_data/" + + "9" * 40 + ) + elif mutation == "source_tree": + health["artifact"]["sourceTree"] = "9" * 40 + elif mutation == "artifact_digest": + health["artifact"]["artifactSha256"] = "9" * 64 + elif mutation == "version": + controller["Config"]["Labels"]["org.opencontainers.image.version"] = "forged" + elif mutation == "compose_label": + controller["Config"]["Labels"]["com.docker.compose.service"] = "other" + elif mutation == "extra_label": + controller["Config"]["Labels"]["unexpected"] = "value" + elif mutation == "missing_label": + del controller["Config"]["Labels"]["tech.itworx.role"] + elif mutation == "extra_mount": + controller["Mounts"].append(dict(controller["Mounts"][0])) + controller["Mounts"][-1]["Destination"] = "/unexpected" + elif mutation == "missing_mount": + controller["Mounts"].pop() + elif mutation == "writable_mount": + controller["Mounts"][1]["RW"] = True + elif mutation == "integer_rw_mount": + controller["Mounts"][1]["RW"] = 0 + elif mutation == "missing_rw_mount": + del controller["Mounts"][1]["RW"] + elif mutation == "malformed_mount_source": + controller["Mounts"][1]["Source"] = 123 + elif mutation == "malformed_mount_name": + controller["Mounts"][8]["Name"] = None + elif mutation == "mount_alias": + controller["Mounts"][9]["Source"] = "/tmp/controller-config" + elif mutation == "dead_controller": + controller["State"]["Dead"] = True + elif mutation == "paused_controller": + controller["State"]["Paused"] = True + elif mutation == "restarting_controller": + controller["State"]["Restarting"] = True + elif mutation == "missing_state_flag": + del controller["State"]["Dead"] + elif mutation == "health_revision": + health["artifact"]["sourceRevision"] = "9" * 40 + elif mutation == "health_tree": + health["artifact"]["sourceTree"] = "9" * 40 + elif mutation == "health_artifact": + health["artifact"]["artifactSha256"] = "9" * 64 + elif mutation == "health_version": + health["artifact"]["controllerVersion"] = "2.1.13" + docker.containers["gitea-deploy-control"] = controller + + with pytest.raises(bootstrap.BootstrapError): + _run_bootstrap( + docker, + FakeProtectedGit(helper_source), + health_sequence=[health, health, health], + ) + + assert docker.removed == [] + assert docker.created == [] + assert docker.started == [] + + @pytest.mark.parametrize( "mutation", [ diff --git a/backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py b/backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py new file mode 100644 index 0000000..ff1d2d9 --- /dev/null +++ b/backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import copy +import subprocess + +import pytest + +from scripts import bootstrap_controller_artifact_promoter as bootstrap +from backend.tests import test_controller_artifact_bootstrap as original + +FAILED_REVISION = "1" * 40 +FAILED_TREE = "2" * 40 +FAILED_ID = "3" * 64 + + +class Git(original.FakeProtectedGit): + def verify_failed_modern_ancestor(self, revision, current_revision): + if ( + revision != FAILED_REVISION + or current_revision != self.revision + or not self.retired_tree_valid + ): + raise bootstrap.BootstrapError( + "controller_promoter_failed_helper_not_ancestor" + ) + return FAILED_TREE + + def read_blob(self, revision, path): + if revision == FAILED_REVISION: + self.blobs_read.append((revision, path)) + assert path == bootstrap.MODERN_HELPER_SOURCE_PATH + return self.helper_source.encode() + return super().read_blob(revision, path) + + +class Docker(original.FakeDocker): + def inspect_container(self, reference): + if reference == FAILED_ID: + return self.existing_child + return super().inspect_container(reference) + + def remove_retired_failed_container(self, identifier): + assert identifier == FAILED_ID + self.removed.append(identifier) + self.existing_child = None + + +def inputs(): + source = (original.ROOT / bootstrap.MODERN_HELPER_SOURCE_PATH).read_text() + child = original._retired_failed_child(source) + baseline = bootstrap.MODERN_CONTROLLER_BASELINE + spec = bootstrap.render_modern_helper_spec( + source, + revision=FAILED_REVISION, + image_id=baseline.image_id, + current_controller_id=baseline.container_id, + current_controller_revision=baseline.revision, + current_controller_tree=baseline.tree, + current_controller_version=baseline.version, + ) + child["Id"] = FAILED_ID + child["Config"].update( + { + "Hostname": original.HOST_HOSTNAME, + "Cmd": spec["Cmd"], + "Labels": {**original._controller_labels(), **spec["Labels"]}, + "Env": ["PATH=/usr/bin"], + "WorkingDir": "/tmp", + "Entrypoint": None, + "Domainname": "", + "AttachStdin": False, + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "OpenStdin": False, + "StdinOnce": False, + "OnBuild": None, + } + ) + child["State"].update({"Paused": False, "Restarting": False, "Dead": False}) + child["HostConfig"].update(copy.deepcopy(bootstrap.FAILED_HELPER_HOST_DEFAULTS)) + child["HostConfig"]["RestartPolicy"] = {"Name": "no", "MaximumRetryCount": 0} + docker = Docker(original._legacy_helper_spec(), existing_child=child) + docker.containers[bootstrap.CONTROLLER_NAME] = ( + original._modern_controller_source_inspect(baseline.revision) + ) + docker.images[baseline.image_id]["Config"].update( + { + "Env": ["PATH=/usr/bin"], + "WorkingDir": "/tmp", + "Entrypoint": None, + "Shell": ["/bin/bash", "-c"], + } + ) + return docker, Git(source), child + + +def run(docker, git): + return original._run_bootstrap( + docker, + git, + health_sequence=[ + original._modern_baseline_health(), + original._modern_baseline_health(), + original._modern_baseline_health(), + original._modern_baseline_health(), + original._health(), + original._health(), + ], + ) + + +def test_noncurrent_protected_ancestor_failure_releases_only_its_verified_name(): + docker, git, child = inputs() + result = run(docker, git) + assert result["status"] == "promoted" + assert docker.removed == [FAILED_ID] + assert ( + git.blobs_read.count((FAILED_REVISION, bootstrap.MODERN_HELPER_SOURCE_PATH)) + == 1 + ) + assert ( + docker.created[0]["Labels"]["itworx.controller.bootstrap.target.id"] + == bootstrap.MODERN_CONTROLLER_ID + ) + + +@pytest.mark.parametrize( + "change", + [ + "running", + "succeeded", + "bool_exit", + "paused", + "dead", + "restarting", + "source", + "target", + "command", + "entrypoint", + "environment", + "mount", + "host_capability", + "controller", + "appops", + "ancestor", + ], +) +def test_noncurrent_failed_child_recovery_refuses_unattested_identity_or_baseline( + change, +): + docker, git, child = inputs() + if change == "running": + child["State"].update(Running=True, Status="running") + elif change == "succeeded": + child["State"]["ExitCode"] = 0 + elif change == "bool_exit": + child["State"]["ExitCode"] = True + elif change in {"paused", "dead", "restarting"}: + child["State"][change.capitalize()] = True + elif change == "source": + child["Config"]["Labels"]["itworx.source.repository"] = "other/repository" + elif change == "target": + child["Config"]["Labels"]["itworx.controller.bootstrap.target.id"] = "9" * 64 + elif change == "command": + child["Config"]["Cmd"][2] += "\necho changed" + elif change == "entrypoint": + child["Config"]["Entrypoint"] = ["/bin/sh"] + elif change == "environment": + child["Config"]["Env"].append("CUSTOM_VARIABLE=extra") + elif change == "mount": + child["Mounts"][2]["RW"] = True + elif change == "host_capability": + child["HostConfig"]["CapAdd"] = ["SYS_ADMIN"] + elif change == "controller": + docker.containers[bootstrap.CONTROLLER_NAME]["Id"] = "9" * 64 + elif change == "appops": + docker.containers[original.APPOPS_CONTAINER_ID]["Image"] = "sha256:" + "9" * 64 + else: + git.retired_tree_valid = False + with pytest.raises(bootstrap.BootstrapError): + run(docker, git) + assert docker.removed == docker.created == docker.started == [] + + +def test_noncurrent_child_identity_drift_on_final_named_reread_refuses_delete(): + docker, git, child = inputs() + docker.named_inspect_override = copy.deepcopy(child) + docker.named_inspect_override["Id"] = "9" * 64 + docker.named_inspect_override_at = 3 + with pytest.raises(bootstrap.BootstrapError): + run(docker, git) + assert not docker.removed + + +def test_dynamic_failed_child_delete_retains_nonforce_and_no_volume_policy(monkeypatch): + client = bootstrap.DockerSocketClient() + calls = [] + monkeypatch.setattr( + client, + "_request", + lambda method, path, **kw: (calls.append((method, path)) or (204, b"")), + ) + client.remove_retired_failed_container(FAILED_ID) + assert calls == [("DELETE", f"/containers/{FAILED_ID}?force=false&v=false")] + + +@pytest.mark.parametrize( + "change", + ["cpu", "dns", "masked_paths", "logging", "boolean_default", "unknown_option"], +) +def test_failed_child_requires_complete_known_host_defaults(change): + docker, git, child = inputs() + if change == "cpu": + child["HostConfig"]["CpuShares"] = 100 + elif change == "dns": + child["HostConfig"]["Dns"] = ["192.0.2.1"] + elif change == "masked_paths": + child["HostConfig"]["MaskedPaths"] = [] + elif change == "logging": + child["HostConfig"]["LogConfig"]["Type"] = "none" + elif change == "boolean_default": + child["HostConfig"]["CpuShares"] = False + else: + child["HostConfig"]["UnreviewedOption"] = None + with pytest.raises(bootstrap.BootstrapError): + run(docker, git) + assert not docker.removed + + +def test_raw_docker_api_nil_mount_omission_matches_cli_null(): + docker, git, child = inputs() + child["HostConfig"].pop("Mounts") + assert run(docker, git)["status"] == "promoted" + + +def test_appops_drift_on_final_baseline_reread_refuses_cleanup(): + docker, git, child = inputs() + docker.appops_inspect_override = copy.deepcopy( + docker.containers[original.APPOPS_CONTAINER_ID] + ) + docker.appops_inspect_override["State"]["Running"] = False + docker.appops_inspect_override_at = 3 + with pytest.raises(bootstrap.BootstrapError): + run(docker, git) + assert not docker.removed + + +def test_protected_main_drift_immediately_before_delete_refuses_cleanup(): + docker, git, child = inputs() + git.fail_current_main_at = 5 + with pytest.raises(bootstrap.BootstrapError): + run(docker, git) + assert not docker.removed + + +@pytest.mark.parametrize( + "identifier", ["", "short", "A" * 64, "3" * 63, "../containers/other"] +) +def test_delete_adapter_rejects_noncanonical_full_container_identity( + monkeypatch, identifier +): + client = bootstrap.DockerSocketClient() + monkeypatch.setattr( + client, + "_request", + lambda *_args, **_kw: pytest.fail("unexpected Docker mutation"), + ) + with pytest.raises(bootstrap.BootstrapError): + client.remove_retired_failed_container(identifier) + + +@pytest.mark.parametrize("scenario", ["ancestor", "unrelated", "replace_ref"]) +def test_git_ancestor_proof_reads_original_protected_objects( + tmp_path, monkeypatch, scenario +): + def git(*args): + return ( + subprocess.run( + ["git", "-C", str(tmp_path), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + .stdout.decode() + .strip() + ) + + git("init", "-q", "-b", "main") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.invalid") + path = tmp_path / "tracked" + path.write_text("protected ancestor") + git("add", ".") + git("commit", "-qm", "ancestor") + old = git("rev-parse", "HEAD") + tree = git("rev-parse", "HEAD^{tree}") + path.write_text("new protected main") + git("commit", "-qam", "main") + current = git("rev-parse", "HEAD") + monkeypatch.setattr(bootstrap, "PROTECTED_REPO", str(tmp_path / ".git")) + proof = bootstrap.ProtectedGitRepository() + if scenario == "replace_ref": + git("replace", old, current) + assert git("rev-parse", f"{old}^{{tree}}") != tree + if scenario == "unrelated": + git("checkout", "-qb", "side", old) + path.write_text("unrelated child") + git("commit", "-qam", "side") + old = git("rev-parse", "HEAD") + with pytest.raises(bootstrap.BootstrapError): + proof.verify_failed_modern_ancestor(old, current) + else: + assert proof.verify_failed_modern_ancestor(old, current) == tree diff --git a/backend/tests/test_controller_artifact_promotion_diagnostics.py b/backend/tests/test_controller_artifact_promotion_diagnostics.py new file mode 100644 index 0000000..f98ee18 --- /dev/null +++ b/backend/tests/test_controller_artifact_promotion_diagnostics.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from types import SimpleNamespace + +import httpx +import pytest +from sqlmodel import Session, SQLModel, create_engine + +DIAGNOSTIC = ( + "APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC " + "code=helper_failed phase=source_archive exitCode=19" +) +SAFE_DIAGNOSTICS = {"code": "helper_failed", "phase": "source_archive", "exitCode": 19} +SECRET = "must-never-escape-helper-log" + + +def _docker_frame(stream_type, payload): + return bytes([stream_type, 0, 0, 0]) + len(payload).to_bytes(4, "big") + payload + + +def _default_docker_logs(): + marker = DIAGNOSTIC.encode() + split_at = len(marker) // 2 + return b"".join( + ( + _docker_frame(1, b"helper started\n"), + _docker_frame(2, f"token={SECRET}\n".encode() + marker[:split_at]), + _docker_frame(2, marker[split_at:] + b"\n"), + ) + ) + + +class _Response: + def __init__(self, payload=None): + self.payload = payload or {} + + def json(self): + return self.payload + + +class _Portainer: + def __init__(self, *, exit_code=19, logs=None, running=False): + self.exit_code = exit_code + self.logs = logs if logs is not None else _default_docker_logs() + self.running = running + self.requests = [] + self.request_kwargs = [] + + def _config(self): + return SimpleNamespace(endpoint_id="1") + + def list_containers(self): + return {"containers": []} + + def _request(self, method, path, **kwargs): + self.requests.append((method, path)) + self.request_kwargs.append(kwargs) + if method == "POST" and path.endswith("/containers/create"): + return _Response({"Id": "helper-123"}) + if method == "GET" and path.endswith("/helper-123/json"): + return _Response( + {"State": {"Running": self.running, "ExitCode": self.exit_code}} + ) + if method == "GET" and path.endswith("/helper-123/logs"): + return httpx.Response(status_code=200, content=self.logs) + return _Response() + + def get_container_logs(self, container_name, tail=100): + return {"status": "ok", "container": container_name, "logs": self.logs} + + +def _session(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'controller-diagnostics.db'}") + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def test_helper_spec_emits_only_fixed_failure_markers(): + from backend.app.services.controller_artifact_helper_spec import ( + helper_container_spec, + ) + + command = helper_container_spec(revision="a" * 40, image_id="sha256:" + "b" * 64)["Cmd"][2] + assert "APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC" in command + assert "phase=source_commit" in command + assert "phase=source_archive" in command + assert "phase=upgrade" in command + assert "phase=cleanup" in command + assert "exitCode=" in command + + +def test_generated_helper_command_parses_without_evaluating_command_substitutions(): + from backend.app.services.controller_artifact_helper_spec import ( + helper_container_spec, + ) + + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash is required to parse the generated helper command") + command = helper_container_spec( + revision="a" * 40, + image_id="sha256:" + "b" * 64, + )["Cmd"][2] + result = subprocess.run( + [bash, "-n"], + input=command, + capture_output=True, + text=True, + shell=False, + ) + assert result.returncode == 0, result.stderr + + +def test_failed_workspace_validation_does_not_run_cleanup_or_remove_unvalidated_path(): + from backend.app.services.controller_artifact_helper_spec import ( + helper_container_spec, + ) + + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash is required to exercise the generated helper command") + command = helper_container_spec( + revision="a" * 40, + image_id="sha256:" + "b" * 64, + )["Cmd"][2] + shell_stubs = """\ +stat() { + case "$*" in + *"%a:%u:%g"*) printf '1777:0:0\\n' ;; + *) printf '999:700\\n' ;; + esac +} +mktemp() { printf '/tmp\\n'; } +rm() { printf 'RM_WOULD_RUN\\n'; } +""" + result = subprocess.run( + [bash, "-s"], + input=shell_stubs + command, + capture_output=True, + text=True, + shell=False, + ) + assert result.returncode != 0 + assert "phase=workspace_validate" in result.stderr + assert "RM_WOULD_RUN" not in result.stdout + result.stderr + + +def test_diagnostic_parser_accepts_fixed_marker_and_returns_no_log_text(): + from backend.app.services import controller_artifact_promoter as promoter + + parser = getattr(promoter, "parse_helper_diagnostics", None) + assert callable(parser), "the helper diagnostic parser is not implemented" + assert parser(f"safe status line\n{DIAGNOSTIC}", 19) == SAFE_DIAGNOSTICS + + +def test_docker_log_demux_reads_stdout_stderr_and_markers_across_frames(): + from backend.app.services import controller_artifact_promoter as promoter + + demux = getattr(promoter, "_decode_docker_log_stream", None) + assert callable(demux), "the bounded Docker stdcopy log demux is not implemented" + marker = DIAGNOSTIC.encode() + split_at = len(marker) // 2 + framed = b"".join( + ( + _docker_frame(1, b"stdout line\n"), + _docker_frame(2, b"stderr prefix\n" + marker[:split_at]), + _docker_frame(2, marker[split_at:] + b"\n"), + ) + ) + assert demux(framed) == f"stdout line\nstderr prefix\n{DIAGNOSTIC}\n" + + +@pytest.mark.parametrize( + "framed", + [ + b"", + _docker_frame(3, b"unknown stream"), + bytes([2, 1, 0, 0]) + (1).to_bytes(4, "big") + b"x", + bytes([2, 0, 0, 0]), + _docker_frame(2, b"truncated")[:-1], + _docker_frame(2, b"\xff"), + bytes([2, 0, 0, 0]) + (4001).to_bytes(4, "big"), + b"".join(_docker_frame(1, b"x") for _ in range(201)), + ], +) +def test_docker_log_demux_rejects_invalid_truncated_or_oversized_streams(framed): + from backend.app.services import controller_artifact_promoter as promoter + + demux = getattr(promoter, "_decode_docker_log_stream", None) + assert callable(demux), "the bounded Docker stdcopy log demux is not implemented" + assert demux(framed) is None + + +@pytest.mark.parametrize( + ("log_text", "exit_code"), + [ + ("APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=unlisted exitCode=19", 19), + ("APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=source_archive exitCode=19 extra=secret", 19), + ("APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=source_archive exitCode=256", 19), + (DIAGNOSTIC, 256), + (DIAGNOSTIC + "\n" + DIAGNOSTIC, 19), + ("x" * 4001 + "\n" + DIAGNOSTIC, 19), + (DIAGNOSTIC, True), + ], +) +def test_diagnostic_parser_rejects_malformed_oversized_or_adversarial_input(log_text, exit_code): + from backend.app.services import controller_artifact_promoter as promoter + + parser = getattr(promoter, "parse_helper_diagnostics", None) + assert callable(parser), "the helper diagnostic parser is not implemented" + expected_exit = exit_code if type(exit_code) is int and 0 <= exit_code <= 255 else None + assert parser(log_text, exit_code) == { + "code": "helper_failed", + "phase": "unknown", + "exitCode": expected_exit, + } + + +def test_helper_failure_diagnostics_reach_job_audit_and_operation_result_without_logs(tmp_path, monkeypatch): + from backend.app.db.models import AuditEvent, JobRun + from backend.app.services import action_execution + from backend.app.services.controller_artifact_promoter import ( + ControllerArtifactPromoter, + ) + + portainer = _Portainer() + promoted = ControllerArtifactPromoter(portainer=portainer)._run_helper( + revision="a" * 40, + image_id="sha256:" + "b" * 64, + ) + assert promoted["status"] == "failed" + assert promoted["diagnostics"] == SAFE_DIAGNOSTICS + assert "logTail" not in promoted + assert any( + method == "GET" and path.endswith("/helper-123/logs") + for method, path in portainer.requests + ) + log_request_index = next( + index + for index, (method, path) in enumerate(portainer.requests) + if method == "GET" and path.endswith("/helper-123/logs") + ) + assert portainer.request_kwargs[log_request_index]["params"] == { + "stdout": 1, + "stderr": 1, + "tail": 200, + } + assert SECRET not in json.dumps(promoted) + + monkeypatch.setattr(ControllerArtifactPromoter, "run", lambda self: promoted) + with _session(tmp_path) as session: + result = action_execution._execute_controller_artifact_promote( + session, + source="operator", + risk="high", + reason="diagnose the failed promotion", + original_policy_decision="approval_required", + ) + job = session.get(JobRun, result["jobId"]) + audit = session.get(AuditEvent, result["auditId"]) + + assert result["status"] == "failed" + assert result["diagnostics"] == SAFE_DIAGNOSTICS + assert job is not None and "phase=source_archive" in job.result_summary + assert audit is not None + assert audit.after_state["diagnostics"] == SAFE_DIAGNOSTICS + assert audit.redacted_metadata["diagnostics"] == SAFE_DIAGNOSTICS + durable = json.dumps( + { + "operation": result, + "jobSummary": job.result_summary, + "auditAfter": audit.after_state, + "auditMetadata": audit.redacted_metadata, + } + ) + assert SECRET not in durable + assert "logTail" not in durable + + +@pytest.mark.parametrize( + "logs", + [ + b"", + _docker_frame( + 2, + b"APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=unknown exitCode=19", + ), + _docker_frame( + 2, + b"APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=source_archive exitCode=19 extra=value", + ), + _docker_frame(2, b"x" * 4001), + _docker_frame(2, b"truncated")[:-1], + ], +) +def test_missing_or_unparseable_helper_phase_is_explicitly_unknown(logs): + from backend.app.services.controller_artifact_promoter import ( + ControllerArtifactPromoter, + ) + + result = ControllerArtifactPromoter(portainer=_Portainer(exit_code=19, logs=logs))._run_helper( + revision="a" * 40, + image_id="sha256:" + "b" * 64, + ) + assert result["status"] == "failed" + assert result["diagnostics"] == { + "code": "helper_failed", + "phase": "unknown", + "exitCode": 19, + } + assert "logTail" not in result + assert SECRET not in json.dumps(result) + + +def test_successful_helper_remains_promoted_and_never_returns_logs(): + from backend.app.services.controller_artifact_promoter import ( + ControllerArtifactPromoter, + ) + + portainer = _Portainer(exit_code=0) + portainer.get_container_logs = lambda *args, **kwargs: pytest.fail("success must not fetch helper logs") + result = ControllerArtifactPromoter(portainer=portainer)._run_helper( + revision="a" * 40, + image_id="sha256:" + "b" * 64, + ) + assert result == {"status": "promoted", "revision": "a" * 40} + assert not any(path.endswith("/logs") for _, path in portainer.requests) + + +def test_helper_timeout_remains_outcome_unknown_without_cleanup_or_log_fetch(monkeypatch): + from backend.app.services import controller_artifact_promoter as promoter + + portainer = _Portainer(running=True) + portainer.get_container_logs = lambda *args, **kwargs: pytest.fail( + "a running helper has no terminal diagnostic to read" + ) + clock_values = iter((100.0, 341.0)) + monkeypatch.setattr(promoter.time, "monotonic", lambda: next(clock_values)) + + result = promoter.ControllerArtifactPromoter(portainer=portainer)._run_helper( + revision="a" * 40, + image_id="sha256:" + "b" * 64, + ) + assert result == { + "status": "outcome_unknown", + "revision": "a" * 40, + "reason": "controller_promoter_timeout", + "helperId": "helper-123", + } + assert not any(method == "DELETE" for method, _ in portainer.requests) + assert not any(path.endswith("/logs") for _, path in portainer.requests) diff --git a/backend/tests/test_controller_hub_release_volume.py b/backend/tests/test_controller_hub_release_volume.py index bdcaff3..0763515 100644 --- a/backend/tests/test_controller_hub_release_volume.py +++ b/backend/tests/test_controller_hub_release_volume.py @@ -24,6 +24,29 @@ spec.loader.exec_module(module) REVISION = "a" * 40 IMAGE_ID = "sha256:" + "b" * 64 ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n" +# Frozen historical layouts; these fixtures must not follow the acceptance registry. +HISTORICAL_PRE_CANDIDATE_RELEASE_FILES = ( + "controller_paths.py", "mcp_hub_release_store.py", "deploy.py", + "managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py", + "runner_capacity_controller.py", "runner_capacity_policy.py", + "runner_fleet_upgrade_v2.py", "status_server.py", + "repopulse_adoption_store.py", "repopulse_registry_restore.py", + "repopulse_adoption_executor.py", "repopulse_adoption_effects.py", + "repopulse_adoption_storage_effects.py", "repopulse_target_attestation.py", + "stage_repopulse_target_attestation.py", "repopulse_adoption_cli.py", + "stage_repopulse_maintenance_environment.py", "run_repopulse_adoption.py", +) +HISTORICAL_CANDIDATE_HELPER_RELEASE_FILES = ( + "controller_paths.py", "mcp_hub_release_store.py", "deploy.py", + "managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py", + "runner_capacity_controller.py", "runner_capacity_policy.py", + "runner_fleet_upgrade_v2.py", "status_server.py", "prepare_mcp_hub_candidate.py", + "repopulse_adoption_store.py", "repopulse_registry_restore.py", + "repopulse_adoption_executor.py", "repopulse_adoption_effects.py", + "repopulse_adoption_storage_effects.py", "repopulse_target_attestation.py", + "stage_repopulse_target_attestation.py", "repopulse_adoption_cli.py", + "stage_repopulse_maintenance_environment.py", "run_repopulse_adoption.py", +) HISTORICAL_CONFIG_ROOT_RELEASE_FILES = ( "controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py", "deploy.py", "managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py", @@ -279,6 +302,220 @@ def test_promotion_does_not_prepare_backup_volume_when_staged_configuration_fail assert "backup_volume_prepared" not in events +@pytest.mark.parametrize( + ("failed_step", "expected_phase", "error_code"), + [ + ("old_tls", "old_tls", "controller_volume_private_tls_unsafe"), + ("config", "config", "controller_volume_config_missing"), + ("release_build", "release_build", "controller_volume_source_archive_incomplete"), + ("staged_config", "staged_config", "controller_volume_command_failed"), + ("old_release", "old_release", "controller_volume_writer_failed"), + ("new_release", "new_release", "controller_volume_writer_failed"), + ], +) +def test_prepare_reports_only_fixed_failure_phase_and_code( + monkeypatch, tmp_path, capsys, failed_step, expected_phase, error_code +): + _prepare_promotion_doubles(monkeypatch, tmp_path) + def fail(*_args, **_kwargs): + raise module.VolumeTransitionError(error_code) + + if failed_step == "old_tls": + monkeypatch.setattr(module, "_validate_previous_private_tls", fail) + elif failed_step == "config": + monkeypatch.setattr(module, "_read_config_leaf", fail) + elif failed_step == "release_build": + monkeypatch.setattr(module, "_prepare_merged_tree", fail) + elif failed_step == "staged_config": + monkeypatch.setattr(module, "_validate_staged_configuration", fail) + else: + installed = 0 + + def install(*_args, **_kwargs): + nonlocal installed + installed += 1 + if installed == (1 if failed_step == "old_release" else 2): + fail() + + monkeypatch.setattr(module, "_install_release_leaf", install) + + monkeypatch.setattr(sys, "argv", [ + "preparer", "prepare", "--stage-root", str(tmp_path / "stage"), + "--source-revision", "d" * 40, "--source-tree", "e" * 40, + "--controller-version", "2.2.0", "--image-id", IMAGE_ID, + ]) + with pytest.raises(SystemExit) as failure: + module.main() + assert str(failure.value) == ( + "controller protected Hub volume transition failed " + f"phase={expected_phase} code={error_code}" + ) + assert capsys.readouterr().out == "" + + +@pytest.mark.parametrize("error", [ + module.VolumeTransitionError("controller_volume_writer_failed\nTOKEN=private"), + module.VolumeTransitionError("controller_volume_unreviewed_code"), + ValueError("TOKEN=private"), +]) +def test_prepare_diagnostic_suppresses_untrusted_error_and_phase(monkeypatch, tmp_path, error): + def forged_prepare(*_args, **kwargs): + kwargs["on_phase"]("old_release\nTOKEN=private") + raise error + + monkeypatch.setattr(module, "prepare_promotion", forged_prepare) + monkeypatch.setattr(sys, "argv", [ + "preparer", "prepare", "--stage-root", str(tmp_path / "stage"), + "--source-revision", "d" * 40, "--source-tree", "e" * 40, + "--controller-version", "2.2.0", "--image-id", IMAGE_ID, + ]) + with pytest.raises(SystemExit) as failure: + module.main() + assert str(failure.value) == ( + "controller protected Hub volume transition failed phase=prepare code=unclassified" + ) + + +@pytest.mark.parametrize( + ("stderr", "expected_condition"), + [ + (b"container rootfs is marked read-only at /private/path TOKEN=fixture-secret", + "rootfs_read_only"), + (b"read-only file system at /private/path TOKEN=fixture-secret", "mount_read_only"), + (b"permission denied: /private/path TOKEN=fixture-secret", "permission_denied"), + (b"no such file or directory: /private/path TOKEN=fixture-secret", + "destination_missing"), + (b"No such container: private-container-id TOKEN=fixture-secret", "container_missing"), + (b"client version is too old TOKEN=fixture-secret", "api_version_unsupported"), + (b"unrecognized failure TOKEN=fixture-secret", None), + ], +) +def test_writer_command_failure_is_categorized_without_retaining_stderr( + monkeypatch, stderr, expected_condition +): + monkeypatch.setattr(module.subprocess, "run", lambda *_args, **_kwargs: types.SimpleNamespace( + returncode=1, stdout=b"", stderr=stderr, + )) + + with pytest.raises(module.VolumeTransitionError) as failure: + module._run( + ["docker", "cp", "-", "container:/private/path"], + data=b"TOKEN=fixture-secret", + diagnostic_action="writer_copy", + ) + + assert failure.value.args == ("controller_volume_command_failed",) + assert failure.value.command_failure == ( + "writer_copy", "exit_1", expected_condition, + ) + diagnostic = module._safe_command_diagnostic(failure.value.command_failure) + assert diagnostic is not None + assert "fixture-secret" not in repr(failure.value) + assert "fixture-secret" not in diagnostic + assert "private/path" not in diagnostic + + +@pytest.mark.parametrize( + ("failure_kind", "expected_outcome"), + [("timeout", "timeout"), ("spawn", "spawn_failed"), ("overflow", "output_limit")], +) +def test_writer_command_failure_diagnostics_bound_non_exit_outcomes( + monkeypatch, failure_kind, expected_outcome +): + def run(*_args, **_kwargs): + if failure_kind == "timeout": + raise subprocess.TimeoutExpired(cmd=["docker", "cp"], timeout=45, + stderr=b"TOKEN=fixture-secret") + if failure_kind == "spawn": + raise OSError("TOKEN=fixture-secret") + return types.SimpleNamespace(returncode=0, stdout=b"oversized", stderr=b"") + + monkeypatch.setattr(module.subprocess, "run", run) + kwargs = {"max_output": 1} if failure_kind == "overflow" else {} + with pytest.raises(module.VolumeTransitionError) as failure: + module._run(["docker", "cp", "-", "container:/target"], + diagnostic_action="writer_copy", **kwargs) + + assert failure.value.command_failure == ("writer_copy", expected_outcome, None) + + +def test_prepare_reports_only_allowlisted_command_and_stderr_conditions(monkeypatch, tmp_path): + def failed_prepare(*_args, **kwargs): + kwargs["on_phase"]("old_release") + raise module.VolumeTransitionError( + "controller_volume_command_failed", + command_failure=("writer_copy", "exit_1", "rootfs_read_only"), + cleanup_failure=("writer_cleanup", "exit_2", "permission_denied"), + ) + + monkeypatch.setattr(module, "prepare_promotion", failed_prepare) + monkeypatch.setattr(sys, "argv", [ + "preparer", "prepare", "--stage-root", str(tmp_path / "stage"), + "--source-revision", "d" * 40, "--source-tree", "e" * 40, + "--controller-version", "2.2.0", "--image-id", IMAGE_ID, + ]) + with pytest.raises(SystemExit) as failure: + module.main() + + assert str(failure.value) == ( + "controller protected Hub volume transition failed phase=old_release " + "code=controller_volume_command_failed " + "diagnostic=writer_copy_exit_1_rootfs_read_only " + "cleanupDiagnostic=writer_cleanup_exit_2_permission_denied" + ) + + +@pytest.mark.parametrize("value", [ + ("docker cp /private/file", "exit_1", None), + ("writer_copy", "exit_1", "TOKEN=fixture-secret"), + ("writer_copy", "exit_999", None), + ("writer_copy", "exit_1", "unknown_condition"), + ["writer_copy", "exit_1", None], +]) +def test_command_diagnostic_rejects_unreviewed_fields(value): + assert module._safe_command_diagnostic(value) is None + + +def test_release_writer_cleanup_failure_preserves_primary_copy_failure(monkeypatch): + partial, expected = _release_with_config() + monkeypatch.setattr(module, "_read_release_leaf", lambda _revision: partial) + monkeypatch.setattr(module, "ensure_volume", lambda **_kwargs: None) + monkeypatch.setattr(module, "_remove_stale_writer", lambda _image_id: None) + monkeypatch.setattr(module, "_inspect_writer", lambda *_args, **_kwargs: {}) + calls = [] + writer_id = "f" * 64 + + def run(arguments, *, diagnostic_action=None, **_kwargs): + calls.append(diagnostic_action) + if diagnostic_action == "writer_create": + return writer_id.encode() + if diagnostic_action == "writer_wait": + return b"0" + if diagnostic_action == "writer_copy": + raise module.VolumeTransitionError( + "controller_volume_command_failed", + command_failure=("writer_copy", "exit_1", "rootfs_read_only"), + ) + if diagnostic_action == "writer_cleanup": + raise module.VolumeTransitionError( + "controller_volume_command_failed", + command_failure=("writer_cleanup", "exit_2", "permission_denied"), + ) + return b"" + + monkeypatch.setattr(module, "_run", run) + with pytest.raises(module.VolumeTransitionError) as failure: + module._install_release_leaf(REVISION, expected, IMAGE_ID) + + assert failure.value.command_failure == ( + "writer_copy", "exit_1", "rootfs_read_only", + ) + assert failure.value.cleanup_failure == ( + "writer_cleanup", "exit_2", "permission_denied", + ) + assert calls[-2:] == ["writer_copy", "writer_cleanup"] + + def writer_inventory(inherited_labels): identifier = "c" * 64 spec = module._writer_spec(IMAGE_ID, REVISION, kind="config") @@ -542,6 +779,90 @@ def test_compose_accepts_empty_bind_normalization_and_canonicalizes_both_managed } +def test_compose_normalizes_all_observed_live_generated_bind_defaults(compose): + baseline, _calls = compose + volumes = baseline["services"][module.SERVICE]["volumes"] + for mount in volumes: + mount["bind"] = {"create_host_path": True} + rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)) + graph = next(mount for mount in rendered["services"][module.SERVICE]["volumes"] + if mount["target"] == module.DOCKER_ROOT.as_posix()) + assert graph == { + "type": "bind", "source": module.DOCKER_ROOT.as_posix(), + "target": module.DOCKER_ROOT.as_posix(), "read_only": True, + } + module._bound_model(json.dumps(rendered).encode()) + + +@pytest.mark.parametrize("bind", [ + {"create_host_path": 1}, {"create_host_path": "true"}, + {"create_host_path": False}, + {"create_host_path": True, "propagation": "rslave"}, + {"create_host_path": True, "unknown": True}, +]) +def test_compose_rejects_unsafe_generated_docker_root_bind(compose, bind): + baseline, _calls = compose + graph = next(mount for mount in baseline["services"][module.SERVICE]["volumes"] + if mount["target"] == module.DOCKER_ROOT.as_posix()) + graph["bind"] = bind + with pytest.raises(module.VolumeTransitionError): + module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID) + + +@pytest.mark.parametrize("target", [module.EXECUTABLE_TARGET, module.CONFIG_TARGET]) +@pytest.mark.parametrize("private", [False, True]) +def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts( + compose, target, private +): + baseline, _calls = compose + volumes = baseline["services"][module.SERVICE]["volumes"] + bind = {"create_host_path": True} + if private: + bind["propagation"] = "rprivate" + if target == module.EXECUTABLE_TARGET: + mount = next(item for item in volumes if item["target"] == target) + mount["bind"] = bind + else: + volumes.append({ + "type": "bind", "source": module._expected_config_path().as_posix(), + "target": target, "read_only": True, "bind": bind, + }) + + rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)) + managed = [mount for mount in rendered["services"][module.SERVICE]["volumes"] + if mount["target"] in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}] + assert len(managed) == 2 + assert all(mount["read_only"] is True for mount in managed) + assert all(mount["bind"] == {"propagation": "rprivate"} for mount in managed) + assert rendered["services"]["runner"] == baseline["services"]["runner"] + # Only captured source metadata is normalized. Activation stays canonical. + module._bound_model(json.dumps(rendered).encode()) + next(mount for mount in managed if mount["target"] == target)["bind"]["create_host_path"] = True + with pytest.raises(module.VolumeTransitionError): + module._bound_model(json.dumps(rendered).encode()) + + +@pytest.mark.parametrize("target", [module.EXECUTABLE_TARGET, module.CONFIG_TARGET]) +@pytest.mark.parametrize("bind", [ + {"create_host_path": 1}, + {"create_host_path": "true"}, + {"create_host_path": True, "propagation": "rslave"}, + {"create_host_path": True, "selinux": "z"}, +]) +def test_compose_rejects_unsafe_or_untyped_generated_bind_metadata(compose, target, bind): + baseline, _calls = compose + volumes = baseline["services"][module.SERVICE]["volumes"] + if target == module.EXECUTABLE_TARGET: + next(mount for mount in volumes if mount["target"] == target)["bind"] = bind + else: + volumes.append({ + "type": "bind", "source": module._expected_config_path().as_posix(), + "target": target, "read_only": True, "bind": bind, + }) + with pytest.raises(module.VolumeTransitionError): + module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID) + + @pytest.mark.parametrize( ("target", "bind"), [ @@ -869,6 +1190,117 @@ def _forge_secret_entries(): } +def _release_with_config(*, forge_secrets=False): + release = { + name: (f'fixed:{name}\n'.encode(), 0o644) + for name in module.RELEASE_FILES + } + manifest = { + 'apiVersion': 'itworx.controller-artifact/v1', + 'controllerVersion': '2.1.14', + 'sourceRepository': 'jens/unraid-appops-gateway', + 'sourceRevision': REVISION, + 'sourceTree': 'c' * 40, + 'artifactSha256': module._artifact_digest(release), + 'builtAt': '2026-10-04T00:00:00Z', + } + release['controller-release.json'] = (json.dumps(manifest).encode(), 0o644) + config = {'broker-token': (b'fixture-token', 0o600)} + directories = {'': 0o755} + if forge_secrets: + config.update(_forge_secret_entries()) + directories[module.FORGE_SECRET_DIRECTORY] = 0o700 + return (release, {'': 0o755}), (release | config, directories) + + +def _fake_release_writer(monkeypatch, state): + writer_id = 'f' * 64 + calls = [] + + def run(arguments, *, data=None, **_kwargs): + calls.append(arguments) + if arguments[:2] == ['docker', 'create']: + return writer_id.encode() + if arguments[:2] == ['docker', 'wait']: + return b'0' + if arguments[:2] == ['docker', 'cp']: + state['tree'] = module._read_tar_tree(data) + with tarfile.open(fileobj=io.BytesIO(data), mode='r:') as archive: + archive_mode = '-a' in arguments + state['owners'] = { + member.name: ((0, 0) if archive_mode else (member.uid, member.gid)) + for member in archive.getmembers() if member.isfile() + } + return b'' + + monkeypatch.setattr(module, 'ensure_volume', lambda **_kwargs: None) + monkeypatch.setattr(module, '_remove_stale_writer', lambda _image_id: None) + monkeypatch.setattr(module, '_inspect_writer', lambda *_args, **_kwargs: {}) + monkeypatch.setattr(module, '_run', run) + return calls + + +def test_existing_exact_release_subset_is_augmented_and_read_back(monkeypatch): + old, expected = _release_with_config() + state = {'tree': old} + monkeypatch.setattr(module, '_read_release_leaf', lambda _revision: state['tree']) + calls = _fake_release_writer(monkeypatch, state) + + module._install_release_leaf(REVISION, expected, IMAGE_ID) + + assert state['tree'] == expected + assert len([call for call in calls if call[:2] == ['docker', 'create']]) == 1 + assert len([call for call in calls if call[:2] == ['docker', 'cp']]) == 1 + + +def test_divergent_existing_release_refuses_before_writer(monkeypatch): + old, expected = _release_with_config() + damaged = dict(old[0]) + damaged['status_server.py'] = (b'tampered', 0o644) + monkeypatch.setattr(module, '_read_release_leaf', lambda _revision: (damaged, old[1])) + calls = _fake_release_writer(monkeypatch, {'tree': None}) + + with pytest.raises(module.VolumeTransitionError, match='release_collision'): + module._install_release_leaf(REVISION, expected, IMAGE_ID) + assert not any(call[:2] == ['docker', 'create'] for call in calls) + + +def test_complete_existing_release_uses_no_writer(monkeypatch): + _old, expected = _release_with_config() + monkeypatch.setattr(module, '_read_release_leaf', lambda _revision: expected) + calls = _fake_release_writer(monkeypatch, {'tree': None}) + + module._install_release_leaf(REVISION, expected, IMAGE_ID) + + assert not any(call[:2] == ['docker', 'create'] for call in calls) + + +def test_release_copy_preserves_fixed_forge_file_owners_on_readback(monkeypatch): + _old, expected = _release_with_config(forge_secrets=True) + state = {'tree': None, 'owners': {}} + + def read_leaf(_revision): + if state['tree'] is not None: + for name, owner in module.FORGE_SECRET_OWNERS.items(): + if state['owners'].get(name) != owner: + raise module.VolumeTransitionError('controller_volume_tree_file_unsafe') + return state['tree'] + + monkeypatch.setattr(module, '_read_release_leaf', read_leaf) + calls = _fake_release_writer(monkeypatch, state) + + module._install_release_leaf(REVISION, expected, IMAGE_ID) + + assert state['tree'] == expected + assert all(state['owners'][name] == owner + for name, owner in module.FORGE_SECRET_OWNERS.items()) + assert len([call for call in calls if call[:2] == ['docker', 'cp']]) == 1 + assert next(call for call in calls if call[:2] == ['docker', 'cp']) == [ + 'docker', 'cp', '-', + f"{'f' * 64}:{module.TARGET}/{module.ARTIFACTS}/{REVISION}", + ] + + def test_complete_forge_secret_bundle_has_exact_export_modes_and_owners(): entries = { 'projects.json': (b'{}', 0o600), @@ -931,7 +1363,7 @@ def test_forge_secret_tar_owner_drift_is_rejected(): module._read_tar_tree(output.getvalue()) -def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypatch): +def test_config_writer_uses_tar_ownership_without_copy_archive_mode(monkeypatch): tree = ( { 'projects.json': (b'{}', 0o600), @@ -942,8 +1374,10 @@ def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypat {'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700}, ) writer_id = 'f' * 64 + expected_archive = module._release_tar_bytes('a' * 40, tree) commands = [] copy_payloads = [] + copy_owners = {} def run(arguments, *, data=None, **_kwargs): commands.append(arguments) @@ -953,11 +1387,17 @@ def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypat return b'0' if arguments[:2] == ['docker', 'cp']: copy_payloads.append(data) + with tarfile.open(fileobj=io.BytesIO(data), mode='r:') as archive: + archive_mode = '-a' in arguments + copy_owners.update({ + member.name: ((0, 0) if archive_mode else (member.uid, member.gid)) + for member in archive.getmembers() if member.isfile() + }) return b'' monkeypatch.setattr(module, '_read_partial_config_leaf', lambda: None) monkeypatch.setattr(module, 'ensure_volume', lambda **_kwargs: None) - monkeypatch.setattr(module, '_release_tar_bytes', lambda *_args, **_kwargs: b'archive') + monkeypatch.setattr(module, '_release_tar_bytes', lambda *_args, **_kwargs: expected_archive) monkeypatch.setattr(module, '_remove_stale_writer', lambda *_args, **_kwargs: None) monkeypatch.setattr(module, '_writer_spec', lambda *_args, **_kwargs: ['docker', 'create']) monkeypatch.setattr(module, '_inspect_writer', lambda *_args, **_kwargs: {}) @@ -967,9 +1407,11 @@ def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypat module._install_config_tree(tree, IMAGE_ID) assert [command for command in commands if command[:2] == ['docker', 'cp']] == [[ - 'docker', 'cp', '-a', '-', f'{writer_id}:{module.TARGET}/{module.CONFIG_DIR}', + 'docker', 'cp', '-', f'{writer_id}:{module.TARGET}/{module.CONFIG_DIR}', ]] - assert copy_payloads == [b'archive'] + assert copy_payloads == [expected_archive] + assert all(copy_owners[name] == owner + for name, owner in module.FORGE_SECRET_OWNERS.items()) def test_partial_forge_bundle_fails_during_promotion_preparation_before_stop( @@ -1215,6 +1657,7 @@ def test_writer_is_exact_image_only_bounded_and_has_no_host_paths(kind): assert module.WRITER_NAME in command assert IMAGE_ID in command assert "--network" in command and command[command.index("--network") + 1] == "none" + assert command[command.index("--user") + 1] == "0:0" assert "--read-only" in command and "--cap-drop" in command assert "--cpu-quota" in command and "--memory" in command and "--pids-limit" in command assert "--mount" in command @@ -1223,6 +1666,21 @@ def test_writer_is_exact_image_only_bounded_and_has_no_host_paths(kind): assert not any(item.startswith(("/mnt/", "/var/lib/")) for item in command) +@pytest.mark.parametrize("action", ["writer_copy", "config_writer_copy"]) +def test_copy_diagnostic_classifies_missing_passwd_user_without_retaining_user_value(action): + stderr = (b'Error response from daemon: getent unable to find entry ' + b'"fixture-user-name" in passwd database') + + assert module._command_stderr_condition(stderr, action, max_bytes=1024) == ( + "passwd_user_lookup_failed" + ) + diagnostic = module._safe_command_diagnostic( + (action, "exit_1", "passwd_user_lookup_failed") + ) + assert diagnostic == f"{action}_exit_1_passwd_user_lookup_failed" + assert "fixture-user-name" not in diagnostic + + def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose): baseline, calls = compose rendered = module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID) @@ -1307,9 +1765,44 @@ def test_installer_release_order_matches_renderer_contract(): renderer = importlib.util.module_from_spec(renderer_spec) renderer_spec.loader.exec_module(renderer) assert tuple(module.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES) + assert len(renderer._ARTIFACT_FILES) == len(set(renderer._ARTIFACT_FILES)) assert module.RELEASE_FILES[:3] == ( "controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py" ) + candidate_helper = "prepare_mcp_hub_candidate.py" + assert candidate_helper in module.RELEASE_FILES + assert candidate_helper in renderer._ARTIFACT_FILES + assert module.RELEASE_FILES.count(candidate_helper) == 1 + previous_files = module.LEGACY_RELEASE_FILES_PRE_CANDIDATE_HELPER + assert len(previous_files) == 20 + previous_with_helper = module.LEGACY_RELEASE_FILES_WITH_CANDIDATE_HELPER + assert len(previous_with_helper) == 21 + assert previous_with_helper == tuple( + [*previous_files[:previous_files.index("status_server.py") + 1], candidate_helper, + *previous_files[previous_files.index("status_server.py") + 1:]] + ) + assert module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT == HISTORICAL_CONFIG_ROOT_RELEASE_FILES + assert module._KNOWN_RELEASE_FILE_SETS == ( + module.RELEASE_FILES, + module.LEGACY_RELEASE_FILES_PRE_COMPONENT, + module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER, + module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER, + module.LEGACY_RELEASE_FILES_BEFORE_FORGE, + previous_files, + previous_with_helper, + HISTORICAL_CONFIG_ROOT_RELEASE_FILES, + module.LEGACY_RELEASE_FILES_2_1_13, + module.LEGACY_RELEASE_FILES_2_1_5, + ) + assert all( + candidate_helper not in legacy + for legacy in ( + previous_files, + module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT, + module.LEGACY_RELEASE_FILES_2_1_13, + module.LEGACY_RELEASE_FILES_2_1_5, + ) + ) assert module.RELEASE_FILES[module.RELEASE_FILES.index("forge_first_install.py") + 1] == ( "forge_database_credentials.py" ) @@ -1319,7 +1812,7 @@ def test_installer_release_order_matches_renderer_contract(): ) assert module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER == tuple( name for name in module.RELEASE_FILES - if name not in {"forge_backup_paths.py", "forge_secret_bundle.py"} + if name not in {"forge_backup_paths.py", "forge_secret_bundle.py", "immutable_service_promotion.py", "registered_legacy_adoption.py", "registered_legacy_transport.py", "registered_build_context.py"} ) assert module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER == tuple( name for name in module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER @@ -1339,6 +1832,106 @@ def test_installer_release_order_matches_renderer_contract(): ] +def test_pre_candidate_20_file_manifest_remains_accepted_and_current_digest_requires_helper(): + files = HISTORICAL_PRE_CANDIDATE_RELEASE_FILES + assert module.LEGACY_RELEASE_FILES_PRE_CANDIDATE_HELPER == files + entries = {name: (f"pre-candidate:{name}".encode(), 0o644) for name in files} + manifest = { + "apiVersion": "itworx.controller-artifact/v1", + "controllerVersion": "2.1.14", + "sourceRepository": "jens/unraid-appops-gateway", + "sourceRevision": "b" * 40, + "sourceTree": "c" * 40, + "artifactSha256": module._artifact_digest(entries, files), + "builtAt": "2026-10-02T00:00:00.000Z", + } + entries["controller-release.json"] = ( + json.dumps(manifest, separators=(",", ":")).encode(), 0o644 + ) + + identity = module._release_identity(entries) + assert identity["artifactSha256"] == manifest["artifactSha256"] + assert module._matching_release_files(entries, manifest["artifactSha256"]) == files + + current_entries = {name: (f"current:{name}".encode(), 0o644) for name in module.RELEASE_FILES} + assert module._artifact_digest(current_entries) == module._artifact_digest( + current_entries, module.RELEASE_FILES + ) + del current_entries["prepare_mcp_hub_candidate.py"] + with pytest.raises(module.VolumeTransitionError, match="release_incomplete"): + module._artifact_digest(current_entries) + + +def test_previous_21_file_helper_manifest_remains_accepted(): + files = HISTORICAL_CANDIDATE_HELPER_RELEASE_FILES + assert module.LEGACY_RELEASE_FILES_WITH_CANDIDATE_HELPER == files + assert len(files) == 21 + assert files.count("prepare_mcp_hub_candidate.py") == 1 + entries = {name: (f"previous-helper:{name}".encode(), 0o644) for name in files} + manifest = { + "apiVersion": "itworx.controller-artifact/v1", + "controllerVersion": "2.1.14", + "sourceRepository": "jens/unraid-appops-gateway", + "sourceRevision": "d" * 40, + "sourceTree": "e" * 40, + "artifactSha256": module._artifact_digest(entries, files), + "builtAt": "2026-10-02T00:00:00.000Z", + } + entries["controller-release.json"] = ( + json.dumps(manifest, separators=(",", ":")).encode(), 0o644 + ) + + identity = module._release_identity(entries) + assert identity["artifactSha256"] == manifest["artifactSha256"] + assert module._matching_release_files(entries, manifest["artifactSha256"]) == files + + +def test_candidate_preparation_helper_is_staged_archived_and_digest_bound(tmp_path): + if os.name == "nt": + pytest.skip("release staging requires POSIX descriptor-based file checks") + helper_name = "prepare_mcp_hub_candidate.py" + helper_source = ROOT / "ops" / "gitea-deploy-broker" / helper_name + helper_bytes = helper_source.read_bytes() + stage_root = tmp_path / "source" + ops = stage_root / "ops" / "gitea-deploy-broker" + scripts = stage_root / "scripts" + ops.mkdir(parents=True, mode=0o700) + scripts.mkdir(mode=0o700) + for name in module.RELEASE_FILES: + folder = scripts if name == "run_repopulse_adoption.py" else ops + contents = helper_bytes if name == helper_name else f"source:{name}\n".encode() + (folder / name).write_bytes(contents) + staged = module._stage_source_files(stage_root) + assert staged[helper_name] == (helper_bytes, 0o644) + + revision = "a" * 40 + tree = "b" * 40 + entries, directories = module._prepare_merged_tree( + stage_root, ({}, {"": 0o755}), revision, tree, "2.1.14" + ) + artifact_digest = module._release_identity(entries)["artifactSha256"] + assert artifact_digest == module._artifact_digest(entries) + archive = module._release_tar_bytes(revision, (entries, directories)) + extracted, _ = module._read_tar_tree(archive) + assert extracted[helper_name][0] == helper_bytes + assert module._release_identity(extracted)["artifactSha256"] == artifact_digest + + tampered = dict(extracted) + tampered[helper_name] = (helper_bytes + b"# altered after manifest\n", 0o644) + with pytest.raises(module.VolumeTransitionError, match="release_identity_invalid"): + module._release_identity(tampered) + + +def test_installer_explicit_archive_and_install_contract_includes_candidate_helper(): + source = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8") + archive_paths = source.split("source_paths=(", 1)[1].split("\n)", 1)[0] + installed_modules = source.split("module_files=(", 1)[1].split("\n)", 1)[0] + assert "ops/gitea-deploy-broker/prepare_mcp_hub_candidate.py" in archive_paths + assert "prepare_mcp_hub_candidate.py" in installed_modules + powershell = (ROOT / "scripts/upgrade_deployment_controller.ps1").read_text(encoding="utf-8") + assert "ops/gitea-deploy-broker" in powershell.split("& git archive", 1)[1].splitlines()[0] + + def test_current_release_digest_binds_the_role_password_helper(): entries = {name: (f"source:{name}".encode(), 0o644) for name in module.RELEASE_FILES} expected = module._artifact_digest(entries) @@ -1970,3 +2563,75 @@ def test_current_e854_crlf_exception_integrates_with_git_source_verification(mon git_outputs[other_path] = b"tampered protected Git source" with pytest.raises(module.VolumeTransitionError, match="source_mismatch"): module._verify_git_source_identity(entries, identity, controller_id=controller_id) + + +def _current_controller_with_graph_mount(): + return { + "Id": "c" * 64, "Image": IMAGE_ID, "Name": "/gitea-deploy-control", + "State": {"Running": True}, + "Mounts": [ + {"Type": "bind", "Source": module._expected_revision_path(REVISION).as_posix(), + "Destination": module.EXECUTABLE_TARGET, "RW": False, "Propagation": "rslave"}, + {"Type": "bind", "Source": "/var/lib/docker", "Destination": "/var/lib/docker", + "RW": False, "Propagation": "rslave"}, + ], + } + + +def test_current_controller_accepts_only_fixed_readonly_docker_root_ancestor(monkeypatch): + item = _current_controller_with_graph_mount() + monkeypatch.setattr(module, "_run", lambda *_args, **_kwargs: json.dumps([item]).encode()) + assert module._controller_image_inspect(IMAGE_ID) == item + + +@pytest.mark.parametrize("damage", ["writable", "redirected_target", "child_source", "parent_source", "volume_type", "named_volume_alias", "duplicate_graph", "shared_propagation", "private_propagation"]) +def test_current_controller_rejects_other_docker_root_appdata_aliases(monkeypatch, damage): + item = _current_controller_with_graph_mount() + graph = item["Mounts"][1] + if damage == "writable": + graph["RW"] = True + elif damage == "redirected_target": + graph["Destination"] = "/graph-alias" + elif damage == "child_source": + graph["Source"] = "/var/lib/docker/volumes" + elif damage == "parent_source": + graph["Source"] = "/var/lib" + elif damage == "volume_type": + graph["Type"] = "volume" + elif damage == "named_volume_alias": + graph["Name"] = module.MANAGED_APPDATA_VOLUME + elif damage == "duplicate_graph": + item["Mounts"].append(dict(graph)) + elif damage == "shared_propagation": + graph["Propagation"] = "rshared" + else: + graph["Propagation"] = "rprivate" + monkeypatch.setattr(module, "_run", lambda *_args, **_kwargs: json.dumps([item]).encode()) + with pytest.raises(module.VolumeTransitionError, match="managed_appdata_mount_alias"): + module._controller_image_inspect(IMAGE_ID) + + +@pytest.mark.parametrize("populated", [False, True]) +def test_config_snapshot_preserves_fixed_appops_legacy_intent_directory(populated): + entries = {name: (b"{}", 0o600) for name in ("projects.json", "broker-token", "gitea-validation.json", "gitea-validation-token")} + if populated: + entries["appops-legacy-upgrade/unraid-appops-gateway.json"] = (b'{"schema":"itworx.appops-legacy-upgrade-intent/v1"}', 0o600) + directories = {"": 0o755, "appops-legacy-upgrade": 0o700} + assert module._config_tree(entries, directories) == (entries, directories) + assert module._read_tar_tree(module._release_tar_bytes(REVISION, (entries, directories))) == (entries, directories) + + +@pytest.mark.parametrize("damage", ["unknown_file", "nested_directory", "public_directory", "public_file"]) +def test_config_snapshot_keeps_appops_legacy_state_contract_closed(damage): + entries = {name: (b"{}", 0o600) for name in ("projects.json", "broker-token", "gitea-validation.json", "gitea-validation-token")} + directories = {"": 0o755, "appops-legacy-upgrade": 0o700} + if damage == "unknown_file": + entries["appops-legacy-upgrade/shell.py"] = (b"unexpected", 0o600) + elif damage == "nested_directory": + directories["appops-legacy-upgrade/nested"] = 0o700 + elif damage == "public_directory": + directories["appops-legacy-upgrade"] = 0o755 + else: + entries["appops-legacy-upgrade/unraid-appops-gateway.json"] = (b"{}", 0o644) + with pytest.raises(module.VolumeTransitionError): + module._config_tree(entries, directories) diff --git a/backend/tests/test_controller_runtime_capabilities.py b/backend/tests/test_controller_runtime_capabilities.py new file mode 100644 index 0000000..5af4c68 --- /dev/null +++ b/backend/tests/test_controller_runtime_capabilities.py @@ -0,0 +1,140 @@ +"""Runtime readiness is independent of storage for enrolling a new target.""" +from __future__ import annotations + +import http.client +import io +import json +import threading +from email.message import Message + +import pytest +from test_deploy_broker import BROKER_ROOT, _load_module + + +@pytest.fixture +def controller(monkeypatch, tmp_path): + module = _load_module("appops_runtime_capabilities_test", BROKER_ROOT / "status_server.py") + repository_root = tmp_path / "repos" + repository_root.mkdir() + secret_path = tmp_path / "bootstrap" + secret = b"runtime-capabilities-test-" + b"x" * 48 + secret_path.write_bytes(secret) + secret_path.chmod(0o600) + monkeypatch.setattr(module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", secret_path) + monkeypatch.setattr(module, "APPOPS_NONCE_ROOT", tmp_path / "nonces") + monkeypatch.setattr(module, "REPOSITORY_ROOT", repository_root) + monkeypatch.setattr(module, "_controller_release", lambda: {"revision": "a" * 40}) + monkeypatch.setattr(module, "_projects_registry_is_safe", lambda: True) + monkeypatch.setattr(module, "_load_projects", lambda: {"owner/existing": {}}) + monkeypatch.setattr(module, "_refresh_appdata_root_selection", lambda: None) + monkeypatch.setattr(module, "trusted_appdata_roots_readiness", lambda: [ + {"root": "/srv/example/appdata", "status": "unavailable", "reason": "unsafe_permissions"}, + ]) + monkeypatch.setattr(module, "managed_storage_readiness", lambda: {"status": "needs_initialization"}) + server = module.BoundedThreadingHTTPServer(("127.0.0.1", 0), module.Handler) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + counter = 0 + + def request(*, authenticated=True, replay_headers=None): + nonlocal counter + counter += 1 + body = b"{}" + path = "/v1/appops/enrollments/capabilities" + timestamp = str(int(module.time.time())) + nonce = f"{counter:032x}" + headers = replay_headers or { + "Content-Type": "application/json", + "X-ITWorx-Deployment-Timestamp": timestamp, + "X-ITWorx-Deployment-Nonce": nonce, + "X-ITWorx-Deployment-Signature": module._request_signature( + secret, method="POST", path=path, timestamp=timestamp, nonce=nonce, raw_body=body, + ) if authenticated else "0" * 64, + } + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request("POST", path, body=body, headers=headers) + response = client.getresponse() + payload = json.loads(response.read()) + client.close() + assert secret.decode() not in json.dumps(payload) + return response.status, payload, headers + + yield module, request + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_registered_runtime_ready_without_new_enrollment_storage(controller): + _, request = controller + status, payload, headers = request() + assert status == 200 + assert payload["status"] == "ready" + assert payload["registry"] == {"status": "ready", "registeredProjectCount": 1} + assert payload["enrollmentReadiness"] == { + "status": "unavailable", "reason": "trusted_appdata_roots_unavailable", + } + for name in ("deploymentApply", "deploymentVerify", "rollback", "exactTargetPolicy", "hmacV2", "replayProtection"): + assert payload["features"][name] is True + for name in ("enrollment", "targetRegistration", "runtimeSecretSealing"): + assert payload["features"][name] is False + assert request(replay_headers=headers)[0] == 403 + assert request(authenticated=False)[0] == 403 + + +@pytest.mark.parametrize("unavailable", ["registry", "registry_content", "artifact", "repository"]) +def test_runtime_prerequisites_remain_fail_closed(controller, monkeypatch, tmp_path, unavailable): + module, request = controller + if unavailable == "registry": + monkeypatch.setattr(module, "_projects_registry_is_safe", lambda: False) + elif unavailable == "registry_content": + def invalid_registry(): + raise ValueError("invalid registry") + monkeypatch.setattr(module, "_load_projects", invalid_registry) + elif unavailable == "artifact": + monkeypatch.setattr(module, "_controller_release", lambda: None) + else: + monkeypatch.setattr(module, "REPOSITORY_ROOT", tmp_path / "missing") + status, payload, _ = request() + assert status == 503 + assert payload["reason"] == "controller_runtime_prerequisite_unavailable" + for name in ("deploymentApply", "deploymentVerify", "rollback", "exactTargetPolicy", "enrollment", "targetRegistration"): + assert payload["features"][name] is False + + +def test_enrollment_recovers_when_safe_root_becomes_ready(controller, monkeypatch): + module, request = controller + assert request()[1]["features"]["enrollment"] is False + monkeypatch.setattr(module, "trusted_appdata_roots_readiness", lambda: [{"status": "ready"}]) + status, payload, _ = request() + assert status == 200 + assert payload["enrollmentReadiness"] == {"status": "ready", "reason": ""} + for name in ("enrollment", "targetRegistration", "runtimeSecretSealing"): + assert payload["features"][name] is True + + +@pytest.mark.parametrize("current,reason", [ + ({"status": "blocked", "checks": [{"name": "required_push_validation", "passed": False}]}, + "controller_preflight_failed_required_push_validation"), + ({"status": "preflight_ok", "currentRevision": "b" * 40, "checks": []}, + "current_revision_changed"), +]) +def test_self_upgrade_reports_failed_gate_before_missing_current_revision(controller, monkeypatch, current, reason): + module, _ = controller + monkeypatch.setattr(module, "_self_upgrade_preflight", lambda _revision: dict(current)) + monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_args, **_kwargs: True) + intent = {"repository": module.SELF_UPGRADE_REPOSITORY, "revision": "c" * 40, + "reason": "Validated infrastructure repair", "expectedCurrentRevision": "a" * 40} + body = json.dumps(intent).encode() + handler = module.Handler.__new__(module.Handler) + handler.path = module.SELF_UPGRADE_PREFLIGHT_PATH + handler.headers = Message() + handler.headers["Content-Length"] = str(len(body)) + handler.headers["Content-Type"] = "application/json" + handler.rfile = io.BytesIO(body) + handler._trusted_client = lambda: True + responses = [] + handler._send_json = lambda status, payload: responses.append((status, json.loads(payload))) + handler._handle_self_upgrade() + assert responses[0][0] == 409 + assert responses[0][1]["reason"] == reason diff --git a/backend/tests/test_deploy_broker.py b/backend/tests/test_deploy_broker.py index b77d4d0..faecb6f 100644 --- a/backend/tests/test_deploy_broker.py +++ b/backend/tests/test_deploy_broker.py @@ -764,6 +764,41 @@ def test_appops_legacy_intent_accepts_only_the_reviewed_dockerfile_blob_pair( deploy_module._appops_validate_intent_payload(unknown_update) +def test_new_database_pair_keeps_protected_full_and_runtime_intent_proofs_strict( + deploy_module, +): + _repository, _metadata, _source, _main, _backup, intent = ( + _appops_rollback_intent_fixture(deploy_module, "c" * 40, "a" * 64) + ) + + def rebind_migration_proof(value): + proof = value["migrationProof"] + evidence = value["rollbackSnapshot"]["appops_legacy"] + evidence["sourceProofSha256"] = deploy_module._canonical_json_sha256(proof["source"]) + evidence["protectedMainSha256"] = deploy_module._canonical_json_sha256(proof["protectedMain"]) + evidence["migrationProofSha256"] = deploy_module._canonical_json_sha256(proof) + + intent["migrationProof"]["source"]["databaseTree"] = ( + "21d29c1799118fdc43946c2b6fd5585ccc2c4db1" + ) + rebind_migration_proof(intent) + deploy_module._appops_validate_intent_payload(intent) + + weak_protected = copy.deepcopy(intent) + weak_protected["migrationProof"]["protectedMain"]["validationContext"] = ( + "Managed validation / partial (push)" + ) + rebind_migration_proof(weak_protected) + with pytest.raises(deploy_module.DeployError, match="protected-main proof is invalid"): + deploy_module._appops_validate_intent_payload(weak_protected) + + incomplete_runtime = copy.deepcopy(intent) + incomplete_runtime["migrationProof"]["runtime"].pop("healthSha256") + rebind_migration_proof(incomplete_runtime) + with pytest.raises(deploy_module.DeployError, match="legacy runtime proof is invalid"): + deploy_module._appops_validate_intent_payload(incomplete_runtime) + + def test_appops_cli_rollback_retries_after_state_write_crash_without_synthetic_receipt( deploy_module, monkeypatch, tmp_path, capsys ): @@ -1537,6 +1572,47 @@ def test_appops_state_write_rejects_compare_and_swap_drift(deploy_module, monkey assert path.read_bytes() == original +def test_registered_legacy_state_cas_drift_preserves_external_state_and_records_recovery( + deploy_module, monkeypatch, tmp_path +): + repository = "jens/quiz-monitor" + request_id = "6f9619ff-8b86-4011-b42d-00c04fc964ff" + revision = "a" * 40 + state_root = tmp_path / "state" + state_root.mkdir() + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_root) + monkeypatch.setattr(deploy_module, "_registered_legacy_receipt_key", lambda: b"k" * 32) + project = { + "slug": "quiz-monitor", "branch": "main", "compose_project": "quiz-monitor", + "compose_files": ["docker-compose.yml"], + "containers": ["quiz-monitor-quiz-server-1"], + "source_dir": "/srv/example/appdata/quiz-monitor/source", + "_registered_legacy_request_id": request_id, + "_registered_legacy_authorization": { + "requestId": request_id, "revision": revision, + "expectedStateSha256": "0" * 64, + }, + } + path = state_root / "quiz-monitor.json" + external_state = b'{"schema_version":3,"live_revision":"independent-update"}\n' + path.write_bytes(external_state) + + with pytest.raises(deploy_module.DeployError, match="compare-and-swap"): + deploy_module.write_state( + repository, project, revision, "failed", "deployment failed", + expected_state_sha256="0" * 64, + ) + + assert path.read_bytes() == external_state + helper = deploy_module._load_registered_legacy_adoption() + outcome = helper.status( + {"repository": repository, "requestId": request_id}, + registry={repository: project}, receipt_key=b"k" * 32, state_root=state_root, + ) + assert outcome["status"] == "recovery-required" + assert outcome["reasonCode"] == "controller_state_cas_failed" + + def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None): """Build the reviewed DB and Dockerfile transitions without old Git history.""" repository = tmp_path / "reviewed-source.git" @@ -1601,6 +1677,7 @@ def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None): forge_seed_rows = ( b' Policy(id="policy-forge-oidc-provision", action="forge.oidc.provision", target="itworx-forge", voice="blocked", manual="allowed", risk="critical", reason="The fixed Forge OIDC client may be provisioned only by an authenticated manual operator route; voice and generic Authentik mutations remain blocked."),\n', b' Policy(id="policy-forge-secret-prepare", action="forge.secrets.prepare", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Preparing the fixed Forge bootstrap secret bundle is default-denied and requires an explicit exact-target manual operator policy; voice remains blocked."),\n', + b' Policy(id="policy-forge-route-stage", action="forge.route.stage", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Staging the fixed disabled Forge proxy host is default-denied; voice remains blocked."),\n', ) for row in forge_seed_rows: assert current_init.count(row) == 1 @@ -1629,7 +1706,15 @@ def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None): old_tree = git("rev-parse", f"{old_revision}^{{tree}}") old_database_tree = git("rev-parse", f"{old_revision}:backend/app/db") - (db / "init_db.py").write_bytes(current_init) + candidate_init = current_init + if candidate_mutation == "previous_reviewed_seed_tree": + route_seed = forge_seed_rows[2] + assert candidate_init.count(route_seed) == 1 + candidate_init = candidate_init.replace(route_seed, b"", 1) + elif candidate_mutation == "legacy_database_unchanged": + candidate_init = old_init + + (db / "init_db.py").write_bytes(candidate_init) if candidate_mutation == "extra_database_file": (db / "unreviewed.py").write_text("UNREVIEWED = True\n", encoding="utf-8") elif candidate_mutation == "models_content": @@ -1637,21 +1722,47 @@ def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None): elif candidate_mutation == "session_content": (db / "session.py").write_bytes(normalize(current_db / "session.py") + b"\n# unreviewed\n") elif candidate_mutation == "init_db_content": - (db / "init_db.py").write_bytes(current_init + b"\n# unreviewed database behavior\n") + (db / "init_db.py").write_bytes(candidate_init + b"\n# unreviewed database behavior\n") elif candidate_mutation == "forge_policy_changed": - changed_init = current_init.replace( + changed_init = candidate_init.replace( b'action="forge.oidc.provision"', b'action="forge.oidc.provision-unreviewed"', 1, ) assert changed_init != current_init (db / "init_db.py").write_bytes(changed_init) + elif candidate_mutation in { + "route_stage_manual_allowed", "route_stage_voice_allowed", "route_stage_risk_lowered", + }: + route_seed = forge_seed_rows[2] + replacements = { + "route_stage_manual_allowed": (b'manual="blocked"', b'manual="allowed"'), + "route_stage_voice_allowed": (b'voice="blocked"', b'voice="allowed"'), + "route_stage_risk_lowered": (b'risk="critical"', b'risk="high"'), + } + old_field, new_field = replacements[candidate_mutation] + assert route_seed.count(old_field) == 1 + candidate_init = candidate_init.replace(route_seed, route_seed.replace(old_field, new_field, 1), 1) + (db / "init_db.py").write_bytes(candidate_init) + elif candidate_mutation == "unknown_policy_row": + route_seed = forge_seed_rows[2] + unknown_policy = ( + b' Policy(id="policy-forge-route-stage-unreviewed", ' + b'action="forge.route.stage.unreviewed", target="itworx-forge", ' + b'voice="blocked", manual="blocked", risk="critical", ' + b'reason="Unreviewed policy row."),\n' + ) + assert candidate_init.count(route_seed) == 1 + candidate_init = candidate_init.replace(route_seed, route_seed + unknown_policy, 1) + (db / "init_db.py").write_bytes(candidate_init) elif candidate_mutation == "database_file_mode": (db / "session.py").chmod(0o755) elif candidate_mutation == "entrypoint_content": entrypoint.write_bytes(entrypoint.read_bytes() + b"# unreviewed entrypoint\n") elif candidate_mutation not in { - None, "dockerfile_one_byte", "dockerfile_legacy_unchanged", + None, "previous_reviewed_seed_tree", "legacy_database_unchanged", + "route_stage_manual_allowed", "route_stage_voice_allowed", "route_stage_risk_lowered", + "unknown_policy_row", "dockerfile_one_byte", "dockerfile_legacy_unchanged", }: raise AssertionError("unknown fixture mutation") @@ -1673,6 +1784,11 @@ def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None): (working / "Dockerfile").write_bytes(candidate_dockerfile) git("add", "--all") + if candidate_mutation == "database_file_mode": + # Windows does not preserve chmod's executable bit on the worktree; + # stage the real Git mode mutation so this remains a meaningful tree + # identity negative fixture on every platform. + git("update-index", "--chmod=+x", "--", "backend/app/db/session.py") git("commit", "-m", "candidate source") candidate_revision = git("rev-parse", "HEAD") git("push", "origin", "HEAD:refs/heads/main") @@ -1729,7 +1845,7 @@ def test_appops_legacy_source_proof_accepts_the_exact_main_184_dockerfile_update assert proof == { "legacyRevision": history["oldRevision"], "legacyTree": history["oldTree"], - "databaseTree": "c87c9aff5a0f0e55b9209e2f085cf1663876520a", + "databaseTree": "21d29c1799118fdc43946c2b6fd5585ccc2c4db1", "entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8", "candidateRevision": history["candidateRevision"], "composeBlob": "243aee2c6924775bed7706373a5777f4e619e053", @@ -1755,6 +1871,26 @@ def test_appops_legacy_source_proof_keeps_the_old_dockerfile_candidate_valid( assert proof["dockerfileBlob"] == "73e083dcfef25402665ace5a0bf07a083cf37432" +def test_appops_legacy_source_proof_keeps_the_previous_reviewed_seed_tree_valid( + deploy_module, monkeypatch, tmp_path +): + history = _build_reviewed_appops_seed_history( + tmp_path, candidate_mutation="previous_reviewed_seed_tree" + ) + assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28" + assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a" + monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"]) + monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"]) + + proof = deploy_module.verify_appops_legacy_source( + history["repository"], + _reviewed_appops_source_project(deploy_module), + history["candidateRevision"], + ) + + assert proof["databaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a" + + def test_appops_legacy_source_proof_rejects_a_one_byte_dockerfile_change( deploy_module, monkeypatch, tmp_path ): @@ -1772,12 +1908,59 @@ def test_appops_legacy_source_proof_rejects_a_one_byte_dockerfile_change( ) -def test_appops_schema_compatibility_accepts_the_exact_reviewed_seed_tree_pair( - deploy_module, monkeypatch, tmp_path +def test_appops_legacy_source_bindings_remain_fixed_for_the_new_reviewed_tree( + deploy_module, monkeypatch, tmp_path, ): history = _build_reviewed_appops_seed_history(tmp_path) + monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"]) + monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"]) + mutations = ( + ("_repository_name", "jens/other-repository"), + ("branch", "release"), + ("gitea_repository_id", 10), + ("source_dir", "/tmp/unreviewed-source"), + ("compose_project", "unreviewed-project"), + ("compose_files", ["other-compose.yml"]), + ("containers", ["OtherContainer"]), + ("verify_oci_revision", False), + ) + for field, value in mutations: + project = _reviewed_appops_source_project(deploy_module) + project[field] = value + with pytest.raises(deploy_module.DeployError, match="fixed legacy source or resolved Compose inputs"): + deploy_module.verify_appops_legacy_source( + history["repository"], project, history["candidateRevision"], + ) + + for field, value in ( + ("APPOPS_DATA_PATH", "/tmp/other-data"), + ("APPOPS_PUBLISH_HOST", "127.0.0.1"), + ("APPOPS_PROXY_NETWORK", "unreviewed-network"), + ): + project = _reviewed_appops_source_project(deploy_module) + project["environment"][field] = value + with pytest.raises(deploy_module.DeployError, match="fixed legacy source or resolved Compose inputs"): + deploy_module.verify_appops_legacy_source( + history["repository"], project, history["candidateRevision"], + ) + + +@pytest.mark.parametrize( + ("candidate_mutation", "expected_database_tree"), + [ + (None, "21d29c1799118fdc43946c2b6fd5585ccc2c4db1"), + ("previous_reviewed_seed_tree", "c87c9aff5a0f0e55b9209e2f085cf1663876520a"), + ("legacy_database_unchanged", "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28"), + ], +) +def test_appops_schema_compatibility_accepts_only_the_two_reviewed_pairs_or_same_tree( + deploy_module, monkeypatch, tmp_path, candidate_mutation, expected_database_tree, +): + history = _build_reviewed_appops_seed_history( + tmp_path, candidate_mutation=candidate_mutation, + ) assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28" - assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a" + assert history["candidateDatabaseTree"] == expected_database_tree assert history["candidateEntrypoint"] == "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8" monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"]) monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"]) @@ -1789,7 +1972,7 @@ def test_appops_schema_compatibility_accepts_the_exact_reviewed_seed_tree_pair( assert proof == { "legacyRevision": history["oldRevision"], "legacyTree": history["oldTree"], - "databaseTree": "c87c9aff5a0f0e55b9209e2f085cf1663876520a", + "databaseTree": expected_database_tree, "entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8", "candidateRevision": history["candidateRevision"], } @@ -1803,6 +1986,10 @@ def test_appops_schema_compatibility_accepts_the_exact_reviewed_seed_tree_pair( "session_content", "init_db_content", "forge_policy_changed", + "unknown_policy_row", + "route_stage_manual_allowed", + "route_stage_voice_allowed", + "route_stage_risk_lowered", "database_file_mode", "entrypoint_content", ], @@ -1815,10 +2002,13 @@ def test_appops_seed_tree_exception_rejects_every_unreviewed_tree_or_entrypoint_ ) assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28" if mutation == "entrypoint_content": - assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a" + assert history["candidateDatabaseTree"] == "21d29c1799118fdc43946c2b6fd5585ccc2c4db1" assert history["candidateEntrypoint"] != "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8" else: - assert history["candidateDatabaseTree"] != "c87c9aff5a0f0e55b9209e2f085cf1663876520a" + assert history["candidateDatabaseTree"] not in { + "c87c9aff5a0f0e55b9209e2f085cf1663876520a", + "21d29c1799118fdc43946c2b6fd5585ccc2c4db1", + } monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"]) monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"]) @@ -3126,8 +3316,9 @@ def test_deployment_environment_clears_unverified_build_date(deploy_module, tmp_ assert environment["APPOPS_BUILD_DATE"] == "" -def test_deployment_environment_allows_project_build_date_override(deploy_module, tmp_path): +def test_deployment_environment_allows_unverified_project_build_date_override(deploy_module, tmp_path): project = _project(tmp_path) + project["verify_oci_revision"] = False project["environment"] = {"APPOPS_BUILD_DATE": "operator-configured-value"} repository = "jens/unraid-appops-gateway" revision = "a" * 40 @@ -7573,6 +7764,8 @@ def test_controller_release_identity_is_content_bound(status_module, tmp_path, m renderer = importlib.util.module_from_spec(renderer_spec) renderer_spec.loader.exec_module(renderer) assert renderer._ARTIFACT_FILES == status_module._CONTROLLER_ARTIFACT_FILES + candidate_helper = "prepare_mcp_hub_candidate.py" + assert candidate_helper in status_module._CONTROLLER_ARTIFACT_FILES forge_first_install_index = status_module._CONTROLLER_ARTIFACT_FILES.index( "forge_first_install.py" ) @@ -7588,6 +7781,9 @@ def test_controller_release_identity_is_content_bound(status_module, tmp_path, m else status_module.ROOT / filename ) (artifact_root / filename).write_bytes(source.read_bytes()) + assert (artifact_root / candidate_helper).read_bytes() == ( + status_module.ROOT / candidate_helper + ).read_bytes() monkeypatch.setattr(status_module, "ROOT", artifact_root) monkeypatch.setattr(status_module, "CONTROLLER_RELEASE_PATH", artifact_root / "controller-release.json") digest = status_module._controller_artifact_digest() @@ -7608,7 +7804,9 @@ def test_controller_release_identity_is_content_bound(status_module, tmp_path, m monkeypatch.setattr(status_module.sys, "argv", ["status_server.py", "--check-artifact"]) status_module.main() assert "validated controller artifact 2.0.0" in capsys.readouterr().out - (artifact_root / "deploy.py").write_text("tampered", encoding="utf-8") + (artifact_root / candidate_helper).write_text("tampered", encoding="utf-8") + assert status_module._controller_release() is None + (artifact_root / candidate_helper).unlink() assert status_module._controller_release() is None diff --git a/backend/tests/test_enrollment_plane.py b/backend/tests/test_enrollment_plane.py index 0192d56..01a3b53 100644 --- a/backend/tests/test_enrollment_plane.py +++ b/backend/tests/test_enrollment_plane.py @@ -81,7 +81,21 @@ class _Broker: def enrollment_capabilities(self, *, secret): assert secret == "b" * 64 - return {"status": "ready", "trustedAppdataRoot": self.appdata_root} + return { + "status": "ready", + "trustedAppdataRoot": self.appdata_root, + "features": { + "deploymentApply": True, + "deploymentVerify": True, + "rollback": True, + "exactTargetPolicy": True, + "hmacV2": True, + "replayProtection": True, + "enrollment": True, + "targetRegistration": True, + "runtimeSecretSealing": True, + }, + } def enrollment_preflight(self, enrollment, *, secret): assert secret == "b" * 64 @@ -209,6 +223,30 @@ def _plane(portainer=None, broker=None, npm=None, authentik=None): ) +class _CompensatingBroker(_Broker): + def unenroll_target(self, _target, *, secret): + assert secret == "b" * 64 + return {"status": "rolled_back"} + + +class _CompensatingNpm(_Npm): + def remove_managed_proxy_host(self, *, host_id, ownership_id): + return {"success": True, "status": "removed"} + + +class _CompensatingAuthentik(_Authentik): + def remove_deployment_oidc_application(self, **_kwargs): + return {"success": True, "status": "removed"} + + +def _compensating_plane(broker=None): + return _plane( + broker=broker or _CompensatingBroker(), + npm=_CompensatingNpm(), + authentik=_CompensatingAuthentik(), + ) + + def _configure(session): set_secret(session, "ENROLLMENT_BOOTSTRAP", "b" * 64) EnrollmentPlane.set_trust_config(session, _trust()) @@ -405,8 +443,9 @@ def test_capabilities_block_when_controller_root_differs_from_operator_trust(tmp result = _plane(broker=AlternateRootBroker()).capabilities(session) assert result["targetEnrollment"]["status"] == "unavailable" - assert result["controller"]["status"] == "unavailable" - assert result["controller"]["reason"] == "trusted_appdata_root_mismatch" + assert result["controller"]["status"] == "ready" + assert result["controller"]["reason"] == "" + assert result["enrollmentReadiness"]["reason"] == "trusted_appdata_root_unavailable" def test_capabilities_treat_roots_as_independent_availability_not_global_selection(tmp_path): @@ -424,6 +463,17 @@ def test_capabilities_treat_roots_as_independent_availability_not_global_selecti {"root": managed_root, "status": "ready", "reason": ""}, ], "managedStorage": {"status": "ready", "initializationAvailable": False}, + "features": { + "deploymentApply": True, + "deploymentVerify": True, + "rollback": True, + "exactTargetPolicy": True, + "hmacV2": True, + "replayProtection": True, + "enrollment": True, + "targetRegistration": True, + "runtimeSecretSealing": True, + }, } with _session(tmp_path) as session: @@ -454,6 +504,123 @@ def test_capabilities_treat_roots_as_independent_availability_not_global_selecti ] +def test_runtime_capabilities_remain_available_when_selected_enrollment_root_is_unavailable(tmp_path): + legacy_root = "/srv/example/appdata" + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + + class CoreReadyBroker(_Broker): + def enrollment_capabilities(self, *, secret): + assert secret == "b" * 64 + return { + "status": "ready", + "reason": "", + "availableTrustedAppdataRoots": [ + {"root": legacy_root, "status": "unavailable", "reason": "legacy_appdata_root_unavailable"}, + {"root": managed_root, "status": "unavailable", "reason": "managed_appdata_volume_missing"}, + ], + "managedStorage": { + "status": "unavailable", + "reason": "managed_appdata_volume_missing", + "initializationAvailable": False, + }, + "features": { + "deploymentApply": True, + "deploymentVerify": True, + "rollback": True, + "exactTargetPolicy": True, + "hmacV2": True, + "replayProtection": True, + "enrollment": False, + "targetRegistration": False, + "runtimeSecretSealing": False, + }, + "enrollmentReadiness": { + "status": "unavailable", + "reason": "legacy_appdata_root_unavailable", + }, + "registry": {"status": "ready", "registeredProjectCount": 48}, + } + + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker=CoreReadyBroker()).capabilities(session) + + assert result["controller"] == {"status": "ready", "reason": ""} + assert result["targetEnrollment"]["status"] == "unavailable" + assert result["runtimeDeployment"]["status"] == "ready" + assert result["rollback"]["status"] == "ready" + assert result["verification"]["status"] == "ready" + assert result["enrollmentReadiness"] == { + "status": "unavailable", + "reason": "legacy_appdata_root_unavailable", + } + assert result["availableTrustedAppdataRoots"] == [ + {"root": legacy_root, "status": "unavailable", "reason": "legacy_appdata_root_unavailable"}, + {"root": managed_root, "status": "unavailable", "reason": "managed_appdata_volume_missing"}, + ] + + +def test_runtime_capabilities_fail_closed_when_controller_feature_attestation_is_missing(tmp_path): + class MissingFeaturesBroker(_Broker): + def enrollment_capabilities(self, *, secret): + return {"status": "ready", "trustedAppdataRoot": self.appdata_root} + + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker=MissingFeaturesBroker()).capabilities(session) + + assert result["controller"]["status"] == "ready" + assert result["runtimeDeployment"]["status"] == "unavailable" + assert result["rollback"]["status"] == "unavailable" + assert result["verification"]["status"] == "unavailable" + assert result["targetEnrollment"]["status"] == "unavailable" + + +def test_managed_storage_capability_preserves_only_bounded_controller_reason(tmp_path): + managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data" + + class ManagedStorageUnavailableBroker(_Broker): + def enrollment_capabilities(self, *, secret): + return { + "status": "ready", + "availableTrustedAppdataRoots": [ + {"root": "/srv/example/appdata", "status": "ready"}, + {"root": managed_root, "status": "unavailable", "reason": "managed_appdata_volume_missing"}, + ], + "managedStorage": { + "status": "unavailable", + "reason": "managed_appdata_volume_missing", + "initializationAvailable": False, + }, + "features": { + "deploymentApply": True, + "deploymentVerify": True, + "rollback": True, + "exactTargetPolicy": True, + "hmacV2": True, + "replayProtection": True, + "enrollment": False, + "targetRegistration": False, + "runtimeSecretSealing": False, + }, + } + + with _session(tmp_path) as session: + _configure(session) + EnrollmentPlane.set_trust_config( + session, + _trust().model_copy(update={ + "trusted_appdata_root": managed_root, + "managed_appdata_backup_acknowledged": True, + }), + ) + result = _plane(broker=ManagedStorageUnavailableBroker()).capabilities(session) + + assert result["managedStorage"]["status"] == "unavailable" + assert result["managedStorage"]["reason"] == "managed_appdata_volume_missing" + assert result["enrollmentReadiness"]["reason"] == "managed_appdata_volume_missing" + + def test_capabilities_fail_closed_and_hide_unrecognized_controller_roots(tmp_path): class MalformedRootsBroker(_Broker): def enrollment_capabilities(self, *, secret): @@ -543,6 +710,186 @@ def test_enrollment_creates_write_only_credentials_exact_policies_and_is_idempot assert len(session.exec(select(EnrollmentJob)).all()) == 1 +def test_enrollment_policy_id_collision_blocks_before_mutation_and_leaves_no_partial_policy(tmp_path): + class RecordingBroker(_CompensatingBroker): + enroll_calls = 0 + + def enroll_target(self, *args, **kwargs): + self.enroll_calls += 1 + return super().enroll_target(*args, **kwargs) + + with _session(tmp_path) as session: + _configure(session) + collision = Policy( + id="enrollment-wsbc-portal-deployment-rollback", + action="deployment.rollback", + target="wsbc-portal", + voice="blocked", + manual="allowed", + risk="high", + reason="pre-existing exact-target policy", + provenance_json={"owner": "pre-existing"}, + ) + session.add(collision) + session.commit() + + broker = RecordingBroker() + result = _compensating_plane(broker).enroll( + session, _plan(), idempotency_key="enroll-policy-collision-0001" + ) + + # A pre-existing rollback policy must never leave behind the apply half + # of the enrollment-generated pair, even after failure compensation. + assert session.get(Policy, "enrollment-wsbc-portal-deployment-apply") is None + assert session.get(Policy, collision.id).provenance_json == {"owner": "pre-existing"} + assert result["enrollment"]["status"] == "preflight_failed" + assert result["job"]["status"] == "preflight_failed" + assert "exact_policy_id_collision" in result["job"]["preflight"]["blockers"] + assert broker.enroll_calls == 0 + assert session.exec(select(Policy).where(Policy.target == "wsbc-portal")).all() == [collision] + + +def test_late_policy_id_collision_rolls_back_the_whole_pair(tmp_path, monkeypatch): + class FailedControllerCompensationBroker(_CompensatingBroker): + def unenroll_target(self, _target, *, secret): + assert secret == "b" * 64 + return {"status": "rollback_failed"} + + collision = Policy( + id="enrollment-wsbc-portal-deployment-rollback", + action="deployment.rollback", + target="wsbc-portal", + voice="blocked", + manual="allowed", + risk="high", + reason="concurrent exact-target policy", + provenance_json={"owner": "concurrent"}, + ) + checkpoint = EnrollmentPlane._checkpoint + + def add_collision_after_preflight(session, enrollment, job, *, stage, evidence): + checkpoint(session, enrollment, job, stage=stage, evidence=evidence) + if stage == "installing_policy": + session.add(collision) + session.commit() + + monkeypatch.setattr( + EnrollmentPlane, "_checkpoint", staticmethod(add_collision_after_preflight) + ) + with _session(tmp_path) as session: + _configure(session) + result = _compensating_plane(FailedControllerCompensationBroker()).enroll( + session, _plan(), idempotency_key="fixture-" + "x" * 32 + ) + + assert result["job"]["preflight"]["status"] == "ready" + assert result["enrollment"]["status"] == "compensation_required" + assert result["job"]["status"] == "compensation_required" + assert result["job"]["evidence"]["compensation"] == { + "completed": False, + "outcomes": { + "oidc": "removed", + "proxy": "removed", + "controller": "rollback_failed", + }, + } + assert session.get(Policy, "enrollment-wsbc-portal-deployment-apply") is None + assert session.get(Policy, collision.id).provenance_json == {"owner": "concurrent"} + assert session.exec(select(Policy).where(Policy.target == "wsbc-portal")).all() == [collision] + + +def test_final_ready_commit_failure_rolls_back_the_policy_pair(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'enrollment.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + _configure(session) + plane = _plane() + commit = session.commit + + def fail_after_policy_pair_is_staged(): + ready = session.exec( + select(Enrollment).where(Enrollment.status == "ready") + ).first() + policies = session.exec( + select(Policy).where(Policy.target == "wsbc-portal") + ).all() + if ready is not None and {policy.action for policy in policies} == { + "deployment.apply", "deployment.rollback", + }: + raise RuntimeError("simulated final enrollment commit failure") + commit() + + session.commit = fail_after_policy_pair_is_staged + with pytest.raises(RuntimeError, match="final enrollment commit failure"): + plane.enroll(session, _plan(), idempotency_key="enroll-final-commit-failure-0001") + + # A request-scoped MCP session is closed when the operation raises; prove + # the pending pair was not committed by reading through a fresh session. + with Session(engine) as verification: + enrollment = verification.exec(select(Enrollment)).one() + job = verification.exec(select(EnrollmentJob)).one() + assert enrollment.status == "provisioning" + assert job.stage == "installing_policy" + assert verification.exec( + select(Policy).where(Policy.target == "wsbc-portal") + ).all() == [] + + +def test_concurrent_policy_id_collision_during_flush_rolls_back_pending_pair(tmp_path): + collision = Policy( + id="enrollment-wsbc-portal-deployment-rollback", + action="deployment.rollback", + target="wsbc-portal", + voice="blocked", + manual="allowed", + risk="high", + reason="concurrent exact-target policy", + provenance_json={"owner": "concurrent"}, + ) + with _session(tmp_path) as session: + _configure(session) + flush = session.flush + inserted = False + + def flush_with_concurrent_collision(*args, **kwargs): + nonlocal inserted + staged_ids = { + policy.id for policy in session.new if isinstance(policy, Policy) + } + if not inserted and staged_ids == { + "enrollment-wsbc-portal-deployment-apply", + "enrollment-wsbc-portal-deployment-rollback", + }: + inserted = True + with Session(session.get_bind()) as concurrent_session: + concurrent_session.add(collision) + concurrent_session.commit() + return flush(*args, **kwargs) + + session.flush = flush_with_concurrent_collision + result = _compensating_plane().enroll( + session, _plan(), idempotency_key="enroll-policy-concurrent-0001" + ) + + assert inserted is True + assert result["job"]["preflight"]["status"] == "ready" + assert result["enrollment"]["status"] == "failed" + assert session.get(Policy, "enrollment-wsbc-portal-deployment-apply") is None + concurrent_policy = session.get( + Policy, "enrollment-wsbc-portal-deployment-rollback" + ) + assert concurrent_policy is not None + assert concurrent_policy.provenance_json == {"owner": "concurrent"} + policies = session.exec(select(Policy).where(Policy.target == "wsbc-portal")).all() + assert [(policy.id, policy.action, policy.provenance_json) for policy in policies] == [ + ( + "enrollment-wsbc-portal-deployment-rollback", + "deployment.rollback", + {"owner": "concurrent"}, + ) + ] + + def test_interrupted_external_enrollment_requires_explicit_recovery(tmp_path): class InterruptedBroker(_Broker): def enroll_target(self, _enrollment, *, secret, idempotency_key): diff --git a/backend/tests/test_existing_registered_target_reconciliation.py b/backend/tests/test_existing_registered_target_reconciliation.py new file mode 100644 index 0000000..3161dd6 --- /dev/null +++ b/backend/tests/test_existing_registered_target_reconciliation.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import ast +import json +import os +import stat +import subprocess +import sys +import time +from types import SimpleNamespace + +import pytest + +from backend.app.services.deployment_target_registry import ( + DeploymentTargetRegistry, + _REGISTER_SCRIPT, +) +from test_deployment_target_registry import FakePortainer + +REPOSITORY = "jens/quiz-monitor" +REVISION = "a" * 40 + + +def requested(verify=True): + return DeploymentTargetRegistry._project( + REPOSITORY, branch="main", compose_file="docker-compose.yml", + compose_project="quiz-monitor", containers=["quiz-monitor-quiz-server-1"], + health_timeout=180, verify_oci_revision=verify, + ) + + +def live(): + return { + "branch": "main", "slug": "quiz-monitor", + "source_dir": "/srv/example/appdata/quiz-monitor/source", + "compose_files": ["docker-compose.yml"], "compose_project": "quiz-monitor", + "containers": ["quiz-monitor-quiz-server-1"], "legacy_containers": ["QuizMonitor"], + "gitea_repository_id": 6, "gitea_webhook_secret_file": "jens__quiz-monitor", + "validation": {"validation_gate_version": 1, "required": True, + "contexts": ["Managed validation / full (push)"], + "timeout_seconds": 1800, "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30}, + "environment": {"TEST_VALUE": "private-value-$preserve"}, + "env_source": "/srv/example/appdata/quiz-monitor/runtime.env", + } + + +def descriptor(): + return {"apiVersion": "itworx.deployment-target/v1", "applicationId": "quiz-monitor", + "primaryService": "quiz-server", "composeFile": "docker-compose.yml", + "composeProject": "quiz-monitor", "verifyOciRevision": True} + + +@pytest.fixture +def transaction(tmp_path, monkeypatch): + pytest.importorskip("fcntl") + if os.geteuid() != 0: + pytest.skip("fixed root-owned registry transaction requires Linux root") + registry = tmp_path / "volume" / "config" / "projects.json" + registry.parent.mkdir(parents=True, mode=0o700) + registry.parent.parent.chmod(0o700) + state = tmp_path / "state" + state.mkdir(mode=0o700) + bare = tmp_path / "repositories" / "jens" / "quiz-monitor.git" + bare.mkdir(parents=True) + script = _REGISTER_SCRIPT.replace("/controller-hub-releases/controller-config/projects.json", str(registry)) + script = script.replace("/controller-config/projects.json", str(registry)) + script = script.replace("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock", str(tmp_path / "deploy.lock")) + script = script.replace("/mnt/cache/appdata/gitea-deploy-runners/state/registry-backups", str(state / "registry-backups")) + script = script.replace("/gitea-repositories", str(tmp_path / "repositories")) + + def run(current, desired=None, *, source=None, protected=True, head=REVISION, + protected_heads=None, mutate_during_gate=False, expire_during_gate=False): + desired = desired or requested() + projects = {REPOSITORY: current, "jens/unrelated": {"unchanged": "private-other"}} if current is not None else {} + original = json.dumps(projects).encode() + registry.write_bytes(original) + registry.chmod(0o600) + calls = [] + elapsed = [0] + if expire_during_gate: + monkeypatch.setattr("time.monotonic", lambda: elapsed[0]) + + def git(command, **_kwargs): + assert command[:5] == ["git", "--no-replace-objects", "-c", + f"safe.directory={bare}", f"--git-dir={bare}"] + args = command[5:] + if args[0] == "rev-parse": + text = head if args[1].startswith("refs/heads/") else REVISION + return SimpleNamespace(returncode=0, stdout=text + "\n") + if args[:2] == ["show", f"{REVISION}:.itworx/deployment-target.json"]: + text = source if isinstance(source, str) else json.dumps(source if source is not None else descriptor()) + return SimpleNamespace(returncode=0, stdout=text) + if args[0] in {"merge-base", "cat-file"}: + return SimpleNamespace(returncode=0, stdout="") + raise AssertionError(args) + + def read(path, *, query): + assert path == f"/repos/{REPOSITORY}/branches/main" and query == {} + calls.append(path) + if expire_during_gate: + elapsed[0] = 21 + if mutate_during_gate and (mutate_during_gate is True or len(calls) == mutate_during_gate): + registry.write_bytes(original + b" ") + current_head = protected_heads[min(len(calls) - 1, len(protected_heads) - 1)] if protected_heads else head + return {"protected": protected, "commit": {"id": current_head}} + + monkeypatch.setattr("subprocess.run", git) + monkeypatch.setitem(sys.modules, "status_server", SimpleNamespace(_registered_legacy_api_reader=lambda repo: read)) + monkeypatch.setattr(sys, "argv", ["registration", REPOSITORY, REVISION, "main", json.dumps(desired)]) + code = 0 + try: + exec(compile(script, "fixed-registration-script", "exec"), {}) + except SystemExit as error: + code = error.code + return code, json.loads(registry.read_bytes()), original, registry.read_bytes(), calls, state + + return run + + +def test_exact_protected_descriptor_strengthens_verification_and_preserves_all_existing_metadata(transaction, capsys): + current = live() + code, result, _old, _new, calls, state = transaction(current) + assert code == 0 + assert result[REPOSITORY] == {**current, "verify_oci_revision": True} + assert result["jens/unrelated"] == {"unchanged": "private-other"} + assert len(calls) == 2 # Fresh protected-main proof at preflight and atomic install. + assert len(list((state / "registry-backups").iterdir())) == 1 + assert "private" not in capsys.readouterr().out + + +def test_known_absent_defaults_and_legacy_fields_are_an_idempotent_noop(transaction): + current = live() + code, result, old, new, calls, state = transaction(current, requested(False)) + assert code == 0 and old == new and result[REPOSITORY] == current + assert not calls and not (state / "registry-backups").exists() + + +@pytest.mark.parametrize("change", [ + {"branch": "other"}, {"slug": "other"}, {"compose_project": "other"}, + {"containers": ["other-container"]}, {"compose_files": ["other.yml"]}, + {"source_dir": "/srv/example/appdata/other/source"}, {"source_dir": "/srv/example/appdata/quiz-monitor/source/../other"}, + {"exact_source": "false"}, {"verify_oci_revision": "false"}, {"health_timeout": False}, + {"health_timeout": 300}, {"deploy_script": "scripts/privileged.sh"}, + {"legacy_containers": ["../escape"]}, {"legacy_containers": ["QuizMonitor", "QuizMonitor"]}, + {"legacy_containers": ["quiz-monitor-quiz-server-1"]}, + {"validation": {"required": False}}, +]) +def test_conflicting_identity_paths_and_unsafe_metadata_never_write(transaction, change): + code, _result, old, new, calls, state = transaction({**live(), **change}) + assert code == 23 and old == new + assert not calls and not (state / "registry-backups").exists() + + +@pytest.mark.parametrize("source", [ + {}, {**descriptor(), "verifyOciRevision": False}, + {**descriptor(), "verifyOciRevision": "true"}, + {**descriptor(), "composeProject": "other"}, + {**descriptor(), "composeFile": "other.yml"}, + {**descriptor(), "healthTimeout": True}, + {**descriptor(), "command": "arbitrary"}, + '{"verifyOciRevision":true,"verifyOciRevision":false}', +]) +def test_strengthening_requires_exact_boolean_source_contract(transaction, source): + code, _result, old, new, _calls, state = transaction(live(), source=source) + assert code == 39 and old == new and not (state / "registry-backups").exists() + + +def test_strengthening_rejects_noncurrent_or_unprotected_main(transaction): + for options in ({"head": "b" * 40}, {"protected": False}): + code, _result, old, new, _calls, state = transaction(live(), **options) + assert code == 39 and old == new and not (state / "registry-backups").exists() + + +def test_strengthening_cas_rejects_head_change_after_preflight(transaction): + code, result, old, new, calls, _state = transaction(live(), protected_heads=[REVISION, "b" * 40]) + assert code == 39 and old == new and len(calls) == 2 + assert "verify_oci_revision" not in result[REPOSITORY] + + +def test_delayed_proof_cannot_commit_after_the_registration_polling_window(transaction): + code, _result, old, new, calls, state = transaction(live(), expire_during_gate=True) + assert code == 39 and old == new and len(calls) == 1 + assert not (state / "registry-backups").exists() + + +@pytest.mark.parametrize("gate", [1, 2]) +def test_registry_byte_drift_prevents_atomic_install(transaction, gate): + code, result, _old, _new, _calls, _state = transaction(live(), mutate_during_gate=gate) + assert code == 34 and "verify_oci_revision" not in result[REPOSITORY] + + +def test_verification_cannot_be_weakened(transaction): + code, _result, old, new, _calls, state = transaction({**live(), "verify_oci_revision": True}, requested(False)) + assert code == 23 and old == new and not (state / "registry-backups").exists() + + +def test_new_registration_keeps_existing_behavior(transaction): + code, result, _old, _new, calls, _state = transaction(None) + assert code == 0 and result[REPOSITORY] == requested() and not calls + + +def test_registration_api_rejects_string_verification_before_portainer_access(): + fake = FakePortainer() + result = DeploymentTargetRegistry(fake).register_compose_target(REPOSITORY, REVISION, verify_oci_revision="false") + assert result["status"] == "invalid" and not fake.calls + + +@pytest.fixture +def original_git_reader(tmp_path, monkeypatch, request): + if os.name != "posix" or os.geteuid() != 0: + pytest.skip("controller Git mount ownership requires Linux root") + work = tmp_path / "work" + work.mkdir() + repositories = tmp_path / "repositories" + bare = repositories / "jens" / "quiz-monitor.git" + bare.parent.mkdir(parents=True) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/dev/null") + monkeypatch.setenv("GIT_CONFIG_COUNT", "0") + + def git(*args): + return subprocess.check_output(["git", "-C", str(work), *args], text=True).strip() + + git("init", "-b", "main") + git("config", "user.name", "Controller fixture") + git("config", "user.email", "controller-fixture@example.invalid") + (work / ".itworx").mkdir() + source = descriptor() + source["verifyOciRevision"] = getattr(request, "param", False) + (work / ".itworx/deployment-target.json").write_text(json.dumps(source)) + (work / "docker-compose.yml").write_text("services: {}\n") + git("add", ".") + git("commit", "-m", "original protected source") + original = git("rev-parse", "HEAD") + replacement_source = {**source, "verifyOciRevision": not source["verifyOciRevision"]} + (work / ".itworx/deployment-target.json").write_text(json.dumps(replacement_source)) + git("commit", "-am", "replacement object") + replacement = git("rev-parse", "HEAD") + git("reset", "--hard", original) + subprocess.run(["git", "clone", "--bare", str(work), str(bare)], check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["git", f"--git-dir={bare}", "replace", original, replacement], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + functions = [node for node in ast.parse(_REGISTER_SCRIPT).body + if isinstance(node, ast.FunctionDef) + and node.name in {"git_run", "_source_verified_strengthening"}] + namespace = {"bare_repo": bare, "subprocess": subprocess, "stat": stat, + "time": time, "json": json, "re": __import__("re"), "sys": sys, + "branch": "main", "revision": original, "repository": REPOSITORY, + "compose_file": "docker-compose.yml", "requested_project": requested(), + "strengthening_deadline": time.monotonic() + 20} + exec(compile(ast.Module(body=functions, type_ignores=[]), "fixed-git-reader", "exec"), namespace) + monkeypatch.setitem(sys.modules, "status_server", SimpleNamespace( + _registered_legacy_api_reader=lambda _repo: lambda _path, **_kwargs: { + "protected": True, "commit": {"id": original}, + }, + )) + return bare, original, namespace + + +def test_original_descriptor_cannot_be_substituted_by_git_replacement(original_git_reader): + bare, original, namespace = original_git_reader + # A real replacement keeps the branch SHA but swaps the source descriptor. + replaced = subprocess.check_output( + ["git", f"--git-dir={bare}", "show", f"{original}:.itworx/deployment-target.json"], + text=True, + ) + assert json.loads(replaced)["verifyOciRevision"] is True + result = namespace["git_run"]("show", f"{original}:.itworx/deployment-target.json") + assert result.returncode == 0 and json.loads(result.stdout)["verifyOciRevision"] is False + assert namespace["_source_verified_strengthening"]() is False + + +@pytest.mark.parametrize("owner", [0, 1000]) +def test_read_only_gitea_repository_owner_is_supported(original_git_reader, owner): + bare, original, namespace = original_git_reader + for directory in (bare.parent.parent, bare.parent, bare): + directory.chmod(0o755) + os.chown(directory, owner, owner) + result = namespace["git_run"]("rev-parse", "refs/heads/main") + assert result.returncode == 0 and result.stdout.strip() == original + + +@pytest.mark.parametrize("depth", [0, 1, 2]) +@pytest.mark.parametrize("unsafe", ["owner", "writable", "symlink"]) +def test_repository_local_trust_rejects_unsafe_path(original_git_reader, depth, unsafe): + bare, _original, namespace = original_git_reader + directory = (bare.parent.parent, bare.parent, bare)[depth] + if unsafe == "owner": + os.chown(directory, 1234, 1234) + elif unsafe == "writable": + directory.chmod(0o775) + else: + renamed = directory.with_name(directory.name + "-original") + directory.rename(renamed) + directory.symlink_to(renamed, target_is_directory=True) + with pytest.raises(ValueError, match="unsafe mounted repository"): + namespace["git_run"]("rev-parse", "refs/heads/main") + + +@pytest.mark.parametrize("original_git_reader", [True], indirect=True) +def test_replacement_cannot_hide_valid_original_protected_descriptor(original_git_reader): + _bare, _original, namespace = original_git_reader + assert namespace["_source_verified_strengthening"]() is True diff --git a/backend/tests/test_forge_route_stage.py b/backend/tests/test_forge_route_stage.py new file mode 100644 index 0000000..58b0434 --- /dev/null +++ b/backend/tests/test_forge_route_stage.py @@ -0,0 +1,761 @@ +from __future__ import annotations + +import pytest +from sqlmodel import SQLModel, Session, create_engine + +from backend.app.core.config import settings +from backend.app.db.models import Policy +from backend.app.services.action_registry import ACTION_RISK, ACTIONS, action_default_target +from backend.app.services.policy_engine import evaluate_policy + + +_ACTION = "forge.route.stage" +_TARGET = "itworx-forge" +_NETWORK = "itworx-mcp-hub-npm" + + +def _session(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'forge-route-stage.db'}") + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def _policy(target: str = _TARGET, *, manual: str = "allowed") -> Policy: + return Policy( + id=f"route-{target}", + action=_ACTION, + target=target, + manual=manual, + voice="blocked", + risk="critical", + reason="test policy", + ) + + +def test_route_stage_policy_requires_one_exact_manual_critical_grant(tmp_path): + with _session(tmp_path) as session: + session.add(_policy()) + session.commit() + + decision = evaluate_policy(session, _ACTION, _TARGET, "manual") + + assert _ACTION in ACTIONS + assert ACTION_RISK[_ACTION] == "critical" + assert action_default_target(_ACTION) == _TARGET + assert decision == { + "decision": "allowed", + "reason": "Forge route staging requires one exact-target manual policy; voice is always blocked.", + "risk": "critical", + "policy_id": "route-itworx-forge", + "scope_decision": "allowed", + } + + +def test_route_stage_policy_rejects_wildcard_even_when_manual_is_allowed(tmp_path): + with _session(tmp_path) as session: + session.add(_policy("*")) + session.commit() + + manual = evaluate_policy(session, _ACTION, _TARGET, "manual") + voice = evaluate_policy(session, _ACTION, _TARGET, "voice") + + assert manual["decision"] == "blocked" + assert manual["policy_id"] is None + assert voice["decision"] == "blocked" + + +def test_route_stage_seed_is_default_denied_and_duplicate_exact_rows_do_not_grant(tmp_path): + from backend.app.db.init_db import _seed_policies + + seeded = [policy for policy in _seed_policies() if policy.action == _ACTION] + assert len(seeded) == 1 + assert seeded[0].target == _TARGET + assert seeded[0].manual == "blocked" + assert seeded[0].voice == "blocked" + assert seeded[0].risk == "critical" + + with _session(tmp_path) as session: + first = _policy() + second = _policy() + second.id = "route-duplicate" + session.add(first) + session.add(second) + session.commit() + + decision = evaluate_policy(session, _ACTION, _TARGET, "manual") + + assert decision["decision"] == "blocked" + assert decision["policy_id"] is None + + +class _DockerResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def json(self): + return self._payload + + +def _docker_container(name, *, container_id, networks, state="running", labels=None, image="image"): + return { + "Id": container_id, + "Names": [f"/{name}"], + "Image": image, + "State": state, + "Labels": labels or {}, + "NetworkSettings": { + "Networks": networks if isinstance(networks, dict) else {network: {} for network in networks} + }, + } + + +def _forge_network_inventory(): + return [ + _docker_container( + "NginxProxyManager", + container_id="a" * 64, + networks={_NETWORK: {"NetworkID": "1" * 64, "EndpointID": "2" * 64}}, + image="jlesage/nginx-proxy-manager:2.15.1", + ), + _docker_container( + "itworx-forge-itworx-forge-1", + container_id="b" * 64, + networks={ + "forge_app": {"NetworkID": "3" * 64, "EndpointID": "4" * 64}, + _NETWORK: {"NetworkID": "1" * 64, "EndpointID": "5" * 64}, + }, + labels={ + "com.docker.compose.project": "itworx-forge", + "com.docker.compose.service": "itworx-forge", + }, + image="gitea.example.com/owner/itworx-forge-frontdoor@sha256:" + "c" * 64, + ), + ] + + +def _portainer(monkeypatch, payload, *, status_code=200): + from backend.app.connectors import portainer as portainer_module + from backend.app.connectors.portainer import PortainerConnector + + apis = dict(settings.apis) + apis.update( + { + "PORTAINER_URL": "https://portainer.example", + "PORTAINER_API_KEY": "test-api-key-not-a-real-secret", + "PORTAINER_ENDPOINT_ID": "1", + "PORTAINER_VERIFY_TLS": True, + } + ) + monkeypatch.setattr(settings, "apis", apis) + class _Client: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def get(self, *_args, **_kwargs): + return _DockerResponse(payload, status_code=status_code) + + monkeypatch.setattr(portainer_module.httpx, "Client", _Client) + return PortainerConnector() + + +def test_portainer_route_network_proof_requires_unique_running_fixed_containers(monkeypatch): + connector = _portainer(monkeypatch, _forge_network_inventory()) + + result = connector.forge_route_network_readiness() + + assert result == { + "status": "ready", + "network": _NETWORK, + "npmAttached": True, + "frontdoorAttached": True, + } + + +def test_portainer_route_network_proof_fails_closed_on_missing_membership(monkeypatch): + payload = _forge_network_inventory() + payload[0]["NetworkSettings"]["Networks"] = {"bridge": {}} + connector = _portainer(monkeypatch, payload) + + result = connector.forge_route_network_readiness() + + assert result["status"] == "not_ready" + assert result["npmAttached"] is False + assert result["frontdoorAttached"] is True + assert "a" * 64 not in repr(result) + + +def test_portainer_route_network_proof_rejects_duplicate_or_unavailable_inventory(monkeypatch): + duplicate = _forge_network_inventory() + [_forge_network_inventory()[0]] + assert _portainer(monkeypatch, duplicate).forge_route_network_readiness()["status"] == "unknown" + + connector = _portainer(monkeypatch, {"not": "a list"}) + assert connector.forge_route_network_readiness()["status"] == "unknown" + + +def test_portainer_route_network_proof_requires_distinct_docker_containers(monkeypatch): + same_container = _docker_container( + "NginxProxyManager", + container_id="a" * 64, + networks={_NETWORK: {"NetworkID": "1" * 64, "EndpointID": "2" * 64}}, + labels={ + "com.docker.compose.project": "itworx-forge", + "com.docker.compose.service": "itworx-forge", + }, + image="jlesage/nginx-proxy-manager:2.15.1", + ) + + result = _portainer(monkeypatch, [same_container]).forge_route_network_readiness() + + assert result["status"] == "unknown" + + +def test_portainer_route_network_proof_requires_same_network_identity(monkeypatch): + inventory = _forge_network_inventory() + inventory[1]["NetworkSettings"]["Networks"][_NETWORK]["NetworkID"] = "9" * 64 + + result = _portainer(monkeypatch, inventory).forge_route_network_readiness() + + assert result["status"] == "unknown" + + +def test_portainer_route_network_proof_requires_https_verified_tls_and_200(monkeypatch): + from backend.app.connectors.portainer import PortainerConnector + + apis = dict(settings.apis) + apis.update( + { + "PORTAINER_URL": "http://portainer.example", + "PORTAINER_API_KEY": "test-api-key-not-a-real-secret", + "PORTAINER_ENDPOINT_ID": "1", + "PORTAINER_VERIFY_TLS": True, + } + ) + monkeypatch.setattr(settings, "apis", apis) + calls = [] + monkeypatch.setattr( + PortainerConnector, + "_request", + lambda *args, **kwargs: calls.append((args, kwargs)) or _DockerResponse(_forge_network_inventory()), + ) + assert PortainerConnector().forge_route_network_readiness()["status"] == "unknown" + assert calls == [] + assert _portainer(monkeypatch, _forge_network_inventory(), status_code=302).forge_route_network_readiness()["status"] == "unknown" + + malformed = dict(settings.apis) + malformed.update({"PORTAINER_URL": "https://[bad", "PORTAINER_VERIFY_TLS": True}) + monkeypatch.setattr(settings, "apis", malformed) + assert PortainerConnector().forge_route_network_readiness()["status"] == "unknown" + + +@pytest.mark.parametrize("network_entry", [None, {}, {"NetworkID": "", "EndpointID": ""}]) +def test_portainer_route_network_proof_requires_valid_network_membership_identity(monkeypatch, network_entry): + inventory = _forge_network_inventory() + inventory[0]["NetworkSettings"]["Networks"][_NETWORK] = network_entry + result = _portainer(monkeypatch, inventory).forge_route_network_readiness() + assert result["status"] == "unknown" + assert result["npmAttached"] is None + + +def test_portainer_route_uses_one_verified_tls_config_snapshot_without_generic_reread(monkeypatch): + from backend.app.connectors import portainer as portainer_module + from backend.app.connectors.portainer import PortainerConnector + + apis = dict(settings.apis) + apis.update( + { + "PORTAINER_URL": "https://portainer.example", + "PORTAINER_API_KEY": "snapshot-api-key", + "PORTAINER_ENDPOINT_ID": "7", + "PORTAINER_VERIFY_TLS": True, + } + ) + monkeypatch.setattr(settings, "apis", apis) + calls = [] + + class _PinnedClient: + def __init__(self, *, timeout, verify, follow_redirects): + calls.append(("client", timeout, verify, follow_redirects)) + changed = dict(settings.apis) + changed["PORTAINER_URL"] = "http://attacker.example" + changed["PORTAINER_API_KEY"] = "changed-key" + changed["PORTAINER_VERIFY_TLS"] = False + monkeypatch.setattr(settings, "apis", changed) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def get(self, url, *, headers, follow_redirects): + calls.append(("get", url, headers, follow_redirects)) + return _DockerResponse(_forge_network_inventory(), status_code=200) + + monkeypatch.setattr(portainer_module.httpx, "Client", _PinnedClient) + monkeypatch.setattr( + PortainerConnector, + "_request", + lambda *_args, **_kwargs: pytest.fail("route proof must use its captured verified HTTPS config"), + ) + + result = PortainerConnector().forge_route_network_readiness() + + assert result["status"] == "ready" + assert calls[0] == ("client", 8.0, True, False) + assert calls[1][0] == "get" + assert calls[1][1] == "https://portainer.example/api/endpoints/7/docker/containers/json?all=1" + assert calls[1][2]["X-API-Key"] == "snapshot-api-key" + assert calls[1][3] is False + + +def test_npm_forge_route_inventory_requires_bounded_bare_array(): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + parse = NginxProxyManagerConnector._parse_forge_route_hosts + with pytest.raises(RuntimeError, match="npm_forge_route_inventory_invalid"): + parse({"data": [{"domain_names": ["forge.example.com"]}]}) + + with pytest.raises(RuntimeError, match="npm_forge_route_inventory_invalid"): + parse([{"domain_names": ["forge.example.com"]}, "malformed row"]) + + with pytest.raises(RuntimeError, match="npm_forge_route_inventory_invalid"): + parse([{"id": index} for index in range(1001)]) + + assert parse([{"domain_names": ["forge.example.com"]}]) == [ + {"domain_names": ["forge.example.com"]} + ] + + +def test_npm_route_session_pins_endpoint_and_verified_tls_across_inventory_and_post(monkeypatch): + from backend.app.connectors import nginx_proxy_manager as npm_module + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + apis = dict(settings.apis) + apis.update( + { + "NPM_URL": "https://npm.example", + "NPM_IDENTITY": "operator@example.test", + "NPM_SECRET": "route-session-secret", + "NPM_VERIFY_TLS": True, + } + ) + monkeypatch.setattr(settings, "apis", apis) + calls = [] + api_calls = [] + permissions = { + "id": 7, + "is_disabled": False, + "permissions": {"visibility": "all", "proxy_hosts": "manage"}, + } + + class _Response: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def json(self): + return self._payload + + class _Client: + def __init__(self, *, timeout, verify, follow_redirects): + calls.append(("client", timeout, verify, follow_redirects)) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, *, json, headers, follow_redirects): + calls.append(("login", url, json, headers, follow_redirects)) + return _Response({"token": "opaque-test-token"}) + + def request(self, method, url, *, headers, follow_redirects, **kwargs): + calls.append(("request", method, url, headers, follow_redirects, kwargs)) + api_calls.append((method, url, kwargs)) + if url.endswith("/users/me"): + return _Response(permissions) + if url.endswith("/nginx/proxy-hosts") and method == "GET": + return _Response([]) + if url.endswith("/nginx/proxy-hosts") and method == "POST": + return _Response({"id": 31, **kwargs["json"]}, status_code=201) + pytest.fail("route session used an unsupported NPM endpoint") + + monkeypatch.setattr(npm_module.httpx, "Client", _Client) + connector = NginxProxyManagerConnector() + route_session = connector.forge_route_session() + assert route_session is not None + assert route_session.verified_tls is True + assert route_session.list_forge_route_hosts() == [] + created = route_session.create_forge_disabled_proxy_host("froute-" + "e" * 32) + assert isinstance(created, dict) + assert [call[:2] for call in api_calls] == [ + ("GET", "https://npm.example/api/users/me"), + ("GET", "https://npm.example/api/nginx/proxy-hosts"), + ("GET", "https://npm.example/api/users/me"), + ("POST", "https://npm.example/api/nginx/proxy-hosts"), + ] + assert api_calls[0][2] == {"params": {"expand": "permissions"}} + assert api_calls[1][2] == {} + assert api_calls[2][2] == {"params": {"expand": "permissions"}} + request_calls = [call for call in calls if call[0] == "request"] + assert all(call[3]["Authorization"] == "Bearer opaque-test-token" for call in request_calls) + create_call = next(call for call in calls if call[0] == "request" and call[1] == "POST") + assert create_call[2] == "https://npm.example/api/nginx/proxy-hosts" + assert create_call[4] is False + assert create_call[5]["json"] == { + "domain_names": ["forge.example.com"], + "forward_scheme": "http", + "forward_host": "itworx-forge", + "forward_port": 8080, + "access_list_id": 0, + "certificate_id": 0, + "enabled": False, + "ssl_forced": False, + "http2_support": False, + "hsts_enabled": False, + "hsts_subdomains": False, + "caching_enabled": False, + "block_exploits": True, + "allow_websocket_upgrade": False, + "trust_forwarded_proto": False, + "locations": [], + "meta": {"appops_forge_route_id": "froute-" + "e" * 32}, + "advanced_config": "", + } + + changed = dict(settings.apis) + changed["NPM_URL"] = "https://different-npm.example" + monkeypatch.setattr(settings, "apis", changed) + with pytest.raises(RuntimeError, match="npm_configuration_changed"): + route_session.create_forge_disabled_proxy_host("froute-" + "e" * 32) + + assert calls[0] == ("client", 10.0, True, False) + assert calls[1][0] == "login" + assert calls[1][1] == "https://npm.example/api/tokens" + assert calls[1][2]["secret"] == "route-session-secret" + assert calls[1][4] is False + assert calls[3][1:3] == ("GET", "https://npm.example/api/users/me") + assert calls[3][4] is False + assert all(call[2] is True and call[3] is False for call in calls if call[0] == "client") + assert sum(call[0] == "request" and call[1] == "POST" for call in calls) == 1 + + +@pytest.mark.parametrize( + ("user", "status_code", "invalid_json"), + [ + ({}, 200, False), + ({"id": 7, "is_disabled": False}, 200, False), + ( + {"id": 7, "is_disabled": False, "permissions": {"visibility": "all"}}, + 200, + False, + ), + ( + { + "id": 7, + "is_disabled": False, + "permissions": {"visibility": "user", "proxy_hosts": "manage"}, + }, + 200, + False, + ), + ( + { + "id": 7, + "is_disabled": False, + "permissions": {"visibility": "all", "proxy_hosts": "view"}, + }, + 200, + False, + ), + ( + { + "id": 7, + "is_disabled": True, + "permissions": {"visibility": "all", "proxy_hosts": "manage"}, + }, + 200, + False, + ), + ( + { + "id": True, + "is_disabled": False, + "permissions": {"visibility": "all", "proxy_hosts": "manage"}, + }, + 200, + False, + ), + ( + { + "id": 0, + "is_disabled": False, + "permissions": {"visibility": "all", "proxy_hosts": "manage"}, + }, + 200, + False, + ), + ( + { + "id": 7, + "is_disabled": "false", + "permissions": {"visibility": "all", "proxy_hosts": "manage"}, + }, + 200, + False, + ), + ( + { + "id": 7, + "is_disabled": False, + "permissions": ["all", "manage"], + }, + 200, + False, + ), + ( + { + "id": 7, + "is_disabled": False, + "permissions": {"visibility": "all", "proxy_hosts": "manage"}, + }, + 302, + False, + ), + (None, 200, True), + ], + ids=( + "absent-user", + "absent-permissions", + "partial-permissions", + "wrong-visibility", + "wrong-capability", + "disabled-user", + "boolean-user-id", + "zero-user-id", + "string-disabled-flag", + "malformed-permissions", + "redirect", + "malformed-json", + ), +) +def test_npm_route_session_denies_incomplete_permissions_before_inventory( + monkeypatch, user, status_code, invalid_json +): + from backend.app.connectors import nginx_proxy_manager as npm_module + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + apis = dict(settings.apis) + apis.update( + { + "NPM_URL": "https://npm.example", + "NPM_IDENTITY": "operator@example.test", + "NPM_SECRET": "route-session-secret", + "NPM_VERIFY_TLS": True, + } + ) + monkeypatch.setattr(settings, "apis", apis) + api_calls = [] + + class _Response: + def __init__(self, payload, response_status, invalid_json_response=False): + self._payload = payload + self.status_code = response_status + self._invalid_json_response = invalid_json_response + + def json(self): + if self._invalid_json_response: + raise ValueError("malformed response") + return self._payload + + class _Client: + def __init__(self, *, timeout, verify, follow_redirects): + assert verify is True + assert follow_redirects is False + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, *, json, headers, follow_redirects): + return _Response({"token": "opaque-test-token"}, 200) + + def request(self, method, url, *, headers, follow_redirects, **kwargs): + api_calls.append((method, url, kwargs)) + assert url.endswith("/users/me") + return _Response(user, status_code, invalid_json) + + monkeypatch.setattr(npm_module.httpx, "Client", _Client) + route_session = NginxProxyManagerConnector().forge_route_session() + assert route_session is not None + + from backend.app.connectors.nginx_proxy_manager import _NpmForgeRoutePermissionDenied + + with pytest.raises(_NpmForgeRoutePermissionDenied): + route_session.create_forge_disabled_proxy_host("froute-" + "e" * 32) + + assert api_calls == [ + ("GET", "https://npm.example/api/users/me", {"params": {"expand": "permissions"}}) + ] + assert not any(method == "POST" for method, _url, _kwargs in api_calls) + assert all(url.endswith("/users/me") for _method, url, _kwargs in api_calls) + + +def test_npm_route_session_treats_permission_probe_transport_failure_as_preflight_denial(monkeypatch): + import httpx + + from backend.app.connectors import nginx_proxy_manager as npm_module + from backend.app.connectors.nginx_proxy_manager import ( + NginxProxyManagerConnector, + _NpmForgeRoutePermissionDenied, + ) + + apis = dict(settings.apis) + apis.update( + { + "NPM_URL": "https://npm.example", + "NPM_IDENTITY": "operator@example.test", + "NPM_SECRET": "route-session-secret", + "NPM_VERIFY_TLS": True, + } + ) + monkeypatch.setattr(settings, "apis", apis) + api_calls = [] + + class _Response: + status_code = 200 + + def json(self): + return {"token": "opaque-test-token"} + + class _Client: + def __init__(self, *, timeout, verify, follow_redirects): + assert verify is True + assert follow_redirects is False + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, *, json, headers, follow_redirects): + return _Response() + + def request(self, method, url, *, headers, follow_redirects, **kwargs): + api_calls.append((method, url, kwargs)) + request = httpx.Request(method, url) + raise httpx.ConnectTimeout("private transport detail", request=request) + + monkeypatch.setattr(npm_module.httpx, "Client", _Client) + route_session = NginxProxyManagerConnector().forge_route_session() + assert route_session is not None + + with pytest.raises(_NpmForgeRoutePermissionDenied): + route_session.list_forge_route_hosts() + + assert api_calls == [ + ("GET", "https://npm.example/api/users/me", {"params": {"expand": "permissions"}}) + ] + + +def test_npm_route_session_rechecks_permission_drift_before_single_post(monkeypatch): + from backend.app.connectors import nginx_proxy_manager as npm_module + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + apis = dict(settings.apis) + apis.update( + { + "NPM_URL": "https://npm.example", + "NPM_IDENTITY": "operator@example.test", + "NPM_SECRET": "route-session-secret", + "NPM_VERIFY_TLS": True, + } + ) + monkeypatch.setattr(settings, "apis", apis) + api_calls = [] + user_responses = [ + {"id": 7, "is_disabled": False, "permissions": {"visibility": "all", "proxy_hosts": "manage"}}, + {"id": 7, "is_disabled": False, "permissions": {"visibility": "all", "proxy_hosts": "view"}}, + ] + + class _Response: + status_code = 200 + + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + class _Client: + def __init__(self, *, timeout, verify, follow_redirects): + assert verify is True + assert follow_redirects is False + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, *, json, headers, follow_redirects): + return _Response({"token": "opaque-test-token"}) + + def request(self, method, url, *, headers, follow_redirects, **kwargs): + api_calls.append((method, url, kwargs)) + if url.endswith("/users/me"): + return _Response(user_responses.pop(0)) + if url.endswith("/nginx/proxy-hosts") and method == "GET": + return _Response([]) + pytest.fail("permission drift must prevent the NPM POST") + + monkeypatch.setattr(npm_module.httpx, "Client", _Client) + route_session = NginxProxyManagerConnector().forge_route_session() + assert route_session is not None + assert route_session.list_forge_route_hosts() == [] + + from backend.app.connectors.nginx_proxy_manager import _NpmForgeRoutePermissionDenied + + with pytest.raises(_NpmForgeRoutePermissionDenied): + route_session.create_forge_disabled_proxy_host("froute-" + "e" * 32) + + assert [call[:2] for call in api_calls] == [ + ("GET", "https://npm.example/api/users/me"), + ("GET", "https://npm.example/api/nginx/proxy-hosts"), + ("GET", "https://npm.example/api/users/me"), + ] + assert not any(method == "POST" for method, _url, _kwargs in api_calls) + + +def test_npm_route_session_refuses_insecure_config_before_login(monkeypatch): + from backend.app.connectors import nginx_proxy_manager as npm_module + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + apis = dict(settings.apis) + apis.update( + { + "NPM_URL": "http://npm.example", + "NPM_IDENTITY": "operator@example.test", + "NPM_SECRET": "route-session-secret", + "NPM_VERIFY_TLS": True, + } + ) + monkeypatch.setattr(settings, "apis", apis) + monkeypatch.setattr( + npm_module.httpx, + "Client", + lambda **_kwargs: pytest.fail("insecure NPM configuration must be rejected before login"), + ) + + assert NginxProxyManagerConnector().forge_route_session() is None diff --git a/backend/tests/test_forge_route_stage_api.py b/backend/tests/test_forge_route_stage_api.py new file mode 100644 index 0000000..b9a6ead --- /dev/null +++ b/backend/tests/test_forge_route_stage_api.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.services import user_access + +_REAL_CURRENT_USER = user_access.current_user +_JOB_ID = "forge-install-" + "a" * 64 +_PATH = f"/api/deployments/forge-bootstrap/first-install/{_JOB_ID}/route" + + +@pytest.fixture +def forge_route_operator_client(tmp_path, monkeypatch: pytest.MonkeyPatch): + from backend.app.core.config import settings + from backend.app.db import session as db_session + from backend.app.db.init_db import init_db + from backend.app.main import app + + engine = create_engine( + f"sqlite:///{tmp_path / 'forge-route-api.db'}", + connect_args={"check_same_thread": False}, + ) + monkeypatch.setattr(db_session, "engine", engine) + monkeypatch.setenv("APPOPS_REQUIRE_OPERATOR_AUTH", "false") + monkeypatch.setattr(settings, "operator_proxy_auth_enabled", False) + monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"], raising=False) + monkeypatch.setattr(settings, "auth_admin_emails", [], raising=False) + monkeypatch.setattr(settings, "data_dir", str(tmp_path)) + monkeypatch.setattr(user_access, "current_user", _REAL_CURRENT_USER) + SQLModel.metadata.create_all(engine) + init_db() + client = TestClient(app, client=("198.18.10.20", 42000)) + yield client + engine.dispose() + + +def _sign_in(client: TestClient, subject: str) -> None: + from backend.app.db import session as db_session + from backend.app.services.user_access import issue_session, provision_user + + with Session(db_session.engine) as session: + user = provision_user( + session, + "authentik", + "https://auth.example/application/o/appops/", + {"sub": subject, "email": f"{subject}@example.test"}, + ) + cookie = issue_session(session, user) + client.cookies.set("appops_user_session", cookie) + + +def _safe_result(status="host_staged_disabled"): + staged = status == "host_staged_disabled" + return { + "success": staged, + "status": status, + "message": "forge_route_host_staged_disabled_not_routed" if staged else "forge_route_operation_not_found", + "target": "itworx-forge", + "jobId": _JOB_ID, + "hostname": "forge.example.com", + "upstream": "http://itworx-forge:8080", + "routeState": "host_staged_disabled" if staged else "not_routed", + "routed": False, + } + + +def test_route_post_requires_operator_csrf_strict_empty_body_and_bounded_key( + forge_route_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.services import forge_route_stage + + calls = [] + monkeypatch.setattr( + forge_route_stage, + "stage_forge_route", + lambda session, **kwargs: calls.append((session, kwargs)) or _safe_result(), + ) + + unauthenticated = forge_route_operator_client.post( + _PATH, + headers={"Idempotency-Key": "forge-route-api-0001"}, + content=b"", + ) + assert unauthenticated.status_code == 401 + + _sign_in(forge_route_operator_client, "owner") + missing_csrf = forge_route_operator_client.post( + _PATH, + headers={"Idempotency-Key": "forge-route-api-0001"}, + content=b"", + ) + assert missing_csrf.status_code == 403 + assert calls == [] + + invalid_body = forge_route_operator_client.post( + _PATH, + headers={ + "X-AppOps-Session-CSRF": "1", + "Idempotency-Key": "forge-route-api-0001", + }, + json={}, + ) + assert invalid_body.status_code == 400 + + invalid_key = forge_route_operator_client.post( + _PATH, + headers={ + "X-AppOps-Session-CSRF": "1", + "Idempotency-Key": "bad key", + }, + content=b"", + ) + assert invalid_key.status_code == 400 + + oversized_key = forge_route_operator_client.post( + _PATH, + headers={ + "X-AppOps-Session-CSRF": "1", + "Idempotency-Key": "x" * 129, + }, + content=b"", + ) + assert oversized_key.status_code == 400 + + duplicate_key = forge_route_operator_client.post( + _PATH, + headers=[ + ("X-AppOps-Session-CSRF", "1"), + ("Idempotency-Key", "forge-route-api-duplicate-01"), + ("Idempotency-Key", "forge-route-api-duplicate-02"), + ], + content=b"", + ) + assert duplicate_key.status_code == 400 + assert calls == [] + + accepted = forge_route_operator_client.post( + _PATH, + headers={ + "X-AppOps-Session-CSRF": "1", + "Idempotency-Key": "forge-route-api-0001", + }, + content=b"", + ) + assert accepted.status_code == 200 + assert accepted.json() == _safe_result() + assert len(calls) == 1 + assert calls[0][1] == { + "job_id": _JOB_ID, + "idempotency_key": "forge-route-api-0001", + "actor_identity": "authentik:owner", + } + + +def test_route_post_denies_read_only_user_before_service( + forge_route_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.services import forge_route_stage + + monkeypatch.setattr( + forge_route_stage, + "stage_forge_route", + lambda *_args, **_kwargs: pytest.fail("read-only user must not reach the executor"), + ) + _sign_in(forge_route_operator_client, "reader") + + denied = forge_route_operator_client.post( + _PATH, + headers={ + "X-AppOps-Session-CSRF": "1", + "Idempotency-Key": "forge-route-api-0002", + }, + content=b"", + ) + + assert denied.status_code == 403 + + +def test_route_get_is_authenticated_and_delegates_only_safe_reconcile( + forge_route_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.services import forge_route_stage + + calls = [] + monkeypatch.setattr( + forge_route_stage, + "reconcile_forge_route", + lambda session, **kwargs: calls.append((session, kwargs)) or _safe_result("not_found"), + ) + + unauthenticated = forge_route_operator_client.get(_PATH) + assert unauthenticated.status_code == 401 + + _sign_in(forge_route_operator_client, "reader") + response = forge_route_operator_client.get(_PATH) + assert response.status_code == 200 + assert response.json() == _safe_result("not_found") + assert len(calls) == 1 + assert calls[0][1] == {"job_id": _JOB_ID, "actor_identity": "authentik:reader"} + + invalid_body = forge_route_operator_client.request("GET", _PATH, content=b"unexpected") + assert invalid_body.status_code == 400 + assert len(calls) == 1 + + +def test_empty_request_body_guard_rejects_first_nonempty_stream_chunk(): + import asyncio + + from fastapi import HTTPException + from starlette.requests import Request + + from backend.app.api.routes_deployments import _require_empty_forge_route_request + + receive_calls = [] + + async def receive(): + receive_calls.append(True) + if len(receive_calls) == 1: + return {"type": "http.request", "body": b"x", "more_body": True} + return {"type": "http.request", "body": b"oversized remainder", "more_body": False} + + request = Request( + { + "type": "http", + "method": "POST", + "path": _PATH, + "raw_path": _PATH.encode(), + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 12345), + "server": ("testserver", 80), + "scheme": "http", + }, + receive, + ) + + with pytest.raises(HTTPException) as error: + asyncio.run(_require_empty_forge_route_request(request)) + + assert error.value.status_code == 400 + assert len(receive_calls) == 1 diff --git a/backend/tests/test_forge_route_stage_service.py b/backend/tests/test_forge_route_stage_service.py new file mode 100644 index 0000000..ad3ee31 --- /dev/null +++ b/backend/tests/test_forge_route_stage_service.py @@ -0,0 +1,904 @@ +from __future__ import annotations + +import hashlib +import json +from contextlib import nullcontext +from datetime import UTC, datetime + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + + +_JOB_ID = "forge-install-" + "d" * 64 +_REVISION = "059" + "a" * 37 +_APP_OPS_REVISION = "b" * 40 +_WORKSPACE_DIGEST = "sha256:" + "b" * 64 +_FRONTDOOR_DIGEST = "sha256:" + "c" * 64 +_NETWORK = "itworx-mcp-hub-npm" + + +@pytest.fixture +def route_db(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'forge-route-stage.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + yield engine, session + engine.dispose() + + +def _job(session, *, status="deployed"): + from backend.app.db.models import DeploymentJob + from backend.app.services.forge_bootstrap_apply import _sha256 + + plan = { + "api_version": "itworx.forge-first-install/v1", + "revision": _REVISION, + "workspace_image_digest": _WORKSPACE_DIGEST, + "frontdoor_image_digest": _FRONTDOOR_DIGEST, + "preflight_job_id": "forge-preflight-" + "e" * 16, + "preflight_sha256": "f" * 64, + } + historical = { + "apiVersion": "itworx.forge-first-install/v1", + "status": "deployed", + "request": "2" * 64, + "repository": "jens/itworx-forge", + "revision": _REVISION, + "operation": "forge_first_install", + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + "receipt": { + "revision": _REVISION, + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + "migration": {"service": "migrate", "status": "completed", "result": "succeeded"}, + "health": { + "status": "healthy", + "services": ["api", "dashboard", "itworx-forge", "postgres", "worker"], + }, + }, + } + job = DeploymentJob( + id=_JOB_ID, + application_id="itworx-forge", + repository="jens/itworx-forge", + revision=_REVISION, + plan_sha256=_sha256(plan), + idempotency_sha256="1" * 64, + status=status, + stage="controller_deployed" if status == "deployed" else "applying", + plan_json=plan, + preflight_json={ + "apiVersion": "itworx.forge-bootstrap-preflight/v1", + "jobId": plan["preflight_job_id"], + "sha256": plan["preflight_sha256"], + }, + evidence_json={"profile": "forge-first-install", "controller": historical}, + broker_request_id="2" * 64, + completed_at=datetime.now(UTC) if status == "deployed" else None, + ) + session.add(job) + session.commit() + return job + + +def _ready(job_id=_JOB_ID, *, status="ready", observed_at=None, migration_completed=True): + from backend.app.schemas.forge_runtime_readiness import _RUNTIME_SERVICES + + return { + "status": status, + "message": { + "ready": "forge_runtime_ready", + "not_ready": "forge_runtime_not_ready", + "unavailable": "forge_runtime_readiness_unavailable", + }[status], + "target": "itworx-forge", + "jobId": job_id, + "revision": _REVISION, + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + "observedAt": int(datetime.now(UTC).timestamp()) if observed_at is None else observed_at, + "health": { + "status": "healthy" if status == "ready" else "not_ready", + "services": [ + {"name": name, "status": "healthy" if status == "ready" else "not_ready"} + for name in _RUNTIME_SERVICES + ], + }, + "migrationCompleted": migration_completed, + "controllerArtifact": { + "controllerVersion": "2.4.1", + "sourceRepository": "jens/unraid-appops-gateway", + "sourceRevision": _APP_OPS_REVISION, + "sourceTree": "3" * 40, + "artifactSha256": "4" * 64, + }, + } + + +def _route_host(owner: str, *, enabled=False, domains=None): + return { + "id": 31, + "domain_names": ["forge.example.com"] if domains is None else domains, + "forward_scheme": "http", + "forward_host": "itworx-forge", + "forward_port": 8080, + "access_list_id": 0, + "certificate_id": 0, + "enabled": enabled, + "ssl_forced": False, + "http2_support": False, + "hsts_enabled": False, + "hsts_subdomains": False, + "caching_enabled": False, + "block_exploits": True, + "allow_websocket_upgrade": False, + "trust_forwarded_proto": False, + "locations": [], + "advanced_config": "", + "meta": {"appops_forge_route_id": owner}, + } + + +class _FakeNpm: + def __init__( + self, + hosts=None, + *, + create_error=None, + permission_denied=False, + host_enabled=False, + endpoint="https://npm.example", + ): + self.hosts = list(hosts or []) + self.verified_tls = True + self.endpoint_sha256 = hashlib.sha256(endpoint.encode("ascii")).hexdigest() + self.create_error = create_error + self.permission_denied = permission_denied + self.host_enabled = host_enabled + self.calls = [] + self.ownership_id = None + + def forge_route_session(self): + return self + + def list_forge_route_hosts(self): + self.calls.append("GET") + return list(self.hosts) + + def create_forge_disabled_proxy_host(self, ownership_id): + if self.permission_denied: + self.calls.append("PERMISSION_DENIED") + from backend.app.connectors.nginx_proxy_manager import _NpmForgeRoutePermissionDenied + + raise _NpmForgeRoutePermissionDenied() + self.calls.append("POST") + self.ownership_id = ownership_id + if self.create_error: + raise RuntimeError("secret-looking raw upstream detail must not escape") + self.hosts.append(_route_host(ownership_id, enabled=self.host_enabled)) + return {"id": 31} + + +class _FakePortainer: + def __init__(self, result=None): + self.result = result or { + "status": "ready", + "network": _NETWORK, + "npmAttached": True, + "frontdoorAttached": True, + } + self.calls = 0 + + def forge_route_network_readiness(self): + self.calls += 1 + return self.result + + +def _allow_route_policy(session): + from backend.app.db.models import Policy + + policy = Policy( + id="policy-forge-route-stage", + action="forge.route.stage", + target="itworx-forge", + manual="allowed", + voice="blocked", + risk="critical", + ) + session.add(policy) + session.commit() + return policy + + +def _wire_service(monkeypatch, *, ready=None, after_readiness=None): + from backend.app.services import forge_route_stage + from backend.app.services.forge_bootstrap_apply import ForgeBootstrapApplyService + + monkeypatch.setattr(forge_route_stage, "_forge_oidc_process_lock", lambda: nullcontext()) + monkeypatch.setattr(forge_route_stage, "_secret_store_ready", lambda: True) + + def current_readiness(_service, session, job_id): + if after_readiness is not None: + after_readiness(session) + return ready or _ready(job_id) + + monkeypatch.setattr(ForgeBootstrapApplyService, "current_readiness", current_readiness) + monkeypatch.setattr(forge_route_stage, "_audit", lambda *args, **kwargs: True) + return forge_route_stage + + +def test_stage_persists_encrypted_one_shot_intent_before_single_fixed_post( + route_db, monkeypatch +): + from backend.app.db.models import Secret + from backend.app.services.secret_store import get_secret + + engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm() + fake_portainer = _FakePortainer() + stage = _wire_service(monkeypatch) + observed_intent = [] + original_create = fake_npm.create_forge_disabled_proxy_host + observer_errors = [] + + def inspect_intent_before_post(owner): + try: + with Session(engine) as observer: + secret_ref = stage._journal_secret_ref(job.id) + row = observer.get(Secret, secret_ref) + assert row is not None + assert "route-stage-key-0001" not in row.encrypted_value + assert owner not in row.encrypted_value + record = json.loads(get_secret(observer, secret_ref, touch=False)) + assert record["phase"] == "pending" + assert record["idempotencyKeySha256"] == __import__("hashlib").sha256( + b"route-stage-key-0001" + ).hexdigest() + assert record["ownershipId"] == owner + expected_endpoint = hashlib.sha256(b"https://npm.example").hexdigest() + assert record["npmEndpointSha256"] == expected_endpoint + assert "npm.example" not in json.dumps(record) + assert "nonce" not in record + observed_intent.append(record) + except Exception as exc: + observer_errors.append(repr(exc)) + raise + return original_create(owner) + + fake_npm.create_forge_disabled_proxy_host = inspect_intent_before_post + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-key-0001", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=fake_portainer, + ) + + assert observed_intent, {"result": result, "calls": fake_npm.calls, "errors": observer_errors} + assert result["status"] == "host_staged_disabled", result + assert result["routeState"] == "host_staged_disabled" + assert result["routed"] is False + assert result["hostname"] == "forge.example.com" + assert fake_npm.calls == ["GET", "POST", "GET"] + assert fake_portainer.calls == 1 + assert observed_intent[0]["revision"] == _REVISION + assert observed_intent[0]["desired"]["trust_forwarded_proto"] is False + assert observed_intent[0]["desired"]["locations"] == [] + assert observed_intent[0]["workspaceImageDigest"] == _WORKSPACE_DIGEST + assert observed_intent[0]["frontdoorImageDigest"] == _FRONTDOOR_DIGEST + assert "secret-looking raw upstream detail" not in json.dumps(result) + + +def test_stage_records_permission_denial_as_aborted_before_post(route_db, monkeypatch): + from backend.app.services import forge_route_stage + + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm(permission_denied=True) + stage = _wire_service(monkeypatch) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-key-0001", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + state, record = forge_route_stage._load_record(session, job.id) + assert result["status"] == "blocked" + assert result["message"] == "forge_route_npm_permission_required" + assert fake_npm.calls == ["GET", "PERMISSION_DENIED"] + assert "POST" not in fake_npm.calls + assert state == "present" and record is not None + assert record["phase"] == "aborted_before_post" + + +@pytest.mark.parametrize( + "readiness,network", + [ + (_ready(status="not_ready"), None), + (_ready(observed_at=1), None), + (_ready(migration_completed=False), None), + (None, {"status": "ready", "network": _NETWORK, "npmAttached": True, "frontdoorAttached": "true"}), + ], +) +def test_stage_fails_closed_on_stale_or_incomplete_readiness_or_network( + route_db, monkeypatch, readiness, network +): + engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm() + fake_portainer = _FakePortainer(network) if network is not None else _FakePortainer() + stage = _wire_service(monkeypatch, ready=readiness) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-key-0002", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=fake_portainer, + ) + + assert result["status"] == "blocked" + assert "POST" not in fake_npm.calls + + +@pytest.mark.parametrize( + "field,value", + [ + ("jobId", "forge-install-" + "9" * 64), + ("revision", "f" * 40), + ("workspaceImageDigest", "sha256:" + "9" * 64), + ("frontdoorImageDigest", "sha256:" + "8" * 64), + ], +) +def test_stage_fails_closed_on_wrong_current_readiness_identity(route_db, monkeypatch, field, value): + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm() + readiness = _ready(job.id) + readiness[field] = value + stage = _wire_service(monkeypatch, ready=readiness) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-wrong-readiness-01", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + assert result["status"] == "blocked" + assert fake_npm.calls.count("POST") == 0 + + +@pytest.mark.parametrize("case", ["job_status", "repository", "target", "profile_receipt"]) +def test_stage_fails_closed_on_nonexact_first_install_job(route_db, monkeypatch, case): + from backend.app.services.forge_bootstrap_apply import _sha256 + + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + if case == "job_status": + job.status = "applying" + elif case == "repository": + job.repository = "jens/another-repository" + elif case == "target": + job.application_id = "another-target" + else: + job.plan_json = {**job.plan_json, "frontdoor_image_digest": "sha256:" + "9" * 64} + job.plan_sha256 = _sha256(job.plan_json) + session.add(job) + session.commit() + fake_npm = _FakeNpm() + stage = _wire_service(monkeypatch) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-nonexact-job-01", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + assert result["status"] == "blocked" + assert fake_npm.calls == [] + + +@pytest.mark.parametrize( + "domain,expected_status", + [ + ("*.example.com", "conflict"), + ("*.tech", "conflict"), + ("*.unrelated.example", "host_staged_disabled"), + ], +) +def test_stage_blocks_covering_wildcards_but_allows_unrelated_wildcard( + route_db, monkeypatch, domain, expected_status +): + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm([_route_host("froute-" + "8" * 32, domains=[domain])]) + stage = _wire_service(monkeypatch) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-wildcard-0001", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + assert result["status"] == expected_status + assert fake_npm.calls.count("POST") == (1 if expected_status == "host_staged_disabled" else 0) + + +def test_stage_rejects_malformed_npm_domain_inventory_without_post(route_db, monkeypatch): + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm([_route_host("froute-" + "8" * 32, domains=["bad..example.com"])]) + stage = _wire_service(monkeypatch) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-malformed-domain-01", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + assert result["status"] == "failed" + assert fake_npm.calls == ["GET"] + + +def test_stage_lock_time_policy_recheck_prevents_npm_post(route_db, monkeypatch): + engine, session = route_db + job = _job(session) + policy = _allow_route_policy(session) + fake_npm = _FakeNpm() + stage = _wire_service( + monkeypatch, + after_readiness=lambda active_session: _block_route_policy(active_session), + ) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-key-0003", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + session.refresh(policy) + assert result["status"] == "blocked" + assert policy.manual == "blocked" + assert "POST" not in fake_npm.calls + + +def test_readiness_expiring_after_intent_commit_aborts_before_npm_post(route_db, monkeypatch): + from backend.app.services import forge_route_stage + + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm() + fake_time = [1000] + monkeypatch.setattr(forge_route_stage.time, "time", lambda: fake_time[0]) + stage = _wire_service(monkeypatch, ready=_ready(job.id, observed_at=1000)) + original_save = stage._save_record + first_save = [True] + + def save_then_expire(active_session, record): + result = original_save(active_session, record) + if first_save[0]: + first_save[0] = False + fake_time[0] = 1031 + return result + + monkeypatch.setattr(stage, "_save_record", save_then_expire) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-expired-ready-01", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + assert result["status"] == "blocked" + assert result["message"] == "forge_route_current_readiness_expired" + assert "POST" not in fake_npm.calls + + +def _block_route_policy(session): + from backend.app.db.models import Policy + + policy = session.exec(select(Policy).where(Policy.action == "forge.route.stage")).one() + policy.manual = "blocked" + session.add(policy) + session.commit() + + +def test_foreign_host_collision_is_conflict_and_never_adopted(route_db, monkeypatch): + engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm([_route_host("froute-" + "9" * 32)]) + stage = _wire_service(monkeypatch) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-key-0004", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + assert result["status"] == "conflict" + assert "POST" not in fake_npm.calls + assert fake_npm.hosts[0]["meta"]["appops_forge_route_id"] == "froute-" + "9" * 32 + + +def test_get_reconciliation_audits_phase_change_once_and_skips_unchanged_write(route_db, monkeypatch): + from backend.app.services import forge_route_stage + + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm(create_error=RuntimeError("uncertain")) + stage = _wire_service(monkeypatch) + first = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-get-audit-0001", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + assert first["status"] == "outcome_unknown" + fake_npm.create_error = None + fake_npm.hosts = [_route_host(fake_npm.ownership_id)] + audit_calls = [] + def audit_and_commit(session, **kwargs): + audit_calls.append(kwargs) + session.commit() + return True + + monkeypatch.setattr(forge_route_stage, "_audit", audit_and_commit) + + reconciled = forge_route_stage.reconcile_forge_route( + session, + job_id=job.id, + actor_identity="authentik:operator-42", + npm=fake_npm, + ) + assert reconciled["status"] == "host_staged_disabled" + assert len(audit_calls) == 1 + assert audit_calls[0]["actor_identity"] == "authentik:operator-42" + assert audit_calls[0]["decision"] == "allowed" + assert audit_calls[0]["policy_id"] is None + assert audit_calls[0]["result"]["routed"] is False + assert audit_calls[0]["idempotency_hash"] == hashlib.sha256( + b"route-stage-get-audit-0001" + ).hexdigest() + + original_save = forge_route_stage._save_record + writes = [] + + def track_save(session, record): + writes.append(record["phase"]) + return original_save(session, record) + + monkeypatch.setattr(forge_route_stage, "_save_record", track_save) + polled = forge_route_stage.reconcile_forge_route( + session, + job_id=job.id, + actor_identity="authentik:operator-42", + npm=fake_npm, + ) + assert polled["status"] == "host_staged_disabled" + assert writes == [] + assert len(audit_calls) == 1 + + +@pytest.mark.parametrize("error_message", ["timeout", "npm_api_error"]) +def test_uncertain_post_replays_and_reconciles_with_get_only(route_db, monkeypatch, error_message): + from backend.app.services import forge_route_stage + + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm(create_error=RuntimeError(error_message)) + stage = _wire_service(monkeypatch) + + first = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-key-0005", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + replay = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-key-0005", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + other_key = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-key-other", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + assert first["status"] == "outcome_unknown" + assert replay["status"] == "outcome_unknown" + assert other_key["status"] == "conflict", other_key + assert fake_npm.calls.count("POST") == 1 + assert fake_npm.calls[:4] == ["GET", "POST", "GET", "GET"] + + fake_npm.create_error = None + fake_npm.hosts = [_route_host(fake_npm.ownership_id)] + reconciled = forge_route_stage.reconcile_forge_route( + session, + job_id=job.id, + actor_identity="authentik:owner", + npm=fake_npm, + ) + + assert reconciled["status"] == "host_staged_disabled" + assert fake_npm.calls.count("POST") == 1 + + +def test_reconcile_refuses_changed_npm_endpoint_without_get(route_db, monkeypatch): + from backend.app.services import forge_route_stage + + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + original_npm = _FakeNpm( + create_error=RuntimeError("timeout"), endpoint="https://npm.example/npm-a" + ) + stage = _wire_service(monkeypatch) + first = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-endpoint-bind-01", + actor_identity="authentik:owner", + npm=original_npm, + portainer=_FakePortainer(), + ) + assert first["status"] == "outcome_unknown" + state, record = forge_route_stage._load_record(session, job.id) + assert state == "present" and record is not None + assert record["npmEndpointSha256"] == hashlib.sha256( + b"https://npm.example/npm-a" + ).hexdigest() + assert "/npm-a" not in json.dumps(record) + changed_npm = _FakeNpm(endpoint="https://npm.example/npm-b") + + reconciled = forge_route_stage.reconcile_forge_route( + session, + job_id=job.id, + actor_identity="authentik:owner", + npm=changed_npm, + ) + + assert first["status"] == "outcome_unknown" + assert reconciled["status"] == "conflict" + assert reconciled["message"] == "forge_route_endpoint_binding_changed" + assert changed_npm.calls == [] + + +def test_concurrent_same_key_requests_serialize_to_one_npm_post(route_db, monkeypatch): + from concurrent.futures import ThreadPoolExecutor + from threading import Lock + + from backend.app.services import forge_route_stage + + engine, session = route_db + job = _job(session) + job_id = job.id + _allow_route_policy(session) + fake_npm = _FakeNpm() + stage = _wire_service(monkeypatch) + route_lock = Lock() + monkeypatch.setattr(forge_route_stage, "_forge_oidc_process_lock", lambda: route_lock) + + def call_stage(): + with Session(engine) as worker_session: + return stage.stage_forge_route( + worker_session, + job_id=job_id, + idempotency_key="route-stage-concurrent-0001", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _index: call_stage(), range(2))) + + assert [item["status"] for item in results] == [ + "host_staged_disabled", + "host_staged_disabled", + ] + assert fake_npm.calls.count("POST") == 1 + + +@pytest.mark.parametrize("conflicting_hosts", [ + lambda owner: [_route_host("froute-" + "9" * 32)], + lambda owner: [_route_host(owner), _route_host(owner)], +]) +def test_reconciliation_never_adopts_wrong_or_duplicate_ownership_marker( + route_db, monkeypatch, conflicting_hosts +): + from backend.app.services import forge_route_stage + + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm(create_error=RuntimeError("timeout")) + stage = _wire_service(monkeypatch) + + first = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-marker-conflict-01", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + assert first["status"] == "outcome_unknown" + fake_npm.hosts = conflicting_hosts(fake_npm.ownership_id) + + reconciled = forge_route_stage.reconcile_forge_route( + session, + job_id=job.id, + actor_identity="authentik:owner", + npm=fake_npm, + ) + + assert reconciled["status"] == "conflict" + assert fake_npm.calls.count("POST") == 1 + + +def test_stage_refuses_string_boolean_or_enabled_host_readback(route_db, monkeypatch): + engine, session = route_db + job = _job(session) + _allow_route_policy(session) + fake_npm = _FakeNpm(host_enabled="false") + stage = _wire_service(monkeypatch) + + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-key-0006", + actor_identity="authentik:owner", + npm=fake_npm, + portainer=_FakePortainer(), + ) + + assert result["status"] == "conflict" + assert fake_npm.calls.count("POST") == 1 + assert fake_npm.hosts[0]["enabled"] == "false" + + +def test_readiness_binds_distinct_authenticated_controller_revision(route_db, monkeypatch): + from backend.app.services import forge_route_stage + + _engine, session = route_db + job = _job(session) + profile = forge_route_stage._verified_job(session, job.id)[1] + readiness = _ready(job.id) + + identity = forge_route_stage._readiness_identity(readiness, job=job, profile=profile) + + assert identity is not None + assert identity["revision"] == _REVISION + assert identity["controllerArtifact"]["sourceRevision"] == _APP_OPS_REVISION + assert identity["revision"] != identity["controllerArtifact"]["sourceRevision"] + + readiness["controllerArtifact"]["sourceRepository"] = "jens/itworx-forge" + assert forge_route_stage._readiness_identity(readiness, job=job, profile=profile) is None + + +def test_record_validation_recomputes_readiness_identity_digest(route_db, monkeypatch): + from backend.app.services import forge_route_stage + + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + stage = _wire_service(monkeypatch) + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-record-hash-01", + actor_identity="authentik:owner", + npm=_FakeNpm(), + portainer=_FakePortainer(), + ) + assert result["status"] == "host_staged_disabled" + state, record = forge_route_stage._load_record(session, job.id) + assert state == "present" and record is not None + + record["readinessIdentity"]["observedAt"] += 1 + assert forge_route_stage._record_valid(record, job.id) is False + + +def test_record_loader_rejects_duplicate_keys_and_noncanonical_json(route_db, monkeypatch): + from backend.app.db.models import Secret + from backend.app.services import forge_route_stage + from backend.app.services.secret_store import get_secret + + _engine, session = route_db + job = _job(session) + _allow_route_policy(session) + stage = _wire_service(monkeypatch) + result = stage.stage_forge_route( + session, + job_id=job.id, + idempotency_key="route-stage-record-json-01", + actor_identity="authentik:owner", + npm=_FakeNpm(), + portainer=_FakePortainer(), + ) + assert result["status"] == "host_staged_disabled" + secret_ref = forge_route_stage._journal_secret_ref(job.id) + assert session.get(Secret, secret_ref) is not None + original_get_secret = get_secret(session, secret_ref, touch=False) + assert original_get_secret is not None + + monkeypatch.setattr(forge_route_stage, "get_secret", lambda *_args, **_kwargs: original_get_secret[:-1] + ',"phase":"pending"}') + assert forge_route_stage._load_record(session, job.id) == ("invalid", None) + + monkeypatch.setattr(forge_route_stage, "get_secret", lambda *_args, **_kwargs: " " + original_get_secret) + assert forge_route_stage._load_record(session, job.id) == ("invalid", None) + + +def test_route_host_readback_requires_exact_false_trust_forwarded_proto(): + from backend.app.services.forge_route_stage import ( + _desired_state, + _host_is_exact_disabled, + ) + + owner = "froute-" + "a" * 32 + desired = _desired_state(owner) + assert desired["trust_forwarded_proto"] is False + assert desired["locations"] == [] + record = {"desired": desired, "ownershipId": owner} + host = _route_host(owner) + + assert _host_is_exact_disabled(host, record) is True + for untrusted_value in (True, 0, "false", None): + changed = dict(host) + changed["trust_forwarded_proto"] = untrusted_value + assert _host_is_exact_disabled(changed, record) is False + + missing = dict(host) + missing.pop("trust_forwarded_proto") + assert _host_is_exact_disabled(missing, record) is False diff --git a/backend/tests/test_gitea_project_auto_registration.py b/backend/tests/test_gitea_project_auto_registration.py index 20dec0f..4bb66bf 100644 --- a/backend/tests/test_gitea_project_auto_registration.py +++ b/backend/tests/test_gitea_project_auto_registration.py @@ -180,19 +180,70 @@ def test_descriptor_can_select_primary_service_for_multi_service_project(): assert contract is not None assert contract["applicationId"] == "api" assert contract["composeProject"] == "api" + assert contract["primaryService"] == "api" assert contract["serviceNames"] == ["api", "worker"] assert contract["containers"] == ["api-api-1", "api-worker-1"] -def test_descriptor_cannot_diverge_compose_project_from_application_id(): - with pytest.raises(ValueError, match="composeProject must equal applicationId"): +def test_descriptor_separates_application_identity_compose_project_and_primary_service(): + descriptor = { + "apiVersion": "itworx.deployment-target/v1", + "applicationId": "quizmonitor", + "composeProject": "quiz-monitor", + "primaryService": "quiz-server", + "composeFile": "docker-compose.yml", + } + service = FakeComposeInspection( + [".itworx", "docker-compose.yml"], + { + ".itworx/deployment-target.json": json.dumps(descriptor), + "docker-compose.yml": "services:\n quiz-server:\n image: example/quiz-server:1\n", + }, + ) + + contract = service._compose_contract( + FakeClient(), repository_name="QuizMonitor", revision="d" * 40 + ) + + assert contract == { + "applicationId": "quizmonitor", + "composeFile": "docker-compose.yml", + "composeProject": "quiz-monitor", + "primaryService": "quiz-server", + "containers": ["quiz-monitor-quiz-server-1"], + "serviceNames": ["quiz-server"], + "healthTimeout": 180, + "verifyOciRevision": False, + } + + +def test_descriptor_rejects_invalid_compose_project_and_missing_primary_service(): + with pytest.raises(ValueError, match="composeProject is invalid"): GiteaProjectAutoRegistration._safe_descriptor( { "apiVersion": "itworx.deployment-target/v1", - "applicationId": "api", - "composeProject": "different", + "applicationId": "quizmonitor", + "composeProject": "../quiz-monitor", + "primaryService": "quiz-server", }, - "Example", + "QuizMonitor", + ) + + service = FakeComposeInspection( + [".itworx", "compose.yml"], + { + ".itworx/deployment-target.json": json.dumps({ + "apiVersion": "itworx.deployment-target/v1", + "applicationId": "quizmonitor", + "composeProject": "quiz-monitor", + "primaryService": "quiz-server", + }), + "compose.yml": "services:\n web:\n image: example/web:1\n", + }, + ) + with pytest.raises(ValueError, match="primaryService must name a Compose service"): + service._compose_contract( + FakeClient(), repository_name="QuizMonitor", revision="e" * 40 ) @@ -236,6 +287,39 @@ def test_sync_catalogs_every_repo_but_only_activates_proven_compose_targets(): assert all(item.voice == "blocked" for item in policies) +def test_sync_registers_quizmonitor_identity_without_changing_compose_project_or_service(): + registry = FakeTargetRegistry() + contract = { + "applicationId": "quizmonitor", + "composeFile": "docker-compose.yml", + "composeProject": "quiz-monitor", + "primaryService": "quiz-server", + "containers": ["quiz-monitor-quiz-server-1"], + "serviceNames": ["quiz-server"], + "healthTimeout": 180, + "verifyOciRevision": False, + } + service = FakeProjectSync( + [repo("QuizMonitor")], + {"QuizMonitor": contract}, + target_registry=registry, + ) + + with make_session() as session: + result = service.sync(session) + catalog = GiteaProjectAutoRegistration.catalog(session) + + assert result["summary"]["registered"] == 1 + assert len(registry.calls) == 1 + repository, revision, kwargs = registry.calls[0] + assert repository == "jens/quizmonitor" + assert kwargs["compose_project"] == "quiz-monitor" + assert kwargs["containers"] == ["quiz-monitor-quiz-server-1"] + assert catalog["projects"][0]["applicationId"] == "quizmonitor" + assert catalog["projects"][0]["composeProject"] == "quiz-monitor" + assert catalog["projects"][0]["primaryService"] == "quiz-server" + + def test_unchanged_catalog_entries_do_not_repeat_registration_work(): registry = FakeTargetRegistry() repositories = [repo("Deployable")] diff --git a/backend/tests/test_hub_candidate_import.py b/backend/tests/test_hub_candidate_import.py index 69d1110..1312b48 100644 --- a/backend/tests/test_hub_candidate_import.py +++ b/backend/tests/test_hub_candidate_import.py @@ -194,6 +194,266 @@ def test_run_identity_requires_exact_protected_push_and_no_pull_request(modules) assert not importer._run_matches({**run, **change}, revision) +def test_connector_ingestion_binding_is_scoped_to_authenticated_run_attempt(modules): + controller = modules.controller + revision = "a" * 40 + run_id, attempt = 21531, 2 + repository = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES["connector-ingestion"] + archive_ref = f"{repository}:{revision}-run-{run_id}-attempt-{attempt}" + digest = "d" * 64 + image_ref = f"{archive_ref}@sha256:{digest}" + image_id = "sha256:" + "e" * 64 + + binding = controller.mcp_hub_connector_ingestion_binding( + revision, run_id, attempt, image_ref, image_id + ) + + assert binding == { + "workflowRunId": run_id, + "workflowRunAttempt": attempt, + "archiveRef": archive_ref, + "imageRef": image_ref, + "imageId": image_id, + } + for invalid_ref in ( + f"{repository}:{revision}-run-{run_id}@sha256:{digest}", + f"{repository}:{revision}-run-{run_id}-attempt-1@sha256:{digest}", + f"attacker.example/{repository.rsplit('/', 1)[-1]}:{revision}-run-{run_id}-attempt-{attempt}@sha256:{digest}", + f"{archive_ref}@sha256:{'F' * 64}", + f"{archive_ref}@sha256:{digest}:extra", + ): + with pytest.raises(controller.DeployError): + controller.mcp_hub_connector_ingestion_binding( + revision, run_id, attempt, invalid_ref, image_id + ) + with pytest.raises(controller.DeployError): + controller.mcp_hub_connector_ingestion_binding( + revision, run_id, 0, image_ref, image_id + ) + + +def test_candidate_ingestion_binding_overrides_only_fixed_hub_render_environment( + modules, +): + controller = modules.controller + revision, tree = "a" * 40, "b" * 40 + run_id, attempt = 21531, 2 + repository = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES["connector-ingestion"] + archive_ref = f"{repository}:{revision}-run-{run_id}-attempt-{attempt}" + image_ref = f"{archive_ref}@sha256:{'d' * 64}" + binding = controller.mcp_hub_connector_ingestion_binding( + revision, run_id, attempt, image_ref, "sha256:" + "e" * 64 + ) + project = { + "compose_project": controller.MCP_HUB_PROJECT, + "_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT, + "_mcp_hub_connector_ingestion_binding": binding, + "environment": {"CONNECTOR_INGESTION_IMAGE": "attacker.invalid/image:latest"}, + } + metadata = { + "id": revision[:12], + "repository": controller.MCP_HUB_REPOSITORY, + "revision": revision, + "tree": tree, + "treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(), + "builtAt": "2026-10-03T12:00:00.000Z", + } + + environment = controller.deployment_environment( + project, revision, controller.MCP_HUB_REPOSITORY, metadata + ) + + assert environment["CONNECTOR_INGESTION_IMAGE"] == image_ref + + project["_mcp_hub_connector_ingestion_binding"]["archiveRef"] += "-tampered" + with pytest.raises(controller.DeployError): + controller.deployment_environment( + project, revision, controller.MCP_HUB_REPOSITORY, metadata + ) + + +def test_registered_hub_cannot_supply_private_ingestion_binding(modules): + controller = modules.controller + raw = { + "branch": "main", + "slug": controller.MCP_HUB_PROJECT, + "compose_project": controller.MCP_HUB_PROJECT, + "containers": list(controller.MCP_HUB_LEGACY_CONTAINERS), + "source_dir": ( + controller.MCP_HUB_APPDATA_ROOT / "deployment" / "repository" + ).as_posix(), + "compose_dir": "infrastructure/compose", + "compose_files": list(controller.MCP_HUB_LEGACY_COMPOSE_FILES), + "env_source": (controller.MCP_HUB_APPDATA_ROOT / "deploy.env").as_posix(), + "_mcp_hub_connector_ingestion_binding": {"imageRef": "attacker.invalid/x"}, + } + + effective = controller.effective_mcp_hub_project( + controller.MCP_HUB_REPOSITORY, raw + ) + + assert "_mcp_hub_connector_ingestion_binding" not in effective + + +def test_import_manifest_ingestion_binding_rejects_wrong_run_or_archive_tag(modules): + importer = modules.importer + revision, tree = "a" * 40, "b" * 40 + run = {"id": 21531, "run_attempt": 2} + repository = importer.controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[ + "connector-ingestion" + ] + archive_ref = f"{repository}:{revision}-run-{run['id']}-attempt-{run['run_attempt']}" + row = { + "imageRef": f"{archive_ref}@sha256:{'d' * 64}", + "archiveRef": archive_ref, + "imageId": "sha256:" + "e" * 64, + } + manifest = { + "schema": importer.ARTIFACT_SCHEMA, + "repository": importer.REPOSITORY, + "sourceRevision": revision, + "sourceTree": tree, + "workflowPath": importer.WORKFLOW_PATH, + "workflowRunId": run["id"], + "artifactName": importer.ARTIFACT_PREFIX + revision, + "images": { + service: row if service == "connector-ingestion" else {} + for service in importer.controller.MCP_HUB_SERVICES + }, + } + + binding = importer._manifest_ingestion_binding( + manifest, revision=revision, tree=tree, run=run + ) + assert binding["imageRef"] == row["imageRef"] + assert binding["archiveRef"] == archive_ref + + for changed in ( + {**manifest, "workflowRunId": run["id"] + 1}, + { + **manifest, + "images": { + **manifest["images"], + "connector-ingestion": { + **row, + "archiveRef": archive_ref.replace("-attempt-2", "-attempt-1"), + }, + }, + }, + ): + with pytest.raises(importer.CandidateRejected): + importer._manifest_ingestion_binding( + changed, revision=revision, tree=tree, run=run + ) + + +@pytest.mark.parametrize("drift", [None, "image_id", "repo_digest", "provenance"]) +def test_import_immutably_pulls_and_reads_back_connector_ingestion( + modules, monkeypatch, drift +): + importer = modules.importer + controller = modules.controller + revision, tree, run_id, attempt = "a" * 40, "b" * 40, 21531, 2 + built_at = "2026-10-03T12:00:00.000Z" + build = { + "id": revision[:12], + "repository": controller.MCP_HUB_REPOSITORY, + "revision": revision, + "tree": tree, + "treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(), + "builtAt": built_at, + } + labels = { + "org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE, + "org.opencontainers.image.revision": revision, + "org.opencontainers.image.source-tree": tree, + "org.opencontainers.image.version": revision[:12], + "org.opencontainers.image.created": built_at, + "org.opencontainers.image.source-date-epoch": "1790985600", + } + images = {} + for index, service in enumerate(controller.MCP_HUB_SERVICES, start=1): + external = importer.EXTERNAL_IMAGES.get(service) + if external: + image_ref, archive_ref = external[0], external[1] + elif service == "connector-ingestion": + repo = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service] + archive_ref = f"{repo}:{revision}-run-{run_id}-attempt-{attempt}" + image_ref = f"{archive_ref}@sha256:{'d' * 64}" + elif service == "contextforge": + image_ref = archive_ref = f"itworx-mcp-hub/contextforge-schema:{revision}" + else: + image_ref = archive_ref = f"fixture/{service}:{revision}" + images[service] = { + "imageRef": image_ref, + "archiveRef": archive_ref, + "imageId": "sha256:" + f"{index:064x}", + } + ingestion = images["connector-ingestion"] + immutable_ref = ingestion["imageRef"] + repo = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES["connector-ingestion"] + immutable_inspect = { + "Id": ingestion["imageId"], + "RepoDigests": [f"{repo}@sha256:{'d' * 64}"], + "Config": {"User": "10001:10001", "Labels": dict(labels)}, + } + tagged_inspect = {"Id": ingestion["imageId"], "Config": {"Labels": dict(labels)}} + if drift == "image_id": + immutable_inspect["Id"] = "sha256:" + "f" * 64 + elif drift == "repo_digest": + immutable_inspect["RepoDigests"] = [] + elif drift == "provenance": + immutable_inspect["Config"]["Labels"][ + "org.opencontainers.image.source-tree" + ] = "c" * 40 + monkeypatch.setattr( + importer, + "_inspect_image", + lambda ref: tagged_inspect if ref == ingestion["archiveRef"] else immutable_inspect, + ) + monkeypatch.setattr( + importer, + "_require_loaded_image", + lambda _service, row, **_kwargs: { + "Id": row["imageId"], + "Config": {"User": "10001:10001", "Labels": dict(labels)}, + }, + ) + docker_calls = [] + monkeypatch.setattr( + importer, + "_fixed_docker", + lambda *args, **kwargs: docker_calls.append((args, kwargs)), + ) + + if drift is None: + result = importer._verify_loaded_images( + images, + revision, + build, + "1790985600", + pull_external=True, + ingestion_run_id=run_id, + ingestion_run_attempt=attempt, + ) + assert result["connector-ingestion"] == ingestion["imageId"] + assert any( + call[0] == ("pull", immutable_ref) + for call in docker_calls + ) + else: + with pytest.raises(importer.CandidateRejected): + importer._verify_loaded_images( + images, + revision, + build, + "1790985600", + pull_external=True, + ingestion_run_id=run_id, + ingestion_run_attempt=attempt, + ) + + def test_selected_run_and_jobs_accept_the_live_gitea_job_shape(modules): importer = modules.importer revision = "a" * 40 @@ -353,12 +613,15 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings( images = {} for index, service in enumerate(controller.MCP_HUB_SERVICES, start=1): external = importer.EXTERNAL_IMAGES.get(service) - image_ref = ( - external[0] - if external is not None - else f"registry.example/itworx/{service}:{revision[:12]}" - ) - archive_ref = external[1] if external is not None else image_ref + if external is not None: + image_ref, archive_ref = external[0], external[1] + elif service == "connector-ingestion": + repository = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service] + archive_ref = f"{repository}:{revision}-run-21531-attempt-2" + image_ref = f"{archive_ref}@sha256:{'f' * 64}" + else: + image_ref = f"registry.example/itworx/{service}:{revision[:12]}" + archive_ref = image_ref model_services[service] = {"image": image_ref} images[service] = { "imageRef": image_ref, @@ -449,8 +712,8 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings( ) monkeypatch.setattr(importer, "_remote_main_tree", lambda *_args: tree) monkeypatch.setattr(importer, "_fixed_workflow", lambda *_args: None) - run = {"id": 21531, "run_attempt": 1} - run_state = {"attempt": 1} + run = {"id": 21531, "run_attempt": 2} + run_state = {"attempt": 2} monkeypatch.setattr( importer, "_selected_run", @@ -467,14 +730,35 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings( monkeypatch.setattr(importer, "_candidate_artifact", lambda *_args: artifact) monkeypatch.setattr(importer, "_docker_root", lambda: tmp_path) monkeypatch.setattr(importer, "_check_disk_headroom", lambda **_kwargs: None) - monkeypatch.setattr(importer, "_parse_artifact_zip", lambda *_args, **_kwargs: ( - {}, - report_data, - hashlib.sha256(artifact_bytes).hexdigest(), - "d" * 64, - "e" * 64, - 123, - )) + artifact_manifest = { + "schema": importer.ARTIFACT_SCHEMA, + "repository": importer.REPOSITORY, + "sourceRevision": revision, + "sourceTree": tree, + "workflowPath": importer.WORKFLOW_PATH, + "workflowRunId": run["id"], + "artifactName": importer.ARTIFACT_PREFIX + revision, + "images": { + service: { + "imageRef": row["imageRef"], + "imageId": row["imageId"], + "archiveRef": row["archiveRef"], + } + for service, row in images.items() + }, + } + + def fake_parse_artifact(*_args, **_kwargs): + return ( + artifact_manifest, + report_data, + hashlib.sha256(artifact_bytes).hexdigest(), + "d" * 64, + "e" * 64, + 123, + ) + + monkeypatch.setattr(importer, "_parse_artifact_zip", fake_parse_artifact) monkeypatch.setattr( importer, "_validate_manifest", @@ -486,11 +770,17 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings( def fake_fixed_docker(*args, **_kwargs): if rerun_during_load and args and args[0] == "load": - run_state["attempt"] = 2 + run_state["attempt"] = 3 return object() monkeypatch.setattr(importer, "_fixed_docker", fake_fixed_docker) - monkeypatch.setattr(importer, "_verify_loaded_images", lambda *_args, **_kwargs: {}) + verified_runs = [] + + def verify_loaded(_images, _revision, _build, _epoch, **kwargs): + verified_runs.append(kwargs) + return {} + + monkeypatch.setattr(importer, "_verify_loaded_images", verify_loaded) monkeypatch.setattr( controller, "_mcp_hub_check_candidate_exceptions", @@ -502,7 +792,18 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings( lambda *_args: draft, ) - def fake_plan(_repository, _revision, *, built_at, staging_directory, **_kwargs): + planned_bindings = [] + + def fake_plan( + _repository, + _revision, + *, + built_at, + staging_directory, + connector_ingestion_binding, + **_kwargs, + ): + planned_bindings.append(connector_ingestion_binding) Path(staging_directory).mkdir(mode=0o700) frozen = { "schema": prepare.BUILD_SCHEMA, @@ -519,7 +820,15 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings( monkeypatch.setattr(prepare, "plan_candidate", fake_plan) - def fake_capture(_repository, _revision, *, staging_directory, **_kwargs): + def fake_capture( + _repository, + _revision, + *, + staging_directory, + connector_ingestion_binding, + **_kwargs, + ): + assert connector_ingestion_binding == planned_bindings[0] image_rows = { service: { "imageRef": images[service]["imageRef"], @@ -570,6 +879,15 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings( _private_file(destination, artifact_bytes) return None + def fake_no_overwrite_publish(stage, final_directory): + if final_directory.exists() or final_directory.is_symlink(): + importer._reject( + "MCP Hub candidate destination already exists; import never overwrites" + ) + final_directory.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + stage.rename(final_directory) + + monkeypatch.setattr(importer, "_publish_candidate", fake_no_overwrite_publish) candidate_directory = state_root / "mcp-hub-release-candidates" / revision if rerun_during_load: with pytest.raises(importer.CandidateRejected, match="run changed"): @@ -589,7 +907,22 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings( download_artifact=download_artifact, ) assert candidate_directory.is_dir() - assert stat.S_IMODE(candidate_directory.stat().st_mode) == 0o700 + assert planned_bindings[0]["workflowRunId"] == run["id"] + assert planned_bindings[0]["workflowRunAttempt"] == run["run_attempt"] + assert verified_runs == [ + { + "pull_external": True, + "ingestion_run_id": run["id"], + "ingestion_run_attempt": run["run_attempt"], + }, + { + "pull_external": False, + "ingestion_run_id": run["id"], + "ingestion_run_attempt": run["run_attempt"], + }, + ] + if importer.os.name != "nt": + assert stat.S_IMODE(candidate_directory.stat().st_mode) == 0o700 candidate_before_review = (candidate_directory / "candidate.json").read_bytes() receipt_before_review = (candidate_directory / "import-receipt.json").read_bytes() collision_stage = tmp_path / "second-candidate" diff --git a/backend/tests/test_hub_component_transport.py b/backend/tests/test_hub_component_transport.py new file mode 100644 index 0000000..2f2e09d --- /dev/null +++ b/backend/tests/test_hub_component_transport.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import hashlib +import io +import json +import subprocess +import sys +from email.message import Message +from types import SimpleNamespace + +import pytest +from test_deploy_broker import BROKER_ROOT, _load_module + + +@pytest.fixture +def controller(monkeypatch): + module = _load_module("component_transport_test", BROKER_ROOT / "status_server.py") + monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: True) + monkeypatch.setattr(module, "_self_upgrade_secret", lambda: b"transport-test-key-" + b"x" * 48) + projects = {module.REGISTERED_AUTO_APPLY_REPOSITORY: {"branch": "main", "validation": {"required": True}}, + "other/unchanged": {"branch": "main"}} + monkeypatch.setattr(module, "_load_projects", lambda: projects) + monkeypatch.setattr(module, "_write_projects", lambda updated: (projects.clear(), projects.update(updated))) + monkeypatch.setattr(module, "_validation_policy", lambda *_a: {"required": True}) + return module, projects + + +def call(module, path, payload, method="_handle_registered_auto_apply"): + handler = module.Handler.__new__(module.Handler) + body = json.dumps(payload).encode() + handler.path = path + handler.headers = Message() + handler.headers["Content-Length"] = str(len(body)) + handler.headers["Content-Type"] = "application/json" + handler.rfile = io.BytesIO(body) + handler._trusted_client = lambda: True + responses = [] + handler._send_json = lambda status, data: responses.append((status, json.loads(data))) + getattr(handler, method)() + return responses[0] + + +def intent(module, projects): + project = projects[module.REGISTERED_AUTO_APPLY_REPOSITORY] + return {"repository": module.REGISTERED_AUTO_APPLY_REPOSITORY, "held": True, + "expectedRegistrySha256": hashlib.sha256(json.dumps(project, sort_keys=True, separators=(",", ":")).encode()).hexdigest(), + "reason": "Limit rollout to the validated Gitea component", "requestId": "11111111-1111-4111-8111-111111111111"} + + +def test_hold_is_cas_bound_and_retains_registered_policy(controller): + module, projects = controller + payload = intent(module, projects) + original = dict(projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]) + code, report = call(module, module.REGISTERED_AUTO_APPLY_PATH, payload) + assert code == 200 and report["status"] == "held" and report["autoApply"] is False + updated = projects[module.REGISTERED_AUTO_APPLY_REPOSITORY] + assert updated["branch"] == original["branch"] + assert updated["validation"] == original["validation"] + assert projects["other/unchanged"] == {"branch": "main"} + assert payload["reason"] not in json.dumps(updated) + assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[1]["reason"] == "registered_target_policy_changed" + code, preflight = call(module, module.REGISTERED_AUTO_APPLY_PATH + "/preflight", {"repository": payload["repository"]}) + assert code == 200 and preflight["autoApply"] is False + payload.update(held=False, expectedRegistrySha256=preflight["registrySha256"]) + assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[1]["status"] == "released" + + +@pytest.mark.parametrize("change", [{"repository": "other/unchanged"}, {"held": "true"}, {"image": "caller/image"}, + {"requestId": "bad"}, {"expectedRegistrySha256": "0" * 64}]) +def test_hold_rejects_unknown_target_input_and_stale_fingerprint(controller, change): + module, projects = controller + before = json.dumps(projects, sort_keys=True) + code, _ = call(module, module.REGISTERED_AUTO_APPLY_PATH, {**intent(module, projects), **change}) + assert code in {400, 409} + assert json.dumps(projects, sort_keys=True) == before + + +def test_hold_authentication_and_inflight_deployment_remain_fail_closed(controller, monkeypatch): + module, projects = controller + payload = intent(module, projects) + monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False) + assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[0] == 403 + monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: True) + module._registered_auto_apply_lock.acquire() + try: + assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[1]["reason"] == "registered_target_deployment_active" + finally: + module._registered_auto_apply_lock.release() + + +def test_execution_rechecks_hold_under_same_lock(controller, monkeypatch): + module, projects = controller + invoked = [] + def execute(command, **_kwargs): + assert module._registered_auto_apply_lock.acquire(blocking=False) is False + invoked.append(command) + return subprocess.CompletedProcess(command, 0) + monkeypatch.setattr(module.subprocess, "run", execute) + projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]["auto_apply"] = False + assert module._run_registered_deploy(module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40).returncode == 4 + assert invoked == [] + projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]["auto_apply"] = True + assert module._run_registered_deploy(module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40).returncode == 0 + assert invoked == [["python3", str(module.ROOT / "deploy.py"), "deploy", module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40]] + + +def test_component_authentication_blocks_before_reading_or_mutating(controller, monkeypatch): + module, _ = controller + monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False) + assert call(module, "/v1/appops/hub-components/apply", {"image": "caller/image"}, "_handle_hub_component")[0] == 403 + + +def test_component_worker_serializes_hold_and_records_sanitized_failure(controller, monkeypatch): + module, _ = controller + failures = [] + plan_id = "a" * 64 + def apply(*_args, **_kwargs): + assert module._registered_auto_apply_lock.acquire(blocking=False) is False + raise RuntimeError("secret runtime configuration must never escape") + fake = SimpleNamespace(apply=apply, PromotionRejected=ValueError, + record_worker_failure=lambda *args, **_kw: failures.append(args)) + monkeypatch.setitem(sys.modules, "immutable_service_promotion", fake) + module._component_worker_plans.add(plan_id) + module._run_component_worker({"planId": plan_id}, b"x" * 64) + assert failures == [(plan_id, "component_worker_failed")] + assert plan_id not in module._component_worker_plans + assert module._registered_auto_apply_lock.acquire(blocking=False) + module._registered_auto_apply_lock.release() + + +def test_component_queue_is_idempotent_and_failed_start_is_durable(controller, monkeypatch): + module, _ = controller + failures, starts = [], [] + plan_id = "a" * 64 + fake = SimpleNamespace(accept_apply=lambda *_a, **_k: {"status": "queued", "planId": plan_id}, + PromotionRejected=ValueError, + record_worker_failure=lambda *args, **_kw: failures.append(args)) + monkeypatch.setitem(sys.modules, "immutable_service_promotion", fake) + monkeypatch.setattr(module.threading, "Thread", lambda **kwargs: SimpleNamespace(start=lambda: starts.append(kwargs))) + payload = {"planId": plan_id} + assert call(module, "/v1/appops/hub-components/apply", payload, "_handle_hub_component")[0] == 202 + assert call(module, "/v1/appops/hub-components/apply", payload, "_handle_hub_component")[0] == 202 + assert len(starts) == 1 + module._component_worker_plans.clear() + def fail_start(**_kwargs): + raise RuntimeError("thread unavailable") + monkeypatch.setattr(module.threading, "Thread", fail_start) + assert call(module, "/v1/appops/hub-components/apply", payload, "_handle_hub_component")[1]["reason"] == "component_worker_start_failed" + assert failures == [(plan_id, "component_worker_start_failed")] + assert not module._component_worker_plans + + +@pytest.mark.parametrize("validated", [False, True]) +def test_hold_retains_push_validation_and_candidate_preparation_without_deploy(controller, monkeypatch, tmp_path, validated): + module, projects = controller + repository = module.REGISTERED_AUTO_APPLY_REPOSITORY + projects[repository]["auto_apply"] = False + revision = "a" * 40 + semantic = module._webhook_semantic_digest(34, "refs/heads/main", revision) + marker = tmp_path / (semantic + ".json") + record = {"schema": module._WEBHOOK_SCHEMA, "repository": repository, "gitea_repository_id": 34, + "ref": "refs/heads/main", "revision": revision, "semantic_sha256": semantic, + "body_sha256": "b" * 64, "status": "validation_passed" if validated else "awaiting_validation"} + monkeypatch.setattr(module, "_read_inbox_record", lambda _path: record) + monkeypatch.setattr(module, "_webhook_policy", lambda *_a: (34, "main")) + monkeypatch.setattr(module, "_record_policy_for_processing", lambda *_a: True) + monkeypatch.setattr(module, "_process_validation", lambda *_a: False) + monkeypatch.setattr(module, "_atomic_inbox_update", lambda *_a: None) + monkeypatch.setattr(module, "_supersede_older_deliveries", lambda *_a: None) + prepared = [] + monkeypatch.setattr(module, "_prepare_hub_candidate_delivery", lambda *_a: prepared.append(revision) or True) + monkeypatch.setattr(module, "_run_registered_deploy", lambda *_a: pytest.fail("held target must never deploy")) + module._process_webhook_record(marker) + assert prepared == ([revision] if validated else []) + assert record["status"] == ("validation_passed" if validated else "awaiting_validation") + if validated: + assert record["reason"] == "registered_target_auto_apply_held" diff --git a/backend/tests/test_immutable_service_promotion.py b/backend/tests/test_immutable_service_promotion.py new file mode 100644 index 0000000..ffdea71 --- /dev/null +++ b/backend/tests/test_immutable_service_promotion.py @@ -0,0 +1,667 @@ +from __future__ import annotations + +import contextlib +import copy +import importlib.util +import io +import json +import tarfile +import types +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location( + "immutable_promotion_test", + ROOT / "ops/gitea-deploy-broker/immutable_service_promotion.py", +) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +KEY = b"test-receipt-key" * 3 +REVISION = "b" * 40 + + +class Runtime: + def __init__(self, path): + self.receipt_root = path / "receipts" + self.state = { + "target": { + "containerId": "a" * 64, + "imageId": "sha256:" + "a" * 64, + "revision": m.OLD_REVISION, + "spec": { + "image": "sha256:" + "a" * 64, + "environment": {"GITEA_CONNECTOR_BUILD_SHA": m.OLD_REVISION}, + }, + }, + "nonTargets": { + "postgres": {"containerId": "c" * 64, "imageId": "sha256:" + "c" * 64} + }, + } + self.calls = [] + self.fail_health = False + self.fail_rollback = False + self.artifact = { + "revision": REVISION, + "imageId": "sha256:" + "b" * 64, + "candidateSha256": "d" * 64, + } + + def lock(self): + return contextlib.nullcontext() + + def snapshot(self): + return copy.deepcopy(self.state) + + def candidate(self, revision, **kwargs): + self.calls.append("candidate") + assert revision == REVISION + return copy.deepcopy(self.artifact) + + desired = m.ControllerRuntime.desired + + def promote(self, desired): + self.calls.append("promote") + self.state["target"] = {**copy.deepcopy(desired), "containerId": "e" * 64} + + def verify(self, desired, baseline, *, rollback=False): + if self.fail_health and not rollback: + raise ValueError("private diagnostic must not escape") + assert self.state["nonTargets"] == baseline["nonTargets"] + return {"containerId": self.state["target"]["containerId"]} + + def rollback(self, baseline, desired): + self.calls.append("rollback") + if self.fail_rollback: + raise ValueError("secret rollback diagnostic") + self.state = copy.deepcopy(baseline) + + def verify_record(self, record): + self.calls.append("verify_record") + + +@pytest.fixture +def fixture(tmp_path): + runtime = Runtime(tmp_path) + request = { + "repository": "Jens/ITWorx_MCP_Hub", + "service": m.SERVICE, + "revision": REVISION, + "expectedBaseline": m.digest(runtime.state), + "reason": "Repair verified Gitea compatibility", + "requestId": "d6fe0ba3-81f0-4ed6-b36d-10b12dbf55e4", + } + return runtime, request + + +def plan(runtime, request): + return m.preflight(request, api_json=None, receipt_key=KEY, runtime=runtime) + + +def arguments(request, result): + return {**request, "planId": result["planId"], "planDigest": result["planDigest"]} + + +@pytest.mark.parametrize( + "change", + [ + {"service": "postgres"}, + {"repository": "Jens/other"}, + {"revision": "main"}, + {"revision": "B" * 40}, + {"requestId": "bad"}, + {"image": "evil"}, + {"command": "sh"}, + {"expectedBaseline": "bad"}, + {"reason": "line\nbreak"}, + ], +) +def test_closed_request_rejects_untrusted_target_or_extra_fields(fixture, change): + runtime, request = fixture + with pytest.raises(m.PromotionRejected): + plan(runtime, {**request, **change}) + assert not runtime.calls + + +def test_preflight_is_readonly_and_receipts_do_not_expose_configuration(fixture): + runtime, request = fixture + result = plan(runtime, request) + assert result["status"] == "prepared" + assert result["expiresAt"] - result["createdAt"] == 300 + assert runtime.calls == ["candidate"] + assert "spec" not in json.dumps(result) and "environment" not in json.dumps(result) + assert result["planId"] == m.digest(m.request(request)) + + +def test_queue_is_durable_idempotent_and_no_runtime_operation(fixture): + runtime, request = fixture + result = plan(runtime, request) + runtime.calls.clear() + args = arguments(request, result) + queued = m.accept_apply(args, receipt_key=KEY, runtime=runtime) + assert queued["status"] == "queued" + assert m.accept_apply(args, receipt_key=KEY, runtime=runtime) == queued + assert runtime.calls == [] + assert len(list(runtime.receipt_root.rglob("*.json"))) == 2 + + +def test_apply_rechecks_artifact_and_baseline_then_only_promotes_once(fixture): + runtime, request = fixture + result = plan(runtime, request) + args = arguments(request, result) + m.accept_apply(args, receipt_key=KEY, runtime=runtime) + applied = m.apply( + args, api_json=None, download_artifact=None, receipt_key=KEY, runtime=runtime + ) + assert applied["status"] == "applied" + assert runtime.calls == ["candidate", "candidate", "promote"] + assert ( + m.apply( + args, + api_json=None, + download_artifact=None, + receipt_key=KEY, + runtime=runtime, + ) + == applied + ) + assert runtime.calls.count("promote") == 1 + assert runtime.state["nonTargets"]["postgres"]["containerId"] == "c" * 64 + assert ( + m.status({"planId": result["planId"]}, receipt_key=KEY, runtime=runtime) + == applied + ) + + +def test_unrelated_baseline_drift_blocks_before_any_import_or_write(fixture): + runtime, request = fixture + result = plan(runtime, request) + runtime.calls.clear() + runtime.state["nonTargets"]["postgres"]["containerId"] = "f" * 64 + with pytest.raises(m.PromotionRejected, match="baseline_changed"): + m.apply( + arguments(request, result), + api_json=None, + download_artifact=None, + receipt_key=KEY, + runtime=runtime, + ) + assert not runtime.calls + + +def test_artifact_change_after_preflight_blocks_without_promoting(fixture): + runtime, request = fixture + result = plan(runtime, request) + runtime.artifact["candidateSha256"] = "f" * 64 + with pytest.raises(m.PromotionRejected, match="artifact_changed"): + m.apply( + arguments(request, result), + api_json=None, + download_artifact=None, + receipt_key=KEY, + runtime=runtime, + ) + assert "promote" not in runtime.calls + + +def test_health_failure_restores_exact_baseline_and_records_rollback(fixture): + runtime, request = fixture + before = runtime.snapshot() + result = plan(runtime, request) + runtime.fail_health = True + outcome = m.apply( + arguments(request, result), + api_json=None, + download_artifact=None, + receipt_key=KEY, + runtime=runtime, + ) + assert outcome["status"] == "rolled_back" + assert runtime.snapshot() == before + assert "private" not in json.dumps(outcome) + + +def test_rollback_failure_is_durable_and_cannot_blindly_retry(fixture): + runtime, request = fixture + result = plan(runtime, request) + runtime.fail_health = runtime.fail_rollback = True + args = arguments(request, result) + with pytest.raises(m.PromotionRejected, match="rollback_recovery_required"): + m.apply( + args, + api_json=None, + download_artifact=None, + receipt_key=KEY, + runtime=runtime, + ) + assert ( + m.status({"planId": result["planId"]}, receipt_key=KEY, runtime=runtime)[ + "status" + ] + == "recovery_required" + ) + with pytest.raises(m.PromotionRejected, match="recovery_required"): + m.apply( + args, + api_json=None, + download_artifact=None, + receipt_key=KEY, + runtime=runtime, + ) + assert runtime.calls.count("promote") == 1 + + +def test_expired_plan_and_different_intent_are_rejected(fixture, monkeypatch): + runtime, request = fixture + result = plan(runtime, request) + args = arguments(request, result) + with pytest.raises(m.PromotionRejected, match="plan_mismatch"): + m.accept_apply( + {**args, "reason": "Changed reason"}, receipt_key=KEY, runtime=runtime + ) + monkeypatch.setattr(m.time, "time", lambda: result["expiresAt"] + 1) + with pytest.raises(m.PromotionRejected, match="plan_expired"): + m.accept_apply(args, receipt_key=KEY, runtime=runtime) + + +def test_signature_tampering_chain_gaps_and_weak_key_fail_closed(fixture): + runtime, request = fixture + result = plan(runtime, request) + path = next(runtime.receipt_root.rglob("*.json")) + data = json.loads(path.read_text()) + data["record"]["revision"] = "f" * 40 + path.write_text(json.dumps(data)) + with pytest.raises(m.PromotionRejected, match="signature_invalid"): + m.status({"planId": result["planId"]}, receipt_key=KEY, runtime=runtime) + with pytest.raises(m.PromotionRejected, match="key_invalid"): + m.ReceiptStore(runtime.receipt_root, b"weak") + + +def test_async_rejection_records_only_safe_reason_and_never_overwrites_recovery( + fixture, +): + runtime, request = fixture + result = plan(runtime, request) + m.accept_apply(arguments(request, result), receipt_key=KEY, runtime=runtime) + rejected = m.record_worker_failure( + result["planId"], "secret=raw error!", receipt_key=KEY, runtime=runtime + ) + assert rejected["status"] == "rejected" + assert rejected["reasonCode"] == "component_worker_failed" + assert ( + m.record_worker_failure( + result["planId"], "later", receipt_key=KEY, runtime=runtime + ) + == rejected + ) + + +@pytest.fixture +def adapter(): + runtime = m.ControllerRuntime.__new__(m.ControllerRuntime) + runtime.docker = lambda *_args: [ + { + "Id": "sha256:" + "a" * 64, + "Config": { + "Labels": { + "org.opencontainers.image.revision": m.OLD_REVISION, + "org.opencontainers.image.source": "https://gitea.example.com/jens/itworx-mcp-hub", + } + }, + } + ] + runtime.runtime_fingerprint = lambda: { + "blob": m.ABI_BLOBS["services/runtime/connector_gitea.py"][0] + } + env = { + "SERVICE_NAME": "connector-gitea", + "GITEA_CONNECTOR_BUILD_SHA": m.OLD_REVISION, + "GITEA_ENDPOINT": "https://gitea.example.com", + "GITEA_TOKEN_FILE": "/run/secrets/gitea_mcp_token", + "GITEA_WRITE_TOKEN_FILE": "/run/secrets/gitea_mcp_write_token", + "SECRET_EXPECTED_UID": "99", + "SECRET_EXPECTED_GID": "100", + "CONNECTOR_IDENTITY_KEY_FILE": "/run/secrets/connector_identity_signing_key", + "GPG_KEY": "public image fingerprint", + } + mounts = [ + { + "Type": "bind", + "RW": False, + "Source": "/srv/example/appdata/itworx-mcp-hub/secrets/" + name, + "Destination": "/run/secrets/" + name, + } + for name in ( + "gitea_mcp_token", + "gitea_mcp_write_token", + "connector_identity_signing_key", + ) + ] + mounts.append( + { + "Type": "bind", + "RW": False, + "Source": f"/srv/example/appdata/itworx-mcp-hub/deployment/releases/{m.OLD_REVISION}/services/runtime/connector_gitea.py", + "Destination": "/opt/hub/services/runtime/connector_gitea.py", + } + ) + row = { + "Id": "a" * 64, + "Image": "sha256:" + "a" * 64, + "Config": { + "Entrypoint": ["python", "-m", "services.runtime"], + "Cmd": None, + "User": "99:100", + "WorkingDir": "/opt/hub", + "Tty": False, + "OpenStdin": False, + "ExposedPorts": {"8080/tcp": {}}, + "Env": [k + "=" + v for k, v in env.items()], + "Labels": {}, + "Healthcheck": { + "Interval": 60000000000, + "Retries": 5, + "StartPeriod": 10000000000, + "Timeout": 3000000000, + "Test": [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2)", + ], + }, + }, + "HostConfig": { + "ReadonlyRootfs": True, + "Privileged": False, + "CapDrop": ["ALL"], + "SecurityOpt": ["no-new-privileges:true"], + "Memory": 536870912, + "MemorySwap": -1, + "MemoryReservation": 0, + "NetworkMode": "itworx-mcp-hub-connectors", + "LogConfig": { + "Type": "json-file", + "Config": {"max-file": "3", "max-size": "10m"}, + }, + "ShmSize": 67108864, + "AutoRemove": False, + "CgroupnsMode": "private", + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger", + ], + "MaskedPaths": [ + "/proc/asound", + "/proc/acpi", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/proc/scsi", + "/sys/firmware", + "/sys/devices/virtual/powercap", + ], + "NanoCpus": 1000000000, + "PidsLimit": 128, + "Tmpfs": {"/tmp": "size=512m,mode=1777"}, + "IpcMode": "private", + "RestartPolicy": {"Name": "unless-stopped", "MaximumRetryCount": 0}, + }, + "Mounts": mounts, + "NetworkSettings": { + "Networks": { + n: {"Aliases": [m.CONTAINER, m.SERVICE]} + for n in ( + "itworx-mcp-hub-connectors", + "itworx-mcp-hub-observe", + "itworx-mcp-hub-egress", + ) + } + }, + } + return runtime, row + + +def test_known_live_target_preserves_public_metadata_and_only_image_build_sha_changes( + adapter, +): + runtime, row = adapter + target = runtime.target(row) + desired = runtime.desired( + {"target": target}, {"imageId": "sha256:" + "b" * 64, "revision": REVISION} + ) + changed = copy.deepcopy(desired) + changed.update(imageId=target["imageId"], revision=target["revision"]) + changed["spec"]["image"] = target["imageId"] + changed["spec"]["environment"]["GITEA_CONNECTOR_BUILD_SHA"] = m.OLD_REVISION + assert changed == target + assert target["spec"]["environment"]["SECRET_EXPECTED_UID"] == "99" + assert target["spec"]["environment"]["GPG_KEY"] == "public image fingerprint" + + +@pytest.mark.parametrize( + "field,value", + [ + ("Privileged", True), + ("CapAdd", ["SYS_ADMIN"]), + ("Devices", [{"PathOnHost": "/dev/sda"}]), + ("ExtraHosts", ["attacker:127.0.0.1"]), + ("PidMode", "host"), + ("Memory", 0), + ("ReadonlyRootfs", False), + ], +) +def test_unexpected_security_runtime_composition_is_rejected(adapter, field, value): + runtime, row = adapter + row["HostConfig"][field] = value + with pytest.raises(m.PromotionRejected, match="configuration_unexpected"): + runtime.target(row) + + +@pytest.mark.parametrize( + "key,value", + [ + ("GITEA_TOKEN", "secret"), + ("GITEA_ENDPOINT", "https://attacker.invalid"), + ("PYTHONPATH", "/tmp/evil"), + ("SECRET_EXPECTED_UID", "0"), + ], +) +def test_env_drift_inline_secret_or_alternate_upstream_is_rejected(adapter, key, value): + runtime, row = adapter + row["Config"]["Env"] = [ + v for v in row["Config"]["Env"] if not v.startswith(key + "=") + ] + row["Config"]["Env"].append(key + "=" + value) + with pytest.raises(m.PromotionRejected): + runtime.target(row) + + +def test_fixed_promote_contains_only_one_service_no_database_or_build_commands( + adapter, tmp_path, monkeypatch +): + runtime, row = adapter + runtime.receipt_root = tmp_path + target = runtime.target(row) + calls = [] + + def run(argv, **kwargs): + calls.append(argv) + config = json.loads(Path(argv[5]).read_text()) + assert set(config["services"]) == {m.SERVICE} + assert ( + config["services"][m.SERVICE]["environment"] + == target["spec"]["environment"] + ) + assert ( + kwargs["stdout"] == m.subprocess.DEVNULL + and kwargs["stderr"] == m.subprocess.DEVNULL + ) + return types.SimpleNamespace(returncode=0) + + monkeypatch.setattr(m.subprocess, "run", run) + runtime.docker = lambda *_args: [{"Id": "d" * 64}] + runtime.promote(target) + assert calls[0][:5] == [ + "docker", + "compose", + "--project-name", + "itworx-mcp-hub", + "--file", + ] + assert calls[0][6:] == [ + "up", + "--detach", + "--no-deps", + "--no-build", + "--pull", + "never", + "connector-gitea", + ] + assert not list(tmp_path.glob(".component-*")) + + +def source_archive( + *, uid=0, regular=True, mode=0o664, content=b"reviewed", name="connector_gitea.py" +): + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as archive: + member = tarfile.TarInfo(name) + member.uid = uid + member.gid = 100 + member.mode = mode + member.mtime = 1234 + member.type = tarfile.REGTYPE if regular else tarfile.SYMTYPE + member.size = len(content) if regular else 0 + archive.addfile(member, io.BytesIO(content) if regular else None) + return buffer.getvalue() + + +@pytest.mark.parametrize( + "value", + [ + {"uid": 99}, + {"uid": 0, "regular": False}, + {"uid": 0, "regular": True, "mode": 0o666}, + {"uid": 0, "regular": True, "mode": 0o664}, + ], +) +def test_runtime_source_change_symlink_unowned_or_writable_file_rejected( + value, monkeypatch +): + runtime = m.ControllerRuntime.__new__(m.ControllerRuntime) + raw = source_archive( + uid=value.get("uid", 0), + regular=value.get("regular", True), + mode=value.get("mode", 0o664), + content=b"changed", + ) + monkeypatch.setattr( + m.subprocess, + "run", + lambda *_args, **_kwargs: types.SimpleNamespace(returncode=0, stdout=raw), + ) + with pytest.raises(m.PromotionRejected, match="runtime_source_unreviewed"): + runtime.runtime_fingerprint() + + +def test_source_fingerprint_uses_readonly_archive_even_when_target_is_stopped( + monkeypatch, +): + runtime = m.ControllerRuntime.__new__(m.ControllerRuntime) + data = b"reviewed" + blob = m.hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest() + monkeypatch.setitem( + m.ABI_BLOBS, "services/runtime/connector_gitea.py", (blob, "new") + ) + + def run(argv, **kwargs): + assert argv == [ + "docker", + "cp", + m.CONTAINER + ":/opt/hub/services/runtime/connector_gitea.py", + "-", + ] + assert kwargs["stderr"] == m.subprocess.DEVNULL + return types.SimpleNamespace(returncode=0, stdout=source_archive(content=data)) + + monkeypatch.setattr(m.subprocess, "run", run) + assert runtime.runtime_fingerprint() == { + "uid": 0, + "gid": 100, + "mode": 0o664, + "size": 8, + "mtime": "1234", + "blob": blob, + } + + +def test_nonterminal_status_never_waits_for_the_deployment_lock(fixture): + runtime, request = fixture + result = plan(runtime, request) + m.accept_apply(arguments(request, result), receipt_key=KEY, runtime=runtime) + runtime.lock = lambda: pytest.fail("status must not block behind runtime promotion") + assert ( + m.status({"planId": result["planId"]}, receipt_key=KEY, runtime=runtime)[ + "status" + ] + == "queued" + ) + + +def test_component_requires_explicit_auto_apply_hold(): + runtime = m.ControllerRuntime.__new__(m.ControllerRuntime) + runtime.controller = types.SimpleNamespace( + load_projects=lambda: {m.REPOSITORY: {"auto_apply": True}} + ) + with pytest.raises(m.PromotionRejected, match="auto_apply_hold_required"): + runtime.snapshot() + + +def test_rollback_never_overwrites_a_recreated_or_edited_operator_target(adapter): + runtime, row = adapter + before = runtime.target(row) + baseline = {"target": before, "nonTargets": {"postgres": "unchanged"}} + desired = runtime.desired( + baseline, {"revision": REVISION, "imageId": "sha256:" + "b" * 64} + ) + runtime.promoted_id = "d" * 64 + observed = { + "target": {**desired, "containerId": "e" * 64}, + "nonTargets": baseline["nonTargets"], + } + runtime.snapshot = lambda: observed + runtime.promote = lambda *_: pytest.fail("must never overwrite operator container") + with pytest.raises(m.PromotionRejected, match="rollback_cas_failed"): + runtime.rollback(baseline, desired) + observed["target"]["containerId"] = runtime.promoted_id + observed["target"]["runtimeFingerprint"] = {"blob": "changed"} + with pytest.raises(m.PromotionRejected, match="rollback_cas_failed"): + runtime.rollback(baseline, desired) + + +def test_rollback_loaded_image_provenance_must_match_bound_revision(adapter): + runtime, row = adapter + runtime.docker = lambda *_: [ + { + "Id": row["Image"], + "Config": {"Labels": {"org.opencontainers.image.revision": "f" * 40}}, + } + ] + with pytest.raises(m.PromotionRejected, match="rollback_image_unverified"): + runtime.target(row) + + +def test_baseline_digest_changes_when_bound_source_stat_or_blob_changes(fixture): + runtime, _request = fixture + runtime.state["target"]["runtimeFingerprint"] = {"blob": "old", "inode": 5} + before = m.digest(runtime.state) + runtime.state["target"]["runtimeFingerprint"]["inode"] = 6 + assert m.digest(runtime.state) != before diff --git a/backend/tests/test_managed_validation_parallel.py b/backend/tests/test_managed_validation_parallel.py index 1145dbb..a082294 100644 --- a/backend/tests/test_managed_validation_parallel.py +++ b/backend/tests/test_managed_validation_parallel.py @@ -1,3 +1,4 @@ +import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -16,3 +17,47 @@ def test_preflight_preserves_docs_only_fast_path(): text = WORKFLOW.read_text(encoding="utf-8") assert "runtime_change" in text assert "*.md|*.mdx|docs/*" in text + + +def test_backend_budget_is_bounded_without_weakening_validation(): + text = WORKFLOW.read_text(encoding="utf-8") + + expected_timeouts = { + "preflight": 10, + "backend": 60, + "frontend": 30, + "security": 20, + "polyglot": 30, + "full": 10, + } + for job, expected_minutes in expected_timeouts.items(): + match = re.search( + rf"(?ms)^ {re.escape(job)}:\n(?P.*?)(?=^ [a-z][a-z0-9_-]*:\n|\Z)", + text, + ) + assert match is not None, f"missing managed-validation job {job}" + timeout = re.search(r"(?m)^ timeout-minutes: (\d+)\s*$", match["body"]) + assert timeout is not None, f"missing timeout for {job}" + assert int(timeout.group(1)) == expected_minutes + + for lane in ("preflight", "backend", "frontend", "security", "polyglot", "full"): + assert f" {lane}:" in text + assert "needs: [preflight, backend, frontend, security, polyglot]" in text + assert "All managed validation lanes passed." in text + + backend = re.search( + r"(?ms)^ backend:\n(?P.*?)(?=^ [a-z][a-z0-9_-]*:\n|\Z)", + text, + ) + assert backend is not None + backend_body = backend["body"] + assert 'if [[ "${profile}" == test || "${profile}" == full ]]; then' in backend_body + pytest_commands = [ + line.strip() for line in backend_body.splitlines() if "-m pytest " in line + ] + assert pytest_commands == [ + '"${py}" -m pytest backend/tests -q --durations=20' + ] + assert "continue-on-error" not in backend_body + assert "--ignore" not in backend_body + assert "--deselect" not in backend_body diff --git a/backend/tests/test_mcp_hub_candidate_preparation.py b/backend/tests/test_mcp_hub_candidate_preparation.py index 7159cff..27a2314 100644 --- a/backend/tests/test_mcp_hub_candidate_preparation.py +++ b/backend/tests/test_mcp_hub_candidate_preparation.py @@ -4,6 +4,7 @@ import hashlib import importlib import json import os +import shutil import subprocess import sys import types @@ -64,6 +65,8 @@ def harness(modules, tmp_path, monkeypatch): state = types.SimpleNamespace( source=tmp_path / "releases" / revision, compose_marker="baseline", + clock=now, + ingestion_digest=None, images={}, mutations=[], inspect_calls=[], @@ -119,9 +122,13 @@ def harness(modules, tmp_path, monkeypatch): "validate_commit", lambda _repo, branch, commit: commit if branch == "main" else "", ) - monkeypatch.setattr( - controller, "deployment_metadata", lambda *_args, **_kwargs: dict(metadata) - ) + def deployment_metadata(_repo, _revision, _repository, *, built_at=None): + result = dict(metadata) + result["builtAt"] = built_at or state.clock + datetime.fromisoformat(result["builtAt"].replace("Z", "+00:00")) + return result + + monkeypatch.setattr(controller, "deployment_metadata", deployment_metadata) monkeypatch.setattr(controller, "mcp_hub_source_date_epoch", lambda *_args: epoch) def stage(_project, _repo, commit): @@ -144,11 +151,18 @@ def harness(modules, tmp_path, monkeypatch): for index, service in enumerate(controller.MCP_HUB_SERVICES, 1): if service in controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES: local = controller.MCP_HUB_LOCAL_BUILD_IMAGE_REPOSITORIES.get(service) - reference = ( - f"{local}:{revision[:12]}" - if local - else f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]}@sha256:{index:064x}" - ) + if service == "connector-ingestion": + reference = ( + f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]}@sha256:{state.ingestion_digest}" + if state.ingestion_digest + else "" + ) + else: + reference = ( + f"{local}:{revision[:12]}" + if local + else f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]}@sha256:{index:064x}" + ) elif service == "contextforge": reference = f"itworx-mcp-hub/contextforge-schema:{revision}" elif service == "ingestion-clamd": @@ -174,6 +188,13 @@ def harness(modules, tmp_path, monkeypatch): ) monkeypatch.setattr(utility, "_is_host_root", lambda: True) + utility.freeze_candidate(controller.MCP_HUB_REPOSITORY, revision) + freeze_dir = tmp_path / "mcp-hub-source-freezes" / revision + freeze_doc = json.loads((freeze_dir / "freeze.json").read_text()) + # Publication happens after freeze and advances the wall clock. The plan + # uses the immutable timestamp and the now-available registry digest. + state.clock = "2026-10-04T02:03:04.567Z" + state.ingestion_digest = "f" * 64 plan = utility.plan_candidate(controller.MCP_HUB_REPOSITORY, revision) candidate_dir = tmp_path / "mcp-hub-release-candidates" / revision plan_doc = json.loads((candidate_dir / "build.json").read_text()) @@ -255,6 +276,8 @@ def harness(modules, tmp_path, monkeypatch): now=now, plan=plan, plan_doc=plan_doc, + freeze_dir=freeze_dir, + freeze_doc=freeze_doc, ) @@ -265,13 +288,225 @@ def test_plan_freezes_protected_metadata_without_candidate_or_approval(harness): assert document["repository"] == h.controller.MCP_HUB_REPOSITORY assert document["revision"] == h.revision assert document["build"] == h.metadata + assert document["build"]["builtAt"] == h.freeze_doc["build"]["builtAt"] + assert document["build"]["builtAt"] != h.state.clock assert document["sourceDateEpoch"] == h.epoch assert len(document["composeSha256"]) == 64 + assert h.state.ingestion_digest in str(h.model) assert not (h.candidate_dir / "candidate.json").exists() assert not (h.candidate_dir / "approval.json").exists() assert h.state.mutations == [] +def _remove_candidate_and_freeze(h): + shutil.rmtree(h.candidate_dir, ignore_errors=True) + shutil.rmtree(h.freeze_dir.parent, ignore_errors=True) + + +def _prepare_freeze_dir(h): + h.freeze_dir.parent.mkdir(mode=0o700) + h.freeze_dir.mkdir(mode=0o700) + + +def test_plan_requires_existing_freeze_before_any_candidate_write(harness): + h = harness + _remove_candidate_and_freeze(h) + with pytest.raises( + h.controller.DeployError, + match="run freeze, publish the fixed ingestion image, then run plan", + ): + h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.candidate_dir.exists() + + +def test_freeze_is_non_replayable(harness): + h = harness + original = (h.freeze_dir / "freeze.json").read_bytes() + with pytest.raises(h.controller.DeployError, match="never overwritten|already exists"): + h.utility.freeze_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert (h.freeze_dir / "freeze.json").read_bytes() == original + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("revision", "freeze record.*source identity"), + ("tree", "freeze record.*source identity"), + ("calendar", "freeze record.*source identity"), + ("epoch", "freeze record.*source epoch"), + ("duplicate", "unavailable or invalid|duplicate"), + ("partial", "unavailable or invalid|partial"), + ], +) +def test_plan_rejects_invalid_freeze_records_without_candidate_writes( + harness, mutation, message +): + h = harness + _remove_candidate_and_freeze(h) + _prepare_freeze_dir(h) + record = dict(h.freeze_doc) + if mutation == "revision": + record["revision"] = "c" * 40 + payload = json.dumps(record).encode() + elif mutation == "tree": + record["build"] = dict(record["build"]) + record["build"]["tree"] = "d" * 40 + payload = json.dumps(record).encode() + elif mutation == "calendar": + record["build"] = dict(record["build"]) + record["build"]["builtAt"] = "2026-99-99T00:00:00.000Z" + payload = json.dumps(record).encode() + elif mutation == "epoch": + record["sourceDateEpoch"] = "1790888864" + payload = json.dumps(record).encode() + elif mutation == "duplicate": + payload = b'{"schema":"duplicate","schema":"duplicate"}' + else: + payload = b'{"schema":' + (h.freeze_dir / "freeze.json").write_bytes(payload) + (h.freeze_dir / "freeze.json").chmod(0o600) + with pytest.raises(h.controller.DeployError, match=message): + h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.candidate_dir.exists() + + +def test_plan_rejects_symlink_freeze_file_without_candidate_writes(harness, tmp_path): + h = harness + _remove_candidate_and_freeze(h) + _prepare_freeze_dir(h) + outside = tmp_path / "outside.json" + outside.write_text(json.dumps(h.freeze_doc)) + try: + (h.freeze_dir / "freeze.json").symlink_to(outside) + except OSError: + pytest.skip("symlink creation is unavailable on this filesystem") + with pytest.raises( + h.controller.DeployError, + match="unavailable or invalid|unsafe|not a regular file", + ): + h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.candidate_dir.exists() + + +def test_plan_rejects_fifo_freeze_record_without_blocking(harness): + if os.name == "nt" or not hasattr(os, "mkfifo"): + pytest.skip("FIFO files are a POSIX filesystem contract") + h = harness + _remove_candidate_and_freeze(h) + _prepare_freeze_dir(h) + os.mkfifo(h.freeze_dir / "freeze.json", 0o600) + with pytest.raises(h.controller.DeployError, match="not a regular file"): + h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.candidate_dir.exists() + + +def test_plan_rejects_unsafe_freeze_directory_mode_without_candidate_writes(harness): + h = harness + if os.name == "nt": + pytest.skip("POSIX private-mode enforcement is a Linux controller contract") + _remove_candidate_and_freeze(h) + _prepare_freeze_dir(h) + (h.freeze_dir / "freeze.json").write_text(json.dumps(h.freeze_doc)) + (h.freeze_dir / "freeze.json").chmod(0o600) + h.freeze_dir.chmod(0o755) + with pytest.raises(h.controller.DeployError, match="private|root-owned|ACL"): + h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.candidate_dir.exists() + + +@pytest.mark.parametrize( + "fault", ["file_mode", "owner", "dir_owner", "acl", "dir_acl"] +) +def test_plan_rejects_unsafe_freeze_file_metadata_without_candidate_writes( + harness, monkeypatch, fault +): + h = harness + if fault == "file_mode" and os.name == "nt": + pytest.skip("POSIX private-mode enforcement is a Linux controller contract") + _remove_candidate_and_freeze(h) + _prepare_freeze_dir(h) + freeze_file = h.freeze_dir / "freeze.json" + freeze_file.write_text(json.dumps(h.freeze_doc)) + freeze_file.chmod(0o600) + if fault == "file_mode": + freeze_file.chmod(0o644) + match = "unsafe" + elif fault in {"owner", "dir_owner"}: + original = h.controller._root_owned + denied_path = h.freeze_dir if fault == "dir_owner" else freeze_file + monkeypatch.setattr( + h.controller, + "_root_owned", + lambda path: False if Path(path) == denied_path else original(path), + ) + match = "root-owned|unsafe|owner" + else: + original = h.controller._mcp_hub_require_no_posix_acl + + def reject_freeze_acl(path): + if Path(path) == (h.freeze_dir if fault == "dir_acl" else freeze_file): + raise h.controller.DeployError("freeze ACL denied") + original(path) + + monkeypatch.setattr(h.controller, "_mcp_hub_require_no_posix_acl", reject_freeze_acl) + match = "ACL" + with pytest.raises(h.controller.DeployError, match=match): + h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.candidate_dir.exists() + + +def test_freeze_rejects_nonexact_local_main_head_without_writes(harness, monkeypatch): + h = harness + _remove_candidate_and_freeze(h) + monkeypatch.setattr(h.controller, "output", lambda _args: "c" * 40) + with pytest.raises(h.controller.DeployError, match="exact protected main head"): + h.utility.freeze_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.freeze_dir.parent.exists() + + +def test_freeze_rejects_staged_source_mismatch_without_freeze_record(harness, monkeypatch): + h = harness + _remove_candidate_and_freeze(h) + monkeypatch.setattr(h.controller, "exact_source_matches_revision", lambda *_args: False) + with pytest.raises(h.controller.DeployError, match="Staged MCP Hub source differs"): + h.utility.freeze_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.freeze_dir.parent.exists() + + +def test_freeze_rejects_invalid_epoch_before_staging_or_freeze_record(harness, monkeypatch): + h = harness + _remove_candidate_and_freeze(h) + monkeypatch.setattr(h.controller, "mcp_hub_source_date_epoch", lambda *_args: "invalid") + monkeypatch.setattr( + h.controller, + "mcp_hub_stage_release_source", + lambda *_args: pytest.fail("invalid epoch must fail before source staging"), + ) + with pytest.raises(h.controller.DeployError, match="source epoch"): + h.utility.freeze_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.freeze_dir.parent.exists() + + +def test_plan_rejects_staged_source_drift_before_candidate_writes(harness, monkeypatch): + h = harness + shutil.rmtree(h.candidate_dir) + monkeypatch.setattr(h.controller, "exact_source_matches_revision", lambda *_args: False) + with pytest.raises(h.controller.DeployError, match="Staged MCP Hub source differs"): + h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision) + assert not h.candidate_dir.exists() + + +def test_freeze_wrong_repository_and_nonroot_fail_before_writes(modules, monkeypatch, tmp_path): + controller, utility = modules + monkeypatch.setattr(utility, "_is_host_root", lambda: True) + monkeypatch.setattr(controller, "STATE_ROOT", tmp_path) + with pytest.raises(controller.DeployError, match="fixed MCP Hub repository"): + utility.freeze_candidate("someone/else", "a" * 40) + assert list(tmp_path.iterdir()) == [] + monkeypatch.setattr(utility, "_is_host_root", lambda: False) + with pytest.raises(controller.DeployError, match="host root"): + utility.freeze_candidate(controller.MCP_HUB_REPOSITORY, "a" * 40) + assert list(tmp_path.iterdir()) == [] def test_plan_candidate_freezes_the_selected_workflow_creation_timestamp( harness, monkeypatch ): @@ -334,7 +569,7 @@ def test_plan_candidate_freezes_the_selected_workflow_creation_timestamp( plan = json.loads( (staging / "build.json").read_text() ) - assert observed == [built_at] + assert observed == [built_at, built_at] assert plan["build"]["builtAt"] == built_at assert len(compose_calls) == 1 @@ -349,6 +584,7 @@ def test_candidate_preparation_rejects_staging_path_outside_private_import_ancho h.utility.plan_candidate( h.controller.MCP_HUB_REPOSITORY, revision, + built_at="2026-10-03T12:34:56.789Z", staging_directory=staging, ) assert not staging.exists() @@ -363,16 +599,22 @@ def test_candidate_preparation_rejects_symlinked_staging_destination(harness): target = h.controller.STATE_ROOT / "untrusted-target" target.mkdir(mode=0o700) staging = token / revision - staging.symlink_to(target, target_is_directory=True) + try: + staging.symlink_to(target, target_is_directory=True) + except OSError: + pytest.skip("symlink creation is unavailable on this filesystem") with pytest.raises(h.controller.DeployError): h.utility.plan_candidate( h.controller.MCP_HUB_REPOSITORY, revision, + built_at="2026-10-03T12:34:56.789Z", staging_directory=staging, ) def test_candidate_preparation_rejects_nonprivate_staging_parent(harness): + if os.name == "nt": + pytest.skip("POSIX private-mode enforcement is a Linux controller contract") h = harness revision = h.revision anchor = h.controller.STATE_ROOT / "mcp-hub-release-imports" / revision @@ -384,6 +626,7 @@ def test_candidate_preparation_rejects_nonprivate_staging_parent(harness): h.utility.plan_candidate( h.controller.MCP_HUB_REPOSITORY, revision, + built_at="2026-10-03T12:34:56.789Z", staging_directory=staging, ) @@ -447,6 +690,10 @@ def test_capture_emits_candidate_and_pending_only_envelope_without_apply_calls( digests = json.loads((h.candidate_dir / "image-digests.json").read_text()) assert result["status"] == "pending_review" assert set(candidate["images"]) == set(h.controller.MCP_HUB_SERVICES) + assert candidate["images"]["connector-ingestion"]["imageRef"].endswith( + "@sha256:" + h.state.ingestion_digest + ) + assert candidate["build"]["builtAt"] == h.freeze_doc["build"]["builtAt"] assert digests["images"]["web"]["tag"] == candidate["images"]["web"]["imageRef"] assert digests["images"]["web"]["imageId"] == candidate["images"]["web"]["imageId"] candidate_bytes = (h.candidate_dir / "candidate.json").read_bytes() diff --git a/backend/tests/test_mcp_hub_deployment_reconciliation.py b/backend/tests/test_mcp_hub_deployment_reconciliation.py index 16ccb49..8646b1f 100644 --- a/backend/tests/test_mcp_hub_deployment_reconciliation.py +++ b/backend/tests/test_mcp_hub_deployment_reconciliation.py @@ -385,10 +385,25 @@ def test_external_candidate_pins_are_exact_and_declared_by_protected_main(): node for node in parsed.body if isinstance(node, ast.FunctionDef) and node.name == "fixed_image_reference" ) - declared = { - "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04", - "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48", - } + postgres = ( + "gitea.example.com/jens/itworx-mcp-hub/postgres:" + "wp400-gosu-v2-20261002-a94e0b4d2029@sha256:" + "a60d81e06987671ae903757f1b71cdfbbff4d481b42610e4de2b7dcfa32aad73" + ) + redis = ( + "gitea.example.com/jens/itworx-mcp-hub/redis:" + "wp400-remediation-20261002-4e18f61c@sha256:" + "7d826b44f245ad32e8fe4bf40d42eae95855c3942c5f2e42784ee8fd3f9547a2" + ) + previous_postgres = ( + "postgres:18.6@sha256:" + "0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04" + ) + previous_redis = ( + "redis:8.10.2@sha256:" + "7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48" + ) + declared = {previous_postgres, previous_redis, postgres, redis} namespace = { "re": re, "main_declares_external_image": lambda image_ref: image_ref in declared, @@ -399,13 +414,19 @@ def test_external_candidate_pins_are_exact_and_declared_by_protected_main(): fixed = namespace["fixed_image_reference"] assert fixed("postgres", "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a") - assert fixed( - "postgres", - "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04", - ) + assert fixed("postgres", "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04") assert fixed("redis", "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236") assert fixed("redis", "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48") - assert not fixed("postgres", "postgres:18.6@sha256:" + "a" * 64) + assert fixed("postgres", postgres) + assert fixed("redis", redis) + assert not fixed("redis", postgres) + assert not fixed("postgres", postgres[:-1] + "4") + assert not fixed("postgres", postgres.replace("wp400-gosu-v2-20261002-a94e0b4d2029", "wp400-gosu-v2-20261002-wrong")) + assert not fixed("postgres", postgres.replace("gitea.example.com/jens/itworx-mcp-hub", "gitea.example.com/other/itworx-mcp-hub")) + assert not fixed("redis", redis[:-1] + "3") + assert not fixed("redis", redis.replace("wp400-remediation-20261002-4e18f61c", "wp400-remediation-20261002-wrong")) + assert not fixed("redis", redis.replace("gitea.example.com/jens/itworx-mcp-hub", "gitea.example.com/other/itworx-mcp-hub")) + assert not fixed("postgres", "postgres:19.0@sha256:" + "a" * 64) assert not fixed("redis", "redis:8.10.3@sha256:" + "b" * 64) for service in ("web", "control-api", "dataplane-router", "connector-ingestion"): assert fixed(service, "sha256:" + "a" * 64) @@ -413,8 +434,58 @@ def test_external_candidate_pins_are_exact_and_declared_by_protected_main(): assert not fixed(service, "sha256:" + "a" * 64) namespace["main_declares_external_image"] = lambda _image_ref: False - assert not fixed("postgres", "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04") - assert not fixed("redis", "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48") + assert fixed("postgres", "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a") + assert not fixed("postgres", previous_postgres) + assert fixed("redis", "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236") + assert not fixed("redis", previous_redis) + assert not fixed("postgres", postgres) + assert not fixed("redis", redis) + + +@pytest.mark.parametrize("service, image_ref", [ + ( + "postgres", + "gitea.example.com/jens/itworx-mcp-hub/postgres:" + "wp400-gosu-v2-20261002-a94e0b4d2029@sha256:" + "a60d81e06987671ae903757f1b71cdfbbff4d481b42610e4de2b7dcfa32aad73", + ), + ( + "redis", + "gitea.example.com/jens/itworx-mcp-hub/redis:" + "wp400-remediation-20261002-4e18f61c@sha256:" + "7d826b44f245ad32e8fe4bf40d42eae95855c3942c5f2e42784ee8fd3f9547a2", + ), +]) +def test_new_db_pin_requires_exact_protected_main_compose_declaration(service, image_ref): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + parsed = ast.parse(script) + function = next( + node for node in parsed.body + if isinstance(node, ast.FunctionDef) and node.name == "main_declares_external_image" + ) + main_revision = "a" * 40 + content = {"value": f"services:\n {service}:\n image: {image_ref}\n"} + calls = [] + + def git_run(*args): + calls.append(args) + return subprocess.CompletedProcess(args, 0, stdout=content["value"]) + + namespace = {"re": re, "main_revision": main_revision, "git_run": git_run} + exec(compile(ast.Module(body=[function], type_ignores=[]), "main-image-declaration-test", "exec"), namespace) + declares = namespace["main_declares_external_image"] + assert declares(image_ref) + assert calls == [("show", f"{main_revision}:infrastructure/compose/docker-compose.yml")] + + calls.clear() + content["value"] = f"services:\n {service}:\n image: {image_ref[:-1]}0\n" + assert not declares(image_ref) + assert calls == [ + ("show", f"{main_revision}:infrastructure/compose/docker-compose.yml"), + ("show", f"{main_revision}:infrastructure/compose/docker-compose.prod.yml"), + ] @pytest.fixture diff --git a/backend/tests/test_mcp_hub_governed_topology.py b/backend/tests/test_mcp_hub_governed_topology.py index 5df1638..570b3cf 100644 --- a/backend/tests/test_mcp_hub_governed_topology.py +++ b/backend/tests/test_mcp_hub_governed_topology.py @@ -268,15 +268,49 @@ def test_exact_legacy_target_derives_only_the_fixed_17_service_contract(controll assert target["containers"] == list(controller.MCP_HUB_LEGACY_CONTAINERS) assert controller.effective_mcp_hub_project("jens/other", target) is target -def test_postgres_candidate_pin_matches_the_reviewed_release_evidence(controller): - expected = ( +def test_db_candidate_pins_and_observed_transition_refs_are_exact(controller): + postgres = ( + "gitea.example.com/jens/itworx-mcp-hub/postgres:" + "wp400-gosu-v2-20261002-a94e0b4d2029@sha256:" + "a60d81e06987671ae903757f1b71cdfbbff4d481b42610e4de2b7dcfa32aad73" + ) + redis = ( + "gitea.example.com/jens/itworx-mcp-hub/redis:" + "wp400-remediation-20261002-4e18f61c@sha256:" + "7d826b44f245ad32e8fe4bf40d42eae95855c3942c5f2e42784ee8fd3f9547a2" + ) + assert controller.MCP_HUB_POSTGRES_IMAGE == postgres + assert controller.MCP_HUB_REDIS_IMAGE == redis + assert controller.MCP_HUB_POSTGRES_PREVIOUS_IMAGE == ( "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04" ) - assert controller.MCP_HUB_POSTGRES_IMAGE == expected - assert controller.MCP_HUB_OBSERVED_EXTERNAL_IMAGE_TRANSITIONS["postgres"] == ( - controller.MCP_HUB_POSTGRES_LEGACY_IMAGE, - expected, + assert controller.MCP_HUB_REDIS_PREVIOUS_IMAGE == ( + "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48" ) + assert controller.MCP_HUB_OBSERVED_EXTERNAL_IMAGE_TRANSITIONS == { + "postgres": ( + (controller.MCP_HUB_POSTGRES_LEGACY_IMAGE, controller.MCP_HUB_POSTGRES_PREVIOUS_IMAGE), + postgres, + ), + "redis": ( + (controller.MCP_HUB_REDIS_LEGACY_IMAGE, controller.MCP_HUB_REDIS_PREVIOUS_IMAGE), + redis, + ), + } + +@pytest.mark.parametrize("service", ["postgres", "redis"]) +def test_db_transition_accepts_only_new_forward_pin_against_exact_observed_refs(controller, service): + observed_refs, candidate_ref = controller.MCP_HUB_OBSERVED_EXTERNAL_IMAGE_TRANSITIONS[service] + assert controller._mcp_hub_observed_external_ref_allowed(service, candidate_ref, candidate_ref) + for old_ref in observed_refs: + assert controller._mcp_hub_observed_external_ref_allowed(service, candidate_ref, old_ref) + assert not controller._mcp_hub_observed_external_ref_allowed(service, old_ref, old_ref) + assert not controller._mcp_hub_observed_external_ref_allowed(service, candidate_ref, old_ref + "-unexpected") + assert not controller._mcp_hub_observed_external_ref_allowed(service, candidate_ref + "-unexpected", candidate_ref) + other_candidate = ( + controller.MCP_HUB_REDIS_IMAGE if service == "postgres" else controller.MCP_HUB_POSTGRES_IMAGE + ) + assert not controller._mcp_hub_observed_external_ref_allowed(service, other_candidate, candidate_ref) def test_installed_legacy_registry_shape_is_adapted_only_in_memory(controller): target = { @@ -1402,7 +1436,7 @@ def test_private_backup_artifacts_publish_atomically_without_overwrite( @pytest.mark.parametrize( "image_ref_name", - ["MCP_HUB_POSTGRES_IMAGE", "MCP_HUB_POSTGRES_LEGACY_IMAGE"], + ["MCP_HUB_POSTGRES_IMAGE", "MCP_HUB_POSTGRES_PREVIOUS_IMAGE", "MCP_HUB_POSTGRES_LEGACY_IMAGE"], ) def test_live_mount_inventory_includes_volume_identity_source_and_read_only_state( controller, monkeypatch, image_ref_name diff --git a/backend/tests/test_mcp_hub_release_candidates.py b/backend/tests/test_mcp_hub_release_candidates.py index 170f797..903dc90 100644 --- a/backend/tests/test_mcp_hub_release_candidates.py +++ b/backend/tests/test_mcp_hub_release_candidates.py @@ -54,6 +54,7 @@ def prepared(controller, tmp_path, monkeypatch): project = { "_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT, "_repository_path": str(tmp_path / "hub.git"), + "compose_project": controller.MCP_HUB_PROJECT, "_deployment_metadata": metadata, "_mcp_hub_source_date_epoch": "1790888863", } @@ -89,6 +90,10 @@ def prepared(controller, tmp_path, monkeypatch): ref = f"fixture/{service}@sha256:" + f"{index:064x}" if service == "contextforge": ref = f"itworx-mcp-hub/contextforge-schema:{revision}" + elif service == "connector-ingestion": + repository = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service] + archive_ref = f"{repository}:{revision}-run-21531-attempt-2" + ref = f"{archive_ref}@sha256:" + f"{index:064x}" model["services"][service] = {"image": ref} vuln = { "SchemaVersion": 2, @@ -142,6 +147,27 @@ def prepared(controller, tmp_path, monkeypatch): } candidate_sha = write("candidate.json", candidate) exceptions_sha = write("exceptions.json", {"exceptions": []}) + write( + "import-receipt.json", + { + "schema": "itworx.mcp-hub-import-receipt/v1", + "repository": controller.MCP_HUB_REPOSITORY, + "sourceRevision": revision, + "sourceTree": metadata["tree"], + "workflowRunId": 21531, + "workflowRunAttempt": 2, + "artifactId": 882, + "artifactName": "hub-release-candidate-" + revision, + "artifactZipSha256": "1" * 64, + "manifestSha256": "2" * 64, + "dockerTarSha256": "3" * 64, + "candidateSha256": candidate_sha, + "draftExceptionsSha256": exceptions_sha, + "imageIds": { + service: image["imageId"] for service, image in images.items() + }, + }, + ) write( "approval.json", { @@ -162,8 +188,17 @@ def prepared(controller, tmp_path, monkeypatch): v["imageRef"]: { "Id": v["imageId"], "Config": {"User": "10001:10001", "Labels": dict(labels)}, + **( + { + "RepoDigests": [ + f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES['connector-ingestion']}@{v['imageRef'].rsplit('@', 1)[1]}" + ] + } + if service == "connector-ingestion" + else {} + ), } - for v in images.values() + for service, v in images.items() } monkeypatch.setattr(controller, "inspect_image", lambda ref: facts.get(ref)) return types.SimpleNamespace( @@ -182,6 +217,9 @@ def prepared(controller, tmp_path, monkeypatch): def test_fresh_complete_candidate_freezes_metadata_and_checks_explicit_gate( controller, prepared ): + prepared.project["environment"] = { + "CONNECTOR_INGESTION_IMAGE": "attacker.invalid/connector:latest" + } result = controller.mcp_hub_load_release_candidate( prepared.project, prepared.revision ) @@ -191,6 +229,35 @@ def test_fresh_complete_candidate_freezes_metadata_and_checks_explicit_gate( controller.mcp_hub_require_candidate_images( prepared.project, prepared.model, prepared.revision ) + binding = prepared.project["_mcp_hub_connector_ingestion_binding"] + environment = controller.deployment_environment( + prepared.project, + prepared.revision, + controller.MCP_HUB_REPOSITORY, + prepared.metadata, + ) + assert environment["CONNECTOR_INGESTION_IMAGE"] == binding["imageRef"] + + +@pytest.mark.parametrize("mutation", ["missing", "attempt", "image_id"]) +def test_import_receipt_is_required_and_binds_connector_ingestion_attempt( + controller, prepared, mutation +): + path = prepared.directory / "import-receipt.json" + if mutation == "missing": + path.unlink() + else: + receipt = json.loads(path.read_text(encoding="utf-8")) + if mutation == "attempt": + receipt["workflowRunAttempt"] = 1 + else: + receipt["imageIds"]["connector-ingestion"] = "sha256:" + "f" * 64 + prepared.write("import-receipt.json", receipt) + with pytest.raises(controller.DeployError): + controller.mcp_hub_load_release_candidate( + prepared.project, prepared.revision + ) + assert prepared.checks == [] @pytest.mark.parametrize( @@ -435,7 +502,8 @@ def test_compose_operations_pin_all_scanned_ids_without_changing_source_model( def compose(project): path = Path(project["_mcp_hub_image_override_file"]) - assert path.stat().st_mode & 0o777 == 0o600 + if controller.os.name != "nt": + assert path.stat().st_mode & 0o777 == 0o600 observed.append((path, json.loads(path.read_text()))) return prepared.directory, ["docker", "compose", "-f", str(path)] @@ -775,6 +843,11 @@ def test_actual_high_findings_are_bound_to_current_service_tags(controller, prep } prepared.candidate["summarySha256"] = prepared.write("scan-summary.json", summary) candidate_sha = prepared.write("candidate.json", prepared.candidate) + receipt = json.loads( + (prepared.directory / "import-receipt.json").read_text(encoding="utf-8") + ) + receipt["candidateSha256"] = candidate_sha + prepared.write("import-receipt.json", receipt) prepared.write( "approval.json", { @@ -804,6 +877,11 @@ def test_fixable_high_findings_cannot_be_approved_around(controller, prepared): "vulnerability-web.json", report ) candidate_sha = prepared.write("candidate.json", prepared.candidate) + receipt = json.loads( + (prepared.directory / "import-receipt.json").read_text(encoding="utf-8") + ) + receipt["candidateSha256"] = candidate_sha + prepared.write("import-receipt.json", receipt) prepared.write( "approval.json", { diff --git a/backend/tests/test_nginx_proxy_manager_connector.py b/backend/tests/test_nginx_proxy_manager_connector.py index 1bc225f..764dfae 100644 --- a/backend/tests/test_nginx_proxy_manager_connector.py +++ b/backend/tests/test_nginx_proxy_manager_connector.py @@ -30,7 +30,7 @@ def _set_npm_config( settings.apis["NPM_URL"] = url settings.apis["NPM_IDENTITY"] = identity settings.apis["NPM_SECRET"] = secret - settings.apis["NPM_VERIFY_TLS"] = False + settings.apis["NPM_VERIFY_TLS"] = True settings.apis["NPM_TIMEOUT_SECONDS"] = "10" return previous diff --git a/backend/tests/test_npm_secure_transport.py b/backend/tests/test_npm_secure_transport.py new file mode 100644 index 0000000..69ab17e --- /dev/null +++ b/backend/tests/test_npm_secure_transport.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture +def npm_settings(monkeypatch): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis.update( + { + "NPM_URL": "https://npm.example.test", + "NPM_IDENTITY": "operator@example.test", + "NPM_SECRET": "fixture password value", + "NPM_VERIFY_TLS": True, + "NPM_TIMEOUT_SECONDS": "3", + } + ) + yield settings.apis + settings.apis.clear() + settings.apis.update(previous) + + +@pytest.mark.parametrize( + ("url", "verify_tls", "reason"), + [ + ("http://npm.example.test", True, "npm_https_required"), + ("https://user:fixture-password@npm.example.test", True, "npm_url_invalid"), + ("https://npm.example.test/?token=fixture-password", True, "npm_url_invalid"), + ("https://npm.example.test/#fixture-password", True, "npm_url_invalid"), + ("https://npm.example.test:", True, "npm_url_invalid"), + ("https://npm.example.test/base/../api", True, "npm_url_invalid"), + ("https://[broken", True, "npm_url_invalid"), + ("https://npm.example.test", False, "npm_tls_verification_required"), + ], +) +def test_unsafe_npm_transport_is_rejected_before_client_construction( + npm_settings, monkeypatch, url, verify_tls, reason +): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + npm_settings["NPM_URL"] = url + npm_settings["NPM_VERIFY_TLS"] = verify_tls + client_calls = [] + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", + lambda **kwargs: client_calls.append(kwargs), + ) + + connector = NginxProxyManagerConnector() + with pytest.raises(RuntimeError, match=f"^{reason}$") as error: + connector._login() + + assert str(error.value) == reason + assert client_calls == [] + assert "fixture password value" not in str(error.value) + assert "npm.example.test" not in str(error.value) + + +def test_missing_verify_setting_defaults_to_verified_https(npm_settings, monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + npm_settings.pop("NPM_VERIFY_TLS") + clients = [] + + class Response: + status_code = 200 + + def json(self): + return {"token": "fixture bearer"} + + class Client: + def __init__(self, **kwargs): + clients.append(kwargs) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, *_args, **_kwargs): + return Response() + + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", Client + ) + connector = NginxProxyManagerConnector() + + assert connector._login() == "fixture bearer" + assert clients == [{"timeout": 3.0, "verify": True, "follow_redirects": False}] + + +def test_invalid_url_is_not_echoed_in_health_metadata(npm_settings, monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + npm_settings["NPM_URL"] = ( + "https://operator:fixture-password@npm.example.test/?token=fixture-password" + ) + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", + lambda **_kwargs: pytest.fail("invalid URL must be rejected before HTTP"), + ) + + result = NginxProxyManagerConnector().health() + + assert result["status"] == "unreachable" + assert result["message"] == "npm_url_invalid" + assert result["url"] == "" + assert "fixture-password" not in str(result) + assert "operator@npm.example.test" not in str(result) + + +def test_login_and_api_request_preserve_base_path_without_following_redirects( + npm_settings, monkeypatch +): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + npm_settings["NPM_URL"] = "https://npm.example.test/manager/" + calls = [] + + class Response: + def __init__(self, status_code, payload, headers=None): + self.status_code = status_code + self._payload = payload + self.headers = headers or {} + + def json(self): + return self._payload + + class Client: + def __init__(self, **kwargs): + assert kwargs == { + "timeout": 3.0, + "verify": True, + "follow_redirects": False, + } + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, **kwargs): + calls.append(("POST", url, kwargs)) + return Response(200, {"token": "fixture bearer"}) + + def request(self, method, url, **kwargs): + calls.append((method, url, kwargs)) + return Response(200, {"ok": True}) + + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", Client + ) + connector = NginxProxyManagerConnector() + + assert connector._request("GET", "/nginx/proxy-hosts") == {"ok": True} + assert [call[:2] for call in calls] == [ + ("POST", "https://npm.example.test/manager/api/tokens"), + ("GET", "https://npm.example.test/manager/api/nginx/proxy-hosts"), + ] + assert "Authorization" not in calls[0][2].get("headers", {}) + + +def test_redirect_responses_are_not_followed_or_reported_as_success( + npm_settings, monkeypatch +): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + calls = [] + + class Response: + def __init__(self): + self.status_code = 302 + self.headers = {"location": "https://attacker.example.test/collect"} + + class Client: + def __init__(self, **kwargs): + assert kwargs["follow_redirects"] is False + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, **kwargs): + calls.append((url, kwargs)) + return Response() + + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", Client + ) + connector = NginxProxyManagerConnector() + + with pytest.raises(RuntimeError, match="^npm_redirect_denied$"): + connector._login() + + assert len(calls) == 1 + assert calls[0][0] == "https://npm.example.test/api/tokens" + assert calls[0][1]["json"]["secret"] == "fixture password value" + + +def test_api_redirect_does_not_forward_bearer_token(npm_settings, monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + calls = [] + + class Response: + def __init__(self, status_code, payload=None): + self.status_code = status_code + self._payload = payload + self.headers = {"location": "https://attacker.example.test/collect"} + + def json(self): + return self._payload + + class Client: + def __init__(self, **kwargs): + assert kwargs["follow_redirects"] is False + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, **kwargs): + calls.append(("POST", url, kwargs)) + return Response(200, {"token": "fixture bearer"}) + + def request(self, method, url, **kwargs): + calls.append((method, url, kwargs)) + return Response(302) + + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", Client + ) + connector = NginxProxyManagerConnector() + + with pytest.raises(RuntimeError, match="^npm_redirect_denied$"): + connector._request("GET", "/nginx/proxy-hosts", follow_redirects=True) + + assert [call[:2] for call in calls] == [ + ("POST", "https://npm.example.test/api/tokens"), + ("GET", "https://npm.example.test/api/nginx/proxy-hosts"), + ] + assert calls[1][2]["headers"]["Authorization"] == "Bearer fixture bearer" + assert calls[1][2]["follow_redirects"] is False + + +def test_login_error_body_cannot_echo_configured_secret(npm_settings, monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + class Response: + status_code = 500 + text = "login failed for fixture password value" + + class Client: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, *_args, **_kwargs): + return Response() + + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", Client + ) + connector = NginxProxyManagerConnector() + + with pytest.raises(RuntimeError, match="^npm_api_error$") as error: + connector._login() + + assert "fixture password value" not in str(error.value) + assert connector.last_error == "npm_api_error" + + +def test_cached_token_is_cleared_before_unsafe_transport_reuse(npm_settings, monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + connector = NginxProxyManagerConnector() + connector._config() + connector._runtime_token = "stale fixture bearer" + connector._read_cache["proxy_hosts"] = {"success": True, "items": ["stale"]} + npm_settings["NPM_URL"] = "http://npm.example.test" + client_calls = [] + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", + lambda **kwargs: client_calls.append(kwargs), + ) + + with pytest.raises(RuntimeError, match="^npm_https_required$"): + connector._login() + + assert connector._runtime_token is None + assert connector._read_cache == {} + assert client_calls == [] + + +@pytest.mark.parametrize( + "changed_key,changed_value", + [ + ("NPM_URL", "https://other.example.test"), + ("NPM_IDENTITY", "different@example.test"), + ("NPM_SECRET", "different fixture password"), + ], +) +def test_cached_token_and_read_cache_are_bound_to_transport_config( + npm_settings, monkeypatch, changed_key, changed_value +): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + connector = NginxProxyManagerConnector() + connector._config() + connector._runtime_token = "cached fixture bearer" + connector._read_cache["proxy_hosts"] = {"success": True, "items": ["old"]} + npm_settings[changed_key] = changed_value + requested = [] + monkeypatch.setattr( + connector, + "_request", + lambda method, path, **_kwargs: requested.append((method, path)) or [], + ) + + result = connector.proxy_hosts() + + assert result["items"] == [] + assert requested == [("GET", "/nginx/proxy-hosts")] + assert connector._runtime_token is None + assert connector._read_cache["proxy_hosts"] is result + + +def test_disabled_tls_clears_cached_state_before_read_cache_return( + npm_settings, monkeypatch +): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + connector = NginxProxyManagerConnector() + connector._config() + connector._runtime_token = "cached fixture bearer" + connector._read_cache["proxy_hosts"] = {"success": True, "items": ["old"]} + npm_settings["NPM_VERIFY_TLS"] = False + monkeypatch.setattr( + connector, + "_request", + lambda *_args, **_kwargs: pytest.fail("unsafe transport must not request"), + ) + + result = connector.proxy_hosts() + + assert result["success"] is False + assert result["message"] == "npm_tls_verification_required" + assert connector._runtime_token is None + assert connector._read_cache == {} + + +def test_malformed_tls_setting_is_rejected_and_url_metadata_is_hidden( + npm_settings, monkeypatch +): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + npm_settings["NPM_VERIFY_TLS"] = "maybe" + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", + lambda **_kwargs: pytest.fail("invalid TLS setting must be rejected before HTTP"), + ) + + result = NginxProxyManagerConnector().health() + + assert result["status"] == "unreachable" + assert result["message"] == "npm_config_invalid" + assert result["url"] == "" + + +@pytest.mark.parametrize( + "changed_key,changed_value", + [ + ("NPM_URL", "https://other.example.test"), + ("NPM_IDENTITY", "different@example.test"), + ("NPM_SECRET", "different fixture password"), + ], +) +def test_changed_credentials_require_a_new_login( + npm_settings, monkeypatch, changed_key, changed_value +): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + token_calls = [] + token_urls = [] + + class Response: + status_code = 200 + + def __init__(self, token): + self._token = token + + def json(self): + return {"token": self._token} + + class Client: + def __init__(self, **kwargs): + assert kwargs["verify"] is True + assert kwargs["follow_redirects"] is False + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post(self, url, **kwargs): + token_urls.append(url) + token_calls.append(kwargs["json"]) + return Response(f"fixture-token-{len(token_calls)}") + + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", Client + ) + connector = NginxProxyManagerConnector() + + assert connector._login() == "fixture-token-1" + npm_settings[changed_key] = changed_value + assert connector._login() == "fixture-token-2" + assert len(token_calls) == 2 + assert token_urls[0] == "https://npm.example.test/api/tokens" + expected_url = npm_settings["NPM_URL"].rstrip("/") + "/api/tokens" + assert token_urls[1] == expected_url + assert token_calls[1]["identity"] == npm_settings["NPM_IDENTITY"] + assert token_calls[1]["secret"] == npm_settings["NPM_SECRET"] + + +def test_missing_config_rejects_before_client_even_with_cached_token( + npm_settings, monkeypatch +): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + connector = NginxProxyManagerConnector() + connector._config() + connector._runtime_token = "stale fixture bearer" + npm_settings["NPM_URL"] = None + client_calls = [] + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.httpx.Client", + lambda **kwargs: client_calls.append(kwargs), + ) + + with pytest.raises(RuntimeError, match="^missing_config$"): + connector._login() + + assert connector._runtime_token is None + assert client_calls == [] + + +def test_missing_config_check_clears_cached_context_before_configuration_returns( + npm_settings, +): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + connector = NginxProxyManagerConnector() + connector._config() + connector._runtime_token = "stale fixture bearer" + connector._read_cache["proxy_hosts"] = {"success": True, "items": []} + npm_settings["NPM_SECRET"] = None + + assert connector.is_configured() is False + assert connector._runtime_token is None + assert connector._read_cache == {} + + +def test_missing_npm_tls_environment_default_does_not_inherit_global_disable( + monkeypatch, +): + import builtins + + from backend.app.core import config + + monkeypatch.setenv("APPOPS_VERIFY_TLS", "false") + monkeypatch.delenv("NPM_VERIFY_TLS", raising=False) + for name in ( + "APPOPS_MCP_TOKEN_FILE", + "APPOPS_N8N_TOKEN_FILE", + "APPOPS_SECRET_KEY_FILE", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(config, "_secret_value", lambda *_args: None) + monkeypatch.setattr( + builtins, + "open", + lambda *_args, **_kwargs: pytest.fail("config test must not read secret files"), + ) + + configured = config.Settings() + + assert configured.verify_tls is False + assert configured.apis["NPM_VERIFY_TLS"] is True diff --git a/backend/tests/test_registered_build_context.py b/backend/tests/test_registered_build_context.py new file mode 100644 index 0000000..819ca2e --- /dev/null +++ b/backend/tests/test_registered_build_context.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +import importlib.util +import os +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +MODULE = ( + Path(__file__).resolve().parents[2] + / "ops/gitea-deploy-broker/registered_build_context.py" +) + + +def load(): + spec = importlib.util.spec_from_file_location( + "registered_build_context_test", MODULE + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def git(path, *args): + return ( + subprocess.run( + ["git", "-C", str(path), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + .stdout.decode() + .strip() + ) + + +@pytest.fixture +def inputs(tmp_path): + checkout = tmp_path / "working" + checkout.mkdir() + git(checkout, "init", "-q") + git(checkout, "config", "user.email", "test@example.invalid") + git(checkout, "config", "user.name", "Test") + (checkout / "server").mkdir() + (checkout / "server/Dockerfile").write_text("FROM scratch\nCOPY . /app\n") + (checkout / "server/main.py").write_text("print('protected')\n") + (checkout / "run.sh").write_text("#!/bin/sh\nexit 0\n") + (checkout / "run.sh").chmod(0o755) + git(checkout, "add", ".") + git(checkout, "update-index", "--chmod=+x", "run.sh") + git(checkout, "commit", "-qm", "protected source") + revision = git(checkout, "rev-parse", "HEAD") + bare = tmp_path / "repo.git" + subprocess.run( + ["git", "clone", "--bare", "--quiet", str(checkout), str(bare)], check=True + ) + live = tmp_path / "live" + live.mkdir() + (live / "server").mkdir() + (live / "server/stale.py").write_text("untracked") + (live / ".env").write_text("PRIVATE_VALUE=do-not-copy") + project = {"slug": "quiz-monitor", "source_dir": str(live), "compose_dir": ""} + model = { + "services": { + "quiz-server": { + "build": {"context": str(live), "dockerfile": "server/Dockerfile"} + } + } + } + return bare, revision, project, model, tmp_path / "state", live, checkout + + +def prepare(module, inputs): + return module.prepare_build_contexts(*inputs[:5]) + + +def test_exact_archive_omits_live_untracked_files_and_preserves_live_tree(inputs): + result = prepare(load(), inputs) + tree = Path(result["quiz-server"]) + assert tree.is_absolute() + assert (tree / "server/main.py").read_text() == "print('protected')\n" + assert not (tree / "server/stale.py").exists() + assert not (tree / ".env").exists() + assert (inputs[5] / "server/stale.py").read_text() == "untracked" + assert (inputs[5] / ".env").read_text() == "PRIVATE_VALUE=do-not-copy" + assert (tree.parent / "manifest.json").is_file() + assert not (tree / "manifest.json").exists() + assert prepare(load(), inputs) == result + if os.name != "nt": + assert tree.parent.stat().st_mode & 0o777 == 0o700 + assert (tree / "run.sh").stat().st_mode & 0o777 == 0o755 + + +@pytest.mark.parametrize("change", ["content", "extra", "missing", "mode", "manifest"]) +def test_cached_context_drift_fails_without_replacing_source(inputs, change): + module = load() + tree = Path(prepare(module, inputs)["quiz-server"]) + if change == "content": + (tree / "server/main.py").write_text("tampered") + elif change == "extra": + (tree / "untracked.py").write_text("tampered") + elif change == "missing": + (tree / "server/main.py").unlink() + elif change == "mode": + if os.name == "nt": + pytest.skip("POSIX permissions are verified on Linux") + (tree / "run.sh").chmod(0o644) + else: + (tree.parent / "manifest.json").write_text("{}") + with pytest.raises(module.RegisteredBuildContextError): + prepare(module, inputs) + assert (inputs[5] / "server/stale.py").is_file() + + +@pytest.mark.parametrize( + "build", + [ + {"context": "../outside", "dockerfile": "Dockerfile"}, + {"context": "https://example.invalid/source.git"}, + {"context": ".", "dockerfile": "../outside/Dockerfile"}, + {"context": ".", "dockerfile": "not-tracked"}, + {"context": ".", "additional_contexts": {"other": "/elsewhere"}}, + {"context": ".", "dockerfile_inline": "FROM scratch"}, + ], +) +def test_external_or_untracked_build_inputs_are_rejected(inputs, build): + module = load() + inputs[3]["services"]["quiz-server"]["build"] = build + with pytest.raises(module.RegisteredBuildContextError): + prepare(module, inputs) + + +def test_subdirectory_context_is_remapped_with_exact_revision(inputs): + inputs[3]["services"]["quiz-server"]["build"] = { + "context": "server", + "dockerfile": "Dockerfile", + } + result = prepare(load(), inputs) + assert Path(result["quiz-server"]).name == "server" + assert (Path(result["quiz-server"]) / "main.py").is_file() + + +def test_revision_is_not_a_branch_and_old_archive_survives_new_commit(inputs): + module = load() + result = prepare(module, inputs) + inputs[6].joinpath("server/main.py").write_text("changed main") + git(inputs[6], "commit", "-qam", "advance main") + git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main") + assert prepare(module, inputs) == result + assert ( + Path(result["quiz-server"]).joinpath("server/main.py").read_text() + == "print('protected')\n" + ) + with pytest.raises(module.RegisteredBuildContextError): + module.prepare_build_contexts(inputs[0], "main", *inputs[2:5]) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink boundary") +def test_symlinked_live_context_or_archived_file_is_rejected(inputs): + module = load() + (inputs[5] / "link").symlink_to(inputs[5] / "server", target_is_directory=True) + inputs[3]["services"]["quiz-server"]["build"] = { + "context": "link", + "dockerfile": "Dockerfile", + } + with pytest.raises(module.RegisteredBuildContextError): + prepare(module, inputs) + inputs[6].joinpath("tracked-link").symlink_to("server/main.py") + git(inputs[6], "add", "tracked-link") + git(inputs[6], "commit", "-qm", "unsupported tracked symlink") + revision = git(inputs[6], "rev-parse", "HEAD") + git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main") + inputs[3]["services"]["quiz-server"]["build"] = { + "context": ".", + "dockerfile": "server/Dockerfile", + } + with pytest.raises(module.RegisteredBuildContextError): + module.prepare_build_contexts(inputs[0], revision, *inputs[2:5]) + + +@pytest.mark.parametrize( + "field", ["secrets", "ssh", "additional_contexts", "dockerfile_inline"] +) +def test_build_cannot_request_host_credentials(inputs, field): + module = load() + inputs[3]["services"]["quiz-server"]["build"][field] = ["default"] + with pytest.raises(module.RegisteredBuildContextError): + prepare(module, inputs) + + +def test_tracked_runtime_env_is_rejected_before_cache_creation(inputs): + module = load() + inputs[6].joinpath(".env").write_text("PRIVATE_CONFIG=value") + git(inputs[6], "add", ".env") + git(inputs[6], "commit", "-qm", "unsupported runtime env") + revision = git(inputs[6], "rev-parse", "HEAD") + git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main") + with pytest.raises(module.RegisteredBuildContextError): + module.prepare_build_contexts(inputs[0], revision, *inputs[2:5]) + assert not inputs[4].exists() + + +def test_git_export_attributes_cannot_change_exact_archive_bytes(inputs): + module = load() + inputs[6].joinpath(".gitattributes").write_text("server/main.py export-ignore\n") + git(inputs[6], "add", ".gitattributes") + git(inputs[6], "commit", "-qm", "unsupported export omission") + revision = git(inputs[6], "rev-parse", "HEAD") + git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main") + with pytest.raises(module.RegisteredBuildContextError): + module.prepare_build_contexts(inputs[0], revision, *inputs[2:5]) + assert not inputs[4].joinpath("registered-builds/quiz-monitor", revision).exists() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink boundary") +def test_cached_directory_link_is_rejected_without_touching_link_target(inputs): + module = load() + tree = Path(prepare(module, inputs)["quiz-server"]) + tree.rename(tree.parent / "original") + tree.symlink_to(inputs[5], target_is_directory=True) + with pytest.raises(module.RegisteredBuildContextError): + prepare(module, inputs) + assert (inputs[5] / ".env").is_file() + + +def test_tree_size_bound_prevents_archive_execution(inputs, monkeypatch): + module = load() + monkeypatch.setattr(module, "MAX_BYTES", 1) + with pytest.raises(module.RegisteredBuildContextError, match="bound_exceeded"): + prepare(module, inputs) + assert not inputs[4].exists() + + +@pytest.mark.skipif( + os.name == "nt" or os.getuid() != 0, + reason="observed Linux read-only Gitea mount ownership", +) +def test_gitea_owned_readonly_repository_uses_exact_safe_directory(inputs, monkeypatch): + module = load() + os.chown(inputs[0], 1000, 1000) + commands = [] + original = module.subprocess.Popen + + def spy(command, **kwargs): + commands.append(command) + return original(command, **kwargs) + + monkeypatch.setattr(module.subprocess, "Popen", spy) + result = prepare(module, inputs) + assert Path(result["quiz-server"]).joinpath("server/main.py").is_file() + assert commands + assert all( + command[:4] + == ["git", "--no-replace-objects", "-c", f"safe.directory={inputs[0]}"] + for command in commands + ) + + +@pytest.mark.skipif( + os.name == "nt" or os.getuid() != 0, reason="Linux repository UID boundary" +) +@pytest.mark.parametrize("owner,mode", [(1001, 0o755), (1000, 0o775), (0, 0o777)]) +def test_unexpected_or_writable_repository_is_rejected(inputs, owner, mode): + module = load() + os.chown(inputs[0], owner, owner) + inputs[0].chmod(mode) + with pytest.raises(module.RegisteredBuildContextError): + prepare(module, inputs) + assert not inputs[4].exists() + + +def test_windows_path_identity_uses_creation_time_not_inconsistent_ctime(monkeypatch): + module = load() + monkeypatch.setattr(module, "os", SimpleNamespace(name="nt")) + descriptor = SimpleNamespace( + st_dev=1, + st_ino=2, + st_size=3, + st_mtime_ns=40, + st_ctime_ns=40, + st_birthtime_ns=30, + ) + named = SimpleNamespace(**{**vars(descriptor), "st_ctime_ns": 30}) + assert module._path_identity(descriptor) == module._path_identity(named) + named.st_ino = 9 + assert module._path_identity(descriptor) != module._path_identity(named) + + +def test_posix_path_identity_preserves_change_time(monkeypatch): + module = load() + monkeypatch.setattr(module, "os", SimpleNamespace(name="posix")) + descriptor = SimpleNamespace( + st_dev=1, + st_ino=2, + st_size=3, + st_mtime_ns=40, + st_ctime_ns=40, + ) + named = SimpleNamespace(**{**vars(descriptor), "st_ctime_ns": 41}) + assert module._path_identity(descriptor) != module._path_identity(named) + + +def test_descriptor_metadata_changed_during_read_is_rejected(tmp_path, monkeypatch): + module = load() + path = tmp_path / "tracked" + raw = b"tracked bytes" + path.write_bytes(raw) + path.chmod(0o644) + original = module.os.fstat + calls = 0 + + def changed(descriptor): + nonlocal calls + calls += 1 + info = original(descriptor) + if calls == 1: + return info + fields = { + name: getattr(info, name) for name in dir(info) if name.startswith("st_") + } + fields["st_ctime_ns"] += 1 + return SimpleNamespace(**fields) + + monkeypatch.setattr(module.os, "fstat", changed) + with pytest.raises(module.RegisteredBuildContextError, match="file_changed"): + module._file( + path, {"size": len(raw), "oid": module._blob_digest(raw), "mode": 0o644} + ) + + +def test_git_replacement_refs_cannot_substitute_other_commit_contents(inputs): + checkout, bare, protected = inputs[6], inputs[0], inputs[1] + checkout.joinpath("server/main.py").write_text("replacement commit bytes\n") + git(checkout, "commit", "-qam", "replacement commit") + other = git(checkout, "rev-parse", "HEAD") + git(bare, "fetch", "--quiet", str(checkout), "HEAD") + git(bare, "replace", protected, other) + assert ( + git(bare, "show", f"{protected}:server/main.py") == "replacement commit bytes" + ) + tree = Path(prepare(load(), inputs)["quiz-server"]) + assert tree.joinpath("server/main.py").read_text() == "print('protected')\n" + + +@pytest.mark.parametrize( + "name", ["C:/outside.txt", "D:outside.txt", "server/file:stream"] +) +def test_git_manifest_rejects_windows_drive_and_stream_paths_before_extraction( + tmp_path, monkeypatch, name +): + module = load() + revision, tree, blob = "a" * 40, "b" * 40, "c" * 40 + responses = iter( + [ + f"{revision}\n".encode(), + f"{tree}\n".encode(), + f"100644 blob {blob} 1\t{name}\0".encode(), + ] + ) + monkeypatch.setattr(module, "_git_output", lambda *_args: next(responses)) + with pytest.raises(module.RegisteredBuildContextError, match="path_invalid"): + module._manifest(tmp_path, revision) + assert not list(tmp_path.iterdir()) diff --git a/backend/tests/test_registered_build_metadata.py b/backend/tests/test_registered_build_metadata.py new file mode 100644 index 0000000..10d449f --- /dev/null +++ b/backend/tests/test_registered_build_metadata.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import json +import subprocess +from types import SimpleNamespace + +import pytest +from test_deploy_broker import BROKER_ROOT, _load_module + + +@pytest.mark.parametrize("repository", ["jens/quiz-monitor", "jens/another-registered-app"]) +def test_verified_registered_build_uses_controller_metadata(repository, monkeypatch): + module = _load_module("registered_build_metadata", BROKER_ROOT / "deploy.py") + revision = "a" * 40 + metadata = {"repository": repository, "revision": revision, "tree": "b" * 40, + "id": "protected-build-42", "builtAt": "2026-10-07T00:00:00Z"} + monkeypatch.setenv("APPOPS_SOURCE_TREE", "stale-inherited-tree") + project = {"compose_project": "existing-project", "verify_oci_revision": True, + "environment": {"APPOPS_SOURCE_TREE": "wrong-tree", "APPOPS_BUILD_ID": "wrong-id", + "APPOPS_BUILD_DATE": "wrong-date", "USER_SETTING": "preserved$literal"}} + actual = module.deployment_environment(project, revision, repository, metadata) + assert {key: actual[key] for key in ( + "APPOPS_SOURCE_REVISION", "APPOPS_SOURCE_REPOSITORY", "APPOPS_SOURCE_TREE", + "APPOPS_BUILD_ID", "APPOPS_BUILD_DATE", "COMPOSE_PROJECT_NAME", "USER_SETTING", + )} == {"APPOPS_SOURCE_REVISION": revision, "APPOPS_SOURCE_REPOSITORY": repository, + "APPOPS_SOURCE_TREE": metadata["tree"], "APPOPS_BUILD_ID": metadata["id"], + "APPOPS_BUILD_DATE": metadata["builtAt"], "COMPOSE_PROJECT_NAME": "existing-project", + "USER_SETTING": "preserved$literal"} + + +def test_unverified_target_retains_existing_environment_overrides(): + module = _load_module("unverified_build_metadata", BROKER_ROOT / "deploy.py") + project = {"compose_project": "existing-project", "environment": { + "APPOPS_SOURCE_TREE": "existing-tree", "APPOPS_BUILD_ID": "existing-id", + "APPOPS_BUILD_DATE": "existing-date"}} + actual = module.deployment_environment(project, "a" * 40, "jens/another-app") + assert actual["APPOPS_SOURCE_TREE"] == "existing-tree" + assert actual["APPOPS_BUILD_ID"] == "existing-id" + assert actual["APPOPS_BUILD_DATE"] == "existing-date" + + +def test_unbound_target_does_not_enter_legacy_build_adoption(monkeypatch): + module = _load_module("unbound_build_metadata", BROKER_ROOT / "deploy.py") + monkeypatch.setattr(module, "compose_command", lambda *_: pytest.fail("unbound target rendered")) + module.prepare_registered_legacy_build({}, "a" * 40, "jens/quiz-monitor", {}) + + +def test_adopted_target_environment_excludes_controller_secrets(monkeypatch): + module = _load_module("isolated_legacy_environment", BROKER_ROOT / "deploy.py") + monkeypatch.setenv("UNRELATED_CONTROLLER_CREDENTIAL", "private-controller-value") + project = {"compose_project": "quiz-monitor", "_registered_legacy_request_id": + "6f9619ff-8b86-4011-b42d-00c04fc964ff", "environment": { + "OPENRGB_HOST": "preserved-value"}} + environment = module.deployment_environment(project, "a" * 40, "jens/quiz-monitor") + assert "UNRELATED_CONTROLLER_CREDENTIAL" not in environment + assert environment["OPENRGB_HOST"] == "preserved-value" + assert environment["APPOPS_SOURCE_REVISION"] == "a" * 40 + + +def test_legacy_build_binds_archive_and_private_runtime_configuration(tmp_path, monkeypatch): + module = _load_module("legacy_build_metadata", BROKER_ROOT / "deploy.py") + revision = "a" * 40 + repository = "jens/quiz-monitor" + project = {"_registered_legacy_request_id": "6f9619ff-8b86-4011-b42d-00c04fc964ff", + "_repository_path": str(tmp_path / "repository.git")} + model = {"services": {"quiz-server": {"build": {"context": str(tmp_path)}}}} + contexts = {"quiz-server": str(tmp_path / "verified-archive")} + environment = {"OPENRGB_HOST": "preserved-value", "APPOPS_SOURCE_REVISION": revision} + observed = {} + + def archive(repo_path, target_revision, target_project, rendered_model, source_root): + observed["archive"] = (repo_path, target_revision, target_project, rendered_model, source_root) + return contexts + + def configuration(repo, target_project, request_id, target_revision, rendered_model, + context_map, supplied_environment, **kwargs): + observed["configuration"] = (repo, request_id, target_revision, rendered_model, + context_map, supplied_environment, kwargs) + return {"overlayPath": str(tmp_path / "private-compose.json")} + + monkeypatch.setattr(module, "compose_command", lambda *_: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr(module.subprocess, "run", lambda *_, **__: SimpleNamespace( + returncode=0, stdout=json.dumps(model))) + monkeypatch.setattr(module, "_load_registered_build_context", lambda: SimpleNamespace( + prepare_build_contexts=archive)) + monkeypatch.setattr(module, "_load_registered_legacy_adoption", lambda: SimpleNamespace( + prepare_build_configuration=configuration)) + monkeypatch.setattr(module, "_registered_legacy_receipt_key", lambda: b"k" * 32) + module.prepare_registered_legacy_build(project, revision, repository, environment) + assert observed["archive"][1:] == (revision, project, model, module.EXACT_SOURCE_ROOT) + assert observed["configuration"][:6] == ( + repository, project["_registered_legacy_request_id"], revision, model, contexts, environment) + assert project["_registered_legacy_compose_override_file"] == str(tmp_path / "private-compose.json") + assert project["_registered_legacy_compose_environment"] == environment + assert project["_registered_legacy_compose_environment"] is not environment + + +def test_failed_source_render_cannot_prepare_archive_or_runtime(tmp_path, monkeypatch): + module = _load_module("failed_render_build_metadata", BROKER_ROOT / "deploy.py") + project = {"_registered_legacy_request_id": "6f9619ff-8b86-4011-b42d-00c04fc964ff"} + monkeypatch.setattr(module, "compose_command", lambda *_: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr(module.subprocess, "run", lambda *_, **__: SimpleNamespace(returncode=1, stdout="")) + monkeypatch.setattr(module, "_load_registered_build_context", lambda: pytest.fail("unsafe archive preparation")) + with pytest.raises(module.DeployError, match="could not be rendered"): + module.prepare_registered_legacy_build(project, "a" * 40, "jens/quiz-monitor", {}) + + +def test_registered_metadata_and_source_ignore_git_replacement_refs(tmp_path, monkeypatch): + module = _load_module("registered_original_git_objects", BROKER_ROOT / "deploy.py") + monkeypatch.delenv("GIT_NO_REPLACE_OBJECTS", raising=False) + + def git(*arguments): + return subprocess.check_output(["git", "-C", str(tmp_path), *arguments], text=True).strip() + + git("init", "-b", "main") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.invalid") + tracked = tmp_path / "source.txt" + tracked.write_text("protected original\n") + git("add", "source.txt") + git("commit", "-qm", "protected source") + protected, original_tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}") + tracked.write_text("substituted source\n") + git("commit", "-qam", "replacement source") + replacement = git("rev-parse", "HEAD") + git("reset", "--hard", protected) + git("replace", protected, replacement) + assert git("show", f"{protected}:source.txt") == "substituted source" + monkeypatch.setenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", "6f9619ff-8b86-4011-b42d-00c04fc964ff") + # Restore the process variable after testing the production binding. + monkeypatch.setenv("GIT_NO_REPLACE_OBJECTS", "0") + project = {} + module.bind_registered_legacy_request(project, "deploy") + assert module.validate_commit(tmp_path / ".git", "main", protected) == protected + assert module.deployment_metadata(tmp_path / ".git", protected, "jens/quiz-monitor")["tree"] == original_tree + assert git("show", f"{protected}:source.txt") == "protected original" + + +@pytest.mark.parametrize("mode,request_id", [("rollback", "6f9619ff-8b86-4011-b42d-00c04fc964ff"), + ("deploy", "invalid")]) +def test_invalid_registered_binding_cannot_change_git_reads(monkeypatch, mode, request_id): + module = _load_module("invalid_registered_git_binding", BROKER_ROOT / "deploy.py") + monkeypatch.setenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", request_id) + monkeypatch.delenv("GIT_NO_REPLACE_OBJECTS", raising=False) + with pytest.raises(module.DeployError, match="binding is invalid"): + module.bind_registered_legacy_request({}, mode) + assert "GIT_NO_REPLACE_OBJECTS" not in module.os.environ + + +def test_registered_compose_promotion_keeps_sealed_local_image(tmp_path, monkeypatch): + module = _load_module("registered_no_mutable_pull", BROKER_ROOT / "deploy.py") + project = {"source_dir": str(tmp_path), "_registered_legacy_request_id": + "6f9619ff-8b86-4011-b42d-00c04fc964ff"} + commands, events = [], [] + monkeypatch.setattr(module, "deployment_environment", lambda *_: {}) + monkeypatch.setattr(module, "prepare_registered_legacy_build", lambda *_: None) + monkeypatch.setattr(module, "compose_command", lambda *_: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr(module, "prepare_compose_migration", lambda *_: []) + monkeypatch.setattr(module, "workmesh_transition_enabled", lambda *_: False) + monkeypatch.setattr(module, "_registered_legacy_receipt_key", lambda: b"k" * 32) + monkeypatch.setattr(module, "_load_registered_legacy_adoption", lambda: SimpleNamespace( + prepare_candidate_deployment=lambda *_, **__: events.append("sealed"), + verify_candidate_pre_mutation=lambda *_, **__: events.append("checked"), + record_candidate_runtime=lambda *_, **__: events.append("observed"))) + + def run(command, **_): + commands.append(command) + if "up" in command: + assert events == ["sealed", "checked"] + + monkeypatch.setattr(module, "run", run) + module.deploy_project(project, "a" * 40, "jens/quiz-monitor") + assert commands[-1] == ["docker", "compose", "up", "--detach", "--no-build", "--pull", "never"] + assert events[-1] == "observed" diff --git a/backend/tests/test_registered_legacy_adoption.py b/backend/tests/test_registered_legacy_adoption.py new file mode 100644 index 0000000..4a5911f --- /dev/null +++ b/backend/tests/test_registered_legacy_adoption.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = ROOT / "ops" / "gitea-deploy-broker" / "registered_legacy_adoption.py" +REPOSITORY = "jens/quiz-monitor" +REQUEST_ID = "6f9619ff-8b86-4011-b42d-00c04fc964ff" +TARGET_SHA = "a" * 40 +KEY = b"k" * 32 + + +def _load_module(): + spec = importlib.util.spec_from_file_location("registered_legacy_adoption_test", MODULE_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def module(): + return _load_module() + + +@pytest.fixture +def project(): + return { + "slug": "quiz-monitor", + "branch": "main", + "compose_project": "quiz-monitor", + "compose_files": ["docker-compose.yml"], + "containers": ["quiz-monitor-quiz-server-1"], + "source_dir": "/srv/example/appdata/quiz-monitor/source", + "verify_oci_revision": True, + } + + +@pytest.fixture +def baseline_inputs(): + state_bytes = b'{"schema_version":3,"live_revision":"0c9c1747","live_status":"unknown"}\n' + image_id = "sha256:" + "a" * 64 + observation = { + "sourceFingerprint": "1" * 64, + "runtimeFingerprint": "2" * 64, + "imageIDs": {"quiz-monitor-quiz-server-1": image_id}, + "runtimeConfiguration": { + "environment": {"OPENRGB_HOST": "198.19.1.2", "OPENRGB_TOKEN": "private-test-value"}, + "composeModel": {"services": {"quiz-server": {"ports": ["1205:8000"]}}}, + }, + "artifacts": { + "containers": { + "quiz-monitor-quiz-server-1": { + "image_id": image_id, + "container_id": "b" * 64, + "image_ref": "quiz-monitor:latest", + "service": "quiz-server", + "running": True, + } + } + }, + } + return state_bytes, observation + + +class _Runtime: + def __init__(self, observation): + self.observation = observation + self.restored = [] + self.snapshots = [] + + def observe(self, repository, target, *, receipt_key): + assert repository == REPOSITORY + assert target["slug"] == "quiz-monitor" + assert receipt_key == KEY + return copy.deepcopy(self.observation) + + def snapshot(self, repository, target, observation, request_id, *, receipt_key): + value = { + "revision": "unknown", + "legacy_provenance": True, + "containers": [ + { + "name": "quiz-monitor-quiz-server-1", + "image_id": observation["imageIDs"]["quiz-monitor-quiz-server-1"], + "image_ref": "quiz-monitor:latest", + "rollback_ref": "gitea-deploy-rollback/quiz-monitor-0:legacy-6f9619ff", + "was_running": True, + } + ], + } + self.snapshots.append(value) + return copy.deepcopy(value) + + def restore(self, repository, target, snapshot, current_revision, *, receipt_key): + self.restored.append((repository, target["slug"], snapshot, current_revision)) + return copy.deepcopy(self.observation) + + +@pytest.fixture +def api(): + def api_json(path, *, query): + if path == f"/repos/{REPOSITORY}/branches/main": + return {"protected": True, "commit": {"id": TARGET_SHA}} + if path == f"/repos/{REPOSITORY}/actions/runs": + assert query == { + "event": "push", "branch": "main", "head_sha": TARGET_SHA, + "page": "1", "limit": "100", + } + return {"total_count": 1, "workflow_runs": [{ + "id": 7, "run_number": 19, "run_attempt": 1, + "event": "push", "head_sha": TARGET_SHA, "head_branch": "main", + "path": "managed-validation.yml@refs/heads/main", + "display_title": "Managed validation / full (push)", + "created_at": "2026-10-07T12:00:00Z", "status": "completed", + "conclusion": "success", "repository": {"full_name": REPOSITORY}, + "head_repository": {"full_name": REPOSITORY}, "pull_requests": [], + }]} + if path == f"/repos/{REPOSITORY}/actions/runs/7/jobs": + assert query == {"page": "1", "limit": "100"} + return {"total_count": 1, "jobs": [{ + "id": 9, "name": "full", "run_id": 7, "run_attempt": 1, + "head_sha": TARGET_SHA, "head_branch": "main", + "status": "completed", "conclusion": "success", + }]} + raise AssertionError(f"unexpected Gitea read: {path}") + return api_json + + +def _read_state(state_bytes): + state = json.loads(state_bytes.decode("utf-8")) if state_bytes else {} + return lambda _repository, _project: (state, state_bytes) + + +def _baseline(module, project, state_bytes, observation, tmp_path): + (tmp_path / "quiz-monitor.json").write_bytes(state_bytes) + return module.baseline( + {"repository": REPOSITORY, "requestId": REQUEST_ID}, + registry={REPOSITORY: project}, + read_state=_read_state(state_bytes), + runtime=_Runtime(observation), receipt_key=KEY, state_root=tmp_path, + ) + + +def test_registered_target_policy_accepts_bounded_main_compose_and_rejects_special_targets( + module, project +): + assert module.validate_registered_legacy_target(REPOSITORY, project) == "quiz-monitor" + for repository, mutate in [ + ("jens/unraid-appops-gateway", lambda candidate: None), + ("jens/workmesh", lambda candidate: None), + (REPOSITORY, lambda candidate: candidate.update(managed_plan_version="itworx.deployment/v1")), + (REPOSITORY, lambda candidate: candidate.update(deploy_script="scripts/deploy.sh")), + (REPOSITORY, lambda candidate: candidate.update(containers=[f"quiz-monitor-svc-{n}" for n in range(9)])), + (REPOSITORY, lambda candidate: candidate.update(compose_files=["../other/docker-compose.yml"])), + ]: + candidate = copy.deepcopy(project) + mutate(candidate) + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.validate_registered_legacy_target(repository, candidate) + + +def test_rendered_compose_must_match_observed_environment_mounts_and_published_ports(module): + model = {"services": {"quiz-server": { + "environment": {"OPENRGB_HOST": "198.19.1.2"}, + "volumes": [{"type": "bind", "source": "/srv/example/appdata/quiz-monitor/config", + "target": "/config", "read_only": False}], + "ports": [{"target": 8000, "published": "1205", "protocol": "tcp", + "host_ip": "0.0.0.0"}], + }}, "networks": {"default": {"name": "quiz-monitor_default"}}} + container = {"quiz-monitor-quiz-server-1": { + "Config": {"Env": ["OPENRGB_HOST=198.19.1.2"]}, + "Mounts": [{"Type": "bind", "Source": "/srv/example/appdata/quiz-monitor/config", + "Destination": "/config", "RW": True}], + "HostConfig": {"NetworkMode": "quiz-monitor_default"}, + "NetworkSettings": {"Networks": {"quiz-monitor_default": {}}, + "Ports": {"8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "1205"}]}}, + }} + module.validate_compose_runtime(model, container) + wrong = copy.deepcopy(container) + wrong["quiz-monitor-quiz-server-1"]["NetworkSettings"]["Ports"]["8000/tcp"][0]["HostPort"] = "1206" + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.validate_compose_runtime(model, wrong) + + +def test_rendered_compose_rejects_changed_command_security_and_named_volume_identity(module): + model = {"services": {"quiz-server": { + "image": "quiz-monitor:latest", "command": ["python", "server.py"], + "security_opt": ["no-new-privileges:true"], + "volumes": [{"type": "volume", "source": "quiz-config", "target": "/config"}], + }}, "volumes": {"quiz-config": {"name": "quiz-monitor-config"}}, + "networks": {"default": {"name": "quiz-monitor_default"}}} + original = {"quiz-monitor-quiz-server-1": { + "Config": {"Cmd": ["python", "server.py"], "Labels": {"com.docker.compose.service": "quiz-server"}}, + "HostConfig": {"SecurityOpt": ["no-new-privileges:true"], "NetworkMode": "quiz-monitor_default"}, + "Mounts": [{"Type": "volume", "Name": "quiz-monitor-config", "Destination": "/config", "RW": True}], + "NetworkSettings": {"Networks": {"quiz-monitor_default": {}}, "Ports": {}}, + }} + module.validate_compose_runtime(model, original, compose_project="quiz-monitor") + changed = copy.deepcopy(original) + changed["quiz-monitor-quiz-server-1"]["Config"]["Cmd"] = ["sh", "-c", "unexpected"] + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.validate_compose_runtime(model, changed, compose_project="quiz-monitor") + changed = copy.deepcopy(original) + changed["quiz-monitor-quiz-server-1"]["HostConfig"]["SecurityOpt"] = [] + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.validate_compose_runtime(model, changed, compose_project="quiz-monitor") + changed = copy.deepcopy(original) + changed["quiz-monitor-quiz-server-1"]["Mounts"][0]["Name"] = "unexpected-volume" + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.validate_compose_runtime(model, changed, compose_project="quiz-monitor") + + +def test_baseline_binds_exact_runtime_state_without_revealing_stale_sha_or_secrets( + module, project, baseline_inputs, tmp_path +): + state_bytes, observation = baseline_inputs + result = _baseline(module, project, state_bytes, observation, tmp_path) + + assert result["previousRevision"] == "unknown" + assert result["expectedStateSha256"] == hashlib.sha256(state_bytes).hexdigest() + assert result["runtimeFingerprint"] == "2" * 64 + assert result["imageIDs"] == observation["imageIDs"] + assert "0c9c1747" not in json.dumps(result) + assert "private-test-value" not in json.dumps(result) + root = tmp_path / "registered-legacy-adoption" / "quiz-monitor" + assert (root / f"{REQUEST_ID}.baseline.json").is_file() + private = root / f"{REQUEST_ID}.private.json" + assert private.is_file() + assert "private-test-value" not in (root / f"{REQUEST_ID}.baseline.json").read_text() + + +def test_preflight_requires_latest_exact_main_push_and_registered_full_job( + module, project, baseline_inputs, api, tmp_path +): + state_bytes, observation = baseline_inputs + baseline = _baseline(module, project, state_bytes, observation, tmp_path) + result = module.preflight( + {"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID, + "expectedStateSha256": baseline["expectedStateSha256"]}, + registry={REPOSITORY: project}, read_state=_read_state(state_bytes), + runtime=_Runtime(observation), api_json=api, receipt_key=KEY, state_root=tmp_path, + ) + assert result["status"] == "preflight-ok" + assert result["revision"] == TARGET_SHA + assert result["validationEvidenceSha256"] + assert result["preflightSha256"] + + +@pytest.mark.parametrize("invalid", ["pr_event", "wrong_sha", "failed_latest", "missing_full_job", "unprotected"]) +def test_preflight_rejects_noncanonical_or_failed_latest_validation( + module, project, baseline_inputs, api, tmp_path, invalid +): + state_bytes, observation = baseline_inputs + baseline = _baseline(module, project, state_bytes, observation, tmp_path) + + def bad_api(path, *, query): + value = api(path, query=query) + if path.endswith("/actions/runs"): + run = value["workflow_runs"][0] + if invalid == "pr_event": + run["event"] = "pull_request" + elif invalid == "wrong_sha": + run["head_sha"] = "b" * 40 + elif invalid == "failed_latest": + run["run_attempt"] = 2 + run["conclusion"] = "failure" + elif path.endswith("/branches/main") and invalid == "unprotected": + value["protected"] = False + elif path.endswith("/jobs") and invalid == "missing_full_job": + value["jobs"][0]["name"] = "backend" + return value + + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.preflight( + {"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID, + "expectedStateSha256": baseline["expectedStateSha256"]}, + registry={REPOSITORY: project}, read_state=_read_state(state_bytes), + runtime=_Runtime(observation), api_json=bad_api, receipt_key=KEY, state_root=tmp_path, + ) + + +def test_adoption_rechecks_gate_and_cas_then_saves_unknown_rollback_proof( + module, project, baseline_inputs, api, tmp_path +): + state_bytes, observation = baseline_inputs + (tmp_path / "quiz-monitor.json").write_bytes(state_bytes) + runtime = _Runtime(observation) + registry = {REPOSITORY: project} + read_state = _read_state(state_bytes) + baseline = module.baseline( + {"repository": REPOSITORY, "requestId": REQUEST_ID}, registry=registry, + read_state=read_state, runtime=runtime, receipt_key=KEY, state_root=tmp_path, + ) + preflight = module.preflight( + {"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID, + "expectedStateSha256": baseline["expectedStateSha256"]}, + registry=registry, read_state=read_state, runtime=runtime, api_json=api, + receipt_key=KEY, state_root=tmp_path, + ) + # Adoption independently rereads main and validation rather than trusting the saved preflight. + calls = [] + + def rechecked_api(path, *, query): + calls.append(path) + return api(path, query=query) + + result = module.adopt( + {"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID, + "expectedStateSha256": baseline["expectedStateSha256"], + "preflightSha256": preflight["preflightSha256"]}, + registry=registry, read_state=read_state, runtime=runtime, api_json=rechecked_api, + receipt_key=KEY, state_root=tmp_path, + ) + assert result["status"] == "adopted" + assert result["previousRevision"] == "unknown" + assert any(path.endswith("/actions/runs") for path in calls) + receipt = tmp_path / "registered-legacy-adoption" / "quiz-monitor" / f"{REQUEST_ID}.receipt.json" + assert receipt.is_file() + assert "private-test-value" not in receipt.read_text() + + snapshot = module.capture_deployment_snapshot( + REPOSITORY, project, json.loads(state_bytes), request_id=REQUEST_ID, + target_revision=TARGET_SHA, receipt_key=KEY, state_root=tmp_path, runtime=runtime, + ) + assert snapshot["revision"] == "unknown" + assert snapshot["legacy_provenance"] is True + assert snapshot["containers"][0]["image_id"] == observation["imageIDs"]["quiz-monitor-quiz-server-1"] + assert snapshot["registered_legacy"]["snapshotSha256"] + assert module.status( + {"repository": REPOSITORY, "requestId": REQUEST_ID}, registry=registry, + read_state=read_state, receipt_key=KEY, state_root=tmp_path, + )["status"] == "adopted" + + +def test_adoption_rejects_a_changed_controller_state_before_snapshot( + module, project, baseline_inputs, api, tmp_path +): + state_bytes, observation = baseline_inputs + current_bytes = [state_bytes] + def read_state(_repository, _project): + return json.loads(current_bytes[0].decode("utf-8")), current_bytes[0] + runtime = _Runtime(observation) + registry = {REPOSITORY: project} + baseline = module.baseline( + {"repository": REPOSITORY, "requestId": REQUEST_ID}, registry=registry, + read_state=read_state, runtime=runtime, receipt_key=KEY, state_root=tmp_path, + ) + preflight = module.preflight( + {"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID, + "expectedStateSha256": baseline["expectedStateSha256"]}, + registry=registry, read_state=read_state, runtime=runtime, api_json=api, + receipt_key=KEY, state_root=tmp_path, + ) + current_bytes[0] += b" " + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.adopt( + {"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID, + "expectedStateSha256": baseline["expectedStateSha256"], + "preflightSha256": preflight["preflightSha256"]}, + registry=registry, read_state=read_state, runtime=runtime, api_json=api, + receipt_key=KEY, state_root=tmp_path, + ) + assert not (tmp_path / "registered-legacy-adoption" / "quiz-monitor" / f"{REQUEST_ID}.receipt.json").exists() + + +def test_status_is_nonblocking_and_worker_failure_is_durable_and_terminal_safe( + module, project, baseline_inputs, tmp_path +): + state_bytes, observation = baseline_inputs + _baseline(module, project, state_bytes, observation, tmp_path) + pending = module.record_worker_failure( + REPOSITORY, REQUEST_ID, "worker_start_failed", receipt_key=KEY, state_root=tmp_path, + ) + assert pending["status"] == "recovery-required" + assert module.record_worker_failure( + REPOSITORY, REQUEST_ID, "second_failure", receipt_key=KEY, state_root=tmp_path, + )["status"] == "recovery-required" + assert module.status( + {"repository": REPOSITORY, "requestId": REQUEST_ID}, + registry={REPOSITORY: project}, read_state=_read_state(state_bytes), + receipt_key=KEY, state_root=tmp_path, + )["status"] == "recovery-required" diff --git a/backend/tests/test_registered_legacy_http.py b/backend/tests/test_registered_legacy_http.py new file mode 100644 index 0000000..4a1b49c --- /dev/null +++ b/backend/tests/test_registered_legacy_http.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import sys +from types import SimpleNamespace + +import pytest +from test_hub_component_transport import call +from test_hub_component_transport import controller as _controller_fixture + +REPO = "jens/quiz-monitor" +REQUEST = "11111111-1111-4111-8111-111111111111" +PREFIX = "/v1/appops/registered-legacy/" + + +@pytest.fixture +def controller(monkeypatch): + return _controller_fixture.__wrapped__(monkeypatch) + + +@pytest.fixture +def adoption(monkeypatch): + observations = [] + def status(payload, **_kwargs): + return {"status": "adopted", "repository": payload["repository"], "requestId": payload["requestId"], + "revision": "a" * 40, "expectedStateSha256": "b" * 64} + fake = SimpleNamespace(RegisteredLegacyAdoptionError=ValueError, read_registered_state=lambda *_a: ({}, None), + status=status, baseline=lambda payload, **_k: {"status": "ready", **payload}, + accept_rollback=lambda payload, **_k: {"status": "rollback_pending", **payload}, + record_worker_failure=lambda *args, **_k: observations.append(args)) + monkeypatch.setitem(sys.modules, "registered_legacy_adoption", fake) + return fake, observations + + +def invoke(module, stage, payload): + return call(module, PREFIX + stage, payload, "_handle_registered_legacy") + + +def rollback_payload(): + return {"repository": REPO, "requestId": REQUEST, "revision": "a" * 40, "expectedStateSha256": "b" * 64} + + +def test_generic_authentication_rejects_before_helper_loading(controller, monkeypatch): + module, _ = controller + monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False) + assert invoke(module, "adopt", {"image": "attacker/image"})[0] == 403 + + +@pytest.mark.parametrize("change", [{"repository": "Jens/quiz-monitor"}, {"repository": "../escape"}, + {"requestId": "bad"}, {"requestId": 123}]) +def test_generic_envelope_rejects_invalid_identity_before_helpers(controller, adoption, change): + module, _ = controller + assert invoke(module, "baseline", {"repository": REPO, "requestId": REQUEST, **change})[0] == 400 + + +def test_delivery_requires_adopted_exact_target_and_preserves_original_revalidation(controller, adoption, monkeypatch): + module, _ = controller + called = [] + monkeypatch.setattr(module, "_request_terminal_revalidation", lambda *args, **kwargs: called.append((args, kwargs)) or {"status": "awaiting_validation"}) + payload = {"repository": REPO, "requestId": REQUEST, "revision": "a" * 40} + assert invoke(module, "delivery", {**payload, "revision": "c" * 40})[1]["reason"] == "registered_delivery_adoption_required" + assert invoke(module, "delivery", {**payload, "image": "caller/image"})[1]["reason"] == "registered_delivery_contract_invalid" + assert not called + assert invoke(module, "delivery", payload)[1]["status"] == "awaiting_validation" + assert called == [((REPO, "a" * 40), {"registered_legacy_request_id": REQUEST})] + + +def test_only_internal_adopted_retry_binds_request_to_deployer_environment(controller, monkeypatch): + module, _ = controller + called = [] + monkeypatch.setenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", "inherited-untrusted-value") + monkeypatch.setattr(module.subprocess, "run", lambda args, **kwargs: called.append((args, kwargs)) or SimpleNamespace(returncode=0)) + module._run_registered_deploy(REPO, "a" * 40) + assert "APPOPS_REGISTERED_LEGACY_REQUEST_ID" not in called[-1][1]["env"] + module._run_registered_deploy(REPO, "a" * 40, registered_legacy_request_id=REQUEST) + assert called[-1][1]["env"]["APPOPS_REGISTERED_LEGACY_REQUEST_ID"] == REQUEST + assert REQUEST not in called[-1][0] + with pytest.raises(ValueError): + module._run_registered_deploy(REPO, "a" * 40, registered_legacy_request_id="bad") + with pytest.raises(ValueError): + module._run_registered_deploy(module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40, registered_legacy_request_id=REQUEST) + + +def test_rollback_queue_is_bounded_idempotent_and_rejects_changed_binding(controller, adoption, monkeypatch): + module, _ = controller + starts = [] + monkeypatch.setattr(module.threading, "Thread", lambda **kwargs: SimpleNamespace(start=lambda: starts.append(kwargs))) + payload = rollback_payload() + assert invoke(module, "rollback", payload)[0] == 202 + assert invoke(module, "rollback", payload)[0] == 200 + assert len(starts) == 1 + assert invoke(module, "rollback", {**payload, "expectedStateSha256": "c" * 64})[1]["reason"] == "registered_rollback_binding_changed" + assert invoke(module, "rollback", {**payload, "requestId": "22222222-2222-4222-8222-222222222222"})[1]["reason"] == "registered_rollback_capacity_busy" + + +def test_rollback_start_failure_is_recorded_and_capacity_released(controller, adoption, monkeypatch): + module, _ = controller + _, observations = adoption + def fail_start(**_kwargs): + raise RuntimeError("thread start unavailable") + monkeypatch.setattr(module.threading, "Thread", fail_start) + assert invoke(module, "rollback", rollback_payload())[1]["reason"] == "registered_rollback_worker_start_failed" + assert observations == [(REPO, REQUEST, "registered_rollback_worker_start_failed")] + assert not module._registered_legacy_worker_requests + + +def test_worker_never_discloses_raw_failure_and_releases_capacity(controller, adoption): + module, _ = controller + fake, observations = adoption + def fail(*_args, **_kwargs): + raise RuntimeError("private environment credential") + fake.rollback = fail + module._registered_legacy_worker_requests.add(REPO + "/" + REQUEST) + module._run_registered_legacy_rollback(rollback_payload(), b"x" * 64) + assert observations == [(REPO, REQUEST, "registered_rollback_worker_failed")] + assert not module._registered_legacy_worker_requests + + +def test_failed_delivery_retry_requires_explicit_adoption_and_keeps_original_record(controller, monkeypatch, tmp_path): + module, _ = controller + revision = "a" * 40 + semantic = module._webhook_semantic_digest(6, "refs/heads/main", revision) + original = {"schema": module._WEBHOOK_SCHEMA, "semantic_sha256": semantic, + "gitea_repository_id": 6, "ref": "refs/heads/main", "repository": REPO, + "revision": revision, "body_sha256": "b" * 64, "validation": {}, + "status": "failed", "reason": "deployment_failed"} + written = [] + monkeypatch.setattr(module, "WEBHOOK_INBOX_ROOT", tmp_path) + monkeypatch.setattr(module, "_secure_inbox_root", lambda: None) + monkeypatch.setattr(module, "_webhook_policy", lambda *_a: (6, "main", "fixed-secret-file")) + monkeypatch.setattr(module, "_validation_policy", lambda *_a: {}) + monkeypatch.setattr(module, "_validation_snapshot_matches", lambda *_a: True) + monkeypatch.setattr(module, "_validation_snapshot", lambda *_a: {"required": True}) + monkeypatch.setattr(module, "_read_inbox_record", lambda _p: original) + monkeypatch.setattr(module, "_is_current_branch_head", lambda *_a: True) + monkeypatch.setattr(module, "_write_new_inbox_record", lambda path, record: written.append((path, record))) + assert module._enqueue_terminal_revalidation(REPO, revision, {})[0] == "not_revalidatable" + assert module._enqueue_terminal_revalidation(REPO, revision, {}, registered_legacy_request_id="bad")[0] == "invalid" + assert not written + assert module._enqueue_terminal_revalidation(REPO, revision, {}, registered_legacy_request_id=REQUEST)[0] == "awaiting_validation" + path, record = written[0] + assert path.name == semantic + "." + REQUEST + ".revalidation.json" + assert record["registered_legacy_request_id"] == REQUEST + assert record["validation"] == {"required": True} + assert original["status"] == "failed" and original["reason"] == "deployment_failed" + + +def test_webhook_cannot_inject_registered_adoption_binding(controller, monkeypatch, tmp_path): + module, _ = controller + monkeypatch.setattr(module, "_read_inbox_record", lambda _p: { + "schema": module._WEBHOOK_SCHEMA, "status": "validation_passed", + "registered_legacy_request_id": REQUEST, + }) + monkeypatch.setattr(module, "_load_projects", lambda: pytest.fail("injected webhook must be rejected before processing")) + module._process_webhook_record(tmp_path / "a.json") diff --git a/backend/tests/test_registered_legacy_runtime.py b/backend/tests/test_registered_legacy_runtime.py new file mode 100644 index 0000000..f5a3dcd --- /dev/null +++ b/backend/tests/test_registered_legacy_runtime.py @@ -0,0 +1,1184 @@ +from __future__ import annotations + +import copy +import importlib.util +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = ROOT / "ops" / "gitea-deploy-broker" / "registered_legacy_adoption.py" +REPOSITORY = "jens/quiz-monitor" +REQUEST_ID = "6f9619ff-8b86-4011-b42d-00c04fc964ff" +TARGET_SHA = "a" * 40 +BASELINE_IMAGE = "sha256:" + "b" * 64 +CANDIDATE_IMAGE = "sha256:" + "c" * 64 +CONTAINER_NAME = "quiz-monitor-quiz-server-1" +KEY = b"k" * 32 +CONFIG_BIND = "/srv/example/appdata/quiz-monitor/config" +TEMPLATE_VALUE = "keep literal $DOLLAR and ${BRACED}" +QUIZ_IMAGE_CMD = [ + "uvicorn", + "server:app", + "--host", + "0.0.0.0", + "--port", + "8000", +] + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "registered_legacy_runtime_test", MODULE_PATH + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def module(): + return _load_module() + + +def _project(source: Path) -> dict[str, object]: + return { + "slug": "quiz-monitor", + "branch": "main", + "compose_project": "quiz-monitor", + "compose_files": ["docker-compose.yml"], + "containers": [CONTAINER_NAME], + "source_dir": str(source), + "verify_oci_revision": True, + } + + +def _compose_model() -> dict[str, object]: + return { + "name": "quiz-monitor", + "services": { + "quiz-server": { + "image": "quiz-monitor:latest", + "command": None, + "entrypoint": None, + "environment": { + "OPENRGB_HOST": "198.19.1.2", + "QUIZ_TEMPLATE": TEMPLATE_VALUE, + }, + "volumes": [ + { + "type": "bind", + "source": CONFIG_BIND, + "target": "/config", + "read_only": True, + }, + { + "type": "volume", + "source": "quiz-monitor-data", + "target": "/data", + "read_only": False, + }, + ], + "ports": [ + { + "target": 8000, + "published": "1205", + "protocol": "tcp", + "host_ip": "0.0.0.0", + } + ], + "networks": {"default": None}, + "labels": {"org.example.app": "quiz-monitor"}, + "cap_drop": ["ALL"], + "security_opt": ["no-new-privileges:true"], + "read_only": True, + "mem_limit": "536870912", + "cpus": "0.5", + "restart": "unless-stopped", + } + }, + "networks": {"default": {"name": "quiz-monitor_default"}}, + "volumes": {"quiz-monitor-data": {"name": "quiz-monitor_quiz-monitor-data"}}, + } + + +def _container( + *, + image_id: str = BASELINE_IMAGE, + container_id: str = "d" * 64, + hostname: str = "a1b2c3d4e5f6", + config_hash: str = "old-config-hash", + environment: str = TEMPLATE_VALUE, + config_bind: str = CONFIG_BIND, + host_port: str = "1205", + security_opt: list[str] | None = None, + memory: int = 536870912, + network_mode: str = "quiz-monitor_default", +) -> dict[str, object]: + labels = { + "com.docker.compose.project": "quiz-monitor", + "com.docker.compose.service": "quiz-server", + "com.docker.compose.container-number": "1", + "com.docker.compose.config-hash": config_hash, + "com.docker.compose.project.config_files": "/srv/example/appdata/quiz-monitor/source/docker-compose.yml", + "com.docker.compose.project.working_dir": "/srv/example/appdata/quiz-monitor/source", + "com.docker.compose.version": "2.39.1", + "org.example.app": "quiz-monitor", + } + return { + "Id": container_id, + "Image": image_id, + "Config": { + "Hostname": hostname, + "Image": "quiz-monitor:latest", + "Env": ["OPENRGB_HOST=198.19.1.2", f"QUIZ_TEMPLATE={environment}"], + "Labels": labels, + "ExposedPorts": {"8000/tcp": {}}, + "WorkingDir": "/app", + "Cmd": list(QUIZ_IMAGE_CMD), + "Entrypoint": None, + }, + "HostConfig": { + "Binds": [f"{config_bind}:/config:ro"], + "NetworkMode": network_mode, + "PortBindings": { + "8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": host_port}] + }, + "RestartPolicy": {"Name": "unless-stopped", "MaximumRetryCount": 0}, + "CapDrop": ["ALL"], + "SecurityOpt": list(security_opt or ["no-new-privileges:true"]), + "ReadonlyRootfs": True, + "Memory": memory, + "NanoCpus": 500000000, + "ShmSize": 67108864, + }, + "Mounts": [ + { + "Type": "bind", + "Source": config_bind, + "Destination": "/config", + "RW": False, + }, + { + "Type": "volume", + "Name": "quiz-monitor_quiz-monitor-data", + "Source": "/var/lib/docker/volumes/quiz-monitor_quiz-monitor-data/_data", + "Destination": "/data", + "RW": True, + }, + ], + "NetworkSettings": { + "Ports": {"8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": host_port}]}, + "Networks": {"quiz-monitor_default": {"NetworkID": "e" * 64}}, + }, + "State": {"Running": True}, + } + + +class _DockerComposeBoundary: + """Mock the bounded subprocess boundary while exercising the real adapter.""" + + def __init__(self, module, project, model): + self.module, self.project, self.model = module, project, model + self.live = _container() + self.images = { + BASELINE_IMAGE: { + "Id": BASELINE_IMAGE, + "Config": { + "Labels": {}, + "Cmd": list(QUIZ_IMAGE_CMD), + "Entrypoint": None, + "WorkingDir": "/app", + }, + }, + CANDIDATE_IMAGE: { + "Id": CANDIDATE_IMAGE, + "Config": { + "Labels": { + "org.opencontainers.image.revision": TARGET_SHA, + "org.opencontainers.image.source": REPOSITORY, + }, + "Cmd": list(QUIZ_IMAGE_CMD), + "Entrypoint": None, + "WorkingDir": "/app", + }, + }, + } + self.commands: list[list[str]] = [] + self.compose_config_commands: list[list[str]] = [] + self.compose_ups = 0 + self.source = Path(project["source_dir"]) + + def install(self, monkeypatch): + def compose_config(command, **_kwargs): + self.compose_config_commands.append(list(command)) + if command[-3:] == ["config", "--format", "json"]: + output = json.dumps(self.model) + elif command[-2:] == ["config", "--images"]: + output = "quiz-monitor:latest\n" + else: + raise AssertionError(f"unexpected Compose config command: {command}") + return subprocess.CompletedProcess(command, 0, stdout=output) + + monkeypatch.setattr(self.module.subprocess, "run", compose_config) + + def docker_run(command, *, cwd=None, timeout=45, env=None): + del cwd, timeout, env + self.commands.append(list(command)) + if command[:2] == ["docker", "ps"]: + return CONTAINER_NAME + "\n" + if command[:2] == ["docker", "inspect"]: + return json.dumps([copy.deepcopy(self.live)]) + if command[:3] == ["docker", "image", "inspect"]: + image = self.images.get(command[3]) + return json.dumps([copy.deepcopy(image)]) if image else "[]" + if command[:3] == ["docker", "image", "tag"]: + image = self.images.get(command[3]) + if image is None: + raise AssertionError("tag source image was not inspected") + self.images[command[4]] = copy.deepcopy(image) + return "" + if command[:3] == ["docker", "compose", "--project-name"]: + if command[-3:] == ["config", "--format", "json"]: + return json.dumps(self.model) + if command[-2:] == ["config", "--images"]: + return "quiz-monitor:latest\n" + if ( + command[:3] == ["docker", "compose", "--project-name"] + and command[-1] == "--force-recreate" + ): + self.compose_ups += 1 + rollback_file = Path(command[command.index("--file") + 1]) + model = json.loads(rollback_file.read_text(encoding="utf-8")) + service = model["services"]["quiz-server"] + assert service["image"].startswith("gitea-deploy-rollback/") + assert service["environment"][ + "QUIZ_TEMPLATE" + ] == TEMPLATE_VALUE.replace("$", "$$") + self.live = _container( + container_id="f" * 64, + hostname="new-generated-hostname", + config_hash="new-config-hash", + ) + self.live["Config"]["Image"] = service["image"] + return "" + raise AssertionError(f"unexpected Docker command: {command}") + + monkeypatch.setattr( + self.module._ProductionRuntime, "_run", staticmethod(docker_run) + ) + self.images["quiz-monitor:latest"] = copy.deepcopy(self.images[CANDIDATE_IMAGE]) + + +@pytest.fixture +def production_runtime(module, monkeypatch, tmp_path): + source = tmp_path / "quiz-source" + source.mkdir() + (source / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8") + project = _project(source) + model = _compose_model() + boundary = _DockerComposeBoundary(module, project, model) + boundary.install(monkeypatch) + return module._ProductionRuntime(tmp_path / "state"), boundary, project, model + + +def test_production_observe_binds_rendered_model_and_runtime_contract( + production_runtime, +): + runtime, boundary, project, model = production_runtime + observed = runtime.observe(REPOSITORY, project, receipt_key=KEY) + contract = runtime._runtime_contract(boundary.live) + effective_model = copy.deepcopy(model) + effective_model["services"]["quiz-server"]["logging"] = { + "driver": "json-file", + "options": {}, + } + + assert observed["runtimeConfiguration"]["composeModel"] == effective_model + assert ( + observed["runtimeConfiguration"]["containers"][CONTAINER_NAME] == boundary.live + ) + assert observed["imageIDs"] == {CONTAINER_NAME: BASELINE_IMAGE} + assert contract["config"]["Env"] == [ + "OPENRGB_HOST=198.19.1.2", + f"QUIZ_TEMPLATE={TEMPLATE_VALUE}", + ] + assert contract["mounts"] == [ + ("bind", CONFIG_BIND, "/config", False, ""), + ( + "volume", + "/var/lib/docker/volumes/quiz-monitor_quiz-monitor-data/_data", + "/data", + True, + "quiz-monitor_quiz-monitor-data", + ), + ] + assert contract["ports"] == { + "8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "1205"}], + } + assert contract["host"]["NetworkMode"] == "quiz-monitor_default" + assert contract["host"]["SecurityOpt"] == ["no-new-privileges:true"] + assert contract["host"]["CapDrop"] == ["ALL"] + assert contract["host"]["ReadonlyRootfs"] is True + assert contract["host"]["Memory"] == 536870912 + assert contract["host"]["NanoCpus"] == 500000000 + assert contract["host"]["ShmSize"] == 67108864 + assert contract["config"]["Cmd"] == QUIZ_IMAGE_CMD + assert contract["config"]["Entrypoint"] is None + assert model["services"]["quiz-server"]["command"] is None + assert model["services"]["quiz-server"]["entrypoint"] is None + assert "Hostname" not in contract["config"] + assert "com.docker.compose.config-hash" not in contract["config"]["Labels"] + + recreated = _container( + container_id="f" * 64, + hostname="regenerated-hostname", + config_hash="regenerated-config-hash", + ) + recreated["Config"]["Labels"].update( + { + "com.docker.compose.container-number": "7", + "com.docker.compose.project.config_files": "/staged/release/docker-compose.yml", + "com.docker.compose.project.working_dir": "/staged/release", + "com.docker.compose.version": "2.40.0", + } + ) + assert runtime._runtime_contract(recreated) == contract + + +@pytest.mark.parametrize( + "change", + [ + lambda item: item["Config"]["Env"].__setitem__(1, "QUIZ_TEMPLATE=drift"), + lambda item: item["Mounts"][0].__setitem__("Source", "/other/config"), + lambda item: item["NetworkSettings"]["Ports"]["8000/tcp"][0].__setitem__( + "HostPort", "1206" + ), + lambda item: item["HostConfig"]["SecurityOpt"].clear(), + lambda item: item["HostConfig"].__setitem__("Memory", 1073741824), + lambda item: item["HostConfig"].__setitem__("NetworkMode", "host"), + lambda item: item["NetworkSettings"]["Networks"].__setitem__( + "rogue", {"NetworkID": "9" * 64} + ), + ], +) +def test_production_observe_rejects_runtime_drift_before_baseline_capture( + production_runtime, change, module +): + runtime, boundary, project, _model = production_runtime + change(boundary.live) + + with pytest.raises(module.RegisteredLegacyAdoptionError): + runtime.observe(REPOSITORY, project, receipt_key=KEY) + + assert boundary.compose_ups == 0 + assert not any( + command[:3] == ["docker", "image", "tag"] for command in boundary.commands + ) + + +@pytest.mark.parametrize( + ("compose_option", "host_option", "unsafe_value"), + [ + ("privileged", "Privileged", True), + ("security_opt", "SecurityOpt", ["seccomp=unconfined"]), + ("cap_add", "CapAdd", ["SYS_ADMIN"]), + ], +) +def test_production_observe_rejects_unsafe_host_options_omitted_from_compose( + production_runtime, compose_option, host_option, unsafe_value, module +): + runtime, boundary, project, _model = production_runtime + service = boundary.model["services"]["quiz-server"] + service.pop(compose_option, None) + boundary.live["HostConfig"][host_option] = unsafe_value + + with pytest.raises(module.RegisteredLegacyAdoptionError): + runtime.observe(REPOSITORY, project, receipt_key=KEY) + + assert boundary.compose_ups == 0 + assert not any( + command[:3] == ["docker", "image", "tag"] for command in boundary.commands + ) + + +@pytest.mark.parametrize( + ("inspect_key", "unsafe_value"), + [ + ("Cmd", ["/bin/sh", "-c", "exit 0"]), + ("Entrypoint", ["/bin/sh"]), + ], +) +def test_production_observe_rejects_process_args_that_differ_from_image_defaults( + production_runtime, inspect_key, unsafe_value, module +): + runtime, boundary, project, _model = production_runtime + boundary.live["Config"][inspect_key] = unsafe_value + + with pytest.raises(module.RegisteredLegacyAdoptionError): + runtime.observe(REPOSITORY, project, receipt_key=KEY) + + assert boundary.compose_ups == 0 + + +def test_production_snapshot_restore_preserves_literal_env_mount_ports_and_known_image( + adopted_production_runtime, tmp_path +): + ( + runtime, + boundary, + project, + _model, + registry, + _read_state, + state_bytes, + snapshot, + ) = adopted_production_runtime + module = boundary.module + prior_contract = runtime._runtime_contract(boundary.live) + prior_mounts = copy.deepcopy(boundary.live["Mounts"]) + prior_ports = copy.deepcopy(boundary.live["NetworkSettings"]["Ports"]) + prior_environment = copy.deepcopy(boundary.live["Config"]["Env"]) + live, _controller_state = _candidate_deployment( + module, runtime, boundary, project, registry, state_bytes, tmp_path + ) + boundary.commands.clear() + + result = module.restore_deployment_snapshot( + REPOSITORY, + project, + {**snapshot, "candidateSha256": live["candidateSha256"]}, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + expected_current=live, + ) + + assert boundary.compose_ups == 1 + assert result["imageIDs"] == {CONTAINER_NAME: BASELINE_IMAGE} + assert boundary.live["Config"]["Env"] == prior_environment + assert boundary.live["Mounts"] == prior_mounts + assert boundary.live["NetworkSettings"]["Ports"] == prior_ports + assert runtime._runtime_contract(boundary.live) == prior_contract + + +def test_production_restore_restarts_a_stopped_signed_baseline( + adopted_production_runtime, tmp_path +): + runtime, boundary, project, _model, _registry, _read_state, _state_bytes, snapshot = ( + adopted_production_runtime + ) + module = boundary.module + boundary.live["State"]["Running"] = False + boundary.commands.clear() + + restored = module.restore_deployment_snapshot( + REPOSITORY, + project, + snapshot, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + + assert boundary.compose_ups == 1 + assert any(command[-1:] == ["--force-recreate"] for command in boundary.commands) + assert restored["imageIDs"] == {CONTAINER_NAME: BASELINE_IMAGE} + assert boundary.live["State"]["Running"] is True + assert restored["artifacts"]["containers"][CONTAINER_NAME]["running"] is True + + +def test_production_restore_rejects_unattested_candidate_before_compose_mutation( + production_runtime, module +): + runtime, boundary, project, model = production_runtime + observation = runtime.observe(REPOSITORY, project, receipt_key=KEY) + snapshot = runtime.snapshot( + REPOSITORY, project, observation, REQUEST_ID, receipt_key=KEY + ) + snapshot.update( + { + "requestId": REQUEST_ID, + "runtimeConfiguration": { + "composeModel": copy.deepcopy(model), + "containers": {CONTAINER_NAME: copy.deepcopy(boundary.live)}, + "composeDirectory": str(boundary.source), + }, + "artifacts": copy.deepcopy(observation["artifacts"]), + } + ) + boundary.live = _container(image_id=CANDIDATE_IMAGE, container_id="e" * 64) + boundary.images[CANDIDATE_IMAGE]["Config"]["Labels"][ + "org.opencontainers.image.revision" + ] = "f" * 40 + boundary.commands.clear() + + with pytest.raises( + module.RegisteredLegacyAdoptionError, + match="candidate|signed baseline|current registered", + ): + runtime.restore(REPOSITORY, project, snapshot, TARGET_SHA, receipt_key=KEY) + + assert boundary.compose_ups == 0 + assert not any( + command[:3] == ["docker", "compose", "--project-name"] + for command in boundary.commands + ) + + +def _validation_api(path, *, query): + if path == f"/repos/{REPOSITORY}/branches/main": + return {"protected": True, "commit": {"id": TARGET_SHA}} + if path == f"/repos/{REPOSITORY}/actions/runs": + return { + "total_count": 1, + "workflow_runs": [ + { + "id": 7, + "run_number": 19, + "run_attempt": 1, + "event": "push", + "head_sha": TARGET_SHA, + "head_branch": "main", + "path": "managed-validation.yml@refs/heads/main", + "created_at": "2026-10-07T12:00:00Z", + "status": "completed", + "conclusion": "success", + "repository": {"full_name": REPOSITORY}, + "head_repository": {"full_name": REPOSITORY}, + "pull_requests": [], + } + ], + } + if path == f"/repos/{REPOSITORY}/actions/runs/7/jobs": + return { + "total_count": 1, + "jobs": [ + { + "id": 9, + "name": "full", + "run_id": 7, + "run_attempt": 1, + "head_sha": TARGET_SHA, + "head_branch": "main", + "status": "completed", + "conclusion": "success", + } + ], + } + raise AssertionError(f"unexpected validation request: {path} {query}") + + +def _adopt_with_production_runtime(module, boundary, project, state_root, monkeypatch): + original_validate = module.validate_registered_legacy_target + + def validate_temp_target(repository, registered_project): + if ( + repository == REPOSITORY + and registered_project.get("slug") == "quiz-monitor" + ): + return "quiz-monitor" + return original_validate(repository, registered_project) + + # The policy tests cover the fixed production source prefix separately. This + # adapter test maps that already-validated registration to the temporary tree. + monkeypatch.setattr( + module, "validate_registered_legacy_target", validate_temp_target + ) + state_bytes = ( + b'{"schema_version":3,"live_revision":"unknown","live_status":"unknown"}\n' + ) + (state_root / "quiz-monitor.json").write_bytes(state_bytes) + + def read_state(_repository, _project): + return json.loads(state_bytes.decode("utf-8")), state_bytes + + runtime = module._ProductionRuntime(state_root) + registry = {REPOSITORY: project} + baseline = module.baseline( + {"repository": REPOSITORY, "requestId": REQUEST_ID}, + registry=registry, + read_state=read_state, + runtime=runtime, + receipt_key=KEY, + state_root=state_root, + ) + preflight = module.preflight( + { + "repository": REPOSITORY, + "revision": TARGET_SHA, + "requestId": REQUEST_ID, + "expectedStateSha256": baseline["expectedStateSha256"], + }, + registry=registry, + read_state=read_state, + runtime=runtime, + api_json=_validation_api, + receipt_key=KEY, + state_root=state_root, + ) + module.adopt( + { + "repository": REPOSITORY, + "revision": TARGET_SHA, + "requestId": REQUEST_ID, + "expectedStateSha256": baseline["expectedStateSha256"], + "preflightSha256": preflight["preflightSha256"], + }, + registry=registry, + read_state=read_state, + runtime=runtime, + api_json=_validation_api, + receipt_key=KEY, + state_root=state_root, + ) + snapshot = module.capture_deployment_snapshot( + REPOSITORY, + project, + json.loads(state_bytes.decode("utf-8")), + request_id=REQUEST_ID, + target_revision=TARGET_SHA, + receipt_key=KEY, + state_root=state_root, + runtime=runtime, + ) + assert boundary.live["Image"] == BASELINE_IMAGE + return runtime, registry, read_state, state_bytes, snapshot + + +@pytest.fixture +def adopted_production_runtime(production_runtime, tmp_path, monkeypatch): + _runtime, boundary, project, model = production_runtime + runtime, registry, read_state, state_bytes, snapshot = ( + _adopt_with_production_runtime( + boundary.module, + boundary, + project, + tmp_path, + monkeypatch, + ) + ) + return ( + runtime, + boundary, + project, + model, + registry, + read_state, + state_bytes, + snapshot, + ) + + +def _prepare_candidate_overlay(module, boundary, project, tmp_path): + bound_project = dict(project) + bound_project.update( + { + "_registered_legacy_request_id": REQUEST_ID, + "_registered_legacy_repository": REPOSITORY, + } + ) + module.prepare_build_configuration( + REPOSITORY, + bound_project, + REQUEST_ID, + TARGET_SHA, + copy.deepcopy(boundary.model), + {}, + { + "APPOPS_SOURCE_REVISION": TARGET_SHA, + "APPOPS_SOURCE_REPOSITORY": REPOSITORY, + "APPOPS_SOURCE_TREE": "d" * 40, + "APPOPS_BUILD_ID": "test-build", + "APPOPS_BUILD_DATE": "2026-10-07T12:00:00Z", + }, + receipt_key=KEY, + state_root=tmp_path, + ) + return bound_project + + +def _candidate_deployment( + module, + runtime, + boundary, + project, + registry, + state_bytes, + tmp_path, + *, + receipt_container_id=None, + receipt_image_id=None, +): + bound_project = _prepare_candidate_overlay(module, boundary, project, tmp_path) + sealed = module.prepare_candidate_deployment( + REPOSITORY, + bound_project, + REQUEST_ID, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + module.verify_candidate_pre_mutation( + REPOSITORY, + bound_project, + REQUEST_ID, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + boundary.live = _container( + image_id=CANDIDATE_IMAGE, + container_id="e" * 64, + hostname="candidate-generated-hostname", + config_hash="candidate-config-hash", + ) + boundary.live["Config"]["Image"] = "quiz-monitor:latest" + module.record_candidate_runtime( + REPOSITORY, + bound_project, + REQUEST_ID, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + deployment_receipt = { + "schema": "gitea-deploy-receipt.v1", + "repository": REPOSITORY, + "revision": TARGET_SHA, + "containers": { + CONTAINER_NAME: { + "container_id": receipt_container_id or boundary.live["Id"], + "image_id": receipt_image_id or CANDIDATE_IMAGE, + } + }, + } + live_binding = module.record_live_runtime( + REPOSITORY, + bound_project, + REQUEST_ID, + TARGET_SHA, + deployment_receipt, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + artifacts = { + "registered_legacy": { + "requestId": REQUEST_ID, + "revision": TARGET_SHA, + **live_binding, + }, + "containers": { + CONTAINER_NAME: { + "container_id": boundary.live["Id"], + "image_id": CANDIDATE_IMAGE, + } + }, + } + controller_state = { + "live_status": "healthy", + "live_revision": TARGET_SHA, + "live_artifacts": artifacts, + "live_receipt": {"containers": copy.deepcopy(artifacts["containers"])}, + } + live = module.verify_current_live_runtime( + REPOSITORY, + dict(project), + controller_state, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + assert sealed["candidateSha256"] + assert live["revision"] == TARGET_SHA + return live, controller_state + + +def test_candidate_and_live_runtime_are_bound_to_exact_production_observation( + adopted_production_runtime, tmp_path +): + ( + runtime, + boundary, + project, + _model, + registry, + _read_state, + state_bytes, + snapshot, + ) = adopted_production_runtime + module = boundary.module + + live, controller_state = _candidate_deployment( + module, runtime, boundary, project, registry, state_bytes, tmp_path + ) + + assert live["containers"] == { + CONTAINER_NAME: { + "containerId": "e" * 64, + "imageId": CANDIDATE_IMAGE, + "service": "quiz-server", + } + } + assert live["imageIDs"] == {CONTAINER_NAME: CANDIDATE_IMAGE} + assert ( + live["runtimeFingerprint"] + == controller_state["live_artifacts"]["registered_legacy"]["runtimeFingerprint"] + ) + assert snapshot["registered_legacy"]["snapshotSha256"] + + +def test_restart_verification_reconstructs_signed_overlay_from_registry_project( + adopted_production_runtime, tmp_path +): + ( + runtime, + boundary, + project, + _model, + registry, + _read_state, + state_bytes, + _snapshot, + ) = adopted_production_runtime + module = boundary.module + _live, controller_state = _candidate_deployment( + module, runtime, boundary, project, registry, state_bytes, tmp_path + ) + fresh_project = dict(project) + assert not any(name.startswith("_registered_") for name in fresh_project) + overlay_path = module._paths(tmp_path, project["slug"], REQUEST_ID)["overlay_file"] + before = len(boundary.compose_config_commands) + + verified = module.verify_current_live_runtime( + REPOSITORY, + fresh_project, + controller_state, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + + assert verified["revision"] == TARGET_SHA + observed_commands = boundary.compose_config_commands[before:] + assert observed_commands + assert any(str(overlay_path) in command for command in observed_commands) + + +def test_candidate_image_must_carry_the_exact_registered_revision( + adopted_production_runtime, tmp_path +): + ( + runtime, + boundary, + project, + _model, + _registry, + _read_state, + _state_bytes, + _snapshot, + ) = adopted_production_runtime + module = boundary.module + boundary.images["quiz-monitor:latest"]["Config"]["Labels"][ + "org.opencontainers.image.revision" + ] = "f" * 40 + + with pytest.raises( + module.RegisteredLegacyAdoptionError, + match="exact repository revision provenance", + ): + module.prepare_candidate_deployment( + REPOSITORY, + project, + REQUEST_ID, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + + candidate_file = ( + tmp_path + / "registered-legacy-adoption" + / "quiz-monitor" + / f"{REQUEST_ID}.candidate.json" + ) + assert not candidate_file.exists() + + +def test_candidate_sealing_rejects_runtime_drift_after_build_before_mutation( + adopted_production_runtime, tmp_path +): + ( + runtime, + boundary, + project, + _model, + _registry, + _read_state, + _state_bytes, + _snapshot, + ) = adopted_production_runtime + module = boundary.module + boundary.live["Id"] = "f" * 64 + boundary.commands.clear() + + with pytest.raises( + module.RegisteredLegacyAdoptionError, + match="baseline container runtime changed", + ): + module.prepare_candidate_deployment( + REPOSITORY, + project, + REQUEST_ID, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + + candidate_file = ( + tmp_path + / "registered-legacy-adoption" + / "quiz-monitor" + / f"{REQUEST_ID}.candidate.json" + ) + assert not candidate_file.exists() + assert boundary.compose_ups == 0 + + +def test_pre_mutation_cas_rejects_runtime_drift_after_candidate_is_sealed( + adopted_production_runtime, tmp_path +): + ( + runtime, + boundary, + project, + _model, + _registry, + _read_state, + _state_bytes, + _snapshot, + ) = adopted_production_runtime + module = boundary.module + bound_project = _prepare_candidate_overlay(module, boundary, project, tmp_path) + module.prepare_candidate_deployment( + REPOSITORY, + bound_project, + REQUEST_ID, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + boundary.live["HostConfig"]["SecurityOpt"] = ["seccomp=unconfined"] + boundary.commands.clear() + + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.verify_candidate_pre_mutation( + REPOSITORY, + bound_project, + REQUEST_ID, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + + assert boundary.compose_ups == 0 + assert not any( + command[:3] == ["docker", "compose", "--project-name"] + and command[-1:] == ["--force-recreate"] + for command in boundary.commands + ) + + +@pytest.mark.parametrize("drift", ["container_id", "image_id", "environment", "mount"]) +def test_signed_live_runtime_drift_is_rejected_before_restore_mutation( + adopted_production_runtime, tmp_path, drift +): + ( + runtime, + boundary, + project, + _model, + registry, + _read_state, + state_bytes, + snapshot, + ) = adopted_production_runtime + module = boundary.module + live, _controller_state = _candidate_deployment( + module, runtime, boundary, project, registry, state_bytes, tmp_path + ) + if drift == "container_id": + boundary.live["Id"] = "f" * 64 + elif drift == "image_id": + boundary.live["Image"] = "sha256:" + "9" * 64 + boundary.images[boundary.live["Image"]] = { + "Id": boundary.live["Image"], + "Config": { + "Labels": { + "org.opencontainers.image.revision": TARGET_SHA, + "org.opencontainers.image.source": REPOSITORY, + } + }, + } + elif drift == "environment": + boundary.live["Config"]["Env"][1] = "QUIZ_TEMPLATE=changed" + elif drift == "mount": + boundary.live["Mounts"][0]["Source"] = "/other/config" + boundary.commands.clear() + + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.restore_deployment_snapshot( + REPOSITORY, + project, + {**snapshot, "candidateSha256": live["candidateSha256"]}, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + expected_current=live, + ) + + assert boundary.compose_ups == 0 + assert not any( + command[-1:] == ["--force-recreate"] for command in boundary.commands + ) + + +@pytest.mark.parametrize("mismatch", ["container_id", "image_id"]) +def test_live_receipt_must_match_sealed_candidate_container_identity( + adopted_production_runtime, tmp_path, mismatch +): + ( + runtime, + boundary, + project, + _model, + _registry, + _read_state, + _state_bytes, + _snapshot, + ) = adopted_production_runtime + module = boundary.module + bound_project = _prepare_candidate_overlay(module, boundary, project, tmp_path) + module.prepare_candidate_deployment( + REPOSITORY, + bound_project, + REQUEST_ID, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + boundary.live = _container( + image_id=CANDIDATE_IMAGE, + container_id="e" * 64, + hostname="candidate-generated-hostname", + config_hash="candidate-config-hash", + ) + boundary.live["Config"]["Image"] = "quiz-monitor:latest" + receipt_container_id = boundary.live["Id"] + receipt_image_id = CANDIDATE_IMAGE + if mismatch == "container_id": + receipt_container_id = "f" * 64 + else: + receipt_image_id = BASELINE_IMAGE + deployment_receipt = { + "schema": "gitea-deploy-receipt.v1", + "repository": REPOSITORY, + "revision": TARGET_SHA, + "containers": { + CONTAINER_NAME: { + "container_id": receipt_container_id, + "image_id": receipt_image_id, + } + }, + } + + with pytest.raises( + module.RegisteredLegacyAdoptionError, + match="sealed candidate image", + ): + module.record_live_runtime( + REPOSITORY, + bound_project, + REQUEST_ID, + TARGET_SHA, + deployment_receipt, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + + live_file = ( + tmp_path + / "registered-legacy-adoption" + / "quiz-monitor" + / f"{REQUEST_ID}.live.json" + ) + assert not live_file.exists() + assert boundary.compose_ups == 0 + + +def test_restore_rechecks_verified_candidate_cas_before_force_recreate( + adopted_production_runtime, tmp_path +): + ( + runtime, + boundary, + project, + _model, + _registry, + _read_state, + state_bytes, + snapshot, + ) = adopted_production_runtime + module = boundary.module + live, controller_state = _candidate_deployment( + module, runtime, boundary, project, _registry, state_bytes, tmp_path + ) + # A separate read verifies the signed HMAC receipt and controller's per-container IDs. + expected_current = module.verify_current_live_runtime( + REPOSITORY, + project, + controller_state, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + boundary.commands.clear() + + restored = module.restore_deployment_snapshot( + REPOSITORY, + project, + {**snapshot, "candidateSha256": live["candidateSha256"]}, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + expected_current=expected_current, + ) + + assert boundary.compose_ups == 1 + assert restored["imageIDs"] == {CONTAINER_NAME: BASELINE_IMAGE} + assert boundary.live["Image"] == BASELINE_IMAGE + assert boundary.live["Config"]["Env"] == [ + "OPENRGB_HOST=198.19.1.2", + f"QUIZ_TEMPLATE={TEMPLATE_VALUE}", + ] + assert boundary.live["Mounts"][0]["Source"] == CONFIG_BIND + assert ( + boundary.live["NetworkSettings"]["Ports"]["8000/tcp"][0]["HostPort"] == "1205" + ) diff --git a/backend/tests/test_registered_legacy_transport.py b/backend/tests/test_registered_legacy_transport.py new file mode 100644 index 0000000..c8e9e64 --- /dev/null +++ b/backend/tests/test_registered_legacy_transport.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import io +from types import SimpleNamespace + +import pytest +from test_deploy_broker import BROKER_ROOT, _load_module + +with pytest.MonkeyPatch.context() as patch: + patch.syspath_prepend(str(BROKER_ROOT)) + transport = _load_module("registered_transport_test", BROKER_ROOT / "registered_legacy_transport.py") +REPO = "jens/quiz-monitor" +PREFIX = "/repos/" + REPO + + +def payload(path, query=None): + return {"repository": REPO, "path": PREFIX + path, "query": query or {}, + "headers": {"Accept": "application/json", "Authorization": "token " + "x" * 40, + "User-Agent": transport.transport.USER_AGENT}} + + +@pytest.mark.parametrize("path,query", [ + ("/branches/main", {}), + ("/actions/runs", {"event": "push", "branch": "main", "head_sha": "a" * 40, "page": "1", "limit": "100"}), + ("/actions/runs/123/jobs", {"page": "10", "limit": "100"}), +]) +def test_only_exact_main_metadata_is_admitted(path, query): + assert transport.allowed(REPO, PREFIX + path, query) + + +@pytest.mark.parametrize("path,query", [ + ("/branches/feature", {}), ("/branches/main", {"extra": "1"}), + ("/actions/runs/123/jobs", {"page": "11", "limit": "100"}), + ("/actions/runs", {"event": "pull_request", "branch": "main", "head_sha": "a" * 40, "page": "1", "limit": "100"}), + ("/actions/artifacts/123/zip", {}), ("/branches/main/../../admin", {}), +]) +def test_other_branches_runs_paths_and_artifacts_are_denied(path, query): + assert not transport.allowed(REPO, PREFIX + path, query) + + +def test_repository_and_headers_cannot_escape_read_scope(): + assert not transport.allowed("jens/quiz-monitor/../other", PREFIX + "/branches/main", {}) + assert not transport.allowed(REPO, "/repos/jens/other/branches/main", {}) + request = payload("/branches/main") + request["headers"]["Authorization"] = "token invalid\nheader" + with pytest.raises(ValueError, match="headers_invalid"): + transport.execute(request) + + +def test_fixed_get_uses_bounded_reader_without_redirects_or_secret_output(): + observed = [] + class Response(io.BytesIO): + status = 200 + def open_request(request, timeout): + observed.append((request.full_url, request.get_method(), timeout)) + return Response(b'{"commit":{"id":"abc"}}') + result = transport.execute(payload("/branches/main"), opener=SimpleNamespace(open=open_request)) + assert result == b'{"commit":{"id":"abc"}}' + assert observed == [("https://gitea.example.com/api/v1" + PREFIX + "/branches/main", "GET", 10)] diff --git a/src/api/liveClient.ts b/src/api/liveClient.ts index 493de04..90c0d20 100644 --- a/src/api/liveClient.ts +++ b/src/api/liveClient.ts @@ -4,7 +4,7 @@ import { AISummary, AIRecommendation, AIChatMessage, AIAutopilotRule, AITool } f import { toIsoString } from '@/lib/backendDateTime'; import type { AiSecurityFinding, AiSecurityRecommendation, AiSecurityReviewResult, AiSecurityScope, AiSecuritySummary, NotificationChannel, NotificationEvaluationResult, NotificationEvent, NotificationRule, NotificationSummary, RemediationApprovalProposal, RemediationDryRunResult, RemediationPlan, RemediationPlanCollection, SafetyDashboard, SafetyFinding, SafetyRecommendation, SafetyScoreBreakdown, SafetySection, SafetySummary, UnraidDisksResponse, UnraidDockerResponse, UnraidSharesResponse, UnraidVmCapabilitiesResponse, UnraidVmsResponse } from '../types/appops'; import type { GiteaActionsStatus, IntegrationStatus, GatewayUser, IdentityProvider, PermissionDefinition, UserAccessUpdate, UserAuthSession, RagQueryResult, RagStatus, RunnerFleetStatus } from '../types/appops'; -import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, EnrollmentStorageRootSelection, EnrollmentTrustStatus, WorkMeshPromotion } from '../types/appops'; +import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, EnrollmentStorageRootSelection, EnrollmentTrustStatus, ForgeRouteStageResult, WorkMeshPromotion } from '../types/appops'; import type { ExposurePreferences, ManagedAiConfig, ManagedAiConfigUpdate, ManagedAiStatus, ManagedAiInference } from '../types/appops'; type HealthStatus = { @@ -371,6 +371,19 @@ export const createLiveClient = () => ({ }); }, async getDeliveryStatus(): Promise { return get('/api/deployments/delivery-status', defaultConfig); }, + async stageForgeRoute(jobId: string, idempotencyKey: string): Promise { + return request( + `/api/deployments/forge-bootstrap/first-install/${encodeURIComponent(jobId)}/route`, + defaultConfig, + { method: 'POST', headers: { 'Idempotency-Key': idempotencyKey } }, + ); + }, + async getForgeRouteStage(jobId: string): Promise { + return get( + `/api/deployments/forge-bootstrap/first-install/${encodeURIComponent(jobId)}/route`, + defaultConfig, + ); + }, async getEnrollmentCapabilities(): Promise { return get('/api/enrollments/capabilities', defaultConfig); }, async getEnrollmentTrustStatus(): Promise { return get('/api/enrollments/trust', defaultConfig); }, async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise { diff --git a/src/types/appops.ts b/src/types/appops.ts index 478c2ec..a14bd42 100644 --- a/src/types/appops.ts +++ b/src/types/appops.ts @@ -32,6 +32,18 @@ export interface DeploymentJob { idempotentReplay?: boolean; } +export interface ForgeRouteStageResult { + success: boolean; + status: 'host_staged_disabled' | 'blocked' | 'conflict' | 'failed' | 'outcome_unknown' | 'not_found'; + message: string; + target: 'itworx-forge'; + jobId: string; + hostname: 'forge.example.com'; + upstream: 'http://itworx-forge:8080'; + routeState: 'host_staged_disabled' | 'not_routed'; + routed: false; +} + export interface WorkMeshPromotion { promotionId: string; jobId: string;