Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8
This commit is contained in:
1 parent
820244dbb3
commit
8dd4b40252
52 files changed
+11798
-272
No files matched your search
@@ -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,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
@@ -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",
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user