From e9b7cc3baf031c59c7107f1c75a36f7c15201e2e Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 4 Sep 2026 13:21:44 +0100 Subject: [PATCH 1/7] feat: run conformance without the control plane Signed-off-by: lucarlig --- CHANGELOG.md | 20 +- Cargo.lock | 555 +++++++++++++++++- Cargo.toml | 5 +- README.md | 15 +- ...r-compose.cf-conformance-controlplane.yaml | 4 + docker/docker-compose.cf-conformance.yaml | 7 +- ...ocker-compose.cf-dataplane-standalone.yaml | 81 +++ scripts/conformance/write_client_config.py | 90 --- .../conformance/write_dataplane_config.mjs | 169 ++++++ src/app.rs | 27 +- src/app_tests.rs | 52 ++ src/cli.rs | 4 + src/cli_public_tests.rs | 21 + src/conformance/client.rs | 11 +- src/infrastructure/assets.rs | 4 +- src/infrastructure/compose.rs | 50 ++ .../compose_integration_tests.rs | 44 +- .../process_integration_tests.rs | 2 +- src/runtime/conformance/mod.rs | 322 +++++++--- src/runtime/stack/mod.rs | 185 +++++- src/runtime/stack/sources.rs | 2 +- 21 files changed, 1451 insertions(+), 219 deletions(-) create mode 100644 docker/docker-compose.cf-conformance-controlplane.yaml create mode 100644 docker/docker-compose.cf-dataplane-standalone.yaml delete mode 100644 scripts/conformance/write_client_config.py create mode 100644 scripts/conformance/write_dataplane_config.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index cf474ce..f31fb2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [0.3.1] - 2026-09-04 + +### Added + +- Added standalone external-dataplane conformance runs backed by an ephemeral + Redis instance, a locally signed test token, and no control-plane services. + +### Changed + +- Published server and client conformance routes through the running + dataplane's serializer so mocked Redis snapshots always use that image's + current MessagePack schema. + +### Fixed + +- Avoided a Linux `ETXTBSY` race in the native process-runner test. + ## [0.3.0] - 2026-09-03 ### Added @@ -68,7 +85,8 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - Added builtin and external dataplane routing through reusable Docker Compose overlays. -[Unreleased]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/contextforge-org/contextforge-dev-tools/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 8d2e0f9..81dafe9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -139,18 +145,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -177,12 +204,13 @@ dependencies = [ [[package]] name = "cf-integration" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "axum", "clap", "indicatif", + "jsonwebtoken", "reqwest", "serde", "serde_json", @@ -214,8 +242,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -294,6 +322,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.10.1" @@ -310,6 +344,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -319,6 +362,78 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -336,6 +451,65 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -364,6 +538,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -445,6 +635,17 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -468,10 +669,21 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -484,6 +696,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.2" @@ -800,12 +1030,49 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "rand 0.8.8", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libyaml-rs" version = "0.3.0" @@ -865,6 +1132,51 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -883,6 +1195,39 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -895,6 +1240,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -916,6 +1282,24 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -955,7 +1339,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -997,6 +1381,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -1005,7 +1400,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1020,7 +1434,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1063,6 +1477,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1077,6 +1501,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1210,6 +1654,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1305,6 +1763,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1321,6 +1790,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -1359,6 +1838,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1599,6 +2094,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1653,6 +2154,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1914,6 +2421,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -1940,6 +2467,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index f8cf40d..c1d0308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cf-integration" -version = "0.3.0" +version = "0.3.1" edition = "2024" rust-version = "1.97" license = "Apache-2.0" @@ -20,7 +20,7 @@ include = [ "/scripts/locustfile_mcp.py", "/scripts/prepare_standalone_config.py", "/scripts/live_protocol/sitecustomize.py", - "/scripts/conformance/write_client_config.py", + "/scripts/conformance/write_dataplane_config.mjs", "/tests/conformance/baselines/**", "/README.md", "/CHANGELOG.md", @@ -36,6 +36,7 @@ anyhow = "1" axum = "0.8.9" clap = { version = "4.5.60", features = ["derive"] } indicatif = { version = "0.18.6", default-features = false } +jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto"] } reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index c78b5b8..54b69dd 100644 --- a/README.md +++ b/README.md @@ -178,11 +178,24 @@ cf-integration conformance run \ # Replace selected baselines only after every selected run succeeds cf-integration conformance run --server-era dual --bless + +# Test only the external dataplane with mocked Redis and no control plane +cf-integration conformance run --lane external --standalone \ + --client-era modern --server-era modern + +# Refresh the external dataplane baselines using the same isolated stack +cf-integration conformance run --lane external --standalone \ + --client-era modern --server-era modern --bless ``` `--client-era` and `--server-era` accept `legacy`, `modern`, or `dual`. `--results-dir`, `--baseline-dir`, and `--output-dir` override artifact -locations. +locations. `--standalone` requires `--lane external` as the only lane. It +starts only Redis, the external dataplane, nginx, ClickStack, and the pinned +fixture; the control plane is neither started nor used. The CLI signs a +one-hour test token locally and publishes every server/client route through the +running dataplane's admin serializer, so each ephemeral Redis snapshot uses the +tested image's current MessagePack schema. `report` regenerates Markdown comparisons from existing results without running the suite: diff --git a/docker/docker-compose.cf-conformance-controlplane.yaml b/docker/docker-compose.cf-conformance-controlplane.yaml new file mode 100644 index 0000000..3072da7 --- /dev/null +++ b/docker/docker-compose.cf-conformance-controlplane.yaml @@ -0,0 +1,4 @@ +services: + gateway: + environment: + GATEWAY_TOOL_NAME_SEPARATOR: "_" diff --git a/docker/docker-compose.cf-conformance.yaml b/docker/docker-compose.cf-conformance.yaml index ca54f47..2551693 100644 --- a/docker/docker-compose.cf-conformance.yaml +++ b/docker/docker-compose.cf-conformance.yaml @@ -1,12 +1,9 @@ services: - gateway: - environment: - GATEWAY_TOOL_NAME_SEPARATOR: "_" - volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_client_config.py:/opt/contextforge-conformance/write_client_config.py:ro mcp_conformance_server: networks: - mcpnet + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_dataplane_config.mjs:/opt/contextforge-conformance/write_dataplane_config.mjs:ro mcp_conformance_proxy: profiles: ["conformance"] image: nginx:1.30.4-alpine3.24 diff --git a/docker/docker-compose.cf-dataplane-standalone.yaml b/docker/docker-compose.cf-dataplane-standalone.yaml new file mode 100644 index 0000000..19c2151 --- /dev/null +++ b/docker/docker-compose.cf-dataplane-standalone.yaml @@ -0,0 +1,81 @@ +# Minimal external-dataplane stack for conformance runs. The control plane is +# deliberately absent: the harness signs an ephemeral token and asks the +# dataplane to serialize the mocked per-user routing snapshot into Redis. + +services: + redis: + image: redis:8.2.8-alpine3.22 + labels: + name: cf-redis + restart: "no" + command: + - redis-server + - --save + - "" + - --appendonly + - "no" + expose: + - "6379" + networks: + - mcpnet + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 2s + + dataplane: + image: ${CF_DATAPLANE_IMAGE:?Set CF_DATAPLANE_IMAGE to the cf-dataplane image tag} + pull_policy: ${CF_DATAPLANE_PULL_POLICY:-always} + platform: ${CF_DATAPLANE_PLATFORM:?Set CF_DATAPLANE_PLATFORM to the cf-dataplane image platform} + labels: + name: cf-dataplane + restart: "no" + networks: + mcpnet: + aliases: + - cf-dataplane + extra_hosts: + - host.docker.internal:host-gateway + expose: + - "4445" + environment: + CONTEXTFORGE_DATA_PLANE_ADDRESS: 0.0.0.0:4445 + CONTEXTFORGE_DATA_PLANE_REDIS_HOSTNAME: redis + CONTEXTFORGE_DATA_PLANE_REDIS_PORT: "6379" + CONTEXTFORGE_DATA_PLANE_REDIS_CONNECTION_MODE: plain-text + CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET: ${JWT_SECRET_KEY:-my-test-key-but-now-longer-than-32-bytes} + CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY: /dev/null + CONTEXTFORGE_DATA_PLANE_UPSTREAM_CONNECTION_MODE: plain-text-or-tls + CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS: ${CF_DATAPLANE_MCP_ALLOWED_HOSTS:-127.0.0.1:${NGINX_PORT:-8080},localhost:${NGINX_PORT:-8080},nginx} + CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS: ${CF_DATAPLANE_MCP_ALLOWED_ORIGINS:-http://127.0.0.1:${NGINX_PORT:-8080},http://localhost:${NGINX_PORT:-8080}} + CONTEXTFORGE_DATA_PLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS: "0" + RUST_LOG: ${CF_DATAPLANE_LOG:-info} + depends_on: + redis: + condition: service_healthy + + nginx: + image: nginx:1.30.4-alpine3.24 + labels: + name: cf-nginx + restart: "no" + ports: + - "${NGINX_PORT:-8080}:80" + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/docker/nginx.cf-dataplane.conf:/etc/nginx/nginx.conf:ro + networks: + - mcpnet + depends_on: + dataplane: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/nginx_status >/dev/null"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 2s + +networks: + mcpnet: diff --git a/scripts/conformance/write_client_config.py b/scripts/conformance/write_client_config.py deleted file mode 100644 index 7fa9d9b..0000000 --- a/scripts/conformance/write_client_config.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -"""Publish one isolated upstream-client conformance route for the dataplane.""" - -from __future__ import annotations - -import base64 -import json -import os -import sys -from urllib.parse import urlparse - -import msgpack -import redis - - -def token_subject(token: str) -> str: - parts = token.split(".") - if len(parts) != 3: - raise SystemExit("MCP_CONFORMANCE_TOKEN is not a JWT") - payload = parts[1] + ("=" * (-len(parts[1]) % 4)) - try: - claims = json.loads(base64.urlsafe_b64decode(payload)) - except (ValueError, json.JSONDecodeError) as error: - raise SystemExit("MCP_CONFORMANCE_TOKEN has invalid claims") from error - subject = claims.get("sub") - if not isinstance(subject, str) or not subject: - raise SystemExit("MCP_CONFORMANCE_TOKEN has no string subject") - return subject - - -def main() -> None: - if len(sys.argv) != 4: - raise SystemExit( - "usage: write_client_config.py " - ) - virtual_host_id, backend_url, tool_names_json = sys.argv[1:] - token = os.environ.get("MCP_CONFORMANCE_TOKEN") - redis_url = os.environ.get("REDIS_URL") - if not token: - raise SystemExit("MCP_CONFORMANCE_TOKEN is required") - if not redis_url: - raise SystemExit("REDIS_URL is required") - if not virtual_host_id: - raise SystemExit("virtual-host-id must not be empty") - - parsed_url = urlparse(backend_url) - if parsed_url.scheme not in {"http", "https"} or not parsed_url.hostname: - raise SystemExit("backend-url must be an absolute HTTP(S) URL") - tool_names = json.loads(tool_names_json) - if ( - not isinstance(tool_names, list) - or not tool_names - or not all(isinstance(name, str) and name for name in tool_names) - ): - raise SystemExit("tool-names-json must be a non-empty JSON string array") - backend_name = "conformance-backend" - config = { - "virtual_hosts": { - virtual_host_id: { - "backends": { - backend_name: { - "name": backend_name, - "url": backend_url, - "mcp_protocol_version": "2026-07-28", - "passthrough_headers": [], - "add_headers": {}, - "remove_headers": [], - "tool_name_aliases": [ - { - "downstream_prefixed_name": name, - "upstream_name": name, - } - for name in tool_names - ], - "resource_uri_aliases": [], - "prompt_name_aliases": [], - "completion": {}, - "tool_schemas": {name: {} for name in tool_names}, - } - } - } - } - } - key = msgpack.dumps(("UserConfig", token_subject(token)), use_bin_type=True) - value = msgpack.dumps(config, use_bin_type=True) - redis.Redis.from_url(redis_url, decode_responses=False).set(key, value, ex=600) - - -if __name__ == "__main__": - main() diff --git a/scripts/conformance/write_dataplane_config.mjs b/scripts/conformance/write_dataplane_config.mjs new file mode 100644 index 0000000..232ae06 --- /dev/null +++ b/scripts/conformance/write_dataplane_config.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** Publish one conformance route through the dataplane's current serializer. */ + +const DATAPLANE_CONFIG_URL = + 'http://dataplane:4445/contextforge-rs/admin/userconfigs'; +const FIXTURE_TOOLS = [ + 'test_simple_text', + 'test_image_content', + 'test_audio_content', + 'test_embedded_resource', + 'test_multiple_content_types', + 'test_tool_with_logging', + 'test_tool_with_progress', + 'test_error_handling', + 'test_reconnection', + 'test_sampling', + 'test_elicitation', + 'test_elicitation_sep1034_defaults', + 'test_elicitation_sep1330_enums', + 'json_schema_2020_12_tool', +]; +const FIXTURE_RESOURCES = [ + 'test://static-text', + 'test://static-binary', + 'test://watched-resource', +]; +const FIXTURE_RESOURCE_TEMPLATES = ['test://template/{id}/data']; +const FIXTURE_PROMPTS = [ + 'test_simple_prompt', + 'test_prompt_with_arguments', + 'test_prompt_with_embedded_resource', + 'test_prompt_with_image', +]; + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function tokenSubject(token) { + const parts = token.split('.'); + if (parts.length !== 3) fail('MCP_CONFORMANCE_TOKEN is not a JWT'); + let claims; + try { + claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + } catch { + fail('MCP_CONFORMANCE_TOKEN has invalid claims'); + } + if (typeof claims.sub !== 'string' || claims.sub.length === 0) { + fail('MCP_CONFORMANCE_TOKEN has no string subject'); + } + return claims.sub; +} + +function stringArray(value, label) { + let parsed; + try { + parsed = JSON.parse(value); + } catch { + fail(`${label} must be valid JSON`); + } + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string' || !item)) { + fail(`${label} must be a JSON string array`); + } + return parsed; +} + +function routes(names, backendName) { + return Object.fromEntries( + names.map((name) => [name, { backend_name: backendName, upstream_name: name }]), + ); +} + +function config(serverId, backendUrl, protocolVersion, catalogs) { + const backendName = 'conformance-backend'; + return { + virtual_hosts: { + [serverId]: { + backends: { + [backendName]: { + name: backendName, + url: backendUrl, + mcp_protocol_version: protocolVersion, + passthrough_headers: [], + add_headers: {}, + remove_headers: [], + completion: {}, + tool_schemas: Object.fromEntries(catalogs.tools.map((name) => [name, {}])), + // Older published dataplanes used aliases instead of service routes. + tool_name_aliases: catalogs.tools.map((name) => ({ + downstream_prefixed_name: name, + upstream_name: name, + })), + resource_uri_aliases: catalogs.resources.map((name) => ({ + downstream_prefixed_uri: name, + upstream_uri: name, + })), + prompt_name_aliases: catalogs.prompts.map((name) => ({ + downstream_prefixed_name: name, + upstream_name: name, + })), + }, + }, + tools: routes(catalogs.tools, backendName), + resources: routes(catalogs.resources, backendName), + resource_templates: routes(catalogs.resourceTemplates, backendName), + prompts: routes(catalogs.prompts, backendName), + }, + }, + }; +} + +async function publish(subject, body) { + const endpoint = `${DATAPLANE_CONFIG_URL}/${encodeURIComponent(subject)}`; + let lastError = 'dataplane did not respond'; + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(2000), + }); + if (response.status === 202) return; + lastError = `HTTP ${response.status}: ${(await response.text()).slice(0, 512)}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + fail(`dataplane config serializer was unavailable: ${lastError}`); +} + +async function main() { + const [mode, serverId, backendUrl, protocolVersion, toolNamesJson] = process.argv.slice(2); + if (!['fixture', 'client'].includes(mode)) { + fail('mode must be fixture or client'); + } + if (!serverId) fail('virtual-host-id must not be empty'); + try { + const parsed = new URL(backendUrl); + if (!['http:', 'https:'].includes(parsed.protocol)) fail('backend-url must use HTTP(S)'); + } catch { + fail('backend-url must be an absolute HTTP(S) URL'); + } + if (!protocolVersion) fail('protocol-version must not be empty'); + const token = process.env.MCP_CONFORMANCE_TOKEN; + if (!token) fail('MCP_CONFORMANCE_TOKEN is required'); + + const catalogs = mode === 'fixture' + ? { + tools: FIXTURE_TOOLS, + resources: FIXTURE_RESOURCES, + resourceTemplates: FIXTURE_RESOURCE_TEMPLATES, + prompts: FIXTURE_PROMPTS, + } + : { + tools: stringArray(toolNamesJson, 'tool-names-json'), + resources: [], + resourceTemplates: [], + prompts: [], + }; + await publish( + tokenSubject(token), + config(serverId, backendUrl, protocolVersion, catalogs), + ); +} + +await main(); diff --git a/src/app.rs b/src/app.rs index 6bcee5e..4940cfc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -97,15 +97,22 @@ impl Action { ), Self::Conformance(ConformanceAction::Run { lanes, + standalone, client_eras, server_eras, .. - }) => format!( - "Lane: {}\nClient era: {}\nServer era: {}", - join_lane_labels(lanes), - join_client_eras(client_eras), - join_server_eras(server_eras), - ), + }) => { + let mut summary = format!( + "Lane: {}\nClient era: {}\nServer era: {}", + join_lane_labels(lanes), + join_client_eras(client_eras), + join_server_eras(server_eras), + ); + if *standalone { + summary.push_str("\nControl plane: disabled; Redis config: mocked"); + } + summary + } Self::Conformance(ConformanceAction::Report { .. }) => String::from( "Lane: recorded conformance results\nClient era: recorded conformance results\nServer era: recorded conformance results", ), @@ -245,6 +252,7 @@ pub(crate) struct ResolvedLoadArgs { pub(crate) enum ConformanceAction { Run { lanes: Vec, + standalone: bool, client_eras: Vec, client_versions: Vec, server_eras: Vec, @@ -351,8 +359,13 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Ok(Action::Conformance(match args.command { ConformanceCommand::Run(args) => { let (client_eras, client_versions) = resolve_client_eras(args.client_era); + let lanes = resolve_lanes(args.lane.into_iter().map(Into::into)); + if args.standalone && lanes != [SemanticLane::ExternalDataPlane] { + bail!("--standalone requires --lane external as the only lane"); + } ConformanceAction::Run { - lanes: resolve_lanes(args.lane.into_iter().map(Into::into)), + lanes, + standalone: args.standalone, client_eras, client_versions, server_eras: resolve_server_eras(args.server_era), diff --git a/src/app_tests.rs b/src/app_tests.rs index c5da693..3415b85 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -466,6 +466,7 @@ fn conformance_defaults_to_all_three_ordered_lanes() { SemanticLane::BuiltInDataPlane, SemanticLane::ExternalDataPlane, ], + standalone: false, client_eras: vec![ConformanceServerEra::Modern], client_versions: vec!["2026-07-28".to_owned()], server_eras: vec![ConformanceServerEra::Legacy, ConformanceServerEra::Modern], @@ -513,6 +514,7 @@ fn conformance_lanes_are_deduplicated_and_normalized() { ), Action::Conformance(ConformanceAction::Run { lanes: vec![SemanticLane::FixtureDirect, SemanticLane::ExternalDataPlane,], + standalone: false, client_eras: vec![ConformanceServerEra::Legacy, ConformanceServerEra::Modern], client_versions: vec![ "2025-06-18".to_owned(), @@ -528,6 +530,56 @@ fn conformance_lanes_are_deduplicated_and_normalized() { ); } +#[test] +fn standalone_conformance_is_external_only() { + let standalone = action( + &[ + "cf-integration", + "conformance", + "run", + "--lane", + "external", + "--standalone", + ], + &[], + ); + let Action::Conformance(ConformanceAction::Run { + lanes, + standalone: enabled, + .. + }) = &standalone + else { + panic!("expected conformance run"); + }; + assert_eq!(lanes, &[SemanticLane::ExternalDataPlane]); + assert!(enabled); + assert!( + standalone + .startup_summary() + .contains("Control plane: disabled; Redis config: mocked") + ); + + for arguments in [ + vec!["cf-integration", "conformance", "run", "--standalone"], + vec![ + "cf-integration", + "conformance", + "run", + "--lane", + "builtin", + "--standalone", + ], + ] { + let cli = Cli::try_parse_from(arguments).expect("CLI should parse standalone mode"); + let error = resolve_action(cli, &Environment::new()) + .expect_err("standalone conformance must reject non-external lane selections"); + assert_eq!( + error.to_string(), + "--standalone requires --lane external as the only lane" + ); + } +} + #[test] fn conformance_report_is_official_only() { assert_eq!( diff --git a/src/cli.rs b/src/cli.rs index 5f30daa..5f805bb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -413,6 +413,10 @@ pub(crate) struct ConformanceRunArgs { #[arg(long, value_enum, action = ArgAction::Append)] pub(crate) lane: Vec, + /// Run the external dataplane against mocked Redis without a control plane. + #[arg(long)] + pub(crate) standalone: bool, + /// Protocol era used by the official client; repeat for a matrix. #[arg(long, value_enum, action = ArgAction::Append)] pub(crate) client_era: Vec, diff --git a/src/cli_public_tests.rs b/src/cli_public_tests.rs index 7d9d9b2..73ce240 100644 --- a/src/cli_public_tests.rs +++ b/src/cli_public_tests.rs @@ -462,6 +462,7 @@ fn conformance_defaults_to_all_lanes_and_july_revision_at_resolution_time() { panic!("expected conformance run") }; assert!(args.lane.is_empty()); + assert!(!args.standalone); assert!(args.client_era.is_empty()); assert!(args.server_era.is_empty()); assert!(args.results_dir.is_none()); @@ -550,6 +551,26 @@ fn conformance_accepts_repeatable_exact_lanes_and_protocol_eras() { ]); } +#[test] +fn conformance_accepts_standalone_external_mode() { + let Command::Conformance(ConformanceArgs { + command: ConformanceCommand::Run(args), + }) = parse(&[ + "cf-integration", + "conformance", + "run", + "--lane", + "external", + "--standalone", + ]) + .command + else { + panic!("expected conformance run") + }; + assert_eq!(args.lane, [CliLane::External]); + assert!(args.standalone); +} + #[test] fn root_version_flag_reports_the_package_version() { let error = Cli::try_parse_from(["cf-integration", "--version"]) diff --git a/src/conformance/client.rs b/src/conformance/client.rs index b747700..a943ef1 100644 --- a/src/conformance/client.rs +++ b/src/conformance/client.rs @@ -21,7 +21,7 @@ pub(crate) const CLIENT_DRIVER_FAILURE_PREFIX: &str = "client conformance driver const SCENARIO_ENV: &str = "MCP_CONFORMANCE_SCENARIO"; const PROTOCOL_VERSION_ENV: &str = "MCP_CONFORMANCE_PROTOCOL_VERSION"; const CONTEXT_ENV: &str = "MCP_CONFORMANCE_CONTEXT"; -const CONFIG_WRITER: &str = "/opt/contextforge-conformance/write_client_config.py"; +const CONFIG_WRITER: &str = "/opt/contextforge-conformance/write_dataplane_config.mjs"; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -56,7 +56,7 @@ pub(crate) async fn run_internal_client(arguments: &[OsString]) -> Result<()> { let base_url = required_environment(CLIENT_BASE_URL_ENV)?; let tool_calls = scenario_tool_calls(&scenario)?; let backend_url = container_backend_url(scenario_server_url)?; - publish_scenario_config(&backend_url, &server_id, &tool_calls)?; + publish_scenario_config(&backend_url, &server_id, &protocol_version, &tool_calls)?; let mut client = GatewayClient::builder(GatewayTopology::Dataplane, &base_url, &server_id, &token) @@ -146,6 +146,7 @@ fn container_backend_url(value: &str) -> Result { fn publish_scenario_config( backend_url: &str, server_id: &str, + protocol_version: &str, tool_calls: &[ToolCall], ) -> Result<()> { let serialized_args = required_environment(CLIENT_COMPOSE_ARGS_ENV)?; @@ -167,11 +168,13 @@ fn publish_scenario_config( "-e", CLIENT_TOKEN_ENV, "--entrypoint", - "python3", - "gateway", + "node", + "mcp_conformance_server", CONFIG_WRITER, + "client", server_id, backend_url, + protocol_version, &tool_names, ]); SystemProcessRunner diff --git a/src/infrastructure/assets.rs b/src/infrastructure/assets.rs index 02d57e6..495ebaf 100644 --- a/src/infrastructure/assets.rs +++ b/src/infrastructure/assets.rs @@ -24,10 +24,12 @@ macro_rules! asset { const ASSETS: &[EmbeddedAsset] = &[ asset!("docker/docker-compose.cf-conformance-fixture.yaml"), + asset!("docker/docker-compose.cf-conformance-controlplane.yaml"), asset!("docker/docker-compose.cf-conformance-runtime.yaml"), asset!("docker/docker-compose.cf-conformance.yaml"), asset!("docker/docker-compose.cf-controlplane-build-labels.yaml"), asset!("docker/docker-compose.cf-dataplane-build.yaml"), + asset!("docker/docker-compose.cf-dataplane-standalone.yaml"), asset!("docker/docker-compose.cf-dataplane.yaml"), asset!("docker/docker-compose.cf-integration.yaml"), asset!("docker/mcp-conformance-server.Dockerfile"), @@ -35,7 +37,7 @@ const ASSETS: &[EmbeddedAsset] = &[ asset!("docker/nginx.cf-dataplane.conf"), asset!("docker/patch-mcp-conformance-hosts.mjs"), asset!("scripts/live_protocol/sitecustomize.py"), - asset!("scripts/conformance/write_client_config.py"), + asset!("scripts/conformance/write_dataplane_config.mjs"), asset!("scripts/locustfile_mcp.py"), asset!("scripts/prepare_standalone_config.py"), asset!("tests/conformance/baselines/2026-07-28/legacy/built-in-data-plane.yml"), diff --git a/src/infrastructure/compose.rs b/src/infrastructure/compose.rs index f7246e4..80f2bde 100644 --- a/src/infrastructure/compose.rs +++ b/src/infrastructure/compose.rs @@ -108,6 +108,32 @@ impl ComposeProject { } } + /// Builds the minimal external-dataplane project used by standalone conformance. + #[must_use] + pub(crate) fn standalone_dataplane( + repository_root: &Path, + project_name: OsString, + build_dataplane: bool, + ) -> Self { + let mut files = vec![ + repository_root + .join("docker") + .join("docker-compose.cf-dataplane-standalone.yaml"), + ]; + if build_dataplane { + files.push( + repository_root + .join("docker") + .join("docker-compose.cf-dataplane-build.yaml"), + ); + } + Self { + project_name, + files, + profiles: Vec::new(), + } + } + /// Builds the stock control-plane-only project. #[must_use] pub(crate) fn controlplane( @@ -179,6 +205,18 @@ impl ComposeProject { self } + /// Applies conformance-only settings to the control-plane service. + #[must_use] + pub(crate) fn with_controlplane_conformance_overlay(mut self, repository_root: &Path) -> Self { + let overlay = repository_root + .join("docker") + .join("docker-compose.cf-conformance-controlplane.yaml"); + if !self.files.contains(&overlay) { + self.files.push(overlay); + } + self + } + /// Applies the control-plane runtime settings used by conformance runs. #[must_use] pub(crate) fn with_conformance_runtime(mut self, repository_root: &Path) -> Self { @@ -215,6 +253,18 @@ impl ComposeProject { self } + /// Enables only the external dataplane OTLP exporters. + #[must_use] + pub(crate) fn with_dataplane_observability(mut self, repository_root: &Path) -> Self { + let overlay = repository_root + .join("docker") + .join("docker-compose.cf-dataplane-observability.yaml"); + if !self.files.contains(&overlay) { + self.files.push(overlay); + } + self + } + /// Enables the isolated official MCP conformance server fixture. #[must_use] pub(crate) fn with_conformance_fixture(self, repository_root: &Path) -> Self { diff --git a/src/infrastructure/compose_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index 1f2270a..8463c88 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -357,6 +357,26 @@ fn source_dataplane_adds_build_overlay_last() { ); } +#[test] +fn standalone_dataplane_has_no_controlplane_compose_inputs() { + let project = + ComposeProject::standalone_dataplane(Path::new("/repo"), OsString::from("project"), true); + + assert_eq!( + project.files(), + [ + PathBuf::from("/repo/docker/docker-compose.cf-dataplane-standalone.yaml"), + PathBuf::from("/repo/docker/docker-compose.cf-dataplane-build.yaml"), + ] + ); + assert!( + project + .files() + .iter() + .all(|file| !file.to_string_lossy().contains("controlplane")) + ); +} + #[test] fn conformance_fixture_is_an_explicit_overlay_and_profile() { let default_project = ComposeProject::dataplane( @@ -598,6 +618,9 @@ fn conformance_container_inputs_pin_the_runner_revision_and_protocol_fixture() { .expect("read standalone conformance fixture Compose file"); let routed_compose = fs::read_to_string(root.join("docker/docker-compose.cf-conformance.yaml")) .expect("read routed conformance Compose overlay"); + let controlplane_compose = + fs::read_to_string(root.join("docker/docker-compose.cf-conformance-controlplane.yaml")) + .expect("read control-plane conformance Compose overlay"); assert!(dockerfile.contains("FROM node:22-bookworm-slim")); assert!( @@ -679,14 +702,11 @@ services: let expected_routed_compose: yaml_serde::Value = yaml_serde::from_str( r#" services: - gateway: - environment: - GATEWAY_TOOL_NAME_SEPARATOR: "_" - volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_client_config.py:/opt/contextforge-conformance/write_client_config.py:ro mcp_conformance_server: networks: - mcpnet + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_dataplane_config.mjs:/opt/contextforge-conformance/write_dataplane_config.mjs:ro mcp_conformance_proxy: profiles: ["conformance"] image: nginx:1.30.4-alpine3.24 @@ -705,6 +725,20 @@ services: .expect("parse expected routed conformance Compose overlay"); assert_eq!(actual_routed_compose, expected_routed_compose); + let actual_controlplane_compose: yaml_serde::Value = + yaml_serde::from_str(&controlplane_compose) + .expect("parse control-plane conformance Compose overlay"); + let expected_controlplane_compose: yaml_serde::Value = yaml_serde::from_str( + r#" +services: + gateway: + environment: + GATEWAY_TOOL_NAME_SEPARATOR: "_" +"#, + ) + .expect("parse expected control-plane conformance Compose overlay"); + assert_eq!(actual_controlplane_compose, expected_controlplane_compose); + let proxy = fs::read_to_string(root.join("docker/nginx.cf-conformance-proxy.conf")) .expect("read conformance proxy config"); assert!(proxy.contains("proxy_pass http://mcp_conformance_server:3000;")); diff --git a/src/infrastructure/process_integration_tests.rs b/src/infrastructure/process_integration_tests.rs index 73f8dbf..67e7ffc 100644 --- a/src/infrastructure/process_integration_tests.rs +++ b/src/infrastructure/process_integration_tests.rs @@ -62,7 +62,7 @@ async fn logging_runner_hides_ordinary_output_in_an_aggregate_log() { let runner = LoggingProcessRunner::new(&system, &log_path); runner - .run_async(&CommandSpec::new(script)) + .run_async(&CommandSpec::new("/bin/sh").arg(script)) .await .expect("logged child should succeed"); diff --git a/src/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index 70aea68..5cfeff8 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -15,6 +15,7 @@ use crate::conformance::profile::{ use crate::conformance::results::{DEFAULT_CONFORMANCE_SUITE, ScenarioOutcome}; const CLIENT_CONFORMANCE_SERVER_ID: &str = "dataplane-client-conformance"; +const DATAPLANE_CONFIG_WRITER: &str = "/opt/contextforge-conformance/write_dataplane_config.mjs"; #[derive(Debug, Clone, PartialEq, Eq)] struct ConformanceOperationalFailure { @@ -175,9 +176,11 @@ impl RuntimeContext { &self, topology: StackMode, server_era: ConformanceServerEra, + standalone: bool, ) -> AppResult<()> { - self.build_conformance_service(topology, server_era).await?; - self.start_conformance_containers(topology, server_era) + self.build_conformance_service(topology, server_era, standalone) + .await?; + self.start_conformance_containers(topology, server_era, standalone) .await } @@ -185,11 +188,12 @@ impl RuntimeContext { &self, topology: StackMode, server_era: ConformanceServerEra, + standalone: bool, ) -> AppResult<()> { - let project = self.conformance_compose_project(topology); + let project = self.routed_conformance_project(topology, standalone); let build = project.command(["build", OFFICIAL_CONFORMANCE_SERVICE]); let build = self - .compose_environment(build, topology, true)? + .routed_conformance_environment(build, topology, standalone)? .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); Ok(self.runner.run_async(&build).await?) } @@ -198,8 +202,9 @@ impl RuntimeContext { &self, topology: StackMode, server_era: ConformanceServerEra, + standalone: bool, ) -> AppResult<()> { - let project = self.conformance_compose_project(topology); + let project = self.routed_conformance_project(topology, standalone); let up = project.command([ "up", "-d", @@ -208,7 +213,7 @@ impl RuntimeContext { OFFICIAL_CONFORMANCE_PROXY_SERVICE, ]); let up = self - .compose_environment(up, topology, true)? + .routed_conformance_environment(up, topology, standalone)? .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); Ok(self.runner.run_async(&up).await?) } @@ -224,25 +229,55 @@ impl RuntimeContext { parse_conformance_fixture_endpoint(&output).map_err(AppFailure::from) } - async fn stop_conformance_service(&self, topology: StackMode) -> AppResult<()> { - let remove = self.conformance_compose_project(topology).command([ - "rm", - "--stop", - "--force", - OFFICIAL_CONFORMANCE_PROXY_SERVICE, - OFFICIAL_CONFORMANCE_SERVICE, - ]); - let remove = self.compose_environment(remove, topology, true)?; + async fn stop_conformance_service( + &self, + topology: StackMode, + standalone: bool, + ) -> AppResult<()> { + let remove = self + .routed_conformance_project(topology, standalone) + .command([ + "rm", + "--stop", + "--force", + OFFICIAL_CONFORMANCE_PROXY_SERVICE, + OFFICIAL_CONFORMANCE_SERVICE, + ]); + let remove = self.routed_conformance_environment(remove, topology, standalone)?; self.runner .run_async(&remove) .await .map_err(AppFailure::from) } + fn routed_conformance_project(&self, topology: StackMode, standalone: bool) -> ComposeProject { + if standalone { + debug_assert_eq!(topology, StackMode::Dataplane); + self.standalone_conformance_compose_project() + } else { + self.conformance_compose_project(topology) + } + } + + fn routed_conformance_environment( + &self, + command: CommandSpec, + topology: StackMode, + standalone: bool, + ) -> AppResult { + if standalone { + debug_assert_eq!(topology, StackMode::Dataplane); + self.standalone_dataplane_environment(command, true) + } else { + self.compose_environment(command, topology, true) + } + } + pub(super) async fn execute_conformance(&self, action: ConformanceAction) -> AppResult<()> { match action { ConformanceAction::Run { lanes, + standalone, client_eras: _, client_versions, server_eras, @@ -287,7 +322,7 @@ impl RuntimeContext { let executor = RuntimeContext::new(self.config.clone(), quiet_runner); let matrix_started = Instant::now(); let run_result = executor - .run_conformance(&lanes, &client_version, server_era, &paths) + .run_conformance(&lanes, &client_version, server_era, &paths, standalone) .await; let run_completed = matches!( &run_result, @@ -599,12 +634,20 @@ impl RuntimeContext { spec_version: &str, server_era: ConformanceServerEra, paths: &ConformancePaths, + standalone: bool, ) -> AppResult { - self.run_conformance_with_interrupt(lanes, spec_version, server_era, paths, async { - if tokio::signal::ctrl_c().await.is_err() { - std::future::pending::<()>().await; - } - }) + self.run_conformance_with_interrupt( + lanes, + spec_version, + server_era, + paths, + standalone, + async { + if tokio::signal::ctrl_c().await.is_err() { + std::future::pending::<()>().await; + } + }, + ) .await } @@ -614,6 +657,7 @@ impl RuntimeContext { spec_version: &str, server_era: ConformanceServerEra, paths: &ConformancePaths, + standalone: bool, interrupt: I, ) -> AppResult where @@ -713,7 +757,11 @@ impl RuntimeContext { if !topologies.is_empty() && !interrupted { let cleanup_progress = Activity::spinner("Clear prior integration stacks"); - let cleanup_result = self.cleanup(LaneSelection::All, CleanupKind::Reset); + let cleanup_result = if standalone { + self.cleanup_standalone_dataplane(CleanupKind::Reset) + } else { + self.cleanup(LaneSelection::All, CleanupKind::Reset) + }; cleanup_progress.finish(cleanup_result.is_ok()); if let Err(error) = cleanup_result { failures.push(ConformanceOperationalFailure::server( @@ -728,10 +776,15 @@ impl RuntimeContext { if interrupted { break; } + let standalone_topology = standalone && topology == StackMode::Dataplane; let target = conformance_target(topology); let run_routed = lanes.contains(&target); let stack_progress = Activity::spinner(format!("Prepare {}", topology.lane_label())); - let mut topology_failure = self.stack_up_for_conformance(topology, true).await.err(); + let mut topology_failure = if standalone_topology { + self.stack_up_standalone_conformance(true).await.err() + } else { + self.stack_up_for_conformance(topology, true).await.err() + }; let stack_started = topology_failure.is_none(); stack_progress.finish(topology_failure.is_none()); let mut fixture_state = None; @@ -745,7 +798,7 @@ impl RuntimeContext { topology.lane_label() )); let (start_result, start_interrupted) = finish_phase_after_interrupt( - self.start_conformance_service(topology, server_era), + self.start_conformance_service(topology, server_era, standalone_topology), interrupt.as_mut(), ) .await; @@ -774,7 +827,7 @@ impl RuntimeContext { } } - if topology_failure.is_none() && run_routed { + if topology_failure.is_none() && run_routed && !standalone_topology { match self.admin_session_token().await.and_then(|token| { ConformanceFixtureClient::builder(self.base_url()?, token) .build() @@ -830,41 +883,62 @@ impl RuntimeContext { } if topology_failure.is_none() && run_routed { - let run_inputs = fixture_state - .as_ref() - .map(|(_, fixture)| fixture) - .zip(fixture_metadata.as_ref()); + let server_id = if standalone_topology { + Some(OFFICIAL_CONFORMANCE_SERVER_ID) + } else { + fixture_state + .as_ref() + .map(|(_, fixture)| fixture.server_id.as_str()) + }; + let run_inputs = server_id.zip(fixture_metadata.as_ref()); match run_inputs { - Some((fixture, metadata)) => match self.issue_conformance_token().await { + Some((server_id, metadata)) => match if standalone_topology { + self.standalone_conformance_token() + } else { + self.issue_conformance_token().await + } { Ok(token) => { managed_token = Some(token); let token = managed_token .as_ref() .expect("managed token was just stored"); - let tests = async { - self.run_official_conformance_mode( - &OfficialConformanceRun { - topology, - server_id: &fixture.server_id, - token: &token.value, + if standalone_topology + && let Err(error) = self + .publish_standalone_conformance_config( + server_id, spec_version, - server_era, - fixture: metadata, - cancellation: cancellation_receiver.clone(), - }, - paths, - ) - .await - .err() - }; - tokio::pin!(tests); - tokio::select! { - failure = &mut tests => topology_failure = failure, - () = interrupt.as_mut() => { - interrupted = true; - cancellation_sender.send_replace(true); - let _ = tests.await; - topology_failure = Some(interrupted_conformance_failure()); + &token.value, + ) + .await + { + topology_failure = Some(error); + } + if topology_failure.is_none() { + let tests = async { + self.run_official_conformance_mode( + &OfficialConformanceRun { + topology, + server_id, + token: &token.value, + spec_version, + server_era, + fixture: metadata, + cancellation: cancellation_receiver.clone(), + }, + paths, + ) + .await + .err() + }; + tokio::pin!(tests); + tokio::select! { + failure = &mut tests => topology_failure = failure, + () = interrupt.as_mut() => { + interrupted = true; + cancellation_sender.send_replace(true); + let _ = tests.await; + topology_failure = Some(interrupted_conformance_failure()); + } } } } @@ -878,7 +952,7 @@ impl RuntimeContext { } } - if let Some(token) = managed_token.as_ref() { + if !standalone_topology && let Some(token) = managed_token.as_ref() { topology_failure = finish_with_cleanup(topology_failure, self.revoke_managed_token(token).await) .err(); @@ -889,7 +963,9 @@ impl RuntimeContext { .cleanup(Some(&fixture)) .await .map_err(AppFailure::from); - let service_cleanup = self.stop_conformance_service(topology).await; + let service_cleanup = self + .stop_conformance_service(topology, standalone_topology) + .await; topology_failure = finish_with_cleanup( topology_failure, combine_cleanup_results(api_cleanup, service_cleanup), @@ -898,7 +974,8 @@ impl RuntimeContext { } else if service_started { topology_failure = finish_with_cleanup( topology_failure, - self.stop_conformance_service(topology).await, + self.stop_conformance_service(topology, standalone_topology) + .await, ) .err(); } @@ -906,11 +983,12 @@ impl RuntimeContext { if can_reuse_external_stack(topology, stack_started, interrupted, run_external_client) { external_stack_retained = true; } else { - topology_failure = finish_with_cleanup( - topology_failure, - self.cleanup(topology_selection(topology), CleanupKind::Down), - ) - .err(); + let cleanup = if standalone_topology { + self.cleanup_standalone_dataplane(CleanupKind::Down) + } else { + self.cleanup(topology_selection(topology), CleanupKind::Down) + }; + topology_failure = finish_with_cleanup(topology_failure, cleanup).err(); } if let Some(error) = topology_failure { failures.push(ConformanceOperationalFailure::server( @@ -932,6 +1010,7 @@ impl RuntimeContext { paths, cancellation_receiver.clone(), external_stack_retained, + standalone, ); tokio::pin!(client); tokio::select! { @@ -1049,6 +1128,7 @@ impl RuntimeContext { paths: &ConformancePaths, cancellation: tokio::sync::watch::Receiver, reuse_stack: bool, + standalone: bool, ) -> AppResult<()> { let progress = if reuse_stack { "Reuse external dataplane for client conformance" @@ -1056,21 +1136,28 @@ impl RuntimeContext { "Prepare external dataplane client conformance" }; let stack_progress = Activity::spinner(progress); - let stack_result = self - .stack_up_for_conformance(StackMode::Dataplane, !reuse_stack) - .await; + let stack_result = if standalone { + self.stack_up_standalone_conformance(!reuse_stack).await + } else { + self.stack_up_for_conformance(StackMode::Dataplane, !reuse_stack) + .await + }; stack_progress.finish(stack_result.is_ok()); let mut failure = stack_result.err(); let mut token = None; let mut publisher_stopped = false; if failure.is_none() { - match self.issue_conformance_token().await { + match if standalone { + self.standalone_conformance_token() + } else { + self.issue_conformance_token().await + } { Ok(issued) => token = Some(issued), Err(error) => failure = Some(error), } } - if failure.is_none() { + if failure.is_none() && !standalone { let progress = Activity::spinner("Pause the control-plane publisher"); let result = self.set_control_plane_publisher(false).await; progress.finish(result.is_ok()); @@ -1089,6 +1176,7 @@ impl RuntimeContext { &issued.value, paths, cancellation, + standalone, ) .await .err(); @@ -1107,13 +1195,15 @@ impl RuntimeContext { progress.finish(result.is_ok()); failure = finish_with_cleanup(failure, result).err(); } - if let Some(token) = token.as_ref() { + if !standalone && let Some(token) = token.as_ref() { failure = finish_with_cleanup(failure, self.revoke_managed_token(token).await).err(); } - finish_with_cleanup( - failure, - self.cleanup(topology_selection(StackMode::Dataplane), CleanupKind::Down), - ) + let cleanup = if standalone { + self.cleanup_standalone_dataplane(CleanupKind::Down) + } else { + self.cleanup(topology_selection(StackMode::Dataplane), CleanupKind::Down) + }; + finish_with_cleanup(failure, cleanup) } async fn set_control_plane_publisher(&self, running: bool) -> AppResult<()> { @@ -1130,6 +1220,74 @@ impl RuntimeContext { .map_err(AppFailure::from) } + fn standalone_conformance_token(&self) -> AppResult { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock is before the Unix epoch") + .map_err(AppFailure::from)? + .as_secs(); + let claims = serde_json::json!({ + "iss": "mcpgateway", + "sub": uuid::Uuid::new_v4().to_string(), + "aud": "mcpgateway-api", + "exp": now + 3_600, + "iat": now, + "jti": uuid::Uuid::new_v4().to_string(), + "token_use": "api", + "teams": [], + "user": { + "email": "cf-integration@example.invalid", + "full_name": "cf-integration standalone conformance", + "is_admin": true, + "auth_provider": "api_token" + }, + "scopes": null + }); + let secret = required_text(&self.config.jwt_secret_key().value, "JWT_SECRET_KEY")?; + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &claims, + &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()), + ) + .context("failed to sign the standalone conformance token") + .map_err(AppFailure::from)?; + Ok(ManagedBearerToken::unmanaged(token)) + } + + async fn publish_standalone_conformance_config( + &self, + server_id: &str, + protocol_version: &str, + token: &str, + ) -> AppResult<()> { + let progress = Activity::spinner("Publish mocked Redis conformance configuration"); + let command = self.standalone_conformance_compose_project().command([ + "run", + "--rm", + "--no-deps", + "-e", + CLIENT_TOKEN_ENV, + "--entrypoint", + "node", + OFFICIAL_CONFORMANCE_SERVICE, + DATAPLANE_CONFIG_WRITER, + "fixture", + server_id, + OFFICIAL_CONFORMANCE_BACKEND_URL, + protocol_version, + ]); + let command = self + .standalone_dataplane_environment(command, true)? + .env(CLIENT_TOKEN_ENV, token); + let result = self + .runner + .run_async(&command) + .await + .map_err(AppFailure::from); + progress.finish(result.is_ok()); + result + } + async fn run_official_client_conformance( &self, spec_version: &str, @@ -1137,6 +1295,7 @@ impl RuntimeContext { token: &str, paths: &ConformancePaths, cancellation: tokio::sync::watch::Receiver, + standalone: bool, ) -> AppResult<()> { let expected_scenarios = expected_client_scenarios(spec_version).map_err(AppFailure::from)?; @@ -1177,12 +1336,23 @@ impl RuntimeContext { }, )?; - let compose = self.compose_environment( + let compose_project = if standalone { + self.standalone_conformance_compose_project() + } else { self.conformance_runtime_project(StackMode::Dataplane) - .command(std::iter::empty::<&str>()), - StackMode::Dataplane, - true, - )?; + }; + let compose = if standalone { + self.standalone_dataplane_environment( + compose_project.command(std::iter::empty::<&str>()), + true, + )? + } else { + self.compose_environment( + compose_project.command(std::iter::empty::<&str>()), + StackMode::Dataplane, + true, + )? + }; let compose_args = compose .arguments() .iter() diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index 04f00ed..1290946 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -21,7 +21,7 @@ impl RuntimeContext { let quiet_runtime = RuntimeContext::new(self.config.clone(), quiet_runner); let build_progress = Activity::spinner("Building conformance image"); let build_result = quiet_runtime - .build_conformance_service(topology, DEFAULT_CONFORMANCE_SERVER_ERA) + .build_conformance_service(topology, DEFAULT_CONFORMANCE_SERVER_ERA, false) .await; build_progress.finish(build_result.is_ok()); if let Err(error) = build_result { @@ -33,7 +33,7 @@ impl RuntimeContext { return Err(error); } - self.start_conformance_containers(topology, DEFAULT_CONFORMANCE_SERVER_ERA) + self.start_conformance_containers(topology, DEFAULT_CONFORMANCE_SERVER_ERA, false) .await?; let conformance_endpoint = self.conformance_fixture_endpoint(topology)?; Activity::completed("Integration stack ready"); @@ -106,6 +106,48 @@ impl RuntimeContext { .await } + pub(super) async fn stack_up_standalone_conformance(&self, fresh: bool) -> AppResult<()> { + if !self.config.dataplane_ref().value.is_empty() { + self.ensure_dataplane()?; + } + + let build = self.resolve_standalone_dataplane_build()?; + if !build && self.config.dataplane_ref().value.is_empty() { + let platform = self.dataplane_platform()?; + self.pull_if_changed( + "cf-dataplane", + self.config.dataplane_image().resolved(), + Some(platform.as_os_str()), + self.config.dataplane_image().pull_policy(), + false, + )?; + } + if fresh { + self.cleanup_standalone_dataplane(CleanupKind::Reset)?; + } + self.ensure_other_stack_stopped(StackMode::Dataplane)?; + self.start_observability()?; + + let mut arguments = vec![ + OsString::from("up"), + OsString::from("-d"), + OsString::from("--remove-orphans"), + ]; + if build { + arguments.push(OsString::from("--build")); + } + arguments.extend( + ["redis", "dataplane", "nginx"] + .into_iter() + .map(OsString::from), + ); + let command = self.standalone_dataplane_project().command(arguments); + let command = self.standalone_dataplane_environment(command, true)?; + self.runner.run_async(&command).await?; + self.wait_for_public_endpoint(StackMode::Dataplane, false) + .await + } + pub(super) async fn stack_up_with_project( &self, mode: StackMode, @@ -270,7 +312,24 @@ impl RuntimeContext { self.environment_flag("CONTROLPLANE_ENABLE_SSO", false), ), }; - project.with_conformance_overlay(self.config.asset_root()) + project + .with_conformance_overlay(self.config.asset_root()) + .with_controlplane_conformance_overlay(self.config.asset_root()) + } + + pub(super) fn standalone_dataplane_project(&self) -> ComposeProject { + ComposeProject::standalone_dataplane( + self.config.asset_root(), + self.config.integration_project().value.clone(), + !self.config.dataplane_ref().value.is_empty(), + ) + .with_dataplane_observability(self.config.asset_root()) + .with_conformance_overlay(self.config.asset_root()) + } + + pub(super) fn standalone_conformance_compose_project(&self) -> ComposeProject { + self.standalone_dataplane_project() + .with_conformance_fixture(self.config.asset_root()) } pub(super) fn conformance_compose_project(&self, mode: StackMode) -> ComposeProject { @@ -339,6 +398,40 @@ impl RuntimeContext { checkout_labels: bool, ) -> AppResult { let controlplane_image = self.resolved_controlplane_image()?; + self.compose_environment_with_controlplane_image( + command, + mode, + checkout_labels, + controlplane_image, + ) + } + + pub(super) fn standalone_dataplane_environment( + &self, + command: CommandSpec, + checkout_labels: bool, + ) -> AppResult { + let controlplane_image = self.config.controlplane_image().resolved().to_owned(); + let command = self.compose_environment_with_controlplane_image( + command, + StackMode::Dataplane, + false, + controlplane_image, + )?; + if checkout_labels && !self.config.dataplane_ref().value.is_empty() { + self.add_dataplane_checkout_labels(command) + } else { + Ok(command) + } + } + + fn compose_environment_with_controlplane_image( + &self, + command: CommandSpec, + mode: StackMode, + checkout_labels: bool, + controlplane_image: OsString, + ) -> AppResult { let command_environment = command.environment().clone(); let mut command = if command.working_directory().is_some() { command @@ -497,25 +590,29 @@ impl RuntimeContext { .env("CF_CONTROLPLANE_CHECKOUT_REVISION", controlplane_revision) .env("CF_CONTROLPLANE_CHECKOUT_REF", controlplane_ref); if mode == StackMode::Dataplane && !self.config.dataplane_ref().value.is_empty() { - let revision = self.git_required(self.config.dataplane_dir(), ["rev-parse", "HEAD"])?; - let branch = - self.git_required(self.config.dataplane_dir(), ["branch", "--show-current"])?; - let reference = if branch.is_empty() { - self.config - .dataplane_ref() - .value - .to_string_lossy() - .into_owned() - } else { - branch - }; - command = command - .env("CF_DATAPLANE_CHECKOUT_REVISION", revision) - .env("CF_DATAPLANE_CHECKOUT_REF", reference); + command = self.add_dataplane_checkout_labels(command)?; } Ok(command) } + fn add_dataplane_checkout_labels(&self, command: CommandSpec) -> AppResult { + let revision = self.git_required(self.config.dataplane_dir(), ["rev-parse", "HEAD"])?; + let branch = + self.git_required(self.config.dataplane_dir(), ["branch", "--show-current"])?; + let reference = if branch.is_empty() { + self.config + .dataplane_ref() + .value + .to_string_lossy() + .into_owned() + } else { + branch + }; + Ok(command + .env("CF_DATAPLANE_CHECKOUT_REVISION", revision) + .env("CF_DATAPLANE_CHECKOUT_REF", reference)) + } + fn validate_compose_contract(&self) -> AppResult<()> { let command = self .compose_project(StackMode::Dataplane) @@ -591,6 +688,41 @@ impl RuntimeContext { Ok(decision.build) } + fn resolve_standalone_dataplane_build(&self) -> AppResult { + let setting = required_text(&self.config.compose_build().value, "CF_COMPOSE_BUILD")?; + let mode = + BuildMode::from_str(setting).map_err(|error| AppFailure::from(anyhow!(error)))?; + let source_ref = (!self.config.dataplane_ref().value.is_empty()).then(|| { + self.config + .dataplane_ref() + .value + .to_string_lossy() + .into_owned() + }); + let checkout_revision = if source_ref.is_some() { + Some(self.git_required(self.config.dataplane_dir(), ["rev-parse", "HEAD"])?) + } else { + None + }; + let (image_present, image_revision) = + self.image_state(self.config.dataplane_image().resolved())?; + Ok(resolve_build( + mode, + &BuildInputs { + controlplane_image_prebuilt: true, + controlplane_image_present: true, + controlplane_checkout_revision: None, + controlplane_image_revision: None, + include_dataplane: true, + dataplane_source_ref: source_ref, + dataplane_image_present: image_present, + dataplane_checkout_revision: checkout_revision, + dataplane_image_revision: image_revision, + }, + ) + .build) + } + fn image_state(&self, image: &OsStr) -> AppResult<(bool, Option)> { let image_id = if image.to_string_lossy().contains("@sha256:") { let images = self.capture_text(&CommandSpec::new("docker").args([ @@ -945,6 +1077,23 @@ impl RuntimeContext { self.cleanup_with_output(selection, kind, false) } + pub(super) fn cleanup_standalone_dataplane(&self, kind: CleanupKind) -> AppResult<()> { + let project = self + .standalone_conformance_compose_project() + .with_profiles(["conformance"]); + let command = StackCommandPlan::cleanup(project, kind); + let command = self.standalone_dataplane_environment(command.command().clone(), false)?; + let primary = self + .run_cleanup_command(&command, true) + .map_err(AppFailure::from) + .err(); + let project = required_text( + &self.config.integration_project().value, + "CF_INTEGRATION_PROJECT", + )?; + finish_with_cleanup(primary, self.remove_project_by_label(project, kind, true)) + } + fn cleanup_with_output( &self, selection: LaneSelection, diff --git a/src/runtime/stack/sources.rs b/src/runtime/stack/sources.rs index e97249c..c9d3aab 100644 --- a/src/runtime/stack/sources.rs +++ b/src/runtime/stack/sources.rs @@ -41,7 +41,7 @@ impl RuntimeContext { self.ensure_checkout(&request) } - fn ensure_dataplane(&self) -> AppResult<()> { + pub(super) fn ensure_dataplane(&self) -> AppResult<()> { let request = CheckoutRequest::dataplane( self.config.dataplane_dir(), self.config.dataplane_repo().value.clone(), From b12f0c3f531ee6333df4cf790dc28747745bc943 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 4 Sep 2026 14:40:41 +0100 Subject: [PATCH 2/7] feat: isolate external dataplane workflows Signed-off-by: lucarlig --- CHANGELOG.md | 20 +- Cargo.lock | 553 +----------------- Cargo.toml | 3 +- README.md | 252 +++----- docker/docker-compose.cf-conformance.yaml | 3 +- ...ocker-compose.cf-dataplane-standalone.yaml | 67 ++- docker/docker-compose.cf-dataplane.yaml | 3 - docker/docker-compose.cf-integration.yaml | 31 - ...ginx.cf-dataplane-standalone.conf.template | 49 ++ .../conformance/write_dataplane_config.mjs | 32 +- scripts/prepare_standalone_config.py | 128 ---- scripts/standalone/generate_auth_key.mjs | 25 + src/app.rs | 286 ++++++--- src/app_tests.rs | 98 +++- src/cli.rs | 18 +- src/cli_public_tests.rs | 70 ++- src/conformance/client.rs | 6 +- src/infrastructure/assets.rs | 6 +- src/infrastructure/compose.rs | 4 +- .../compose_integration_tests.rs | 63 +- src/infrastructure/stack.rs | 3 + src/mcp/probe.rs | 192 +++--- src/mcp/probe_tests.rs | 32 + src/mcp/protocol.rs | 1 + src/performance/locust.rs | 114 ++-- src/performance/locust_integration_tests.rs | 82 +-- src/performance/python_adapter_tests.rs | 64 +- src/runtime/conformance/mod.rs | 142 ++--- src/runtime/inspect.rs | 73 +-- src/runtime/live/mod.rs | 126 +++- src/runtime/mod.rs | 44 +- src/runtime/performance/mod.rs | 8 + src/runtime/probe.rs | 70 ++- src/runtime/session.rs | 205 +++---- src/runtime/stack/mod.rs | 209 ++++--- 35 files changed, 1464 insertions(+), 1618 deletions(-) create mode 100644 docker/nginx.cf-dataplane-standalone.conf.template delete mode 100644 scripts/prepare_standalone_config.py create mode 100644 scripts/standalone/generate_auth_key.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f31fb2b..ba13e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,18 +11,26 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Added -- Added standalone external-dataplane conformance runs backed by an ephemeral - Redis instance, a locally signed test token, and no control-plane services. +- Added global standalone external-dataplane mode across stack, probe, load, + live, conformance, and debug-token workflows, backed by mocked Redis, + ephemeral RSA authentication, and no control-plane services. ### Changed -- Published server and client conformance routes through the running - dataplane's serializer so mocked Redis snapshots always use that image's - current MessagePack schema. +- Published every standalone route through the running dataplane's serializer + so mocked Redis snapshots always use that image's current MessagePack schema. +- Added protocol-mode selection to `stack up` and native standalone live checks + for route authentication, lifecycle negotiation, and routed tool calls. +- Left the independent ClickStack service running during explicit stack cleanup + as well as managed workflow cleanup. ### Fixed - Avoided a Linux `ETXTBSY` race in the native process-runner test. +- Embedded the complete standalone and observability asset set in installed + binaries and updated standalone authentication for the dataplane JWKS model. +- Skipped unsupported external-dataplane catalog fan-out during standalone + probes and load tests while retaining routed tool-call coverage. ## [0.3.0] - 2026-09-03 @@ -44,7 +52,7 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - Reused the external conformance stack between compatible server and client phases while preserving setup, execution, and cleanup failures. - Moved ClickStack into an independent Compose lifecycle so managed test cleanup - leaves telemetry available for inspection; explicit `stack down` removes it. + leaves telemetry available for inspection. - Made ClickStack the default for non-performance workflows and an explicit opt-in for load tests to avoid skewing benchmark results. diff --git a/Cargo.lock b/Cargo.lock index 81dafe9..32b710b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,12 +64,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -145,39 +139,18 @@ dependencies = [ "tracing", ] -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -210,7 +183,6 @@ dependencies = [ "axum", "clap", "indicatif", - "jsonwebtoken", "reqwest", "serde", "serde_json", @@ -242,8 +214,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures", + "rand_core", ] [[package]] @@ -322,12 +294,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "core-foundation" version = "0.10.1" @@ -344,15 +310,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -362,78 +319,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -451,65 +336,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "encode_unicode" version = "1.0.0" @@ -538,22 +364,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -635,17 +445,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -669,21 +468,10 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core 0.10.1", + "rand_core", "wasm-bindgen", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -696,24 +484,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.4.2" @@ -1030,49 +800,12 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "11.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" -dependencies = [ - "base64", - "ed25519-dalek", - "getrandom 0.2.17", - "hmac", - "js-sys", - "p256", - "p384", - "rand 0.8.8", - "rsa", - "serde", - "serde_json", - "sha2", - "signature", - "zeroize", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libyaml-rs" version = "0.3.0" @@ -1132,51 +865,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.8", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1195,39 +883,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1240,27 +895,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.33" @@ -1282,24 +916,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1339,7 +955,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash", @@ -1381,17 +997,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.10.2" @@ -1400,26 +1005,7 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -1434,7 +1020,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -1477,16 +1063,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - [[package]] name = "ring" version = "0.17.14" @@ -1501,26 +1077,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -1654,20 +1210,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -1763,17 +1305,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - [[package]] name = "shlex" version = "2.0.1" @@ -1790,16 +1321,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "simd_cesu8" version = "1.1.1" @@ -1838,22 +1359,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2094,12 +1599,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2154,12 +1653,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "walkdir" version = "2.5.0" @@ -2421,26 +1914,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -2467,20 +1940,6 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index c1d0308..35cd8cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,9 @@ include = [ "/src/**", "/docker/**", "/scripts/locustfile_mcp.py", - "/scripts/prepare_standalone_config.py", "/scripts/live_protocol/sitecustomize.py", "/scripts/conformance/write_dataplane_config.mjs", + "/scripts/standalone/generate_auth_key.mjs", "/tests/conformance/baselines/**", "/README.md", "/CHANGELOG.md", @@ -36,7 +36,6 @@ anyhow = "1" axum = "0.8.9" clap = { version = "4.5.60", features = ["derive"] } indicatif = { version = "0.18.6", default-features = false } -jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto"] } reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index 54b69dd..3be668d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ # cf-integration -`cf-integration` runs ContextForge stacks and tests against the built-in Python -dataplane or the external Rust dataplane. It manages Docker Compose, source -checkouts, MCP probes, Locust load tests, upstream live tests, and official MCP -conformance runs. +`cf-integration` manages ContextForge Docker stacks and runs probes, load tests, +live gateway checks, and official MCP conformance. Routed traffic uses one of +two lanes: -`/servers/{virtual_host_id}/mcp` routes through `cf-dataplane`; raw `/mcp`, -UI, and API traffic route to `cf-controlplane`. The external dataplane fails -closed and never falls back to the built-in dataplane. +- `builtin`: the Python dataplane in `cf-controlplane`. +- `external`: the Rust `cf-dataplane`. -## Install and requirements +`/servers/{virtual_host_id}/mcp` routes to the external dataplane. Raw `/mcp` +and control-plane UI/API traffic route to `cf-controlplane`; there is no +fallback between them. + +## Install ```bash cargo binstall cf-integration @@ -17,188 +19,138 @@ cargo binstall cf-integration cargo install cf-integration --locked ``` -To run the current checkout, put `cargo run --` before any command: - -```bash -cargo run -- probe --lane external --protocol-version modern -``` - -Runtime requirements are Docker with Compose v2, Git, and Node.js 22.7.5 or -newer with `npx`. Load tests also need the control-plane checkout's Python and -Locust dependencies. Rust 1.97 is required only to compile the CLI or a local -source image. +Use `cargo run --` before a command when running this checkout. Runtime use +requires Docker Compose v2, Git, and Node.js 22.7.5 or newer. Rust 1.97 is +needed only to compile the CLI or a local dataplane image. -Published images are used by default. Set `CF_DATAPLANE_REF` to build an -external dataplane source ref. +Published images are the default. Set `CF_DATAPLANE_REF` to build and test a +local dataplane ref. -## Common selectors +## Selection -Wherever `--lane` is accepted, use these values: +Routed commands accept `--lane builtin|external`; `external` is the default. +Conformance and protocol-only live tests also accept `fixture-direct`. +`stack down` accepts `all`. No command accepts the old `--topology` option. -- `builtin`: Python built-in dataplane. -- `external`: external Rust dataplane. -- `fixture-direct`: reference fixture without ContextForge; available only to - conformance and `live --group protocol`. +Commands that exercise MCP accept `--protocol-version modern|legacy`: -Routed commands default to `CF_MCP_LANE`, then `external`, and run one lane -at a time. Run them once per lane when comparing `builtin` and `external`. -`stack down` also accepts `all`; conformance accepts repeated `--lane` -options. No command accepts `--topology`. +- `modern`: current per-request, stateless MCP. +- `legacy`: current initialization-based MCP. -`probe`, `load`, `live`, and `debug inspect` accept -`--protocol-version modern|legacy`: +The CLI deliberately does not expose dated wire revisions. Defaults may be set +with `CF_MCP_LANE` and `MCP_PROTOCOL_VERSION`. -- `modern`: latest per-request, stateless MCP revision. -- `legacy`: latest initialization-based MCP revision. +Add the global `--standalone` flag to run the external lane without any control +plane. Standalone mode starts Redis, the Rust dataplane, nginx, and the required +test fixture. It generates an ephemeral RSA key, obtains a test token from the +dataplane's local tool endpoint, validates it through the dataplane's loopback +JWKS endpoint, and publishes a fresh config through the dataplane serializer. +Redis therefore always contains the schema understood by the image under test. -The default is `MCP_PROTOCOL_VERSION`, then `modern`. Dated revisions are -internal wire values, not operational CLI options. +Use `cf-integration --help` for the complete interface. -Use `--help` at any level for the authoritative interface, such as -`cf-integration stack --help` or `cf-integration load --help`. +## Stack -## Commands +Use a persistent stack for manual testing: -Test workflows prepare and clean up their required stack. Use `stack` when you -want a persistent stack for manual work. +```bash +cf-integration stack up --lane builtin --protocol-version modern +cf-integration stack up --lane external --protocol-version legacy --fresh +cf-integration stack up --lane external --protocol-version legacy --standalone -### `stack` +cf-integration stack status --lane external --standalone +cf-integration stack logs --lane external --standalone +cf-integration stack logs --lane external --standalone dataplane nginx +cf-integration stack config --lane external --standalone -```bash -# Start one lane -cf-integration stack up --lane builtin -cf-integration stack up --lane external --fresh - -# Inspect one lane -cf-integration stack status --lane external -cf-integration stack logs --lane external -cf-integration stack logs --lane external nginx -cf-integration stack config --lane external - -# Stop one or both lanes -cf-integration stack down --lane builtin +cf-integration stack down --lane external --standalone cf-integration stack down --lane all cf-integration stack down --lane all --volumes ``` -`up --fresh` removes existing volumes before starting. `logs` follows all -services unless service names are supplied. `config` prints merged Compose -configuration. `down --volumes` also removes persistent volumes. - -ClickStack starts by default for `stack`, `probe`, `live`, `conformance`, and -`debug inspect`. Open the no-login HyperDX UI at to -inspect traces and metrics. Managed test cleanup leaves ClickStack running, so -the UI remains available after a command finishes; `stack down --lane all` -removes it. The external dataplane exports the exact -HTTP counters, latency histograms, in-flight gauge, and body sizes recorded by -its `HttpMetricsLayer`. HyperDX opens on live application logs. For metrics, -open **Chart Explorer**, select the **Metrics** data source and a metric, then -click **Run**. Run a load for at least 60 seconds so its 30-second cumulative -export interval produces the two samples needed for a non-zero chart. All -telemetry storage is ephemeral and disappears when ClickStack is removed. +`up --fresh` and `down --volumes` remove the selected stack's volumes. +ClickStack has an independent lifecycle and is intentionally left running by +all stack and managed-test cleanup. -### `probe` +## Probe -Probe one public MCP route, including discovery or initialization, -`tools/list`, a safe `tools/call`, authentication, and backend identity. +Probe authentication, protocol lifecycle, backend identity, catalog selection, +and a safe routed tool call: ```bash -cf-integration probe [--lane builtin|external] \ - [--protocol-version modern|legacy] +cf-integration probe --lane builtin --protocol-version modern +cf-integration probe --lane external --protocol-version legacy +cf-integration probe --lane external --protocol-version legacy --standalone ``` -### `load` +For standalone external runs, the known catalog comes from the mocked Redis +snapshot because the Rust dataplane intentionally does not implement fan-out +`tools/list`. -Run Locust against one public MCP route: +## Load ```bash -cf-integration load [--lane builtin|external] \ - [--protocol-version modern|legacy] [--standalone] [--smoke] \ - [--observability] [--users N] [--spawn-rate N] [--run-time DURATION] - # Compare both lanes for two minutes cf-integration load --lane builtin --protocol-version legacy \ --users 10 --spawn-rate 2 --run-time 2m cf-integration load --lane external --protocol-version legacy \ --users 10 --spawn-rate 2 --run-time 2m -# Measure only the external dataplane request path -cargo run -- load --lane external --protocol-version legacy --standalone \ +# Isolate the external dataplane and mocked Redis +cf-integration load --lane external --protocol-version legacy --standalone \ --users 10 --spawn-rate 2 --run-time 2m -# Inspect traces and native HTTP metrics while using the mocked Redis snapshot -cargo run -- load --lane external --protocol-version legacy --standalone \ +# Include telemetry when diagnostic value matters more than benchmark purity +cf-integration load --lane external --protocol-version modern --standalone \ --observability --users 10 --spawn-rate 2 --run-time 2m ``` -`--smoke` selects a short smoke workload. Duration accepts positive `h`, -`m`, and `s` groups such as `2m30s` or `1h30m`. Defaults come from -`LOCUST_USERS`, `LOCUST_SPAWN_RATE`, and `LOCUST_RUN_TIME`. -Observability is disabled for load tests by default to avoid skewing -performance results; pass `--observability` when diagnostics are more important -than an uncontaminated benchmark. - -`--standalone` is valid only with `--lane external`. Each run starts the full -stack to issue a scoped token, starts an isolated current-protocol MCP fixture, -then stops the control-plane gateway before Locust begins. A fresh mock config -for the token subject is written through the running dataplane's own serializer -on every run, so Redis receives the dataplane's current MessagePack schema. The -snapshot is non-expiring for the load duration; traffic does not depend on the -control-plane publisher or its schema-sync timing. +`--smoke` selects a short workload. Durations accept ordered positive `h`, `m`, +and `s` groups such as `2m30s`. Defaults are `100` users, `10` users/s, and +`5m`, overridable with `LOCUST_USERS`, `LOCUST_SPAWN_RATE`, and +`LOCUST_RUN_TIME`. Observability is opt-in for load tests to avoid skew. -### `live` +## Live gateway checks -Run the managed upstream control-plane test groups: `mcp` for Fast Time MCP -routes, `rbac` for authorization and transports, `protocol` for -protocol-specific behavior, or `all` (the default). +Groups are `mcp`, `rbac`, `protocol`, and `all` (default): ```bash -cf-integration live [--lane fixture-direct|builtin|external] \ - [--protocol-version modern|legacy] [--group mcp|rbac|protocol|all] - cf-integration live --lane builtin --protocol-version legacy --group all -cf-integration live --lane fixture-direct \ - --protocol-version legacy --group protocol +cf-integration live --lane external --protocol-version modern --group all +cf-integration live --lane external --protocol-version legacy --group all --standalone +cf-integration live --lane fixture-direct --protocol-version legacy --group protocol ``` -### `conformance` +Normal routed runs execute the upstream control-plane live suites. Standalone +external runs execute the dataplane-native route/auth/protocol contract, with +no control-plane checkout or API calls. + +## Conformance -`run` executes the pinned official suite and compares it with checked-in -baselines. With no options it runs all three lanes using a modern client against -legacy and modern fixture servers. +`run` executes the pinned official MCP suite and compares results with checked-in +baselines. With no selectors it runs all three lanes with a modern client +against legacy and modern fixtures. ```bash cf-integration conformance run -# Repeat selectors to build a matrix cf-integration conformance run \ --lane fixture-direct --lane builtin --lane external \ --client-era legacy --client-era modern \ --server-era legacy --server-era modern -# Replace selected baselines only after every selected run succeeds -cf-integration conformance run --server-era dual --bless - -# Test only the external dataplane with mocked Redis and no control plane cf-integration conformance run --lane external --standalone \ --client-era modern --server-era modern -# Refresh the external dataplane baselines using the same isolated stack cf-integration conformance run --lane external --standalone \ --client-era modern --server-era modern --bless ``` `--client-era` and `--server-era` accept `legacy`, `modern`, or `dual`. -`--results-dir`, `--baseline-dir`, and `--output-dir` override artifact -locations. `--standalone` requires `--lane external` as the only lane. It -starts only Redis, the external dataplane, nginx, ClickStack, and the pinned -fixture; the control plane is neither started nor used. The CLI signs a -one-hour test token locally and publishes every server/client route through the -running dataplane's admin serializer, so each ephemeral Redis snapshot uses the -tested image's current MessagePack schema. +`--bless` replaces only the selected baselines and only after every selected +run succeeds. `--standalone` permits the external lane only. -`report` regenerates Markdown comparisons from existing results without -running the suite: +Regenerate Markdown from existing results without running tests: ```bash cf-integration conformance report @@ -206,46 +158,36 @@ cf-integration conformance report \ --results-dir .integration/conformance --output-dir reports/conformance ``` -### `debug` - -`inspect` runs an MCP Inspector method against one routed lane. The method -defaults to `tools/list`, and the server defaults to the Fast Time fixture. +## Debug ```bash cf-integration debug inspect --lane external \ --protocol-version modern --method tools/list cf-integration debug inspect --lane builtin \ --protocol-version legacy --server-id -``` -`token` prints a token from an already-running control plane. `scoped` -creates the minimum catalog token used by public MCP tests; `admin` creates a -platform-admin session token. `--server-id` is valid only for `scoped`. - -```bash cf-integration debug token --kind scoped cf-integration debug token --kind scoped --server-id cf-integration debug token --kind admin -``` -## Configuration and artifacts +# Issue a token from an already-running standalone external stack +cf-integration debug token --kind scoped --standalone +``` -Copy `.env.example` to `.env`; process environment values override it. +`inspect` uses the official MCP Inspector. Control-plane tokens are revoked when +the workflow owns them; caller-supplied `MCPGATEWAY_BEARER_TOKEN` values are +never revoked. -| Variable | Purpose | Default | -| --- | --- | --- | -| `CF_MCP_LANE` | Routed lane | `external` | -| `MCP_PROTOCOL_VERSION` | Protocol mode | `modern` | -| `CF_INTEGRATION_DIR` | Checkouts, state, and load reports | `.integration` | -| `CF_DATAPLANE_REF` | Optional local dataplane Git ref | unset | -| `LOCUST_*` | Users, spawn rate, and duration | `100`, `10`, `5m` | +## Observability and artifacts -See [`.env.example`](.env.example) for every setting. Missing Compose secrets -are generated under `CF_INTEGRATION_DIR`. Workflow-created tokens are revoked -during cleanup; a caller-supplied `MCPGATEWAY_BEARER_TOKEN` is never revoked. +ClickStack starts by default for stack, probe, live, conformance, and Inspector +workflows. Open the no-login HyperDX UI at . Logs open by +default; for metrics use **Chart Explorer**, choose the **Metrics** source and a +metric such as `http.server.request.duration`, then run the query. Allow at +least 60 seconds of traffic for multiple 30-second cumulative exports. +Telemetry storage is ephemeral inside ClickStack. -Installed binaries embed their runtime assets. Set `CF_INTEGRATION_ROOT` to -force a developer checkout. Load reports default below -`CF_INTEGRATION_DIR/reports/load`; conformance results below -`CF_INTEGRATION_DIR/conformance`; and conformance Markdown below -`reports/conformance`. +Load reports are written below `CF_INTEGRATION_DIR/reports/load`, conformance +results below `CF_INTEGRATION_DIR/conformance`, and comparison Markdown below +`reports/conformance`. Copy [`.env.example`](.env.example) for the complete +configuration list. Process environment values override `.env`. diff --git a/docker/docker-compose.cf-conformance.yaml b/docker/docker-compose.cf-conformance.yaml index 2551693..9209f1e 100644 --- a/docker/docker-compose.cf-conformance.yaml +++ b/docker/docker-compose.cf-conformance.yaml @@ -2,8 +2,7 @@ services: mcp_conformance_server: networks: - mcpnet - volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_dataplane_config.mjs:/opt/contextforge-conformance/write_dataplane_config.mjs:ro + mcp_conformance_proxy: profiles: ["conformance"] image: nginx:1.30.4-alpine3.24 diff --git a/docker/docker-compose.cf-dataplane-standalone.yaml b/docker/docker-compose.cf-dataplane-standalone.yaml index 19c2151..1efe3fc 100644 --- a/docker/docker-compose.cf-dataplane-standalone.yaml +++ b/docker/docker-compose.cf-dataplane-standalone.yaml @@ -1,8 +1,22 @@ -# Minimal external-dataplane stack for conformance runs. The control plane is -# deliberately absent: the harness signs an ephemeral token and asks the -# dataplane to serialize the mocked per-user routing snapshot into Redis. +# Minimal external-dataplane stack for standalone workflows. The control plane +# is deliberately absent: the dataplane issues a test token from an ephemeral +# RSA key and serializes the mocked per-user routing snapshot into Redis. services: + auth_keygen: + image: node:22-bookworm-slim + labels: + name: cf-dataplane-auth-keygen + restart: "no" + network_mode: none + volumes: + - standalone_auth:/keys + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/standalone/generate_auth_key.mjs:/opt/contextforge-integration/generate_auth_key.mjs:ro + command: + - node + - /opt/contextforge-integration/generate_auth_key.mjs + - /keys/jwt.key + redis: image: redis:8.2.8-alpine3.22 labels: @@ -40,19 +54,27 @@ services: - host.docker.internal:host-gateway expose: - "4445" + command: + - --token-verification-private-key + - /keys/jwt.key + volumes: + - standalone_auth:/keys:ro environment: CONTEXTFORGE_DATA_PLANE_ADDRESS: 0.0.0.0:4445 CONTEXTFORGE_DATA_PLANE_REDIS_HOSTNAME: redis CONTEXTFORGE_DATA_PLANE_REDIS_PORT: "6379" CONTEXTFORGE_DATA_PLANE_REDIS_CONNECTION_MODE: plain-text - CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET: ${JWT_SECRET_KEY:-my-test-key-but-now-longer-than-32-bytes} - CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY: /dev/null + # The current dataplane validates test tokens against its own + # loopback-only JWKS tool endpoint, keeping auth entirely local. + CONTEXTFORGE_DATA_PLANE_JWKS_URL: http://127.0.0.1:4445/contextforge-rs/admin/.well-known/jwks.json CONTEXTFORGE_DATA_PLANE_UPSTREAM_CONNECTION_MODE: plain-text-or-tls CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS: ${CF_DATAPLANE_MCP_ALLOWED_HOSTS:-127.0.0.1:${NGINX_PORT:-8080},localhost:${NGINX_PORT:-8080},nginx} CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS: ${CF_DATAPLANE_MCP_ALLOWED_ORIGINS:-http://127.0.0.1:${NGINX_PORT:-8080},http://localhost:${NGINX_PORT:-8080}} CONTEXTFORGE_DATA_PLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS: "0" RUST_LOG: ${CF_DATAPLANE_LOG:-info} depends_on: + auth_keygen: + condition: service_completed_successfully redis: condition: service_healthy @@ -63,19 +85,50 @@ services: restart: "no" ports: - "${NGINX_PORT:-8080}:80" + environment: + MCP_SERVER_ID: ${MCP_SERVER_ID:-9779b6698cbd4b4995ee04a4fab38737} volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/docker/nginx.cf-dataplane.conf:/etc/nginx/nginx.conf:ro + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/docker/nginx.cf-dataplane-standalone.conf.template:/etc/nginx/templates/default.conf.template:ro networks: - mcpnet depends_on: dataplane: condition: service_started healthcheck: - test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/nginx_status >/dev/null"] + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health >/dev/null"] interval: 2s timeout: 2s retries: 30 start_period: 2s + config_writer: + profiles: ["helpers"] + image: node:22-bookworm-slim + labels: + name: cf-dataplane-config-writer + restart: "no" + networks: + - mcpnet + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_dataplane_config.mjs:/opt/contextforge-integration/write_dataplane_config.mjs:ro + entrypoint: + - node + - /opt/contextforge-integration/write_dataplane_config.mjs + + locust: + profiles: ["performance"] + image: locustio/locust:2.46.2 + labels: + name: cf-locust + restart: "no" + user: "${HOST_UID:-1000}:${HOST_GID:-1000}" + networks: + - mcpnet + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/locustfile_mcp.py:/mnt/locust-cf/locustfile_mcp.py:ro + networks: mcpnet: + +volumes: + standalone_auth: diff --git a/docker/docker-compose.cf-dataplane.yaml b/docker/docker-compose.cf-dataplane.yaml index eafa479..ef92be8 100644 --- a/docker/docker-compose.cf-dataplane.yaml +++ b/docker/docker-compose.cf-dataplane.yaml @@ -27,9 +27,6 @@ services: # Requires a control-plane image with configurable publisher interval; # older images ignore the variable (60s behavior). DATAPLANE_PUBLISHER_INTERVAL_SECONDS: ${CF_DATAPLANE_PUBLISHER_INTERVAL_SECONDS:-2} - volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/prepare_standalone_config.py:/opt/contextforge-integration/prepare_standalone_config.py:ro - dataplane: image: ${CF_DATAPLANE_IMAGE:?Set CF_DATAPLANE_IMAGE to the cf-dataplane image tag} pull_policy: ${CF_DATAPLANE_PULL_POLICY:-always} diff --git a/docker/docker-compose.cf-integration.yaml b/docker/docker-compose.cf-integration.yaml index 1ca0515..026052e 100644 --- a/docker/docker-compose.cf-integration.yaml +++ b/docker/docker-compose.cf-integration.yaml @@ -12,37 +12,6 @@ services: register_fast_time: condition: service_completed_successfully - # A current-protocol upstream for standalone dataplane load tests. The - # profile keeps it out of normal stack, live, and probe workflows. - standalone_load_backend: - profiles: ["standalone-load"] - image: cf-integration/mcp-conformance-server:0.2.0-alpha.11 - build: - context: ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root} - dockerfile: docker/mcp-conformance-server.Dockerfile - labels: - name: cf-standalone-load-backend - restart: "no" - environment: - PORT: "3000" - MCP_CONFORMANCE_SERVER_ERA: modern - expose: - - "3000" - networks: - mcpnet: - aliases: - - mcp_conformance_server - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:3000/mcp').then(response => { if (response.status !== 400) process.exit(1); }).catch(() => process.exit(1)) - interval: 2s - timeout: 2s - retries: 30 - start_period: 2s - locust: volumes: # Harness locustfile with streamable-HTTP content negotiation; the diff --git a/docker/nginx.cf-dataplane-standalone.conf.template b/docker/nginx.cf-dataplane-standalone.conf.template new file mode 100644 index 0000000..37ec5e8 --- /dev/null +++ b/docker/nginx.cf-dataplane-standalone.conf.template @@ -0,0 +1,49 @@ +server { + listen 80; + server_name localhost; + resolver 127.0.0.11 valid=10s ipv6=off; + + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Authorization $http_authorization; + proxy_set_header Mcp-Session-Id $http_mcp_session_id; + proxy_set_header Mcp-Protocol-Version $http_mcp_protocol_version; + proxy_set_header Mcp-Method $http_mcp_method; + proxy_set_header Mcp-Name $http_mcp_name; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header Connection ""; + proxy_hide_header X-CF-Integration-Backend; + + location = /health { + default_type application/json; + add_header X-ContextForge-MCP-Transport-Mounted rust always; + return 200 '{"status":"ok","mode":"standalone"}'; + } + + location ~ ^/mcp/?$ { + proxy_pass http://dataplane:4445/contextforge-rs/servers/${MCP_SERVER_ID}/mcp$is_args$args; + proxy_request_buffering on; + proxy_buffering off; + proxy_cache off; + add_header X-Accel-Buffering "no" always; + add_header X-CF-Integration-Backend dataplane always; + proxy_connect_timeout 30s; + proxy_send_timeout 1h; + proxy_read_timeout 1h; + } + + location ~ ^/servers/([^/]+)/mcp/?$ { + proxy_pass http://dataplane:4445/contextforge-rs/servers/$1/mcp$is_args$args; + proxy_request_buffering on; + proxy_buffering off; + proxy_cache off; + add_header X-Accel-Buffering "no" always; + add_header X-CF-Integration-Backend dataplane always; + proxy_connect_timeout 30s; + proxy_send_timeout 1h; + proxy_read_timeout 1h; + } +} diff --git a/scripts/conformance/write_dataplane_config.mjs b/scripts/conformance/write_dataplane_config.mjs index 232ae06..ff59ea1 100644 --- a/scripts/conformance/write_dataplane_config.mjs +++ b/scripts/conformance/write_dataplane_config.mjs @@ -3,6 +3,8 @@ const DATAPLANE_CONFIG_URL = 'http://dataplane:4445/contextforge-rs/admin/userconfigs'; +const DATAPLANE_TOKEN_URL = + 'http://dataplane:4445/contextforge-rs/admin/tokens'; const FIXTURE_TOOLS = [ 'test_simple_text', 'test_image_content', @@ -131,11 +133,36 @@ async function publish(subject, body) { fail(`dataplane config serializer was unavailable: ${lastError}`); } +async function issueToken(tenantId, userId) { + const endpoint = `${DATAPLANE_TOKEN_URL}/${encodeURIComponent(tenantId)}/${encodeURIComponent(userId)}`; + let lastError = 'dataplane did not respond'; + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + const response = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }); + const body = await response.text(); + if (response.ok && body.split('.').length === 3) return body; + lastError = `HTTP ${response.status}: ${body.slice(0, 512)}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + fail(`dataplane token helper was unavailable: ${lastError}`); +} + async function main() { - const [mode, serverId, backendUrl, protocolVersion, toolNamesJson] = process.argv.slice(2); + const [mode, ...args] = process.argv.slice(2); + if (mode === 'token') { + const [tenantId, userId] = args; + if (!tenantId) fail('tenant-id must not be empty'); + if (!userId) fail('user-id must not be empty'); + process.stdout.write(`${await issueToken(tenantId, userId)}\n`); + return; + } if (!['fixture', 'client'].includes(mode)) { - fail('mode must be fixture or client'); + fail('mode must be token, fixture, or client'); } + const [serverId, backendUrl, protocolVersion, toolNamesJson] = args; if (!serverId) fail('virtual-host-id must not be empty'); try { const parsed = new URL(backendUrl); @@ -164,6 +191,7 @@ async function main() { tokenSubject(token), config(serverId, backendUrl, protocolVersion, catalogs), ); + process.stdout.write(`${JSON.stringify(catalogs.tools)}\n`); } await main(); diff --git a/scripts/prepare_standalone_config.py b/scripts/prepare_standalone_config.py deleted file mode 100644 index 456d17d..0000000 --- a/scripts/prepare_standalone_config.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -"""Publish an isolated load fixture through the dataplane's own serializer.""" - -from __future__ import annotations - -import base64 -import json -import os -import sys -from urllib.error import HTTPError, URLError -from urllib.parse import quote -from urllib.request import Request, urlopen - -DATAPLANE_CONFIG_URL = ( - "http://dataplane:4445/contextforge-rs/admin/userconfigs/{subject}" -) -BACKEND_URL = "http://mcp_conformance_server:3000/mcp" -BACKEND_NAME = "standalone-load" -TOOL_NAMES = ["test_simple_text"] - - -def token_subject(token: str) -> str: - parts = token.split(".") - if len(parts) != 3: - raise SystemExit("MCPGATEWAY_BEARER_TOKEN is not a JWT") - payload = parts[1] + ("=" * (-len(parts[1]) % 4)) - try: - claims = json.loads(base64.urlsafe_b64decode(payload)) - except (ValueError, json.JSONDecodeError) as error: - raise SystemExit("MCPGATEWAY_BEARER_TOKEN has invalid claims") from error - subject = claims.get("sub") - if not isinstance(subject, str) or not subject: - raise SystemExit("MCPGATEWAY_BEARER_TOKEN has no string subject") - return subject - - -def prepare_config(server_id: str, protocol_version: str) -> dict: - if not server_id: - raise SystemExit("virtual-host-id must not be empty") - routes = { - name: {"backend_name": BACKEND_NAME, "upstream_name": name} - for name in TOOL_NAMES - } - return { - "virtual_hosts": { - server_id: { - "backends": { - BACKEND_NAME: { - "name": BACKEND_NAME, - "url": BACKEND_URL, - "mcp_protocol_version": protocol_version, - "passthrough_headers": [], - "add_headers": {}, - "remove_headers": [], - "tool_name_aliases": [ - { - "downstream_prefixed_name": name, - "upstream_name": name, - } - for name in TOOL_NAMES - ], - "resource_uri_aliases": [], - "prompt_name_aliases": [], - "completion": {}, - "tool_schemas": {name: {} for name in TOOL_NAMES}, - } - }, - # Published dataplane images can trail the control-plane schema. - # The dataplane serializer ignores fields it does not understand, - # so publish both routing shapes while the two releases overlap. - "tools": routes, - "resources": {}, - "resource_templates": {}, - "prompts": {}, - } - } - } - - -def publish_config(subject: str, config: dict) -> None: - endpoint = DATAPLANE_CONFIG_URL.format(subject=quote(subject, safe="")) - request = Request( - endpoint, - data=json.dumps(config).encode(), - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urlopen(request, timeout=30) as response: - if response.status != 202: - raise SystemExit( - f"dataplane config serializer returned HTTP {response.status}" - ) - except HTTPError as error: - detail = error.read(512).decode(errors="replace").strip() - raise SystemExit( - f"dataplane config serializer returned HTTP {error.code}: {detail}" - ) from error - except URLError as error: - raise SystemExit(f"dataplane config serializer is unavailable: {error.reason}") from error - - -def main() -> None: - import msgpack - import redis - - if len(sys.argv) != 3: - raise SystemExit( - "usage: prepare_standalone_config.py " - ) - server_id, protocol_version = sys.argv[1:] - token = os.environ.get("MCPGATEWAY_BEARER_TOKEN", "") - if not token: - raise SystemExit("MCPGATEWAY_BEARER_TOKEN is required") - subject = token_subject(token) - key = msgpack.dumps(("UserConfig", subject), use_bin_type=True) - client = redis.Redis.from_url( - os.environ.get("REDIS_URL", "redis://redis:6379/0"), - decode_responses=False, - ) - publish_config(subject, prepare_config(server_id, protocol_version)) - if client.ttl(key) != -1: - raise SystemExit("dataplane serializer did not persist the Redis snapshot") - print(json.dumps(TOOL_NAMES, separators=(",", ":"))) - - -if __name__ == "__main__": - main() diff --git a/scripts/standalone/generate_auth_key.mjs b/scripts/standalone/generate_auth_key.mjs new file mode 100644 index 0000000..70e6c86 --- /dev/null +++ b/scripts/standalone/generate_auth_key.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/** Generate the ephemeral RSA key used by the standalone dataplane stack. */ + +import { generateKeyPairSync } from 'node:crypto'; +import { chmodSync, existsSync, writeFileSync } from 'node:fs'; + +const [outputPath] = process.argv.slice(2); +if (!outputPath) { + process.stderr.write('output-path is required\n'); + process.exit(1); +} + +if (existsSync(outputPath)) { + chmodSync(outputPath, 0o600); + process.exit(0); +} + +const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}); + +writeFileSync(outputPath, privateKey, { encoding: 'utf8', mode: 0o600 }); +chmodSync(outputPath, 0o600); diff --git a/src/app.rs b/src/app.rs index 4940cfc..598d4d0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,11 +27,13 @@ pub(crate) enum Action { Stack(StackAction), Probe { topology: StackMode, + standalone: bool, protocol_version: ProtocolVersion, }, Load(ResolvedLoadArgs), Live { lane: SemanticLane, + standalone: bool, group: LiveGroup, protocol_version: ProtocolVersion, }, @@ -47,9 +49,9 @@ impl Action { match self { Self::Stack(StackAction::Up { .. }) => "stack up", Self::Stack(StackAction::Down { .. }) => "stack down", - Self::Stack(StackAction::Status(_)) => "stack status", + Self::Stack(StackAction::Status { .. }) => "stack status", Self::Stack(StackAction::Logs { .. }) => "stack logs", - Self::Stack(StackAction::Config(_)) => "stack config", + Self::Stack(StackAction::Config { .. }) => "stack config", Self::Probe { .. } => "probe", Self::Load(_) => "load test", Self::Live { .. } => "live tests", @@ -70,18 +72,18 @@ impl Action { Self::Stack(action) => action.startup_summary(), Self::Probe { topology, + standalone, protocol_version, } | Self::Debug(DebugAction::Inspect { topology, + standalone, protocol_version, .. - }) => lane_and_protocol(*topology, protocol_version), + }) => target_summary(*topology, *standalone, protocol_version), Self::Load(args) => { - let mut summary = lane_and_protocol(args.topology, &args.protocol_version); - if args.standalone { - summary.push_str("\nControl plane: disabled during load"); - } + let mut summary = + target_summary(args.topology, args.standalone, &args.protocol_version); if args.observability { summary.push_str("\nObservability: ClickStack enabled during load"); } @@ -89,12 +91,17 @@ impl Action { } Self::Live { lane, + standalone, protocol_version, .. - } => format!( - "Lane: {}\nProtocol version: {protocol_version}", - lane.label() - ), + } => { + let mut summary = format!( + "Lane: {}\nProtocol version: {protocol_version}", + lane.label() + ); + append_standalone_summary(&mut summary, *standalone); + summary + } Self::Conformance(ConformanceAction::Run { lanes, standalone, @@ -116,8 +123,12 @@ impl Action { Self::Conformance(ConformanceAction::Report { .. }) => String::from( "Lane: recorded conformance results\nClient era: recorded conformance results\nServer era: recorded conformance results", ), - Self::Debug(DebugAction::Token { .. }) => { - String::from("Lane: not applicable (token only)") + Self::Debug(DebugAction::Token { standalone, .. }) => { + if *standalone { + String::from("Lane: external\nControl plane: disabled; Redis config: mocked") + } else { + String::from("Lane: not applicable (token only)") + } } Self::Ci(CiAction::PrepareImage { .. }) => { String::from("CI operation: prepare prebuilt image") @@ -143,12 +154,11 @@ impl Action { /// Returns whether this operation needs Compose overlays or runtime scripts. #[must_use] pub(crate) const fn requires_runtime_assets(&self) -> bool { - !matches!( - self, - Self::Conformance(ConformanceAction::Report { .. }) - | Self::Debug(DebugAction::Token { .. }) - | Self::Ci(_) - ) + match self { + Self::Conformance(ConformanceAction::Report { .. }) | Self::Ci(_) => false, + Self::Debug(DebugAction::Token { standalone, .. }) => *standalone, + _ => true, + } } } @@ -156,9 +166,9 @@ impl StackAction { fn startup_summary(&self) -> String { let lane = match self { Self::Up { topology, .. } - | Self::Status(topology) + | Self::Status { topology, .. } | Self::Logs { topology, .. } - | Self::Config(topology) => topology.lane_label().to_owned(), + | Self::Config { topology, .. } => topology.lane_label().to_owned(), Self::Down { lane, .. } => match lane { LaneSelection::Builtin => StackMode::Controlplane.lane_label().to_owned(), LaneSelection::External => StackMode::Dataplane.lane_label().to_owned(), @@ -169,22 +179,41 @@ impl StackAction { ), }, }; - if matches!(self, Self::Up { .. }) { - format!( - "Lane: {lane}\nProtocol version: {}", - ProtocolVersion::default() - ) - } else { - format!("Lane: {lane}") - } + let mut summary = match self { + Self::Up { + protocol_version, .. + } => format!("Lane: {lane}\nProtocol version: {protocol_version}"), + _ => format!("Lane: {lane}"), + }; + let standalone = match self { + Self::Up { standalone, .. } + | Self::Down { standalone, .. } + | Self::Status { standalone, .. } + | Self::Logs { standalone, .. } + | Self::Config { standalone, .. } => *standalone, + }; + append_standalone_summary(&mut summary, standalone); + summary } } -fn lane_and_protocol(topology: StackMode, protocol_version: &ProtocolVersion) -> String { - format!( +fn target_summary( + topology: StackMode, + standalone: bool, + protocol_version: &ProtocolVersion, +) -> String { + let mut summary = format!( "Lane: {}\nProtocol version: {protocol_version}", topology.lane_label() - ) + ); + append_standalone_summary(&mut summary, standalone); + summary +} + +fn append_standalone_summary(summary: &mut String, standalone: bool) { + if standalone { + summary.push_str("\nControl plane: disabled; Redis config: mocked"); + } } fn join_lane_labels(lanes: &[SemanticLane]) -> String { @@ -223,18 +252,28 @@ fn join_server_eras(server_eras: &[ConformanceServerEra]) -> String { pub(crate) enum StackAction { Up { topology: StackMode, + protocol_version: ProtocolVersion, fresh: bool, + standalone: bool, }, Down { lane: LaneSelection, volumes: bool, + standalone: bool, + }, + Status { + topology: StackMode, + standalone: bool, }, - Status(StackMode), Logs { topology: StackMode, services: Vec, + standalone: bool, + }, + Config { + topology: StackMode, + standalone: bool, }, - Config(StackMode), } /// Fully resolved load-test options. @@ -272,6 +311,7 @@ pub(crate) enum ConformanceAction { pub(crate) enum DebugAction { Inspect { topology: StackMode, + standalone: bool, protocol_version: ProtocolVersion, method: String, server_id: Option, @@ -279,6 +319,7 @@ pub(crate) enum DebugAction { Token { kind: TokenKind, server_id: Option, + standalone: bool, }, } @@ -306,12 +347,17 @@ pub(crate) enum CiAction { /// Returns an error when a command needs `CF_MCP_LANE` and its value is neither /// `builtin` nor `external`. pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result { + let standalone = cli.standalone; match cli.command { - Command::Stack(args) => resolve_stack(args.command, environment).map(Action::Stack), + Command::Stack(args) => { + resolve_stack(args.command, environment, standalone).map(Action::Stack) + } Command::Probe(args) => { let topology = resolve_lane(args.lane, environment)?; + validate_standalone_lane(standalone, topology)?; Ok(Action::Probe { topology, + standalone, protocol_version: resolve_protocol_version( args.protocol_version, environment, @@ -321,9 +367,7 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result { let topology = resolve_lane(args.target.lane, environment)?; - if args.standalone && topology != StackMode::Dataplane { - bail!("--standalone requires --lane external"); - } + validate_standalone_lane(standalone, topology)?; Ok(Action::Load(ResolvedLoadArgs { topology, protocol_version: resolve_protocol_version( @@ -331,7 +375,7 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Result { let lane = resolve_live_lane(args.target.lane, environment)?; + if standalone && lane != SemanticLane::ExternalDataPlane { + bail!("--standalone requires --lane external"); + } if lane == SemanticLane::FixtureDirect && args.group != LiveGroup::Protocol { bail!("--lane fixture-direct requires --group protocol"); } Ok(Action::Live { lane, + standalone, group: args.group, protocol_version: resolve_protocol_version( args.target.protocol_version, @@ -359,13 +407,17 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Ok(Action::Conformance(match args.command { ConformanceCommand::Run(args) => { let (client_eras, client_versions) = resolve_client_eras(args.client_era); - let lanes = resolve_lanes(args.lane.into_iter().map(Into::into)); - if args.standalone && lanes != [SemanticLane::ExternalDataPlane] { + let lanes = if standalone && args.lane.is_empty() { + vec![SemanticLane::ExternalDataPlane] + } else { + resolve_lanes(args.lane.into_iter().map(Into::into)) + }; + if standalone && lanes != [SemanticLane::ExternalDataPlane] { bail!("--standalone requires --lane external as the only lane"); } ConformanceAction::Run { lanes, - standalone: args.standalone, + standalone, client_eras, client_versions, server_eras: resolve_server_eras(args.server_era), @@ -375,16 +427,23 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result ConformanceAction::Report { - results_dir: args.results_dir, - output_dir: args.output_dir, - }, + ConformanceCommand::Report(args) => { + if standalone { + bail!("--standalone is not valid for conformance report"); + } + ConformanceAction::Report { + results_dir: args.results_dir, + output_dir: args.output_dir, + } + } })), Command::Debug(args) => Ok(Action::Debug(match args.command { DebugCommand::Inspect(args) => { let topology = resolve_lane(args.target.lane, environment)?; + validate_standalone_lane(standalone, topology)?; DebugAction::Inspect { topology, + standalone, protocol_version: resolve_protocol_version( args.target.protocol_version, environment, @@ -401,36 +460,42 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Ok(Action::Ci(match args.command { - CiCommand::PrepareImage(args) => { - let mut components = args.binary.components(); - if !matches!(components.next(), Some(Component::Normal(_))) - || components.next().is_some() - { - bail!("--binary must be one filename at the artifact root"); - } - let repository = args - .repository - .or_else(|| environment_utf8(environment, "GITHUB_REPOSITORY")) - .filter(|value| !value.is_empty()) - .ok_or_else(|| anyhow::anyhow!("set --repository or GITHUB_REPOSITORY"))?; - CiAction::PrepareImage { - artifact: args.artifact, - binary: args.binary, - image: args.image, - repository, - revision: args.revision, - dockerfile: args.dockerfile, - target: args.target, - download_dir: args.download_dir, - } + Command::Ci(args) => { + if standalone { + bail!("--standalone is not valid for CI commands"); } - CiCommand::PrepareRelease => CiAction::PrepareRelease, - CiCommand::SelectRelease => CiAction::SelectRelease, - })), + Ok(Action::Ci(match args.command { + CiCommand::PrepareImage(args) => { + let mut components = args.binary.components(); + if !matches!(components.next(), Some(Component::Normal(_))) + || components.next().is_some() + { + bail!("--binary must be one filename at the artifact root"); + } + let repository = args + .repository + .or_else(|| environment_utf8(environment, "GITHUB_REPOSITORY")) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("set --repository or GITHUB_REPOSITORY"))?; + CiAction::PrepareImage { + artifact: args.artifact, + binary: args.binary, + image: args.image, + repository, + revision: args.revision, + dockerfile: args.dockerfile, + target: args.target, + download_dir: args.download_dir, + } + } + CiCommand::PrepareRelease => CiAction::PrepareRelease, + CiCommand::SelectRelease => CiAction::SelectRelease, + })) + } } } @@ -474,29 +539,76 @@ fn resolve_protocol_version( .map_err(|error| anyhow::anyhow!("invalid {PROTOCOL_VERSION_ENV}: {error}")) } -fn resolve_stack(command: StackCommand, environment: &Environment) -> Result { +fn resolve_stack( + command: StackCommand, + environment: &Environment, + standalone: bool, +) -> Result { match command { - StackCommand::Up(args) => Ok(StackAction::Up { - topology: resolve_lane(args.lane, environment)?, - fresh: args.fresh, - }), - StackCommand::Down(args) => Ok(StackAction::Down { - lane: args.lane.unwrap_or(LaneSelection::All), - volumes: args.volumes, - }), + StackCommand::Up(args) => { + let topology = resolve_lane(args.target.lane, environment)?; + validate_standalone_lane(standalone, topology)?; + Ok(StackAction::Up { + topology, + protocol_version: resolve_protocol_version( + args.target.protocol_version, + environment, + ProtocolVersion::default(), + )?, + fresh: args.fresh, + standalone, + }) + } + StackCommand::Down(args) => { + let lane = args.lane.unwrap_or(if standalone { + LaneSelection::External + } else { + LaneSelection::All + }); + if standalone && lane != LaneSelection::External { + bail!("--standalone requires --lane external"); + } + Ok(StackAction::Down { + lane, + volumes: args.volumes, + standalone, + }) + } StackCommand::Status(args) => { - Ok(StackAction::Status(resolve_lane(args.lane, environment)?)) + let topology = resolve_lane(args.lane, environment)?; + validate_standalone_lane(standalone, topology)?; + Ok(StackAction::Status { + topology, + standalone, + }) + } + StackCommand::Logs(args) => { + let topology = resolve_lane(args.lane, environment)?; + validate_standalone_lane(standalone, topology)?; + Ok(StackAction::Logs { + topology, + services: args.services, + standalone, + }) } - StackCommand::Logs(args) => Ok(StackAction::Logs { - topology: resolve_lane(args.lane, environment)?, - services: args.services, - }), StackCommand::Config(args) => { - Ok(StackAction::Config(resolve_lane(args.lane, environment)?)) + let topology = resolve_lane(args.lane, environment)?; + validate_standalone_lane(standalone, topology)?; + Ok(StackAction::Config { + topology, + standalone, + }) } } } +fn validate_standalone_lane(standalone: bool, topology: StackMode) -> Result<()> { + if standalone && topology != StackMode::Dataplane { + bail!("--standalone requires --lane external"); + } + Ok(()) +} + fn resolve_lanes(lanes: impl IntoIterator) -> Vec { let selected = lanes.into_iter().collect::>(); let all = [ diff --git a/src/app_tests.rs b/src/app_tests.rs index 3415b85..32d0527 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -212,6 +212,7 @@ fn lane_precedence_is_cli_then_environment_then_external() { action(&["cf-integration", "probe"], &[]), Action::Probe { topology: StackMode::Dataplane, + standalone: false, protocol_version: ProtocolVersion::default(), } ); @@ -219,6 +220,7 @@ fn lane_precedence_is_cli_then_environment_then_external() { action(&["cf-integration", "probe"], &[("CF_MCP_LANE", "builtin")],), Action::Probe { topology: StackMode::Controlplane, + standalone: false, protocol_version: ProtocolVersion::default(), } ); @@ -236,6 +238,7 @@ fn lane_precedence_is_cli_then_environment_then_external() { ), Action::Probe { topology: StackMode::Dataplane, + standalone: false, protocol_version: ProtocolVersion::Legacy, } ); @@ -284,7 +287,9 @@ fn stack_actions_resolve_freshness_and_volume_cleanup() { ), Action::Stack(StackAction::Up { topology: StackMode::Controlplane, + protocol_version: ProtocolVersion::Modern, fresh: true, + standalone: false, }) ); assert_eq!( @@ -292,8 +297,38 @@ fn stack_actions_resolve_freshness_and_volume_cleanup() { Action::Stack(StackAction::Down { lane: LaneSelection::All, volumes: true, + standalone: false, + }) + ); +} + +#[test] +fn stack_up_resolves_the_semantic_protocol_mode() { + let resolved = action( + &[ + "cf-integration", + "stack", + "up", + "--standalone", + "--protocol-version", + "legacy", + ], + &[], + ); + + assert_eq!( + resolved, + Action::Stack(StackAction::Up { + topology: StackMode::Dataplane, + protocol_version: ProtocolVersion::Legacy, + fresh: false, + standalone: true, }) ); + assert_eq!( + resolved.startup_summary(), + "Lane: external\nProtocol version: legacy\nControl plane: disabled; Redis config: mocked" + ); } #[test] @@ -361,7 +396,7 @@ fn standalone_load_is_external_only() { ); assert_eq!( standalone.startup_summary(), - "Lane: external\nProtocol version: modern\nControl plane: disabled during load" + "Lane: external\nProtocol version: modern\nControl plane: disabled; Redis config: mocked" ); let cli = Cli::try_parse_from([ @@ -403,6 +438,7 @@ fn live_resolves_lane_group_and_protocol_version() { ), Action::Live { lane: SemanticLane::BuiltInDataPlane, + standalone: false, group: LiveGroup::Mcp, protocol_version: ProtocolVersion::Legacy, } @@ -430,6 +466,7 @@ fn live_fixture_lane_bypasses_topology_and_cli_version_wins() { ), Action::Live { lane: SemanticLane::FixtureDirect, + standalone: false, group: LiveGroup::Protocol, protocol_version: ProtocolVersion::Modern, } @@ -559,25 +596,30 @@ fn standalone_conformance_is_external_only() { .contains("Control plane: disabled; Redis config: mocked") ); - for arguments in [ - vec!["cf-integration", "conformance", "run", "--standalone"], - vec![ - "cf-integration", - "conformance", - "run", - "--lane", - "builtin", - "--standalone", - ], - ] { - let cli = Cli::try_parse_from(arguments).expect("CLI should parse standalone mode"); - let error = resolve_action(cli, &Environment::new()) - .expect_err("standalone conformance must reject non-external lane selections"); - assert_eq!( - error.to_string(), - "--standalone requires --lane external as the only lane" - ); - } + let implied = action( + &["cf-integration", "conformance", "run", "--standalone"], + &[], + ); + let Action::Conformance(ConformanceAction::Run { lanes, .. }) = implied else { + panic!("expected conformance run"); + }; + assert_eq!(lanes, [SemanticLane::ExternalDataPlane]); + + let arguments = [ + "cf-integration", + "conformance", + "run", + "--lane", + "builtin", + "--standalone", + ]; + let cli = Cli::try_parse_from(arguments).expect("CLI should parse standalone mode"); + let error = resolve_action(cli, &Environment::new()) + .expect_err("standalone conformance must reject non-external lane selections"); + assert_eq!( + error.to_string(), + "--standalone requires --lane external as the only lane" + ); } #[test] @@ -603,7 +645,7 @@ fn conformance_report_is_official_only() { } #[test] -fn only_report_and_token_actions_skip_runtime_assets() { +fn only_report_and_controlplane_token_actions_skip_runtime_assets() { let report = action(&["cf-integration", "conformance", "report"], &[]); let token = action( &["cf-integration", "debug", "token", "--kind", "admin"], @@ -613,10 +655,22 @@ fn only_report_and_token_actions_skip_runtime_assets() { &["cf-integration", "stack", "status", "--lane", "external"], &[], ); + let standalone_token = action( + &[ + "cf-integration", + "debug", + "token", + "--kind", + "scoped", + "--standalone", + ], + &[], + ); assert!(!report.requires_runtime_assets()); assert!(!token.requires_runtime_assets()); assert!(stack.requires_runtime_assets()); + assert!(standalone_token.requires_runtime_assets()); } #[test] @@ -637,6 +691,7 @@ fn debug_token_and_inspector_remain_explicit_non_gate_operations() { Action::Debug(DebugAction::Token { kind: TokenKind::Scoped, server_id: Some("server-1".to_owned()), + standalone: false, }) ); assert_eq!( @@ -656,6 +711,7 @@ fn debug_token_and_inspector_remain_explicit_non_gate_operations() { ), Action::Debug(DebugAction::Inspect { topology: StackMode::Controlplane, + standalone: false, protocol_version: ProtocolVersion::Legacy, method: "prompts/list".to_owned(), server_id: None, diff --git a/src/cli.rs b/src/cli.rs index 5f805bb..f79f75f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -79,6 +79,10 @@ fn parse_run_time(value: &str) -> Result { #[derive(Debug, Clone, PartialEq, Parser)] #[command(name = "cf-integration", version, arg_required_else_help = true)] pub(crate) struct Cli { + /// Run the external dataplane with mocked Redis and no control plane. + #[arg(long, global = true)] + pub(crate) standalone: bool, + /// Workflow to run. #[command(subcommand)] pub(crate) command: Command, @@ -185,9 +189,9 @@ pub(crate) enum StackCommand { /// Options for starting one stack. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub(crate) struct StackUpArgs { - /// Execution lane; defaults to CF_MCP_LANE, then external. - #[arg(long, value_enum)] - pub(crate) lane: Option, + /// Routed lane and protocol-version selection. + #[command(flatten)] + pub(crate) target: RoutedWorkflowTargetArgs, /// Remove existing stack volumes before starting. #[arg(long)] @@ -286,10 +290,6 @@ pub(crate) struct LoadArgs { #[command(flatten)] pub(crate) target: RoutedWorkflowTargetArgs, - /// Stop the control plane during an external-dataplane load test. - #[arg(long)] - pub(crate) standalone: bool, - /// Enable the ClickStack observability UI during the load test. #[arg(long)] pub(crate) observability: bool, @@ -413,10 +413,6 @@ pub(crate) struct ConformanceRunArgs { #[arg(long, value_enum, action = ArgAction::Append)] pub(crate) lane: Vec, - /// Run the external dataplane against mocked Redis without a control plane. - #[arg(long)] - pub(crate) standalone: bool, - /// Protocol era used by the official client; repeat for a matrix. #[arg(long, value_enum, action = ArgAction::Append)] pub(crate) client_era: Vec, diff --git a/src/cli_public_tests.rs b/src/cli_public_tests.rs index 73ce240..6c2ae8f 100644 --- a/src/cli_public_tests.rs +++ b/src/cli_public_tests.rs @@ -158,7 +158,8 @@ fn stack_up_and_down_make_destructive_behavior_explicit() { else { panic!("expected stack up") }; - assert_eq!(up.lane, Some(CliRoutedLane::External)); + assert_eq!(up.target.lane, Some(CliRoutedLane::External)); + assert_eq!(up.target.protocol_version, None); assert!(up.fresh); let Command::Stack(StackArgs { @@ -207,7 +208,6 @@ fn stack_logs_preserve_service_arguments() { fn load_keeps_validated_locust_settings() { let Command::Load(LoadArgs { target, - standalone, observability, users, spawn_rate, @@ -229,7 +229,6 @@ fn load_keeps_validated_locust_settings() { }; assert_eq!(target.lane, None); assert_eq!(target.protocol_version, None); - assert!(!standalone); assert!(!observability); assert_eq!(users, Some(2)); assert_eq!(spawn_rate, Some(0.5)); @@ -243,20 +242,19 @@ fn load_keeps_validated_locust_settings() { #[test] fn load_accepts_standalone_external_dataplane_mode() { - let Command::Load(args) = parse(&[ + let cli = parse(&[ "cf-integration", "load", "--lane", "external", "--standalone", - ]) - .command - else { + ]); + assert!(cli.standalone); + let Command::Load(args) = cli.command else { panic!("expected load") }; assert_eq!(args.target.lane, Some(CliRoutedLane::External)); - assert!(args.standalone); } #[test] @@ -382,6 +380,20 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { } let common = ["--lane", "builtin", "--protocol-version", "legacy"]; + let Command::Stack(StackArgs { + command: StackCommand::Up(stack), + }) = parse( + &["cf-integration", "stack", "up"] + .into_iter() + .chain(common) + .collect::>(), + ) + .command + else { + panic!("expected stack-up workflow") + }; + assert_routed_target(&stack.target); + let Command::Probe(probe) = parse( &["cf-integration", "probe"] .into_iter() @@ -435,6 +447,31 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { assert_routed_target(&inspect.target); } +#[test] +fn standalone_is_global_across_operational_commands() { + for arguments in [ + vec!["cf-integration", "stack", "up", "--standalone"], + vec!["cf-integration", "stack", "status", "--standalone"], + vec!["cf-integration", "probe", "--standalone"], + vec!["cf-integration", "load", "--standalone"], + vec!["cf-integration", "live", "--standalone"], + vec!["cf-integration", "conformance", "run", "--standalone"], + vec![ + "cf-integration", + "debug", + "token", + "--kind", + "scoped", + "--standalone", + ], + ] { + assert!( + parse(&arguments).standalone, + "missing global flag for {arguments:?}" + ); + } +} + #[test] fn routed_workflows_reject_the_fixture_lane_during_parsing() { for arguments in [ @@ -455,14 +492,15 @@ fn routed_workflows_reject_the_fixture_lane_during_parsing() { #[test] fn conformance_defaults_to_all_lanes_and_july_revision_at_resolution_time() { + let cli = parse(&["cf-integration", "conformance", "run"]); + assert!(!cli.standalone); let Command::Conformance(ConformanceArgs { command: ConformanceCommand::Run(args), - }) = parse(&["cf-integration", "conformance", "run"]).command + }) = cli.command else { panic!("expected conformance run") }; assert!(args.lane.is_empty()); - assert!(!args.standalone); assert!(args.client_era.is_empty()); assert!(args.server_era.is_empty()); assert!(args.results_dir.is_none()); @@ -553,22 +591,22 @@ fn conformance_accepts_repeatable_exact_lanes_and_protocol_eras() { #[test] fn conformance_accepts_standalone_external_mode() { - let Command::Conformance(ConformanceArgs { - command: ConformanceCommand::Run(args), - }) = parse(&[ + let cli = parse(&[ "cf-integration", "conformance", "run", "--lane", "external", "--standalone", - ]) - .command + ]); + assert!(cli.standalone); + let Command::Conformance(ConformanceArgs { + command: ConformanceCommand::Run(args), + }) = cli.command else { panic!("expected conformance run") }; assert_eq!(args.lane, [CliLane::External]); - assert!(args.standalone); } #[test] diff --git a/src/conformance/client.rs b/src/conformance/client.rs index a943ef1..abf510e 100644 --- a/src/conformance/client.rs +++ b/src/conformance/client.rs @@ -21,7 +21,6 @@ pub(crate) const CLIENT_DRIVER_FAILURE_PREFIX: &str = "client conformance driver const SCENARIO_ENV: &str = "MCP_CONFORMANCE_SCENARIO"; const PROTOCOL_VERSION_ENV: &str = "MCP_CONFORMANCE_PROTOCOL_VERSION"; const CONTEXT_ENV: &str = "MCP_CONFORMANCE_CONTEXT"; -const CONFIG_WRITER: &str = "/opt/contextforge-conformance/write_dataplane_config.mjs"; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -167,10 +166,7 @@ fn publish_scenario_config( "--no-deps", "-e", CLIENT_TOKEN_ENV, - "--entrypoint", - "node", - "mcp_conformance_server", - CONFIG_WRITER, + "config_writer", "client", server_id, backend_url, diff --git a/src/infrastructure/assets.rs b/src/infrastructure/assets.rs index 495ebaf..dac253e 100644 --- a/src/infrastructure/assets.rs +++ b/src/infrastructure/assets.rs @@ -28,18 +28,22 @@ const ASSETS: &[EmbeddedAsset] = &[ asset!("docker/docker-compose.cf-conformance-runtime.yaml"), asset!("docker/docker-compose.cf-conformance.yaml"), asset!("docker/docker-compose.cf-controlplane-build-labels.yaml"), + asset!("docker/docker-compose.cf-controlplane-observability.yaml"), asset!("docker/docker-compose.cf-dataplane-build.yaml"), + asset!("docker/docker-compose.cf-dataplane-observability.yaml"), asset!("docker/docker-compose.cf-dataplane-standalone.yaml"), asset!("docker/docker-compose.cf-dataplane.yaml"), asset!("docker/docker-compose.cf-integration.yaml"), + asset!("docker/docker-compose.cf-telemetry.yaml"), asset!("docker/mcp-conformance-server.Dockerfile"), asset!("docker/nginx.cf-conformance-proxy.conf"), asset!("docker/nginx.cf-dataplane.conf"), + asset!("docker/nginx.cf-dataplane-standalone.conf.template"), asset!("docker/patch-mcp-conformance-hosts.mjs"), asset!("scripts/live_protocol/sitecustomize.py"), asset!("scripts/conformance/write_dataplane_config.mjs"), asset!("scripts/locustfile_mcp.py"), - asset!("scripts/prepare_standalone_config.py"), + asset!("scripts/standalone/generate_auth_key.mjs"), asset!("tests/conformance/baselines/2026-07-28/legacy/built-in-data-plane.yml"), asset!("tests/conformance/baselines/2026-07-28/legacy/client/external-data-plane.yml"), asset!("tests/conformance/baselines/2026-07-28/legacy/external-data-plane.yml"), diff --git a/src/infrastructure/compose.rs b/src/infrastructure/compose.rs index 80f2bde..8c2a68a 100644 --- a/src/infrastructure/compose.rs +++ b/src/infrastructure/compose.rs @@ -15,6 +15,7 @@ const LEGACY_FAST_TIME_IMAGE_PREFIXES: &[&str] = &[ /// Compose service keys and their public container display names. pub(crate) const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ + ("auth_keygen", "cf-dataplane-auth-keygen"), ("gateway", "cf-controlplane"), ("migration", "cf-migration"), ("register_fast_time", "cf-register-fast-time"), @@ -24,6 +25,7 @@ pub(crate) const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ ("pgbouncer", "cf-pgbouncer"), ("redis", "cf-redis"), ("dataplane", "cf-dataplane"), + ("config_writer", "cf-dataplane-config-writer"), ("locust", "cf-locust"), ("locust_worker", "cf-locust-worker"), ("locust_token", "cf-locust-token"), @@ -108,7 +110,7 @@ impl ComposeProject { } } - /// Builds the minimal external-dataplane project used by standalone conformance. + /// Builds the minimal external-dataplane project used by standalone workflows. #[must_use] pub(crate) fn standalone_dataplane( repository_root: &Path, diff --git a/src/infrastructure/compose_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index 8463c88..0f7357c 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -246,16 +246,7 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { let environment = compose["services"]["dataplane"]["environment"] .as_mapping() .expect("dataplane environment must be a mapping"); - let gateway_volumes = compose["services"]["gateway"]["volumes"] - .as_sequence() - .expect("gateway volumes must be a sequence"); - assert!(gateway_volumes.iter().any(|volume| { - volume.as_str().is_some_and(|volume| { - volume.ends_with( - "/scripts/prepare_standalone_config.py:/opt/contextforge-integration/prepare_standalone_config.py:ro", - ) - }) - })); + assert!(compose["services"]["gateway"]["volumes"].is_null()); for key in [ "CONTEXTFORGE_DATA_PLANE_ADDRESS", @@ -377,6 +368,56 @@ fn standalone_dataplane_has_no_controlplane_compose_inputs() { ); } +#[test] +fn standalone_dataplane_owns_ephemeral_jwks_auth_and_mock_helpers() { + let root = workspace_root(); + let compose = + fs::read_to_string(root.join("docker/docker-compose.cf-dataplane-standalone.yaml")) + .expect("read standalone dataplane Compose file"); + let compose: yaml_serde::Value = + yaml_serde::from_str(&compose).expect("parse standalone dataplane Compose file"); + let services = compose["services"] + .as_mapping() + .expect("standalone services must be a mapping"); + + assert_eq!(services.len(), 6); + assert!(compose["services"]["gateway"].is_null()); + assert_eq!( + compose["services"]["auth_keygen"]["network_mode"].as_str(), + Some("none") + ); + assert_eq!( + compose["services"]["dataplane"]["environment"]["CONTEXTFORGE_DATA_PLANE_JWKS_URL"] + .as_str(), + Some("http://127.0.0.1:4445/contextforge-rs/admin/.well-known/jwks.json") + ); + assert_eq!( + compose["services"]["dataplane"]["command"][1].as_str(), + Some("/keys/jwt.key") + ); + assert!( + compose["services"]["dataplane"]["environment"]["CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET"] + .is_null() + ); + assert!( + compose["services"]["dataplane"]["environment"] + ["CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY"] + .is_null() + ); + assert_eq!( + compose["services"]["nginx"]["healthcheck"]["test"][1].as_str(), + Some("wget -q -O - http://127.0.0.1/health >/dev/null") + ); + assert_eq!( + compose["services"]["config_writer"]["profiles"][0].as_str(), + Some("helpers") + ); + assert_eq!( + compose["services"]["locust"]["profiles"][0].as_str(), + Some("performance") + ); +} + #[test] fn conformance_fixture_is_an_explicit_overlay_and_profile() { let default_project = ComposeProject::dataplane( @@ -705,8 +746,6 @@ services: mcp_conformance_server: networks: - mcpnet - volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_dataplane_config.mjs:/opt/contextforge-conformance/write_dataplane_config.mjs:ro mcp_conformance_proxy: profiles: ["conformance"] image: nginx:1.30.4-alpine3.24 diff --git a/src/infrastructure/stack.rs b/src/infrastructure/stack.rs index aaa3801..298afc6 100644 --- a/src/infrastructure/stack.rs +++ b/src/infrastructure/stack.rs @@ -185,6 +185,7 @@ impl StackCommandPlan { /// Builds a Compose command that stops one service without removing it. #[must_use] + #[cfg(test)] pub(crate) fn stop_service(project: ComposeProject, service: &str) -> Self { Self { command: project.command(["stop", "--timeout", "5", service]), @@ -193,6 +194,7 @@ impl StackCommandPlan { /// Builds a Compose command that restarts one previously stopped service. #[must_use] + #[cfg(test)] pub(crate) fn start_service(project: ComposeProject, service: &str) -> Self { Self { command: project.command(["start", service]), @@ -201,6 +203,7 @@ impl StackCommandPlan { /// Builds a Compose command that restarts one service without its dependencies. #[must_use] + #[cfg(test)] pub(crate) fn restart_service(project: ComposeProject, service: &str) -> Self { Self { command: project.command(["restart", "--timeout", "5", service]), diff --git a/src/mcp/probe.rs b/src/mcp/probe.rs index 6178c98..bf8a4a4 100644 --- a/src/mcp/probe.rs +++ b/src/mcp/probe.rs @@ -47,6 +47,11 @@ pub(crate) struct ProbeConfig { pub(crate) request_timeout: Duration, /// MCP protocol revision requested during initialization. pub(crate) protocol_version: String, + /// Tool names supplied by standalone mocked configuration. + /// + /// A nonempty value bypasses `tools/list`, which the external dataplane + /// deliberately does not implement, while still exercising routed calls. + pub(crate) tool_names: Vec, /// Terminal-aware styling for human-readable probe results. pub(crate) output_style: OutputStyle, } @@ -63,6 +68,7 @@ impl fmt::Debug for ProbeConfig { .field("retry_interval", &self.retry_interval) .field("request_timeout", &self.request_timeout) .field("protocol_version", &self.protocol_version) + .field("tool_name_count", &self.tool_names.len()) .finish() } } @@ -337,53 +343,29 @@ pub(crate) async fn run_probe( "failed to write initialized notification result", )?; - let tools_response = post_with_timeout( - transport, - ProbeRequest { - url: url.clone(), - payload: jsonrpc_with_id("tools/list", Some(json!({})), json!(TOOLS_LIST_ID)), - bearer_token: Some(config.bearer_token.clone()), - session_id: Some(session_id.clone()), - protocol_version: Some(negotiated_version.clone()), - }, - config.request_timeout, - "tools_list", - config.mode, - ) - .await?; - let tools_result = result_of("tools_list", &tools_response, TOOLS_LIST_ID)?; - let tools = tools_result - .get("tools") - .and_then(Value::as_array) - .ok_or_else(|| { - anyhow::anyhow!("tools_list=FAIL unexpected response: missing tools array") - })?; - if tools.is_empty() { - bail!("tools_list=FAIL no tools returned"); - } - let mut tool_names = Vec::with_capacity(tools.len()); - for tool in tools { - let Some(name) = tool - .as_object() - .and_then(|tool| tool.get("name")) - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - else { - bail!("tools_list=FAIL every tool must have a nonempty name"); - }; - tool_names.push(name); - } - write_probe_result( - output, - config, - TestStatus::Pass, - "tools_list", - &format!("count={}", tool_names.len()), - "failed to write tools list result", - )?; + let tool_names = if config.tool_names.is_empty() { + let tools_response = post_with_timeout( + transport, + ProbeRequest { + url: url.clone(), + payload: jsonrpc_with_id("tools/list", Some(json!({})), json!(TOOLS_LIST_ID)), + bearer_token: Some(config.bearer_token.clone()), + session_id: Some(session_id.clone()), + protocol_version: Some(negotiated_version.clone()), + }, + config.request_timeout, + "tools_list", + config.mode, + ) + .await?; + listed_tool_names(&tools_response, output, config)? + } else { + write_mocked_tool_catalog(output, config)?; + config.tool_names.clone() + }; let callable = tool_names .iter() - .find_map(|name| tool_call_args(name).map(|arguments| (*name, arguments))); + .find_map(|name| tool_call_args(name).map(|arguments| (name.as_str(), arguments))); let Some((tool_name, arguments)) = callable else { write_probe_result( output, @@ -555,56 +537,34 @@ async fn run_stateless_probe( "failed to write server discovery result", )?; - let tools_response = post_with_timeout( - transport, - ProbeRequest { - url: url.clone(), - payload: stateless_jsonrpc_with_id( - "tools/list", - Some(json!({})), - json!(TOOLS_LIST_ID), - &config.protocol_version, - ), - bearer_token: Some(config.bearer_token.clone()), - session_id: None, - protocol_version: Some(config.protocol_version.clone()), - }, - config.request_timeout, - "tools_list", - config.mode, - ) - .await?; - let tools_result = result_of("tools_list", &tools_response, TOOLS_LIST_ID)?; - let tools = tools_result - .get("tools") - .and_then(Value::as_array) - .ok_or_else(|| anyhow!("tools_list=FAIL unexpected response: missing tools array"))?; - if tools.is_empty() { - bail!("tools_list=FAIL no tools returned"); - } - let mut tool_names = Vec::with_capacity(tools.len()); - for tool in tools { - let Some(name) = tool - .as_object() - .and_then(|tool| tool.get("name")) - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - else { - bail!("tools_list=FAIL every tool must have a nonempty name"); - }; - tool_names.push(name); - } - write_probe_result( - output, - config, - TestStatus::Pass, - "tools_list", - &format!("count={}", tool_names.len()), - "failed to write tools list result", - )?; + let tool_names = if config.tool_names.is_empty() { + let tools_response = post_with_timeout( + transport, + ProbeRequest { + url: url.clone(), + payload: stateless_jsonrpc_with_id( + "tools/list", + Some(json!({})), + json!(TOOLS_LIST_ID), + &config.protocol_version, + ), + bearer_token: Some(config.bearer_token.clone()), + session_id: None, + protocol_version: Some(config.protocol_version.clone()), + }, + config.request_timeout, + "tools_list", + config.mode, + ) + .await?; + listed_tool_names(&tools_response, output, config)? + } else { + write_mocked_tool_catalog(output, config)?; + config.tool_names.clone() + }; let callable = tool_names .iter() - .find_map(|name| tool_call_args(name).map(|arguments| (*name, arguments))); + .find_map(|name| tool_call_args(name).map(|arguments| (name.as_str(), arguments))); let Some((tool_name, arguments)) = callable else { write_probe_result( output, @@ -656,6 +616,52 @@ async fn run_stateless_probe( Ok(()) } +fn listed_tool_names( + response: &ProbeResponse, + output: &mut W, + config: &ProbeConfig, +) -> Result> { + let tools_result = result_of("tools_list", response, TOOLS_LIST_ID)?; + let tools = tools_result + .get("tools") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("tools_list=FAIL unexpected response: missing tools array"))?; + if tools.is_empty() { + bail!("tools_list=FAIL no tools returned"); + } + let tool_names = tools + .iter() + .map(|tool| { + tool.as_object() + .and_then(|tool| tool.get("name")) + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| anyhow!("tools_list=FAIL every tool must have a nonempty name")) + }) + .collect::>>()?; + write_probe_result( + output, + config, + TestStatus::Pass, + "tools_list", + &format!("count={}", tool_names.len()), + "failed to write tools list result", + )?; + Ok(tool_names) +} + +fn write_mocked_tool_catalog(output: &mut W, config: &ProbeConfig) -> Result<()> { + write_probe_result( + output, + config, + TestStatus::Pass, + "tools_catalog", + &format!("count={} source=mocked-redis", config.tool_names.len()), + "failed to write mocked tool catalog result", + ) +} + async fn post_with_timeout( transport: &T, request: ProbeRequest, diff --git a/src/mcp/probe_tests.rs b/src/mcp/probe_tests.rs index 98f0e02..302bb2e 100644 --- a/src/mcp/probe_tests.rs +++ b/src/mcp/probe_tests.rs @@ -83,6 +83,7 @@ fn config() -> ProbeConfig { retry_interval: Duration::ZERO, request_timeout: Duration::from_secs(1), protocol_version: "2025-11-25".to_owned(), + tool_names: Vec::new(), output_style: OutputStyle::plain(), } } @@ -325,6 +326,37 @@ async fn stateless_happy_path_uses_discovery_request_metadata_and_no_session() { assert!(!output.contains("PASS initialized")); } +#[tokio::test] +async fn mocked_catalog_bypasses_unsupported_fanout_and_calls_a_known_tool() { + let transport = FakeTransport::new([ + ProbeResponse::new(401, None, None), + discover_success(), + call_success(), + ]); + let mut configured = config(); + configured.protocol_version = "2026-07-28".to_owned(); + configured.tool_names = vec!["test_simple_text".to_owned()]; + let mut output = Vec::new(); + + run_probe(&transport, &configured, &mut output) + .await + .expect("mocked catalog probe should succeed without tools/list"); + + let requests = transport.requests(); + assert_eq!(requests.len(), 3); + assert!( + requests + .iter() + .all(|request| request.payload["method"] != "tools/list") + ); + assert_eq!(requests[2].payload["method"], "tools/call"); + assert_eq!(requests[2].payload["params"]["name"], "test_simple_text"); + assert_eq!(requests[2].payload["params"]["arguments"], json!({})); + let output = String::from_utf8(output).expect("probe output should be UTF-8"); + assert!(output.contains("PASS tools_catalog count=1 source=mocked-redis")); + assert!(output.contains("PASS tool_call tool=test_simple_text")); +} + #[tokio::test] async fn requested_version_drives_initialize_payload_and_negotiated_version_drives_headers() { let transport = FakeTransport::new([ diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs index c26efe2..6e9e2db 100644 --- a/src/mcp/protocol.rs +++ b/src/mcp/protocol.rs @@ -202,6 +202,7 @@ fn flush_sse_event(event_data: &mut String, has_data: &mut bool, message: &mut O #[must_use] pub(crate) fn tool_call_args(tool_name: &str) -> Option { match tool_name { + "test_simple_text" => Some(json!({})), "echo" | "fast_time_echo" | "fast-time-echo" => Some(json!({"message": "cf-integration"})), "get_system_time" | "get-system-time" diff --git a/src/performance/locust.rs b/src/performance/locust.rs index bdc4afd..cdf4e96 100644 --- a/src/performance/locust.rs +++ b/src/performance/locust.rs @@ -36,6 +36,7 @@ impl LocustCommand { /// ID, an invalid timeout, or an inaccessible report directory. pub(crate) fn new_with_protocol_version( config: &AppConfig, + project: ComposeProject, mode: StackMode, settings: &LoadSettings, bearer_token: &str, @@ -68,69 +69,44 @@ impl LocustCommand { })?; let volume = volume_argument(&report_dir); - let project = match mode { - StackMode::Dataplane => ComposeProject::dataplane( - config.asset_root(), - config.controlplane_dir(), - config.integration_project().value.clone(), - !config.dataplane_ref().value.is_empty(), - ), - StackMode::Controlplane => ComposeProject::controlplane( - config.asset_root(), - config.controlplane_dir(), - config.controlplane_project().value.clone(), - controlplane_sso_enabled(config), - ), - }; - - let command = match mode { - StackMode::Dataplane => project.command([ - OsString::from("--profile"), - OsString::from("testing"), - OsString::from("run"), - OsString::from("--rm"), - OsString::from("--no-deps"), - OsString::from("--volume"), - volume, - OsString::from("locust"), - ]), - StackMode::Controlplane => { - let adapter_volume = adapter_volume_argument(config.asset_root()); - let arguments = vec![ - OsString::from("run"), - OsString::from("--rm"), - OsString::from("--no-deps"), - OsString::from("--volume"), - volume, - OsString::from("--volume"), - adapter_volume, - OsString::from("-e"), - OsString::from("MCPGATEWAY_BEARER_TOKEN"), - OsString::from("-e"), - OsString::from("MCP_STACK_MODE"), - OsString::from("-e"), - OsString::from(REQUEST_TIMEOUT_ENV), - OsString::from("-e"), - OsString::from("MCP_PROTOCOL_VERSION"), - OsString::from("-e"), - OsString::from("MCP_TOOL_NAMES"), - OsString::from("--entrypoint"), - OsString::from("locust"), - OsString::from("locust"), - OsString::from("-f"), - OsString::from(LOCUST_ADAPTER_CONTAINER_PATH), - OsString::from("--host=http://nginx:80"), - OsString::from(format!("--users={}", settings.users().get())), - OsString::from(format!("--spawn-rate={}", settings.spawn_rate())), - OsString::from(format!("--run-time={}", settings.run_time())), - OsString::from("--headless"), - OsString::from("--html=/mnt/reports/locust_report.html"), - OsString::from("--csv=/mnt/reports/locust"), - OsString::from("--only-summary"), - ]; - project.command(arguments) - } - }; + let adapter_volume = adapter_volume_argument(config.asset_root()); + let arguments = vec![ + OsString::from("run"), + OsString::from("--rm"), + OsString::from("--no-deps"), + OsString::from("--volume"), + volume, + OsString::from("--volume"), + adapter_volume, + OsString::from("-e"), + OsString::from("MCPGATEWAY_BEARER_TOKEN"), + OsString::from("-e"), + OsString::from("MCP_STACK_MODE"), + OsString::from("-e"), + OsString::from(REQUEST_TIMEOUT_ENV), + OsString::from("-e"), + OsString::from("MCP_PROTOCOL_VERSION"), + OsString::from("-e"), + OsString::from("MCP_TOOL_NAMES"), + OsString::from("-e"), + OsString::from("MCP_SKIP_TOOL_LIST"), + OsString::from("-e"), + OsString::from("MCP_SERVER_ID"), + OsString::from("--entrypoint"), + OsString::from("locust"), + OsString::from("locust"), + OsString::from("-f"), + OsString::from(LOCUST_ADAPTER_CONTAINER_PATH), + OsString::from("--host=http://nginx:80"), + OsString::from(format!("--users={}", settings.users().get())), + OsString::from(format!("--spawn-rate={}", settings.spawn_rate())), + OsString::from(format!("--run-time={}", settings.run_time())), + OsString::from("--headless"), + OsString::from("--html=/mnt/reports/locust_report.html"), + OsString::from("--csv=/mnt/reports/locust"), + OsString::from("--only-summary"), + ]; + let command = project.command(arguments); let mut command = command .env("CF_INTEGRATION_ROOT", config.asset_root().as_os_str()) @@ -150,7 +126,9 @@ impl LocustCommand { } StackMode::Controlplane => { let tool_names = configured_text(config, "MCP_TOOL_NAMES").unwrap_or(""); - command = command.env("MCP_TOOL_NAMES", tool_names); + command = command + .env("MCP_TOOL_NAMES", tool_names) + .env("MCP_SERVER_ID", ""); } } @@ -309,14 +287,6 @@ fn adapter_volume_argument(root: &Path) -> OsString { argument } -fn controlplane_sso_enabled(config: &AppConfig) -> bool { - config - .environment() - .get(OsStr::new("CONTROLPLANE_ENABLE_SSO")) - .and_then(|value| value.value.to_str()) - .is_some_and(|value| matches!(value, "true" | "1")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/performance/locust_integration_tests.rs b/src/performance/locust_integration_tests.rs index e8337d9..e0b0891 100644 --- a/src/performance/locust_integration_tests.rs +++ b/src/performance/locust_integration_tests.rs @@ -4,6 +4,7 @@ use std::fs; use std::path::Path; use cf_integration::infrastructure::StackMode; +use cf_integration::infrastructure::compose::ComposeProject; use cf_integration::infrastructure::config::{ AppConfig, ConfigBootstrap, ConfigRequirements, Environment, }; @@ -49,6 +50,23 @@ fn args(smoke: bool) -> LoadRequest { } } +fn project(config: &AppConfig, mode: StackMode) -> ComposeProject { + match mode { + StackMode::Dataplane => ComposeProject::dataplane( + config.asset_root(), + config.controlplane_dir(), + config.integration_project().value.clone(), + !config.dataplane_ref().value.is_empty(), + ), + StackMode::Controlplane => ComposeProject::controlplane( + config.asset_root(), + config.controlplane_dir(), + config.controlplane_project().value.clone(), + false, + ), + } +} + #[test] fn full_load_uses_configured_defaults() { let root = repository_root(None); @@ -156,6 +174,7 @@ fn dataplane_locust_command_has_exact_compose_shape_and_environment() { let run = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Dataplane), StackMode::Dataplane, &settings, "scoped.jwt.value", @@ -173,41 +192,23 @@ fn dataplane_locust_command_has_exact_compose_shape_and_environment() { .join("locust"); assert_eq!(run.report_dir(), report_dir); assert!(run.report_dir().is_dir()); - assert_eq!( - run.command().arguments(), - [ - OsString::from("compose"), - OsString::from("-p"), - OsString::from("cf"), - OsString::from("-f"), - integration_dir - .join("mcp-context-forge") - .join("docker-compose.yml") - .into_os_string(), - OsString::from("-f"), - asset_root - .join("docker") - .join("docker-compose.cf-controlplane-build-labels.yaml") - .into_os_string(), - OsString::from("-f"), - asset_root - .join("docker") - .join("docker-compose.cf-dataplane.yaml") - .into_os_string(), - OsString::from("-f"), - asset_root - .join("docker") - .join("docker-compose.cf-integration.yaml") - .into_os_string(), - OsString::from("--profile"), - OsString::from("testing"), - OsString::from("run"), - OsString::from("--rm"), - OsString::from("--no-deps"), - OsString::from("--volume"), - volume_argument(&report_dir), - OsString::from("locust"), - ] + let arguments = run.command().arguments(); + assert!( + arguments + .windows(3) + .any(|values| values == ["run", "--rm", "--no-deps"]) + ); + assert!(arguments.contains(&volume_argument(&report_dir))); + assert!(arguments.contains(&OsString::from("/mnt/locust-cf/locustfile_mcp.py"))); + assert!( + arguments + .windows(2) + .any(|values| values == ["-e", "MCP_SKIP_TOOL_LIST"]) + ); + assert!( + arguments + .windows(2) + .any(|values| values == ["--entrypoint", "locust"]) ); let expected_environment = HashMap::from([ @@ -253,6 +254,7 @@ fn controlplane_uses_the_same_harness_mcp_adapter_and_does_not_require_server_id let run = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Controlplane), StackMode::Controlplane, &settings, "admin.jwt.value", @@ -296,10 +298,9 @@ fn controlplane_uses_the_same_harness_mcp_adapter_and_does_not_require_server_id .get(OsStr::new("MCP_TOOL_NAMES")), Some(&OsString::from("safe_time,safe_echo")) ); - assert!( - !run.command() - .environment() - .contains_key(OsStr::new("MCP_SERVER_ID")) + assert_eq!( + run.command().environment().get(OsStr::new("MCP_SERVER_ID")), + Some(&OsString::new()) ); let arguments = run.command().arguments(); let entrypoint = arguments @@ -353,6 +354,7 @@ fn locust_request_timeout_rejects_empty_non_finite_and_non_positive_values() { let error = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Controlplane), StackMode::Controlplane, &settings, "token", @@ -378,6 +380,7 @@ fn dataplane_requires_nonempty_server_id_and_all_modes_require_a_token() { let missing_server = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Dataplane), StackMode::Dataplane, &settings, "token", @@ -389,6 +392,7 @@ fn dataplane_requires_nonempty_server_id_and_all_modes_require_a_token() { let missing_token = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Controlplane), StackMode::Controlplane, &settings, "", diff --git a/src/performance/python_adapter_tests.rs b/src/performance/python_adapter_tests.rs index a96e67d..8c0b2db 100644 --- a/src/performance/python_adapter_tests.rs +++ b/src/performance/python_adapter_tests.rs @@ -25,8 +25,9 @@ fn locust_adapter_and_compose_overlay_do_not_reference_the_removed_helper() { let root = workspace_root(); let adapter = fs::read_to_string(root.join("scripts/locustfile_mcp.py")) .expect("Locust adapter should be readable"); - let compose = fs::read_to_string(root.join("docker/docker-compose.cf-integration.yaml")) - .expect("Compose overlay should be readable"); + let compose = + fs::read_to_string(root.join("docker/docker-compose.cf-dataplane-standalone.yaml")) + .expect("standalone Compose file should be readable"); assert!(!adapter.contains("mcp_http")); assert!(adapter.contains("allow_redirects=False")); @@ -37,49 +38,30 @@ fn locust_adapter_and_compose_overlay_do_not_reference_the_removed_helper() { !compose.contains("JWT_SECRET_KEY="), "the load container receives a bearer token and must not receive the signing key" ); - assert!(compose.contains("MCP_PROTOCOL_VERSION=${MCP_PROTOCOL_VERSION:-2026-07-28}")); - assert!(compose.contains("standalone_load_backend:")); - assert!(compose.contains("profiles: [\"standalone-load\"]")); - assert!(compose.contains("MCP_CONFORMANCE_SERVER_ERA: modern")); - assert!( - compose.contains("LOCUST_REQUEST_TIMEOUT_SECONDS=${LOCUST_REQUEST_TIMEOUT_SECONDS:-60}") - ); + assert!(!compose.contains("gateway:")); + assert!(compose.contains("config_writer:")); + assert!(compose.contains("locustio/locust:2.46.2")); + assert!(compose.contains("profiles: [\"performance\"]")); } #[test] -fn standalone_config_helper_uses_token_subject_and_selected_protocol() { - let code = r#" -import base64 -import json -import prepare_standalone_config as helper - -claims = base64.urlsafe_b64encode(json.dumps({"sub": "user-123"}).encode()).decode().rstrip("=") -assert helper.token_subject(f"header.{claims}.signature") == "user-123" - -prepared = helper.prepare_config("server-123", "2026-07-28") -backend = prepared["virtual_hosts"]["server-123"]["backends"]["standalone-load"] -assert backend["url"] == "http://mcp_conformance_server:3000/mcp" -assert backend["mcp_protocol_version"] == "2026-07-28" -assert backend["tool_name_aliases"] == [{"downstream_prefixed_name": "test_simple_text", "upstream_name": "test_simple_text"}] -assert backend["tool_schemas"] == {"test_simple_text": {}} -virtual_host = prepared["virtual_hosts"]["server-123"] -assert virtual_host["tools"] == {"test_simple_text": {"backend_name": "standalone-load", "upstream_name": "test_simple_text"}} -assert virtual_host["resources"] == {} -assert virtual_host["resource_templates"] == {} -assert virtual_host["prompts"] == {} -"#; - let output = Command::new(python()) - .arg("-c") - .arg(code) - .env("PYTHONPATH", scripts_dir()) - .output() - .expect("Python helper test should run"); +fn standalone_config_writer_has_valid_javascript_syntax() { + for script in [ + "conformance/write_dataplane_config.mjs", + "standalone/generate_auth_key.mjs", + ] { + let output = Command::new("node") + .arg("--check") + .arg(scripts_dir().join(script)) + .output() + .expect("Node standalone-helper syntax check should run"); - assert!( - output.status.success(), - "standalone config helper failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + assert!( + output.status.success(), + "standalone helper syntax check failed for {script}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } } fn locust_stub() -> TempDir { diff --git a/src/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index 5cfeff8..3496b9a 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -15,7 +15,6 @@ use crate::conformance::profile::{ use crate::conformance::results::{DEFAULT_CONFORMANCE_SUITE, ScenarioOutcome}; const CLIENT_CONFORMANCE_SERVER_ID: &str = "dataplane-client-conformance"; -const DATAPLANE_CONFIG_WRITER: &str = "/opt/contextforge-conformance/write_dataplane_config.mjs"; #[derive(Debug, Clone, PartialEq, Eq)] struct ConformanceOperationalFailure { @@ -177,10 +176,11 @@ impl RuntimeContext { topology: StackMode, server_era: ConformanceServerEra, standalone: bool, + observability: bool, ) -> AppResult<()> { - self.build_conformance_service(topology, server_era, standalone) + self.build_conformance_service(topology, server_era, standalone, observability) .await?; - self.start_conformance_containers(topology, server_era, standalone) + self.start_conformance_containers(topology, server_era, standalone, observability) .await } @@ -189,8 +189,9 @@ impl RuntimeContext { topology: StackMode, server_era: ConformanceServerEra, standalone: bool, + observability: bool, ) -> AppResult<()> { - let project = self.routed_conformance_project(topology, standalone); + let project = self.routed_conformance_project(topology, standalone, observability); let build = project.command(["build", OFFICIAL_CONFORMANCE_SERVICE]); let build = self .routed_conformance_environment(build, topology, standalone)? @@ -203,8 +204,9 @@ impl RuntimeContext { topology: StackMode, server_era: ConformanceServerEra, standalone: bool, + observability: bool, ) -> AppResult<()> { - let project = self.routed_conformance_project(topology, standalone); + let project = self.routed_conformance_project(topology, standalone, observability); let up = project.command([ "up", "-d", @@ -218,13 +220,16 @@ impl RuntimeContext { Ok(self.runner.run_async(&up).await?) } - pub(super) fn conformance_fixture_endpoint(&self, topology: StackMode) -> AppResult { - let command = self.conformance_compose_project(topology).command([ - "port", - OFFICIAL_CONFORMANCE_SERVICE, - "3000", - ]); - let command = self.compose_environment(command, topology, true)?; + pub(super) fn conformance_fixture_endpoint( + &self, + topology: StackMode, + standalone: bool, + observability: bool, + ) -> AppResult { + let command = self + .routed_conformance_project(topology, standalone, observability) + .command(["port", OFFICIAL_CONFORMANCE_SERVICE, "3000"]); + let command = self.routed_conformance_environment(command, topology, standalone)?; let output = self.runner.capture_stdout(&command)?; parse_conformance_fixture_endpoint(&output).map_err(AppFailure::from) } @@ -233,9 +238,10 @@ impl RuntimeContext { &self, topology: StackMode, standalone: bool, + observability: bool, ) -> AppResult<()> { let remove = self - .routed_conformance_project(topology, standalone) + .routed_conformance_project(topology, standalone, observability) .command([ "rm", "--stop", @@ -250,10 +256,15 @@ impl RuntimeContext { .map_err(AppFailure::from) } - fn routed_conformance_project(&self, topology: StackMode, standalone: bool) -> ComposeProject { + fn routed_conformance_project( + &self, + topology: StackMode, + standalone: bool, + observability: bool, + ) -> ComposeProject { if standalone { debug_assert_eq!(topology, StackMode::Dataplane); - self.standalone_conformance_compose_project() + self.standalone_conformance_compose_project(observability) } else { self.conformance_compose_project(topology) } @@ -781,7 +792,7 @@ impl RuntimeContext { let run_routed = lanes.contains(&target); let stack_progress = Activity::spinner(format!("Prepare {}", topology.lane_label())); let mut topology_failure = if standalone_topology { - self.stack_up_standalone_conformance(true).await.err() + self.stack_up_standalone_dataplane(true, true).await.err() } else { self.stack_up_for_conformance(topology, true).await.err() }; @@ -798,7 +809,7 @@ impl RuntimeContext { topology.lane_label() )); let (start_result, start_interrupted) = finish_phase_after_interrupt( - self.start_conformance_service(topology, server_era, standalone_topology), + self.start_conformance_service(topology, server_era, standalone_topology, true), interrupt.as_mut(), ) .await; @@ -893,7 +904,7 @@ impl RuntimeContext { let run_inputs = server_id.zip(fixture_metadata.as_ref()); match run_inputs { Some((server_id, metadata)) => match if standalone_topology { - self.standalone_conformance_token() + self.standalone_dataplane_token(true) } else { self.issue_conformance_token().await } { @@ -908,6 +919,7 @@ impl RuntimeContext { server_id, spec_version, &token.value, + true, ) .await { @@ -964,7 +976,7 @@ impl RuntimeContext { .await .map_err(AppFailure::from); let service_cleanup = self - .stop_conformance_service(topology, standalone_topology) + .stop_conformance_service(topology, standalone_topology, true) .await; topology_failure = finish_with_cleanup( topology_failure, @@ -974,7 +986,7 @@ impl RuntimeContext { } else if service_started { topology_failure = finish_with_cleanup( topology_failure, - self.stop_conformance_service(topology, standalone_topology) + self.stop_conformance_service(topology, standalone_topology, true) .await, ) .err(); @@ -1137,7 +1149,7 @@ impl RuntimeContext { }; let stack_progress = Activity::spinner(progress); let stack_result = if standalone { - self.stack_up_standalone_conformance(!reuse_stack).await + self.stack_up_standalone_dataplane(!reuse_stack, true).await } else { self.stack_up_for_conformance(StackMode::Dataplane, !reuse_stack) .await @@ -1149,7 +1161,7 @@ impl RuntimeContext { if failure.is_none() { match if standalone { - self.standalone_conformance_token() + self.standalone_dataplane_token(true) } else { self.issue_conformance_token().await } { @@ -1220,70 +1232,42 @@ impl RuntimeContext { .map_err(AppFailure::from) } - fn standalone_conformance_token(&self) -> AppResult { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .context("system clock is before the Unix epoch") - .map_err(AppFailure::from)? - .as_secs(); - let claims = serde_json::json!({ - "iss": "mcpgateway", - "sub": uuid::Uuid::new_v4().to_string(), - "aud": "mcpgateway-api", - "exp": now + 3_600, - "iat": now, - "jti": uuid::Uuid::new_v4().to_string(), - "token_use": "api", - "teams": [], - "user": { - "email": "cf-integration@example.invalid", - "full_name": "cf-integration standalone conformance", - "is_admin": true, - "auth_provider": "api_token" - }, - "scopes": null - }); - let secret = required_text(&self.config.jwt_secret_key().value, "JWT_SECRET_KEY")?; - let token = jsonwebtoken::encode( - &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), - &claims, - &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()), - ) - .context("failed to sign the standalone conformance token") - .map_err(AppFailure::from)?; - Ok(ManagedBearerToken::unmanaged(token)) - } - - async fn publish_standalone_conformance_config( + pub(super) async fn publish_standalone_conformance_config( &self, server_id: &str, protocol_version: &str, token: &str, - ) -> AppResult<()> { + observability: bool, + ) -> AppResult> { let progress = Activity::spinner("Publish mocked Redis conformance configuration"); - let command = self.standalone_conformance_compose_project().command([ - "run", - "--rm", - "--no-deps", - "-e", - CLIENT_TOKEN_ENV, - "--entrypoint", - "node", - OFFICIAL_CONFORMANCE_SERVICE, - DATAPLANE_CONFIG_WRITER, - "fixture", - server_id, - OFFICIAL_CONFORMANCE_BACKEND_URL, - protocol_version, - ]); + let command = self + .standalone_conformance_compose_project(observability) + .command([ + "run", + "--rm", + "--no-deps", + "-e", + CLIENT_TOKEN_ENV, + "config_writer", + "fixture", + server_id, + OFFICIAL_CONFORMANCE_BACKEND_URL, + protocol_version, + ]); let command = self .standalone_dataplane_environment(command, true)? .env(CLIENT_TOKEN_ENV, token); - let result = self - .runner - .run_async(&command) - .await - .map_err(AppFailure::from); + let result = self.capture_text(&command).and_then(|output| { + let tool_names = serde_json::from_str::>(&output) + .context("standalone config helper returned invalid tool names") + .map_err(AppFailure::from)?; + if tool_names.is_empty() { + return Err(AppFailure::from(anyhow!( + "standalone Redis config for server {server_id} contains no tools" + ))); + } + Ok(tool_names) + }); progress.finish(result.is_ok()); result } @@ -1337,7 +1321,7 @@ impl RuntimeContext { )?; let compose_project = if standalone { - self.standalone_conformance_compose_project() + self.standalone_conformance_compose_project(true) } else { self.conformance_runtime_project(StackMode::Dataplane) }; diff --git a/src/runtime/inspect.rs b/src/runtime/inspect.rs index 97e9d13..fdbe3bb 100644 --- a/src/runtime/inspect.rs +++ b/src/runtime/inspect.rs @@ -21,6 +21,7 @@ impl RuntimeContext { pub(super) async fn inspect( &self, mode: StackMode, + standalone: bool, protocol_version: &ProtocolVersion, method: &str, server_id: Option<&str>, @@ -29,40 +30,46 @@ impl RuntimeContext { .unwrap_or_else(|| self.default_server_id()) .to_owned(); let operation_server_id = server_id.clone(); - self.with_managed_authenticated_target(mode, &server_id, false, |token, _| async move { - let endpoint = GatewayClient::new( - gateway_topology(mode), - self.base_url()?, - &operation_server_id, - &token, - ) - .context("failed to construct the Inspector gateway endpoint") - .map_err(AppFailure::from)? - .endpoint() - .clone(); - let proxy = AuthProxy::start_with_protocol_version( - endpoint, - &token, - Some(protocol_version.wire_version()), - ) - .await - .context("failed to start the Inspector authentication proxy") - .map_err(AppFailure::from)?; - let command = allowlisted_npx_environment( - inspector_command(proxy.url().as_str(), method).cwd(self.config.root()), - ); - let process_result = self - .runner - .run_async(&command) + self.with_managed_authenticated_target( + mode, + &server_id, + standalone, + protocol_version, + |token, _| async move { + let endpoint = GatewayClient::new( + gateway_topology(mode), + self.base_url()?, + &operation_server_id, + &token, + ) + .context("failed to construct the Inspector gateway endpoint") + .map_err(AppFailure::from)? + .endpoint() + .clone(); + let proxy = AuthProxy::start_with_protocol_version( + endpoint, + &token, + Some(protocol_version.wire_version()), + ) .await - .map_err(AppFailure::from); - let shutdown_result = proxy - .shutdown() - .await - .context("failed to stop the Inspector authentication proxy") - .map_err(AppFailure::from); - finish_with_cleanup(process_result.err(), shutdown_result) - }) + .context("failed to start the Inspector authentication proxy") + .map_err(AppFailure::from)?; + let command = allowlisted_npx_environment( + inspector_command(proxy.url().as_str(), method).cwd(self.config.root()), + ); + let process_result = self + .runner + .run_async(&command) + .await + .map_err(AppFailure::from); + let shutdown_result = proxy + .shutdown() + .await + .context("failed to stop the Inspector authentication proxy") + .map_err(AppFailure::from); + finish_with_cleanup(process_result.err(), shutdown_result) + }, + ) .await } } diff --git a/src/runtime/live/mod.rs b/src/runtime/live/mod.rs index ab5109c..c2d1560 100644 --- a/src/runtime/live/mod.rs +++ b/src/runtime/live/mod.rs @@ -12,6 +12,7 @@ impl RuntimeContext { pub(super) async fn run_live( &self, lane: SemanticLane, + standalone: bool, group: LiveGroup, protocol_version: &ProtocolVersion, ) -> AppResult<()> { @@ -30,11 +31,11 @@ impl RuntimeContext { ) } SemanticLane::BuiltInDataPlane => { - self.run_routed_live(StackMode::Controlplane, group, protocol_version) + self.run_routed_live(StackMode::Controlplane, false, group, protocol_version) .await } SemanticLane::ExternalDataPlane => { - self.run_routed_live(StackMode::Dataplane, group, protocol_version) + self.run_routed_live(StackMode::Dataplane, standalone, group, protocol_version) .await } } @@ -43,29 +44,110 @@ impl RuntimeContext { async fn run_routed_live( &self, topology: StackMode, + standalone: bool, group: LiveGroup, protocol_version: &ProtocolVersion, ) -> AppResult<()> { let server_id = self.default_server_id().to_owned(); - self.with_managed_test_target(topology, &server_id, || async { - match group { - LiveGroup::Mcp => { - self.run_controlplane_make(topology, "test-mcp-protocol-e2e", protocol_version) - } - LiveGroup::Rbac => { - self.run_controlplane_make(topology, "test-mcp-rbac", protocol_version) - } - LiveGroup::Protocol => self.run_controlplane_make( + if standalone { + return self + .with_managed_authenticated_target( topology, - "test-protocol-compliance-gateway", + &server_id, + true, protocol_version, - ), - LiveGroup::All => self.run_live_all(topology, protocol_version), - } + |token, tool_names| async move { + self.run_standalone_live( + topology, + group, + protocol_version, + token, + tool_names, + ) + .await + }, + ) + .await; + } + self.with_managed_test_target(topology, &server_id, || async { + self.run_live_group(topology, group, protocol_version) }) .await } + async fn run_standalone_live( + &self, + topology: StackMode, + group: LiveGroup, + protocol_version: &ProtocolVersion, + token: String, + tool_names: Vec, + ) -> AppResult<()> { + let server_id = self.default_server_id().to_owned(); + let config = ProbeConfig { + mode: gateway_topology(topology), + base_url: self.base_url()?.to_owned(), + server_id: server_id.clone(), + bearer_token: token.clone(), + config_timeout: Duration::ZERO, + retry_interval: Duration::ZERO, + request_timeout: Duration::from_secs( + self.environment_u64("CF_PROBE_REQUEST_TIMEOUT", 30)?, + ), + protocol_version: protocol_version.wire_version().to_owned(), + tool_names, + output_style: OutputStyle::stdout(), + }; + let transport = + GatewayClient::builder(config.mode, &config.base_url, &config.server_id, &token) + .protocol_version(config.protocol_version.clone()) + .build() + .context("failed to construct the standalone live gateway endpoint") + .map_err(AppFailure::from)?; + let started = std::time::Instant::now(); + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + let result = run_probe(&transport, &config, &mut output) + .await + .map_err(AppFailure::from); + println!( + "{}", + OutputStyle::stdout().test_result( + if result.is_ok() { + TestStatus::Pass + } else { + TestStatus::Fail + }, + &format!("live::standalone-{}", live_group_label(group)), + Some(started.elapsed()), + None, + ) + ); + result + } + + fn run_live_group( + &self, + topology: StackMode, + group: LiveGroup, + protocol_version: &ProtocolVersion, + ) -> AppResult<()> { + match group { + LiveGroup::Mcp => { + self.run_controlplane_make(topology, "test-mcp-protocol-e2e", protocol_version) + } + LiveGroup::Rbac => { + self.run_controlplane_make(topology, "test-mcp-rbac", protocol_version) + } + LiveGroup::Protocol => self.run_controlplane_make( + topology, + "test-protocol-compliance-gateway", + protocol_version, + ), + LiveGroup::All => self.run_live_all(topology, protocol_version), + } + } + fn run_controlplane_make( &self, topology: StackMode, @@ -79,9 +161,8 @@ impl RuntimeContext { .arg(self.config.controlplane_dir().as_os_str()) .arg(target); let command = self.live_protocol_environment(command, protocol_version)?; - self.runner - .run(&self.compose_environment(command, topology, false)?) - .map_err(AppFailure::from) + let command = self.compose_environment(command, topology, false)?; + self.runner.run(&command).map_err(AppFailure::from) })(); let status = if result.is_ok() { TestStatus::Pass @@ -136,6 +217,15 @@ impl RuntimeContext { } } +const fn live_group_label(group: LiveGroup) -> &'static str { + match group { + LiveGroup::Mcp => "mcp", + LiveGroup::Rbac => "rbac", + LiveGroup::Protocol => "protocol", + LiveGroup::All => "all", + } +} + fn add_live_protocol_environment( command: CommandSpec, hook_directory: &Path, diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 678789d..09626fc 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -126,35 +126,46 @@ impl RuntimeDispatcher { Action::Stack(action) => StackWorkflow(&self.context).execute(action).await, Action::Probe { topology, + standalone, protocol_version, } => { ProbeWorkflow(&self.context) - .execute(topology, &protocol_version) + .execute(topology, standalone, &protocol_version) .await } Action::Load(args) => PerformanceWorkflow(&self.context).execute(args).await, Action::Live { lane, + standalone, group, protocol_version, } => { LiveWorkflow(&self.context) - .execute(lane, group, &protocol_version) + .execute(lane, standalone, group, &protocol_version) .await } Action::Conformance(action) => ConformanceWorkflow(&self.context).execute(action).await, Action::Ci(action) => CiWorkflow(&self.context).execute(action).await, - Action::Debug(DebugAction::Token { kind, server_id }) => { - self.context.print_token(kind, server_id).await - } + Action::Debug(DebugAction::Token { + kind, + server_id, + standalone, + }) => self.context.print_token(kind, server_id, standalone).await, Action::Debug(DebugAction::Inspect { topology, + standalone, protocol_version, method, server_id, }) => { self.context - .inspect(topology, &protocol_version, &method, server_id.as_deref()) + .inspect( + topology, + standalone, + &protocol_version, + &method, + server_id.as_deref(), + ) .await } } @@ -171,9 +182,12 @@ impl<'a, R: ProcessRunner> ProbeWorkflow<'a, R> { async fn execute( &self, topology: StackMode, + standalone: bool, protocol_version: &ProtocolVersion, ) -> AppResult<()> { - self.0.run_probe(topology, protocol_version).await + self.0 + .run_probe(topology, standalone, protocol_version) + .await } } @@ -187,10 +201,13 @@ impl<'a, R: ProcessRunner> LiveWorkflow<'a, R> { async fn execute( &self, lane: SemanticLane, + standalone: bool, group: LiveGroup, protocol_version: &ProtocolVersion, ) -> AppResult<()> { - self.0.run_live(lane, group, protocol_version).await + self.0 + .run_live(lane, standalone, group, protocol_version) + .await } } @@ -207,7 +224,16 @@ impl<'a, R: ProcessRunner> CiWorkflow<'a, R> { } impl RuntimeContext { - async fn print_token(&self, kind: CliTokenKind, server_id: Option) -> AppResult<()> { + async fn print_token( + &self, + kind: CliTokenKind, + server_id: Option, + standalone: bool, + ) -> AppResult<()> { + if standalone { + println!("{}", self.standalone_dataplane_token(true)?.value); + return Ok(()); + } let token = match kind { CliTokenKind::Scoped => { let server_id = server_id.unwrap_or_else(|| self.default_server_id().to_owned()); diff --git a/src/runtime/performance/mod.rs b/src/runtime/performance/mod.rs index fed45d2..271d54b 100644 --- a/src/runtime/performance/mod.rs +++ b/src/runtime/performance/mod.rs @@ -14,9 +14,17 @@ impl RuntimeContext { &server_id, args.standalone, args.observability, + &args.protocol_version, |token, standalone_tool_names| async move { + let project = if args.standalone { + self.standalone_dataplane_project(args.observability) + .with_profiles(["performance"]) + } else { + self.performance_compose_project(args.topology, args.observability) + }; let command = LocustCommand::new_with_protocol_version( &self.config, + project, args.topology, &settings, &token, diff --git a/src/runtime/probe.rs b/src/runtime/probe.rs index 1f2f50a..50b59f8 100644 --- a/src/runtime/probe.rs +++ b/src/runtime/probe.rs @@ -6,40 +6,48 @@ impl RuntimeContext { pub(super) async fn run_probe( &self, topology: StackMode, + standalone: bool, protocol_version: &ProtocolVersion, ) -> AppResult<()> { let server_id = self.default_server_id().to_owned(); - self.with_managed_authenticated_target(topology, &server_id, false, |token, _| async { - let config = ProbeConfig { - mode: gateway_topology(topology), - base_url: self.base_url()?.to_owned(), - server_id: server_id.clone(), - bearer_token: token, - config_timeout: Duration::from_secs( - self.environment_u64("CF_PROBE_CONFIG_TIMEOUT", 120)?, - ), - retry_interval: Duration::from_secs(5), - request_timeout: Duration::from_secs( - self.environment_u64("CF_PROBE_REQUEST_TIMEOUT", 30)?, - ), - protocol_version: protocol_version.wire_version().to_owned(), - output_style: OutputStyle::stdout(), - }; - let transport = GatewayClient::builder( - config.mode, - &config.base_url, - &config.server_id, - &config.bearer_token, - ) - .protocol_version(config.protocol_version.clone()) - .build() - .map_err(|error| AppFailure::from(anyhow!(error)))?; - let stdout = std::io::stdout(); - let mut output = stdout.lock(); - run_probe(&transport, &config, &mut output) - .await - .map_err(AppFailure::from) - }) + self.with_managed_authenticated_target( + topology, + &server_id, + standalone, + protocol_version, + |token, tool_names| async { + let config = ProbeConfig { + mode: gateway_topology(topology), + base_url: self.base_url()?.to_owned(), + server_id: server_id.clone(), + bearer_token: token, + config_timeout: Duration::from_secs( + self.environment_u64("CF_PROBE_CONFIG_TIMEOUT", 120)?, + ), + retry_interval: Duration::from_secs(5), + request_timeout: Duration::from_secs( + self.environment_u64("CF_PROBE_REQUEST_TIMEOUT", 30)?, + ), + protocol_version: protocol_version.wire_version().to_owned(), + tool_names, + output_style: OutputStyle::stdout(), + }; + let transport = GatewayClient::builder( + config.mode, + &config.base_url, + &config.server_id, + &config.bearer_token, + ) + .protocol_version(config.protocol_version.clone()) + .build() + .map_err(|error| AppFailure::from(anyhow!(error)))?; + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + run_probe(&transport, &config, &mut output) + .await + .map_err(AppFailure::from) + }, + ) .await } } diff --git a/src/runtime/session.rs b/src/runtime/session.rs index 4f56b0a..77b972a 100644 --- a/src/runtime/session.rs +++ b/src/runtime/session.rs @@ -17,6 +17,8 @@ for _, key in ipairs(redis.call('KEYS', '*UserConfig*')) do end return 0 "#; +const STANDALONE_TENANT_ID: &str = "cf-integration"; +const STANDALONE_USER_ID: &str = "cf-integration@example.invalid"; struct ManagedSessionScope<'a, R> { runtime: &'a RuntimeContext, @@ -25,6 +27,15 @@ struct ManagedSessionScope<'a, R> { token: Option, } +struct ManagedTarget<'a> { + topology: StackMode, + server_id: &'a str, + standalone: bool, + project: ComposeProject, + observability: bool, + protocol_version: &'a ProtocolVersion, +} + impl<'a, R: ProcessRunner> ManagedSessionScope<'a, R> { fn new(runtime: &'a RuntimeContext, topology: StackMode, standalone: bool) -> Self { Self { @@ -37,24 +48,18 @@ impl<'a, R: ProcessRunner> ManagedSessionScope<'a, R> { async fn finish(self, primary: AppResult<()>) -> AppResult<()> { let mut cleanup_failures = Vec::new(); - if self.standalone - && self - .token - .as_ref() - .is_some_and(|token| token.catalog_id.is_some()) - && let Err(error) = self.runtime.restore_control_plane_gateway().await - { - cleanup_failures.push(error); - } if let Some(token) = self.token.as_ref() && let Err(error) = self.runtime.revoke_managed_token(token).await { cleanup_failures.push(error); } - if let Err(error) = self - .runtime - .cleanup_quiet(topology_selection(self.topology), CleanupKind::Down) - { + let cleanup = if self.standalone { + self.runtime.cleanup_standalone_dataplane(CleanupKind::Down) + } else { + self.runtime + .cleanup_quiet(topology_selection(self.topology), CleanupKind::Down) + }; + if let Err(error) = cleanup { cleanup_failures.push(error); } finish_with_cleanup_failures(primary.err(), cleanup_failures) @@ -62,6 +67,33 @@ impl<'a, R: ProcessRunner> ManagedSessionScope<'a, R> { } impl RuntimeContext { + pub(super) fn standalone_dataplane_token( + &self, + observability: bool, + ) -> AppResult { + let command = self.standalone_dataplane_project(observability).command([ + "run", + "--rm", + "--no-deps", + "config_writer", + "token", + STANDALONE_TENANT_ID, + STANDALONE_USER_ID, + ]); + let command = self.standalone_dataplane_environment(command, true)?; + let output = self.runner.capture_stdout(&command)?; + let token = std::str::from_utf8(&output) + .context("standalone dataplane token helper returned non-UTF-8 output") + .map_err(AppFailure::from)? + .trim(); + if token.split('.').count() != 3 { + return Err(AppFailure::from(anyhow!( + "standalone dataplane token helper returned an invalid JWT" + ))); + } + Ok(ManagedBearerToken::unmanaged(token.to_owned())) + } + pub(super) async fn with_managed_test_target( &self, topology: StackMode, @@ -88,6 +120,7 @@ impl RuntimeContext { topology: StackMode, server_id: &str, standalone: bool, + protocol_version: &ProtocolVersion, operation: F, ) -> AppResult<()> where @@ -95,11 +128,14 @@ impl RuntimeContext { Fut: Future>, { self.with_managed_authenticated_target_project( - topology, - server_id, - standalone, - self.compose_project(topology), - true, + ManagedTarget { + topology, + server_id, + standalone, + project: self.compose_project(topology), + observability: true, + protocol_version, + }, operation, ) .await @@ -111,6 +147,7 @@ impl RuntimeContext { server_id: &str, standalone: bool, observability: bool, + protocol_version: &ProtocolVersion, operation: F, ) -> AppResult<()> where @@ -118,11 +155,14 @@ impl RuntimeContext { Fut: Future>, { self.with_managed_authenticated_target_project( - topology, - server_id, - standalone, - self.performance_compose_project(topology, observability), - observability, + ManagedTarget { + topology, + server_id, + standalone, + project: self.performance_compose_project(topology, observability), + observability, + protocol_version, + }, operation, ) .await @@ -130,37 +170,57 @@ impl RuntimeContext { async fn with_managed_authenticated_target_project( &self, - topology: StackMode, - server_id: &str, - standalone: bool, - project: ComposeProject, - observability: bool, + target: ManagedTarget<'_>, operation: F, ) -> AppResult<()> where F: FnOnce(String, Vec) -> Fut, Fut: Future>, { + let ManagedTarget { + topology, + server_id, + standalone, + project, + observability, + protocol_version, + } = target; if standalone && topology != StackMode::Dataplane { return Err(AppFailure::from(anyhow!( "standalone mode requires the external lane" ))); } let mut scope = ManagedSessionScope::new(self, topology, standalone); - let primary = match self - .stack_up_with_project(topology, false, project, false, observability) - .await - { + let stack_result = if standalone { + self.stack_up_standalone_dataplane(false, observability) + .await + } else { + self.stack_up_with_project(topology, false, project, false, observability) + .await + }; + let primary = match stack_result { Ok(()) => match self .prepare_authenticated_target(topology, server_id, standalone) .await { - Ok(()) => match self.managed_bearer_token(topology, server_id).await { + Ok(()) => match if standalone { + self.start_standalone_fixture(protocol_version, observability) + .await + .and_then(|()| self.standalone_dataplane_token(observability)) + } else { + self.managed_bearer_token(topology, server_id).await + } { Ok(token) => { let value = token.value.clone(); scope.token = Some(token); let tool_names = if standalone { - self.isolate_external_dataplane(server_id, &value).await + self.publish_standalone_conformance_config( + server_id, + protocol_version.wire_version(), + &value, + observability, + ) + .await } else { Ok(Vec::new()) }; @@ -178,6 +238,19 @@ impl RuntimeContext { scope.finish(primary).await } + async fn start_standalone_fixture( + &self, + protocol_version: &ProtocolVersion, + observability: bool, + ) -> AppResult<()> { + let server_era = match protocol_version { + ProtocolVersion::Modern => ConformanceServerEra::Modern, + ProtocolVersion::Legacy => ConformanceServerEra::Legacy, + }; + self.start_conformance_service(StackMode::Dataplane, server_era, true, observability) + .await + } + async fn prepare_authenticated_target( &self, topology: StackMode, @@ -235,70 +308,6 @@ impl RuntimeContext { } } - async fn isolate_external_dataplane( - &self, - server_id: &str, - token: &str, - ) -> AppResult> { - let project = self.compose_project(StackMode::Dataplane); - let command = project.command([ - "--profile", - "standalone-load", - "up", - "--detach", - "--wait", - "standalone_load_backend", - ]); - let command = self.compose_environment(command, StackMode::Dataplane, true)?; - self.runner.run(&command)?; - let command = StackCommandPlan::stop_service(project.clone(), "gateway"); - let command = - self.compose_environment(command.command().clone(), StackMode::Dataplane, true)?; - self.runner.run(&command)?; - let command = project.command([ - "run", - "--rm", - "--no-deps", - "-e", - "MCPGATEWAY_BEARER_TOKEN", - "--entrypoint", - "python3", - "gateway", - "/opt/contextforge-integration/prepare_standalone_config.py", - server_id, - ProtocolVersion::Modern.wire_version(), - ]); - let command = self - .compose_environment(command, StackMode::Dataplane, true)? - .env("MCPGATEWAY_BEARER_TOKEN", token); - let tool_names = self.capture_text(&command)?; - let tool_names = serde_json::from_str::>(&tool_names) - .context("standalone config helper returned invalid tool names") - .map_err(AppFailure::from)?; - if tool_names.is_empty() { - return Err(AppFailure::from(anyhow!( - "standalone Redis config for server {server_id} contains no tools" - ))); - } - let command = StackCommandPlan::restart_service(project, "dataplane"); - let command = - self.compose_environment(command.command().clone(), StackMode::Dataplane, true)?; - self.runner.run(&command)?; - self.wait_for_public_endpoint(StackMode::Dataplane, false) - .await?; - Ok(tool_names) - } - - async fn restore_control_plane_gateway(&self) -> AppResult<()> { - let project = self.compose_project(StackMode::Dataplane); - let command = StackCommandPlan::start_service(project, "gateway"); - let command = - self.compose_environment(command.command().clone(), StackMode::Dataplane, true)?; - self.runner.run(&command)?; - self.wait_for_public_endpoint(StackMode::Controlplane, false) - .await - } - fn dataplane_redis_container(&self) -> AppResult { let project = required_text( &self.config.integration_project().value, diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index 1290946..c66f378 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -9,8 +9,17 @@ const COMPOSE_PROTOCOL_VERSION_ENV: &str = "MCP_PROTOCOL_VERSION"; impl RuntimeContext { pub(super) async fn execute_stack(&self, action: StackAction) -> AppResult<()> { match action { - StackAction::Up { topology, fresh } => { - self.stack_up_for_conformance(topology, fresh).await?; + StackAction::Up { + topology, + protocol_version, + fresh, + standalone, + } => { + if standalone { + self.stack_up_standalone_dataplane(fresh, true).await?; + } else { + self.stack_up_for_conformance(topology, fresh).await?; + } let build_log = self .config @@ -20,8 +29,12 @@ impl RuntimeContext { let quiet_runner = LoggingProcessRunner::new(&self.runner, &build_log); let quiet_runtime = RuntimeContext::new(self.config.clone(), quiet_runner); let build_progress = Activity::spinner("Building conformance image"); + let server_era = match protocol_version { + ProtocolVersion::Modern => ConformanceServerEra::Modern, + ProtocolVersion::Legacy => ConformanceServerEra::Legacy, + }; let build_result = quiet_runtime - .build_conformance_service(topology, DEFAULT_CONFORMANCE_SERVER_ERA, false) + .build_conformance_service(topology, server_era, standalone, true) .await; build_progress.finish(build_result.is_ok()); if let Err(error) = build_result { @@ -33,56 +46,118 @@ impl RuntimeContext { return Err(error); } - self.start_conformance_containers(topology, DEFAULT_CONFORMANCE_SERVER_ERA, false) + self.start_conformance_containers(topology, server_era, standalone, true) .await?; - let conformance_endpoint = self.conformance_fixture_endpoint(topology)?; + if standalone { + let token = self.standalone_dataplane_token(true)?; + self.publish_standalone_conformance_config( + self.default_server_id(), + protocol_version.wire_version(), + &token.value, + true, + ) + .await?; + } + let conformance_endpoint = + self.conformance_fixture_endpoint(topology, standalone, true)?; Activity::completed("Integration stack ready"); self.print_stack_summary(topology, &conformance_endpoint) } - StackAction::Down { lane, volumes } => { + StackAction::Down { + lane, + volumes, + standalone, + } => { let kind = if volumes { CleanupKind::Reset } else { CleanupKind::Down }; - let primary = self.cleanup(lane, kind).err(); - finish_with_cleanup(primary, self.cleanup_observability(kind, true)) + if standalone { + self.cleanup_standalone_dataplane(kind) + } else { + self.cleanup(lane, kind) + } } - StackAction::Status(mode) => { - self.require_mode_sources(mode)?; - let command = StackCommandPlan::status(self.conformance_compose_project(mode)); - Ok(self.runner.run(&self.compose_environment( + StackAction::Status { + topology, + standalone, + } => { + let project = self.stack_command_project(topology, standalone)?; + let command = StackCommandPlan::status(project); + let command = self.stack_command_environment( command.command().clone(), - mode, - true, - )?)?) + topology, + standalone, + )?; + Ok(self.runner.run(&command)?) } StackAction::Logs { - topology: mode, + topology, services, + standalone, } => { - self.require_mode_sources(mode)?; - let command = - StackCommandPlan::logs(self.conformance_compose_project(mode), services); - Ok(self.runner.run(&self.compose_environment( + let project = self.stack_command_project(topology, standalone)?; + let command = StackCommandPlan::logs(project, services); + let command = self.stack_command_environment( command.command().clone(), - mode, - true, - )?)?) + topology, + standalone, + )?; + Ok(self.runner.run(&command)?) } - StackAction::Config(mode) => { - self.require_mode_sources(mode)?; - if mode == StackMode::Dataplane { + StackAction::Config { + topology, + standalone, + } => { + if !standalone { + self.require_mode_sources(topology)?; + } + if topology == StackMode::Dataplane && !standalone { self.validate_compose_contract()?; } - let command = - StackCommandPlan::config(self.conformance_compose_project(mode), mode); - Ok(self.runner.run(&self.compose_environment( - command.command().clone(), - mode, - true, - )?)?) + let project = self.stack_command_project(topology, standalone)?; + let command = if standalone { + project.command(["config", "--no-interpolate", "--no-env-resolution"]) + } else { + StackCommandPlan::config(project, topology) + .command() + .clone() + }; + let command = self.stack_command_environment(command, topology, standalone)?; + Ok(self.runner.run(&command)?) + } + } + } + + fn stack_command_project( + &self, + topology: StackMode, + standalone: bool, + ) -> AppResult { + if standalone { + if topology != StackMode::Dataplane { + return Err(AppFailure::from(anyhow!( + "standalone mode requires the external lane" + ))); } + Ok(self.standalone_conformance_compose_project(true)) + } else { + self.require_mode_sources(topology)?; + Ok(self.conformance_compose_project(topology)) + } + } + + fn stack_command_environment( + &self, + command: CommandSpec, + topology: StackMode, + standalone: bool, + ) -> AppResult { + if standalone { + self.standalone_dataplane_environment(command, true) + } else { + self.compose_environment(command, topology, true) } } @@ -106,7 +181,11 @@ impl RuntimeContext { .await } - pub(super) async fn stack_up_standalone_conformance(&self, fresh: bool) -> AppResult<()> { + pub(super) async fn stack_up_standalone_dataplane( + &self, + fresh: bool, + observability: bool, + ) -> AppResult<()> { if !self.config.dataplane_ref().value.is_empty() { self.ensure_dataplane()?; } @@ -126,7 +205,9 @@ impl RuntimeContext { self.cleanup_standalone_dataplane(CleanupKind::Reset)?; } self.ensure_other_stack_stopped(StackMode::Dataplane)?; - self.start_observability()?; + if observability { + self.start_observability()?; + } let mut arguments = vec![ OsString::from("up"), @@ -141,7 +222,9 @@ impl RuntimeContext { .into_iter() .map(OsString::from), ); - let command = self.standalone_dataplane_project().command(arguments); + let command = self + .standalone_dataplane_project(observability) + .command(arguments); let command = self.standalone_dataplane_environment(command, true)?; self.runner.run_async(&command).await?; self.wait_for_public_endpoint(StackMode::Dataplane, false) @@ -317,18 +400,24 @@ impl RuntimeContext { .with_controlplane_conformance_overlay(self.config.asset_root()) } - pub(super) fn standalone_dataplane_project(&self) -> ComposeProject { - ComposeProject::standalone_dataplane( + pub(super) fn standalone_dataplane_project(&self, observability: bool) -> ComposeProject { + let project = ComposeProject::standalone_dataplane( self.config.asset_root(), self.config.integration_project().value.clone(), !self.config.dataplane_ref().value.is_empty(), - ) - .with_dataplane_observability(self.config.asset_root()) - .with_conformance_overlay(self.config.asset_root()) + ); + if observability { + project.with_dataplane_observability(self.config.asset_root()) + } else { + project + } } - pub(super) fn standalone_conformance_compose_project(&self) -> ComposeProject { - self.standalone_dataplane_project() + pub(super) fn standalone_conformance_compose_project( + &self, + observability: bool, + ) -> ComposeProject { + self.standalone_dataplane_project(observability) .with_conformance_fixture(self.config.asset_root()) } @@ -412,12 +501,14 @@ impl RuntimeContext { checkout_labels: bool, ) -> AppResult { let controlplane_image = self.config.controlplane_image().resolved().to_owned(); - let command = self.compose_environment_with_controlplane_image( - command, - StackMode::Dataplane, - false, - controlplane_image, - )?; + let command = self + .compose_environment_with_controlplane_image( + command, + StackMode::Dataplane, + false, + controlplane_image, + )? + .env("MCP_SERVER_ID", self.default_server_id()); if checkout_labels && !self.config.dataplane_ref().value.is_empty() { self.add_dataplane_checkout_labels(command) } else { @@ -1079,7 +1170,7 @@ impl RuntimeContext { pub(super) fn cleanup_standalone_dataplane(&self, kind: CleanupKind) -> AppResult<()> { let project = self - .standalone_conformance_compose_project() + .standalone_conformance_compose_project(true) .with_profiles(["conformance"]); let command = StackCommandPlan::cleanup(project, kind); let command = self.standalone_dataplane_environment(command.command().clone(), false)?; @@ -1137,24 +1228,6 @@ impl RuntimeContext { finish_with_cleanup_failures(None, cleanup_failures) } - fn cleanup_observability(&self, kind: CleanupKind, inherit_output: bool) -> AppResult<()> { - let project = self.observability_compose_project(); - let command = StackCommandPlan::cleanup(project, kind); - let command = self.observability_environment(command.command().clone()); - let primary = self - .run_cleanup_command(&command, inherit_output) - .map_err(AppFailure::from) - .err(); - let project = format!( - "{}-observability", - self.config.integration_project().value.to_string_lossy() - ); - finish_with_cleanup( - primary, - self.remove_project_by_label(&project, kind, inherit_output), - ) - } - fn remove_project_by_label( &self, project: &str, From 0c2552285a444e5e1ad8a16c76c5c379bf8e90f9 Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 4 Sep 2026 21:18:43 +0100 Subject: [PATCH 3/7] fix: discover standalone conformance fixture catalogs Signed-off-by: lucarlig --- CHANGELOG.md | 6 + .../conformance/write_dataplane_config.mjs | 136 +++++++++++------- src/performance/python_adapter_tests.rs | 63 ++++++++ 3 files changed, 156 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba13e89..53eed92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Fixed + +- Discover standalone conformance routes and input schemas from the running + fixture, including paginated diagnostic tools. Fixture discovery errors stop + setup before a baseline can be blessed. + ## [0.3.1] - 2026-09-04 ### Added diff --git a/scripts/conformance/write_dataplane_config.mjs b/scripts/conformance/write_dataplane_config.mjs index ff59ea1..0bf2fa6 100644 --- a/scripts/conformance/write_dataplane_config.mjs +++ b/scripts/conformance/write_dataplane_config.mjs @@ -1,38 +1,94 @@ #!/usr/bin/env node /** Publish one conformance route through the dataplane's current serializer. */ +import { realpathSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; const DATAPLANE_CONFIG_URL = 'http://dataplane:4445/contextforge-rs/admin/userconfigs'; const DATAPLANE_TOKEN_URL = 'http://dataplane:4445/contextforge-rs/admin/tokens'; -const FIXTURE_TOOLS = [ - 'test_simple_text', - 'test_image_content', - 'test_audio_content', - 'test_embedded_resource', - 'test_multiple_content_types', - 'test_tool_with_logging', - 'test_tool_with_progress', - 'test_error_handling', - 'test_reconnection', - 'test_sampling', - 'test_elicitation', - 'test_elicitation_sep1034_defaults', - 'test_elicitation_sep1330_enums', - 'json_schema_2020_12_tool', -]; -const FIXTURE_RESOURCES = [ - 'test://static-text', - 'test://static-binary', - 'test://watched-resource', -]; -const FIXTURE_RESOURCE_TEMPLATES = ['test://template/{id}/data']; -const FIXTURE_PROMPTS = [ - 'test_simple_prompt', - 'test_prompt_with_arguments', - 'test_prompt_with_embedded_resource', - 'test_prompt_with_image', -]; +/** Discover the pinned fixture instead of maintaining a second, incomplete catalog. */ +export async function fixtureCatalog(backendUrl, protocolVersion) { + let requestId = 0; + let sessionId; + const modern = protocolVersion >= '2026-07-28'; + const clientInfo = { name: 'cf-integration-config', version: '1.0' }; + async function rpc(method, params = {}, notification = false) { + if (modern) params = { ...params, _meta: { + 'io.modelcontextprotocol/protocolVersion': protocolVersion, + 'io.modelcontextprotocol/clientInfo': clientInfo, + 'io.modelcontextprotocol/clientCapabilities': {}, + } }; + const id = notification ? undefined : ++requestId; + const response = await fetch(backendUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': protocolVersion, + 'mcp-method': method, + ...(sessionId ? { 'mcp-session-id': sessionId } : {}), + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) throw new Error(`fixture ${method} failed: HTTP ${response.status}`); + sessionId = response.headers.get('mcp-session-id') ?? sessionId; + if (notification) { await response.body?.cancel(); return; } + const body = await response.text(); + const messages = response.headers.get('content-type')?.startsWith('text/event-stream') + ? body.split(/\r?\n\r?\n/).filter((event) => /^data:/m.test(event)).map((event) => + JSON.parse(event.split(/\r?\n/).filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()).join('\n'))) + : [JSON.parse(body)]; + const message = messages.find((message) => message.id === id); + if (!message || message.error || !message.result) { + throw new Error(`fixture ${method} did not return a successful result`); + } + return message.result; + } + async function list(method, key) { + const items = []; + const cursors = new Set(); + let cursor; + do { + const page = await rpc(method, cursor ? { cursor } : {}); + if (!Array.isArray(page[key])) throw new Error(`fixture ${method} has no ${key} array`); + items.push(...page[key]); + cursor = page.nextCursor; + if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursors.has(cursor))) { + throw new Error(`fixture ${method} returned an invalid or repeated cursor`); + } + cursors.add(cursor); + } while (cursor !== undefined); + return items; + } + try { + if (modern) await rpc('server/discover'); + else { + await rpc('initialize', { protocolVersion, capabilities: {}, clientInfo }); + await rpc('notifications/initialized', {}, true); + } + const tools = await list('tools/list', 'tools'); + if (!tools.length || tools.some((tool) => !tool.name || !tool.inputSchema + || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema))) { + throw new Error('fixture tools must include names and input schemas'); + } + return { + tools: tools.map((tool) => tool.name), + toolSchemas: Object.fromEntries(tools.map((tool) => [tool.name, tool.inputSchema])), + resources: (await list('resources/list', 'resources')).map((resource) => resource.uri), + resourceTemplates: (await list('resources/templates/list', 'resourceTemplates')).map((resource) => resource.uriTemplate), + prompts: (await list('prompts/list', 'prompts')).map((prompt) => prompt.name), + }; + } finally { + if (sessionId) await fetch(backendUrl, { + method: 'DELETE', + headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': protocolVersion }, + signal: AbortSignal.timeout(10000), + }).then((response) => response.body?.cancel()); + } +} function fail(message) { process.stderr.write(`${message}\n`); @@ -87,20 +143,7 @@ function config(serverId, backendUrl, protocolVersion, catalogs) { add_headers: {}, remove_headers: [], completion: {}, - tool_schemas: Object.fromEntries(catalogs.tools.map((name) => [name, {}])), - // Older published dataplanes used aliases instead of service routes. - tool_name_aliases: catalogs.tools.map((name) => ({ - downstream_prefixed_name: name, - upstream_name: name, - })), - resource_uri_aliases: catalogs.resources.map((name) => ({ - downstream_prefixed_uri: name, - upstream_uri: name, - })), - prompt_name_aliases: catalogs.prompts.map((name) => ({ - downstream_prefixed_name: name, - upstream_name: name, - })), + tool_schemas: catalogs.toolSchemas ?? {}, }, }, tools: routes(catalogs.tools, backendName), @@ -175,12 +218,7 @@ async function main() { if (!token) fail('MCP_CONFORMANCE_TOKEN is required'); const catalogs = mode === 'fixture' - ? { - tools: FIXTURE_TOOLS, - resources: FIXTURE_RESOURCES, - resourceTemplates: FIXTURE_RESOURCE_TEMPLATES, - prompts: FIXTURE_PROMPTS, - } + ? await fixtureCatalog(backendUrl, protocolVersion) : { tools: stringArray(toolNamesJson, 'tool-names-json'), resources: [], @@ -194,4 +232,4 @@ async function main() { process.stdout.write(`${JSON.stringify(catalogs.tools)}\n`); } -await main(); +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main(); diff --git a/src/performance/python_adapter_tests.rs b/src/performance/python_adapter_tests.rs index 8c0b2db..9c26fc9 100644 --- a/src/performance/python_adapter_tests.rs +++ b/src/performance/python_adapter_tests.rs @@ -575,3 +575,66 @@ assert response.successes == 1 and not response.failures String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn standalone_fixture_catalog_preserves_discovered_routes_and_schemas() { + let script = r#" +import assert from 'node:assert/strict'; +const scriptPath = process.argv[1]; +process.argv[1] = undefined; +const { fixtureCatalog } = await import(scriptPath); +const schema = { type: 'object', properties: { value: { type: 'string', 'x-mcp-header': 'Value' } } }; +const calls = []; +globalThis.fetch = async (_, options) => { + const request = JSON.parse(options.body); + calls.push(request); + assert.equal(request.params._meta['io.modelcontextprotocol/protocolVersion'], '2026-07-28'); + assert.equal(options.headers['mcp-method'], request.method); + let result; + switch (request.method) { + case 'server/discover': result = {}; break; + case 'tools/list': result = request.params.cursor + ? { tools: [{ name: 'new_diagnostic_tool', inputSchema: schema }] } + : { tools: [{ name: 'first', inputSchema: {} }], nextCursor: 'page2' }; break; + case 'resources/list': result = { resources: [{ uri: 'test://new-resource' }] }; break; + case 'resources/templates/list': result = { resourceTemplates: [{ uriTemplate: 'test://new/{id}' }] }; break; + case 'prompts/list': result = { prompts: [{ name: 'new_prompt' }] }; break; + default: assert.fail(request.method); + } + const message = JSON.stringify({ jsonrpc: '2.0', id: request.id, result }); + return new Response(request.params.cursor ? `event: message\ndata: ${message}\n\n` : message, + { headers: { 'content-type': request.params.cursor ? 'text/event-stream' : 'application/json' } }); +}; +const catalog = await fixtureCatalog('http://fixture/mcp', '2026-07-28'); +assert.deepEqual(catalog.tools, ['first', 'new_diagnostic_tool']); +assert.deepEqual(catalog.toolSchemas.new_diagnostic_tool, schema); +assert.deepEqual(catalog.resources, ['test://new-resource']); +assert.deepEqual(catalog.resourceTemplates, ['test://new/{id}']); +assert.deepEqual(catalog.prompts, ['new_prompt']); +assert.equal(calls.filter((r) => r.method === 'tools/list').length, 2); + +globalThis.fetch = async (_, options) => { + const request = JSON.parse(options.body); + return Response.json({ id: request.id, error: { code: -32603, message: 'fixture failed' } }); +}; +await assert.rejects(fixtureCatalog('http://fixture/mcp', '2026-07-28'), /successful result/); + +globalThis.fetch = async (_, options) => { + const request = JSON.parse(options.body); + return Response.json({ id: request.id, result: request.method === 'server/discover' ? {} : { + tools: [{ name: 'first', inputSchema: {} }], nextCursor: 'repeated', + }}); +}; +await assert.rejects(fixtureCatalog('http://fixture/mcp', '2026-07-28'), /repeated cursor/); +"#; + let output = Command::new("node") + .args(["--input-type=module", "--eval", script]) + .arg(scripts_dir().join("conformance/write_dataplane_config.mjs")) + .output() + .expect("Node fixture catalog test runs"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} From 96b8ddcaf791699ebbb81478bde1456e15022eec Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 4 Sep 2026 21:29:09 +0100 Subject: [PATCH 4/7] test: import fixture helper portably on Windows Signed-off-by: lucarlig --- src/performance/python_adapter_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/performance/python_adapter_tests.rs b/src/performance/python_adapter_tests.rs index 9c26fc9..4ed24d3 100644 --- a/src/performance/python_adapter_tests.rs +++ b/src/performance/python_adapter_tests.rs @@ -580,9 +580,10 @@ assert response.successes == 1 and not response.failures fn standalone_fixture_catalog_preserves_discovered_routes_and_schemas() { let script = r#" import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; const scriptPath = process.argv[1]; process.argv[1] = undefined; -const { fixtureCatalog } = await import(scriptPath); +const { fixtureCatalog } = await import(pathToFileURL(scriptPath).href); const schema = { type: 'object', properties: { value: { type: 'string', 'x-mcp-header': 'Value' } } }; const calls = []; globalThis.fetch = async (_, options) => { From d41d7c45a7d214ca93c64463d03b87f8457b14ac Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 4 Sep 2026 21:41:04 +0100 Subject: [PATCH 5/7] fix: preserve conformance coverage across external modes Signed-off-by: lucarlig --- CHANGELOG.md | 12 ++- README.md | 10 ++- .../docker-compose.cf-dataplane-config.yaml | 14 +++ ...ocker-compose.cf-dataplane-standalone.yaml | 14 --- .../conformance/write_dataplane_config.mjs | 23 ++--- src/app.rs | 21 ++--- src/app_tests.rs | 26 +++++- src/cli_public_tests.rs | 1 - src/infrastructure/assets.rs | 1 + src/infrastructure/compose.rs | 6 ++ .../compose_integration_tests.rs | 47 ++++++++-- src/performance/python_adapter_tests.rs | 81 +++++++++++++++-- src/runtime/conformance/mod.rs | 5 +- src/runtime/live/mod.rs | 86 +------------------ src/runtime/mod.rs | 8 +- 15 files changed, 206 insertions(+), 149 deletions(-) create mode 100644 docker/docker-compose.cf-dataplane-config.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 53eed92..eb659cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,21 +12,27 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - Discover standalone conformance routes and input schemas from the running fixture, including paginated diagnostic tools. Fixture discovery errors stop setup before a baseline can be blessed. +- Made the dataplane config writer available to normal external client conformance + and preserved schemas for its scenario tools. +- Accepted empty pagination cursors and legacy SSE keepalives during discovery, + and used the fixture's protocol era when configuring backends for clients from + a different era. +- Rejected `live --standalone`, which previously reported live-group success after + only a probe. Isolated route checks remain available through `probe --standalone`. ## [0.3.1] - 2026-09-04 ### Added - Added global standalone external-dataplane mode across stack, probe, load, - live, conformance, and debug-token workflows, backed by mocked Redis, + conformance, and debug-token workflows, backed by mocked Redis, ephemeral RSA authentication, and no control-plane services. ### Changed - Published every standalone route through the running dataplane's serializer so mocked Redis snapshots always use that image's current MessagePack schema. -- Added protocol-mode selection to `stack up` and native standalone live checks - for route authentication, lifecycle negotiation, and routed tool calls. +- Added protocol-mode selection to `stack up`. - Left the independent ClickStack service running during explicit stack cleanup as well as managed workflow cleanup. diff --git a/README.md b/README.md index 3be668d..3c46ef9 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ test fixture. It generates an ephemeral RSA key, obtains a test token from the dataplane's local tool endpoint, validates it through the dataplane's loopback JWKS endpoint, and publishes a fresh config through the dataplane serializer. Redis therefore always contains the schema understood by the image under test. +Routes and tool schemas are discovered from every catalog page of the running +fixture, including the selected protocol era's diagnostic tools and prompts. Use `cf-integration --help` for the complete interface. @@ -117,13 +119,13 @@ Groups are `mcp`, `rbac`, `protocol`, and `all` (default): ```bash cf-integration live --lane builtin --protocol-version legacy --group all cf-integration live --lane external --protocol-version modern --group all -cf-integration live --lane external --protocol-version legacy --group all --standalone cf-integration live --lane fixture-direct --protocol-version legacy --group protocol ``` -Normal routed runs execute the upstream control-plane live suites. Standalone -external runs execute the dataplane-native route/auth/protocol contract, with -no control-plane checkout or API calls. +Routed runs execute the upstream control-plane live suites. `live --standalone` +is unsupported because these suites require the control plane. Use +`cf-integration probe --lane external --standalone` for isolated authentication, +protocol, and routed tool-call checks. ## Conformance diff --git a/docker/docker-compose.cf-dataplane-config.yaml b/docker/docker-compose.cf-dataplane-config.yaml new file mode 100644 index 0000000..c557e41 --- /dev/null +++ b/docker/docker-compose.cf-dataplane-config.yaml @@ -0,0 +1,14 @@ +services: + config_writer: + profiles: ["helpers"] + image: node:22-bookworm-slim + labels: + name: cf-dataplane-config-writer + restart: "no" + networks: + - mcpnet + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_dataplane_config.mjs:/opt/contextforge-integration/write_dataplane_config.mjs:ro + entrypoint: + - node + - /opt/contextforge-integration/write_dataplane_config.mjs diff --git a/docker/docker-compose.cf-dataplane-standalone.yaml b/docker/docker-compose.cf-dataplane-standalone.yaml index 1efe3fc..c92b154 100644 --- a/docker/docker-compose.cf-dataplane-standalone.yaml +++ b/docker/docker-compose.cf-dataplane-standalone.yaml @@ -101,20 +101,6 @@ services: retries: 30 start_period: 2s - config_writer: - profiles: ["helpers"] - image: node:22-bookworm-slim - labels: - name: cf-dataplane-config-writer - restart: "no" - networks: - - mcpnet - volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_dataplane_config.mjs:/opt/contextforge-integration/write_dataplane_config.mjs:ro - entrypoint: - - node - - /opt/contextforge-integration/write_dataplane_config.mjs - locust: profiles: ["performance"] image: locustio/locust:2.46.2 diff --git a/scripts/conformance/write_dataplane_config.mjs b/scripts/conformance/write_dataplane_config.mjs index 0bf2fa6..09d357e 100644 --- a/scripts/conformance/write_dataplane_config.mjs +++ b/scripts/conformance/write_dataplane_config.mjs @@ -25,8 +25,8 @@ export async function fixtureCatalog(backendUrl, protocolVersion) { headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', - 'mcp-protocol-version': protocolVersion, - 'mcp-method': method, + ...(method === 'initialize' ? {} : { 'mcp-protocol-version': protocolVersion }), + ...(modern ? { 'mcp-method': method } : {}), ...(sessionId ? { 'mcp-session-id': sessionId } : {}), }, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), @@ -37,9 +37,10 @@ export async function fixtureCatalog(backendUrl, protocolVersion) { if (notification) { await response.body?.cancel(); return; } const body = await response.text(); const messages = response.headers.get('content-type')?.startsWith('text/event-stream') - ? body.split(/\r?\n\r?\n/).filter((event) => /^data:/m.test(event)).map((event) => - JSON.parse(event.split(/\r?\n/).filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).trimStart()).join('\n'))) + ? body.split(/\r?\n\r?\n/).map((event) => + event.split(/\r?\n/).filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()).join('\n')) + .filter((data) => data.trim()).map((data) => JSON.parse(data)) : [JSON.parse(body)]; const message = messages.find((message) => message.id === id); if (!message || message.error || !message.result) { @@ -52,11 +53,11 @@ export async function fixtureCatalog(backendUrl, protocolVersion) { const cursors = new Set(); let cursor; do { - const page = await rpc(method, cursor ? { cursor } : {}); + const page = await rpc(method, cursor === undefined ? {} : { cursor }); if (!Array.isArray(page[key])) throw new Error(`fixture ${method} has no ${key} array`); items.push(...page[key]); - cursor = page.nextCursor; - if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursors.has(cursor))) { + cursor = page.nextCursor ?? undefined; + if (cursor !== undefined && (typeof cursor !== 'string' || cursors.has(cursor))) { throw new Error(`fixture ${method} returned an invalid or repeated cursor`); } cursors.add(cursor); @@ -143,7 +144,7 @@ function config(serverId, backendUrl, protocolVersion, catalogs) { add_headers: {}, remove_headers: [], completion: {}, - tool_schemas: catalogs.toolSchemas ?? {}, + tool_schemas: catalogs.toolSchemas, }, }, tools: routes(catalogs.tools, backendName), @@ -217,10 +218,12 @@ async function main() { const token = process.env.MCP_CONFORMANCE_TOKEN; if (!token) fail('MCP_CONFORMANCE_TOKEN is required'); + const tools = mode === 'client' ? stringArray(toolNamesJson, 'tool-names-json') : undefined; const catalogs = mode === 'fixture' ? await fixtureCatalog(backendUrl, protocolVersion) : { - tools: stringArray(toolNamesJson, 'tool-names-json'), + tools, + toolSchemas: Object.fromEntries(tools.map((name) => [name, {}])), resources: [], resourceTemplates: [], prompts: [], diff --git a/src/app.rs b/src/app.rs index 598d4d0..fe4a3f1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -33,7 +33,6 @@ pub(crate) enum Action { Load(ResolvedLoadArgs), Live { lane: SemanticLane, - standalone: bool, group: LiveGroup, protocol_version: ProtocolVersion, }, @@ -91,17 +90,12 @@ impl Action { } Self::Live { lane, - standalone, protocol_version, .. - } => { - let mut summary = format!( - "Lane: {}\nProtocol version: {protocol_version}", - lane.label() - ); - append_standalone_summary(&mut summary, *standalone); - summary - } + } => format!( + "Lane: {}\nProtocol version: {protocol_version}", + lane.label() + ), Self::Conformance(ConformanceAction::Run { lanes, standalone, @@ -387,15 +381,16 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result { let lane = resolve_live_lane(args.target.lane, environment)?; - if standalone && lane != SemanticLane::ExternalDataPlane { - bail!("--standalone requires --lane external"); + if standalone { + bail!( + "live suites require the control plane; use probe --standalone for isolated external route checks" + ); } if lane == SemanticLane::FixtureDirect && args.group != LiveGroup::Protocol { bail!("--lane fixture-direct requires --group protocol"); } Ok(Action::Live { lane, - standalone, group: args.group, protocol_version: resolve_protocol_version( args.target.protocol_version, diff --git a/src/app_tests.rs b/src/app_tests.rs index 32d0527..777107d 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -438,7 +438,6 @@ fn live_resolves_lane_group_and_protocol_version() { ), Action::Live { lane: SemanticLane::BuiltInDataPlane, - standalone: false, group: LiveGroup::Mcp, protocol_version: ProtocolVersion::Legacy, } @@ -466,7 +465,6 @@ fn live_fixture_lane_bypasses_topology_and_cli_version_wins() { ), Action::Live { lane: SemanticLane::FixtureDirect, - standalone: false, group: LiveGroup::Protocol, protocol_version: ProtocolVersion::Modern, } @@ -735,3 +733,27 @@ fn admin_token_rejects_a_server_scope() { .expect_err("admin token server restriction must not be discarded"); assert!(error.to_string().contains("only valid with --kind scoped")); } + +#[test] +fn standalone_live_rejects_every_group_before_runtime_setup() { + for group in ["mcp", "rbac", "protocol", "all"] { + let cli = Cli::try_parse_from([ + "cf-integration", + "live", + "--lane", + "external", + "--standalone", + "--group", + group, + ]) + .expect("CLI parses before workflow validation"); + let error = + resolve_action(cli, &Environment::new()).expect_err("standalone live is unsupported"); + assert!( + error + .to_string() + .contains("live suites require the control plane") + ); + assert!(error.to_string().contains("probe --standalone")); + } +} diff --git a/src/cli_public_tests.rs b/src/cli_public_tests.rs index 6c2ae8f..1d0566f 100644 --- a/src/cli_public_tests.rs +++ b/src/cli_public_tests.rs @@ -454,7 +454,6 @@ fn standalone_is_global_across_operational_commands() { vec!["cf-integration", "stack", "status", "--standalone"], vec!["cf-integration", "probe", "--standalone"], vec!["cf-integration", "load", "--standalone"], - vec!["cf-integration", "live", "--standalone"], vec!["cf-integration", "conformance", "run", "--standalone"], vec![ "cf-integration", diff --git a/src/infrastructure/assets.rs b/src/infrastructure/assets.rs index dac253e..866fa27 100644 --- a/src/infrastructure/assets.rs +++ b/src/infrastructure/assets.rs @@ -30,6 +30,7 @@ const ASSETS: &[EmbeddedAsset] = &[ asset!("docker/docker-compose.cf-controlplane-build-labels.yaml"), asset!("docker/docker-compose.cf-controlplane-observability.yaml"), asset!("docker/docker-compose.cf-dataplane-build.yaml"), + asset!("docker/docker-compose.cf-dataplane-config.yaml"), asset!("docker/docker-compose.cf-dataplane-observability.yaml"), asset!("docker/docker-compose.cf-dataplane-standalone.yaml"), asset!("docker/docker-compose.cf-dataplane.yaml"), diff --git a/src/infrastructure/compose.rs b/src/infrastructure/compose.rs index 8c2a68a..007064d 100644 --- a/src/infrastructure/compose.rs +++ b/src/infrastructure/compose.rs @@ -95,6 +95,9 @@ impl ComposeProject { repository_root .join("docker") .join("docker-compose.cf-integration.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-dataplane-config.yaml"), ]; if build_dataplane { files.push( @@ -121,6 +124,9 @@ impl ComposeProject { repository_root .join("docker") .join("docker-compose.cf-dataplane-standalone.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-dataplane-config.yaml"), ]; if build_dataplane { files.push( diff --git a/src/infrastructure/compose_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index 0f7357c..6062c1e 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -69,6 +69,7 @@ fn dataplane_compose_files_are_in_override_order() { repository_root .join("docker") .join("docker-compose.cf-integration.yaml"), + repository_root.join("docker/docker-compose.cf-dataplane-config.yaml"), ]; assert_eq!(project.files(), expected_files); @@ -87,6 +88,8 @@ fn dataplane_compose_files_are_in_override_order() { expected_files[2].as_os_str().to_owned(), OsString::from("-f"), expected_files[3].as_os_str().to_owned(), + OsString::from("-f"), + expected_files[4].as_os_str().to_owned(), OsString::from("config"), OsString::from("--format"), OsString::from("json"), @@ -357,6 +360,7 @@ fn standalone_dataplane_has_no_controlplane_compose_inputs() { project.files(), [ PathBuf::from("/repo/docker/docker-compose.cf-dataplane-standalone.yaml"), + PathBuf::from("/repo/docker/docker-compose.cf-dataplane-config.yaml"), PathBuf::from("/repo/docker/docker-compose.cf-dataplane-build.yaml"), ] ); @@ -368,6 +372,43 @@ fn standalone_dataplane_has_no_controlplane_compose_inputs() { ); } +#[test] +fn both_external_projects_provide_the_client_conformance_config_writer() { + let root = workspace_root(); + for project in [ + ComposeProject::dataplane( + root, + Path::new("/checkout"), + OsString::from("normal"), + false, + ), + ComposeProject::standalone_dataplane(root, OsString::from("standalone"), false), + ] { + let helpers: Vec<_> = project + .files() + .iter() + .filter_map(|file| { + let source = fs::read_to_string(file).ok()?; + let compose: yaml_serde::Value = + yaml_serde::from_str(&source).expect("Compose YAML"); + let service = &compose["services"]["config_writer"]; + (!service.is_null()).then(|| service.clone()) + }) + .collect(); + assert_eq!( + helpers.len(), + 1, + "each external project needs exactly one writer" + ); + assert_eq!(helpers[0]["profiles"][0].as_str(), Some("helpers")); + assert_eq!(helpers[0]["networks"][0].as_str(), Some("mcpnet")); + assert_eq!( + helpers[0]["entrypoint"][1].as_str(), + Some("/opt/contextforge-integration/write_dataplane_config.mjs") + ); + } +} + #[test] fn standalone_dataplane_owns_ephemeral_jwks_auth_and_mock_helpers() { let root = workspace_root(); @@ -380,7 +421,7 @@ fn standalone_dataplane_owns_ephemeral_jwks_auth_and_mock_helpers() { .as_mapping() .expect("standalone services must be a mapping"); - assert_eq!(services.len(), 6); + assert_eq!(services.len(), 5); assert!(compose["services"]["gateway"].is_null()); assert_eq!( compose["services"]["auth_keygen"]["network_mode"].as_str(), @@ -408,10 +449,6 @@ fn standalone_dataplane_owns_ephemeral_jwks_auth_and_mock_helpers() { compose["services"]["nginx"]["healthcheck"]["test"][1].as_str(), Some("wget -q -O - http://127.0.0.1/health >/dev/null") ); - assert_eq!( - compose["services"]["config_writer"]["profiles"][0].as_str(), - Some("helpers") - ); assert_eq!( compose["services"]["locust"]["profiles"][0].as_str(), Some("performance") diff --git a/src/performance/python_adapter_tests.rs b/src/performance/python_adapter_tests.rs index 4ed24d3..c8da5ba 100644 --- a/src/performance/python_adapter_tests.rs +++ b/src/performance/python_adapter_tests.rs @@ -39,7 +39,6 @@ fn locust_adapter_and_compose_overlay_do_not_reference_the_removed_helper() { "the load container receives a bearer token and must not receive the signing key" ); assert!(!compose.contains("gateway:")); - assert!(compose.contains("config_writer:")); assert!(compose.contains("locustio/locust:2.46.2")); assert!(compose.contains("profiles: [\"performance\"]")); } @@ -594,17 +593,17 @@ globalThis.fetch = async (_, options) => { let result; switch (request.method) { case 'server/discover': result = {}; break; - case 'tools/list': result = request.params.cursor - ? { tools: [{ name: 'new_diagnostic_tool', inputSchema: schema }] } - : { tools: [{ name: 'first', inputSchema: {} }], nextCursor: 'page2' }; break; + case 'tools/list': result = request.params.cursor !== undefined + ? { tools: [{ name: 'new_diagnostic_tool', inputSchema: schema }], nextCursor: null } + : { tools: [{ name: 'first', inputSchema: {} }], nextCursor: '' }; break; case 'resources/list': result = { resources: [{ uri: 'test://new-resource' }] }; break; case 'resources/templates/list': result = { resourceTemplates: [{ uriTemplate: 'test://new/{id}' }] }; break; case 'prompts/list': result = { prompts: [{ name: 'new_prompt' }] }; break; default: assert.fail(request.method); } const message = JSON.stringify({ jsonrpc: '2.0', id: request.id, result }); - return new Response(request.params.cursor ? `event: message\ndata: ${message}\n\n` : message, - { headers: { 'content-type': request.params.cursor ? 'text/event-stream' : 'application/json' } }); + return new Response(request.params.cursor !== undefined ? `event: message\ndata: ${message}\n\n` : message, + { headers: { 'content-type': request.params.cursor !== undefined ? 'text/event-stream' : 'application/json' } }); }; const catalog = await fixtureCatalog('http://fixture/mcp', '2026-07-28'); assert.deepEqual(catalog.tools, ['first', 'new_diagnostic_tool']); @@ -627,6 +626,42 @@ globalThis.fetch = async (_, options) => { }}); }; await assert.rejects(fixtureCatalog('http://fixture/mcp', '2026-07-28'), /repeated cursor/); + +const legacyMethods = []; +globalThis.fetch = async (_, options) => { + if (options.method === 'DELETE') { + assert.equal(options.headers['mcp-session-id'], 'legacy-session'); + legacyMethods.push('DELETE'); + return new Response(null, { status: 204 }); + } + const request = JSON.parse(options.body); + legacyMethods.push(request.method); + assert.equal(options.headers['mcp-method'], undefined); + assert.equal(request.params._meta, undefined); + let result; + if (request.method === 'initialize') { + assert.equal(options.headers['mcp-protocol-version'], undefined); + assert.equal(request.params.protocolVersion, '2025-11-25'); + result = { protocolVersion: '2025-11-25' }; + } else { + assert.equal(options.headers['mcp-protocol-version'], '2025-11-25'); + assert.equal(options.headers['mcp-session-id'], 'legacy-session'); + if (request.method === 'notifications/initialized') return new Response(null, { status: 202 }); + result = request.method === 'tools/list' ? { tools: [{ name: 'legacy_tool', inputSchema: schema }] } + : request.method === 'resources/list' ? { resources: [] } + : request.method === 'resources/templates/list' ? { resourceTemplates: [] } + : { prompts: [] }; + } + const message = JSON.stringify({ id: request.id, result }); + return new Response(`event: message\ndata:\n\nevent: message\ndata: ${message}\n\n`, { + headers: { 'content-type': 'text/event-stream', 'mcp-session-id': 'legacy-session' }, + }); +}; +const legacyCatalog = await fixtureCatalog('http://fixture/mcp', '2025-11-25'); +assert.deepEqual(legacyCatalog.tools, ['legacy_tool']); +assert.deepEqual(legacyCatalog.toolSchemas.legacy_tool, schema); +assert.deepEqual(legacyMethods, ['initialize', 'notifications/initialized', 'tools/list', + 'resources/list', 'resources/templates/list', 'prompts/list', 'DELETE']); "#; let output = Command::new("node") .args(["--input-type=module", "--eval", script]) @@ -639,3 +674,37 @@ await assert.rejects(fixtureCatalog('http://fixture/mcp', '2026-07-28'), /repeat String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn client_config_writer_publishes_a_schema_for_each_scenario_tool() { + let script = r#" +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +const scriptPath = process.argv[1]; +process.argv = ['node', scriptPath, 'client', 'scenario-server', 'http://fixture/mcp', + '2026-07-28', '["metadata_probe","add_numbers"]']; +process.env.MCP_CONFORMANCE_TOKEN = `header.${Buffer.from('{"sub":"scenario-user"}').toString('base64url')}.signature`; +let published = false; +globalThis.fetch = async (url, options) => { + assert.ok(url.endsWith('/userconfigs/scenario-user')); + assert.equal(options.method, 'POST'); + const host = JSON.parse(options.body).virtual_hosts['scenario-server']; + assert.deepEqual(Object.keys(host.tools), ['metadata_probe', 'add_numbers']); + assert.deepEqual(host.backends['conformance-backend'].tool_schemas, { metadata_probe: {}, add_numbers: {} }); + published = true; + return new Response(null, { status: 202 }); +}; +await import(pathToFileURL(scriptPath).href); +assert.ok(published); +"#; + let output = Command::new("node") + .args(["--input-type=module", "--eval", script]) + .arg(scripts_dir().join("conformance/write_dataplane_config.mjs")) + .output() + .expect("Node config writer test runs"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/src/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index 3496b9a..8a443e5 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -917,7 +917,10 @@ impl RuntimeContext { && let Err(error) = self .publish_standalone_conformance_config( server_id, - spec_version, + server_era + .protocol_versions() + .last() + .expect("fixture has supported protocols"), &token.value, true, ) diff --git a/src/runtime/live/mod.rs b/src/runtime/live/mod.rs index c2d1560..f90ce0c 100644 --- a/src/runtime/live/mod.rs +++ b/src/runtime/live/mod.rs @@ -12,7 +12,6 @@ impl RuntimeContext { pub(super) async fn run_live( &self, lane: SemanticLane, - standalone: bool, group: LiveGroup, protocol_version: &ProtocolVersion, ) -> AppResult<()> { @@ -31,11 +30,11 @@ impl RuntimeContext { ) } SemanticLane::BuiltInDataPlane => { - self.run_routed_live(StackMode::Controlplane, false, group, protocol_version) + self.run_routed_live(StackMode::Controlplane, group, protocol_version) .await } SemanticLane::ExternalDataPlane => { - self.run_routed_live(StackMode::Dataplane, standalone, group, protocol_version) + self.run_routed_live(StackMode::Dataplane, group, protocol_version) .await } } @@ -44,88 +43,16 @@ impl RuntimeContext { async fn run_routed_live( &self, topology: StackMode, - standalone: bool, group: LiveGroup, protocol_version: &ProtocolVersion, ) -> AppResult<()> { let server_id = self.default_server_id().to_owned(); - if standalone { - return self - .with_managed_authenticated_target( - topology, - &server_id, - true, - protocol_version, - |token, tool_names| async move { - self.run_standalone_live( - topology, - group, - protocol_version, - token, - tool_names, - ) - .await - }, - ) - .await; - } self.with_managed_test_target(topology, &server_id, || async { self.run_live_group(topology, group, protocol_version) }) .await } - async fn run_standalone_live( - &self, - topology: StackMode, - group: LiveGroup, - protocol_version: &ProtocolVersion, - token: String, - tool_names: Vec, - ) -> AppResult<()> { - let server_id = self.default_server_id().to_owned(); - let config = ProbeConfig { - mode: gateway_topology(topology), - base_url: self.base_url()?.to_owned(), - server_id: server_id.clone(), - bearer_token: token.clone(), - config_timeout: Duration::ZERO, - retry_interval: Duration::ZERO, - request_timeout: Duration::from_secs( - self.environment_u64("CF_PROBE_REQUEST_TIMEOUT", 30)?, - ), - protocol_version: protocol_version.wire_version().to_owned(), - tool_names, - output_style: OutputStyle::stdout(), - }; - let transport = - GatewayClient::builder(config.mode, &config.base_url, &config.server_id, &token) - .protocol_version(config.protocol_version.clone()) - .build() - .context("failed to construct the standalone live gateway endpoint") - .map_err(AppFailure::from)?; - let started = std::time::Instant::now(); - let stdout = std::io::stdout(); - let mut output = stdout.lock(); - let result = run_probe(&transport, &config, &mut output) - .await - .map_err(AppFailure::from); - println!( - "{}", - OutputStyle::stdout().test_result( - if result.is_ok() { - TestStatus::Pass - } else { - TestStatus::Fail - }, - &format!("live::standalone-{}", live_group_label(group)), - Some(started.elapsed()), - None, - ) - ); - result - } - fn run_live_group( &self, topology: StackMode, @@ -217,15 +144,6 @@ impl RuntimeContext { } } -const fn live_group_label(group: LiveGroup) -> &'static str { - match group { - LiveGroup::Mcp => "mcp", - LiveGroup::Rbac => "rbac", - LiveGroup::Protocol => "protocol", - LiveGroup::All => "all", - } -} - fn add_live_protocol_environment( command: CommandSpec, hook_directory: &Path, diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 09626fc..f6fba6a 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -136,12 +136,11 @@ impl RuntimeDispatcher { Action::Load(args) => PerformanceWorkflow(&self.context).execute(args).await, Action::Live { lane, - standalone, group, protocol_version, } => { LiveWorkflow(&self.context) - .execute(lane, standalone, group, &protocol_version) + .execute(lane, group, &protocol_version) .await } Action::Conformance(action) => ConformanceWorkflow(&self.context).execute(action).await, @@ -201,13 +200,10 @@ impl<'a, R: ProcessRunner> LiveWorkflow<'a, R> { async fn execute( &self, lane: SemanticLane, - standalone: bool, group: LiveGroup, protocol_version: &ProtocolVersion, ) -> AppResult<()> { - self.0 - .run_live(lane, standalone, group, protocol_version) - .await + self.0.run_live(lane, group, protocol_version).await } } From f2c42105a141b3a989a1e06f70e0c5511e63ff5c Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 4 Sep 2026 22:02:54 +0100 Subject: [PATCH 6/7] fix: isolate standalone runtime dependencies and lifecycle Signed-off-by: lucarlig --- CHANGELOG.md | 6 + README.md | 4 + src/app.rs | 34 ++- src/app_tests.rs | 39 +++- src/infrastructure/config.rs | 44 ++-- .../config_integration_tests.rs | 8 +- src/infrastructure/process.rs | 52 ++++- .../process_integration_tests.rs | 48 +++- src/lib.rs | 8 +- src/performance/locust_integration_tests.rs | 2 +- src/runtime/conformance/mod.rs | 173 ++++++++++---- src/runtime/mod.rs | 2 +- src/runtime/performance/mod.rs | 7 +- src/runtime/stack/mod.rs | 212 +++++++++++++----- 14 files changed, 490 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb659cf..8bf5a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Fixed +- Removed control-plane checkout, worker-configuration, and secret-generation + dependencies from standalone workflows, including installed-binary load tests. +- Kept captured command data, including test tokens, out of conformance setup + logs while retaining helper diagnostics when stdout capture fails. +- Stopped the entire conformance matrix after Ctrl-C cleanup and prevented + interrupted runs from updating baselines. - Discover standalone conformance routes and input schemas from the running fixture, including paginated diagnostic tools. Fixture discovery errors stop setup before a baseline can be blessed. diff --git a/README.md b/README.md index 3c46ef9..b39ab0b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ test fixture. It generates an ephemeral RSA key, obtains a test token from the dataplane's local tool endpoint, validates it through the dataplane's loopback JWKS endpoint, and publishes a fresh config through the dataplane serializer. Redis therefore always contains the schema understood by the image under test. +Standalone commands also work from an installed binary without control-plane +checkouts or generated control-plane secrets. Routes and tool schemas are discovered from every catalog page of the running fixture, including the selected protocol era's diagnostic tools and prompts. @@ -151,6 +153,8 @@ cf-integration conformance run --lane external --standalone \ `--client-era` and `--server-era` accept `legacy`, `modern`, or `dual`. `--bless` replaces only the selected baselines and only after every selected run succeeds. `--standalone` permits the external lane only. +Ctrl-C finishes cleanup for the active run, skips the remaining matrix entries, +and leaves baselines unchanged. Regenerate Markdown from existing results without running tests: diff --git a/src/app.rs b/src/app.rs index fe4a3f1..54cc65c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,7 @@ use crate::conformance::profile::{ }; use crate::conformance::results::{ConformanceServerEra, SemanticLane}; use crate::infrastructure::StackMode; -use crate::infrastructure::config::Environment; +use crate::infrastructure::config::{ConfigRequirements, Environment}; use crate::performance::LoadRequest; use anyhow::{Result, bail}; @@ -145,13 +145,33 @@ impl Action { ) } - /// Returns whether this operation needs Compose overlays or runtime scripts. + /// Filesystem resources needed by the resolved operation. #[must_use] - pub(crate) const fn requires_runtime_assets(&self) -> bool { - match self { - Self::Conformance(ConformanceAction::Report { .. }) | Self::Ci(_) => false, - Self::Debug(DebugAction::Token { standalone, .. }) => *standalone, - _ => true, + pub(crate) fn config_requirements(&self) -> ConfigRequirements { + let standalone = match self { + Self::Conformance(ConformanceAction::Report { .. }) + | Self::Ci(_) + | Self::Debug(DebugAction::Token { + standalone: false, .. + }) => { + return ConfigRequirements::ReadOnly; + } + Self::Probe { standalone, .. } + | Self::Debug(DebugAction::Inspect { standalone, .. }) + | Self::Debug(DebugAction::Token { standalone, .. }) + | Self::Conformance(ConformanceAction::Run { standalone, .. }) + | Self::Stack(StackAction::Up { standalone, .. }) + | Self::Stack(StackAction::Down { standalone, .. }) + | Self::Stack(StackAction::Status { standalone, .. }) + | Self::Stack(StackAction::Logs { standalone, .. }) + | Self::Stack(StackAction::Config { standalone, .. }) => *standalone, + Self::Load(args) => args.standalone, + Self::Live { .. } => false, + }; + if standalone { + ConfigRequirements::StandaloneRuntime + } else { + ConfigRequirements::Runtime } } } diff --git a/src/app_tests.rs b/src/app_tests.rs index 777107d..aaa3ea1 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -7,7 +7,7 @@ use cf_integration::app::{ use cf_integration::cli::{Cli, LaneSelection, LiveGroup, ProtocolVersion, TokenKind}; use cf_integration::conformance::results::{ConformanceServerEra, SemanticLane}; use cf_integration::infrastructure::StackMode; -use cf_integration::infrastructure::config::Environment; +use cf_integration::infrastructure::config::{ConfigRequirements, Environment}; use cf_integration::performance::LoadRequest; use clap::Parser; @@ -73,7 +73,7 @@ fn ci_image_preparation_is_read_only_until_execution() { assert!(matches!(&action, Action::Ci(CiAction::PrepareImage { .. }))); assert_eq!(action.description(), "prepare prebuilt CI image"); - assert!(!action.requires_runtime_assets()); + assert_eq!(action.config_requirements(), ConfigRequirements::ReadOnly); } #[test] @@ -665,10 +665,37 @@ fn only_report_and_controlplane_token_actions_skip_runtime_assets() { &[], ); - assert!(!report.requires_runtime_assets()); - assert!(!token.requires_runtime_assets()); - assert!(stack.requires_runtime_assets()); - assert!(standalone_token.requires_runtime_assets()); + assert_eq!(report.config_requirements(), ConfigRequirements::ReadOnly); + assert_eq!(token.config_requirements(), ConfigRequirements::ReadOnly); + assert_eq!(stack.config_requirements(), ConfigRequirements::Runtime); + assert_eq!( + standalone_token.config_requirements(), + ConfigRequirements::StandaloneRuntime + ); +} + +#[test] +fn standalone_workflows_do_not_request_controlplane_secrets() { + for command in [ + vec!["stack", "up"], + vec!["stack", "status"], + vec!["stack", "config"], + vec!["stack", "down"], + vec!["stack", "logs"], + vec!["probe"], + vec!["load"], + vec!["conformance", "run"], + vec!["debug", "token", "--kind", "scoped"], + ] { + let arguments: Vec<_> = ["cf-integration", "--standalone"] + .into_iter() + .chain(command) + .collect(); + assert_eq!( + action(&arguments, &[]).config_requirements(), + ConfigRequirements::StandaloneRuntime + ); + } } #[test] diff --git a/src/infrastructure/config.rs b/src/infrastructure/config.rs index 4afb3df..dedb3e3 100644 --- a/src/infrastructure/config.rs +++ b/src/infrastructure/config.rs @@ -144,15 +144,13 @@ pub(crate) struct ConfigBootstrap { /// Filesystem resources required by a resolved action. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ConfigRequirements { - runtime: bool, -} - -impl ConfigRequirements { - /// Configuration for report and token operations that must not write files. - pub(crate) const READ_ONLY: Self = Self { runtime: false }; - /// Configuration for operations backed by Compose or runtime scripts. - pub(crate) const RUNTIME: Self = Self { runtime: true }; +pub(crate) enum ConfigRequirements { + /// Resolve configuration without creating files. + ReadOnly, + /// Materialize runtime assets and control-plane secrets. + Runtime, + /// Materialize runtime assets without control-plane secrets. + StandaloneRuntime, } impl fmt::Debug for SourcedValue { @@ -271,7 +269,7 @@ impl AppConfig { root.join(".integration").into_os_string(), ), ); - let asset_root = if requirements.runtime { + let asset_root = if requirements != ConfigRequirements::ReadOnly { if contains_runtime_assets(&root) { root.clone() } else if bootstrap.root_overridden { @@ -325,7 +323,7 @@ impl AppConfig { "CF_CONTROLPLANE_PROJECT", OsString::from("cf-controlplane-only"), ); - let local_secrets = if requirements.runtime + let local_secrets = if requirements == ConfigRequirements::Runtime && (first_nonempty(&environment, "JWT_SECRET_KEY").is_none() || first_nonempty(&environment, "AUTH_ENCRYPTION_SECRET").is_none()) { @@ -337,7 +335,7 @@ impl AppConfig { }; let jwt_secret_key = match first_nonempty(&environment, "JWT_SECRET_KEY") { Some(value) => value.clone(), - None if !requirements.runtime => default_value(""), + None if requirements != ConfigRequirements::Runtime => default_value(""), None => default_value( &local_secrets .as_ref() @@ -347,7 +345,7 @@ impl AppConfig { }; let auth_encryption_secret = match first_nonempty(&environment, "AUTH_ENCRYPTION_SECRET") { Some(value) => value.clone(), - None if !requirements.runtime => default_value(""), + None if requirements != ConfigRequirements::Runtime => default_value(""), None => default_value( &local_secrets .as_ref() @@ -927,7 +925,7 @@ mod tests { fn load_app_config(root: &Path, process: &Environment) -> AppConfig { let bootstrap = ConfigBootstrap::load(process, root).expect("bootstrap should load"); - AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect("application config should load") } @@ -941,13 +939,27 @@ mod tests { let outside = tempfile::tempdir().expect("temporary directory should be created"); let bootstrap = ConfigBootstrap::load(&Environment::new(), outside.path()) .expect("bootstrap should load"); - let config = AppConfig::load(bootstrap, ConfigRequirements::READ_ONLY) + let config = AppConfig::load(bootstrap, ConfigRequirements::ReadOnly) .expect("read-only config should resolve"); assert_eq!(config.root(), outside.path()); assert!(!outside.path().join(".integration").exists()); } + #[test] + fn standalone_config_materializes_assets_without_controlplane_secrets() { + let outside = tempfile::tempdir().expect("temporary directory"); + let bootstrap = + ConfigBootstrap::load(&Environment::new(), outside.path()).expect("bootstrap"); + let config = AppConfig::load(bootstrap, ConfigRequirements::StandaloneRuntime) + .expect("standalone runtime config"); + assert!(contains_runtime_assets(config.asset_root())); + assert!(config.jwt_secret_key().value.is_empty()); + assert!(config.auth_encryption_secret().value.is_empty()); + assert!(!config.integration_dir().join(LOCAL_SECRETS_FILE).exists()); + assert!(!config.controlplane_dir().exists()); + } + #[test] fn app_config_uses_documented_defaults() { let root = repository_root(); @@ -1263,7 +1275,7 @@ mod tests { let bootstrap = ConfigBootstrap::load(&process, root.path()).expect("bootstrap should load"); - let error = AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + let error = AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect_err("an unknown pull policy must fail"); assert_eq!( diff --git a/src/infrastructure/config_integration_tests.rs b/src/infrastructure/config_integration_tests.rs index 215b309..d496be5 100644 --- a/src/infrastructure/config_integration_tests.rs +++ b/src/infrastructure/config_integration_tests.rs @@ -42,7 +42,7 @@ struct TestConfigLoad { fn load_app_config(root: &Path, process: &Environment) -> TestConfigLoad { let bootstrap = ConfigBootstrap::load(process, root).expect("bootstrap should load"); let warnings = bootstrap.warnings().to_vec(); - let config = AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + let config = AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect("application config should load"); TestConfigLoad { config, warnings } } @@ -231,7 +231,7 @@ fn workspace_root_prefers_process_override() { let bootstrap = ConfigBootstrap::load(&process, cwd_root.path()).expect("bootstrap should load"); - let config = AppConfig::load(bootstrap, ConfigRequirements::READ_ONLY) + let config = AppConfig::load(bootstrap, ConfigRequirements::ReadOnly) .expect("read-only config should resolve"); assert_eq!(config.root(), process_root.path()); @@ -242,7 +242,7 @@ fn workspace_root_defaults_to_cwd_without_writing_files() { let outside = tempfile::tempdir().expect("temporary directory should be created"); let bootstrap = ConfigBootstrap::load(&Environment::new(), outside.path()).expect("bootstrap should load"); - let config = AppConfig::load(bootstrap, ConfigRequirements::READ_ONLY) + let config = AppConfig::load(bootstrap, ConfigRequirements::ReadOnly) .expect("read-only config should resolve"); assert_eq!(config.root(), outside.path()); @@ -259,7 +259,7 @@ fn invalid_explicit_runtime_root_fails_closed() { )]); let bootstrap = ConfigBootstrap::load(&process, outside.path()).expect("bootstrap should load"); - let error = AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + let error = AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect_err("an explicit root without assets must fail"); assert!(error.to_string().contains(ROOT_OVERRIDE)); diff --git a/src/infrastructure/process.rs b/src/infrastructure/process.rs index 9c7b19b..28022f3 100644 --- a/src/infrastructure/process.rs +++ b/src/infrastructure/process.rs @@ -134,6 +134,7 @@ impl CapturedOutput { /// Returns captured standard output bytes. #[must_use] + #[cfg(test)] pub(crate) fn stdout(&self) -> &[u8] { &self.stdout } @@ -213,6 +214,17 @@ pub(crate) trait ProcessRunner { /// Captures standard output while inheriting standard error. fn capture_stdout(&self, spec: &CommandSpec) -> Result, InfrastructureError>; + /// Returns command data on stdout and appends diagnostics on stderr to a log. + fn capture_stdout_to_log( + &self, + spec: &CommandSpec, + log_path: &Path, + ) -> Result, InfrastructureError> { + let output = self.capture_output(spec)?; + append_captured_stderr(log_path, spec, &output)?; + Ok(output.stdout) + } + /// Captures standard output and error separately. fn capture_output(&self, spec: &CommandSpec) -> Result; @@ -282,14 +294,20 @@ impl ProcessRunner for LoggingProcessRunner<'_, R> { } fn capture_stdout(&self, spec: &CommandSpec) -> Result, InfrastructureError> { - let output = self.inner.capture_output(spec)?; - append_captured_output(self.log_path, spec, &output)?; - Ok(output.stdout) + self.inner.capture_stdout_to_log(spec, self.log_path) + } + + fn capture_stdout_to_log( + &self, + spec: &CommandSpec, + log_path: &Path, + ) -> Result, InfrastructureError> { + self.inner.capture_stdout_to_log(spec, log_path) } fn capture_output(&self, spec: &CommandSpec) -> Result { let output = self.inner.capture_output(spec)?; - append_captured_output(self.log_path, spec, &output)?; + append_captured_stderr(self.log_path, spec, &output)?; Ok(output) } @@ -445,6 +463,27 @@ impl ProcessRunner for SystemProcessRunner { Ok(CapturedOutput::new(output.stdout, output.stderr)) } + fn capture_stdout_to_log( + &self, + spec: &CommandSpec, + log_path: &Path, + ) -> Result, InfrastructureError> { + let log = OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .with_context(|| log_context("open", log_path, spec))?; + let output = command(spec) + .stdout(Stdio::piped()) + .stderr(Stdio::from(log)) + .output() + .with_context(|| operation_context("capture", spec))?; + // stderr is already durable even when the command fails. stdout can + // contain credentials or structured data and must never enter the log. + require_success(spec, output.status)?; + Ok(output.stdout) + } + fn run_to_log(&self, spec: &CommandSpec, log_path: &Path) -> Result<(), InfrastructureError> { let (log, stderr_log) = log_handles(log_path, spec)?; let mut command = command(spec); @@ -473,7 +512,7 @@ fn log_handles(log_path: &Path, spec: &CommandSpec) -> Result<(File, File), Infr Ok((log, stderr_log)) } -fn append_captured_output( +fn append_captured_stderr( log_path: &Path, spec: &CommandSpec, output: &CapturedOutput, @@ -483,8 +522,7 @@ fn append_captured_output( .append(true) .open(log_path) .with_context(|| log_context("open", log_path, spec))?; - log.write_all(output.stdout()) - .and_then(|()| log.write_all(output.stderr())) + log.write_all(output.stderr()) .with_context(|| log_context("append output to", log_path, spec))?; Ok(()) } diff --git a/src/infrastructure/process_integration_tests.rs b/src/infrastructure/process_integration_tests.rs index 67e7ffc..6ccf7e9 100644 --- a/src/infrastructure/process_integration_tests.rs +++ b/src/infrastructure/process_integration_tests.rs @@ -3,7 +3,6 @@ use std::fs; use std::path::{Path, PathBuf}; use cf_integration::infrastructure::InfrastructureError; -#[cfg(unix)] use cf_integration::infrastructure::process::LoggingProcessRunner; use cf_integration::infrastructure::process::{ CapturedOutput, CommandSpec, ProcessRunner, SystemProcessRunner, @@ -25,6 +24,53 @@ use std::os::unix::fs::PermissionsExt; fn assert_runner_interface(_runner: &dyn ProcessRunner) {} +#[test] +fn captured_data_is_returned_without_becoming_diagnostic_output() { + let directory = tempfile::tempdir().expect("temporary directory"); + let log = directory.path().join("setup.log"); + let runner = LoggingProcessRunner::new(&FakeProcessRunner, &log); + let command = CommandSpec::new("helper"); + assert_eq!( + runner.capture_stdout(&command).expect("stdout"), + b"synthetic stdout" + ); + assert_eq!( + runner.capture_output(&command).expect("output").stdout(), + b"synthetic stdout" + ); + let contents = fs::read_to_string(log).expect("diagnostic log"); + assert!(!contents.contains("synthetic stdout")); + assert_eq!(contents, "synthetic stderrsynthetic stderr"); +} + +#[cfg(unix)] +#[test] +fn captured_credentials_stay_private_and_failed_commands_keep_diagnostics() { + let directory = tempfile::tempdir().expect("temporary directory"); + for exit_code in [0, 7] { + let log = directory.path().join(format!("{exit_code}.log")); + let runner = LoggingProcessRunner::new(&SystemProcessRunner, &log); + let output = runner.capture_stdout(&CommandSpec::new("/bin/sh").args([ + "-c", + &format!("printf 'private-token'; printf 'helper diagnostic' >&2; exit {exit_code}"), + ])); + if exit_code == 0 { + assert_eq!(output.expect("successful capture"), b"private-token"); + } else { + assert!( + !output + .expect_err("failed helper") + .to_string() + .contains("private-token") + ); + } + assert_eq!( + fs::read_to_string(log).expect("diagnostic log"), + "helper diagnostic" + ); + } +} + struct FakeProcessRunner; impl ProcessRunner for FakeProcessRunner { diff --git a/src/lib.rs b/src/lib.rs index 4762520..aba4883 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,7 @@ mod runtime; use app::resolve_action; use cli::Cli; use error::AppFailure; -use infrastructure::config::{AppConfig, ConfigBootstrap, ConfigRequirements, Environment}; +use infrastructure::config::{AppConfig, ConfigBootstrap, Environment}; use infrastructure::process::SystemProcessRunner; pub(crate) use output::{Activity, OutputStyle, TestStatus}; use runtime::RuntimeDispatcher; @@ -77,11 +77,7 @@ pub async fn run() -> ExitCode { Ok(action) => action, Err(error) => return report_failure(AppFailure::from(error)), }; - let requirements = if action.requires_runtime_assets() { - ConfigRequirements::RUNTIME - } else { - ConfigRequirements::READ_ONLY - }; + let requirements = action.config_requirements(); eprintln!("{}", OutputStyle::stderr().info(&action.startup_summary())); let activity = action .uses_global_activity() diff --git a/src/performance/locust_integration_tests.rs b/src/performance/locust_integration_tests.rs index e0b0891..431ab57 100644 --- a/src/performance/locust_integration_tests.rs +++ b/src/performance/locust_integration_tests.rs @@ -38,7 +38,7 @@ fn repository_root(dotenv: Option<&str>) -> TempDir { fn config(root: &Path, process: &Environment) -> AppConfig { let bootstrap = ConfigBootstrap::load(process, root).expect("bootstrap should load"); - AppConfig::load(bootstrap, ConfigRequirements::RUNTIME).expect("application config should load") + AppConfig::load(bootstrap, ConfigRequirements::Runtime).expect("application config should load") } fn args(smoke: bool) -> LoadRequest { diff --git a/src/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index 8a443e5..a2bf72c 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -194,7 +194,7 @@ impl RuntimeContext { let project = self.routed_conformance_project(topology, standalone, observability); let build = project.command(["build", OFFICIAL_CONFORMANCE_SERVICE]); let build = self - .routed_conformance_environment(build, topology, standalone)? + .target_environment(build, topology, standalone)? .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); Ok(self.runner.run_async(&build).await?) } @@ -215,7 +215,7 @@ impl RuntimeContext { OFFICIAL_CONFORMANCE_PROXY_SERVICE, ]); let up = self - .routed_conformance_environment(up, topology, standalone)? + .target_environment(up, topology, standalone)? .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); Ok(self.runner.run_async(&up).await?) } @@ -229,7 +229,7 @@ impl RuntimeContext { let command = self .routed_conformance_project(topology, standalone, observability) .command(["port", OFFICIAL_CONFORMANCE_SERVICE, "3000"]); - let command = self.routed_conformance_environment(command, topology, standalone)?; + let command = self.target_environment(command, topology, standalone)?; let output = self.runner.capture_stdout(&command)?; parse_conformance_fixture_endpoint(&output).map_err(AppFailure::from) } @@ -249,7 +249,7 @@ impl RuntimeContext { OFFICIAL_CONFORMANCE_PROXY_SERVICE, OFFICIAL_CONFORMANCE_SERVICE, ]); - let remove = self.routed_conformance_environment(remove, topology, standalone)?; + let remove = self.target_environment(remove, topology, standalone)?; self.runner .run_async(&remove) .await @@ -270,21 +270,31 @@ impl RuntimeContext { } } - fn routed_conformance_environment( - &self, - command: CommandSpec, - topology: StackMode, - standalone: bool, - ) -> AppResult { - if standalone { - debug_assert_eq!(topology, StackMode::Dataplane); - self.standalone_dataplane_environment(command, true) - } else { - self.compose_environment(command, topology, true) - } + pub(super) async fn execute_conformance(&self, action: ConformanceAction) -> AppResult<()> { + self.execute_conformance_with_interrupt(action, async { + if tokio::signal::ctrl_c().await.is_err() { + std::future::pending::<()>().await; + } + }) + .await } - pub(super) async fn execute_conformance(&self, action: ConformanceAction) -> AppResult<()> { + async fn execute_conformance_with_interrupt( + &self, + action: ConformanceAction, + interrupt: I, + ) -> AppResult<()> + where + I: Future, + { + // Cancellation belongs to the matrix even if an entry cannot write its + // final artifacts and returns an error instead of an outcome. + let interruption_requested = std::cell::Cell::new(false); + let interrupt = async { + interrupt.await; + interruption_requested.set(true); + }; + tokio::pin!(interrupt); match action { ConformanceAction::Run { lanes, @@ -333,7 +343,14 @@ impl RuntimeContext { let executor = RuntimeContext::new(self.config.clone(), quiet_runner); let matrix_started = Instant::now(); let run_result = executor - .run_conformance(&lanes, &client_version, server_era, &paths, standalone) + .run_conformance_with_interrupt( + &lanes, + &client_version, + server_era, + &paths, + standalone, + interrupt.as_mut(), + ) .await; let run_completed = matches!( &run_result, @@ -614,6 +631,10 @@ impl RuntimeContext { setup_log.display() ); } + if interruption_requested.get() { + reported_failure = true; + break; + } } let baselines_updated = finalize_conformance_execution( @@ -639,29 +660,6 @@ impl RuntimeContext { } } - async fn run_conformance( - &self, - lanes: &[SemanticLane], - spec_version: &str, - server_era: ConformanceServerEra, - paths: &ConformancePaths, - standalone: bool, - ) -> AppResult { - self.run_conformance_with_interrupt( - lanes, - spec_version, - server_era, - paths, - standalone, - async { - if tokio::signal::ctrl_c().await.is_err() { - std::future::pending::<()>().await; - } - }, - ) - .await - } - async fn run_conformance_with_interrupt( &self, lanes: &[SemanticLane], @@ -1957,6 +1955,99 @@ mod tests { use super::*; use crate::conformance::results::{CheckStatus, ConformanceCheck, ConformanceScenarioResult}; + #[derive(Default)] + struct MatrixRunner(std::cell::RefCell>); + + impl ProcessRunner for MatrixRunner { + fn run(&self, spec: &CommandSpec) -> Result<(), InfrastructureError> { + self.0.borrow_mut().push(spec.clone()); + Ok(()) + } + fn run_async_to_log<'a>( + &'a self, + spec: &'a CommandSpec, + _: &'a Path, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + self.run(spec)?; + // Leave setup pending long enough for the injected interrupt. + tokio::task::yield_now().await; + Ok(()) + }) + } + fn capture_stdout(&self, _: &CommandSpec) -> Result, InfrastructureError> { + Ok(b"test-docker".to_vec()) + } + fn capture_output( + &self, + _: &CommandSpec, + ) -> Result { + Ok(crate::infrastructure::process::CapturedOutput::new( + b"test-docker".to_vec(), + Vec::new(), + )) + } + fn run_to_log(&self, spec: &CommandSpec, _: &Path) -> Result<(), InfrastructureError> { + self.run(spec) + } + } + + #[tokio::test] + async fn interruption_finishes_cleanup_and_stops_the_entire_matrix_without_blessing() { + use crate::infrastructure::config::{ConfigBootstrap, ConfigRequirements, Environment}; + let directory = tempfile::tempdir().expect("temporary root"); + let config = AppConfig::load( + ConfigBootstrap::load(&Environment::new(), directory.path()).expect("bootstrap"), + ConfigRequirements::StandaloneRuntime, + ) + .expect("config"); + let runtime = RuntimeContext::new(config, MatrixRunner::default()); + let artifacts = directory.path().join("results"); + let baselines = directory.path().join("baselines"); + fs::create_dir(&baselines).expect("baseline directory"); + let sentinel = baselines.join("existing.yml"); + fs::write(&sentinel, "unchanged").expect("existing baseline"); + let result = runtime + .execute_conformance_with_interrupt( + ConformanceAction::Run { + lanes: vec![SemanticLane::FixtureDirect], + standalone: true, + client_eras: vec![ConformanceServerEra::Modern], + client_versions: vec![DEFAULT_MCP_SPEC_VERSION.to_owned()], + server_eras: vec![ConformanceServerEra::Modern, ConformanceServerEra::Legacy], + results_dir: Some(artifacts.clone()), + baseline_dir: Some(baselines.clone()), + bless: true, + output_dir: Some(directory.path().join("reports")), + }, + std::future::ready(()), + ) + .await; + assert!(result.is_err(), "interrupt must fail the run"); + assert!( + artifacts + .join("conformance/2026-07-28/modern/setup.log") + .is_file() + ); + assert!( + !artifacts.join("conformance/2026-07-28/legacy").exists(), + "next matrix entry started after cancellation" + ); + assert_eq!(fs::read_to_string(sentinel).expect("baseline"), "unchanged"); + assert_eq!(fs::read_dir(baselines).expect("baselines").count(), 1); + let commands = runtime.runner.0.borrow(); + let build = commands + .iter() + .position(|command| command.arguments().contains(&OsString::from("build"))) + .expect("fixture setup started"); + assert!( + commands[build + 1..] + .iter() + .any(|command| command.arguments().contains(&OsString::from("down"))), + "fixture cleanup was skipped" + ); + } + fn conformance_result(scenario: &str, status: CheckStatus) -> ConformanceScenarioResult { ConformanceScenarioResult { scenario: scenario.to_owned(), diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index f6fba6a..84b29e3 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -505,7 +505,7 @@ mod tests { .map(|(key, value)| (OsString::from(key), OsString::from(value))), ); let bootstrap = ConfigBootstrap::load(&environment, root).expect("bootstrap should load"); - AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect("test application config should load") } diff --git a/src/runtime/performance/mod.rs b/src/runtime/performance/mod.rs index 271d54b..7b92749 100644 --- a/src/runtime/performance/mod.rs +++ b/src/runtime/performance/mod.rs @@ -32,8 +32,11 @@ impl RuntimeContext { args.protocol_version.wire_version(), ) .map_err(AppFailure::from)?; - let mut command_spec = - self.compose_environment(command.command().clone(), args.topology, true)?; + let mut command_spec = self.target_environment( + command.command().clone(), + args.topology, + args.standalone, + )?; if args.standalone { command_spec = command_spec .env("MCP_TOOL_NAMES", standalone_tool_names.join(",")) diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index c66f378..0accf00 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -85,11 +85,8 @@ impl RuntimeContext { } => { let project = self.stack_command_project(topology, standalone)?; let command = StackCommandPlan::status(project); - let command = self.stack_command_environment( - command.command().clone(), - topology, - standalone, - )?; + let command = + self.target_environment(command.command().clone(), topology, standalone)?; Ok(self.runner.run(&command)?) } StackAction::Logs { @@ -99,11 +96,8 @@ impl RuntimeContext { } => { let project = self.stack_command_project(topology, standalone)?; let command = StackCommandPlan::logs(project, services); - let command = self.stack_command_environment( - command.command().clone(), - topology, - standalone, - )?; + let command = + self.target_environment(command.command().clone(), topology, standalone)?; Ok(self.runner.run(&command)?) } StackAction::Config { @@ -124,7 +118,7 @@ impl RuntimeContext { .command() .clone() }; - let command = self.stack_command_environment(command, topology, standalone)?; + let command = self.target_environment(command, topology, standalone)?; Ok(self.runner.run(&command)?) } } @@ -148,7 +142,7 @@ impl RuntimeContext { } } - fn stack_command_environment( + pub(super) fn target_environment( &self, command: CommandSpec, topology: StackMode, @@ -500,14 +494,10 @@ impl RuntimeContext { command: CommandSpec, checkout_labels: bool, ) -> AppResult { - let controlplane_image = self.config.controlplane_image().resolved().to_owned(); + let command = self.base_compose_environment(command)?; + let command = self.dataplane_environment(command)?; let command = self - .compose_environment_with_controlplane_image( - command, - StackMode::Dataplane, - false, - controlplane_image, - )? + .host_identity_environment(command)? .env("MCP_SERVER_ID", self.default_server_id()); if checkout_labels && !self.config.dataplane_ref().value.is_empty() { self.add_dataplane_checkout_labels(command) @@ -523,24 +513,11 @@ impl RuntimeContext { checkout_labels: bool, controlplane_image: OsString, ) -> AppResult { - let command_environment = command.environment().clone(); - let mut command = if command.working_directory().is_some() { - command - } else { - command.cwd(self.config.root()) - }; - for (key, value) in self.config.environment().iter() { - if !command_environment.contains_key(key) { - command = command.env(key.clone(), value.value.clone()); - } - } - if let Some(protocol_version) = compose_protocol_version( - &command_environment, - self.environment_text(COMPOSE_PROTOCOL_VERSION_ENV), - )? { - command = command.env(COMPOSE_PROTOCOL_VERSION_ENV, protocol_version); + let mut command = self.base_compose_environment(command)?; + if mode == StackMode::Dataplane { + command = self.dataplane_environment(command)?; } - let (controlplane_pull_policy, dataplane_pull_policy) = compose_pull_policies( + let (controlplane_pull_policy, _) = compose_pull_policies( mode, false, !self.config.dataplane_ref().value.is_empty(), @@ -548,17 +525,10 @@ impl RuntimeContext { self.config.dataplane_image().pull_policy(), ); command = command - .env("CF_INTEGRATION_ROOT", self.config.asset_root().as_os_str()) - .env("CF_OBSERVABILITY_NETWORK", self.observability_network()) - .env( - "CF_INTEGRATION_DIR", - self.config.integration_dir().as_os_str(), - ) .env( "CF_CONTROLPLANE_DIR", self.config.controlplane_dir().as_os_str(), ) - .env("CF_DATAPLANE_DIR", self.config.dataplane_dir().as_os_str()) .env("CF_CONTROLPLANE_IMAGE", controlplane_image.clone()) .env("CF_CONTROLPLANE_PULL_POLICY", controlplane_pull_policy) .env("IMAGE_LOCAL", controlplane_image) @@ -566,18 +536,11 @@ impl RuntimeContext { "FAST_TIME_IMAGE", self.config.fast_time_expected_image().value.clone(), ) - .env( - "CF_DATAPLANE_IMAGE", - self.config.dataplane_image().resolved().to_owned(), - ) - .env("CF_DATAPLANE_PULL_POLICY", dataplane_pull_policy) - .env("CF_DATAPLANE_PLATFORM", self.dataplane_platform()?) .env("JWT_SECRET_KEY", self.config.jwt_secret_key().value.clone()) .env( "AUTH_ENCRYPTION_SECRET", self.config.auth_encryption_secret().value.clone(), ) - .env("MCP_CLI_BASE_URL", self.config.base_url().value.clone()) .env( "PLATFORM_ADMIN_EMAIL", self.config.platform_admin_email().value.clone(), @@ -590,7 +553,6 @@ impl RuntimeContext { "KEY_FILE_PASSWORD", self.config.key_file_password().value.clone(), ); - command = with_default_conformance_server_era(command); for (key, default) in [ ("PASSWORD_CHANGE_ENFORCEMENT_ENABLED", "false"), @@ -628,12 +590,7 @@ impl RuntimeContext { command = command.env(key, docker_cpus.as_deref().unwrap_or("4")); } } - for (key, argument) in [("HOST_UID", "-u"), ("HOST_GID", "-g")] { - if self.config.environment().get(OsStr::new(key)).is_none() { - let value = self.host_identity(argument)?; - command = command.env(key, value); - } - } + command = self.host_identity_environment(command)?; if self .config .environment() @@ -659,6 +616,64 @@ impl RuntimeContext { Ok(command) } + fn base_compose_environment(&self, command: CommandSpec) -> AppResult { + let command_environment = command.environment().clone(); + let mut command = if command.working_directory().is_some() { + command + } else { + command.cwd(self.config.root()) + }; + for (key, value) in self.config.environment().iter() { + if !command_environment.contains_key(key) { + command = command.env(key.clone(), value.value.clone()); + } + } + if let Some(protocol_version) = compose_protocol_version( + &command_environment, + self.environment_text(COMPOSE_PROTOCOL_VERSION_ENV), + )? { + command = command.env(COMPOSE_PROTOCOL_VERSION_ENV, protocol_version); + } + Ok(with_default_conformance_server_era( + command + .env("CF_INTEGRATION_ROOT", self.config.asset_root().as_os_str()) + .env( + "CF_INTEGRATION_DIR", + self.config.integration_dir().as_os_str(), + ) + .env("CF_OBSERVABILITY_NETWORK", self.observability_network()) + .env("MCP_CLI_BASE_URL", self.config.base_url().value.clone()), + )) + } + + fn dataplane_environment(&self, command: CommandSpec) -> AppResult { + let (_, pull_policy) = compose_pull_policies( + StackMode::Dataplane, + false, + !self.config.dataplane_ref().value.is_empty(), + self.config.controlplane_image().pull_policy(), + self.config.dataplane_image().pull_policy(), + ); + Ok(command + .env("CF_DATAPLANE_DIR", self.config.dataplane_dir().as_os_str()) + .env( + "CF_DATAPLANE_IMAGE", + self.config.dataplane_image().resolved().to_owned(), + ) + .env("CF_DATAPLANE_PULL_POLICY", pull_policy) + .env("CF_DATAPLANE_PLATFORM", self.dataplane_platform()?)) + } + + fn host_identity_environment(&self, mut command: CommandSpec) -> AppResult { + for (key, argument) in [("HOST_UID", "-u"), ("HOST_GID", "-g")] { + if self.config.environment().get(OsStr::new(key)).is_none() { + let value = self.host_identity(argument)?; + command = command.env(key, value); + } + } + Ok(command) + } + fn add_checkout_labels( &self, mut command: CommandSpec, @@ -1479,6 +1494,89 @@ fn with_default_conformance_server_era(command: CommandSpec) -> CommandSpec { mod tests { use super::*; + struct NoProcesses; + + impl ProcessRunner for NoProcesses { + fn run(&self, spec: &CommandSpec) -> Result<(), InfrastructureError> { + panic!("unexpected setup process: {spec:?}"); + } + fn capture_stdout(&self, spec: &CommandSpec) -> Result, InfrastructureError> { + panic!("unexpected setup process: {spec:?}"); + } + fn capture_output( + &self, + spec: &CommandSpec, + ) -> Result { + panic!("unexpected setup process: {spec:?}"); + } + fn run_to_log(&self, spec: &CommandSpec, _: &Path) -> Result<(), InfrastructureError> { + panic!("unexpected setup process: {spec:?}"); + } + } + + #[test] + fn standalone_environment_needs_no_controlplane_checkout_or_worker_setup() { + use crate::infrastructure::config::{ConfigBootstrap, ConfigRequirements, Environment}; + let directory = tempfile::tempdir().expect("temporary root"); + let environment = Environment::from([ + ("CF_DATAPLANE_PLATFORM".into(), "linux/amd64".into()), + ("CF_DATAPLANE_IMAGE".into(), "test/dataplane".into()), + ("CF_DATAPLANE_PULL_POLICY".into(), "never".into()), + ("HOST_UID".into(), "123".into()), + ("HOST_GID".into(), "456".into()), + ("MCP_PROTOCOL_VERSION".into(), "modern".into()), + ]); + let config = AppConfig::load( + ConfigBootstrap::load(&environment, directory.path()).expect("bootstrap"), + ConfigRequirements::StandaloneRuntime, + ) + .expect("standalone config"); + assert!(!config.controlplane_dir().exists()); + let runtime = RuntimeContext::new(config, NoProcesses); + let working_directory = directory.path().join("caller"); + let command = runtime + .target_environment( + CommandSpec::new("docker") + .cwd(&working_directory) + .env(COMPOSE_PROTOCOL_VERSION_ENV, "2025-11-25") + .env(CONFORMANCE_SERVER_ERA_ENV, "legacy"), + StackMode::Dataplane, + true, + ) + .expect("standalone environment"); + let values = command.environment(); + assert_eq!( + command.working_directory(), + Some(working_directory.as_path()) + ); + for (key, expected) in [ + ("CF_DATAPLANE_IMAGE", "test/dataplane"), + ("CF_DATAPLANE_PULL_POLICY", "never"), + ("HOST_UID", "123"), + ("HOST_GID", "456"), + (COMPOSE_PROTOCOL_VERSION_ENV, "2025-11-25"), + (CONFORMANCE_SERVER_ERA_ENV, "legacy"), + ] { + assert_eq!(values.get(OsStr::new(key)), Some(&OsString::from(expected))); + } + for key in [ + "CF_CONTROLPLANE_DIR", + "CF_CONTROLPLANE_IMAGE", + "CF_CONTROLPLANE_CHECKOUT_REVISION", + "JWT_SECRET_KEY", + "AUTH_ENCRYPTION_SECRET", + "GUNICORN_WORKERS", + "GATEWAY_CPU_LIMIT", + "PLATFORM_ADMIN_PASSWORD", + "FAST_TIME_IMAGE", + ] { + assert!( + !values.contains_key(OsStr::new(key)), + "standalone inherited control-plane setup: {key}" + ); + } + } + #[test] fn main_tracking_selects_the_newest_published_controlplane_commit_image() { let newest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; From 9aee772916f80e88adb6aa9206ce66a88094f79b Mon Sep 17 00:00:00 2001 From: lucarlig Date: Fri, 4 Sep 2026 22:15:25 +0100 Subject: [PATCH 7/7] refactor: remove redundant workflow layers Signed-off-by: lucarlig --- CHANGELOG.md | 5 + .../conformance/write_dataplane_config.mjs | 5 +- src/infrastructure/compose.rs | 9 +- .../compose_integration_tests.rs | 5 + src/lib.rs | 4 +- src/performance/python_adapter_tests.rs | 36 +--- src/runtime/conformance/mod.rs | 22 ++- src/runtime/inspect.rs | 1 + src/runtime/live/mod.rs | 30 +--- src/runtime/mod.rs | 123 +++---------- src/runtime/performance/mod.rs | 2 +- src/runtime/probe.rs | 1 + src/runtime/session.rs | 165 ++++-------------- src/runtime/stack/mod.rs | 22 +-- 14 files changed, 106 insertions(+), 324 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf5a8e..979ae07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Changed + +- Simplified runtime dispatch and shared authenticated workflow setup, removing + forwarding wrappers while preserving token revocation and stack cleanup. + ### Fixed - Removed control-plane checkout, worker-configuration, and secret-generation diff --git a/scripts/conformance/write_dataplane_config.mjs b/scripts/conformance/write_dataplane_config.mjs index 09d357e..c32fbb5 100644 --- a/scripts/conformance/write_dataplane_config.mjs +++ b/scripts/conformance/write_dataplane_config.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node /** Publish one conformance route through the dataplane's current serializer. */ +import { setTimeout } from 'node:timers/promises'; import { realpathSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; @@ -172,7 +173,7 @@ async function publish(subject, body) { } catch (error) { lastError = error instanceof Error ? error.message : String(error); } - await new Promise((resolve) => setTimeout(resolve, 500)); + await setTimeout(500); } fail(`dataplane config serializer was unavailable: ${lastError}`); } @@ -189,7 +190,7 @@ async function issueToken(tenantId, userId) { } catch (error) { lastError = error instanceof Error ? error.message : String(error); } - await new Promise((resolve) => setTimeout(resolve, 500)); + await setTimeout(500); } fail(`dataplane token helper was unavailable: ${lastError}`); } diff --git a/src/infrastructure/compose.rs b/src/infrastructure/compose.rs index 007064d..cadff54 100644 --- a/src/infrastructure/compose.rs +++ b/src/infrastructure/compose.rs @@ -237,7 +237,7 @@ impl ComposeProject { self } - /// Enables the local ClickStack instance and OTLP exporters for routed services. + /// Enables OTLP exporters for routed services; ClickStack runs independently. #[must_use] pub(crate) fn with_observability( mut self, @@ -251,12 +251,7 @@ impl ComposeProject { self.files.push(controlplane); } if include_dataplane { - let overlay = repository_root - .join("docker") - .join("docker-compose.cf-dataplane-observability.yaml"); - if !self.files.contains(&overlay) { - self.files.push(overlay); - } + self = self.with_dataplane_observability(repository_root); } self } diff --git a/src/infrastructure/compose_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index 6062c1e..70fb388 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -453,6 +453,11 @@ fn standalone_dataplane_owns_ephemeral_jwks_auth_and_mock_helpers() { compose["services"]["locust"]["profiles"][0].as_str(), Some("performance") ); + assert_eq!( + compose["services"]["locust"]["image"].as_str(), + Some("locustio/locust:2.46.2") + ); + assert!(compose["services"]["locust"]["environment"]["JWT_SECRET_KEY"].is_null()); } #[test] diff --git a/src/lib.rs b/src/lib.rs index aba4883..f682938 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,7 @@ use error::AppFailure; use infrastructure::config::{AppConfig, ConfigBootstrap, Environment}; use infrastructure::process::SystemProcessRunner; pub(crate) use output::{Activity, OutputStyle, TestStatus}; -use runtime::RuntimeDispatcher; +use runtime::RuntimeContext; /// Runs the CLI using the current process arguments and environment. pub async fn run() -> ExitCode { @@ -91,7 +91,7 @@ pub async fn run() -> ExitCode { return report_failure(AppFailure::from(error)); } }; - let runtime = RuntimeDispatcher::new(config, SystemProcessRunner); + let runtime = RuntimeContext::new(config, SystemProcessRunner); let result = runtime.execute(action).await; if let Some(activity) = activity { activity.finish(result.is_ok()); diff --git a/src/performance/python_adapter_tests.rs b/src/performance/python_adapter_tests.rs index c8da5ba..00c0fa5 100644 --- a/src/performance/python_adapter_tests.rs +++ b/src/performance/python_adapter_tests.rs @@ -20,29 +20,6 @@ fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() } -#[test] -fn locust_adapter_and_compose_overlay_do_not_reference_the_removed_helper() { - let root = workspace_root(); - let adapter = fs::read_to_string(root.join("scripts/locustfile_mcp.py")) - .expect("Locust adapter should be readable"); - let compose = - fs::read_to_string(root.join("docker/docker-compose.cf-dataplane-standalone.yaml")) - .expect("standalone Compose file should be readable"); - - assert!(!adapter.contains("mcp_http")); - assert!(adapter.contains("allow_redirects=False")); - assert!(adapter.contains("timeout=REQUEST_TIMEOUT_SECONDS")); - assert!(adapter.contains("self.client.trust_env = False")); - assert!(!compose.contains("mcp_http.py")); - assert!( - !compose.contains("JWT_SECRET_KEY="), - "the load container receives a bearer token and must not receive the signing key" - ); - assert!(!compose.contains("gateway:")); - assert!(compose.contains("locustio/locust:2.46.2")); - assert!(compose.contains("profiles: [\"performance\"]")); -} - #[test] fn standalone_config_writer_has_valid_javascript_syntax() { for script in [ @@ -92,7 +69,7 @@ def task(_weight): } #[test] -fn locust_adapter_imports_without_the_removed_helper_and_handles_mcp_bodies() { +fn locust_adapter_imports_and_handles_mcp_bodies() { let stub = locust_stub(); let python_path = std::env::join_paths([stub.path(), scripts_dir().as_path()]) .expect("Python path should join"); @@ -285,7 +262,7 @@ adapter.validate_result("server/discover", { } #[test] -fn locust_adapter_validates_and_applies_timeout_to_every_request() { +fn locust_adapter_applies_timeouts_and_disables_redirects_and_environment_proxies() { let stub = locust_stub(); let python_path = std::env::join_paths([stub.path(), scripts_dir().as_path()]) .expect("Python path should join"); @@ -321,20 +298,25 @@ class FakeClient: def __init__(self): self.timeouts = [] - def post(self, _path, *, data, timeout, **_kwargs): + def post(self, _path, *, data, timeout, allow_redirects, **_kwargs): + assert allow_redirects is False self.timeouts.append(("POST", timeout)) payload = json.loads(data) if "id" in payload: return FakeResponse({"jsonrpc": "2.0", "id": payload["id"], "result": {}}) return FakeResponse(status=202) - def delete(self, _path, *, timeout, **_kwargs): + def delete(self, _path, *, timeout, allow_redirects, **_kwargs): + assert allow_redirects is False self.timeouts.append(("DELETE", timeout)) return FakeResponse(status=204) user = adapter.MCPGatewayUser.__new__(adapter.MCPGatewayUser) user._session_id = "session" user.client = FakeClient() +user.on_start() +assert user.client.trust_env is False +user.client.timeouts.clear() assert user._mcp_request("ping", None, name="ping") == {} user._mcp_notification("notifications/initialized", None, name="initialized") user.on_stop() diff --git a/src/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index a2bf72c..326515f 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -912,17 +912,15 @@ impl RuntimeContext { .as_ref() .expect("managed token was just stored"); if standalone_topology - && let Err(error) = self - .publish_standalone_conformance_config( - server_id, - server_era - .protocol_versions() - .last() - .expect("fixture has supported protocols"), - &token.value, - true, - ) - .await + && let Err(error) = self.publish_standalone_conformance_config( + server_id, + server_era + .protocol_versions() + .last() + .expect("fixture has supported protocols"), + &token.value, + true, + ) { topology_failure = Some(error); } @@ -1233,7 +1231,7 @@ impl RuntimeContext { .map_err(AppFailure::from) } - pub(super) async fn publish_standalone_conformance_config( + pub(super) fn publish_standalone_conformance_config( &self, server_id: &str, protocol_version: &str, diff --git a/src/runtime/inspect.rs b/src/runtime/inspect.rs index fdbe3bb..1286ea9 100644 --- a/src/runtime/inspect.rs +++ b/src/runtime/inspect.rs @@ -34,6 +34,7 @@ impl RuntimeContext { mode, &server_id, standalone, + true, protocol_version, |token, _| async move { let endpoint = GatewayClient::new( diff --git a/src/runtime/live/mod.rs b/src/runtime/live/mod.rs index f90ce0c..c6e2d23 100644 --- a/src/runtime/live/mod.rs +++ b/src/runtime/live/mod.rs @@ -48,33 +48,17 @@ impl RuntimeContext { ) -> AppResult<()> { let server_id = self.default_server_id().to_owned(); self.with_managed_test_target(topology, &server_id, || async { - self.run_live_group(topology, group, protocol_version) + let target = match group { + LiveGroup::Mcp => "test-mcp-protocol-e2e", + LiveGroup::Rbac => "test-mcp-rbac", + LiveGroup::Protocol => "test-protocol-compliance-gateway", + LiveGroup::All => return self.run_live_all(topology, protocol_version), + }; + self.run_controlplane_make(topology, target, protocol_version) }) .await } - fn run_live_group( - &self, - topology: StackMode, - group: LiveGroup, - protocol_version: &ProtocolVersion, - ) -> AppResult<()> { - match group { - LiveGroup::Mcp => { - self.run_controlplane_make(topology, "test-mcp-protocol-e2e", protocol_version) - } - LiveGroup::Rbac => { - self.run_controlplane_make(topology, "test-mcp-rbac", protocol_version) - } - LiveGroup::Protocol => self.run_controlplane_make( - topology, - "test-protocol-compliance-gateway", - protocol_version, - ), - LiveGroup::All => self.run_live_all(topology, protocol_version), - } - } - fn run_controlplane_make( &self, topology: StackMode, diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 84b29e3..3f14bfc 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -49,8 +49,8 @@ use crate::performance::{LoadSettings, LocustCommand, audit_locust_reports}; use anyhow::{Context, anyhow}; use crate::app::{ - Action, CiAction, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, - selected_topologies, topology_selection, + Action, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, selected_topologies, + topology_selection, }; use crate::cli::{LaneSelection, LiveGroup, ProtocolVersion, TokenKind as CliTokenKind}; use crate::error::AppFailure; @@ -78,8 +78,8 @@ use control_plane::CONFORMANCE_TOKEN_DESCRIPTION; use control_plane::{ControlPlaneClient, ManagedBearerToken}; use inspect::*; -/// Shared dependencies borrowed by concrete workflow owners. -struct RuntimeContext { +/// Runtime dependencies and execution of resolved CLI actions. +pub(crate) struct RuntimeContext { config: AppConfig, runner: R, controlplane_image: OnceLock, @@ -88,7 +88,7 @@ struct RuntimeContext { impl RuntimeContext { /// Creates runtime context without starting any process. #[must_use] - fn new(config: AppConfig, runner: R) -> Self { + pub(crate) fn new(config: AppConfig, runner: R) -> Self { Self { config, runner, @@ -97,59 +97,32 @@ impl RuntimeContext { } } -/// Small CLI action dispatcher backed by concrete workflow owners. -pub(crate) struct RuntimeDispatcher { - context: RuntimeContext, -} - -impl RuntimeDispatcher { - /// Creates a dispatcher without starting any process. - #[must_use] - pub(crate) fn new(config: AppConfig, runner: R) -> Self { - Self { - context: RuntimeContext::new(config, runner), - } - } -} - -struct StackWorkflow<'a, R>(&'a RuntimeContext); -struct ProbeWorkflow<'a, R>(&'a RuntimeContext); -struct PerformanceWorkflow<'a, R>(&'a RuntimeContext); -struct LiveWorkflow<'a, R>(&'a RuntimeContext); -struct ConformanceWorkflow<'a, R>(&'a RuntimeContext); -struct CiWorkflow<'a, R>(&'a RuntimeContext); - -impl RuntimeDispatcher { - /// Dispatches one fully resolved operation through its workflow owner. +impl RuntimeContext { + /// Executes one fully resolved operation. pub(crate) async fn execute(&self, action: Action) -> AppResult<()> { match action { - Action::Stack(action) => StackWorkflow(&self.context).execute(action).await, + Action::Stack(action) => self.execute_stack(action).await, Action::Probe { topology, standalone, protocol_version, } => { - ProbeWorkflow(&self.context) - .execute(topology, standalone, &protocol_version) + self.run_probe(topology, standalone, &protocol_version) .await } - Action::Load(args) => PerformanceWorkflow(&self.context).execute(args).await, + Action::Load(args) => self.run_load(args).await, Action::Live { lane, group, protocol_version, - } => { - LiveWorkflow(&self.context) - .execute(lane, group, &protocol_version) - .await - } - Action::Conformance(action) => ConformanceWorkflow(&self.context).execute(action).await, - Action::Ci(action) => CiWorkflow(&self.context).execute(action).await, + } => self.run_live(lane, group, &protocol_version).await, + Action::Conformance(action) => self.execute_conformance(action).await, + Action::Ci(action) => self.execute_ci(action).await, Action::Debug(DebugAction::Token { kind, server_id, standalone, - }) => self.context.print_token(kind, server_id, standalone).await, + }) => self.print_token(kind, server_id, standalone).await, Action::Debug(DebugAction::Inspect { topology, standalone, @@ -157,69 +130,17 @@ impl RuntimeDispatcher { method, server_id, }) => { - self.context - .inspect( - topology, - standalone, - &protocol_version, - &method, - server_id.as_deref(), - ) - .await + self.inspect( + topology, + standalone, + &protocol_version, + &method, + server_id.as_deref(), + ) + .await } } } -} - -impl<'a, R: ProcessRunner> StackWorkflow<'a, R> { - async fn execute(&self, action: StackAction) -> AppResult<()> { - self.0.execute_stack(action).await - } -} - -impl<'a, R: ProcessRunner> ProbeWorkflow<'a, R> { - async fn execute( - &self, - topology: StackMode, - standalone: bool, - protocol_version: &ProtocolVersion, - ) -> AppResult<()> { - self.0 - .run_probe(topology, standalone, protocol_version) - .await - } -} - -impl<'a, R: ProcessRunner> PerformanceWorkflow<'a, R> { - async fn execute(&self, args: ResolvedLoadArgs) -> AppResult<()> { - self.0.run_load(args).await - } -} - -impl<'a, R: ProcessRunner> LiveWorkflow<'a, R> { - async fn execute( - &self, - lane: SemanticLane, - group: LiveGroup, - protocol_version: &ProtocolVersion, - ) -> AppResult<()> { - self.0.run_live(lane, group, protocol_version).await - } -} - -impl<'a, R: ProcessRunner> ConformanceWorkflow<'a, R> { - async fn execute(&self, action: ConformanceAction) -> AppResult<()> { - self.0.execute_conformance(action).await - } -} - -impl<'a, R: ProcessRunner> CiWorkflow<'a, R> { - async fn execute(&self, action: CiAction) -> AppResult<()> { - self.0.execute_ci(action).await - } -} - -impl RuntimeContext { async fn print_token( &self, kind: CliTokenKind, diff --git a/src/runtime/performance/mod.rs b/src/runtime/performance/mod.rs index 7b92749..e1b5721 100644 --- a/src/runtime/performance/mod.rs +++ b/src/runtime/performance/mod.rs @@ -9,7 +9,7 @@ impl RuntimeContext { let server_id = self.default_server_id().to_owned(); let operation_server_id = server_id.clone(); let preparation = Activity::spinner("Preparing performance stack"); - self.with_managed_performance_target( + self.with_managed_authenticated_target( args.topology, &server_id, args.standalone, diff --git a/src/runtime/probe.rs b/src/runtime/probe.rs index 50b59f8..980ad0d 100644 --- a/src/runtime/probe.rs +++ b/src/runtime/probe.rs @@ -14,6 +14,7 @@ impl RuntimeContext { topology, &server_id, standalone, + true, protocol_version, |token, tool_names| async { let config = ProbeConfig { diff --git a/src/runtime/session.rs b/src/runtime/session.rs index 77b972a..ada8425 100644 --- a/src/runtime/session.rs +++ b/src/runtime/session.rs @@ -27,15 +27,6 @@ struct ManagedSessionScope<'a, R> { token: Option, } -struct ManagedTarget<'a> { - topology: StackMode, - server_id: &'a str, - standalone: bool, - project: ComposeProject, - observability: bool, - protocol_version: &'a ProtocolVersion, -} - impl<'a, R: ProcessRunner> ManagedSessionScope<'a, R> { fn new(runtime: &'a RuntimeContext, topology: StackMode, standalone: bool) -> Self { Self { @@ -105,43 +96,16 @@ impl RuntimeContext { Fut: Future>, { let scope = ManagedSessionScope::new(self, topology, false); - let primary = match self.stack_up(topology, false).await { - Ok(()) => match self.prepare_test_target(topology, server_id).await { - Ok(()) => operation().await, - Err(error) => Err(error), - }, - Err(error) => Err(error), - }; + let primary = async { + self.stack_up(topology, false).await?; + self.prepare_test_target(topology, server_id).await?; + operation().await + } + .await; scope.finish(primary).await } pub(super) async fn with_managed_authenticated_target( - &self, - topology: StackMode, - server_id: &str, - standalone: bool, - protocol_version: &ProtocolVersion, - operation: F, - ) -> AppResult<()> - where - F: FnOnce(String, Vec) -> Fut, - Fut: Future>, - { - self.with_managed_authenticated_target_project( - ManagedTarget { - topology, - server_id, - standalone, - project: self.compose_project(topology), - observability: true, - protocol_version, - }, - operation, - ) - .await - } - - pub(super) async fn with_managed_performance_target( &self, topology: StackMode, server_id: &str, @@ -154,87 +118,41 @@ impl RuntimeContext { F: FnOnce(String, Vec) -> Fut, Fut: Future>, { - self.with_managed_authenticated_target_project( - ManagedTarget { - topology, - server_id, - standalone, - project: self.performance_compose_project(topology, observability), - observability, - protocol_version, - }, - operation, - ) - .await - } - - async fn with_managed_authenticated_target_project( - &self, - target: ManagedTarget<'_>, - operation: F, - ) -> AppResult<()> - where - F: FnOnce(String, Vec) -> Fut, - Fut: Future>, - { - let ManagedTarget { - topology, - server_id, - standalone, - project, - observability, - protocol_version, - } = target; if standalone && topology != StackMode::Dataplane { return Err(AppFailure::from(anyhow!( "standalone mode requires the external lane" ))); } let mut scope = ManagedSessionScope::new(self, topology, standalone); - let stack_result = if standalone { - self.stack_up_standalone_dataplane(false, observability) - .await - } else { - self.stack_up_with_project(topology, false, project, false, observability) - .await - }; - let primary = match stack_result { - Ok(()) => match self - .prepare_authenticated_target(topology, server_id, standalone) - .await - { - Ok(()) => match if standalone { - self.start_standalone_fixture(protocol_version, observability) - .await - .and_then(|()| self.standalone_dataplane_token(observability)) - } else { - self.managed_bearer_token(topology, server_id).await - } { - Ok(token) => { - let value = token.value.clone(); - scope.token = Some(token); - let tool_names = if standalone { - self.publish_standalone_conformance_config( - server_id, - protocol_version.wire_version(), - &value, - observability, - ) - .await - } else { - Ok(Vec::new()) - }; - match tool_names { - Ok(tool_names) => operation(value, tool_names).await, - Err(error) => Err(error), - } - } - Err(error) => Err(error), - }, - Err(error) => Err(error), - }, - Err(error) => Err(error), - }; + let primary = async { + let token = if standalone { + self.stack_up_standalone_dataplane(false, observability) + .await?; + self.start_standalone_fixture(protocol_version, observability) + .await?; + self.standalone_dataplane_token(observability)? + } else { + let project = self.performance_compose_project(topology, observability); + self.stack_up_with_project(topology, false, project, false, observability) + .await?; + self.prepare_test_target(topology, server_id).await?; + self.managed_bearer_token(topology, server_id).await? + }; + let value = token.value.clone(); + scope.token = Some(token); + let tool_names = if standalone { + self.publish_standalone_conformance_config( + server_id, + protocol_version.wire_version(), + &value, + observability, + )? + } else { + Vec::new() + }; + operation(value, tool_names).await + } + .await; scope.finish(primary).await } @@ -251,19 +169,6 @@ impl RuntimeContext { .await } - async fn prepare_authenticated_target( - &self, - topology: StackMode, - server_id: &str, - standalone: bool, - ) -> AppResult<()> { - if standalone { - self.ensure_other_stack_stopped(topology)?; - return Ok(()); - } - self.prepare_test_target(topology, server_id).await - } - pub(super) async fn prepare_test_target( &self, topology: StackMode, diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index 0accf00..6e40fa1 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -55,8 +55,7 @@ impl RuntimeContext { protocol_version.wire_version(), &token.value, true, - ) - .await?; + )?; } let conformance_endpoint = self.conformance_fixture_endpoint(topology, standalone, true)?; @@ -474,21 +473,6 @@ impl RuntimeContext { ) } - pub(super) fn compose_environment( - &self, - command: CommandSpec, - mode: StackMode, - checkout_labels: bool, - ) -> AppResult { - let controlplane_image = self.resolved_controlplane_image()?; - self.compose_environment_with_controlplane_image( - command, - mode, - checkout_labels, - controlplane_image, - ) - } - pub(super) fn standalone_dataplane_environment( &self, command: CommandSpec, @@ -506,13 +490,13 @@ impl RuntimeContext { } } - fn compose_environment_with_controlplane_image( + pub(super) fn compose_environment( &self, command: CommandSpec, mode: StackMode, checkout_labels: bool, - controlplane_image: OsString, ) -> AppResult { + let controlplane_image = self.resolved_controlplane_image()?; let mut command = self.base_compose_environment(command)?; if mode == StackMode::Dataplane { command = self.dataplane_environment(command)?;