Publish curated ITWorx_MCP_Hub source from 2aa63ba527a98b38b4d28f2a1bb85b1b1dbe7f6d
This commit is contained in:
1 parent
49627c7a7c
commit
378ae83d30
22 files changed
+4055
-81
No files matched your search
@@ -1,3 +1,3 @@
|
|||||||
# Public source export
|
# Public source export
|
||||||
|
|
||||||
This source snapshot was generated from the private canonical repository at revision `8aef76ab42f1ff10ee5a1b3ad94a184120687e47`. 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 `2aa63ba527a98b38b4d28f2a1bb85b1b1dbe7f6d`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||||
+65
-25
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"sourceRevision": "8aef76ab42f1ff10ee5a1b3ad94a184120687e47",
|
"sourceRevision": "2aa63ba527a98b38b4d28f2a1bb85b1b1dbe7f6d",
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": ".dockerignore",
|
"path": ".dockerignore",
|
||||||
@@ -2037,6 +2037,21 @@
|
|||||||
"sha256": "90d0a0017a33ac6e25b74b42f7209d54fcb1424854a56a4d22332fc8af65044c",
|
"sha256": "90d0a0017a33ac6e25b74b42f7209d54fcb1424854a56a4d22332fc8af65044c",
|
||||||
"bytes": 702
|
"bytes": 702
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "infrastructure/images/contextforge/candidate/build-tools.lock",
|
||||||
|
"sha256": "4ba5f91f6473fc4fc9971b2353629203099d9409f537895972e60a65890af02f",
|
||||||
|
"bytes": 741
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "infrastructure/images/contextforge/candidate/locks.json",
|
||||||
|
"sha256": "28029214650a4d864ff1f6cfdecf4f2c2e1f8438e3f792b35ce52fda90d2d896",
|
||||||
|
"bytes": 3132
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "infrastructure/images/contextforge/candidate/production-requirements.lock",
|
||||||
|
"sha256": "daaa815ef1ce2e58c60d9bac840f5754a12eab4b18642713e197d3f5f9467c30",
|
||||||
|
"bytes": 131433
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "infrastructure/images/contextforge/compat-image.env",
|
"path": "infrastructure/images/contextforge/compat-image.env",
|
||||||
"sha256": "ff35e0be3f6abfe440fb901fec92219410d3d0aef78bc75b7cb0fb679219a81f",
|
"sha256": "ff35e0be3f6abfe440fb901fec92219410d3d0aef78bc75b7cb0fb679219a81f",
|
||||||
@@ -2047,15 +2062,30 @@
|
|||||||
"sha256": "d6f502ecb2f98d929c7c050a67313e3802271ab123cfedd6b3ea7c886a558560",
|
"sha256": "d6f502ecb2f98d929c7c050a67313e3802271ab123cfedd6b3ea7c886a558560",
|
||||||
"bytes": 3095
|
"bytes": 3095
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "infrastructure/images/contextforge/os-bases/Dockerfile.builder",
|
||||||
|
"sha256": "0c7962ca473f7b61073e4e57b0211fc84cdedc1a89effcda8d9e4ee796a0a0e0",
|
||||||
|
"bytes": 1466
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "infrastructure/images/contextforge/os-bases/Dockerfile.runtime",
|
||||||
|
"sha256": "5ff86c30ed089fc0d74f1b05190059990bf1b1870b66fa33f3f6d5a3b2414848",
|
||||||
|
"bytes": 1618
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "infrastructure/images/contextforge/runtime-contract.json",
|
"path": "infrastructure/images/contextforge/runtime-contract.json",
|
||||||
"sha256": "11345ea89e229e7bf164eb037b525b685a5dfb143c08a220544c0b6cdde411d9",
|
"sha256": "11345ea89e229e7bf164eb037b525b685a5dfb143c08a220544c0b6cdde411d9",
|
||||||
"bytes": 700
|
"bytes": 700
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "infrastructure/images/contextforge/security-requirements.txt",
|
||||||
|
"sha256": "1fdf9cd965f70ce32364bfdb538a5d93cc24559b4a4b69fcd35fb1190d994ecb",
|
||||||
|
"bytes": 187
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "infrastructure/images/docker-read-broker/Dockerfile",
|
"path": "infrastructure/images/docker-read-broker/Dockerfile",
|
||||||
"sha256": "453d0024e175c77105544756c98f890a7839be2c8475ecbadbff5dddd0f91300",
|
"sha256": "17d49ab4af92d0c26a0a62491cc5c296ad57d9f758f031d0d57e343361a079cd",
|
||||||
"bytes": 1153
|
"bytes": 1391
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "infrastructure/images/docker-read-broker/broker_health.py",
|
"path": "infrastructure/images/docker-read-broker/broker_health.py",
|
||||||
@@ -2069,8 +2099,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "infrastructure/images/observability/Dockerfile",
|
"path": "infrastructure/images/observability/Dockerfile",
|
||||||
"sha256": "27082e021d0f808cb83c8a9f8033b1bebedbbf8ceec9d2407e57b079d0b98088",
|
"sha256": "c9804d64b0e137721ec5c57e1a7956483a38109dc3063262b19d94a45991e6f9",
|
||||||
"bytes": 1012
|
"bytes": 1250
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "infrastructure/images/observability/collector.py",
|
"path": "infrastructure/images/observability/collector.py",
|
||||||
@@ -2084,8 +2114,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "infrastructure/images/python-service/Dockerfile",
|
"path": "infrastructure/images/python-service/Dockerfile",
|
||||||
"sha256": "f00cf3e66c3918a362b02a1dce1efc4ebe383db27fc06474b697e1b220098f72",
|
"sha256": "011a5b3d2900b996ab3f522d9de09e79cfc1cae76c91aa6c03994c660f0965e3",
|
||||||
"bytes": 2219
|
"bytes": 2457
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "infrastructure/images/python-service/health_server.py",
|
"path": "infrastructure/images/python-service/health_server.py",
|
||||||
@@ -2389,8 +2419,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/build_contextforge_compat_image.sh",
|
"path": "scripts/build_contextforge_compat_image.sh",
|
||||||
"sha256": "df0fa058b3837add913de32da29bea7a464bcf7cfadff730b3b61edfb431f4da",
|
"sha256": "b77dee65d61ff2425a1f4de75e38d854c738ae21713ecd992c5609dde866618e",
|
||||||
"bytes": 8234
|
"bytes": 17296
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/check_boundaries.py",
|
"path": "scripts/check_boundaries.py",
|
||||||
@@ -2404,14 +2434,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/check_cve_exceptions.py",
|
"path": "scripts/check_cve_exceptions.py",
|
||||||
"sha256": "3a140095f2cb6ec7c23760503410236dd644d780c7456d3248276e1ce9bf507d",
|
"sha256": "6e72a325f0c4733db0be84c2afb90e57dd15060101f2b0d6d3509d6a5031e936",
|
||||||
"bytes": 6761
|
"bytes": 14228
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/contextforge_bootstrap.py",
|
"path": "scripts/contextforge_bootstrap.py",
|
||||||
"sha256": "e7ae621d1803f98ce7022c673435088a24863761448b162f8479d403eb63b225",
|
"sha256": "e7ae621d1803f98ce7022c673435088a24863761448b162f8479d403eb63b225",
|
||||||
"bytes": 12239
|
"bytes": 12239
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/contextforge_candidate_recipe.py",
|
||||||
|
"sha256": "d33f5c11d10ab2987128adc264a20ef4dc9913b80c9e55563a1908b2c7154962",
|
||||||
|
"bytes": 21090
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/contextforge_runtime_contract.py",
|
"path": "scripts/contextforge_runtime_contract.py",
|
||||||
"sha256": "3a93aa6b278a974e7b09688f34ecb9016f298cc859c724e208ff418e5336d6a3",
|
"sha256": "3a93aa6b278a974e7b09688f34ecb9016f298cc859c724e208ff418e5336d6a3",
|
||||||
@@ -2424,8 +2459,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/draft_cve_exceptions.py",
|
"path": "scripts/draft_cve_exceptions.py",
|
||||||
"sha256": "aaebac11f274002eb86562f3ca62a4601bd6eee78b8cb987934db3c407cba000",
|
"sha256": "15bc47c90ed7f83d790219555d31939e88e92a0cffe02eb0b2da04035eff08b3",
|
||||||
"bytes": 4176
|
"bytes": 6798
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "scripts/export-public-mirror.py",
|
"path": "scripts/export-public-mirror.py",
|
||||||
@@ -3029,8 +3064,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/fixtures/wp200-compose-config.env",
|
"path": "tests/fixtures/wp200-compose-config.env",
|
||||||
"sha256": "beb0865fd116bdf1fae350803735ec15f273f326b0806b5c9bbf330807673aa6",
|
"sha256": "4c341dd335e4e32d9f25ca5cc142ad4013d6c1e7c4012dd755840b1e5458441c",
|
||||||
"bytes": 2208
|
"bytes": 2326
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_appops_connector.py",
|
"path": "tests/test_appops_connector.py",
|
||||||
@@ -3074,8 +3109,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_compose.py",
|
"path": "tests/test_compose.py",
|
||||||
"sha256": "2d02f6106ae5bf7cd28e7e5202be7ed721dd9f527f2228ba7df5dcb4a7d9be87",
|
"sha256": "b0cf2be5b0b53aa6698a30568ad7999e113daa37bd831aa535d1866a0d373fc6",
|
||||||
"bytes": 16814
|
"bytes": 18737
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_contextforge.py",
|
"path": "tests/test_contextforge.py",
|
||||||
@@ -3087,10 +3122,15 @@
|
|||||||
"sha256": "c2aba6c99e221b31ea0f562b150e60894051d5aa98ae8d51d8ac76ad7147a533",
|
"sha256": "c2aba6c99e221b31ea0f562b150e60894051d5aa98ae8d51d8ac76ad7147a533",
|
||||||
"bytes": 15850
|
"bytes": 15850
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "tests/test_contextforge_candidate_recipe.py",
|
||||||
|
"sha256": "379a240d4ca41dcd04e514fa721c692cd5b6cf23d0d5945f99039b24e2ef3474",
|
||||||
|
"bytes": 17265
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_contextforge_compat_image_contract.py",
|
"path": "tests/test_contextforge_compat_image_contract.py",
|
||||||
"sha256": "430332785d64ddab20e42727c00aeae3a54ffcdb9f80a56dfe5441e09791844a",
|
"sha256": "d415e2bc7b64144fb9c3f5b0115ab2e8b828516053bc0bbdce3b46e710f87b56",
|
||||||
"bytes": 4480
|
"bytes": 24559
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_contextforge_integration.py",
|
"path": "tests/test_contextforge_integration.py",
|
||||||
@@ -3099,8 +3139,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_contextforge_production_image_contract.py",
|
"path": "tests/test_contextforge_production_image_contract.py",
|
||||||
"sha256": "fc6fe95a81e2e2265c82f2b3ed679e9d7791735070c9bfcf0926005215307462",
|
"sha256": "45a656050f73b6e64a626c2407f5ba6fffcfe25886bb42cd408c57a8d64e2633",
|
||||||
"bytes": 1787
|
"bytes": 2257
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_contextforge_promotion_preflight_contract.py",
|
"path": "tests/test_contextforge_promotion_preflight_contract.py",
|
||||||
@@ -3244,8 +3284,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_ingestion_catalog_compose.py",
|
"path": "tests/test_ingestion_catalog_compose.py",
|
||||||
"sha256": "835bc9218a27aa26ace1882daf0f5142a54e135ead7575c0f88d89d0272dde24",
|
"sha256": "60aa6957947ba96974ed0a6941c5e40b8871ec612919f0812e1f4bc1b427a0da",
|
||||||
"bytes": 10746
|
"bytes": 10888
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_ingestion_connector.py",
|
"path": "tests/test_ingestion_connector.py",
|
||||||
@@ -3409,8 +3449,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_wp220_cve_exceptions.py",
|
"path": "tests/test_wp220_cve_exceptions.py",
|
||||||
"sha256": "afdd0aebfca0c2bb379edbd91d700f27fb6684f85a3b77ed339e7f7f901b03b4",
|
"sha256": "ceae7f0a167ae8083be4daee0e291b2cb669c90877e2a88113861e85d038b07b",
|
||||||
"bytes": 6217
|
"bytes": 18080
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "tests/test_wp220_export.py",
|
"path": "tests/test_wp220_export.py",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
packaging==26.2 \
|
||||||
|
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||||
|
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||||
|
pip==26.2.1 \
|
||||||
|
--hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \
|
||||||
|
--hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f
|
||||||
|
setuptools==82.0.1 \
|
||||||
|
--hash=sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9 \
|
||||||
|
--hash=sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb
|
||||||
|
wheel==0.46.2 \
|
||||||
|
--hash=sha256:33ae60725d69eaa249bc1982e739943c23b34b58d51f1cb6253453773aca6e65 \
|
||||||
|
--hash=sha256:3d79e48fde9847618a5a181f3cc35764c349c752e2fe911e65fa17faab9809b0
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"baseImages": [
|
||||||
|
{
|
||||||
|
"arg": "UBI_BASE",
|
||||||
|
"childManifestDigest": "sha256:e23a733abd28376eb3328676a3ab1e6d153122228110de0c881bb5f3c38a4483",
|
||||||
|
"imageId": "sha256:c6bb70a7a340ed1047e7cb934aa55b086ee68a5eb9ba6b2ad2e6825c7184a3e8",
|
||||||
|
"parentReference": "registry.access.redhat.com/ubi10@sha256:1e32d829a4de713a835d702d3bc05f0c448a08590796281b7dec181912e9aeb8",
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"reference": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder@sha256:e23a733abd28376eb3328676a3ab1e6d153122228110de0c881bb5f3c38a4483",
|
||||||
|
"rpmInventorySha256": "6a8c0d4f66fc5a38008541ad15614711e7f536cd18c70c02d2eb0691552b88b0",
|
||||||
|
"sourceTag": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder:sha256-c6bb70a7a340ed1047e7cb934aa55b086ee68a5eb9ba6b2ad2e6825c7184a3e8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "NODEJS_IMAGE",
|
||||||
|
"childManifestDigest": "sha256:0f110e68682f4a49188ba4db786eb4f207b119420913fc76bb5ba9512ffbef06",
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"reference": "registry.access.redhat.com/ubi10/nodejs-24@sha256:0f110e68682f4a49188ba4db786eb4f207b119420913fc76bb5ba9512ffbef06",
|
||||||
|
"sourceTag": "registry.access.redhat.com/ubi10/nodejs-24:1784092609"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "UBI_MINIMAL",
|
||||||
|
"childManifestDigest": "sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236",
|
||||||
|
"imageId": "sha256:fd94e2643dfc0401790d0528d3740bb2f0c04709e743f6c774a4e8a38606e40b",
|
||||||
|
"parentReference": "registry.access.redhat.com/ubi10/ubi-minimal@sha256:afbfb5c18c628796fed3ad3122dcf096e0b2dec9dad3fbabe8620de06bc6f078",
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"reference": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime@sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236",
|
||||||
|
"rpmInventorySha256": "a74d60ecb43d1aa561874305d1a9c62ac35c99a14c1dc275ba1656f14df7d034",
|
||||||
|
"sourceTag": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime:sha256-fd94e2643dfc0401790d0528d3740bb2f0c04709e743f6c774a4e8a38606e40b"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buildTools": [
|
||||||
|
"pip==26.2.1",
|
||||||
|
"setuptools==82.0.1",
|
||||||
|
"wheel==0.46.2"
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
"mcp==1.30.0",
|
||||||
|
"PyJWT==2.15.0",
|
||||||
|
"urllib3==2.8.0"
|
||||||
|
],
|
||||||
|
"extras": [
|
||||||
|
"redis",
|
||||||
|
"postgres",
|
||||||
|
"observability",
|
||||||
|
"granian",
|
||||||
|
"plugins",
|
||||||
|
"llmchat"
|
||||||
|
],
|
||||||
|
"freshnessOverrideUtc": "2026-10-02T00:00:00Z",
|
||||||
|
"globalExcludeNewerUtc": "2026-09-22T00:00:00Z",
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"python": "3.12",
|
||||||
|
"requirements": {
|
||||||
|
"build-tools.lock": "4ba5f91f6473fc4fc9971b2353629203099d9409f537895972e60a65890af02f",
|
||||||
|
"production-requirements.lock": "daaa815ef1ce2e58c60d9bac840f5754a12eab4b18642713e197d3f5f9467c30"
|
||||||
|
},
|
||||||
|
"schema": "itworx.contextforge-candidate-locks/v1",
|
||||||
|
"upstreamCommit": "1e58a0434a78bbdbe1e975abeb0bed1043ff14c1",
|
||||||
|
"upstreamFiles": {
|
||||||
|
"Containerfile": "2ab0187f3a63eced093a1dec3141ec3e30ef879eb7de0dfd9e4bc5fce33f64f2",
|
||||||
|
"pyproject.toml": "cd365566f922546e569c65475d78cd53e5d49f10df2c82534efa607ffcf55e4b",
|
||||||
|
"uv.lock": "c5297a4701e4a0ecb9903bd3f4aa1c8cf40cf4e5568880fcb8f4078703589df1"
|
||||||
|
},
|
||||||
|
"uvVersion": "0.12.10"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
|||||||
|
# syntax=docker/dockerfile:1.12
|
||||||
|
# Scratch-derived, linux/amd64 only. Freeze by registry manifest digest after validation.
|
||||||
|
FROM registry.access.redhat.com/ubi10@sha256:1e32d829a4de713a835d702d3bc05f0c448a08590796281b7dec181912e9aeb8
|
||||||
|
LABEL io.itworx.contextforge.os-base.role="builder" \
|
||||||
|
io.itworx.contextforge.os-base.upstream-commit="1e58a0434a78bbdbe1e975abeb0bed1043ff14c1" \
|
||||||
|
io.itworx.contextforge.os-base.upstream-containerfile-sha256="2ab0187f3a63eced093a1dec3141ec3e30ef879eb7de0dfd9e4bc5fce33f64f2" \
|
||||||
|
io.itworx.contextforge.os-base.parent-ref="registry.access.redhat.com/ubi10@sha256:1e32d829a4de713a835d702d3bc05f0c448a08590796281b7dec181912e9aeb8" \
|
||||||
|
io.itworx.contextforge.os-base.platform="linux/amd64"
|
||||||
|
SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
|
||||||
|
|
||||||
|
RUN set -euo pipefail \
|
||||||
|
&& dnf upgrade -y \
|
||||||
|
&& dnf install -y --allowerasing \
|
||||||
|
python3.12 \
|
||||||
|
python3.12-devel \
|
||||||
|
binutils openssl-devel gcc postgresql-devel gcc-c++ curl libpq-devel \
|
||||||
|
&& update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 \
|
||||||
|
&& dnf clean all
|
||||||
|
|
||||||
|
RUN set -eu \
|
||||||
|
&& mkdir -p /usr/share/itworx \
|
||||||
|
&& rpm -qa --qf '%{NAME}|%{EPOCHNUM}|%{VERSION}|%{RELEASE}|%{ARCH}\n' > /tmp/itworx-rpm-inventory.unsorted \
|
||||||
|
&& LC_ALL=C sort /tmp/itworx-rpm-inventory.unsorted > /usr/share/itworx/rpm-inventory.txt \
|
||||||
|
&& rm /tmp/itworx-rpm-inventory.unsorted \
|
||||||
|
&& chmod 0444 /usr/share/itworx/rpm-inventory.txt
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# syntax=docker/dockerfile:1.12
|
||||||
|
# Scratch-derived, linux/amd64 only. Freeze by registry manifest digest after validation.
|
||||||
|
FROM registry.access.redhat.com/ubi10/ubi-minimal@sha256:afbfb5c18c628796fed3ad3122dcf096e0b2dec9dad3fbabe8620de06bc6f078
|
||||||
|
LABEL io.itworx.contextforge.os-base.role="runtime" \
|
||||||
|
io.itworx.contextforge.os-base.upstream-commit="1e58a0434a78bbdbe1e975abeb0bed1043ff14c1" \
|
||||||
|
io.itworx.contextforge.os-base.upstream-containerfile-sha256="2ab0187f3a63eced093a1dec3141ec3e30ef879eb7de0dfd9e4bc5fce33f64f2" \
|
||||||
|
io.itworx.contextforge.os-base.parent-ref="registry.access.redhat.com/ubi10/ubi-minimal@sha256:afbfb5c18c628796fed3ad3122dcf096e0b2dec9dad3fbabe8620de06bc6f078" \
|
||||||
|
io.itworx.contextforge.os-base.platform="linux/amd64"
|
||||||
|
|
||||||
|
RUN microdnf upgrade -y --nodocs --setopt=install_weak_deps=0 \
|
||||||
|
&& microdnf install -y --nodocs --setopt=install_weak_deps=0 \
|
||||||
|
python3.12 \
|
||||||
|
ca-certificates \
|
||||||
|
procps-ng \
|
||||||
|
shadow-utils \
|
||||||
|
libpq \
|
||||||
|
&& microdnf clean all \
|
||||||
|
&& rm -rf /var/cache/yum \
|
||||||
|
&& ln -sf /usr/bin/python3.12 /usr/bin/python3 \
|
||||||
|
&& groupadd --gid 10001 app \
|
||||||
|
&& useradd --uid 10001 --gid 10001 --home-dir /app --shell /sbin/nologin --no-create-home --comment app app
|
||||||
|
|
||||||
|
RUN set -eu \
|
||||||
|
&& mkdir -p /usr/share/itworx \
|
||||||
|
&& rpm -qa --qf '%{NAME}|%{EPOCHNUM}|%{VERSION}|%{RELEASE}|%{ARCH}\n' > /tmp/itworx-rpm-inventory.unsorted \
|
||||||
|
&& LC_ALL=C sort /tmp/itworx-rpm-inventory.unsorted > /usr/share/itworx/rpm-inventory.txt \
|
||||||
|
&& rm /tmp/itworx-rpm-inventory.unsorted \
|
||||||
|
&& chmod 0444 /usr/share/itworx/rpm-inventory.txt
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
PyJWT==2.15.0 --hash=sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8
|
||||||
|
urllib3==2.8.0 --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3
|
||||||
@@ -6,6 +6,8 @@ RUN sh /tmp/install-base-security-updates.sh \
|
|||||||
&& rm /tmp/install-base-security-updates.sh
|
&& rm /tmp/install-base-security-updates.sh
|
||||||
|
|
||||||
ARG GIT_REVISION
|
ARG GIT_REVISION
|
||||||
|
ARG GIT_SOURCE_TREE
|
||||||
|
ARG IMAGE_VERSION
|
||||||
ARG SOURCE_DATE_EPOCH
|
ARG SOURCE_DATE_EPOCH
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
|
|
||||||
@@ -20,6 +22,9 @@ RUN chown -R 10002:10002 /opt/broker
|
|||||||
LABEL org.opencontainers.image.title="itworx-mcp-hub-docker-read-broker" \
|
LABEL org.opencontainers.image.title="itworx-mcp-hub-docker-read-broker" \
|
||||||
org.opencontainers.image.revision="${GIT_REVISION}" \
|
org.opencontainers.image.revision="${GIT_REVISION}" \
|
||||||
org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \
|
org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \
|
||||||
|
org.opencontainers.image.source-tree="${GIT_SOURCE_TREE}" \
|
||||||
|
org.opencontainers.image.version="${IMAGE_VERSION}" \
|
||||||
|
org.opencontainers.image.source-date-epoch="${SOURCE_DATE_EPOCH}" \
|
||||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
org.opencontainers.image.vendor="ITWorx"
|
org.opencontainers.image.vendor="ITWorx"
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ RUN sh /tmp/install-base-security-updates.sh \
|
|||||||
&& rm /tmp/install-base-security-updates.sh
|
&& rm /tmp/install-base-security-updates.sh
|
||||||
|
|
||||||
ARG GIT_REVISION
|
ARG GIT_REVISION
|
||||||
|
ARG GIT_SOURCE_TREE
|
||||||
|
ARG IMAGE_VERSION
|
||||||
ARG SOURCE_DATE_EPOCH
|
ARG SOURCE_DATE_EPOCH
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
|
|
||||||
@@ -17,6 +19,9 @@ RUN chown -R 10001:10001 /opt/hub
|
|||||||
LABEL org.opencontainers.image.title="itworx-mcp-hub-observability-collector" \
|
LABEL org.opencontainers.image.title="itworx-mcp-hub-observability-collector" \
|
||||||
org.opencontainers.image.revision="${GIT_REVISION}" \
|
org.opencontainers.image.revision="${GIT_REVISION}" \
|
||||||
org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \
|
org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \
|
||||||
|
org.opencontainers.image.source-tree="${GIT_SOURCE_TREE}" \
|
||||||
|
org.opencontainers.image.version="${IMAGE_VERSION}" \
|
||||||
|
org.opencontainers.image.source-date-epoch="${SOURCE_DATE_EPOCH}" \
|
||||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
org.opencontainers.image.vendor="ITWorx"
|
org.opencontainers.image.vendor="ITWorx"
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ RUN sh /tmp/install-base-security-updates.sh \
|
|||||||
|
|
||||||
ARG SERVICE_NAME=itworx-mcp-hub-service
|
ARG SERVICE_NAME=itworx-mcp-hub-service
|
||||||
ARG GIT_REVISION
|
ARG GIT_REVISION
|
||||||
|
ARG GIT_SOURCE_TREE
|
||||||
|
ARG IMAGE_VERSION
|
||||||
ARG SOURCE_DATE_EPOCH
|
ARG SOURCE_DATE_EPOCH
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
|
|
||||||
@@ -49,6 +51,9 @@ RUN chown -R 10001:10001 /opt/hub
|
|||||||
LABEL org.opencontainers.image.title="itworx-mcp-hub-${SERVICE_NAME}" \
|
LABEL org.opencontainers.image.title="itworx-mcp-hub-${SERVICE_NAME}" \
|
||||||
org.opencontainers.image.revision="${GIT_REVISION}" \
|
org.opencontainers.image.revision="${GIT_REVISION}" \
|
||||||
org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \
|
org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \
|
||||||
|
org.opencontainers.image.source-tree="${GIT_SOURCE_TREE}" \
|
||||||
|
org.opencontainers.image.version="${IMAGE_VERSION}" \
|
||||||
|
org.opencontainers.image.source-date-epoch="${SOURCE_DATE_EPOCH}" \
|
||||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
org.opencontainers.image.vendor="ITWorx"
|
org.opencontainers.image.vendor="ITWorx"
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# Candidate-only mode emits a local SHA-scoped image and does not publish or deploy it.
|
||||||
|
MODE="legacy"
|
||||||
|
if [[ "$#" -gt 0 ]]; then
|
||||||
|
[[ "$#" -eq 1 && "$1" == "--release-candidate" ]] || {
|
||||||
|
echo "FATAL: supported mode is --release-candidate (or no arguments for legacy mode)" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
MODE="release-candidate"
|
||||||
|
fi
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
MANIFEST="$ROOT/infrastructure/images/contextforge/compat-image.env"
|
MANIFEST="$ROOT/infrastructure/images/contextforge/compat-image.env"
|
||||||
CONTRACT="$ROOT/infrastructure/images/contextforge/runtime-contract.json"
|
CONTRACT="$ROOT/infrastructure/images/contextforge/runtime-contract.json"
|
||||||
ENTRYPOINT="$ROOT/infrastructure/images/contextforge/entrypoint.sh"
|
ENTRYPOINT="$ROOT/infrastructure/images/contextforge/entrypoint.sh"
|
||||||
RUNTIME_VERIFIER="$ROOT/scripts/contextforge_runtime_contract.py"
|
RUNTIME_VERIFIER="$ROOT/scripts/contextforge_runtime_contract.py"
|
||||||
|
SECURITY_REQUIREMENTS="$ROOT/infrastructure/images/contextforge/security-requirements.txt"
|
||||||
|
CANDIDATE_RECIPE="$ROOT/scripts/contextforge_candidate_recipe.py"
|
||||||
|
|
||||||
for required in "$MANIFEST" "$CONTRACT" "$ENTRYPOINT" "$RUNTIME_VERIFIER"; do
|
for required in "$MANIFEST" "$CONTRACT" "$ENTRYPOINT" "$RUNTIME_VERIFIER" "$SECURITY_REQUIREMENTS"; do
|
||||||
[[ -f "$required" ]] || {
|
[[ -f "$required" ]] || {
|
||||||
echo "FATAL: required ContextForge compatibility input is missing: $required" >&2
|
echo "FATAL: required ContextForge compatibility input is missing: $required" >&2
|
||||||
exit 66
|
exit 66
|
||||||
@@ -22,15 +34,87 @@ source "$MANIFEST"
|
|||||||
: "${CONTEXTFORGE_MCP_SDK:?missing MCP SDK version}"
|
: "${CONTEXTFORGE_MCP_SDK:?missing MCP SDK version}"
|
||||||
: "${CONTEXTFORGE_LOCAL_IMAGE:?missing local output image}"
|
: "${CONTEXTFORGE_LOCAL_IMAGE:?missing local output image}"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
[[ -f "$CANDIDATE_RECIPE" ]] || {
|
||||||
|
echo "FATAL: the source-locked candidate recipe helper is missing" >&2
|
||||||
|
exit 66
|
||||||
|
}
|
||||||
|
# Validate every interpolated value before invoking Git, Docker, or Python.
|
||||||
|
# These fixed-format fields become Docker tags, Git comparisons, and labels.
|
||||||
|
: "${APPOPS_SOURCE_REPOSITORY:?missing APPOPS_SOURCE_REPOSITORY}"
|
||||||
|
: "${APPOPS_SOURCE_REVISION:?missing APPOPS_SOURCE_REVISION}"
|
||||||
|
: "${APPOPS_SOURCE_TREE:?missing APPOPS_SOURCE_TREE}"
|
||||||
|
: "${APPOPS_BUILD_ID:?missing APPOPS_BUILD_ID}"
|
||||||
|
: "${APPOPS_BUILD_DATE:?missing APPOPS_BUILD_DATE}"
|
||||||
|
: "${SOURCE_DATE_EPOCH:?missing SOURCE_DATE_EPOCH}"
|
||||||
|
: "${GITEA_REPOSITORY:?missing GITEA_REPOSITORY}"
|
||||||
|
: "${GITEA_COMMIT_SHA:?missing GITEA_COMMIT_SHA}"
|
||||||
|
|
||||||
|
[[ "$APPOPS_SOURCE_REPOSITORY" == "jens/itworx_mcp_hub" ]] || {
|
||||||
|
echo "FATAL: release-candidate source repository is not the protected Hub repository" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
[[ "$GITEA_REPOSITORY" == "$APPOPS_SOURCE_REPOSITORY" ]] || {
|
||||||
|
echo "FATAL: Gitea and AppOps repositories disagree" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
[[ "$APPOPS_SOURCE_REVISION" =~ ^[0-9a-f]{40}$ ]] || {
|
||||||
|
echo "FATAL: APPOPS_SOURCE_REVISION must be a full lowercase Git SHA" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
[[ "$GITEA_COMMIT_SHA" == "$APPOPS_SOURCE_REVISION" ]] || {
|
||||||
|
echo "FATAL: Gitea and AppOps revisions disagree" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
[[ "$APPOPS_SOURCE_TREE" =~ ^[0-9a-f]{40}$ ]] || {
|
||||||
|
echo "FATAL: APPOPS_SOURCE_TREE must be a full lowercase Git tree SHA" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
[[ "$APPOPS_BUILD_ID" == "${APPOPS_SOURCE_REVISION:0:12}" ]] || {
|
||||||
|
echo "FATAL: APPOPS_BUILD_ID must be the first 12 revision characters" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
[[ "$APPOPS_BUILD_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$ ]] || {
|
||||||
|
echo "FATAL: APPOPS_BUILD_DATE must be canonical UTC with millisecond precision" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
[[ "$SOURCE_DATE_EPOCH" =~ ^[1-9][0-9]{0,11}$ ]] || {
|
||||||
|
echo "FATAL: SOURCE_DATE_EPOCH must be a canonical positive integer of at most 12 digits" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
[[ "$CONTEXTFORGE_LOCAL_IMAGE" == "itworx-mcp-hub/contextforge-schema:12d4a0c7789c" ]] || {
|
||||||
|
echo "FATAL: compatibility manifest image name is not the reviewed Hub image" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
command -v git >/dev/null 2>&1 || { echo "FATAL: git is required" >&2; exit 69; }
|
command -v git >/dev/null 2>&1 || { echo "FATAL: git is required" >&2; exit 69; }
|
||||||
command -v docker >/dev/null 2>&1 || { echo "FATAL: docker is required" >&2; exit 69; }
|
command -v docker >/dev/null 2>&1 || { echo "FATAL: docker is required" >&2; exit 69; }
|
||||||
command -v python3 >/dev/null 2>&1 || { echo "FATAL: python3 is required" >&2; exit 69; }
|
command -v python3 >/dev/null 2>&1 || { echo "FATAL: python3 is required" >&2; exit 69; }
|
||||||
|
|
||||||
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
python3 - "$APPOPS_BUILD_DATE" <<'PY'
|
||||||
|
from datetime import datetime
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit("FATAL: APPOPS_BUILD_DATE is not a valid UTC calendar timestamp") from exc
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
SRC="$TMP/contextforge"
|
SRC="$TMP/contextforge"
|
||||||
BASE_IMAGE="${CONTEXTFORGE_LOCAL_IMAGE}-upstream"
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
STAGED_IMAGE="${CONTEXTFORGE_LOCAL_IMAGE}-staged"
|
CANDIDATE_IMAGE="itworx-mcp-hub/contextforge-schema:${APPOPS_SOURCE_REVISION}"
|
||||||
|
BASE_IMAGE="itworx-mcp-hub/contextforge-schema:${APPOPS_SOURCE_REVISION}-upstream"
|
||||||
|
STAGED_IMAGE="itworx-mcp-hub/contextforge-schema:${APPOPS_SOURCE_REVISION}-staged"
|
||||||
|
else
|
||||||
|
BASE_IMAGE="${CONTEXTFORGE_LOCAL_IMAGE}-upstream"
|
||||||
|
STAGED_IMAGE="${CONTEXTFORGE_LOCAL_IMAGE}-staged"
|
||||||
|
fi
|
||||||
REVISION_PREFIX="${CONTEXTFORGE_ALEMBIC_HEAD}_"
|
REVISION_PREFIX="${CONTEXTFORGE_ALEMBIC_HEAD}_"
|
||||||
MCP_REQUIREMENT="mcp==${CONTEXTFORGE_MCP_SDK}"
|
MCP_REQUIREMENT="mcp==${CONTEXTFORGE_MCP_SDK}"
|
||||||
|
|
||||||
@@ -70,12 +154,41 @@ CONTRACT_REQUIRED_SYMBOL="$(read_contract requiredSymbol)"
|
|||||||
CONTRACT_SHA256="$(sha256_file "$CONTRACT")"
|
CONTRACT_SHA256="$(sha256_file "$CONTRACT")"
|
||||||
ENTRYPOINT_SHA256="$(sha256_file "$ENTRYPOINT")"
|
ENTRYPOINT_SHA256="$(sha256_file "$ENTRYPOINT")"
|
||||||
VERIFIER_SHA256="$(sha256_file "$RUNTIME_VERIFIER")"
|
VERIFIER_SHA256="$(sha256_file "$RUNTIME_VERIFIER")"
|
||||||
|
SECURITY_REQUIREMENTS_SHA256="$(sha256_file "$SECURITY_REQUIREMENTS")"
|
||||||
|
|
||||||
[[ "$CONTRACT_SOURCE_COMMIT" == "$CONTEXTFORGE_UPSTREAM_COMMIT" ]] || fail "contract source commit disagrees with compatibility manifest"
|
[[ "$CONTRACT_SOURCE_COMMIT" == "$CONTEXTFORGE_UPSTREAM_COMMIT" ]] || fail "contract source commit disagrees with compatibility manifest"
|
||||||
[[ "$CONTRACT_ALEMBIC_HEAD" == "$CONTEXTFORGE_ALEMBIC_HEAD" ]] || fail "contract Alembic head disagrees with compatibility manifest"
|
[[ "$CONTRACT_ALEMBIC_HEAD" == "$CONTEXTFORGE_ALEMBIC_HEAD" ]] || fail "contract Alembic head disagrees with compatibility manifest"
|
||||||
[[ "$CONTRACT_MCP_SDK" == "$CONTEXTFORGE_MCP_SDK" ]] || fail "contract MCP SDK disagrees with compatibility manifest"
|
[[ "$CONTRACT_MCP_SDK" == "$CONTEXTFORGE_MCP_SDK" ]] || fail "contract MCP SDK disagrees with compatibility manifest"
|
||||||
[[ "$CONTRACT_REQUIRED_SYMBOL" == "McpError" ]] || fail "unsupported required MCP symbol: $CONTRACT_REQUIRED_SYMBOL"
|
[[ "$CONTRACT_REQUIRED_SYMBOL" == "McpError" ]] || fail "unsupported required MCP symbol: $CONTRACT_REQUIRED_SYMBOL"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
[[ -z "$(git -C "$ROOT" status --porcelain --untracked-files=all)" ]] || fail "Hub source checkout has changes"
|
||||||
|
[[ "$(git -C "$ROOT" rev-parse HEAD)" == "$APPOPS_SOURCE_REVISION" ]] || fail "checked-out Hub revision differs from frozen release metadata"
|
||||||
|
[[ "$(git -C "$ROOT" rev-parse 'HEAD^{tree}')" == "$APPOPS_SOURCE_TREE" ]] || fail "checked-out Hub source tree differs from frozen release metadata"
|
||||||
|
[[ "$(git -C "$ROOT" show -s --format=%ct HEAD)" == "$SOURCE_DATE_EPOCH" ]] || fail "source date epoch differs from frozen release metadata"
|
||||||
|
|
||||||
|
ensure_candidate_tag_absent() {
|
||||||
|
local image="$1"
|
||||||
|
local diagnostic="$TMP/docker-image-inspect.err"
|
||||||
|
if docker image inspect "$image" >/dev/null 2>"$diagnostic"; then
|
||||||
|
fail "release-candidate image tag already exists: $image"
|
||||||
|
fi
|
||||||
|
local error_line
|
||||||
|
IFS= read -r error_line < "$diagnostic" || true
|
||||||
|
case "$error_line" in
|
||||||
|
*"No such image"*|*"no such image"*|*"No such object"*|*"no such object"*|*"reference does not exist"*) ;;
|
||||||
|
*) fail "could not prove release-candidate image tag is absent: $image" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Never build over or retag an existing revision-scoped image. Docker errors
|
||||||
|
# other than its explicit not-found response fail closed.
|
||||||
|
ensure_candidate_tag_absent "$BASE_IMAGE"
|
||||||
|
ensure_candidate_tag_absent "$STAGED_IMAGE"
|
||||||
|
ensure_candidate_tag_absent "$CANDIDATE_IMAGE"
|
||||||
|
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== FETCH EXACT CONTEXTFORGE SOURCE ==="
|
echo "=== FETCH EXACT CONTEXTFORGE SOURCE ==="
|
||||||
git init -q "$SRC"
|
git init -q "$SRC"
|
||||||
git -C "$SRC" remote add origin https://github.com/IBM/mcp-context-forge.git
|
git -C "$SRC" remote add origin https://github.com/IBM/mcp-context-forge.git
|
||||||
@@ -97,13 +210,28 @@ echo "contract sha : $CONTRACT_SHA256"
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== BUILD UPSTREAM SOURCE IMAGE ==="
|
echo "=== BUILD UPSTREAM SOURCE IMAGE ==="
|
||||||
docker build --pull=false -f "$SRC/Containerfile" -t "$BASE_IMAGE" "$SRC"
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
python3 "$CANDIDATE_RECIPE" prepare --source-dir "$SRC"
|
||||||
|
BUILD_ARG_FILE="$TMP/contextforge-build-args.txt"
|
||||||
|
python3 "$CANDIDATE_RECIPE" build-args > "$BUILD_ARG_FILE"
|
||||||
|
mapfile -t CANDIDATE_BUILD_ARGS < "$BUILD_ARG_FILE"
|
||||||
|
[[ "${#CANDIDATE_BUILD_ARGS[@]}" -eq 9 ]] || fail "candidate base/build argument contract is incomplete"
|
||||||
|
CANDIDATE_DOCKER_ARGS=()
|
||||||
|
for build_arg in "${CANDIDATE_BUILD_ARGS[@]}"; do
|
||||||
|
[[ "$build_arg" =~ ^[A-Z_]+=([A-Za-z0-9./:@_-]+)$ ]] || fail "candidate build argument is malformed"
|
||||||
|
CANDIDATE_DOCKER_ARGS+=(--build-arg "$build_arg")
|
||||||
|
done
|
||||||
|
docker build --pull=false --platform linux/amd64 "${CANDIDATE_DOCKER_ARGS[@]}" -f "$SRC/Containerfile.itworx-candidate" -t "$BASE_IMAGE" "$SRC"
|
||||||
|
else
|
||||||
|
docker build --pull=false -f "$SRC/Containerfile" -t "$BASE_IMAGE" "$SRC"
|
||||||
|
fi
|
||||||
|
|
||||||
BASE_USER="$(docker image inspect -f '{{.Config.User}}' "$BASE_IMAGE")"
|
BASE_USER="$(docker image inspect -f '{{.Config.User}}' "$BASE_IMAGE")"
|
||||||
LAYER="$TMP/Containerfile.compat"
|
LAYER="$TMP/Containerfile.compat"
|
||||||
cp "$CONTRACT" "$TMP/contextforge-runtime-contract.json"
|
cp "$CONTRACT" "$TMP/contextforge-runtime-contract.json"
|
||||||
cp "$ENTRYPOINT" "$TMP/contextforge-entrypoint.sh"
|
cp "$ENTRYPOINT" "$TMP/contextforge-entrypoint.sh"
|
||||||
cp "$RUNTIME_VERIFIER" "$TMP/contextforge-runtime-contract.py"
|
cp "$RUNTIME_VERIFIER" "$TMP/contextforge-runtime-contract.py"
|
||||||
|
cp "$SECURITY_REQUIREMENTS" "$TMP/contextforge-security-requirements.txt"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "FROM $BASE_IMAGE"
|
echo "FROM $BASE_IMAGE"
|
||||||
@@ -111,6 +239,7 @@ cp "$RUNTIME_VERIFIER" "$TMP/contextforge-runtime-contract.py"
|
|||||||
echo 'COPY contextforge-runtime-contract.json /opt/itworx/contextforge-runtime-contract.json'
|
echo 'COPY contextforge-runtime-contract.json /opt/itworx/contextforge-runtime-contract.json'
|
||||||
echo 'COPY contextforge-entrypoint.sh /opt/itworx/contextforge-entrypoint.sh'
|
echo 'COPY contextforge-entrypoint.sh /opt/itworx/contextforge-entrypoint.sh'
|
||||||
echo 'COPY contextforge-runtime-contract.py /opt/itworx/contextforge-runtime-contract.py'
|
echo 'COPY contextforge-runtime-contract.py /opt/itworx/contextforge-runtime-contract.py'
|
||||||
|
echo 'COPY contextforge-security-requirements.txt /opt/itworx/contextforge-security-requirements.txt'
|
||||||
echo 'RUN chmod 0555 /opt/itworx/contextforge-entrypoint.sh /opt/itworx/contextforge-runtime-contract.py'
|
echo 'RUN chmod 0555 /opt/itworx/contextforge-entrypoint.sh /opt/itworx/contextforge-runtime-contract.py'
|
||||||
printf 'LABEL io.itworx.contextforge.contract-sha256="%s" \\\n' "$CONTRACT_SHA256"
|
printf 'LABEL io.itworx.contextforge.contract-sha256="%s" \\\n' "$CONTRACT_SHA256"
|
||||||
printf ' io.itworx.contextforge.entrypoint-sha256="%s" \\\n' "$ENTRYPOINT_SHA256"
|
printf ' io.itworx.contextforge.entrypoint-sha256="%s" \\\n' "$ENTRYPOINT_SHA256"
|
||||||
@@ -118,8 +247,24 @@ cp "$RUNTIME_VERIFIER" "$TMP/contextforge-runtime-contract.py"
|
|||||||
printf ' io.itworx.contextforge.source-commit="%s" \\\n' "$CONTEXTFORGE_UPSTREAM_COMMIT"
|
printf ' io.itworx.contextforge.source-commit="%s" \\\n' "$CONTEXTFORGE_UPSTREAM_COMMIT"
|
||||||
printf ' io.itworx.contextforge.alembic-head="%s" \\\n' "$CONTEXTFORGE_ALEMBIC_HEAD"
|
printf ' io.itworx.contextforge.alembic-head="%s" \\\n' "$CONTEXTFORGE_ALEMBIC_HEAD"
|
||||||
printf ' io.itworx.contextforge.mcp-sdk="%s"\n' "$CONTEXTFORGE_MCP_SDK"
|
printf ' io.itworx.contextforge.mcp-sdk="%s"\n' "$CONTEXTFORGE_MCP_SDK"
|
||||||
printf 'RUN /app/.venv/bin/python -m pip install --no-cache-dir --upgrade %q \\\n' "$MCP_REQUIREMENT"
|
printf 'LABEL io.itworx.contextforge.security-requirements-sha256="%s"\n' "$SECURITY_REQUIREMENTS_SHA256"
|
||||||
printf '%s\n' ' && /app/.venv/bin/python -c "import importlib.metadata as md; from mcp import McpError; assert md.version(\"mcp\") == \"'"${CONTEXTFORGE_MCP_SDK}"'\"; print(\"MCP SDK\", md.version(\"mcp\"), \"McpError OK\")"'
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
printf 'LABEL org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \\\n'
|
||||||
|
printf ' org.opencontainers.image.revision="%s" \\\n' "$APPOPS_SOURCE_REVISION"
|
||||||
|
printf ' org.opencontainers.image.source-tree="%s" \\\n' "$APPOPS_SOURCE_TREE"
|
||||||
|
printf ' org.opencontainers.image.version="%s" \\\n' "$APPOPS_BUILD_ID"
|
||||||
|
printf ' org.opencontainers.image.source-date-epoch="%s" \\\n' "$SOURCE_DATE_EPOCH"
|
||||||
|
printf ' org.opencontainers.image.created="%s"\n' "$APPOPS_BUILD_DATE"
|
||||||
|
fi
|
||||||
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
echo 'RUN /app/.venv/bin/python -c "import importlib.metadata as md; import importlib.util; from mcp import McpError; assert md.version(\"mcp\") == \"1.30.0\"; assert md.version(\"PyJWT\") == \"2.15.0\"; assert md.version(\"urllib3\") == \"2.8.0\"; assert all(importlib.util.find_spec(name) is None for name in (\"pip\", \"setuptools\", \"wheel\", \"uv\"))"'
|
||||||
|
else
|
||||||
|
printf 'RUN /app/.venv/bin/python -m pip install --no-cache-dir --upgrade %q \\\n' "$MCP_REQUIREMENT"
|
||||||
|
printf '%s\n' ' && /app/.venv/bin/python -c "import importlib.metadata as md; from mcp import McpError; assert md.version(\"mcp\") == \"'"${CONTEXTFORGE_MCP_SDK}"'\"; print(\"MCP SDK\", md.version(\"mcp\"), \"McpError OK\")"'
|
||||||
|
echo 'RUN /app/.venv/bin/python -m pip install --no-cache-dir --no-deps --require-hashes --only-binary=:all: -r /opt/itworx/contextforge-security-requirements.txt \'
|
||||||
|
echo ' && /app/.venv/bin/python -m pip check \'
|
||||||
|
echo ' && /app/.venv/bin/python -c "import importlib.metadata as md; assert md.version(\"PyJWT\") == \"2.15.0\"; assert md.version(\"urllib3\") == \"2.8.0\""'
|
||||||
|
fi
|
||||||
if [[ -n "$BASE_USER" ]]; then
|
if [[ -n "$BASE_USER" ]]; then
|
||||||
printf 'USER %s\n' "$BASE_USER"
|
printf 'USER %s\n' "$BASE_USER"
|
||||||
fi
|
fi
|
||||||
@@ -142,6 +287,7 @@ embedded_sha() {
|
|||||||
[[ "$(embedded_sha /opt/itworx/contextforge-runtime-contract.json)" == "$CONTRACT_SHA256" ]] || fail "embedded runtime contract digest mismatch"
|
[[ "$(embedded_sha /opt/itworx/contextforge-runtime-contract.json)" == "$CONTRACT_SHA256" ]] || fail "embedded runtime contract digest mismatch"
|
||||||
[[ "$(embedded_sha /opt/itworx/contextforge-entrypoint.sh)" == "$ENTRYPOINT_SHA256" ]] || fail "embedded entrypoint digest mismatch"
|
[[ "$(embedded_sha /opt/itworx/contextforge-entrypoint.sh)" == "$ENTRYPOINT_SHA256" ]] || fail "embedded entrypoint digest mismatch"
|
||||||
[[ "$(embedded_sha /opt/itworx/contextforge-runtime-contract.py)" == "$VERIFIER_SHA256" ]] || fail "embedded verifier digest mismatch"
|
[[ "$(embedded_sha /opt/itworx/contextforge-runtime-contract.py)" == "$VERIFIER_SHA256" ]] || fail "embedded verifier digest mismatch"
|
||||||
|
[[ "$(embedded_sha /opt/itworx/contextforge-security-requirements.txt)" == "$SECURITY_REQUIREMENTS_SHA256" ]] || fail "embedded security requirements digest mismatch"
|
||||||
|
|
||||||
label_value() {
|
label_value() {
|
||||||
docker image inspect -f "{{ index .Config.Labels \"$1\" }}" "$STAGED_IMAGE"
|
docker image inspect -f "{{ index .Config.Labels \"$1\" }}" "$STAGED_IMAGE"
|
||||||
@@ -152,19 +298,43 @@ label_value() {
|
|||||||
[[ "$(label_value io.itworx.contextforge.source-commit)" == "$CONTEXTFORGE_UPSTREAM_COMMIT" ]] || fail "source label mismatch"
|
[[ "$(label_value io.itworx.contextforge.source-commit)" == "$CONTEXTFORGE_UPSTREAM_COMMIT" ]] || fail "source label mismatch"
|
||||||
[[ "$(label_value io.itworx.contextforge.alembic-head)" == "$CONTEXTFORGE_ALEMBIC_HEAD" ]] || fail "Alembic label mismatch"
|
[[ "$(label_value io.itworx.contextforge.alembic-head)" == "$CONTEXTFORGE_ALEMBIC_HEAD" ]] || fail "Alembic label mismatch"
|
||||||
[[ "$(label_value io.itworx.contextforge.mcp-sdk)" == "$CONTEXTFORGE_MCP_SDK" ]] || fail "MCP SDK label mismatch"
|
[[ "$(label_value io.itworx.contextforge.mcp-sdk)" == "$CONTEXTFORGE_MCP_SDK" ]] || fail "MCP SDK label mismatch"
|
||||||
|
[[ "$(label_value io.itworx.contextforge.security-requirements-sha256)" == "$SECURITY_REQUIREMENTS_SHA256" ]] || fail "security requirements label mismatch"
|
||||||
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
[[ "$(label_value org.opencontainers.image.source)" == "https://gitea.example.com/jens/itworx-mcp-hub" ]] || fail "OCI source label mismatch"
|
||||||
|
[[ "$(label_value org.opencontainers.image.revision)" == "$APPOPS_SOURCE_REVISION" ]] || fail "OCI revision label mismatch"
|
||||||
|
[[ "$(label_value org.opencontainers.image.source-tree)" == "$APPOPS_SOURCE_TREE" ]] || fail "OCI source tree label mismatch"
|
||||||
|
[[ "$(label_value org.opencontainers.image.version)" == "$APPOPS_BUILD_ID" ]] || fail "OCI version label mismatch"
|
||||||
|
[[ "$(label_value org.opencontainers.image.source-date-epoch)" == "$SOURCE_DATE_EPOCH" ]] || fail "OCI source date epoch label mismatch"
|
||||||
|
[[ "$(label_value org.opencontainers.image.created)" == "$APPOPS_BUILD_DATE" ]] || fail "OCI created label mismatch"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "MCP SDK : $CONTEXTFORGE_MCP_SDK"
|
echo "MCP SDK : $CONTEXTFORGE_MCP_SDK"
|
||||||
echo "Alembic head : $IMAGE_HEAD"
|
echo "Alembic head : $IMAGE_HEAD"
|
||||||
echo "Contract SHA : $CONTRACT_SHA256"
|
echo "Contract SHA : $CONTRACT_SHA256"
|
||||||
|
|
||||||
docker tag "$STAGED_IMAGE" "$CONTEXTFORGE_LOCAL_IMAGE"
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
# Recheck immediately before publishing. There is intentionally no cleanup or
|
||||||
|
# retagging of any static compatibility image in candidate mode.
|
||||||
|
ensure_candidate_tag_absent "$CANDIDATE_IMAGE"
|
||||||
|
docker tag "$STAGED_IMAGE" "$CANDIDATE_IMAGE"
|
||||||
|
else
|
||||||
|
docker tag "$STAGED_IMAGE" "$CONTEXTFORGE_LOCAL_IMAGE"
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "======================================================"
|
echo "======================================================"
|
||||||
echo " SUCCESS: CONTEXTFORGE COMPATIBILITY IMAGE VERIFIED"
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
echo " CANDIDATE IMAGE BUILT AND VERIFIED (NOT APPROVED OR DEPLOYED)"
|
||||||
|
else
|
||||||
|
echo " SUCCESS: CONTEXTFORGE COMPATIBILITY IMAGE VERIFIED"
|
||||||
|
fi
|
||||||
echo " source commit : $CONTEXTFORGE_UPSTREAM_COMMIT"
|
echo " source commit : $CONTEXTFORGE_UPSTREAM_COMMIT"
|
||||||
echo " Alembic head : $CONTEXTFORGE_ALEMBIC_HEAD"
|
echo " Alembic head : $CONTEXTFORGE_ALEMBIC_HEAD"
|
||||||
echo " MCP SDK : $CONTEXTFORGE_MCP_SDK"
|
echo " MCP SDK : $CONTEXTFORGE_MCP_SDK"
|
||||||
echo " contract SHA : $CONTRACT_SHA256"
|
echo " contract SHA : $CONTRACT_SHA256"
|
||||||
echo " local image : $CONTEXTFORGE_LOCAL_IMAGE"
|
if [[ "$MODE" == "release-candidate" ]]; then
|
||||||
|
echo " candidate tag : $CANDIDATE_IMAGE"
|
||||||
|
else
|
||||||
|
echo " local image : $CONTEXTFORGE_LOCAL_IMAGE"
|
||||||
|
fi
|
||||||
echo "======================================================"
|
echo "======================================================"
|
||||||
+190
-22
@@ -12,9 +12,8 @@ Specifically fails on:
|
|||||||
grant);
|
grant);
|
||||||
- a matching exception that has expired (expiryDate in the past) or is past
|
- a matching exception that has expired (expiryDate in the past) or is past
|
||||||
its mandatory rescan date;
|
its mandatory rescan date;
|
||||||
- a matching exception whose recorded affectedImageDigests disagree with
|
- a matching exception without a valid image binding for each affected
|
||||||
--image-digests for any service both documents name (the digest changed --
|
service, or whose binding differs from the current image inventory;
|
||||||
the exception was reviewed against a build that no longer exists);
|
|
||||||
- an exception missing any required field.
|
- an exception missing any required field.
|
||||||
|
|
||||||
See docs/09-release/CVE_EXCEPTIONS.md for the full policy and
|
See docs/09-release/CVE_EXCEPTIONS.md for the full policy and
|
||||||
@@ -25,6 +24,7 @@ context. This script has no "--force"/override flag by design.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -36,6 +36,8 @@ if str(_ROOT_DIR) not in sys.path:
|
|||||||
|
|
||||||
from scripts.packlib import load_json # noqa: E402
|
from scripts.packlib import load_json # noqa: E402
|
||||||
|
|
||||||
|
_SHA256_IMAGE_ID = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
|
|
||||||
REQUIRED_FIELDS = (
|
REQUIRED_FIELDS = (
|
||||||
"id",
|
"id",
|
||||||
"cve",
|
"cve",
|
||||||
@@ -57,24 +59,134 @@ REQUIRED_FIELDS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _findings(scan: dict[str, Any]) -> list[tuple[str, str, str]]:
|
def _findings(scan: dict[str, Any]) -> list[tuple[dict[str, Any], str]]:
|
||||||
"""Return (package, cve, severity) for every CRITICAL/HIGH finding."""
|
"""Return (scan row, CVE) pairs for every CRITICAL/HIGH finding."""
|
||||||
findings = []
|
findings = []
|
||||||
for row in scan.get("unresolvedCriticalOrHigh", []):
|
for row in scan.get("unresolvedCriticalOrHigh", []):
|
||||||
for cve in row["cves"]:
|
for cve in row["cves"]:
|
||||||
findings.append((row["package"], cve, row.get("severity", "UNKNOWN")))
|
findings.append((row, cve))
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def _current_image_inventory(
|
||||||
|
image_digests: Any,
|
||||||
|
) -> tuple[dict[str, dict[str, str]], list[str]]:
|
||||||
|
"""Validate immutable current image bindings while retaining optional tags."""
|
||||||
|
errors: list[str] = []
|
||||||
|
if not isinstance(image_digests, dict) or not image_digests:
|
||||||
|
return {}, ["IMAGE INVENTORY INVALID: --image-digests must contain a non-empty images map"]
|
||||||
|
|
||||||
|
inventory: dict[str, dict[str, str]] = {}
|
||||||
|
for service, entry in image_digests.items():
|
||||||
|
if not isinstance(service, str) or not service.strip():
|
||||||
|
errors.append("IMAGE INVENTORY INVALID: every image service needs a non-empty name")
|
||||||
|
continue
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
errors.append(f"IMAGE INVENTORY INVALID: {service} must be an image evidence object")
|
||||||
|
continue
|
||||||
|
|
||||||
|
image_id = entry.get("imageId")
|
||||||
|
if not isinstance(image_id, str) or not _SHA256_IMAGE_ID.fullmatch(image_id):
|
||||||
|
errors.append(f"CURRENT IMAGE ID INVALID: {service} needs a sha256 imageId")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "digest" in entry:
|
||||||
|
digest = entry.get("digest")
|
||||||
|
if not isinstance(digest, str) or not _SHA256_IMAGE_ID.fullmatch(digest):
|
||||||
|
errors.append(f"CURRENT IMAGE DIGEST INVALID: {service} has a malformed digest")
|
||||||
|
continue
|
||||||
|
binding = digest
|
||||||
|
else:
|
||||||
|
# Older v1 inventory evidence records the immutable local image ID
|
||||||
|
# instead of a registry manifest digest.
|
||||||
|
binding = image_id
|
||||||
|
|
||||||
|
tag = entry.get("tag")
|
||||||
|
if tag is not None and (not isinstance(tag, str) or not tag.strip()):
|
||||||
|
errors.append(f"IMAGE INVENTORY INVALID: {service} has a malformed tag")
|
||||||
|
continue
|
||||||
|
inventory[service] = {"binding": binding, "imageId": image_id}
|
||||||
|
if isinstance(tag, str):
|
||||||
|
inventory[service]["tag"] = tag
|
||||||
|
|
||||||
|
if not inventory:
|
||||||
|
errors.append("IMAGE INVENTORY INVALID: no valid current image bindings were supplied")
|
||||||
|
return inventory, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _affected_services(
|
||||||
|
row: dict[str, Any], inventory: dict[str, dict[str, str]]
|
||||||
|
) -> tuple[list[str], list[str]]:
|
||||||
|
"""Resolve scan image tags to services, or use the legacy full inventory."""
|
||||||
|
if "affectedImages" not in row:
|
||||||
|
# Legacy v1 scan rows had no affectedImages field. Requiring the
|
||||||
|
# exception to cover the whole supplied inventory is the safe fallback.
|
||||||
|
return list(inventory), []
|
||||||
|
|
||||||
|
affected_images = row.get("affectedImages")
|
||||||
|
if (
|
||||||
|
not isinstance(affected_images, list)
|
||||||
|
or not affected_images
|
||||||
|
or any(not isinstance(image, str) or not image.strip() for image in affected_images)
|
||||||
|
or len(set(affected_images)) != len(affected_images)
|
||||||
|
):
|
||||||
|
return [], ["SCAN AFFECTED IMAGES INVALID: expected a non-empty list of unique image tags"]
|
||||||
|
|
||||||
|
services_by_tag: dict[str, list[str]] = {}
|
||||||
|
for service, evidence in inventory.items():
|
||||||
|
tag = evidence.get("tag")
|
||||||
|
if tag:
|
||||||
|
services_by_tag.setdefault(tag, []).append(service)
|
||||||
|
|
||||||
|
services: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
for image in affected_images:
|
||||||
|
matches = services_by_tag.get(image, [])
|
||||||
|
if not matches:
|
||||||
|
errors.append(
|
||||||
|
f"AFFECTED IMAGE UNBOUND: scan image {image} has no current service binding"
|
||||||
|
)
|
||||||
|
elif len(matches) > 1:
|
||||||
|
errors.append(f"AFFECTED IMAGE AMBIGUOUS: scan image {image} maps to multiple services")
|
||||||
|
else:
|
||||||
|
services.append(matches[0])
|
||||||
|
return services, errors
|
||||||
|
|
||||||
|
|
||||||
def check(
|
def check(
|
||||||
scan: dict[str, Any],
|
scan: dict[str, Any],
|
||||||
exceptions_doc: dict[str, Any],
|
exceptions_doc: dict[str, Any],
|
||||||
image_digests: dict[str, str] | None,
|
image_digests: dict[str, Any] | None,
|
||||||
today: date,
|
today: date,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
by_key: dict[tuple[str, str], dict[str, Any]] = {}
|
image_inventory: dict[str, dict[str, str]] = {}
|
||||||
|
if image_digests is not None:
|
||||||
|
image_inventory, inventory_errors = _current_image_inventory(image_digests)
|
||||||
|
errors.extend(inventory_errors)
|
||||||
|
|
||||||
|
by_key: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||||
|
raw_by_key: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||||
|
conflicting_keys: set[tuple[str, str, str]] = set()
|
||||||
for exception in exceptions_doc.get("exceptions", []):
|
for exception in exceptions_doc.get("exceptions", []):
|
||||||
|
if not isinstance(exception, dict):
|
||||||
|
errors.append("exception entry must be an object")
|
||||||
|
continue
|
||||||
|
package = exception.get("package")
|
||||||
|
cve = exception.get("cve")
|
||||||
|
version = exception.get("installedVersion")
|
||||||
|
key: tuple[str, str, str] | None = None
|
||||||
|
if isinstance(package, str) and isinstance(cve, str) and isinstance(version, str):
|
||||||
|
if package and cve and version:
|
||||||
|
key = (package, cve, version)
|
||||||
|
if key is not None and key in raw_by_key and raw_by_key[key] != exception:
|
||||||
|
conflicting_keys.add(key)
|
||||||
|
errors.append(
|
||||||
|
f"CONFLICTING DUPLICATE: multiple exception entries disagree for {package}/{cve}"
|
||||||
|
)
|
||||||
|
elif key is not None:
|
||||||
|
raw_by_key.setdefault(key, exception)
|
||||||
|
|
||||||
missing = [field for field in REQUIRED_FIELDS if not exception.get(field)]
|
missing = [field for field in REQUIRED_FIELDS if not exception.get(field)]
|
||||||
# riskOwner/approver/compensatingControls may be legitimately empty
|
# riskOwner/approver/compensatingControls may be legitimately empty
|
||||||
# only while approvalStatus is "pending" -- once "approved" they are
|
# only while approvalStatus is "pending" -- once "approved" they are
|
||||||
@@ -87,12 +199,44 @@ def check(
|
|||||||
f"exception {exception.get('id', '<no id>')} is missing required fields: {missing}"
|
f"exception {exception.get('id', '<no id>')} is missing required fields: {missing}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
key = (exception["package"], exception["cve"])
|
if key is not None:
|
||||||
by_key[key] = exception
|
by_key.setdefault(key, exception)
|
||||||
|
|
||||||
for package, cve, severity in _findings(scan):
|
for key in conflicting_keys:
|
||||||
key = (package, cve)
|
by_key.pop(key, None)
|
||||||
exception = by_key.get(key)
|
|
||||||
|
by_pair: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||||
|
for (package, cve, _version), exception in by_key.items():
|
||||||
|
by_pair.setdefault((package, cve), []).append(exception)
|
||||||
|
|
||||||
|
for row, cve in _findings(scan):
|
||||||
|
package = row["package"]
|
||||||
|
severity = row.get("severity", "UNKNOWN")
|
||||||
|
reviewed = by_pair.get((package, cve), [])
|
||||||
|
if "installedVersion" in row:
|
||||||
|
scanned_version = row.get("installedVersion")
|
||||||
|
if not isinstance(scanned_version, str) or not scanned_version:
|
||||||
|
errors.append(
|
||||||
|
f"SCAN VERSION MISSING: {package}/{cve} has no valid installedVersion"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
exception = by_key.get((package, cve, scanned_version))
|
||||||
|
if exception is None and reviewed:
|
||||||
|
errors.append(
|
||||||
|
f"VERSION MISMATCH: {package}/{cve} scan found {scanned_version}, "
|
||||||
|
"but no exception reviewed that exact version"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Existing v1 evidence may omit the installed version. It remains
|
||||||
|
# usable only when one unambiguous reviewed version exists.
|
||||||
|
if len(reviewed) > 1:
|
||||||
|
errors.append(
|
||||||
|
f"AMBIGUOUS VERSION: {package}/{cve} legacy scan omits installedVersion "
|
||||||
|
"and multiple versions were reviewed"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
exception = reviewed[0] if reviewed else None
|
||||||
if exception is None:
|
if exception is None:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"UNAPPROVED: {severity} finding {package}/{cve} has no exception recorded at all"
|
f"UNAPPROVED: {severity} finding {package}/{cve} has no exception recorded at all"
|
||||||
@@ -128,11 +272,38 @@ def check(
|
|||||||
f"{rescan_due.isoformat()} has passed"
|
f"{rescan_due.isoformat()} has passed"
|
||||||
)
|
)
|
||||||
|
|
||||||
if image_digests:
|
if image_digests is not None:
|
||||||
|
legacy_full_inventory = "affectedImages" not in row
|
||||||
|
services, scope_errors = _affected_services(row, image_inventory)
|
||||||
|
errors.extend(scope_errors)
|
||||||
recorded = exception.get("affectedImageDigests", {})
|
recorded = exception.get("affectedImageDigests", {})
|
||||||
for service, current_digest in image_digests.items():
|
if not isinstance(recorded, dict):
|
||||||
|
errors.append(
|
||||||
|
f"IMAGE BINDING INVALID: {package}/{cve} affectedImageDigests must be an object"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if legacy_full_inventory and set(recorded) != set(image_inventory):
|
||||||
|
errors.append(
|
||||||
|
f"IMAGE INVENTORY SCOPE CHANGED: {package}/{cve} uses a legacy scan "
|
||||||
|
"without affectedImages and its reviewed service set does not exactly "
|
||||||
|
"match the current full inventory"
|
||||||
|
)
|
||||||
|
for service in services:
|
||||||
|
current_digest = image_inventory[service]["binding"]
|
||||||
recorded_digest = recorded.get(service)
|
recorded_digest = recorded.get(service)
|
||||||
if recorded_digest and recorded_digest != current_digest:
|
if recorded_digest is None or recorded_digest == "":
|
||||||
|
errors.append(
|
||||||
|
f"IMAGE BINDING MISSING: {package}/{cve} has no valid reviewed digest "
|
||||||
|
f"for {service}"
|
||||||
|
)
|
||||||
|
elif not isinstance(recorded_digest, str) or not _SHA256_IMAGE_ID.fullmatch(
|
||||||
|
recorded_digest
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"IMAGE BINDING INVALID: {package}/{cve} has a malformed reviewed digest "
|
||||||
|
f"for {service}"
|
||||||
|
)
|
||||||
|
elif recorded_digest != current_digest:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"DIGEST CHANGED: {package}/{cve} exception recorded "
|
f"DIGEST CHANGED: {package}/{cve} exception recorded "
|
||||||
f"{service}={recorded_digest}, but the current build is "
|
f"{service}={recorded_digest}, but the current build is "
|
||||||
@@ -151,19 +322,16 @@ def main() -> int:
|
|||||||
"--image-digests",
|
"--image-digests",
|
||||||
type=Path,
|
type=Path,
|
||||||
help="evidence/<packet>/image-digests.json; when given, exceptions whose "
|
help="evidence/<packet>/image-digests.json; when given, exceptions whose "
|
||||||
"recorded digest for a named service disagrees with the current build fail.",
|
"current image IDs or reviewed bindings are missing, malformed, or changed fail.",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
scan = load_json(args.scan_summary)
|
scan = load_json(args.scan_summary)
|
||||||
exceptions_doc = load_json(args.exceptions)
|
exceptions_doc = load_json(args.exceptions)
|
||||||
image_digests: dict[str, str] | None = None
|
image_digests: dict[str, Any] | None = None
|
||||||
if args.image_digests:
|
if args.image_digests:
|
||||||
digest_doc = load_json(args.image_digests)
|
digest_doc = load_json(args.image_digests)
|
||||||
image_digests = {
|
image_digests = digest_doc.get("images", {})
|
||||||
service: (entry.get("digest") or entry.get("imageId"))
|
|
||||||
for service, entry in digest_doc.get("images", {}).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
errors = check(scan, exceptions_doc, image_digests, date.today())
|
errors = check(scan, exceptions_doc, image_digests, date.today())
|
||||||
if errors:
|
if errors:
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepare a hash-locked, source-checked ContextForge candidate build recipe."""
|
||||||
|
|
||||||
|
# Exact image references, source anchors, and Dockerfile instructions exceed 100 columns.
|
||||||
|
# ruff: noqa: E501
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
LOCK_DIR = ROOT / "infrastructure" / "images" / "contextforge" / "candidate"
|
||||||
|
LOCK_MANIFEST = LOCK_DIR / "locks.json"
|
||||||
|
PRODUCTION_LOCK = LOCK_DIR / "production-requirements.lock"
|
||||||
|
BUILD_TOOLS_LOCK = LOCK_DIR / "build-tools.lock"
|
||||||
|
|
||||||
|
UPSTREAM_COMMIT = "1e58a0434a78bbdbe1e975abeb0bed1043ff14c1"
|
||||||
|
UV_VERSION = "0.12.10"
|
||||||
|
LOCK_TIMESTAMP = "2026-10-02T00:00:00Z"
|
||||||
|
GLOBAL_LOCK_CUTOFF = "2026-09-22T00:00:00Z"
|
||||||
|
EXTRAS = ("redis", "postgres", "observability", "granian", "plugins", "llmchat")
|
||||||
|
CONSTRAINTS = ("mcp==1.30.0", "PyJWT==2.15.0", "urllib3==2.8.0")
|
||||||
|
BUILD_TOOLS = ("pip==26.2.1", "setuptools==82.0.1", "wheel==0.46.2")
|
||||||
|
|
||||||
|
SOURCE_HASHES = {
|
||||||
|
"Containerfile": "2ab0187f3a63eced093a1dec3141ec3e30ef879eb7de0dfd9e4bc5fce33f64f2",
|
||||||
|
"pyproject.toml": "cd365566f922546e569c65475d78cd53e5d49f10df2c82534efa607ffcf55e4b",
|
||||||
|
"uv.lock": "c5297a4701e4a0ecb9903bd3f4aa1c8cf40cf4e5568880fcb8f4078703589df1",
|
||||||
|
}
|
||||||
|
|
||||||
|
BASE_IMAGES = (
|
||||||
|
{
|
||||||
|
"arg": "UBI_BASE",
|
||||||
|
"sourceTag": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder:sha256-c6bb70a7a340ed1047e7cb934aa55b086ee68a5eb9ba6b2ad2e6825c7184a3e8",
|
||||||
|
"reference": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder@sha256:e23a733abd28376eb3328676a3ab1e6d153122228110de0c881bb5f3c38a4483",
|
||||||
|
"parentReference": "registry.access.redhat.com/ubi10@sha256:1e32d829a4de713a835d702d3bc05f0c448a08590796281b7dec181912e9aeb8",
|
||||||
|
"imageId": "sha256:c6bb70a7a340ed1047e7cb934aa55b086ee68a5eb9ba6b2ad2e6825c7184a3e8",
|
||||||
|
"rpmInventorySha256": "6a8c0d4f66fc5a38008541ad15614711e7f536cd18c70c02d2eb0691552b88b0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "NODEJS_IMAGE",
|
||||||
|
"sourceTag": "registry.access.redhat.com/ubi10/nodejs-24:1784092609",
|
||||||
|
"reference": "registry.access.redhat.com/ubi10/nodejs-24@sha256:0f110e68682f4a49188ba4db786eb4f207b119420913fc76bb5ba9512ffbef06",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arg": "UBI_MINIMAL",
|
||||||
|
"sourceTag": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime:sha256-fd94e2643dfc0401790d0528d3740bb2f0c04709e743f6c774a4e8a38606e40b",
|
||||||
|
"reference": "gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime@sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236",
|
||||||
|
"parentReference": "registry.access.redhat.com/ubi10/ubi-minimal@sha256:afbfb5c18c628796fed3ad3122dcf096e0b2dec9dad3fbabe8620de06bc6f078",
|
||||||
|
"imageId": "sha256:fd94e2643dfc0401790d0528d3740bb2f0c04709e743f6c774a4e8a38606e40b",
|
||||||
|
"rpmInventorySha256": "a74d60ecb43d1aa561874305d1a9c62ac35c99a14c1dc275ba1656f14df7d034",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
LOCK_OUTPUT_NAMES = {
|
||||||
|
"production-requirements.lock": "contextforge-candidate-production-requirements.txt",
|
||||||
|
"build-tools.lock": "contextforge-candidate-build-tools.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
INSTALL_START = (
|
||||||
|
"RUN set -euo pipefail \\\n"
|
||||||
|
" && . /etc/profile.d/use-openssl.sh \\\n"
|
||||||
|
" && python3 -m venv /app/.venv"
|
||||||
|
)
|
||||||
|
INSTALL_END = "\n# ----------------------------------------------------------------------------\n# Copy Rust runtime binaries and frontend static output from their builder\n"
|
||||||
|
LOCK_COPY_ANCHOR = "COPY pyproject.toml /app/\n"
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRecipeError(ValueError):
|
||||||
|
"""Raised when a candidate recipe input is absent or differs from contract."""
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_hex(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_file(path: Path) -> bytes:
|
||||||
|
if not path.is_file() or path.is_symlink():
|
||||||
|
raise CandidateRecipeError(f"required regular file is missing: {path.name}")
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_upstream_source(
|
||||||
|
source_dir: Path, expected_hashes: dict[str, str] | None = None
|
||||||
|
) -> dict[str, bytes]:
|
||||||
|
files: dict[str, bytes] = {}
|
||||||
|
for name, expected in (expected_hashes or SOURCE_HASHES).items():
|
||||||
|
content = _require_file(source_dir / name)
|
||||||
|
actual = sha256_hex(content)
|
||||||
|
if actual != expected:
|
||||||
|
raise CandidateRecipeError(f"pinned upstream {name} hash mismatch")
|
||||||
|
files[name] = content
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _table_range(text: str, table: str) -> tuple[int, int, str]:
|
||||||
|
header = f"[{table}]"
|
||||||
|
matches = list(re.finditer(rf"(?m)^{re.escape(header)}\s*$", text))
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise CandidateRecipeError(f"expected exactly one TOML table {header}")
|
||||||
|
start = matches[0].start()
|
||||||
|
next_header = re.search(r"(?m)^\[[^\r\n]+\]\s*$", text[matches[0].end() :])
|
||||||
|
end = matches[0].end() + next_header.start() if next_header else len(text)
|
||||||
|
return start, end, text[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_pyproject(source_text: str) -> str:
|
||||||
|
"""Apply the narrowly scoped candidate-only dependency resolution policy."""
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
source = tomllib.loads(source_text)
|
||||||
|
except (ImportError, ValueError) as exc:
|
||||||
|
raise CandidateRecipeError("upstream pyproject is not valid TOML") from exc
|
||||||
|
|
||||||
|
uv = source.get("tool", {}).get("uv", {})
|
||||||
|
if uv.get("exclude-newer") != "10 days":
|
||||||
|
raise CandidateRecipeError("upstream uv freshness policy changed")
|
||||||
|
groups = source.get("dependency-groups", {})
|
||||||
|
if set(groups) != {"dev"}:
|
||||||
|
raise CandidateRecipeError("upstream dependency-group shape changed")
|
||||||
|
sources = uv.get("sources", {})
|
||||||
|
if sources != {
|
||||||
|
"compliance-reference-server": {
|
||||||
|
"path": "mcp-servers/python/compliance_reference_server",
|
||||||
|
"editable": True,
|
||||||
|
}
|
||||||
|
}:
|
||||||
|
raise CandidateRecipeError("upstream uv source override shape changed")
|
||||||
|
|
||||||
|
text = source_text.replace("\r\n", "\n")
|
||||||
|
source_start, source_end, source_table = _table_range(text, "tool.uv.sources")
|
||||||
|
source_lines = source_table.strip().splitlines()
|
||||||
|
expected_source_lines = [
|
||||||
|
"[tool.uv.sources]",
|
||||||
|
'compliance-reference-server = { path = "mcp-servers/python/compliance_reference_server", editable = true }',
|
||||||
|
]
|
||||||
|
if source_lines[:2] != expected_source_lines or any(
|
||||||
|
line.strip() and not line.lstrip().startswith("#") for line in source_lines[2:]
|
||||||
|
):
|
||||||
|
raise CandidateRecipeError("upstream local development source table changed")
|
||||||
|
text = text[:source_start] + text[source_end:]
|
||||||
|
|
||||||
|
group_start, group_end, _group_table = _table_range(text, "dependency-groups")
|
||||||
|
text = text[:group_start] + text[group_end:]
|
||||||
|
|
||||||
|
uv_anchor = 'exclude-newer = "10 days"\n'
|
||||||
|
if text.count(uv_anchor) != 1:
|
||||||
|
raise CandidateRecipeError("upstream uv table anchor changed")
|
||||||
|
text = text.replace(
|
||||||
|
uv_anchor,
|
||||||
|
f'exclude-newer = "{GLOBAL_LOCK_CUTOFF}"\n'
|
||||||
|
+ 'constraint-dependencies = ["mcp==1.30.0", "PyJWT==2.15.0", "urllib3==2.8.0"]\n',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
package_table = "[tool.uv.exclude-newer-package]\n"
|
||||||
|
if text.count(package_table) != 1:
|
||||||
|
raise CandidateRecipeError("upstream package freshness table is missing")
|
||||||
|
package_overrides = "".join(
|
||||||
|
f'{name.lower()} = "{LOCK_TIMESTAMP}"\n'
|
||||||
|
for name in ("mcp", "pyjwt", "urllib3", "pip", "setuptools", "wheel")
|
||||||
|
)
|
||||||
|
text = text.replace(package_table, package_table + package_overrides, 1)
|
||||||
|
|
||||||
|
text = text.rstrip() + "\n\n[dependency-groups]\nbuild-tools = [\n"
|
||||||
|
text += "".join(f' "{requirement}",\n' for requirement in BUILD_TOOLS)
|
||||||
|
text += "]\n"
|
||||||
|
try:
|
||||||
|
tomllib.loads(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CandidateRecipeError("generated candidate pyproject is invalid TOML") from exc
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _run_uv(args: list[str], cwd: Path) -> None:
|
||||||
|
completed = subprocess.run( # noqa: S603 - fixed executable and structured arguments
|
||||||
|
[_uv_binary(), *args],
|
||||||
|
cwd=cwd,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if completed.returncode:
|
||||||
|
diagnostic = completed.stderr.strip().splitlines()[-1:] or ["uv command failed"]
|
||||||
|
raise CandidateRecipeError(diagnostic[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _uv_binary() -> str:
|
||||||
|
binary = shutil.which("uv")
|
||||||
|
if binary is None or not Path(binary).is_absolute():
|
||||||
|
raise CandidateRecipeError("uv executable is missing or non-absolute")
|
||||||
|
return binary
|
||||||
|
|
||||||
|
|
||||||
|
def generate_locks(source_dir: Path, output_dir: Path) -> None:
|
||||||
|
"""Regenerate locks from the exact upstream project using the pinned uv tool."""
|
||||||
|
source = verify_upstream_source(source_dir)
|
||||||
|
version = subprocess.run( # noqa: S603 - fixed version check before lock generation
|
||||||
|
[_uv_binary(), "--version"], check=False, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
version_parts = version.stdout.strip().split()
|
||||||
|
if version.returncode or version_parts[:2] != ["uv", UV_VERSION]:
|
||||||
|
raise CandidateRecipeError(f"lock generation requires uv {UV_VERSION}")
|
||||||
|
|
||||||
|
output_dir = output_dir.resolve()
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="contextforge-lock-") as temp_name:
|
||||||
|
temp = Path(temp_name)
|
||||||
|
(temp / "pyproject.toml").write_text(
|
||||||
|
candidate_pyproject(source["pyproject.toml"].decode("utf-8")), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(temp / "uv.lock").write_bytes(source["uv.lock"])
|
||||||
|
_run_uv(["lock", "--project", str(temp)], temp)
|
||||||
|
|
||||||
|
production_path = output_dir / "production-requirements.lock"
|
||||||
|
build_path = output_dir / "build-tools.lock"
|
||||||
|
_run_uv(
|
||||||
|
[
|
||||||
|
"export",
|
||||||
|
"--project",
|
||||||
|
str(temp),
|
||||||
|
"--format",
|
||||||
|
"requirements-txt",
|
||||||
|
"--locked",
|
||||||
|
"--no-dev",
|
||||||
|
"--no-default-groups",
|
||||||
|
"--no-emit-project",
|
||||||
|
"--no-header",
|
||||||
|
"--no-annotate",
|
||||||
|
*[value for extra in EXTRAS for value in ("--extra", extra)],
|
||||||
|
"--output-file",
|
||||||
|
str(production_path),
|
||||||
|
],
|
||||||
|
temp,
|
||||||
|
)
|
||||||
|
_run_uv(
|
||||||
|
[
|
||||||
|
"export",
|
||||||
|
"--project",
|
||||||
|
str(temp),
|
||||||
|
"--format",
|
||||||
|
"requirements-txt",
|
||||||
|
"--locked",
|
||||||
|
"--only-group",
|
||||||
|
"build-tools",
|
||||||
|
"--no-default-groups",
|
||||||
|
"--no-emit-project",
|
||||||
|
"--no-header",
|
||||||
|
"--no-annotate",
|
||||||
|
"--output-file",
|
||||||
|
str(build_path),
|
||||||
|
],
|
||||||
|
temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = _make_manifest(
|
||||||
|
source_hashes=SOURCE_HASHES,
|
||||||
|
production_hash=sha256_hex(_require_file(output_dir / "production-requirements.lock")),
|
||||||
|
build_tools_hash=sha256_hex(_require_file(output_dir / "build-tools.lock")),
|
||||||
|
)
|
||||||
|
_write_atomic(output_dir / "locks.json", _canonical_json(manifest))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_manifest(
|
||||||
|
*, source_hashes: dict[str, str], production_hash: str, build_tools_hash: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema": "itworx.contextforge-candidate-locks/v1",
|
||||||
|
"upstreamCommit": UPSTREAM_COMMIT,
|
||||||
|
"upstreamFiles": source_hashes,
|
||||||
|
"uvVersion": UV_VERSION,
|
||||||
|
"python": "3.12",
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"extras": list(EXTRAS),
|
||||||
|
"constraints": list(CONSTRAINTS),
|
||||||
|
"buildTools": list(BUILD_TOOLS),
|
||||||
|
"globalExcludeNewerUtc": GLOBAL_LOCK_CUTOFF,
|
||||||
|
"freshnessOverrideUtc": LOCK_TIMESTAMP,
|
||||||
|
"requirements": {
|
||||||
|
"production-requirements.lock": production_hash,
|
||||||
|
"build-tools.lock": build_tools_hash,
|
||||||
|
},
|
||||||
|
"baseImages": [
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"childManifestDigest": base["reference"].split("@", 1)[1],
|
||||||
|
}
|
||||||
|
for base in BASE_IMAGES
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: Any) -> bytes:
|
||||||
|
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_atomic(path: Path, content: bytes) -> None:
|
||||||
|
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||||
|
try:
|
||||||
|
temporary.write_bytes(content)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_lock_manifest() -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(_require_file(LOCK_MANIFEST).decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
raise CandidateRecipeError("candidate lock manifest is invalid JSON") from exc
|
||||||
|
expected = _make_manifest(
|
||||||
|
source_hashes=SOURCE_HASHES,
|
||||||
|
production_hash=str(
|
||||||
|
payload.get("requirements", {}).get("production-requirements.lock", "")
|
||||||
|
),
|
||||||
|
build_tools_hash=str(payload.get("requirements", {}).get("build-tools.lock", "")),
|
||||||
|
)
|
||||||
|
if payload != expected:
|
||||||
|
raise CandidateRecipeError("candidate lock manifest differs from the reviewed contract")
|
||||||
|
for name, expected_hash in payload["requirements"].items():
|
||||||
|
if sha256_hex(_require_file(LOCK_DIR / name)) != expected_hash:
|
||||||
|
raise CandidateRecipeError(f"candidate lock hash mismatch: {name}")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def verify_lock_contents(manifest: dict[str, Any]) -> None:
|
||||||
|
production = _require_file(LOCK_DIR / "production-requirements.lock").decode("utf-8")
|
||||||
|
build_tools = _require_file(LOCK_DIR / "build-tools.lock").decode("utf-8")
|
||||||
|
for requirement in CONSTRAINTS:
|
||||||
|
name, version = requirement.split("==", 1)
|
||||||
|
match = re.search(rf"(?im)^{re.escape(name)}==([^ ;\\]+)", production)
|
||||||
|
if match is None or match.group(1) != version:
|
||||||
|
raise CandidateRecipeError(f"production lock does not pin {name}=={version}")
|
||||||
|
for requirement in BUILD_TOOLS:
|
||||||
|
name, version = requirement.split("==", 1)
|
||||||
|
match = re.search(rf"(?im)^{re.escape(name)}==([^ ;\\]+)", build_tools)
|
||||||
|
if match is None or match.group(1) != version:
|
||||||
|
raise CandidateRecipeError(f"build-tool lock does not pin {name}=={version}")
|
||||||
|
for name, requirements in (
|
||||||
|
("production", production),
|
||||||
|
("build-tool", build_tools),
|
||||||
|
):
|
||||||
|
blocks = re.split(r"(?m)(?=^[A-Za-z0-9_.-]+==)", requirements)
|
||||||
|
blocks = [block for block in blocks if re.match(r"(?m)^[A-Za-z0-9_.-]+==", block)]
|
||||||
|
if not blocks or any(
|
||||||
|
not re.search(r"(?m)^\s*--hash=sha256:[0-9a-f]{64}\s*$", block) for block in blocks
|
||||||
|
):
|
||||||
|
raise CandidateRecipeError(f"candidate {name} requirements must pin every wheel hash")
|
||||||
|
if "pip==26.2.1" not in build_tools:
|
||||||
|
raise CandidateRecipeError("candidate build tools must include the removable pip bootstrap")
|
||||||
|
if manifest["platform"] != "linux/amd64":
|
||||||
|
raise CandidateRecipeError("candidate lock platform differs from the fixed build platform")
|
||||||
|
|
||||||
|
|
||||||
|
def freeze_os_layers(text: str) -> str:
|
||||||
|
"""Replace the four upstream RPM transactions with frozen-base assertions."""
|
||||||
|
guards = (
|
||||||
|
(
|
||||||
|
'RUN if [ "$ENABLE_RUST" = "true" ]; then',
|
||||||
|
'RUN test "$ENABLE_RUST" = "false" && test "$ENABLE_RUST_MCP_RMCP" = "false"',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"RUN set -euo pipefail",
|
||||||
|
'RUN test "$PYTHON_VERSION" = "3.12" && test -s /usr/share/itworx/rpm-inventory.txt',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"RUN microdnf upgrade",
|
||||||
|
'RUN test "$PYTHON_VERSION" = "3.12" && test "$ENABLE_PROFILING" = "false" '
|
||||||
|
'&& test "$(id -u app):$(id -g app)" = "10001:10001" '
|
||||||
|
"&& test -s /usr/share/itworx/rpm-inventory.txt",
|
||||||
|
),
|
||||||
|
('RUN if [ "$ENABLE_FIPS" = "true" ]; then', 'RUN test "$ENABLE_FIPS" = "false"'),
|
||||||
|
)
|
||||||
|
transactions = [
|
||||||
|
match
|
||||||
|
for match in re.finditer(r"(?m)^RUN [^\n]*(?:\n(?<=\\\n)[^\n]*)*", text)
|
||||||
|
if re.search(r"\b(?:microdnf|dnf) (?:upgrade|install)\b", match.group())
|
||||||
|
]
|
||||||
|
if len(transactions) != len(guards):
|
||||||
|
raise CandidateRecipeError("upstream RPM transaction count changed")
|
||||||
|
for match, (prefix, guard) in reversed(list(zip(transactions, guards, strict=True))):
|
||||||
|
if not match.group().startswith(prefix):
|
||||||
|
raise CandidateRecipeError("upstream RPM transaction order changed")
|
||||||
|
text = text[: match.start()] + guard + text[match.end() :]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def transform_containerfile(containerfile: bytes, expected_hash: str | None = None) -> bytes:
|
||||||
|
if sha256_hex(containerfile) != (expected_hash or SOURCE_HASHES["Containerfile"]):
|
||||||
|
raise CandidateRecipeError("pinned upstream Containerfile hash mismatch")
|
||||||
|
text = freeze_os_layers(containerfile.decode("utf-8"))
|
||||||
|
if text.count(LOCK_COPY_ANCHOR) != 1:
|
||||||
|
raise CandidateRecipeError("upstream dependency-copy anchor count changed")
|
||||||
|
text = text.replace(
|
||||||
|
LOCK_COPY_ANCHOR,
|
||||||
|
LOCK_COPY_ANCHOR
|
||||||
|
+ "COPY contextforge-candidate-production-requirements.txt /tmp/contextforge-production-requirements.txt\n"
|
||||||
|
+ "COPY contextforge-candidate-build-tools.txt /tmp/contextforge-build-tools.txt\n",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
start = text.find(INSTALL_START)
|
||||||
|
end = text.find(INSTALL_END, start)
|
||||||
|
if start < 0 or end < 0 or text.find(INSTALL_START, start + 1) >= 0:
|
||||||
|
raise CandidateRecipeError("upstream dependency-install block anchors changed")
|
||||||
|
locked_install = """RUN set -euo pipefail \\
|
||||||
|
&& . /etc/profile.d/use-openssl.sh \\
|
||||||
|
&& python3 -m venv /app/.venv \\
|
||||||
|
&& /app/.venv/bin/python -m ensurepip --upgrade \\
|
||||||
|
&& /app/.venv/bin/python -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary=:all: -r /tmp/contextforge-build-tools.txt \\
|
||||||
|
&& /app/.venv/bin/python -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary=:all: -r /tmp/contextforge-production-requirements.txt \\
|
||||||
|
&& /app/.venv/bin/python -m pip install --disable-pip-version-check --no-cache-dir --no-deps --no-build-isolation ".[redis,postgres,observability,granian,plugins,llmchat]" \\
|
||||||
|
&& /app/.venv/bin/python -m pip check \\
|
||||||
|
&& /app/.venv/bin/python -c "import importlib.metadata as md; assert md.version('mcp') == '1.30.0'; assert md.version('PyJWT') == '2.15.0'; assert md.version('urllib3') == '2.8.0'" \\
|
||||||
|
&& /app/.venv/bin/python -m pip uninstall --yes pip setuptools wheel \\
|
||||||
|
&& (find /app/.venv -name "*.dist-info" -type d \\
|
||||||
|
\\( -name "pip-*" -o -name "setuptools-*" -o -name "wheel-*" \\) \\
|
||||||
|
-exec rm -rf {} + 2>/dev/null || true) \\
|
||||||
|
&& rm -rf /app/.venv/share/python-wheels \\
|
||||||
|
&& rm -rf "/app/"*.egg-info /app/build /app/dist /app/.eggs \\
|
||||||
|
&& rm -rf /root/.cache /var/cache/dnf
|
||||||
|
"""
|
||||||
|
text = text[:start] + locked_install + text[end:]
|
||||||
|
return text.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_source(source_dir: Path) -> None:
|
||||||
|
source = verify_upstream_source(source_dir)
|
||||||
|
manifest = _read_lock_manifest()
|
||||||
|
verify_lock_contents(manifest)
|
||||||
|
transformed = transform_containerfile(source["Containerfile"])
|
||||||
|
|
||||||
|
for source_name, target_name in LOCK_OUTPUT_NAMES.items():
|
||||||
|
_write_atomic(source_dir / target_name, _require_file(LOCK_DIR / source_name))
|
||||||
|
_write_atomic(source_dir / "Containerfile.itworx-candidate", transformed)
|
||||||
|
|
||||||
|
|
||||||
|
def print_build_args() -> None:
|
||||||
|
for key, value in candidate_build_args().items():
|
||||||
|
print(f"{key}={value}")
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_build_args() -> dict[str, str]:
|
||||||
|
manifest = _read_lock_manifest()
|
||||||
|
verify_lock_contents(manifest)
|
||||||
|
overrides = {item["arg"]: item["reference"] for item in BASE_IMAGES}
|
||||||
|
overrides.update(
|
||||||
|
{
|
||||||
|
"WHEELS_REF": overrides["UBI_MINIMAL"],
|
||||||
|
"PYTHON_VERSION": "3.12",
|
||||||
|
"ENABLE_RUST": "false",
|
||||||
|
"ENABLE_RUST_MCP_RMCP": "false",
|
||||||
|
"ENABLE_PROFILING": "false",
|
||||||
|
"ENABLE_FIPS": "false",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
prepare = commands.add_parser(
|
||||||
|
"prepare", help="verify and transform an exact fetched source tree"
|
||||||
|
)
|
||||||
|
prepare.add_argument("--source-dir", type=Path, required=True)
|
||||||
|
generate = commands.add_parser("generate-locks", help="regenerate lock files with pinned uv")
|
||||||
|
generate.add_argument("--source-dir", type=Path, required=True)
|
||||||
|
generate.add_argument("--output-dir", type=Path, default=LOCK_DIR)
|
||||||
|
commands.add_parser("build-args", help="print fixed platform/base image build arguments")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.command == "prepare":
|
||||||
|
prepare_source(args.source_dir)
|
||||||
|
elif args.command == "generate-locks":
|
||||||
|
generate_locks(args.source_dir, args.output_dir)
|
||||||
|
else:
|
||||||
|
print_build_args()
|
||||||
|
except (CandidateRecipeError, OSError, subprocess.SubprocessError) as exc:
|
||||||
|
parser.exit(1, f"FATAL: {exc}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -16,6 +16,7 @@ satisfying the release gate for that finding.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
@@ -28,6 +29,52 @@ if str(_ROOT_DIR) not in sys.path:
|
|||||||
from scripts.packlib import brussels_now, load_json # noqa: E402
|
from scripts.packlib import brussels_now, load_json # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _affected_image_digests(row: dict[str, object], images: dict[str, object]) -> dict[str, str]:
|
||||||
|
"""Select current image bindings by scan tag, with a legacy full-inventory fallback."""
|
||||||
|
if not images:
|
||||||
|
raise ValueError("--image-digests must contain a non-empty images map")
|
||||||
|
|
||||||
|
if "affectedImages" not in row:
|
||||||
|
services = list(images)
|
||||||
|
else:
|
||||||
|
affected_images = row.get("affectedImages")
|
||||||
|
if (
|
||||||
|
not isinstance(affected_images, list)
|
||||||
|
or not affected_images
|
||||||
|
or any(not isinstance(tag, str) or not tag.strip() for tag in affected_images)
|
||||||
|
or len(set(affected_images)) != len(affected_images)
|
||||||
|
):
|
||||||
|
raise ValueError("scan affectedImages must be a non-empty list of unique image tags")
|
||||||
|
|
||||||
|
services_by_tag: dict[str, list[str]] = {}
|
||||||
|
for service, entry in images.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
tag = entry.get("tag")
|
||||||
|
if isinstance(tag, str) and tag:
|
||||||
|
services_by_tag.setdefault(tag, []).append(service)
|
||||||
|
|
||||||
|
services = []
|
||||||
|
for tag in affected_images:
|
||||||
|
matches = services_by_tag.get(tag, [])
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"scan image {tag} must resolve to exactly one current image service"
|
||||||
|
)
|
||||||
|
services.append(matches[0])
|
||||||
|
|
||||||
|
bindings: dict[str, str] = {}
|
||||||
|
for service in services:
|
||||||
|
entry = images.get(service)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ValueError(f"image evidence for {service} must be an object")
|
||||||
|
digest = entry.get("digest") or entry.get("imageId")
|
||||||
|
if not isinstance(digest, str) or not digest:
|
||||||
|
raise ValueError(f"image evidence for {service} has no immutable digest or imageId")
|
||||||
|
bindings[service] = digest
|
||||||
|
return bindings
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--scan-summary", required=True, type=Path)
|
parser.add_argument("--scan-summary", required=True, type=Path)
|
||||||
@@ -46,25 +93,41 @@ def main() -> int:
|
|||||||
scan_date = scan.get("scannedAt", brussels_now())[:10]
|
scan_date = scan.get("scannedAt", brussels_now())[:10]
|
||||||
scanner = f"trivy {scan.get('trivyVersion', 'unknown')}"
|
scanner = f"trivy {scan.get('trivyVersion', 'unknown')}"
|
||||||
|
|
||||||
affected_digests: dict[str, str] = {}
|
image_inventory: dict[str, object] = {}
|
||||||
if args.image_digests:
|
if args.image_digests:
|
||||||
digest_doc = load_json(args.image_digests)
|
digest_doc = load_json(args.image_digests)
|
||||||
for service, entry in digest_doc.get("images", {}).items():
|
raw_images = digest_doc.get("images", {})
|
||||||
digest = entry.get("digest") or entry.get("imageId")
|
if isinstance(raw_images, dict):
|
||||||
if digest:
|
image_inventory = raw_images
|
||||||
affected_digests[service] = digest
|
|
||||||
|
|
||||||
today = date.fromisoformat(scan_date) if len(scan_date) == 10 else date.today()
|
today = date.fromisoformat(scan_date) if len(scan_date) == 10 else date.today()
|
||||||
expiry = (today + timedelta(days=args.expiry_days)).isoformat()
|
expiry = (today + timedelta(days=args.expiry_days)).isoformat()
|
||||||
rescan = (today + timedelta(days=args.rescan_days)).isoformat()
|
rescan = (today + timedelta(days=args.rescan_days)).isoformat()
|
||||||
|
|
||||||
|
versions_by_pair: dict[tuple[str, str], set[str]] = {}
|
||||||
|
for row in scan.get("unresolvedCriticalOrHigh", []):
|
||||||
|
for cve in row["cves"]:
|
||||||
|
versions_by_pair.setdefault((row["package"], cve), set()).add(
|
||||||
|
str(row.get("installedVersion"))
|
||||||
|
)
|
||||||
exceptions = []
|
exceptions = []
|
||||||
for row in scan.get("unresolvedCriticalOrHigh", []):
|
for row in scan.get("unresolvedCriticalOrHigh", []):
|
||||||
package = row["package"]
|
package = row["package"]
|
||||||
|
try:
|
||||||
|
affected_digests = (
|
||||||
|
_affected_image_digests(row, image_inventory) if args.image_digests else {}
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
for cve in row["cves"]:
|
for cve in row["cves"]:
|
||||||
|
version_suffix = ""
|
||||||
|
if len(versions_by_pair[(package, cve)]) > 1:
|
||||||
|
version_suffix = (
|
||||||
|
"-" + hashlib.sha256(str(row.get("installedVersion")).encode()).hexdigest()[:12]
|
||||||
|
)
|
||||||
exceptions.append(
|
exceptions.append(
|
||||||
{
|
{
|
||||||
"id": f"{package}-{cve}",
|
"id": f"{package}-{cve}{version_suffix}",
|
||||||
"cve": cve,
|
"cve": cve,
|
||||||
"package": package,
|
"package": package,
|
||||||
"installedVersion": row.get("installedVersion"),
|
"installedVersion": row.get("installedVersion"),
|
||||||
|
|||||||
+3
@@ -22,3 +22,6 @@ INSPECTOR_BIND_IP=127.0.0.1
|
|||||||
COMPOSE_PROFILES=observability
|
COMPOSE_PROFILES=observability
|
||||||
DEMO_TOKEN_REVOCATIONS_DIR_HOST=/tmp
|
DEMO_TOKEN_REVOCATIONS_DIR_HOST=/tmp
|
||||||
APPOPS_BUILD_DATE=2026-10-01T18:00:00Z
|
APPOPS_BUILD_DATE=2026-10-01T18:00:00Z
|
||||||
|
APPOPS_SOURCE_TREE=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||||
|
APPOPS_BUILD_ID=aaaaaaaaaaaa
|
||||||
|
SOURCE_DATE_EPOCH=1790888863
|
||||||
@@ -259,16 +259,43 @@ class ComposeContractTests(unittest.TestCase):
|
|||||||
arguments.get("BUILD_DATE"),
|
arguments.get("BUILD_DATE"),
|
||||||
"${APPOPS_BUILD_DATE:?APPOPS_BUILD_DATE is required}",
|
"${APPOPS_BUILD_DATE:?APPOPS_BUILD_DATE is required}",
|
||||||
)
|
)
|
||||||
|
for argument, value in {
|
||||||
|
"GIT_SOURCE_TREE": "${APPOPS_SOURCE_TREE:?APPOPS_SOURCE_TREE is required}",
|
||||||
|
"IMAGE_VERSION": "${APPOPS_BUILD_ID:?APPOPS_BUILD_ID is required}",
|
||||||
|
"SOURCE_DATE_EPOCH": "${SOURCE_DATE_EPOCH:?SOURCE_DATE_EPOCH is required}",
|
||||||
|
}.items():
|
||||||
|
self.assertEqual(arguments.get(argument), value, name)
|
||||||
original_arguments = self.document["services"][name]["build"].get("args", {})
|
original_arguments = self.document["services"][name]["build"].get("args", {})
|
||||||
for key, value in original_arguments.items():
|
for key, value in original_arguments.items():
|
||||||
self.assertEqual(arguments.get(key), value)
|
self.assertEqual(arguments.get(key), value)
|
||||||
|
|
||||||
|
def test_build_dockerfiles_emit_controller_tree_version_and_epoch_labels(self) -> None:
|
||||||
|
dockerfiles = (
|
||||||
|
"infrastructure/images/python-service/Dockerfile",
|
||||||
|
"infrastructure/images/docker-read-broker/Dockerfile",
|
||||||
|
"infrastructure/images/observability/Dockerfile",
|
||||||
|
)
|
||||||
|
expected_labels = {
|
||||||
|
"org.opencontainers.image.source-tree": "GIT_SOURCE_TREE",
|
||||||
|
"org.opencontainers.image.version": "IMAGE_VERSION",
|
||||||
|
"org.opencontainers.image.source-date-epoch": "SOURCE_DATE_EPOCH",
|
||||||
|
}
|
||||||
|
for relative_path in dockerfiles:
|
||||||
|
text = (ROOT / relative_path).read_text(encoding="utf-8")
|
||||||
|
with self.subTest(dockerfile=relative_path):
|
||||||
|
for label, argument in expected_labels.items():
|
||||||
|
self.assertIn(f"ARG {argument}", text)
|
||||||
|
self.assertIn(f'{label}="${{{argument}}}"', text)
|
||||||
|
|
||||||
def test_real_autodeploy_preserves_intake_digest_and_owned_build_revision(self) -> None:
|
def test_real_autodeploy_preserves_intake_digest_and_owned_build_revision(self) -> None:
|
||||||
docker = shutil.which("docker")
|
docker = shutil.which("docker")
|
||||||
if docker is None:
|
if docker is None:
|
||||||
self.skipTest("docker CLI is not available in this environment")
|
self.skipTest("docker CLI is not available in this environment")
|
||||||
revision = "a" * 40
|
revision = "a" * 40
|
||||||
built_at = "2026-10-01T18:00:00Z"
|
built_at = "2026-10-01T18:00:00Z"
|
||||||
|
source_tree = "b" * 40
|
||||||
|
image_version = revision[:12]
|
||||||
|
source_date_epoch = "1790888863"
|
||||||
with tempfile.TemporaryDirectory() as secrets_dir:
|
with tempfile.TemporaryDirectory() as secrets_dir:
|
||||||
for name in self.document["secrets"]:
|
for name in self.document["secrets"]:
|
||||||
(Path(secrets_dir) / name).write_text("test-fixture-secret\n", encoding="utf-8")
|
(Path(secrets_dir) / name).write_text("test-fixture-secret\n", encoding="utf-8")
|
||||||
@@ -301,6 +328,9 @@ class ComposeContractTests(unittest.TestCase):
|
|||||||
"CONNECTOR_INGESTION_IMAGE": f"registry.example.test/intake@sha256:{'b' * 64}",
|
"CONNECTOR_INGESTION_IMAGE": f"registry.example.test/intake@sha256:{'b' * 64}",
|
||||||
"GITEA_COMMIT_SHA": revision,
|
"GITEA_COMMIT_SHA": revision,
|
||||||
"APPOPS_BUILD_DATE": built_at,
|
"APPOPS_BUILD_DATE": built_at,
|
||||||
|
"APPOPS_SOURCE_TREE": source_tree,
|
||||||
|
"APPOPS_BUILD_ID": image_version,
|
||||||
|
"SOURCE_DATE_EPOCH": source_date_epoch,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||||
@@ -316,6 +346,9 @@ class ComposeContractTests(unittest.TestCase):
|
|||||||
continue
|
continue
|
||||||
self.assertEqual(service["build"]["args"]["GIT_REVISION"], revision, name)
|
self.assertEqual(service["build"]["args"]["GIT_REVISION"], revision, name)
|
||||||
self.assertEqual(service["build"]["args"]["BUILD_DATE"], built_at, name)
|
self.assertEqual(service["build"]["args"]["BUILD_DATE"], built_at, name)
|
||||||
|
self.assertEqual(service["build"]["args"]["GIT_SOURCE_TREE"], source_tree, name)
|
||||||
|
self.assertEqual(service["build"]["args"]["IMAGE_VERSION"], image_version, name)
|
||||||
|
self.assertEqual(service["build"]["args"]["SOURCE_DATE_EPOCH"], source_date_epoch, name)
|
||||||
self.assertTrue(service["image"].endswith(f":{revision}"), name)
|
self.assertTrue(service["image"].endswith(f":{revision}"), name)
|
||||||
|
|
||||||
def test_unraid_folder_view_groups_the_complete_production_stack(self) -> None:
|
def test_unraid_folder_view_groups_the_complete_production_stack(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
# The upstream TOML fixture retains exact source formatting.
|
||||||
|
# ruff: noqa: E501
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts"))
|
||||||
|
|
||||||
|
import contextforge_candidate_recipe as recipe # noqa: E402 - script path added above
|
||||||
|
|
||||||
|
LOCK_DIR = ROOT / "infrastructure" / "images" / "contextforge" / "candidate"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _containerfile_fixture() -> bytes:
|
||||||
|
return (
|
||||||
|
b"FROM base\n"
|
||||||
|
b'RUN if [ "$ENABLE_RUST" = "true" ]; then \\\n dnf upgrade -y; fi\n'
|
||||||
|
b"RUN set -euo pipefail \\\n && dnf install -y python3.12\n"
|
||||||
|
b"RUN microdnf upgrade -y \\\n && microdnf install -y python3.12\n"
|
||||||
|
b'RUN if [ "$ENABLE_FIPS" = "true" ]; then \\\n microdnf install -y crypto-policies; fi\n'
|
||||||
|
b"COPY pyproject.toml /app/\n"
|
||||||
|
+ recipe.INSTALL_START.encode("utf-8")
|
||||||
|
+ b"\n && /app/.venv/bin/uv pip install unpinned\n"
|
||||||
|
+ recipe.INSTALL_END.encode("utf-8")
|
||||||
|
+ b"COPY source /app\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_fixture(tmp_path: Path, containerfile: bytes) -> tuple[Path, dict[str, str]]:
|
||||||
|
source = tmp_path / "upstream"
|
||||||
|
source.mkdir()
|
||||||
|
contents = {
|
||||||
|
"Containerfile": containerfile,
|
||||||
|
"pyproject.toml": b"[project]\nname='fixture'\n",
|
||||||
|
"uv.lock": b"version = 1\n",
|
||||||
|
}
|
||||||
|
expected = {name: _hash(value) for name, value in contents.items()}
|
||||||
|
for name, value in contents.items():
|
||||||
|
(source / name).write_bytes(value)
|
||||||
|
return source, expected
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_candidate_locks(destination: Path) -> dict[str, str]:
|
||||||
|
destination.mkdir(parents=True)
|
||||||
|
requirements: dict[str, str] = {}
|
||||||
|
for name in ("production-requirements.lock", "build-tools.lock"):
|
||||||
|
data = (LOCK_DIR / name).read_bytes()
|
||||||
|
(destination / name).write_bytes(data)
|
||||||
|
requirements[name] = _hash(data)
|
||||||
|
return requirements
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_lock_exports_are_hash_complete_and_exclude_build_tools() -> None:
|
||||||
|
manifest = json.loads((LOCK_DIR / "locks.json").read_text(encoding="utf-8"))
|
||||||
|
production = (LOCK_DIR / "production-requirements.lock").read_text(encoding="utf-8")
|
||||||
|
build_tools = (LOCK_DIR / "build-tools.lock").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert manifest["upstreamCommit"] == recipe.UPSTREAM_COMMIT
|
||||||
|
assert manifest["uvVersion"] == recipe.UV_VERSION == "0.12.10"
|
||||||
|
assert manifest["globalExcludeNewerUtc"] == "2026-09-22T00:00:00Z"
|
||||||
|
assert manifest["freshnessOverrideUtc"] == "2026-10-02T00:00:00Z"
|
||||||
|
assert manifest["extras"] == list(recipe.EXTRAS)
|
||||||
|
assert manifest["constraints"] == list(recipe.CONSTRAINTS)
|
||||||
|
for name in ("production-requirements.lock", "build-tools.lock"):
|
||||||
|
assert _hash((LOCK_DIR / name).read_bytes()) == manifest["requirements"][name]
|
||||||
|
|
||||||
|
for requirement in recipe.CONSTRAINTS:
|
||||||
|
package, version = requirement.split("==", 1)
|
||||||
|
assert re.search(rf"(?im)^{re.escape(package)}=={re.escape(version)}\s*\\", production)
|
||||||
|
for requirement in recipe.BUILD_TOOLS:
|
||||||
|
package, version = requirement.split("==", 1)
|
||||||
|
assert re.search(rf"(?im)^{re.escape(package)}=={re.escape(version)}\s*\\", build_tools)
|
||||||
|
|
||||||
|
package_blocks = re.split(r"(?m)(?=^[A-Za-z0-9_.-]+==)", production)
|
||||||
|
package_blocks = [
|
||||||
|
block for block in package_blocks if re.match(r"(?m)^[A-Za-z0-9_.-]+==", block)
|
||||||
|
]
|
||||||
|
assert len(package_blocks) > 100
|
||||||
|
assert all(re.search(r"--hash=sha256:[0-9a-f]{64}", block) for block in package_blocks)
|
||||||
|
assert not re.search(r"(?im)^(pip|setuptools|wheel|uv)==", production)
|
||||||
|
build_blocks = re.split(r"(?m)(?=^[A-Za-z0-9_.-]+==)", build_tools)
|
||||||
|
build_blocks = [block for block in build_blocks if re.match(r"(?m)^[A-Za-z0-9_.-]+==", block)]
|
||||||
|
assert all(re.search(r"--hash=sha256:[0-9a-f]{64}", block) for block in build_blocks)
|
||||||
|
assert "pip==26.2.1" in build_tools
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_pyproject_freezes_relative_freshness_and_preserves_project_metadata() -> None:
|
||||||
|
source = """[tool.uv]
|
||||||
|
exclude-newer = "10 days"
|
||||||
|
|
||||||
|
[tool.uv.exclude-newer-package]
|
||||||
|
known-safe-package = "2026-07-01T00:00:00Z"
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
compliance-reference-server = { path = "mcp-servers/python/compliance_reference_server", editable = true }
|
||||||
|
|
||||||
|
# Project table follows the local source table.
|
||||||
|
[project]
|
||||||
|
name = "contextforge"
|
||||||
|
dependencies = ["httpx>=0.27"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest>=8"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
generated = recipe.candidate_pyproject(source)
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
parsed = tomllib.loads(generated)
|
||||||
|
assert parsed["project"] == {
|
||||||
|
"name": "contextforge",
|
||||||
|
"dependencies": ["httpx>=0.27"],
|
||||||
|
}
|
||||||
|
assert "sources" not in parsed["tool"]["uv"]
|
||||||
|
assert parsed["tool"]["uv"]["exclude-newer"] == "2026-09-22T00:00:00Z"
|
||||||
|
assert parsed["tool"]["uv"]["constraint-dependencies"] == list(recipe.CONSTRAINTS)
|
||||||
|
assert parsed["tool"]["uv"]["exclude-newer-package"]["known-safe-package"] == (
|
||||||
|
"2026-07-01T00:00:00Z"
|
||||||
|
)
|
||||||
|
assert parsed["tool"]["uv"]["exclude-newer-package"]["pyjwt"] == "2026-10-02T00:00:00Z"
|
||||||
|
assert parsed["dependency-groups"]["build-tools"] == list(recipe.BUILD_TOOLS)
|
||||||
|
assert "dev" not in parsed["dependency-groups"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_transform_is_exact_and_uses_hash_locked_runtime_install() -> None:
|
||||||
|
original = _containerfile_fixture()
|
||||||
|
transformed = recipe.transform_containerfile(original, expected_hash=_hash(original)).decode()
|
||||||
|
|
||||||
|
assert transformed.count("COPY contextforge-candidate-production-requirements.txt") == 1
|
||||||
|
assert transformed.count("COPY contextforge-candidate-build-tools.txt") == 1
|
||||||
|
assert (
|
||||||
|
"--require-hashes --only-binary=:all: -r /tmp/contextforge-production-requirements.txt"
|
||||||
|
in transformed
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
"--require-hashes --only-binary=:all: -r /tmp/contextforge-build-tools.txt" in transformed
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
'--no-deps --no-build-isolation ".[redis,postgres,observability,granian,plugins,llmchat]"'
|
||||||
|
in transformed
|
||||||
|
)
|
||||||
|
assert "uv pip install unpinned" not in transformed
|
||||||
|
assert "COPY source /app" in transformed
|
||||||
|
assert not re.search(r"\b(?:dnf|microdnf) (?:upgrade|install)\b", transformed)
|
||||||
|
assert 'test "$ENABLE_RUST" = "false"' in transformed
|
||||||
|
assert 'test "$ENABLE_FIPS" = "false"' in transformed
|
||||||
|
assert 'test "$ENABLE_PROFILING" = "false"' in transformed
|
||||||
|
assert 'test "$(id -u app):$(id -g app)" = "10001:10001"' in transformed
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_os_freeze_rejects_missing_or_additional_transactions() -> None:
|
||||||
|
original = _containerfile_fixture().decode()
|
||||||
|
for changed in (
|
||||||
|
original.replace("dnf upgrade -y", "echo changed", 1),
|
||||||
|
original + "\nRUN dnf install -y unexpected\n",
|
||||||
|
):
|
||||||
|
with pytest.raises(recipe.CandidateRecipeError, match="RPM transaction count"):
|
||||||
|
recipe.freeze_os_layers(changed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_transform_rejects_source_drift_and_unexpected_anchor_counts() -> None:
|
||||||
|
original = _containerfile_fixture()
|
||||||
|
digest = _hash(original)
|
||||||
|
with pytest.raises(recipe.CandidateRecipeError, match="Containerfile hash mismatch"):
|
||||||
|
recipe.transform_containerfile(original + b"# drift\n", expected_hash=digest)
|
||||||
|
with pytest.raises(recipe.CandidateRecipeError, match="anchor count changed"):
|
||||||
|
recipe.transform_containerfile(
|
||||||
|
original.replace(b"COPY pyproject.toml /app/\n", b""),
|
||||||
|
expected_hash=_hash(original.replace(b"COPY pyproject.toml /app/\n", b"")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_verification_rejects_missing_and_tampered_pinned_inputs(tmp_path: Path) -> None:
|
||||||
|
source, expected = _source_fixture(tmp_path, _containerfile_fixture())
|
||||||
|
assert recipe.verify_upstream_source(source, expected) == {
|
||||||
|
name: (source / name).read_bytes() for name in expected
|
||||||
|
}
|
||||||
|
(source / "uv.lock").write_bytes(b"changed lock")
|
||||||
|
with pytest.raises(recipe.CandidateRecipeError, match="uv.lock hash mismatch"):
|
||||||
|
recipe.verify_upstream_source(source, expected)
|
||||||
|
(source / "uv.lock").unlink()
|
||||||
|
with pytest.raises(recipe.CandidateRecipeError, match="missing: uv.lock"):
|
||||||
|
recipe.verify_upstream_source(source, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_copies_verified_locks_and_fails_before_writing_on_missing_lock(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
source, source_hashes = _source_fixture(tmp_path, _containerfile_fixture())
|
||||||
|
lock_dir = tmp_path / "candidate-locks"
|
||||||
|
lock_hashes = _copy_candidate_locks(lock_dir)
|
||||||
|
manifest = recipe._make_manifest(
|
||||||
|
source_hashes=source_hashes,
|
||||||
|
production_hash=lock_hashes["production-requirements.lock"],
|
||||||
|
build_tools_hash=lock_hashes["build-tools.lock"],
|
||||||
|
)
|
||||||
|
(lock_dir / "locks.json").write_bytes(recipe._canonical_json(manifest))
|
||||||
|
monkeypatch.setattr(recipe, "SOURCE_HASHES", source_hashes)
|
||||||
|
monkeypatch.setattr(recipe, "LOCK_DIR", lock_dir)
|
||||||
|
monkeypatch.setattr(recipe, "LOCK_MANIFEST", lock_dir / "locks.json")
|
||||||
|
|
||||||
|
recipe.prepare_source(source)
|
||||||
|
assert (source / "Containerfile.itworx-candidate").exists()
|
||||||
|
for name, target in recipe.LOCK_OUTPUT_NAMES.items():
|
||||||
|
assert (source / target).read_bytes() == (lock_dir / name).read_bytes()
|
||||||
|
|
||||||
|
(source / "Containerfile.itworx-candidate").unlink()
|
||||||
|
(lock_dir / "build-tools.lock").unlink()
|
||||||
|
with pytest.raises(recipe.CandidateRecipeError, match="missing: build-tools.lock"):
|
||||||
|
recipe.prepare_source(source)
|
||||||
|
assert not (source / "Containerfile.itworx-candidate").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_build_args_pin_every_base_and_disable_optional_builds() -> None:
|
||||||
|
args = recipe.candidate_build_args()
|
||||||
|
assert set(args) == {
|
||||||
|
"UBI_BASE",
|
||||||
|
"NODEJS_IMAGE",
|
||||||
|
"UBI_MINIMAL",
|
||||||
|
"WHEELS_REF",
|
||||||
|
"PYTHON_VERSION",
|
||||||
|
"ENABLE_RUST",
|
||||||
|
"ENABLE_RUST_MCP_RMCP",
|
||||||
|
"ENABLE_PROFILING",
|
||||||
|
"ENABLE_FIPS",
|
||||||
|
}
|
||||||
|
assert all("@sha256:" in args[key] for key in ("UBI_BASE", "NODEJS_IMAGE", "UBI_MINIMAL"))
|
||||||
|
assert args["UBI_BASE"] == (
|
||||||
|
"gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder@"
|
||||||
|
"sha256:e23a733abd28376eb3328676a3ab1e6d153122228110de0c881bb5f3c38a4483"
|
||||||
|
)
|
||||||
|
assert args["UBI_MINIMAL"] == (
|
||||||
|
"gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime@"
|
||||||
|
"sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236"
|
||||||
|
)
|
||||||
|
assert args["NODEJS_IMAGE"] == (
|
||||||
|
"registry.access.redhat.com/ubi10/nodejs-24@"
|
||||||
|
"sha256:0f110e68682f4a49188ba4db786eb4f207b119420913fc76bb5ba9512ffbef06"
|
||||||
|
)
|
||||||
|
assert args["WHEELS_REF"] == args["UBI_MINIMAL"]
|
||||||
|
assert args["PYTHON_VERSION"] == "3.12"
|
||||||
|
assert (
|
||||||
|
args["ENABLE_RUST"] == args["ENABLE_RUST_MCP_RMCP"] == args["ENABLE_PROFILING"] == "false"
|
||||||
|
)
|
||||||
|
assert args["ENABLE_FIPS"] == "false"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frozen_base_manifest_binds_published_digests_and_rpm_inventories() -> None:
|
||||||
|
manifest = json.loads((LOCK_DIR / "locks.json").read_text(encoding="utf-8"))
|
||||||
|
bases = {item["arg"]: item for item in manifest["baseImages"]}
|
||||||
|
|
||||||
|
assert set(bases) == {"UBI_BASE", "NODEJS_IMAGE", "UBI_MINIMAL"}
|
||||||
|
assert bases["UBI_BASE"] == {
|
||||||
|
"arg": "UBI_BASE",
|
||||||
|
"sourceTag": (
|
||||||
|
"gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder:"
|
||||||
|
"sha256-c6bb70a7a340ed1047e7cb934aa55b086ee68a5eb9ba6b2ad2e6825c7184a3e8"
|
||||||
|
),
|
||||||
|
"reference": (
|
||||||
|
"gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder@"
|
||||||
|
"sha256:e23a733abd28376eb3328676a3ab1e6d153122228110de0c881bb5f3c38a4483"
|
||||||
|
),
|
||||||
|
"parentReference": (
|
||||||
|
"registry.access.redhat.com/ubi10@"
|
||||||
|
"sha256:1e32d829a4de713a835d702d3bc05f0c448a08590796281b7dec181912e9aeb8"
|
||||||
|
),
|
||||||
|
"imageId": "sha256:c6bb70a7a340ed1047e7cb934aa55b086ee68a5eb9ba6b2ad2e6825c7184a3e8",
|
||||||
|
"rpmInventorySha256": "6a8c0d4f66fc5a38008541ad15614711e7f536cd18c70c02d2eb0691552b88b0",
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"childManifestDigest": (
|
||||||
|
"sha256:e23a733abd28376eb3328676a3ab1e6d153122228110de0c881bb5f3c38a4483"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
assert bases["UBI_MINIMAL"] == {
|
||||||
|
"arg": "UBI_MINIMAL",
|
||||||
|
"sourceTag": (
|
||||||
|
"gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime:"
|
||||||
|
"sha256-fd94e2643dfc0401790d0528d3740bb2f0c04709e743f6c774a4e8a38606e40b"
|
||||||
|
),
|
||||||
|
"reference": (
|
||||||
|
"gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime@"
|
||||||
|
"sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236"
|
||||||
|
),
|
||||||
|
"parentReference": (
|
||||||
|
"registry.access.redhat.com/ubi10/ubi-minimal@"
|
||||||
|
"sha256:afbfb5c18c628796fed3ad3122dcf096e0b2dec9dad3fbabe8620de06bc6f078"
|
||||||
|
),
|
||||||
|
"imageId": "sha256:fd94e2643dfc0401790d0528d3740bb2f0c04709e743f6c774a4e8a38606e40b",
|
||||||
|
"rpmInventorySha256": "a74d60ecb43d1aa561874305d1a9c62ac35c99a14c1dc275ba1656f14df7d034",
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"childManifestDigest": (
|
||||||
|
"sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
assert bases["NODEJS_IMAGE"]["reference"] == (
|
||||||
|
"registry.access.redhat.com/ubi10/nodejs-24@"
|
||||||
|
"sha256:0f110e68682f4a49188ba4db786eb4f207b119420913fc76bb5ba9512ffbef06"
|
||||||
|
)
|
||||||
|
assert "imageId" not in bases["NODEJS_IMAGE"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_frozen_base_reference_drift_fails_before_candidate_build(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
original = recipe.BASE_IMAGES[0]
|
||||||
|
changed = dict(original)
|
||||||
|
changed["reference"] = changed["reference"].replace("e23a733", "f23a733", 1)
|
||||||
|
monkeypatch.setattr(recipe, "BASE_IMAGES", (changed, *recipe.BASE_IMAGES[1:]))
|
||||||
|
|
||||||
|
with pytest.raises(recipe.CandidateRecipeError, match="reviewed contract"):
|
||||||
|
recipe._read_lock_manifest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_frozen_base_evidence_matches_candidate_pins_and_repository_inputs() -> None:
|
||||||
|
evidence_path = ROOT / "evidence" / "WP-400" / "CF_FROZEN_BASES.json"
|
||||||
|
evidence = json.loads(evidence_path.read_text(encoding="utf-8"))
|
||||||
|
manifest = json.loads((LOCK_DIR / "locks.json").read_text(encoding="utf-8"))
|
||||||
|
bases = {item["arg"]: item for item in manifest["baseImages"]}
|
||||||
|
|
||||||
|
assert evidence["schema"] == "itworx.contextforge-frozen-os-bases-evidence/v1"
|
||||||
|
assert evidence["status"] == "passed"
|
||||||
|
assert evidence["finalContextForgeCandidateBuilt"] is False
|
||||||
|
assert evidence["sourceInputs"]["upstreamCommit"] == manifest["upstreamCommit"]
|
||||||
|
assert evidence["sourceInputs"]["ContainerfileSha256"] == manifest["upstreamFiles"][
|
||||||
|
"Containerfile"
|
||||||
|
]
|
||||||
|
assert evidence["sourceInputs"]["pyprojectTomlSha256"] == manifest["upstreamFiles"][
|
||||||
|
"pyproject.toml"
|
||||||
|
]
|
||||||
|
assert evidence["sourceInputs"]["uvLockSha256"] == manifest["upstreamFiles"]["uv.lock"]
|
||||||
|
assert set(evidence["hubInputsSha256"]) == {
|
||||||
|
"scripts/contextforge_candidate_recipe.py",
|
||||||
|
"scripts/build_contextforge_compat_image.sh",
|
||||||
|
"infrastructure/images/contextforge/candidate/locks.json",
|
||||||
|
"infrastructure/images/contextforge/candidate/production-requirements.lock",
|
||||||
|
"infrastructure/images/contextforge/candidate/build-tools.lock",
|
||||||
|
"infrastructure/images/contextforge/os-bases/Dockerfile.builder",
|
||||||
|
"infrastructure/images/contextforge/os-bases/Dockerfile.runtime",
|
||||||
|
"infrastructure/compose/docker-compose.autodeploy.yml",
|
||||||
|
}
|
||||||
|
assert evidence["hubInputsSha256"] == {
|
||||||
|
name: _hash((ROOT / name).read_bytes()) for name in evidence["hubInputsSha256"]
|
||||||
|
}
|
||||||
|
for role, arg in (("builder", "UBI_BASE"), ("runtime", "UBI_MINIMAL")):
|
||||||
|
published = evidence["publishedBases"][role]
|
||||||
|
locked = bases[arg]
|
||||||
|
assert published["reference"] == locked["reference"]
|
||||||
|
assert published["imageId"] == locked["imageId"]
|
||||||
|
assert published["rpmInventorySha256"] == locked["rpmInventorySha256"]
|
||||||
|
assert published["platform"] == locked["platform"] == "linux/amd64"
|
||||||
|
assert published["publication"]["pulledByDigest"] is True
|
||||||
|
assert published["publication"]["manifestConfigImageIdMatches"] is True
|
||||||
|
assert published["publication"]["rpmInventoryMatchesAfterPull"] is True
|
||||||
|
assert published["rpmInventory"]["exactMatch"] is True
|
||||||
|
assert published["scan"]["secretFindings"] == 0
|
||||||
|
assert published["scan"]["vulnerabilityFindingRecords"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_manifest_rejects_changed_lock_bytes(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
lock_dir = tmp_path / "locks"
|
||||||
|
lock_hashes = _copy_candidate_locks(lock_dir)
|
||||||
|
manifest = recipe._make_manifest(
|
||||||
|
source_hashes=recipe.SOURCE_HASHES,
|
||||||
|
production_hash=lock_hashes["production-requirements.lock"],
|
||||||
|
build_tools_hash=lock_hashes["build-tools.lock"],
|
||||||
|
)
|
||||||
|
(lock_dir / "locks.json").write_bytes(recipe._canonical_json(manifest))
|
||||||
|
(lock_dir / "production-requirements.lock").write_bytes(b"changed")
|
||||||
|
monkeypatch.setattr(recipe, "LOCK_DIR", lock_dir)
|
||||||
|
monkeypatch.setattr(recipe, "LOCK_MANIFEST", lock_dir / "locks.json")
|
||||||
|
with pytest.raises(recipe.CandidateRecipeError, match="lock hash mismatch"):
|
||||||
|
recipe._read_lock_manifest()
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
# Shell fixture lines preserve exact mocked build arguments and OCI values.
|
||||||
|
# ruff: noqa: E501
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,6 +18,9 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
MANIFEST = ROOT / "infrastructure" / "images" / "contextforge" / "compat-image.env"
|
MANIFEST = ROOT / "infrastructure" / "images" / "contextforge" / "compat-image.env"
|
||||||
CONTRACT = ROOT / "infrastructure" / "images" / "contextforge" / "runtime-contract.json"
|
CONTRACT = ROOT / "infrastructure" / "images" / "contextforge" / "runtime-contract.json"
|
||||||
BUILDER = ROOT / "scripts" / "build_contextforge_compat_image.sh"
|
BUILDER = ROOT / "scripts" / "build_contextforge_compat_image.sh"
|
||||||
|
SECURITY_REQUIREMENTS = (
|
||||||
|
ROOT / "infrastructure" / "images" / "contextforge" / "security-requirements.txt"
|
||||||
|
)
|
||||||
|
|
||||||
EXPECTED = {
|
EXPECTED = {
|
||||||
"CONTEXTFORGE_UPSTREAM_COMMIT": "1e58a0434a78bbdbe1e975abeb0bed1043ff14c1",
|
"CONTEXTFORGE_UPSTREAM_COMMIT": "1e58a0434a78bbdbe1e975abeb0bed1043ff14c1",
|
||||||
@@ -73,18 +82,18 @@ def test_contextforge_compat_builder_guards_source_and_runtime() -> None:
|
|||||||
assert 'CONTRACT="$ROOT/infrastructure/images/contextforge/runtime-contract.json"' in script
|
assert 'CONTRACT="$ROOT/infrastructure/images/contextforge/runtime-contract.json"' in script
|
||||||
assert 'ENTRYPOINT="$ROOT/infrastructure/images/contextforge/entrypoint.sh"' in script
|
assert 'ENTRYPOINT="$ROOT/infrastructure/images/contextforge/entrypoint.sh"' in script
|
||||||
assert 'RUNTIME_VERIFIER="$ROOT/scripts/contextforge_runtime_contract.py"' in script
|
assert 'RUNTIME_VERIFIER="$ROOT/scripts/contextforge_runtime_contract.py"' in script
|
||||||
assert 'https://github.com/IBM/mcp-context-forge.git' in script
|
assert "https://github.com/IBM/mcp-context-forge.git" in script
|
||||||
assert 'git -C "$SRC" fetch --depth 1 origin "$CONTEXTFORGE_UPSTREAM_COMMIT"' in script
|
assert 'git -C "$SRC" fetch --depth 1 origin "$CONTEXTFORGE_UPSTREAM_COMMIT"' in script
|
||||||
assert 'git -C "$SRC" checkout --detach FETCH_HEAD' in script
|
assert 'git -C "$SRC" checkout --detach FETCH_HEAD' in script
|
||||||
assert 'git -C "$SRC" rev-parse HEAD' in script
|
assert 'git -C "$SRC" rev-parse HEAD' in script
|
||||||
assert 'REVISION_PREFIX="${CONTEXTFORGE_ALEMBIC_HEAD}_"' in script
|
assert 'REVISION_PREFIX="${CONTEXTFORGE_ALEMBIC_HEAD}_"' in script
|
||||||
assert 'MCP_REQUIREMENT="mcp==${CONTEXTFORGE_MCP_SDK}"' in script
|
assert 'MCP_REQUIREMENT="mcp==${CONTEXTFORGE_MCP_SDK}"' in script
|
||||||
assert "from mcp import McpError" in script
|
assert "from mcp import McpError" in script
|
||||||
assert 'alembic -c mcpgateway/alembic.ini heads' in script
|
assert "alembic -c mcpgateway/alembic.ini heads" in script
|
||||||
assert 'CONTEXTFORGE_ALEMBIC_HEAD' in script
|
assert "CONTEXTFORGE_ALEMBIC_HEAD" in script
|
||||||
assert 'contextforge-runtime-contract.json' in script
|
assert "contextforge-runtime-contract.json" in script
|
||||||
assert 'contextforge-entrypoint.sh' in script
|
assert "contextforge-entrypoint.sh" in script
|
||||||
assert 'contextforge-runtime-contract.py' in script
|
assert "contextforge-runtime-contract.py" in script
|
||||||
for label in (
|
for label in (
|
||||||
"io.itworx.contextforge.contract-sha256",
|
"io.itworx.contextforge.contract-sha256",
|
||||||
"io.itworx.contextforge.entrypoint-sha256",
|
"io.itworx.contextforge.entrypoint-sha256",
|
||||||
@@ -92,12 +101,68 @@ def test_contextforge_compat_builder_guards_source_and_runtime() -> None:
|
|||||||
"io.itworx.contextforge.source-commit",
|
"io.itworx.contextforge.source-commit",
|
||||||
"io.itworx.contextforge.alembic-head",
|
"io.itworx.contextforge.alembic-head",
|
||||||
"io.itworx.contextforge.mcp-sdk",
|
"io.itworx.contextforge.mcp-sdk",
|
||||||
|
"io.itworx.contextforge.security-requirements-sha256",
|
||||||
):
|
):
|
||||||
assert label in script
|
assert label in script
|
||||||
assert 'docker image inspect -f' in script
|
assert "docker image inspect -f" in script
|
||||||
assert 'docker tag "$STAGED_IMAGE" "$CONTEXTFORGE_LOCAL_IMAGE"' in script
|
assert 'docker tag "$STAGED_IMAGE" "$CONTEXTFORGE_LOCAL_IMAGE"' in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_contextforge_security_wheels_match_the_reviewed_hub_lock() -> None:
|
||||||
|
lock = tomllib.loads((ROOT / "uv.lock").read_text(encoding="utf-8"))
|
||||||
|
packages = {package["name"]: package for package in lock["package"]}
|
||||||
|
requirements = SECURITY_REQUIREMENTS.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert len(requirements) == 2
|
||||||
|
names = set()
|
||||||
|
for requirement in requirements:
|
||||||
|
match = re.fullmatch(
|
||||||
|
r"([A-Za-z0-9]+)==([0-9]+\.[0-9]+\.[0-9]+) --hash=sha256:([0-9a-f]{64})", requirement
|
||||||
|
)
|
||||||
|
assert match is not None, (
|
||||||
|
"Security inputs must pin a wheel version and SHA-256 without extra flags."
|
||||||
|
)
|
||||||
|
name, version, digest = match.groups()
|
||||||
|
normalized = name.lower()
|
||||||
|
assert normalized not in names
|
||||||
|
names.add(normalized)
|
||||||
|
package = packages[normalized]
|
||||||
|
assert version == package["version"]
|
||||||
|
assert f"sha256:{digest}" in {wheel["hash"] for wheel in package["wheels"]}
|
||||||
|
assert names == {"pyjwt", "urllib3"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_security_inputs_stop_builder_before_any_external_command(tmp_path: Path) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("The Linux builder executes in the required Linux validation job")
|
||||||
|
scripts = tmp_path / "scripts"
|
||||||
|
scripts.mkdir()
|
||||||
|
builder = scripts / BUILDER.name
|
||||||
|
builder.write_text(BUILDER.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
inputs = tmp_path / "infrastructure" / "images" / "contextforge"
|
||||||
|
inputs.mkdir(parents=True)
|
||||||
|
for name in ("compat-image.env", "runtime-contract.json", "entrypoint.sh"):
|
||||||
|
(inputs / name).write_text("fixture", encoding="utf-8")
|
||||||
|
(scripts / "contextforge_runtime_contract.py").write_text("fixture", encoding="utf-8")
|
||||||
|
bin_directory = tmp_path / "bin"
|
||||||
|
bin_directory.mkdir()
|
||||||
|
marker = tmp_path / "external-command-ran"
|
||||||
|
for name in ("git", "docker"):
|
||||||
|
executable = bin_directory / name
|
||||||
|
executable.write_text(f"#!/bin/sh\ntouch '{marker}'\nexit 99\n", encoding="utf-8")
|
||||||
|
executable.chmod(0o755)
|
||||||
|
env = {**os.environ, "PATH": f"{bin_directory}:{os.environ['PATH']}"}
|
||||||
|
completed = subprocess.run( # noqa: S603
|
||||||
|
[shutil.which("bash") or "/bin/bash", str(builder)],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode == 66
|
||||||
|
assert "security-requirements.txt" in completed.stderr
|
||||||
|
assert not marker.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_contextforge_compat_builder_has_valid_shell_syntax() -> None:
|
def test_contextforge_compat_builder_has_valid_shell_syntax() -> None:
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
pytest.skip("WSL bash cannot read a Windows path for syntax validation")
|
pytest.skip("WSL bash cannot read a Windows path for syntax validation")
|
||||||
@@ -110,3 +175,392 @@ def test_contextforge_compat_builder_has_valid_shell_syntax() -> None:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
assert completed.returncode == 0, completed.stderr
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_builder_fixture(tmp_path: Path) -> tuple[Path, dict[str, str], Path, Path]:
|
||||||
|
root = tmp_path / "hub"
|
||||||
|
for relative in (
|
||||||
|
"scripts/build_contextforge_compat_image.sh",
|
||||||
|
"scripts/contextforge_candidate_recipe.py",
|
||||||
|
"scripts/contextforge_runtime_contract.py",
|
||||||
|
"infrastructure/images/contextforge/compat-image.env",
|
||||||
|
"infrastructure/images/contextforge/runtime-contract.json",
|
||||||
|
"infrastructure/images/contextforge/entrypoint.sh",
|
||||||
|
"infrastructure/images/contextforge/security-requirements.txt",
|
||||||
|
):
|
||||||
|
target = root / relative
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(ROOT / relative, target)
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
log = tmp_path / "external-calls.log"
|
||||||
|
layer_capture = tmp_path / "candidate-layer.Containerfile"
|
||||||
|
git = bin_dir / "git"
|
||||||
|
git.write_text(
|
||||||
|
"#!/bin/sh\n"
|
||||||
|
'echo "git $*" >> "$CALL_LOG"\n'
|
||||||
|
'if [ "$1" = "-C" ]; then\n'
|
||||||
|
' src="$2"; action="$3"\n'
|
||||||
|
' if [ "$src" = "$SOURCE_ROOT" ]; then\n'
|
||||||
|
' case "$action:$4:$5" in\n'
|
||||||
|
' rev-parse:HEAD:) printf "%s\\n" "$GIT_HEAD" ;;\n'
|
||||||
|
' rev-parse:HEAD*:) printf "%s\\n" "$GIT_TREE" ;;\n'
|
||||||
|
' show:-s:--format=%ct) printf "%s\\n" "$GIT_EPOCH" ;;\n'
|
||||||
|
' show:-s:--format=%cI) printf "%s\\n" "$GIT_COMMIT_DATE" ;;\n'
|
||||||
|
' status:*:*) printf "%s" "${GIT_DIRTY:-}" ;;\n'
|
||||||
|
" esac\n"
|
||||||
|
" exit 0\n"
|
||||||
|
" fi\n"
|
||||||
|
' case "$action" in\n'
|
||||||
|
' fetch|checkout) mkdir -p "$src/mcpgateway/alembic/versions"; '
|
||||||
|
' printf "revision = \\"12d4a0c7789c\\"\\n" > '
|
||||||
|
'"$src/mcpgateway/alembic/versions/12d4a0c7789c_fixture.py"; '
|
||||||
|
' printf "FROM python:3.12\\n" > "$src/Containerfile" ;;\n'
|
||||||
|
' rev-parse) printf "1e58a0434a78bbdbe1e975abeb0bed1043ff14c1\\n" ;;\n'
|
||||||
|
" esac\n"
|
||||||
|
"fi\n"
|
||||||
|
"exit 0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git.chmod(0o755)
|
||||||
|
real_python = shutil.which("python3")
|
||||||
|
assert real_python is not None
|
||||||
|
python3 = bin_dir / "python3"
|
||||||
|
python3.write_text(
|
||||||
|
"#!/bin/sh\n"
|
||||||
|
'case "$1" in\n'
|
||||||
|
" */contextforge_candidate_recipe.py)\n"
|
||||||
|
' case "$2" in\n'
|
||||||
|
' prepare) printf "candidate recipe\\n" > "$4/Containerfile.itworx-candidate" ;;\n'
|
||||||
|
" build-args)\n"
|
||||||
|
' printf "%s\\n" \\\n'
|
||||||
|
' "UBI_BASE=gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder@sha256:e23a733abd28376eb3328676a3ab1e6d153122228110de0c881bb5f3c38a4483" \\\n'
|
||||||
|
' "NODEJS_IMAGE=registry.access.redhat.com/ubi10/nodejs-24@sha256:0f110e68682f4a49188ba4db786eb4f207b119420913fc76bb5ba9512ffbef06" \\\n'
|
||||||
|
' "UBI_MINIMAL=gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime@sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236" \\\n'
|
||||||
|
' "WHEELS_REF=gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime@sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236" \\\n'
|
||||||
|
' "PYTHON_VERSION=3.12" "ENABLE_RUST=false" "ENABLE_RUST_MCP_RMCP=false" "ENABLE_PROFILING=false" "ENABLE_FIPS=false" ;;\n'
|
||||||
|
" esac\n"
|
||||||
|
" exit 0 ;;\n"
|
||||||
|
"esac\n"
|
||||||
|
f'exec "{real_python}" "$@"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
python3.chmod(0o755)
|
||||||
|
docker = bin_dir / "docker"
|
||||||
|
docker.write_text(
|
||||||
|
"#!/bin/sh\n"
|
||||||
|
'echo "docker $*" >> "$CALL_LOG"\n'
|
||||||
|
'if [ "$1" = "image" ] && [ "$2" = "inspect" ]; then\n'
|
||||||
|
' if [ "$3" != "-f" ]; then\n'
|
||||||
|
' [ "${COLLISION_TAG:-}" = "$3" ] && exit 0\n'
|
||||||
|
' printf "Error response from daemon: No such image: %s\\n" "$3" >&2\n'
|
||||||
|
" exit 1\n"
|
||||||
|
" fi\n"
|
||||||
|
' case "$4" in\n'
|
||||||
|
' *Config.User*) printf "10001:10001\\n" ;;\n'
|
||||||
|
' *contract-sha256*) printf "%s\\n" "$CONTRACT_SHA" ;;\n'
|
||||||
|
' *entrypoint-sha256*) printf "%s\\n" "$ENTRYPOINT_SHA" ;;\n'
|
||||||
|
' *verifier-sha256*) printf "%s\\n" "$VERIFIER_SHA" ;;\n'
|
||||||
|
' *source-commit*) printf "1e58a0434a78bbdbe1e975abeb0bed1043ff14c1\\n" ;;\n'
|
||||||
|
' *alembic-head*) printf "12d4a0c7789c\\n" ;;\n'
|
||||||
|
' *mcp-sdk*) printf "1.30.0\\n" ;;\n'
|
||||||
|
' *security-requirements-sha256*) printf "%s\\n" "$SECURITY_SHA" ;;\n'
|
||||||
|
' *org.opencontainers.image.source-tree*) printf "%s\\n" "$APPOPS_SOURCE_TREE" ;;\n'
|
||||||
|
' *org.opencontainers.image.revision*) printf "%s\\n" "$APPOPS_SOURCE_REVISION" ;;\n'
|
||||||
|
' *org.opencontainers.image.version*) printf "%s\\n" "$APPOPS_BUILD_ID" ;;\n'
|
||||||
|
' *org.opencontainers.image.source-date-epoch*) printf "%s\\n" "$SOURCE_DATE_EPOCH" ;;\n'
|
||||||
|
' *org.opencontainers.image.created*) printf "%s\\n" "$APPOPS_BUILD_DATE" ;;\n'
|
||||||
|
' *org.opencontainers.image.source*) printf "%s\\n" "$OCI_SOURCE" ;;\n'
|
||||||
|
" esac\n"
|
||||||
|
" exit 0\n"
|
||||||
|
"fi\n"
|
||||||
|
'if [ "$1" = "build" ]; then\n'
|
||||||
|
' while [ "$#" -gt 0 ]; do\n'
|
||||||
|
' if [ "$1" = "-f" ]; then layer="$2"; shift 2; else shift; fi\n'
|
||||||
|
" done\n"
|
||||||
|
' case "$layer" in *Containerfile.compat) cp "$layer" "$LAYER_CAPTURE" ;; esac\n'
|
||||||
|
" exit 0\n"
|
||||||
|
"fi\n"
|
||||||
|
'if [ "$1" = "run" ]; then\n'
|
||||||
|
' case "$*" in\n'
|
||||||
|
' *"alembic -c mcpgateway/alembic.ini heads"*) printf "12d4a0c7789c (head)\\n" ;;\n'
|
||||||
|
' *"contextforge-runtime-contract.json"*) printf "%s\\n" "$CONTRACT_SHA" ;;\n'
|
||||||
|
' *"contextforge-entrypoint.sh"*) printf "%s\\n" "$ENTRYPOINT_SHA" ;;\n'
|
||||||
|
' *"contextforge-runtime-contract.py"*) printf "%s\\n" "$VERIFIER_SHA" ;;\n'
|
||||||
|
' *"contextforge-security-requirements.txt"*) printf "%s\\n" "$SECURITY_SHA" ;;\n'
|
||||||
|
" esac\n"
|
||||||
|
" exit 0\n"
|
||||||
|
"fi\n"
|
||||||
|
"exit 0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
docker.chmod(0o755)
|
||||||
|
contract_sha = hashlib.sha256(
|
||||||
|
(root / "infrastructure/images/contextforge/runtime-contract.json").read_bytes()
|
||||||
|
).hexdigest()
|
||||||
|
entrypoint_sha = hashlib.sha256(
|
||||||
|
(root / "infrastructure/images/contextforge/entrypoint.sh").read_bytes()
|
||||||
|
).hexdigest()
|
||||||
|
verifier_sha = hashlib.sha256(
|
||||||
|
(root / "scripts/contextforge_runtime_contract.py").read_bytes()
|
||||||
|
).hexdigest()
|
||||||
|
security_sha = hashlib.sha256(
|
||||||
|
(root / "infrastructure/images/contextforge/security-requirements.txt").read_bytes()
|
||||||
|
).hexdigest()
|
||||||
|
environment = {
|
||||||
|
**os.environ,
|
||||||
|
"PATH": f"{bin_dir}:{os.environ['PATH']}",
|
||||||
|
"CALL_LOG": str(log),
|
||||||
|
"LAYER_CAPTURE": str(layer_capture),
|
||||||
|
"SOURCE_ROOT": str(root),
|
||||||
|
"CONTRACT_SHA": contract_sha,
|
||||||
|
"ENTRYPOINT_SHA": entrypoint_sha,
|
||||||
|
"VERIFIER_SHA": verifier_sha,
|
||||||
|
"SECURITY_SHA": security_sha,
|
||||||
|
"OCI_SOURCE": "https://gitea.example.com/jens/itworx-mcp-hub",
|
||||||
|
"GIT_HEAD": "a" * 40,
|
||||||
|
"GIT_TREE": "b" * 40,
|
||||||
|
"GIT_EPOCH": "1790964000",
|
||||||
|
"GIT_COMMIT_DATE": "2026-09-30T00:00:00+00:00",
|
||||||
|
"APPOPS_SOURCE_REPOSITORY": "jens/itworx_mcp_hub",
|
||||||
|
"APPOPS_SOURCE_REVISION": "a" * 40,
|
||||||
|
"APPOPS_SOURCE_TREE": "b" * 40,
|
||||||
|
"APPOPS_BUILD_ID": "a" * 12,
|
||||||
|
"APPOPS_BUILD_DATE": "2026-10-02T18:00:00.123Z",
|
||||||
|
"SOURCE_DATE_EPOCH": "1790964000",
|
||||||
|
"GITEA_REPOSITORY": "jens/itworx_mcp_hub",
|
||||||
|
"GITEA_COMMIT_SHA": "a" * 40,
|
||||||
|
}
|
||||||
|
return root / "scripts/build_contextforge_compat_image.sh", environment, log, layer_capture
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_mode_requires_frozen_metadata_before_git_or_docker(tmp_path: Path) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("The Linux builder executes in the required Linux validation job")
|
||||||
|
builder, env, log, _layer = _candidate_builder_fixture(tmp_path)
|
||||||
|
env.pop("APPOPS_SOURCE_TREE")
|
||||||
|
completed = subprocess.run( # noqa: S603
|
||||||
|
[shutil.which("bash") or "/bin/bash", str(builder), "--release-candidate"],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode != 0
|
||||||
|
assert "APPOPS_SOURCE_TREE" in completed.stderr
|
||||||
|
assert not log.exists() or log.read_text(encoding="utf-8") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_mode_rejects_unreviewed_build_variants_before_git_or_docker(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("The Linux builder executes in the required Linux validation job")
|
||||||
|
builder, env, log, _layer = _candidate_builder_fixture(tmp_path)
|
||||||
|
completed = subprocess.run( # noqa: S603
|
||||||
|
[
|
||||||
|
shutil.which("bash") or "/bin/bash",
|
||||||
|
str(builder),
|
||||||
|
"--release-candidate",
|
||||||
|
"--enable-fips",
|
||||||
|
],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode == 64
|
||||||
|
assert "supported mode" in completed.stderr
|
||||||
|
assert not log.exists() or log.read_text(encoding="utf-8") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_mode_rejects_invalid_calendar_date_before_git_or_docker(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("The Linux builder executes in the required Linux validation job")
|
||||||
|
builder, env, log, _layer = _candidate_builder_fixture(tmp_path)
|
||||||
|
env["APPOPS_BUILD_DATE"] = "2026-02-30T18:00:00.123Z"
|
||||||
|
completed = subprocess.run( # noqa: S603
|
||||||
|
[shutil.which("bash") or "/bin/bash", str(builder), "--release-candidate"],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode != 0
|
||||||
|
assert "valid UTC calendar timestamp" in completed.stderr
|
||||||
|
assert not log.exists() or log.read_text(encoding="utf-8") == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("collision", ["final", "upstream", "staged"])
|
||||||
|
def test_candidate_mode_rejects_existing_revision_tags_after_source_validation_before_build(
|
||||||
|
tmp_path: Path, collision: str
|
||||||
|
) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("The Linux builder executes in the required Linux validation job")
|
||||||
|
builder, env, log, _layer = _candidate_builder_fixture(tmp_path)
|
||||||
|
revision = env["APPOPS_SOURCE_REVISION"]
|
||||||
|
suffix = {"final": "", "upstream": "-upstream", "staged": "-staged"}[collision]
|
||||||
|
env["COLLISION_TAG"] = f"itworx-mcp-hub/contextforge-schema:{revision}{suffix}"
|
||||||
|
completed = subprocess.run( # noqa: S603
|
||||||
|
[shutil.which("bash") or "/bin/bash", str(builder), "--release-candidate"],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode != 0
|
||||||
|
assert "already exists" in completed.stderr
|
||||||
|
calls = log.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert any("docker image inspect" in call for call in calls)
|
||||||
|
assert any(call.startswith("git ") for call in calls)
|
||||||
|
assert not any("docker build" in call or "docker tag" in call for call in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_mode_uses_only_revision_scoped_tags_and_frozen_oci_metadata(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("The Linux builder executes in the required Linux validation job")
|
||||||
|
builder, env, log, layer_capture = _candidate_builder_fixture(tmp_path)
|
||||||
|
static = EXPECTED["CONTEXTFORGE_LOCAL_IMAGE"]
|
||||||
|
revision = env["APPOPS_SOURCE_REVISION"]
|
||||||
|
completed = subprocess.run( # noqa: S603
|
||||||
|
[shutil.which("bash") or "/bin/bash", str(builder), "--release-candidate"],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode == 0, completed.stderr
|
||||||
|
calls = log.read_text(encoding="utf-8").splitlines()
|
||||||
|
tags = [call for call in calls if call.startswith("docker tag ")]
|
||||||
|
assert tags == [
|
||||||
|
f"docker tag itworx-mcp-hub/contextforge-schema:{revision}-staged "
|
||||||
|
f"itworx-mcp-hub/contextforge-schema:{revision}"
|
||||||
|
]
|
||||||
|
assert any(
|
||||||
|
f"-t itworx-mcp-hub/contextforge-schema:{revision}-upstream " in call for call in calls
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
f"-t itworx-mcp-hub/contextforge-schema:{revision}-staged " in call for call in calls
|
||||||
|
)
|
||||||
|
candidate_build = next(
|
||||||
|
call
|
||||||
|
for call in calls
|
||||||
|
if "docker build" in call and "Containerfile.itworx-candidate" in call
|
||||||
|
)
|
||||||
|
assert "--platform linux/amd64" in candidate_build
|
||||||
|
assert (
|
||||||
|
"UBI_BASE=gitea.example.com/jens/itworx-mcp-hub/contextforge-os-builder@"
|
||||||
|
"sha256:e23a733abd28376eb3328676a3ab1e6d153122228110de0c881bb5f3c38a4483"
|
||||||
|
in candidate_build
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
"NODEJS_IMAGE=registry.access.redhat.com/ubi10/nodejs-24@sha256:0f110e686"
|
||||||
|
in candidate_build
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
"UBI_MINIMAL=gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime@"
|
||||||
|
"sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236"
|
||||||
|
in candidate_build
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
"WHEELS_REF=gitea.example.com/jens/itworx-mcp-hub/contextforge-os-runtime@"
|
||||||
|
"sha256:9b23d8152f253b10e47ce077bb9c5f8c3c370f3e0d5533f8a4a0e986ff176236"
|
||||||
|
in candidate_build
|
||||||
|
)
|
||||||
|
assert "ENABLE_RUST=false" in candidate_build
|
||||||
|
assert "ENABLE_RUST_MCP_RMCP=false" in candidate_build
|
||||||
|
assert "ENABLE_PROFILING=false" in candidate_build
|
||||||
|
assert "ENABLE_FIPS=false" in candidate_build
|
||||||
|
assert not any(static in call for call in calls)
|
||||||
|
assert not any(call.startswith("docker rmi ") for call in calls)
|
||||||
|
assert "NOT APPROVED OR DEPLOYED" in completed.stdout
|
||||||
|
layer = layer_capture.read_text(encoding="utf-8")
|
||||||
|
assert "-m pip" not in layer
|
||||||
|
assert "import importlib.util" in layer
|
||||||
|
assert "find_spec" in layer
|
||||||
|
assert (
|
||||||
|
'org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub"' in layer
|
||||||
|
)
|
||||||
|
assert f'org.opencontainers.image.revision="{revision}"' in layer
|
||||||
|
assert f'org.opencontainers.image.source-tree="{env["APPOPS_SOURCE_TREE"]}"' in layer
|
||||||
|
assert f'org.opencontainers.image.version="{env["APPOPS_BUILD_ID"]}"' in layer
|
||||||
|
assert f'org.opencontainers.image.created="{env["APPOPS_BUILD_DATE"]}"' in layer
|
||||||
|
assert f'org.opencontainers.image.source-date-epoch="{env["SOURCE_DATE_EPOCH"]}"' in layer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"dirty", [" M scripts/contextforge_runtime_contract.py\n", "?? unexpected.py\n"]
|
||||||
|
)
|
||||||
|
def test_candidate_mode_rejects_a_dirty_source_checkout_before_fetch_or_build(
|
||||||
|
tmp_path: Path,
|
||||||
|
dirty: str,
|
||||||
|
) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("The Linux builder executes in the required Linux validation job")
|
||||||
|
builder, env, log, _layer = _candidate_builder_fixture(tmp_path)
|
||||||
|
env["GIT_DIRTY"] = dirty
|
||||||
|
completed = subprocess.run( # noqa: S603
|
||||||
|
[shutil.which("bash") or "/bin/bash", str(builder), "--release-candidate"],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode != 0
|
||||||
|
assert "source checkout has changes" in completed.stderr
|
||||||
|
calls = log.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert not any("git init" in call or "git fetch" in call for call in calls)
|
||||||
|
assert not any(call.startswith("docker ") for call in calls)
|
||||||
|
assert not any("docker build" in call or "docker tag" in call for call in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_mode_rejects_source_tree_mismatch_before_fetch_or_build(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("The Linux builder executes in the required Linux validation job")
|
||||||
|
builder, env, log, _layer = _candidate_builder_fixture(tmp_path)
|
||||||
|
env["APPOPS_SOURCE_TREE"] = "c" * 40
|
||||||
|
completed = subprocess.run( # noqa: S603
|
||||||
|
[shutil.which("bash") or "/bin/bash", str(builder), "--release-candidate"],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode != 0
|
||||||
|
assert "source tree differs" in completed.stderr
|
||||||
|
calls = log.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert any("rev-parse" in call for call in calls)
|
||||||
|
assert not any("git init" in call or "git fetch" in call for call in calls)
|
||||||
|
assert not any(call.startswith("docker ") for call in calls)
|
||||||
|
assert not any("docker build" in call or "docker tag" in call for call in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_mode_rejects_revision_mismatch_with_checked_out_head(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("The Linux builder executes in the required Linux validation job")
|
||||||
|
builder, env, log, _layer = _candidate_builder_fixture(tmp_path)
|
||||||
|
env["APPOPS_SOURCE_REVISION"] = "c" * 40
|
||||||
|
env["GITEA_COMMIT_SHA"] = "c" * 40
|
||||||
|
env["APPOPS_BUILD_ID"] = "c" * 12
|
||||||
|
completed = subprocess.run( # noqa: S603
|
||||||
|
[shutil.which("bash") or "/bin/bash", str(builder), "--release-candidate"],
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode != 0
|
||||||
|
assert "checked-out Hub revision differs" in completed.stderr
|
||||||
|
calls = log.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert any("rev-parse" in call for call in calls)
|
||||||
|
assert not any("git init" in call or "git fetch" in call for call in calls)
|
||||||
|
assert not any(call.startswith("docker ") for call in calls)
|
||||||
|
assert not any("docker build" in call or "docker tag" in call for call in calls)
|
||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
from scripts.promotion_preflight import (
|
from scripts.promotion_preflight import (
|
||||||
CONTEXTFORGE_PRODUCTION_IMAGE,
|
CONTEXTFORGE_PRODUCTION_IMAGE,
|
||||||
IMAGE_ENVIRONMENT,
|
IMAGE_ENVIRONMENT,
|
||||||
@@ -14,15 +16,23 @@ ENV_EXAMPLE = ROOT / "infrastructure" / "compose" / ".env.example"
|
|||||||
INPUT_MAP = ROOT / "infrastructure" / "compose" / "production-input-map.json"
|
INPUT_MAP = ROOT / "infrastructure" / "compose" / "production-input-map.json"
|
||||||
PROD_COMPOSE = ROOT / "infrastructure" / "compose" / "docker-compose.prod.yml"
|
PROD_COMPOSE = ROOT / "infrastructure" / "compose" / "docker-compose.prod.yml"
|
||||||
CONTRACT = ROOT / "infrastructure" / "images" / "contextforge" / "runtime-contract.json"
|
CONTRACT = ROOT / "infrastructure" / "images" / "contextforge" / "runtime-contract.json"
|
||||||
|
AUTODEPLOY = ROOT / "infrastructure" / "compose" / "docker-compose.autodeploy.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_governed_contextforge_candidate_uses_the_exact_release_revision() -> None:
|
||||||
|
document = yaml.safe_load(AUTODEPLOY.read_text(encoding="utf-8"))
|
||||||
|
service = document["services"]["contextforge"]
|
||||||
|
assert service["image"] == (
|
||||||
|
"itworx-mcp-hub/contextforge-schema:${GITEA_COMMIT_SHA:?GITEA_COMMIT_SHA is required}"
|
||||||
|
)
|
||||||
|
assert service["pull_policy"] == "never"
|
||||||
|
assert not service.get("build")
|
||||||
|
|
||||||
|
|
||||||
def test_contextforge_uses_the_verified_local_compatibility_image() -> None:
|
def test_contextforge_uses_the_verified_local_compatibility_image() -> None:
|
||||||
assert "contextforge" not in IMAGE_ENVIRONMENT
|
assert "contextforge" not in IMAGE_ENVIRONMENT
|
||||||
assert "CONTEXTFORGE_IMAGE" not in REQUIRED_ENV
|
assert "CONTEXTFORGE_IMAGE" not in REQUIRED_ENV
|
||||||
assert (
|
assert CONTEXTFORGE_PRODUCTION_IMAGE == "itworx-mcp-hub/contextforge-schema:12d4a0c7789c"
|
||||||
CONTEXTFORGE_PRODUCTION_IMAGE
|
|
||||||
== "itworx-mcp-hub/contextforge-schema:12d4a0c7789c"
|
|
||||||
)
|
|
||||||
|
|
||||||
env_example = ENV_EXAMPLE.read_text(encoding="utf-8")
|
env_example = ENV_EXAMPLE.read_text(encoding="utf-8")
|
||||||
assert "CONTEXTFORGE_IMAGE=" not in env_example
|
assert "CONTEXTFORGE_IMAGE=" not in env_example
|
||||||
@@ -42,8 +52,5 @@ def test_contextforge_contract_digest_is_repository_owned_and_exact() -> None:
|
|||||||
expected = hashlib.sha256(CONTRACT.read_bytes()).hexdigest()
|
expected = hashlib.sha256(CONTRACT.read_bytes()).hexdigest()
|
||||||
production = PROD_COMPOSE.read_text(encoding="utf-8")
|
production = PROD_COMPOSE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert (
|
assert f'ITWORX_CONTEXTFORGE_EXPECTED_CONTRACT_SHA256: "{expected}"' in production
|
||||||
f'ITWORX_CONTEXTFORGE_EXPECTED_CONTRACT_SHA256: "{expected}"'
|
|
||||||
in production
|
|
||||||
)
|
|
||||||
assert "${CONTEXTFORGE_CONTRACT_SHA256" not in production
|
assert "${CONTEXTFORGE_CONTRACT_SHA256" not in production
|
||||||
@@ -255,7 +255,10 @@ class IngestionCatalogComposeTests(unittest.TestCase):
|
|||||||
**os.environ,
|
**os.environ,
|
||||||
"SECRETS_DIR": str(ROOT / "tests/fixtures"),
|
"SECRETS_DIR": str(ROOT / "tests/fixtures"),
|
||||||
"CONNECTOR_INGESTION_IMAGE": digest,
|
"CONNECTOR_INGESTION_IMAGE": digest,
|
||||||
"GITEA_COMMIT_SHA": "test-sha",
|
"GITEA_COMMIT_SHA": "a" * 40,
|
||||||
|
"APPOPS_SOURCE_TREE": "b" * 40,
|
||||||
|
"APPOPS_BUILD_ID": "a" * 12,
|
||||||
|
"SOURCE_DATE_EPOCH": "1790888863",
|
||||||
},
|
},
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from scripts.check_cve_exceptions import REQUIRED_FIELDS, check
|
from scripts.check_cve_exceptions import REQUIRED_FIELDS, check
|
||||||
|
from scripts.draft_cve_exceptions import main as draft_exceptions_main
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
TODAY = date(2026, 8, 15)
|
TODAY = date(2026, 8, 15)
|
||||||
@@ -45,6 +47,13 @@ def _exception(**overrides) -> dict:
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _image_entry(image_id: str, tag: str | None = None) -> dict[str, str]:
|
||||||
|
entry = {"imageId": image_id}
|
||||||
|
if tag is not None:
|
||||||
|
entry["tag"] = tag
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def test_real_wp220_registry_has_every_required_field() -> None:
|
def test_real_wp220_registry_has_every_required_field() -> None:
|
||||||
"""The actual drafted registry this packet ships must be schema-complete."""
|
"""The actual drafted registry this packet ships must be schema-complete."""
|
||||||
doc = json.loads(
|
doc = json.loads(
|
||||||
@@ -122,7 +131,7 @@ def test_changed_digest_fails() -> None:
|
|||||||
exceptions = {
|
exceptions = {
|
||||||
"exceptions": [_exception(affectedImageDigests={"control-api": "sha256:" + "a" * 64})]
|
"exceptions": [_exception(affectedImageDigests={"control-api": "sha256:" + "a" * 64})]
|
||||||
}
|
}
|
||||||
current_digests = {"control-api": "sha256:" + "b" * 64}
|
current_digests = {"control-api": _image_entry("sha256:" + "b" * 64)}
|
||||||
errors = check(scan, exceptions, current_digests, TODAY)
|
errors = check(scan, exceptions, current_digests, TODAY)
|
||||||
assert any("DIGEST CHANGED" in e for e in errors)
|
assert any("DIGEST CHANGED" in e for e in errors)
|
||||||
|
|
||||||
@@ -132,11 +141,258 @@ def test_matching_digest_passes() -> None:
|
|||||||
exceptions = {
|
exceptions = {
|
||||||
"exceptions": [_exception(affectedImageDigests={"control-api": "sha256:" + "a" * 64})]
|
"exceptions": [_exception(affectedImageDigests={"control-api": "sha256:" + "a" * 64})]
|
||||||
}
|
}
|
||||||
current_digests = {"control-api": "sha256:" + "a" * 64}
|
current_digests = {"control-api": _image_entry("sha256:" + "a" * 64)}
|
||||||
errors = check(scan, exceptions, current_digests, TODAY)
|
errors = check(scan, exceptions, current_digests, TODAY)
|
||||||
assert errors == []
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_current_service_for_affected_image_fails() -> None:
|
||||||
|
scan = _scan(
|
||||||
|
{
|
||||||
|
"package": "pkg",
|
||||||
|
"cves": ["CVE-2026-1"],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"affectedImages": ["local/control-api:dev", "local/worker:dev"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exceptions = {
|
||||||
|
"exceptions": [
|
||||||
|
_exception(
|
||||||
|
affectedImageDigests={
|
||||||
|
"control-api": "sha256:" + "a" * 64,
|
||||||
|
"worker": "sha256:" + "b" * 64,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
current = {"control-api": _image_entry("sha256:" + "a" * 64, "local/control-api:dev")}
|
||||||
|
|
||||||
|
errors = check(scan, exceptions, current, TODAY)
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
"AFFECTED IMAGE UNBOUND" in error and "local/worker:dev" in error for error in errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_exception_digest_binding_fails_closed() -> None:
|
||||||
|
scan = _scan(
|
||||||
|
{
|
||||||
|
"package": "pkg",
|
||||||
|
"cves": ["CVE-2026-1"],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"affectedImages": ["local/control-api:dev"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exceptions = {
|
||||||
|
"exceptions": [
|
||||||
|
_exception(
|
||||||
|
affectedImageDigests={
|
||||||
|
"unrelated": "sha256:" + "c" * 64,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
current = {"control-api": _image_entry("sha256:" + "a" * 64, "local/control-api:dev")}
|
||||||
|
|
||||||
|
errors = check(scan, exceptions, current, TODAY)
|
||||||
|
|
||||||
|
assert any("IMAGE BINDING MISSING" in error and "control-api" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_exception_digest_binding_fails_closed() -> None:
|
||||||
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
|
exceptions = {"exceptions": [_exception(affectedImageDigests={"control-api": ""})]}
|
||||||
|
current = {"control-api": _image_entry("sha256:" + "a" * 64)}
|
||||||
|
|
||||||
|
errors = check(scan, exceptions, current, TODAY)
|
||||||
|
|
||||||
|
assert any("IMAGE BINDING MISSING" in error and "control-api" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_recorded_digest_fails_closed() -> None:
|
||||||
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
|
exceptions = {"exceptions": [_exception(affectedImageDigests={"control-api": "sha256:abc"})]}
|
||||||
|
current = {"control-api": _image_entry("sha256:" + "a" * 64)}
|
||||||
|
|
||||||
|
errors = check(scan, exceptions, current, TODAY)
|
||||||
|
|
||||||
|
assert any("IMAGE BINDING INVALID" in error and "control-api" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_or_malformed_current_image_id_fails_even_with_digest() -> None:
|
||||||
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
|
exceptions = {"exceptions": [_exception()]}
|
||||||
|
current = {"control-api": {"digest": "sha256:" + "a" * 64}}
|
||||||
|
|
||||||
|
errors = check(scan, exceptions, current, TODAY)
|
||||||
|
|
||||||
|
assert any("CURRENT IMAGE ID INVALID" in error and "control-api" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_current_image_id_fails_even_with_digest() -> None:
|
||||||
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
|
exceptions = {"exceptions": [_exception()]}
|
||||||
|
current = {
|
||||||
|
"control-api": {
|
||||||
|
"imageId": "sha256:not-a-full-image-id",
|
||||||
|
"digest": "sha256:" + "a" * 64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = check(scan, exceptions, current, TODAY)
|
||||||
|
|
||||||
|
assert any("CURRENT IMAGE ID INVALID" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_current_image_inventory_fails_when_supplied() -> None:
|
||||||
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
|
|
||||||
|
errors = check(scan, {"exceptions": [_exception()]}, {}, TODAY)
|
||||||
|
|
||||||
|
assert any("IMAGE INVENTORY INVALID" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_installed_version_must_match_reviewed_version() -> None:
|
||||||
|
scan = _scan(
|
||||||
|
{
|
||||||
|
"package": "pkg",
|
||||||
|
"cves": ["CVE-2026-1"],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"installedVersion": "2.0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = check(scan, {"exceptions": [_exception(installedVersion="1.0")]}, None, TODAY)
|
||||||
|
|
||||||
|
assert any("VERSION MISMATCH" in error and "2.0" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflicting_duplicate_exceptions_fail_instead_of_overwriting() -> None:
|
||||||
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
|
approved = _exception()
|
||||||
|
conflicting = _exception(
|
||||||
|
approvalStatus="pending", riskOwner="", approver="", compensatingControls=""
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = check(scan, {"exceptions": [approved, conflicting]}, None, TODAY)
|
||||||
|
|
||||||
|
assert any("CONFLICTING DUPLICATE" in error for error in errors)
|
||||||
|
assert any("UNAPPROVED" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scoped_approval_binds_only_affected_services() -> None:
|
||||||
|
scan = _scan(
|
||||||
|
{
|
||||||
|
"package": "pkg",
|
||||||
|
"cves": ["CVE-2026-1"],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"installedVersion": "1.0",
|
||||||
|
"affectedImages": ["local/worker:dev"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exceptions = {"exceptions": [_exception(affectedImageDigests={"worker": "sha256:" + "b" * 64})]}
|
||||||
|
current = {
|
||||||
|
"control-api": _image_entry("sha256:" + "c" * 64, "local/control-api:dev"),
|
||||||
|
"worker": _image_entry("sha256:" + "b" * 64, "local/worker:dev"),
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = check(scan, exceptions, current, TODAY)
|
||||||
|
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_scan_without_affected_images_uses_full_inventory() -> None:
|
||||||
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
|
exceptions = {
|
||||||
|
"exceptions": [_exception(affectedImageDigests={"control-api": "sha256:" + "a" * 64})]
|
||||||
|
}
|
||||||
|
current = {
|
||||||
|
"control-api": _image_entry("sha256:" + "a" * 64),
|
||||||
|
"worker": _image_entry("sha256:" + "b" * 64),
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = check(scan, exceptions, current, TODAY)
|
||||||
|
|
||||||
|
assert any("IMAGE BINDING MISSING" in error and "worker" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_scan_rejects_inventory_missing_a_reviewed_service() -> None:
|
||||||
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
|
exceptions = {
|
||||||
|
"exceptions": [
|
||||||
|
_exception(
|
||||||
|
affectedImageDigests={
|
||||||
|
"control-api": "sha256:" + "a" * 64,
|
||||||
|
"worker": "sha256:" + "b" * 64,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
current = {"control-api": _image_entry("sha256:" + "a" * 64)}
|
||||||
|
|
||||||
|
errors = check(scan, exceptions, current, TODAY)
|
||||||
|
|
||||||
|
assert any("IMAGE INVENTORY SCOPE CHANGED" in error for error in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_is_pending_and_scopes_digests_to_affected_images(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
scan_path = tmp_path / "scan.json"
|
||||||
|
image_path = tmp_path / "images.json"
|
||||||
|
output_path = tmp_path / "draft.json"
|
||||||
|
scan_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
**_scan(
|
||||||
|
{
|
||||||
|
"package": "pkg",
|
||||||
|
"cves": ["CVE-2026-1"],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"installedVersion": "1.0",
|
||||||
|
"affectedImages": ["local/worker:dev"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"scannedAt": "2026-08-15T00:00:00Z",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
image_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"images": {
|
||||||
|
"control-api": _image_entry("sha256:" + "a" * 64, "local/control-api:dev"),
|
||||||
|
"worker": _image_entry("sha256:" + "b" * 64, "local/worker:dev"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
"draft_cve_exceptions.py",
|
||||||
|
"--scan-summary",
|
||||||
|
str(scan_path),
|
||||||
|
"--image-digests",
|
||||||
|
str(image_path),
|
||||||
|
"--evidence-reference",
|
||||||
|
"evidence/WP-200/test.md",
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert draft_exceptions_main() == 0
|
||||||
|
drafted = json.loads(output_path.read_text(encoding="utf-8"))["exceptions"]
|
||||||
|
|
||||||
|
assert len(drafted) == 1
|
||||||
|
assert drafted[0]["approvalStatus"] == "pending"
|
||||||
|
assert drafted[0]["affectedImageDigests"] == {"worker": "sha256:" + "b" * 64}
|
||||||
|
|
||||||
|
|
||||||
def test_malformed_exception_missing_required_field_fails() -> None:
|
def test_malformed_exception_missing_required_field_fails() -> None:
|
||||||
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
broken = _exception()
|
broken = _exception()
|
||||||
@@ -158,3 +414,103 @@ def test_multiple_cves_in_one_package_row_each_need_their_own_exception() -> Non
|
|||||||
errors = check(scan, exceptions, None, TODAY)
|
errors = check(scan, exceptions, None, TODAY)
|
||||||
assert not any("CVE-2026-1" in e for e in errors)
|
assert not any("CVE-2026-1" in e for e in errors)
|
||||||
assert any("CVE-2026-2" in e and "UNAPPROVED" in e for e in errors)
|
assert any("CVE-2026-2" in e and "UNAPPROVED" in e for e in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distinct_installed_versions_are_reviewed_separately_for_scoped_images() -> None:
|
||||||
|
scan = _scan(
|
||||||
|
{
|
||||||
|
"package": "pkg",
|
||||||
|
"cves": ["CVE-2026-1"],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"installedVersion": "1.0",
|
||||||
|
"affectedImages": ["local/web:dev"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"package": "pkg",
|
||||||
|
"cves": ["CVE-2026-1"],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"installedVersion": "2.0",
|
||||||
|
"affectedImages": ["local/postgres:dev"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
exceptions = {
|
||||||
|
"exceptions": [
|
||||||
|
_exception(affectedImageDigests={"web": "sha256:" + "a" * 64}),
|
||||||
|
_exception(
|
||||||
|
id="pkg-CVE-2026-1-v2",
|
||||||
|
installedVersion="2.0",
|
||||||
|
affectedImageDigests={"postgres": "sha256:" + "b" * 64},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
images = {
|
||||||
|
"web": _image_entry("sha256:" + "a" * 64, "local/web:dev"),
|
||||||
|
"postgres": _image_entry("sha256:" + "b" * 64, "local/postgres:dev"),
|
||||||
|
}
|
||||||
|
assert check(scan, exceptions, images, TODAY) == []
|
||||||
|
exceptions["exceptions"].pop()
|
||||||
|
assert any("VERSION MISMATCH" in error for error in check(scan, exceptions, images, TODAY))
|
||||||
|
|
||||||
|
|
||||||
|
def test_versionless_legacy_scan_rejects_ambiguous_version_reviews() -> None:
|
||||||
|
scan = _scan({"package": "pkg", "cves": ["CVE-2026-1"], "severity": "HIGH"})
|
||||||
|
exceptions = {"exceptions": [_exception(), _exception(id="v2", installedVersion="2.0")]}
|
||||||
|
assert any("AMBIGUOUS VERSION" in error for error in check(scan, exceptions, None, TODAY))
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_distinct_versions_keeps_unique_pending_image_bound_reviews(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
scan = _scan(
|
||||||
|
*[
|
||||||
|
{
|
||||||
|
"package": "pkg",
|
||||||
|
"cves": ["CVE-2026-1"],
|
||||||
|
"severity": "HIGH",
|
||||||
|
"installedVersion": version,
|
||||||
|
"affectedImages": [f"local/{service}:dev"],
|
||||||
|
}
|
||||||
|
for version, service in [("1.0", "web"), ("2.0", "postgres")]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
scans, images, output = (
|
||||||
|
tmp_path / "scan.json",
|
||||||
|
tmp_path / "images.json",
|
||||||
|
tmp_path / "draft.json",
|
||||||
|
)
|
||||||
|
scans.write_text(json.dumps(scan), encoding="utf-8")
|
||||||
|
images.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"images": {
|
||||||
|
"web": _image_entry("sha256:" + "a" * 64, "local/web:dev"),
|
||||||
|
"postgres": _image_entry("sha256:" + "b" * 64, "local/postgres:dev"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
"draft",
|
||||||
|
"--scan-summary",
|
||||||
|
str(scans),
|
||||||
|
"--image-digests",
|
||||||
|
str(images),
|
||||||
|
"--output",
|
||||||
|
str(output),
|
||||||
|
"--evidence-reference",
|
||||||
|
"fixture",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert draft_exceptions_main() == 0
|
||||||
|
drafted = json.loads(output.read_text(encoding="utf-8"))["exceptions"]
|
||||||
|
assert len(drafted) == 2 and len({entry["id"] for entry in drafted}) == 2
|
||||||
|
assert all(
|
||||||
|
entry["approvalStatus"] == "pending" and not entry["riskOwner"] and not entry["approver"]
|
||||||
|
for entry in drafted
|
||||||
|
)
|
||||||
|
assert drafted[0]["affectedImageDigests"] == {"web": "sha256:" + "a" * 64}
|
||||||
|
assert drafted[1]["affectedImageDigests"] == {"postgres": "sha256:" + "b" * 64}
|
||||||
Reference in new issue
Block a user