Publish curated Unraid-AppOps-Gateway source from a506550ef83ef2d81651e37c98a89f7ee330c730
This commit is contained in:
1 parent
2facb8de34
commit
767e66921e
68 files changed
+15919
-308
No files matched your search
@@ -1,3 +1,3 @@
|
||||
# Public source export
|
||||
|
||||
This source snapshot was generated from the private canonical repository at revision `2e53f628d284213b90ca2f9c5e121671f5933574`. 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 `a506550ef83ef2d81651e37c98a89f7ee330c730`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||
+202
-87
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"sourceRevision": "2e53f628d284213b90ca2f9c5e121671f5933574",
|
||||
"sourceRevision": "a506550ef83ef2d81651e37c98a89f7ee330c730",
|
||||
"files": [
|
||||
{
|
||||
"path": ".dockerignore",
|
||||
@@ -294,8 +294,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/connectors/gitea_actions.py",
|
||||
"sha256": "711a92fcfdb0a27bd192d587d9b2597d2c64852f4343929a0e93f95042467323",
|
||||
"bytes": 18661
|
||||
"sha256": "b367bebc60e2542c7d7763c78e4eabfa98bca5d2be753e387c8faa269fdbe330",
|
||||
"bytes": 18440
|
||||
},
|
||||
{
|
||||
"path": "backend/app/connectors/gpu_metrics.py",
|
||||
@@ -314,8 +314,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/connectors/portainer.py",
|
||||
"sha256": "b22b3d36294d2aaa1dcbcc0e140d515893ec0ba58f095c5f6ccb6d82f161a517",
|
||||
"bytes": 19001
|
||||
"sha256": "dcbfb3926929b10e08190944acf0217a71d9696f0c8e91b24fe832bc71034f8f",
|
||||
"bytes": 19655
|
||||
},
|
||||
{
|
||||
"path": "backend/app/connectors/prowlarr.py",
|
||||
@@ -354,8 +354,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/core/config.py",
|
||||
"sha256": "c95e402679a238042c13d816d00a81ba4374d146eb13aab0b24ddb2f215fe8ea",
|
||||
"bytes": 23746
|
||||
"sha256": "83441a676b4e6f415f80b283897bb613805ecbf82a6d405d767532e086dbc5fc",
|
||||
"bytes": 25697
|
||||
},
|
||||
{
|
||||
"path": "backend/app/core/network_security.py",
|
||||
@@ -369,8 +369,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/core/security.py",
|
||||
"sha256": "2ddc677fe34e4818ba33cb287ad57df2caeb43849940f4d1ec760dab83be7bc3",
|
||||
"bytes": 3257
|
||||
"sha256": "abc3de16a802253ea3fa7e0bdaed51efb4d99a82e353ec062fdadb6c20e5e313",
|
||||
"bytes": 3837
|
||||
},
|
||||
{
|
||||
"path": "backend/app/core/time.py",
|
||||
@@ -399,13 +399,18 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/main.py",
|
||||
"sha256": "07ff0720575a518131ea6a80027cb515210bdf433f721c79050bbad0bddbf137",
|
||||
"bytes": 17347
|
||||
"sha256": "17aa07c08bbe9eecbc0c100a6b573ef7db4b682e9bd7c6e1cb5982f72966d853",
|
||||
"bytes": 18104
|
||||
},
|
||||
{
|
||||
"path": "backend/app/mcp_server.py",
|
||||
"sha256": "75779ae0a366cca415c04fc6fe24f38d99c338939874acf17be6616cc12b4f25",
|
||||
"bytes": 35237
|
||||
"sha256": "7cb748e5350231bfe938c91867b7eef408dd3589ba4e7ec078e07589338554c6",
|
||||
"bytes": 35626
|
||||
},
|
||||
{
|
||||
"path": "backend/app/runner_statistics_mcp.py",
|
||||
"sha256": "72b72bd512c8ace97ce3eea54ee4babaa0156044535f793411cd0228eb294ec8",
|
||||
"bytes": 5860
|
||||
},
|
||||
{
|
||||
"path": "backend/app/schemas/ai_remediation.py",
|
||||
@@ -444,13 +449,13 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/action_execution.py",
|
||||
"sha256": "fb32be9b89ac8235f826aabd8cb6ca0068fc9f5f2e5fc95a9963dac8c1df2cb3",
|
||||
"bytes": 83876
|
||||
"sha256": "3ba0a33cd6947dee1d7961e63e6e9a4b247eda3ea8253537b9f879a57cf063ac",
|
||||
"bytes": 99269
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/action_registry.py",
|
||||
"sha256": "b34a8217b0bbdaf1a9cda8d7bc310f5577a3dea0abf4bcc95d106a8227dd8ea9",
|
||||
"bytes": 20499
|
||||
"sha256": "11add43608abc05d51d89d29d772fe17f6d429a032c471376ad8f6d8400976b2",
|
||||
"bytes": 21035
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/ai_remediation_planner.py",
|
||||
@@ -492,10 +497,15 @@
|
||||
"sha256": "320b7edc1cc47701216897929bb4197fe7cf496a61ef4efae477658482aaf185",
|
||||
"bytes": 9150
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/controller_artifact_helper_spec.py",
|
||||
"sha256": "1da47a3f061ef0b89158c9f646bb7018ce88b81c0687d8b27abe65b74df67fea",
|
||||
"bytes": 4948
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/controller_artifact_promoter.py",
|
||||
"sha256": "6e740205449af2adc1fbbf9adf15989532fc6a92e252c8dcc81e7b4d644e58cd",
|
||||
"bytes": 16389
|
||||
"sha256": "bc06a28941c111b5a0f6eed96e407259626c0b9fc1ba5ecb8953befc83b66304",
|
||||
"bytes": 13602
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/controller_bootstrap_import.py",
|
||||
@@ -504,8 +514,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/deployment_broker.py",
|
||||
"sha256": "2feb43b65684f3f6a2dba688a47c18117d907ca34426919270f67e4de42db453",
|
||||
"bytes": 23266
|
||||
"sha256": "829fe26916967dad3c6907989a5a8825a0aabbc294aa2cf600e701bc76c5e1e3",
|
||||
"bytes": 24904
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/deployment_control_plane.py",
|
||||
@@ -514,8 +524,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/deployment_target_registry.py",
|
||||
"sha256": "c3a454fee8613439d28728d97f7db2d80e0854bd8b48e9d4f72378d1b6f6068b",
|
||||
"bytes": 20175
|
||||
"sha256": "322208e3793306d72d47685ee1567a568a64078abe7547de3f2d0721ec888851",
|
||||
"bytes": 20168
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/discovery_scheduler.py",
|
||||
@@ -599,8 +609,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/media_codec_dashboard_deployment_reconciliation.py",
|
||||
"sha256": "78648b078b7525e15a8ac136e13914db1fae6835325901a3ba5b4965e7f1adbe",
|
||||
"bytes": 43243
|
||||
"sha256": "1f61df61d796e9d8aafa2f6dab137ccb8c5422e6e69a681d33362fe584c566e2",
|
||||
"bytes": 43236
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/media_pipeline_service.py",
|
||||
@@ -644,13 +654,13 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/portfolio_deployment_enrollment.py",
|
||||
"sha256": "04e7a3b9a603f9cf50476d9e3f2e67e46d8e169b0a6952eb3753dc6d059d6418",
|
||||
"bytes": 33458
|
||||
"sha256": "55acd53bf2a7b412935f61a60e5b21ad27eb591e6bfe0741fe85d4105e8a4d1e",
|
||||
"bytes": 33452
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/portfolio_deployment_recovery.py",
|
||||
"sha256": "c1adb3734ca68eb961b61def1925cdb08a82a562767813064c170f16b2754865",
|
||||
"bytes": 17107
|
||||
"sha256": "f7bf495631ec35359f7adc47642b2ae2089ba2c9c8e73447664b6edcb9a7a8fb",
|
||||
"bytes": 17108
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/preflight_service.py",
|
||||
@@ -664,18 +674,18 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/privatemuse_deployment_enrollment.py",
|
||||
"sha256": "523834b875641603fb3f3aedf29bcd72ec3bcfa2a9c5f2c587961adddae96f19",
|
||||
"bytes": 21876
|
||||
"sha256": "fa12901f31b5ce13b6ad32669e7012906d1d713d6881e7d410b0ab10645e7116",
|
||||
"bytes": 21869
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/privatemuse_deployment_recovery.py",
|
||||
"sha256": "36b5a74971ef746712b3d557d3a35001334838e6b892ffbce9da4ac713b38b04",
|
||||
"bytes": 47799
|
||||
"sha256": "df9cde501fa48bcad15387596c4c870cb8651ee8def93b92b65d274f1d7d4c9c",
|
||||
"bytes": 47800
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/privatemuse_webhook_repair.py",
|
||||
"sha256": "114cf8f6ee8cc2e93cdadaacc1034209dd25c11407cac6740a61d14db7c25d8c",
|
||||
"bytes": 11884
|
||||
"sha256": "79a7ae42f57bb3e4b556230b9c7624fff8a072e80565c938992e242b6bf14839",
|
||||
"bytes": 11886
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/repopulse_adoption_journal.py",
|
||||
@@ -689,8 +699,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/repopulse_adoption_preflight.py",
|
||||
"sha256": "b92cdccfdd0458bd5a9debc49b12887105fc93d19b169027fa40a2b661d104e0",
|
||||
"bytes": 37153
|
||||
"sha256": "b993d8c12a16ffc720b207d9d5191ea41f603786e6c1f085895d431c799a02c8",
|
||||
"bytes": 37146
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/repopulse_adoption_runner.py",
|
||||
@@ -732,10 +742,15 @@
|
||||
"sha256": "2a10a4996456f5dec30b466fcb5c3fc375a31e6dd512df9ddf7c7ed055019ff3",
|
||||
"bytes": 3212
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/runner_statistics.py",
|
||||
"sha256": "1f132221db4bcd2a7e122f0c0838ce2c8597e969dc912853444eb9a7fe52cbbf",
|
||||
"bytes": 7735
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/runnerless_target_provisioning.py",
|
||||
"sha256": "99b503697365a750379bd6d15da3ee89d9fcc97c77c8aac2be6a98730cd2c3a9",
|
||||
"bytes": 17552
|
||||
"sha256": "ddadcbf82d6b37df18f415f969891ecf31726aef7a9d880c7becf9cc96eb6ffc",
|
||||
"bytes": 17602
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/runtime_service.py",
|
||||
@@ -774,8 +789,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/workmesh_first_deployment.py",
|
||||
"sha256": "4af175ab68a572779ada153783f417e1045321418ed41b87b4c226b2ed2a7a27",
|
||||
"bytes": 22488
|
||||
"sha256": "1ce6581b378bb9ee341c93047e8e5430f80fb053462bf83ad50d766b98abe06f",
|
||||
"bytes": 22487
|
||||
},
|
||||
{
|
||||
"path": "backend/app/services/workmesh_promotion.py",
|
||||
@@ -809,38 +824,53 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_appops_live_state_recovery.py",
|
||||
"sha256": "33b5fa25e441128f84c4e518cef803fa9229ffe04a6ec4e980a3bb2707175959",
|
||||
"bytes": 12368
|
||||
"sha256": "e65bf9968fba65318295ea3d2395da8f7a567657d8050029bd8db323cad5a0dc",
|
||||
"bytes": 12658
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_appops_migration_bridge.py",
|
||||
"sha256": "11af3dfaf72d35affa1b26a52d7850c8e4d44d36183728f326e5e615bcbde102",
|
||||
"bytes": 11481
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_appops_self_upgrade_controller.py",
|
||||
"sha256": "ad6d8fba2213c1c8018bc9ff1c2ebd61684792c27fa00a07195c5d6aea99d69f",
|
||||
"bytes": 20392
|
||||
"sha256": "b41b936e6351a9e2a5a947b7d69e13eb05d2eb6b02bd5bee1b4d3fdfa2c63eea",
|
||||
"bytes": 23121
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_authentik_connector.py",
|
||||
"sha256": "1ea6eaea54f0ce6f5a1e92a6c338edb880eff4c6bc43f09a32ea7a0e4a537870",
|
||||
"bytes": 28252
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_ci_job_docker.py",
|
||||
"sha256": "7292a3ad3e52e934e6a410fdea8adb6a00dc90fe8de28d8159205ec069951d73",
|
||||
"bytes": 27439
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_container_restart.py",
|
||||
"sha256": "2b84085e0e35d4405f39a6b5274b0ba123191c5855306bc0bd7bb67f6c3b7822",
|
||||
"bytes": 108066
|
||||
"sha256": "6bbe8c44a07a0609873148c5b8a0775d375c92c2a5f44ffbfeafb1960d462cab",
|
||||
"bytes": 108190
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_artifact_bootstrap.py",
|
||||
"sha256": "fcb29fae7778e00a3c84e6bcdb51a20ffaea73c24b5a8bd4304058825e30d359",
|
||||
"bytes": 42896
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_artifact_promoter.py",
|
||||
"sha256": "671f62d61ec1281644d0b49500141dd4a50ef75b7bf82accbe80dd0bf16d04ec",
|
||||
"bytes": 3922
|
||||
"sha256": "8eee2bdaf60422824f605637b1820c431fdacea305e1b68b3618fffa0dc000ee",
|
||||
"bytes": 7570
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_bootstrap_import_host.py",
|
||||
"sha256": "541f786746b42447fb738f14343fb6d1cb0097d10a5e4949f0e940e111a6aec3",
|
||||
"bytes": 1577
|
||||
"sha256": "58c153b8e3d1b016006b0bed5c98f9aa069d58e670d65edac368833ae38dd6f1",
|
||||
"bytes": 3060
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_config_paths.py",
|
||||
"sha256": "9cbe851651abec8c5a792d9843ceca308993e71f67bdcbc081ae7eb9a96a0888",
|
||||
"bytes": 5207
|
||||
"sha256": "1081ecf7206e59be24aebf92284d3833743b1a84eeb1cefc3891a340dea883ae",
|
||||
"bytes": 8254
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_host_dependencies.py",
|
||||
@@ -849,8 +879,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_hub_release_volume.py",
|
||||
"sha256": "8c7dd4f95407c9b274f8ee6ca9c98088ee0e842afa0c537cbd1d7311ec9bfa30",
|
||||
"bytes": 32553
|
||||
"sha256": "2aedc6c8f97a9e6aaa58ab43c79c2189360e4f254519314558f77c2de72b25ee",
|
||||
"bytes": 41287
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_root_procedure_contract.py",
|
||||
@@ -859,8 +889,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_controller_state_reconciliation.py",
|
||||
"sha256": "d00b8bcf388e17f0c1b8999d4b511fede7212b8ef83282905328d1b0b4b70f76",
|
||||
"bytes": 10523
|
||||
"sha256": "d9ddea9971414954a0b34738ef8de6bb330a867f7f41376063e473855ff5d17e",
|
||||
"bytes": 10957
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_deluge_connector.py",
|
||||
@@ -869,8 +899,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_deploy_broker.py",
|
||||
"sha256": "6bc23cb5883961316acae07daf12ec261e1c21e229bf5ee8ebc56ff245f6a34d",
|
||||
"bytes": 309532
|
||||
"sha256": "56dffec32ac7383b9d9cf0f82bfb7e586458cb2780bfefb0bfc5785b97bf4736",
|
||||
"bytes": 381065
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_deployment_auto_rollback.py",
|
||||
@@ -894,8 +924,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_deployment_target_registry.py",
|
||||
"sha256": "32c90b7c065183bbc0595f568e1729fc98c787a9fa8a158b082d15f90700fde8",
|
||||
"bytes": 9437
|
||||
"sha256": "2c28d05cbf6156d4ab53e2b52a770e2b0bda34138a3d715ba5d97bca0b9e8ce7",
|
||||
"bytes": 9429
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_dockdeck_classifier.py",
|
||||
@@ -929,8 +959,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_gitea_actions.py",
|
||||
"sha256": "b2c99ac7e5548b9394cce6ce6f8a3fd0e4b7bdf6c72006c95edb6aac108c35fd",
|
||||
"bytes": 10766
|
||||
"sha256": "7c4bebd43553b6de6738bdcb70153640a8ec6f2e855c09eac35a40345995b1d8",
|
||||
"bytes": 12826
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_gitea_project_auto_registration.py",
|
||||
@@ -947,6 +977,31 @@
|
||||
"sha256": "718603f23478261e178bc4377fcea35322a59ff5cb2912e7d4a685fd9da7c2e4",
|
||||
"bytes": 8311
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_host_guard_memory_threshold_installer.py",
|
||||
"sha256": "c50a26646d73a5fe0279b5d9923f121c31fc2c59ee9b8f21cb2a34478729147c",
|
||||
"bytes": 7846
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_hub_candidate_delivery.py",
|
||||
"sha256": "582c987db50715930b79f0fe07d88e9acc79c41905c973e9935c865695a7e714",
|
||||
"bytes": 9323
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_hub_candidate_import.py",
|
||||
"sha256": "178a07b0dc9489545723c8ff5ecbfd7060aa0c1491ba6c1ced50edd7f3481e68",
|
||||
"bytes": 30015
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_hub_candidate_transport.py",
|
||||
"sha256": "da0b302e0bf45d87f0b3339b2c1392aec22e650f45468ab236fc37f448d9f9ea",
|
||||
"bytes": 9353
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_hub_permission_preparation.py",
|
||||
"sha256": "3ba8efe338a21b100e4bc20e4fbffe043eb57c411a4143c00332fdb368db5011",
|
||||
"bytes": 28482
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_inventory_discovery.py",
|
||||
"sha256": "db7a7fbe23ef01f2a4e2e58ca72514889b9740a04374851b4a414118908bc594",
|
||||
@@ -984,8 +1039,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_mcp_hub_candidate_preparation.py",
|
||||
"sha256": "aa5525214e034464ac3586252ba5bad09f59c96c861cb9449a3dc86ae7a91c74",
|
||||
"bytes": 22525
|
||||
"sha256": "ed0d60e8f0e4371622405dc200e80ecfa3c4574ad2a9439373032f997a1af414",
|
||||
"bytes": 27390
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_mcp_hub_deployment_reconciliation.py",
|
||||
@@ -994,8 +1049,13 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_mcp_hub_governed_topology.py",
|
||||
"sha256": "117c07dfb34e9ab0fac5675dd51585906b33d7bf5ea0a059d2d8f6eb4d2897a7",
|
||||
"bytes": 118901
|
||||
"sha256": "9e306fbce479f15610a87ad666023de7deae588dc01f69be6024140d5df2158d",
|
||||
"bytes": 125396
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_mcp_hub_legacy_registry_compatibility.py",
|
||||
"sha256": "f382839f7ac5ec5a038f305bd9204e01514b73ad2b6116e6d388ec2d4f728b25",
|
||||
"bytes": 4101
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_mcp_hub_policy_reconciliation.py",
|
||||
@@ -1009,8 +1069,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_mcp_hub_release_store.py",
|
||||
"sha256": "91dbd0da6a131f76a04c51f93e31a77f4acd057f788f4b1a6fce75d37c23525e",
|
||||
"bytes": 18199
|
||||
"sha256": "4483020ccfe8cd31cc00ac4e9030a792af176e2d54929316a954bf738237f571",
|
||||
"bytes": 21241
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_mcp_rag_n8n_integrations.py",
|
||||
@@ -1019,8 +1079,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py",
|
||||
"sha256": "a7fd78d28e191f3370aec320c88dff150682d3ca8acfcb5766b59ed6978e6e9c",
|
||||
"bytes": 9161
|
||||
"sha256": "7933221aedbaa0dd1b1c4e0fcde9987ec60d6b9f390ab5b3f59529e681a8d35a",
|
||||
"bytes": 9088
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_media_codec_dashboard_reconcile_diagnostics.py",
|
||||
@@ -1069,13 +1129,13 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_portainer_restart_timeout.py",
|
||||
"sha256": "3502178913145313afe376ccde8edcfc452ca723fa161aa572debba0b2f57a96",
|
||||
"bytes": 1339
|
||||
"sha256": "6ac7e5234de0d044b77c35390da14a26d26c99f21ecf35dd2e690ec8bcf28f3a",
|
||||
"bytes": 4354
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_portfolio_deployment_enrollment.py",
|
||||
"sha256": "3d70cc965d56db374ab88d1df3a25ac497807d0b1f0c3d385789b20fd8fe57e4",
|
||||
"bytes": 6734
|
||||
"sha256": "6ff8929444d3455db66955270dcd57750f9b3c4ab55c96daccc127386a68cb2e",
|
||||
"bytes": 6727
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_portfolio_deployment_recovery.py",
|
||||
@@ -1099,8 +1159,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_privatemuse_deployment_enrollment.py",
|
||||
"sha256": "2739d9e0d31db4eaec6c18c2da07a0ba16200d379274a8d9a47516110518241e",
|
||||
"bytes": 7490
|
||||
"sha256": "ec086f9dc6560066e7fe21a6052b0ec435ef9fcbd19f3a231a2d8cb07e1bf0e0",
|
||||
"bytes": 7482
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_privatemuse_deployment_recovery.py",
|
||||
@@ -1119,8 +1179,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_privatemuse_webhook_repair.py",
|
||||
"sha256": "2c6ba79e8cd3d63faf6103a8578d9cb8e9ecb7278008e9ad36037cc08a02c2a7",
|
||||
"bytes": 6391
|
||||
"sha256": "aa04a057b29302ae585751a5d4c5cdbb268e55350b6b6d20575c78808492a778",
|
||||
"bytes": 6392
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_production_runtime.py",
|
||||
@@ -1204,8 +1264,8 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_repopulse_target_attestation.py",
|
||||
"sha256": "6c0c61262aceb58b924edab70bfa079e4b46b222897e00c72e39f7a73bd5c9e6",
|
||||
"bytes": 11564
|
||||
"sha256": "1fccd197fbb8abe3c13187401b16ed21a44cc0f0137966f6427678a77a4d81f3",
|
||||
"bytes": 14015
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_repopulse_target_credential.py",
|
||||
@@ -1232,20 +1292,35 @@
|
||||
"sha256": "7ea8409b3129938fd57390db26ba2e6068af90cf46db31204a0f0cd567c254ed",
|
||||
"bytes": 3242
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_admission_controller.py",
|
||||
"sha256": "dcfb8e7a1d1ee86d6bfadef71bbd8be629a7f70a26baafb37bb134c61e78491d",
|
||||
"bytes": 57459
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_admission_policy.py",
|
||||
"sha256": "e4bdf5b454b5e17d1785b2db82819c1e43150fe84de1bf0fd242288352f4ee26",
|
||||
"bytes": 5951
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_capacity_controller.py",
|
||||
"sha256": "d757a661f06507ec7c685103de274c0dd26a987def676c70dd8b44be6e9c265d",
|
||||
"bytes": 6909
|
||||
"sha256": "fbc1025db04ab98f5aaeee7c94bb6b5dfb1a5652bfe696d92f2ac5f631620448",
|
||||
"bytes": 18051
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_capacity_policy.py",
|
||||
"sha256": "662bc7a4a9f0ec4cd721e032068e08f4530409aa2fc03636a04077d4d9b5e6f3",
|
||||
"bytes": 5292
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_controller_artifact.py",
|
||||
"sha256": "ec3a53cebab1fae3a68817798d72b2831f35eda4c46fbb3d07ad84fc5eb55ab3",
|
||||
"bytes": 2830
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_fleet.py",
|
||||
"sha256": "a442a54748caf77f912d05b7ed9d68645db502d1a2a96cd1838501343bccdf90",
|
||||
"bytes": 5966
|
||||
"sha256": "88bfd354ceb8b7f7fbbd68142ed8b0dd14133de5844c89cedea72cb3909781db",
|
||||
"bytes": 8826
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_fleet_cutover_safety.py",
|
||||
@@ -1254,19 +1329,59 @@
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_fleet_scheduler_recovery.py",
|
||||
"sha256": "567c59526c430c61c7823a984fb8c2a7eb17c5614e3e0a28a2b25b9af85fc55f",
|
||||
"bytes": 34952
|
||||
"sha256": "cc2e5c2b1076df701e4124e62fc9173b0ea3badb148e99ea5d54f84f40619568",
|
||||
"bytes": 50645
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_fleet_specialize.py",
|
||||
"sha256": "e2b142ed5656e118156d44baf6f1f8568ba25297ea9af13b817861ce3c4fd4da",
|
||||
"bytes": 107149
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_fleet_v2_execution_surface.py",
|
||||
"sha256": "62dcc692085ea9af004c4618e0c770701396be02254daf06bd5b55f56b5119fa",
|
||||
"bytes": 2337
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_profiles.py",
|
||||
"sha256": "45a8dba20d1e8d778dc90c20a997630037e2eaa1d462fa62f48671285aa82e0e",
|
||||
"bytes": 3111
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_resource_envelope.py",
|
||||
"sha256": "a0e469bc980dbec76cedda9a1ae5011fc1444d49ffed4c067268a10f1905a5a1",
|
||||
"bytes": 40504
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_scheduler_liveness_recovery.py",
|
||||
"sha256": "86e9a1e336206506f48e149fedf21e79f9c7ad54771dea4f53ec039ac81548af",
|
||||
"bytes": 3824
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_specialization_controller_surface.py",
|
||||
"sha256": "dc26476686237318953bf335865ad0901635044629826690af0e1d98d786612b",
|
||||
"bytes": 9174
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_statistics_binding.py",
|
||||
"sha256": "2a684cc43097a0ac86270140a2f47273be23d33964ed6c75e4bb9e063a79b228",
|
||||
"bytes": 16674
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_statistics_mcp.py",
|
||||
"sha256": "330d1ba91fb47d932a03b3657782e92087e6f93bd7b9fa3120e2d2e4f502e9f6",
|
||||
"bytes": 12567
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_statistics_surface.py",
|
||||
"sha256": "a5786c51bf0aff6982b8491d4f3c5c81e1e249ad5aeb3398d720402b712b15bd",
|
||||
"bytes": 11212
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_runner_task_image_producer.py",
|
||||
"sha256": "c55f54bf58fa81649ddb31a8953abadf299c2fe32d1b4411dd59889981cc7376",
|
||||
"bytes": 12112
|
||||
},
|
||||
{
|
||||
"path": "backend/tests/test_secret_storage.py",
|
||||
"sha256": "3e02994542763f4f7b2137356f883c48a17dc9ddb0af0efcd218e0d1c9ac6dce",
|
||||
|
||||
@@ -217,7 +217,9 @@ class GiteaActionsConnector(BaseConnector):
|
||||
"order": "asc",
|
||||
},
|
||||
)
|
||||
pending_payload = self._request(
|
||||
# Keep pending inventory in the all-or-error fetch, but it is not
|
||||
# runnable queue pressure and must not drive runner diagnostics.
|
||||
self._request(
|
||||
"/api/v1/admin/actions/jobs",
|
||||
{
|
||||
"status": "pending",
|
||||
@@ -267,9 +269,10 @@ class GiteaActionsConnector(BaseConnector):
|
||||
]
|
||||
queued_jobs = [
|
||||
item
|
||||
for payload in (queued_payload, pending_payload)
|
||||
for item in (
|
||||
payload.get("jobs") if isinstance(payload.get("jobs"), list) else []
|
||||
queued_payload.get("jobs")
|
||||
if isinstance(queued_payload.get("jobs"), list)
|
||||
else []
|
||||
)
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
@@ -462,8 +465,7 @@ class GiteaActionsConnector(BaseConnector):
|
||||
"offline": sum(item["status"] != "online" for item in runners),
|
||||
},
|
||||
"queue": {
|
||||
"queued": int(queued_payload.get("total_count", 0) or 0)
|
||||
+ int(pending_payload.get("total_count", 0) or 0),
|
||||
"queued": int(queued_payload.get("total_count", 0) or 0),
|
||||
"inProgress": int(
|
||||
active_payload.get("total_count", len(active_jobs)) or 0
|
||||
),
|
||||
@@ -476,12 +478,6 @@ class GiteaActionsConnector(BaseConnector):
|
||||
if isinstance(queued_payload.get("jobs"), list)
|
||||
else []
|
||||
)
|
||||
or int(pending_payload.get("total_count", 0) or 0)
|
||||
> len(
|
||||
pending_payload.get("jobs")
|
||||
if isinstance(pending_payload.get("jobs"), list)
|
||||
else []
|
||||
)
|
||||
),
|
||||
"blockedByRunnerLabels": sorted(set(blocked_labels))[:20],
|
||||
"staleActiveRunnerLabels": sorted(set(stale_active_labels))[:20],
|
||||
|
||||
@@ -11,8 +11,22 @@ from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from .base import BaseConnector
|
||||
|
||||
|
||||
_PORTAINER_DEFAULT_TIMEOUT_SECONDS = 8.0
|
||||
_CONTROLLER_OWNED_CI_CONTAINER_NAMES = frozenset({
|
||||
'itworx-ci-checks',
|
||||
'itworx-ci-backend',
|
||||
'itworx-ci-frontend',
|
||||
'itworx-ci-security',
|
||||
'itworx-ci-browser',
|
||||
'itworx-ci-build',
|
||||
'itworx-security-runner',
|
||||
'itworx-validation-runner-2',
|
||||
'itworx-validation-runner-3',
|
||||
'itworx-docker-runner',
|
||||
'itworx-browser-runner',
|
||||
'itworx-validation-runner-burst-1',
|
||||
'gitea-runner-dind',
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -213,6 +227,10 @@ class PortainerConnector(BaseConnector):
|
||||
if not selected:
|
||||
return {'status': 'not_found', 'reason': f'Container {target} not found'}
|
||||
|
||||
selected_name = str(selected.get('name', '')).strip().strip('/').lower()
|
||||
if selected_name in _CONTROLLER_OWNED_CI_CONTAINER_NAMES:
|
||||
return {'status': 'blocked', 'reason': 'controller_owned_ci_lifecycle'}
|
||||
|
||||
container_name = selected.get('id') or selected.get('name')
|
||||
if not container_name:
|
||||
return {'status': 'error', 'reason': 'Unable to resolve container identifier'}
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -34,6 +36,38 @@ def _secret_value(name: str, file_name: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _private_secret_file(path: str | None) -> str | None:
|
||||
"""Read a small owner-private regular secret file without following links."""
|
||||
if not path:
|
||||
return None
|
||||
descriptor: int | None = None
|
||||
try:
|
||||
source = Path(path)
|
||||
before = source.lstat()
|
||||
if not source.is_absolute() or source.is_symlink() or not stat.S_ISREG(before.st_mode) or before.st_size > 512:
|
||||
return None
|
||||
descriptor = os.open(
|
||||
source,
|
||||
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0),
|
||||
)
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or opened.st_size > 512:
|
||||
return None
|
||||
if os.name != "nt" and (
|
||||
opened.st_uid != os.geteuid() or stat.S_IMODE(opened.st_mode) & 0o077
|
||||
):
|
||||
return None
|
||||
value = os.read(descriptor, 513)
|
||||
if len(value) > 512:
|
||||
return None
|
||||
return value.decode("utf-8").strip() or None
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _env_list(name: str, default: list[str] | None = None) -> list[str]:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
@@ -86,6 +120,7 @@ class Settings:
|
||||
mcp_token: str | None = None
|
||||
mcp_token_file: str | None = None
|
||||
mcp_allowed_hosts: list[str] | None = None
|
||||
runner_statistics_token_file: str | None = None
|
||||
n8n_enabled: bool = False
|
||||
n8n_token: str | None = None
|
||||
n8n_token_file: str | None = None
|
||||
@@ -120,6 +155,11 @@ class Settings:
|
||||
self.mcp_token = token_file.read().strip() or None
|
||||
except OSError:
|
||||
self.mcp_token = None
|
||||
default_runner_statistics_token_file = str(Path(self.data_dir) / "runner-statistics-token")
|
||||
self.runner_statistics_token_file = _env_value(
|
||||
"APPOPS_RUNNER_STATISTICS_TOKEN_FILE",
|
||||
self.runner_statistics_token_file or default_runner_statistics_token_file,
|
||||
)
|
||||
allowed_hosts = _env_value("APPOPS_MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*,198.18.10.150:*") or ""
|
||||
self.mcp_allowed_hosts = [item.strip() for item in allowed_hosts.split(",") if item.strip()]
|
||||
self.n8n_enabled = _env_bool("APPOPS_ENABLE_N8N", self.n8n_enabled)
|
||||
@@ -267,6 +307,10 @@ class Settings:
|
||||
"APPOPS_VERIFY_TLS": self.verify_tls,
|
||||
}
|
||||
|
||||
def read_runner_statistics_token(self) -> str | None:
|
||||
"""Read the fixed private credential at request time for no-restart provisioning."""
|
||||
return _private_secret_file(self.runner_statistics_token_file)
|
||||
|
||||
def redact_env_preview(self) -> dict[str, Any]:
|
||||
return {
|
||||
"APPOPS_BIND_HOST": self.bind_host,
|
||||
@@ -291,6 +335,8 @@ class Settings:
|
||||
"APPOPS_MCP_TOKEN_FILE": bool(self.mcp_token_file),
|
||||
"APPOPS_MCP_TOKEN_CONFIGURED": bool(self.mcp_token),
|
||||
"APPOPS_MCP_ALLOWED_HOSTS": list(self.mcp_allowed_hosts or []),
|
||||
"APPOPS_RUNNER_STATISTICS_TOKEN_FILE": bool(self.runner_statistics_token_file),
|
||||
"APPOPS_RUNNER_STATISTICS_TOKEN_CONFIGURED": bool(self.read_runner_statistics_token()),
|
||||
"APPOPS_ENABLE_N8N": self.n8n_enabled,
|
||||
"APPOPS_N8N_TOKEN_FILE": bool(self.n8n_token_file),
|
||||
"APPOPS_N8N_TOKEN_CONFIGURED": bool(self.n8n_token),
|
||||
|
||||
@@ -53,6 +53,15 @@ def require_service_request(request: Request, service: str) -> str:
|
||||
enabled, expected = settings.mcp_enabled, settings.mcp_token
|
||||
elif service == "n8n":
|
||||
enabled, expected = settings.n8n_enabled, settings.n8n_token
|
||||
elif service == "mcp_runner_statistics":
|
||||
expected = settings.read_runner_statistics_token()
|
||||
if not expected:
|
||||
raise HTTPException(404, "runner statistics MCP integration is disabled")
|
||||
if not token_is_strong(expected):
|
||||
raise HTTPException(503, "runner statistics MCP authentication is not safely configured")
|
||||
if settings.mcp_token and hmac.compare_digest(expected, settings.mcp_token):
|
||||
raise HTTPException(503, "runner statistics MCP token must be separate from the governed MCP token")
|
||||
enabled = True
|
||||
elif service == "voice":
|
||||
enabled, expected = True, settings.voice_token or os.getenv("APP_OPS_VOICE_TOKEN")
|
||||
else:
|
||||
|
||||
+13
-3
@@ -73,6 +73,7 @@ from .mcp_server import (
|
||||
restore_tool_metrics,
|
||||
set_session_running,
|
||||
)
|
||||
from .runner_statistics_mcp import runner_statistics_mcp_http_app, run_runner_statistics_mcp
|
||||
from .services import knowledge_service
|
||||
from .services.discovery_scheduler import run_periodic_discovery
|
||||
from .services.maintenance_scheduler import (
|
||||
@@ -130,6 +131,7 @@ async def lifespan(_: FastAPI):
|
||||
if settings.mcp_enabled:
|
||||
await stack.enter_async_context(appops_mcp.session_manager.run())
|
||||
set_session_running(True)
|
||||
await stack.enter_async_context(run_runner_statistics_mcp())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -182,6 +184,7 @@ class MachineBodyLimitMiddleware:
|
||||
method = str(scope.get("method", "GET")).upper()
|
||||
is_machine_post = method not in _SAFE_METHODS and (
|
||||
path == "/mcp" or path.startswith("/mcp/")
|
||||
or path == "/mcp-runner-statistics" or path.startswith("/mcp-runner-statistics/")
|
||||
or path == "/api/integrations/n8n" or path.startswith("/api/integrations/n8n/")
|
||||
)
|
||||
if scope["type"] != "http" or not is_machine_post:
|
||||
@@ -243,17 +246,23 @@ async def private_operator_boundary(request, call_next):
|
||||
is_api = path == "/api" or path.startswith("/api/")
|
||||
is_voice = path.startswith("/api/voice/")
|
||||
is_mcp = path == "/mcp" or path.startswith("/mcp/")
|
||||
is_runner_statistics_mcp = path == "/mcp-runner-statistics" or path.startswith("/mcp-runner-statistics/")
|
||||
is_n8n = path == "/api/integrations/n8n" or path.startswith("/api/integrations/n8n/")
|
||||
is_public_auth = request.method in _SAFE_METHODS and (
|
||||
path in {"/api/auth/session", "/api/auth/providers", "/api/health"}
|
||||
or path.startswith(("/api/auth/login/", "/api/auth/callback/"))
|
||||
)
|
||||
if is_mcp or is_n8n:
|
||||
if is_runner_statistics_mcp:
|
||||
try:
|
||||
require_service_request(request, "mcp_runner_statistics")
|
||||
except HTTPException as exc:
|
||||
return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}))
|
||||
elif is_mcp or is_n8n:
|
||||
try:
|
||||
require_service_request(request, "mcp" if is_mcp else "n8n")
|
||||
except HTTPException as exc:
|
||||
return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}))
|
||||
if is_mcp or is_n8n:
|
||||
if is_mcp or is_runner_statistics_mcp or is_n8n:
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and content_length.isdigit() and int(content_length) > 1_048_576:
|
||||
return finalize_response(JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"}))
|
||||
@@ -297,7 +306,7 @@ async def private_operator_boundary(request, call_next):
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
actor_identity.reset(actor_token)
|
||||
if is_api:
|
||||
if is_api or is_runner_statistics_mcp:
|
||||
response.headers.setdefault("Cache-Control", "no-store")
|
||||
return finalize_response(response)
|
||||
|
||||
@@ -356,6 +365,7 @@ app.include_router(integrations_router)
|
||||
app.include_router(n8n_router)
|
||||
app.include_router(knowledge_router)
|
||||
app.mount("/mcp", mcp_http_app)
|
||||
app.mount("/mcp-runner-statistics", runner_statistics_mcp_http_app)
|
||||
|
||||
|
||||
@app.get('/api')
|
||||
|
||||
@@ -46,6 +46,7 @@ from .services.privatemuse_artifact_installer import ArtifactInstallError
|
||||
from .services.privatemuse_artifact_installer import install as install_artifact_file
|
||||
from .services.privatemuse_artifact_installer import preflight as artifact_preflight
|
||||
from .services.runtime_service import runtime_info
|
||||
from .services.runner_statistics import RunnerStatisticsService
|
||||
|
||||
_SESSION_RUNNING = False
|
||||
_SESSION_VERIFIED_AT: str | None = None
|
||||
@@ -233,6 +234,13 @@ def gitea_actions_runners_status() -> dict[str, Any]:
|
||||
return _json(GiteaActionsConnector().snapshot())
|
||||
|
||||
|
||||
@appops_mcp.tool(name="gitea.actions.runners.statistics", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("gitea.actions.runners.statistics")
|
||||
def gitea_actions_runner_statistics() -> dict[str, Any]:
|
||||
"""Read bounded CI profile budgets, measured usage and job statistics."""
|
||||
return _json(RunnerStatisticsService().snapshot())
|
||||
|
||||
|
||||
def _json(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump"):
|
||||
return _json(value.model_dump(mode="json"))
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Dedicated one-tool MCP surface for bounded, read-only CI statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
import ipaddress
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.types import ToolAnnotations
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import Response
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from .core.config import settings
|
||||
from .core.version import APP_VERSION
|
||||
from .services.runner_statistics import RunnerStatisticsService, sanitize_statistics, unavailable_statistics
|
||||
|
||||
RUNNER_STATISTICS_TOOL = "gitea.actions.runners.statistics"
|
||||
_READ_ONLY_TOOL = ToolAnnotations(
|
||||
read_only_hint=True,
|
||||
destructive_hint=False,
|
||||
idempotent_hint=True,
|
||||
open_world_hint=False,
|
||||
)
|
||||
_DNS_HOST_PATTERN = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$")
|
||||
|
||||
|
||||
def _validated_auth_public_host() -> tuple[str, int | None] | None:
|
||||
try:
|
||||
parsed = urlsplit(settings.auth_public_url)
|
||||
hostname = parsed.hostname
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
if (
|
||||
parsed.scheme.lower() != "https"
|
||||
or not hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or (port is not None and not 1 <= port <= 65535)
|
||||
):
|
||||
return None
|
||||
try:
|
||||
normalized = hostname.rstrip(".").encode("idna").decode("ascii").lower()
|
||||
except UnicodeError:
|
||||
return None
|
||||
if not normalized:
|
||||
return None
|
||||
if ":" in normalized:
|
||||
try:
|
||||
ipaddress.IPv6Address(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
elif len(normalized) > 253 or not _DNS_HOST_PATTERN.fullmatch(normalized):
|
||||
return None
|
||||
return normalized, port
|
||||
|
||||
|
||||
def _readonly_allowed_hosts() -> list[str]:
|
||||
"""Add only the validated public auth host to this read-only transport."""
|
||||
hosts = list(settings.mcp_allowed_hosts or [])
|
||||
validated = _validated_auth_public_host()
|
||||
if validated is None:
|
||||
return hosts
|
||||
normalized, port = validated
|
||||
host_value = f"[{normalized}]" if ":" in normalized else normalized
|
||||
candidates = [f"{host_value}:{port}" if port is not None else host_value]
|
||||
for candidate in candidates:
|
||||
if candidate not in hosts:
|
||||
hosts.append(candidate)
|
||||
return hosts
|
||||
|
||||
|
||||
def _readonly_allowed_origins() -> list[str]:
|
||||
"""Keep existing frontend origin and admit only the validated auth origin."""
|
||||
origins = [settings.frontend_origin] if settings.frontend_origin else []
|
||||
validated = _validated_auth_public_host()
|
||||
if validated is None:
|
||||
return origins
|
||||
normalized, port = validated
|
||||
host_value = f"[{normalized}]" if ":" in normalized else normalized
|
||||
netloc = f"{host_value}:{port}" if port is not None else host_value
|
||||
origin = f"https://{netloc}"
|
||||
if origin not in origins:
|
||||
origins.append(origin)
|
||||
return origins
|
||||
|
||||
|
||||
def create_runner_statistics_mcp(
|
||||
*,
|
||||
snapshot_provider: Callable[[], object] | None = None,
|
||||
allowed_hosts: Sequence[str] | None = None,
|
||||
allowed_origins: Sequence[str] | None = None,
|
||||
) -> tuple[MCPServer, Starlette]:
|
||||
"""Build an isolated MCP server exposing only the sanitized statistics tool."""
|
||||
provider = snapshot_provider or RunnerStatisticsService().snapshot
|
||||
server = MCPServer(
|
||||
"AppOps CI Runner Statistics",
|
||||
version=APP_VERSION,
|
||||
instructions=(
|
||||
"Read-only CI runner profile statistics. This server exposes no operational, "
|
||||
"administrative, deployment, filesystem, or mutation tools."
|
||||
),
|
||||
)
|
||||
|
||||
@server.tool(name=RUNNER_STATISTICS_TOOL, annotations=_READ_ONLY_TOOL)
|
||||
def gitea_actions_runner_statistics() -> dict[str, Any]:
|
||||
"""Read bounded runner budgets, measured usage, and job statistics."""
|
||||
try:
|
||||
return sanitize_statistics(provider())
|
||||
except Exception: # noqa: BLE001 - keep internal broker details off the MCP surface.
|
||||
return unavailable_statistics()
|
||||
|
||||
transport = server.streamable_http_app(
|
||||
streamable_http_path="/",
|
||||
stateless_http=True,
|
||||
json_response=True,
|
||||
max_request_body_size=1_048_576,
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=_readonly_allowed_hosts() if allowed_hosts is None else list(allowed_hosts),
|
||||
allowed_origins=_readonly_allowed_origins() if allowed_origins is None else list(allowed_origins),
|
||||
),
|
||||
)
|
||||
return server, transport
|
||||
|
||||
|
||||
class _ReplaceableASGIApp:
|
||||
"""Keep the mounted path stable while giving each app lifespan a fresh manager."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._app: ASGIApp | None = None
|
||||
|
||||
def replace(self, app: ASGIApp | None) -> None:
|
||||
self._app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if self._app is None:
|
||||
response = Response("Runner statistics MCP is unavailable", status_code=503)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self._app(scope, receive, send)
|
||||
|
||||
|
||||
runner_statistics_mcp_http_app = _ReplaceableASGIApp()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def run_runner_statistics_mcp():
|
||||
"""Run a fresh stateless transport manager for this FastAPI lifespan."""
|
||||
server, transport = create_runner_statistics_mcp()
|
||||
runner_statistics_mcp_http_app.replace(transport)
|
||||
try:
|
||||
async with server.session_manager.run():
|
||||
yield
|
||||
finally:
|
||||
runner_statistics_mcp_http_app.replace(None)
|
||||
@@ -53,6 +53,9 @@ APPROVAL_EXECUTOR_ACTIONS = {
|
||||
"container.restart",
|
||||
"controller.artifact.promote",
|
||||
"runner.fleet.upgrade_v2",
|
||||
"runner.fleet.specialize",
|
||||
"runner.statistics.configure",
|
||||
"hub.migration.prepare_permissions",
|
||||
"deployment.targets.sync",
|
||||
"deployment.runnerless.provision",
|
||||
"deployment.target.deploy_workmesh",
|
||||
@@ -1648,18 +1651,27 @@ def _execute_runner_fleet_upgrade_v2(
|
||||
risk: str,
|
||||
reason: str,
|
||||
original_policy_decision: str,
|
||||
specialized: bool = False,
|
||||
statistics_configuration: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
|
||||
action = "runner.fleet.upgrade_v2"
|
||||
target = "tower/runner-fleet-v2"
|
||||
action = ("runner.statistics.configure" if statistics_configuration else
|
||||
"runner.fleet.specialize" if specialized else "runner.fleet.upgrade_v2")
|
||||
target = ("tower/projectbrain-runner-statistics" if statistics_configuration else
|
||||
"tower/runner-fleet-specialized" if specialized else "tower/runner-fleet-v2")
|
||||
steps = [
|
||||
"Validate exact fixed runner-fleet target",
|
||||
"Call HMAC-signed root controller endpoint",
|
||||
"Prove protected main and successful push validation",
|
||||
"Execute exact-revision fleet-v2 migration script",
|
||||
"Execute exact-revision specialized migration script" if specialized
|
||||
else "Execute exact-revision fleet-v2 migration script",
|
||||
"Verify specialized runner topology",
|
||||
]
|
||||
if statistics_configuration:
|
||||
steps = ["Verify fixed read-only statistics target", "Prove protected controller/runtime source",
|
||||
"Lock production configuration", "Provision private independent credential with durable CAS",
|
||||
"Configure ProjectBrain for its governed delivery"]
|
||||
job = create_job(
|
||||
session,
|
||||
action_id=action,
|
||||
@@ -1670,24 +1682,31 @@ def _execute_runner_fleet_upgrade_v2(
|
||||
steps=steps,
|
||||
)
|
||||
start_job(session, job)
|
||||
set_job_progress(session, job, 25, "Requesting fixed Tower runner-fleet v2 migration.")
|
||||
set_job_progress(session, job, 25, f"Requesting {action} through the fixed controller endpoint.")
|
||||
try:
|
||||
result = DeploymentBrokerClient().runner_fleet_upgrade_v2(reason=reason)
|
||||
broker = DeploymentBrokerClient()
|
||||
result = (broker.runner_statistics_configure(reason=reason) if statistics_configuration else
|
||||
broker.runner_fleet_specialize(reason=reason) if specialized else broker.runner_fleet_upgrade_v2(reason=reason))
|
||||
status = str(result.get("status") or "error")
|
||||
success = status in {"upgraded", "already_applied"}
|
||||
success = status in ({"configured", "already_configured"} if statistics_configuration else
|
||||
{"specialized", "already_applied"} if specialized else {"upgraded", "already_applied"})
|
||||
message = str(
|
||||
result.get("reason")
|
||||
or (
|
||||
"Tower runner-fleet v2 migration verified."
|
||||
if success
|
||||
else "Tower runner-fleet v2 migration was rejected or failed."
|
||||
("ProjectBrain read-only statistics configuration verified." if statistics_configuration else
|
||||
"Tower runner specialization verified." if specialized
|
||||
else "Tower runner-fleet v2 migration verified.")
|
||||
if success else
|
||||
("ProjectBrain statistics configuration was rejected or failed." if statistics_configuration else
|
||||
"Tower runner specialization was rejected or failed." if specialized
|
||||
else "Tower runner-fleet v2 migration was rejected or failed.")
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
result = {"status": "error"}
|
||||
status = "error"
|
||||
success = False
|
||||
message = f"{type(exc).__name__}: runner fleet v2 migration failed"
|
||||
message = f"{type(exc).__name__}: fixed controller operation failed"
|
||||
|
||||
completed = complete_job(
|
||||
session,
|
||||
@@ -1711,6 +1730,7 @@ def _execute_runner_fleet_upgrade_v2(
|
||||
"status": status,
|
||||
"revision": result.get("revision"),
|
||||
"verifiedRunners": result.get("verifiedRunners"),
|
||||
"projectbrainDeliveryRequired": result.get("projectbrainDeliveryRequired"),
|
||||
},
|
||||
redacted_metadata={
|
||||
"executor": action,
|
||||
@@ -1732,6 +1752,253 @@ def _execute_runner_fleet_upgrade_v2(
|
||||
"controllerStatus": status,
|
||||
"revision": result.get("revision"),
|
||||
"verifiedRunners": result.get("verifiedRunners"),
|
||||
"projectbrainDeliveryRequired": result.get("projectbrainDeliveryRequired"),
|
||||
}
|
||||
|
||||
|
||||
def _execute_hub_migration_prepare_permissions(
|
||||
session: Session,
|
||||
*,
|
||||
source: str,
|
||||
risk: str,
|
||||
reason: str,
|
||||
original_policy_decision: str,
|
||||
) -> dict[str, Any]:
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
|
||||
action = "hub.migration.prepare_permissions"
|
||||
target = "tower/itworx-mcp-hub-permissions"
|
||||
state_file = "/mnt/cache/appdata/gitea-deploy-runners/state/itworx-mcp-hub.json"
|
||||
fixed_targets = {state_file}
|
||||
steps = [
|
||||
"Validate the exact fixed MCP Hub permission target",
|
||||
"Authenticate the fixed root-controller request with HMAC and replay protection",
|
||||
"Verify the running controller is the protected current main release with full-push validation",
|
||||
"Reinspect the fixed Hub state file and shared deployment lock",
|
||||
"Apply only the fixed state-file mode correction and verify its final metadata",
|
||||
]
|
||||
job = create_job(
|
||||
session,
|
||||
action_id=action,
|
||||
target=target,
|
||||
source=source,
|
||||
risk=risk,
|
||||
policy_decision="allowed",
|
||||
steps=steps,
|
||||
)
|
||||
start_job(session, job)
|
||||
set_job_progress(session, job, 25, "Requesting fixed MCP Hub permission preparation through the controller.")
|
||||
try:
|
||||
result = DeploymentBrokerClient().hub_migration_prepare_permissions(reason=reason)
|
||||
status = str(result.get("status") or "blocked")
|
||||
revision_value = result.get("revision")
|
||||
revision = (
|
||||
revision_value
|
||||
if isinstance(revision_value, str)
|
||||
and len(revision_value) == 40
|
||||
and all(character in "0123456789abcdef" for character in revision_value)
|
||||
else None
|
||||
)
|
||||
|
||||
changed_value = result.get("changedTargets", [])
|
||||
changed_targets = (
|
||||
changed_value
|
||||
if isinstance(changed_value, list)
|
||||
and all(isinstance(path, str) and path in fixed_targets for path in changed_value)
|
||||
and len(changed_value) == len(set(changed_value))
|
||||
else None
|
||||
)
|
||||
changed_details_value = result.get("changedTargetDetails", [])
|
||||
target_names = {
|
||||
state_file: "state_file",
|
||||
}
|
||||
changed_details = (
|
||||
changed_details_value
|
||||
if isinstance(changed_details_value, list)
|
||||
and all(
|
||||
isinstance(item, dict)
|
||||
and set(item) == {"name", "path", "observedMode", "sameIdentityAtPath"}
|
||||
and isinstance(item.get("path"), str)
|
||||
and item.get("path") in fixed_targets
|
||||
and item.get("name") == target_names.get(item.get("path"))
|
||||
and isinstance(item.get("observedMode"), str)
|
||||
and len(item["observedMode"]) == 4
|
||||
and item["observedMode"].startswith("0")
|
||||
and all(character in "01234567" for character in item["observedMode"])
|
||||
and isinstance(item.get("sameIdentityAtPath"), bool)
|
||||
and changed_targets is not None
|
||||
and item["path"] in changed_targets
|
||||
for item in changed_details_value
|
||||
)
|
||||
and len({item["path"] for item in changed_details_value}) == len(changed_details_value)
|
||||
else None
|
||||
)
|
||||
uncertain_value = result.get("uncertainTargets", [])
|
||||
uncertain_targets = (
|
||||
uncertain_value
|
||||
if isinstance(uncertain_value, list)
|
||||
and all(isinstance(path, str) and path in fixed_targets for path in uncertain_value)
|
||||
and len(uncertain_value) == len(set(uncertain_value))
|
||||
else None
|
||||
)
|
||||
expected_verified = {
|
||||
"state_file": {
|
||||
"path": state_file,
|
||||
"uid": 0,
|
||||
"gid": 0,
|
||||
"mode": "0600",
|
||||
"aclPresent": False,
|
||||
},
|
||||
}
|
||||
verified_value = result.get("verifiedTargets")
|
||||
verified_targets = None
|
||||
if isinstance(verified_value, list) and len(verified_value) == len(expected_verified):
|
||||
by_name = {
|
||||
item.get("name"): item
|
||||
for item in verified_value
|
||||
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
||||
}
|
||||
if set(by_name) == set(expected_verified) and all(
|
||||
all(by_name[name].get(key) == value for key, value in expected.items())
|
||||
for name, expected in expected_verified.items()
|
||||
):
|
||||
verified_targets = [
|
||||
{"name": name, **expected_verified[name]}
|
||||
for name in ("state_file",)
|
||||
]
|
||||
|
||||
has_controller_projection = all(
|
||||
key in result
|
||||
for key in (
|
||||
"revision",
|
||||
"changedTargets",
|
||||
"changedTargetDetails",
|
||||
"uncertainTargets",
|
||||
)
|
||||
)
|
||||
success = (
|
||||
status in {"prepared", "already_compliant"}
|
||||
and has_controller_projection
|
||||
and revision is not None
|
||||
and changed_targets is not None
|
||||
and changed_details is not None
|
||||
and uncertain_targets == []
|
||||
and verified_targets is not None
|
||||
and (bool(changed_targets) if status == "prepared" else not changed_targets)
|
||||
)
|
||||
if success:
|
||||
message = (
|
||||
"The fixed MCP Hub state-file permission was prepared and verified."
|
||||
if status == "prepared"
|
||||
else "The fixed MCP Hub state-file permission was already compliant and verified."
|
||||
)
|
||||
elif (
|
||||
status == "partial"
|
||||
and has_controller_projection
|
||||
and revision is not None
|
||||
and changed_targets is not None
|
||||
and changed_details is not None
|
||||
and uncertain_targets is not None
|
||||
and (changed_targets or uncertain_targets)
|
||||
and len(changed_details) == len(changed_targets)
|
||||
and {item["path"] for item in changed_details} == set(changed_targets)
|
||||
):
|
||||
message = "Permission preparation stopped partway; inspect the exact changed and uncertain fixed targets before retrying."
|
||||
elif (
|
||||
status in {
|
||||
"blocked",
|
||||
"unauthorized",
|
||||
"missing_config",
|
||||
"missing_secret",
|
||||
"runtime_identity_unavailable",
|
||||
"controller_source_mismatch",
|
||||
"preflight_rejected",
|
||||
"configuration_unavailable",
|
||||
"exact_source_unavailable",
|
||||
"invalid_request",
|
||||
"invalid_body_size",
|
||||
"authentication_rejected",
|
||||
"forbidden",
|
||||
}
|
||||
and changed_targets == []
|
||||
and changed_details == []
|
||||
and uncertain_targets == []
|
||||
):
|
||||
status = "blocked"
|
||||
message = "The fixed MCP Hub permission request was rejected before any target change."
|
||||
else:
|
||||
status = "outcome_unknown"
|
||||
changed_targets = []
|
||||
changed_details = []
|
||||
uncertain_targets = sorted(fixed_targets)
|
||||
verified_targets = None
|
||||
message = (
|
||||
"The controller request outcome is unknown; the fixed Hub state file may have changed. "
|
||||
"Inspect it before retrying."
|
||||
)
|
||||
except Exception:
|
||||
result = {"status": "outcome_unknown"}
|
||||
status = "outcome_unknown"
|
||||
revision = None
|
||||
changed_targets = []
|
||||
changed_details = []
|
||||
uncertain_targets = sorted(fixed_targets)
|
||||
verified_targets = None
|
||||
success = False
|
||||
message = (
|
||||
"The controller request outcome is unknown; the fixed Hub state file may have changed. "
|
||||
"Inspect it before retrying."
|
||||
)
|
||||
|
||||
completed = complete_job(
|
||||
session,
|
||||
job,
|
||||
status="succeeded" if success else "failed",
|
||||
result_summary=message,
|
||||
)
|
||||
audit = log_event(
|
||||
session,
|
||||
source="action",
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
target_type="mcp-hub-permissions",
|
||||
risk=risk,
|
||||
policy_decision="allowed",
|
||||
reason=reason,
|
||||
result="success" if success else "outcome_unknown" if status == "outcome_unknown" else "failed",
|
||||
execution_plan={"steps": steps, "jobId": completed.id},
|
||||
after_state={
|
||||
"status": status,
|
||||
"revision": revision,
|
||||
"changedTargets": changed_targets,
|
||||
"changedTargetDetails": changed_details,
|
||||
"uncertainTargets": uncertain_targets,
|
||||
"verifiedTargets": verified_targets,
|
||||
},
|
||||
redacted_metadata={
|
||||
"executor": action,
|
||||
"integration": "mcp" if source == "chatgpt-mcp" else "operator",
|
||||
"originalPolicyDecision": original_policy_decision,
|
||||
"targetSelectionCallerSelectable": False,
|
||||
"pathOrCommandCallerSelectable": False,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"success": success,
|
||||
"status": "success" if success else status,
|
||||
"result": "success" if success else "outcome_unknown" if status == "outcome_unknown" else "failed",
|
||||
"message": message,
|
||||
"jobId": completed.id,
|
||||
"auditId": audit.id,
|
||||
"policyDecision": "allowed",
|
||||
"originalPolicyDecision": original_policy_decision,
|
||||
"controllerStatus": status,
|
||||
"revision": revision,
|
||||
"changedTargets": changed_targets,
|
||||
"changedTargetDetails": changed_details,
|
||||
"uncertainTargets": uncertain_targets,
|
||||
"verifiedTargets": verified_targets,
|
||||
}
|
||||
|
||||
|
||||
@@ -1809,6 +2076,24 @@ def execute_governed_action(
|
||||
original_policy_decision=original_policy_decision,
|
||||
)
|
||||
|
||||
if action == "hub.migration.prepare_permissions":
|
||||
if target.strip().lower() != "tower/itworx-mcp-hub-permissions":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failed",
|
||||
"result": "blocked",
|
||||
"message": "MCP Hub permission preparation accepts only tower/itworx-mcp-hub-permissions.",
|
||||
"policyDecision": "blocked",
|
||||
"originalPolicyDecision": original_policy_decision,
|
||||
}
|
||||
return _execute_hub_migration_prepare_permissions(
|
||||
session,
|
||||
source=source,
|
||||
risk=risk,
|
||||
reason=reason,
|
||||
original_policy_decision=original_policy_decision,
|
||||
)
|
||||
|
||||
if action == "runner.fleet.upgrade_v2":
|
||||
if target.strip().lower() != "tower/runner-fleet-v2":
|
||||
return {
|
||||
@@ -1827,6 +2112,28 @@ def execute_governed_action(
|
||||
original_policy_decision=original_policy_decision,
|
||||
)
|
||||
|
||||
if action == "runner.statistics.configure":
|
||||
if target.strip().lower() != "tower/projectbrain-runner-statistics":
|
||||
return {"success": False, "status": "failed", "result": "blocked",
|
||||
"message": "Statistics configuration accepts only tower/projectbrain-runner-statistics.",
|
||||
"policyDecision": "blocked", "originalPolicyDecision": original_policy_decision}
|
||||
return _execute_runner_fleet_upgrade_v2(
|
||||
session, source=source, risk=risk, reason=reason,
|
||||
original_policy_decision=original_policy_decision, statistics_configuration=True,
|
||||
)
|
||||
|
||||
if action == "runner.fleet.specialize":
|
||||
if target.strip().lower() != "tower/runner-fleet-specialized":
|
||||
return {
|
||||
"success": False, "status": "failed", "result": "blocked",
|
||||
"message": "Runner specialization accepts only tower/runner-fleet-specialized.",
|
||||
"policyDecision": "blocked", "originalPolicyDecision": original_policy_decision,
|
||||
}
|
||||
return _execute_runner_fleet_upgrade_v2(
|
||||
session, source=source, risk=risk, reason=reason,
|
||||
original_policy_decision=original_policy_decision, specialized=True,
|
||||
)
|
||||
|
||||
if action == "deployment.runnerless.provision":
|
||||
return _execute_runnerless_target_provisioning(
|
||||
session,
|
||||
@@ -2287,6 +2594,44 @@ def execute_approved_action(session: Session, approval: Approval) -> dict[str, A
|
||||
original_policy_decision="approval_required",
|
||||
)
|
||||
|
||||
if approval.action == "runner.statistics.configure":
|
||||
if approval.target.strip().lower() != "tower/projectbrain-runner-statistics":
|
||||
return {"success": False, "status": "failed", "result": "blocked",
|
||||
"message": "Statistics configuration accepts only tower/projectbrain-runner-statistics."}
|
||||
return _execute_runner_fleet_upgrade_v2(
|
||||
session, source="approved_operator", risk=approval.risk,
|
||||
reason=approval.reason or f"Approved through {approval.id}.",
|
||||
original_policy_decision="approval_required", statistics_configuration=True,
|
||||
)
|
||||
|
||||
if approval.action == "runner.fleet.specialize":
|
||||
if approval.target.strip().lower() != "tower/runner-fleet-specialized":
|
||||
return {
|
||||
"success": False, "status": "failed", "result": "blocked",
|
||||
"message": "Runner specialization accepts only tower/runner-fleet-specialized.",
|
||||
}
|
||||
return _execute_runner_fleet_upgrade_v2(
|
||||
session, source="approved_operator", risk=approval.risk,
|
||||
reason=approval.reason or f"Approved through {approval.id}.",
|
||||
original_policy_decision="approval_required", specialized=True,
|
||||
)
|
||||
|
||||
if approval.action == "hub.migration.prepare_permissions":
|
||||
if approval.target.strip().lower() != "tower/itworx-mcp-hub-permissions":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "failed",
|
||||
"result": "blocked",
|
||||
"message": "MCP Hub permission preparation accepts only tower/itworx-mcp-hub-permissions.",
|
||||
}
|
||||
return _execute_hub_migration_prepare_permissions(
|
||||
session,
|
||||
source="approved_operator",
|
||||
risk=approval.risk,
|
||||
reason=approval.reason or f"Approved through {approval.id}.",
|
||||
original_policy_decision="approval_required",
|
||||
)
|
||||
|
||||
if approval.action in {"unraid.vm.start", "unraid.vm.resume", "unraid.vm.pause"}:
|
||||
from .unraid_vm_service import execute_approved_vm_action
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@ ACTIONS = [
|
||||
'container.restart',
|
||||
'container.stop',
|
||||
'runner.fleet.upgrade_v2',
|
||||
'runner.fleet.specialize',
|
||||
'runner.statistics.configure',
|
||||
'controller.artifact.promote',
|
||||
'controller.managed-storage.initialize',
|
||||
'hub.migration.prepare_permissions',
|
||||
'gateway.clear_cache',
|
||||
'stack.status',
|
||||
'stack.redeploy',
|
||||
@@ -214,8 +217,11 @@ ACTION_RISK = {
|
||||
'container.restart': 'medium',
|
||||
'container.stop': 'high',
|
||||
'runner.fleet.upgrade_v2': 'high',
|
||||
'runner.fleet.specialize': 'high',
|
||||
'runner.statistics.configure': 'high',
|
||||
'controller.artifact.promote': 'high',
|
||||
'controller.managed-storage.initialize': 'high',
|
||||
'hub.migration.prepare_permissions': 'high',
|
||||
'gateway.clear_cache': 'high',
|
||||
'stack.status': 'low',
|
||||
'stack.redeploy': 'high',
|
||||
@@ -529,8 +535,14 @@ def action_default_target(action: str) -> str:
|
||||
return 'tower/deployment-controller'
|
||||
if action == 'controller.managed-storage.initialize':
|
||||
return 'tower/deployment-controller'
|
||||
if action == 'hub.migration.prepare_permissions':
|
||||
return 'tower/itworx-mcp-hub-permissions'
|
||||
if action == 'runner.fleet.upgrade_v2':
|
||||
return 'tower/runner-fleet-v2'
|
||||
if action == 'runner.fleet.specialize':
|
||||
return 'tower/runner-fleet-specialized'
|
||||
if action == 'runner.statistics.configure':
|
||||
return 'tower/projectbrain-runner-statistics'
|
||||
if action == 'deployment.targets.sync':
|
||||
return 'jens/*'
|
||||
if action == 'deployment.target.deploy_workmesh':
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
SOURCE_REPOSITORY = "jens/unraid-appops-gateway"
|
||||
HELPER_NAME = "itworx-controller-artifact-promoter-modern"
|
||||
CONTROLLER_VERSION = "2.1.14"
|
||||
HELPER_LABEL = "itworx.controller.promoter"
|
||||
_REVISION = re.compile(r"^[0-9a-f]{40}$")
|
||||
_IMAGE_ID = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
_CONTAINER_ID = re.compile(r"^[0-9a-f]{64}$")
|
||||
_VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
def helper_container_spec(
|
||||
*,
|
||||
revision: str,
|
||||
image_id: str,
|
||||
current_controller_id: str | None = None,
|
||||
current_controller_revision: str | None = None,
|
||||
current_controller_tree: str | None = None,
|
||||
current_controller_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not _REVISION.fullmatch(revision):
|
||||
raise ValueError("controller_promoter_revision_invalid")
|
||||
if not _IMAGE_ID.fullmatch(image_id):
|
||||
raise ValueError("controller_promoter_image_invalid")
|
||||
|
||||
bootstrap_values = (
|
||||
current_controller_id,
|
||||
current_controller_revision,
|
||||
current_controller_tree,
|
||||
current_controller_version,
|
||||
)
|
||||
if any(value is not None for value in bootstrap_values):
|
||||
if (
|
||||
not _CONTAINER_ID.fullmatch(current_controller_id or "")
|
||||
or not _REVISION.fullmatch(current_controller_revision or "")
|
||||
or not _REVISION.fullmatch(current_controller_tree or "")
|
||||
or not _VERSION.fullmatch(current_controller_version or "")
|
||||
):
|
||||
raise ValueError("controller_promoter_bootstrap_binding_invalid")
|
||||
|
||||
command = f"""set -Eeuo pipefail
|
||||
umask 077
|
||||
revision='{revision}'
|
||||
version='{CONTROLLER_VERSION}'
|
||||
image_id='{image_id}'
|
||||
repo='/gitea-repositories/jens/unraid-appops-gateway.git'
|
||||
[[ \"$(stat -c '%a:%u:%g' /tmp)\" == '1777:0:0' ]]
|
||||
work=\"$(mktemp -d -p /tmp itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX)\"
|
||||
[[ -d \"$work\" && ! -L \"$work\" && \"$(stat -c '%u:%a' \"$work\")\" == '0:700' ]]
|
||||
cleanup() {{ rm -rf -- \"$work\"; }}
|
||||
trap cleanup EXIT
|
||||
command -v git >/dev/null
|
||||
command -v docker >/dev/null
|
||||
docker compose version >/dev/null
|
||||
command -v python3 >/dev/null
|
||||
command -v tar >/dev/null
|
||||
command -v curl >/dev/null
|
||||
git --git-dir=\"$repo\" cat-file -e \"$revision^{{commit}}\"
|
||||
tree=\"$(git --git-dir=\"$repo\" rev-parse \"$revision^{{tree}}\")\"
|
||||
git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\
|
||||
ops/gitea-deploy-broker \\
|
||||
scripts/prepare_controller_artifact.py \\
|
||||
scripts/reconcile_controller_state.py \\
|
||||
scripts/prepare_controller_hub_release_volume.py \\
|
||||
scripts/vendor \\
|
||||
scripts/run_repopulse_adoption.py \
|
||||
scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"
|
||||
chmod 0600 \"$work/controller.tar\"
|
||||
archive_sha=\"$(sha256sum \"$work/controller.tar\" | cut -d ' ' -f 1)\"
|
||||
git --git-dir=\"$repo\" show \"$revision:scripts/upgrade_deployment_controller.sh\" > \"$work/upgrade.sh\"
|
||||
chmod 0700 \"$work/upgrade.sh\"
|
||||
bash \"$work/upgrade.sh\" \\
|
||||
--archive \"$work/controller.tar\" \\
|
||||
--archive-sha256 \"$archive_sha\" \\
|
||||
--source-repository '{SOURCE_REPOSITORY}' \\
|
||||
--source-revision \"$revision\" \\
|
||||
--source-tree \"$tree\" \\
|
||||
--controller-version \"$version\" \\
|
||||
--image-id \"$image_id\" \\
|
||||
--confirm UPGRADE_DEPLOYMENT_CONTROLLER_V2
|
||||
"""
|
||||
|
||||
labels = {
|
||||
HELPER_LABEL: "true",
|
||||
"itworx.source.repository": SOURCE_REPOSITORY,
|
||||
"itworx.source.revision": revision,
|
||||
"itworx.controller.version": CONTROLLER_VERSION,
|
||||
}
|
||||
if current_controller_id is not None:
|
||||
labels.update(
|
||||
{
|
||||
"itworx.controller.bootstrap.target.id": current_controller_id,
|
||||
"itworx.controller.bootstrap.target.image": image_id,
|
||||
"itworx.controller.bootstrap.target.revision": current_controller_revision or "",
|
||||
"itworx.controller.bootstrap.target.tree": current_controller_tree or "",
|
||||
"itworx.controller.bootstrap.target.version": current_controller_version or "",
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"Image": image_id,
|
||||
"User": "0:0",
|
||||
"Cmd": ["/bin/bash", "-lc", command],
|
||||
"Labels": labels,
|
||||
"HostConfig": {
|
||||
"Binds": [
|
||||
"/mnt/cache:/mnt/cache",
|
||||
"/mnt/user:/mnt/user",
|
||||
"/var/lib/docker:/var/lib/docker:ro",
|
||||
"/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
],
|
||||
"NetworkMode": "host",
|
||||
"Privileged": False,
|
||||
"ReadonlyRootfs": True,
|
||||
"CpuPeriod": 100000,
|
||||
"CpuQuota": 100000,
|
||||
"Memory": 1024 * 1024 * 1024,
|
||||
"PidsLimit": 128,
|
||||
"Tmpfs": {"/tmp": "rw,nosuid,nodev,size=268435456,mode=1777"},
|
||||
"SecurityOpt": ["no-new-privileges:true"],
|
||||
"RestartPolicy": {"Name": "no"},
|
||||
},
|
||||
}
|
||||
@@ -11,19 +11,22 @@ import httpx
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from .controller_artifact_helper_spec import (
|
||||
CONTROLLER_VERSION,
|
||||
HELPER_LABEL,
|
||||
HELPER_NAME,
|
||||
SOURCE_REPOSITORY as SOURCE_REPOSITORY,
|
||||
_IMAGE_ID,
|
||||
_REVISION,
|
||||
helper_container_spec,
|
||||
)
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
|
||||
SOURCE_REPOSITORY = "jens/unraid-appops-gateway"
|
||||
GITEA_OWNER = "Jens"
|
||||
GITEA_REPOSITORY = "Unraid-AppOps-Gateway"
|
||||
REQUIRED_PUSH_CONTEXT = "Managed validation / full (push)"
|
||||
HELPER_NAME = "itworx-controller-artifact-promoter"
|
||||
CONTROLLER_CONTAINER = "gitea-deploy-control"
|
||||
CONTROLLER_VERSION = "2.1.13"
|
||||
HELPER_LABEL = "itworx.controller.promoter"
|
||||
_HELPER_TIMEOUT_SECONDS = 240
|
||||
_REVISION = re.compile(r"^[0-9a-f]{40}$")
|
||||
_IMAGE_ID = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$")
|
||||
|
||||
|
||||
@@ -31,78 +34,6 @@ class ControllerArtifactPromotionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def helper_container_spec(*, revision: str, image_id: str) -> dict[str, Any]:
|
||||
if not _REVISION.fullmatch(revision):
|
||||
raise ValueError("controller_promoter_revision_invalid")
|
||||
if not _IMAGE_ID.fullmatch(image_id):
|
||||
raise ValueError("controller_promoter_image_invalid")
|
||||
|
||||
command = f"""set -Eeuo pipefail
|
||||
umask 077
|
||||
revision='{revision}'
|
||||
version='{CONTROLLER_VERSION}'
|
||||
repo='/gitea-repositories/jens/unraid-appops-gateway.git'
|
||||
work="$(mktemp -d -p /tmp 'itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX')"
|
||||
[[ -d "$work" && ! -L "$work" && "$(stat -c '%u:%a' "$work")" == '0:700' ]]
|
||||
cleanup() {{ rm -rf -- "$work"; }}
|
||||
trap cleanup EXIT
|
||||
command -v git >/dev/null
|
||||
command -v docker >/dev/null
|
||||
docker compose version >/dev/null
|
||||
command -v python3 >/dev/null
|
||||
command -v tar >/dev/null
|
||||
command -v curl >/dev/null
|
||||
git --git-dir="$repo" cat-file -e "$revision^{{commit}}"
|
||||
tree="$(git --git-dir="$repo" rev-parse "$revision^{{tree}}")"
|
||||
git --git-dir="$repo" archive --format=tar "$revision" \
|
||||
ops/gitea-deploy-broker \
|
||||
scripts/prepare_controller_artifact.py \
|
||||
scripts/reconcile_controller_state.py \
|
||||
scripts/run_repopulse_adoption.py \
|
||||
scripts/prepare_controller_hub_release_volume.py \
|
||||
scripts/vendor \
|
||||
scripts/upgrade_deployment_controller.sh > "$work/controller.tar"
|
||||
chmod 0600 "$work/controller.tar"
|
||||
archive_sha="$(sha256sum "$work/controller.tar" | cut -d ' ' -f 1)"
|
||||
git --git-dir="$repo" show "$revision:scripts/upgrade_deployment_controller.sh" > "$work/upgrade.sh"
|
||||
chmod 0700 "$work/upgrade.sh"
|
||||
bash "$work/upgrade.sh" \
|
||||
--archive "$work/controller.tar" \
|
||||
--archive-sha256 "$archive_sha" \
|
||||
--source-repository '{SOURCE_REPOSITORY}' \
|
||||
--source-revision "$revision" \
|
||||
--source-tree "$tree" \
|
||||
--controller-version "$version" \
|
||||
--image-id '{image_id}' \
|
||||
--confirm UPGRADE_DEPLOYMENT_CONTROLLER_V2
|
||||
"""
|
||||
|
||||
return {
|
||||
"Image": image_id,
|
||||
"User": "0:0",
|
||||
"Cmd": ["/bin/bash", "-lc", command],
|
||||
"Labels": {
|
||||
HELPER_LABEL: "true",
|
||||
"itworx.source.repository": SOURCE_REPOSITORY,
|
||||
"itworx.source.revision": revision,
|
||||
"itworx.controller.version": CONTROLLER_VERSION,
|
||||
},
|
||||
"HostConfig": {
|
||||
"Binds": [
|
||||
"/mnt/cache:/mnt/cache",
|
||||
"/mnt/user:/mnt/user",
|
||||
"/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro",
|
||||
"/var/lib/docker:/var/lib/docker:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
],
|
||||
"NetworkMode": "host",
|
||||
"Privileged": False,
|
||||
"SecurityOpt": ["no-new-privileges:true"],
|
||||
"RestartPolicy": {"Name": "no"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ControllerArtifactPromoter:
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -551,6 +551,27 @@ class DeploymentBrokerClient:
|
||||
payload={"reason": reason[:1000]},
|
||||
)
|
||||
|
||||
def runner_fleet_specialize(self, *, reason: str) -> dict[str, Any]:
|
||||
"""Request the fixed protected-source CI specialization migration."""
|
||||
return self._request(
|
||||
"POST", "/v1/appops/runner-fleet/specialize",
|
||||
secret=settings.mcp_token or "", payload={"reason": reason[:1000]},
|
||||
)
|
||||
|
||||
def runner_statistics_configure(self, *, reason: str) -> dict[str, Any]:
|
||||
"""Provision the fixed independent read-only ProjectBrain capability."""
|
||||
return self._request(
|
||||
"POST", "/v1/appops/runner-statistics/configure",
|
||||
secret=settings.mcp_token or "", payload={"reason": reason[:1000]},
|
||||
)
|
||||
|
||||
def hub_migration_prepare_permissions(self, *, reason: str) -> dict[str, Any]:
|
||||
"""Prepare only the fixed Hub backup and controller-state permissions."""
|
||||
return self._request(
|
||||
"POST", "/v1/appops/hub-migration/prepare-permissions",
|
||||
secret=settings.mcp_token or "", payload={"reason": reason[:1000]},
|
||||
)
|
||||
|
||||
def runner_fleet_status(self) -> dict[str, Any]:
|
||||
"""Read the controller's private, bounded CI sandbox projection.
|
||||
|
||||
@@ -558,16 +579,25 @@ class DeploymentBrokerClient:
|
||||
derives the payload from fixed runner identities and its private DinD
|
||||
daemon; AppOps never receives a Docker socket or a cleanup selector.
|
||||
"""
|
||||
return self._runner_fleet_read("/v1/runner-fleet")
|
||||
|
||||
def runner_fleet_statistics(self) -> dict[str, Any]:
|
||||
"""Read the persisted profile projection without scheduling work."""
|
||||
return self._runner_fleet_read("/v1/runner-fleet/statistics", timeout_seconds=min(self.timeout_seconds, 5))
|
||||
|
||||
def _runner_fleet_read(self, path: str, *, timeout_seconds: float | None = None) -> dict[str, Any]:
|
||||
if path not in {"/v1/runner-fleet", "/v1/runner-fleet/statistics"}:
|
||||
return {"status": "unavailable", "reason": "runner_fleet_path_invalid"}
|
||||
if not self.is_configured():
|
||||
return {"status": "missing_config", "reason": self.configuration_error()}
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=self.timeout_seconds,
|
||||
timeout=self.timeout_seconds if timeout_seconds is None else timeout_seconds,
|
||||
verify=self._verify_option(),
|
||||
follow_redirects=False,
|
||||
) as client, client.stream(
|
||||
"GET",
|
||||
f"{self.base_url}/v1/runner-fleet",
|
||||
f"{self.base_url}{path}",
|
||||
headers={"Accept": "application/json"},
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
|
||||
@@ -32,8 +32,8 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
path = Path("/opt/gitea-deploy/projects.json")
|
||||
host_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")
|
||||
path = Path("/controller-config/projects.json")
|
||||
host_path = Path("/controller-hub-releases/controller-config/projects.json")
|
||||
lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")
|
||||
repository, revision, branch, raw_project = sys.argv[1:5]
|
||||
project = json.loads(raw_project)
|
||||
|
||||
@@ -29,8 +29,8 @@ import sys
|
||||
import time
|
||||
|
||||
repository, expected_container = sys.argv[1:3]
|
||||
registry_path = Path("/opt/gitea-deploy/projects.json")
|
||||
host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")
|
||||
registry_path = Path("/controller-config/projects.json")
|
||||
host_registry_path = Path("/controller-hub-releases/controller-config/projects.json")
|
||||
state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state")
|
||||
state_path = state_root / "media-codec-dashboard.json"
|
||||
project_lock_path = state_root / "locks" / "media-codec-dashboard.lock"
|
||||
|
||||
@@ -50,7 +50,7 @@ from pathlib import Path
|
||||
import sys
|
||||
|
||||
repository = sys.argv[1]
|
||||
path = Path("/opt/gitea-deploy/projects.json")
|
||||
path = Path("/controller-config/projects.json")
|
||||
try:
|
||||
projects = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
@@ -145,8 +145,8 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
registry_path = Path("/opt/gitea-deploy/projects.json")
|
||||
host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")
|
||||
registry_path = Path("/controller-config/projects.json")
|
||||
host_registry_path = Path("/controller-hub-releases/controller-config/projects.json")
|
||||
state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state")
|
||||
state_path = state_root / "portfolio.json"
|
||||
project_lock_path = state_root / "locks" / "portfolio.lock"
|
||||
|
||||
@@ -46,7 +46,7 @@ if (
|
||||
raise SystemExit(40)
|
||||
|
||||
controller_path = Path("/opt/gitea-deploy/deploy.py")
|
||||
registry_path = Path("/opt/gitea-deploy/projects.json")
|
||||
registry_path = Path("/controller-config/projects.json")
|
||||
state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state")
|
||||
state_path = state_root / "portfolio.json"
|
||||
project_lock_path = state_root / "locks" / "portfolio.lock"
|
||||
|
||||
@@ -46,8 +46,8 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
registry_path = Path("/opt/gitea-deploy/projects.json")
|
||||
host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")
|
||||
registry_path = Path("/controller-config/projects.json")
|
||||
host_registry_path = Path("/controller-hub-releases/controller-config/projects.json")
|
||||
state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state")
|
||||
state_path = state_root / "privatemuse.json"
|
||||
project_lock_path = state_root / "locks" / "privatemuse.lock"
|
||||
|
||||
@@ -64,7 +64,7 @@ if (
|
||||
raise SystemExit(40)
|
||||
|
||||
controller_path = Path("/opt/gitea-deploy/deploy.py")
|
||||
registry_path = Path("/opt/gitea-deploy/projects.json")
|
||||
registry_path = Path("/controller-config/projects.json")
|
||||
state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state")
|
||||
state_path = state_root / "privatemuse.json"
|
||||
project_lock_path = state_root / "locks" / "privatemuse.lock"
|
||||
|
||||
@@ -26,8 +26,8 @@ repository, endpoint = sys.argv[1:3]
|
||||
if repository != "jens/privatemuse" or endpoint != "http://198.19.0.1:1299/v1/gitea/push/jens/privatemuse":
|
||||
raise SystemExit(40)
|
||||
|
||||
projects_path = Path("/opt/gitea-deploy/projects.json")
|
||||
secret_root = Path("/opt/gitea-deploy/webhook-secrets")
|
||||
projects_path = Path("/controller-config/projects.json")
|
||||
secret_root = Path("/controller-config/webhook-secrets")
|
||||
secret_path = secret_root / "jens__privatemuse"
|
||||
admin_token_path = Path("/mnt/cache/appdata/gitea-deploy-runners/secrets/runner_guard_admin_token")
|
||||
expected_validation = {
|
||||
|
||||
@@ -75,8 +75,8 @@ CONTAINER = "repopulse"
|
||||
VOLUME = "repopulse_data"
|
||||
APPDATA_ROOT = Path("/srv/example/appdata/repopulse")
|
||||
SCAN_ROOT = Path("/mnt/cache/appdata/gitea-deploy-runners/sources")
|
||||
REGISTRY = Path("/opt/gitea-deploy/projects.json")
|
||||
HOST_REGISTRY = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")
|
||||
REGISTRY = Path("/controller-config/projects.json")
|
||||
HOST_REGISTRY = Path("/controller-hub-releases/controller-config/projects.json")
|
||||
STATE = Path("/mnt/cache/appdata/gitea-deploy-runners/state/repopulse.json")
|
||||
BARE_REPO = Path("/gitea-repositories/jens/repopulse.git")
|
||||
SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Bounded read-only CI statistics; absent measurements remain unknown."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from .deployment_broker import DeploymentBrokerClient
|
||||
|
||||
|
||||
class _StatisticsModel(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", allow_inf_nan=False, strict=True)
|
||||
|
||||
|
||||
class _Budget(_StatisticsModel):
|
||||
cpuLimit: float | int | None = Field(default=None, ge=0, le=20)
|
||||
memoryLimitBytes: int | None = Field(default=None, ge=0)
|
||||
activeJobLimit: int | None = Field(default=None, ge=0, le=6)
|
||||
heavyJobLimit: int | None = Field(default=None, ge=0, le=6)
|
||||
reservedCpu: float | int | None = Field(default=None, ge=0)
|
||||
reservedMemoryBytes: int | None = Field(default=None, ge=0)
|
||||
activeJobs: int | None = Field(default=None, ge=0)
|
||||
heavyJobs: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class _Host(_StatisticsModel):
|
||||
cpuBusyPercent: float | int | None = Field(default=None, ge=0, le=100)
|
||||
loadRatio: float | int | None = Field(default=None, ge=0)
|
||||
availableMemoryBytes: int | None = Field(default=None, ge=0)
|
||||
cacheFreeBytes: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class _Profile(_StatisticsModel):
|
||||
id: Literal["checks", "backend", "frontend", "security", "browser", "build"]
|
||||
displayName: str = Field(min_length=1, max_length=80)
|
||||
labels: list[str] = Field(max_length=8)
|
||||
runnerName: str = Field(min_length=1, max_length=80)
|
||||
status: str = Field(min_length=1, max_length=80)
|
||||
queueDepth: int | None = Field(default=None, ge=0)
|
||||
activeJobs: int | None = Field(default=None, ge=0)
|
||||
oldestQueuedSeconds: float | int | None = Field(default=None, ge=0)
|
||||
reason: str | None = Field(default=None, max_length=120)
|
||||
cpuLimit: float | int | None = Field(default=None, ge=0, le=8)
|
||||
memoryLimitBytes: int | None = Field(default=None, ge=0)
|
||||
cpuUsed: float | int | None = Field(default=None, ge=0)
|
||||
memoryUsedBytes: int | None = Field(default=None, ge=0)
|
||||
completedJobs: int = Field(default=0, ge=0)
|
||||
p50WaitSeconds: float | int | None = Field(default=None, ge=0)
|
||||
p95WaitSeconds: float | int | None = Field(default=None, ge=0)
|
||||
p50DurationSeconds: float | int | None = Field(default=None, ge=0)
|
||||
p95DurationSeconds: float | int | None = Field(default=None, ge=0)
|
||||
lastDurationSeconds: float | int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class _Window(_StatisticsModel):
|
||||
startAt: str | None = Field(default=None, max_length=40)
|
||||
endAt: str | None = Field(default=None, max_length=40)
|
||||
completedJobs: int = Field(default=0, ge=0)
|
||||
samples: int = Field(default=0, ge=0)
|
||||
coverage: Literal["bounded", "complete", "unavailable"] = "unavailable"
|
||||
|
||||
|
||||
class _History(_StatisticsModel):
|
||||
at: str = Field(min_length=1, max_length=40)
|
||||
activeJobs: int | None = Field(default=None, ge=0)
|
||||
queuedJobs: int | None = Field(default=None, ge=0)
|
||||
cpuUsed: float | int | None = Field(default=None, ge=0)
|
||||
memoryUsedBytes: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class RunnerStatistics(_StatisticsModel):
|
||||
schemaVersion: Literal[1]
|
||||
status: Literal["healthy", "degraded", "unavailable", "not_configured"]
|
||||
scope: Literal["ci-fleet"]
|
||||
generatedAt: str = Field(min_length=1, max_length=40)
|
||||
budget: _Budget
|
||||
host: _Host
|
||||
profiles: list[_Profile] = Field(max_length=6)
|
||||
window: _Window
|
||||
history: list[_History] = Field(max_length=288)
|
||||
|
||||
|
||||
def _valid_timestamp(value: str) -> bool:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return False
|
||||
return parsed.tzinfo is not None and 0 < parsed.timestamp() <= time.time() + 15
|
||||
|
||||
|
||||
def unavailable_statistics(*, configured: bool = True) -> dict[str, object]:
|
||||
return RunnerStatistics(
|
||||
schemaVersion=1,
|
||||
status="unavailable" if configured else "not_configured",
|
||||
scope="ci-fleet",
|
||||
generatedAt=datetime.now(UTC).isoformat(),
|
||||
budget=_Budget(),
|
||||
host=_Host(),
|
||||
profiles=[],
|
||||
window=_Window(),
|
||||
history=[],
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
def sanitize_statistics(payload: object) -> dict[str, object]:
|
||||
"""Accept only the public contract, never raw paths, credentials or jobs."""
|
||||
try:
|
||||
snapshot = RunnerStatistics.model_validate(payload)
|
||||
except (ValidationError, TypeError, ValueError):
|
||||
return unavailable_statistics()
|
||||
timestamps = [snapshot.generatedAt, *(sample.at for sample in snapshot.history)]
|
||||
timestamps.extend(value for value in (snapshot.window.startAt, snapshot.window.endAt) if value)
|
||||
if any(not _valid_timestamp(value) for value in timestamps):
|
||||
return unavailable_statistics()
|
||||
if len({profile.id for profile in snapshot.profiles}) != len(snapshot.profiles):
|
||||
return unavailable_statistics()
|
||||
for profile in snapshot.profiles:
|
||||
allowed_labels = {f"itworx-ci-{profile.id}"}
|
||||
if profile.id in {"backend", "frontend"}:
|
||||
allowed_labels.update({"linux-validation", "ubuntu-latest"})
|
||||
elif profile.id == "build":
|
||||
allowed_labels.add("docker-validation")
|
||||
if (
|
||||
profile.displayName != f"ITWorx CI · {profile.id.title()}"
|
||||
or profile.runnerName != f"itworx-ci-{profile.id}"
|
||||
or not profile.labels
|
||||
or len(set(profile.labels)) != len(profile.labels)
|
||||
or not set(profile.labels).issubset(allowed_labels)
|
||||
or re.fullmatch(r"[a-z][a-z0-9_-]{0,79}", profile.status) is None
|
||||
or (profile.reason is not None and re.fullmatch(r"[a-z][a-z0-9_-]{0,119}", profile.reason) is None)
|
||||
):
|
||||
return unavailable_statistics()
|
||||
sample_time = datetime.fromisoformat(snapshot.generatedAt.replace("Z", "+00:00")).timestamp()
|
||||
history_times = [datetime.fromisoformat(sample.at.replace("Z", "+00:00")).timestamp() for sample in snapshot.history]
|
||||
if history_times != sorted(set(history_times)) or any(at > sample_time + 15 for at in history_times):
|
||||
return unavailable_statistics()
|
||||
window = snapshot.window
|
||||
if (window.startAt is None) != (window.endAt is None):
|
||||
return unavailable_statistics()
|
||||
if window.startAt is not None and window.endAt is not None:
|
||||
start = datetime.fromisoformat(window.startAt.replace("Z", "+00:00")).timestamp()
|
||||
end = datetime.fromisoformat(window.endAt.replace("Z", "+00:00")).timestamp()
|
||||
if not start <= end <= sample_time + 15:
|
||||
return unavailable_statistics()
|
||||
elif window.coverage != "unavailable":
|
||||
return unavailable_statistics()
|
||||
if time.time() - sample_time > 90 and snapshot.status == "healthy":
|
||||
snapshot.status = "degraded"
|
||||
return snapshot.model_dump(mode="json")
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cached: dict[str, object] | None = None
|
||||
_cached_at = 0.0
|
||||
_CACHE_SECONDS = 30.0
|
||||
|
||||
|
||||
class RunnerStatisticsService:
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
global _cached, _cached_at
|
||||
with _lock:
|
||||
now = time.monotonic()
|
||||
if _cached is not None and now - _cached_at < _CACHE_SECONDS:
|
||||
return copy.deepcopy(_cached)
|
||||
broker = DeploymentBrokerClient()
|
||||
if not broker.is_configured():
|
||||
snapshot = unavailable_statistics(configured=False)
|
||||
else:
|
||||
try:
|
||||
snapshot = sanitize_statistics(broker.runner_fleet_statistics())
|
||||
except Exception:
|
||||
snapshot = unavailable_statistics()
|
||||
_cached = snapshot
|
||||
_cached_at = time.monotonic()
|
||||
return copy.deepcopy(snapshot)
|
||||
@@ -36,8 +36,8 @@ if (
|
||||
):
|
||||
raise SystemExit(40)
|
||||
|
||||
projects_path = Path("/opt/gitea-deploy/projects.json")
|
||||
secret_root = Path("/opt/gitea-deploy/webhook-secrets")
|
||||
projects_path = Path("/controller-hub-releases/controller-config/projects.json")
|
||||
secret_root = Path("/controller-hub-releases/controller-config/webhook-secrets")
|
||||
admin_token_path = Path(
|
||||
"/mnt/cache/appdata/gitea-deploy-runners/secrets/runner_guard_admin_token"
|
||||
)
|
||||
|
||||
@@ -53,8 +53,8 @@ containers = [
|
||||
]
|
||||
ports = {1255, 1256, 1257, 1258}
|
||||
controller_root = Path("/opt/gitea-deploy")
|
||||
registry_path = controller_root / "projects.json"
|
||||
host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")
|
||||
registry_path = Path("/controller-config/projects.json")
|
||||
host_registry_path = Path("/controller-hub-releases/controller-config/projects.json")
|
||||
lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")
|
||||
env_dir = Path("/srv/example/appdata/workmesh")
|
||||
env_path = env_dir / ".env"
|
||||
|
||||
@@ -123,9 +123,14 @@ def test_reattest_preserves_evidence_and_observed_legacy_identity(recovery):
|
||||
before = r.path.read_bytes()
|
||||
compose = (r.repo / "docker-compose.yml").read_bytes()
|
||||
result = r.m.reattest_appops_live_state(r.project, r.repo, OBSERVED)
|
||||
read_state, read_raw, read_sha256 = r.m.read_appops_state_preimage(r.project)
|
||||
state = json.loads(r.path.read_bytes())
|
||||
backup = r.path.parent / result["backupFile"]
|
||||
assert backup.read_bytes() == before
|
||||
assert read_state == state
|
||||
assert read_raw == r.path.read_bytes()
|
||||
assert read_sha256 == hashlib.sha256(read_raw).hexdigest()
|
||||
assert os.name == "nt" or (backup.stat().st_mode & 0o777) == 0o600
|
||||
assert state["recovery"]["prior_state_sha256"] == hashlib.sha256(before).hexdigest()
|
||||
assert state["last_attempt"] == r.prior["last_attempt"]
|
||||
assert state["live_revision"] == OBSERVED
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from test_deploy_broker import (
|
||||
_appops_known_legacy_state,
|
||||
_appops_rollback_intent_fixture,
|
||||
deploy_module as deploy_module,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge(deploy_module, monkeypatch, tmp_path):
|
||||
m = deploy_module
|
||||
revision = "c" * 40
|
||||
state_root = tmp_path / "state"
|
||||
state_root.mkdir(mode=0o700)
|
||||
monkeypatch.setattr(m, "STATE_ROOT", state_root)
|
||||
monkeypatch.setattr(m, "APPOPS_LEGACY_INTENT_ROOT", state_root / "appops-legacy")
|
||||
monkeypatch.setattr(m, "APPOPS_LEGACY_INTENT_PATH", state_root / "appops-legacy" / "unraid-appops-gateway.json")
|
||||
prior = _appops_known_legacy_state()
|
||||
monkeypatch.setattr(m, "APPOPS_LEGACY_ATTEMPT_METADATA_SHA256", m._canonical_json_sha256(prior["last_attempt"]["metadata"]))
|
||||
monkeypatch.setattr(m, "APPOPS_LEGACY_PROJECTION_SHA256", m._canonical_json_sha256(m._appops_legacy_state_projection(prior)))
|
||||
repository, metadata, source, green, backup, sample = _appops_rollback_intent_fixture(m, revision, "e" * 64)
|
||||
project = {"slug": "unraid-appops-gateway", "compose_project": "unraid-appops-gateway",
|
||||
"_repository_name": repository, "branch": "main", "gitea_repository_id": 9,
|
||||
"containers": ["Unraid-AppOps-Gateway"], "compose_files": ["docker-compose.yml"],
|
||||
"verify_oci_revision": True, "environment": {"APPOPS_DATA_PATH": str(m.APPOPS_DATA_ROOT)}}
|
||||
runtime = {"containerId": m.APPOPS_LEGACY_OLD_CONTAINER_ID, "imageId": m.APPOPS_LEGACY_OLD_IMAGE_ID,
|
||||
"imageRef": m.APPOPS_LEGACY_OLD_IMAGE_REF, "sourceRepository": repository}
|
||||
artifacts = m._appops_legacy_state_override(prior, runtime)["live_artifacts"]
|
||||
artifacts["compose_sha256"] = ""
|
||||
monkeypatch.setattr(m, "require_host_root", lambda: None)
|
||||
monkeypatch.setattr(m, "_appops_recovery_config_unchanged", lambda _p: None)
|
||||
monkeypatch.setattr(m, "validate_commit", lambda _r, _b, rev: rev)
|
||||
monkeypatch.setattr(m, "validate_recorded_receipt", lambda *_a: None)
|
||||
monkeypatch.setattr(m, "_appops_recovery_identity", lambda *_a: runtime.copy())
|
||||
monkeypatch.setattr(m, "deployment_container_artifacts", lambda _p: copy.deepcopy(artifacts["containers"]))
|
||||
monkeypatch.setattr(m, "deployment_artifacts", lambda _p: copy.deepcopy(artifacts))
|
||||
monkeypatch.setattr(m, "verify_appops_green_delivery", lambda rev: green if rev == revision else pytest.fail("wrong candidate"))
|
||||
monkeypatch.setattr(m, "verify_appops_legacy_source", lambda *_a: source)
|
||||
monkeypatch.setattr(m, "revalidate_deployment_candidate", lambda *_a: None)
|
||||
proof = sample["migrationProof"]["runtime"]
|
||||
monkeypatch.setattr(m, "appops_legacy_compose_model", lambda *_a: ({"name": project["compose_project"]}, proof["resolvedComposeSha256"], proof["resolvedEnvironmentSha256"]))
|
||||
monkeypatch.setattr(m, "_appops_legacy_inspect_runtime", lambda *_a, **_k: ({}, {}, runtime, None))
|
||||
monkeypatch.setattr(m, "appops_legacy_runtime_hashes", lambda *_a: proof)
|
||||
events = []
|
||||
monkeypatch.setattr(m, "capture_appops_data_backup", lambda *_a: events.append("backup") or copy.deepcopy(backup))
|
||||
monkeypatch.setattr(m, "verify_appops_data_backup", lambda *_a: None)
|
||||
monkeypatch.setattr(m, "capture_compose_rollback", lambda *_a, **_k: events.append("snapshot") or copy.deepcopy(sample["rollbackSnapshot"]))
|
||||
monkeypatch.setattr(m, "_appops_legacy_tag_snapshot", lambda *_a: events.append("tag"))
|
||||
path = state_root / "unraid-appops-gateway.json"
|
||||
path.write_text(json.dumps(prior), encoding="utf-8")
|
||||
path.chmod(0o644)
|
||||
return m, project, tmp_path, revision, metadata, path, events
|
||||
|
||||
|
||||
def prepare_bridge(bridge):
|
||||
m, project, repo, revision, metadata, _path, _events = bridge
|
||||
state, raw, digest = m.read_appops_state_preimage(project)
|
||||
return m.prepare_appops_legacy_migration(project, project["_repository_name"], repo, revision, metadata, state, raw, digest)
|
||||
|
||||
|
||||
def test_reattested_state_prepares_durable_migration_and_completed_rollback_retry(bridge):
|
||||
m, project, repo, revision, metadata, path, events = bridge
|
||||
m.reattest_appops_live_state(project, repo, m.APPOPS_LEGACY_OLD_REVISION)
|
||||
state, raw, digest = m.read_appops_state_preimage(project)
|
||||
migration = prepare_bridge(bridge)
|
||||
assert events == ["backup", "snapshot", "tag"]
|
||||
intent, intent_hash = m.read_appops_legacy_intent()
|
||||
assert intent["stateSha256"] == digest
|
||||
assert intent["migrationProof"]["recovery"]["auditSha256"] == m._canonical_json_sha256(state["recovery"])
|
||||
assert m._appops_legacy_pending_state_is_bound(state, raw, digest, intent, revision, project, repo)
|
||||
wrong_intent = copy.deepcopy(intent)
|
||||
wrong_intent["migrationProof"]["recovery"]["auditSha256"] = "f" * 64
|
||||
assert not m._appops_legacy_pending_state_is_bound(state, raw, digest, wrong_intent, revision, project, repo)
|
||||
|
||||
project["_compose_rollback"] = migration["snapshot"]
|
||||
project["_rollback_restored_revision"] = m.APPOPS_LEGACY_OLD_REVISION
|
||||
m.write_state(project["_repository_name"], project, m.APPOPS_LEGACY_OLD_REVISION, "success",
|
||||
m.APPOPS_LEGACY_ROLLBACK_STATE_MESSAGE, live_legacy_provenance=True,
|
||||
live_receipt={}, rollback_snapshot={}, attempt_revision=revision,
|
||||
attempt_metadata={"operation": "rollback", "from_revision": revision}, expected_state_sha256=digest)
|
||||
restored, restored_raw, restored_hash = m.read_appops_state_preimage(project)
|
||||
assert m._appops_legacy_rollback_state_matches(restored, project, revision, intent)
|
||||
tampered = copy.deepcopy(restored)
|
||||
tampered["recovery"]["at_epoch"] += 1
|
||||
assert not m._appops_legacy_rollback_state_matches(tampered, project, revision, intent)
|
||||
completed = m._appops_legacy_rebind_observed_state(intent, restored_hash)
|
||||
completed["status"] = "rolled_back"
|
||||
m.write_appops_legacy_intent(completed, expected_sha256=intent_hash)
|
||||
assert m.appops_legacy_rollback_state_anchor(restored, restored_raw, completed, revision)
|
||||
events.clear()
|
||||
retried = prepare_bridge(bridge)
|
||||
assert events == ["tag"]
|
||||
assert retried["intent"]["migrationProof"] == intent["migrationProof"]
|
||||
assert retried["intent"]["status"] == "deploying"
|
||||
|
||||
|
||||
def test_candidate_crash_reconciles_the_reattested_preimage_to_a_real_receipt(bridge, monkeypatch):
|
||||
m, project, repo, revision, metadata, path, _events = bridge
|
||||
m.reattest_appops_live_state(project, repo, m.APPOPS_LEGACY_OLD_REVISION)
|
||||
original, raw, digest = m.read_appops_state_preimage(project)
|
||||
prepare_bridge(bridge)
|
||||
receipt = {"repository": project["_repository_name"], "revision": revision,
|
||||
"tree": metadata["tree"], "build": metadata}
|
||||
monkeypatch.setattr(m, "verify_deployment", lambda *_a: receipt)
|
||||
result = m.reconcile_appops_legacy_candidate(
|
||||
project, project["_repository_name"], repo, revision, metadata, original, raw, digest,
|
||||
)
|
||||
assert result == receipt
|
||||
state = json.loads(path.read_bytes())
|
||||
assert state["live_revision"] == revision
|
||||
assert state["live_receipt"] == receipt
|
||||
assert state["live_legacy_provenance"] is False
|
||||
assert state["recovery"] == original["recovery"]
|
||||
assert m.read_appops_legacy_intent()[0]["status"] == "deployed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["backup-bytes", "backup-mode", "backup-link", "audit", "state"])
|
||||
def test_reattest_bridge_rejects_tampering_before_backup_or_docker(bridge, damage):
|
||||
m, project, repo, _revision, _metadata, path, events = bridge
|
||||
result = m.reattest_appops_live_state(project, repo, m.APPOPS_LEGACY_OLD_REVISION)
|
||||
backup = path.parent / result["backupFile"]
|
||||
if damage == "backup-bytes":
|
||||
backup.write_bytes(b"{}")
|
||||
elif damage == "backup-mode":
|
||||
backup.chmod(0o644)
|
||||
elif damage == "backup-link":
|
||||
target = backup.with_suffix(".saved")
|
||||
backup.rename(target)
|
||||
backup.symlink_to(target)
|
||||
else:
|
||||
state = json.loads(path.read_bytes())
|
||||
if damage == "audit":
|
||||
state["recovery"]["observed_image_id"] = "sha256:" + "f" * 64
|
||||
else:
|
||||
state["live_artifacts"]["containers"]["Unraid-AppOps-Gateway"]["image_id"] = "sha256:" + "f" * 64
|
||||
path.write_text(json.dumps(state))
|
||||
with pytest.raises(m.AppOpsLegacyNoMutation):
|
||||
prepare_bridge(bridge)
|
||||
assert events == []
|
||||
assert not m.APPOPS_LEGACY_INTENT_PATH.exists()
|
||||
|
||||
|
||||
def prior_record(m, revision):
|
||||
semantic = m._canonical_json_sha256({"gitea_repository_id": 9, "ref": "refs/heads/main", "revision": revision})
|
||||
record = {"schema": "gitea-webhook-inbox.v2", "semantic_sha256": semantic,
|
||||
"body_sha256": "a" * 64, "delivery": "12345678-1234-4234-8234-123456789012",
|
||||
"gitea_repository_id": 9, "ref": "refs/heads/main", "repository": "jens/unraid-appops-gateway",
|
||||
"revision": revision, "source": "gitea_webhook", "status": "failed", "reason": "deployment_failed",
|
||||
"validation": {"validation_gate_version": 1, "required_contexts": ["Managed validation / full (push)"],
|
||||
"timeout_seconds": 1800, "poll_interval_seconds": 5, "max_poll_interval_seconds": 30,
|
||||
"last_result": "pass", "observed": [{"context": "Managed validation / full (push)", "status": "success"}]},
|
||||
"audit": [{"stage": "validation_passed"}, {"stage": "deployment_started"},
|
||||
{"stage": "deployment_failed", "reason": "deployment_failed"}]}
|
||||
inbox = m.STATE_ROOT / "webhook-inbox"
|
||||
inbox.mkdir(mode=0o700)
|
||||
marker = inbox / f"{semantic}.json"
|
||||
marker.write_text(json.dumps(record))
|
||||
marker.chmod(0o600)
|
||||
return marker, record
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", [None, "deployed", "pending", "missing", "metadata", "ancestry"])
|
||||
def test_prior_main_attempt_requires_private_green_failure_and_git_proof(bridge, monkeypatch, damage):
|
||||
m, _project, _repo, _revision, metadata, path, events = bridge
|
||||
prior_revision = "b" * 40
|
||||
state = json.loads(path.read_bytes())
|
||||
prior_metadata = {**metadata, "revision": prior_revision, "id": prior_revision[:12]}
|
||||
state["last_attempt"].update(revision=prior_revision, metadata=prior_metadata)
|
||||
path.write_text(json.dumps(state))
|
||||
marker, record = prior_record(m, prior_revision)
|
||||
monkeypatch.setattr(m, "deployment_metadata", lambda *_a, **_k: prior_metadata if damage != "metadata" else {**prior_metadata, "tree": "e" * 40})
|
||||
def ancestry(command, **_kwargs):
|
||||
assert command[-4:] == ["merge-base", "--is-ancestor", prior_revision, metadata["revision"]]
|
||||
if damage == "ancestry":
|
||||
raise subprocess.CalledProcessError(1, command)
|
||||
monkeypatch.setattr(m.subprocess, "run", ancestry)
|
||||
if damage == "deployed":
|
||||
record["status"] = "deployed" # Retain the old failure audit deliberately.
|
||||
elif damage == "pending":
|
||||
record["validation"]["observed"][0]["status"] = "pending"
|
||||
marker.write_text(json.dumps(record))
|
||||
if damage == "missing":
|
||||
marker.unlink()
|
||||
if damage is not None:
|
||||
with pytest.raises(m.AppOpsLegacyNoMutation):
|
||||
prepare_bridge(bridge)
|
||||
assert events == []
|
||||
else:
|
||||
migration = prepare_bridge(bridge)
|
||||
assert migration["intent"]["migrationProof"]["priorAttempt"]["metadataSha256"] == m._canonical_json_sha256(prior_metadata)
|
||||
assert events == ["backup", "snapshot", "tag"]
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
@@ -53,7 +54,10 @@ def _project():
|
||||
"containers": ["Unraid-AppOps-Gateway"],
|
||||
"compose_project": "unraid-appops-gateway",
|
||||
"compose_files": ["docker-compose.yml"],
|
||||
"environment": {"APPOPS_DATA_PATH": "/srv/example/appdata/unraid-appops-gateway-1216"},
|
||||
"environment": {
|
||||
"APPOPS_DATA_PATH": "/srv/example/appdata/unraid-appops-gateway-1216",
|
||||
"APPOPS_PUBLISH_HOST": "198.18.10.150",
|
||||
},
|
||||
"verify_oci_revision": True,
|
||||
"validation": {
|
||||
"validation_gate_version": 1, "required": True,
|
||||
@@ -71,6 +75,9 @@ def _ready(controller, monkeypatch):
|
||||
monkeypatch.setattr(controller, "_fetch_gitea_validation", lambda repository, revision, policy: ("pass", []))
|
||||
monkeypatch.setattr(controller, "_self_upgrade_runtime", lambda: {
|
||||
"revision": "a" * 40, "imageId": "sha256:" + "b" * 64,
|
||||
"containerId": "c" * 64,
|
||||
"imageRef": "unraid-appops-gateway:production",
|
||||
"sourceTree": "d" * 40,
|
||||
"dataPath": "/srv/example/appdata/unraid-appops-gateway-1216",
|
||||
})
|
||||
monkeypatch.setattr(controller.shutil, "disk_usage", lambda path: SimpleNamespace(free=50 * 1024**3))
|
||||
@@ -150,24 +157,65 @@ def test_self_upgrade_preflight_rejects_non_sha_and_conflicting_upgrade(controll
|
||||
|
||||
|
||||
def test_runtime_requires_exact_container_mount_port_and_health(controller, monkeypatch):
|
||||
monkeypatch.setattr(controller, "_gitea_validation_settings", lambda: ("https://gitea.example.com/api/v1", b"t" * 32))
|
||||
payload = [{
|
||||
"State": {"Running": True, "Health": {"Status": "healthy"}},
|
||||
"Mounts": [{"Destination": "/data", "Source": "/srv/example/appdata/unraid-appops-gateway-1216", "RW": True}],
|
||||
"Config": {"Labels": {"org.opencontainers.image.revision": "a" * 40,
|
||||
"org.opencontainers.image.source": "jens/unraid-appops-gateway"}},
|
||||
"NetworkSettings": {"Ports": {"1216/tcp": [{"HostPort": "1216"}]}},
|
||||
"Id": "1" * 64,
|
||||
"Mounts": [
|
||||
{"Type": "bind", "Destination": "/data", "Source": "/srv/example/appdata/unraid-appops-gateway-1216", "RW": True},
|
||||
{"Type": "bind", "Destination": "/mnt/privatemuse-comfyui-models", "Source": "/srv/example/appdata/comfyui-nvidia/basedir/models", "RW": True},
|
||||
],
|
||||
"Config": {
|
||||
"Image": "unraid-appops-gateway:platform-5553c04ce497",
|
||||
"Hostname": "unraid-appops-gateway",
|
||||
"Labels": {
|
||||
"com.docker.compose.project": "unraid-appops-gateway",
|
||||
"com.docker.compose.service": "appops-gateway",
|
||||
"com.docker.compose.project.working_dir": "/srv/example/appdata/unraid-appops-gateway/source",
|
||||
"com.docker.compose.project.config_files": "/srv/example/appdata/unraid-appops-gateway/source/docker-compose.yml",
|
||||
"org.opencontainers.image.revision": "5553c04ce497c08a35ed301052dc5e0b02e5d9b4",
|
||||
"org.opencontainers.image.source": "https://gitea.example.com/Jens/Unraid-AppOps-Gateway",
|
||||
"org.opencontainers.image.source-tree": "1624e1748053f998d8427c8c8a4203277d22f13c",
|
||||
"org.opencontainers.image.version": "1.8.0-platform-5553c04ce497",
|
||||
"org.opencontainers.image.created": "2026-10-01T16:32:01Z",
|
||||
},
|
||||
},
|
||||
"HostConfig": {"RestartPolicy": {"Name": "unless-stopped"}},
|
||||
"NetworkSettings": {
|
||||
"Ports": {"1216/tcp": [{"HostIp": "198.18.10.150", "HostPort": "1216"}]},
|
||||
"Networks": {"itworx-mcp-hub-npm": {}, "unraid-appops-gateway_default": {}},
|
||||
},
|
||||
"Image": "sha256:" + "b" * 64,
|
||||
}]
|
||||
image = [{"Id": payload[0]["Image"], "Config": {"Labels": {
|
||||
key: value for key, value in payload[0]["Config"]["Labels"].items()
|
||||
if key.startswith("org.opencontainers.image.")
|
||||
}}}]
|
||||
names = ["Unraid-AppOps-Gateway"]
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
return subprocess.CompletedProcess(args, 0, json.dumps(payload) if args[1] == "inspect" else "\n".join(names), "")
|
||||
if args[1] == "inspect":
|
||||
result = payload
|
||||
elif args[1] == "image":
|
||||
result = image
|
||||
else:
|
||||
result = "\n".join(names)
|
||||
return subprocess.CompletedProcess(args, 0, json.dumps(result) if isinstance(result, list) else result, "")
|
||||
|
||||
monkeypatch.setattr(controller.subprocess, "run", fake_run)
|
||||
assert controller._self_upgrade_runtime()["revision"] == "a" * 40
|
||||
runtime = controller._self_upgrade_runtime()
|
||||
assert runtime["revision"] == "5553c04ce497c08a35ed301052dc5e0b02e5d9b4"
|
||||
assert runtime["imageRef"] == "unraid-appops-gateway:platform-5553c04ce497"
|
||||
assert runtime["sourceTree"] == "1624e1748053f998d8427c8c8a4203277d22f13c"
|
||||
payload[0]["Mounts"][0]["Source"] = "/wrong"
|
||||
assert controller._self_upgrade_runtime() is None
|
||||
payload[0]["Mounts"][0]["Source"] = "/srv/example/appdata/unraid-appops-gateway-1216"
|
||||
payload[0]["NetworkSettings"]["Ports"]["1216/tcp"][0]["HostIp"] = "127.0.0.1"
|
||||
assert controller._self_upgrade_runtime() is None
|
||||
payload[0]["NetworkSettings"]["Ports"]["1216/tcp"][0]["HostIp"] = "198.18.10.150"
|
||||
image[0]["Id"] = "sha256:" + "0" * 64
|
||||
assert controller._self_upgrade_runtime() is None
|
||||
image[0]["Id"] = payload[0]["Image"]
|
||||
names.append("Unraid-AppOps-Gateway-copy")
|
||||
assert controller._self_upgrade_runtime() is None
|
||||
|
||||
@@ -184,30 +232,13 @@ def test_runtime_requires_exact_container_mount_port_and_health(controller, monk
|
||||
("http://gitea.example.com/Jens/Unraid-AppOps-Gateway", False),
|
||||
],
|
||||
)
|
||||
def test_runtime_accepts_only_fixed_appops_oci_sources(controller, monkeypatch, source, accepted):
|
||||
def test_source_validation_accepts_only_fixed_appops_oci_sources(controller, monkeypatch, source, accepted):
|
||||
monkeypatch.setattr(
|
||||
controller,
|
||||
"_gitea_validation_settings",
|
||||
lambda: ("https://gitea.example.com/api/v1", b"t" * 32),
|
||||
)
|
||||
payload = [{
|
||||
"State": {"Running": True, "Health": {"Status": "healthy"}},
|
||||
"Mounts": [{"Destination": "/data", "Source": "/srv/example/appdata/unraid-appops-gateway-1216", "RW": True}],
|
||||
"Config": {"Labels": {
|
||||
"org.opencontainers.image.revision": "a" * 40,
|
||||
"org.opencontainers.image.source": source,
|
||||
}},
|
||||
"NetworkSettings": {"Ports": {"1216/tcp": [{"HostPort": "1216"}]}},
|
||||
"Image": "sha256:" + "b" * 64,
|
||||
}]
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
stdout = json.dumps(payload) if args[1] == "inspect" else "Unraid-AppOps-Gateway"
|
||||
return subprocess.CompletedProcess(args, 0, stdout, "")
|
||||
|
||||
monkeypatch.setattr(controller.subprocess, "run", fake_run)
|
||||
|
||||
assert (controller._self_upgrade_runtime() is not None) is accepted
|
||||
assert controller._self_upgrade_source_is_valid(source) is accepted
|
||||
|
||||
|
||||
def test_mcp_self_upgrade_rejects_arbitrary_repository(monkeypatch):
|
||||
@@ -219,12 +250,16 @@ def test_mcp_self_upgrade_rejects_arbitrary_repository(monkeypatch):
|
||||
mcp_server.appops_self_upgrade_apply("other/repo", "a" * 40, "test reason", "b" * 64, "key-12345")
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == 'win32', reason='Linux controller pinned directory descriptors')
|
||||
def test_self_upgrade_signature_binds_endpoint_and_rejects_replay(controller, monkeypatch, tmp_path):
|
||||
secret = b"fixed-test-self-upgrade-capability-0123456789"
|
||||
token_file = tmp_path / "mcp-token"
|
||||
token_file.write_bytes(secret + b"\n")
|
||||
token_file.chmod(0o600)
|
||||
monkeypatch.setattr(controller, "SELF_UPGRADE_TOKEN_FILE", token_file)
|
||||
import runner_statistics_binding
|
||||
monkeypatch.setattr(runner_statistics_binding, 'APPOPS_DATA', tmp_path)
|
||||
monkeypatch.setattr(runner_statistics_binding, 'ADMIN_TOKEN_FILE', token_file)
|
||||
monkeypatch.setattr(runner_statistics_binding, 'ROOT_UID', os.getuid())
|
||||
monkeypatch.setattr(controller, "APPOPS_NONCE_ROOT", tmp_path / "nonces")
|
||||
stamp = str(int(time.time()))
|
||||
nonce = "a" * 32
|
||||
@@ -245,6 +280,7 @@ def test_self_upgrade_signature_binds_endpoint_and_rejects_replay(controller, mo
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == 'win32', reason='Linux controller pinned directory descriptors')
|
||||
def test_self_upgrade_http_contract_is_typed_idempotent_and_durable(controller, monkeypatch, tmp_path):
|
||||
_ready(controller, monkeypatch)
|
||||
monkeypatch.setattr(controller, "SELF_UPGRADE_REQUEST_ROOT", tmp_path / "requests")
|
||||
@@ -254,7 +290,10 @@ def test_self_upgrade_http_contract_is_typed_idempotent_and_durable(controller,
|
||||
token_file = tmp_path / "mcp-token"
|
||||
token_file.write_bytes(secret + b"\n")
|
||||
token_file.chmod(0o600)
|
||||
monkeypatch.setattr(controller, "SELF_UPGRADE_TOKEN_FILE", token_file)
|
||||
import runner_statistics_binding
|
||||
monkeypatch.setattr(runner_statistics_binding, 'APPOPS_DATA', tmp_path)
|
||||
monkeypatch.setattr(runner_statistics_binding, 'ADMIN_TOKEN_FILE', token_file)
|
||||
monkeypatch.setattr(runner_statistics_binding, 'ROOT_UID', os.getuid())
|
||||
monkeypatch.setattr(controller.Handler, "log_message", lambda *args: None)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), controller.Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
@@ -333,6 +372,9 @@ def test_self_upgrade_http_contract_is_typed_idempotent_and_durable(controller,
|
||||
)
|
||||
monkeypatch.setattr(controller, "_self_upgrade_runtime", lambda: {
|
||||
"revision": "c" * 40, "imageId": "sha256:" + "b" * 64,
|
||||
"containerId": "d" * 64,
|
||||
"imageRef": "unraid-appops-gateway:production",
|
||||
"sourceTree": "e" * 40,
|
||||
"dataPath": "/srv/example/appdata/unraid-appops-gateway-1216",
|
||||
})
|
||||
monkeypatch.setattr(controller, "_deployment_state", lambda slug: {})
|
||||
@@ -389,7 +431,10 @@ def test_appops_backup_is_fixed_and_digest_verified(deploy_module, monkeypatch,
|
||||
archive = Path(receipt["archive"])
|
||||
assert hashlib.sha256(archive.read_bytes()).hexdigest() == receipt["sha256"]
|
||||
assert receipt["previousRevision"] == "a" * 40
|
||||
assert json.loads((archive.parent / "receipt.json").read_text()) == receipt
|
||||
stored_receipt = json.loads((archive.parent / "receipt.json").read_text())
|
||||
assert {**stored_receipt, "receiptSha256": hashlib.sha256(
|
||||
json.dumps(stored_receipt, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()} == receipt
|
||||
with tarfile.open(archive, "r:gz") as backup:
|
||||
assert backup.extractfile("./state.txt").read() == b"preserved"
|
||||
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
"""Job-local Docker producer contract without starting a container daemon."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import runpy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROFILES = ("checks", "backend", "frontend", "security", "browser", "build")
|
||||
BASE_LABELS = {
|
||||
"io.buildah.version": "1.35.0",
|
||||
"org.opencontainers.image.authors": "Gitea",
|
||||
"org.opencontainers.image.created": "2026-10-01T00:01:57Z",
|
||||
"org.opencontainers.image.description": "Official docker images used by Gitea runner to run workflows.",
|
||||
"org.opencontainers.image.documentation": "https://gitea.com/gitea/runner-images/src/branch/main/README.md",
|
||||
"org.opencontainers.image.licenses": "MIT",
|
||||
"org.opencontainers.image.title": "Official Gitea Actions runner images",
|
||||
"org.opencontainers.image.url": "https://gitea.com/gitea/runner-images",
|
||||
"org.opencontainers.image.vendor": "Gitea",
|
||||
"org.opencontainers.image.version": "v26.10.01",
|
||||
}
|
||||
|
||||
|
||||
def _job_local_producer(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
wrong_driver: bool = False,
|
||||
wrong_security: bool = False,
|
||||
job_local: bool = True,
|
||||
):
|
||||
repo = tmp_path / "repository"
|
||||
repo.mkdir()
|
||||
shutil.copytree(ROOT / "ops" / "runner-task-images", repo / "ops" / "runner-task-images")
|
||||
scripts = repo / "scripts"
|
||||
scripts.mkdir()
|
||||
shutil.copyfile(ROOT / "scripts" / "normalize_ci_task_image_archive.py", scripts / "normalize_ci_task_image_archive.py")
|
||||
code = repo / "ops" / "gitea-deploy-broker"
|
||||
code.mkdir()
|
||||
shutil.copyfile(ROOT / "ops" / "gitea-deploy-broker" / "runner_profiles.py", code / "runner_profiles.py")
|
||||
for args in (
|
||||
["init", "-q"],
|
||||
["add", "ops", "scripts"],
|
||||
["-c", "user.name=Producer test", "-c", "user.email=producer-test@localhost", "commit", "-qm", "fixture"],
|
||||
):
|
||||
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
|
||||
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
docker = fake_bin / "docker"
|
||||
docker.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import hashlib,io,json,os,sys,tarfile\n"
|
||||
"from pathlib import Path\n"
|
||||
"a=sys.argv[1:]; rows=Path(os.environ['FAKE_DOCKER_ROWS']); calls=Path(os.environ['FAKE_DOCKER_CALLS'])\n"
|
||||
"history=json.loads(calls.read_text()) if calls.exists() else []\n"
|
||||
"history.append({'argv':a,'host':os.environ.get('DOCKER_HOST'),'context':os.environ.get('DOCKER_CONTEXT'),'tls':os.environ.get('DOCKER_TLS_VERIFY'),'buildkit':os.environ.get('DOCKER_BUILDKIT')})\n"
|
||||
"calls.write_text(json.dumps(history))\n"
|
||||
"layerbuf=io.BytesIO()\n"
|
||||
"with tarfile.open(fileobj=layerbuf,mode='w:') as t:\n"
|
||||
" p=b'producer fixture layer'; x=tarfile.TarInfo('fixture.txt'); x.size=len(p); t.addfile(x,io.BytesIO(p))\n"
|
||||
"layer=layerbuf.getvalue(); layer_sha=hashlib.sha256(layer).hexdigest()\n"
|
||||
"if a[:1]==['info']:\n"
|
||||
" fmt=a[a.index('--format')+1] if '--format' in a else ''\n"
|
||||
f" driver={('vfs' if wrong_driver else 'overlay2')!r}\n"
|
||||
" security='[\"name=seccomp,profile=builtin\",\"name=rootless\",\"name=cgroupns\"]'\n"
|
||||
" if os.environ.get('FAKE_DOCKER_WRONG_SECURITY')=='1': security='[\"name=seccomp,profile=builtin\",\"name=rootless\",\"name=cgroupns\",\"name=unknown\"]'\n"
|
||||
" if fmt=='{{.Driver}}|{{.CgroupDriver}}|{{.DockerRootDir}}|{{json .SecurityOptions}}': print(driver+'|none|/home/itworx-builder/.local/share/docker|'+security); sys.exit(0)\n"
|
||||
" sys.exit(0)\n"
|
||||
"if a[0]=='build':\n"
|
||||
" builds=Path(os.environ['FAKE_DOCKER_BUILDS']); argv=json.loads(builds.read_text()) if builds.exists() else []\n"
|
||||
" pairs=[a[i+1].split('=',1) for i,x in enumerate(a) if x=='--build-arg']\n"
|
||||
" is_build=dict(pairs).get('PROFILE')=='build'\n"
|
||||
" staged=Path(a[-1]).is_dir() and all((Path(a[-1])/n).is_file() for n in ['job_docker.py','itworx-ci-job-docker-probe','normalize_ci_task_image_archive.py']) if is_build else True\n"
|
||||
" argv.append({'argv':a,'staged':staged}); builds.write_text(json.dumps(argv))\n"
|
||||
" b=dict(pairs); profile=b['PROFILE']; ref=a[a.index('--tag')+1]\n"
|
||||
f" labels={BASE_LABELS!r}.copy()\n"
|
||||
" labels.update({'org.opencontainers.image.source':'https://gitea.example.com/Jens/Unraid-AppOps-Gateway','org.opencontainers.image.revision':b['SOURCE_REVISION'],'tech.itworx.source-tree':b['SOURCE_TREE'],'tech.itworx.ci.profile':profile})\n"
|
||||
" config={'architecture':'amd64','os':'linux','config':{'Labels':labels},'rootfs':{'type':'layers','diff_ids':['sha256:'+layer_sha]}}\n"
|
||||
" config_bytes=json.dumps(config,sort_keys=True,separators=(',',':')).encode(); image_id='sha256:'+hashlib.sha256(config_bytes).hexdigest()\n"
|
||||
" current=json.loads(rows.read_text()) if rows.exists() else []\n"
|
||||
" current.append({'Id':image_id,'Os':'linux','Architecture':'amd64','RepoTags':[ref],'Config':{'Labels':labels}})\n"
|
||||
" Path(os.environ['FAKE_DOCKER_CONFIGS']+'/'+profile+'.json').write_bytes(config_bytes)\n"
|
||||
" rows.write_text(json.dumps(current)); sys.exit(0)\n"
|
||||
"if a[:2]==['image','inspect']: print(rows.read_text()); sys.exit(0)\n"
|
||||
"if a[:2]==['image','save']:\n"
|
||||
" selected=set(a[a.index('--output')+2:]); current=[r for r in json.loads(rows.read_text()) if r['RepoTags'][0] in selected]; items=[]; members={'blobs/sha256/'+layer_sha:layer}\n"
|
||||
" for row in current:\n"
|
||||
" profile=row['Config']['Labels']['tech.itworx.ci.profile']; config=Path(os.environ['FAKE_DOCKER_CONFIGS']+'/'+profile+'.json').read_bytes(); config_sha=hashlib.sha256(config).hexdigest(); config_path='blobs/sha256/'+config_sha; members[config_path]=config\n"
|
||||
" items.append({'Config':config_path,'RepoTags':row['RepoTags'],'Layers':['blobs/sha256/'+layer_sha],'LayerSources':{'sha256:'+layer_sha:{'mediaType':'application/vnd.oci.image.layer.v1.tar','size':len(layer),'digest':'sha256:'+layer_sha}}})\n"
|
||||
" members['manifest.json']=json.dumps(items,separators=(',',':')).encode(); members['oci-layout']=b'{\"imageLayoutVersion\":\"1.0.0\"}'; members['index.json']=b'{\"schemaVersion\":2,\"manifests\":[]}'\n"
|
||||
" with tarfile.open(a[a.index('--output')+1],mode='w:') as t:\n"
|
||||
" for name,payload in members.items():\n"
|
||||
" x=tarfile.TarInfo(name); x.size=len(payload); t.addfile(x,io.BytesIO(payload))\n"
|
||||
" sys.exit(0)\n"
|
||||
"sys.exit('unexpected Docker invocation')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
docker.chmod(0o755)
|
||||
output = tmp_path / "output"
|
||||
env = {
|
||||
**os.environ,
|
||||
"PATH": str(fake_bin) + os.pathsep + os.environ["PATH"],
|
||||
"GITHUB_EVENT_NAME": "push",
|
||||
"GITHUB_REF": "refs/heads/main",
|
||||
"GITHUB_REPOSITORY": "Jens/Unraid-AppOps-Gateway",
|
||||
"DOCKER_HOST": "tcp://shared-dind:2375",
|
||||
"DOCKER_CONTEXT": "shared-context",
|
||||
"DOCKER_TLS_VERIFY": "1",
|
||||
"FAKE_DOCKER_ROWS": str(tmp_path / "docker-rows.json"),
|
||||
"FAKE_DOCKER_CALLS": str(tmp_path / "docker-calls.json"),
|
||||
"FAKE_DOCKER_BUILDS": str(tmp_path / "docker-builds.json"),
|
||||
"FAKE_DOCKER_CONFIGS": str(tmp_path / "docker-configs"),
|
||||
"FAKE_DOCKER_WRONG_SECURITY": "1" if wrong_security else "0",
|
||||
}
|
||||
Path(env["FAKE_DOCKER_CONFIGS"]).mkdir()
|
||||
command = ["bash", str(ROOT / "scripts" / "build_ci_task_images.sh"), revision, "123", str(output)]
|
||||
if job_local:
|
||||
command.append("--job-local")
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=repo,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
tree = subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"], cwd=repo, text=True).strip()
|
||||
return result, output, tmp_path, wrong_driver, revision, tree
|
||||
|
||||
|
||||
def _oci_task_image_fixture(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
layer_media_type: str = "application/vnd.oci.image.layer.v1.tar",
|
||||
profiles: tuple[str, ...] = PROFILES,
|
||||
):
|
||||
layer_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=layer_buffer, mode="w:") as layer_tar:
|
||||
payload = b"task image rootfs fixture"
|
||||
member = tarfile.TarInfo("fixture.txt")
|
||||
member.size = len(payload)
|
||||
layer_tar.addfile(member, io.BytesIO(payload))
|
||||
layer_bytes = layer_buffer.getvalue()
|
||||
layer_sha = hashlib.sha256(layer_bytes).hexdigest()
|
||||
labels_base = {
|
||||
"org.opencontainers.image.source": "https://gitea.example.com/Jens/Unraid-AppOps-Gateway",
|
||||
"org.opencontainers.image.revision": "a" * 40,
|
||||
"tech.itworx.source-tree": "b" * 40,
|
||||
}
|
||||
rows = []
|
||||
source_manifest = []
|
||||
members: dict[str, bytes] = {f"blobs/sha256/{layer_sha}": layer_bytes}
|
||||
for profile in profiles:
|
||||
labels = {**labels_base, "tech.itworx.ci.profile": profile}
|
||||
config = {
|
||||
"architecture": "amd64",
|
||||
"os": "linux",
|
||||
"config": {"Labels": labels},
|
||||
"rootfs": {"type": "layers", "diff_ids": [f"sha256:{layer_sha}"]},
|
||||
}
|
||||
config_bytes = json.dumps(config, sort_keys=True, separators=(",", ":")).encode()
|
||||
config_sha = hashlib.sha256(config_bytes).hexdigest()
|
||||
config_path = f"blobs/sha256/{config_sha}"
|
||||
members[config_path] = config_bytes
|
||||
reference = f"localhost/itworx-ci-{profile}:sha-{'a' * 40}"
|
||||
rows.append({
|
||||
"Id": f"sha256:{config_sha}",
|
||||
"Os": "linux",
|
||||
"Architecture": "amd64",
|
||||
"RepoTags": [reference],
|
||||
"Config": {"Labels": labels},
|
||||
})
|
||||
source_manifest.append({
|
||||
"Config": config_path,
|
||||
"RepoTags": [reference],
|
||||
"Layers": [f"blobs/sha256/{layer_sha}"],
|
||||
"LayerSources": {
|
||||
f"sha256:{layer_sha}": {
|
||||
"mediaType": layer_media_type,
|
||||
"size": len(layer_bytes),
|
||||
"digest": f"sha256:{layer_sha}",
|
||||
},
|
||||
},
|
||||
})
|
||||
members["manifest.json"] = json.dumps(source_manifest, separators=(",", ":")).encode()
|
||||
members["oci-layout"] = b'{"imageLayoutVersion":"1.0.0"}'
|
||||
members["index.json"] = b'{"schemaVersion":2,"manifests":[]}'
|
||||
source = tmp_path / "source-oci.tar"
|
||||
with tarfile.open(source, mode="w:") as archive:
|
||||
for name, payload in members.items():
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(payload)
|
||||
archive.addfile(info, io.BytesIO(payload))
|
||||
inspection = tmp_path / "inspection.json"
|
||||
inspection.write_text(json.dumps(rows), encoding="utf-8")
|
||||
output = tmp_path / "normalized.tar"
|
||||
return source, inspection, output, rows, layer_bytes
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
||||
def test_archive_normalizer_converts_uncompressed_oci_blobs_to_legacy_docker_save_layout(tmp_path):
|
||||
source, inspection, output, rows, layer_bytes = _oci_task_image_fixture(tmp_path)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "scripts" / "normalize_ci_task_image_archive.py"), str(source), str(output), str(inspection)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
with tarfile.open(output, mode="r:") as archive:
|
||||
members = {member.name: member for member in archive}
|
||||
normalized = json.loads(archive.extractfile(members["manifest.json"]).read())
|
||||
assert len(normalized) == len(PROFILES)
|
||||
assert all(set(item) == {"Config", "RepoTags", "Layers"} for item in normalized)
|
||||
for item, expected in zip(normalized, rows, strict=True):
|
||||
assert item["Config"] == expected["Id"].removeprefix("sha256:") + ".json"
|
||||
assert item["RepoTags"] == expected["RepoTags"]
|
||||
assert len(item["Layers"]) == 1
|
||||
assert item["Layers"][0].endswith("/layer.tar")
|
||||
config_bytes = archive.extractfile(members[item["Config"]]).read()
|
||||
assert "sha256:" + hashlib.sha256(config_bytes).hexdigest() == expected["Id"]
|
||||
assert json.loads(config_bytes)["config"]["Labels"] == expected["Config"]["Labels"]
|
||||
assert archive.extractfile(members[item["Layers"][0]]).read() == layer_bytes
|
||||
assert not any(name.startswith("blobs/") for name in members)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
||||
def test_archive_normalizer_rejects_compressed_oci_layers_before_emitting_output(tmp_path):
|
||||
source, inspection, output, _rows, _layer_bytes = _oci_task_image_fixture(
|
||||
tmp_path,
|
||||
layer_media_type="application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "scripts" / "normalize_ci_task_image_archive.py"), str(source), str(output), str(inspection)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "compressed or unsupported layer media type" in result.stderr
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
||||
def test_archive_normalizer_probe_mode_accepts_and_emits_one_build_image(tmp_path):
|
||||
source, inspection, output, rows, _layer_bytes = _oci_task_image_fixture(tmp_path, profiles=("build",))
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "scripts" / "normalize_ci_task_image_archive.py"), "--probe", str(source), str(output), str(inspection)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
with tarfile.open(output, mode="r:") as archive:
|
||||
manifest = json.loads(archive.extractfile("manifest.json").read())
|
||||
assert len(manifest) == 1
|
||||
assert set(manifest[0]) == {"Config", "RepoTags", "Layers"}
|
||||
assert manifest[0]["RepoTags"] == rows[0]["RepoTags"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
||||
def test_job_local_mode_builds_all_profiles_through_private_rootless_engine_without_sibling_caps(tmp_path):
|
||||
result, output, scratch, _wrong_driver, revision, tree = _job_local_producer(tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
calls = json.loads((scratch / "docker-calls.json").read_text())
|
||||
assert calls[0] == {
|
||||
"argv": ["info", "--format", "{{.Driver}}|{{.CgroupDriver}}|{{.DockerRootDir}}|{{json .SecurityOptions}}"],
|
||||
"host": "unix:///run/itworx-job-docker/docker.sock",
|
||||
"context": None,
|
||||
"tls": None,
|
||||
"buildkit": "1",
|
||||
}
|
||||
builds = json.loads((scratch / "docker-builds.json").read_text())
|
||||
assert len(builds) == len(PROFILES)
|
||||
assert all(row["staged"] for row in builds)
|
||||
build_arguments = [row["argv"] for row in builds]
|
||||
assert [next(args[i + 1].split("=", 1)[1] for i, arg in enumerate(args[:-1]) if arg == "--build-arg" and args[i + 1].startswith("PROFILE=")) for args in build_arguments] == list(PROFILES)
|
||||
for args in build_arguments:
|
||||
assert not any(arg.startswith(("--cpu-period", "--cpu-quota", "--memory", "--cgroup-parent", "--cpus")) for arg in args)
|
||||
assert "--rm=true" in args and "--force-rm=true" in args
|
||||
|
||||
manifest = json.loads((output / "task-image-manifest.json").read_text())
|
||||
assert manifest["apiVersion"] == "itworx.runner-task-images/v1"
|
||||
assert manifest["sourceRevision"] == revision
|
||||
assert manifest["sourceTree"] == tree
|
||||
assert tuple(row["profile"] for row in manifest["images"]) == PROFILES
|
||||
assert manifest["dockerTarSha256"] == hashlib.sha256((output / "task-images.tar").read_bytes()).hexdigest()
|
||||
for row in manifest["images"]:
|
||||
assert row["labels"] == {
|
||||
"org.opencontainers.image.source": "https://gitea.example.com/Jens/Unraid-AppOps-Gateway",
|
||||
"org.opencontainers.image.revision": revision,
|
||||
"tech.itworx.source-tree": tree,
|
||||
"tech.itworx.ci.profile": row["profile"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
||||
def test_job_local_mode_rejects_non_overlay_or_non_rootless_server_before_build(tmp_path):
|
||||
result, output, scratch, _wrong_driver, _revision, _tree = _job_local_producer(tmp_path, wrong_driver=True)
|
||||
assert result.returncode != 0
|
||||
assert "job-local Docker daemon verification failed" in result.stderr
|
||||
assert not (scratch / "docker-builds.json").exists()
|
||||
assert not (output / "task-image-manifest.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
||||
def test_job_local_mode_rejects_unknown_daemon_security_options_before_build(tmp_path):
|
||||
result, output, scratch, _wrong_driver, _revision, _tree = _job_local_producer(tmp_path, wrong_security=True)
|
||||
assert result.returncode != 0
|
||||
assert "job-local Docker daemon verification failed" in result.stderr
|
||||
assert not (scratch / "docker-builds.json").exists()
|
||||
assert not (output / "task-image-manifest.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
||||
def test_bootstrap_mode_keeps_classic_shared_daemon_caps_and_archive_contract(tmp_path):
|
||||
result, output, scratch, _wrong_driver, _revision, _tree = _job_local_producer(tmp_path, job_local=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
calls = json.loads((scratch / "docker-calls.json").read_text())
|
||||
assert calls[0]["argv"] == ["info"]
|
||||
assert calls[0]["host"] == "tcp://shared-dind:2375"
|
||||
assert calls[0]["buildkit"] == "0"
|
||||
builds = json.loads((scratch / "docker-builds.json").read_text())
|
||||
assert len(builds) == len(PROFILES)
|
||||
assert all(row["staged"] for row in builds)
|
||||
build_arguments = [row["argv"] for row in builds]
|
||||
assert all(any(arg.startswith("--cpu-quota=") for arg in args) for args in build_arguments)
|
||||
assert all(any(arg.startswith("--memory=5120m") for arg in args) for args in build_arguments)
|
||||
assert (output / "task-images.tar").is_file()
|
||||
with tarfile.open(output / "task-images.tar", mode="r:") as archive:
|
||||
manifest = json.loads(archive.extractfile("manifest.json").read())
|
||||
assert len(manifest) == len(PROFILES)
|
||||
assert all(set(item) == {"Config", "RepoTags", "Layers"} for item in manifest)
|
||||
|
||||
|
||||
def test_build_image_uses_only_minimal_file_capabilities_for_uidmap_helpers():
|
||||
dockerfile = (ROOT / "ops" / "runner-task-images" / "build" / "Dockerfile").read_text(encoding="utf-8")
|
||||
pins = json.loads((ROOT / "ops" / "runner-task-images" / "pins.json").read_text(encoding="utf-8"))
|
||||
assert pins["jobLocalDocker"]["uidmapFileCapabilities"] == {
|
||||
"newuidmap": "cap_setuid=ep",
|
||||
"newgidmap": "cap_setgid=ep",
|
||||
"setuidBits": False,
|
||||
}
|
||||
assert '"$setcap_bin" cap_setuid=ep "$newuidmap_bin"' in dockerfile
|
||||
assert '"$setcap_bin" cap_setgid=ep "$newgidmap_bin"' in dockerfile
|
||||
assert 'test "$("$getcap_bin" "$newuidmap_bin")" = "$newuidmap_bin cap_setuid=ep"' in dockerfile
|
||||
assert 'test "$("$getcap_bin" "$newgidmap_bin")" = "$newgidmap_bin cap_setgid=ep"' in dockerfile
|
||||
assert '! test -u "$newuidmap_bin"' in dockerfile
|
||||
assert '! test -u "$newgidmap_bin"' in dockerfile
|
||||
|
||||
|
||||
def test_build_image_declares_exactly_one_private_daemon_data_volume():
|
||||
dockerfile = (ROOT / "ops" / "runner-task-images" / "build" / "Dockerfile").read_text(encoding="utf-8")
|
||||
pins = json.loads((ROOT / "ops" / "runner-task-images" / "pins.json").read_text(encoding="utf-8"))
|
||||
volumes = [line.strip() for line in dockerfile.splitlines() if line.strip().startswith("VOLUME")]
|
||||
assert volumes == ['VOLUME ["/home/itworx-builder/.local/share/docker"]']
|
||||
assert pins["jobLocalDocker"]["dataRoot"] == "/home/itworx-builder/.local/share/docker"
|
||||
assert pins["jobLocalDocker"]["anonymousDataVolume"] == volumes[0].split('"')[1]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
||||
@pytest.mark.parametrize("mode", [0o700, 0o710])
|
||||
def test_job_docker_accepts_private_data_root_modes_used_before_and_after_daemon_start(tmp_path, monkeypatch, mode):
|
||||
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
||||
import job_docker
|
||||
|
||||
runtime = tmp_path / "runtime"
|
||||
data_root = tmp_path / "data"
|
||||
runtime.mkdir(mode=0o700)
|
||||
data_root.mkdir(mode=mode)
|
||||
os.chmod(runtime, 0o700)
|
||||
os.chmod(data_root, mode)
|
||||
data_info = data_root.stat()
|
||||
monkeypatch.setattr(job_docker, "BUILDER_UID", data_info.st_uid)
|
||||
monkeypatch.setattr(job_docker, "BUILDER_GID", data_info.st_gid)
|
||||
monkeypatch.setattr(job_docker, "RUNTIME", runtime)
|
||||
monkeypatch.setattr(job_docker, "DATA_ROOT", data_root)
|
||||
|
||||
job_docker._ensure_runtime()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
||||
@pytest.mark.parametrize("mode", [0o711, 0o750, 0o777])
|
||||
def test_job_docker_rejects_unexpected_private_data_root_modes(tmp_path, monkeypatch, mode):
|
||||
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
||||
import job_docker
|
||||
|
||||
runtime = tmp_path / "runtime"
|
||||
data_root = tmp_path / "data"
|
||||
runtime.mkdir(mode=0o700)
|
||||
data_root.mkdir(mode=mode)
|
||||
os.chmod(runtime, 0o700)
|
||||
os.chmod(data_root, mode)
|
||||
data_info = data_root.stat()
|
||||
monkeypatch.setattr(job_docker, "BUILDER_UID", data_info.st_uid)
|
||||
monkeypatch.setattr(job_docker, "BUILDER_GID", data_info.st_gid)
|
||||
monkeypatch.setattr(job_docker, "RUNTIME", runtime)
|
||||
monkeypatch.setattr(job_docker, "DATA_ROOT", data_root)
|
||||
|
||||
with pytest.raises(job_docker.WrapperError, match="data directory ownership invalid"):
|
||||
job_docker._ensure_runtime()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
||||
def test_job_docker_rejects_symlink_private_data_root(tmp_path, monkeypatch):
|
||||
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
||||
import job_docker
|
||||
|
||||
runtime = tmp_path / "runtime"
|
||||
real_data_root = tmp_path / "real-data"
|
||||
data_root_link = tmp_path / "data-link"
|
||||
runtime.mkdir(mode=0o700)
|
||||
real_data_root.mkdir(mode=0o700)
|
||||
os.chmod(runtime, 0o700)
|
||||
os.chmod(real_data_root, 0o700)
|
||||
data_root_link.symlink_to(real_data_root, target_is_directory=True)
|
||||
real_data_info = real_data_root.stat()
|
||||
monkeypatch.setattr(job_docker, "BUILDER_UID", real_data_info.st_uid)
|
||||
monkeypatch.setattr(job_docker, "BUILDER_GID", real_data_info.st_gid)
|
||||
monkeypatch.setattr(job_docker, "RUNTIME", runtime)
|
||||
monkeypatch.setattr(job_docker, "DATA_ROOT", data_root_link)
|
||||
|
||||
with pytest.raises(job_docker.WrapperError, match="data directory ownership invalid"):
|
||||
job_docker._ensure_runtime()
|
||||
|
||||
|
||||
def test_private_socket_ping_reads_the_complete_bounded_docker_response(monkeypatch):
|
||||
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
||||
import job_docker
|
||||
|
||||
class ChunkedSocket:
|
||||
def __init__(self, response: bytes):
|
||||
self.response = response
|
||||
self.offset = 0
|
||||
self.recv_calls = 0
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def settimeout(self, _timeout):
|
||||
pass
|
||||
|
||||
def connect(self, _path):
|
||||
pass
|
||||
|
||||
def sendall(self, _request):
|
||||
pass
|
||||
|
||||
def recv(self, size):
|
||||
self.recv_calls += 1
|
||||
end = min(self.offset + min(size, 256), len(self.response))
|
||||
chunk = self.response[self.offset:end]
|
||||
self.offset = end
|
||||
return chunk
|
||||
|
||||
response = (
|
||||
b"HTTP/1.1 200 OK\r\nX-Long-Header: "
|
||||
+ b"x" * 300
|
||||
+ b"\r\nContent-Length: 2\r\n\r\nOK"
|
||||
)
|
||||
assert len(response) > 256
|
||||
|
||||
wrapper_socket = ChunkedSocket(response)
|
||||
monkeypatch.setattr(
|
||||
job_docker,
|
||||
"socket",
|
||||
type("SocketModule", (), {
|
||||
"AF_UNIX": 1,
|
||||
"SOCK_STREAM": 2,
|
||||
"socket": lambda *_args: wrapper_socket,
|
||||
}),
|
||||
)
|
||||
assert job_docker._socket_ping(Path("/run/itworx-job-docker/docker.sock"))
|
||||
assert wrapper_socket.recv_calls == 2
|
||||
|
||||
probe = runpy.run_path(str(ROOT / "ops" / "runner-task-images" / "build" / "itworx-ci-job-docker-probe"))
|
||||
probe_socket = ChunkedSocket(response)
|
||||
probe["_socket_ready"].__globals__["socket"] = type("SocketModule", (), {
|
||||
"AF_UNIX": 1,
|
||||
"SOCK_STREAM": 2,
|
||||
"socket": lambda *_args: probe_socket,
|
||||
})
|
||||
assert probe["_socket_ready"]()
|
||||
assert probe_socket.recv_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
||||
def test_job_docker_wrapper_sanitizes_remote_endpoint_environment_and_rejects_cli_overrides(monkeypatch):
|
||||
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
||||
import job_docker
|
||||
|
||||
monkeypatch.setenv("DOCKER_HOST", "tcp://shared-dind:2375")
|
||||
monkeypatch.setenv("DOCKER_CONTEXT", "shared-context")
|
||||
monkeypatch.setenv("DOCKER_TLS_VERIFY", "1")
|
||||
monkeypatch.setenv("DOCKER_CERT_PATH", "/shared/certs")
|
||||
monkeypatch.setenv("DOCKER_CONFIG", "/job/config")
|
||||
environment = job_docker._client_environment()
|
||||
assert environment["DOCKER_HOST"] == "unix:///run/itworx-job-docker/docker.sock"
|
||||
assert environment["DOCKER_CONFIG"] == "/job/config"
|
||||
assert not any(name in environment for name in ("DOCKER_CONTEXT", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"))
|
||||
with pytest.raises(job_docker.WrapperError, match="endpoint override"):
|
||||
job_docker._reject_endpoint_override(["--host=tcp://shared-dind:2375", "build"])
|
||||
with pytest.raises(job_docker.WrapperError, match="endpoint override"):
|
||||
job_docker._reject_endpoint_override(["--context", "shared-context", "info"])
|
||||
job_docker._reject_endpoint_override(["run", "alpine", "sh", "-c", "echo --context"])
|
||||
assert job_docker._is_local_version_request(["compose", "version"])
|
||||
assert job_docker._is_local_version_request(["compose", "--version"])
|
||||
assert job_docker._is_local_version_request(["buildx", "version"])
|
||||
assert job_docker._is_local_version_request(["buildx", "--version"])
|
||||
assert not job_docker._is_local_version_request(["compose", "up"])
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="rootless Docker wrapper is Linux-only")
|
||||
def test_job_docker_daemon_command_uses_fixed_rootlesskit_and_unix_only_engine():
|
||||
sys.path.insert(0, str(ROOT / "ops" / "runner-task-images" / "build"))
|
||||
import job_docker
|
||||
|
||||
command = job_docker._daemon_command()
|
||||
assert command[:8] == [
|
||||
"/usr/local/bin/rootlesskit",
|
||||
"--state-dir=/run/itworx-job-docker/rootlesskit",
|
||||
"--net=slirp4netns",
|
||||
"--mtu=1500",
|
||||
"--disable-host-loopback",
|
||||
"--port-driver=builtin",
|
||||
"--copy-up=/etc",
|
||||
"--copy-up=/run",
|
||||
]
|
||||
assert command[8:11] == ["/usr/local/bin/docker-init", "--", "/usr/local/bin/dockerd"]
|
||||
daemon_args = command[11:]
|
||||
assert "--host=unix:///run/itworx-job-docker/docker.sock" in daemon_args
|
||||
assert not any(argument.startswith("--host=tcp:") for argument in daemon_args)
|
||||
assert "--storage-driver=overlay2" in daemon_args
|
||||
assert "--feature=containerd-snapshotter=false" in daemon_args
|
||||
assert "--data-root=/home/itworx-builder/.local/share/docker" in daemon_args
|
||||
@@ -344,7 +344,11 @@ def test_workmesh_first_deploy_script_accepts_the_pinned_release_descriptor(tmp_
|
||||
for original, replacement in (
|
||||
('Path("/opt/gitea-deploy")', f'Path({json.dumps(str(controller_root))})'),
|
||||
(
|
||||
'Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")',
|
||||
'Path("/controller-config/projects.json")',
|
||||
f'Path({json.dumps(str(registry_path))})',
|
||||
),
|
||||
(
|
||||
'Path("/controller-hub-releases/controller-config/projects.json")',
|
||||
f'Path({json.dumps(str(registry_path))})',
|
||||
),
|
||||
(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -8,6 +9,22 @@ import pytest
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_promoter_archive_covers_every_required_installer_source():
|
||||
from backend.app.services.controller_artifact_promoter import helper_container_spec
|
||||
|
||||
spec = helper_container_spec(revision="a" * 40, image_id="sha256:" + "b" * 64)
|
||||
command = spec["Cmd"][2].replace("\\\n", " ")
|
||||
archive = re.search(r'archive --format=tar "\$revision"(.*?)> "\$work/controller.tar"', command, re.S)
|
||||
assert archive is not None
|
||||
packaged = shlex.split(archive.group(1))
|
||||
installer = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8")
|
||||
required_block = re.search(r"(?m)^source_paths=\(\n(.*?)\n\)", installer, re.S)
|
||||
assert required_block is not None
|
||||
required = shlex.split(required_block.group(1))
|
||||
assert required
|
||||
assert all(any(path == prefix or path.startswith(prefix + "/") for prefix in packaged) for path in required)
|
||||
|
||||
|
||||
def test_controller_promoter_action_is_fixed_and_registered():
|
||||
registry = (ROOT / "backend/app/services/action_registry.py").read_text(encoding="utf-8")
|
||||
execution = (ROOT / "backend/app/services/action_execution.py").read_text(encoding="utf-8")
|
||||
@@ -28,39 +45,103 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded():
|
||||
image_id = "sha256:" + "b" * 64
|
||||
spec = helper_container_spec(revision=revision, image_id=image_id)
|
||||
|
||||
assert HELPER_NAME == "itworx-controller-artifact-promoter"
|
||||
assert CONTROLLER_VERSION == "2.1.13"
|
||||
assert HELPER_NAME == "itworx-controller-artifact-promoter-modern"
|
||||
assert CONTROLLER_VERSION == "2.1.14"
|
||||
assert re.fullmatch(r"2\.1\.\d+", CONTROLLER_VERSION)
|
||||
assert spec["Image"] == image_id
|
||||
assert spec["HostConfig"]["Privileged"] is False
|
||||
assert spec["HostConfig"]["NetworkMode"] == "host"
|
||||
assert spec["HostConfig"]["SecurityOpt"] == ["no-new-privileges:true"]
|
||||
assert spec["HostConfig"]["ReadonlyRootfs"] is True
|
||||
assert spec["HostConfig"]["PidsLimit"] <= 128
|
||||
assert spec["HostConfig"]["Memory"] <= 1024 * 1024 * 1024
|
||||
assert spec["HostConfig"]["CpuQuota"] <= spec["HostConfig"]["CpuPeriod"]
|
||||
assert set(spec["HostConfig"]["Tmpfs"]) == {"/tmp"}
|
||||
assert set(spec["HostConfig"]["Binds"]) == {
|
||||
"/mnt/cache:/mnt/cache",
|
||||
"/mnt/user:/mnt/user",
|
||||
"/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro",
|
||||
"/var/lib/docker:/var/lib/docker:ro",
|
||||
"/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
}
|
||||
assert spec["Cmd"][:2] == ["/bin/bash", "-lc"]
|
||||
command = spec["Cmd"][2]
|
||||
assert "scripts/upgrade_deployment_controller.sh" in command
|
||||
assert "UPGRADE_DEPLOYMENT_CONTROLLER_V2" in command
|
||||
assert revision in command
|
||||
assert CONTROLLER_VERSION in command
|
||||
assert "/gitea-repositories/jens/unraid-appops-gateway.git" in command
|
||||
assert "mktemp -d -p /tmp itworx-controller-artifact-promoter-" in command
|
||||
assert f"itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX" in command
|
||||
assert "/mnt/cache/appdata/gitea-deploy-runners/.controller-promoter-" not in command
|
||||
assert "work='/mnt/cache" not in command
|
||||
assert "rm -rf -- \"$work\"" in command
|
||||
assert "scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"" in command
|
||||
assert "chmod 0600 \"$work/controller.tar\"" in command
|
||||
assert "scripts/prepare_controller_hub_release_volume.py" in command
|
||||
assert "scripts/run_repopulse_adoption.py" in command
|
||||
assert "UPGRADE_DEPLOYMENT_CONTROLLER_V2" in command
|
||||
assert revision in command
|
||||
assert "--image-id" in command
|
||||
assert CONTROLLER_VERSION in command
|
||||
assert "version='2.1.14'" in command
|
||||
assert "/gitea-repositories/jens/unraid-appops-gateway.git" in command
|
||||
archive_start = command.index('git --git-dir="$repo" archive')
|
||||
archive_end = command.index('> "$work/controller.tar"', archive_start)
|
||||
assert "scripts/vendor" in command[archive_start:archive_end]
|
||||
assert "scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"" in command
|
||||
assert f"--image-id '{image_id}'" in command
|
||||
assert '--image-id "$image_id"' in command
|
||||
|
||||
|
||||
def test_controller_promoter_bootstrap_child_spec_binds_legacy_target_identity():
|
||||
from backend.app.services.controller_artifact_promoter import helper_container_spec
|
||||
|
||||
revision = "a" * 40
|
||||
image_id = "sha256:" + "b" * 64
|
||||
target_id = "c" * 64
|
||||
target_revision = "d" * 40
|
||||
target_tree = "e" * 40
|
||||
spec = helper_container_spec(
|
||||
revision=revision,
|
||||
image_id=image_id,
|
||||
current_controller_id=target_id,
|
||||
current_controller_revision=target_revision,
|
||||
current_controller_tree=target_tree,
|
||||
current_controller_version="2.1.12",
|
||||
)
|
||||
|
||||
assert spec["Image"] == image_id
|
||||
assert spec["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": target_id,
|
||||
"itworx.controller.bootstrap.target.image": image_id,
|
||||
"itworx.controller.bootstrap.target.revision": target_revision,
|
||||
"itworx.controller.bootstrap.target.tree": target_tree,
|
||||
"itworx.controller.bootstrap.target.version": "2.1.12",
|
||||
}
|
||||
|
||||
|
||||
def test_controller_promoter_rejects_partial_or_mutable_bootstrap_binding():
|
||||
from backend.app.services.controller_artifact_promoter import helper_container_spec
|
||||
|
||||
base = {"revision": "a" * 40, "image_id": "sha256:" + "b" * 64}
|
||||
with pytest.raises(ValueError):
|
||||
helper_container_spec(**base, current_controller_id="c" * 64)
|
||||
with pytest.raises(ValueError):
|
||||
helper_container_spec(
|
||||
**base,
|
||||
current_controller_id="c" * 64,
|
||||
current_controller_revision="../controller",
|
||||
current_controller_tree="e" * 40,
|
||||
current_controller_version="2.1.12",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
helper_container_spec(
|
||||
**base,
|
||||
current_controller_id="../controller",
|
||||
current_controller_revision="d" * 40,
|
||||
current_controller_tree="e" * 40,
|
||||
current_controller_version="2.1.12",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"revision,image_id",
|
||||
[
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -41,3 +42,43 @@ def test_bootstrap_passes_only_on_stdin(monkeypatch):
|
||||
assert observed["input"] == b"sensitive-test-value"
|
||||
assert "sensitive-test-value" not in " ".join(observed["argv"])
|
||||
assert observed["capture_output"] is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX metadata and symlinks")
|
||||
def test_bootstrap_import_rejects_symlinks_and_multiple_links(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(host_import.os, "geteuid", lambda: 0)
|
||||
secret = tmp_path / "bootstrap"
|
||||
secret.write_bytes(b"x" * 64)
|
||||
secret.chmod(0o600)
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(secret)
|
||||
with pytest.raises(ValueError):
|
||||
host_import.read_bootstrap(link)
|
||||
link.unlink()
|
||||
os.link(secret, link)
|
||||
with pytest.raises(ValueError):
|
||||
host_import.read_bootstrap(secret)
|
||||
|
||||
|
||||
def test_host_import_uses_protected_volume_configuration():
|
||||
assert host_import.BOOTSTRAP_FILE.as_posix() == (
|
||||
"/var/lib/docker/volumes/itworx-controller-hub-releases/_data/controller-config/"
|
||||
"appops-enrollment-bootstrap"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode checks")
|
||||
def test_bootstrap_import_rechecks_private_mode_after_open(tmp_path, monkeypatch):
|
||||
secret = tmp_path / "bootstrap"
|
||||
secret.write_bytes(b"x" * 64)
|
||||
secret.chmod(0o600)
|
||||
monkeypatch.setattr(host_import.os, "geteuid", lambda: 0)
|
||||
original_open = host_import.os.open
|
||||
|
||||
def change_mode_before_open(path, flags):
|
||||
secret.chmod(0o644)
|
||||
return original_open(path, flags)
|
||||
|
||||
monkeypatch.setattr(host_import.os, "open", change_mode_before_open)
|
||||
with pytest.raises(ValueError, match="changed while opening"):
|
||||
host_import.read_bootstrap(secret)
|
||||
@@ -1,7 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -114,3 +122,64 @@ def test_status_reads_hub_state_through_the_fixed_pinned_adapter(monkeypatch, tm
|
||||
|
||||
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", lambda: b'\xff')
|
||||
assert controller._deployment_state("itworx-mcp-hub") == {}
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="the registration script uses POSIX flock")
|
||||
@pytest.mark.parametrize("matching_aliases", [True, False])
|
||||
def test_embedded_registry_writer_uses_protected_aliases_and_preserves_code(
|
||||
monkeypatch, tmp_path, matching_aliases
|
||||
):
|
||||
from backend.app.services.deployment_target_registry import _REGISTER_SCRIPT
|
||||
|
||||
registry = tmp_path / "protected" / "projects.json"
|
||||
registry.parent.mkdir(mode=0o755)
|
||||
registry.write_text("{}\n")
|
||||
registry.chmod(0o600)
|
||||
readonly = registry if matching_aliases else tmp_path / "different-registry.json"
|
||||
if not matching_aliases:
|
||||
readonly.write_text("{}\n")
|
||||
readonly.chmod(0o600)
|
||||
code = tmp_path / "old-code"
|
||||
code.mkdir()
|
||||
old_registry = code / "projects.json"
|
||||
old_registry.write_text('{"old":"must stay unchanged"}\n')
|
||||
original = old_registry.read_bytes()
|
||||
old_host_registry = tmp_path / "old-host-registry.json"
|
||||
old_host_registry.write_text("{}\n")
|
||||
repositories = tmp_path / "repositories"
|
||||
bare = repositories / "jens" / "example.git"
|
||||
bare.mkdir(parents=True)
|
||||
paths = {
|
||||
"/controller-config/projects.json": readonly,
|
||||
"/controller-hub-releases/controller-config/projects.json": registry,
|
||||
"/opt/gitea-deploy/projects.json": old_registry,
|
||||
"/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json": old_host_registry,
|
||||
"/mnt/cache/appdata/gitea-deploy-runners/deploy.lock": tmp_path / "deploy.lock",
|
||||
"/gitea-repositories": repositories,
|
||||
}
|
||||
|
||||
def fixture_path(value):
|
||||
return paths.get(str(value), Path(value))
|
||||
|
||||
revision = "a" * 40
|
||||
monkeypatch.setattr(
|
||||
subprocess, "run", lambda *_args, **_kwargs: SimpleNamespace(returncode=0, stdout=revision + "\n")
|
||||
)
|
||||
project = {"branch": "main", "slug": "example", "compose_files": ["docker-compose.yml"]}
|
||||
monkeypatch.setattr(sys, "argv", ["script", "jens/example", revision, "main", json.dumps(project)])
|
||||
tree = ast.parse(_REGISTER_SCRIPT)
|
||||
# Redirect only filesystem paths into disposable fixtures. All production
|
||||
# validation, locking, backup, atomic replace and readback code executes.
|
||||
tree.body = [node for node in tree.body if not (isinstance(node, ast.ImportFrom) and node.module == "pathlib")]
|
||||
if matching_aliases:
|
||||
exec(compile(tree, "registry-writer", "exec"), {"Path": fixture_path})
|
||||
assert json.loads(registry.read_text()) == {"jens/example": project}
|
||||
assert registry.stat().st_mode & 0o777 == 0o600
|
||||
assert list(registry.parent.glob("projects.json.appops-backup-*"))
|
||||
else:
|
||||
with pytest.raises(SystemExit) as error:
|
||||
exec(compile(tree, "registry-writer", "exec"), {"Path": fixture_path})
|
||||
assert error.value.code == 36
|
||||
assert registry.read_text() == "{}\n"
|
||||
assert old_registry.read_bytes() == original
|
||||
assert old_host_registry.read_text() == "{}\n"
|
||||
@@ -57,6 +57,109 @@ def test_only_the_exact_local_volume_is_accepted():
|
||||
module.validate_volume(volume())
|
||||
|
||||
|
||||
def writer_inventory(inherited_labels):
|
||||
identifier = "c" * 64
|
||||
spec = module._writer_spec(IMAGE_ID, REVISION, kind="config")
|
||||
return {
|
||||
"Id": identifier, "Name": f"/{module.WRITER_NAME}", "Image": IMAGE_ID,
|
||||
"Config": {
|
||||
"Image": IMAGE_ID, "User": "0:0", "Entrypoint": ["python3"], "Cmd": spec[-2:],
|
||||
"Labels": {**inherited_labels, module.WRITER_LABEL: "true",
|
||||
module.WRITER_TARGET_LABEL: "config", module.WRITER_REVISION_LABEL: REVISION},
|
||||
},
|
||||
"HostConfig": {
|
||||
"NetworkMode": "none", "Binds": [], "Privileged": False, "ReadonlyRootfs": True,
|
||||
"CpuQuota": module.WRITER_CPU_QUOTA, "CpuPeriod": module.WRITER_CPU_PERIOD,
|
||||
"Memory": module.WRITER_MEMORY_BYTES, "PidsLimit": module.WRITER_PIDS_LIMIT,
|
||||
"CapDrop": ["ALL"], "Tmpfs": {"/tmp": "rw,nosuid,nodev,size=16777216,mode=1777"},
|
||||
"SecurityOpt": ["no-new-privileges:true"],
|
||||
},
|
||||
"Mounts": [{"Type": "volume", "Name": module.VOLUME,
|
||||
"Source": module.MOUNTPOINT.as_posix(), "Destination": module.TARGET, "RW": True}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", [None, "changed_image_label", "extra_label", "reserved_image_label", "falsey_labels"])
|
||||
def test_writer_labels_are_bound_to_exact_image_inheritance(monkeypatch, damage):
|
||||
inherited = {"org.opencontainers.image.revision": "d" * 40, "io.buildah.version": "1.40.0"}
|
||||
item = writer_inventory(inherited)
|
||||
if damage == "changed_image_label":
|
||||
item["Config"]["Labels"]["org.opencontainers.image.revision"] = "e" * 40
|
||||
elif damage == "extra_label":
|
||||
item["Config"]["Labels"]["unexpected"] = "value"
|
||||
elif damage == "reserved_image_label":
|
||||
inherited[module.WRITER_LABEL] = "inherited-marker"
|
||||
elif damage == "falsey_labels":
|
||||
inherited = []
|
||||
|
||||
def run(arguments, **kwargs):
|
||||
if arguments == ["docker", "inspect", item["Id"]]:
|
||||
return json.dumps([item]).encode()
|
||||
assert arguments == ["docker", "image", "inspect", IMAGE_ID]
|
||||
return json.dumps([{"Id": IMAGE_ID, "Config": {"Labels": inherited}}]).encode()
|
||||
|
||||
monkeypatch.setattr(module, "_run", run)
|
||||
if damage:
|
||||
with pytest.raises(module.VolumeTransitionError, match="writer_.*invalid"):
|
||||
module._inspect_writer(item["Id"], IMAGE_ID, REVISION, kind="config")
|
||||
else:
|
||||
assert module._inspect_writer(item["Id"], IMAGE_ID, REVISION, kind="config") == item
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", [None, "tmpfs_projection", "extra_bind", "wrong_volume", "read_only"])
|
||||
def test_writer_accepts_declared_tmpfs_without_inspect_mount_projection(monkeypatch, damage):
|
||||
item = writer_inventory({})
|
||||
if damage == "tmpfs_projection":
|
||||
item["Mounts"].append({"Type": "tmpfs", "Destination": "/tmp"})
|
||||
elif damage == "extra_bind":
|
||||
item["Mounts"].append({"Type": "bind", "Destination": "/foreign"})
|
||||
elif damage == "wrong_volume":
|
||||
item["Mounts"][0]["Name"] = "foreign-volume"
|
||||
elif damage == "read_only":
|
||||
item["Mounts"][0]["RW"] = False
|
||||
|
||||
def run(arguments, **kwargs):
|
||||
if arguments == ["docker", "inspect", item["Id"]]:
|
||||
return json.dumps([item]).encode()
|
||||
assert arguments == ["docker", "image", "inspect", IMAGE_ID]
|
||||
return json.dumps([{"Id": IMAGE_ID, "Config": {"Labels": None}}]).encode()
|
||||
|
||||
monkeypatch.setattr(module, "_run", run)
|
||||
if damage in {"extra_bind", "wrong_volume", "read_only"}:
|
||||
with pytest.raises(module.VolumeTransitionError, match="writer_mount_invalid"):
|
||||
module._inspect_writer(item["Id"], IMAGE_ID, REVISION, kind="config")
|
||||
else:
|
||||
module._inspect_writer(item["Id"], IMAGE_ID, REVISION, kind="config")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["foreign-project", "", None, "${COMPOSE_PROJECT_NAME}"])
|
||||
def test_source_compose_name_cannot_be_overridden_by_fixed_project_flag(monkeypatch, name):
|
||||
def never_compose(*args, **kwargs):
|
||||
pytest.fail("Foreign source project reached Compose")
|
||||
|
||||
monkeypatch.setattr(module, "_run", never_compose)
|
||||
source = f"name: {json.dumps(name)}\nservices:\n deploy-control:\n image: immutable-controller\n".encode()
|
||||
with pytest.raises(module.VolumeTransitionError, match="project_invalid"):
|
||||
module.rendered_model(source, REVISION, IMAGE_ID)
|
||||
|
||||
|
||||
def test_compose_without_explicit_name_retains_canonical_project(monkeypatch):
|
||||
def run(arguments, *, data=None, **kwargs):
|
||||
if arguments[:3] == ["docker", "image", "inspect"]:
|
||||
return IMAGE_ID.encode()
|
||||
normalized = model()
|
||||
normalized["name"] = (
|
||||
arguments[arguments.index("--project-name") + 1]
|
||||
if "--project-name" in arguments else "data"
|
||||
)
|
||||
return json.dumps(normalized).encode()
|
||||
|
||||
monkeypatch.setattr(module, "_run", run)
|
||||
original = b"services:\n deploy-control:\n image: immutable-controller\n"
|
||||
rendered = json.loads(module.rendered_model(original, REVISION, IMAGE_ID))
|
||||
assert rendered["name"] == "gitea-deploy-runners"
|
||||
|
||||
|
||||
def model():
|
||||
return {
|
||||
"name": "gitea-deploy-runners",
|
||||
@@ -146,12 +249,30 @@ def test_compose_uses_protected_code_and_two_config_views(compose):
|
||||
]
|
||||
assert compose_calls
|
||||
assert all("--project-directory" in arguments for arguments in compose_calls)
|
||||
assert all(module.MOUNTPOINT.as_posix() in arguments for arguments in compose_calls)
|
||||
assert module.ROOT.as_posix() in compose_calls[0]
|
||||
assert all(module.MOUNTPOINT.as_posix() in arguments for arguments in compose_calls[1:])
|
||||
assert all("--env-file" in arguments and "/dev/null" in arguments for arguments in compose_calls)
|
||||
assert all("-f" in arguments and "-" in arguments for arguments in compose_calls)
|
||||
assert calls[0][1] == ORIGINAL_COMPOSE
|
||||
|
||||
|
||||
def test_source_relative_controller_bind_uses_original_compose_directory(monkeypatch):
|
||||
def run(arguments, *, data=None, **kwargs):
|
||||
if arguments[:3] == ["docker", "image", "inspect"]:
|
||||
return IMAGE_ID.encode()
|
||||
normalized = model()
|
||||
directory = arguments[arguments.index("--project-directory") + 1]
|
||||
if arguments[-2:] == ["--format", "json"]:
|
||||
normalized["services"][module.SERVICE]["volumes"][1]["source"] = directory + "/controller"
|
||||
return json.dumps(normalized).encode()
|
||||
|
||||
monkeypatch.setattr(module, "_run", run)
|
||||
original = ORIGINAL_COMPOSE + b" volumes:\n - ./controller:/opt/gitea-deploy:ro\n"
|
||||
rendered = json.loads(module.rendered_model(original, REVISION, IMAGE_ID))
|
||||
mounts = rendered["services"][module.SERVICE]["volumes"]
|
||||
assert next(m["source"] for m in mounts if m["target"] == module.EXECUTABLE_TARGET) == module._mount_source_for_revision(REVISION)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["target_collision", "foreign_volume", "graph_rw", "relative_bind", "env_file"])
|
||||
def test_compose_rejects_unsafe_or_unpinned_activation_inputs(compose, damage):
|
||||
baseline, _ = compose
|
||||
@@ -191,6 +312,55 @@ def test_compose_tag_replacement_cannot_change_controller_startup_image(compose,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", [None, "foreign_reference", "wrong_project", "wrong_service", "unhealthy", "digest_reference", "wrong_image", "normalized_reference", "interpolation"])
|
||||
def test_drifted_tag_requires_healthy_current_controller_provenance(compose, monkeypatch, damage):
|
||||
baseline, calls = compose
|
||||
reference = "registry.example/controller:stable"
|
||||
if damage == "digest_reference":
|
||||
reference = "registry.example/controller@sha256:" + "c" * 64
|
||||
baseline["services"][module.SERVICE]["image"] = reference
|
||||
current = {
|
||||
"Image": IMAGE_ID,
|
||||
"Config": {"Image": reference, "Labels": {
|
||||
"com.docker.compose.project": "gitea-deploy-runners",
|
||||
"com.docker.compose.service": module.SERVICE,
|
||||
}},
|
||||
"State": {"Health": {"Status": "healthy"}},
|
||||
}
|
||||
if damage == "foreign_reference":
|
||||
current["Config"]["Image"] = "registry.example/foreign:stable"
|
||||
elif damage == "wrong_project":
|
||||
current["Config"]["Labels"]["com.docker.compose.project"] = "foreign"
|
||||
elif damage == "wrong_service":
|
||||
current["Config"]["Labels"]["com.docker.compose.service"] = "foreign"
|
||||
elif damage == "unhealthy":
|
||||
current["State"]["Health"]["Status"] = "unhealthy"
|
||||
elif damage == "wrong_image":
|
||||
current["Image"] = "sha256:" + "c" * 64
|
||||
original_run = module._run
|
||||
|
||||
def drifted_tag(arguments, **kwargs):
|
||||
if arguments[:3] == ["docker", "image", "inspect"]:
|
||||
return ("sha256:" + "c" * 64).encode("ascii")
|
||||
return original_run(arguments, **kwargs)
|
||||
|
||||
monkeypatch.setattr(module, "_run", drifted_tag)
|
||||
source_reference = reference
|
||||
if damage == "normalized_reference":
|
||||
source_reference = "registry.example/foreign:stable"
|
||||
elif damage == "interpolation":
|
||||
source_reference = "${CONTROLLER_IMAGE}"
|
||||
original = f"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: {source_reference}\n".encode()
|
||||
if damage:
|
||||
with pytest.raises(module.VolumeTransitionError, match="image_reference_(changed|invalid)"):
|
||||
module.rendered_model(original, REVISION, IMAGE_ID, current_container=current)
|
||||
else:
|
||||
rendered = json.loads(module.rendered_model(original, REVISION, IMAGE_ID, current_container=current))
|
||||
assert rendered["services"][module.SERVICE]["image"] == IMAGE_ID
|
||||
assert rendered["services"][module.SERVICE]["pull_policy"] == "never"
|
||||
assert not any("up" in arguments or "pull" in arguments for arguments, _ in calls)
|
||||
|
||||
|
||||
def test_config_snapshot_has_exact_allowlist_and_broker_token():
|
||||
entries = {
|
||||
"projects.json": (b"{}", 0o600),
|
||||
@@ -482,7 +652,7 @@ def test_installer_release_order_matches_renderer_contract():
|
||||
renderer_spec.loader.exec_module(renderer)
|
||||
assert tuple(module.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES)
|
||||
assert module.RELEASE_FILES[:3] == (
|
||||
"controller_paths.py", "mcp_hub_release_store.py", "deploy.py"
|
||||
"controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py"
|
||||
)
|
||||
|
||||
|
||||
@@ -566,11 +736,12 @@ def test_private_stage_rejects_symlinked_source_directory():
|
||||
shutil.rmtree(work)
|
||||
|
||||
|
||||
def test_source_compose_preflight_rejects_host_file_reads_before_compose(compose):
|
||||
@pytest.mark.parametrize("directive", ["env_file", "label_file"])
|
||||
def test_source_compose_preflight_rejects_host_file_reads_before_compose(compose, directive):
|
||||
_baseline, calls = compose
|
||||
unsafe = (
|
||||
b"name: gitea-deploy-runners\nservices:\n deploy-control:\n"
|
||||
b" image: x\n env_file: /tmp/secret\n"
|
||||
+ f" image: x\n {directive}: /tmp/secret\n".encode()
|
||||
)
|
||||
with pytest.raises(module.VolumeTransitionError, match="external_source_invalid"):
|
||||
module.rendered_model(unsafe, REVISION, IMAGE_ID)
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
@@ -211,7 +213,9 @@ def test_upgrade_prepares_and_validates_captured_optional_private_tls_before_sto
|
||||
|
||||
def test_upgrade_rolls_back_state_before_restoring_or_starting_the_old_writer():
|
||||
script = UPGRADE_PATH.read_text(encoding="utf-8")
|
||||
restore = script[script.index("restore_previous() {") : script.index("cleanup() {")]
|
||||
restore_start = script.index("restore_previous() {")
|
||||
cleanup_start = script.index("cleanup() {", restore_start)
|
||||
restore = script[restore_start:cleanup_start]
|
||||
|
||||
stop = 'if ! python3 "${work}/${PREPARER_NAME}" rollback --backup "$backup" --stop-only; then'
|
||||
rollback = '--rollback'
|
||||
@@ -245,6 +249,12 @@ def test_wrapper_archives_the_fixed_operator_handoff_required_by_root_upgrade():
|
||||
wrapper = WRAPPER_PATH.read_text(encoding="utf-8")
|
||||
upgrade = UPGRADE_PATH.read_text(encoding="utf-8")
|
||||
archive_line = next(line for line in wrapper.splitlines() if "git archive" in line)
|
||||
source_paths_match = re.search(r"(?ms)^source_paths=\((.*?)^\)", upgrade)
|
||||
|
||||
assert "scripts/run_repopulse_adoption.py" in archive_line
|
||||
assert '"scripts/run_repopulse_adoption.py"' in upgrade
|
||||
assert source_paths_match is not None
|
||||
assert "scripts/run_repopulse_adoption.py" in shlex.split(source_paths_match.group(1))
|
||||
assert (
|
||||
'install -o root -g root -m 0700 "${script_dir}/run_repopulse_adoption.py" '
|
||||
'"${work}/source/controller/run_repopulse_adoption.py"'
|
||||
) in upgrade
|
||||
File diff suppressed because it is too large.
Load diff
@@ -87,7 +87,7 @@ def test_derives_bounded_single_container_topology_and_uses_fixed_exec():
|
||||
assert '"source_dir":"/mnt/cache/appdata/gitea-deploy-runners/sources/certscope"' in command[6]
|
||||
assert '"containers":["certscope"]' in command[6]
|
||||
script = command[2]
|
||||
assert '/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json' in script
|
||||
assert '/controller-hub-releases/controller-config/projects.json' in script
|
||||
assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in script
|
||||
assert '/mnt/cache/appdata/gitea-deploy-runners/deploy.lock' in script
|
||||
assert 'os.path.samefile(path, host_path)' in script
|
||||
|
||||
@@ -95,6 +95,63 @@ def test_runner_snapshot_recommends_only_approval_gated_restart_for_stalled_queu
|
||||
assert "fixture-secret" not in repr(result)
|
||||
|
||||
|
||||
def test_pending_jobs_do_not_trigger_runnable_queue_diagnostics(monkeypatch):
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setitem(
|
||||
settings.apis,
|
||||
"GITEA_RUNNER_TARGETS_JSON",
|
||||
'{"itworx-ci-build":"itworx-security-runner"}',
|
||||
)
|
||||
connector = GiteaActionsConnector()
|
||||
created_at = (datetime.now(UTC) - timedelta(minutes=15)).isoformat()
|
||||
requested_statuses = []
|
||||
|
||||
def fake_request(path, params=None):
|
||||
if path.endswith("/runners"):
|
||||
return {
|
||||
"total_count": 1,
|
||||
"runners": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "itworx-security-runner",
|
||||
"status": "online",
|
||||
"busy": False,
|
||||
"disabled": False,
|
||||
"labels": ["linux-validation"],
|
||||
}
|
||||
],
|
||||
}
|
||||
status = params["status"]
|
||||
requested_statuses.append(status)
|
||||
if status == "pending":
|
||||
return {
|
||||
"total_count": 1,
|
||||
"jobs": [
|
||||
{
|
||||
"id": 171,
|
||||
"name": "conditional release full",
|
||||
"status": "requested",
|
||||
"created_at": created_at,
|
||||
"labels": ["itworx-ci-build"],
|
||||
}
|
||||
],
|
||||
}
|
||||
return {"total_count": 0, "jobs": []}
|
||||
|
||||
monkeypatch.setattr(connector, "_request", fake_request)
|
||||
result = connector.snapshot(refresh=True)
|
||||
|
||||
assert requested_statuses == ["queued", "pending", "in_progress"]
|
||||
assert result["severity"] == "healthy"
|
||||
assert result["queue"]["queued"] == 0
|
||||
assert result["queue"]["oldestQueuedAgeSeconds"] is None
|
||||
assert result["queue"]["sampleTruncated"] is False
|
||||
assert result["queue"]["blockedByRunnerLabels"] == []
|
||||
assert result["queue"]["staleAssignmentRunnerLabels"] == []
|
||||
assert result["queue"]["items"] == []
|
||||
assert result["recommendedAction"] is None
|
||||
|
||||
|
||||
def test_runner_snapshot_is_cached_and_healthy_when_queue_is_clear(monkeypatch):
|
||||
_configure(monkeypatch)
|
||||
connector = GiteaActionsConnector()
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
INSTALLER_PATH = (
|
||||
ROOT
|
||||
/ "ops"
|
||||
/ "gitea-deploy-broker"
|
||||
/ "tune_host_guard_memory_thresholds.py"
|
||||
)
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"host_guard_memory_threshold_installer_test", INSTALLER_PATH
|
||||
)
|
||||
assert SPEC and SPEC.loader
|
||||
installer = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(installer)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guard_script() -> bytes:
|
||||
return b"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
readonly MEM_PAUSE_KB=4194304
|
||||
readonly MEM_RESUME_KB=6291456
|
||||
readonly SWAP_CRITICAL_MEMORY_KB=8388608
|
||||
readonly SWAP_PAUSE_KBPS=1024
|
||||
readonly SWAP_RESUME_KBPS=256
|
||||
readonly SWAP_CRITICAL_SAMPLES=3
|
||||
if [ \"$mem_kb\" -lt \"$MEM_PAUSE_KB\" ]; then
|
||||
runner_critical=1
|
||||
fi
|
||||
"""
|
||||
|
||||
|
||||
def _baseline(monkeypatch, payload: bytes) -> None:
|
||||
monkeypatch.setattr(installer, "BASELINE_SHA256", hashlib.sha256(payload).hexdigest())
|
||||
|
||||
|
||||
def _assignment_values(payload: bytes) -> dict[str, int]:
|
||||
rows = re.findall(rb"(?m)^readonly ([A-Z0-9_]+)=([0-9]+)$", payload)
|
||||
return {name.decode("ascii"): int(value) for name, value in rows}
|
||||
|
||||
|
||||
def _fixture_install(monkeypatch, tmp_path, guard_script, *, fsync_fails=False):
|
||||
target = tmp_path / "script"
|
||||
backup = tmp_path / ".script.backup"
|
||||
lock = tmp_path / ".lock"
|
||||
target.write_bytes(guard_script)
|
||||
_baseline(monkeypatch, guard_script)
|
||||
monkeypatch.setattr(installer, "TARGET_PATH", target)
|
||||
monkeypatch.setattr(installer, "BACKUP_PATH", backup)
|
||||
monkeypatch.setattr(installer, "LOCK_PATH", lock)
|
||||
monkeypatch.setattr(installer, "_assert_fixed_environment", lambda: target.stat())
|
||||
monkeypatch.setattr(installer, "_exclusive_lock", contextlib.nullcontext)
|
||||
monkeypatch.setattr(
|
||||
installer,
|
||||
"_read_private_backup",
|
||||
lambda: backup.read_bytes() if backup.exists() else None,
|
||||
)
|
||||
|
||||
events: list[str] = []
|
||||
|
||||
def assert_private_file(path, *, allow_missing=False):
|
||||
if path == backup:
|
||||
events.append("backup_identity")
|
||||
elif path == target:
|
||||
events.append("target_identity")
|
||||
return path.lstat() if path.exists() else None
|
||||
|
||||
def write_backup(path, payload):
|
||||
events.append("backup_create")
|
||||
with path.open("xb") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def write_stage(payload):
|
||||
stage = tmp_path / f".stage-{len(events)}"
|
||||
stage.write_bytes(payload)
|
||||
return stage
|
||||
|
||||
def fsync_directory(_path):
|
||||
events.append("directory_fsync")
|
||||
if fsync_fails:
|
||||
raise OSError("fixture directory fsync failure")
|
||||
|
||||
def atomic_replace(stage, destination):
|
||||
events.append("replace")
|
||||
os.replace(stage, destination)
|
||||
events.append("post_replace_fsync")
|
||||
|
||||
original_read_bytes = Path.read_bytes
|
||||
|
||||
def traced_read_bytes(path):
|
||||
payload = original_read_bytes(path)
|
||||
if path == backup:
|
||||
events.append("backup_readback")
|
||||
return payload
|
||||
|
||||
monkeypatch.setattr(installer, "_assert_private_regular_file", assert_private_file)
|
||||
monkeypatch.setattr(installer, "_write_private_file_exclusive", write_backup)
|
||||
monkeypatch.setattr(installer, "_write_stage", write_stage)
|
||||
monkeypatch.setattr(installer, "_bash_syntax_check", lambda _path: None)
|
||||
monkeypatch.setattr(installer, "_fsync_directory", fsync_directory)
|
||||
monkeypatch.setattr(installer, "_atomic_replace", atomic_replace)
|
||||
monkeypatch.setattr(Path, "read_bytes", traced_read_bytes)
|
||||
return target, backup, events
|
||||
|
||||
|
||||
def test_fixture_patch_changes_only_reviewed_memory_literals(guard_script, monkeypatch):
|
||||
_baseline(monkeypatch, guard_script)
|
||||
|
||||
candidate = installer._render_candidate(guard_script)
|
||||
|
||||
before = _assignment_values(guard_script)
|
||||
after = _assignment_values(candidate)
|
||||
assert after == {
|
||||
**before,
|
||||
"MEM_PAUSE_KB": 2_097_152,
|
||||
"MEM_RESUME_KB": 4_194_304,
|
||||
"SWAP_CRITICAL_MEMORY_KB": 4_194_304,
|
||||
}
|
||||
changed = [
|
||||
(old.decode("ascii"), new.decode("ascii"))
|
||||
for old, new in zip(guard_script.splitlines(), candidate.splitlines())
|
||||
if old != new
|
||||
]
|
||||
assert changed == [
|
||||
("readonly MEM_PAUSE_KB=4194304", "readonly MEM_PAUSE_KB=2097152"),
|
||||
("readonly MEM_RESUME_KB=6291456", "readonly MEM_RESUME_KB=4194304"),
|
||||
(
|
||||
"readonly SWAP_CRITICAL_MEMORY_KB=8388608",
|
||||
"readonly SWAP_CRITICAL_MEMORY_KB=4194304",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_candidate_passes_real_bash_syntax(guard_script, monkeypatch, tmp_path):
|
||||
bash = shutil.which("bash")
|
||||
if not bash:
|
||||
pytest.skip("bash is required for this fixture syntax check")
|
||||
_baseline(monkeypatch, guard_script)
|
||||
candidate = installer._render_candidate(guard_script)
|
||||
fixture = tmp_path / "Host_Resource_Guard.script"
|
||||
fixture.write_bytes(candidate)
|
||||
|
||||
fixture_argument = str(fixture)
|
||||
if os.name == "nt":
|
||||
# Windows bash.exe here is the WSL launcher; translate C:\... to /mnt/c/...
|
||||
drive = fixture.drive.rstrip(":").lower()
|
||||
fixture_argument = f"/mnt/{drive}/{fixture.as_posix()[3:]}"
|
||||
completed = subprocess.run(
|
||||
[bash, "-n", fixture_argument], check=False, capture_output=True, timeout=15
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def test_baseline_fingerprint_mismatch_refuses_candidate(guard_script, monkeypatch):
|
||||
_baseline(monkeypatch, guard_script)
|
||||
changed = guard_script + b"# unreviewed source change\n"
|
||||
|
||||
with pytest.raises(installer.InstallError, match="baseline_fingerprint_mismatch"):
|
||||
installer._render_candidate(changed)
|
||||
|
||||
|
||||
def test_duplicate_or_changed_memory_literal_refuses_candidate(guard_script, monkeypatch):
|
||||
duplicate = guard_script + b"readonly MEM_PAUSE_KB=4194304\n"
|
||||
_baseline(monkeypatch, duplicate)
|
||||
|
||||
with pytest.raises(installer.InstallError, match="threshold_shape_invalid:MEM_PAUSE_KB"):
|
||||
installer._render_candidate(duplicate)
|
||||
|
||||
wrong_rate = guard_script.replace(
|
||||
b"readonly SWAP_RESUME_KBPS=256", b"readonly SWAP_RESUME_KBPS=512"
|
||||
)
|
||||
_baseline(monkeypatch, wrong_rate)
|
||||
with pytest.raises(installer.InstallError, match="threshold_shape_invalid:SWAP_RESUME_KBPS"):
|
||||
installer._render_candidate(wrong_rate)
|
||||
|
||||
|
||||
def test_backup_is_readback_verified_and_directory_synced_before_replace(
|
||||
guard_script, monkeypatch, tmp_path
|
||||
):
|
||||
target, backup, events = _fixture_install(
|
||||
monkeypatch, tmp_path, guard_script
|
||||
)
|
||||
|
||||
status, after_sha = installer.install()
|
||||
|
||||
assert status == "applied"
|
||||
assert hashlib.sha256(target.read_bytes()).hexdigest() == after_sha
|
||||
assert backup.read_bytes() == guard_script
|
||||
replace_at = events.index("replace")
|
||||
assert events.index("backup_create") < events.index("backup_readback")
|
||||
assert events.index("backup_readback") < events.index("directory_fsync")
|
||||
assert events.index("directory_fsync") < max(
|
||||
index
|
||||
for index, event in enumerate(events[:replace_at])
|
||||
if event == "target_identity"
|
||||
) < replace_at
|
||||
assert events.index("post_replace_fsync") > replace_at
|
||||
|
||||
|
||||
def test_backup_directory_sync_failure_leaves_target_unmodified(
|
||||
guard_script, monkeypatch, tmp_path
|
||||
):
|
||||
target, backup, events = _fixture_install(
|
||||
monkeypatch, tmp_path, guard_script, fsync_fails=True
|
||||
)
|
||||
|
||||
with pytest.raises(installer.InstallError, match="backup_directory_sync_failed"):
|
||||
installer.install()
|
||||
|
||||
assert target.read_bytes() == guard_script
|
||||
assert backup.read_bytes() == guard_script
|
||||
assert "replace" not in events
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(monkeypatch):
|
||||
path = ROOT / "ops/gitea-deploy-broker/status_server.py"
|
||||
spec = importlib.util.spec_from_file_location("hub_candidate_delivery_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, "_gitea_validation_settings",
|
||||
lambda: ("https://gitea.example.com/api/v1", b"x" * 40))
|
||||
return module
|
||||
|
||||
|
||||
def test_candidate_api_reads_only_fixed_hub_paths(controller, monkeypatch):
|
||||
opened = []
|
||||
|
||||
def run(argv, **kwargs):
|
||||
opened.append((argv, kwargs))
|
||||
assert argv == [sys.executable, str(controller.ROOT / "hub_candidate_transport.py"), "json"]
|
||||
assert kwargs["timeout"] == 20
|
||||
assert kwargs["pass_fds"] == ()
|
||||
assert kwargs["stderr"] == controller.subprocess.DEVNULL
|
||||
assert set(kwargs["env"]) == {"PATH", "PYTHONDONTWRITEBYTECODE", "PYTHONIOENCODING"}
|
||||
return types.SimpleNamespace(returncode=0, stdout=b'{"workflows":[]}')
|
||||
|
||||
monkeypatch.setattr(controller.subprocess, "run", run)
|
||||
prefix = "/repos/jens/itworx_mcp_hub"
|
||||
assert controller._hub_candidate_api_json(prefix + "/actions/workflows", query={}) == {"workflows": []}
|
||||
payload = json.loads(opened[0][1]["input"])
|
||||
assert payload["url"] == "https://gitea.example.com/api/v1" + prefix + "/actions/workflows"
|
||||
assert payload["outputFd"] is None
|
||||
assert payload["headers"]["Authorization"] == "token " + "x" * 40
|
||||
assert all("x" * 40 not in item for item in opened[0][0])
|
||||
for path, query in (
|
||||
("https://other.invalid/api/v1", {}),
|
||||
(prefix + "/../other/actions/workflows", {}),
|
||||
(prefix + "/actions/runs", {"event": "pull_request"}),
|
||||
(prefix + "/actions/runs/1/jobs", {"page": "11", "limit": "100"}),
|
||||
(prefix + "/actions/runs/1/artifacts", {"name": "other-artifact"}),
|
||||
):
|
||||
with pytest.raises(controller._HubCandidateTransportError):
|
||||
controller._hub_candidate_api_json(path, query=query)
|
||||
assert len(opened) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [b'{"id":1,"id":2}', b"x" * (2 * 1024 * 1024 + 1)])
|
||||
def test_candidate_metadata_is_bounded_and_rejects_duplicate_keys(controller, monkeypatch, raw):
|
||||
monkeypatch.setattr(controller.subprocess, "run", lambda *_args, **_kwargs:
|
||||
types.SimpleNamespace(returncode=0, stdout=raw))
|
||||
with pytest.raises(controller._HubCandidateTransportError):
|
||||
controller._hub_candidate_api_json("/repos/jens/itworx_mcp_hub/branches/main", query={})
|
||||
|
||||
|
||||
def test_candidate_transport_deadline_is_parent_enforced(controller, monkeypatch):
|
||||
def blocked(argv, **kwargs):
|
||||
assert kwargs["timeout"] == 20
|
||||
raise controller.subprocess.TimeoutExpired(argv, kwargs["timeout"], output=b"private")
|
||||
|
||||
monkeypatch.setattr(controller.subprocess, "run", blocked)
|
||||
with pytest.raises(controller._HubCandidateTransportError, match="read_unavailable") as error:
|
||||
controller._hub_candidate_api_json("/repos/jens/itworx_mcp_hub/branches/main", query={})
|
||||
assert "private" not in str(error.value)
|
||||
|
||||
|
||||
def test_artifact_transport_writes_only_parent_owned_fd(controller, monkeypatch, tmp_path):
|
||||
import os
|
||||
controller.STATE_ROOT = tmp_path
|
||||
parent = tmp_path / "mcp-hub-release-imports"
|
||||
parent.mkdir(mode=0o700)
|
||||
destination = parent / "candidate.zip"
|
||||
|
||||
def run(argv, **kwargs):
|
||||
assert argv[-1] == "zip"
|
||||
assert kwargs["timeout"] == 1800
|
||||
payload = json.loads(kwargs["input"])
|
||||
assert set(payload) == {"url", "headers", "maxBytes", "outputFd"}
|
||||
assert str(destination) not in kwargs["input"].decode()
|
||||
assert kwargs["pass_fds"] == (payload["outputFd"],)
|
||||
os.write(payload["outputFd"], b"zip data")
|
||||
return types.SimpleNamespace(returncode=0, stdout=b'{"bytes":8}\n')
|
||||
|
||||
monkeypatch.setattr(controller.subprocess, "run", run)
|
||||
controller._hub_candidate_download_artifact(123, destination, 1024)
|
||||
assert destination.read_bytes() == b"zip data"
|
||||
assert destination.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
@pytest.mark.parametrize("receipt", [b'{"bytes":9}', b'{"bytes":true}', b'{"bytes":0}'])
|
||||
def test_artifact_receipt_must_match_actual_file(controller, monkeypatch, tmp_path, receipt):
|
||||
controller.STATE_ROOT = tmp_path
|
||||
parent = tmp_path / "mcp-hub-release-imports"
|
||||
parent.mkdir(mode=0o700)
|
||||
monkeypatch.setattr(controller.subprocess, "run", lambda *_args, **_kwargs:
|
||||
types.SimpleNamespace(returncode=0, stdout=receipt))
|
||||
with pytest.raises(controller._HubCandidateTransportError):
|
||||
controller._hub_candidate_download_artifact(123, parent / "candidate.zip", 1024)
|
||||
|
||||
|
||||
def _delivery(controller, monkeypatch, tmp_path, outcome):
|
||||
record = {"repository": controller.CANONICAL_HUB_REPOSITORY, "revision": "a" * 40,
|
||||
"status": "validation_passed", "audit": []}
|
||||
marker = tmp_path / "inbox.json"
|
||||
monkeypatch.setattr(controller, "_atomic_inbox_update",
|
||||
lambda _marker, item: marker.write_text(json.dumps(item)))
|
||||
monkeypatch.setattr(controller, "_is_current_branch_head", lambda *_args: True)
|
||||
calls = []
|
||||
|
||||
def prepare(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return {"revision": record["revision"], "status": outcome,
|
||||
"candidateSha256": "b" * 64}
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hub_candidate_import", types.SimpleNamespace(
|
||||
prepare_candidate=prepare, CandidateRejected=type("CandidateRejected", (ValueError,), {}),
|
||||
CandidateDeferred=type("CandidateDeferred", (RuntimeError,), {})))
|
||||
return marker, record, calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["awaiting_candidate", "pending_review"])
|
||||
def test_candidate_wait_never_marks_deployment_started_and_throttles(controller, monkeypatch, tmp_path, outcome):
|
||||
marker, record, calls = _delivery(controller, monkeypatch, tmp_path, outcome)
|
||||
assert not controller._prepare_hub_candidate_delivery(marker, record, {})
|
||||
assert record["status"] == "validation_passed"
|
||||
assert "deployment_started" not in [entry["stage"] for entry in record["audit"]]
|
||||
assert not controller._prepare_hub_candidate_delivery(marker, record, {})
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_candidate_ready_is_hash_bound_before_deployment(controller, monkeypatch, tmp_path):
|
||||
marker, record, calls = _delivery(controller, monkeypatch, tmp_path, "ready")
|
||||
assert controller._prepare_hub_candidate_delivery(marker, record, {})
|
||||
assert record["status"] == "validation_passed"
|
||||
assert record["candidate_preparation"]["candidate_sha256"] == "b" * 64
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_candidate_wait_expires_without_production_failure(controller, monkeypatch, tmp_path):
|
||||
marker, record, calls = _delivery(controller, monkeypatch, tmp_path, "awaiting_candidate")
|
||||
monkeypatch.setattr(controller.time, "time", lambda: 12000)
|
||||
record["candidate_preparation"] = {"started_at": 1, "deadline_at": 10801,
|
||||
"next_check_at": 2, "status": "awaiting_candidate"}
|
||||
assert not controller._prepare_hub_candidate_delivery(marker, record, {})
|
||||
assert record["status"] == "validation_timeout"
|
||||
assert record["reason"] == "hub_candidate_timeout"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_candidate_preparation_does_not_import_superseded_head(controller, monkeypatch, tmp_path):
|
||||
marker, record, calls = _delivery(controller, monkeypatch, tmp_path, "ready")
|
||||
monkeypatch.setattr(controller, "_is_current_branch_head", lambda *_args: False)
|
||||
assert not controller._prepare_hub_candidate_delivery(marker, record, {})
|
||||
assert record["status"] == "superseded"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_candidate_does_not_deploy_when_head_advances_during_import(controller, monkeypatch, tmp_path):
|
||||
marker, record, calls = _delivery(controller, monkeypatch, tmp_path, "ready")
|
||||
heads = iter((True, False))
|
||||
monkeypatch.setattr(controller, "_is_current_branch_head", lambda *_args: next(heads))
|
||||
assert not controller._prepare_hub_candidate_delivery(marker, record, {})
|
||||
assert len(calls) == 1
|
||||
assert record["status"] == "superseded"
|
||||
assert "candidate_sha256" not in record["candidate_preparation"]
|
||||
assert "deployment_started" not in [entry["stage"] for entry in record["audit"]]
|
||||
|
||||
|
||||
def test_candidate_import_cannot_overrun_the_delivery_deadline(controller, monkeypatch, tmp_path):
|
||||
marker, record, calls = _delivery(controller, monkeypatch, tmp_path, "ready")
|
||||
clock = [1]
|
||||
monkeypatch.setattr(controller.time, "time", lambda: clock[0])
|
||||
prepare = sys.modules["hub_candidate_import"].prepare_candidate
|
||||
|
||||
def slow(*args, **kwargs):
|
||||
result = prepare(*args, **kwargs)
|
||||
clock[0] = 10802
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(sys.modules["hub_candidate_import"], "prepare_candidate", slow)
|
||||
assert not controller._prepare_hub_candidate_delivery(marker, record, {})
|
||||
assert len(calls) == 1
|
||||
assert record["status"] == "validation_timeout"
|
||||
assert record["reason"] == "hub_candidate_timeout"
|
||||
assert "candidate_sha256" not in record["candidate_preparation"]
|
||||
@@ -0,0 +1,837 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import io
|
||||
import json
|
||||
import contextlib
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
import tarfile
|
||||
import types
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BROKER_DIR = ROOT / "ops" / "gitea-deploy-broker"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def modules(monkeypatch):
|
||||
if "fcntl" not in sys.modules:
|
||||
shim = types.ModuleType("fcntl")
|
||||
shim.LOCK_EX, shim.LOCK_UN, shim.LOCK_NB = 2, 8, 4
|
||||
shim.flock = lambda *_args: None
|
||||
monkeypatch.setitem(sys.modules, "fcntl", shim)
|
||||
monkeypatch.syspath_prepend(str(BROKER_DIR))
|
||||
for name in ("deploy", "prepare_mcp_hub_candidate", "hub_candidate_import"):
|
||||
sys.modules.pop(name, None)
|
||||
controller = importlib.import_module("deploy")
|
||||
prepare = importlib.import_module("prepare_mcp_hub_candidate")
|
||||
importer = importlib.import_module("hub_candidate_import")
|
||||
monkeypatch.setattr(prepare, "_is_host_root", lambda: True)
|
||||
yield types.SimpleNamespace(
|
||||
controller=controller, prepare=prepare, importer=importer
|
||||
)
|
||||
for name in ("hub_candidate_import", "prepare_mcp_hub_candidate", "deploy"):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
def _zip_stream_member(name: str, payload: bytes, offset: int) -> tuple[bytes, bytes]:
|
||||
encoded_name = name.encode("ascii")
|
||||
crc = zipfile.crc32(payload) & 0xFFFFFFFF
|
||||
local = struct.pack(
|
||||
"<IHHHHHIIIHH",
|
||||
0x04034B50,
|
||||
20,
|
||||
0x8,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
len(encoded_name),
|
||||
0,
|
||||
) + encoded_name + payload + struct.pack(
|
||||
"<IIII", 0x08074B50, crc, len(payload), len(payload)
|
||||
)
|
||||
central = struct.pack(
|
||||
"<IHHHHHHIIIHHHHHII",
|
||||
0x02014B50,
|
||||
20,
|
||||
20,
|
||||
0x8,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
crc,
|
||||
len(payload),
|
||||
len(payload),
|
||||
len(encoded_name),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
(stat.S_IFREG | 0o600) << 16,
|
||||
offset,
|
||||
) + encoded_name
|
||||
return local, central
|
||||
|
||||
|
||||
def _streamed_zip_bytes(importer, *, extra_member: bool = False) -> bytearray:
|
||||
names = sorted(importer._expected_zip_members())
|
||||
if extra_member:
|
||||
names.append("unexpected.txt")
|
||||
members = [(name, b"{}" if name != "images.tar" else b"tar-data") for name in names]
|
||||
output = bytearray()
|
||||
centrals: list[bytes] = []
|
||||
for name, payload in members:
|
||||
local, central = _zip_stream_member(name, payload, len(output))
|
||||
output.extend(local)
|
||||
centrals.append(central)
|
||||
central_offset = len(output)
|
||||
for central in centrals:
|
||||
output.extend(central)
|
||||
central_size = len(output) - central_offset
|
||||
output.extend(
|
||||
struct.pack(
|
||||
"<IHHHHIIH",
|
||||
0x06054B50,
|
||||
0,
|
||||
0,
|
||||
len(members),
|
||||
len(members),
|
||||
central_size,
|
||||
central_offset,
|
||||
0,
|
||||
)
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def _private_file(path: Path, data: bytes) -> None:
|
||||
path.write_bytes(data)
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def _synthetic_scan_reports(
|
||||
services, images, *, created_at: str, finding_service: str | None = None
|
||||
) -> dict[str, bytes]:
|
||||
report_data: dict[str, bytes] = {}
|
||||
for service in services:
|
||||
image_id = images[service]["imageId"]
|
||||
vulnerabilities = (
|
||||
[
|
||||
{
|
||||
"Severity": "HIGH",
|
||||
"PkgName": "package",
|
||||
"VulnerabilityID": "CVE-2099-0001",
|
||||
"InstalledVersion": "1.0",
|
||||
}
|
||||
]
|
||||
if service == finding_service
|
||||
else []
|
||||
)
|
||||
vulnerability = {
|
||||
"SchemaVersion": 2,
|
||||
"Metadata": {
|
||||
"ImageID": image_id,
|
||||
"ImageConfig": {"os": "linux", "architecture": "amd64"},
|
||||
},
|
||||
"CreatedAt": created_at,
|
||||
"Trivy": {"Version": "0.65.0"},
|
||||
"Results": [
|
||||
{
|
||||
"Packages": [{"Name": "package", "Version": "1.0"}],
|
||||
"Vulnerabilities": vulnerabilities,
|
||||
}
|
||||
],
|
||||
}
|
||||
secret = {
|
||||
"ImageID": image_id,
|
||||
"CreatedAt": created_at,
|
||||
"TrivyVersion": "0.65.0",
|
||||
"FindingCount": 0,
|
||||
"SensitiveFindingDetailsPersisted": False,
|
||||
"Results": [{"FindingCount": 0}],
|
||||
}
|
||||
report_data[f"vulnerability-{service}.json"] = json.dumps(
|
||||
vulnerability
|
||||
).encode()
|
||||
report_data[f"secret-{service}.json"] = json.dumps(secret).encode()
|
||||
report_data["trivy-db-metadata.json"] = json.dumps(
|
||||
{"UpdatedAt": created_at, "DownloadedAt": created_at}
|
||||
).encode()
|
||||
return report_data
|
||||
|
||||
|
||||
def test_run_identity_requires_exact_protected_push_and_no_pull_request(modules):
|
||||
importer = modules.importer
|
||||
revision = "a" * 40
|
||||
run = {
|
||||
"path": importer.RUN_PATH,
|
||||
"event": "push",
|
||||
"head_branch": "main",
|
||||
"head_sha": revision,
|
||||
"repository": {"full_name": "Jens/ITWorx_MCP_Hub"},
|
||||
"head_repository": None,
|
||||
"pull_requests": [],
|
||||
}
|
||||
assert importer._run_matches(run, revision)
|
||||
for change in (
|
||||
{"event": "workflow_dispatch"},
|
||||
{"path": "other.yml@refs/heads/main"},
|
||||
{"head_repository": {"full_name": "attacker/fork"}},
|
||||
{"pull_requests": [{"number": 12}]},
|
||||
{"head_sha": "b" * 40},
|
||||
{"repository": {"full_name": "attacker/repository"}},
|
||||
):
|
||||
assert not importer._run_matches({**run, **change}, revision)
|
||||
|
||||
|
||||
def test_selected_run_and_jobs_accept_the_live_gitea_job_shape(modules):
|
||||
importer = modules.importer
|
||||
revision = "a" * 40
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
run = {
|
||||
"id": 21531,
|
||||
"path": importer.RUN_PATH,
|
||||
"event": "push",
|
||||
"head_branch": "main",
|
||||
"head_sha": revision,
|
||||
"repository": {"full_name": "Jens/ITWorx_MCP_Hub"},
|
||||
"head_repository": None,
|
||||
"pull_requests": [],
|
||||
"created_at": now,
|
||||
"run_attempt": 1,
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
}
|
||||
job_rows = [
|
||||
{
|
||||
"id": 37145 + index,
|
||||
"name": name,
|
||||
"run_id": 21531,
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"runner_id": 22,
|
||||
"started_at": now,
|
||||
"completed_at": now,
|
||||
}
|
||||
for index, name in enumerate(("full", "release-images"))
|
||||
]
|
||||
calls = []
|
||||
|
||||
def api(path, *, query):
|
||||
calls.append((path, query))
|
||||
if path.endswith("/actions/runs"):
|
||||
return {"total_count": 1, "workflow_runs": [run]}
|
||||
if path.endswith("/actions/runs/21531/jobs"):
|
||||
return {"total_count": 2, "jobs": job_rows}
|
||||
raise AssertionError(f"unexpected fixed API path: {path}")
|
||||
|
||||
selected, built_at = importer._selected_run(api, revision)
|
||||
assert selected == run
|
||||
assert built_at.endswith("Z")
|
||||
assert importer._jobs_ready(api, revision, selected)
|
||||
assert calls[0][1] == {
|
||||
"event": "push",
|
||||
"branch": "main",
|
||||
"head_sha": revision,
|
||||
"page": "1",
|
||||
"limit": "100",
|
||||
}
|
||||
assert calls[1][1] == {"page": "1", "limit": "100"}
|
||||
|
||||
|
||||
def test_required_candidate_jobs_must_both_succeed_and_bind_run(modules):
|
||||
importer = modules.importer
|
||||
run = {
|
||||
"id": 5,
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"run_attempt": 1,
|
||||
}
|
||||
rows = [
|
||||
{"name": name, "run_id": 5, "status": "completed", "conclusion": "success"}
|
||||
for name in ("full", "release-images")
|
||||
]
|
||||
|
||||
def api(_path, *, query):
|
||||
assert query == {"page": "1", "limit": "100"}
|
||||
return {"total_count": len(rows), "jobs": rows}
|
||||
|
||||
assert importer._jobs_ready(api, "a" * 40, run)
|
||||
rows[1] = {**rows[1], "conclusion": "failure"}
|
||||
with pytest.raises(importer.CandidateRejected):
|
||||
importer._jobs_ready(api, "a" * 40, run)
|
||||
|
||||
|
||||
def test_recheck_rejects_a_new_attempt_for_the_same_workflow_run(modules, monkeypatch):
|
||||
importer = modules.importer
|
||||
revision = "a" * 40
|
||||
original = {"id": 21531, "run_attempt": 1}
|
||||
restarted = {"id": 21531, "run_attempt": 2}
|
||||
artifact = {"id": 730}
|
||||
monkeypatch.setattr(importer, "_remote_main_tree", lambda *_args: "b" * 40)
|
||||
monkeypatch.setattr(importer, "_fixed_workflow", lambda *_args: None)
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"_selected_run",
|
||||
lambda *_args: (restarted, "2026-10-03T10:00:00.000Z"),
|
||||
)
|
||||
monkeypatch.setattr(importer, "_jobs_ready", lambda *_args: True)
|
||||
with pytest.raises(importer.CandidateRejected, match="run changed"):
|
||||
importer._recheck_protected_artifact(
|
||||
lambda *_args, **_kwargs: None,
|
||||
revision=revision,
|
||||
tree="b" * 40,
|
||||
initial_run=original,
|
||||
initial_built_at="2026-10-03T10:00:00.000Z",
|
||||
initial_artifact=artifact,
|
||||
)
|
||||
|
||||
|
||||
def test_unfixable_vulnerability_finding_is_bound_to_fixed_image_reference(modules):
|
||||
importer = modules.importer
|
||||
controller = modules.controller
|
||||
created_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
images = {
|
||||
service: {
|
||||
"imageId": f"sha256:{index:064x}",
|
||||
"imageRef": f"registry.example/{service}:fixed",
|
||||
}
|
||||
for index, service in enumerate(controller.MCP_HUB_SERVICES, start=1)
|
||||
}
|
||||
report_data = _synthetic_scan_reports(
|
||||
controller.MCP_HUB_SERVICES,
|
||||
images,
|
||||
created_at=created_at,
|
||||
finding_service=controller.MCP_HUB_SERVICES[0],
|
||||
)
|
||||
|
||||
_reports, findings = importer._validate_reports(images, report_data)
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["imageRef"] == images[controller.MCP_HUB_SERVICES[0]]["imageRef"]
|
||||
summary = importer._candidate_findings_summary(findings, images)
|
||||
assert summary["unresolvedCriticalOrHigh"][0]["affectedImages"] == [
|
||||
findings[0]["imageRef"]
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("finding_present", [False, True])
|
||||
@pytest.mark.parametrize("rerun_during_load", [False, True])
|
||||
def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
|
||||
modules, monkeypatch, tmp_path, finding_present, rerun_during_load
|
||||
):
|
||||
importer = modules.importer
|
||||
prepare = modules.prepare
|
||||
controller = modules.controller
|
||||
revision, tree, epoch = "a" * 40, "b" * 40, "1790985600"
|
||||
built_at = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
build = {
|
||||
"id": revision[:12],
|
||||
"repository": importer.REPOSITORY,
|
||||
"revision": revision,
|
||||
"tree": tree,
|
||||
"treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(),
|
||||
"builtAt": built_at,
|
||||
}
|
||||
compose_sha = "c" * 64
|
||||
model_services = {}
|
||||
images = {}
|
||||
for index, service in enumerate(controller.MCP_HUB_SERVICES, start=1):
|
||||
external = importer.EXTERNAL_IMAGES.get(service)
|
||||
image_ref = (
|
||||
external[0]
|
||||
if external is not None
|
||||
else f"registry.example/itworx/{service}:{revision[:12]}"
|
||||
)
|
||||
archive_ref = external[1] if external is not None else image_ref
|
||||
model_services[service] = {"image": image_ref}
|
||||
images[service] = {
|
||||
"imageRef": image_ref,
|
||||
"archiveRef": archive_ref,
|
||||
"imageId": f"sha256:{index:064x}",
|
||||
}
|
||||
model = {"services": model_services}
|
||||
created_at = built_at
|
||||
finding_service = controller.MCP_HUB_SERVICES[0] if finding_present else None
|
||||
report_data = _synthetic_scan_reports(
|
||||
controller.MCP_HUB_SERVICES,
|
||||
images,
|
||||
created_at=created_at,
|
||||
finding_service=finding_service,
|
||||
)
|
||||
_reports, findings = importer._validate_reports(images, report_data)
|
||||
summary = importer._candidate_findings_summary(findings, images)
|
||||
digests = {
|
||||
"images": {
|
||||
service: {
|
||||
"imageId": images[service]["imageId"],
|
||||
"tag": images[service]["imageRef"],
|
||||
}
|
||||
for service in controller.MCP_HUB_SERVICES
|
||||
}
|
||||
}
|
||||
exception_rows = [
|
||||
{
|
||||
"package": finding["package"],
|
||||
"cve": finding["cve"],
|
||||
"installedVersion": finding["installedVersion"],
|
||||
"severity": finding["severity"],
|
||||
"affectedImageDigests": {
|
||||
finding["imageRef"]: images_by_ref["imageId"]
|
||||
for images_by_ref in [
|
||||
next(
|
||||
row
|
||||
for row in images.values()
|
||||
if row["imageRef"] == finding["imageRef"]
|
||||
)
|
||||
]
|
||||
},
|
||||
}
|
||||
for finding in findings
|
||||
]
|
||||
draft = {"schemaVersion": "1.0", "exceptions": exception_rows}
|
||||
state_root = tmp_path / "state"
|
||||
state_root.mkdir(mode=0o700)
|
||||
state_root.chmod(0o700)
|
||||
releases_root = tmp_path / "releases"
|
||||
source = releases_root / revision
|
||||
source.mkdir(parents=True)
|
||||
monkeypatch.setattr(controller, "STATE_ROOT", state_root)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_RELEASES_ROOT", releases_root)
|
||||
monkeypatch.setattr(controller, "GLOBAL_DEPLOY_LOCK", state_root / "deploy.lock")
|
||||
monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_require_no_posix_acl", lambda *_args: None)
|
||||
monkeypatch.setattr(controller, "_root_owned", lambda *_args: True)
|
||||
|
||||
def create_private_directory(path, *, create=False, root=None):
|
||||
del root
|
||||
if create:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
path.chmod(0o700)
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
raise controller.DeployError("test private directory missing")
|
||||
|
||||
def candidate_directory(candidate_revision):
|
||||
directory = state_root / "mcp-hub-release-candidates" / candidate_revision
|
||||
for path in (directory.parent, directory):
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
raise controller.DeployError("test candidate directory missing")
|
||||
return directory
|
||||
|
||||
monkeypatch.setattr(controller, "_mcp_hub_private_directory", create_private_directory)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_candidate_directory", candidate_directory)
|
||||
project, repo = {"branch": "main"}, tmp_path / "repo"
|
||||
monkeypatch.setattr(prepare, "_fixed_target", lambda *_args: (project, repo))
|
||||
monkeypatch.setattr(controller, "deployment_metadata", lambda *_args, **_kwargs: build)
|
||||
monkeypatch.setattr(controller, "mcp_hub_source_date_epoch", lambda *_args: epoch)
|
||||
monkeypatch.setattr(controller, "mcp_hub_stage_release_source", lambda *_args: source)
|
||||
monkeypatch.setattr(controller, "exact_source_matches_revision", lambda *_args: True)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_verify_release_tree_metadata", lambda *_args: None)
|
||||
monkeypatch.setattr(
|
||||
prepare,
|
||||
"_render",
|
||||
lambda *_args, **_kwargs: (model, compose_sha),
|
||||
)
|
||||
monkeypatch.setattr(importer, "_remote_main_tree", lambda *_args: tree)
|
||||
monkeypatch.setattr(importer, "_fixed_workflow", lambda *_args: None)
|
||||
run = {"id": 21531, "run_attempt": 1}
|
||||
run_state = {"attempt": 1}
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"_selected_run",
|
||||
lambda *_args: ({**run, "run_attempt": run_state["attempt"]}, built_at),
|
||||
)
|
||||
monkeypatch.setattr(importer, "_jobs_ready", lambda *_args: True)
|
||||
artifact_bytes = b"bounded fake Gitea artifact"
|
||||
artifact = {
|
||||
"id": 730,
|
||||
"size_in_bytes": len(artifact_bytes),
|
||||
"name": importer.ARTIFACT_PREFIX + revision,
|
||||
"workflow_run": {"id": run["id"]},
|
||||
}
|
||||
monkeypatch.setattr(importer, "_candidate_artifact", lambda *_args: artifact)
|
||||
monkeypatch.setattr(importer, "_docker_root", lambda: tmp_path)
|
||||
monkeypatch.setattr(importer, "_check_disk_headroom", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(importer, "_parse_artifact_zip", lambda *_args, **_kwargs: (
|
||||
{},
|
||||
report_data,
|
||||
hashlib.sha256(artifact_bytes).hexdigest(),
|
||||
"d" * 64,
|
||||
"e" * 64,
|
||||
123,
|
||||
))
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"_validate_manifest",
|
||||
lambda *_args, **_kwargs: (images, {}),
|
||||
)
|
||||
monkeypatch.setattr(importer, "_validate_docker_save", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(importer, "_bounded_global_deploy_lock", contextlib.nullcontext)
|
||||
monkeypatch.setattr(importer, "_image_preflight", lambda *_args: None)
|
||||
|
||||
def fake_fixed_docker(*args, **_kwargs):
|
||||
if rerun_during_load and args and args[0] == "load":
|
||||
run_state["attempt"] = 2
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(importer, "_fixed_docker", fake_fixed_docker)
|
||||
monkeypatch.setattr(importer, "_verify_loaded_images", lambda *_args, **_kwargs: {})
|
||||
monkeypatch.setattr(
|
||||
controller,
|
||||
"_mcp_hub_check_candidate_exceptions",
|
||||
lambda *_args: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
prepare,
|
||||
"_draft_pending_exceptions",
|
||||
lambda *_args: draft,
|
||||
)
|
||||
|
||||
def fake_plan(_repository, _revision, *, built_at, staging_directory, **_kwargs):
|
||||
Path(staging_directory).mkdir(mode=0o700)
|
||||
frozen = {
|
||||
"schema": prepare.BUILD_SCHEMA,
|
||||
"repository": importer.REPOSITORY,
|
||||
"revision": revision,
|
||||
"build": build,
|
||||
"sourceDateEpoch": epoch,
|
||||
"composeSha256": compose_sha,
|
||||
}
|
||||
controller._mcp_hub_write_private_new(
|
||||
Path(staging_directory) / "build.json", prepare._json_bytes(frozen)
|
||||
)
|
||||
return {"status": "planned", "revision": revision, "composeSha256": compose_sha}
|
||||
|
||||
monkeypatch.setattr(prepare, "plan_candidate", fake_plan)
|
||||
|
||||
def fake_capture(_repository, _revision, *, staging_directory, **_kwargs):
|
||||
image_rows = {
|
||||
service: {
|
||||
"imageRef": images[service]["imageRef"],
|
||||
"imageId": images[service]["imageId"],
|
||||
"vulnerabilitySha256": hashlib.sha256(
|
||||
report_data[f"vulnerability-{service}.json"]
|
||||
).hexdigest(),
|
||||
"secretSha256": hashlib.sha256(
|
||||
report_data[f"secret-{service}.json"]
|
||||
).hexdigest(),
|
||||
}
|
||||
for service in controller.MCP_HUB_SERVICES
|
||||
}
|
||||
candidate = {
|
||||
"schema": prepare.CANDIDATE_SCHEMA,
|
||||
"repository": importer.REPOSITORY,
|
||||
"revision": revision,
|
||||
"build": build,
|
||||
"sourceDateEpoch": epoch,
|
||||
"composeSha256": compose_sha,
|
||||
"images": image_rows,
|
||||
"databaseMetadataSha256": hashlib.sha256(
|
||||
report_data["trivy-db-metadata.json"]
|
||||
).hexdigest(),
|
||||
"summarySha256": hashlib.sha256(prepare._json_bytes(summary)).hexdigest(),
|
||||
"digestsSha256": hashlib.sha256(prepare._json_bytes(digests)).hexdigest(),
|
||||
}
|
||||
outputs = {
|
||||
"candidate.json": candidate,
|
||||
"scan-summary.json": summary,
|
||||
"image-digests.json": digests,
|
||||
"exceptions.json": draft,
|
||||
}
|
||||
for filename, value in outputs.items():
|
||||
controller._mcp_hub_write_private_new(
|
||||
Path(staging_directory) / filename, prepare._json_bytes(value)
|
||||
)
|
||||
candidate_sha = hashlib.sha256(prepare._json_bytes(candidate)).hexdigest()
|
||||
exceptions_sha = hashlib.sha256(prepare._json_bytes(draft)).hexdigest()
|
||||
return {
|
||||
"candidateSha256": candidate_sha,
|
||||
"exceptionsSha256": exceptions_sha,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(prepare, "capture_candidate", fake_capture)
|
||||
|
||||
def download_artifact(_artifact_id, destination, _maximum):
|
||||
_private_file(destination, artifact_bytes)
|
||||
return None
|
||||
|
||||
candidate_directory = state_root / "mcp-hub-release-candidates" / revision
|
||||
if rerun_during_load:
|
||||
with pytest.raises(importer.CandidateRejected, match="run changed"):
|
||||
importer.prepare_candidate(
|
||||
importer.REPOSITORY,
|
||||
revision,
|
||||
api_json=lambda *_args, **_kwargs: None,
|
||||
download_artifact=download_artifact,
|
||||
)
|
||||
assert not candidate_directory.exists()
|
||||
return
|
||||
|
||||
first = importer.prepare_candidate(
|
||||
importer.REPOSITORY,
|
||||
revision,
|
||||
api_json=lambda *_args, **_kwargs: None,
|
||||
download_artifact=download_artifact,
|
||||
)
|
||||
assert candidate_directory.is_dir()
|
||||
assert stat.S_IMODE(candidate_directory.stat().st_mode) == 0o700
|
||||
candidate_before_review = (candidate_directory / "candidate.json").read_bytes()
|
||||
receipt_before_review = (candidate_directory / "import-receipt.json").read_bytes()
|
||||
collision_stage = tmp_path / "second-candidate"
|
||||
collision_stage.mkdir(mode=0o700)
|
||||
with pytest.raises(importer.CandidateRejected, match="never overwrites"):
|
||||
importer._publish_candidate(collision_stage, candidate_directory)
|
||||
assert (candidate_directory / "candidate.json").read_bytes() == candidate_before_review
|
||||
if finding_present:
|
||||
assert first["status"] == "pending_review"
|
||||
reviewed = {
|
||||
**draft,
|
||||
"reviewer": "release-operator",
|
||||
"exceptions": [
|
||||
{**row, "justification": "Reviewed unfixable finding"}
|
||||
for row in draft["exceptions"]
|
||||
],
|
||||
}
|
||||
exceptions_path = candidate_directory / "exceptions.json"
|
||||
exceptions_path.unlink()
|
||||
controller._mcp_hub_write_private_new(
|
||||
exceptions_path, prepare._json_bytes(reviewed)
|
||||
)
|
||||
approval = {
|
||||
"schema": importer.APPROVAL_SCHEMA,
|
||||
"candidateSha256": first["candidateSha256"],
|
||||
"exceptionsSha256": hashlib.sha256(
|
||||
prepare._json_bytes(reviewed)
|
||||
).hexdigest(),
|
||||
}
|
||||
controller._mcp_hub_write_private_new(
|
||||
candidate_directory / "approval.json", prepare._json_bytes(approval)
|
||||
)
|
||||
final = importer.prepare_candidate(
|
||||
importer.REPOSITORY,
|
||||
revision,
|
||||
api_json=lambda *_args, **_kwargs: None,
|
||||
download_artifact=download_artifact,
|
||||
)
|
||||
assert final["status"] == "ready"
|
||||
else:
|
||||
assert first["status"] == "ready"
|
||||
final = first
|
||||
assert final["candidateSha256"] == hashlib.sha256(candidate_before_review).hexdigest()
|
||||
assert (candidate_directory / "candidate.json").read_bytes() == candidate_before_review
|
||||
assert (candidate_directory / "import-receipt.json").read_bytes() == receipt_before_review
|
||||
|
||||
|
||||
def test_candidate_artifact_is_exact_bounded_and_unexpired(modules):
|
||||
importer = modules.importer
|
||||
revision = "c" * 40
|
||||
run_id = 11
|
||||
artifact = {
|
||||
"id": 22,
|
||||
"name": importer.ARTIFACT_PREFIX + revision,
|
||||
"size_in_bytes": 4096,
|
||||
"expired": False,
|
||||
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=1))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
"workflow_run": {"id": run_id},
|
||||
}
|
||||
result = importer._candidate_artifact(
|
||||
lambda _path, *, query: {"artifacts": [artifact]}, run_id, revision
|
||||
)
|
||||
assert result == artifact
|
||||
for changed in (
|
||||
{"expired": True},
|
||||
{"name": "hub-release-candidate-" + "d" * 40},
|
||||
{"size_in_bytes": importer.MAX_ARTIFACT_ZIP_BYTES + 1},
|
||||
{"workflow_run": {"id": run_id + 1}},
|
||||
):
|
||||
with pytest.raises(importer.CandidateRejected):
|
||||
importer._candidate_artifact(
|
||||
lambda _path, *, query: {"artifacts": [{**artifact, **changed}]},
|
||||
run_id,
|
||||
revision,
|
||||
)
|
||||
|
||||
|
||||
def test_streamed_uploader_zip_descriptors_are_verified(modules, tmp_path):
|
||||
importer = modules.importer
|
||||
payload = _streamed_zip_bytes(importer)
|
||||
zip_path = tmp_path / "candidate.zip"
|
||||
tar_path = tmp_path / "images.tar"
|
||||
_private_file(zip_path, bytes(payload))
|
||||
(
|
||||
manifest,
|
||||
reports,
|
||||
zip_sha,
|
||||
manifest_sha,
|
||||
tar_sha,
|
||||
tar_bytes,
|
||||
) = importer._parse_artifact_zip(zip_path, tar_path, expected_size=len(payload))
|
||||
assert manifest == {}
|
||||
assert len(reports) == 35
|
||||
assert zip_sha == hashlib.sha256(payload).hexdigest()
|
||||
assert manifest_sha == hashlib.sha256(b"{}").hexdigest()
|
||||
assert tar_sha == hashlib.sha256(b"tar-data").hexdigest()
|
||||
assert tar_bytes == len(b"tar-data")
|
||||
assert tar_path.read_bytes() == b"tar-data"
|
||||
|
||||
tampered = bytearray(payload)
|
||||
name_size = len("candidate-manifest.json")
|
||||
descriptor_offset = 30 + name_size + 2
|
||||
tampered[descriptor_offset + 4] ^= 0x01
|
||||
_private_file(zip_path, bytes(tampered))
|
||||
with pytest.raises(importer.CandidateRejected, match="descriptor"):
|
||||
importer._parse_artifact_zip(zip_path, tar_path, expected_size=len(tampered))
|
||||
|
||||
|
||||
def test_zip_central_directory_is_bounded_before_zipfile_parse(modules, tmp_path, monkeypatch):
|
||||
importer = modules.importer
|
||||
payload = _streamed_zip_bytes(importer, extra_member=True)
|
||||
path = tmp_path / "too-many.zip"
|
||||
_private_file(path, bytes(payload))
|
||||
monkeypatch.setattr(
|
||||
importer.zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile parsed an oversized member inventory"),
|
||||
)
|
||||
with pytest.raises(importer.CandidateRejected, match="central directory"):
|
||||
importer._zip_central_directory_preflight(path, len(payload))
|
||||
|
||||
|
||||
def test_zip_oversized_central_directory_is_rejected_without_allocating_it(
|
||||
modules, tmp_path, monkeypatch
|
||||
):
|
||||
importer = modules.importer
|
||||
payload = struct.pack(
|
||||
"<IHHHHIIH",
|
||||
0x06054B50,
|
||||
0,
|
||||
0,
|
||||
37,
|
||||
37,
|
||||
65 * 1024,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
path = tmp_path / "oversized-directory.zip"
|
||||
_private_file(path, payload)
|
||||
monkeypatch.setattr(
|
||||
importer.zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile parsed an oversized central directory"),
|
||||
)
|
||||
with pytest.raises(importer.CandidateRejected, match="central directory"):
|
||||
importer._zip_central_directory_preflight(path, len(payload))
|
||||
|
||||
|
||||
def test_tar_member_count_is_stopped_during_streamed_header_walk(
|
||||
modules, tmp_path, monkeypatch
|
||||
):
|
||||
importer = modules.importer
|
||||
path = tmp_path / "many-members.tar"
|
||||
with tarfile.open(path, "w", format=tarfile.USTAR_FORMAT) as archive:
|
||||
for name in ("one/", "two/"):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.type = tarfile.DIRTYPE
|
||||
archive.addfile(info)
|
||||
monkeypatch.setattr(importer, "MAX_TAR_MEMBERS", 1)
|
||||
with pytest.raises(importer.CandidateRejected, match="member-count"):
|
||||
importer._preflight_uncompressed_tar(path)
|
||||
|
||||
|
||||
def test_tar_preflight_rejects_extension_headers_before_tarfile(modules, tmp_path):
|
||||
importer = modules.importer
|
||||
path = tmp_path / "pax.tar"
|
||||
with tarfile.open(path, "w", format=tarfile.PAX_FORMAT) as archive:
|
||||
info = tarfile.TarInfo("x" * 120)
|
||||
archive.addfile(info, io.BytesIO(b"x"))
|
||||
with pytest.raises(importer.CandidateRejected, match="unsupported member type"):
|
||||
importer._preflight_uncompressed_tar(path)
|
||||
|
||||
|
||||
def test_remote_head_without_completed_candidate_stays_awaiting(modules, tmp_path, monkeypatch):
|
||||
controller = modules.controller
|
||||
prepare = modules.prepare
|
||||
importer = modules.importer
|
||||
revision = "a" * 40
|
||||
tree = "b" * 40
|
||||
monkeypatch.setattr(
|
||||
prepare, "_fixed_target", lambda _repository, _revision: ({}, tmp_path)
|
||||
)
|
||||
responses = {
|
||||
"/repos/jens/itworx_mcp_hub/branches/main": {"commit": {"id": revision}},
|
||||
f"/repos/jens/itworx_mcp_hub/git/commits/{revision}": {
|
||||
"sha": revision,
|
||||
"commit": {"tree": {"sha": tree}},
|
||||
},
|
||||
importer.WORKFLOW_LIST_PATH: {
|
||||
"total_count": 1,
|
||||
"workflows": [
|
||||
{
|
||||
"id": importer.WORKFLOW_ID,
|
||||
"path": importer.WORKFLOW_PATH,
|
||||
"state": "active",
|
||||
}
|
||||
],
|
||||
},
|
||||
"/repos/jens/itworx_mcp_hub/actions/runs": {
|
||||
"total_count": 0,
|
||||
"workflow_runs": [],
|
||||
},
|
||||
}
|
||||
calls = []
|
||||
|
||||
def api(path, *, query):
|
||||
calls.append((path, query))
|
||||
return responses[path]
|
||||
|
||||
result = importer.prepare_candidate(
|
||||
controller.MCP_HUB_REPOSITORY,
|
||||
revision,
|
||||
api_json=api,
|
||||
download_artifact=lambda *_args: pytest.fail("an absent run cannot download artifacts"),
|
||||
)
|
||||
assert result == {"status": "awaiting_candidate", "revision": revision}
|
||||
assert len(calls) == 4
|
||||
|
||||
|
||||
def test_fixed_compose_environment_discards_ambient_docker_and_compose_context(
|
||||
modules, monkeypatch
|
||||
):
|
||||
controller = modules.controller
|
||||
importer = modules.importer
|
||||
monkeypatch.setattr(
|
||||
controller,
|
||||
"deployment_environment",
|
||||
lambda *_args: {
|
||||
"PATH": "/usr/bin",
|
||||
"DOCKER_HOST": "tcp://peer:2375",
|
||||
"DOCKER_CONTEXT": "attacker",
|
||||
"DOCKER_TLS_VERIFY": "1",
|
||||
"COMPOSE_FILE": "/tmp/untrusted.yml",
|
||||
"COMPOSE_PROFILES": "untrusted",
|
||||
},
|
||||
)
|
||||
env = importer._fixed_compose_environment({"compose_project": "fixed"}, "a" * 40, "jens/itworx_mcp_hub")
|
||||
assert env == {
|
||||
"PATH": "/usr/bin",
|
||||
"DOCKER_HOST": importer.DOCKER_HOST,
|
||||
"COMPOSE_PROJECT_NAME": "fixed",
|
||||
"COMPOSE_PROFILES": ",".join(controller.MCP_HUB_COMPOSE_PROFILES),
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATH = ROOT / "ops/gitea-deploy-broker/hub_candidate_transport.py"
|
||||
SPEC = importlib.util.spec_from_file_location("hub_candidate_transport_test", PATH)
|
||||
transport = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
sys.modules[SPEC.name] = transport
|
||||
SPEC.loader.exec_module(transport)
|
||||
|
||||
|
||||
def _request(url: str, *, mode="json", maximum=None, output_fd=None):
|
||||
return {
|
||||
"url": url,
|
||||
"headers": {
|
||||
"Accept": "application/json" if mode == "json" else "application/zip",
|
||||
"Authorization": "token " + "x" * 40,
|
||||
"User-Agent": transport.USER_AGENT,
|
||||
},
|
||||
"maxBytes": maximum if maximum is not None else (
|
||||
transport.MAX_METADATA_BYTES if mode == "json" else transport.MAX_ARTIFACT_BYTES
|
||||
),
|
||||
"outputFd": output_fd,
|
||||
}
|
||||
|
||||
|
||||
class Response:
|
||||
status = 200
|
||||
|
||||
def __init__(self, chunks):
|
||||
self.chunks = list(chunks)
|
||||
self.reads = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def read1(self, limit):
|
||||
self.reads.append(limit)
|
||||
if not self.chunks:
|
||||
return b""
|
||||
chunk = self.chunks.pop(0)
|
||||
return chunk[:limit]
|
||||
|
||||
def read(self, _limit):
|
||||
pytest.fail("transport must use deadline-aware read1")
|
||||
|
||||
|
||||
class Opener:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.requests = []
|
||||
self.timeouts = []
|
||||
|
||||
def open(self, request, timeout):
|
||||
self.requests.append(request)
|
||||
self.timeouts.append(timeout)
|
||||
return self.response
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/branches/main",
|
||||
"https://gitea.example.com:443/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/git/commits/" + "a" * 40,
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs?event=push&branch=main&head_sha=" + "a" * 40 + "&page=1&limit=100",
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs/123/jobs?page=2&limit=100",
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs/123/artifacts?name=hub-release-candidate-" + "a" * 40,
|
||||
])
|
||||
def test_fixed_json_routes_are_allowed(url):
|
||||
assert transport._url_is_allowed(url, "json")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://other.invalid/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
||||
"http://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
||||
"https://gitea.example.com:444/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
||||
"https://user@gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/workflows",
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs?event=push&event=push&branch=main&head_sha=" + "a" * 40 + "&page=1&limit=100",
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs?event=pull_request&branch=main&head_sha=" + "a" * 40 + "&page=1&limit=100",
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs/99999999999999999999/jobs?page=1&limit=100",
|
||||
"https://gitea.example.com/api/v1/repos/jens/other/actions/workflows",
|
||||
])
|
||||
def test_nonfixed_json_routes_are_rejected(url):
|
||||
assert not transport._url_is_allowed(url, "json")
|
||||
|
||||
|
||||
def test_zip_route_is_fixed_and_has_bounded_artifact_id():
|
||||
assert transport._url_is_allowed(
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/12/zip", "zip"
|
||||
)
|
||||
assert not transport._url_is_allowed(
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/9223372036854775808/zip", "zip"
|
||||
)
|
||||
assert not transport._url_is_allowed(
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/12/zip?other=x", "zip"
|
||||
)
|
||||
|
||||
|
||||
def test_ipc_requires_exact_fields_unique_json_and_16kib_bound():
|
||||
valid = json.dumps(_request("https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/branches/main")).encode()
|
||||
assert transport._read_request(io.BytesIO(valid))["maxBytes"] == transport.MAX_METADATA_BYTES
|
||||
for invalid in (
|
||||
b'{"url":"x","url":"y"}',
|
||||
valid[:-1] + b',"unexpected":true}',
|
||||
b'{"a":NaN}',
|
||||
b"x" * (transport.MAX_INPUT_BYTES + 1),
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
transport._read_request(io.BytesIO(invalid))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patch", [
|
||||
{"headers": {"Accept": "application/json", "Authorization": "token x\r\nInjected: yes", "User-Agent": transport.USER_AGENT}},
|
||||
{"headers": {"Accept": "application/json", "Authorization": "token " + "x" * 40, "User-Agent": transport.USER_AGENT, "Cookie": "x"}},
|
||||
{"maxBytes": True},
|
||||
{"outputFd": 3},
|
||||
])
|
||||
def test_json_request_rejects_header_injection_extra_header_and_bad_limits(patch):
|
||||
value = _request("https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/branches/main")
|
||||
value.update(patch)
|
||||
with pytest.raises(transport.TransportError):
|
||||
transport._validate_request(value, "json")
|
||||
|
||||
|
||||
def test_json_mode_emits_only_raw_bounded_response_bytes():
|
||||
body = b'{"workflows":[]}'
|
||||
opener = Opener(Response([body]))
|
||||
value = _request("https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/workflows")
|
||||
result = transport.execute(value, "json", opener_factory=lambda mode: opener)
|
||||
assert result == body
|
||||
assert opener.timeouts == [10]
|
||||
headers = {key.lower(): val for key, val in opener.requests[0].header_items()}
|
||||
assert headers == {
|
||||
"accept": "application/json",
|
||||
"authorization": "token " + "x" * 40,
|
||||
"user-agent": transport.USER_AGENT,
|
||||
}
|
||||
|
||||
|
||||
def test_zip_mode_streams_only_to_inherited_fd_and_reports_byte_count():
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
response = Response([b"zip-", b"bytes"])
|
||||
opener = Opener(response)
|
||||
value = _request(
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/12/zip",
|
||||
mode="zip", output_fd=write_fd,
|
||||
)
|
||||
assert transport.execute(value, "zip", opener_factory=lambda mode: opener) == 9
|
||||
os.close(write_fd)
|
||||
write_fd = -1
|
||||
assert os.read(read_fd, 32) == b"zip-bytes"
|
||||
assert opener.timeouts == [60]
|
||||
finally:
|
||||
if write_fd >= 0:
|
||||
os.close(write_fd)
|
||||
os.close(read_fd)
|
||||
|
||||
|
||||
def test_redirect_strips_authorization_and_rejects_other_hosts():
|
||||
request = transport.Request(
|
||||
"https://gitea.example.com/api/v1/artifact",
|
||||
headers={"Accept": "application/zip", "Authorization": "token secret", "User-Agent": transport.USER_AGENT},
|
||||
)
|
||||
handler = transport._SameHostArtifactRedirect()
|
||||
redirected = handler.redirect_request(
|
||||
request, None, 302, "", {}, "https://gitea.example.com/attachments/opaque?signature=opaque"
|
||||
)
|
||||
assert redirected is not None
|
||||
assert {key.lower(): value for key, value in redirected.header_items()} == {
|
||||
"accept": "application/zip", "user-agent": transport.USER_AGENT,
|
||||
}
|
||||
for url in (
|
||||
"http://gitea.example.com/attachments/file",
|
||||
"https://other.invalid/attachments/file",
|
||||
"https://gitea.example.com:444/attachments/file",
|
||||
"https://user@gitea.example.com/attachments/file",
|
||||
"https://gitea.example.com/attachments/file#fragment",
|
||||
):
|
||||
assert handler.redirect_request(request, None, 302, "", {}, url) is None
|
||||
|
||||
|
||||
def test_deadline_stops_a_continuous_read1_trickle():
|
||||
clock = [0.0]
|
||||
|
||||
class Trickle(Response):
|
||||
def read1(self, _limit):
|
||||
clock[0] += 2
|
||||
return b" "
|
||||
|
||||
value = _request("https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/branches/main")
|
||||
with pytest.raises(transport.TransportError, match="deadline"):
|
||||
transport.execute(value, "json", opener_factory=lambda _mode: Opener(Trickle([])),
|
||||
monotonic=lambda: clock[0])
|
||||
|
||||
|
||||
def test_cli_errors_are_generic_and_zip_marker_is_compact():
|
||||
errors = io.BytesIO()
|
||||
stdout = io.BytesIO()
|
||||
assert transport.main(["wrong"], stdin=io.BytesIO(b""), stdout=stdout, stderr=errors) == 1
|
||||
assert errors.getvalue() == transport._GENERIC_ERROR
|
||||
assert b"wrong" not in errors.getvalue()
|
||||
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
value = _request(
|
||||
"https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/artifacts/12/zip",
|
||||
mode="zip", output_fd=write_fd,
|
||||
)
|
||||
response = Response([b"zip"])
|
||||
payload = json.dumps(value, separators=(",", ":")).encode()
|
||||
stdout = io.BytesIO()
|
||||
errors = io.BytesIO()
|
||||
result = transport.main(["zip"], stdin=io.BytesIO(payload), stdout=stdout,
|
||||
stderr=errors, opener_factory=lambda _mode: Opener(response))
|
||||
assert result == 0
|
||||
assert stdout.getvalue() == b'{"bytes":3}\n'
|
||||
assert errors.getvalue() == b""
|
||||
os.close(write_fd)
|
||||
write_fd = -1
|
||||
assert os.read(read_fd, 32) == b"zip"
|
||||
finally:
|
||||
if write_fd >= 0:
|
||||
os.close(write_fd)
|
||||
os.close(read_fd)
|
||||
@@ -0,0 +1,775 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from email.message import Message
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from backend.app.db.models import AuditEvent, JobRun
|
||||
from backend.app.services import action_execution, deployment_broker
|
||||
from backend.app.services.action_execution import (
|
||||
approval_execution_supported,
|
||||
mcp_execution_supported,
|
||||
)
|
||||
from backend.app.services.action_registry import ACTION_RISK, ACTIONS, action_default_target
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CONTROLLER = ROOT / "ops" / "gitea-deploy-broker"
|
||||
ACTION = "hub.migration.prepare_permissions"
|
||||
TARGET = "tower/itworx-mcp-hub-permissions"
|
||||
STATE_PATH = "/mnt/cache/appdata/gitea-deploy-runners/state/itworx-mcp-hub.json"
|
||||
LEGACY_BACKUP_PATH = "/srv/example/appdata/itworx-mcp-hub/backups"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub():
|
||||
path = CONTROLLER / "hub_permission_preparation.py"
|
||||
assert path.is_file(), "the protected controller permission helper is missing"
|
||||
spec = importlib.util.spec_from_file_location("hub_permission_preparation_test", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(CONTROLLER))
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hub_permission_controller_test", CONTROLLER / "status_server.py"
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def permission_fixture(hub, tmp_path, monkeypatch):
|
||||
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
pytest.skip("permission ownership fixture requires Linux root")
|
||||
state_dir = tmp_path / "cache" / "appdata" / "gitea-deploy-runners" / "state"
|
||||
state = state_dir / "itworx-mcp-hub.json"
|
||||
lock = state_dir.parent / "deploy.lock"
|
||||
state_dir.mkdir(parents=True)
|
||||
|
||||
state_bytes = b'{"fixture":"hub state"}\n'.ljust(4096, b" ")
|
||||
state.write_bytes(state_bytes)
|
||||
lock.write_bytes(b"")
|
||||
os.chown(state, 0, 0)
|
||||
os.chmod(state, 0o644)
|
||||
os.chown(lock, 0, 0)
|
||||
os.chmod(lock, 0o644)
|
||||
|
||||
monkeypatch.setattr(hub, "STATE_FILE", str(state))
|
||||
monkeypatch.setattr(hub, "LOCK_FILE", str(lock))
|
||||
monkeypatch.setattr(hub, "LOCK_TIMEOUT_SECONDS", 0.25)
|
||||
monkeypatch.setattr(hub, "LOCK_POLL_INTERVAL_SECONDS", 0.01)
|
||||
digest = hashlib.sha256(state_bytes).hexdigest()
|
||||
return state, lock, state_bytes, digest
|
||||
|
||||
|
||||
def state_target(state: Path, *, mode: str = "0600", content: bytes | None = None) -> dict:
|
||||
payload = content or b'{"fixture":"hub state"}\n'.ljust(4096, b" ")
|
||||
return {
|
||||
"name": "state_file",
|
||||
"path": str(state),
|
||||
"uid": 0,
|
||||
"gid": 0,
|
||||
"mode": mode,
|
||||
"desired_mode": "0600",
|
||||
"would_change": mode != "0600",
|
||||
"acl_present": False,
|
||||
"size": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def test_permission_action_is_high_risk_and_has_one_fixed_target():
|
||||
assert ACTION in ACTIONS
|
||||
assert ACTION_RISK[ACTION] == "high"
|
||||
assert action_default_target(ACTION) == TARGET
|
||||
assert approval_execution_supported(ACTION)
|
||||
assert mcp_execution_supported(ACTION)
|
||||
|
||||
|
||||
def test_broker_posts_only_the_bounded_reason_to_fixed_signed_path(monkeypatch):
|
||||
calls = []
|
||||
broker = deployment_broker.DeploymentBrokerClient()
|
||||
monkeypatch.setattr(broker, "_request", lambda *args, **kwargs: calls.append((args, kwargs)) or {})
|
||||
|
||||
broker.hub_migration_prepare_permissions(reason="prepare fixed Hub permissions" * 40)
|
||||
|
||||
assert calls[0][0] == ("POST", "/v1/appops/hub-migration/prepare-permissions")
|
||||
assert calls[0][1]["payload"] == {"reason": ("prepare fixed Hub permissions" * 40)[:1000]}
|
||||
|
||||
|
||||
def test_governed_execution_blocks_wrong_target_before_job_or_broker(tmp_path, monkeypatch):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'blocked.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
monkeypatch.setattr(
|
||||
deployment_broker.DeploymentBrokerClient,
|
||||
"hub_migration_prepare_permissions",
|
||||
lambda *args, **kwargs: pytest.fail("wrong target must not call the controller"),
|
||||
)
|
||||
with Session(engine) as session:
|
||||
result = action_execution.execute_governed_action(
|
||||
session,
|
||||
action=ACTION,
|
||||
target="tower/other",
|
||||
source="chatgpt-mcp",
|
||||
risk="high",
|
||||
reason="prepare fixed Hub permissions",
|
||||
original_policy_decision="allowed",
|
||||
)
|
||||
assert result["policyDecision"] == "blocked"
|
||||
assert session.exec(select(JobRun)).all() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status,success",
|
||||
[("prepared", True), ("already_compliant", True), ("partial", False), ("blocked", False)],
|
||||
)
|
||||
def test_governed_execution_audits_single_state_file_outcome(tmp_path, monkeypatch, status, success):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / f'{status}.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
result_payload = {
|
||||
"status": status,
|
||||
"revision": "a" * 40,
|
||||
"changedTargets": [STATE_PATH] if status in {"prepared", "partial"} else [],
|
||||
"changedTargetDetails": [],
|
||||
"uncertainTargets": [STATE_PATH] if status == "partial" else [],
|
||||
}
|
||||
if status == "partial":
|
||||
result_payload["changedTargetDetails"] = [
|
||||
{
|
||||
"name": "state_file",
|
||||
"path": STATE_PATH,
|
||||
"observedMode": "0600",
|
||||
"sameIdentityAtPath": True,
|
||||
}
|
||||
]
|
||||
if status in {"prepared", "already_compliant"}:
|
||||
result_payload["verifiedTargets"] = [
|
||||
{
|
||||
"name": "state_file",
|
||||
"path": STATE_PATH,
|
||||
"uid": 0,
|
||||
"gid": 0,
|
||||
"mode": "0600",
|
||||
"aclPresent": False,
|
||||
}
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
deployment_broker.DeploymentBrokerClient,
|
||||
"hub_migration_prepare_permissions",
|
||||
lambda self, **kwargs: result_payload,
|
||||
)
|
||||
with Session(engine) as session:
|
||||
result = action_execution.execute_governed_action(
|
||||
session,
|
||||
action=ACTION,
|
||||
target=TARGET,
|
||||
source="chatgpt-mcp",
|
||||
risk="high",
|
||||
reason="prepare fixed Hub permissions",
|
||||
original_policy_decision="allowed",
|
||||
)
|
||||
job = session.get(JobRun, result["jobId"])
|
||||
audit = session.get(AuditEvent, result["auditId"])
|
||||
assert result["success"] is success
|
||||
assert job.status == ("succeeded" if success else "failed")
|
||||
assert audit.result == ("success" if success else "failed")
|
||||
assert audit.target == TARGET
|
||||
assert audit.after_state["status"] == status
|
||||
assert audit.after_state["changedTargets"] == result_payload["changedTargets"]
|
||||
assert audit.after_state["changedTargetDetails"] == result_payload["changedTargetDetails"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy_status", ["prepared", "already_compliant", "partial"])
|
||||
def test_governed_execution_rejects_legacy_two_target_reports_as_unknown(
|
||||
tmp_path, monkeypatch, legacy_status
|
||||
):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / f'legacy-{legacy_status}.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
result_payload = {
|
||||
"status": legacy_status,
|
||||
"revision": "a" * 40,
|
||||
"changedTargets": [LEGACY_BACKUP_PATH] if legacy_status != "already_compliant" else [],
|
||||
"changedTargetDetails": [],
|
||||
"uncertainTargets": [],
|
||||
"verifiedTargets": [
|
||||
{
|
||||
"name": "backup_directory",
|
||||
"path": LEGACY_BACKUP_PATH,
|
||||
"uid": 99,
|
||||
"gid": 100,
|
||||
"mode": "0700",
|
||||
"aclPresent": False,
|
||||
},
|
||||
{
|
||||
"name": "state_file",
|
||||
"path": STATE_PATH,
|
||||
"uid": 0,
|
||||
"gid": 0,
|
||||
"mode": "0600",
|
||||
"aclPresent": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
deployment_broker.DeploymentBrokerClient,
|
||||
"hub_migration_prepare_permissions",
|
||||
lambda self, **kwargs: result_payload,
|
||||
)
|
||||
with Session(engine) as session:
|
||||
result = action_execution.execute_governed_action(
|
||||
session,
|
||||
action=ACTION,
|
||||
target=TARGET,
|
||||
source="chatgpt-mcp",
|
||||
risk="high",
|
||||
reason="prepare fixed Hub permissions",
|
||||
original_policy_decision="allowed",
|
||||
)
|
||||
audit = session.get(AuditEvent, result["auditId"])
|
||||
assert result["success"] is False
|
||||
assert result["status"] == "outcome_unknown"
|
||||
assert result["result"] == "outcome_unknown"
|
||||
assert result["uncertainTargets"] == [STATE_PATH]
|
||||
assert audit.result == "outcome_unknown"
|
||||
assert audit.after_state["uncertainTargets"] == [STATE_PATH]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure", ["broker_exception", "unreachable", "malformed_success", "malformed_partial"]
|
||||
)
|
||||
def test_governed_execution_marks_unresolved_controller_results_unknown(
|
||||
tmp_path, monkeypatch, failure
|
||||
):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / f'{failure}.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
calls = []
|
||||
|
||||
def broker_call(self, **_kwargs):
|
||||
calls.append("called")
|
||||
if failure == "broker_exception":
|
||||
raise RuntimeError("request ended without a controller result")
|
||||
if failure == "unreachable":
|
||||
return {"status": "unreachable"}
|
||||
if failure == "malformed_partial":
|
||||
return {
|
||||
"status": "partial",
|
||||
"revision": "a" * 40,
|
||||
"changedTargets": [LEGACY_BACKUP_PATH],
|
||||
"changedTargetDetails": [],
|
||||
"uncertainTargets": [],
|
||||
}
|
||||
return {
|
||||
"status": "prepared",
|
||||
"revision": "a" * 40,
|
||||
"changedTargets": [STATE_PATH],
|
||||
"changedTargetDetails": [],
|
||||
"uncertainTargets": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
deployment_broker.DeploymentBrokerClient,
|
||||
"hub_migration_prepare_permissions",
|
||||
broker_call,
|
||||
)
|
||||
with Session(engine) as session:
|
||||
result = action_execution.execute_governed_action(
|
||||
session,
|
||||
action=ACTION,
|
||||
target=TARGET,
|
||||
source="chatgpt-mcp",
|
||||
risk="high",
|
||||
reason="prepare fixed Hub permissions",
|
||||
original_policy_decision="allowed",
|
||||
)
|
||||
job = session.get(JobRun, result["jobId"])
|
||||
audit = session.get(AuditEvent, result["auditId"])
|
||||
|
||||
assert calls == ["called"]
|
||||
assert result["success"] is False
|
||||
assert result["status"] == "outcome_unknown"
|
||||
assert result["result"] == "outcome_unknown"
|
||||
assert result["uncertainTargets"] == [STATE_PATH]
|
||||
assert job.status == "failed"
|
||||
assert "outcome is unknown" in job.result_summary
|
||||
assert audit.result == "outcome_unknown"
|
||||
assert audit.after_state["status"] == "outcome_unknown"
|
||||
assert audit.after_state["changedTargets"] == []
|
||||
|
||||
|
||||
def test_preparation_changes_only_fixed_state_file_and_is_idempotent(hub, tmp_path, monkeypatch):
|
||||
state, lock, state_bytes, digest = permission_fixture(hub, tmp_path, monkeypatch)
|
||||
state_identity = (state.stat().st_dev, state.stat().st_ino)
|
||||
original_open = hub._open_pinned_object
|
||||
opened = []
|
||||
changed_objects = []
|
||||
|
||||
# Keep a legacy backup path with an intentionally irrelevant mode. The
|
||||
# helper must neither open nor chmod it.
|
||||
legacy_backup = tmp_path / "user" / "appdata" / "itworx-mcp-hub" / "backups"
|
||||
legacy_backup.mkdir(parents=True)
|
||||
os.chmod(legacy_backup, 0o750)
|
||||
monkeypatch.setattr(hub, "BACKUP_DIRECTORY", str(legacy_backup), raising=False)
|
||||
|
||||
def record_open(name, path):
|
||||
opened.append((name, path))
|
||||
return original_open(name, path)
|
||||
|
||||
real_set_mode = hub._set_mode
|
||||
|
||||
def record_change(fd, mode):
|
||||
st = os.fstat(fd)
|
||||
changed_objects.append((st.st_dev, st.st_ino))
|
||||
real_set_mode(fd, mode)
|
||||
|
||||
monkeypatch.setattr(hub, "_open_pinned_object", record_open)
|
||||
monkeypatch.setattr(hub, "_set_mode", record_change)
|
||||
|
||||
code, report = hub.prepare_fixed_permissions()
|
||||
|
||||
assert code == 0
|
||||
assert report["status"] == "applied"
|
||||
assert report["changed_targets"] == [str(state)]
|
||||
assert [target["path"] for target in report["targets"]] == [str(state)]
|
||||
assert stat.S_IMODE(state.stat().st_mode) == 0o600
|
||||
assert stat.S_IMODE(lock.stat().st_mode) == 0o644
|
||||
assert stat.S_IMODE(legacy_backup.stat().st_mode) == 0o750
|
||||
assert state.read_bytes() == state_bytes
|
||||
assert hashlib.sha256(state.read_bytes()).hexdigest() == digest
|
||||
assert (state.stat().st_dev, state.stat().st_ino) == state_identity
|
||||
assert (state.stat().st_uid, state.stat().st_gid) == (0, 0)
|
||||
assert report["lock"]["acquired"] is True
|
||||
assert opened == [("deploy_lock", str(lock)), ("state_file", str(state))]
|
||||
assert changed_objects == [state_identity]
|
||||
|
||||
code, report = hub.prepare_fixed_permissions()
|
||||
assert code == 0
|
||||
assert report["status"] == "already_compliant"
|
||||
assert report["changed_targets"] == []
|
||||
assert [target["path"] for target in report["targets"]] == [str(state)]
|
||||
assert stat.S_IMODE(legacy_backup.stat().st_mode) == 0o750
|
||||
|
||||
|
||||
def test_dry_run_does_not_change_state(hub, tmp_path, monkeypatch):
|
||||
state, lock, before, _digest = permission_fixture(hub, tmp_path, monkeypatch)
|
||||
|
||||
code, report = hub.execute()
|
||||
|
||||
assert code == 0
|
||||
assert report["status"] == "ready"
|
||||
assert [item["path"] for item in report["targets"]] == [str(state)]
|
||||
assert stat.S_IMODE(state.stat().st_mode) == 0o644
|
||||
assert stat.S_IMODE(lock.stat().st_mode) == 0o644
|
||||
assert state.read_bytes() == before
|
||||
|
||||
|
||||
def test_preparation_rechecks_fresh_state_digest_before_changing_mode(hub, tmp_path, monkeypatch):
|
||||
state, _lock, _before, _digest = permission_fixture(hub, tmp_path, monkeypatch)
|
||||
real_apply = hub._apply_changes
|
||||
|
||||
def change_before_apply(lock, pinned_state, mode, expected_digest):
|
||||
state.write_bytes(b"changed after inspection".ljust(4096, b" "))
|
||||
return real_apply(lock, pinned_state, mode, expected_digest)
|
||||
|
||||
monkeypatch.setattr(hub, "_apply_changes", change_before_apply)
|
||||
|
||||
code, report = hub.prepare_fixed_permissions()
|
||||
|
||||
assert code == 2
|
||||
assert report["status"] == "failed"
|
||||
assert "digest mismatch" in report["error"]
|
||||
assert stat.S_IMODE(state.stat().st_mode) == 0o644
|
||||
|
||||
|
||||
def test_symlinks_wrong_owner_mode_hardlinks_and_acl_fail_closed(hub, tmp_path, monkeypatch):
|
||||
state, _lock, _before, _digest = permission_fixture(hub, tmp_path, monkeypatch)
|
||||
|
||||
real_state = state.with_name("state-real.json")
|
||||
state.rename(real_state)
|
||||
state.symlink_to(real_state)
|
||||
code, report = hub.execute()
|
||||
assert code == 2 and report["status"] == "blocked"
|
||||
state.unlink()
|
||||
real_state.rename(state)
|
||||
|
||||
os.chown(state, 99, 100)
|
||||
code, report = hub.execute()
|
||||
assert code == 2 and "owner mismatch" in report["error"]
|
||||
os.chown(state, 0, 0)
|
||||
|
||||
os.link(state, state.with_name("unexpected-hardlink"))
|
||||
code, report = hub.execute()
|
||||
assert code == 2 and report["status"] == "blocked"
|
||||
assert "link-count mismatch" in report["error"]
|
||||
state.with_name("unexpected-hardlink").unlink()
|
||||
|
||||
os.chmod(state, 0o640)
|
||||
code, report = hub.execute()
|
||||
assert code == 2 and "mode mismatch" in report["error"]
|
||||
os.chmod(state, 0o644)
|
||||
|
||||
state_identity = (state.stat().st_dev, state.stat().st_ino)
|
||||
actual_has_acl = hub._has_acl
|
||||
monkeypatch.setattr(
|
||||
hub,
|
||||
"_has_acl",
|
||||
lambda fd: (os.fstat(fd).st_dev, os.fstat(fd).st_ino) == state_identity
|
||||
or actual_has_acl(fd),
|
||||
)
|
||||
code, report = hub.execute()
|
||||
assert code == 2 and "POSIX ACL is present" in report["error"]
|
||||
assert stat.S_IMODE(state.stat().st_mode) == 0o644
|
||||
|
||||
|
||||
def test_unsafe_deploy_lock_metadata_blocks_without_state_mutation(hub, tmp_path, monkeypatch):
|
||||
state, lock, _before, _digest = permission_fixture(hub, tmp_path, monkeypatch)
|
||||
os.chmod(lock, 0o600)
|
||||
|
||||
code, report = hub.prepare_fixed_permissions()
|
||||
|
||||
assert code == 2 and report["status"] == "blocked"
|
||||
assert "mode mismatch" in report["error"]
|
||||
assert stat.S_IMODE(state.stat().st_mode) == 0o644
|
||||
assert stat.S_IMODE(lock.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_replaced_state_path_reports_uncertain_outcome(hub, tmp_path, monkeypatch):
|
||||
state, _lock, _before, _digest = permission_fixture(hub, tmp_path, monkeypatch)
|
||||
moved_state = state.with_name("state-pinned.json")
|
||||
real_set_mode = hub._set_mode
|
||||
replaced = False
|
||||
|
||||
def replace_after_mode_change(fd, mode):
|
||||
nonlocal replaced
|
||||
real_set_mode(fd, mode)
|
||||
if not replaced:
|
||||
replaced = True
|
||||
state.rename(moved_state)
|
||||
state.write_bytes(b"replacement")
|
||||
os.chown(state, 0, 0)
|
||||
os.chmod(state, 0o644)
|
||||
|
||||
monkeypatch.setattr(hub, "_set_mode", replace_after_mode_change)
|
||||
|
||||
code, report = hub.prepare_fixed_permissions()
|
||||
|
||||
assert code == 2
|
||||
assert report["status"] == "partial"
|
||||
assert report["changed_targets"] == [
|
||||
{
|
||||
"name": "state_file",
|
||||
"path": str(state),
|
||||
"observed_mode": "0600",
|
||||
"same_identity_at_path": False,
|
||||
}
|
||||
]
|
||||
assert report["uncertain_targets"] == [str(state)]
|
||||
assert stat.S_IMODE(moved_state.stat().st_mode) == 0o600
|
||||
assert stat.S_IMODE(state.stat().st_mode) == 0o644
|
||||
|
||||
|
||||
def test_fsync_failure_reports_changed_and_uncertain_state(hub, tmp_path, monkeypatch):
|
||||
state, _lock, _before, _digest = permission_fixture(hub, tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(hub, "_fsync", lambda _fd: (_ for _ in ()).throw(OSError("fsync failed")))
|
||||
|
||||
code, report = hub.prepare_fixed_permissions()
|
||||
|
||||
assert code == 2
|
||||
assert report["status"] == "partial"
|
||||
assert report["changed_targets"] == [
|
||||
{
|
||||
"name": "state_file",
|
||||
"path": str(state),
|
||||
"observed_mode": "0600",
|
||||
"same_identity_at_path": True,
|
||||
}
|
||||
]
|
||||
assert report["uncertain_targets"] == [str(state)]
|
||||
|
||||
|
||||
def test_bounded_deploy_lock_contention_fails_without_mutation(hub, tmp_path, monkeypatch):
|
||||
state, lock, _before, _digest = permission_fixture(hub, tmp_path, monkeypatch)
|
||||
fd = os.open(lock, os.O_RDONLY)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
started = time.monotonic()
|
||||
code, report = hub.prepare_fixed_permissions()
|
||||
elapsed = time.monotonic() - started
|
||||
finally:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
os.close(fd)
|
||||
|
||||
assert code == 2
|
||||
assert report["status"] == "blocked"
|
||||
assert "bounded wait" in report["error"]
|
||||
assert elapsed < 1.0
|
||||
assert stat.S_IMODE(state.stat().st_mode) == 0o644
|
||||
|
||||
|
||||
def controller_handler(controller, monkeypatch, payload=None):
|
||||
instance = object.__new__(controller.Handler)
|
||||
body = json.dumps(payload or {"reason": "Prepare fixed Hub permissions"}).encode()
|
||||
instance.headers = Message()
|
||||
instance.headers["Content-Length"] = str(len(body))
|
||||
instance.headers["Content-Type"] = "application/json"
|
||||
instance.rfile = io.BytesIO(body)
|
||||
instance.path = controller.HUB_PERMISSION_PREPARATION_PATH
|
||||
responses = []
|
||||
signatures = []
|
||||
monkeypatch.setattr(instance, "_trusted_client", lambda: True)
|
||||
monkeypatch.setattr(instance, "_send_json", lambda code, data: responses.append((code, json.loads(data))))
|
||||
monkeypatch.setattr(
|
||||
controller,
|
||||
"_self_upgrade_signature_is_valid",
|
||||
lambda *args, **kwargs: signatures.append(kwargs) or True,
|
||||
)
|
||||
monkeypatch.setattr(controller, "_self_upgrade_runtime", lambda: {"revision": "a" * 40})
|
||||
monkeypatch.setattr(
|
||||
controller,
|
||||
"_require_controller_release",
|
||||
lambda: {
|
||||
"sourceRevision": "a" * 40,
|
||||
"sourceRepository": controller.SELF_UPGRADE_REPOSITORY,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(controller, "_self_upgrade_preflight", lambda revision: {"status": "already_deployed"})
|
||||
monkeypatch.setattr(controller, "_load_projects", lambda: {controller.SELF_UPGRADE_REPOSITORY: {"branch": "main"}})
|
||||
monkeypatch.setattr(controller, "_repository_path_for_delivery", lambda *args: Path("fixed-repo.git"))
|
||||
monkeypatch.setattr(controller, "_is_current_branch_head", lambda *args: True)
|
||||
return instance, responses, signatures
|
||||
|
||||
|
||||
def controller_success_report(state_path: str, *, already: bool = False) -> dict:
|
||||
return {
|
||||
"status": "already_compliant" if already else "applied",
|
||||
"changed_targets": [] if already else [state_path],
|
||||
"uncertain_targets": [],
|
||||
"targets": [state_target(Path(state_path))],
|
||||
}
|
||||
|
||||
|
||||
def test_controller_route_uses_only_signed_protected_validated_current_main(
|
||||
controller, monkeypatch
|
||||
):
|
||||
instance, responses, signatures = controller_handler(controller, monkeypatch)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
controller.hub_permission_preparation,
|
||||
"prepare_fixed_permissions",
|
||||
lambda: calls.append("prepare")
|
||||
or (0, controller_success_report(STATE_PATH)),
|
||||
)
|
||||
|
||||
instance.do_POST()
|
||||
|
||||
assert signatures == [{"method": "POST", "path": controller.HUB_PERMISSION_PREPARATION_PATH}]
|
||||
assert calls == ["prepare"]
|
||||
assert responses == [
|
||||
(
|
||||
200,
|
||||
{
|
||||
"status": "prepared",
|
||||
"revision": "a" * 40,
|
||||
"changedTargets": [STATE_PATH],
|
||||
"changedTargetDetails": [],
|
||||
"uncertainTargets": [],
|
||||
"verifiedTargets": [
|
||||
{
|
||||
"name": "state_file",
|
||||
"path": STATE_PATH,
|
||||
"uid": 0,
|
||||
"gid": 0,
|
||||
"mode": "0600",
|
||||
"aclPresent": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_controller_route_rejects_legacy_two_target_success_report(controller, monkeypatch):
|
||||
instance, responses, _signatures = controller_handler(controller, monkeypatch)
|
||||
legacy = controller_success_report(STATE_PATH)
|
||||
legacy["changed_targets"] = [LEGACY_BACKUP_PATH, STATE_PATH]
|
||||
legacy["targets"].insert(
|
||||
0,
|
||||
{
|
||||
"name": "backup_directory",
|
||||
"path": LEGACY_BACKUP_PATH,
|
||||
"uid": 99,
|
||||
"gid": 100,
|
||||
"mode": "0700",
|
||||
"desired_mode": "0700",
|
||||
"would_change": False,
|
||||
"acl_present": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
controller.hub_permission_preparation,
|
||||
"prepare_fixed_permissions",
|
||||
lambda: (0, legacy),
|
||||
)
|
||||
|
||||
instance.do_POST()
|
||||
|
||||
assert responses == [
|
||||
(
|
||||
200,
|
||||
{
|
||||
"status": "outcome_unknown",
|
||||
"revision": "a" * 40,
|
||||
"changedTargets": [],
|
||||
"changedTargetDetails": [],
|
||||
"uncertainTargets": [STATE_PATH],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_controller_route_rejects_success_without_full_helper_projection(controller, monkeypatch):
|
||||
instance, responses, _signatures = controller_handler(controller, monkeypatch)
|
||||
malformed = controller_success_report(STATE_PATH, already=True)
|
||||
malformed.pop("changed_targets")
|
||||
monkeypatch.setattr(
|
||||
controller.hub_permission_preparation,
|
||||
"prepare_fixed_permissions",
|
||||
lambda: (0, malformed),
|
||||
)
|
||||
|
||||
instance.do_POST()
|
||||
|
||||
assert responses == [
|
||||
(
|
||||
200,
|
||||
{
|
||||
"status": "outcome_unknown",
|
||||
"revision": "a" * 40,
|
||||
"changedTargets": [],
|
||||
"changedTargetDetails": [],
|
||||
"uncertainTargets": [STATE_PATH],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"reason": "Prepare fixed Hub permissions", "path": "/"},
|
||||
{"reason": "Prepare fixed Hub permissions", "mode": "0777"},
|
||||
{"reason": "Prepare fixed Hub permissions", "command": "chmod -R 700 /"},
|
||||
],
|
||||
)
|
||||
def test_controller_route_rejects_caller_selected_effects(controller, monkeypatch, payload):
|
||||
instance, responses, _signatures = controller_handler(controller, monkeypatch, payload)
|
||||
monkeypatch.setattr(
|
||||
controller.hub_permission_preparation,
|
||||
"prepare_fixed_permissions",
|
||||
lambda: pytest.fail("invalid request must not inspect or mutate permissions"),
|
||||
)
|
||||
|
||||
instance.do_POST()
|
||||
|
||||
assert responses == [(400, {"status": "invalid_request"})]
|
||||
|
||||
|
||||
def test_controller_route_does_not_mutate_before_full_push_validation(controller, monkeypatch):
|
||||
instance, responses, _signatures = controller_handler(controller, monkeypatch)
|
||||
monkeypatch.setattr(controller, "_self_upgrade_preflight", lambda _revision: {"status": "preflight_rejected"})
|
||||
monkeypatch.setattr(
|
||||
controller.hub_permission_preparation,
|
||||
"prepare_fixed_permissions",
|
||||
lambda: pytest.fail("pending validation must block permission changes"),
|
||||
)
|
||||
|
||||
instance.do_POST()
|
||||
|
||||
assert responses == [(409, {"status": "preflight_rejected", "revision": "a" * 40})]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["helper_exception", "malformed_report", "malformed_partial"])
|
||||
def test_controller_route_reports_unknown_state_after_unresolved_helper_call(
|
||||
controller, monkeypatch, failure
|
||||
):
|
||||
instance, responses, _signatures = controller_handler(controller, monkeypatch)
|
||||
if failure == "helper_exception":
|
||||
def helper_failure():
|
||||
raise RuntimeError("helper returned no result")
|
||||
|
||||
monkeypatch.setattr(
|
||||
controller.hub_permission_preparation, "prepare_fixed_permissions", helper_failure
|
||||
)
|
||||
elif failure == "malformed_report":
|
||||
monkeypatch.setattr(
|
||||
controller.hub_permission_preparation,
|
||||
"prepare_fixed_permissions",
|
||||
lambda: (0, {"status": "applied", "changed_targets": "unexpected"}),
|
||||
)
|
||||
else:
|
||||
monkeypatch.setattr(
|
||||
controller.hub_permission_preparation,
|
||||
"prepare_fixed_permissions",
|
||||
lambda: (
|
||||
2,
|
||||
{
|
||||
"status": "partial",
|
||||
"changed_targets": [LEGACY_BACKUP_PATH],
|
||||
"uncertain_targets": [],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
instance.do_POST()
|
||||
|
||||
assert responses == [
|
||||
(
|
||||
200,
|
||||
{
|
||||
"status": "outcome_unknown",
|
||||
"revision": "a" * 40,
|
||||
"changedTargets": [],
|
||||
"changedTargetDetails": [],
|
||||
"uncertainTargets": [STATE_PATH],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_controller_artifact_manifests_include_new_fixed_helper(controller):
|
||||
renderer_spec = importlib.util.spec_from_file_location(
|
||||
"hub_permission_artifact_renderer", ROOT / "scripts/prepare_controller_artifact.py"
|
||||
)
|
||||
assert renderer_spec is not None and renderer_spec.loader is not None
|
||||
renderer = importlib.util.module_from_spec(renderer_spec)
|
||||
renderer_spec.loader.exec_module(renderer)
|
||||
volume_spec = importlib.util.spec_from_file_location(
|
||||
"hub_permission_volume_preparer", ROOT / "scripts/prepare_controller_hub_release_volume.py"
|
||||
)
|
||||
assert volume_spec is not None and volume_spec.loader is not None
|
||||
volume = importlib.util.module_from_spec(volume_spec)
|
||||
volume_spec.loader.exec_module(volume)
|
||||
installer = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "hub_permission_preparation.py" in controller._CONTROLLER_ARTIFACT_FILES
|
||||
assert "hub_permission_preparation.py" in renderer._ARTIFACT_FILES
|
||||
assert tuple(volume.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES)
|
||||
assert "ops/gitea-deploy-broker/hub_permission_preparation.py" in installer
|
||||
assert "hub_permission_preparation.py" in installer
|
||||
@@ -272,6 +272,122 @@ def test_plan_freezes_protected_metadata_without_candidate_or_approval(harness):
|
||||
assert h.state.mutations == []
|
||||
|
||||
|
||||
def test_plan_candidate_freezes_the_selected_workflow_creation_timestamp(
|
||||
harness, monkeypatch
|
||||
):
|
||||
h = harness
|
||||
revision = h.revision
|
||||
built_at = "2026-10-03T12:34:56.789Z"
|
||||
observed = []
|
||||
metadata = {
|
||||
"id": revision[:12],
|
||||
"repository": h.controller.MCP_HUB_REPOSITORY,
|
||||
"revision": revision,
|
||||
"tree": "d" * 40,
|
||||
"treeDigest": hashlib.sha256(("git-tree:" + "d" * 40).encode()).hexdigest(),
|
||||
"builtAt": built_at,
|
||||
}
|
||||
monkeypatch.setattr(h.controller, "output", lambda _args: revision)
|
||||
monkeypatch.setattr(h.controller, "validate_commit", lambda _repo, _branch, commit: commit)
|
||||
|
||||
def deployment_metadata(_repo, supplied_revision, repository, *, built_at=None):
|
||||
observed.append(built_at)
|
||||
assert supplied_revision == revision
|
||||
assert repository == h.controller.MCP_HUB_REPOSITORY
|
||||
return dict(metadata)
|
||||
|
||||
monkeypatch.setattr(h.controller, "deployment_metadata", deployment_metadata)
|
||||
monkeypatch.setattr(h.controller, "mcp_hub_stage_release_source", lambda *_args: h.state.source)
|
||||
monkeypatch.setattr(h.controller, "exact_source_matches_revision", lambda *_args: True)
|
||||
compose_calls = []
|
||||
|
||||
def fixed_compose_modeler(project, revision, repository):
|
||||
compose_calls.append((project, revision, repository))
|
||||
return {
|
||||
"services": {
|
||||
service: {"image": f"fixture/{service}:candidate"}
|
||||
for service in h.controller.MCP_HUB_SERVICES
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
h.controller,
|
||||
"mcp_hub_compose_model",
|
||||
lambda *_args: pytest.fail("plan used the ambient Compose renderer"),
|
||||
)
|
||||
|
||||
staging = (
|
||||
h.controller.STATE_ROOT
|
||||
/ "mcp-hub-release-imports"
|
||||
/ revision
|
||||
/ ("1" * 32)
|
||||
/ revision
|
||||
)
|
||||
h.utility.plan_candidate(
|
||||
h.controller.MCP_HUB_REPOSITORY,
|
||||
revision,
|
||||
built_at=built_at,
|
||||
staging_directory=staging,
|
||||
compose_modeler=fixed_compose_modeler,
|
||||
)
|
||||
|
||||
plan = json.loads(
|
||||
(staging / "build.json").read_text()
|
||||
)
|
||||
assert observed == [built_at]
|
||||
assert plan["build"]["builtAt"] == built_at
|
||||
assert len(compose_calls) == 1
|
||||
|
||||
|
||||
def test_candidate_preparation_rejects_staging_path_outside_private_import_anchor(
|
||||
harness,
|
||||
):
|
||||
h = harness
|
||||
revision = h.revision
|
||||
staging = h.controller.STATE_ROOT / "outside-imports" / revision
|
||||
with pytest.raises(h.controller.DeployError, match="private anchor|staging path"):
|
||||
h.utility.plan_candidate(
|
||||
h.controller.MCP_HUB_REPOSITORY,
|
||||
revision,
|
||||
staging_directory=staging,
|
||||
)
|
||||
assert not staging.exists()
|
||||
|
||||
|
||||
def test_candidate_preparation_rejects_symlinked_staging_destination(harness):
|
||||
h = harness
|
||||
revision = h.revision
|
||||
anchor = h.controller.STATE_ROOT / "mcp-hub-release-imports" / revision
|
||||
token = anchor / ("2" * 32)
|
||||
token.mkdir(parents=True, mode=0o700)
|
||||
target = h.controller.STATE_ROOT / "untrusted-target"
|
||||
target.mkdir(mode=0o700)
|
||||
staging = token / revision
|
||||
staging.symlink_to(target, target_is_directory=True)
|
||||
with pytest.raises(h.controller.DeployError):
|
||||
h.utility.plan_candidate(
|
||||
h.controller.MCP_HUB_REPOSITORY,
|
||||
revision,
|
||||
staging_directory=staging,
|
||||
)
|
||||
|
||||
|
||||
def test_candidate_preparation_rejects_nonprivate_staging_parent(harness):
|
||||
h = harness
|
||||
revision = h.revision
|
||||
anchor = h.controller.STATE_ROOT / "mcp-hub-release-imports" / revision
|
||||
token = anchor / ("3" * 32)
|
||||
token.mkdir(parents=True, mode=0o700)
|
||||
token.chmod(0o755)
|
||||
staging = token / revision
|
||||
with pytest.raises(h.controller.DeployError, match="private|safe|canonical"):
|
||||
h.utility.plan_candidate(
|
||||
h.controller.MCP_HUB_REPOSITORY,
|
||||
revision,
|
||||
staging_directory=staging,
|
||||
)
|
||||
|
||||
|
||||
def test_wrong_repository_and_nonroot_fail_before_controller_access(
|
||||
modules, monkeypatch
|
||||
):
|
||||
@@ -354,6 +470,37 @@ def test_capture_emits_candidate_and_pending_only_envelope_without_apply_calls(
|
||||
}
|
||||
|
||||
|
||||
def test_capture_can_use_the_importers_fixed_image_inspector(harness, monkeypatch):
|
||||
h = harness
|
||||
inspected = []
|
||||
|
||||
def fixed_inspector(reference):
|
||||
inspected.append(reference)
|
||||
return h.state.images.get(reference)
|
||||
|
||||
monkeypatch.setattr(
|
||||
h.controller,
|
||||
"inspect_image",
|
||||
lambda _reference: pytest.fail("capture used the ambient Docker context"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
h.controller,
|
||||
"mcp_hub_compose_model",
|
||||
lambda *_args: pytest.fail("capture used the ambient Compose renderer"),
|
||||
)
|
||||
result = h.utility.capture_candidate(
|
||||
h.controller.MCP_HUB_REPOSITORY,
|
||||
h.revision,
|
||||
image_inspector=fixed_inspector,
|
||||
compose_modeler=lambda *_args: h.model,
|
||||
)
|
||||
assert result["status"] == "pending_review"
|
||||
assert len(inspected) == 17
|
||||
assert set(inspected) == {
|
||||
row["image"] for row in h.model["services"].values()
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["label", "root"])
|
||||
def test_capture_rejects_contextforge_provenance_drift_without_candidate(
|
||||
harness, mutation
|
||||
|
||||
@@ -48,6 +48,138 @@ def _legacy_target(controller):
|
||||
"source_dir": (controller.EXACT_SOURCE_ROOT / "itworx-mcp-hub").as_posix(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="backup ownership contract requires root-owned POSIX fixtures",
|
||||
)
|
||||
def test_hub_backup_child_is_created_with_controller_owner_and_private_mode(
|
||||
controller, tmp_path, monkeypatch
|
||||
):
|
||||
state_root = tmp_path / "controller-state"
|
||||
backup_root = state_root / "mcp-hub-backups"
|
||||
state_root.mkdir(mode=0o700)
|
||||
backup_root.mkdir(mode=0o700)
|
||||
monkeypatch.setattr(controller, "STATE_ROOT", state_root)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None)
|
||||
|
||||
attempt = backup_root / "attempt"
|
||||
controller._mcp_hub_private_directory(attempt, create=True, root=backup_root)
|
||||
|
||||
metadata = attempt.stat()
|
||||
assert (metadata.st_uid, metadata.st_gid) == (0, 0)
|
||||
assert stat.S_IMODE(metadata.st_mode) == 0o700
|
||||
controller._mcp_hub_private_directory(attempt, root=backup_root)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="backup root contract requires fixed-owner POSIX fixtures",
|
||||
)
|
||||
def test_hub_backup_root_rejects_group_readable_root(controller, tmp_path, monkeypatch):
|
||||
state_root = tmp_path / "controller-state"
|
||||
backup_root = state_root / "mcp-hub-backups"
|
||||
state_root.mkdir(mode=0o700)
|
||||
backup_root.mkdir(mode=0o750)
|
||||
monkeypatch.setattr(controller, "STATE_ROOT", state_root)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None)
|
||||
|
||||
with pytest.raises(controller.DeployError, match="not private and canonical"):
|
||||
controller._mcp_hub_private_directory(backup_root, root=backup_root)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="fixed Hub backup owner contract requires root-owned POSIX fixtures",
|
||||
)
|
||||
def test_hub_backup_root_rejects_runtime_owner_even_when_it_matches_parent(
|
||||
controller, tmp_path, monkeypatch
|
||||
):
|
||||
state_root = tmp_path / "controller-state"
|
||||
backup_root = state_root / "mcp-hub-backups"
|
||||
state_root.mkdir(mode=0o700)
|
||||
backup_root.mkdir(mode=0o700)
|
||||
os.chown(state_root, 99, 100)
|
||||
os.chown(backup_root, 99, 100)
|
||||
monkeypatch.setattr(controller, "STATE_ROOT", state_root)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None)
|
||||
|
||||
with pytest.raises(controller.DeployError, match="owner differs"):
|
||||
controller._mcp_hub_private_directory(backup_root, root=backup_root)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="descriptor-relative private storage requires root-owned POSIX fixtures",
|
||||
)
|
||||
def test_hub_backup_anchor_is_created_under_the_pinned_controller_state(
|
||||
controller, tmp_path, monkeypatch
|
||||
):
|
||||
state_root = tmp_path / "controller-state"
|
||||
state_root.mkdir(mode=0o755)
|
||||
backup_root = state_root / "mcp-hub-backups"
|
||||
monkeypatch.setattr(controller, "STATE_ROOT", state_root)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None)
|
||||
|
||||
controller._mcp_hub_private_directory(backup_root, create=True)
|
||||
|
||||
metadata = backup_root.stat()
|
||||
assert (metadata.st_uid, metadata.st_gid) == (0, 0)
|
||||
assert stat.S_IMODE(metadata.st_mode) == 0o700
|
||||
assert stat.S_IMODE(state_root.stat().st_mode) == 0o755
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="descriptor-relative path replacement requires root-owned POSIX fixtures",
|
||||
)
|
||||
def test_hub_backup_child_rejects_parent_replacement_during_creation(
|
||||
controller, tmp_path, monkeypatch
|
||||
):
|
||||
state_root = tmp_path / "controller-state"
|
||||
state_root.mkdir(mode=0o700)
|
||||
backup_root = state_root / "mcp-hub-backups"
|
||||
backup_root.mkdir(mode=0o700)
|
||||
monkeypatch.setattr(controller, "STATE_ROOT", state_root)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None)
|
||||
real_mkdir = controller.os.mkdir
|
||||
|
||||
def replace_parent(path, mode=0o777, *, dir_fd=None):
|
||||
real_mkdir(path, mode, dir_fd=dir_fd)
|
||||
if path == "attempt" and dir_fd is not None:
|
||||
backup_root.rename(state_root / "replaced-backups")
|
||||
real_mkdir(backup_root, 0o700)
|
||||
|
||||
monkeypatch.setattr(controller.os, "mkdir", replace_parent)
|
||||
|
||||
with pytest.raises(controller.DeployError, match="could not be created safely"):
|
||||
controller._mcp_hub_private_directory(backup_root / "attempt", create=True)
|
||||
assert not (backup_root / "attempt").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="release receipt storage requires POSIX mode and ACL metadata")
|
||||
def test_hub_backup_receipt_directory_is_guarded_by_its_release_volume_parent(
|
||||
controller, tmp_path, monkeypatch
|
||||
):
|
||||
volume_root = tmp_path / "controller-hub-releases"
|
||||
state_root = volume_root / "state"
|
||||
receipt_root = state_root / "backup-receipts"
|
||||
receipt_root.mkdir(mode=0o700, parents=True)
|
||||
volume_root.chmod(0o700)
|
||||
state_root.chmod(0o700)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_RELEASE_STORE_STATE_ROOT", state_root)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_BACKUP_RECEIPT_ROOT", receipt_root)
|
||||
|
||||
controller._mcp_hub_private_directory(receipt_root, root=state_root)
|
||||
with pytest.raises(controller.DeployError, match="escapes its fixed parent"):
|
||||
controller._mcp_hub_private_directory(receipt_root, root=controller.STATE_ROOT)
|
||||
|
||||
|
||||
def _valid_model(controller):
|
||||
services = {}
|
||||
for service_name in controller.MCP_HUB_SERVICES:
|
||||
@@ -159,14 +291,11 @@ def test_installed_legacy_registry_shape_is_adapted_only_in_memory(controller):
|
||||
"docker-compose.prod.yml",
|
||||
"docker-compose.autodeploy.yml",
|
||||
],
|
||||
"exact_source": None,
|
||||
"verify_oci_revision": None,
|
||||
"manual": False,
|
||||
"deploy_script": "",
|
||||
"archive_deploy_script": "",
|
||||
"live_probe": None,
|
||||
"managed_plan_version": "",
|
||||
"clean_untracked_paths": None,
|
||||
"env_source": "/srv/example/appdata/itworx-mcp-hub/deploy.env",
|
||||
}
|
||||
original = copy.deepcopy(target)
|
||||
@@ -182,8 +311,15 @@ def test_installed_legacy_registry_shape_is_adapted_only_in_memory(controller):
|
||||
for field, value in (
|
||||
("source_dir", "/srv/example/appdata/other/repository"),
|
||||
("compose_files", ["docker-compose.yml", "unexpected.yml"]),
|
||||
("exact_source", None),
|
||||
("exact_source", False),
|
||||
("exact_source", True),
|
||||
("verify_oci_revision", None),
|
||||
("verify_oci_revision", False),
|
||||
("verify_oci_revision", True),
|
||||
("clean_untracked_paths", None),
|
||||
("clean_untracked_paths", False),
|
||||
("clean_untracked_paths", []),
|
||||
("env_source", "/tmp/untrusted.env"),
|
||||
):
|
||||
changed = {**original, field: value}
|
||||
@@ -921,14 +1057,28 @@ def test_database_size_query_is_limited_to_two_fixed_database_names(controller,
|
||||
assert "pg_database_size" in query
|
||||
assert captured["timeout"] == 30
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name != "nt" and (not hasattr(os, "geteuid") or os.geteuid() != 0),
|
||||
reason="backup cleanup ownership fixture requires root on POSIX",
|
||||
)
|
||||
def test_failed_backup_attempt_removes_only_its_unique_private_directory(
|
||||
controller, monkeypatch, tmp_path
|
||||
):
|
||||
backup_root = tmp_path / "backups"
|
||||
appdata_root = tmp_path / "hub-appdata"
|
||||
backup_root = appdata_root / "backups"
|
||||
appdata_root.mkdir(mode=0o700)
|
||||
backup_root.mkdir(mode=0o700)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_BACKUP_ROOT", backup_root)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", tmp_path)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_private_directory", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(controller, "STATE_ROOT", appdata_root)
|
||||
monkeypatch.setattr(controller, "_mcp_hub_require_trusted_state_root", lambda: None)
|
||||
monkeypatch.setattr(controller, "MCP_HUB_APPDATA_ROOT", appdata_root)
|
||||
private_directory = controller._mcp_hub_private_directory
|
||||
monkeypatch.setattr(
|
||||
controller,
|
||||
"_mcp_hub_private_directory",
|
||||
lambda path, **kwargs: private_directory(path, **{"root": backup_root, **kwargs}),
|
||||
)
|
||||
monkeypatch.setattr(controller, "mcp_hub_validate_secret_files", lambda _model: 21)
|
||||
monkeypatch.setattr(
|
||||
controller,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.tests.test_mcp_hub_governed_topology import _legacy_target, _load_controller
|
||||
|
||||
|
||||
LEGACY_SOURCE = "/srv/example/appdata/itworx-mcp-hub/deployment/repository"
|
||||
|
||||
|
||||
def observed_legacy_target(controller):
|
||||
project = _legacy_target(controller)
|
||||
project.pop("exact_source")
|
||||
project.pop("verify_oci_revision")
|
||||
project["source_dir"] = LEGACY_SOURCE
|
||||
project["compose_dir"] = "infrastructure/compose"
|
||||
project["compose_files"] = list(controller.MCP_HUB_COMPOSE_FILES[:3])
|
||||
project["env_source"] = "/srv/example/appdata/itworx-mcp-hub/deploy.env"
|
||||
return project
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy", [False, True])
|
||||
def test_observed_hub_legacy_source_is_only_normalized_in_memory(legacy):
|
||||
controller = _load_controller()
|
||||
project = observed_legacy_target(controller) if legacy else _legacy_target(controller)
|
||||
canonical = (controller.EXACT_SOURCE_ROOT / "itworx-mcp-hub").as_posix()
|
||||
original = LEGACY_SOURCE if legacy else canonical
|
||||
project["source_dir"] = original
|
||||
|
||||
effective = controller.effective_mcp_hub_project(controller.MCP_HUB_REPOSITORY, project)
|
||||
|
||||
assert effective["source_dir"] == canonical
|
||||
assert project["source_dir"] == original
|
||||
assert effective["containers"] == list(controller.MCP_HUB_CONTAINERS)
|
||||
assert effective["compose_files"] == list(controller.MCP_HUB_COMPOSE_FILES)
|
||||
assert effective["_mcp_hub_contract"] == controller.MCP_HUB_TOPOLOGY_CONTRACT
|
||||
|
||||
|
||||
def test_observed_legacy_registry_omissions_are_upgraded_only_in_memory():
|
||||
controller = _load_controller()
|
||||
project = observed_legacy_target(controller)
|
||||
effective = controller.effective_mcp_hub_project(controller.MCP_HUB_REPOSITORY, project)
|
||||
assert effective["exact_source"] is True
|
||||
assert effective["verify_oci_revision"] is True
|
||||
assert "exact_source" not in project
|
||||
assert "verify_oci_revision" not in project
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("source_dir", "/mnt/cache/appdata/gitea-deploy-runners/sources/itworx-mcp-hub"),
|
||||
("compose_dir", "."),
|
||||
("compose_files", ["other.yml"]),
|
||||
("env_source", "/tmp/deploy.env"),
|
||||
("exact_source", None),
|
||||
("exact_source", False),
|
||||
("exact_source", True),
|
||||
("verify_oci_revision", None),
|
||||
("verify_oci_revision", False),
|
||||
("verify_oci_revision", True),
|
||||
("clean_untracked_paths", None),
|
||||
("clean_untracked_paths", False),
|
||||
("clean_untracked_paths", []),
|
||||
("clean_untracked_paths", ["local.env"]),
|
||||
],
|
||||
)
|
||||
def test_legacy_registry_omissions_do_not_widen_other_target_shapes(field, value):
|
||||
controller = _load_controller()
|
||||
project = observed_legacy_target(controller)
|
||||
project[field] = value
|
||||
with pytest.raises(controller.DeployError, match="exact legacy 15-container contract"):
|
||||
controller.effective_mcp_hub_project(controller.MCP_HUB_REPOSITORY, project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["exact_source", "verify_oci_revision"])
|
||||
def test_fully_declared_legacy_target_rejects_explicit_false_verification(field):
|
||||
controller = _load_controller()
|
||||
project = observed_legacy_target(controller)
|
||||
project[field] = False
|
||||
with pytest.raises(controller.DeployError, match="exact legacy 15-container contract"):
|
||||
controller.effective_mcp_hub_project(controller.MCP_HUB_REPOSITORY, project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
None,
|
||||
True,
|
||||
{},
|
||||
LEGACY_SOURCE + "/",
|
||||
LEGACY_SOURCE + "/../repository",
|
||||
"/mnt/cache/appdata/itworx-mcp-hub/deployment/repository",
|
||||
"/srv/example/appdata/another-hub/deployment/repository",
|
||||
"/tmp/repository",
|
||||
],
|
||||
)
|
||||
def test_hub_legacy_source_compatibility_rejects_other_spellings_and_types(source):
|
||||
controller = _load_controller()
|
||||
project = _legacy_target(controller)
|
||||
project["source_dir"] = source
|
||||
|
||||
with pytest.raises(controller.DeployError, match="exact legacy 15-container contract"):
|
||||
controller.effective_mcp_hub_project(controller.MCP_HUB_REPOSITORY, project)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -106,6 +107,91 @@ def test_fixed_hub_state_reader_is_bounded_and_rejects_symlink_or_missing_state(
|
||||
assert attacker_state.read_bytes() == b'{"status":"attacker"}'
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="Hub state reader verifies root-owned fixed storage",
|
||||
)
|
||||
@pytest.mark.parametrize("state_file_mode", [0o640, 0o644, 0o660, 0o664, 0o666])
|
||||
def test_fixed_hub_state_reader_rejects_other_file_modes(
|
||||
store, tmp_path, monkeypatch, state_file_mode
|
||||
):
|
||||
controller_root = tmp_path / "controller"
|
||||
state_root = controller_root / "state"
|
||||
state_root.mkdir(parents=True, mode=0o700)
|
||||
controller_root.chmod(0o700)
|
||||
state_file = state_root / store.HUB_STATE_FILENAME
|
||||
state_file.write_bytes(b'{"status":"success"}')
|
||||
state_file.chmod(state_file_mode)
|
||||
monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller_root)
|
||||
monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state_root)
|
||||
|
||||
with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"):
|
||||
store.read_hub_state_bytes(missing_ok=False)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="external backup owner checks require root-owned POSIX fixtures",
|
||||
)
|
||||
def test_descriptor_helpers_verify_an_exact_external_owner(store, tmp_path):
|
||||
expected_owner = (99, 100)
|
||||
backup_root = tmp_path / "backups"
|
||||
backup_root.mkdir(mode=0o700)
|
||||
backup_root.chmod(0o700)
|
||||
os.chown(backup_root, *expected_owner)
|
||||
root_fd = os.open(backup_root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
attempt_fd = None
|
||||
try:
|
||||
attempt_fd = store.open_child_directory(
|
||||
root_fd,
|
||||
"attempt",
|
||||
create=True,
|
||||
mode=0o700,
|
||||
expected_owner=expected_owner,
|
||||
)
|
||||
artifact_fd = os.open(
|
||||
"manifest.json",
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600,
|
||||
dir_fd=attempt_fd,
|
||||
)
|
||||
try:
|
||||
os.write(artifact_fd, b"private manifest")
|
||||
os.fchmod(artifact_fd, 0o600)
|
||||
os.fchown(artifact_fd, *expected_owner)
|
||||
finally:
|
||||
os.close(artifact_fd)
|
||||
|
||||
assert store.read_at(
|
||||
attempt_fd,
|
||||
"manifest.json",
|
||||
max_bytes=64,
|
||||
expected_mode=0o600,
|
||||
expected_owner=expected_owner,
|
||||
) == b"private manifest"
|
||||
digest, size = store.sha256_at(
|
||||
attempt_fd,
|
||||
"manifest.json",
|
||||
max_bytes=64,
|
||||
expected_mode=0o600,
|
||||
expected_owner=expected_owner,
|
||||
)
|
||||
assert digest == hashlib.sha256(b"private manifest").hexdigest()
|
||||
assert size == len(b"private manifest")
|
||||
with pytest.raises(store.ReleaseStoreError, match="metadata is unsafe"):
|
||||
store.read_at(
|
||||
attempt_fd,
|
||||
"manifest.json",
|
||||
max_bytes=64,
|
||||
expected_mode=0o600,
|
||||
expected_owner=(0, 0),
|
||||
)
|
||||
finally:
|
||||
if attempt_fd is not None:
|
||||
os.close(attempt_fd)
|
||||
os.close(root_fd)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="Hub state reader verifies root-owned fixed storage",
|
||||
|
||||
@@ -69,9 +69,8 @@ def test_reconciliation_derives_the_verified_live_revision_from_runtime_evidence
|
||||
"Media-Codec-Dashboard",
|
||||
]
|
||||
script = command[2]
|
||||
# The controller registry must be the canonical physical cache bind, not
|
||||
# the user-share alias that can resolve to a different inode.
|
||||
assert "/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json" in script
|
||||
# The mutable registry must identify the protected volume's fixed config.
|
||||
assert "/controller-hub-releases/controller-config/projects.json" in script
|
||||
assert "/srv/example/appdata/gitea-deploy-runners/controller/projects.json" not in script
|
||||
assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script
|
||||
assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.connectors.portainer import PortainerConnector
|
||||
from backend.app.core.config import settings
|
||||
|
||||
CONTROLLER_OWNED_CI_NAMES = (
|
||||
"itworx-ci-checks",
|
||||
"itworx-ci-backend",
|
||||
"itworx-ci-frontend",
|
||||
"itworx-ci-security",
|
||||
"itworx-ci-browser",
|
||||
"itworx-ci-build",
|
||||
"itworx-security-runner",
|
||||
"itworx-validation-runner-2",
|
||||
"itworx-validation-runner-3",
|
||||
"itworx-docker-runner",
|
||||
"itworx-browser-runner",
|
||||
"itworx-validation-runner-burst-1",
|
||||
"gitea-runner-dind",
|
||||
)
|
||||
|
||||
|
||||
def _connector_with_containers(monkeypatch, containers):
|
||||
monkeypatch.setitem(settings.apis, "PORTAINER_URL", "https://portainer.example.test")
|
||||
monkeypatch.setitem(settings.apis, "PORTAINER_API_KEY", "test-key")
|
||||
monkeypatch.setitem(settings.apis, "PORTAINER_ENDPOINT_ID", "9")
|
||||
|
||||
connector = PortainerConnector()
|
||||
monkeypatch.setattr(
|
||||
connector,
|
||||
"list_containers",
|
||||
lambda: {"status": "ok", "containers": containers},
|
||||
)
|
||||
calls = []
|
||||
|
||||
def request(method: str, path: str, **kwargs):
|
||||
calls.append((method, path, kwargs))
|
||||
return SimpleNamespace(status_code=204)
|
||||
|
||||
monkeypatch.setattr(connector, "_request", request)
|
||||
return connector, calls
|
||||
|
||||
|
||||
def test_restart_request_allows_docker_grace_period_without_shortening_container_timeout(monkeypatch):
|
||||
previous = dict(settings.apis)
|
||||
@@ -17,7 +56,7 @@ def test_restart_request_allows_docker_grace_period_without_shortening_container
|
||||
"list_containers",
|
||||
lambda: {
|
||||
"status": "ok",
|
||||
"containers": [{"id": "runner-id", "name": "itworx-validation-runner-2", "appId": "itworx-validation-runner-2"}],
|
||||
"containers": [{"id": "user-container-id", "name": "jellyfin", "appId": "jellyfin"}],
|
||||
},
|
||||
)
|
||||
calls: list[tuple[str, str, dict]] = []
|
||||
@@ -28,7 +67,7 @@ def test_restart_request_allows_docker_grace_period_without_shortening_container
|
||||
|
||||
monkeypatch.setattr(connector, "_request", request)
|
||||
try:
|
||||
result = connector.restart_container("itworx-validation-runner-2")
|
||||
result = connector.restart_container("jellyfin")
|
||||
finally:
|
||||
settings.apis.clear()
|
||||
settings.apis.update(previous)
|
||||
@@ -36,6 +75,67 @@ def test_restart_request_allows_docker_grace_period_without_shortening_container
|
||||
assert result["status"] == "ok"
|
||||
assert calls == [(
|
||||
"POST",
|
||||
"/api/endpoints/9/docker/containers/runner-id/restart",
|
||||
"/api/endpoints/9/docker/containers/user-container-id/restart",
|
||||
{"timeout": 30.0},
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("container_name", CONTROLLER_OWNED_CI_NAMES)
|
||||
def test_restart_blocks_fixed_controller_owned_ci_containers_by_name(monkeypatch, container_name):
|
||||
connector, calls = _connector_with_containers(
|
||||
monkeypatch,
|
||||
[{"id": "0123456789abcdef0123456789abcdef", "name": container_name, "appId": "ci-stack"}],
|
||||
)
|
||||
|
||||
result = connector.restart_container(container_name)
|
||||
|
||||
assert result == {"status": "blocked", "reason": "controller_owned_ci_lifecycle"}
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
(
|
||||
"itworx-ci-backend",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"01234567",
|
||||
"backend-ci-stack",
|
||||
),
|
||||
)
|
||||
def test_restart_blocks_controller_owned_container_selected_by_alias(monkeypatch, target):
|
||||
connector, calls = _connector_with_containers(
|
||||
monkeypatch,
|
||||
[{
|
||||
"id": "0123456789abcdef0123456789abcdef",
|
||||
"name": "itworx-ci-backend",
|
||||
"appId": "backend-ci-stack",
|
||||
}],
|
||||
)
|
||||
|
||||
result = connector.restart_container(target)
|
||||
|
||||
assert result == {"status": "blocked", "reason": "controller_owned_ci_lifecycle"}
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("container_name", ("itworx-unraid-deploy", "jellyfin"))
|
||||
def test_restart_still_allows_unrelated_deploy_and_user_containers(monkeypatch, container_name):
|
||||
container_id = f"id-{container_name}"
|
||||
connector, calls = _connector_with_containers(
|
||||
monkeypatch,
|
||||
[{"id": container_id, "name": container_name, "appId": container_name}],
|
||||
)
|
||||
|
||||
result = connector.restart_container(container_name)
|
||||
|
||||
assert result == {
|
||||
"status": "ok",
|
||||
"container": container_id,
|
||||
"target": container_name,
|
||||
"action": "container.restart",
|
||||
}
|
||||
assert calls == [(
|
||||
"POST",
|
||||
f"/api/endpoints/9/docker/containers/{container_id}/restart",
|
||||
{"timeout": 30.0},
|
||||
)]
|
||||
@@ -55,9 +55,9 @@ def test_portfolio_enrollment_contract_is_fixed_and_paired():
|
||||
|
||||
|
||||
def test_portfolio_enrollment_registry_alias_matches_controller_cache_bind_source():
|
||||
assert 'registry_path = Path("/opt/gitea-deploy/projects.json")' in _ENROLL_SCRIPT
|
||||
assert 'registry_path = Path("/controller-config/projects.json")' in _ENROLL_SCRIPT
|
||||
assert (
|
||||
'host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")'
|
||||
'host_registry_path = Path("/controller-hub-releases/controller-config/projects.json")'
|
||||
in _ENROLL_SCRIPT
|
||||
)
|
||||
assert "os.path.samefile(registry_path, host_registry_path)" in _ENROLL_SCRIPT
|
||||
|
||||
@@ -75,7 +75,7 @@ def test_enrollment_uses_fixed_private_muse_contract_and_controller_exec():
|
||||
assert "appops_managed_target" not in canonical_payload
|
||||
|
||||
script = command[2]
|
||||
assert "/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json" in script
|
||||
assert "/controller-hub-releases/controller-config/projects.json" in script
|
||||
assert "/srv/example/appdata/gitea-deploy-runners/controller/projects.json" not in script
|
||||
assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script
|
||||
assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_privatemuse_webhook_repair_executes_only_fixed_contract():
|
||||
|
||||
def test_privatemuse_webhook_repair_script_never_returns_credentials():
|
||||
assert "runner_guard_admin_token" in _REPAIR_SCRIPT
|
||||
assert 'secret_root = Path("/opt/gitea-deploy/webhook-secrets")' in _REPAIR_SCRIPT
|
||||
assert 'secret_root = Path("/controller-config/webhook-secrets")' in _REPAIR_SCRIPT
|
||||
assert 'secret_path = secret_root / "jens__privatemuse"' in _REPAIR_SCRIPT
|
||||
assert "secretMaterialReturned" not in _REPAIR_SCRIPT
|
||||
assert 'print(f"privatemuse_runnerless_webhook_ready hook_id={hook_id}"' in _REPAIR_SCRIPT
|
||||
|
||||
@@ -5,11 +5,15 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import hmac
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _module(monkeypatch):
|
||||
root = Path(__file__).resolve().parents[2] / "ops" / "gitea-deploy-broker"
|
||||
@@ -17,6 +21,52 @@ def _module(monkeypatch):
|
||||
return importlib.import_module("repopulse_target_attestation")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code_root", [Path("/opt/gitea-deploy"), Path("/private/controller-stage")])
|
||||
def test_bootstrap_producer_and_verifier_share_the_config_path(monkeypatch, code_root):
|
||||
root = Path(__file__).resolve().parents[2] / "ops/gitea-deploy-broker"
|
||||
monkeypatch.syspath_prepend(str(root))
|
||||
spec = importlib.util.spec_from_file_location("config_path_attestation_test", root / "repopulse_target_attestation.py")
|
||||
verifier = importlib.util.module_from_spec(spec)
|
||||
verifier.__file__ = str(code_root / "repopulse_target_attestation.py")
|
||||
exec(compile((root / "repopulse_target_attestation.py").read_bytes(), str(spec.origin), "exec"), verifier.__dict__)
|
||||
expected = (
|
||||
Path("/controller-hub-releases/controller-config")
|
||||
if code_root == Path("/opt/gitea-deploy") else code_root
|
||||
) / "appops-enrollment-bootstrap"
|
||||
assert verifier.BOOTSTRAP_PATH == expected
|
||||
monkeypatch.setitem(sys.modules, "repopulse_target_attestation", verifier)
|
||||
producer_spec = importlib.util.spec_from_file_location("config_path_producer_test", root / "stage_repopulse_target_attestation.py")
|
||||
producer = importlib.util.module_from_spec(producer_spec)
|
||||
producer_spec.loader.exec_module(producer)
|
||||
observed = []
|
||||
|
||||
def unavailable_secret(path, _maximum):
|
||||
observed.append(path)
|
||||
raise OSError("fixture stops before any external operation")
|
||||
|
||||
monkeypatch.setattr(producer.os, "geteuid", lambda: 0, raising=False)
|
||||
monkeypatch.setattr(producer.sys, "argv", ["stage_repopulse_target_attestation.py"])
|
||||
monkeypatch.setattr(producer, "_private_bytes", unavailable_secret)
|
||||
assert producer.main() == 2
|
||||
assert observed == [expected]
|
||||
observed.clear()
|
||||
|
||||
def verifier_file(path, *, maximum):
|
||||
if path == verifier.ATTESTATION_PATH:
|
||||
return json.dumps({"schema": verifier.ENVELOPE_SCHEMA, "claims": {}, "signatureSha256": "0" * 64}).encode()
|
||||
return unavailable_secret(path, maximum)
|
||||
|
||||
monkeypatch.setattr(verifier, "_private_file", verifier_file)
|
||||
required = {
|
||||
"repository", "applicationId", "profileId", "profileVersion",
|
||||
"planSha256", "intentId", "targetSecretRef", "controllerTlsCertificateSha256",
|
||||
}
|
||||
assert verifier.FixedTargetAttestationVerifier().verify_target_attestation(
|
||||
expected={key: "fixture" for key in required}
|
||||
) is None
|
||||
assert observed == [expected]
|
||||
|
||||
|
||||
def _sample(now: datetime) -> dict:
|
||||
return {
|
||||
"schema": "itworx.repopulse-target-attestation/v1",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
POLICY_PATH = ROOT / "ops" / "gitea-deploy-broker" / "runner_admission_policy.py"
|
||||
PROFILES_PATH = ROOT / "ops" / "gitea-deploy-broker" / "runner_profiles.py"
|
||||
|
||||
|
||||
def _load(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy():
|
||||
profiles = _load("appops_runner_profiles_for_admission_test", PROFILES_PATH)
|
||||
sys.modules["runner_profiles"] = profiles
|
||||
return _load("appops_runner_admission_policy_test", POLICY_PATH)
|
||||
|
||||
|
||||
def _job(job_id: str, labels: list[str], queued_at: float):
|
||||
return {"id": job_id, "labels": labels, "queued_at": queued_at}
|
||||
|
||||
|
||||
def test_oldest_compatible_job_wins_and_profile_choice_rotates(policy):
|
||||
jobs = [
|
||||
_job("newer-security", ["itworx-ci-security"], 200.0),
|
||||
_job("older-linux", ["self-hosted", "linux-validation"], 100.0),
|
||||
]
|
||||
|
||||
first = policy.select_admission(
|
||||
jobs,
|
||||
available_profiles=["backend", "frontend", "security"],
|
||||
active_claims=[],
|
||||
cursor=0,
|
||||
)
|
||||
second = policy.select_admission(
|
||||
jobs,
|
||||
available_profiles=["backend", "frontend", "security"],
|
||||
active_claims=[],
|
||||
cursor=first.next_cursor,
|
||||
)
|
||||
|
||||
assert (first.job_id, first.profile_id) == ("older-linux", "backend")
|
||||
assert (second.job_id, second.profile_id) == ("older-linux", "frontend")
|
||||
|
||||
|
||||
def test_enabled_legacy_slots_count_as_claims_and_can_consume_all_capacity(policy):
|
||||
decision = policy.select_admission(
|
||||
[_job("checks", ["itworx-ci-checks"], 1.0)],
|
||||
available_profiles=["checks"],
|
||||
# Two old broad Tower registrations are charged as two backend-envelope slots.
|
||||
active_claims=["backend", "backend"],
|
||||
)
|
||||
|
||||
assert decision.profile_id is None
|
||||
assert decision.reason == "runner_admission_claim_limit"
|
||||
|
||||
|
||||
def test_duplicate_legacy_claims_are_charged_against_dind_and_family_budgets(policy):
|
||||
dind_limited = policy.validate_claims(["frontend", "backend"])
|
||||
family_excess = policy.validate_claims(["build", "backend"])
|
||||
|
||||
assert dind_limited.ok
|
||||
assert family_excess.reason == "runner_admission_dind_budget_exceeded"
|
||||
|
||||
|
||||
def test_two_heavy_profiles_cannot_be_admitted_together(policy):
|
||||
result = policy.validate_claims(["browser", "build"])
|
||||
|
||||
assert not result.ok
|
||||
assert result.reason == "runner_admission_heavy_claim_limit"
|
||||
|
||||
|
||||
def test_unmapped_enabled_registration_fails_closed(policy):
|
||||
result = policy.validate_claims(["unknown-legacy-runner"])
|
||||
|
||||
assert not result.ok
|
||||
assert result.reason == "runner_admission_profile_unmapped"
|
||||
|
||||
|
||||
def test_candidate_is_reserved_before_it_can_be_selected(policy):
|
||||
result = policy.select_admission(
|
||||
[_job("build", ["itworx-ci-build"], 1.0)],
|
||||
available_profiles=["build"],
|
||||
active_claims=["browser"],
|
||||
)
|
||||
|
||||
assert result.profile_id is None
|
||||
assert result.reason == "runner_admission_heavy_claim_limit"
|
||||
|
||||
|
||||
def test_unknown_required_job_label_does_not_fall_back_to_a_broad_profile(policy):
|
||||
result = policy.select_admission(
|
||||
[_job("mystery", ["linux-validation", "unraid-deploy"], 1.0)],
|
||||
available_profiles=["backend", "frontend"],
|
||||
active_claims=[],
|
||||
)
|
||||
|
||||
assert result.profile_id is None
|
||||
assert result.reason == "runner_admission_no_compatible_queue"
|
||||
|
||||
|
||||
def test_legacy_docker_alias_keeps_heavy_reservation_and_budget_gates(policy):
|
||||
selected = policy.select_admission(
|
||||
[_job("docker", ["docker-validation"], 1.0)],
|
||||
available_profiles=["build"], active_claims=[],
|
||||
)
|
||||
assert selected.profile_id == "build"
|
||||
blocked = policy.select_admission(
|
||||
[_job("docker", ["docker-validation"], 1.0)],
|
||||
available_profiles=["build"], active_claims=["browser"],
|
||||
)
|
||||
assert blocked.profile_id is None
|
||||
assert blocked.reason == "runner_admission_heavy_claim_limit"
|
||||
|
||||
|
||||
def test_malformed_queue_row_fails_closed(policy):
|
||||
with pytest.raises(policy.RunnerAdmissionPolicyError):
|
||||
policy.select_admission(
|
||||
[{"id": "bad", "labels": ["itworx-ci-checks"], "queued_at": "now"}],
|
||||
available_profiles=["checks"],
|
||||
active_claims=[],
|
||||
)
|
||||
|
||||
|
||||
def test_lease_yields_to_the_oldest_eligible_other_profile_without_stealing_same_profile_work(
|
||||
policy,
|
||||
):
|
||||
queue = [
|
||||
_job("older-build", ["itworx-ci-build"], 100.0),
|
||||
_job("newer-backend", ["linux-validation"], 200.0),
|
||||
]
|
||||
decision = policy.select_lease_yield(
|
||||
queue,
|
||||
leased_profile="backend",
|
||||
available_profiles=["checks", "security", "browser", "build"],
|
||||
active_claims=["backend", "frontend"],
|
||||
cursor=0,
|
||||
)
|
||||
|
||||
assert (decision.profile_id, decision.job_id) == ("build", "older-build")
|
||||
|
||||
same_profile_is_oldest = policy.select_lease_yield(
|
||||
[
|
||||
_job("older-backend", ["linux-validation"], 100.0),
|
||||
_job("newer-build", ["itworx-ci-build"], 200.0),
|
||||
],
|
||||
leased_profile="backend",
|
||||
available_profiles=["checks", "security", "browser", "build"],
|
||||
active_claims=["backend", "frontend"],
|
||||
cursor=0,
|
||||
)
|
||||
|
||||
assert same_profile_is_oldest.profile_id is None
|
||||
assert same_profile_is_oldest.reason == "runner_admission_lease_retained"
|
||||
|
||||
|
||||
def test_lease_does_not_yield_when_current_claims_can_admit_queued_work(policy):
|
||||
decision = policy.select_lease_yield(
|
||||
[_job("checks", ["itworx-ci-checks"], 100.0)],
|
||||
leased_profile="backend",
|
||||
available_profiles=["checks"],
|
||||
active_claims=["backend"],
|
||||
cursor=0,
|
||||
)
|
||||
|
||||
assert decision.profile_id is None
|
||||
assert decision.reason == "runner_admission_lease_not_needed"
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -14,6 +16,9 @@ CONTROLLER_PATH = CONTROLLER_ROOT / "runner_capacity_controller.py"
|
||||
|
||||
@pytest.fixture
|
||||
def capacity_module(monkeypatch):
|
||||
if sys.platform == "win32":
|
||||
# The daemon lock is not exercised by these in-process cycle tests.
|
||||
monkeypatch.setitem(sys.modules, "fcntl", types.ModuleType("fcntl"))
|
||||
monkeypatch.syspath_prepend(str(CONTROLLER_ROOT))
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"appops_runner_capacity_controller_test", CONTROLLER_PATH
|
||||
@@ -25,6 +30,137 @@ def capacity_module(monkeypatch):
|
||||
return module
|
||||
|
||||
|
||||
def _wire_read_only_host(service, module, monkeypatch, samples):
|
||||
counters = iter(samples)
|
||||
monkeypatch.setattr(service, "_strict_swap_pages", lambda: next(counters))
|
||||
monkeypatch.setattr(module.os, "cpu_count", lambda: 20)
|
||||
monkeypatch.setattr(module.os, "getloadavg", lambda: (4.0, 3.0, 2.0), raising=False)
|
||||
monkeypatch.setattr(module.os, "sysconf", lambda _name: 4096, raising=False)
|
||||
monkeypatch.setattr(service, "_mem_available_bytes", lambda: 16 * 1024**3)
|
||||
monkeypatch.setattr(service, "_cache_free_bytes", lambda: 32 * 1024**3)
|
||||
|
||||
def forbidden(*_args, **_kwargs):
|
||||
pytest.fail("read-only host sampling touched burst inventory or a mutation")
|
||||
|
||||
for name in ("_burst_container", "_runner_registration", "_gitea_request",
|
||||
"_run", "_read_state", "_write_state", "_record"):
|
||||
monkeypatch.setattr(service, name, forbidden)
|
||||
|
||||
|
||||
def test_host_snapshot_measures_swap_without_optional_burst(capacity_module, monkeypatch):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_read_only_host(service, capacity_module, monkeypatch, [(50, 70)])
|
||||
prior = {"swapSampleAt": 99.0, "swapInPages": 0, "swapOutPages": 0}
|
||||
preserved = dict(prior)
|
||||
monkeypatch.setattr(capacity_module.time, "sleep", lambda _seconds: pytest.fail("baseline already available"))
|
||||
|
||||
result = service.host_snapshot(prior, now=100.0)
|
||||
|
||||
assert result["status"] == "observed"
|
||||
assert result["host"]["loadRatio"] == 0.2
|
||||
assert result["host"]["swapInKbps"] == 200.0
|
||||
assert result["host"]["swapOutKbps"] == 280.0
|
||||
assert result["host"]["swapCritical"] is False
|
||||
assert prior == preserved
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prior", [None, {"swapSampleAt": 0}, {
|
||||
"swapSampleAt": 1.0, "swapInPages": 5, "swapOutPages": 5,
|
||||
}, {"swapSampleAt": float("nan"), "swapInPages": 5, "swapOutPages": 5}])
|
||||
def test_host_snapshot_takes_two_real_samples_for_missing_or_stale_baseline(
|
||||
capacity_module, monkeypatch, prior
|
||||
):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_read_only_host(service, capacity_module, monkeypatch, [(10, 20), (266, 532)])
|
||||
elapsed = iter([4.0, 5.0])
|
||||
sleeps = []
|
||||
monkeypatch.setattr(capacity_module.time, "monotonic", lambda: next(elapsed))
|
||||
monkeypatch.setattr(capacity_module.time, "sleep", sleeps.append)
|
||||
|
||||
result = service.host_snapshot(prior, now=100.0)
|
||||
|
||||
assert result["status"] == "observed"
|
||||
assert result["host"]["swapInKbps"] == 1024.0
|
||||
assert result["host"]["swapOutKbps"] == 2048.0
|
||||
assert result["host"]["swapSampleAt"] == 101.0
|
||||
assert sleeps == [1.0]
|
||||
|
||||
|
||||
def test_host_snapshot_reports_unavailable_if_swap_counter_resets(capacity_module, monkeypatch):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_read_only_host(service, capacity_module, monkeypatch, [(10, 20), (9, 20)])
|
||||
elapsed = iter([4.0, 5.0])
|
||||
monkeypatch.setattr(capacity_module.time, "monotonic", lambda: next(elapsed))
|
||||
monkeypatch.setattr(capacity_module.time, "sleep", lambda _seconds: None)
|
||||
|
||||
result = service.host_snapshot(now=100.0)
|
||||
|
||||
assert result["status"] == "unavailable"
|
||||
assert result["reason"] == "runner_capacity_swap_baseline_unavailable"
|
||||
assert result["host"] is None
|
||||
|
||||
|
||||
def test_host_snapshot_rejects_reset_against_recent_protected_baseline(capacity_module, monkeypatch):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_read_only_host(service, capacity_module, monkeypatch, [(9, 20), (10, 21)])
|
||||
monkeypatch.setattr(capacity_module.time, "sleep", lambda _seconds: pytest.fail("recent counter reset must fail closed"))
|
||||
|
||||
result = service.host_snapshot({"swapSampleAt": 99.0, "swapInPages": 10, "swapOutPages": 20}, now=100.0)
|
||||
|
||||
assert result["status"] == "unavailable"
|
||||
assert result["reason"] == "runner_capacity_swap_baseline_unavailable"
|
||||
assert result["host"] is None
|
||||
|
||||
|
||||
def test_host_snapshot_handles_absent_load_tuple(capacity_module, monkeypatch):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_read_only_host(service, capacity_module, monkeypatch, [(10, 20)])
|
||||
monkeypatch.setattr(capacity_module.os, "getloadavg", lambda: ())
|
||||
|
||||
result = service.host_snapshot({"swapSampleAt": 99.0, "swapInPages": 0, "swapOutPages": 0}, now=100.0)
|
||||
|
||||
assert result["status"] == "unavailable"
|
||||
assert result["host"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", "pswpin 1\n", "pswpin -1\npswpout 2\n",
|
||||
"pswpin 1\npswpin 2\npswpout 3\n", "pswpin invalid\npswpout 2\n", "x" * 65537])
|
||||
def test_strict_swap_reader_rejects_missing_or_invalid_counters(capacity_module, monkeypatch, raw):
|
||||
monkeypatch.setattr(capacity_module, "Path", lambda _path: types.SimpleNamespace(
|
||||
open=lambda **_kwargs: io.StringIO(raw)
|
||||
))
|
||||
with pytest.raises(capacity_module.RunnerCapacityError, match="swap_telemetry_unavailable"):
|
||||
capacity_module.RunnerCapacityController._strict_swap_pages()
|
||||
|
||||
|
||||
def test_host_snapshot_does_not_turn_missing_swap_into_zero(capacity_module, monkeypatch):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_read_only_host(service, capacity_module, monkeypatch, [])
|
||||
|
||||
def unavailable():
|
||||
raise capacity_module.RunnerCapacityError("runner_capacity_swap_telemetry_unavailable")
|
||||
|
||||
monkeypatch.setattr(service, "_strict_swap_pages", unavailable)
|
||||
result = service.host_snapshot(now=100.0)
|
||||
assert result["status"] == "unavailable"
|
||||
assert result["host"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("metric", ["cpu_count", "getloadavg", "sysconf", "memory", "cache"])
|
||||
def test_host_snapshot_rejects_invalid_global_metrics(capacity_module, monkeypatch, metric):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_read_only_host(service, capacity_module, monkeypatch, [(10, 20)])
|
||||
if metric == "getloadavg":
|
||||
monkeypatch.setattr(capacity_module.os, metric, lambda: (float("nan"), 0, 0))
|
||||
elif metric in {"cpu_count", "sysconf"}:
|
||||
monkeypatch.setattr(capacity_module.os, metric, lambda *_args: 0)
|
||||
else:
|
||||
monkeypatch.setattr(service, "_mem_available_bytes" if metric == "memory" else "_cache_free_bytes", lambda: True)
|
||||
result = service.host_snapshot({"swapSampleAt": 99.0, "swapInPages": 0, "swapOutPages": 0}, now=100.0)
|
||||
assert result["status"] == "unavailable"
|
||||
assert result["host"] is None
|
||||
|
||||
|
||||
def _wire_common(service, monkeypatch, *, running=False, busy=False, disabled=True):
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
@@ -50,6 +186,7 @@ def _wire_common(service, monkeypatch, *, running=False, busy=False, disabled=Tr
|
||||
monkeypatch.setattr(service, "_read_state", lambda: {})
|
||||
monkeypatch.setattr(service, "_write_state", lambda _state: None)
|
||||
monkeypatch.setattr(service, "_record", lambda _event: None)
|
||||
monkeypatch.setattr(service, "_host_guard_state", lambda: ("clear", ""))
|
||||
|
||||
|
||||
def test_cycle_starts_and_enables_burst_for_queue_pressure(capacity_module, monkeypatch):
|
||||
@@ -218,3 +355,141 @@ def test_cycle_never_stops_when_gitea_still_has_active_assignment(
|
||||
|
||||
assert result["decision"]["result"] == "stop_deferred_not_proven_idle"
|
||||
assert commands == []
|
||||
|
||||
|
||||
def test_paused_host_guard_blocks_burst_start_before_docker_or_gitea_write(
|
||||
capacity_module, monkeypatch
|
||||
):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_common(service, monkeypatch, running=False, disabled=True)
|
||||
monkeypatch.setattr(service, "_queue", lambda: {"depth": 2, "oldestSeconds": 60})
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_host",
|
||||
lambda _previous, now: {
|
||||
"cpuCount": 20,
|
||||
"loadOne": 2.0,
|
||||
"loadRatio": 0.1,
|
||||
"availableMemoryBytes": 20 * 1024**3,
|
||||
"cacheFreeBytes": 100 * 1024**3,
|
||||
"swapInKbps": 0.0,
|
||||
"swapOutKbps": 0.0,
|
||||
"swapPressureSamples": 0,
|
||||
"swapCritical": False,
|
||||
"swapSampleAt": now,
|
||||
"swapInPages": 0,
|
||||
"swapOutPages": 0,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(service, "_host_guard_state", lambda: ("paused", "guard"))
|
||||
commands = []
|
||||
disabled = []
|
||||
monkeypatch.setattr(service, "_run", lambda args, **_kwargs: commands.append(args) or "")
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_set_disabled",
|
||||
lambda runner_id, value: disabled.append((runner_id, value)),
|
||||
)
|
||||
|
||||
result = service.cycle()
|
||||
|
||||
assert result["decision"]["action"] == "start"
|
||||
assert result["decision"]["result"] == "failed:runner_capacity_host_guard_paused"
|
||||
assert commands == []
|
||||
assert disabled == []
|
||||
|
||||
|
||||
def test_guard_is_rechecked_after_docker_start_before_gitea_enable(
|
||||
capacity_module, monkeypatch
|
||||
):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_common(service, monkeypatch, running=False, disabled=True)
|
||||
monkeypatch.setattr(service, "_queue", lambda: {"depth": 2, "oldestSeconds": 60})
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_host",
|
||||
lambda _previous, now: {
|
||||
"cpuCount": 20,
|
||||
"loadOne": 2.0,
|
||||
"loadRatio": 0.1,
|
||||
"availableMemoryBytes": 20 * 1024**3,
|
||||
"cacheFreeBytes": 100 * 1024**3,
|
||||
"swapInKbps": 0.0,
|
||||
"swapOutKbps": 0.0,
|
||||
"swapPressureSamples": 0,
|
||||
"swapCritical": False,
|
||||
"swapSampleAt": now,
|
||||
"swapInPages": 0,
|
||||
"swapOutPages": 0,
|
||||
},
|
||||
)
|
||||
guard_states = iter([("clear", ""), ("paused", "guard")])
|
||||
monkeypatch.setattr(service, "_host_guard_state", lambda: next(guard_states))
|
||||
commands = []
|
||||
disabled = []
|
||||
monkeypatch.setattr(service, "_run", lambda args, **_kwargs: commands.append(args) or "")
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_set_disabled",
|
||||
lambda runner_id, value: disabled.append((runner_id, value)),
|
||||
)
|
||||
|
||||
result = service.cycle()
|
||||
|
||||
assert result["decision"]["result"] == "failed:runner_capacity_host_guard_paused"
|
||||
assert commands == [["docker", "start", "itworx-validation-runner-burst-1"]]
|
||||
assert disabled == []
|
||||
|
||||
|
||||
def test_fresh_pressure_is_rechecked_before_burst_start(
|
||||
capacity_module, monkeypatch
|
||||
):
|
||||
service = capacity_module.RunnerCapacityController()
|
||||
_wire_common(service, monkeypatch, running=False, disabled=True)
|
||||
monkeypatch.setattr(service, "_queue", lambda: {"depth": 2, "oldestSeconds": 60})
|
||||
samples = iter(
|
||||
[
|
||||
{
|
||||
"loadRatio": 0.1,
|
||||
"availableMemoryBytes": 20 * 1024**3,
|
||||
"cacheFreeBytes": 100 * 1024**3,
|
||||
"swapCritical": False,
|
||||
},
|
||||
{
|
||||
"loadRatio": 0.95,
|
||||
"availableMemoryBytes": 20 * 1024**3,
|
||||
"cacheFreeBytes": 100 * 1024**3,
|
||||
"swapCritical": False,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
def sample(_previous, now):
|
||||
values = next(samples)
|
||||
return {
|
||||
"cpuCount": 20,
|
||||
"loadOne": values["loadRatio"] * 20,
|
||||
**values,
|
||||
"swapInKbps": 0.0,
|
||||
"swapOutKbps": 0.0,
|
||||
"swapPressureSamples": 0,
|
||||
"swapSampleAt": now,
|
||||
"swapInPages": 0,
|
||||
"swapOutPages": 0,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(service, "_host", sample)
|
||||
commands = []
|
||||
monkeypatch.setattr(service, "_run", lambda args, **_kwargs: commands.append(args) or "")
|
||||
disabled = []
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_set_disabled",
|
||||
lambda runner_id, value: disabled.append((runner_id, value)),
|
||||
)
|
||||
|
||||
result = service.cycle()
|
||||
|
||||
assert result["decision"]["result"] == "failed:runner_capacity_pressure_critical"
|
||||
assert commands == []
|
||||
assert disabled == []
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def artifact(tmp_path, monkeypatch):
|
||||
renderer = load('runner_artifact_renderer', ROOT / 'scripts/prepare_controller_artifact.py')
|
||||
controller_dir = ROOT / 'ops/gitea-deploy-broker'
|
||||
monkeypatch.syspath_prepend(str(controller_dir))
|
||||
controller = load('runner_artifact_controller', controller_dir / 'status_server.py')
|
||||
stage = tmp_path / 'controller'
|
||||
stage.mkdir()
|
||||
for name in renderer._ARTIFACT_FILES:
|
||||
source = ROOT / 'scripts/run_repopulse_adoption.py' if name == 'run_repopulse_adoption.py' else controller_dir / name
|
||||
shutil.copyfile(source, stage / name)
|
||||
result = subprocess.run([
|
||||
sys.executable, str(ROOT / 'scripts/prepare_controller_artifact.py'),
|
||||
'--artifact-dir', str(stage), '--source-repository', 'jens/unraid-appops-gateway',
|
||||
'--source-revision', 'a' * 40, '--source-tree', 'b' * 40,
|
||||
'--controller-version', '2.1.13', '--built-at', '2026-10-01T12:00:00Z',
|
||||
], capture_output=True, text=True, check=True)
|
||||
assert 'artifactSha256' in result.stdout
|
||||
monkeypatch.setattr(controller, 'ROOT', stage)
|
||||
monkeypatch.setattr(controller, 'CONTROLLER_RELEASE_PATH', stage / 'controller-release.json')
|
||||
return controller, stage
|
||||
|
||||
|
||||
def test_renderer_and_runtime_accept_the_same_complete_specialized_artifact(artifact):
|
||||
controller, _stage = artifact
|
||||
release = controller._controller_release()
|
||||
assert release['controllerVersion'] == '2.1.13'
|
||||
assert release['sourceRevision'] == 'a' * 40
|
||||
|
||||
|
||||
@pytest.mark.parametrize('damage', ['missing_binding', 'missing_hub_store', 'changed_admission', 'symlink_envelope'])
|
||||
def test_incomplete_or_changed_specialized_artifact_cannot_be_promoted(artifact, damage):
|
||||
controller, stage = artifact
|
||||
if damage == 'missing_binding':
|
||||
(stage / 'runner_statistics_binding.py').unlink()
|
||||
elif damage == 'missing_hub_store':
|
||||
(stage / 'mcp_hub_release_store.py').unlink()
|
||||
elif damage == 'changed_admission':
|
||||
with (stage / 'runner_admission_policy.py').open('ab') as stream:
|
||||
stream.write(b'\n# changed after manifest rendering\n')
|
||||
else:
|
||||
target = stage / 'runner_resource_envelope.py'
|
||||
original = stage / 'moved_envelope.py'
|
||||
target.rename(original)
|
||||
try:
|
||||
target.symlink_to(original)
|
||||
except OSError:
|
||||
pytest.skip('Host cannot create symlink fixture')
|
||||
assert controller._controller_release() is None
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -11,14 +14,71 @@ FLEET_PATH = ROOT / "ops" / "gitea-deploy-broker" / "runner_fleet.py"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fleet_module():
|
||||
def fleet_module(monkeypatch):
|
||||
if sys.platform == "win32" and "fcntl" not in sys.modules:
|
||||
fcntl = types.ModuleType("fcntl")
|
||||
fcntl.LOCK_EX = 2
|
||||
fcntl.LOCK_UN = 8
|
||||
fcntl.LOCK_NB = 4
|
||||
fcntl.flock = lambda *_args, **_kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "fcntl", fcntl)
|
||||
spec = importlib.util.spec_from_file_location("appops_runner_fleet_test", FLEET_PATH)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"runner_fleet_specialize",
|
||||
types.SimpleNamespace(specialization_state=lambda: "legacy"),
|
||||
)
|
||||
monkeypatch.setattr(module, "_host_guard_state", lambda: ("clear", ""))
|
||||
monkeypatch.setattr(
|
||||
module.RunnerCapacityController,
|
||||
"snapshot",
|
||||
lambda _self: _healthy_capacity_snapshot(),
|
||||
)
|
||||
return module
|
||||
|
||||
|
||||
def _healthy_capacity_snapshot():
|
||||
return {
|
||||
"status": "ok",
|
||||
"decision": {"pressure": "headroom"},
|
||||
"host": {
|
||||
"swapCritical": False,
|
||||
"loadRatio": 0.1,
|
||||
"availableMemoryBytes": 12 * 1024**3,
|
||||
"cacheFreeBytes": 100 * 1024**3,
|
||||
"swapSampleAt": datetime.now(UTC).timestamp(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_dind_uses_private_unix_socket_only_for_trusted_active_specialization(
|
||||
fleet_module, monkeypatch
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
commands = []
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_run",
|
||||
lambda args, **_kwargs: commands.append(args) or "",
|
||||
)
|
||||
specialization = sys.modules["runner_fleet_specialize"]
|
||||
|
||||
service._dind(["info"])
|
||||
assert "DOCKER_HOST=tcp://127.0.0.1:2375" in commands[-1]
|
||||
|
||||
monkeypatch.setattr(specialization, "specialization_state", lambda: "active")
|
||||
service._dind(["info"])
|
||||
assert f"DOCKER_HOST={fleet_module.DIND_DOCKER_HOST}" in commands[-1]
|
||||
|
||||
for state in ("legacy", "blocked", "in_progress"):
|
||||
monkeypatch.setattr(specialization, "specialization_state", lambda value=state: value)
|
||||
service._dind(["info"])
|
||||
assert "DOCKER_HOST=tcp://127.0.0.1:2375" in commands[-1]
|
||||
|
||||
|
||||
def test_cleanup_candidates_exclude_unknown_attached_and_labeled_networks(
|
||||
fleet_module, monkeypatch
|
||||
):
|
||||
@@ -80,6 +140,7 @@ def test_automatic_cycle_only_deletes_exact_preview_candidates(fleet_module, mon
|
||||
}
|
||||
snapshot = {
|
||||
"status": "healthy",
|
||||
"capacity": _healthy_capacity_snapshot(),
|
||||
"networks": {"cleanupCandidates": [candidate]},
|
||||
"storage": {"status": "healthy"},
|
||||
"topology": {
|
||||
@@ -143,6 +204,7 @@ def test_automatic_cycle_starts_only_an_exited_known_runner(fleet_module, monkey
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = {
|
||||
"status": "healthy",
|
||||
"capacity": _healthy_capacity_snapshot(),
|
||||
"networks": {"cleanupCandidates": []},
|
||||
"storage": {"status": "healthy"},
|
||||
"topology": {
|
||||
@@ -178,3 +240,21 @@ def test_automatic_cycle_starts_only_an_exited_known_runner(fleet_module, monkey
|
||||
|
||||
assert commands == [["docker", "start", "itworx-validation-runner-2"]]
|
||||
assert {event["action"] for event in events} == {"start", "restart.deferred"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("specialization", ["active", "blocked"])
|
||||
def test_legacy_automatic_cycle_never_observes_or_mutates_after_specialization(
|
||||
fleet_module, monkeypatch, specialization
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"runner_fleet_specialize",
|
||||
types.SimpleNamespace(specialization_state=lambda: specialization),
|
||||
)
|
||||
monkeypatch.setattr(service, "snapshot", lambda: pytest.fail("legacy snapshot ran"))
|
||||
monkeypatch.setattr(service, "_run", lambda *_args, **_kwargs: pytest.fail("legacy mutation ran"))
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert result["status"] == "deferred"
|
||||
assert result["reason"] == "runner_fleet_specialization_owns_recovery"
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,41 +14,93 @@ FLEET_PATH = ROOT / "ops" / "gitea-deploy-broker" / "runner_fleet.py"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fleet_module():
|
||||
def fleet_module(monkeypatch):
|
||||
if sys.platform == "win32" and "fcntl" not in sys.modules:
|
||||
fcntl = types.ModuleType("fcntl")
|
||||
fcntl.LOCK_EX = 2
|
||||
fcntl.LOCK_UN = 8
|
||||
fcntl.LOCK_NB = 4
|
||||
fcntl.flock = lambda *_args, **_kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "fcntl", fcntl)
|
||||
spec = importlib.util.spec_from_file_location("appops_runner_fleet_scheduler_test", FLEET_PATH)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
module._host_guard_state_unpatched = module._host_guard_state
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"runner_fleet_specialize",
|
||||
types.SimpleNamespace(specialization_state=lambda: "legacy"),
|
||||
)
|
||||
monkeypatch.setattr(module, "_host_guard_state", lambda: ("clear", ""))
|
||||
monkeypatch.setattr(
|
||||
module.RunnerCapacityController,
|
||||
"snapshot",
|
||||
lambda _self: _healthy_capacity_snapshot(),
|
||||
)
|
||||
return module
|
||||
|
||||
|
||||
def _healthy_capacity_snapshot():
|
||||
return {
|
||||
"status": "ok",
|
||||
"decision": {"pressure": "headroom"},
|
||||
"host": {
|
||||
"swapCritical": False,
|
||||
"loadRatio": 0.1,
|
||||
"availableMemoryBytes": 12 * 1024**3,
|
||||
"cacheFreeBytes": 100 * 1024**3,
|
||||
"swapSampleAt": datetime.now(UTC).timestamp(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _healthy_snapshot():
|
||||
runners = [
|
||||
{
|
||||
"name": name,
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restarts": 0,
|
||||
"startedAt": "2026-09-16T18:00:00Z",
|
||||
"oomKilled": False,
|
||||
"restartPolicy": "unless-stopped",
|
||||
}
|
||||
for name in (
|
||||
"itworx-security-runner",
|
||||
"itworx-validation-runner-2",
|
||||
)
|
||||
]
|
||||
return {
|
||||
"status": "healthy",
|
||||
"networks": {"cleanupCandidates": []},
|
||||
"storage": {"status": "healthy"},
|
||||
"generatedAt": "2026-10-01T10:00:00Z",
|
||||
"capacity": _healthy_capacity_snapshot(),
|
||||
"networks": {"count": 0, "capacity": None, "cleanupCandidates": []},
|
||||
"storage": {
|
||||
"status": "healthy",
|
||||
"freeBytes": 30 * 1024**3,
|
||||
"totalBytes": 40 * 1024**3,
|
||||
},
|
||||
"scheduler": {
|
||||
"status": "healthy",
|
||||
"candidate": None,
|
||||
"stallThresholdSeconds": 600,
|
||||
},
|
||||
"topology": {
|
||||
"dind": {
|
||||
"name": "gitea-runner-dind",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restarts": 0,
|
||||
"startedAt": "2026-09-16T18:00:00Z",
|
||||
"oomKilled": False,
|
||||
"restartPolicy": "unless-stopped",
|
||||
},
|
||||
"runners": [
|
||||
{
|
||||
"name": "itworx-validation-runner-2",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"startedAt": "2026-09-16T18:00:00Z",
|
||||
},
|
||||
{
|
||||
"name": "itworx-security-runner",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"startedAt": "2026-09-16T18:00:00Z",
|
||||
},
|
||||
]
|
||||
"runners": runners,
|
||||
"generalSlots": 2,
|
||||
"runnerCount": len(runners),
|
||||
"specializedRunners": [],
|
||||
"burstRunner": None,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -220,19 +274,20 @@ def test_automatic_cycle_defers_every_other_recovery_while_dind_is_starting(
|
||||
assert result["schedulerRecovery"] == {"status": "deferred_for_dind_recovery"}
|
||||
|
||||
|
||||
def test_automatic_cycle_sets_self_restart_and_starts_only_fixed_exited_dind(
|
||||
def test_automatic_cycle_recovers_critical_exited_dind_with_safe_host_capacity(
|
||||
fleet_module, monkeypatch
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["status"] = "critical"
|
||||
snapshot["topology"]["dind"] = {
|
||||
"name": "gitea-runner-dind",
|
||||
snapshot["topology"]["dind"].update(
|
||||
{
|
||||
"running": False,
|
||||
"health": "none",
|
||||
"startedAt": "",
|
||||
"restartPolicy": "no",
|
||||
}
|
||||
)
|
||||
commands: list[tuple[list[str], int]] = []
|
||||
events: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
@@ -250,14 +305,45 @@ def test_automatic_cycle_sets_self_restart_and_starts_only_fixed_exited_dind(
|
||||
(["docker", "update", "--restart", "unless-stopped", "gitea-runner-dind"], 30),
|
||||
(["docker", "start", "gitea-runner-dind"], 30),
|
||||
]
|
||||
assert result["dindRecovery"]["status"] == "requested"
|
||||
assert result["schedulerRecovery"]["status"] == "deferred_for_dind_recovery"
|
||||
assert any(
|
||||
event.get("action") == "start"
|
||||
and event.get("target") == "gitea-runner-dind"
|
||||
and event.get("reason") == "known_ci_dind_exited"
|
||||
for event in events
|
||||
assert [event["action"] for event in events] == [
|
||||
"restart-policy.update",
|
||||
"start",
|
||||
]
|
||||
assert result["status"] == "ok"
|
||||
assert result["dindRecovery"] == {
|
||||
"status": "requested",
|
||||
"restartPolicy": "requested",
|
||||
"target": "gitea-runner-dind",
|
||||
}
|
||||
|
||||
|
||||
def test_automatic_cycle_repairs_fixed_dind_policy_when_healthy_and_guard_clear(
|
||||
fleet_module, monkeypatch
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["topology"]["dind"]["restartPolicy"] = "no"
|
||||
commands: list[tuple[list[str], int]] = []
|
||||
events: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_run",
|
||||
lambda args, timeout=20: commands.append((args, timeout)) or "",
|
||||
)
|
||||
monkeypatch.setattr(service, "_record", lambda **event: events.append(event))
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert commands == [
|
||||
(["docker", "update", "--restart", "unless-stopped", "gitea-runner-dind"], 30)
|
||||
]
|
||||
assert result["dindRecovery"] == {
|
||||
"status": "not_needed",
|
||||
"restartPolicy": "requested",
|
||||
}
|
||||
assert len(events) == 1
|
||||
assert events[0]["action"] == "restart-policy.update"
|
||||
|
||||
|
||||
def test_automatic_cycle_never_restarts_running_unhealthy_dind(
|
||||
@@ -265,7 +351,7 @@ def test_automatic_cycle_never_restarts_running_unhealthy_dind(
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["status"] = "critical"
|
||||
snapshot["status"] = "healthy"
|
||||
snapshot["topology"]["dind"] = {
|
||||
"name": "gitea-runner-dind",
|
||||
"running": True,
|
||||
@@ -293,6 +379,385 @@ def test_automatic_cycle_never_restarts_running_unhealthy_dind(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("guard_state", "guard_reason"),
|
||||
[
|
||||
(("paused", "host_resource_guard_paused"), "host_resource_guard_paused"),
|
||||
(("unavailable", "host_guard_state_untrusted"), "host_guard_state_untrusted"),
|
||||
],
|
||||
)
|
||||
def test_automatic_cycle_never_mutates_when_host_guard_is_paused_or_untrusted(
|
||||
fleet_module, monkeypatch, guard_state, guard_reason
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["topology"]["dind"].update(
|
||||
{"running": False, "health": "none", "restartPolicy": "no"}
|
||||
)
|
||||
commands: list[list[str]] = []
|
||||
events: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(service, "_run", lambda args, timeout=20: commands.append(args) or "")
|
||||
monkeypatch.setattr(service, "_dind", lambda args, timeout=20: commands.append(args) or "")
|
||||
monkeypatch.setattr(service, "_record", lambda **event: events.append(event))
|
||||
monkeypatch.setattr(fleet_module, "_host_guard_state", lambda: guard_state)
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert result["status"] == "deferred"
|
||||
assert result["reason"] == guard_reason
|
||||
assert commands == []
|
||||
assert events == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
[
|
||||
"restart-policy-update",
|
||||
"dind-start",
|
||||
"scheduler-restart",
|
||||
"network-remove",
|
||||
"cache-prune",
|
||||
"runner-start",
|
||||
],
|
||||
)
|
||||
def test_guard_pause_appearing_after_cycle_entry_blocks_each_mutation(
|
||||
fleet_module, monkeypatch, action
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
dind = snapshot["topology"]["dind"]
|
||||
monkeypatch.setattr(service, "_scheduler_recovery_candidate", lambda: None)
|
||||
if action == "restart-policy-update":
|
||||
dind["restartPolicy"] = "no"
|
||||
elif action == "dind-start":
|
||||
dind.update({"running": False, "health": "none"})
|
||||
elif action == "scheduler-restart":
|
||||
snapshot["topology"]["runners"] = [
|
||||
{
|
||||
"name": "itworx-validation-runner-2",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
}
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_scheduler_recovery_candidate",
|
||||
lambda: {
|
||||
"target": "itworx-validation-runner-2",
|
||||
"jobId": 22,
|
||||
"ageSeconds": 900,
|
||||
"reason": "stale_scheduler_assignment",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_container",
|
||||
lambda name: {
|
||||
"name": name,
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
},
|
||||
)
|
||||
elif action == "network-remove":
|
||||
snapshot["networks"]["cleanupCandidates"] = [
|
||||
{
|
||||
"id": "a" * 64,
|
||||
"name": "GITEA-ACTIONS-TASK-22-WORKFLOW-ci-JOB-test-network",
|
||||
"ageSeconds": 3600,
|
||||
"reason": "empty_gitea_actions_task_network_in_dedicated_dind",
|
||||
}
|
||||
]
|
||||
elif action == "cache-prune":
|
||||
snapshot["storage"]["status"] = "critical"
|
||||
elif action == "runner-start":
|
||||
snapshot["topology"]["runners"] = [
|
||||
{
|
||||
"name": "itworx-validation-runner-2",
|
||||
"running": False,
|
||||
"health": "unhealthy",
|
||||
}
|
||||
]
|
||||
|
||||
guard_states = iter(
|
||||
[
|
||||
("clear", ""),
|
||||
("paused", "host_resource_guard_paused"),
|
||||
]
|
||||
)
|
||||
commands: list[list[str]] = []
|
||||
events: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(fleet_module, "_host_guard_state", lambda: next(guard_states))
|
||||
monkeypatch.setattr(service, "_recovery_available", lambda _target: True)
|
||||
monkeypatch.setattr(service, "_run", lambda args, timeout=20: commands.append(args) or "")
|
||||
monkeypatch.setattr(service, "_dind", lambda args, timeout=20: commands.append(args) or "")
|
||||
monkeypatch.setattr(service, "_record", lambda **event: events.append(event))
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert result["status"] == "deferred"
|
||||
assert result["reason"] == "host_resource_guard_paused"
|
||||
assert commands == []
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_critical_current_pressure_after_cycle_entry_blocks_mutation(
|
||||
fleet_module, monkeypatch
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["topology"]["dind"]["restartPolicy"] = "no"
|
||||
current_samples = iter(
|
||||
[
|
||||
{
|
||||
"status": "ok",
|
||||
"decision": {"pressure": "critical"},
|
||||
"host": {"swapCritical": True},
|
||||
}
|
||||
]
|
||||
)
|
||||
commands: list[list[str]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(fleet_module, "_host_guard_state", lambda: ("clear", ""))
|
||||
monkeypatch.setattr(
|
||||
fleet_module.RunnerCapacityController,
|
||||
"snapshot",
|
||||
lambda _self: next(current_samples),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_run",
|
||||
lambda args, timeout=20: commands.append(args) or "",
|
||||
)
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert result["status"] == "deferred"
|
||||
assert result["reason"] == "runner_capacity_pressure_critical"
|
||||
assert commands == []
|
||||
|
||||
|
||||
def test_stale_capacity_sample_blocks_automatic_mutation(fleet_module, monkeypatch):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["topology"]["dind"]["restartPolicy"] = "no"
|
||||
stale = _healthy_capacity_snapshot()
|
||||
stale["host"]["swapSampleAt"] = (
|
||||
datetime.now(UTC).timestamp()
|
||||
- fleet_module.CAPACITY_SAMPLE_MAX_AGE_SECONDS
|
||||
- 1
|
||||
)
|
||||
commands: list[list[str]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(fleet_module, "_host_guard_state", lambda: ("clear", ""))
|
||||
monkeypatch.setattr(
|
||||
fleet_module.RunnerCapacityController,
|
||||
"snapshot",
|
||||
lambda _self: stale,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_run",
|
||||
lambda args, timeout=20: commands.append(args) or "",
|
||||
)
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert result["status"] == "deferred"
|
||||
assert result["reason"] == "runner_capacity_telemetry_unavailable"
|
||||
assert commands == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("capacity", [{"status": "unavailable"}, {"status": "not_configured"}])
|
||||
def test_unavailable_or_missing_current_pressure_blocks_mutation(
|
||||
fleet_module, monkeypatch, capacity
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["topology"]["dind"]["restartPolicy"] = "no"
|
||||
commands: list[list[str]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(fleet_module, "_host_guard_state", lambda: ("clear", ""))
|
||||
monkeypatch.setattr(
|
||||
fleet_module.RunnerCapacityController,
|
||||
"snapshot",
|
||||
lambda _self: capacity,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_run",
|
||||
lambda args, timeout=20: commands.append(args) or "",
|
||||
)
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert result["status"] == "deferred"
|
||||
assert result["reason"] == "runner_capacity_telemetry_unavailable"
|
||||
assert commands == []
|
||||
|
||||
|
||||
def test_automatic_cycle_rejects_incomplete_critical_snapshot(
|
||||
fleet_module, monkeypatch
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["status"] = "critical"
|
||||
snapshot["topology"]["dind"].update(
|
||||
{"running": False, "health": "none", "restartPolicy": "no"}
|
||||
)
|
||||
snapshot["topology"].pop("runnerCount")
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(
|
||||
service, "_run", lambda *_args, **_kwargs: pytest.fail("critical fleet must not mutate")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service, "_record", lambda **_event: pytest.fail("critical fleet must not audit a mutation")
|
||||
)
|
||||
monkeypatch.setattr(fleet_module, "_host_guard_state", lambda: ("clear", ""))
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert result["status"] == "deferred"
|
||||
assert result["reason"] == "runner_fleet_snapshot_untrusted"
|
||||
assert result["dindRecovery"]["status"] == "deferred"
|
||||
|
||||
|
||||
def test_automatic_cycle_recovers_stalled_scheduler_in_critical_snapshot(
|
||||
fleet_module, monkeypatch
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["status"] = "critical"
|
||||
snapshot["scheduler"] = {
|
||||
"status": "stalled",
|
||||
"candidate": {
|
||||
"target": "itworx-validation-runner-2",
|
||||
"jobId": 22931,
|
||||
"label": "linux-validation",
|
||||
"ageSeconds": 900,
|
||||
"reason": "stale_scheduler_assignment",
|
||||
},
|
||||
"stallThresholdSeconds": 600,
|
||||
}
|
||||
commands: list[tuple[list[str], int]] = []
|
||||
events: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_scheduler_recovery_candidate",
|
||||
lambda: snapshot["scheduler"]["candidate"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_container",
|
||||
lambda name: {
|
||||
"name": name,
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"startedAt": "2026-09-16T18:00:00Z",
|
||||
"restarts": 0,
|
||||
"oomKilled": False,
|
||||
"restartPolicy": "unless-stopped",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(service, "_recovery_available", lambda _target: True)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_run",
|
||||
lambda args, timeout=20: commands.append((args, timeout)) or "",
|
||||
)
|
||||
monkeypatch.setattr(service, "_record", lambda **event: events.append(event) or True)
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert commands == [
|
||||
(["docker", "restart", "--timeout", "30", "itworx-validation-runner-2"], 45)
|
||||
]
|
||||
assert result["status"] == "ok"
|
||||
assert result["schedulerRecovery"] == {
|
||||
"status": "requested",
|
||||
"target": "itworx-validation-runner-2",
|
||||
"jobId": 22931,
|
||||
}
|
||||
assert [event["action"] for event in events] == ["restart", "restart.result"]
|
||||
|
||||
|
||||
def test_automatic_cycle_allows_critical_storage_prune_with_safe_host_capacity(
|
||||
fleet_module, monkeypatch
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["status"] = "critical"
|
||||
snapshot["storage"] = {
|
||||
"status": "critical",
|
||||
"freeBytes": 8 * 1024**3,
|
||||
"totalBytes": 40 * 1024**3,
|
||||
}
|
||||
commands: list[list[str]] = []
|
||||
events: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(service, "_scheduler_recovery_candidate", lambda: None)
|
||||
monkeypatch.setattr(service, "_dind", lambda args, timeout=20: commands.append(args) or "")
|
||||
monkeypatch.setattr(service, "_record", lambda **event: events.append(event))
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert commands == [
|
||||
["image", "prune", "--force"],
|
||||
["builder", "prune", "--force", "--filter", "until=168h"],
|
||||
]
|
||||
assert result["status"] == "ok"
|
||||
assert events == [
|
||||
{
|
||||
"action": "cache.prune",
|
||||
"target": "gitea-runner-dind",
|
||||
"reason": "ci_storage_critical",
|
||||
"result": "completed",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capacity", "reason"),
|
||||
[
|
||||
({"status": "ok", "decision": {"pressure": "critical"}, "host": {"swapCritical": False}},
|
||||
"runner_capacity_pressure_critical"),
|
||||
({"status": "unavailable"}, "runner_capacity_telemetry_unavailable"),
|
||||
({"status": "ok", "decision": {}, "host": {}}, "runner_capacity_telemetry_untrusted"),
|
||||
],
|
||||
)
|
||||
def test_automatic_cycle_blocks_critical_or_untrusted_capacity_snapshot(
|
||||
fleet_module, monkeypatch, capacity, reason
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["status"] = "critical"
|
||||
snapshot["capacity"] = capacity
|
||||
snapshot["topology"]["dind"].update(
|
||||
{"running": False, "health": "none", "restartPolicy": "no"}
|
||||
)
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(
|
||||
service, "_run", lambda *_args, **_kwargs: pytest.fail("unsafe capacity must not mutate")
|
||||
)
|
||||
monkeypatch.setattr(fleet_module, "_host_guard_state", lambda: ("clear", ""))
|
||||
|
||||
result = service.automatic_cycle()
|
||||
|
||||
assert result["status"] == "deferred"
|
||||
assert result["reason"] == reason
|
||||
|
||||
|
||||
def test_host_guard_probe_fails_closed_when_path_is_not_posix_root(monkeypatch, fleet_module):
|
||||
monkeypatch.setattr(fleet_module.os, "name", "nt")
|
||||
|
||||
assert fleet_module._host_guard_state_unpatched() == (
|
||||
"unavailable",
|
||||
"host_guard_state_untrusted",
|
||||
)
|
||||
|
||||
|
||||
def test_recovery_budget_counts_start_and_restart_attempts(fleet_module, monkeypatch):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
now = 1_800_000_000
|
||||
@@ -952,7 +1417,11 @@ def test_scheduler_recovery_rejects_runner_without_healthy_docker_state(
|
||||
):
|
||||
service = fleet_module.RunnerFleetService()
|
||||
snapshot = _healthy_snapshot()
|
||||
snapshot["topology"]["runners"][0]["health"] = "unhealthy"
|
||||
next(
|
||||
runner
|
||||
for runner in snapshot["topology"]["runners"]
|
||||
if runner["name"] == "itworx-validation-runner-2"
|
||||
)["health"] = "unhealthy"
|
||||
commands: list[list[str]] = []
|
||||
monkeypatch.setattr(service, "snapshot", lambda: snapshot)
|
||||
monkeypatch.setattr(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROFILES_PATH = ROOT / "ops" / "gitea-deploy-broker" / "runner_profiles.py"
|
||||
|
||||
|
||||
def _profiles_module():
|
||||
spec = importlib.util.spec_from_file_location("appops_runner_profiles_test", PROFILES_PATH)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_fixed_profiles_expose_stable_names_labels_and_resource_envelopes():
|
||||
profiles = _profiles_module()
|
||||
|
||||
assert tuple(profiles.RUNNER_PROFILES) == (
|
||||
"checks",
|
||||
"backend",
|
||||
"frontend",
|
||||
"security",
|
||||
"browser",
|
||||
"build",
|
||||
)
|
||||
assert [profile.display_name for profile in profiles.RUNNER_PROFILES.values()] == [
|
||||
"ITWorx CI · Checks",
|
||||
"ITWorx CI · Backend",
|
||||
"ITWorx CI · Frontend",
|
||||
"ITWorx CI · Security",
|
||||
"ITWorx CI · Browser",
|
||||
"ITWorx CI · Build",
|
||||
]
|
||||
assert [profile.container_name for profile in profiles.RUNNER_PROFILES.values()] == [
|
||||
"itworx-ci-checks",
|
||||
"itworx-ci-backend",
|
||||
"itworx-ci-frontend",
|
||||
"itworx-ci-security",
|
||||
"itworx-ci-browser",
|
||||
"itworx-ci-build",
|
||||
]
|
||||
assert [
|
||||
(profile.cpu_milli, profile.memory_mib, profile.heavy)
|
||||
for profile in profiles.RUNNER_PROFILES.values()
|
||||
] == [
|
||||
(1000, 1536, False),
|
||||
(3000, 5120, False),
|
||||
(3000, 5120, False),
|
||||
(1000, 2048, False),
|
||||
(3000, 6144, True),
|
||||
(4000, 6144, True),
|
||||
]
|
||||
assert (profiles.BASE_OVERHEAD_CPU_MILLI, profiles.BASE_OVERHEAD_MEMORY_MIB) == (
|
||||
1000,
|
||||
2048,
|
||||
)
|
||||
assert (profiles.HOST_CPU_BUDGET_MILLI, profiles.HOST_MEMORY_BUDGET_MIB) == (
|
||||
8000,
|
||||
16384,
|
||||
)
|
||||
assert (profiles.MAX_ACTIVE_CLAIMS, profiles.MAX_HEAVY_ACTIVE) == (2, 1)
|
||||
|
||||
|
||||
def test_shared_legacy_labels_only_admit_backend_and_frontend_profiles():
|
||||
profiles = _profiles_module()
|
||||
|
||||
assert [
|
||||
profile.id for profile in profiles.profiles_for_labels(["linux-validation"])
|
||||
] == ["backend", "frontend"]
|
||||
assert [
|
||||
profile.id for profile in profiles.profiles_for_labels(["ubuntu-latest"])
|
||||
] == ["backend", "frontend"]
|
||||
assert profiles.profiles_for_labels(["itworx-ci-security"])[0].id == "security"
|
||||
assert profiles.profiles_for_labels(["linux-validation", "itworx-ci-security"]) == ()
|
||||
assert profiles.profile_for_container("itworx-ci-browser").id == "browser"
|
||||
assert profiles.profile_for_container("itworx-ci-unknown") is None
|
||||
|
||||
|
||||
def test_legacy_docker_jobs_use_only_the_fixed_heavy_build_profile():
|
||||
profiles = _profiles_module()
|
||||
assert [profile.id for profile in profiles.profiles_for_labels(["docker-validation"])] == ["build"]
|
||||
assert profiles.RUNNER_PROFILES['build'].heavy
|
||||
assert profiles.profiles_for_labels(["docker-validation", "linux-validation"]) == ()
|
||||
assert profiles.profiles_for_labels(["docker-validation", "unraid-deploy"]) == ()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from email.message import Message
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[2] / "ops/gitea-deploy-broker"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(CONTROLLER))
|
||||
spec = importlib.util.spec_from_file_location("specialization_surface_test", CONTROLLER / "status_server.py")
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def handler(controller, monkeypatch, payload=None):
|
||||
instance = object.__new__(controller.Handler)
|
||||
body = json.dumps(payload or {"reason": "Specialize CI profiles"}).encode()
|
||||
instance.headers = Message()
|
||||
instance.headers["Content-Length"] = str(len(body))
|
||||
instance.rfile = io.BytesIO(body)
|
||||
instance.path = controller.RUNNER_FLEET_SPECIALIZE_PATH
|
||||
responses = []
|
||||
monkeypatch.setattr(instance, "_trusted_client", lambda: True)
|
||||
monkeypatch.setattr(instance, "_send_json", lambda code, data: responses.append((code, json.loads(data))))
|
||||
signatures = []
|
||||
monkeypatch.setattr(controller, "_self_upgrade_signature_is_valid", lambda *args, **kwargs: signatures.append(kwargs) or True)
|
||||
monkeypatch.setattr(controller, "_self_upgrade_runtime", lambda: {"revision": "a" * 40})
|
||||
monkeypatch.setattr(controller, "_require_controller_release", lambda: {"sourceRevision": "a" * 40, "sourceRepository": controller.SELF_UPGRADE_REPOSITORY})
|
||||
monkeypatch.setattr(controller, "_self_upgrade_preflight", lambda revision: {"status": "already_deployed"})
|
||||
monkeypatch.setattr(controller, "_load_projects", lambda: {controller.SELF_UPGRADE_REPOSITORY: {"branch": "main"}})
|
||||
monkeypatch.setattr(controller, "_repository_path_for_delivery", lambda *args: Path("fixed-repo.git"))
|
||||
monkeypatch.setattr(controller, "_is_current_branch_head", lambda *args: True)
|
||||
monkeypatch.setattr(controller, "runner_fleet_specialized_topology", lambda: {"ready": False})
|
||||
return instance, responses, signatures
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["blocked", "active", "unknown"])
|
||||
def test_legacy_upgrade_cannot_recreate_runners_after_cutover(controller, monkeypatch, state):
|
||||
instance, responses, _ = handler(controller, monkeypatch)
|
||||
monkeypatch.setattr(controller, "runner_fleet_specialization_state", lambda: state)
|
||||
monkeypatch.setattr(controller, "execute_runner_fleet_v2", lambda _: pytest.fail("legacy executor must be fenced"))
|
||||
instance._handle_runner_fleet_upgrade_v2()
|
||||
assert responses == [(409, {"status": "legacy_fleet_upgrade_blocked"})]
|
||||
|
||||
|
||||
def test_specialization_requires_matching_installed_controller_source(controller, monkeypatch):
|
||||
instance, responses, _ = handler(controller, monkeypatch)
|
||||
monkeypatch.setattr(controller, "_require_controller_release", lambda: {"sourceRevision": "b" * 40, "sourceRepository": controller.SELF_UPGRADE_REPOSITORY})
|
||||
monkeypatch.setattr(controller, "execute_runner_fleet_specialize", lambda _: pytest.fail("source mismatch must block mutation"))
|
||||
instance.do_POST()
|
||||
assert responses == [(409, {"status": "controller_source_mismatch"})]
|
||||
|
||||
|
||||
def test_specialization_uses_only_signed_current_main_script(controller, monkeypatch):
|
||||
instance, responses, signatures = handler(controller, monkeypatch)
|
||||
commands = []
|
||||
scripts = []
|
||||
monkeypatch.setattr(controller.subprocess, "run", lambda command, **_: commands.append(command) or SimpleNamespace(returncode=0, stdout=b"exact source script"))
|
||||
monkeypatch.setattr(controller, "execute_runner_fleet_specialize", lambda script: scripts.append(script) or {"status": "specialized", "verifiedRunners": [], "resourceEnvelope": {"ready": True}})
|
||||
instance.do_POST()
|
||||
assert signatures == [{"method": "POST", "path": controller.RUNNER_FLEET_SPECIALIZE_PATH}]
|
||||
assert commands == [["git", "--git-dir=fixed-repo.git", "show", "a" * 40 + ":scripts/upgrade_ci_runner_fleet_specialized.sh"]]
|
||||
assert scripts == [b"exact source script"]
|
||||
assert responses[0][0] == 200
|
||||
assert responses[0][1]["revision"] == "a" * 40
|
||||
assert "burstRunning" not in responses[0][1]
|
||||
|
||||
|
||||
def test_pending_protected_validation_blocks_specialization(controller, monkeypatch):
|
||||
instance, responses, _ = handler(controller, monkeypatch)
|
||||
monkeypatch.setattr(controller, "_self_upgrade_preflight", lambda _: {"status": "preflight_rejected"})
|
||||
monkeypatch.setattr(controller, "execute_runner_fleet_specialize", lambda _: pytest.fail("pending validation must block mutation"))
|
||||
instance.do_POST()
|
||||
assert responses[0][0] == 409
|
||||
assert responses[0][1]["status"] == "preflight_rejected"
|
||||
|
||||
|
||||
def test_caller_cannot_choose_script_image_or_runner(controller, monkeypatch):
|
||||
instance, responses, _ = handler(controller, monkeypatch, {"reason": "Specialize CI profiles", "script": "untrusted.sh"})
|
||||
instance.do_POST()
|
||||
assert responses == [(400, {"status": "invalid_request"})]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["blocked", "unknown"])
|
||||
def test_maintenance_never_falls_back_to_legacy_during_cutover(controller, monkeypatch, state):
|
||||
monkeypatch.setattr(controller, "runner_fleet_specialization_state", lambda: state)
|
||||
monkeypatch.setattr(controller.RunnerFleetService, "automatic_cycle", lambda _: pytest.fail("legacy maintenance must be fenced"))
|
||||
assert controller._runner_maintenance_cycle() == "deferred"
|
||||
|
||||
|
||||
def test_cutover_and_maintenance_share_single_owner_lock(controller, monkeypatch):
|
||||
monkeypatch.setattr(controller, "runner_fleet_specialization_state", lambda: pytest.fail("maintenance must not inspect during cutover"))
|
||||
with controller._runner_fleet_upgrade_lock:
|
||||
assert controller._runner_maintenance_cycle() == "migration_busy"
|
||||
|
||||
|
||||
def test_active_maintenance_calls_only_admission_controller(controller, monkeypatch):
|
||||
import runner_admission_controller
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(controller, "runner_fleet_specialization_state", lambda: "active")
|
||||
monkeypatch.setattr(runner_admission_controller.RunnerAdmissionController, "cycle", lambda _: calls.append("admission"))
|
||||
monkeypatch.setattr(controller.RunnerFleetService, "automatic_cycle", lambda _: pytest.fail("legacy worker must stop"))
|
||||
assert controller._runner_maintenance_cycle() == "specialized"
|
||||
assert calls == ["admission"]
|
||||
|
||||
|
||||
def test_statistics_get_returns_persisted_projection_without_a_cycle(controller, monkeypatch):
|
||||
import runner_admission_controller
|
||||
|
||||
instance, responses, _ = handler(controller, monkeypatch)
|
||||
instance.path = "/v1/runner-fleet/statistics"
|
||||
monkeypatch.setattr(runner_admission_controller.RunnerAdmissionController, "cycle", lambda _: pytest.fail("GET may not start collection or mutate fleet"))
|
||||
monkeypatch.setattr(runner_admission_controller.RunnerAdmissionController, "snapshot", lambda _: {"schemaVersion": 1, "status": "unavailable"})
|
||||
instance.do_GET()
|
||||
assert responses == [(200, {"schemaVersion": 1, "status": "unavailable"})]
|
||||
|
||||
|
||||
def test_statistics_configuration_uses_same_protected_source_and_fixed_signed_route(controller, monkeypatch):
|
||||
instance, responses, signatures = handler(controller, monkeypatch)
|
||||
instance.path = controller.RUNNER_STATISTICS_CONFIGURE_PATH
|
||||
calls = []
|
||||
monkeypatch.setattr(controller, 'configure_runner_statistics_binding',
|
||||
lambda **kwargs: calls.append(kwargs) or {'status': 'configured', 'credentialReturned': False})
|
||||
monkeypatch.setattr(controller, 'runner_fleet_specialized_topology', lambda: pytest.fail('configuration must not mutate fleet'))
|
||||
instance.do_POST()
|
||||
assert signatures == [{'method': 'POST', 'path': controller.RUNNER_STATISTICS_CONFIGURE_PATH}]
|
||||
assert calls == [{'revision': 'a' * 40, 'projects': {controller.SELF_UPGRADE_REPOSITORY: {'branch': 'main'}}}]
|
||||
assert responses == [(200, {'status': 'configured', 'credentialReturned': False})]
|
||||
|
||||
|
||||
def test_statistics_configuration_source_mismatch_writes_nothing(controller, monkeypatch):
|
||||
instance, responses, _ = handler(controller, monkeypatch)
|
||||
instance.path = controller.RUNNER_STATISTICS_CONFIGURE_PATH
|
||||
monkeypatch.setattr(controller, '_require_controller_release', lambda: {'sourceRevision': 'b' * 40, 'sourceRepository': controller.SELF_UPGRADE_REPOSITORY})
|
||||
monkeypatch.setattr(controller, 'configure_runner_statistics_binding', lambda **kwargs: pytest.fail('must not write configuration'))
|
||||
instance.do_POST()
|
||||
assert responses == [(409, {'status': 'controller_source_mismatch'})]
|
||||
|
||||
|
||||
def test_statistics_configuration_caller_cannot_select_credential_or_endpoint(controller, monkeypatch):
|
||||
instance, responses, _ = handler(controller, monkeypatch, {'reason': 'configure stats', 'token': 'caller-credential'})
|
||||
instance.path = controller.RUNNER_STATISTICS_CONFIGURE_PATH
|
||||
monkeypatch.setattr(controller, 'configure_runner_statistics_binding', lambda **kwargs: pytest.fail('must not write configuration'))
|
||||
instance.do_POST()
|
||||
assert responses == [(400, {'status': 'invalid_request'})]
|
||||
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.name != 'posix', reason='Linux controller ownership/flock contract')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def binding(tmp_path, monkeypatch):
|
||||
path = Path(__file__).resolve().parents[2] / 'ops/gitea-deploy-broker/runner_statistics_binding.py'
|
||||
spec = importlib.util.spec_from_file_location('binding_test', path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
anchor = tmp_path / 'controller-anchor'
|
||||
state = anchor / 'state'
|
||||
(state / 'locks').mkdir(parents=True)
|
||||
source = tmp_path / 'brain'
|
||||
source.mkdir()
|
||||
data = tmp_path / 'appops'
|
||||
data.mkdir()
|
||||
monkeypatch.setattr(module, 'ROOT_UID', os.getuid())
|
||||
if os.geteuid() != 0:
|
||||
# The production controller is root. Keep portable unit coverage for
|
||||
# non-root Linux runs while root-only tests assert 100:101.
|
||||
monkeypatch.setattr(module, 'APPOPS_UID', os.geteuid())
|
||||
monkeypatch.setattr(module, 'APPOPS_GID', os.getegid())
|
||||
for key, value in {'STATE_ROOT': state, 'BINDING_ROOT': state / 'runner-statistics-binding',
|
||||
'PB_SOURCE': source, 'PB_ENV': source / '.env', 'APPOPS_DATA': data,
|
||||
'TOKEN_FILE': data / 'runner-statistics-token',
|
||||
'ADMIN_TOKEN_FILE': data / 'mcp-token', 'GLOBAL_LOCK': anchor / 'deploy.lock'}.items():
|
||||
monkeypatch.setattr(module, key, value)
|
||||
module.PB_ENV.write_bytes(b'EXISTING_PRIVATE_VALUE=keep-me\nPROJECTBRAIN_APPOPS_MCP_URL=\nPROJECTBRAIN_APPOPS_MCP_TOKEN=\n')
|
||||
module.PB_ENV.chmod(0o600)
|
||||
module.ADMIN_TOKEN_FILE.write_text('admin-capability-separate-secret\n')
|
||||
module.ADMIN_TOKEN_FILE.chmod(0o600)
|
||||
module.project = {'jens/projectbrain': {'branch': 'main', 'slug': 'projectbrain',
|
||||
'source_dir': str(source), 'containers': ['projectbrain'],
|
||||
'deploy_script': 'scripts/deploy-unraid-single.sh',
|
||||
'validation': {'required': True, 'contexts': ['Managed validation / full (push)']}}}
|
||||
return module
|
||||
|
||||
|
||||
def invoke(binding):
|
||||
return binding.configure(revision='a' * 40, projects=binding.project)
|
||||
|
||||
|
||||
def test_configuration_is_separate_private_and_idempotent(binding):
|
||||
before = binding.PB_ENV.read_bytes()
|
||||
result = invoke(binding)
|
||||
assert result['status'] == 'configured'
|
||||
assert result['projectbrainDeliveryRequired'] is True
|
||||
token = binding.TOKEN_FILE.read_text().strip()
|
||||
assert len(token) == 64
|
||||
assert token != binding.ADMIN_TOKEN_FILE.read_text().strip()
|
||||
assert token not in json.dumps(result)
|
||||
assert binding._configured_values(binding.PB_ENV.read_bytes()) == {
|
||||
binding.URL_KEY: binding.ENDPOINT, binding.TOKEN_KEY: token}
|
||||
assert b'EXISTING_PRIVATE_VALUE=keep-me\n' in binding.PB_ENV.read_bytes()
|
||||
assert (binding.BINDING_ROOT / 'environment-before').read_bytes() == before
|
||||
for path in (binding.PB_ENV, binding.TOKEN_FILE, binding.BINDING_ROOT / 'receipt.json'):
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
token_info = binding.TOKEN_FILE.stat()
|
||||
assert (token_info.st_uid, token_info.st_gid) == (binding.APPOPS_UID, binding.APPOPS_GID)
|
||||
assert token_info.st_nlink == 1
|
||||
assert invoke(binding)['status'] == 'already_configured'
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
|
||||
reason='fixed AppOps ownership requires a privileged Linux controller')
|
||||
def test_statistics_token_is_appops_readable_while_binding_state_stays_root_private(binding):
|
||||
assert os.geteuid() != 100
|
||||
|
||||
result = invoke(binding)
|
||||
|
||||
token_info = binding.TOKEN_FILE.stat()
|
||||
assert (token_info.st_uid, token_info.st_gid) == (100, 101)
|
||||
assert token_info.st_mode & 0o777 == 0o600
|
||||
assert token_info.st_nlink == 1
|
||||
|
||||
assert len(binding.TOKEN_FILE.read_text().strip()) == 64
|
||||
assert result['status'] == 'configured'
|
||||
for path in (binding.PB_ENV, binding.BINDING_ROOT / 'receipt.json',
|
||||
binding.BINDING_ROOT / 'environment-before'):
|
||||
info = path.stat()
|
||||
assert info.st_uid == binding.ROOT_UID
|
||||
assert info.st_mode & 0o777 == 0o600
|
||||
assert info.st_nlink == 1
|
||||
assert invoke(binding)['status'] == 'already_configured'
|
||||
token_info = binding.TOKEN_FILE.stat()
|
||||
assert (token_info.st_uid, token_info.st_gid) == (100, 101)
|
||||
assert token_info.st_mode & 0o777 == 0o600
|
||||
assert token_info.st_nlink == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
|
||||
reason='dropping to the fixed AppOps identity requires a privileged Linux controller')
|
||||
def test_private_secret_reader_accepts_statistics_token_as_dropped_appops_identity(binding):
|
||||
invoke(binding)
|
||||
repository = Path(__file__).resolve().parents[2]
|
||||
code = (
|
||||
'import sys; from pathlib import Path; '
|
||||
'sys.path.insert(0, sys.argv[2]); '
|
||||
'from backend.app.core.config import _private_secret_file; '
|
||||
'value = _private_secret_file(sys.argv[1]); '
|
||||
'raise SystemExit(0 if value is not None and len(value) == 64 else 1)'
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='appops-statistics-reader-', dir='/tmp') as directory:
|
||||
os.chmod(directory, 0o711)
|
||||
token_copy = Path(directory) / 'runner-statistics-token'
|
||||
token_copy.write_bytes(binding.TOKEN_FILE.read_bytes())
|
||||
source_info = binding.TOKEN_FILE.stat()
|
||||
os.chown(token_copy, source_info.st_uid, source_info.st_gid)
|
||||
token_copy.chmod(source_info.st_mode & 0o777)
|
||||
|
||||
def drop_to_appops():
|
||||
os.setgroups([])
|
||||
os.setgid(101)
|
||||
os.setuid(100)
|
||||
|
||||
result = subprocess.run(
|
||||
['/usr/bin/python3', '-c', code, str(token_copy), str(repository)],
|
||||
cwd=directory, preexec_fn=drop_to_appops,
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
token = binding.TOKEN_FILE.read_text().strip()
|
||||
assert token not in result.stdout and token not in result.stderr
|
||||
assert result.stdout == ''
|
||||
assert result.returncode == 0, (
|
||||
'AppOps config private-secret reader rejected the fixed token: '
|
||||
+ result.stderr[-500:]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
|
||||
reason='wrong-owner validation requires a privileged Linux controller')
|
||||
def test_configured_root_owned_statistics_token_fails_closed(binding):
|
||||
assert invoke(binding)['status'] == 'configured'
|
||||
token = binding.TOKEN_FILE.read_bytes()
|
||||
os.chown(binding.TOKEN_FILE, binding.ROOT_UID, os.getgid())
|
||||
|
||||
with pytest.raises(binding.StatisticsBindingError, match='file_untrusted'):
|
||||
invoke(binding)
|
||||
assert binding.TOKEN_FILE.read_bytes() == token
|
||||
info = binding.TOKEN_FILE.stat()
|
||||
assert (info.st_uid, info.st_gid) == (binding.ROOT_UID, os.getgid())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ['source_dir', 'deploy_script', 'containers', 'validation'])
|
||||
def test_unknown_project_configuration_is_rejected_before_write(binding, key):
|
||||
binding.project['jens/projectbrain'][key] = 'untrusted'
|
||||
with pytest.raises(binding.StatisticsBindingError):
|
||||
invoke(binding)
|
||||
assert not binding.TOKEN_FILE.exists()
|
||||
assert not binding.BINDING_ROOT.exists()
|
||||
|
||||
|
||||
def test_existing_operator_configuration_is_preserved(binding):
|
||||
before = b'PROJECTBRAIN_APPOPS_MCP_URL=https://operator.example/mcp\nOTHER=preserve\n'
|
||||
binding.PB_ENV.write_bytes(before)
|
||||
with pytest.raises(binding.StatisticsBindingError, match='existing_operator_configuration'):
|
||||
invoke(binding)
|
||||
assert binding.PB_ENV.read_bytes() == before
|
||||
assert not binding.TOKEN_FILE.exists()
|
||||
|
||||
|
||||
def test_existing_operator_token_is_never_adopted(binding):
|
||||
binding.TOKEN_FILE.write_text('existing-operator-secret')
|
||||
binding.TOKEN_FILE.chmod(0o600)
|
||||
os.chown(binding.TOKEN_FILE, binding.APPOPS_UID, binding.APPOPS_GID)
|
||||
with pytest.raises(binding.StatisticsBindingError, match='existing_operator_credential'):
|
||||
invoke(binding)
|
||||
assert binding.TOKEN_FILE.read_text() == 'existing-operator-secret'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('fault_path', ['token', 'environment', 'receipt'])
|
||||
def test_crash_after_each_durable_boundary_recovers_same_credential(binding, monkeypatch, fault_path):
|
||||
atomic = binding._atomic
|
||||
interrupted = False
|
||||
|
||||
def crash(path, value, expected):
|
||||
nonlocal interrupted
|
||||
atomic(path, value, expected)
|
||||
selected = {'token': binding.TOKEN_FILE, 'environment': binding.PB_ENV,
|
||||
'receipt': binding.BINDING_ROOT / 'receipt.json'}[fault_path]
|
||||
if path == selected and not interrupted:
|
||||
interrupted = True
|
||||
raise OSError('simulated interruption after fsync')
|
||||
|
||||
monkeypatch.setattr(binding, '_atomic', crash)
|
||||
with pytest.raises(OSError):
|
||||
invoke(binding)
|
||||
journal = json.loads((binding.BINDING_ROOT / 'receipt.json').read_bytes())
|
||||
expected = journal['token']
|
||||
monkeypatch.setattr(binding, '_atomic', atomic)
|
||||
assert invoke(binding)['status'] == 'configured'
|
||||
assert binding.TOKEN_FILE.read_text().strip() == expected
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != 'posix' or os.geteuid() != 0,
|
||||
reason='simulated ownership failure requires a privileged Linux controller')
|
||||
def test_statistics_token_owner_failure_closes_fd_and_never_publishes(binding, monkeypatch, capsys):
|
||||
opened_token_fds = []
|
||||
|
||||
def fail_chown(fd, uid, gid):
|
||||
opened_token_fds.append(fd)
|
||||
raise PermissionError('simulated AppOps ownership failure')
|
||||
|
||||
monkeypatch.setattr(binding.os, 'fchown', fail_chown)
|
||||
with pytest.raises(PermissionError):
|
||||
invoke(binding)
|
||||
|
||||
assert len(opened_token_fds) == 1
|
||||
with pytest.raises(OSError):
|
||||
binding.os.fstat(opened_token_fds[0])
|
||||
assert not binding.TOKEN_FILE.exists()
|
||||
assert [path.name for path in binding.APPOPS_DATA.iterdir()] == ['mcp-token']
|
||||
assert capsys.readouterr().out == ''
|
||||
|
||||
|
||||
def test_crash_after_backup_before_receipt_has_safe_retry(binding, monkeypatch):
|
||||
atomic = binding._atomic
|
||||
|
||||
def crash(path, value, expected):
|
||||
atomic(path, value, expected)
|
||||
if path.name == 'environment-before':
|
||||
raise OSError('interruption after backup')
|
||||
|
||||
monkeypatch.setattr(binding, '_atomic', crash)
|
||||
with pytest.raises(OSError):
|
||||
invoke(binding)
|
||||
monkeypatch.setattr(binding, '_atomic', atomic)
|
||||
assert invoke(binding)['status'] == 'configured'
|
||||
|
||||
|
||||
def test_pending_environment_cas_conflict_preserves_operator_change(binding, monkeypatch):
|
||||
atomic = binding._atomic
|
||||
|
||||
def crash(path, value, expected):
|
||||
atomic(path, value, expected)
|
||||
if path == binding.TOKEN_FILE:
|
||||
raise OSError('interruption')
|
||||
|
||||
monkeypatch.setattr(binding, '_atomic', crash)
|
||||
with pytest.raises(OSError):
|
||||
invoke(binding)
|
||||
binding.PB_ENV.write_bytes(b'OPERATOR_CHANGED=value\n')
|
||||
monkeypatch.setattr(binding, '_atomic', atomic)
|
||||
with pytest.raises(binding.StatisticsBindingError, match='environment_cas_conflict'):
|
||||
invoke(binding)
|
||||
assert binding.PB_ENV.read_bytes() == b'OPERATOR_CHANGED=value\n'
|
||||
|
||||
|
||||
def test_completed_binding_keeps_unrelated_later_operator_edits(binding):
|
||||
invoke(binding)
|
||||
with binding.PB_ENV.open('ab') as stream:
|
||||
stream.write(b'NEW_OPERATOR_VALUE=preserved\n')
|
||||
before = binding.PB_ENV.read_bytes()
|
||||
assert invoke(binding)['status'] == 'already_configured'
|
||||
assert binding.PB_ENV.read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('unsafe', ['symlink', 'public_file', 'writable_anchor', 'duplicate_key'])
|
||||
def test_unsafe_configuration_blocks_before_credential_write(binding, unsafe):
|
||||
if unsafe == 'symlink':
|
||||
target = binding.PB_ENV.with_name('other.env')
|
||||
binding.PB_ENV.rename(target)
|
||||
binding.PB_ENV.symlink_to(target)
|
||||
elif unsafe == 'public_file':
|
||||
binding.PB_ENV.chmod(0o644)
|
||||
elif unsafe == 'writable_anchor':
|
||||
binding.PB_SOURCE.chmod(0o777)
|
||||
else:
|
||||
binding.PB_ENV.write_text(f'{binding.URL_KEY}=\n{binding.URL_KEY}=\n')
|
||||
with pytest.raises((binding.StatisticsBindingError, OSError)):
|
||||
invoke(binding)
|
||||
assert not binding.TOKEN_FILE.exists()
|
||||
|
||||
|
||||
def test_active_deployment_lock_blocks_without_configuration_changes(binding):
|
||||
import fcntl
|
||||
|
||||
with binding.GLOBAL_LOCK.open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
with pytest.raises(binding.StatisticsBindingError, match='deployment_busy'):
|
||||
invoke(binding)
|
||||
assert not binding.TOKEN_FILE.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('owner,accepted', [(100, True), (200, False)])
|
||||
def test_existing_fixed_admin_file_owner_contract(binding, monkeypatch, owner, accepted):
|
||||
actual = binding.os.fstat
|
||||
inode = binding.ADMIN_TOKEN_FILE.stat().st_ino
|
||||
|
||||
def metadata(fd):
|
||||
info = actual(fd)
|
||||
if info.st_ino == inode:
|
||||
fields = list(info)
|
||||
fields[4] = owner
|
||||
return os.stat_result(fields)
|
||||
return info
|
||||
|
||||
monkeypatch.setattr(binding.os, 'fstat', metadata)
|
||||
if accepted:
|
||||
assert invoke(binding)['status'] == 'configured'
|
||||
else:
|
||||
with pytest.raises(binding.StatisticsBindingError, match='file_untrusted'):
|
||||
invoke(binding)
|
||||
assert not binding.TOKEN_FILE.exists()
|
||||
|
||||
|
||||
def test_parent_swap_cannot_redirect_a_root_read(binding, monkeypatch):
|
||||
actual_open = binding.os.open
|
||||
original = binding.PB_ENV.read_bytes()
|
||||
moved = binding.PB_SOURCE.with_name('moved-brain')
|
||||
attacker = binding.PB_SOURCE.with_name('attacker')
|
||||
attacker.mkdir()
|
||||
(attacker / '.env').write_bytes(b'ATTACKER=must-not-be-read\n')
|
||||
(attacker / '.env').chmod(0o600)
|
||||
swapped = False
|
||||
|
||||
def swap(path, flags, *args, **kwargs):
|
||||
nonlocal swapped
|
||||
if path == '.env' and 'dir_fd' in kwargs and not swapped:
|
||||
swapped = True
|
||||
binding.PB_SOURCE.rename(moved)
|
||||
binding.PB_SOURCE.symlink_to(attacker, target_is_directory=True)
|
||||
return actual_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(binding.os, 'open', swap)
|
||||
assert binding._read(binding.PB_ENV, binding.MAX_ENV_BYTES) == original
|
||||
assert (attacker / '.env').read_bytes() == b'ATTACKER=must-not-be-read\n'
|
||||
with pytest.raises(OSError):
|
||||
binding._read(binding.PB_ENV, binding.MAX_ENV_BYTES)
|
||||
|
||||
|
||||
def test_parent_swap_cannot_redirect_atomic_replace(binding, monkeypatch):
|
||||
actual_replace = binding.os.replace
|
||||
before = binding.PB_ENV.read_bytes()
|
||||
updated = before + b'APPENDED=private\n'
|
||||
moved = binding.PB_SOURCE.with_name('moved-brain')
|
||||
attacker = binding.PB_SOURCE.with_name('attacker')
|
||||
attacker.mkdir()
|
||||
(attacker / '.env').write_bytes(b'ATTACKER=must-not-be-overwritten\n')
|
||||
(attacker / '.env').chmod(0o600)
|
||||
|
||||
def swap(source, destination, **kwargs):
|
||||
assert source.startswith('.statistics-')
|
||||
assert destination == '.env'
|
||||
assert kwargs['src_dir_fd'] == kwargs['dst_dir_fd']
|
||||
binding.PB_SOURCE.rename(moved)
|
||||
binding.PB_SOURCE.symlink_to(attacker, target_is_directory=True)
|
||||
return actual_replace(source, destination, **kwargs)
|
||||
|
||||
monkeypatch.setattr(binding.os, 'replace', swap)
|
||||
binding._atomic(binding.PB_ENV, updated, before)
|
||||
assert (moved / '.env').read_bytes() == updated
|
||||
assert (attacker / '.env').read_bytes() == b'ATTACKER=must-not-be-overwritten\n'
|
||||
with pytest.raises(OSError):
|
||||
binding._read(binding.PB_ENV, binding.MAX_ENV_BYTES)
|
||||
|
||||
|
||||
def test_parent_swap_cannot_replace_the_authentication_secret(binding, monkeypatch):
|
||||
secret = b'original-administration-capability-1234567890'
|
||||
binding.ADMIN_TOKEN_FILE.write_bytes(secret)
|
||||
actual_open = binding.os.open
|
||||
moved = binding.APPOPS_DATA.with_name('moved-appops')
|
||||
attacker = binding.APPOPS_DATA.with_name('attacker-appops')
|
||||
attacker.mkdir()
|
||||
(attacker / 'mcp-token').write_bytes(b'attacker-chosen-authentication-capability-12345')
|
||||
(attacker / 'mcp-token').chmod(0o600)
|
||||
swapped = False
|
||||
|
||||
def swap(path, flags, *args, **kwargs):
|
||||
nonlocal swapped
|
||||
if path == 'mcp-token' and 'dir_fd' in kwargs and not swapped:
|
||||
swapped = True
|
||||
binding.APPOPS_DATA.rename(moved)
|
||||
binding.APPOPS_DATA.symlink_to(attacker, target_is_directory=True)
|
||||
return actual_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(binding.os, 'open', swap)
|
||||
assert binding.read_administration_secret() == secret
|
||||
assert binding.read_administration_secret() is None
|
||||
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from mcp import Client
|
||||
|
||||
from backend.app.core.config import Settings
|
||||
from backend.app.core.security import require_service_request
|
||||
from backend.app.runner_statistics_mcp import RUNNER_STATISTICS_TOOL, create_runner_statistics_mcp
|
||||
|
||||
|
||||
STATISTICS_TOKEN = "runner-statistics-token-that-is-distinct-and-long"
|
||||
GOVERNED_MCP_TOKEN = "governed-operations-token-that-is-distinct-and-long"
|
||||
|
||||
|
||||
def _request(authorization: str | None = None):
|
||||
from starlette.requests import Request
|
||||
|
||||
headers = [(b"authorization", authorization.encode("ascii"))] if authorization else []
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp-runner-statistics/",
|
||||
"headers": headers,
|
||||
"client": ("127.0.0.1", 12345),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _snapshot() -> dict[str, object]:
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"status": "healthy",
|
||||
"scope": "ci-fleet",
|
||||
"generatedAt": "2026-10-01T12:00:00+00:00",
|
||||
"budget": {
|
||||
"cpuLimit": 8,
|
||||
"memoryLimitBytes": 17_179_869_184,
|
||||
"activeJobLimit": 2,
|
||||
"heavyJobLimit": 1,
|
||||
"reservedCpu": 1,
|
||||
"reservedMemoryBytes": 2_147_483_648,
|
||||
"activeJobs": 0,
|
||||
"heavyJobs": 0,
|
||||
},
|
||||
"host": {
|
||||
"cpuBusyPercent": None,
|
||||
"loadRatio": None,
|
||||
"availableMemoryBytes": None,
|
||||
"cacheFreeBytes": None,
|
||||
},
|
||||
"profiles": [],
|
||||
"window": {
|
||||
"startAt": None,
|
||||
"endAt": None,
|
||||
"completedJobs": 0,
|
||||
"samples": 0,
|
||||
"coverage": "unavailable",
|
||||
},
|
||||
"history": [],
|
||||
}
|
||||
|
||||
|
||||
def test_separate_mcp_catalog_and_tool_are_read_only():
|
||||
server, _http_app = create_runner_statistics_mcp(
|
||||
snapshot_provider=_snapshot,
|
||||
allowed_hosts=["testserver"],
|
||||
allowed_origins=[],
|
||||
)
|
||||
|
||||
async def exercise() -> None:
|
||||
async with Client(server) as client:
|
||||
listing = await client.list_tools()
|
||||
assert [tool.name for tool in listing.tools] == [RUNNER_STATISTICS_TOOL]
|
||||
tool = listing.tools[0]
|
||||
assert tool.annotations.read_only_hint is True
|
||||
assert tool.annotations.destructive_hint is False
|
||||
result = await client.call_tool(RUNNER_STATISTICS_TOOL, {})
|
||||
assert result.structured_content["scope"] == "ci-fleet"
|
||||
unknown = await client.call_tool("container.restart", {"target": "anything"})
|
||||
assert unknown.is_error is True
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_dedicated_service_auth_requires_strong_distinct_token(monkeypatch):
|
||||
from backend.app.core import security
|
||||
from backend.app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(security, "settings", settings)
|
||||
monkeypatch.setattr(settings, "read_runner_statistics_token", lambda: STATISTICS_TOKEN)
|
||||
monkeypatch.setattr(settings, "mcp_token", GOVERNED_MCP_TOKEN)
|
||||
assert require_service_request(_request(f"Bearer {STATISTICS_TOKEN}"), "mcp_runner_statistics") == "mcp_runner_statistics"
|
||||
|
||||
with pytest.raises(HTTPException) as wrong:
|
||||
require_service_request(_request(f"Bearer {GOVERNED_MCP_TOKEN}"), "mcp_runner_statistics")
|
||||
assert wrong.value.status_code == 403
|
||||
|
||||
monkeypatch.setattr(settings, "read_runner_statistics_token", lambda: GOVERNED_MCP_TOKEN)
|
||||
with pytest.raises(HTTPException) as collision:
|
||||
require_service_request(_request(f"Bearer {GOVERNED_MCP_TOKEN}"), "mcp_runner_statistics")
|
||||
assert collision.value.status_code == 503
|
||||
|
||||
monkeypatch.setattr(settings, "read_runner_statistics_token", lambda: "short")
|
||||
with pytest.raises(HTTPException) as weak:
|
||||
require_service_request(_request("Bearer short"), "mcp_runner_statistics")
|
||||
assert weak.value.status_code == 503
|
||||
|
||||
|
||||
def test_statistics_token_file_is_private_and_uses_data_dir_default(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.delenv("APPOPS_RUNNER_STATISTICS_TOKEN_FILE", raising=False)
|
||||
monkeypatch.delenv("APPOPS_RUNNER_STATISTICS_TOKEN", raising=False)
|
||||
token_path = tmp_path / "runner-statistics-token"
|
||||
token_path.write_text(STATISTICS_TOKEN, encoding="utf-8")
|
||||
if __import__("os").name != "nt":
|
||||
token_path.chmod(0o600)
|
||||
|
||||
configured = Settings(data_dir=str(tmp_path), mcp_token=GOVERNED_MCP_TOKEN)
|
||||
|
||||
assert configured.runner_statistics_token_file == str(token_path)
|
||||
assert configured.read_runner_statistics_token() == STATISTICS_TOKEN
|
||||
assert STATISTICS_TOKEN not in str(configured.redact_env_preview())
|
||||
|
||||
|
||||
def test_statistics_token_reader_rejects_oversized_and_symlink_files(tmp_path: Path):
|
||||
from backend.app.core.config import _private_secret_file
|
||||
|
||||
oversized = tmp_path / "oversized-token"
|
||||
oversized.write_text("x" * 513, encoding="utf-8")
|
||||
assert _private_secret_file(str(oversized)) is None
|
||||
|
||||
target = tmp_path / "target-token"
|
||||
target.write_text(STATISTICS_TOKEN, encoding="utf-8")
|
||||
if __import__("os").name != "nt":
|
||||
target.chmod(0o600)
|
||||
linked = tmp_path / "linked-token"
|
||||
try:
|
||||
linked.symlink_to(target)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlink creation is unavailable on this host")
|
||||
assert _private_secret_file(str(linked)) is None
|
||||
|
||||
|
||||
def test_token_file_can_be_provisioned_after_app_start_without_restart(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.delenv("APPOPS_RUNNER_STATISTICS_TOKEN_FILE", raising=False)
|
||||
token_path = tmp_path / "runner-statistics-token"
|
||||
configured = Settings(data_dir=str(tmp_path), mcp_token=GOVERNED_MCP_TOKEN)
|
||||
|
||||
assert configured.read_runner_statistics_token() is None
|
||||
token_path.write_text(STATISTICS_TOKEN, encoding="utf-8")
|
||||
if __import__("os").name != "nt":
|
||||
token_path.chmod(0o600)
|
||||
assert configured.read_runner_statistics_token() == STATISTICS_TOKEN
|
||||
|
||||
|
||||
def test_plaintext_environment_value_is_not_a_runner_statistics_credential(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("APPOPS_RUNNER_STATISTICS_TOKEN", STATISTICS_TOKEN)
|
||||
monkeypatch.setenv("APPOPS_RUNNER_STATISTICS_TOKEN_FILE", str(tmp_path / "missing-token"))
|
||||
configured = Settings(data_dir=str(tmp_path), mcp_token=GOVERNED_MCP_TOKEN)
|
||||
assert configured.read_runner_statistics_token() is None
|
||||
|
||||
|
||||
def test_readonly_transport_adds_only_validated_auth_public_host(monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.runner_statistics_mcp import _readonly_allowed_hosts, _readonly_allowed_origins
|
||||
|
||||
monkeypatch.setattr(settings, "mcp_allowed_hosts", ["localhost:*"])
|
||||
monkeypatch.setattr(settings, "auth_public_url", "https://appops.example.test")
|
||||
monkeypatch.setattr(settings, "frontend_origin", "https://frontend.example.test")
|
||||
assert _readonly_allowed_hosts() == ["localhost:*", "appops.example.test"]
|
||||
assert _readonly_allowed_origins() == ["https://frontend.example.test", "https://appops.example.test"]
|
||||
assert settings.mcp_allowed_hosts == ["localhost:*"]
|
||||
|
||||
monkeypatch.setattr(settings, "auth_public_url", "http://attacker.example.test")
|
||||
assert _readonly_allowed_hosts() == ["localhost:*"]
|
||||
assert _readonly_allowed_origins() == ["https://frontend.example.test"]
|
||||
monkeypatch.setattr(settings, "auth_public_url", "https://attacker_host.example.test")
|
||||
assert _readonly_allowed_hosts() == ["localhost:*"]
|
||||
|
||||
|
||||
def test_transport_factory_uses_readonly_hosts_without_widening_full_mcp(monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
from mcp.server import MCPServer
|
||||
from starlette.applications import Starlette
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def capture_transport(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return Starlette()
|
||||
|
||||
monkeypatch.setattr(MCPServer, "streamable_http_app", capture_transport)
|
||||
monkeypatch.setattr(settings, "mcp_allowed_hosts", ["localhost:*"])
|
||||
monkeypatch.setattr(settings, "auth_public_url", "https://appops.example.test")
|
||||
create_runner_statistics_mcp()
|
||||
|
||||
transport_security = captured["transport_security"]
|
||||
assert transport_security.allowed_hosts == ["localhost:*", "appops.example.test"]
|
||||
assert settings.mcp_allowed_hosts == ["localhost:*"]
|
||||
|
||||
|
||||
def test_readonly_transport_preserves_explicit_port_and_ipv6(monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.runner_statistics_mcp import _readonly_allowed_hosts
|
||||
|
||||
monkeypatch.setattr(settings, "mcp_allowed_hosts", [])
|
||||
monkeypatch.setattr(settings, "auth_public_url", "https://[2001:db8::1]:9443")
|
||||
assert _readonly_allowed_hosts() == ["[2001:db8::1]:9443"]
|
||||
|
||||
|
||||
def test_main_http_boundary_rejects_cross_tokens_and_hides_missing_endpoint(monkeypatch):
|
||||
from backend.app import main
|
||||
from backend.app.core.config import settings
|
||||
|
||||
def no_session_for_machine_token(_request):
|
||||
raise HTTPException(401, "Sign in with Authentik or Microsoft Entra")
|
||||
|
||||
monkeypatch.setattr(main, "authorize_request", no_session_for_machine_token)
|
||||
monkeypatch.setattr(settings, "mcp_enabled", True)
|
||||
monkeypatch.setattr(settings, "mcp_token", GOVERNED_MCP_TOKEN)
|
||||
monkeypatch.setattr(settings, "read_runner_statistics_token", lambda: STATISTICS_TOKEN)
|
||||
monkeypatch.setattr(settings, "mcp_allowed_hosts", ["testserver"])
|
||||
|
||||
async def exercise() -> None:
|
||||
transport = ASGITransport(app=main.app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
stats_with_main_token = await client.post(
|
||||
"/mcp-runner-statistics/",
|
||||
headers={"Authorization": f"Bearer {GOVERNED_MCP_TOKEN}"},
|
||||
json={},
|
||||
)
|
||||
main_with_stats_token = await client.post(
|
||||
"/mcp/",
|
||||
headers={"Authorization": f"Bearer {STATISTICS_TOKEN}"},
|
||||
json={},
|
||||
)
|
||||
native_api_with_stats_token = await client.get(
|
||||
"/api",
|
||||
headers={"Authorization": f"Bearer {STATISTICS_TOKEN}"},
|
||||
)
|
||||
assert stats_with_main_token.status_code == 403
|
||||
assert main_with_stats_token.status_code == 403
|
||||
assert native_api_with_stats_token.status_code == 401
|
||||
|
||||
monkeypatch.setattr(settings, "read_runner_statistics_token", lambda: None)
|
||||
disabled = await client.post("/mcp-runner-statistics/", json={})
|
||||
assert disabled.status_code == 404
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("main_enabled", "expected"),
|
||||
[(False, ["statistics"]), (True, ["main", "statistics"])],
|
||||
)
|
||||
def test_lifespan_always_runs_readonly_mcp_and_gates_governed_mcp(monkeypatch, main_enabled, expected):
|
||||
from backend.app import main
|
||||
from backend.app.core.config import settings
|
||||
|
||||
entered: list[str] = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def run_manager(name: str):
|
||||
entered.append(name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
entered.remove(name)
|
||||
|
||||
monkeypatch.setattr(main, "appops_mcp", SimpleNamespace(session_manager=SimpleNamespace(run=lambda: run_manager("main"))))
|
||||
monkeypatch.setattr(main, "run_runner_statistics_mcp", lambda: run_manager("statistics"))
|
||||
monkeypatch.setattr(main, "init_db", lambda: None)
|
||||
monkeypatch.setattr(main, "Session", lambda _engine: SimpleNamespace(__enter__=lambda self: self, __exit__=lambda *_args: False))
|
||||
monkeypatch.setattr(main, "restore_request_metrics", lambda _session: None)
|
||||
monkeypatch.setattr(main, "restore_tool_metrics", lambda _session: None)
|
||||
monkeypatch.setattr(main.knowledge_service, "ensure_index_current", lambda _session: None)
|
||||
monkeypatch.setattr(main, "persist_observability", lambda: None)
|
||||
monkeypatch.setattr(main, "set_session_running", lambda _value: None)
|
||||
monkeypatch.setattr(settings, "mcp_enabled", main_enabled)
|
||||
for field in (
|
||||
"notification_interval_seconds",
|
||||
"connector_health_interval_seconds",
|
||||
"observability_persist_interval_seconds",
|
||||
"restore_drill_interval_seconds",
|
||||
"mcp_canary_interval_seconds",
|
||||
"discovery_interval_seconds",
|
||||
):
|
||||
monkeypatch.setattr(settings, field, 0)
|
||||
|
||||
async def exercise() -> None:
|
||||
async with main.lifespan(main.app):
|
||||
assert entered == expected
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from backend.app.db.models import AuditEvent, JobRun
|
||||
from backend.app.services import action_execution, deployment_broker, runner_statistics
|
||||
|
||||
|
||||
def snapshot():
|
||||
at = datetime.now(UTC).isoformat()
|
||||
return {
|
||||
"schemaVersion": 1, "status": "healthy", "scope": "ci-fleet", "generatedAt": at,
|
||||
"budget": {"cpuLimit": 8, "memoryLimitBytes": 16 * 1024**3, "activeJobLimit": 2,
|
||||
"heavyJobLimit": 1, "reservedCpu": 2, "reservedMemoryBytes": 3 * 1024**3,
|
||||
"activeJobs": 0, "heavyJobs": 0},
|
||||
"host": {"cpuBusyPercent": None, "loadRatio": None,
|
||||
"availableMemoryBytes": None, "cacheFreeBytes": None},
|
||||
"profiles": [{"id": "checks", "displayName": "ITWorx CI · Checks",
|
||||
"runnerName": "itworx-ci-checks", "labels": ["itworx-ci-checks"],
|
||||
"status": "capacity_wait", "reason": "host_headroom",
|
||||
"cpuLimit": 1, "memoryLimitBytes": 1536 * 1024**2,
|
||||
"queueDepth": 1, "activeJobs": 0, "cpuUsed": None,
|
||||
"memoryUsedBytes": None, "oldestQueuedSeconds": 12,
|
||||
"completedJobs": 0, "p50WaitSeconds": None, "p95WaitSeconds": None,
|
||||
"p50DurationSeconds": None, "p95DurationSeconds": None}],
|
||||
"window": {"startAt": at, "endAt": at, "completedJobs": 0, "samples": 1,
|
||||
"coverage": "bounded"},
|
||||
"history": [{"at": at, "activeJobs": 0, "queuedJobs": 1,
|
||||
"cpuUsed": None, "memoryUsedBytes": None}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_cache(monkeypatch):
|
||||
monkeypatch.setattr(runner_statistics, "_cached", None)
|
||||
monkeypatch.setattr(runner_statistics, "_cached_at", 0.0)
|
||||
|
||||
|
||||
def test_unknown_measurements_survive_and_private_fields_are_removed():
|
||||
payload = snapshot()
|
||||
payload["rawJobs"] = [{"secret": "fixture-secret"}]
|
||||
payload["profiles"][0]["workspacePath"] = "/private/credentials"
|
||||
result = runner_statistics.sanitize_statistics(payload)
|
||||
assert result["status"] == "healthy"
|
||||
assert result["profiles"][0]["cpuUsed"] is None
|
||||
assert result["profiles"][0]["queueDepth"] == 1
|
||||
assert "rawJobs" not in result
|
||||
assert "workspacePath" not in result["profiles"][0]
|
||||
|
||||
|
||||
def test_unattested_profile_limits_remain_unknown():
|
||||
payload = snapshot()
|
||||
payload["status"] = "degraded"
|
||||
payload["profiles"][0].update(cpuLimit=None, memoryLimitBytes=None)
|
||||
result = runner_statistics.sanitize_statistics(payload)
|
||||
assert result["status"] == "degraded"
|
||||
assert result["profiles"][0]["cpuLimit"] is None
|
||||
assert result["profiles"][0]["memoryLimitBytes"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", [
|
||||
lambda p: p.update(schemaVersion=2),
|
||||
lambda p: p.update(generatedAt="2026-10-01T12:00:00"),
|
||||
lambda p: p.update(generatedAt=(datetime.now(UTC) + timedelta(minutes=1)).isoformat()),
|
||||
lambda p: p["profiles"].append(copy.deepcopy(p["profiles"][0])),
|
||||
lambda p: p["profiles"][0].update(cpuUsed=float("nan")),
|
||||
lambda p: p["profiles"][0].update(activeJobs=True),
|
||||
lambda p: p["profiles"][0].update(runnerName="secret-token"),
|
||||
lambda p: p["profiles"][0].update(labels=["security-validation"]),
|
||||
lambda p: p["profiles"][0].update(reason="Bearer fixture-secret-token"),
|
||||
lambda p: p["window"].update(startAt=None),
|
||||
lambda p: p["history"].append(copy.deepcopy(p["history"][0])),
|
||||
lambda p: p.update(history=[p["history"][0]] * 289),
|
||||
])
|
||||
def test_untrusted_or_ambiguous_statistics_fail_to_honest_unknown(mutation):
|
||||
payload = snapshot()
|
||||
mutation(payload)
|
||||
result = runner_statistics.sanitize_statistics(payload)
|
||||
assert result["status"] == "unavailable"
|
||||
assert result["budget"]["cpuLimit"] is None
|
||||
assert result["profiles"] == []
|
||||
|
||||
|
||||
def test_old_sample_is_degraded_without_retimestamping():
|
||||
payload = snapshot()
|
||||
old = (datetime.now(UTC) - timedelta(minutes=2)).isoformat()
|
||||
payload["generatedAt"] = old
|
||||
payload["history"][0]["at"] = old
|
||||
payload["window"].update(startAt=old, endAt=old)
|
||||
result = runner_statistics.sanitize_statistics(payload)
|
||||
assert result["status"] == "degraded"
|
||||
assert result["generatedAt"] == old
|
||||
|
||||
|
||||
def test_singleflight_cache_and_caller_mutation_isolation(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Broker:
|
||||
def is_configured(self):
|
||||
return True
|
||||
|
||||
def runner_fleet_statistics(self):
|
||||
calls.append(1)
|
||||
return snapshot()
|
||||
|
||||
monkeypatch.setattr(runner_statistics, "DeploymentBrokerClient", Broker)
|
||||
service = runner_statistics.RunnerStatisticsService()
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
results = list(pool.map(lambda _: service.snapshot(), range(8)))
|
||||
assert calls == [1]
|
||||
results[0]["budget"]["cpuLimit"] = 999
|
||||
assert service.snapshot()["budget"]["cpuLimit"] == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured,raises,expected", [
|
||||
(False, False, "not_configured"), (True, True, "unavailable"),
|
||||
])
|
||||
def test_unavailable_backend_never_leaks_error_or_invents_budget(monkeypatch, configured, raises, expected):
|
||||
class Broker:
|
||||
def is_configured(self):
|
||||
return configured
|
||||
|
||||
def runner_fleet_statistics(self):
|
||||
assert raises
|
||||
raise RuntimeError("Bearer fixture-private-secret /private/key")
|
||||
|
||||
monkeypatch.setattr(runner_statistics, "DeploymentBrokerClient", Broker)
|
||||
result = runner_statistics.RunnerStatisticsService().snapshot()
|
||||
assert result["status"] == expected
|
||||
assert result["budget"]["reservedCpu"] is None
|
||||
assert "fixture-private" not in str(result)
|
||||
|
||||
|
||||
def test_broker_statistics_fixed_read_path_has_deadline_and_no_credentials(monkeypatch):
|
||||
calls = []
|
||||
real_client = httpx.Client
|
||||
|
||||
def handle(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(200, json=snapshot())
|
||||
|
||||
def client(**kwargs):
|
||||
assert kwargs == {"timeout": 5, "verify": True, "follow_redirects": False}
|
||||
return real_client(**kwargs, transport=httpx.MockTransport(handle))
|
||||
|
||||
monkeypatch.setattr(deployment_broker.httpx, "Client", client)
|
||||
broker = deployment_broker.DeploymentBrokerClient(base_url="https://controller.test", verify_tls=True)
|
||||
assert broker.runner_fleet_statistics()["schemaVersion"] == 1
|
||||
request = calls[0]
|
||||
assert request.method == "GET"
|
||||
assert str(request.url) == "https://controller.test/v1/runner-fleet/statistics"
|
||||
assert "authorization" not in request.headers
|
||||
assert not any(key.startswith("x-itworx") for key in request.headers)
|
||||
assert broker._runner_fleet_read("/v1/arbitrary")["reason"] == "runner_fleet_path_invalid"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_specialize_broker_caller_cannot_choose_script_revision_or_target(monkeypatch):
|
||||
calls = []
|
||||
broker = deployment_broker.DeploymentBrokerClient()
|
||||
monkeypatch.setattr(broker, "_request", lambda *args, **kwargs: calls.append((args, kwargs)) or {})
|
||||
broker.runner_fleet_specialize(reason="x" * 1100)
|
||||
args, kwargs = calls[0]
|
||||
assert args == ("POST", "/v1/appops/runner-fleet/specialize")
|
||||
assert kwargs["payload"] == {"reason": "x" * 1000}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,success", [("specialized", True), ("already_applied", True),
|
||||
("upgraded", False), ("failed", False)])
|
||||
def test_specialize_execution_persists_actual_success_or_failure(tmp_path, monkeypatch, status, success):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'specialize.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
monkeypatch.setattr(deployment_broker.DeploymentBrokerClient, "runner_fleet_specialize",
|
||||
lambda self, **kwargs: {"status": status, "revision": "a" * 40})
|
||||
with Session(engine) as session:
|
||||
result = action_execution.execute_governed_action(
|
||||
session, action="runner.fleet.specialize", target="tower/runner-fleet-specialized",
|
||||
source="chatgpt-mcp", risk="high", reason="approved fleet specialization",
|
||||
original_policy_decision="allowed",
|
||||
)
|
||||
job = session.get(JobRun, result["jobId"])
|
||||
audit = session.get(AuditEvent, result["auditId"])
|
||||
assert result["success"] is success
|
||||
assert job.status == ("succeeded" if success else "failed")
|
||||
assert audit.result == ("success" if success else "failed")
|
||||
assert audit.target == "tower/runner-fleet-specialized"
|
||||
assert ("verified" in result["message"]) is success
|
||||
|
||||
|
||||
def test_wrong_specialization_target_is_blocked_before_job_or_broker(tmp_path, monkeypatch):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'blocked.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
monkeypatch.setattr(deployment_broker.DeploymentBrokerClient, "runner_fleet_specialize",
|
||||
lambda *args, **kwargs: pytest.fail("must not call broker"))
|
||||
with Session(engine) as session:
|
||||
result = action_execution.execute_governed_action(
|
||||
session, action="runner.fleet.specialize", target="other/runner",
|
||||
source="chatgpt-mcp", risk="high", reason="wrong target",
|
||||
original_policy_decision="allowed",
|
||||
)
|
||||
assert result["policyDecision"] == "blocked"
|
||||
assert session.exec(select(JobRun)).all() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status,success', [('configured', True), ('already_configured', True),
|
||||
('specialized', False), ('failed', False)])
|
||||
def test_statistics_configuration_persists_actual_status_without_secret(tmp_path, monkeypatch, status, success):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'binding.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
monkeypatch.setattr(deployment_broker.DeploymentBrokerClient, 'runner_statistics_configure',
|
||||
lambda self, **kwargs: {'status': status, 'revision': 'a' * 40, 'projectbrainDeliveryRequired': True})
|
||||
with Session(engine) as session:
|
||||
result = action_execution.execute_governed_action(
|
||||
session, action='runner.statistics.configure', target='tower/projectbrain-runner-statistics',
|
||||
source='chatgpt-mcp', risk='high', reason='configure read-only metrics', original_policy_decision='allowed')
|
||||
assert result['success'] is success
|
||||
assert session.get(JobRun, result['jobId']).status == ('succeeded' if success else 'failed')
|
||||
assert session.get(AuditEvent, result['auditId']).target == 'tower/projectbrain-runner-statistics'
|
||||
assert 'token' not in result
|
||||
|
||||
|
||||
def test_statistics_configuration_broker_has_fixed_signed_body(monkeypatch):
|
||||
calls = []
|
||||
broker = deployment_broker.DeploymentBrokerClient()
|
||||
monkeypatch.setattr(broker, '_request', lambda *args, **kwargs: calls.append((args, kwargs)) or {})
|
||||
broker.runner_statistics_configure(reason='configure read-only stats')
|
||||
assert calls[0][0] == ('POST', '/v1/appops/runner-statistics/configure')
|
||||
assert calls[0][1]['payload'] == {'reason': 'configure read-only stats'}
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Exercise the real producer script against Docker's inherited-label shape."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROFILES = ("checks", "backend", "frontend", "security", "browser", "build")
|
||||
# Captured from the SHA-verified immutable amd64 base image config c474d435...
|
||||
BASE_LABELS = {
|
||||
"io.buildah.version": "1.35.0",
|
||||
"org.opencontainers.image.authors": "Gitea",
|
||||
"org.opencontainers.image.created": "2026-10-01T00:01:57Z",
|
||||
"org.opencontainers.image.description": "Official docker images used by Gitea runner to run workflows.",
|
||||
"org.opencontainers.image.documentation": "https://gitea.com/gitea/runner-images/src/branch/main/README.md",
|
||||
"org.opencontainers.image.licenses": "MIT",
|
||||
"org.opencontainers.image.title": "Official Gitea Actions runner images",
|
||||
"org.opencontainers.image.url": "https://gitea.com/gitea/runner-images",
|
||||
"org.opencontainers.image.vendor": "Gitea",
|
||||
"org.opencontainers.image.version": "v26.10.01",
|
||||
}
|
||||
|
||||
|
||||
def _run_producer(tmp_path: Path, mutation: str = ""):
|
||||
repo = tmp_path / "repository"
|
||||
repo.mkdir()
|
||||
shutil.copytree(ROOT / "ops" / "runner-task-images", repo / "ops" / "runner-task-images")
|
||||
code = repo / "ops" / "gitea-deploy-broker"
|
||||
code.mkdir()
|
||||
shutil.copyfile(ROOT / "ops" / "gitea-deploy-broker" / "runner_profiles.py", code / "runner_profiles.py")
|
||||
scripts = repo / "scripts"
|
||||
scripts.mkdir()
|
||||
shutil.copyfile(
|
||||
ROOT / "scripts" / "normalize_ci_task_image_archive.py",
|
||||
scripts / "normalize_ci_task_image_archive.py",
|
||||
)
|
||||
for args in (
|
||||
["init", "-q"],
|
||||
["add", "ops", "scripts"],
|
||||
["-c", "user.name=Producer test", "-c", "user.email=producer-test@localhost", "commit", "-qm", "fixture"],
|
||||
):
|
||||
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
|
||||
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
||||
tree = subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"], cwd=repo, text=True).strip()
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
docker = fake_bin / "docker"
|
||||
docker.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import hashlib,io,json,os,sys,tarfile\n"
|
||||
"from pathlib import Path\n"
|
||||
"a=sys.argv[1:]; p=Path(os.environ['FAKE_DOCKER_ROWS'])\n"
|
||||
"layer_buffer=io.BytesIO()\n"
|
||||
"with tarfile.open(fileobj=layer_buffer,mode='w:') as layer_tar:\n"
|
||||
" payload=b'producer fixture layer'; member=tarfile.TarInfo('fixture.txt'); member.size=len(payload); layer_tar.addfile(member,io.BytesIO(payload))\n"
|
||||
"layer=layer_buffer.getvalue(); layer_sha=hashlib.sha256(layer).hexdigest()\n"
|
||||
"if a==['info']: sys.exit(0)\n"
|
||||
"if a[0]=='build':\n"
|
||||
" builds=Path(os.environ['FAKE_DOCKER_BUILD_ARGS'])\n"
|
||||
" argv=json.loads(builds.read_text()) if builds.exists() else []\n"
|
||||
" argv.append(a); builds.write_text(json.dumps(argv))\n"
|
||||
" pairs=[a[i+1].split('=',1) for i,x in enumerate(a) if x=='--build-arg']\n"
|
||||
" b=dict(pairs); profile=b['PROFILE']; ref=a[a.index('--tag')+1]\n"
|
||||
f" labels={BASE_LABELS!r}.copy()\n"
|
||||
" labels.update({'org.opencontainers.image.source':'https://gitea.example.com/Jens/Unraid-AppOps-Gateway',"
|
||||
"'org.opencontainers.image.revision':b['SOURCE_REVISION'],'tech.itworx.source-tree':b['SOURCE_TREE'],'tech.itworx.ci.profile':profile})\n"
|
||||
" mutation=os.environ.get('FAKE_LABEL_MUTATION','')\n"
|
||||
" if mutation=='extra': labels['unexpected.label']='unexpected'\n"
|
||||
" if mutation=='missing-inherited': labels.pop('org.opencontainers.image.vendor')\n"
|
||||
" if mutation=='changed-inherited': labels['org.opencontainers.image.created']='different'\n"
|
||||
" if mutation=='wrong-source': labels['org.opencontainers.image.source']='https://example.invalid'\n"
|
||||
" if mutation=='missing-profile': labels.pop('tech.itworx.ci.profile')\n"
|
||||
" config={'architecture':'amd64','os':'linux','config':{'User':'ubuntu','Labels':labels},'rootfs':{'type':'layers','diff_ids':['sha256:'+layer_sha]}}\n"
|
||||
" config_bytes=json.dumps(config,sort_keys=True,separators=(',',':')).encode(); image_id='sha256:'+hashlib.sha256(config_bytes).hexdigest()\n"
|
||||
" rows=json.loads(p.read_text()) if p.exists() else []\n"
|
||||
" rows.append({'Id':image_id,'Os':'linux','Architecture':'amd64','RepoTags':[ref],'Config':{'User':'ubuntu','Labels':labels}})\n"
|
||||
" configs=Path(os.environ['FAKE_DOCKER_CONFIGS']); configs.mkdir(exist_ok=True); (configs/(profile+'.json')).write_bytes(config_bytes)\n"
|
||||
" p.write_text(json.dumps(rows)); sys.exit(0)\n"
|
||||
"if a[:2]==['image','inspect']: print(p.read_text()); sys.exit(0)\n"
|
||||
"if a[:2]==['image','save']:\n"
|
||||
" selected=set(a[a.index('--output')+2:]); rows=json.loads(p.read_text()); members={'blobs/sha256/'+layer_sha:layer}; manifest=[]\n"
|
||||
" for row in rows:\n"
|
||||
" if row['RepoTags'][0] not in selected: continue\n"
|
||||
" reference=row['RepoTags'][0]; profile=reference.split('itworx-ci-',1)[1].split(':sha-',1)[0]; config=(Path(os.environ['FAKE_DOCKER_CONFIGS'])/(profile+'.json')).read_bytes(); config_sha=hashlib.sha256(config).hexdigest(); config_path='blobs/sha256/'+config_sha; members[config_path]=config\n"
|
||||
" manifest.append({'Config':config_path,'RepoTags':[reference],'Layers':['blobs/sha256/'+layer_sha],'LayerSources':{'sha256:'+layer_sha:{'mediaType':'application/vnd.oci.image.layer.v1.tar','size':len(layer),'digest':'sha256:'+layer_sha}}})\n"
|
||||
" members['manifest.json']=json.dumps(manifest,separators=(',',':')).encode(); members['oci-layout']=json.dumps({'imageLayoutVersion':'1.0.0'}).encode(); members['index.json']=json.dumps({'schemaVersion':2,'manifests':[]}).encode()\n"
|
||||
" with tarfile.open(a[a.index('--output')+1],mode='w:') as archive:\n"
|
||||
" for name,payload in members.items():\n"
|
||||
" member=tarfile.TarInfo(name); member.size=len(payload); archive.addfile(member,io.BytesIO(payload))\n"
|
||||
" sys.exit(0)\n"
|
||||
"sys.exit('unexpected Docker invocation')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
docker.chmod(0o755)
|
||||
output = tmp_path / "output"
|
||||
env = {
|
||||
**os.environ,
|
||||
"PATH": str(fake_bin) + os.pathsep + os.environ["PATH"],
|
||||
"GITHUB_EVENT_NAME": "push",
|
||||
"GITHUB_REF": "refs/heads/main",
|
||||
"GITHUB_REPOSITORY": "Jens/Unraid-AppOps-Gateway",
|
||||
"FAKE_DOCKER_ROWS": str(tmp_path / "docker-rows.json"),
|
||||
"FAKE_DOCKER_BUILD_ARGS": str(tmp_path / "docker-build-args.json"),
|
||||
"FAKE_DOCKER_CONFIGS": str(tmp_path / "docker-configs"),
|
||||
"FAKE_LABEL_MUTATION": mutation,
|
||||
}
|
||||
Path(env["FAKE_DOCKER_CONFIGS"]).mkdir()
|
||||
result = subprocess.run(
|
||||
["bash", str(ROOT / "scripts" / "build_ci_task_images.sh"), revision, "123", str(output)],
|
||||
cwd=repo,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return result, output, revision, tree
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
||||
def test_producer_accepts_exact_pinned_base_metadata_and_keeps_provenance_manifest(tmp_path):
|
||||
result, output, revision, tree = _run_producer(tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
build_args = json.loads((tmp_path / "docker-build-args.json").read_text())
|
||||
assert len(build_args) == len(PROFILES)
|
||||
expected_limits = {
|
||||
"--cpu-period=100000",
|
||||
"--cpu-quota=300000",
|
||||
"--memory=5120m",
|
||||
"--memory-swap=5120m",
|
||||
"--cgroup-parent=docker",
|
||||
"--rm=true",
|
||||
"--force-rm=true",
|
||||
}
|
||||
profiles = [
|
||||
next(
|
||||
args[index + 1]
|
||||
for index, arg in enumerate(args[:-1])
|
||||
if arg == "--build-arg" and args[index + 1].startswith("PROFILE=")
|
||||
)
|
||||
for args in build_args
|
||||
]
|
||||
assert profiles == [f"PROFILE={profile}" for profile in PROFILES]
|
||||
for args in build_args:
|
||||
resource_flags = [
|
||||
arg
|
||||
for arg in args
|
||||
if arg.startswith(
|
||||
(
|
||||
"--cpu-period",
|
||||
"--cpu-quota",
|
||||
"--memory",
|
||||
"--cgroup-parent",
|
||||
"--rm",
|
||||
"--force-rm",
|
||||
"--cpus",
|
||||
)
|
||||
)
|
||||
]
|
||||
assert set(resource_flags) == expected_limits
|
||||
assert len(resource_flags) == len(expected_limits)
|
||||
manifest = json.loads((output / "task-image-manifest.json").read_text())
|
||||
assert manifest["sourceRevision"] == revision
|
||||
assert manifest["sourceTree"] == tree
|
||||
assert tuple(row["profile"] for row in manifest["images"]) == PROFILES
|
||||
for row in manifest["images"]:
|
||||
assert row["labels"] == {
|
||||
"org.opencontainers.image.source": "https://gitea.example.com/Jens/Unraid-AppOps-Gateway",
|
||||
"org.opencontainers.image.revision": revision,
|
||||
"tech.itworx.source-tree": tree,
|
||||
"tech.itworx.ci.profile": row["profile"],
|
||||
}
|
||||
archive_path = output / "task-images.tar"
|
||||
archive_bytes = archive_path.read_bytes()
|
||||
assert manifest["dockerTarSha256"] == hashlib.sha256(archive_bytes).hexdigest()
|
||||
assert manifest["dockerTarBytes"] == len(archive_bytes)
|
||||
inspected = {
|
||||
image["RepoTags"][0]: image
|
||||
for image in json.loads((tmp_path / "docker-rows.json").read_text())
|
||||
}
|
||||
with tarfile.open(archive_path, mode="r:") as archive:
|
||||
members = {member.name: member for member in archive}
|
||||
normalized = json.loads(archive.extractfile(members["manifest.json"]).read())
|
||||
assert len(normalized) == len(PROFILES)
|
||||
assert all(set(item) == {"Config", "RepoTags", "Layers"} for item in normalized)
|
||||
for item, row in zip(normalized, manifest["images"], strict=True):
|
||||
assert item["RepoTags"] == [row["reference"]]
|
||||
assert item["Config"] == row["imageId"].removeprefix("sha256:") + ".json"
|
||||
assert len(item["Layers"]) == 1
|
||||
image = inspected[row["reference"]]
|
||||
config_bytes = archive.extractfile(members[item["Config"]]).read()
|
||||
config = json.loads(config_bytes)
|
||||
assert "sha256:" + hashlib.sha256(config_bytes).hexdigest() == image["Id"] == row["imageId"]
|
||||
assert config["config"]["User"] == "ubuntu"
|
||||
assert config["config"]["Labels"] == image["Config"]["Labels"] == {
|
||||
**BASE_LABELS,
|
||||
**row["labels"],
|
||||
}
|
||||
assert config["rootfs"]["type"] == "layers"
|
||||
assert len(config["rootfs"]["diff_ids"]) == 1
|
||||
diff_id = config["rootfs"]["diff_ids"][0]
|
||||
layer_name = item["Layers"][0]
|
||||
assert layer_name == diff_id.removeprefix("sha256:") + "/layer.tar"
|
||||
layer_bytes = archive.extractfile(members[layer_name]).read()
|
||||
assert "sha256:" + hashlib.sha256(layer_bytes).hexdigest() == diff_id
|
||||
assert not any(name.startswith("blobs/") for name in members)
|
||||
assert not (output / ".task-images.raw.tar").exists()
|
||||
assert not (output / "images.inspect.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="producer is a POSIX shell script")
|
||||
@pytest.mark.parametrize("mutation", ("extra", "missing-inherited", "changed-inherited", "wrong-source", "missing-profile"))
|
||||
def test_producer_rejects_any_untrusted_image_label_change(tmp_path, mutation):
|
||||
result, output, _revision, _tree = _run_producer(tmp_path, mutation)
|
||||
assert result.returncode != 0
|
||||
expected_error = (
|
||||
"task image archive normalization failed: image inspection provenance invalid"
|
||||
if mutation in {"wrong-source", "missing-profile"}
|
||||
else "image identity verification failed"
|
||||
)
|
||||
assert expected_error in result.stderr
|
||||
assert not (output / "task-image-manifest.json").exists()
|
||||
Reference in new issue
Block a user