Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8

This commit is contained in:
Public Source Publisher committed 2026-10-07 01:56:22 +00:00
1 parent 820244dbb3
commit 8dd4b40252
52 files changed
+11798 -272

No files matched your search

+15
View File
@@ -43,3 +43,18 @@ regexTarget = "line"
regexes = ['^\s*\{"plan": profile, "idempotencyKey": "forge-mcp-apply-0001"\},\r?$'] regexes = ['^\s*\{"plan": profile, "idempotencyKey": "forge-mcp-apply-0001"\},\r?$']
condition = "AND" condition = "AND"
targetRules = ["generic-api-key"] 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"]
+1 -1
View File
@@ -1,3 +1,3 @@
# Public source export # 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.
+155 -65
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"sourceRevision": "a480f280f26f4aa48c61394fa841d2233067c208", "sourceRevision": "ba66029223865607deddc234e283c11127324eb8",
"files": [ "files": [
{ {
"path": ".dockerignore", "path": ".dockerignore",
@@ -24,8 +24,8 @@
}, },
{ {
"path": ".gitleaks.toml", "path": ".gitleaks.toml",
"sha256": "e631ebe31e0b4fc034379618fc0b16c2a239015b087eab4d3a57a81f74c384de", "sha256": "33080cc7e9f83d2a2bbc54f754127a68243c6b0ad35c1655f8aa0cb1490ef55c",
"bytes": 2107 "bytes": 2876
}, },
{ {
"path": "CHANGELOG.md", "path": "CHANGELOG.md",
@@ -124,8 +124,8 @@
}, },
{ {
"path": "backend/app/api/routes_deployments.py", "path": "backend/app/api/routes_deployments.py",
"sha256": "ff14b4e40e6e14d0d7f9f8fd74faf5001010a3ba5c0b279253c61f17199f350f", "sha256": "df617522c2636628a220800cfa970233de85df858bef574c9ea7a4c37f13274c",
"bytes": 8604 "bytes": 12171
}, },
{ {
"path": "backend/app/api/routes_enrollments.py", "path": "backend/app/api/routes_enrollments.py",
@@ -304,8 +304,8 @@
}, },
{ {
"path": "backend/app/connectors/nginx_proxy_manager.py", "path": "backend/app/connectors/nginx_proxy_manager.py",
"sha256": "36bbd6b853e4969817d31ca74b7a170cd7c4ca197676f46554f1e5e499e96212", "sha256": "42a47f48bdb4016b452f80a2a5efeff980d958bc67453a52f24256c6a030ca1e",
"bytes": 32804 "bytes": 45381
}, },
{ {
"path": "backend/app/connectors/plex.py", "path": "backend/app/connectors/plex.py",
@@ -314,8 +314,8 @@
}, },
{ {
"path": "backend/app/connectors/portainer.py", "path": "backend/app/connectors/portainer.py",
"sha256": "dcbfb3926929b10e08190944acf0217a71d9696f0c8e91b24fe832bc71034f8f", "sha256": "375141e4e4d01c70e645075395a6d6bf0d2240c178f13b01fcb62f2440cc3d79",
"bytes": 19655 "bytes": 27899
}, },
{ {
"path": "backend/app/connectors/prowlarr.py", "path": "backend/app/connectors/prowlarr.py",
@@ -354,8 +354,8 @@
}, },
{ {
"path": "backend/app/core/config.py", "path": "backend/app/core/config.py",
"sha256": "9a839001e681edb19926a45e859c5103b398cc97922d20042fa739cfcbf84126", "sha256": "56ea46b05cad49d1d7f19b6af06ce80036723c0f75317a33af42a50b9521a289",
"bytes": 27341 "bytes": 27330
}, },
{ {
"path": "backend/app/core/network_security.py", "path": "backend/app/core/network_security.py",
@@ -384,8 +384,8 @@
}, },
{ {
"path": "backend/app/db/init_db.py", "path": "backend/app/db/init_db.py",
"sha256": "450abf3cdf8c8897938cffeef193d69f7fc0fb1d4cd7a713597db1ca5bb811a7", "sha256": "6ddf18c82d29f979b97aa2dde6e2959526a4ce27a0ce805fae979966b6371cc9",
"bytes": 70586 "bytes": 70832
}, },
{ {
"path": "backend/app/db/models.py", "path": "backend/app/db/models.py",
@@ -404,8 +404,8 @@
}, },
{ {
"path": "backend/app/mcp_server.py", "path": "backend/app/mcp_server.py",
"sha256": "aa1246a23ba269a0e52cb5f95d0cd3bde8e4e5aa3943872a0a4059dbaa441f18", "sha256": "9d9e6b881cc293799bde6dc244bfb04442ad3c30f3757bad99d824f12fb30d0a",
"bytes": 38109 "bytes": 44777
}, },
{ {
"path": "backend/app/runner_statistics_mcp.py", "path": "backend/app/runner_statistics_mcp.py",
@@ -452,6 +452,11 @@
"sha256": "8d55f7a8263191f07813d1689eb0697dabec99a1bbd5aff5a704e66e6bf86c47", "sha256": "8d55f7a8263191f07813d1689eb0697dabec99a1bbd5aff5a704e66e6bf86c47",
"bytes": 6045 "bytes": 6045
}, },
{
"path": "backend/app/schemas/forge_route_stage.py",
"sha256": "a84f15e2d8421757e633895fff5ea0aac075a9eca2c5f7bceec322dfbee682ab",
"bytes": 1538
},
{ {
"path": "backend/app/schemas/forge_runtime_readiness.py", "path": "backend/app/schemas/forge_runtime_readiness.py",
"sha256": "4571fd2c7b624fe4f12c219a1f494b03a98ec1745a9512197cf7c49287ce47b5", "sha256": "4571fd2c7b624fe4f12c219a1f494b03a98ec1745a9512197cf7c49287ce47b5",
@@ -469,13 +474,13 @@
}, },
{ {
"path": "backend/app/services/action_execution.py", "path": "backend/app/services/action_execution.py",
"sha256": "3ba0a33cd6947dee1d7961e63e6e9a4b247eda3ea8253537b9f879a57cf063ac", "sha256": "4c4099328c3931ef3811f7d9043d7ad69c2bd27c1e7b6b1bdc139fbb5ba3c29a",
"bytes": 99269 "bytes": 100242
}, },
{ {
"path": "backend/app/services/action_registry.py", "path": "backend/app/services/action_registry.py",
"sha256": "a5c575ee262ca721906a65156383ae2d1c2b08c52b2a5f9145c841894496c31f", "sha256": "3e0f83e62f6d8dfdf0bb4bc6c09b58d612bb6ff2a4b5fcf619dba5aa8d009fd1",
"bytes": 21316 "bytes": 21446
}, },
{ {
"path": "backend/app/services/ai_remediation_planner.py", "path": "backend/app/services/ai_remediation_planner.py",
@@ -519,13 +524,13 @@
}, },
{ {
"path": "backend/app/services/controller_artifact_helper_spec.py", "path": "backend/app/services/controller_artifact_helper_spec.py",
"sha256": "1da47a3f061ef0b89158c9f646bb7018ce88b81c0687d8b27abe65b74df67fea", "sha256": "dc456d0005b7181098d99028fa8ea110fb53b037aac1c031c98a9101a61183bd",
"bytes": 4948 "bytes": 6029
}, },
{ {
"path": "backend/app/services/controller_artifact_promoter.py", "path": "backend/app/services/controller_artifact_promoter.py",
"sha256": "bc06a28941c111b5a0f6eed96e407259626c0b9fc1ba5ecb8953befc83b66304", "sha256": "613dbe8dd8b05553f947e376ccb82958836bb55cdfe683e28d5a6b981720855b",
"bytes": 13602 "bytes": 18179
}, },
{ {
"path": "backend/app/services/controller_bootstrap_import.py", "path": "backend/app/services/controller_bootstrap_import.py",
@@ -534,8 +539,8 @@
}, },
{ {
"path": "backend/app/services/deployment_broker.py", "path": "backend/app/services/deployment_broker.py",
"sha256": "e23b2a0669c846cf37c44766700d29e5d2dfa22b6b6d0d546fe5c94bb86f4c6b", "sha256": "693e0a46920a7a35d129f89b31d31a2fba026a704b899eb89e34023b0a0c52c1",
"bytes": 37749 "bytes": 39197
}, },
{ {
"path": "backend/app/services/deployment_control_plane.py", "path": "backend/app/services/deployment_control_plane.py",
@@ -544,8 +549,8 @@
}, },
{ {
"path": "backend/app/services/deployment_target_registry.py", "path": "backend/app/services/deployment_target_registry.py",
"sha256": "2266540e1820997df473e9d65922b07171becbf0c300f527e939718f8317b4a6", "sha256": "9ada1454f5e63e7557db06f811c28bc6501d29fd02c0474233dda8e12295ef4a",
"bytes": 23483 "bytes": 29274
}, },
{ {
"path": "backend/app/services/discovery_scheduler.py", "path": "backend/app/services/discovery_scheduler.py",
@@ -554,8 +559,8 @@
}, },
{ {
"path": "backend/app/services/enrollment_plane.py", "path": "backend/app/services/enrollment_plane.py",
"sha256": "079163e8634996f572e573d657eae1ec1a72fa7ac739e86e47510a60f248b3f4", "sha256": "d04fd7a4399c698705719e9748130e450702f541c3e69b003480b4e5621a256c",
"bytes": 57487 "bytes": 63491
}, },
{ {
"path": "backend/app/services/enrollment_profiles.py", "path": "backend/app/services/enrollment_profiles.py",
@@ -587,6 +592,11 @@
"sha256": "25d32b9f1acfdf2a2f4032b242f8843ba0e8a48ab1c2c0cf4f048181906c0af1", "sha256": "25d32b9f1acfdf2a2f4032b242f8843ba0e8a48ab1c2c0cf4f048181906c0af1",
"bytes": 31091 "bytes": 31091
}, },
{
"path": "backend/app/services/forge_route_stage.py",
"sha256": "8c748366fa972b864c7b9790d3327da5699fa008136334c7f73d1aad1231fc9a",
"bytes": 35587
},
{ {
"path": "backend/app/services/forge_secret_delivery.py", "path": "backend/app/services/forge_secret_delivery.py",
"sha256": "a0f16bd7745f30d54781c10808ff076b45a9db0c432eeaa36ea6947bf654c17e", "sha256": "a0f16bd7745f30d54781c10808ff076b45a9db0c432eeaa36ea6947bf654c17e",
@@ -594,8 +604,8 @@
}, },
{ {
"path": "backend/app/services/gitea_project_auto_registration.py", "path": "backend/app/services/gitea_project_auto_registration.py",
"sha256": "0da4469d2b75d1d6c74704b9c402348ce246f3325c1726bd50dcf13b378e060b", "sha256": "6055747c802d398cca6d80b7a73ec0dbd49108d1a095b4845218af819e8c3e10",
"bytes": 25143 "bytes": 25796
}, },
{ {
"path": "backend/app/services/healthcheck_service.py", "path": "backend/app/services/healthcheck_service.py",
@@ -639,8 +649,8 @@
}, },
{ {
"path": "backend/app/services/mcp_hub_deployment_reconciliation.py", "path": "backend/app/services/mcp_hub_deployment_reconciliation.py",
"sha256": "2367cf52e39091bd941b19e12aeccf72f65842cdf5ec74e5eb17bd0f636f48bf", "sha256": "caf531c4b11e48984318281bed28160be8d8b8af277600b5471ada622c314966",
"bytes": 44919 "bytes": 45506
}, },
{ {
"path": "backend/app/services/mcp_operations.py", "path": "backend/app/services/mcp_operations.py",
@@ -689,8 +699,8 @@
}, },
{ {
"path": "backend/app/services/policy_engine.py", "path": "backend/app/services/policy_engine.py",
"sha256": "d00f8d1be911d49519791a5c517d261a666a9ff04bb037954501a7f79a1b4dcf", "sha256": "23ede379cf2078de600e0cecd0d54ec5f921e331ca3996d64b5a068cc7ecda1c",
"bytes": 12023 "bytes": 13119
}, },
{ {
"path": "backend/app/services/portfolio_deployment_enrollment.py", "path": "backend/app/services/portfolio_deployment_enrollment.py",
@@ -879,8 +889,8 @@
}, },
{ {
"path": "backend/tests/test_authentik_connector.py", "path": "backend/tests/test_authentik_connector.py",
"sha256": "1ea6eaea54f0ce6f5a1e92a6c338edb880eff4c6bc43f09a32ea7a0e4a537870", "sha256": "ac1056893dfa791e074f9962fb9752cd65944af16c41437a8d1824c1b144dd85",
"bytes": 28252 "bytes": 28251
}, },
{ {
"path": "backend/tests/test_ci_job_docker.py", "path": "backend/tests/test_ci_job_docker.py",
@@ -894,14 +904,24 @@
}, },
{ {
"path": "backend/tests/test_controller_artifact_bootstrap.py", "path": "backend/tests/test_controller_artifact_bootstrap.py",
"sha256": "da2ff9e9ec07c15f2fed076d0d7c76017496252c5584a8383d6c3132bdbaf25f", "sha256": "3389f7cc8225ff2f03b46aa79a3a70dae7a6198d1ab52fd0879638bcf4c92f83",
"bytes": 62434 "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", "path": "backend/tests/test_controller_artifact_promoter.py",
"sha256": "8eee2bdaf60422824f605637b1820c431fdacea305e1b68b3618fffa0dc000ee", "sha256": "8eee2bdaf60422824f605637b1820c431fdacea305e1b68b3618fffa0dc000ee",
"bytes": 7570 "bytes": 7570
}, },
{
"path": "backend/tests/test_controller_artifact_promotion_diagnostics.py",
"sha256": "07341103e2ba10a3ab9a2dc4d83a08b4dd7f7d06e0fe082b9a36230077884371",
"bytes": 12438
},
{ {
"path": "backend/tests/test_controller_bootstrap_import_host.py", "path": "backend/tests/test_controller_bootstrap_import_host.py",
"sha256": "58c153b8e3d1b016006b0bed5c98f9aa069d58e670d65edac368833ae38dd6f1", "sha256": "58c153b8e3d1b016006b0bed5c98f9aa069d58e670d65edac368833ae38dd6f1",
@@ -919,14 +939,19 @@
}, },
{ {
"path": "backend/tests/test_controller_hub_release_volume.py", "path": "backend/tests/test_controller_hub_release_volume.py",
"sha256": "c6f8304f8f3fbbf42900c07a5b01fb40edeb3f6657849b55f9bf4fd59cdb79f6", "sha256": "c0ea7e949d1e1c258eccfed815750f885009337dd56db7be5a9cb598e558fa24",
"bytes": 84733 "bytes": 115271
}, },
{ {
"path": "backend/tests/test_controller_root_procedure_contract.py", "path": "backend/tests/test_controller_root_procedure_contract.py",
"sha256": "964b3e21828a9d815d05ffb0e51acf6e9e45efe56af9fa3b70f39200d3a5c336", "sha256": "964b3e21828a9d815d05ffb0e51acf6e9e45efe56af9fa3b70f39200d3a5c336",
"bytes": 5125 "bytes": 5125
}, },
{
"path": "backend/tests/test_controller_runtime_capabilities.py",
"sha256": "2f45e82c224f858ab9a4b0e0a9ddcc8a6c96a16942eee5595f5a2d8f553415bd",
"bytes": 6701
},
{ {
"path": "backend/tests/test_controller_state_reconciliation.py", "path": "backend/tests/test_controller_state_reconciliation.py",
"sha256": "d1d90f6d4fe35e434797dccd2c0ec10357fc347900e6e148c0f85face9d12598", "sha256": "d1d90f6d4fe35e434797dccd2c0ec10357fc347900e6e148c0f85face9d12598",
@@ -939,8 +964,8 @@
}, },
{ {
"path": "backend/tests/test_deploy_broker.py", "path": "backend/tests/test_deploy_broker.py",
"sha256": "7e32918a89dcb3d02cc75674fcd9313b60cf96ed3651a5016bbfb3fc751c3ec3", "sha256": "8d92404b6719159d1ce2e5b7df73f925ba37ce2fff6f7bbaf9bcb780c46a789c",
"bytes": 406062 "bytes": 415499
}, },
{ {
"path": "backend/tests/test_deployment_auto_rollback.py", "path": "backend/tests/test_deployment_auto_rollback.py",
@@ -974,8 +999,13 @@
}, },
{ {
"path": "backend/tests/test_enrollment_plane.py", "path": "backend/tests/test_enrollment_plane.py",
"sha256": "5979d8922fa03c0954b0c928b263278c1123c48a2f5dda701d543ff4eb1ccffa", "sha256": "d886402e194b2e76d1eac67761e9eb801af525580261d16cca79ed6273bf01d9",
"bytes": 26847 "bytes": 41160
},
{
"path": "backend/tests/test_existing_registered_target_reconciliation.py",
"sha256": "0c493282fecf31c5d2606cce0dac869658dbb0e032cce00b7fa96e82c065f3cc",
"bytes": 14764
}, },
{ {
"path": "backend/tests/test_exposure_preferences.py", "path": "backend/tests/test_exposure_preferences.py",
@@ -1037,6 +1067,21 @@
"sha256": "dc4e9d176a9b4c33fd24e27b2cb83919040babe412d021109a22de41c54edb8b", "sha256": "dc4e9d176a9b4c33fd24e27b2cb83919040babe412d021109a22de41c54edb8b",
"bytes": 6424 "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", "path": "backend/tests/test_forge_runtime_readiness.py",
"sha256": "0f70e327225eceae2ad161e565c9f601c191e3b086a989c7383ac7327434926d", "sha256": "0f70e327225eceae2ad161e565c9f601c191e3b086a989c7383ac7327434926d",
@@ -1069,8 +1114,8 @@
}, },
{ {
"path": "backend/tests/test_gitea_project_auto_registration.py", "path": "backend/tests/test_gitea_project_auto_registration.py",
"sha256": "d34b53f338e53e0bb2d8a6fccac88384b625e63d041766b4da3f1665f6020d56", "sha256": "507b62e5c0a43355c03a8bd196c28d118e612ac5920ae3b139a96c0e6f960744",
"bytes": 13248 "bytes": 16425
}, },
{ {
"path": "backend/tests/test_gpu_metrics.py", "path": "backend/tests/test_gpu_metrics.py",
@@ -1094,19 +1139,29 @@
}, },
{ {
"path": "backend/tests/test_hub_candidate_import.py", "path": "backend/tests/test_hub_candidate_import.py",
"sha256": "178a07b0dc9489545723c8ff5ecbfd7060aa0c1491ba6c1ced50edd7f3481e68", "sha256": "7ffc9b4f10d589f092c16bd23cf2cafabac1a6a26fb1a3fb19dbbd5ccf2f04bc",
"bytes": 30015 "bytes": 42194
}, },
{ {
"path": "backend/tests/test_hub_candidate_transport.py", "path": "backend/tests/test_hub_candidate_transport.py",
"sha256": "da0b302e0bf45d87f0b3339b2c1392aec22e650f45468ab236fc37f448d9f9ea", "sha256": "da0b302e0bf45d87f0b3339b2c1392aec22e650f45468ab236fc37f448d9f9ea",
"bytes": 9353 "bytes": 9353
}, },
{
"path": "backend/tests/test_hub_component_transport.py",
"sha256": "7fbce0be7f6cc12b68f5b05eb3f7c9eb00478207f1c49ab36ef88915ee53eb82",
"bytes": 9756
},
{ {
"path": "backend/tests/test_hub_permission_preparation.py", "path": "backend/tests/test_hub_permission_preparation.py",
"sha256": "3ba8efe338a21b100e4bc20e4fbffe043eb57c411a4143c00332fdb368db5011", "sha256": "3ba8efe338a21b100e4bc20e4fbffe043eb57c411a4143c00332fdb368db5011",
"bytes": 28482 "bytes": 28482
}, },
{
"path": "backend/tests/test_immutable_service_promotion.py",
"sha256": "8220a58307a0dd3a2ab8c885111b00ae57658bbc246e9e826fb46fcfddf76ef2",
"bytes": 22197
},
{ {
"path": "backend/tests/test_inventory_discovery.py", "path": "backend/tests/test_inventory_discovery.py",
"sha256": "db7a7fbe23ef01f2a4e2e58ca72514889b9740a04374851b4a414118908bc594", "sha256": "db7a7fbe23ef01f2a4e2e58ca72514889b9740a04374851b4a414118908bc594",
@@ -1139,23 +1194,23 @@
}, },
{ {
"path": "backend/tests/test_managed_validation_parallel.py", "path": "backend/tests/test_managed_validation_parallel.py",
"sha256": "d704cd4ba299ce2fc51d6f4a8a1b26ecdc2dea0e734e63dc6a65e1ba4c3ed37f", "sha256": "5654211d4ab1ab2749c269c148497f9448b0257c70779ebac063ef55780e3c11",
"bytes": 692 "bytes": 2355
}, },
{ {
"path": "backend/tests/test_mcp_hub_candidate_preparation.py", "path": "backend/tests/test_mcp_hub_candidate_preparation.py",
"sha256": "ed0d60e8f0e4371622405dc200e80ecfa3c4574ad2a9439373032f997a1af414", "sha256": "b5614f0b4828adb464d567f9a113116d47d02ea0b625475f37609ce9ac716ad9",
"bytes": 27390 "bytes": 37965
}, },
{ {
"path": "backend/tests/test_mcp_hub_deployment_reconciliation.py", "path": "backend/tests/test_mcp_hub_deployment_reconciliation.py",
"sha256": "476d02f8c5261e55788b16f8ee490728291b2379395890bbedc3a9be56ae5f58", "sha256": "5459baadd13180b69df7106615a977647c2afa63b0aa5df93af54484a09bb6b6",
"bytes": 28475 "bytes": 31726
}, },
{ {
"path": "backend/tests/test_mcp_hub_governed_topology.py", "path": "backend/tests/test_mcp_hub_governed_topology.py",
"sha256": "9e306fbce479f15610a87ad666023de7deae588dc01f69be6024140d5df2158d", "sha256": "59872024ae0a2e552aafe459d42d90098e022877648c3d77d33ce102c595d234",
"bytes": 125396 "bytes": 127375
}, },
{ {
"path": "backend/tests/test_mcp_hub_legacy_registry_compatibility.py", "path": "backend/tests/test_mcp_hub_legacy_registry_compatibility.py",
@@ -1169,8 +1224,8 @@
}, },
{ {
"path": "backend/tests/test_mcp_hub_release_candidates.py", "path": "backend/tests/test_mcp_hub_release_candidates.py",
"sha256": "f2add61e18641e4a5075948dc0b3b0e36b2b4bfe389d608078a7c5cf506fad54", "sha256": "43075075c0427cb76923f0fe98f0aec4ae864e0224dca45b53f470f4e649c4bd",
"bytes": 34190 "bytes": 37316
}, },
{ {
"path": "backend/tests/test_mcp_hub_release_store.py", "path": "backend/tests/test_mcp_hub_release_store.py",
@@ -1214,14 +1269,19 @@
}, },
{ {
"path": "backend/tests/test_nginx_proxy_manager_connector.py", "path": "backend/tests/test_nginx_proxy_manager_connector.py",
"sha256": "65e5b2be9dc013e872b5600d7374eb541950871c56d8c246e87a503e3bb25b31", "sha256": "4e4fafd2369bb6f86629f4e86f1a87d5e1110a99b1cc3b8c25631be84e88dc0d",
"bytes": 33978 "bytes": 33977
}, },
{ {
"path": "backend/tests/test_notifications.py", "path": "backend/tests/test_notifications.py",
"sha256": "52cb0ed1e82101cbd3439502f6aec131c1a6a85ba9b53cc47f2f4aad72900800", "sha256": "52cb0ed1e82101cbd3439502f6aec131c1a6a85ba9b53cc47f2f4aad72900800",
"bytes": 46292 "bytes": 46292
}, },
{
"path": "backend/tests/test_npm_secure_transport.py",
"sha256": "18661491628f4a6e5be7dc7b5d1e92054d6fd513219fec2967a14f6d17ff2b1d",
"bytes": 16114
},
{ {
"path": "backend/tests/test_oidc_login.py", "path": "backend/tests/test_oidc_login.py",
"sha256": "ae40a20e395155eda7d3de61c08c74db51095632be168bce7c453fb320a86e29", "sha256": "ae40a20e395155eda7d3de61c08c74db51095632be168bce7c453fb320a86e29",
@@ -1302,6 +1362,36 @@
"sha256": "da2a5b9421ced902cf75c7922f15431be0e5db06ef2e350ed5ab2d2a33f8749b", "sha256": "da2a5b9421ced902cf75c7922f15431be0e5db06ef2e350ed5ab2d2a33f8749b",
"bytes": 12332 "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", "path": "backend/tests/test_repopulse_adoption_cli.py",
"sha256": "31799892efbd70e7cbbe686844972ed624a0cd4ee6c8d65416ccfd916ee18fd5", "sha256": "31799892efbd70e7cbbe686844972ed624a0cd4ee6c8d65416ccfd916ee18fd5",
@@ -1659,8 +1749,8 @@
}, },
{ {
"path": "src/api/liveClient.ts", "path": "src/api/liveClient.ts",
"sha256": "12536177fde01cc870bc6aa24d31d922db9341ef2842c4ef244fe0cee4de98c3", "sha256": "f2cf782e3bbdcbe737dee523d47ba6f0d655048a8253a117525872486f8f1553",
"bytes": 62080 "bytes": 62680
}, },
{ {
"path": "src/api/userSession.test.ts", "path": "src/api/userSession.test.ts",
@@ -3054,8 +3144,8 @@
}, },
{ {
"path": "src/types/appops.ts", "path": "src/types/appops.ts",
"sha256": "5ca792943c3d25d8c6180911c46f1ed8e3ea5e3c1993d7542d01690e81030036", "sha256": "cffa840d76327eadf663ee7c896a366ad56d6cf860a0e0e9687ff7b3e14e2f3b",
"bytes": 73737 "bytes": 74110
}, },
{ {
"path": "src/utils/toast.ts", "path": "src/utils/toast.ts",
+88
View File
@@ -6,11 +6,13 @@ for command execution, Compose upload, arbitrary host paths or raw secrets.
from __future__ import annotations from __future__ import annotations
import re
from typing import Annotated, Literal from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Session from sqlmodel import Session
from starlette.concurrency import run_in_threadpool
from ..core.security import require_operator_request from ..core.security import require_operator_request
from ..db.session import get_session from ..db.session import get_session
@@ -22,6 +24,7 @@ from ..schemas.forge_secret_delivery import (
ForgeSecretDeliveryResultV1, ForgeSecretDeliveryResultV1,
) )
from ..schemas.forge_runtime_readiness import ForgeRuntimeReadinessResultV1 from ..schemas.forge_runtime_readiness import ForgeRuntimeReadinessResultV1
from ..schemas.forge_route_stage import ForgeRouteStageResultV1
from ..services.deployment_broker import DeploymentBrokerClient from ..services.deployment_broker import DeploymentBrokerClient
from ..services.deployment_control_plane import ( from ..services.deployment_control_plane import (
DeploymentControlError, DeploymentControlError,
@@ -56,6 +59,27 @@ class WorkMeshPromotionPreflightRequest(BaseModel):
DeploymentSession = Annotated[Session, Depends(get_session)] DeploymentSession = Annotated[Session, Depends(get_session)]
DeploymentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")] DeploymentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")]
DeploymentLimit = Annotated[int, Query(ge=1, le=250)] 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: def _idempotency_header(value: str | None) -> str:
@@ -232,6 +256,70 @@ def get_forge_first_install_readiness(
return ForgeBootstrapApplyService().current_readiness(session, job_id) 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("") @router.get("")
def list_deployments(session: DeploymentSession, limit: DeploymentLimit = 100): def list_deployments(session: DeploymentSession, limit: DeploymentLimit = 100):
return {"items": DeploymentControlPlane().list_jobs(session, limit=limit)} return {"items": DeploymentControlPlane().list_jobs(session, limit=limit)}
+353 -47
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import ipaddress
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import unquote, urlparse
import httpx import httpx
@@ -23,12 +25,172 @@ class _NpmConfig:
timeout_seconds: float timeout_seconds: float
def _as_bool(value: Any, default: bool = False) -> bool: class _NpmForgeRoutePermissionDenied(RuntimeError):
if isinstance(value, bool): """Fixed marker for a failed route-only permission preflight."""
return value
if value is None: def __init__(self) -> None:
return default super().__init__("npm_forge_route_permission_denied")
return str(value).strip().lower() in {'1', 'true', 'yes', 'on'}
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: 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: def _safe_url(value: Any = None) -> str:
raw = str(value or settings.apis.get('NPM_URL') or '').strip().rstrip('/') raw = value if value is not None else settings.apis.get('NPM_URL')
if not raw: try:
url = _validated_npm_url(raw)
_verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS'))
return url
except RuntimeError:
return '' return ''
parsed = urlparse(raw)
if not parsed.username and not parsed.password:
return raw def _validated_npm_url(value: Any) -> str:
host = parsed.hostname or '' if not isinstance(value, str) or not value or len(value) > 2048:
port = f':{parsed.port}' if parsed.port else '' raise RuntimeError('npm_url_invalid')
return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/') 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: def _parse_date(value: Any) -> datetime | None:
@@ -111,57 +353,107 @@ class NginxProxyManagerConnector(BaseConnector):
super().__init__() super().__init__()
self._runtime_token: str | None = None self._runtime_token: str | None = None
self._read_cache: dict[str, dict[str, Any]] = {} 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]: 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: if key not in self._read_cache:
self._read_cache[key] = factory() self._read_cache[key] = factory()
return self._read_cache[key] return self._read_cache[key]
def is_configured(self) -> bool: 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: def _config(self) -> _NpmConfig | None:
if not self.is_configured(): if not self.is_configured():
self._clear_runtime_context()
return None return None
return _NpmConfig( try:
url=str(settings.apis['NPM_URL']).rstrip('/'), url = _validated_npm_url(settings.apis.get('NPM_URL'))
identity=str(settings.apis['NPM_IDENTITY']), verify_tls = _verified_tls_setting(settings.apis.get('NPM_VERIFY_TLS'))
secret=str(settings.apis['NPM_SECRET']), identity = settings.apis.get('NPM_IDENTITY')
verify_tls=_as_bool(settings.apis.get('NPM_VERIFY_TLS'), False), 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), 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
def _login(self) -> str: def _login(self) -> str:
if self._runtime_token:
return self._runtime_token
config = self._config() config = self._config()
if not config: if not config:
raise RuntimeError('missing_config') raise RuntimeError('missing_config')
if self._runtime_token:
return self._runtime_token
try: try:
self.clear_error() 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( response = client.post(
f'{config.url}/api/tokens', f'{config.url}/api/tokens',
json={'identity': config.identity, 'secret': config.secret}, json={'identity': config.identity, 'secret': config.secret},
headers={'Accept': 'application/json'}, headers={'Accept': 'application/json'},
follow_redirects=False,
) )
except httpx.RequestError as exc: except httpx.RequestError:
message = redact_message(str(exc)) self.set_error('npm_request_failed')
self.set_error(message) raise RuntimeError('npm_request_failed') from None
raise RuntimeError(message) 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}: if response.status_code in {400, 401, 403}:
self.set_error('auth_failed') self.set_error('auth_failed')
raise RuntimeError('auth_failed') raise RuntimeError('auth_failed')
if response.status_code >= 400: if response.status_code >= 400:
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}') self.set_error('npm_api_error')
self.set_error(message) raise RuntimeError('npm_api_error')
raise RuntimeError(message)
try: try:
payload = response.json() payload = response.json()
except ValueError as exc: except ValueError:
message = redact_message(str(exc)) self.set_error('npm_invalid_response')
self.set_error(message) raise RuntimeError('npm_invalid_response') from None
raise RuntimeError(message)
token = payload.get('token') if isinstance(payload, dict) else None token = payload.get('token') if isinstance(payload, dict) else None
if not token: if not token:
self.set_error('auth_failed') self.set_error('auth_failed')
@@ -174,31 +466,39 @@ class NginxProxyManagerConnector(BaseConnector):
if not config: if not config:
raise RuntimeError('missing_config') raise RuntimeError('missing_config')
token = self._login() 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: 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( response = client.request(
method, method,
f'{config.url}/api{path}', f'{config.url}/api{path}',
headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'}, headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'},
**kwargs, **kwargs,
) )
except httpx.RequestError as exc: except httpx.RequestError:
message = redact_message(str(exc)) self.set_error('npm_request_failed')
self.set_error(message) raise RuntimeError('npm_request_failed') from None
raise RuntimeError(message) if 300 <= response.status_code < 400:
self.set_error('npm_redirect_denied')
raise RuntimeError('npm_redirect_denied')
if response.status_code in {401, 403}: if response.status_code in {401, 403}:
self.set_error('auth_failed') self.set_error('auth_failed')
raise RuntimeError('auth_failed') raise RuntimeError('auth_failed')
if response.status_code >= 400: if response.status_code >= 400:
message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}') self.set_error('npm_api_error')
self.set_error(message) raise RuntimeError('npm_api_error')
raise RuntimeError(message)
try: try:
return response.json() return response.json()
except ValueError as exc: except ValueError:
message = redact_message(str(exc)) self.set_error('npm_invalid_response')
self.set_error(message) raise RuntimeError('npm_invalid_response') from None
raise RuntimeError(message)
def test_connection(self) -> dict[str, Any]: def test_connection(self) -> dict[str, Any]:
if not self.is_configured(): if not self.is_configured():
@@ -215,6 +515,12 @@ class NginxProxyManagerConnector(BaseConnector):
return {'status': 'unreachable', 'reason': reason} return {'status': 'unreachable', 'reason': reason}
return {'status': 'connected', 'proxyHostCount': len(hosts)} 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]: def health(self) -> dict[str, Any]:
if not self.is_configured(): if not self.is_configured():
return self._missing(proxyHostCount=0, certificateCount=0) return self._missing(proxyHostCount=0, certificateCount=0)
+195
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import re
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -136,6 +137,200 @@ class PortainerConnector(BaseConnector):
return {'status': 'ok', 'containers': containers} 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: def _attach_container_stats(self, containers: list[dict[str, Any]], stats_limit: int) -> None:
candidates = [ candidates = [
container container
+2 -2
View File
@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
import os import os
import stat
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import stat
from typing import Any from typing import Any
@@ -272,7 +272,7 @@ class Settings:
"NPM_URL": _env_value("NPM_URL", None), "NPM_URL": _env_value("NPM_URL", None),
"NPM_IDENTITY": _env_value("NPM_IDENTITY", None), "NPM_IDENTITY": _env_value("NPM_IDENTITY", None),
"NPM_SECRET": _env_value("NPM_SECRET", 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_TIMEOUT_SECONDS": _env_value("NPM_TIMEOUT_SECONDS", "10"),
"NPM_LETSENCRYPT_EMAIL": _env_value("NPM_LETSENCRYPT_EMAIL", None), "NPM_LETSENCRYPT_EMAIL": _env_value("NPM_LETSENCRYPT_EMAIL", None),
"AUTHENTIK_URL": _env_value("AUTHENTIK_URL", None), "AUTHENTIK_URL": _env_value("AUTHENTIK_URL", None),
+1
View File
@@ -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-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-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-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."),
] ]
+117 -1
View File
@@ -46,8 +46,8 @@ from .services.policy_engine import evaluate_policy
from .services.privatemuse_artifact_installer import ArtifactInstallError from .services.privatemuse_artifact_installer import ArtifactInstallError
from .services.privatemuse_artifact_installer import install as install_artifact_file from .services.privatemuse_artifact_installer import install as install_artifact_file
from .services.privatemuse_artifact_installer import preflight as artifact_preflight 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.runner_statistics import RunnerStatisticsService
from .services.runtime_service import runtime_info
_SESSION_RUNNING = False _SESSION_RUNNING = False
_SESSION_VERIFIED_AT: str | None = None _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)) 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) @appops_mcp.tool(name="server.operation.execute", annotations=_LEGACY_APPROVAL_EXECUTION_TOOL)
@_tracked("server.operation.execute") @_tracked("server.operation.execute")
def execute_operation( def execute_operation(
+48
View File
@@ -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
+42 -17
View File
@@ -1555,7 +1555,11 @@ def _execute_controller_artifact_promote(
reason: str, reason: str,
original_policy_decision: str, original_policy_decision: str,
) -> dict[str, Any]: ) -> 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" action = "controller.artifact.promote"
target = "tower/deployment-controller" target = "tower/deployment-controller"
@@ -1595,12 +1599,43 @@ def _execute_controller_artifact_promote(
success = False success = False
message = f"{type(exc).__name__}: controller artifact promotion failed" 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( completed = complete_job(
session, session,
job, job,
status="succeeded" if success else "failed", status="succeeded" if success else "failed",
result_summary=message, 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( audit = log_event(
session, session,
source="action", source="action",
@@ -1613,23 +1648,10 @@ def _execute_controller_artifact_promote(
reason=reason, reason=reason,
result="success" if success else "failed", result="success" if success else "failed",
execution_plan={"steps": steps, "jobId": completed.id}, execution_plan={"steps": steps, "jobId": completed.id},
after_state={ after_state=after_state,
"status": status, redacted_metadata=redacted_metadata,
"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,
},
) )
return { operation_result = {
"success": success, "success": success,
"status": "success" if success else status, "status": "success" if success else status,
"result": "success" if success else "failed", "result": "success" if success else "failed",
@@ -1642,6 +1664,9 @@ def _execute_controller_artifact_promote(
"controllerVersion": result.get("controllerVersion"), "controllerVersion": result.get("controllerVersion"),
"artifactSha256": result.get("artifactSha256"), "artifactSha256": result.get("artifactSha256"),
} }
if diagnostics is not None:
operation_result["diagnostics"] = diagnostics
return operation_result
def _execute_runner_fleet_upgrade_v2( def _execute_runner_fleet_upgrade_v2(
+4
View File
@@ -178,6 +178,7 @@ ACTIONS = [
'authentik.unknown_mutation', 'authentik.unknown_mutation',
'forge.oidc.provision', 'forge.oidc.provision',
'forge.secrets.prepare', 'forge.secrets.prepare',
'forge.route.stage',
'tautulli.terminate_session', 'tautulli.terminate_session',
'tautulli.delete_newsletter', 'tautulli.delete_newsletter',
'tautulli.delete_notification_log', 'tautulli.delete_notification_log',
@@ -390,6 +391,7 @@ ACTION_RISK = {
'authentik.unknown_mutation': 'critical', 'authentik.unknown_mutation': 'critical',
'forge.oidc.provision': 'critical', 'forge.oidc.provision': 'critical',
'forge.secrets.prepare': 'critical', 'forge.secrets.prepare': 'critical',
'forge.route.stage': 'critical',
'tautulli.terminate_session': 'high', 'tautulli.terminate_session': 'high',
'tautulli.delete_newsletter': 'critical', 'tautulli.delete_newsletter': 'critical',
'tautulli.delete_notification_log': 'critical', 'tautulli.delete_notification_log': 'critical',
@@ -539,6 +541,8 @@ def action_default_target(action: str) -> str:
return 'itworx-forge' return 'itworx-forge'
if action == 'forge.secrets.prepare': if action == 'forge.secrets.prepare':
return 'itworx-forge' return 'itworx-forge'
if action == 'forge.route.stage':
return 'itworx-forge'
if action == 'controller.artifact.promote': if action == 'controller.artifact.promote':
return 'tower/deployment-controller' return 'tower/deployment-controller'
if action == 'controller.managed-storage.initialize': if action == 'controller.managed-storage.initialize':
@@ -43,24 +43,68 @@ def helper_container_spec(
raise ValueError("controller_promoter_bootstrap_binding_invalid") raise ValueError("controller_promoter_bootstrap_binding_invalid")
command = f"""set -Eeuo pipefail 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 umask 077
revision='{revision}' revision='{revision}'
version='{CONTROLLER_VERSION}' version='{CONTROLLER_VERSION}'
image_id='{image_id}' image_id='{image_id}'
repo='/gitea-repositories/jens/unraid-appops-gateway.git' repo='/gitea-repositories/jens/unraid-appops-gateway.git'
[[ \"$(stat -c '%a:%u:%g' /tmp)\" == '1777:0:0' ]] phase=tmp_root
work=\"$(mktemp -d -p /tmp itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX)\" [[ \"$(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' ]] [[ -d \"$work\" && ! -L \"$work\" && \"$(stat -c '%u:%a' \"$work\")\" == '0:700' ]]
cleanup() {{ rm -rf -- \"$work\"; }}
trap cleanup EXIT trap cleanup EXIT
phase=tool_git
command -v git >/dev/null command -v git >/dev/null
phase=tool_docker
command -v docker >/dev/null command -v docker >/dev/null
phase=tool_compose
docker compose version >/dev/null docker compose version >/dev/null
phase=tool_python
command -v python3 >/dev/null command -v python3 >/dev/null
phase=tool_tar
command -v tar >/dev/null command -v tar >/dev/null
phase=tool_curl
command -v curl >/dev/null command -v curl >/dev/null
phase=source_commit
git --git-dir=\"$repo\" cat-file -e \"$revision^{{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\" \\ git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\
ops/gitea-deploy-broker \\ ops/gitea-deploy-broker \\
scripts/prepare_controller_artifact.py \\ scripts/prepare_controller_artifact.py \\
@@ -69,10 +113,15 @@ git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\
scripts/vendor \\ scripts/vendor \\
scripts/run_repopulse_adoption.py \ scripts/run_repopulse_adoption.py \
scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\" scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"
phase=archive_permissions
chmod 0600 \"$work/controller.tar\" 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\" git --git-dir=\"$repo\" show \"$revision:scripts/upgrade_deployment_controller.sh\" > \"$work/upgrade.sh\"
phase=upgrade_permissions
chmod 0700 \"$work/upgrade.sh\" chmod 0700 \"$work/upgrade.sh\"
phase=upgrade
bash \"$work/upgrade.sh\" \\ bash \"$work/upgrade.sh\" \\
--archive \"$work/controller.tar\" \\ --archive \"$work/controller.tar\" \\
--archive-sha256 \"$archive_sha\" \\ --archive-sha256 \"$archive_sha\" \\
@@ -10,16 +10,17 @@ import httpx
from ..connectors.portainer import PortainerConnector from ..connectors.portainer import PortainerConnector
from ..core.config import settings from ..core.config import settings
from ..core.redaction import redact_message
from .controller_artifact_helper_spec import ( from .controller_artifact_helper_spec import (
_IMAGE_ID,
_REVISION,
CONTROLLER_VERSION, CONTROLLER_VERSION,
HELPER_LABEL, HELPER_LABEL,
HELPER_NAME, HELPER_NAME,
SOURCE_REPOSITORY as SOURCE_REPOSITORY,
_IMAGE_ID,
_REVISION,
helper_container_spec, helper_container_spec,
) )
from .controller_artifact_helper_spec import (
SOURCE_REPOSITORY as SOURCE_REPOSITORY,
)
from .deployment_broker import DeploymentBrokerClient from .deployment_broker import DeploymentBrokerClient
GITEA_OWNER = "Jens" GITEA_OWNER = "Jens"
@@ -28,6 +29,132 @@ REQUIRED_PUSH_CONTEXT = "Managed validation / full (push)"
CONTROLLER_CONTAINER = "gitea-deploy-control" CONTROLLER_CONTAINER = "gitea-deploy-control"
_HELPER_TIMEOUT_SECONDS = 240 _HELPER_TIMEOUT_SECONDS = 240
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$") _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): class ControllerArtifactPromotionError(RuntimeError):
@@ -257,10 +384,29 @@ class ControllerArtifactPromoter:
} }
exit_code = state.get("ExitCode") exit_code = state.get("ExitCode")
logs = self.portainer.get_container_logs(helper_id, tail=200) if type(exit_code) is not int or exit_code != 0:
log_lines = logs.get("logs") if isinstance(logs, dict) and isinstance(logs.get("logs"), list) else [] try:
tail = "\n".join(str(line) for line in log_lines[-40:])[-4000:] logs_response = self.portainer._request(
if exit_code != 0: "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: try:
self._remove_helper(helper_id) self._remove_helper(helper_id)
except RuntimeError: except RuntimeError:
@@ -269,15 +415,14 @@ class ControllerArtifactPromoter:
"status": "failed", "status": "failed",
"revision": revision, "revision": revision,
"reason": "controller_promoter_helper_failed", "reason": "controller_promoter_helper_failed",
"exitCode": exit_code, "diagnostics": diagnostics,
"logTail": redact_message(tail),
} }
try: try:
self._remove_helper(helper_id) self._remove_helper(helper_id)
except RuntimeError: except RuntimeError:
pass pass
return {"status": "promoted", "revision": revision, "logTail": redact_message(tail)} return {"status": "promoted", "revision": revision}
def run(self) -> dict[str, Any]: def run(self) -> dict[str, Any]:
revision = self._validated_main_revision() revision = self._validated_main_revision()
+28 -3
View File
@@ -214,9 +214,7 @@ class DeploymentBrokerClient:
} }
for field in error_response_fields: for field in error_response_fields:
value = decoded.get(field) value = decoded.get(field)
if isinstance(value, str) and len(value) <= 256: if isinstance(value, str) and len(value) <= 256 or field == "noMutation" and isinstance(value, bool):
rejected[field] = value
elif field == "noMutation" and isinstance(value, bool):
rejected[field] = value rejected[field] = value
return rejected return rejected
return {"status": str(decoded.get("status") or "ok"), **decoded} return {"status": str(decoded.get("status") or "ok"), **decoded}
@@ -279,6 +277,33 @@ class DeploymentBrokerClient:
"reason": reason, "expectedCurrentRevision": expected_current_revision}, "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( def appops_self_upgrade_apply(
self, revision: str, reason: str, expected_current_revision: str | None, self, revision: str, reason: str, expected_current_revision: str | None,
preflight_sha256: str, idempotency_key: str, preflight_sha256: str, idempotency_key: str,
@@ -26,6 +26,7 @@ import fcntl
import json import json
import os import os
from pathlib import Path from pathlib import Path
import re
import stat import stat
import subprocess import subprocess
import sys 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") lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")
repository, revision, branch, raw_project = sys.argv[1:5] repository, revision, branch, raw_project = sys.argv[1:5]
project = json.loads(raw_project) project = json.loads(raw_project)
requested_project = project
strengthening_deadline = time.monotonic() + 20
def _validation_compatible(repository, validation): def _validation_compatible(repository, validation):
expected_validation = { expected_validation = {
@@ -58,6 +61,52 @@ def _validation_compatible(repository, validation):
"contexts": ["CI / gates (push)", "Managed validation / full (push)"], "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): for directory in (path.parent, host_path.parent, host_path.parent.parent):
info = directory.lstat() info = directory.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022: 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) raise SystemExit(24)
def git_run(*args): 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( 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, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
@@ -168,12 +225,34 @@ with lock_handle as lock:
"gitea_repository_id", "gitea_repository_id",
"gitea_webhook_secret_file", "gitea_webhook_secret_file",
"validation", "validation",
"legacy_containers",
} }
extra_keys = set(current) - set(project) extra_keys = set(current) - set(project)
if not extra_keys.issubset(allowed_extra_keys): if not extra_keys.issubset(allowed_extra_keys):
raise SystemExit(23) raise SystemExit(23)
canonical_current = {key: current.get(key) for key in project} immutable_keys = set(project) - {"source_dir", "exact_source", "verify_oci_revision", "health_timeout"}
if canonical_current != project: 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) raise SystemExit(23)
env_source = current.get("env_source") env_source = current.get("env_source")
if env_source is not None: if env_source is not None:
@@ -222,6 +301,15 @@ with lock_handle as lock:
if not _validation_compatible(repository, validation): if not _validation_compatible(repository, validation):
raise SystemExit(23) 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: if changed:
try: try:
backup_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state/registry-backups") backup_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state/registry-backups")
@@ -266,11 +354,17 @@ with lock_handle as lock:
raise SystemExit(33) raise SystemExit(33)
try: 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 = mutation_path.lstat()
latest_identity = (latest.st_dev, latest.st_ino, latest.st_mode, latest.st_uid, 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) 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): if latest_identity != registry_identity or mutation_path.read_bytes() != original or not os.path.samefile(path, mutation_path):
raise SystemExit(34) 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) os.replace(temporary, mutation_path)
except Exception: except Exception:
try: try:
@@ -429,6 +523,8 @@ class DeploymentTargetRegistry:
verify_oci_revision: bool = False, verify_oci_revision: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
if type(verify_oci_revision) is not bool:
raise ValueError("OCI revision verification must be a boolean")
( (
repository, repository,
revision, revision,
@@ -561,6 +657,7 @@ class DeploymentTargetRegistry:
36: "deployment projects registry host alias is unavailable or does not match the controller registry", 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", 37: "controller project branch does not match the requested default branch",
38: "controller project Compose contract is invalid", 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: if exit_code in preflight_reasons:
return { return {
+173 -33
View File
@@ -15,6 +15,7 @@ import secrets
import uuid import uuid
from typing import Any, Literal from typing import Any, Literal
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
from ..connectors.authentik import AuthentikConnector from ..connectors.authentik import AuthentikConnector
@@ -46,6 +47,20 @@ _CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "ap
_PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"} _PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"}
_OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"} _OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"}
_APPDATA_ROOT_STATUSES = {"ready", "needs_initialization", "unavailable"} _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( def _trusted_appdata_roots_readiness(
@@ -57,7 +72,7 @@ def _trusted_appdata_roots_readiness(
if isinstance(reported, list): if isinstance(reported, list):
if len(reported) != len(roots): if len(reported) != len(roots):
return [{"root": root, "status": unavailable[root]} for root in 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: for item in reported:
if not isinstance(item, dict): if not isinstance(item, dict):
return [{"root": root, "status": unavailable[root]} for root in roots] 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 or status not in _APPDATA_ROOT_STATUSES
): ):
return [{"root": expected, "status": unavailable[expected]} for expected in roots] 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): if set(statuses) != set(roots):
return [{"root": root, "status": unavailable[root]} for root in 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 # A legacy V1 controller reports only its configured root. It cannot attest
# independent managed-volume readiness, so only its exact legacy root can # 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") controller_root_matches = bool(selected_root and selected_root.get("status") == "ready")
else: else:
controller_root_matches = False controller_root_matches = False
broker_ready = bool( controller_core_ready = bool(
bootstrap bootstrap
and self.broker.is_configured() and self.broker.is_configured()
and controller_capability.get("status") == "ready" 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 controller_root_matches
and enrollment_features_ready
) )
controller_managed_storage = controller_capability.get("managedStorage") controller_managed_storage = controller_capability.get("managedStorage")
if not isinstance(controller_managed_storage, dict): if not isinstance(controller_managed_storage, dict):
@@ -248,6 +296,41 @@ class EnrollmentPlane:
else: else:
managed_storage_status = "unavailable" managed_storage_status = "unavailable"
managed_storage_blocker = "managed_appdata_volume_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() npm_ready = self.npm.is_configured()
oidc_configured = bool( oidc_configured = bool(
trust trust
@@ -262,18 +345,44 @@ class EnrollmentPlane:
def item(ready: bool, code: str) -> dict[str, str]: def item(ready: bool, code: str) -> dict[str, str]:
return {"status": "ready" if ready else "unavailable", "blockerCode": "" if ready else code} 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 { return {
"apiVersion": "itworx.enrollment-capabilities/v1", "apiVersion": "itworx.enrollment-capabilities/v1",
"trust": item(configured, "enrollment_trust_not_configured"), "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"), "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"), "reverseProxyProvisioning": item(npm_ready, "npm_credentials_missing"),
"tlsProvisioning": item(certificate_ready, "certificate_provisioning_unavailable"), "tlsProvisioning": item(certificate_ready, "certificate_provisioning_unavailable"),
"oidcProvisioning": item(oidc_configured, "oidc_profile_unavailable"), "oidcProvisioning": item(oidc_configured, "oidc_profile_unavailable"),
"policyProvisioning": item(configured, "policy_template_unavailable"), "policyProvisioning": item(configured, "policy_template_unavailable"),
"rollback": item(broker_ready, "deployment_executor_unavailable"), "rollback": item(rollback_ready, "deployment_executor_unavailable"),
"verification": item(broker_ready, "deployment_executor_unavailable"), "verification": item(verify_ready, "deployment_executor_unavailable"),
"availableTrustedAppdataRoots": available_roots, "availableTrustedAppdataRoots": available_roots,
"managedStorage": { "managedStorage": {
"status": managed_storage_status, "status": managed_storage_status,
@@ -283,19 +392,23 @@ class EnrollmentPlane:
"backupAcknowledged": bool( "backupAcknowledged": bool(
trust and trust.managed_appdata_backup_acknowledged trust and trust.managed_appdata_backup_acknowledged
), ),
}, **(
"controller": { {"reason": _safe_capability_reason(controller_managed_storage.get("reason"))}
"status": "ready" if broker_ready else "unavailable", if trust is not None
"reason": ( and trust.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT
"" and _safe_capability_reason(controller_managed_storage.get("reason"))
if broker_ready else {}
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"
))
), ),
}, },
"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")], "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): 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.")) 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() containers = self.portainer.list_containers()
if containers.get("status") != "ok": if containers.get("status") != "ok":
checks.append(_check("container_inventory_unavailable", "blocked", "Portainer inventory is unavailable for collision protection.")) checks.append(_check("container_inventory_unavailable", "blocked", "Portainer inventory is unavailable for collision protection."))
@@ -676,17 +801,25 @@ class EnrollmentPlane:
"sourceRevision": plan.revision, "sourceRevision": plan.revision,
"createdAt": utc_now().isoformat(), "createdAt": utc_now().isoformat(),
} }
identifiers: list[str] = [] policy_specs = (
for action, suffix in (("deployment.apply", "deployment-apply"), ("deployment.rollback", "deployment-rollback")): (
policy_id = f"enrollment-{plan.application_id}-{suffix}" "deployment.apply",
existing = session.get(Policy, policy_id) f"enrollment-{plan.application_id}-deployment-apply",
if existing is not None: ),
# Enrollment never adopts or repurposes a pre-existing policy, (
# even where its visible action/target happen to match. A "deployment.rollback",
# replay is handled before this method; this branch is a f"enrollment-{plan.application_id}-deployment-rollback",
# collision, not a safe recovery path. ),
)
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") raise EnrollmentError("exact policy identifier is already owned and cannot be adopted")
existing = Policy(
for action, policy_id in policy_specs:
policy = Policy(
id=policy_id, id=policy_id,
action=action, action=action,
target=plan.application_id, target=plan.application_id,
@@ -695,10 +828,17 @@ class EnrollmentPlane:
risk="high", risk="high",
reason="Enrollment-generated exact-target deployment policy.", reason="Enrollment-generated exact-target deployment policy.",
) )
existing.provenance_json = provenance policy.provenance_json = provenance
session.add(existing) session.add(policy)
identifiers.append(policy_id) try:
session.commit() # 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 return identifiers
def _compensate( def _compensate(
+835
View File
@@ -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")
@@ -28,6 +28,7 @@ _COMPOSE_CANDIDATES = (
) )
# Must remain identical to DeploymentPlanV1.application_id. # Must remain identical to DeploymentPlanV1.application_id.
_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$") _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"} _TRANSIENT_STATES = {"gitea_unavailable", "controller_unavailable", "registration_error"}
_RETRY_STATES = _TRANSIENT_STATES | {"controller_conflict", "policy_conflict"} _RETRY_STATES = _TRANSIENT_STATES | {"controller_conflict", "policy_conflict"}
_DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback", "deployment.runnerless.provision") _DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback", "deployment.runnerless.provision")
@@ -208,6 +209,7 @@ class GiteaProjectAutoRegistration:
"composeFile", "composeFile",
"composeProject", "composeProject",
"healthTimeout", "healthTimeout",
"primaryService",
"verifyOciRevision", "verifyOciRevision",
} }
unknown = set(payload) - allowed unknown = set(payload) - allowed
@@ -223,14 +225,18 @@ class GiteaProjectAutoRegistration:
raise ValueError("deployment descriptor applicationId is invalid") raise ValueError("deployment descriptor applicationId is invalid")
compose_file = str(payload.get("composeFile") or "").strip() compose_file = str(payload.get("composeFile") or "").strip()
compose_project = str(payload.get("composeProject") or application_id).strip().lower() compose_project = str(payload.get("composeProject") or application_id).strip().lower()
if compose_project != application_id: if not _COMPOSE_PROJECT.fullmatch(compose_project):
raise ValueError("deployment descriptor composeProject must equal applicationId") 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) timeout = int(payload.get("healthTimeout") or 180)
return { return {
"applicationId": application_id, "applicationId": application_id,
"composeFile": compose_file, "composeFile": compose_file,
"composeProject": compose_project, "composeProject": compose_project,
"healthTimeout": timeout, "healthTimeout": timeout,
"primaryService": primary_service,
"verifyOciRevision": bool(payload.get("verifyOciRevision", False)), "verifyOciRevision": bool(payload.get("verifyOciRevision", False)),
} }
@@ -303,8 +309,9 @@ class GiteaProjectAutoRegistration:
if descriptor_present: if descriptor_present:
application_id = str(descriptor["applicationId"]) application_id = str(descriptor["applicationId"])
if application_id not in service_names: primary_service = str(descriptor["primaryService"])
raise ValueError("deployment descriptor applicationId must name a Compose service") if primary_service not in service_names:
raise ValueError("deployment descriptor primaryService must name a Compose service")
else: else:
repository_id = self._application_id(repository_name) repository_id = self._application_id(repository_name)
compose_name = str(compose.get("name") or "").strip().lower() if isinstance(compose, dict) else "" 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. # explicit bounded descriptor to select its primary service.
return None return None
application_id = candidates[0] application_id = candidates[0]
primary_service = application_id
compose_project = application_id compose_project = str(descriptor.get("composeProject") or application_id)
containers: list[str] = [] containers: list[str] = []
for service_name, raw_service in services.items(): for service_name, raw_service in services.items():
service = raw_service if isinstance(raw_service, dict) else {} service = raw_service if isinstance(raw_service, dict) else {}
@@ -334,6 +342,7 @@ class GiteaProjectAutoRegistration:
"applicationId": application_id, "applicationId": application_id,
"composeFile": compose_file, "composeFile": compose_file,
"composeProject": compose_project, "composeProject": compose_project,
"primaryService": primary_service,
"containers": containers, "containers": containers,
"serviceNames": service_names, "serviceNames": service_names,
"healthTimeout": int(descriptor.get("healthTimeout") or 180), "healthTimeout": int(descriptor.get("healthTimeout") or 180),
@@ -510,6 +519,7 @@ class GiteaProjectAutoRegistration:
"applicationId": application_id, "applicationId": application_id,
"composeFile": contract["composeFile"], "composeFile": contract["composeFile"],
"composeProject": contract["composeProject"], "composeProject": contract["composeProject"],
"primaryService": contract.get("primaryService", application_id),
"containers": list(contract["containers"]), "containers": list(contract["containers"]),
"serviceNames": list(contract["serviceNames"]), "serviceNames": list(contract["serviceNames"]),
} }
@@ -337,17 +337,33 @@ def fixed_image_reference(service_name, image_ref):
) )
) )
if service_name == "postgres": if service_name == "postgres":
return image_ref == "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a" or ( if image_ref == "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a":
image_ref == "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04" return True
and main_declares_external_image(image_ref) 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": if service_name == "ingestion-clamd":
return image_ref == "clamav/clamav:1.4.6@sha256:a5f03c12a79dbe9f6d8a527b6bb1ea053fa8dd061d3738a26897f055ee2d9303" return image_ref == "clamav/clamav:1.4.6@sha256:a5f03c12a79dbe9f6d8a527b6bb1ea053fa8dd061d3738a26897f055ee2d9303"
if service_name == "redis": if service_name == "redis":
return image_ref == "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236" or ( if image_ref == "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236":
image_ref == "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48" return True
and main_declares_external_image(image_ref) 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": if service_name == "contextforge":
return main_declares_contextforge_compat_image( return main_declares_contextforge_compat_image(
"itworx-mcp-hub-contextforge-1", image_ref "itworx-mcp-hub-contextforge-1", image_ref
+23
View File
@@ -187,6 +187,29 @@ def evaluate_policy(
"scope_decision": "allowed" if allowed else None, "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: if action in _DEFAULT_DECISIONS:
reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.') reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.')
return { return {
+1 -1
View File
@@ -42,7 +42,7 @@ def _set_npm_config():
settings.apis["NPM_URL"] = "https://npm.example.test" settings.apis["NPM_URL"] = "https://npm.example.test"
settings.apis["NPM_IDENTITY"] = "admin@example.test" settings.apis["NPM_IDENTITY"] = "admin@example.test"
settings.apis["NPM_SECRET"] = "NPM_SECRET_VALUE" 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" settings.apis["NPM_TIMEOUT_SECONDS"] = "10"
@@ -1,12 +1,15 @@
from __future__ import annotations from __future__ import annotations
import ast import ast
import io
import json import json
import os import os
import re import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tarfile
from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -24,6 +27,10 @@ CONTROLLER_IMAGE_ID = "sha256:c474d435a4f55ac5205570da0caa47e103f89a6c2ef96ea458
CONTROLLER_IMAGE_REF = "docker.gitea.com/runner-images:ubuntu-latest" CONTROLLER_IMAGE_REF = "docker.gitea.com/runner-images:ubuntu-latest"
CONTROLLER_REVISION = "034c9f844d2455850ba37b31379b7edaa786583e" CONTROLLER_REVISION = "034c9f844d2455850ba37b31379b7edaa786583e"
CONTROLLER_TREE = "6b8e92942e6e5eca8a9aa9da0a1509b2019d7b7b" CONTROLLER_TREE = "6b8e92942e6e5eca8a9aa9da0a1509b2019d7b7b"
MODERN_CONTROLLER_CONTAINER_ID = "9093c8e29d9064f9948918e5884f9adc5db6b740991e2934902f93c6ea813e01"
MODERN_CONTROLLER_REVISION = "e854334923cca574482d22b8424ba3ce46ec6aa1"
MODERN_CONTROLLER_TREE = "cf2a117f9222b952deb9902c646116612f3a7ea0"
MODERN_CONTROLLER_ARTIFACT_SHA256 = "e88739bf091c8bac77ea0db5b97300666a1baa8ba984bbf6ae91e3bf25719e08"
REVISION = "a" * 40 REVISION = "a" * 40
TREE = "b" * 40 TREE = "b" * 40
CALLER_ID = "c" * 64 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 = _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"]["Image"] = CONTROLLER_IMAGE_ID
controller["Config"]["Labels"] = _modern_controller_labels() controller["Config"]["Labels"] = _modern_controller_labels()
controller["Mounts"] = _modern_controller_mounts(source_revision) controller["Mounts"] = _modern_controller_mounts(source_revision)
return controller 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]: def _appops_inspect() -> dict[str, Any]:
labels = _appops_labels() labels = _appops_labels()
return { return {
@@ -480,7 +511,9 @@ class FakeDocker:
def start_container(self, identifier: str) -> None: def start_container(self, identifier: str) -> None:
self.started.append(identifier) self.started.append(identifier)
source_revision = self.created[-1]["Labels"]["itworx.source.revision"] 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: def remove_retired_failed_container(self, identifier: str) -> None:
assert identifier == RETIRED_HELPER_ID assert identifier == RETIRED_HELPER_ID
@@ -505,6 +538,8 @@ class FakeProtectedGit:
self.main_checks = 0 self.main_checks = 0
self.fail_current_main_at: int | None = None self.fail_current_main_at: int | None = None
self.retired_tree_valid = True 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: def verify_current_main(self, revision: str, tree: str) -> None:
self.main_checks += 1 self.main_checks += 1
@@ -517,6 +552,11 @@ class FakeProtectedGit:
if (revision, tree) != (LEGACY_REVISION, LEGACY_TREE): if (revision, tree) != (LEGACY_REVISION, LEGACY_TREE):
raise bootstrap.BootstrapError("controller_promoter_legacy_provenance_invalid") 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: def verify_retired_modern_revision(self, revision: str, tree: str) -> None:
if (revision, tree) != ( if (revision, tree) != (
bootstrap.RETIRED_FAILED_MODERN_REVISION, 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( def _run_bootstrap(
docker: FakeDocker, docker: FakeDocker,
git: FakeProtectedGit, 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]: def _retired_failed_child(helper_source: str) -> dict[str, Any]:
spec = bootstrap.render_modern_helper_spec( spec = bootstrap.render_modern_helper_spec(
helper_source, 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(): def test_immutable_legacy_helper_source_fixture_passes_bootstrap_identity_guard():
legacy_spec = _legacy_helper_spec() legacy_spec = _legacy_helper_spec()
docker = FakeDocker(legacy_spec) docker = FakeDocker(legacy_spec)
@@ -1002,7 +1302,13 @@ def test_exact_failed_historical_child_is_removed_before_current_helper_launch()
result = _run_bootstrap( result = _run_bootstrap(
docker, docker,
git, 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" assert result["status"] == "promoted"
@@ -1015,6 +1321,174 @@ def test_exact_failed_historical_child_is_removed_before_current_helper_launch()
) == 1 ) == 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( @pytest.mark.parametrize(
"mutation", "mutation",
[ [
@@ -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
@@ -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)
@@ -24,6 +24,29 @@ spec.loader.exec_module(module)
REVISION = "a" * 40 REVISION = "a" * 40
IMAGE_ID = "sha256:" + "b" * 64 IMAGE_ID = "sha256:" + "b" * 64
ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n" 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 = ( HISTORICAL_CONFIG_ROOT_RELEASE_FILES = (
"controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py", "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", "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 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): def writer_inventory(inherited_labels):
identifier = "c" * 64 identifier = "c" * 64
spec = module._writer_spec(IMAGE_ID, REVISION, kind="config") 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( @pytest.mark.parametrize(
("target", "bind"), ("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(): def test_complete_forge_secret_bundle_has_exact_export_modes_and_owners():
entries = { entries = {
'projects.json': (b'{}', 0o600), 'projects.json': (b'{}', 0o600),
@@ -931,7 +1363,7 @@ def test_forge_secret_tar_owner_drift_is_rejected():
module._read_tar_tree(output.getvalue()) 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 = ( tree = (
{ {
'projects.json': (b'{}', 0o600), '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}, {'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700},
) )
writer_id = 'f' * 64 writer_id = 'f' * 64
expected_archive = module._release_tar_bytes('a' * 40, tree)
commands = [] commands = []
copy_payloads = [] copy_payloads = []
copy_owners = {}
def run(arguments, *, data=None, **_kwargs): def run(arguments, *, data=None, **_kwargs):
commands.append(arguments) commands.append(arguments)
@@ -953,11 +1387,17 @@ def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypat
return b'0' return b'0'
if arguments[:2] == ['docker', 'cp']: if arguments[:2] == ['docker', 'cp']:
copy_payloads.append(data) 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'' return b''
monkeypatch.setattr(module, '_read_partial_config_leaf', lambda: None) monkeypatch.setattr(module, '_read_partial_config_leaf', lambda: None)
monkeypatch.setattr(module, 'ensure_volume', lambda **_kwargs: 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, '_remove_stale_writer', lambda *_args, **_kwargs: None)
monkeypatch.setattr(module, '_writer_spec', lambda *_args, **_kwargs: ['docker', 'create']) monkeypatch.setattr(module, '_writer_spec', lambda *_args, **_kwargs: ['docker', 'create'])
monkeypatch.setattr(module, '_inspect_writer', lambda *_args, **_kwargs: {}) 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) module._install_config_tree(tree, IMAGE_ID)
assert [command for command in commands if command[:2] == ['docker', 'cp']] == [[ 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( 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 module.WRITER_NAME in command
assert IMAGE_ID in command assert IMAGE_ID in command
assert "--network" in command and command[command.index("--network") + 1] == "none" 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 "--read-only" in command and "--cap-drop" in command
assert "--cpu-quota" in command and "--memory" in command and "--pids-limit" in command assert "--cpu-quota" in command and "--memory" in command and "--pids-limit" in command
assert "--mount" 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) 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): def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose):
baseline, calls = compose baseline, calls = compose
rendered = module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID) 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 = importlib.util.module_from_spec(renderer_spec)
renderer_spec.loader.exec_module(renderer) renderer_spec.loader.exec_module(renderer)
assert tuple(module.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES) assert tuple(module.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES)
assert len(renderer._ARTIFACT_FILES) == len(set(renderer._ARTIFACT_FILES))
assert module.RELEASE_FILES[:3] == ( assert module.RELEASE_FILES[:3] == (
"controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py" "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] == ( assert module.RELEASE_FILES[module.RELEASE_FILES.index("forge_first_install.py") + 1] == (
"forge_database_credentials.py" "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( assert module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER == tuple(
name for name in module.RELEASE_FILES 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( assert module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER == tuple(
name for name in module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER 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(): def test_current_release_digest_binds_the_role_password_helper():
entries = {name: (f"source:{name}".encode(), 0o644) for name in module.RELEASE_FILES} entries = {name: (f"source:{name}".encode(), 0o644) for name in module.RELEASE_FILES}
expected = module._artifact_digest(entries) 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" git_outputs[other_path] = b"tampered protected Git source"
with pytest.raises(module.VolumeTransitionError, match="source_mismatch"): with pytest.raises(module.VolumeTransitionError, match="source_mismatch"):
module._verify_git_source_identity(entries, identity, controller_id=controller_id) 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)
@@ -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
+211 -13
View File
@@ -764,6 +764,41 @@ def test_appops_legacy_intent_accepts_only_the_reviewed_dockerfile_blob_pair(
deploy_module._appops_validate_intent_payload(unknown_update) 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( def test_appops_cli_rollback_retries_after_state_write_crash_without_synthetic_receipt(
deploy_module, monkeypatch, tmp_path, capsys 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 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): def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None):
"""Build the reviewed DB and Dockerfile transitions without old Git history.""" """Build the reviewed DB and Dockerfile transitions without old Git history."""
repository = tmp_path / "reviewed-source.git" repository = tmp_path / "reviewed-source.git"
@@ -1601,6 +1677,7 @@ def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None):
forge_seed_rows = ( 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-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-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: for row in forge_seed_rows:
assert current_init.count(row) == 1 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_tree = git("rev-parse", f"{old_revision}^{{tree}}")
old_database_tree = git("rev-parse", f"{old_revision}:backend/app/db") 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": if candidate_mutation == "extra_database_file":
(db / "unreviewed.py").write_text("UNREVIEWED = True\n", encoding="utf-8") (db / "unreviewed.py").write_text("UNREVIEWED = True\n", encoding="utf-8")
elif candidate_mutation == "models_content": 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": elif candidate_mutation == "session_content":
(db / "session.py").write_bytes(normalize(current_db / "session.py") + b"\n# unreviewed\n") (db / "session.py").write_bytes(normalize(current_db / "session.py") + b"\n# unreviewed\n")
elif candidate_mutation == "init_db_content": 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": 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"',
b'action="forge.oidc.provision-unreviewed"', b'action="forge.oidc.provision-unreviewed"',
1, 1,
) )
assert changed_init != current_init assert changed_init != current_init
(db / "init_db.py").write_bytes(changed_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": elif candidate_mutation == "database_file_mode":
(db / "session.py").chmod(0o755) (db / "session.py").chmod(0o755)
elif candidate_mutation == "entrypoint_content": elif candidate_mutation == "entrypoint_content":
entrypoint.write_bytes(entrypoint.read_bytes() + b"# unreviewed entrypoint\n") entrypoint.write_bytes(entrypoint.read_bytes() + b"# unreviewed entrypoint\n")
elif candidate_mutation not in { 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") 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) (working / "Dockerfile").write_bytes(candidate_dockerfile)
git("add", "--all") 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") git("commit", "-m", "candidate source")
candidate_revision = git("rev-parse", "HEAD") candidate_revision = git("rev-parse", "HEAD")
git("push", "origin", "HEAD:refs/heads/main") 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 == { assert proof == {
"legacyRevision": history["oldRevision"], "legacyRevision": history["oldRevision"],
"legacyTree": history["oldTree"], "legacyTree": history["oldTree"],
"databaseTree": "c87c9aff5a0f0e55b9209e2f085cf1663876520a", "databaseTree": "21d29c1799118fdc43946c2b6fd5585ccc2c4db1",
"entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8", "entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8",
"candidateRevision": history["candidateRevision"], "candidateRevision": history["candidateRevision"],
"composeBlob": "243aee2c6924775bed7706373a5777f4e619e053", "composeBlob": "243aee2c6924775bed7706373a5777f4e619e053",
@@ -1755,6 +1871,26 @@ def test_appops_legacy_source_proof_keeps_the_old_dockerfile_candidate_valid(
assert proof["dockerfileBlob"] == "73e083dcfef25402665ace5a0bf07a083cf37432" 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( def test_appops_legacy_source_proof_rejects_a_one_byte_dockerfile_change(
deploy_module, monkeypatch, tmp_path 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( def test_appops_legacy_source_bindings_remain_fixed_for_the_new_reviewed_tree(
deploy_module, monkeypatch, tmp_path deploy_module, monkeypatch, tmp_path,
): ):
history = _build_reviewed_appops_seed_history(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["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28"
assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a" assert history["candidateDatabaseTree"] == expected_database_tree
assert history["candidateEntrypoint"] == "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8" assert history["candidateEntrypoint"] == "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8"
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"]) monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"]) 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 == { assert proof == {
"legacyRevision": history["oldRevision"], "legacyRevision": history["oldRevision"],
"legacyTree": history["oldTree"], "legacyTree": history["oldTree"],
"databaseTree": "c87c9aff5a0f0e55b9209e2f085cf1663876520a", "databaseTree": expected_database_tree,
"entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8", "entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8",
"candidateRevision": history["candidateRevision"], "candidateRevision": history["candidateRevision"],
} }
@@ -1803,6 +1986,10 @@ def test_appops_schema_compatibility_accepts_the_exact_reviewed_seed_tree_pair(
"session_content", "session_content",
"init_db_content", "init_db_content",
"forge_policy_changed", "forge_policy_changed",
"unknown_policy_row",
"route_stage_manual_allowed",
"route_stage_voice_allowed",
"route_stage_risk_lowered",
"database_file_mode", "database_file_mode",
"entrypoint_content", "entrypoint_content",
], ],
@@ -1815,10 +2002,13 @@ def test_appops_seed_tree_exception_rejects_every_unreviewed_tree_or_entrypoint_
) )
assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28" assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28"
if mutation == "entrypoint_content": if mutation == "entrypoint_content":
assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a" assert history["candidateDatabaseTree"] == "21d29c1799118fdc43946c2b6fd5585ccc2c4db1"
assert history["candidateEntrypoint"] != "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8" assert history["candidateEntrypoint"] != "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8"
else: 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_REVISION", history["oldRevision"])
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"]) 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"] == "" 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 = _project(tmp_path)
project["verify_oci_revision"] = False
project["environment"] = {"APPOPS_BUILD_DATE": "operator-configured-value"} project["environment"] = {"APPOPS_BUILD_DATE": "operator-configured-value"}
repository = "jens/unraid-appops-gateway" repository = "jens/unraid-appops-gateway"
revision = "a" * 40 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 = importlib.util.module_from_spec(renderer_spec)
renderer_spec.loader.exec_module(renderer) renderer_spec.loader.exec_module(renderer)
assert renderer._ARTIFACT_FILES == status_module._CONTROLLER_ARTIFACT_FILES 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_index = status_module._CONTROLLER_ARTIFACT_FILES.index(
"forge_first_install.py" "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 else status_module.ROOT / filename
) )
(artifact_root / filename).write_bytes(source.read_bytes()) (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, "ROOT", artifact_root)
monkeypatch.setattr(status_module, "CONTROLLER_RELEASE_PATH", artifact_root / "controller-release.json") monkeypatch.setattr(status_module, "CONTROLLER_RELEASE_PATH", artifact_root / "controller-release.json")
digest = status_module._controller_artifact_digest() 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"]) monkeypatch.setattr(status_module.sys, "argv", ["status_server.py", "--check-artifact"])
status_module.main() status_module.main()
assert "validated controller artifact 2.0.0" in capsys.readouterr().out 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 assert status_module._controller_release() is None
+350 -3
View File
@@ -81,7 +81,21 @@ class _Broker:
def enrollment_capabilities(self, *, secret): def enrollment_capabilities(self, *, secret):
assert secret == "b" * 64 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): def enrollment_preflight(self, enrollment, *, secret):
assert secret == "b" * 64 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): def _configure(session):
set_secret(session, "ENROLLMENT_BOOTSTRAP", "b" * 64) set_secret(session, "ENROLLMENT_BOOTSTRAP", "b" * 64)
EnrollmentPlane.set_trust_config(session, _trust()) 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) result = _plane(broker=AlternateRootBroker()).capabilities(session)
assert result["targetEnrollment"]["status"] == "unavailable" assert result["targetEnrollment"]["status"] == "unavailable"
assert result["controller"]["status"] == "unavailable" assert result["controller"]["status"] == "ready"
assert result["controller"]["reason"] == "trusted_appdata_root_mismatch" 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): 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": ""}, {"root": managed_root, "status": "ready", "reason": ""},
], ],
"managedStorage": {"status": "ready", "initializationAvailable": False}, "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: 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): def test_capabilities_fail_closed_and_hide_unrecognized_controller_roots(tmp_path):
class MalformedRootsBroker(_Broker): class MalformedRootsBroker(_Broker):
def enrollment_capabilities(self, *, secret): 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 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): def test_interrupted_external_enrollment_requires_explicit_recovery(tmp_path):
class InterruptedBroker(_Broker): class InterruptedBroker(_Broker):
def enroll_target(self, _enrollment, *, secret, idempotency_key): def enroll_target(self, _enrollment, *, secret, idempotency_key):
@@ -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
+761
View File
@@ -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
+246
View File
@@ -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
@@ -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
@@ -180,19 +180,70 @@ def test_descriptor_can_select_primary_service_for_multi_service_project():
assert contract is not None assert contract is not None
assert contract["applicationId"] == "api" assert contract["applicationId"] == "api"
assert contract["composeProject"] == "api" assert contract["composeProject"] == "api"
assert contract["primaryService"] == "api"
assert contract["serviceNames"] == ["api", "worker"] assert contract["serviceNames"] == ["api", "worker"]
assert contract["containers"] == ["api-api-1", "api-worker-1"] assert contract["containers"] == ["api-api-1", "api-worker-1"]
def test_descriptor_cannot_diverge_compose_project_from_application_id(): def test_descriptor_separates_application_identity_compose_project_and_primary_service():
with pytest.raises(ValueError, match="composeProject must equal applicationId"): 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( GiteaProjectAutoRegistration._safe_descriptor(
{ {
"apiVersion": "itworx.deployment-target/v1", "apiVersion": "itworx.deployment-target/v1",
"applicationId": "api", "applicationId": "quizmonitor",
"composeProject": "different", "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) 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(): def test_unchanged_catalog_entries_do_not_repeat_registration_work():
registry = FakeTargetRegistry() registry = FakeTargetRegistry()
repositories = [repo("Deployable")] repositories = [repo("Deployable")]
+348 -15
View File
@@ -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) 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): def test_selected_run_and_jobs_accept_the_live_gitea_job_shape(modules):
importer = modules.importer importer = modules.importer
revision = "a" * 40 revision = "a" * 40
@@ -353,12 +613,15 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
images = {} images = {}
for index, service in enumerate(controller.MCP_HUB_SERVICES, start=1): for index, service in enumerate(controller.MCP_HUB_SERVICES, start=1):
external = importer.EXTERNAL_IMAGES.get(service) external = importer.EXTERNAL_IMAGES.get(service)
image_ref = ( if external is not None:
external[0] image_ref, archive_ref = external[0], external[1]
if external is not None elif service == "connector-ingestion":
else f"registry.example/itworx/{service}:{revision[:12]}" repository = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]
) archive_ref = f"{repository}:{revision}-run-21531-attempt-2"
archive_ref = external[1] if external is not None else image_ref 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} model_services[service] = {"image": image_ref}
images[service] = { images[service] = {
"imageRef": image_ref, "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, "_remote_main_tree", lambda *_args: tree)
monkeypatch.setattr(importer, "_fixed_workflow", lambda *_args: None) monkeypatch.setattr(importer, "_fixed_workflow", lambda *_args: None)
run = {"id": 21531, "run_attempt": 1} run = {"id": 21531, "run_attempt": 2}
run_state = {"attempt": 1} run_state = {"attempt": 2}
monkeypatch.setattr( monkeypatch.setattr(
importer, importer,
"_selected_run", "_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, "_candidate_artifact", lambda *_args: artifact)
monkeypatch.setattr(importer, "_docker_root", lambda: tmp_path) monkeypatch.setattr(importer, "_docker_root", lambda: tmp_path)
monkeypatch.setattr(importer, "_check_disk_headroom", lambda **_kwargs: None) monkeypatch.setattr(importer, "_check_disk_headroom", lambda **_kwargs: None)
monkeypatch.setattr(importer, "_parse_artifact_zip", lambda *_args, **_kwargs: ( 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, report_data,
hashlib.sha256(artifact_bytes).hexdigest(), hashlib.sha256(artifact_bytes).hexdigest(),
"d" * 64, "d" * 64,
"e" * 64, "e" * 64,
123, 123,
)) )
monkeypatch.setattr(importer, "_parse_artifact_zip", fake_parse_artifact)
monkeypatch.setattr( monkeypatch.setattr(
importer, importer,
"_validate_manifest", "_validate_manifest",
@@ -486,11 +770,17 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
def fake_fixed_docker(*args, **_kwargs): def fake_fixed_docker(*args, **_kwargs):
if rerun_during_load and args and args[0] == "load": if rerun_during_load and args and args[0] == "load":
run_state["attempt"] = 2 run_state["attempt"] = 3
return object() return object()
monkeypatch.setattr(importer, "_fixed_docker", fake_fixed_docker) 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( monkeypatch.setattr(
controller, controller,
"_mcp_hub_check_candidate_exceptions", "_mcp_hub_check_candidate_exceptions",
@@ -502,7 +792,18 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
lambda *_args: draft, 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) Path(staging_directory).mkdir(mode=0o700)
frozen = { frozen = {
"schema": prepare.BUILD_SCHEMA, "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) 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 = { image_rows = {
service: { service: {
"imageRef": images[service]["imageRef"], "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) _private_file(destination, artifact_bytes)
return None 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 candidate_directory = state_root / "mcp-hub-release-candidates" / revision
if rerun_during_load: if rerun_during_load:
with pytest.raises(importer.CandidateRejected, match="run changed"): with pytest.raises(importer.CandidateRejected, match="run changed"):
@@ -589,6 +907,21 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
download_artifact=download_artifact, download_artifact=download_artifact,
) )
assert candidate_directory.is_dir() assert candidate_directory.is_dir()
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 assert stat.S_IMODE(candidate_directory.stat().st_mode) == 0o700
candidate_before_review = (candidate_directory / "candidate.json").read_bytes() candidate_before_review = (candidate_directory / "candidate.json").read_bytes()
receipt_before_review = (candidate_directory / "import-receipt.json").read_bytes() receipt_before_review = (candidate_directory / "import-receipt.json").read_bytes()
@@ -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"
@@ -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
@@ -1,3 +1,4 @@
import re
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] 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") text = WORKFLOW.read_text(encoding="utf-8")
assert "runtime_change" in text assert "runtime_change" in text
assert "*.md|*.mdx|docs/*" 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<body>.*?)(?=^ [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<body>.*?)(?=^ [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
@@ -4,6 +4,7 @@ import hashlib
import importlib import importlib
import json import json
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import types import types
@@ -64,6 +65,8 @@ def harness(modules, tmp_path, monkeypatch):
state = types.SimpleNamespace( state = types.SimpleNamespace(
source=tmp_path / "releases" / revision, source=tmp_path / "releases" / revision,
compose_marker="baseline", compose_marker="baseline",
clock=now,
ingestion_digest=None,
images={}, images={},
mutations=[], mutations=[],
inspect_calls=[], inspect_calls=[],
@@ -119,9 +122,13 @@ def harness(modules, tmp_path, monkeypatch):
"validate_commit", "validate_commit",
lambda _repo, branch, commit: commit if branch == "main" else "", lambda _repo, branch, commit: commit if branch == "main" else "",
) )
monkeypatch.setattr( def deployment_metadata(_repo, _revision, _repository, *, built_at=None):
controller, "deployment_metadata", lambda *_args, **_kwargs: dict(metadata) 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) monkeypatch.setattr(controller, "mcp_hub_source_date_epoch", lambda *_args: epoch)
def stage(_project, _repo, commit): def stage(_project, _repo, commit):
@@ -144,6 +151,13 @@ def harness(modules, tmp_path, monkeypatch):
for index, service in enumerate(controller.MCP_HUB_SERVICES, 1): for index, service in enumerate(controller.MCP_HUB_SERVICES, 1):
if service in controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES: if service in controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES:
local = controller.MCP_HUB_LOCAL_BUILD_IMAGE_REPOSITORIES.get(service) local = controller.MCP_HUB_LOCAL_BUILD_IMAGE_REPOSITORIES.get(service)
if service == "connector-ingestion":
reference = (
f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]}@sha256:{state.ingestion_digest}"
if state.ingestion_digest
else ""
)
else:
reference = ( reference = (
f"{local}:{revision[:12]}" f"{local}:{revision[:12]}"
if local if local
@@ -174,6 +188,13 @@ def harness(modules, tmp_path, monkeypatch):
) )
monkeypatch.setattr(utility, "_is_host_root", lambda: True) 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) plan = utility.plan_candidate(controller.MCP_HUB_REPOSITORY, revision)
candidate_dir = tmp_path / "mcp-hub-release-candidates" / revision candidate_dir = tmp_path / "mcp-hub-release-candidates" / revision
plan_doc = json.loads((candidate_dir / "build.json").read_text()) plan_doc = json.loads((candidate_dir / "build.json").read_text())
@@ -255,6 +276,8 @@ def harness(modules, tmp_path, monkeypatch):
now=now, now=now,
plan=plan, plan=plan,
plan_doc=plan_doc, 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["repository"] == h.controller.MCP_HUB_REPOSITORY
assert document["revision"] == h.revision assert document["revision"] == h.revision
assert document["build"] == h.metadata 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 document["sourceDateEpoch"] == h.epoch
assert len(document["composeSha256"]) == 64 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 / "candidate.json").exists()
assert not (h.candidate_dir / "approval.json").exists() assert not (h.candidate_dir / "approval.json").exists()
assert h.state.mutations == [] 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( def test_plan_candidate_freezes_the_selected_workflow_creation_timestamp(
harness, monkeypatch harness, monkeypatch
): ):
@@ -334,7 +569,7 @@ def test_plan_candidate_freezes_the_selected_workflow_creation_timestamp(
plan = json.loads( plan = json.loads(
(staging / "build.json").read_text() (staging / "build.json").read_text()
) )
assert observed == [built_at] assert observed == [built_at, built_at]
assert plan["build"]["builtAt"] == built_at assert plan["build"]["builtAt"] == built_at
assert len(compose_calls) == 1 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.utility.plan_candidate(
h.controller.MCP_HUB_REPOSITORY, h.controller.MCP_HUB_REPOSITORY,
revision, revision,
built_at="2026-10-03T12:34:56.789Z",
staging_directory=staging, staging_directory=staging,
) )
assert not staging.exists() 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 = h.controller.STATE_ROOT / "untrusted-target"
target.mkdir(mode=0o700) target.mkdir(mode=0o700)
staging = token / revision staging = token / revision
try:
staging.symlink_to(target, target_is_directory=True) 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): with pytest.raises(h.controller.DeployError):
h.utility.plan_candidate( h.utility.plan_candidate(
h.controller.MCP_HUB_REPOSITORY, h.controller.MCP_HUB_REPOSITORY,
revision, revision,
built_at="2026-10-03T12:34:56.789Z",
staging_directory=staging, staging_directory=staging,
) )
def test_candidate_preparation_rejects_nonprivate_staging_parent(harness): 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 h = harness
revision = h.revision revision = h.revision
anchor = h.controller.STATE_ROOT / "mcp-hub-release-imports" / 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.utility.plan_candidate(
h.controller.MCP_HUB_REPOSITORY, h.controller.MCP_HUB_REPOSITORY,
revision, revision,
built_at="2026-10-03T12:34:56.789Z",
staging_directory=staging, 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()) digests = json.loads((h.candidate_dir / "image-digests.json").read_text())
assert result["status"] == "pending_review" assert result["status"] == "pending_review"
assert set(candidate["images"]) == set(h.controller.MCP_HUB_SERVICES) 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"]["tag"] == candidate["images"]["web"]["imageRef"]
assert digests["images"]["web"]["imageId"] == candidate["images"]["web"]["imageId"] assert digests["images"]["web"]["imageId"] == candidate["images"]["web"]["imageId"]
candidate_bytes = (h.candidate_dir / "candidate.json").read_bytes() candidate_bytes = (h.candidate_dir / "candidate.json").read_bytes()
@@ -385,10 +385,25 @@ def test_external_candidate_pins_are_exact_and_declared_by_protected_main():
node for node in parsed.body node for node in parsed.body
if isinstance(node, ast.FunctionDef) and node.name == "fixed_image_reference" if isinstance(node, ast.FunctionDef) and node.name == "fixed_image_reference"
) )
declared = { postgres = (
"postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04", "gitea.example.com/jens/itworx-mcp-hub/postgres:"
"redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48", "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 = { namespace = {
"re": re, "re": re,
"main_declares_external_image": lambda image_ref: image_ref in declared, "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"] fixed = namespace["fixed_image_reference"]
assert fixed("postgres", "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a") assert fixed("postgres", "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a")
assert fixed( assert fixed("postgres", "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04")
"postgres",
"postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04",
)
assert fixed("redis", "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236") assert fixed("redis", "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236")
assert fixed("redis", "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48") 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) assert not fixed("redis", "redis:8.10.3@sha256:" + "b" * 64)
for service in ("web", "control-api", "dataplane-router", "connector-ingestion"): for service in ("web", "control-api", "dataplane-router", "connector-ingestion"):
assert fixed(service, "sha256:" + "a" * 64) 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) assert not fixed(service, "sha256:" + "a" * 64)
namespace["main_declares_external_image"] = lambda _image_ref: False namespace["main_declares_external_image"] = lambda _image_ref: False
assert not fixed("postgres", "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04") assert fixed("postgres", "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a")
assert not fixed("redis", "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48") 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 @pytest.fixture
@@ -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 target["containers"] == list(controller.MCP_HUB_LEGACY_CONTAINERS)
assert controller.effective_mcp_hub_project("jens/other", target) is target assert controller.effective_mcp_hub_project("jens/other", target) is target
def test_postgres_candidate_pin_matches_the_reviewed_release_evidence(controller): def test_db_candidate_pins_and_observed_transition_refs_are_exact(controller):
expected = ( 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" "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04"
) )
assert controller.MCP_HUB_POSTGRES_IMAGE == expected assert controller.MCP_HUB_REDIS_PREVIOUS_IMAGE == (
assert controller.MCP_HUB_OBSERVED_EXTERNAL_IMAGE_TRANSITIONS["postgres"] == ( "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48"
controller.MCP_HUB_POSTGRES_LEGACY_IMAGE,
expected,
) )
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): def test_installed_legacy_registry_shape_is_adapted_only_in_memory(controller):
target = { target = {
@@ -1402,7 +1436,7 @@ def test_private_backup_artifacts_publish_atomically_without_overwrite(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"image_ref_name", "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( def test_live_mount_inventory_includes_volume_identity_source_and_read_only_state(
controller, monkeypatch, image_ref_name controller, monkeypatch, image_ref_name
@@ -54,6 +54,7 @@ def prepared(controller, tmp_path, monkeypatch):
project = { project = {
"_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT, "_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT,
"_repository_path": str(tmp_path / "hub.git"), "_repository_path": str(tmp_path / "hub.git"),
"compose_project": controller.MCP_HUB_PROJECT,
"_deployment_metadata": metadata, "_deployment_metadata": metadata,
"_mcp_hub_source_date_epoch": "1790888863", "_mcp_hub_source_date_epoch": "1790888863",
} }
@@ -89,6 +90,10 @@ def prepared(controller, tmp_path, monkeypatch):
ref = f"fixture/{service}@sha256:" + f"{index:064x}" ref = f"fixture/{service}@sha256:" + f"{index:064x}"
if service == "contextforge": if service == "contextforge":
ref = f"itworx-mcp-hub/contextforge-schema:{revision}" 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} model["services"][service] = {"image": ref}
vuln = { vuln = {
"SchemaVersion": 2, "SchemaVersion": 2,
@@ -142,6 +147,27 @@ def prepared(controller, tmp_path, monkeypatch):
} }
candidate_sha = write("candidate.json", candidate) candidate_sha = write("candidate.json", candidate)
exceptions_sha = write("exceptions.json", {"exceptions": []}) 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( write(
"approval.json", "approval.json",
{ {
@@ -162,8 +188,17 @@ def prepared(controller, tmp_path, monkeypatch):
v["imageRef"]: { v["imageRef"]: {
"Id": v["imageId"], "Id": v["imageId"],
"Config": {"User": "10001:10001", "Labels": dict(labels)}, "Config": {"User": "10001:10001", "Labels": dict(labels)},
**(
{
"RepoDigests": [
f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES['connector-ingestion']}@{v['imageRef'].rsplit('@', 1)[1]}"
]
} }
for v in images.values() if service == "connector-ingestion"
else {}
),
}
for service, v in images.items()
} }
monkeypatch.setattr(controller, "inspect_image", lambda ref: facts.get(ref)) monkeypatch.setattr(controller, "inspect_image", lambda ref: facts.get(ref))
return types.SimpleNamespace( return types.SimpleNamespace(
@@ -182,6 +217,9 @@ def prepared(controller, tmp_path, monkeypatch):
def test_fresh_complete_candidate_freezes_metadata_and_checks_explicit_gate( def test_fresh_complete_candidate_freezes_metadata_and_checks_explicit_gate(
controller, prepared controller, prepared
): ):
prepared.project["environment"] = {
"CONNECTOR_INGESTION_IMAGE": "attacker.invalid/connector:latest"
}
result = controller.mcp_hub_load_release_candidate( result = controller.mcp_hub_load_release_candidate(
prepared.project, prepared.revision 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( controller.mcp_hub_require_candidate_images(
prepared.project, prepared.model, prepared.revision 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( @pytest.mark.parametrize(
@@ -435,6 +502,7 @@ def test_compose_operations_pin_all_scanned_ids_without_changing_source_model(
def compose(project): def compose(project):
path = Path(project["_mcp_hub_image_override_file"]) path = Path(project["_mcp_hub_image_override_file"])
if controller.os.name != "nt":
assert path.stat().st_mode & 0o777 == 0o600 assert path.stat().st_mode & 0o777 == 0o600
observed.append((path, json.loads(path.read_text()))) observed.append((path, json.loads(path.read_text())))
return prepared.directory, ["docker", "compose", "-f", str(path)] 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) prepared.candidate["summarySha256"] = prepared.write("scan-summary.json", summary)
candidate_sha = prepared.write("candidate.json", prepared.candidate) 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( prepared.write(
"approval.json", "approval.json",
{ {
@@ -804,6 +877,11 @@ def test_fixable_high_findings_cannot_be_approved_around(controller, prepared):
"vulnerability-web.json", report "vulnerability-web.json", report
) )
candidate_sha = prepared.write("candidate.json", prepared.candidate) 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( prepared.write(
"approval.json", "approval.json",
{ {
@@ -30,7 +30,7 @@ def _set_npm_config(
settings.apis["NPM_URL"] = url settings.apis["NPM_URL"] = url
settings.apis["NPM_IDENTITY"] = identity settings.apis["NPM_IDENTITY"] = identity
settings.apis["NPM_SECRET"] = secret settings.apis["NPM_SECRET"] = secret
settings.apis["NPM_VERIFY_TLS"] = False settings.apis["NPM_VERIFY_TLS"] = True
settings.apis["NPM_TIMEOUT_SECONDS"] = "10" settings.apis["NPM_TIMEOUT_SECONDS"] = "10"
return previous return previous
+503
View File
@@ -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
@@ -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())
@@ -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"
@@ -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"
@@ -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")
File diff suppressed because it is too large. Load diff
@@ -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)]
+14 -1
View File
@@ -4,7 +4,7 @@ import { AISummary, AIRecommendation, AIChatMessage, AIAutopilotRule, AITool } f
import { toIsoString } from '@/lib/backendDateTime'; 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 { 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 { 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'; import type { ExposurePreferences, ManagedAiConfig, ManagedAiConfigUpdate, ManagedAiStatus, ManagedAiInference } from '../types/appops';
type HealthStatus = { type HealthStatus = {
@@ -371,6 +371,19 @@ export const createLiveClient = () => ({
}); });
}, },
async getDeliveryStatus(): Promise<DeliveryStatus> { return get<DeliveryStatus>('/api/deployments/delivery-status', defaultConfig); }, async getDeliveryStatus(): Promise<DeliveryStatus> { return get<DeliveryStatus>('/api/deployments/delivery-status', defaultConfig); },
async stageForgeRoute(jobId: string, idempotencyKey: string): Promise<ForgeRouteStageResult> {
return request<ForgeRouteStageResult>(
`/api/deployments/forge-bootstrap/first-install/${encodeURIComponent(jobId)}/route`,
defaultConfig,
{ method: 'POST', headers: { 'Idempotency-Key': idempotencyKey } },
);
},
async getForgeRouteStage(jobId: string): Promise<ForgeRouteStageResult> {
return get<ForgeRouteStageResult>(
`/api/deployments/forge-bootstrap/first-install/${encodeURIComponent(jobId)}/route`,
defaultConfig,
);
},
async getEnrollmentCapabilities(): Promise<EnrollmentCapabilities> { return get<EnrollmentCapabilities>('/api/enrollments/capabilities', defaultConfig); }, async getEnrollmentCapabilities(): Promise<EnrollmentCapabilities> { return get<EnrollmentCapabilities>('/api/enrollments/capabilities', defaultConfig); },
async getEnrollmentTrustStatus(): Promise<EnrollmentTrustStatus> { return get<EnrollmentTrustStatus>('/api/enrollments/trust', defaultConfig); }, async getEnrollmentTrustStatus(): Promise<EnrollmentTrustStatus> { return get<EnrollmentTrustStatus>('/api/enrollments/trust', defaultConfig); },
async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise<EnrollmentTrustStatus> { async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise<EnrollmentTrustStatus> {
+12
View File
@@ -32,6 +32,18 @@ export interface DeploymentJob {
idempotentReplay?: boolean; 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 { export interface WorkMeshPromotion {
promotionId: string; promotionId: string;
jobId: string; jobId: string;