diff --git a/.env.example b/.env.example index f542d15..0b92bc3 100644 --- a/.env.example +++ b/.env.example @@ -8,23 +8,28 @@ # Default single-stack mode. Possible: controlplane, dataplane. CF_MCP_STACK_MODE=dataplane -# cf-controlplane checkout. Default: v1.0.7, matching the publisher contract. +# Optional developer checkout containing source overlays. Without this, the +# current directory is the workspace and the binary can materialize embedded +# assets beneath CF_INTEGRATION_DIR. +# CF_INTEGRATION_ROOT=/path/to/contextforge-dev-tools + +# cf-controlplane checkout. Default: main. # Possible: any branch, tag, or commit accepted by git checkout. -CF_CONTROLPLANE_REF=v1.0.7 +CF_CONTROLPLANE_REF=main # cf-controlplane repository. Default: IBM upstream. # Possible: any git clone URL. CF_CONTROLPLANE_REPO=https://github.com/IBM/mcp-context-forge.git -# cf-controlplane image used by upstream compose. -# Default: ghcr.io/ibm/mcp-context-forge:latest. +# cf-controlplane image used by upstream compose. By default, the harness maps +# the freshly fetched origin/main revision to its commit-tagged GHCR image. # Set the full override only to select a different registry or immutable tag. # Possible: any Docker image reference. # CF_CONTROLPLANE_IMAGE=ghcr.io/ibm/mcp-context-forge: -# Official cf-controlplane image version suffix. Default: latest. -# Used only when CF_CONTROLPLANE_IMAGE and IMAGE_LOCAL are unset. -CF_CONTROLPLANE_VERSION=latest +# Official cf-controlplane image selector. Default: main. `main` resolves to the +# fetched origin/main SHA because upstream reserves `latest` for releases. +CF_CONTROLPLANE_VERSION=main # Build behavior. Default: auto. # Possible: @@ -102,9 +107,8 @@ NGINX_PORT=8080 # Direct public-origin override; otherwise derived from NGINX_PORT. # MCP_CLI_BASE_URL=http://127.0.0.1:8080 -# Optional global MCP protocol override. Current probe/load/Inspector/live -# workflows default to the latest dataplane-compatible session protocol, -# 2025-11-25. Conformance keeps its pinned 2026-07-28 readiness default. +# Optional global MCP protocol override. Probe, conformance, live-stack, and +# performance workflows all default to the latest supported protocol. # MCP_PROTOCOL_VERSION=2026-07-28 # Local integration administrator. Stable random signing/encryption secrets are diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b99aeca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: Rust CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: rust-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_TARGET_DIR: target + RUSTFLAGS: -D warnings + +jobs: + quality: + name: quality + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.97.0 + with: + components: clippy,rustfmt + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2.9.1 + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run Clippy + run: cargo clippy --all-targets --locked -- -D warnings + + - name: Run full test suite + run: cargo test --all-targets --locked + + - name: Verify standalone lazy runtime state + shell: bash + run: | + cargo build --locked --bin cf-integration + sandbox=$(mktemp -d) + binary="$GITHUB_WORKSPACE/target/debug/cf-integration" + if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" conformance report); then + echo "report unexpectedly succeeded without prior results" >&2 + exit 1 + fi + test ! -e "$sandbox/state" + if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" stack config --topology dataplane); then + echo "stack config unexpectedly succeeded outside a checkout" >&2 + exit 1 + fi + test -f "$sandbox/state/secrets.env" + find "$sandbox/state/assets" \ + -path '*/docker/docker-compose.cf-integration.yaml' \ + -type f -print -quit | grep -q . + + - name: Verify published package + run: cargo package --locked + + - name: Validate GitHub Actions workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + + native: + name: native (${{ matrix.target }}) + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - runner: macos-15 + target: aarch64-apple-darwin + - runner: windows-2025 + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.97.0 + with: + targets: ${{ matrix.target }} + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2.9.1 + + - name: Run native tests + shell: bash + run: cargo test --all-targets --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5f0781..e472257 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,71 +8,66 @@ concurrency: group: release-${{ github.ref }} cancel-in-progress: false +env: + CARGO_TERM_COLOR: always + CARGO_TARGET_DIR: target + jobs: - publish: - name: Publish crates + quality: + name: Release quality gate runs-on: ubuntu-24.04 permissions: - contents: write - outputs: - tag: ${{ steps.root-release.outputs.tag }} + contents: read steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - name: Install Rust toolchain - run: rustup toolchain install 1.97.0 --profile minimal - - - name: Publish unpublished workspace crates - id: release-plz - uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 + uses: dtolnay/rust-toolchain@1.97.0 with: - command: release - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + components: clippy,rustfmt - - name: Select CLI release - id: root-release + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2.9.1 + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run Clippy + run: cargo clippy --all-targets --locked -- -D warnings + + - name: Run full test suite + run: cargo test --all-targets --locked + + - name: Verify standalone lazy runtime state shell: bash - env: - RELEASES: ${{ steps.release-plz.outputs.releases }} run: | - tag=$(jq -r 'first(.[] | select(.package_name == "cf-integration") | .tag) // ""' <<<"$RELEASES") - echo "tag=$tag" >> "$GITHUB_OUTPUT" - - release-pr: - name: Prepare next release - needs: publish - runs-on: ubuntu-24.04 - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false + cargo build --locked --bin cf-integration + sandbox=$(mktemp -d) + binary="$GITHUB_WORKSPACE/target/debug/cf-integration" + if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" conformance report); then + echo "report unexpectedly succeeded without prior results" >&2 + exit 1 + fi + test ! -e "$sandbox/state" + if (cd "$sandbox" && CF_INTEGRATION_DIR="$sandbox/state" "$binary" stack config --topology dataplane); then + echo "stack config unexpectedly succeeded outside a checkout" >&2 + exit 1 + fi + test -f "$sandbox/state/secrets.env" + find "$sandbox/state/assets" \ + -path '*/docker/docker-compose.cf-integration.yaml' \ + -type f -print -quit | grep -q . - - name: Install Rust toolchain - run: rustup toolchain install 1.97.0 --profile minimal + - name: Verify published package + run: cargo package --locked - - name: Open or update release PR - uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 - with: - command: release-pr - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Validate GitHub Actions workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 build-binaries: name: Build ${{ matrix.target }} - needs: publish - if: needs.publish.outputs.tag != '' + needs: quality strategy: fail-fast: false matrix: @@ -97,38 +92,37 @@ jobs: binary: cf-integration.exe runs-on: ${{ matrix.runner }} permissions: - attestations: write contents: read - id-token: write env: CARGO_TARGET_DIR: target steps: - - name: Checkout release tag + - name: Checkout candidate commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.publish.outputs.tag }} persist-credentials: false - name: Install Rust toolchain - shell: bash - env: - TARGET: ${{ matrix.target }} - run: rustup toolchain install 1.97.0 --profile minimal --target "$TARGET" + uses: dtolnay/rust-toolchain@1.97.0 + with: + targets: ${{ matrix.target }} + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2.9.1 - - name: Build release binary + - name: Build release candidate shell: bash env: TARGET: ${{ matrix.target }} run: cargo build --release --locked --bin cf-integration --target "$TARGET" - - name: Smoke test release binary + - name: Smoke test release candidate shell: bash env: BINARY: ${{ matrix.binary }} TARGET: ${{ matrix.target }} run: '"target/$TARGET/release/$BINARY" --help' - - name: Package release binary + - name: Package release candidate shell: bash env: BINARY: ${{ matrix.binary }} @@ -144,12 +138,7 @@ jobs: shasum -a 256 "dist/$archive" | sed 's#dist/##' > "dist/$archive.sha256" fi - - name: Attest release archive - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: dist/*.tgz - - - name: Upload release archive + - name: Upload prevalidated candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cf-integration-${{ matrix.target }} @@ -160,21 +149,64 @@ jobs: if-no-files-found: error retention-days: 1 + publish: + name: Publish crate and tag + needs: [quality, build-binaries] + runs-on: ubuntu-24.04 + permissions: + contents: write + outputs: + tag: ${{ steps.root-release.outputs.tag }} + steps: + - name: Checkout prevalidated commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.97.0 + + - name: Publish root package + id: release-plz + uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 + with: + command: release + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Select CLI release + id: root-release + shell: bash + env: + RELEASES: ${{ steps.release-plz.outputs.releases }} + run: | + tag=$(jq -r 'first(.[] | select(.package_name == "cf-integration") | .tag) // ""' <<<"$RELEASES") + echo "tag=$tag" >> "$GITHUB_OUTPUT" + publish-binaries: name: Publish release binaries - needs: [publish, build-binaries] + needs: [build-binaries, publish] if: needs.publish.outputs.tag != '' runs-on: ubuntu-24.04 permissions: + attestations: write contents: write + id-token: write steps: - - name: Download release archives + - name: Download prevalidated candidates uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: cf-integration-* path: dist merge-multiple: true + - name: Attest release archives + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/*.tgz + - name: Upload assets and publish release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -182,3 +214,28 @@ jobs: run: | gh release upload "$TAG" dist/* --clobber gh release edit "$TAG" --draft=false --latest + + release-pr: + name: Prepare next release + needs: publish + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@1.97.0 + + - name: Open or update release PR + uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 + with: + command: release-pr + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index e30cd6c..701bc23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,29 +58,12 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" 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" @@ -156,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" @@ -219,75 +181,18 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", - "cf-integration-compliance", - "cf-integration-load", - "cf-integration-mcp", - "cf-integration-platform", "clap", - "jsonwebtoken", + "indicatif", "reqwest", "serde", "serde_json", "tempfile", - "tokio", - "tokio-stream", - "url", - "uuid", -] - -[[package]] -name = "cf-integration-compliance" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "cf-integration-platform", - "reqwest", - "serde", - "serde_json", - "tempfile", - "tokio", - "tokio-stream", - "url", -] - -[[package]] -name = "cf-integration-load" -version = "0.1.0" -dependencies = [ - "anyhow", - "cf-integration-mcp", - "cf-integration-platform", - "tempfile", -] - -[[package]] -name = "cf-integration-mcp" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "axum", - "reqwest", - "serde_json", - "tempfile", "thiserror", "tokio", "tokio-stream", "url", "uuid", -] - -[[package]] -name = "cf-integration-platform" -version = "0.1.0" -dependencies = [ - "anyhow", - "serde_json", - "serde_yaml", - "tempfile", - "tokio", - "uuid", + "yaml_serde", ] [[package]] @@ -304,13 +209,13 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures", + "rand_core", ] [[package]] @@ -379,10 +284,15 @@ dependencies = [ ] [[package]] -name = "const-oid" -version = "0.9.6" +name = "console" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] [[package]] name = "core-foundation" @@ -400,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" @@ -418,84 +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 = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[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" @@ -514,63 +337,10 @@ 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" +name = "encode_unicode" +version = "1.0.0" 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", -] +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "equivalent" @@ -594,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" @@ -691,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" @@ -725,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" @@ -752,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" @@ -987,6 +701,17 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unit-prefix", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1075,39 +800,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "10.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" -dependencies = [ - "base64", - "ed25519-dalek", - "getrandom 0.2.17", - "hmac", - "js-sys", - "p256", - "p384", - "pem", - "rand 0.8.6", - "rsa", - "serde", - "serde_json", - "sha2", - "signature", - "simple_asn1", - "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" @@ -1115,10 +807,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "libm" -version = "0.2.16" +name = "libyaml-rs" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" [[package]] name = "linux-raw-sys" @@ -1173,67 +865,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[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.6", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -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" @@ -1252,49 +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" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[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" @@ -1307,33 +895,18 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1343,30 +916,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[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" @@ -1398,15 +947,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash", @@ -1448,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.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.10.2" @@ -1467,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]] @@ -1501,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]] @@ -1544,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" @@ -1568,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" @@ -1721,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" @@ -1830,30 +1305,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[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" @@ -1870,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" @@ -1896,18 +1337,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -1930,22 +1359,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[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" @@ -2028,36 +1441,6 @@ dependencies = [ "syn", ] -[[package]] -name = "time" -version = "0.3.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -2216,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" @@ -2229,10 +1606,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unsafe-libyaml" -version = "0.2.11" +name = "unit-prefix" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "untrusted" @@ -2276,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" @@ -2507,6 +1878,19 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" +dependencies = [ + "indexmap", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yoke" version = "0.8.3" @@ -2530,26 +1914,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -2576,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 b5b61df..071e8f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,73 +1,51 @@ [package] name = "cf-integration" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true -authors.workspace = true -description = "Integration and conformance harness for ContextForge control-plane and data-plane services" -repository.workspace = true -homepage.workspace = true -readme = "README.md" -keywords = ["contextforge", "mcp", "integration-testing", "conformance"] -categories = ["command-line-utilities", "development-tools::testing"] -default-run = "cf-integration" - -[workspace] -members = [".", "crates/platform", "crates/mcp", "crates/compliance", "crates/load"] -resolver = "3" - -[workspace.package] version = "0.1.0" edition = "2024" rust-version = "1.97" license = "Apache-2.0" publish = true authors = ["ContextForge Authors"] +description = "Integration and conformance harness for ContextForge control-plane and data-plane services" repository = "https://github.com/contextforge-org/contextforge-dev-tools" homepage = "https://github.com/contextforge-org/contextforge-dev-tools" +readme = "README.md" +keywords = ["contextforge", "mcp", "integration-testing", "conformance"] +categories = ["command-line-utilities", "development-tools::testing"] +default-run = "cf-integration" +autobins = false +include = [ + "/src/**", + "/docker/**", + "/scripts/locustfile_mcp.py", + "/scripts/live_protocol/sitecustomize.py", + "/scripts/conformance/write_client_config.py", + "/tests/conformance/baselines/**", + "/README.md", + "/LICENSE", +] + +[[bin]] +name = "cf-integration" +path = "src/main.rs" -[workspace.dependencies] -cf-integration-platform = { version = "0.1.0", path = "crates/platform" } -cf-integration-mcp = { version = "0.1.0", path = "crates/mcp" } -cf-integration-compliance = { version = "0.1.0", path = "crates/compliance" } -cf-integration-load = { version = "0.1.0", path = "crates/load" } +[dependencies] anyhow = "1" -async-trait = "0.1.89" axum = "0.8.9" clap = { version = "4.5.60", features = ["derive"] } -jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } +indicatif = { version = "0.18.6", default-features = false } reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" -tempfile = "3" thiserror = "2.0.18" tokio = { version = "1.48", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } -tokio-stream = "0.1.18" url = "2.5.8" uuid = { version = "1.23.1", features = ["v4", "serde"] } - -[dependencies] -cf-integration-platform.workspace = true -cf-integration-mcp.workspace = true -cf-integration-compliance.workspace = true -cf-integration-load.workspace = true -anyhow.workspace = true -clap.workspace = true -jsonwebtoken.workspace = true -reqwest.workspace = true -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true -url.workspace = true -uuid.workspace = true +yaml_serde = "0.10" [dev-dependencies] -axum.workspace = true -tempfile.workspace = true -tokio-stream.workspace = true +tempfile = "3" +tokio-stream = "0.1.18" [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ target }{ archive-suffix }" @@ -75,13 +53,11 @@ bin-dir = "{ bin }{ binary-ext }" pkg-fmt = "tgz" disabled-strategies = ["quick-install", "compile"] -[lints] -workspace = true - -[workspace.lints.rust] +[lints.rust] unsafe_code = "forbid" +unreachable_pub = "deny" -[workspace.lints.clippy] +[lints.clippy] correctness = { level = "deny", priority = -1 } suspicious = { level = "warn", priority = -1 } style = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index 0836891..df43610 100644 --- a/README.md +++ b/README.md @@ -1,152 +1,74 @@ # cf-integration -Rust 1.97 integration harness for `cf-controlplane` and the Rust +`cf-integration` is the standalone Rust CLI for exercising `cf-controlplane` +with either its built-in Python data plane or the external Rust `cf-dataplane`. -The public routing contract is fixed: +The routing contract is fixed: -- `/servers/{virtual_host_id}/mcp` routes through `cf-dataplane` as - `/contextforge-rs/servers/{virtual_host_id}/mcp`. -- Raw `/mcp`, UI traffic, and API traffic stay on `cf-controlplane`. +- `/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 + control plane. -The `/servers/{id}/mcp` route does not fall back to the Python control plane on -dataplane errors. This makes routing failures visible and keeps the harness -aligned with the planned split between legacy slow-path traffic and modern -Rust dataplane traffic. +The CLI owns Docker Compose overlays, nginx routing, source checkout +orchestration, MCP probes, Locust load tests, upstream live tests, and official +MCP conformance runs. -The harness owns Docker Compose overlays, nginx routing, reproducible stack -lifecycle, public-route probes, Locust load tests, and official MCP -conformance orchestration. Generated checkout, build, and runtime state stays -under `.integration/` or `CF_INTEGRATION_DIR`. +## Install -## Requirements - -- Rust 1.97 or newer and Cargo -- Docker Engine with Docker Compose v2 -- Git -- Node.js 22.7.5 or newer with `npx` -- Python and Locust dependencies from the control-plane checkout when using - the Locust load engine -- The control-plane development prerequisites (`uv`, pytest, Make, and - Playwright where required) when running upstream live tests - -Install a prebuilt release without compiling the workspace. First install -[`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall#installation), -then run: +Release archives cover ARM64 and x86-64 Linux, macOS, and Windows. ```bash cargo binstall cf-integration cf-integration --help ``` -The release archives cover x86-64 and ARM64 Linux, macOS, and Windows. -`cargo-binstall` is required because Cargo's native `cargo install` command -always compiles a crate locally. The CLI still uses the tracked Compose overlays -and scripts at runtime, so run it from this repository checkout or set -`CF_INTEGRATION_ROOT` to the checkout path. - -The checked-in `rust-toolchain.toml` selects Rust 1.97.0 with rustfmt and -Clippy. To build and install the locked CLI from this checkout instead: +To compile from crates.io or this checkout: ```bash -rustup toolchain install 1.97.0 --profile minimal -c clippy -c rustfmt +cargo install cf-integration --locked cargo install --path . --locked -cf-integration --help ``` -Cargo places the executable in `$CARGO_HOME/bin` (normally `~/.cargo/bin`). -Re-run the install command after updating the checkout. - -## Workspace - -The workspace has one application package and four internal libraries: +The installed binary is repository-independent. Required Compose overlays, +runtime scripts, and conformance baselines are embedded in the executable. -- `cf-integration`: CLI and workflow composition -- `cf-integration-platform`: configuration, processes, checkouts, Compose, and - stack lifecycle -- `cf-integration-mcp`: MCP messages, HTTP transport, authentication proxy, - gateway endpoints, and probes -- `cf-integration-compliance`: the official conformance fixture, result parser, - and three-lane comparison report -- `cf-integration-load`: Locust load orchestration +## Runtime assets and workspace resolution -The official TypeScript fixture is the conformance reference target. An -explicit `stack up` starts it for direct MCP access; conformance runs still own -their isolated fixture lifecycle. Fast Time remains the ordinary probe and load -fixture, and upstream live MCP tests start and register the profile-gated Fast -Test server on demand. +The CLI resolves the action before initializing state. Runtime-backed actions +resolve assets in this order: -## Lanes and protocol versions +1. explicit `CF_INTEGRATION_ROOT`, which must be a valid developer checkout; +2. the current directory, when it contains a valid checkout; +3. a versioned embedded-asset tree beneath `CF_INTEGRATION_DIR`. -Probe, load, and Inspector use `--lane controlplane|dataplane` plus -`--protocol-version YYYY-MM-DD`. `controlplane` targets the stock physical -control-plane topology and raw `/mcp`; `dataplane` targets nginx, the Rust -dataplane, and the virtual-server route. Live and conformance use semantic -workflow lanes: `fixture-direct`, `built-in-data-plane`, and -`external-data-plane`. +Embedded assets are materialized atomically, verified byte-for-byte, marked +read-only, and reused. Concurrent first runs converge on one complete tree. A +corrupt or incomplete versioned tree fails closed. -Single-lane commands resolve their lane in this order: +`.env` is loaded from `CF_INTEGRATION_ROOT` when set, otherwise the current +directory. Relative paths resolve from that workspace. Generated checkouts, +assets, secrets, reports, and runtime state default to `.integration/`. -1. explicit `--lane`; -2. `CF_MCP_STACK_MODE`; -3. `dataplane`. +`conformance report` and `debug token` do not materialize assets or generate +local Compose secrets. Compose-backed actions initialize them lazily. -They resolve the protocol version from explicit `--protocol-version`, then -`MCP_PROTOCOL_VERSION`, then `2025-11-25`. That session-oriented default is -the working contract of the current `latest` dataplane image. Pass -`--protocol-version 2026-07-28` explicitly to exercise the implemented -stateless readiness path as the future architecture lands. For live and -conformance, a resolved `controlplane` stack selects `built-in-data-plane`, -while a resolved `dataplane` stack selects `external-data-plane`. Other -workflows reject `fixture-direct` because they have no direct-fixture execution -path. Conformance defaults to all three lanes and its pinned `2026-07-28` -protocol version. - -`--topology` remains a compatibility alias for `--lane` on workflows. -Conformance also retains `--client-version` and `--spec-version` as aliases for -`--protocol-version`. Stack lifecycle commands continue to use `--topology` -because they operate on physical stacks, not test lanes. - -## Quick start - -Probe the dataplane public MCP route: - -```bash -cf-integration probe --lane dataplane -``` - -`stack up` synchronizes the required source checkouts, validates the Compose -contract, resolves local builds or published images, starts the selected -topology, and waits for its public endpoint. It preserves existing volumes by -default. Use `--fresh` when state must be discarded. - -Probe, load, routed live-test, and Inspector commands start their selected -stack, wait for the fixture to be ready, and stop the stack when the command -succeeds or fails. The direct live fixture lane does not start a stack. -Explicit `stack` commands remain available when a persistent environment is -needed. - -The Fast Time backend is registered as virtual server -`9779b6698cbd4b4995ee04a4fab38737`, so probe and load commands need no manual -UI setup. +## Requirements -## Manual Bruno tests +Runtime requirements depend on the command: -The vendored Bruno workspace under -`manual-tests/mcp-manual-test-tools/` provides requests for manually exercising -MCP gateway and server flows. Open that directory as a workspace in Bruno, -select an environment, and set a fresh token where the selected flow requires -authentication. +- Docker Engine with Docker Compose v2 for stack-backed workflows; +- Git for managed source checkouts; +- Node.js 22.7.5 or newer with `npx` for Inspector, live, and conformance; +- the control-plane checkout's Python/Locust dependencies for load tests; +- Rust 1.97 only when compiling the CLI or local source images. -The collection was imported from -[`lucarlig/mcp-manual-test-tools`](https://github.com/lucarlig/mcp-manual-test-tools). -Its exact source revision is recorded in the vendored directory's -`UPSTREAM.md`. +Published control-plane and data-plane images are used by default. Local +data-plane builds require an explicit `CF_DATAPLANE_REF`. ## CLI -The public CLI contains only distinct workflows: - ```text cf-integration ├── stack @@ -166,249 +88,144 @@ cf-integration └── token ``` -Use `--help` at any level for the authoritative flags. +Use `--help` at any level for the authoritative interface. + +Every resolved command reports its lifecycle on standard error using the same +description: `⠋` while active, `✓` in green on success, and `✗` in red on +failure. Test results use aligned nextest-style labels: green `PASS`, yellow +`XFAIL`, red `XPASS` and `FAIL`, and yellow `SKIP`. `NO_COLOR` and +`CARGO_TERM_COLOR` control ANSI output. Command data such as tokens, Compose +configuration, and report paths remains on standard output for scripting. -### Stack lifecycle +Stack commands use physical `--topology controlplane|dataplane`: ```bash cf-integration stack up --topology dataplane cf-integration stack up --topology dataplane --fresh -cf-integration stack down --topology all -cf-integration stack down --topology all --volumes cf-integration stack status --topology dataplane -cf-integration stack logs --topology dataplane cf-nginx cf-dataplane cf-integration stack config --topology dataplane +cf-integration stack down --topology all +cf-integration stack down --topology all --volumes ``` -`stack down --volumes` is the explicit destructive cleanup operation. -Diagnostic commands use the harness Compose project and overlays so callers do -not need to reconstruct its Compose invocation. The dataplane topology defaults -to the `cf` Compose project, so Docker resources use the `cf-*` prefix. -Container viewers expose concise `cf-*` display names, and `stack logs` accepts -those names while translating them to the underlying Compose service keys. - -After readiness succeeds, `stack up` prints the public gateway/API origin, the -mode-correct public MCP endpoint, and the direct loopback address of the pinned -conformance server. Its host port is assigned by Docker and can change after a -fresh start. - -### Probe - -```bash -cf-integration probe --lane dataplane -``` - -The modern dataplane probe checks unauthenticated rejection, -`server/discover`, required per-request metadata and routing headers, -`tools/list`, and one known-safe `tools/call` without creating a session. The -legacy control-plane probe retains initialize, `notifications/initialized`, -and session reuse. It targets `/mcp` in controlplane topology and -`/servers/{id}/mcp` in dataplane topology. - -### Locust +`stack down --volumes` is the explicit destructive reset. Managed workflows +preserve the primary failure, attempt every token and stack cleanup, and report +all cleanup failures. -The load workflow exercises the MCP lifecycle through the framework-required -Python Locust adapter: +Probe, load, and Inspector use physical lanes: ```bash -cf-integration load --lane dataplane \ - --smoke - -cf-integration load --lane dataplane \ - --users 20 --spawn-rate 5 --run-time 2m +cf-integration probe --lane dataplane --protocol-version 2026-07-28 +cf-integration load --lane dataplane --smoke +cf-integration debug inspect --lane dataplane --method tools/list ``` -Default full-run settings are 100 users, 10 users/second, and five minutes. -CLI settings override `.env`; explicitly exported `LOCUST_USERS`, -`LOCUST_SPAWN_RATE`, and `LOCUST_RUN_TIME` remain authoritative. Smoke defaults -are one user, one user/second, and ten seconds. - -On the modern dataplane lane Locust uses `server/discover`, attaches the -mandatory client `_meta` plus `Mcp-Method`/`Mcp-Name` headers to every request, -and avoids sessions and the removed `ping` method. The legacy control-plane -lane retains initialize, `notifications/initialized`, session cleanup, and -ping. The adapter calls only a finite allowlist of safe fixture tools and audits -generated artifacts for credential leakage. - -### Upstream live tests - -Run the control-plane repository's live gateway tests against either topology: +Live and conformance share semantic lanes: `fixture-direct`, +`built-in-data-plane`, and `external-data-plane`. ```bash cf-integration live --lane external-data-plane --group mcp -cf-integration live --lane external-data-plane --group rbac -cf-integration live --lane external-data-plane --group protocol -cf-integration live --lane external-data-plane --group all - -# Run the upstream protocol suite directly against its reference fixture. -cf-integration live \ - --lane fixture-direct \ - --group protocol \ +cf-integration live --lane fixture-direct --group protocol \ --protocol-version 2025-06-18 -``` - -`--group all` is the exact union of the `mcp`, `rbac`, and `protocol` groups. -Upstream plugin and SSO suites are excluded because this harness does not -start their additional services. - -The `mcp` and `all` groups start the upstream profile-gated `fast_test_server`, -run its one-shot registration job, and, for the dataplane topology, wait until -the publisher snapshot contains its fixed virtual server before launching the -tests. The base stack remains unchanged when other workflows run. -`--lane fixture-direct` is valid with `--group protocol` and runs the upstream -`test-protocol-compliance-reference` target without a gateway stack. The -selected date-formatted version is applied to MCP SDK initialization, and the -live run fails with the installed SDK's supported-version list when that SDK -cannot emit it. - -## Official MCP conformance - -The official runner is pinned to -`@modelcontextprotocol/conformance@0.2.0-alpha.11`. The official TypeScript -fixture is built from matching source revision -`c321dd32035556e6769d3724a8ee97d87c3faaac`. - -The default command is intentionally complete and reproducible: - -```bash cf-integration conformance run -``` - -It always: - -- starts fresh stacks owned by the conformance workflow; -- provisions the pinned official fixture; -- runs every applicable official server scenario; -- defaults to MCP `2026-07-28`; -- runs fixture-direct, built-in-data-plane, and external-data-plane lanes; -- routes both gateway lanes through `/servers/{virtual_host_id}/mcp` using the - same unscoped ephemeral catalog-token contract as the control-plane job; -- disables rate limiting and embedded Rust MCP handling, and uses one Gunicorn - worker, matching the control-plane conformance job; -- builds the control plane with `ENABLE_RUST=false` and - `ENABLE_RUST_MCP_RMCP=false` when `CF_COMPOSE_BUILD=true`; -- passes an empty expected-failure file to the official runner; -- records raw failures without suppression; -- hides setup and runner output in artifact logs while showing live progress; -- removes temporary API resources, fixture services, and stacks; -- writes a comparison report even when a lane reports protocol failures. - -The official runner's protocol version and the upstream fixture's server era -are independent. The fixture defaults to `--server-era dual`, preserving the -existing behavior where it selects the matching lifecycle from the incoming -request. - -Run the same-era baselines explicitly: - -```bash -cf-integration conformance run \ - --protocol-version 2026-07-28 \ - --server-era modern cf-integration conformance run \ --protocol-version 2025-11-25 \ - --server-era legacy -``` - -Run the two cross-era paths: - -```bash -# Modern client-facing traffic against a legacy-only upstream. -cf-integration conformance run \ --protocol-version 2026-07-28 \ - --server-era legacy - -# Legacy client-facing traffic against a modern-only upstream. -cf-integration conformance run \ - --protocol-version 2025-11-25 \ + --server-era legacy \ --server-era modern +cf-integration conformance run --server-era dual --bless +cf-integration conformance report +cf-integration --version ``` -In a cross-era run, the fixture-direct lane is the expected incompatible -baseline. A routed lane that passes where fixture-direct fails demonstrates -that the gateway adapted the lifecycle across the boundary; the comparison -report records both axes. The official runner emits the selected client era -strictly. It does not itself test a general-purpose SDK client's automatic -dual-era fallback. +Workflows accept only `--lane`; `--topology` is reserved for stack commands. +The direct fixture spelling is only `fixture-direct`. Protocol selection is +only `--protocol-version`. -The three lanes are: +## MCP and conformance behavior -1. official oracle directly to the official TypeScript fixture; -2. official oracle through the routed Python built-in data-plane endpoint; -3. official oracle through the same route backed by the external Rust data - plane. +One MCP client owns endpoint construction, authorization, sessions, stateful +and stateless headers, JSON/SSE parsing, backend identity validation, response +limits, timeouts, and secret redaction. -Select exact lanes by repeating `--lane`: +The session-oriented probe performs initialize, `notifications/initialized`, +`tools/list`, and one safe `tools/call`. The stateless probe performs +`server/discover`, attaches `Mcp-Method` and `Mcp-Name` routing headers, and +performs the same safe checks without a session. Both verify unauthenticated +rejection and external dataplane backend identity. -```bash -cf-integration conformance run \ - --lane fixture-direct \ - --lane external-data-plane -``` - -Supported client revisions are explicit and use the same pinned runner and -fixture: - -```bash -cf-integration conformance run --protocol-version 2025-11-25 -cf-integration conformance run --protocol-version 2025-06-18 -``` - -Artifacts default below `CF_INTEGRATION_DIR`. Use `--results-dir` to place them -elsewhere. Regenerate only the official comparison report with: +The official runner is pinned to +`@modelcontextprotocol/conformance@0.2.0-alpha.11`. Its TypeScript fixture is +built from revision `c321dd32035556e6769d3724a8ee97d87c3faaac`. A default run +starts workflow-owned stacks and runs both conformance directions. The server +suite sends the official client directly to the fixture and through the +built-in and external dataplane routes. For protocol `2026-07-28`, the client +suite also makes the external dataplane send requests to the official scenario +servers. The four downstream scenarios are `tools_call`, `request-metadata`, +`http-standard-headers`, and `http-custom-headers`; they run automatically +whenever `external-data-plane` is selected. The workflow records raw official +results without suppression, writes deterministic comparisons, and continues +through every selected client-version/server-era combination before returning +one aggregated result. `dual` is supported only when selected explicitly. + +The client protocol and fixture server era are independent: ```bash -cf-integration conformance report -cf-integration conformance report \ - --results-dir /path/to/results \ - --output-dir /path/to/reports +cf-integration conformance run \ + --protocol-version 2025-11-25 \ + --protocol-version 2026-07-28 \ + --server-era legacy \ + --server-era modern ``` -The official runner has no bearer-header option. The harness therefore uses a -random-path loopback proxy that injects authorization while keeping tokens out -of process arguments. Automatic fixture provisioning requires a loopback -`MCP_CLI_BASE_URL`. - -## Debug utilities +The repeated client and server selections form a Cartesian product. Artifacts +default below `CF_INTEGRATION_DIR/conformance///` +and reports below `reports/conformance///`. +Server artifacts retain the lane directly below the era. Client artifacts and +reports use `client/external-data-plane/` below the era. `--results-dir`, +`--baseline-dir`, and `--output-dir` override those roots. -Debug commands are useful for manual diagnosis but are not compliance gates. +Baselines use this strict layout: -```bash -cf-integration debug inspect \ - --lane dataplane \ - --method tools/list - -cf-integration debug token \ - --kind scoped \ - --server-id - -cf-integration debug token --kind admin +```text +tests/conformance/baselines/ + / + / + fixture-direct.yml + built-in-data-plane.yml + external-data-plane.yml + client/ + external-data-plane.yml ``` -Token generation now authenticates against a running control plane using -`PLATFORM_ADMIN_EMAIL` and `PLATFORM_ADMIN_PASSWORD`. Scoped debug tokens -are catalog-backed, restricted to the selected virtual server, expire after -one day, and are intentionally left active for manual use. - -Inspector is pinned to `@modelcontextprotocol/inspector@2.2.0` and uses the -same loopback authentication proxy as conformance. Select `2026-07-28` to use -its modern MCP SDK path for stateless dataplane requests. - -## Configuration - -Copy `.env.example` to `.env`. Shell variables override `.env`, and relative -paths resolve from the repository root. - -Common settings: +Each file contains sorted `FAILURE` and `WARNING` check identities. They are +required to distinguish expected failures from regressions and are embedded +for installed binaries. Every completed lane is printed in nextest style even +when a later lane fails operationally. The direct fixture is gated +independently; findings reproduced there are subtracted from routed lanes +before server comparison. Client findings are gated independently without +fixture subtraction. Unexpected, stale, unknown, malformed, incomplete, +missing, and operational results fail the matrix. `--bless` replaces all +selected server and client baselines in one directory transaction only after +every combination succeeds. Outside a developer checkout, an omitted +`--baseline-dir` writes blessed baselines beneath the current workspace rather +than modifying embedded assets. Server comparison regeneration discovers every +protocol/era partition beneath the selected result root and accepts +`--results-dir` and `--output-dir`. + +## Canonical configuration + +Copy `.env.example` to `.env`. Process values override the file. ```bash -CF_MCP_STACK_MODE=dataplane +CF_INTEGRATION_ROOT=/path/to/contextforge-dev-tools CF_INTEGRATION_DIR=.integration +CF_MCP_STACK_MODE=dataplane CF_CONTROLPLANE_REPO=https://github.com/IBM/mcp-context-forge.git -CF_CONTROLPLANE_REF=v1.0.7 -CF_CONTROLPLANE_IMAGE=ghcr.io/ibm/mcp-context-forge:latest -CF_CONTROLPLANE_VERSION=latest +CF_CONTROLPLANE_REF=main +CF_CONTROLPLANE_VERSION=main CF_DATAPLANE_REPO=https://github.com/contextforge-org/contextforge-data-plane.git CF_DATAPLANE_REF= @@ -420,79 +237,69 @@ CF_FAST_TIME_EXPECTED_IMAGE=ghcr.io/ibm/cfex-mcp-fast-time-server:latest CF_FAST_TIME_SERVER_ID=9779b6698cbd4b4995ee04a4fab38737 MCP_CLI_BASE_URL=http://127.0.0.1:8080 -# Optional global override; leave unset for the current 2025-11-25 default. -# MCP_PROTOCOL_VERSION=2026-07-28 -NGINX_PORT=8080 -``` - -Published control-plane and dataplane images are the defaults; the dataplane -uses its `latest` tag. The control-plane checkout defaults to v1.0.7, whose -publisher uses UUID token subjects and the current backend snapshot schema. Set -`CF_DATAPLANE_REF` to build an explicit local dataplane ref. -`CF_COMPOSE_BUILD=auto` pulls or reuses prebuilt images and rebuilds a missing -or revision-stale source dataplane; `true` always builds and `false` never -builds. +MCP_PROTOCOL_VERSION=2026-07-28 +MCP_SERVER_ID=9779b6698cbd4b4995ee04a4fab38737 -Token and endpoint overrides used by probe, load, and debug commands: - -```bash -# Optional overrides. Without them, stable random local signing values are -# generated once under CF_INTEGRATION_DIR. -JWT_SECRET_KEY= -AUTH_ENCRYPTION_SECRET= PLATFORM_ADMIN_EMAIL=admin@example.com PLATFORM_ADMIN_PASSWORD= -MCPGATEWAY_BEARER_TOKEN= -MCP_SERVER_ID= -MCP_TOOL_NAMES= +MCPGATEWAY_BEARER_TOKEN= ``` -Managed workflows authenticate through the control-plane email-login endpoint. -Dataplane probe, load, Inspector, and conformance runs then request a one-day, -server-scoped API token from the token catalog and revoke it before stack -teardown. This ensures the token's UUID subject selects the same `UserConfig` -snapshot the publisher wrote. `MCPGATEWAY_BEARER_TOKEN` bypasses that -lifecycle and is never revoked by the harness. - -Conformance ignores caller-managed fixture IDs and tokens so every lane uses -the same official fixture. Never commit `.env` or generated tokens. - -## Future architecture alignment - -The dataplane repository's tentative ContextForge 2.0 wiki describes a -management plane, a legacy Python MCP slow path, and a modern `2026-07-28` -Rust fast path consuming revisioned effective configuration from a shared -store. This harness prepares for that split by keeping management and raw -`/mcp` traffic on control-plane, routing `/servers/{id}/mcp` strictly to the -dataplane, providing explicit stateless modern probe/load/Inspector paths, and -obtaining dataplane credentials from the management plane. The ordinary -workflow default remains `2025-11-25` until the current upstream expected -failure baseline for stateless aggregate and targeted operations is retired. - -The remaining boundary belongs upstream rather than in this harness: -control-plane must publish atomic compiled configuration and perform discovery, -catalog normalization, pagination, and liveness; dataplane must serve aggregate -catalog methods from that configuration and route targeted operations to one -backend without live fan-out. When those phases land, the harness should add -revision-isolation and tenant/principal partition tests instead of compatibility -fallbacks. See the -[`_context/wiki` architecture notes](https://github.com/contextforge-org/contextforge-data-plane/tree/main/_context/wiki). - -## Repository layout +`CF_COMPOSE_BUILD=auto` pulls or reuses prebuilt images and builds only an +explicit source data plane when required. `true` always builds; `false` never +builds. Published mode tracks both repositories' main-branch images. The +dataplane uses its floating `:latest` tag. The control plane uses the +commit-tagged image for the freshly fetched `origin/main` revision because +upstream reserves `:latest` for releases. Stack startup pulls changes; +incompatible main images make the workflow fail instead of selecting an older +pair. + +Compose requires `JWT_SECRET_KEY` and `AUTH_ENCRYPTION_SECRET`. If either is +unset, a runtime-backed action generates stable values under +`CF_INTEGRATION_DIR`. Canonical configuration is exported internally as the +upstream Compose adapter names `IMAGE_LOCAL` and `FAST_TIME_IMAGE`; those names +are not accepted as inputs. + +Without `MCPGATEWAY_BEARER_TOKEN`, dataplane workflows issue a one-day +server-scoped catalog token and revoke it during session cleanup. A caller +supplied token is never revoked by the harness. + +## Package layout + +One root package publishes exactly one binary, `cf-integration`. All concern +modules remain private implementation details: ```text -Cargo.toml, Cargo.lock Rust workspace -.cargo/config.toml Cargo output under .integration/ -src/ CLI and workflow composition -crates/platform/ platform orchestration library -crates/mcp/ MCP transport and probe library -crates/compliance/ official conformance library -crates/load/ Locust orchestration library -docker/docker-compose.cf-dataplane.yaml dataplane service and nginx override -docker/docker-compose.cf-integration.yaml Fast Time and Locust overlay -docker/docker-compose.cf-conformance.yaml official fixture overlay -scripts/locustfile_mcp.py Locust MCP adapter -manual-tests/mcp-manual-test-tools/ vendored Bruno workspace -reports/mcp-conformance-comparison.md tracked three-lane comparison -.integration/ ignored checkout/build/runtime state +src/infrastructure/ config, assets, processes, checkouts, Compose plans +src/mcp/ unified MCP client, protocol, auth proxy, probe +src/conformance/ fixture, strict baselines, results, comparisons +src/performance/ Locust settings, commands, and report auditing +src/runtime/live/ upstream live-test workflow +src/runtime/stack/ stack lifecycle and source ownership +src/runtime/conformance/ conformance orchestration and reports +src/runtime/performance/ performance workflow orchestration +src/runtime/probe.rs probe workflow orchestration +src/runtime/session.rs shared managed stack and credential scope +src/runtime/mod.rs thin action dispatcher +docker/ embedded Compose and nginx assets +scripts/ embedded runtime adapters +tests/conformance/ embedded expected-result baselines ``` + +The Bruno collection under `manual-tests/mcp-manual-test-tools/` is an +intentional lower stack layer for manual diagnosis. It remains in the +repository and is excluded from the published crate payload. + +## Development and release + +```bash +cargo fmt --all --check +cargo clippy --all-targets -- -D warnings +cargo test --all-targets +cargo package --locked +``` + +Pull requests run this quality gate plus native tests on Linux, macOS, and +Windows. Releases build and smoke-test all six ARM64/x86-64 Linux, macOS, and +Windows candidates before publishing the crate or tag. Prevalidated archives, +SHA-256 files, and GitHub artifact attestations are published afterward. diff --git a/crates/compliance/Cargo.toml b/crates/compliance/Cargo.toml deleted file mode 100644 index 084f084..0000000 --- a/crates/compliance/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "cf-integration-compliance" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true -authors.workspace = true -description = "MCP conformance orchestration for the ContextForge integration harness" -repository.workspace = true -homepage.workspace = true - -[dependencies] -cf-integration-platform.workspace = true -anyhow.workspace = true -reqwest.workspace = true -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true -url.workspace = true - -[dev-dependencies] -axum.workspace = true -tempfile.workspace = true -tokio-stream.workspace = true - -[lints] -workspace = true diff --git a/crates/compliance/src/lib.rs b/crates/compliance/src/lib.rs deleted file mode 100644 index bb50684..0000000 --- a/crates/compliance/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Official conformance fixture, result, and report primitives. - -pub mod conformance; -pub mod conformance_fixture; -pub mod profile; - -pub use profile::{ - DEFAULT_MCP_SPEC_VERSION, LEGACY_MCP_SPEC_VERSION, OFFICIAL_CONFORMANCE_PACKAGE, - OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION, STABLE_MCP_SPEC_VERSION, -}; diff --git a/crates/compliance/src/profile.rs b/crates/compliance/src/profile.rs deleted file mode 100644 index 74ba5b7..0000000 --- a/crates/compliance/src/profile.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Coherent official conformance runner, fixture, and protocol pins. - -/// Published official CLI package used as the conformance client. -pub const OFFICIAL_CONFORMANCE_PACKAGE: &str = "@modelcontextprotocol/conformance@0.2.0-alpha.11"; -/// Official repository containing the matching TypeScript fixture server. -pub const OFFICIAL_CONFORMANCE_REPOSITORY: &str = - "https://github.com/modelcontextprotocol/conformance"; -/// Exact source revision behind the published CLI and TypeScript fixture. -pub const OFFICIAL_CONFORMANCE_REVISION: &str = "c321dd32035556e6769d3724a8ee97d87c3faaac"; -/// Default draft protocol revision exercised by official conformance commands. -pub const DEFAULT_MCP_SPEC_VERSION: &str = "2026-07-28"; -/// Previous stable revision supported by the pinned official conformance package. -pub const STABLE_MCP_SPEC_VERSION: &str = "2025-11-25"; -/// Oldest revision supported by the pinned official conformance package. -pub const LEGACY_MCP_SPEC_VERSION: &str = "2025-06-18"; diff --git a/crates/load/Cargo.toml b/crates/load/Cargo.toml deleted file mode 100644 index cb25785..0000000 --- a/crates/load/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "cf-integration-load" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true -authors.workspace = true -description = "Locust load orchestration for the ContextForge integration harness" -repository.workspace = true -homepage.workspace = true - -[dependencies] -cf-integration-platform.workspace = true -cf-integration-mcp.workspace = true -anyhow.workspace = true - -[dev-dependencies] -tempfile.workspace = true - -[lints] -workspace = true diff --git a/crates/load/src/lib.rs b/crates/load/src/lib.rs deleted file mode 100644 index 5ff2526..0000000 --- a/crates/load/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Locust load-testing primitives. - -mod locust; -mod settings; - -pub use locust::{LocustCommand, audit_reports as audit_locust_reports}; -pub use settings::{LoadRequest, LoadSettings}; diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml deleted file mode 100644 index b762655..0000000 --- a/crates/mcp/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "cf-integration-mcp" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true -authors.workspace = true -description = "MCP transport and probe primitives for the ContextForge integration harness" -repository.workspace = true -homepage.workspace = true - -[dependencies] -anyhow.workspace = true -async-trait.workspace = true -axum.workspace = true -reqwest.workspace = true -serde_json.workspace = true -thiserror.workspace = true -tokio.workspace = true -url.workspace = true -uuid.workspace = true - -[dev-dependencies] -tempfile.workspace = true -tokio-stream.workspace = true - -[lints] -workspace = true diff --git a/crates/mcp/src/http_transport.rs b/crates/mcp/src/http_transport.rs deleted file mode 100644 index f5b8069..0000000 --- a/crates/mcp/src/http_transport.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Reqwest implementation of the public MCP probe transport. - -use std::fmt; - -use anyhow::{Context, Result, anyhow, bail}; -use async_trait::async_trait; -use reqwest::Client; -use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderValue}; -use url::Url; - -use crate::backend_identity::{BackendIdentity, is_dataplane_endpoint}; -use crate::mcp::{ACCEPT as MCP_ACCEPT, is_stateless_protocol, parse_mcp_body, routing_name}; -use crate::probe::{ProbeRequest, ProbeResponse, ProbeTransport}; - -const REDACTED: &str = ""; -/// Maximum response buffered by the small public-route probe. -pub const MAX_MCP_RESPONSE_BYTES: usize = 4 * 1024 * 1024; - -/// HTTPS-validating, redirect-free MCP probe transport. -#[derive(Clone)] -pub struct ReqwestProbeTransport { - client: Client, -} - -impl ReqwestProbeTransport { - /// Builds a transport with redirects and environment proxies disabled. - /// - /// # Errors - /// - /// Returns an error if the HTTP client cannot be configured. - pub fn new() -> Result { - let client = Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .build() - .context("failed to configure MCP probe HTTP client")?; - Ok(Self { client }) - } -} - -impl fmt::Debug for ReqwestProbeTransport { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("ReqwestProbeTransport") - .field("client", &REDACTED) - .finish() - } -} - -#[async_trait] -impl ProbeTransport for ReqwestProbeTransport { - async fn post(&self, request: ProbeRequest) -> Result { - let endpoint = validated_endpoint(&request.url)?; - let require_dataplane_backend = is_dataplane_endpoint(&endpoint); - let payload = serde_json::to_vec(&request.payload) - .context("failed to encode MCP probe JSON-RPC request")?; - let mut builder = self - .client - .post(endpoint) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .header(ACCEPT, HeaderValue::from_static(MCP_ACCEPT)) - .body(payload); - - if let Some(protocol_version) = request.protocol_version.as_deref() { - let protocol_version = safe_header(protocol_version, "MCP-Protocol-Version")?; - builder = builder.header("MCP-Protocol-Version", protocol_version); - } - if request - .protocol_version - .as_deref() - .is_some_and(is_stateless_protocol) - && let Some(method) = request - .payload - .get("method") - .and_then(serde_json::Value::as_str) - { - builder = builder.header("MCP-Method", safe_header(method, "MCP-Method")?); - if let Some(name) = routing_name(method, request.payload.get("params")) { - builder = builder.header("MCP-Name", safe_header(name, "MCP-Name")?); - } - } - if let Some(token) = request.bearer_token.as_deref() { - let mut authorization = safe_header(&format!("Bearer {token}"), "Authorization")?; - authorization.set_sensitive(true); - builder = builder.header(AUTHORIZATION, authorization); - } - if let Some(session_id) = request.session_id.as_deref() { - let mut session = safe_header(session_id, "MCP-Session-Id")?; - session.set_sensitive(true); - builder = builder.header("MCP-Session-Id", session); - } - - let mut response = builder - .send() - .await - .map_err(|_| anyhow!("MCP probe HTTP request failed"))?; - let status = response.status(); - let backend_identity = BackendIdentity::from_headers(response.headers()); - if require_dataplane_backend && let Some(message) = backend_identity.dataplane_error() { - bail!(message); - } - let session_id = response - .headers() - .get("MCP-Session-Id") - .map(|value| { - value - .to_str() - .map(str::to_owned) - .map_err(|_| anyhow!("MCP response has an invalid session header")) - }) - .transpose()?; - - if !status.is_success() { - return Ok(ProbeResponse::new(status.as_u16(), session_id, None) - .with_backend_identity(backend_identity)); - } - - let content_type = response - .headers() - .get(CONTENT_TYPE) - .map(|value| { - value - .to_str() - .map(str::to_owned) - .map_err(|_| anyhow!("MCP response has an invalid content type")) - }) - .transpose()?; - let body = bounded_body(&mut response).await?; - if body.is_empty() { - return Ok(ProbeResponse::new(status.as_u16(), session_id, None) - .with_backend_identity(backend_identity)); - } - let content_type = content_type - .as_deref() - .filter(|value| is_mcp_content_type(value)) - .ok_or_else(|| anyhow!("unsupported MCP response content type"))?; - let body = std::str::from_utf8(&body) - .map_err(|_| anyhow!("MCP response body is not valid UTF-8"))?; - let message = parse_mcp_body(body, content_type) - .map_err(|_| anyhow!("failed to parse MCP response body"))?; - - Ok(ProbeResponse::new(status.as_u16(), session_id, message) - .with_backend_identity(backend_identity)) - } -} - -fn validated_endpoint(raw: &str) -> Result { - let endpoint = Url::parse(raw).map_err(|_| anyhow!("invalid MCP probe URL"))?; - if !matches!(endpoint.scheme(), "http" | "https") - || endpoint.host().is_none() - || !endpoint.username().is_empty() - || endpoint.password().is_some() - || endpoint.fragment().is_some() - { - bail!("invalid MCP probe URL"); - } - Ok(endpoint) -} - -fn safe_header(value: &str, name: &str) -> Result { - HeaderValue::from_str(value).map_err(|_| anyhow!("invalid {name} header value")) -} - -async fn bounded_body(response: &mut reqwest::Response) -> Result> { - let mut body = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .map_err(|_| anyhow!("failed to read MCP response body"))? - { - if body.len().saturating_add(chunk.len()) > MAX_MCP_RESPONSE_BYTES { - bail!("MCP response body exceeds safety limit"); - } - body.extend_from_slice(&chunk); - } - Ok(body) -} - -fn is_mcp_content_type(value: &str) -> bool { - let media_type = value - .split_once(';') - .map_or(value, |(media_type, _)| media_type) - .trim(); - media_type.eq_ignore_ascii_case("application/json") - || media_type.eq_ignore_ascii_case("text/event-stream") -} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs deleted file mode 100644 index 5b6930c..0000000 --- a/crates/mcp/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! MCP transport, gateway, authentication proxy, and probe primitives. - -pub mod auth_proxy; -pub mod backend_identity; -pub mod gateway; -pub mod http_transport; -pub mod mcp; -pub mod probe; -mod topology; - -pub use topology::GatewayTopology; diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml deleted file mode 100644 index baaec00..0000000 --- a/crates/platform/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "cf-integration-platform" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true -authors.workspace = true -description = "Platform orchestration primitives for the ContextForge integration harness" -repository.workspace = true -homepage.workspace = true - -[dependencies] -anyhow.workspace = true -serde_json.workspace = true -tokio.workspace = true -uuid.workspace = true - -[dev-dependencies] -serde_yaml.workspace = true -tempfile.workspace = true - -[lints] -workspace = true diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs deleted file mode 100644 index 4996fc2..0000000 --- a/crates/platform/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Source, process, configuration, Compose, and stack primitives. - -pub mod checkout; -pub mod compose; -pub mod config; -pub mod error; -mod mode; -pub mod process; -pub mod stack; - -pub use error::PlatformError; -pub use mode::StackMode; diff --git a/crates/platform/src/mode.rs b/crates/platform/src/mode.rs deleted file mode 100644 index 9b474cd..0000000 --- a/crates/platform/src/mode.rs +++ /dev/null @@ -1,8 +0,0 @@ -/// Deployment topology managed by the integration harness. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StackMode { - /// Python control plane only. - Controlplane, - /// Python control plane routed through the Rust dataplane. - Dataplane, -} diff --git a/docker/docker-compose.cf-conformance.yaml b/docker/docker-compose.cf-conformance.yaml index c61b86d..3e1e1a0 100644 --- a/docker/docker-compose.cf-conformance.yaml +++ b/docker/docker-compose.cf-conformance.yaml @@ -2,6 +2,8 @@ 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: profiles: ["conformance"] image: cf-integration/mcp-conformance-server:0.2.0-alpha.11 @@ -13,7 +15,7 @@ services: restart: "no" environment: PORT: "3000" - MCP_CONFORMANCE_SERVER_ERA: ${CF_CONFORMANCE_SERVER_ERA:-dual} + MCP_CONFORMANCE_SERVER_ERA: ${CF_CONFORMANCE_SERVER_ERA:?Set CF_CONFORMANCE_SERVER_ERA to legacy, modern, or dual} ports: - "127.0.0.1:${CF_CONFORMANCE_PORT:-0}:3000" networks: diff --git a/docker/docker-compose.cf-controlplane-build-labels.yaml b/docker/docker-compose.cf-controlplane-build-labels.yaml index 3d35fac..b7e889b 100644 --- a/docker/docker-compose.cf-controlplane-build-labels.yaml +++ b/docker/docker-compose.cf-controlplane-build-labels.yaml @@ -67,14 +67,6 @@ services: labels: name: cf-locust-token - fast_test_server: - labels: - name: cf-fast-test-server - - register_fast_test: - labels: - name: cf-register-fast-test - a2a_echo_agent: labels: name: cf-a2a-echo-agent diff --git a/docker/docker-compose.cf-dataplane.yaml b/docker/docker-compose.cf-dataplane.yaml index 762a9ca..bdfc25c 100644 --- a/docker/docker-compose.cf-dataplane.yaml +++ b/docker/docker-compose.cf-dataplane.yaml @@ -38,6 +38,8 @@ services: mcpnet: aliases: - cf-dataplane + extra_hosts: + - host.docker.internal:host-gateway expose: - "4445" environment: diff --git a/docker/docker-compose.cf-integration.yaml b/docker/docker-compose.cf-integration.yaml index c0d1ac2..c965efc 100644 --- a/docker/docker-compose.cf-integration.yaml +++ b/docker/docker-compose.cf-integration.yaml @@ -31,7 +31,7 @@ services: - MCP_SERVER_ID=${MCP_SERVER_ID:-} - MCP_SERVER_IDS=${MCP_SERVER_IDS:-} - MCP_TOOL_NAMES=${MCP_TOOL_NAMES:-} - - MCP_PROTOCOL_VERSION=${MCP_PROTOCOL_VERSION:-2025-11-25} + - MCP_PROTOCOL_VERSION=${MCP_PROTOCOL_VERSION:-2026-07-28} - LOCUST_LOG_LEVEL=${LOCUST_LOG_LEVEL:-INFO} command: - | diff --git a/docker/patch-mcp-conformance-hosts.mjs b/docker/patch-mcp-conformance-hosts.mjs index 9db9f7b..fb7007e 100644 --- a/docker/patch-mcp-conformance-hosts.mjs +++ b/docker/patch-mcp-conformance-hosts.mjs @@ -25,10 +25,8 @@ const versionListOld = `const LEGACY_SESSION_PROTOCOL_VERSIONS = [ ];`; const versionListReplacement = `${versionListOld} -// Harness-only switch for exercising cross-era gateway paths. The upstream -// fixture remains dual-era by default. -const CONFORMANCE_SERVER_ERA = - process.env.MCP_CONFORMANCE_SERVER_ERA ?? 'dual'; +// Harness-only switch for exercising explicit cross-era gateway paths. +const CONFORMANCE_SERVER_ERA = process.env.MCP_CONFORMANCE_SERVER_ERA; if (!['dual', 'legacy', 'modern'].includes(CONFORMANCE_SERVER_ERA)) { throw new Error( \`invalid MCP_CONFORMANCE_SERVER_ERA: \${CONFORMANCE_SERVER_ERA}\` diff --git a/release-plz.toml b/release-plz.toml index d734acd..2fa108a 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -6,33 +6,10 @@ release_always = true [[package]] name = "cf-integration" -version_group = "cf-integration" -changelog_include = [ - "cf-integration-platform", - "cf-integration-mcp", - "cf-integration-compliance", - "cf-integration-load", -] +semver_check = true git_release_enable = true git_release_draft = true git_release_latest = false git_release_name = "v{{ version }}" git_tag_enable = true git_tag_name = "v{{ version }}" -semver_check = false - -[[package]] -name = "cf-integration-platform" -version_group = "cf-integration" - -[[package]] -name = "cf-integration-mcp" -version_group = "cf-integration" - -[[package]] -name = "cf-integration-compliance" -version_group = "cf-integration" - -[[package]] -name = "cf-integration-load" -version_group = "cf-integration" diff --git a/scripts/conformance/write_client_config.py b/scripts/conformance/write_client_config.py new file mode 100644 index 0000000..f4173de --- /dev/null +++ b/scripts/conformance/write_client_config.py @@ -0,0 +1,89 @@ +#!/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": {}, + } + } + } + } + } + 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/locustfile_mcp.py b/scripts/locustfile_mcp.py index 5423ea4..cae4b7b 100644 --- a/scripts/locustfile_mcp.py +++ b/scripts/locustfile_mcp.py @@ -7,7 +7,7 @@ Env: MCP_STACK_MODE controlplane or dataplane - MCP_SERVER_ID / MCP_VIRTUAL_SERVER_ID virtual server id (dataplane only) + MCP_SERVER_ID virtual server id (dataplane only) MCPGATEWAY_BEARER_TOKEN bearer token (required) MCP_TOOL_NAMES optional comma-separated tools to call LOCUST_REQUEST_TIMEOUT_SECONDS positive finite per-request timeout (default 60) @@ -23,7 +23,7 @@ from locust import HttpUser, between, events, task -PROTOCOL_VERSION = os.environ.get("MCP_PROTOCOL_VERSION", "2025-11-25") +PROTOCOL_VERSION = os.environ.get("MCP_PROTOCOL_VERSION", "2026-07-28") STATELESS = PROTOCOL_VERSION >= "2026-07-28" ACCEPT = "application/json, text/event-stream" _REQUEST_TIMEOUT_ERROR = ( @@ -178,7 +178,7 @@ def validate_result(method: str, result) -> dict: raise ValueError("tools/call result contains invalid content") return result -MCP_SERVER_ID = os.environ.get("MCP_SERVER_ID") or os.environ.get("MCP_VIRTUAL_SERVER_ID", "") +MCP_SERVER_ID = os.environ.get("MCP_SERVER_ID", "") MCP_STACK_MODE = os.environ.get("MCP_STACK_MODE", "dataplane") BEARER_TOKEN = os.environ.get("MCPGATEWAY_BEARER_TOKEN", "") TOOL_NAMES = [name.strip() for name in os.environ.get("MCP_TOOL_NAMES", "").split(",") if name.strip()] @@ -220,7 +220,7 @@ def on_start(self): if MCP_STACK_MODE not in {"controlplane", "dataplane"}: raise RuntimeError("MCP_STACK_MODE must be controlplane or dataplane") if MCP_STACK_MODE == "dataplane" and not MCP_SERVER_ID: - raise RuntimeError("MCP_SERVER_ID or MCP_VIRTUAL_SERVER_ID is required") + raise RuntimeError("MCP_SERVER_ID is required") if not BEARER_TOKEN: raise RuntimeError("MCPGATEWAY_BEARER_TOKEN is required") if STATELESS: diff --git a/src/app.rs b/src/app.rs index e7a62e6..b491259 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,11 +5,12 @@ use std::ffi::{OsStr, OsString}; use std::path::PathBuf; use std::str::FromStr; +use crate::conformance::DEFAULT_MCP_SPEC_VERSION; +use crate::conformance::results::{ConformanceServerEra, SemanticLane}; +use crate::infrastructure::StackMode; +use crate::infrastructure::config::Environment; +use crate::performance::LoadRequest; use anyhow::{Result, bail}; -use cf_integration_compliance::conformance::{ConformanceServerEra, ConformanceTarget}; -use cf_integration_load::LoadRequest; -use cf_integration_platform::StackMode; -use cf_integration_platform::config::Environment; use crate::cli::{ Cli, CliLane, CliTopology, Command, ConformanceCommand, DebugCommand, LiveGroup, @@ -20,7 +21,7 @@ const PROTOCOL_VERSION_ENV: &str = "MCP_PROTOCOL_VERSION"; /// Fully resolved application operation. #[derive(Debug, Clone, PartialEq)] -pub enum Action { +pub(crate) enum Action { Stack(StackAction), Probe { topology: StackMode, @@ -28,7 +29,7 @@ pub enum Action { }, Load(ResolvedLoadArgs), Live { - lane: LiveLane, + lane: SemanticLane, group: LiveGroup, protocol_version: ProtocolVersion, }, @@ -36,9 +37,144 @@ pub enum Action { Debug(DebugAction), } +impl Action { + /// Stable command path used by lifecycle output. + #[must_use] + pub(crate) const fn description(&self) -> &'static str { + match self { + Self::Stack(StackAction::Up { .. }) => "stack up", + Self::Stack(StackAction::Down { .. }) => "stack down", + Self::Stack(StackAction::Status(_)) => "stack status", + Self::Stack(StackAction::Logs { .. }) => "stack logs", + Self::Stack(StackAction::Config(_)) => "stack config", + Self::Probe { .. } => "probe", + Self::Load(_) => "load test", + Self::Live { .. } => "live tests", + Self::Conformance(ConformanceAction::Run { .. }) => "conformance tests", + Self::Conformance(ConformanceAction::Report { .. }) => "conformance report", + Self::Debug(DebugAction::Inspect { .. }) => "debug inspect", + Self::Debug(DebugAction::Token { .. }) => "debug token", + } + } + + /// Resolved execution context printed before the command starts. + #[must_use] + pub(crate) fn startup_summary(&self) -> String { + match self { + Self::Stack(action) => action.startup_summary(), + Self::Probe { + topology, + protocol_version, + } + | Self::Debug(DebugAction::Inspect { + topology, + protocol_version, + .. + }) => topology_and_protocol(*topology, protocol_version), + Self::Load(args) => topology_and_protocol(args.topology, &args.protocol_version), + Self::Live { + lane, + protocol_version, + .. + } => format!( + "Topology: {}\nProtocol version: {protocol_version}", + lane.label() + ), + Self::Conformance(ConformanceAction::Run { + lanes, + client_versions, + server_eras, + .. + }) => format!( + "Topology: {}\nClient protocol versions: {}\nServer protocol versions: {}", + join_lane_labels(lanes), + client_versions.join(", "), + join_server_protocols(server_eras), + ), + Self::Conformance(ConformanceAction::Report { .. }) => String::from( + "Topology: recorded conformance results\nClient protocol versions: recorded conformance results\nServer protocol versions: recorded conformance results", + ), + Self::Debug(DebugAction::Token { .. }) => { + String::from("Topology: not applicable (token only)") + } + } + } + + /// Returns whether the dispatcher should own one command-wide activity line. + #[must_use] + pub(crate) const fn uses_global_activity(&self) -> bool { + !matches!( + self, + Self::Stack(StackAction::Up { .. }) + | Self::Load(_) + | Self::Conformance(ConformanceAction::Run { .. }) + ) + } + + /// 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 { .. }) + ) + } +} + +impl StackAction { + fn startup_summary(&self) -> String { + let topology = match self { + Self::Up { topology, .. } + | Self::Status(topology) + | Self::Logs { topology, .. } + | Self::Config(topology) => topology.topology_label().to_owned(), + Self::Down { topology, .. } => match topology { + TopologySelection::Controlplane => { + StackMode::Controlplane.topology_label().to_owned() + } + TopologySelection::Dataplane => StackMode::Dataplane.topology_label().to_owned(), + TopologySelection::All => format!( + "{}, {}", + StackMode::Controlplane.topology_label(), + StackMode::Dataplane.topology_label() + ), + }, + }; + if matches!(self, Self::Up { .. }) { + format!("Topology: {topology}\nProtocol version: {DEFAULT_MCP_SPEC_VERSION}") + } else { + format!("Topology: {topology}") + } + } +} + +fn topology_and_protocol(topology: StackMode, protocol_version: &ProtocolVersion) -> String { + format!( + "Topology: {}\nProtocol version: {protocol_version}", + topology.topology_label() + ) +} + +fn join_lane_labels(lanes: &[SemanticLane]) -> String { + lanes + .iter() + .map(|lane| lane.label()) + .collect::>() + .join(", ") +} + +fn join_server_protocols(server_eras: &[ConformanceServerEra]) -> String { + server_eras + .iter() + .map(|era| format!("{} [{}]", era.label(), era.protocol_versions_label())) + .collect::>() + .join("; ") +} + /// Fully resolved stack operation. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum StackAction { +pub(crate) enum StackAction { Up { topology: StackMode, fresh: bool, @@ -57,28 +193,23 @@ pub enum StackAction { /// Fully resolved load-test options. #[derive(Debug, Clone, PartialEq)] -pub struct ResolvedLoadArgs { - pub topology: StackMode, - pub protocol_version: ProtocolVersion, - pub request: LoadRequest, -} - -/// One upstream live-test execution path. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LiveLane { - Fixture, - BuiltInDataPlane, - ExternalDataPlane, +pub(crate) struct ResolvedLoadArgs { + pub(crate) topology: StackMode, + pub(crate) protocol_version: ProtocolVersion, + pub(crate) request: LoadRequest, } /// Fully resolved official conformance operation. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ConformanceAction { +pub(crate) enum ConformanceAction { Run { - lanes: Vec, - spec_version: String, - server_era: ConformanceServerEra, + lanes: Vec, + client_versions: Vec, + server_eras: Vec, results_dir: Option, + baseline_dir: Option, + bless: bool, + output_dir: Option, }, Report { results_dir: Option, @@ -88,7 +219,7 @@ pub enum ConformanceAction { /// Fully resolved manual debugging operation. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum DebugAction { +pub(crate) enum DebugAction { Inspect { topology: StackMode, protocol_version: ProtocolVersion, @@ -107,7 +238,7 @@ pub enum DebugAction { /// /// Returns an error when a command needs `CF_MCP_STACK_MODE` and its value is /// neither `controlplane` nor `dataplane`. -pub fn resolve_action(cli: Cli, environment: &Environment) -> Result { +pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result { match cli.command { Command::Stack(args) => resolve_stack(args.command, environment).map(Action::Stack), Command::Probe(args) => { @@ -140,7 +271,7 @@ pub fn resolve_action(cli: Cli, environment: &Environment) -> Result { } Command::Live(args) => { let lane = resolve_live_lane(args.target.lane, environment)?; - if lane == LiveLane::Fixture && args.group != LiveGroup::Protocol { + if lane == SemanticLane::FixtureDirect && args.group != LiveGroup::Protocol { bail!("--lane fixture-direct requires --group protocol"); } Ok(Action::Live { @@ -156,9 +287,12 @@ pub fn resolve_action(cli: Cli, environment: &Environment) -> Result { Command::Conformance(args) => Ok(Action::Conformance(match args.command { ConformanceCommand::Run(args) => ConformanceAction::Run { lanes: resolve_lanes(args.lane.into_iter().map(Into::into)), - spec_version: args.protocol_version.to_string(), - server_era: args.server_era.into(), + client_versions: resolve_client_versions(args.protocol_version), + server_eras: resolve_server_eras(args.server_era), results_dir: args.results_dir, + baseline_dir: args.baseline_dir, + bless: args.bless, + output_dir: args.output_dir, }, ConformanceCommand::Report(args) => ConformanceAction::Report { results_dir: args.results_dir, @@ -192,14 +326,14 @@ pub fn resolve_action(cli: Cli, environment: &Environment) -> Result { } } -fn resolve_live_lane(lane: Option, environment: &Environment) -> Result { +fn resolve_live_lane(lane: Option, environment: &Environment) -> Result { Ok(match lane { - Some(CliLane::FixtureDirect) => LiveLane::Fixture, - Some(CliLane::BuiltInDataPlane) => LiveLane::BuiltInDataPlane, - Some(CliLane::ExternalDataPlane) => LiveLane::ExternalDataPlane, + Some(CliLane::FixtureDirect) => SemanticLane::FixtureDirect, + Some(CliLane::BuiltInDataPlane) => SemanticLane::BuiltInDataPlane, + Some(CliLane::ExternalDataPlane) => SemanticLane::ExternalDataPlane, None => match resolve_topology(None, environment)? { - StackMode::Controlplane => LiveLane::BuiltInDataPlane, - StackMode::Dataplane => LiveLane::ExternalDataPlane, + StackMode::Controlplane => SemanticLane::BuiltInDataPlane, + StackMode::Dataplane => SemanticLane::ExternalDataPlane, }, }) } @@ -250,12 +384,12 @@ fn resolve_stack(command: StackCommand, environment: &Environment) -> Result) -> Vec { +fn resolve_lanes(lanes: impl IntoIterator) -> Vec { let selected = lanes.into_iter().collect::>(); let all = [ - ConformanceTarget::Fixture, - ConformanceTarget::BuiltInDataPlane, - ConformanceTarget::ExternalDataPlane, + SemanticLane::FixtureDirect, + SemanticLane::BuiltInDataPlane, + SemanticLane::ExternalDataPlane, ]; if selected.is_empty() { all.into_iter().collect() @@ -266,6 +400,36 @@ fn resolve_lanes(lanes: impl IntoIterator) -> Vec) -> Vec { + if versions.is_empty() { + return vec![DEFAULT_MCP_SPEC_VERSION.to_owned()]; + } + let mut seen = BTreeSet::new(); + versions + .into_iter() + .map(|version| version.to_string()) + .filter(|version| seen.insert(version.clone())) + .collect() +} + +fn resolve_server_eras( + eras: Vec, +) -> Vec { + let eras = if eras.is_empty() { + vec![ + crate::cli::CliConformanceServerEra::Legacy, + crate::cli::CliConformanceServerEra::Modern, + ] + } else { + eras + }; + let mut seen = BTreeSet::new(); + eras.into_iter() + .map(Into::into) + .filter(|era| seen.insert(*era)) + .collect() +} + fn resolve_topology(explicit: Option, environment: &Environment) -> Result { if let Some(topology) = explicit { return Ok(topology.into()); diff --git a/tests/dispatch.rs b/src/app_tests.rs similarity index 57% rename from tests/dispatch.rs rename to src/app_tests.rs index 337d92d..6015ad7 100644 --- a/tests/dispatch.rs +++ b/src/app_tests.rs @@ -2,13 +2,13 @@ use std::ffi::OsString; use std::path::PathBuf; use cf_integration::app::{ - Action, ConformanceAction, DebugAction, LiveLane, ResolvedLoadArgs, StackAction, resolve_action, + Action, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, resolve_action, }; use cf_integration::cli::{Cli, LiveGroup, ProtocolVersion, TokenKind, TopologySelection}; -use cf_integration_compliance::conformance::{ConformanceServerEra, ConformanceTarget}; -use cf_integration_load::LoadRequest; -use cf_integration_platform::StackMode; -use cf_integration_platform::config::Environment; +use cf_integration::conformance::results::{ConformanceServerEra, SemanticLane}; +use cf_integration::infrastructure::StackMode; +use cf_integration::infrastructure::config::Environment; +use cf_integration::performance::LoadRequest; use clap::Parser; fn action(arguments: &[&str], environment: &[(&str, &str)]) -> Action { @@ -20,6 +20,129 @@ fn action(arguments: &[&str], environment: &[(&str, &str)]) -> Action { resolve_action(cli, &environment).expect("action should resolve") } +#[test] +fn every_subcommand_has_a_stable_progress_description() { + let cases: &[(&[&str], &str)] = &[ + (&["cf-integration", "stack", "up"], "stack up"), + (&["cf-integration", "stack", "down"], "stack down"), + (&["cf-integration", "stack", "status"], "stack status"), + (&["cf-integration", "stack", "logs"], "stack logs"), + (&["cf-integration", "stack", "config"], "stack config"), + (&["cf-integration", "probe"], "probe"), + (&["cf-integration", "load"], "load test"), + (&["cf-integration", "live"], "live tests"), + ( + &["cf-integration", "conformance", "run"], + "conformance tests", + ), + ( + &["cf-integration", "conformance", "report"], + "conformance report", + ), + (&["cf-integration", "debug", "inspect"], "debug inspect"), + ( + &["cf-integration", "debug", "token", "--kind", "admin"], + "debug token", + ), + ]; + + for (arguments, expected) in cases { + assert_eq!(action(arguments, &[]).description(), *expected); + } +} + +#[test] +fn every_subcommand_reports_its_resolved_topology_at_startup() { + let cases: &[(&[&str], &str)] = &[ + ( + &["cf-integration", "stack", "up"], + "Topology: external dataplane\nProtocol version: 2026-07-28", + ), + ( + &["cf-integration", "stack", "down"], + "Topology: built-in dataplane, external dataplane", + ), + ( + &["cf-integration", "stack", "status"], + "Topology: external dataplane", + ), + ( + &["cf-integration", "stack", "logs"], + "Topology: external dataplane", + ), + ( + &["cf-integration", "stack", "config"], + "Topology: external dataplane", + ), + ( + &["cf-integration", "probe"], + "Topology: external dataplane\nProtocol version: 2026-07-28", + ), + ( + &["cf-integration", "load"], + "Topology: external dataplane\nProtocol version: 2026-07-28", + ), + ( + &["cf-integration", "live"], + "Topology: external dataplane\nProtocol version: 2026-07-28", + ), + ( + &["cf-integration", "conformance", "run"], + "Topology: fixture direct, built-in dataplane, external dataplane\nClient protocol versions: 2026-07-28\nServer protocol versions: legacy [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25]; modern [2026-07-28]", + ), + ( + &["cf-integration", "conformance", "report"], + "Topology: recorded conformance results\nClient protocol versions: recorded conformance results\nServer protocol versions: recorded conformance results", + ), + ( + &["cf-integration", "debug", "inspect"], + "Topology: external dataplane\nProtocol version: 2026-07-28", + ), + ( + &["cf-integration", "debug", "token", "--kind", "admin"], + "Topology: not applicable (token only)", + ), + ]; + + for (arguments, expected) in cases { + assert_eq!(action(arguments, &[]).startup_summary(), *expected); + } +} + +#[test] +fn conformance_startup_reports_every_selected_client_and_server_protocol() { + let resolved = action( + &[ + "cf-integration", + "conformance", + "run", + "--lane", + "built-in-data-plane", + "--protocol-version", + "2025-11-25", + "--protocol-version", + "2026-07-28", + "--server-era", + "dual", + ], + &[], + ); + + assert_eq!( + resolved.startup_summary(), + "Topology: built-in dataplane\nClient protocol versions: 2025-11-25, 2026-07-28\nServer protocol versions: dual [2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, 2026-07-28]" + ); +} + +#[test] +fn multi_phase_commands_own_detailed_progress_while_simple_commands_use_global_progress() { + assert!(!action(&["cf-integration", "stack", "up"], &[]).uses_global_activity()); + assert!(!action(&["cf-integration", "load"], &[]).uses_global_activity()); + assert!(!action(&["cf-integration", "conformance", "run"], &[]).uses_global_activity()); + assert!(action(&["cf-integration", "stack", "down"], &[]).uses_global_activity()); + assert!(action(&["cf-integration", "probe"], &[]).uses_global_activity()); +} + #[test] fn topology_precedence_is_cli_then_environment_then_dataplane() { assert_eq!( @@ -145,7 +268,7 @@ fn live_resolves_lane_group_and_protocol_version() { ], ), Action::Live { - lane: LiveLane::BuiltInDataPlane, + lane: SemanticLane::BuiltInDataPlane, group: LiveGroup::Mcp, protocol_version: "2025-06-18" .parse::() @@ -174,7 +297,7 @@ fn live_fixture_lane_bypasses_topology_and_cli_version_wins() { ], ), Action::Live { - lane: LiveLane::Fixture, + lane: SemanticLane::FixtureDirect, group: LiveGroup::Protocol, protocol_version: "2025-03-26" .parse::() @@ -209,13 +332,16 @@ fn conformance_defaults_to_all_three_ordered_lanes() { action(&["cf-integration", "conformance", "run"], &[]), Action::Conformance(ConformanceAction::Run { lanes: vec![ - ConformanceTarget::Fixture, - ConformanceTarget::BuiltInDataPlane, - ConformanceTarget::ExternalDataPlane, + SemanticLane::FixtureDirect, + SemanticLane::BuiltInDataPlane, + SemanticLane::ExternalDataPlane, ], - spec_version: "2026-07-28".to_owned(), - server_era: ConformanceServerEra::Dual, + client_versions: vec!["2026-07-28".to_owned()], + server_eras: vec![ConformanceServerEra::Legacy, ConformanceServerEra::Modern], results_dir: None, + baseline_dir: None, + bless: false, + output_dir: None, }) ); } @@ -236,21 +362,32 @@ fn conformance_lanes_are_deduplicated_and_normalized() { "external-data-plane", "--protocol-version", "2025-06-18", + "--protocol-version", + "2025-11-25", + "--server-era", + "modern", + "--server-era", + "legacy", "--server-era", "modern", "--results-dir", "results", + "--baseline-dir", + "baselines", + "--output-dir", + "reports", + "--bless", ], &[], ), Action::Conformance(ConformanceAction::Run { - lanes: vec![ - ConformanceTarget::Fixture, - ConformanceTarget::ExternalDataPlane, - ], - spec_version: "2025-06-18".to_owned(), - server_era: ConformanceServerEra::Modern, + lanes: vec![SemanticLane::FixtureDirect, SemanticLane::ExternalDataPlane,], + client_versions: vec!["2025-06-18".to_owned(), "2025-11-25".to_owned()], + server_eras: vec![ConformanceServerEra::Modern, ConformanceServerEra::Legacy], results_dir: Some(PathBuf::from("results")), + baseline_dir: Some(PathBuf::from("baselines")), + bless: true, + output_dir: Some(PathBuf::from("reports")), }) ); } @@ -277,6 +414,29 @@ fn conformance_report_is_official_only() { ); } +#[test] +fn only_report_and_token_actions_skip_runtime_assets() { + let report = action(&["cf-integration", "conformance", "report"], &[]); + let token = action( + &["cf-integration", "debug", "token", "--kind", "admin"], + &[], + ); + let stack = action( + &[ + "cf-integration", + "stack", + "status", + "--topology", + "dataplane", + ], + &[], + ); + + assert!(!report.requires_runtime_assets()); + assert!(!token.requires_runtime_assets()); + assert!(stack.requires_runtime_assets()); +} + #[test] fn debug_token_and_inspector_remain_explicit_non_gate_operations() { assert_eq!( diff --git a/src/cli.rs b/src/cli.rs index d50a912..bab6315 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,8 +5,7 @@ use std::fmt; use std::path::PathBuf; use std::str::FromStr; -use cf_integration_compliance::DEFAULT_MCP_SPEC_VERSION; -use cf_integration_mcp::mcp::PROTOCOL_VERSION; +use crate::mcp::protocol::PROTOCOL_VERSION; use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; const RUN_TIME_ERROR: &str = @@ -78,16 +77,16 @@ fn parse_run_time(value: &str) -> Result { /// Orchestrates control-plane and dataplane integration workflows. #[derive(Debug, Clone, PartialEq, Parser)] -#[command(name = "cf-integration", arg_required_else_help = true)] -pub struct Cli { +#[command(name = "cf-integration", version, arg_required_else_help = true)] +pub(crate) struct Cli { /// Workflow to run. #[command(subcommand)] - pub command: Command, + pub(crate) command: Command, } /// Top-level integration workflow. #[derive(Debug, Clone, PartialEq, Subcommand)] -pub enum Command { +pub(crate) enum Command { /// Manage Compose stacks. Stack(StackArgs), /// Probe one public MCP route. @@ -104,15 +103,15 @@ pub enum Command { /// Stack command selection. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct StackArgs { +pub(crate) struct StackArgs { /// Stack operation to run. #[command(subcommand)] - pub command: StackCommand, + pub(crate) command: StackCommand, } /// Operation on one or more Compose stacks. #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] -pub enum StackCommand { +pub(crate) enum StackCommand { /// Start one stack topology. Up(StackUpArgs), /// Stop one or both stack topologies. @@ -127,82 +126,82 @@ pub enum StackCommand { /// Options for starting one stack. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct StackUpArgs { +pub(crate) struct StackUpArgs { /// Stack topology; defaults to CF_MCP_STACK_MODE, then dataplane. #[arg(long, value_enum)] - pub topology: Option, + pub(crate) topology: Option, /// Remove existing stack volumes before starting. #[arg(long)] - pub fresh: bool, + pub(crate) fresh: bool, } /// Options for stopping stacks. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct StackDownArgs { +pub(crate) struct StackDownArgs { /// Stack topology; defaults to all. #[arg(long, value_enum)] - pub topology: Option, + pub(crate) topology: Option, /// Remove persistent volumes as well as containers and networks. #[arg(long)] - pub volumes: bool, + pub(crate) volumes: bool, } /// A command targeting one stack topology. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct TopologyArgs { +pub(crate) struct TopologyArgs { /// Stack topology; defaults to CF_MCP_STACK_MODE, then dataplane. #[arg(long, value_enum)] - pub topology: Option, + pub(crate) topology: Option, } /// Target selection for routed MCP workflows. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct RoutedWorkflowTargetArgs { +pub(crate) struct RoutedWorkflowTargetArgs { /// Execution lane; defaults to CF_MCP_STACK_MODE, then dataplane. - #[arg(long, value_enum, visible_alias = "topology")] - pub lane: Option, + #[arg(long, value_enum)] + pub(crate) lane: Option, - /// MCP version; defaults to MCP_PROTOCOL_VERSION, then 2025-11-25. + /// MCP version; defaults to MCP_PROTOCOL_VERSION, then 2026-07-28. #[arg(long)] - pub protocol_version: Option, + pub(crate) protocol_version: Option, } /// Target selection for MCP workflows that support a direct fixture lane. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct WorkflowTargetArgs { +pub(crate) struct WorkflowTargetArgs { /// Execution lane; defaults to CF_MCP_STACK_MODE, then dataplane. - #[arg(long, value_enum, visible_alias = "topology")] - pub lane: Option, + #[arg(long, value_enum)] + pub(crate) lane: Option, - /// MCP version; defaults to MCP_PROTOCOL_VERSION, then 2025-11-25. + /// MCP version; defaults to MCP_PROTOCOL_VERSION, then 2026-07-28. #[arg(long)] - pub protocol_version: Option, + pub(crate) protocol_version: Option, } /// Options for following stack logs. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct StackLogsArgs { +pub(crate) struct StackLogsArgs { /// Stack topology; defaults to CF_MCP_STACK_MODE, then dataplane. #[arg(long, value_enum)] - pub topology: Option, + pub(crate) topology: Option, /// Services whose logs to follow; all services when omitted. #[arg(value_name = "SERVICE")] - pub services: Vec, + pub(crate) services: Vec, } /// A live stack topology. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum CliTopology { +pub(crate) enum CliTopology { /// Python control plane only. Controlplane, /// Python control plane routed through the Rust dataplane. Dataplane, } -impl From for cf_integration_platform::StackMode { +impl From for crate::infrastructure::StackMode { fn from(topology: CliTopology) -> Self { match topology { CliTopology::Controlplane => Self::Controlplane, @@ -213,7 +212,7 @@ impl From for cf_integration_platform::StackMode { /// One or both stack topologies. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum TopologySelection { +pub(crate) enum TopologySelection { /// Python control plane only. Controlplane, /// Python control plane routed through the Rust dataplane. @@ -224,47 +223,46 @@ pub enum TopologySelection { /// Load-test options. #[derive(Debug, Clone, PartialEq, Args)] -pub struct LoadArgs { +pub(crate) struct LoadArgs { /// Routed lane and protocol-version selection. #[command(flatten)] - pub target: RoutedWorkflowTargetArgs, + pub(crate) target: RoutedWorkflowTargetArgs, /// Use smoke-test settings. #[arg(long)] - pub smoke: bool, + pub(crate) smoke: bool, /// Concurrent users; must be greater than zero. #[arg(long, value_parser = parse_positive_usize)] - pub users: Option, + pub(crate) users: Option, /// Users spawned per second; must be finite and greater than zero. #[arg(long, value_parser = parse_positive_f64)] - pub spawn_rate: Option, + pub(crate) spawn_rate: Option, /// Locust duration using positive h, m, and s groups, such as 1h30m. #[arg(long, value_parser = parse_run_time)] - pub run_time: Option, + pub(crate) run_time: Option, } /// Upstream live-test options. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct LiveArgs { +pub(crate) struct LiveArgs { /// Shared lane and protocol-version selection. #[command(flatten)] - pub target: WorkflowTargetArgs, + pub(crate) target: WorkflowTargetArgs, /// Upstream live-test group. #[arg(long, value_enum, default_value = "all")] - pub group: LiveGroup, + pub(crate) group: LiveGroup, } /// One MCP workflow execution lane. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum CliLane { +pub(crate) enum CliLane { /// Run directly against the workflow's reference fixture. - #[value(alias = "fixture")] FixtureDirect, - /// Run the routed endpoint through the Python built-in data plane. + /// Run the routed endpoint through the Python built-in dataplane. BuiltInDataPlane, /// Run the routed endpoint through the external Rust data plane. ExternalDataPlane, @@ -272,8 +270,8 @@ pub enum CliLane { /// Upstream live-test group. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum LiveGroup { - /// MCP route tests backed by Fast Time and Fast Test. +pub(crate) enum LiveGroup { + /// MCP route tests backed by Fast Time. Mcp, /// Authorization and multi-transport tests. Rbac, @@ -285,12 +283,12 @@ pub enum LiveGroup { /// A syntactically valid date-based MCP protocol version shared by workflows. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProtocolVersion(String); +pub(crate) struct ProtocolVersion(String); impl ProtocolVersion { /// Returns the exact selected MCP protocol version. #[must_use] - pub fn as_str(&self) -> &str { + pub(crate) fn as_str(&self) -> &str { &self.0 } } @@ -329,15 +327,15 @@ impl FromStr for ProtocolVersion { /// Conformance command selection. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct ConformanceArgs { +pub(crate) struct ConformanceArgs { /// Conformance operation to run. #[command(subcommand)] - pub command: ConformanceCommand, + pub(crate) command: ConformanceCommand, } /// Official MCP conformance workflows. #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] -pub enum ConformanceCommand { +pub(crate) enum ConformanceCommand { /// Run the pinned official oracle and TypeScript fixture. Run(ConformanceRunArgs), /// Regenerate the three-lane comparison from existing artifacts. @@ -346,32 +344,40 @@ pub enum ConformanceCommand { /// Official conformance run options. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct ConformanceRunArgs { +pub(crate) struct ConformanceRunArgs { /// Lane to run; repeat to select multiple lanes, defaults to all three. #[arg(long, value_enum, action = ArgAction::Append)] - pub lane: Vec, + pub(crate) lane: Vec, - /// MCP protocol version used by the official client. - #[arg( - long = "protocol-version", - visible_aliases = ["client-version", "spec-version"], - default_value = DEFAULT_MCP_SPEC_VERSION - )] - pub protocol_version: ProtocolVersion, + /// MCP protocol version used by the official client; repeat for a matrix. + #[arg(long = "protocol-version", action = ArgAction::Append)] + pub(crate) protocol_version: Vec, - /// Protocol era exposed by the upstream fixture server. - #[arg(long, value_enum, default_value = "dual")] - pub server_era: CliConformanceServerEra, + /// Protocol era exposed by the fixture; repeat for a matrix. + #[arg(long, value_enum, action = ArgAction::Append)] + pub(crate) server_era: Vec, /// Result artifact root; defaults below CF_INTEGRATION_DIR. #[arg(long)] - pub results_dir: Option, + pub(crate) results_dir: Option, + + /// Baseline root; defaults to tests/conformance/baselines. + #[arg(long)] + pub(crate) baseline_dir: Option, + + /// Replace selected baselines atomically after every run succeeds. + #[arg(long)] + pub(crate) bless: bool, + + /// Report root; defaults to the repository reports directory. + #[arg(long)] + pub(crate) output_dir: Option, } -impl From for cf_integration_compliance::conformance::ConformanceTarget { +impl From for crate::conformance::results::SemanticLane { fn from(lane: CliLane) -> Self { match lane { - CliLane::FixtureDirect => Self::Fixture, + CliLane::FixtureDirect => Self::FixtureDirect, CliLane::BuiltInDataPlane => Self::BuiltInDataPlane, CliLane::ExternalDataPlane => Self::ExternalDataPlane, } @@ -379,8 +385,8 @@ impl From for cf_integration_compliance::conformance::ConformanceTarget } /// Protocol behavior exposed by the pinned upstream fixture. -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum CliConformanceServerEra { +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub(crate) enum CliConformanceServerEra { /// Accept both initialization-based and per-request clients. Dual, /// Accept only initialization-based clients. @@ -389,9 +395,7 @@ pub enum CliConformanceServerEra { Modern, } -impl From - for cf_integration_compliance::conformance::ConformanceServerEra -{ +impl From for crate::conformance::results::ConformanceServerEra { fn from(era: CliConformanceServerEra) -> Self { match era { CliConformanceServerEra::Dual => Self::Dual, @@ -403,27 +407,27 @@ impl From /// Report-only options. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct ConformanceReportArgs { +pub(crate) struct ConformanceReportArgs { /// Existing result artifact root. #[arg(long)] - pub results_dir: Option, + pub(crate) results_dir: Option, /// Markdown report directory; defaults to the repository reports directory. #[arg(long)] - pub output_dir: Option, + pub(crate) output_dir: Option, } /// Debug command selection. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct DebugArgs { +pub(crate) struct DebugArgs { /// Debugging utility to run. #[command(subcommand)] - pub command: DebugCommand, + pub(crate) command: DebugCommand, } /// Manual debugging utilities that are not compliance gates. #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] -pub enum DebugCommand { +pub(crate) enum DebugCommand { /// Debug a live endpoint with the official MCP Inspector. Inspect(InspectArgs), /// Request and print a token from a running control plane. @@ -432,35 +436,35 @@ pub enum DebugCommand { /// Official Inspector options. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct InspectArgs { +pub(crate) struct InspectArgs { /// Routed lane and protocol-version selection. #[command(flatten)] - pub target: RoutedWorkflowTargetArgs, + pub(crate) target: RoutedWorkflowTargetArgs, /// Inspector method such as tools/list. #[arg(long, default_value = "tools/list")] - pub method: String, + pub(crate) method: String, /// Existing virtual server ID; uses the configured/default fixture when omitted. #[arg(long)] - pub server_id: Option, + pub(crate) server_id: Option, } /// Token generation options. #[derive(Debug, Clone, PartialEq, Eq, Args)] -pub struct TokenArgs { +pub(crate) struct TokenArgs { /// Token privilege level. #[arg(long, value_enum)] - pub kind: TokenKind, + pub(crate) kind: TokenKind, /// Virtual server restriction for a scoped token. #[arg(long)] - pub server_id: Option, + pub(crate) server_id: Option, } /// Token privilege level. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum TokenKind { +pub(crate) enum TokenKind { /// Catalog token with the minimum scopes needed by public MCP tests. Scoped, /// Authenticated platform-admin session token. diff --git a/tests/cli.rs b/src/cli_public_tests.rs similarity index 84% rename from tests/cli.rs rename to src/cli_public_tests.rs index 0811246..20a7ec3 100644 --- a/tests/cli.rs +++ b/src/cli_public_tests.rs @@ -1,5 +1,4 @@ use std::ffi::OsString; -use std::process::Command as ProcessCommand; use cf_integration::cli::{ Cli, CliConformanceServerEra, CliLane, CliTopology, Command, ConformanceArgs, @@ -56,7 +55,7 @@ fn command_tree_contains_only_distinct_public_workflows() { } #[test] -fn every_public_command_renders_help_from_the_binary() { +fn every_public_command_renders_help() { let paths: &[&[&str]] = &[ &[], &["stack"], @@ -77,18 +76,8 @@ fn every_public_command_renders_help_from_the_binary() { ]; for path in paths { - let output = ProcessCommand::new(env!("CARGO_BIN_EXE_cf-integration")) - .args(*path) - .arg("--help") - .output() - .expect("help command should start"); - assert!( - output.status.success(), - "help failed for {path:?}: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).expect("help should be UTF-8"); - assert!(stdout.contains("Usage:"), "missing usage for {path:?}"); + let help = command_at(path).render_long_help().to_string(); + assert!(help.contains("Usage:"), "missing usage for {path:?}"); } } @@ -215,7 +204,7 @@ fn live_defaults_to_all_and_accepts_the_main_harness_groups() { let Command::Live(args) = parse(&[ "cf-integration", "live", - "--topology", + "--lane", "external-data-plane", "--group", name, @@ -259,11 +248,27 @@ fn live_accepts_fixture_lane_and_explicit_protocol_version() { rejected(&["cf-integration", "live", "--protocol-version", "latest"]); - let Command::Live(alias) = parse(&["cf-integration", "live", "--lane", "fixture"]).command - else { - panic!("expected live workflow") - }; - assert_eq!(alias.target.lane, Some(CliLane::FixtureDirect)); + rejected(&["cf-integration", "live", "--lane", "fixture"]); +} + +#[test] +fn probe_rejects_removed_topology_alias() { + rejected(&["cf-integration", "probe", "--topology", "dataplane"]); +} + +#[test] +fn load_rejects_removed_topology_alias() { + rejected(&["cf-integration", "load", "--topology", "dataplane"]); +} + +#[test] +fn live_rejects_removed_topology_alias() { + rejected(&[ + "cf-integration", + "live", + "--topology", + "external-data-plane", + ]); } #[test] @@ -373,14 +378,12 @@ fn conformance_defaults_to_all_lanes_and_july_revision_at_resolution_time() { panic!("expected conformance run") }; assert!(args.lane.is_empty()); - assert_eq!( - args.protocol_version, - "2026-07-28" - .parse::() - .expect("valid protocol version") - ); - assert_eq!(args.server_era, CliConformanceServerEra::Dual); + assert!(args.protocol_version.is_empty()); + assert!(args.server_era.is_empty()); assert!(args.results_dir.is_none()); + assert!(args.baseline_dir.is_none()); + assert!(!args.bless); + assert!(args.output_dir.is_none()); } #[test] @@ -397,8 +400,17 @@ fn conformance_accepts_repeatable_exact_lanes_and_supported_revisions() { "external-data-plane", "--protocol-version", "2025-11-25", + "--protocol-version", + "2026-07-28", "--server-era", "legacy", + "--server-era", + "dual", + "--baseline-dir", + "baselines", + "--output-dir", + "reports", + "--bless", ]) .command else { @@ -410,31 +422,39 @@ fn conformance_accepts_repeatable_exact_lanes_and_supported_revisions() { ); assert_eq!( args.protocol_version, - "2025-11-25" - .parse::() - .expect("valid protocol version") + [ + "2025-11-25" + .parse::() + .expect("valid protocol version"), + "2026-07-28" + .parse::() + .expect("valid protocol version"), + ] ); - assert_eq!(args.server_era, CliConformanceServerEra::Legacy); - let Command::Conformance(ConformanceArgs { - command: ConformanceCommand::Run(compatibility_args), - }) = parse(&[ + assert_eq!( + args.server_era, + [ + CliConformanceServerEra::Legacy, + CliConformanceServerEra::Dual + ] + ); + assert_eq!(args.baseline_dir, Some("baselines".into())); + assert_eq!(args.output_dir, Some("reports".into())); + assert!(args.bless); + rejected(&[ "cf-integration", "conformance", "run", "--spec-version", "2025-11-25", - ]) - .command - else { - panic!("expected conformance run") - }; - assert_eq!( - compatibility_args.protocol_version, - "2025-11-25" - .parse::() - .expect("valid protocol version") - ); - assert_eq!(compatibility_args.server_era, CliConformanceServerEra::Dual); + ]); + rejected(&[ + "cf-integration", + "conformance", + "run", + "--client-version", + "2025-11-25", + ]); rejected(&["cf-integration", "conformance", "run", "--suite", "active"]); rejected(&[ "cf-integration", @@ -445,6 +465,18 @@ fn conformance_accepts_repeatable_exact_lanes_and_supported_revisions() { ]); } +#[test] +fn root_version_flag_reports_the_package_version() { + let error = Cli::try_parse_from(["cf-integration", "--version"]) + .expect_err("version should short-circuit parsing"); + + assert_eq!(error.kind(), ErrorKind::DisplayVersion); + assert_eq!( + error.to_string().trim(), + format!("cf-integration {}", env!("CARGO_PKG_VERSION")) + ); +} + #[test] fn debug_token_requires_an_explicit_privilege_kind() { let error = Cli::try_parse_from(["cf-integration", "debug", "token"]) diff --git a/src/conformance/baseline.rs b/src/conformance/baseline.rs new file mode 100644 index 0000000..b9cfab1 --- /dev/null +++ b/src/conformance/baseline.rs @@ -0,0 +1,975 @@ +//! Strict conformance baseline evaluation and transactional updates. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::results::{ + CheckStatus, ConformanceDirection, ConformanceResults, ConformanceServerEra, SemanticLane, +}; + +const MAX_BASELINE_BYTES: u64 = 1024 * 1024; + +/// The semantic lanes in their stable reporting order. +pub(crate) const ALL_CONFORMANCE_LANES: [SemanticLane; 3] = [ + SemanticLane::FixtureDirect, + SemanticLane::BuiltInDataPlane, + SemanticLane::ExternalDataPlane, +]; + +/// A baseline-eligible official check status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub(crate) enum ScoredStatus { + /// A required check failed. + Failure, + /// The oracle emitted a scored warning. + Warning, +} + +/// Stable identity of one scored official finding. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ScoredFinding { + /// Official scenario name. + pub(crate) scenario: String, + /// Stable official check identifier. + pub(crate) check: String, + /// Official check name, which disambiguates reused specification identifiers. + pub(crate) name: String, + /// Scored status. + pub(crate) status: ScoredStatus, +} + +/// Strict YAML payload stored in one lane baseline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ConformanceBaseline { + /// Sorted scored findings expected for this lane. + pub(crate) findings: Vec, +} + +/// One lane's evaluated baseline state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BaselineComparison { + /// Evaluated lane. + pub(crate) lane: SemanticLane, + /// Actual findings after direct-fixture subtraction where applicable. + pub(crate) actual: Vec, + /// Findings loaded from the checked-in baseline. + pub(crate) expected: Vec, + /// Actual findings absent from the baseline. + pub(crate) unexpected: Vec, + /// Baseline findings absent from the actual result. + pub(crate) stale: Vec, +} + +impl BaselineComparison { + /// Whether the actual scored findings exactly match the baseline. + #[must_use] + pub(crate) fn matches(&self) -> bool { + self.unexpected.is_empty() && self.stale.is_empty() + } +} + +/// A staged baseline replacement produced by a successful evaluation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BaselineUpdate { + relative_path: PathBuf, + document: ConformanceBaseline, +} + +/// Results for one client-version/server-era combination. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BaselineEvaluation { + /// Per-lane comparisons in stable lane order. + pub(crate) comparisons: Vec, + /// Documents to commit when blessing was requested. + pub(crate) updates: Vec, +} + +/// Evaluates every selected lane against its strict baseline. +/// +/// Routed findings reproduced by the direct fixture are removed before the +/// routed lane is compared. Blessing still parses existing files when present, +/// so malformed baselines cannot be silently replaced. +pub(crate) fn evaluate_baselines( + results: &BTreeMap, + selected_lanes: &[SemanticLane], + baseline_root: &Path, + client_version: &str, + server_era: ConformanceServerEra, + bless: bool, +) -> Result { + evaluate_direction_baselines( + results, + selected_lanes, + baseline_root, + client_version, + server_era, + ConformanceDirection::Server, + bless, + ) +} + +/// Evaluates scoped client-conformance results without fixture subtraction. +pub(crate) fn evaluate_client_baselines( + results: &BTreeMap, + selected_lanes: &[SemanticLane], + baseline_root: &Path, + client_version: &str, + server_era: ConformanceServerEra, + bless: bool, +) -> Result { + evaluate_direction_baselines( + results, + selected_lanes, + baseline_root, + client_version, + server_era, + ConformanceDirection::Client, + bless, + ) +} + +fn evaluate_direction_baselines( + results: &BTreeMap, + selected_lanes: &[SemanticLane], + baseline_root: &Path, + client_version: &str, + server_era: ConformanceServerEra, + direction: ConformanceDirection, + bless: bool, +) -> Result { + let selected = selected_lanes.iter().copied().collect::>(); + if selected.is_empty() { + bail!("at least one conformance lane must be selected"); + } + let routed_selected = selected.iter().any(|lane| { + matches!( + lane, + SemanticLane::BuiltInDataPlane | SemanticLane::ExternalDataPlane + ) + }); + if direction == ConformanceDirection::Server + && routed_selected + && !results.contains_key(&SemanticLane::FixtureDirect) + { + bail!("missing fixture-direct lane required to gate routed findings"); + } + for lane in &selected { + if !results.contains_key(lane) { + bail!("missing selected conformance lane {}", lane.slug()); + } + } + + let direct = results + .get(&SemanticLane::FixtureDirect) + .map(scored_findings) + .transpose()? + .unwrap_or_default(); + let mut comparisons = Vec::new(); + let mut updates = Vec::new(); + for lane in ALL_CONFORMANCE_LANES { + if !selected.contains(&lane) { + continue; + } + let mut actual = scored_findings( + results + .get(&lane) + .ok_or_else(|| anyhow!("missing selected conformance lane {}", lane.slug()))?, + )?; + if direction == ConformanceDirection::Server && lane != SemanticLane::FixtureDirect { + actual = actual.difference(&direct).cloned().collect(); + } + let path = + direction_baseline_path(baseline_root, client_version, server_era, direction, lane); + let loaded = read_baseline_optional(&path)?; + if loaded.is_none() && !bless { + bail!("missing conformance baseline {}", path.display()); + } + let expected = loaded + .map(|document| document.findings.into_iter().collect::>()) + .unwrap_or_default(); + let unexpected = actual.difference(&expected).cloned().collect::>(); + let stale = expected.difference(&actual).cloned().collect::>(); + comparisons.push(BaselineComparison { + lane, + actual: actual.iter().cloned().collect(), + expected: expected.iter().cloned().collect(), + unexpected, + stale, + }); + if bless { + updates.push(BaselineUpdate { + relative_path: baseline_relative_path(client_version, server_era, direction, lane), + document: ConformanceBaseline { + findings: actual.into_iter().collect(), + }, + }); + } + } + Ok(BaselineEvaluation { + comparisons, + updates, + }) +} + +/// Writes one deterministic machine-readable lane report. +pub(crate) fn write_baseline_report( + path: &Path, + client_version: &str, + server_era: ConformanceServerEra, + comparison: &BaselineComparison, +) -> Result<()> { + write_direction_baseline_report( + path, + client_version, + server_era, + ConformanceDirection::Server, + comparison, + ) +} + +/// Writes one deterministic machine-readable client-lane report. +pub(crate) fn write_client_baseline_report( + path: &Path, + client_version: &str, + server_era: ConformanceServerEra, + comparison: &BaselineComparison, +) -> Result<()> { + write_direction_baseline_report( + path, + client_version, + server_era, + ConformanceDirection::Client, + comparison, + ) +} + +fn write_direction_baseline_report( + path: &Path, + client_version: &str, + server_era: ConformanceServerEra, + direction: ConformanceDirection, + comparison: &BaselineComparison, +) -> Result<()> { + #[derive(Serialize)] + #[serde(deny_unknown_fields)] + struct Report<'a> { + client_version: &'a str, + server_era: ConformanceServerEra, + direction: ConformanceDirection, + lane: &'a str, + actual: &'a [ScoredFinding], + expected: &'a [ScoredFinding], + unexpected: &'a [ScoredFinding], + stale: &'a [ScoredFinding], + } + + let parent = path + .parent() + .context("baseline report path has no parent directory")?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create baseline report directory {parent:?}"))?; + let source = yaml_serde::to_string(&Report { + client_version, + server_era, + direction, + lane: comparison.lane.slug(), + actual: &comparison.actual, + expected: &comparison.expected, + unexpected: &comparison.unexpected, + stale: &comparison.stale, + }) + .context("failed to serialize baseline report")?; + fs::write(path, source).with_context(|| format!("failed to write baseline report {path:?}")) +} + +/// Commits all selected baseline updates as one directory transaction. +pub(crate) fn bless_baselines_transactionally( + root: &Path, + updates: &[BaselineUpdate], +) -> Result<()> { + if updates.is_empty() { + bail!("no conformance baselines were selected for blessing"); + } + let parent = root + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let name = root + .file_name() + .and_then(|value| value.to_str()) + .context("baseline root must have a UTF-8 directory name")?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create baseline parent directory {parent:?}"))?; + + let transaction = Uuid::new_v4(); + let staging = parent.join(format!(".{name}.stage-{transaction}")); + let backup = parent.join(format!(".{name}.backup-{transaction}")); + let mut staging_guard = DirectoryGuard::new(staging.clone()); + let mut backup_guard = DirectoryGuard::new(backup.clone()); + fs::create_dir(&staging) + .with_context(|| format!("failed to create baseline staging directory {staging:?}"))?; + + let root_exists = match fs::symlink_metadata(root) { + Ok(metadata) => { + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + bail!("baseline root {root:?} must be a real directory"); + } + copy_directory(root, &staging)?; + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + return Err(error).context(format!("failed to inspect baseline root {root:?}")); + } + }; + + let mut paths = BTreeSet::new(); + for update in updates { + if !paths.insert(update.relative_path.clone()) { + bail!("duplicate baseline update for {:?}", update.relative_path); + } + let path = staging.join(&update.relative_path); + let directory = path + .parent() + .context("baseline update path has no parent directory")?; + fs::create_dir_all(directory) + .with_context(|| format!("failed to create staged baseline directory {directory:?}"))?; + let source = yaml_serde::to_string(&update.document) + .context("failed to serialize conformance baseline")?; + fs::write(&path, source) + .with_context(|| format!("failed to write staged conformance baseline {path:?}"))?; + read_baseline(&path)?; + } + + if root_exists { + fs::rename(root, &backup).with_context(|| { + format!("failed to move existing baseline root {root:?} to {backup:?}") + })?; + if let Err(error) = fs::rename(&staging, root) { + let rollback = fs::rename(&backup, root); + if rollback.is_err() { + backup_guard.disarm(); + } + return Err(anyhow!(error).context(format!( + "failed to install staged baselines at {root:?}; rollback result: {rollback:?}" + ))); + } + if let Err(error) = fs::remove_dir_all(&backup) { + let move_new = fs::rename(root, &staging); + let restore_old = fs::rename(&backup, root); + if move_new.is_err() || restore_old.is_err() { + staging_guard.disarm(); + } + if restore_old.is_err() { + backup_guard.disarm(); + } + return Err(anyhow!(error).context(format!( + "failed to remove baseline backup after commit; rollback results: new={move_new:?}, old={restore_old:?}" + ))); + } + backup_guard.disarm(); + } else { + fs::rename(&staging, root).with_context(|| { + format!("failed to install staged conformance baselines at {root:?}") + })?; + } + staging_guard.disarm(); + Ok(()) +} + +/// Rejects unknown statuses in official output and returns its scored findings. +pub(crate) fn validate_scored_results(results: &ConformanceResults) -> Result<()> { + scored_findings(results).map(|_| ()) +} + +fn scored_findings(results: &ConformanceResults) -> Result> { + let mut findings = BTreeSet::new(); + for (scenario, result) in &results.scenarios { + for check in &result.checks { + let name = check.name.as_deref().unwrap_or_default(); + let status = match &check.status { + CheckStatus::Failure => Some(ScoredStatus::Failure), + CheckStatus::Warning => Some(ScoredStatus::Warning), + CheckStatus::Success | CheckStatus::Skipped | CheckStatus::Info => None, + CheckStatus::Other(status) => { + bail!( + "unknown official check status {status:?} for scenario {scenario:?}, check {:?}", + check.id + ); + } + }; + if let Some(status) = status { + findings.insert(ScoredFinding { + scenario: scenario.clone(), + check: check.id.clone(), + name: name.to_owned(), + status, + }); + } + } + } + Ok(findings) +} + +#[cfg(test)] +fn baseline_path( + root: &Path, + client_version: &str, + server_era: ConformanceServerEra, + lane: SemanticLane, +) -> PathBuf { + direction_baseline_path( + root, + client_version, + server_era, + ConformanceDirection::Server, + lane, + ) +} + +fn direction_baseline_path( + root: &Path, + client_version: &str, + server_era: ConformanceServerEra, + direction: ConformanceDirection, + lane: SemanticLane, +) -> PathBuf { + root.join(baseline_relative_path( + client_version, + server_era, + direction, + lane, + )) +} + +fn baseline_relative_path( + client_version: &str, + server_era: ConformanceServerEra, + direction: ConformanceDirection, + lane: SemanticLane, +) -> PathBuf { + let root = PathBuf::from(client_version).join(server_era.label()); + match direction { + ConformanceDirection::Server => root.join(format!("{}.yml", lane.slug())), + ConformanceDirection::Client => root + .join(direction.label()) + .join(format!("{}.yml", lane.slug())), + } +} + +fn read_baseline_optional(path: &Path) -> Result> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + bail!("conformance baseline {path:?} must be a real file"); + } + Ok(Some(read_baseline_with_metadata(path, &metadata)?)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => { + Err(error).context(format!("failed to inspect conformance baseline {path:?}")) + } + } +} + +fn read_baseline(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect conformance baseline {path:?}"))?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + bail!("conformance baseline {path:?} must be a real file"); + } + read_baseline_with_metadata(path, &metadata) +} + +fn read_baseline_with_metadata( + path: &Path, + metadata: &fs::Metadata, +) -> Result { + if metadata.len() > MAX_BASELINE_BYTES { + bail!("conformance baseline {path:?} exceeds the {MAX_BASELINE_BYTES} byte safety limit"); + } + let source = + fs::read(path).with_context(|| format!("failed to read conformance baseline {path:?}"))?; + let baseline: ConformanceBaseline = yaml_serde::from_slice(&source) + .with_context(|| format!("failed to parse conformance baseline {path:?}"))?; + if baseline.findings.windows(2).any(|pair| pair[0] >= pair[1]) { + bail!("conformance baseline {path:?} findings must be strictly sorted without duplicates"); + } + Ok(baseline) +} + +fn copy_directory(source: &Path, destination: &Path) -> Result<()> { + let mut entries = fs::read_dir(source) + .with_context(|| format!("failed to read baseline directory {source:?}"))? + .collect::>>() + .with_context(|| format!("failed to enumerate baseline directory {source:?}"))?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let file_type = entry + .file_type() + .with_context(|| format!("failed to inspect baseline entry {:?}", entry.path()))?; + if file_type.is_symlink() { + bail!("baseline tree must not contain symlink {:?}", entry.path()); + } + let target = destination.join(entry.file_name()); + if file_type.is_dir() { + fs::create_dir(&target).with_context(|| { + format!("failed to create staged baseline directory {target:?}") + })?; + copy_directory(&entry.path(), &target)?; + } else if file_type.is_file() { + fs::copy(entry.path(), &target).with_context(|| { + format!( + "failed to copy baseline file {:?} to {target:?}", + entry.path() + ) + })?; + } else { + bail!("unsupported baseline tree entry {:?}", entry.path()); + } + } + Ok(()) +} + +struct DirectoryGuard { + path: PathBuf, + armed: bool, +} + +impl DirectoryGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for DirectoryGuard { + fn drop(&mut self) { + if self.armed { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conformance::results::{ConformanceCheck, ConformanceScenarioResult}; + + fn check(id: &str, status: CheckStatus) -> ConformanceCheck { + ConformanceCheck { + id: id.to_owned(), + name: None, + description: None, + status, + timestamp: None, + spec_references: Vec::new(), + error_message: None, + details: None, + metadata: None, + logs: None, + extensions: BTreeMap::new(), + } + } + + fn results(scenario: &str, checks: Vec) -> ConformanceResults { + ConformanceResults { + scenarios: [( + scenario.to_owned(), + ConformanceScenarioResult { + scenario: scenario.to_owned(), + checks, + source: PathBuf::from("checks.json"), + }, + )] + .into_iter() + .collect(), + } + } + + fn write_document( + root: &Path, + client_version: &str, + era: ConformanceServerEra, + lane: SemanticLane, + findings: Vec, + ) { + let path = baseline_path(root, client_version, era, lane); + fs::create_dir_all(path.parent().expect("baseline path parent")) + .expect("baseline directory"); + fs::write( + path, + yaml_serde::to_string(&ConformanceBaseline { findings }) + .expect("baseline should serialize"), + ) + .expect("baseline should be written"); + } + + fn write_client_document( + root: &Path, + client_version: &str, + era: ConformanceServerEra, + lane: SemanticLane, + findings: Vec, + ) { + let path = direction_baseline_path( + root, + client_version, + era, + ConformanceDirection::Client, + lane, + ); + fs::create_dir_all(path.parent().expect("client baseline path parent")) + .expect("client baseline directory"); + fs::write( + path, + yaml_serde::to_string(&ConformanceBaseline { findings }) + .expect("client baseline should serialize"), + ) + .expect("client baseline should be written"); + } + + #[test] + fn routed_comparison_subtracts_findings_reproduced_by_direct_fixture() { + let directory = tempfile::tempdir().expect("temporary baseline root"); + let root = directory.path(); + let client = "2026-07-28"; + let era = ConformanceServerEra::Modern; + let shared = ScoredFinding { + scenario: "tools-list".to_owned(), + check: "shared".to_owned(), + name: String::new(), + status: ScoredStatus::Failure, + }; + let routed = ScoredFinding { + scenario: "tools-list".to_owned(), + check: "routed".to_owned(), + name: String::new(), + status: ScoredStatus::Warning, + }; + write_document( + root, + client, + era, + SemanticLane::FixtureDirect, + vec![shared.clone()], + ); + write_document( + root, + client, + era, + SemanticLane::ExternalDataPlane, + vec![routed.clone()], + ); + let actual = BTreeMap::from([ + ( + SemanticLane::FixtureDirect, + results("tools-list", vec![check("shared", CheckStatus::Failure)]), + ), + ( + SemanticLane::ExternalDataPlane, + results( + "tools-list", + vec![ + check("shared", CheckStatus::Failure), + check("routed", CheckStatus::Warning), + ], + ), + ), + ]); + + let evaluation = evaluate_baselines( + &actual, + &[SemanticLane::FixtureDirect, SemanticLane::ExternalDataPlane], + root, + client, + era, + false, + ) + .expect("baselines should match after fixture subtraction"); + + assert!( + evaluation + .comparisons + .iter() + .all(BaselineComparison::matches) + ); + assert_eq!(evaluation.comparisons[1].actual, [routed]); + } + + #[test] + fn client_comparison_gates_external_lane_without_fixture_subtraction() { + let directory = tempfile::tempdir().expect("temporary baseline root"); + let root = directory.path(); + let client = "2026-07-28"; + let era = ConformanceServerEra::Modern; + let expected = ScoredFinding { + scenario: "http-custom-headers".to_owned(), + check: "custom-header".to_owned(), + name: String::new(), + status: ScoredStatus::Failure, + }; + write_client_document( + root, + client, + era, + SemanticLane::ExternalDataPlane, + vec![expected.clone()], + ); + let actual = BTreeMap::from([( + SemanticLane::ExternalDataPlane, + results( + "http-custom-headers", + vec![check("custom-header", CheckStatus::Failure)], + ), + )]); + + let evaluation = evaluate_client_baselines( + &actual, + &[SemanticLane::ExternalDataPlane], + root, + client, + era, + false, + ) + .expect("client baseline should not require fixture-direct results"); + + assert_eq!(evaluation.comparisons.len(), 1); + assert!(evaluation.comparisons[0].matches()); + assert_eq!(evaluation.comparisons[0].actual, [expected]); + } + + #[test] + fn baseline_drift_reports_unexpected_and_stale_findings() { + let directory = tempfile::tempdir().expect("temporary baseline root"); + let expected = ScoredFinding { + scenario: "ping".to_owned(), + check: "old".to_owned(), + name: String::new(), + status: ScoredStatus::Warning, + }; + write_document( + directory.path(), + "2025-11-25", + ConformanceServerEra::Legacy, + SemanticLane::FixtureDirect, + vec![expected.clone()], + ); + let actual = BTreeMap::from([( + SemanticLane::FixtureDirect, + results("ping", vec![check("new", CheckStatus::Failure)]), + )]); + + let evaluation = evaluate_baselines( + &actual, + &[SemanticLane::FixtureDirect], + directory.path(), + "2025-11-25", + ConformanceServerEra::Legacy, + false, + ) + .expect("well-formed drift should be evaluated"); + + let comparison = &evaluation.comparisons[0]; + assert!(!comparison.matches()); + assert_eq!(comparison.stale, [expected]); + assert_eq!(comparison.unexpected[0].check, "new"); + } + + #[test] + fn reused_specification_ids_are_disambiguated_by_check_name() { + let mut first = check("shared-spec-id", CheckStatus::Failure); + first.name = Some("FirstAssertion".to_owned()); + let mut second = check("shared-spec-id", CheckStatus::Failure); + second.name = Some("SecondAssertion".to_owned()); + + let findings = scored_findings(&results("header-validation", vec![first, second])) + .expect("distinct named checks should be scoreable") + .into_iter() + .collect::>(); + + assert_eq!(findings.len(), 2); + assert_eq!(findings[0].name, "FirstAssertion"); + assert_eq!(findings[1].name, "SecondAssertion"); + } + + #[test] + fn repeated_official_checks_collapse_to_one_scored_finding() { + let mut repeated = check("shared-spec-id", CheckStatus::Failure); + repeated.name = Some("RepeatedAssertion".to_owned()); + + let findings = scored_findings(&results( + "header-validation", + vec![repeated.clone(), repeated], + )) + .expect("repeated official checks should be scoreable") + .into_iter() + .collect::>(); + + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].name, "RepeatedAssertion"); + } + + #[test] + fn repository_contains_strict_baselines_for_every_default_matrix_lane() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/conformance/baselines"); + + for era in [ConformanceServerEra::Legacy, ConformanceServerEra::Modern] { + for lane in ALL_CONFORMANCE_LANES { + let path = baseline_path(&root, "2026-07-28", era, lane); + read_baseline(&path).unwrap_or_else(|error| { + panic!("default baseline {} must be valid: {error}", path.display()) + }); + } + let client_path = direction_baseline_path( + &root, + "2026-07-28", + era, + ConformanceDirection::Client, + SemanticLane::ExternalDataPlane, + ); + read_baseline(&client_path).unwrap_or_else(|error| { + panic!( + "default client baseline {} must be valid: {error}", + client_path.display() + ) + }); + } + } + + #[test] + fn malformed_unsorted_and_unknown_status_baselines_fail_closed() { + let directory = tempfile::tempdir().expect("temporary baseline root"); + let path = baseline_path( + directory.path(), + "2026-07-28", + ConformanceServerEra::Modern, + SemanticLane::FixtureDirect, + ); + fs::create_dir_all(path.parent().expect("baseline parent")).expect("baseline directory"); + fs::write( + &path, + "findings:\n - scenario: ping\n check: z\n name: z\n status: WARNING\n - scenario: ping\n check: a\n name: a\n status: FAILURE\n", + ) + .expect("malformed ordering should be written"); + assert!(read_baseline(&path).is_err()); + + fs::write( + &path, + "findings:\n - scenario: ping\n check: a\n name: a\n status: SURPRISE\n", + ) + .expect("unknown status should be written"); + assert!(read_baseline(&path).is_err()); + } + + #[test] + fn unknown_official_status_and_missing_fixture_lane_fail_closed() { + let unknown = BTreeMap::from([( + SemanticLane::FixtureDirect, + results( + "ping", + vec![check("future", CheckStatus::Other("FUTURE".to_owned()))], + ), + )]); + let error = evaluate_baselines( + &unknown, + &[SemanticLane::FixtureDirect], + Path::new("unused"), + "2026-07-28", + ConformanceServerEra::Dual, + true, + ) + .expect_err("unknown official statuses must fail") + .to_string(); + assert!(error.contains("unknown official check status")); + + let routed = BTreeMap::from([( + SemanticLane::ExternalDataPlane, + results("ping", vec![check("ok", CheckStatus::Success)]), + )]); + let error = evaluate_baselines( + &routed, + &[SemanticLane::ExternalDataPlane], + Path::new("unused"), + "2026-07-28", + ConformanceServerEra::Dual, + true, + ) + .expect_err("routed gating requires the direct fixture") + .to_string(); + assert!(error.contains("missing fixture-direct lane")); + } + + #[test] + fn blessing_installs_version_era_lane_layout_and_preserves_other_files() { + let directory = tempfile::tempdir().expect("temporary baseline parent"); + let root = directory.path().join("baselines"); + fs::create_dir(&root).expect("baseline root"); + fs::write(root.join("preserved.txt"), "keep\n").expect("preserved baseline file"); + let actual = BTreeMap::from([( + SemanticLane::FixtureDirect, + results("ping", vec![check("warning", CheckStatus::Warning)]), + )]); + let evaluation = evaluate_baselines( + &actual, + &[SemanticLane::FixtureDirect], + &root, + "2026-07-28", + ConformanceServerEra::Legacy, + true, + ) + .expect("bless evaluation should allow a missing baseline"); + + bless_baselines_transactionally(&root, &evaluation.updates) + .expect("baseline transaction should commit"); + + assert_eq!( + fs::read_to_string(root.join("preserved.txt")).expect("preserved file"), + "keep\n" + ); + let installed = read_baseline(&baseline_path( + &root, + "2026-07-28", + ConformanceServerEra::Legacy, + SemanticLane::FixtureDirect, + )) + .expect("installed baseline should parse"); + assert_eq!(installed.findings[0].status, ScoredStatus::Warning); + } + + #[test] + fn failed_blessing_leaves_the_existing_tree_unchanged() { + let directory = tempfile::tempdir().expect("temporary baseline parent"); + let root = directory.path().join("baselines"); + fs::create_dir(&root).expect("baseline root"); + fs::write(root.join("sentinel"), "original\n").expect("sentinel"); + let update = BaselineUpdate { + relative_path: PathBuf::from("2026-07-28/dual/fixture-direct.yml"), + document: ConformanceBaseline { + findings: Vec::new(), + }, + }; + + let error = bless_baselines_transactionally(&root, &[update.clone(), update]) + .expect_err("duplicate updates must abort before commit") + .to_string(); + + assert!(error.contains("duplicate baseline update")); + assert_eq!( + fs::read_to_string(root.join("sentinel")).expect("original sentinel"), + "original\n" + ); + assert!(!root.join("2026-07-28").exists()); + } +} diff --git a/src/conformance/client.rs b/src/conformance/client.rs new file mode 100644 index 0000000..9a34d75 --- /dev/null +++ b/src/conformance/client.rs @@ -0,0 +1,226 @@ +//! Scoped official client conformance for the external dataplane. + +use std::collections::BTreeSet; +use std::ffi::{OsStr, OsString}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::conformance::DEFAULT_MCP_SPEC_VERSION; +use crate::infrastructure::process::{CommandSpec, ProcessRunner, SystemProcessRunner}; +use crate::mcp::GatewayTopology; +use crate::mcp::gateway::{GatewayClient, GatewayRequest}; + +pub(crate) const INTERNAL_CLIENT_COMMAND: &str = "__client-conformance"; +pub(crate) const CLIENT_COMPOSE_ARGS_ENV: &str = "CF_CLIENT_CONFORMANCE_COMPOSE_ARGS"; +pub(crate) const CLIENT_BASE_URL_ENV: &str = "CF_CLIENT_CONFORMANCE_BASE_URL"; +pub(crate) const CLIENT_SERVER_ID_ENV: &str = "CF_CLIENT_CONFORMANCE_SERVER_ID"; +pub(crate) const CLIENT_TOKEN_ENV: &str = "MCP_CONFORMANCE_TOKEN"; +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"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ToolCall { + name: String, + arguments: Value, +} + +/// Returns whether the raw process arguments select the private client driver. +#[must_use] +pub(crate) fn is_internal_client_invocation(arguments: &[OsString]) -> bool { + arguments.get(1).map(OsString::as_os_str) == Some(OsStr::new(INTERNAL_CLIENT_COMMAND)) +} + +/// Runs one official client scenario selected through the runner environment. +pub(crate) async fn run_internal_client(arguments: &[OsString]) -> Result<()> { + if arguments.len() != 3 { + bail!("internal client conformance requires exactly one scenario-server URL"); + } + let scenario_server_url = arguments[2] + .to_str() + .context("client conformance scenario-server URL is not UTF-8")?; + let scenario = required_environment(SCENARIO_ENV)?; + let protocol_version = required_environment(PROTOCOL_VERSION_ENV)?; + if protocol_version != DEFAULT_MCP_SPEC_VERSION { + bail!( + "external dataplane client conformance supports protocol {DEFAULT_MCP_SPEC_VERSION}, not {protocol_version}" + ); + } + let server_id = required_environment(CLIENT_SERVER_ID_ENV)?; + let token = required_environment(CLIENT_TOKEN_ENV)?; + 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)?; + + let mut client = + GatewayClient::builder(GatewayTopology::Dataplane, &base_url, &server_id, &token) + .protocol_version(&protocol_version) + .build() + .context("failed to construct the client-conformance gateway")?; + for (index, tool_call) in tool_calls.into_iter().enumerate() { + let response = client + .send(GatewayRequest::probe(json!({ + "jsonrpc": "2.0", + "id": index + 1, + "method": "tools/call", + "params": { + "name": tool_call.name, + "arguments": tool_call.arguments, + "_meta": { + "io.modelcontextprotocol/protocolVersion": protocol_version, + "io.modelcontextprotocol/clientInfo": { + "name": "dataplane-client-conformance-driver", + "version": env!("CARGO_PKG_VERSION"), + }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }))) + .await + .context("external dataplane rejected a client-conformance tool call")?; + let valid_response = response.status() == 200 + && response + .message() + .is_some_and(|message| message.get("result").is_some_and(|value| !value.is_null())) + && response + .message() + .is_none_or(|message| message.get("error").is_none_or(Value::is_null)); + if !valid_response { + bail!( + "external dataplane returned an invalid client-conformance response for tool {:?}: HTTP {}; body={}", + tool_call.name, + response.status(), + response.body() + ); + } + } + Ok(()) +} + +fn scenario_tool_calls(scenario: &str) -> Result> { + let calls = match scenario { + "tools_call" => json!([{"name": "add_numbers", "arguments": {"a": 2, "b": 3}}]), + "request-metadata" => json!([{"name": "metadata_probe", "arguments": {}}]), + "http-standard-headers" => json!([{"name": "test_headers", "arguments": {}}]), + "http-custom-headers" => { + let context = required_environment(CONTEXT_ENV)?; + serde_json::from_str::(&context) + .context("MCP_CONFORMANCE_CONTEXT is not valid JSON")? + .get("toolCalls") + .cloned() + .context("MCP_CONFORMANCE_CONTEXT has no toolCalls array")? + } + _ => bail!("unsupported external dataplane client conformance scenario {scenario:?}"), + }; + let calls: Vec = + serde_json::from_value(calls).context("client conformance tool calls are malformed")?; + if calls.is_empty() || calls.iter().any(|call| call.name.is_empty()) { + bail!("client conformance requires at least one named tool call"); + } + Ok(calls) +} + +fn container_backend_url(value: &str) -> Result { + let mut url = url::Url::parse(value).context("scenario-server URL is invalid")?; + let loopback = match url.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + None => false, + }; + if !matches!(url.scheme(), "http" | "https") || !loopback { + bail!("scenario-server URL must be an absolute loopback HTTP(S) URL"); + } + url.set_host(Some("host.docker.internal")).map_err(|_| { + anyhow!("failed to address the scenario server from the dataplane container") + })?; + Ok(url.into()) +} + +fn publish_scenario_config( + backend_url: &str, + server_id: &str, + tool_calls: &[ToolCall], +) -> Result<()> { + let serialized_args = required_environment(CLIENT_COMPOSE_ARGS_ENV)?; + let compose_args: Vec = serde_json::from_str(&serialized_args) + .context("CF_CLIENT_CONFORMANCE_COMPOSE_ARGS is not a JSON string array")?; + if compose_args.first().map(String::as_str) != Some("compose") { + bail!("client conformance Compose arguments must begin with compose"); + } + let tool_names = tool_calls + .iter() + .map(|call| call.name.as_str()) + .collect::>(); + let tool_names = serde_json::to_string(&tool_names) + .context("failed to serialize client conformance tool names")?; + let command = CommandSpec::new("docker").args(compose_args).args([ + "run", + "--rm", + "--no-deps", + "-e", + CLIENT_TOKEN_ENV, + "--entrypoint", + "python3", + "gateway", + CONFIG_WRITER, + server_id, + backend_url, + &tool_names, + ]); + SystemProcessRunner + .run(&command) + .context("failed to publish the client-conformance dataplane configuration") +} + +fn required_environment(name: &str) -> Result { + std::env::var(name) + .with_context(|| format!("{name} is required for internal client conformance")) + .and_then(|value| { + if value.is_empty() { + bail!("{name} must not be empty for internal client conformance"); + } + Ok(value) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_scenario_url_is_rewritten_for_the_dataplane_container() { + assert_eq!( + container_backend_url("http://127.0.0.1:43123/mcp?scenario=tools") + .expect("loopback URL should be accepted"), + "http://host.docker.internal:43123/mcp?scenario=tools" + ); + assert!(container_backend_url("https://example.com/mcp").is_err()); + } + + #[test] + fn fixed_client_scenarios_map_to_the_external_driver_tools() { + assert_eq!( + scenario_tool_calls("tools_call").expect("tools_call should be supported")[0].name, + "add_numbers" + ); + assert_eq!( + scenario_tool_calls("request-metadata").expect("request-metadata should be supported") + [0] + .name, + "metadata_probe" + ); + assert_eq!( + scenario_tool_calls("http-standard-headers") + .expect("http-standard-headers should be supported")[0] + .name, + "test_headers" + ); + assert!(scenario_tool_calls("initialize").is_err()); + } +} diff --git a/crates/compliance/src/conformance_fixture.rs b/src/conformance/fixture.rs similarity index 92% rename from crates/compliance/src/conformance_fixture.rs rename to src/conformance/fixture.rs index 4109f4a..749eec2 100644 --- a/crates/compliance/src/conformance_fixture.rs +++ b/src/conformance/fixture.rs @@ -10,21 +10,23 @@ use serde::de::DeserializeOwned; use serde_json::{Value, json}; use url::Url; -pub use crate::profile::{OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION}; +pub(crate) use crate::conformance::profile::{ + OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION, +}; /// Docker Compose service name for the official conformance server. -pub const OFFICIAL_CONFORMANCE_SERVICE: &str = "mcp_conformance_server"; +pub(crate) const OFFICIAL_CONFORMANCE_SERVICE: &str = "mcp_conformance_server"; /// Docker Compose service name for the fixture's backend-only Host proxy. -pub const OFFICIAL_CONFORMANCE_PROXY_SERVICE: &str = "mcp_conformance_proxy"; +pub(crate) const OFFICIAL_CONFORMANCE_PROXY_SERVICE: &str = "mcp_conformance_proxy"; /// Backend URL reachable from the control-plane and dataplane containers. -pub const OFFICIAL_CONFORMANCE_BACKEND_URL: &str = "http://mcp_conformance_proxy/mcp"; +pub(crate) const OFFICIAL_CONFORMANCE_BACKEND_URL: &str = "http://mcp_conformance_proxy/mcp"; /// Reserved gateway name used by the fixture. /// /// `_` intentionally produces an empty gateway slug when paired with the /// conformance-only underscore separator. This preserves the upstream /// `test_*` tool and prompt names exactly. -pub const OFFICIAL_CONFORMANCE_GATEWAY_NAME: &str = "_"; +pub(crate) const OFFICIAL_CONFORMANCE_GATEWAY_NAME: &str = "_"; /// Deterministic virtual-server ID used by the fixture. -pub const OFFICIAL_CONFORMANCE_SERVER_ID: &str = "3f33286667d34b65a31c3bafd30e4c21"; +pub(crate) const OFFICIAL_CONFORMANCE_SERVER_ID: &str = "3f33286667d34b65a31c3bafd30e4c21"; const SERVER_NAME: &str = "Official MCP Conformance Server"; const SERVER_DESCRIPTION: &str = "Virtual server for the pinned official MCP conformance fixture."; @@ -43,17 +45,17 @@ const REDACTED: &str = ""; /// IDs created for one official conformance fixture. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProvisionedConformanceFixture { +pub(crate) struct ProvisionedConformanceFixture { /// ID of the newly created backing gateway. - pub gateway_id: String, + pub(crate) gateway_id: String, /// ID of the deterministic virtual server. - pub server_id: String, + pub(crate) server_id: String, } /// Builder for [`ConformanceFixtureClient`]. #[must_use = "a conformance fixture client builder does nothing until build() is called"] #[derive(Clone)] -pub struct ConformanceFixtureClientBuilder { +pub(crate) struct ConformanceFixtureClientBuilder { base_url: String, admin_token: String, poll_interval: Duration, @@ -80,31 +82,36 @@ impl fmt::Debug for ConformanceFixtureClientBuilder { impl ConformanceFixtureClientBuilder { /// Sets the delay between unsuccessful catalog polling attempts. - pub fn poll_interval(mut self, poll_interval: Duration) -> Self { + #[cfg(test)] + pub(crate) fn poll_interval(mut self, poll_interval: Duration) -> Self { self.poll_interval = poll_interval; self } /// Sets the maximum number of catalog polling attempts. - pub fn max_attempts(mut self, max_attempts: usize) -> Self { + #[cfg(test)] + pub(crate) fn max_attempts(mut self, max_attempts: usize) -> Self { self.max_attempts = max_attempts; self } /// Sets the total timeout for each admin HTTP request. Defaults to 30 seconds. - pub fn request_timeout(mut self, request_timeout: Duration) -> Self { + #[cfg(test)] + pub(crate) fn request_timeout(mut self, request_timeout: Duration) -> Self { self.request_timeout = request_timeout; self } /// Sets the bounded number of gateway reconciliation polls. Defaults to five. - pub fn reconciliation_attempts(mut self, reconciliation_attempts: usize) -> Self { + #[cfg(test)] + pub(crate) fn reconciliation_attempts(mut self, reconciliation_attempts: usize) -> Self { self.reconciliation_attempts = reconciliation_attempts; self } /// Sets the delay between gateway reconciliation polls. Defaults to 100ms. - pub fn reconciliation_interval(mut self, reconciliation_interval: Duration) -> Self { + #[cfg(test)] + pub(crate) fn reconciliation_interval(mut self, reconciliation_interval: Duration) -> Self { self.reconciliation_interval = reconciliation_interval; self } @@ -116,7 +123,7 @@ impl ConformanceFixtureClientBuilder { /// Returns an error when the base URL or bearer token is invalid, or when /// `max_attempts` or `request_timeout` is zero, or fewer than two gateway /// reconciliation attempts are configured. - pub fn build(self) -> Result { + pub(crate) fn build(self) -> Result { if self.max_attempts == 0 { return Err(anyhow!("max_attempts must be greater than zero")); } @@ -153,7 +160,7 @@ impl ConformanceFixtureClientBuilder { /// Authenticated client for provisioning the official conformance fixture. #[derive(Clone)] -pub struct ConformanceFixtureClient { +pub(crate) struct ConformanceFixtureClient { base_url: Url, admin_token: String, poll_interval: Duration, @@ -184,7 +191,7 @@ impl fmt::Debug for ConformanceFixtureClient { impl ConformanceFixtureClient { /// Starts a fixture client builder. - pub fn builder( + pub(crate) fn builder( base_url: impl AsRef, admin_token: impl Into, ) -> ConformanceFixtureClientBuilder { @@ -206,7 +213,10 @@ impl ConformanceFixtureClient { /// Returns an error when an admin request fails or the required conformance /// catalog identities do not appear within the configured attempts. A /// fixture created before the failure is cleaned up automatically. - pub async fn provision(&self, backend_url: &str) -> Result { + pub(crate) async fn provision( + &self, + backend_url: &str, + ) -> Result { if backend_url != OFFICIAL_CONFORMANCE_BACKEND_URL { return Err(anyhow!( "official conformance backend URL does not match the pinned fixture" @@ -277,7 +287,10 @@ impl ConformanceFixtureClient { /// /// Returns an error for transport failures or delete responses other than a /// successful status or `404 Not Found`. - pub async fn cleanup(&self, fixture: Option<&ProvisionedConformanceFixture>) -> Result<()> { + pub(crate) async fn cleanup( + &self, + fixture: Option<&ProvisionedConformanceFixture>, + ) -> Result<()> { let server_id = fixture.map_or(OFFICIAL_CONFORMANCE_SERVER_ID, |value| { value.server_id.as_str() }); @@ -442,12 +455,12 @@ impl ConformanceFixtureClient { async fn get_json(&self, path: &str) -> Result { let response = self.request(Method::GET, path, None).await?; - self.parse_json(path, response).await + self.parse_json("GET", path, response).await } async fn post_json(&self, path: &str, body: &Value) -> Result { let response = self.request(Method::POST, path, Some(body)).await?; - self.parse_json(path, response).await + self.parse_json("POST", path, response).await } async fn post_success(&self, path: &str, body: Option<&Value>) -> Result<()> { @@ -463,13 +476,14 @@ impl ConformanceFixtureClient { async fn parse_json( &self, + method: &str, path: &str, response: reqwest::Response, ) -> Result { let status = response.status(); if !status.is_success() { return Err(anyhow!(redact( - &format!("request to {path} returned status {}", status.as_u16()), + &format!("{method} {path} returned HTTP {}", status.as_u16()), &self.admin_token ))); } @@ -480,7 +494,7 @@ impl ConformanceFixtureClient { self.request_timeout ) } else { - format!("request to {path} returned invalid JSON") + format!("{method} {path} returned invalid JSON") }; anyhow!(redact(&message, &self.admin_token)) }) @@ -561,7 +575,7 @@ struct CatalogRecord { name: String, #[serde(default)] uri: String, - #[serde(default, alias = "gatewayId")] + #[serde(default, rename = "gatewayId")] gateway_id: Option, } diff --git a/crates/compliance/tests/conformance_fixture.rs b/src/conformance/fixture_tests.rs similarity index 98% rename from crates/compliance/tests/conformance_fixture.rs rename to src/conformance/fixture_tests.rs index 313ace8..54c929c 100644 --- a/crates/compliance/tests/conformance_fixture.rs +++ b/src/conformance/fixture_tests.rs @@ -9,7 +9,7 @@ use axum::body::{Body, Bytes, to_bytes}; use axum::extract::State; use axum::http::{Request, Response, StatusCode}; use axum::routing::any; -use cf_integration_compliance::conformance_fixture::{ +use cf_integration::conformance::fixture::{ ConformanceFixtureClient, OFFICIAL_CONFORMANCE_BACKEND_URL, OFFICIAL_CONFORMANCE_GATEWAY_NAME, OFFICIAL_CONFORMANCE_SERVER_ID, ProvisionedConformanceFixture, }; @@ -342,7 +342,7 @@ fn provision_prefix(gateways: Value) -> Vec { #[test] fn admin_client_builder_explicitly_disables_environment_proxies() { - let source = include_str!("../src/conformance_fixture.rs"); + let source = include_str!("fixture.rs"); let builder = source .split("let http = reqwest::Client::builder()") .nth(1) @@ -822,27 +822,28 @@ async fn provision_deletes_every_stale_reserved_gateway_before_creation() { } #[tokio::test] -async fn provision_accepts_snake_case_gateway_ids() { +async fn provision_rejects_noncanonical_snake_case_gateway_ids() { let mut expected = provision_prefix(json!([])); append_create_and_refresh(&mut expected); append_catalogs(&mut expected, "gateway_id", true); - expected.push(response( - "POST", - "/servers", - json!({"id":OFFICIAL_CONFORMANCE_SERVER_ID}), - )); + append_catalogs(&mut expected, "gateway_id", true); + expected.extend([ + response("GET", "/servers", json!([])), + response("GET", "/gateways", json!([gateway_record(GATEWAY_ID)])), + status( + "DELETE", + format!("/gateways/{GATEWAY_ID}"), + StatusCode::NO_CONTENT, + ), + ]); let api = FakeApi::start(expected).await; - test_client(&api.base_url) + let error = test_client(&api.base_url) .provision(OFFICIAL_CONFORMANCE_BACKEND_URL) .await - .expect("snake-case catalog IDs"); + .expect_err("snake-case catalog IDs are not part of the current API contract"); - let server = api.requests().pop().expect("server request"); - assert_eq!( - server.body.expect("server body")["server"]["associated_tools"][0], - "tool-1" - ); + assert!(format!("{error:#}").contains("test_simple_text")); api.assert_complete(); } diff --git a/src/conformance/mod.rs b/src/conformance/mod.rs new file mode 100644 index 0000000..f6505e9 --- /dev/null +++ b/src/conformance/mod.rs @@ -0,0 +1,9 @@ +//! Official conformance fixture, baseline, result, and report primitives. + +pub(crate) mod baseline; +pub(crate) mod client; +pub(crate) mod fixture; +pub(crate) mod profile; +pub(crate) mod results; + +pub(crate) use profile::DEFAULT_MCP_SPEC_VERSION; diff --git a/src/conformance/profile.rs b/src/conformance/profile.rs new file mode 100644 index 0000000..2b93a28 --- /dev/null +++ b/src/conformance/profile.rs @@ -0,0 +1,34 @@ +//! Coherent official conformance runner, fixture, and protocol pins. + +/// Published official CLI package used as the conformance client. +pub(crate) const OFFICIAL_CONFORMANCE_PACKAGE: &str = + "@modelcontextprotocol/conformance@0.2.0-alpha.11"; +/// Official repository containing the matching TypeScript fixture server. +pub(crate) const OFFICIAL_CONFORMANCE_REPOSITORY: &str = + "https://github.com/modelcontextprotocol/conformance"; +/// Exact source revision behind the published CLI and TypeScript fixture. +pub(crate) const OFFICIAL_CONFORMANCE_REVISION: &str = "c321dd32035556e6769d3724a8ee97d87c3faaac"; +/// Default draft protocol revision exercised by official conformance commands. +pub(crate) const DEFAULT_MCP_SPEC_VERSION: &str = "2026-07-28"; +/// Previous stable revision supported by the pinned official conformance package. +pub(crate) const STABLE_MCP_SPEC_VERSION: &str = "2025-11-25"; +/// Oldest revision supported by the pinned official conformance package. +pub(crate) const LEGACY_MCP_SPEC_VERSION: &str = "2025-06-18"; + +/// Stateful protocol revisions accepted by the pinned fixture in legacy mode. +pub(crate) const LEGACY_SERVER_PROTOCOL_VERSIONS: &[&str] = &[ + "2024-11-05", + "2025-03-26", + LEGACY_MCP_SPEC_VERSION, + STABLE_MCP_SPEC_VERSION, +]; +/// Per-request protocol revisions accepted by the pinned fixture in modern mode. +pub(crate) const MODERN_SERVER_PROTOCOL_VERSIONS: &[&str] = &[DEFAULT_MCP_SPEC_VERSION]; +/// Complete protocol set accepted by the pinned fixture in dual mode. +pub(crate) const DUAL_SERVER_PROTOCOL_VERSIONS: &[&str] = &[ + "2024-11-05", + "2025-03-26", + LEGACY_MCP_SPEC_VERSION, + STABLE_MCP_SPEC_VERSION, + DEFAULT_MCP_SPEC_VERSION, +]; diff --git a/crates/compliance/src/conformance.rs b/src/conformance/results.rs similarity index 69% rename from crates/compliance/src/conformance.rs rename to src/conformance/results.rs index 48a238e..a61fc98 100644 --- a/crates/compliance/src/conformance.rs +++ b/src/conformance/results.rs @@ -10,56 +10,92 @@ use anyhow::{Context, Result, bail}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; -use cf_integration_platform::process::CommandSpec; +use crate::infrastructure::process::CommandSpec; -use crate::conformance_fixture::OFFICIAL_CONFORMANCE_SERVER_ID; -pub use crate::profile::{DEFAULT_MCP_SPEC_VERSION, OFFICIAL_CONFORMANCE_PACKAGE}; -use crate::profile::{ - LEGACY_MCP_SPEC_VERSION, OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION, - STABLE_MCP_SPEC_VERSION, +use crate::conformance::fixture::OFFICIAL_CONFORMANCE_SERVER_ID; +pub(crate) use crate::conformance::profile::{ + DEFAULT_MCP_SPEC_VERSION, OFFICIAL_CONFORMANCE_PACKAGE, +}; +use crate::conformance::profile::{ + DUAL_SERVER_PROTOCOL_VERSIONS, LEGACY_MCP_SPEC_VERSION, LEGACY_SERVER_PROTOCOL_VERSIONS, + MODERN_SERVER_PROTOCOL_VERSIONS, OFFICIAL_CONFORMANCE_REPOSITORY, + OFFICIAL_CONFORMANCE_REVISION, STABLE_MCP_SPEC_VERSION, }; /// Default official server scenario suite. -pub const DEFAULT_CONFORMANCE_SUITE: &str = "all"; +pub(crate) const DEFAULT_CONFORMANCE_SUITE: &str = "all"; +/// Client scenarios implemented by the external dataplane conformance driver. +pub(crate) const DEFAULT_CLIENT_CONFORMANCE_SCENARIOS: &[&str] = &[ + "tools_call", + "request-metadata", + "http-standard-headers", + "http-custom-headers", +]; + +/// Direction exercised by one official conformance run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ConformanceDirection { + /// The official client sends requests to a server implementation. + Server, + /// A client implementation sends requests to official scenario servers. + Client, +} + +impl ConformanceDirection { + /// Stable artifact and report label. + #[must_use] + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Server => "server", + Self::Client => "client", + } + } +} + +impl fmt::Display for ConformanceDirection { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.label()) + } +} /// Exact provenance for the backing server used by an official run. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ConformanceFixtureMetadata { +pub(crate) struct ConformanceFixtureMetadata { /// Upstream fixture repository. - pub repository: String, + pub(crate) repository: String, /// Immutable upstream fixture revision. - pub revision: String, + pub(crate) revision: String, /// Provisioned virtual-server identity. - pub server_id: String, + pub(crate) server_id: String, } /// Reproducibility metadata stored beside one official result set. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ConformanceRunMetadata { +pub(crate) struct ConformanceRunMetadata { /// Pinned official runner package. - pub oracle: String, + pub(crate) oracle: String, /// Stack label exercised by this artifact. - pub target: String, - /// MCP specification revision used by the official client. - pub spec_version: String, + pub(crate) target: String, + /// Whether the gateway was the MCP server or MCP client under test. + pub(crate) direction: ConformanceDirection, + /// MCP protocol revision used by the official conformance client. + pub(crate) client_version: String, /// Protocol era exposed by the upstream fixture server. - #[serde(default)] - pub server_era: ConformanceServerEra, + pub(crate) server_era: ConformanceServerEra, /// Official scenario suite label. - pub suite: String, - /// Backing fixture provenance, absent on historical or caller-managed runs. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fixture: Option, + pub(crate) suite: String, + /// Exact backing fixture provenance. + pub(crate) fixture: ConformanceFixtureMetadata, } /// Protocol behavior exposed by the pinned upstream fixture. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub enum ConformanceServerEra { +pub(crate) enum ConformanceServerEra { /// Serve both initialization-based and per-request protocol behavior. - #[default] Dual, /// Serve only initialization-based protocol behavior. Legacy, @@ -70,13 +106,40 @@ pub enum ConformanceServerEra { impl ConformanceServerEra { /// Stable CLI, environment, metadata, and report label. #[must_use] - pub const fn label(self) -> &'static str { + pub(crate) const fn label(self) -> &'static str { match self { Self::Dual => "dual", Self::Legacy => "legacy", Self::Modern => "modern", } } + + /// Exact protocol revisions accepted by this fixture-server era. + #[must_use] + pub(crate) const fn protocol_versions(self) -> &'static [&'static str] { + match self { + Self::Dual => DUAL_SERVER_PROTOCOL_VERSIONS, + Self::Legacy => LEGACY_SERVER_PROTOCOL_VERSIONS, + Self::Modern => MODERN_SERVER_PROTOCOL_VERSIONS, + } + } + + /// Human-readable accepted protocol list. + #[must_use] + pub(crate) fn protocol_versions_label(self) -> String { + self.protocol_versions().join(", ") + } + + /// Parses a stable artifact path component. + #[must_use] + pub(crate) fn from_label(value: &str) -> Option { + match value { + "dual" => Some(Self::Dual), + "legacy" => Some(Self::Legacy), + "modern" => Some(Self::Modern), + _ => None, + } + } } impl fmt::Display for ConformanceServerEra { @@ -87,28 +150,38 @@ impl fmt::Display for ConformanceServerEra { /// Endpoint topology exercised by one official server-conformance run. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ConformanceTarget { +pub(crate) enum SemanticLane { /// Official oracle connected directly to the pinned TypeScript fixture. - Fixture, - /// Official oracle routed through the Python built-in data plane. + FixtureDirect, + /// Official oracle routed through the Python built-in dataplane. BuiltInDataPlane, /// Official oracle routed through the external Rust data plane. ExternalDataPlane, } -impl ConformanceTarget { +impl SemanticLane { /// Stable metadata and report label. #[must_use] - pub const fn label(self) -> &'static str { + pub(crate) const fn label(self) -> &'static str { match self { - Self::Fixture => "fixture direct", - Self::BuiltInDataPlane => "built-in data-plane route", - Self::ExternalDataPlane => "external data-plane route", + Self::FixtureDirect => "fixture direct", + Self::BuiltInDataPlane => "built-in dataplane", + Self::ExternalDataPlane => "external dataplane", + } + } + + /// Stable artifact and baseline path component. + #[must_use] + pub(crate) const fn slug(self) -> &'static str { + match self { + Self::FixtureDirect => "fixture-direct", + Self::BuiltInDataPlane => "built-in-data-plane", + Self::ExternalDataPlane => "external-data-plane", } } } -impl fmt::Display for ConformanceTarget { +impl fmt::Display for SemanticLane { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(self.label()) } @@ -116,12 +189,10 @@ impl fmt::Display for ConformanceTarget { /// Whether provenance identifies the exact pinned official TypeScript fixture. #[must_use] -pub fn is_trusted_official_fixture(fixture: Option<&ConformanceFixtureMetadata>) -> bool { - fixture.is_some_and(|fixture| { - fixture.repository == OFFICIAL_CONFORMANCE_REPOSITORY - && fixture.revision == OFFICIAL_CONFORMANCE_REVISION - && fixture.server_id == OFFICIAL_CONFORMANCE_SERVER_ID - }) +pub(crate) fn is_trusted_official_fixture(fixture: &ConformanceFixtureMetadata) -> bool { + fixture.repository == OFFICIAL_CONFORMANCE_REPOSITORY + && fixture.revision == OFFICIAL_CONFORMANCE_REVISION + && fixture.server_id == OFFICIAL_CONFORMANCE_SERVER_ID } // Exact server catalogs emitted by @modelcontextprotocol/conformance@0.2.0-alpha.11. @@ -212,7 +283,7 @@ const MAX_CHECKS_FILE_BYTES: u64 = 8 * 1024 * 1024; /// Builds the exact official server-conformance invocation. #[must_use = "a command specification does nothing until a process runner executes it"] -pub fn official_server_command( +pub(crate) fn official_server_command( endpoint: &str, suite: &str, spec_version: &str, @@ -237,9 +308,38 @@ pub fn official_server_command( .arg("--verbose") } +/// Builds one exact official client-conformance scenario invocation. +#[must_use = "a command specification does nothing until a process runner executes it"] +pub(crate) fn official_client_command( + client_command: &str, + scenario: &str, + spec_version: &str, + expected_failures: &Path, + output_dir: &Path, +) -> CommandSpec { + CommandSpec::new("npx") + .clear_environment() + .arg("-y") + .arg(OFFICIAL_CONFORMANCE_PACKAGE) + .arg("client") + .arg("--command") + .arg(client_command) + .arg("--scenario") + .arg(scenario) + .arg("--spec-version") + .arg(spec_version) + .arg("--expected-failures") + .arg(expected_failures.as_os_str().to_owned()) + .arg("--timeout") + .arg("60000") + .arg("--output-dir") + .arg(output_dir.as_os_str().to_owned()) + .arg("--verbose") +} + /// Typed official check status with forward-compatible preservation. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum CheckStatus { +pub(crate) enum CheckStatus { /// Required check passed. Success, /// Required check failed. @@ -298,68 +398,68 @@ impl<'de> Deserialize<'de> for CheckStatus { /// An official MCP specification reference. Unknown fields are retained verbatim. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SpecReference { +pub(crate) struct SpecReference { /// Reference identifier emitted by the official framework. - pub id: String, + pub(crate) id: String, /// Optional source URL emitted by the official framework. #[serde(default)] - pub url: Option, + pub(crate) url: Option, /// Forward-compatible fields from the official framework. #[serde(flatten)] - pub extensions: BTreeMap, + pub(crate) extensions: BTreeMap, } /// One check from an official `checks.json` file. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ConformanceCheck { +pub(crate) struct ConformanceCheck { /// Stable official check identifier. - pub id: String, + pub(crate) id: String, /// Human-readable check name. #[serde(default)] - pub name: Option, + pub(crate) name: Option, /// Human-readable check description. #[serde(default)] - pub description: Option, + pub(crate) description: Option, /// Typed official status. - pub status: CheckStatus, + pub(crate) status: CheckStatus, /// Framework timestamp, preserved without interpretation. #[serde(default)] - pub timestamp: Option, + pub(crate) timestamp: Option, /// Specification references, preserved without normalization. #[serde(rename = "specReferences", default)] - pub spec_references: Vec, + pub(crate) spec_references: Vec, /// Failure text, if supplied by the official framework. #[serde(rename = "errorMessage", default)] - pub error_message: Option, + pub(crate) error_message: Option, /// Scenario-specific details. #[serde(default)] - pub details: Option, + pub(crate) details: Option, /// Scenario-specific metadata. #[serde(default)] - pub metadata: Option, + pub(crate) metadata: Option, /// Scenario logs in their original JSON shape. #[serde(default)] - pub logs: Option, + pub(crate) logs: Option, /// Forward-compatible fields from the official framework. #[serde(flatten)] - pub extensions: BTreeMap, + pub(crate) extensions: BTreeMap, } /// Result and provenance for one official server scenario. #[derive(Debug, Clone, PartialEq)] -pub struct ConformanceScenarioResult { +pub(crate) struct ConformanceScenarioResult { /// Validated scenario name extracted from the official result directory. - pub scenario: String, + pub(crate) scenario: String, /// Typed checks parsed from `checks.json`. - pub checks: Vec, + pub(crate) checks: Vec, /// Relative path beneath the caller-provided result root. - pub source: PathBuf, + pub(crate) source: PathBuf, } impl ConformanceScenarioResult { /// Reduces official check statuses into a scenario-level comparison outcome. #[must_use] - pub fn outcome(&self) -> ScenarioOutcome { + pub(crate) fn outcome(&self) -> ScenarioOutcome { let mut has_success = false; let mut has_warning = false; let mut has_skipped = false; @@ -400,16 +500,12 @@ impl ConformanceScenarioResult { } } - /// Reduces the outcome using trusted pinned-fixture provenance. - /// - /// A fixture-shaped not-found result from a trusted fixture is attributed - /// to the gateway path as a compliance failure. Unknown or caller-managed - /// fixtures retain the historical fixture-failure outcome. + /// Reduces fixture-shaped failures from the pinned fixture as compliance failures. #[must_use] - pub fn outcome_with_trusted_fixture(&self, trusted_fixture: bool) -> ScenarioOutcome { - match (self.outcome(), trusted_fixture) { - (ScenarioOutcome::FixtureFailure, true) => ScenarioOutcome::NonCompliant, - (outcome, _) => outcome, + pub(crate) fn gated_outcome(&self) -> ScenarioOutcome { + match self.outcome() { + ScenarioOutcome::FixtureFailure => ScenarioOutcome::NonCompliant, + outcome => outcome, } } } @@ -431,34 +527,9 @@ fn is_missing_official_fixture(message: &str) -> bool { /// Deterministically indexed official server results. #[derive(Debug, Clone, Default, PartialEq)] -pub struct ConformanceResults { +pub(crate) struct ConformanceResults { /// Scenario name to parsed result. - pub scenarios: BTreeMap, -} - -/// Rejects fresh runs when an unknown or caller-managed fixture prevented checks. -/// -/// # Errors -/// -/// Returns an error listing every scenario with a fixture-failure outcome. -/// Pinned fixtures with recorded provenance are handled by the runtime as -/// gateway failures instead of calling this compatibility validator. -pub fn validate_no_fixture_failures(results: &ConformanceResults) -> Result<()> { - let fixture_failures = results - .scenarios - .iter() - .filter_map(|(scenario, result)| { - (result.outcome() == ScenarioOutcome::FixtureFailure).then_some(scenario.as_str()) - }) - .collect::>(); - - if !fixture_failures.is_empty() { - bail!( - "official fixture setup failed for conformance scenarios: {}", - fixture_failures.join(", ") - ); - } - Ok(()) + pub(crate) scenarios: BTreeMap, } /// Returns the exact pinned server scenario set for one supported suite/spec pair. @@ -467,7 +538,7 @@ pub fn validate_no_fixture_failures(results: &ConformanceResults) -> Result<()> /// /// Returns an error for a suite or specification revision without a catalog /// verified against [`OFFICIAL_CONFORMANCE_PACKAGE`]. -pub fn expected_server_scenarios( +pub(crate) fn expected_server_scenarios( suite: &str, spec_version: &str, ) -> Result> { @@ -507,12 +578,25 @@ pub fn expected_server_scenarios( /// /// Returns an error listing missing or unexpected scenarios when a child run /// stopped early or the pinned package catalog changed. -pub fn validate_server_scenario_set( +pub(crate) fn validate_server_scenario_set( results: &ConformanceResults, suite: &str, spec_version: &str, ) -> Result<()> { - let expected = expected_server_scenarios(suite, spec_version)?; + validate_scenario_set( + results, + expected_server_scenarios(suite, spec_version)?, + &format!("server suite {suite:?}"), + spec_version, + ) +} + +fn validate_scenario_set( + results: &ConformanceResults, + expected: BTreeSet<&str>, + selection: &str, + spec_version: &str, +) -> Result<()> { let actual = results .scenarios .keys() @@ -527,17 +611,52 @@ pub fn validate_server_scenario_set( .collect::>(); if !missing.is_empty() || !unexpected.is_empty() || !empty_checks.is_empty() { bail!( - "official conformance scenario set is incomplete for suite {suite:?} and specification {spec_version:?}; missing={missing:?}; unexpected={unexpected:?}; empty_checks={empty_checks:?}" + "official conformance scenario set is incomplete for {selection} and specification {spec_version:?}; missing={missing:?}; unexpected={unexpected:?}; empty_checks={empty_checks:?}" ); } Ok(()) } +/// Returns the exact scoped client scenario set supported by the gateway driver. +pub(crate) fn expected_client_scenarios(spec_version: &str) -> Result> { + if spec_version != DEFAULT_MCP_SPEC_VERSION { + bail!( + "external dataplane client conformance supports specification {DEFAULT_MCP_SPEC_VERSION:?}, not {spec_version:?}" + ); + } + Ok(DEFAULT_CLIENT_CONFORMANCE_SCENARIOS + .iter() + .copied() + .collect()) +} + +/// Requires parsed client results to exactly cover the scoped gateway scenarios. +pub(crate) fn validate_client_scenario_set( + results: &ConformanceResults, + spec_version: &str, +) -> Result<()> { + validate_scenario_set( + results, + expected_client_scenarios(spec_version)?, + "scoped client suite", + spec_version, + ) +} + /// Recursively parses official `server-*/checks.json` result files. /// /// Symlinks are never followed, scenario directory names are strictly validated, /// and stored provenance is relative to `root`. -pub fn load_server_results(root: &Path) -> Result { +pub(crate) fn load_server_results(root: &Path) -> Result { + load_results(root, Some("server-")) +} + +/// Recursively parses official client-scenario `checks.json` result files. +pub(crate) fn load_client_results(root: &Path) -> Result { + load_results(root, None) +} + +fn load_results(root: &Path, directory_prefix: Option<&str>) -> Result { let root = fs::canonicalize(root) .with_context(|| format!("failed to resolve conformance results root {root:?}"))?; if !root.is_dir() { @@ -557,10 +676,10 @@ pub fn load_server_results(root: &Path) -> Result { .file_name() .and_then(OsStr::to_str) .context("official result directory name is not valid UTF-8")?; - if !directory_name.starts_with("server-") { + if directory_prefix.is_some_and(|prefix| !directory_name.starts_with(prefix)) { continue; } - let scenario = scenario_from_result_directory(directory_name)?; + let scenario = scenario_from_result_directory(directory_name, directory_prefix)?; let metadata = fs::metadata(&path) .with_context(|| format!("failed to inspect official result file {path:?}"))?; if metadata.len() > MAX_CHECKS_FILE_BYTES { @@ -618,10 +737,13 @@ fn collect_check_files(directory: &Path, output: &mut Vec) -> Result<() Ok(()) } -fn scenario_from_result_directory(directory: &str) -> Result { - let rest = directory - .strip_prefix("server-") - .context("official result directory must begin with server-")?; +fn scenario_from_result_directory(directory: &str, prefix: Option<&str>) -> Result { + let rest = match prefix { + Some(prefix) => directory + .strip_prefix(prefix) + .with_context(|| format!("official result directory must begin with {prefix}"))?, + None => directory, + }; if rest.len() <= 25 { bail!("official result directory {directory:?} has no scenario or timestamp"); } @@ -686,7 +808,7 @@ fn validate_scenario_name(scenario: &str) -> Result<()> { /// Scenario-level outcome used for direct and routed comparison. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ScenarioOutcome { +pub(crate) enum ScenarioOutcome { /// No failures or unknown statuses; at least one success or warning was observed. Compliant, /// At least one official failure was observed. @@ -704,7 +826,7 @@ pub enum ScenarioOutcome { impl ScenarioOutcome { /// Stable report label. #[must_use] - pub const fn label(self) -> &'static str { + pub(crate) const fn label(self) -> &'static str { match self { Self::Compliant => "compliant", Self::NonCompliant => "failure", @@ -718,19 +840,19 @@ impl ScenarioOutcome { /// Required three-way comparison report classification. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ComparisonClassification { +pub(crate) enum ComparisonClassification { /// Direct and both routed paths are compliant. AllCompliant, /// Only the direct fixture run fails. FixtureOnlyFailure, - /// Only the built-in data-plane route fails. - ControlplaneOnlyFailure, - /// Only the external data-plane route fails. - DataplaneOnlyFailure, - /// The direct fixture and built-in data-plane route fail. - FixtureAndControlplaneFailure, - /// The direct fixture and external data-plane route fail. - FixtureAndDataplaneFailure, + /// Only the built-in dataplane fails. + BuiltInDataPlaneOnlyFailure, + /// Only the external dataplane fails. + ExternalDataPlaneOnlyFailure, + /// The direct fixture and built-in dataplane fail. + FixtureAndBuiltInDataPlaneFailure, + /// The direct fixture and external dataplane fail. + FixtureAndExternalDataPlaneFailure, /// Both gateway paths fail while the direct fixture passes. GatewaysOnlyFailure, /// Direct and both routed paths fail. @@ -747,10 +869,10 @@ impl ComparisonClassification { const ALL: [Self; 11] = [ Self::AllCompliant, Self::FixtureOnlyFailure, - Self::ControlplaneOnlyFailure, - Self::DataplaneOnlyFailure, - Self::FixtureAndControlplaneFailure, - Self::FixtureAndDataplaneFailure, + Self::BuiltInDataPlaneOnlyFailure, + Self::ExternalDataPlaneOnlyFailure, + Self::FixtureAndBuiltInDataPlaneFailure, + Self::FixtureAndExternalDataPlaneFailure, Self::GatewaysOnlyFailure, Self::SharedFailure, Self::FixtureFailure, @@ -760,14 +882,14 @@ impl ComparisonClassification { /// Stable report label matching the compliance-report vocabulary. #[must_use] - pub const fn label(self) -> &'static str { + pub(crate) const fn label(self) -> &'static str { match self { Self::AllCompliant => "all compliant", Self::FixtureOnlyFailure => "fixture-only failure", - Self::ControlplaneOnlyFailure => "built-in data-plane only failure", - Self::DataplaneOnlyFailure => "external data-plane only failure", - Self::FixtureAndControlplaneFailure => "fixture + built-in data-plane failure", - Self::FixtureAndDataplaneFailure => "fixture + external data-plane failure", + Self::BuiltInDataPlaneOnlyFailure => "built-in dataplane only failure", + Self::ExternalDataPlaneOnlyFailure => "external dataplane only failure", + Self::FixtureAndBuiltInDataPlaneFailure => "fixture + built-in dataplane failure", + Self::FixtureAndExternalDataPlaneFailure => "fixture + external dataplane failure", Self::GatewaysOnlyFailure => "both gateways only failure", Self::SharedFailure => "shared failure", Self::FixtureFailure => "fixture failure", @@ -777,42 +899,45 @@ impl ComparisonClassification { } } -/// Classifies direct-fixture, built-in, and external data-plane route outcomes. +/// Classifies direct-fixture, built-in, and external dataplane outcomes. #[must_use] -pub fn classify_outcomes( +pub(crate) fn classify_outcomes( fixture: ScenarioOutcome, - controlplane: ScenarioOutcome, - dataplane: ScenarioOutcome, + built_in_data_plane: ScenarioOutcome, + external_data_plane: ScenarioOutcome, ) -> ComparisonClassification { use ScenarioOutcome::{Ambiguous, FixtureFailure, Missing, NonCompliant, NotApplicable}; if matches!(fixture, FixtureFailure) - || matches!(controlplane, FixtureFailure) - || matches!(dataplane, FixtureFailure) + || matches!(built_in_data_plane, FixtureFailure) + || matches!(external_data_plane, FixtureFailure) { return ComparisonClassification::FixtureFailure; } if matches!(fixture, Ambiguous | Missing) - || matches!(controlplane, Ambiguous | Missing) - || matches!(dataplane, Ambiguous | Missing) + || matches!(built_in_data_plane, Ambiguous | Missing) + || matches!(external_data_plane, Ambiguous | Missing) { return ComparisonClassification::Ambiguous; } - if fixture == NotApplicable && controlplane == NotApplicable && dataplane == NotApplicable { + if fixture == NotApplicable + && built_in_data_plane == NotApplicable + && external_data_plane == NotApplicable + { return ComparisonClassification::NotApplicable; } match ( fixture == NonCompliant, - controlplane == NonCompliant, - dataplane == NonCompliant, + built_in_data_plane == NonCompliant, + external_data_plane == NonCompliant, ) { (false, false, false) => ComparisonClassification::AllCompliant, (true, false, false) => ComparisonClassification::FixtureOnlyFailure, - (false, true, false) => ComparisonClassification::ControlplaneOnlyFailure, - (false, false, true) => ComparisonClassification::DataplaneOnlyFailure, - (true, true, false) => ComparisonClassification::FixtureAndControlplaneFailure, - (true, false, true) => ComparisonClassification::FixtureAndDataplaneFailure, + (false, true, false) => ComparisonClassification::BuiltInDataPlaneOnlyFailure, + (false, false, true) => ComparisonClassification::ExternalDataPlaneOnlyFailure, + (true, true, false) => ComparisonClassification::FixtureAndBuiltInDataPlaneFailure, + (true, false, true) => ComparisonClassification::FixtureAndExternalDataPlaneFailure, (false, true, true) => ComparisonClassification::GatewaysOnlyFailure, (true, true, true) => ComparisonClassification::SharedFailure, } @@ -820,92 +945,62 @@ pub fn classify_outcomes( /// One scenario row in the deterministic comparison report. #[derive(Debug, Clone, PartialEq)] -pub struct ScenarioComparison { +pub(crate) struct ScenarioComparison { /// Official scenario name. - pub scenario: String, + pub(crate) scenario: String, /// Direct official fixture result. - pub fixture: ScenarioOutcome, + pub(crate) fixture: ScenarioOutcome, /// Raw failed checks in the direct fixture result. - pub fixture_failed_checks: usize, - /// Control-plane result. - pub controlplane: ScenarioOutcome, - /// Raw failed checks in the control-plane result. - pub controlplane_failed_checks: usize, - /// Dataplane result. - pub dataplane: ScenarioOutcome, - /// Raw failed checks in the dataplane result. - pub dataplane_failed_checks: usize, + pub(crate) fixture_failed_checks: usize, + /// Built-in data-plane result. + pub(crate) built_in_data_plane: ScenarioOutcome, + /// Raw failed checks in the built-in dataplane result. + pub(crate) built_in_data_plane_failed_checks: usize, + /// External data-plane result. + pub(crate) external_data_plane: ScenarioOutcome, + /// Raw failed checks in the external dataplane result. + pub(crate) external_data_plane_failed_checks: usize, /// Reduced report classification. - pub classification: ComparisonClassification, + pub(crate) classification: ComparisonClassification, /// Raw official references from both result sets. - pub spec_references: Vec, -} - -/// Per-target provenance trust used when reducing fixture-shaped failures. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct ComparisonFixtureTrust { - /// Direct fixture provenance is the exact pinned source revision. - pub fixture: bool, - /// Control-plane fixture provenance is the exact pinned source revision. - pub controlplane: bool, - /// Dataplane fixture provenance is the exact pinned source revision. - pub dataplane: bool, + pub(crate) spec_references: Vec, } -/// Compares direct and routed official results. +/// Compares results produced against the pinned official fixture. #[must_use] -pub fn compare_result_sets( +pub(crate) fn compare_result_sets( fixture: &ConformanceResults, - controlplane: &ConformanceResults, - dataplane: &ConformanceResults, -) -> Vec { - compare_result_sets_with_fixture_trust( - fixture, - controlplane, - dataplane, - ComparisonFixtureTrust::default(), - ) -} - -/// Compares results while independently attributing trusted fixture failures. -/// -/// A trusted side converts fixture-shaped not-found results into ordinary -/// implementation failures. An untrusted side preserves historical behavior. -#[must_use] -pub fn compare_result_sets_with_fixture_trust( - fixture: &ConformanceResults, - controlplane: &ConformanceResults, - dataplane: &ConformanceResults, - trust: ComparisonFixtureTrust, + built_in_data_plane: &ConformanceResults, + external_data_plane: &ConformanceResults, ) -> Vec { let mut scenarios: BTreeSet<_> = fixture.scenarios.keys().cloned().collect(); - scenarios.extend(controlplane.scenarios.keys().cloned()); - scenarios.extend(dataplane.scenarios.keys().cloned()); + scenarios.extend(built_in_data_plane.scenarios.keys().cloned()); + scenarios.extend(external_data_plane.scenarios.keys().cloned()); scenarios .into_iter() .map(|scenario| { let fixture_result = fixture.scenarios.get(&scenario); - let controlplane_result = controlplane.scenarios.get(&scenario); - let dataplane_result = dataplane.scenarios.get(&scenario); + let built_in_result = built_in_data_plane.scenarios.get(&scenario); + let external_result = external_data_plane.scenarios.get(&scenario); let fixture_outcome = fixture_result - .map(|result| result.outcome_with_trusted_fixture(trust.fixture)) + .map(ConformanceScenarioResult::gated_outcome) .unwrap_or(ScenarioOutcome::Missing); - let controlplane_outcome = controlplane_result - .map(|result| result.outcome_with_trusted_fixture(trust.controlplane)) + let built_in_outcome = built_in_result + .map(ConformanceScenarioResult::gated_outcome) .unwrap_or(ScenarioOutcome::Missing); - let dataplane_outcome = dataplane_result - .map(|result| result.outcome_with_trusted_fixture(trust.dataplane)) + let external_outcome = external_result + .map(ConformanceScenarioResult::gated_outcome) .unwrap_or(ScenarioOutcome::Missing); let mut spec_references = Vec::new(); if let Some(result) = fixture_result { append_references(&mut spec_references, result); } - if let Some(result) = controlplane_result { + if let Some(result) = built_in_result { append_references(&mut spec_references, result); } - if let Some(result) = dataplane_result { + if let Some(result) = external_result { append_references(&mut spec_references, result); } sort_and_deduplicate_references(&mut spec_references); @@ -914,14 +1009,14 @@ pub fn compare_result_sets_with_fixture_trust( scenario, fixture: fixture_outcome, fixture_failed_checks: failed_check_count(fixture_result), - controlplane: controlplane_outcome, - controlplane_failed_checks: failed_check_count(controlplane_result), - dataplane: dataplane_outcome, - dataplane_failed_checks: failed_check_count(dataplane_result), + built_in_data_plane: built_in_outcome, + built_in_data_plane_failed_checks: failed_check_count(built_in_result), + external_data_plane: external_outcome, + external_data_plane_failed_checks: failed_check_count(external_result), classification: classify_outcomes( fixture_outcome, - controlplane_outcome, - dataplane_outcome, + built_in_outcome, + external_outcome, ), spec_references, } @@ -962,38 +1057,36 @@ fn reference_key(reference: &SpecReference) -> String { /// Inputs for a deterministic Markdown comparison report. #[derive(Debug, Clone, PartialEq)] -pub struct ComparisonReport { - /// MCP specification version used by the official client in all result sets. - pub spec_version: String, +pub(crate) struct ComparisonReport { + /// MCP protocol version used by the official client in all result sets. + pub(crate) client_version: String, /// Protocol era exposed by the upstream fixture in all result sets. - pub server_era: ConformanceServerEra, + pub(crate) server_era: ConformanceServerEra, /// Official scenario suite exercised by all result sets. - pub suite: String, - /// Exact official fixture provenance when recorded by the run. - pub fixture: Option, + pub(crate) suite: String, + /// Exact official fixture provenance. + pub(crate) fixture: ConformanceFixtureMetadata, /// Scenario comparisons in any order; rendering sorts them. - pub scenarios: Vec, + pub(crate) scenarios: Vec, } /// Renders a deterministic, untrusted-input-safe Markdown comparison report. #[must_use] -pub fn render_comparison_markdown(report: &ComparisonReport) -> String { +pub(crate) fn render_comparison_markdown(report: &ComparisonReport) -> String { let mut output = String::new(); output.push_str("# MCP Conformance Comparison\n\n"); output.push_str(&format!( "- Official oracle: `{}`\n- Client specification: `{}`\n- Upstream server era: `{}`\n- Suite: `{}`\n", OFFICIAL_CONFORMANCE_PACKAGE, - markdown_code(&report.spec_version), + markdown_code(&report.client_version), report.server_era, markdown_code(&report.suite) )); - if let Some(fixture) = report.fixture.as_ref() { - output.push_str(&format!( - "- Fixture source: `{}` at `{}`\n", - markdown_code(&fixture.repository), - markdown_code(&fixture.revision) - )); - } + output.push_str(&format!( + "- Fixture source: `{}` at `{}`\n", + markdown_code(&report.fixture.repository), + markdown_code(&report.fixture.revision) + )); output.push('\n'); let mut counts = BTreeMap::new(); @@ -1013,13 +1106,13 @@ pub fn render_comparison_markdown(report: &ComparisonReport) -> String { ), ( "Built-in data-plane route", - |scenario: &ScenarioComparison| scenario.controlplane, - |scenario: &ScenarioComparison| scenario.controlplane_failed_checks, + |scenario: &ScenarioComparison| scenario.built_in_data_plane, + |scenario: &ScenarioComparison| scenario.built_in_data_plane_failed_checks, ), ( "External data-plane route", - |scenario: &ScenarioComparison| scenario.dataplane, - |scenario: &ScenarioComparison| scenario.dataplane_failed_checks, + |scenario: &ScenarioComparison| scenario.external_data_plane, + |scenario: &ScenarioComparison| scenario.external_data_plane_failed_checks, ), ]; for (label, outcome, failed_checks) in target_outcomes { @@ -1089,8 +1182,8 @@ pub fn render_comparison_markdown(report: &ComparisonReport) -> String { "| {} | {} | {} | {} | {} | {} |\n", markdown_cell(&scenario.scenario), scenario.fixture.label(), - scenario.controlplane.label(), - scenario.dataplane.label(), + scenario.built_in_data_plane.label(), + scenario.external_data_plane.label(), scenario.classification.label(), references )); @@ -1099,7 +1192,7 @@ pub fn render_comparison_markdown(report: &ComparisonReport) -> String { } /// Writes a deterministic Markdown comparison report. -pub fn write_comparison_report(path: &Path, report: &ComparisonReport) -> Result<()> { +pub(crate) fn write_comparison_report(path: &Path, report: &ComparisonReport) -> Result<()> { create_parent_directory(path)?; fs::write(path, render_comparison_markdown(report)) .with_context(|| format!("failed to write conformance comparison report {path:?}")) diff --git a/crates/compliance/tests/conformance.rs b/src/conformance/results_tests.rs similarity index 64% rename from crates/compliance/tests/conformance.rs rename to src/conformance/results_tests.rs index 081ebde..554dcec 100644 --- a/crates/compliance/tests/conformance.rs +++ b/src/conformance/results_tests.rs @@ -3,29 +3,58 @@ use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; -use cf_integration_compliance::conformance::{ - CheckStatus, ComparisonClassification, ComparisonFixtureTrust, ComparisonReport, - ConformanceCheck, ConformanceFixtureMetadata, ConformanceResults, ConformanceRunMetadata, - ConformanceScenarioResult, ConformanceServerEra, DEFAULT_CONFORMANCE_SUITE, - DEFAULT_MCP_SPEC_VERSION, OFFICIAL_CONFORMANCE_PACKAGE, ScenarioComparison, ScenarioOutcome, - SpecReference, classify_outcomes, compare_result_sets, compare_result_sets_with_fixture_trust, - expected_server_scenarios, is_trusted_official_fixture, load_server_results, - official_server_command, render_comparison_markdown, validate_server_scenario_set, - write_comparison_report, -}; -use cf_integration_compliance::conformance_fixture::{ +use cf_integration::conformance::fixture::{ OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION, OFFICIAL_CONFORMANCE_SERVER_ID, }; +use cf_integration::conformance::results::{ + CheckStatus, ComparisonClassification, ComparisonReport, ConformanceCheck, + ConformanceDirection, ConformanceFixtureMetadata, ConformanceResults, ConformanceRunMetadata, + ConformanceScenarioResult, ConformanceServerEra, DEFAULT_CLIENT_CONFORMANCE_SCENARIOS, + DEFAULT_CONFORMANCE_SUITE, DEFAULT_MCP_SPEC_VERSION, OFFICIAL_CONFORMANCE_PACKAGE, + ScenarioComparison, ScenarioOutcome, SemanticLane, SpecReference, classify_outcomes, + compare_result_sets, expected_client_scenarios, expected_server_scenarios, + is_trusted_official_fixture, load_client_results, load_server_results, official_client_command, + official_server_command, render_comparison_markdown, validate_client_scenario_set, + validate_server_scenario_set, write_comparison_report, +}; const SPEC_REFERENCE: &str = "https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization"; +#[test] +fn semantic_lanes_have_one_shared_stable_vocabulary() { + assert_eq!(SemanticLane::FixtureDirect.label(), "fixture direct"); + assert_eq!(SemanticLane::BuiltInDataPlane.label(), "built-in dataplane"); + assert_eq!( + SemanticLane::ExternalDataPlane.label(), + "external dataplane" + ); +} + +#[test] +fn fixture_server_eras_report_their_exact_protocol_sets() { + assert_eq!( + ConformanceServerEra::Legacy.protocol_versions(), + &["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] + ); + assert_eq!( + ConformanceServerEra::Modern.protocol_versions(), + &["2026-07-28"] + ); + assert_eq!( + ConformanceServerEra::Dual.protocol_versions(), + &[ + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", + "2026-07-28" + ] + ); +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("compliance crate should be nested under the workspace root") - .to_path_buf() } fn fixture_metadata() -> ConformanceFixtureMetadata { @@ -85,10 +114,11 @@ fn metadata_roundtrips_exact_fixture_provenance() { let metadata = ConformanceRunMetadata { oracle: OFFICIAL_CONFORMANCE_PACKAGE.to_owned(), target: "control-plane".to_owned(), - spec_version: DEFAULT_MCP_SPEC_VERSION.to_owned(), + direction: ConformanceDirection::Server, + client_version: DEFAULT_MCP_SPEC_VERSION.to_owned(), server_era: ConformanceServerEra::Legacy, suite: DEFAULT_CONFORMANCE_SUITE.to_owned(), - fixture: Some(fixture_metadata()), + fixture: fixture_metadata(), }; let serialized = serde_json::to_vec(&metadata).expect("metadata should serialize"); @@ -97,27 +127,61 @@ fn metadata_roundtrips_exact_fixture_provenance() { assert_eq!(roundtrip, metadata); assert_eq!(roundtrip.server_era, ConformanceServerEra::Legacy); - assert!(is_trusted_official_fixture(roundtrip.fixture.as_ref())); + assert!(is_trusted_official_fixture(&roundtrip.fixture)); } #[test] -fn historical_metadata_defaults_to_dual_server_era() { - let metadata: ConformanceRunMetadata = serde_json::from_value(serde_json::json!({ +fn metadata_without_an_explicit_server_era_is_rejected() { + let error = serde_json::from_value::(serde_json::json!({ "oracle": OFFICIAL_CONFORMANCE_PACKAGE, "target": "fixture direct", - "spec_version": "2025-11-25", + "direction": "server", + "client_version": "2025-11-25", + "suite": "all", + "fixture": fixture_metadata() + })) + .expect_err("metadata must identify the server era") + .to_string(); + + assert!(error.contains("server_era")); +} + +#[test] +fn metadata_without_an_explicit_direction_is_rejected() { + let error = serde_json::from_value::(serde_json::json!({ + "oracle": OFFICIAL_CONFORMANCE_PACKAGE, + "target": "fixture direct", + "client_version": "2026-07-28", + "server_era": "modern", + "suite": "all", + "fixture": fixture_metadata() + })) + .expect_err("metadata must identify the conformance direction") + .to_string(); + + assert!(error.contains("direction")); +} + +#[test] +fn metadata_without_fixture_provenance_is_rejected() { + let error = serde_json::from_value::(serde_json::json!({ + "oracle": OFFICIAL_CONFORMANCE_PACKAGE, + "target": "fixture direct", + "direction": "server", + "client_version": "2025-11-25", + "server_era": "legacy", "suite": "all" })) - .expect("historical metadata should remain readable"); + .expect_err("fixture provenance is mandatory") + .to_string(); - assert_eq!(metadata.server_era, ConformanceServerEra::Dual); + assert!(error.contains("fixture")); } #[test] fn fixture_trust_requires_every_pinned_identity() { let exact = fixture_metadata(); - assert!(is_trusted_official_fixture(Some(&exact))); - assert!(!is_trusted_official_fixture(None)); + assert!(is_trusted_official_fixture(&exact)); for mismatch in [ ConformanceFixtureMetadata { repository: "https://example.test/untrusted".to_owned(), @@ -132,7 +196,7 @@ fn fixture_trust_requires_every_pinned_identity() { ..exact }, ] { - assert!(!is_trusted_official_fixture(Some(&mismatch))); + assert!(!is_trusted_official_fixture(&mismatch)); } } @@ -210,6 +274,79 @@ fn official_command_is_pinned_complete_and_ordered() { ); } +#[test] +fn official_client_command_is_scoped_complete_and_ordered() { + let spec = official_client_command( + "cf-integration __client-conformance", + "tools_call", + DEFAULT_MCP_SPEC_VERSION, + Path::new("expected-failures.yml"), + Path::new("results"), + ); + + assert!(!spec.inherits_environment()); + assert_eq!( + spec.arguments(), + &[ + OsString::from("-y"), + OsString::from(OFFICIAL_CONFORMANCE_PACKAGE), + OsString::from("client"), + OsString::from("--command"), + OsString::from("cf-integration __client-conformance"), + OsString::from("--scenario"), + OsString::from("tools_call"), + OsString::from("--spec-version"), + OsString::from(DEFAULT_MCP_SPEC_VERSION), + OsString::from("--expected-failures"), + OsString::from("expected-failures.yml"), + OsString::from("--timeout"), + OsString::from("60000"), + OsString::from("--output-dir"), + OsString::from("results"), + OsString::from("--verbose"), + ] + ); +} + +#[test] +fn scoped_client_catalog_is_exact_and_versioned() { + assert_eq!( + expected_client_scenarios(DEFAULT_MCP_SPEC_VERSION) + .expect("current client scenario catalog should be pinned"), + DEFAULT_CLIENT_CONFORMANCE_SCENARIOS + .iter() + .copied() + .collect() + ); + assert!(expected_client_scenarios("2025-11-25").is_err()); +} + +#[test] +fn client_results_parse_and_require_every_scoped_scenario() { + let directory = tempfile::tempdir().expect("temporary result directory"); + for (index, scenario) in DEFAULT_CLIENT_CONFORMANCE_SCENARIOS.iter().enumerate() { + let result_directory = directory + .path() + .join(format!("{scenario}-2026-08-28T22-20-5{index}-000Z")); + fs::create_dir(&result_directory).expect("client result directory"); + fs::write( + result_directory.join("checks.json"), + format!(r#"[{{"id":"{scenario}-check","status":"SUCCESS"}}]"#), + ) + .expect("client checks should be written"); + } + + let parsed = load_client_results(directory.path()).expect("client results should parse"); + validate_client_scenario_set(&parsed, DEFAULT_MCP_SPEC_VERSION) + .expect("complete scoped client result set should validate"); + assert_eq!(parsed.scenarios.len(), 4); + + fs::remove_dir_all(directory.path().join("tools_call-2026-08-28T22-20-50-000Z")) + .expect("one client result should be removed"); + let partial = load_client_results(directory.path()).expect("partial results should parse"); + assert!(validate_client_scenario_set(&partial, DEFAULT_MCP_SPEC_VERSION).is_err()); +} + #[test] fn fixture_results_parse_recursively_with_forward_compatible_fields() { let parsed = load_server_results(&workspace_root().join("tests/fixtures/conformance/results")) @@ -257,7 +394,7 @@ fn scenario_outcomes_preserve_failure_warning_and_unknown_precedence() { } #[test] -fn trusted_fixture_turns_fixture_shaped_gateway_failures_into_gateway_failures() { +fn pinned_fixture_turns_fixture_shaped_gateway_failures_into_gateway_failures() { let mut missing = check("missing", CheckStatus::Failure); missing.error_message = Some("Tool not found: test_simple_tool".to_owned()); let result = ConformanceScenarioResult { @@ -267,10 +404,7 @@ fn trusted_fixture_turns_fixture_shaped_gateway_failures_into_gateway_failures() }; assert_eq!(result.outcome(), ScenarioOutcome::FixtureFailure); - assert_eq!( - result.outcome_with_trusted_fixture(true), - ScenarioOutcome::NonCompliant - ); + assert_eq!(result.gated_outcome(), ScenarioOutcome::NonCompliant); } #[test] @@ -293,25 +427,25 @@ fn classifications_cover_all_three_lane_failure_combinations() { Compliant, NonCompliant, Compliant, - ComparisonClassification::ControlplaneOnlyFailure, + ComparisonClassification::BuiltInDataPlaneOnlyFailure, ), ( Compliant, Compliant, NonCompliant, - ComparisonClassification::DataplaneOnlyFailure, + ComparisonClassification::ExternalDataPlaneOnlyFailure, ), ( NonCompliant, NonCompliant, Compliant, - ComparisonClassification::FixtureAndControlplaneFailure, + ComparisonClassification::FixtureAndBuiltInDataPlaneFailure, ), ( NonCompliant, Compliant, NonCompliant, - ComparisonClassification::FixtureAndDataplaneFailure, + ComparisonClassification::FixtureAndExternalDataPlaneFailure, ), ( Compliant, @@ -349,12 +483,12 @@ fn comparison_counts_raw_failures_without_expected_failure_suppression() { compared[0].classification, ComparisonClassification::GatewaysOnlyFailure ); - assert_eq!(compared[0].controlplane_failed_checks, 2); - assert_eq!(compared[0].dataplane_failed_checks, 1); + assert_eq!(compared[0].built_in_data_plane_failed_checks, 2); + assert_eq!(compared[0].external_data_plane_failed_checks, 1); } #[test] -fn comparison_uses_trust_independently_for_each_lane() { +fn comparison_gates_fixture_shaped_failures_for_every_lane() { let mut missing = check("missing", CheckStatus::Failure); missing.error_message = Some("Tool not found: test_simple_tool".to_owned()); let controlplane = results([ConformanceScenarioResult { @@ -365,19 +499,11 @@ fn comparison_uses_trust_independently_for_each_lane() { let fixture = results([result("scenario", [CheckStatus::Success])]); let dataplane = results([result("scenario", [CheckStatus::Success])]); - let compared = compare_result_sets_with_fixture_trust( - &fixture, - &controlplane, - &dataplane, - ComparisonFixtureTrust { - controlplane: true, - ..ComparisonFixtureTrust::default() - }, - ); + let compared = compare_result_sets(&fixture, &controlplane, &dataplane); assert_eq!( compared[0].classification, - ComparisonClassification::ControlplaneOnlyFailure + ComparisonClassification::BuiltInDataPlaneOnlyFailure ); } @@ -387,10 +513,10 @@ fn report_renders_raw_counts_and_no_expected_failure_column() { scenario: "server|stateless".to_owned(), fixture: ScenarioOutcome::Compliant, fixture_failed_checks: 0, - controlplane: ScenarioOutcome::NonCompliant, - controlplane_failed_checks: 27, - dataplane: ScenarioOutcome::NonCompliant, - dataplane_failed_checks: 28, + built_in_data_plane: ScenarioOutcome::NonCompliant, + built_in_data_plane_failed_checks: 27, + external_data_plane: ScenarioOutcome::NonCompliant, + external_data_plane_failed_checks: 28, classification: ComparisonClassification::GatewaysOnlyFailure, spec_references: vec![SpecReference { id: "MCP|Transport".to_owned(), @@ -399,10 +525,10 @@ fn report_renders_raw_counts_and_no_expected_failure_column() { }], }; let report = ComparisonReport { - spec_version: DEFAULT_MCP_SPEC_VERSION.to_owned(), + client_version: DEFAULT_MCP_SPEC_VERSION.to_owned(), server_era: ConformanceServerEra::Modern, suite: DEFAULT_CONFORMANCE_SUITE.to_owned(), - fixture: Some(fixture_metadata()), + fixture: fixture_metadata(), scenarios: vec![scenario], }; diff --git a/src/error.rs b/src/error.rs index 7c73127..64231f8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,13 +3,13 @@ use std::error::Error; use std::fmt; -use cf_integration_platform::PlatformError; +use crate::infrastructure::InfrastructureError; -/// An application operation or platform operation failure. +/// An application operation or infrastructure operation failure. #[derive(Debug)] -pub enum AppFailure { - /// A reusable platform operation failed. - Platform(PlatformError), +pub(crate) enum AppFailure { + /// A reusable infrastructure operation failed. + Infrastructure(InfrastructureError), /// An application orchestration operation failed. Native(anyhow::Error), } @@ -17,9 +17,9 @@ pub enum AppFailure { impl AppFailure { /// Returns the process exit code represented by this failure. #[must_use] - pub fn exit_code(&self) -> i32 { + pub(crate) fn exit_code(&self) -> i32 { match self { - Self::Platform(error) => error.exit_code(), + Self::Infrastructure(error) => error.exit_code(), Self::Native(_) => 1, } } @@ -31,16 +31,16 @@ impl From for AppFailure { } } -impl From for AppFailure { - fn from(error: PlatformError) -> Self { - Self::Platform(error) +impl From for AppFailure { + fn from(error: InfrastructureError) -> Self { + Self::Infrastructure(error) } } impl fmt::Display for AppFailure { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Platform(error) => write!(formatter, "{error}"), + Self::Infrastructure(error) => write!(formatter, "{error}"), Self::Native(error) => write!(formatter, "{error:#}"), } } @@ -49,7 +49,7 @@ impl fmt::Display for AppFailure { impl Error for AppFailure { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { - Self::Platform(error) => Some(error), + Self::Infrastructure(error) => Some(error), Self::Native(error) => Some(error.as_ref()), } } diff --git a/src/infrastructure/assets.rs b/src/infrastructure/assets.rs new file mode 100644 index 0000000..b6d7f41 --- /dev/null +++ b/src/infrastructure/assets.rs @@ -0,0 +1,214 @@ +//! Embedded runtime assets used by installed CLI binaries. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use uuid::Uuid; + +const COMPLETE_MARKER: &str = ".complete"; + +struct EmbeddedAsset { + path: &'static str, + contents: &'static [u8], +} + +macro_rules! asset { + ($path:literal) => { + EmbeddedAsset { + path: $path, + contents: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $path)), + } + }; +} + +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-dataplane-build.yaml"), + asset!("docker/docker-compose.cf-dataplane.yaml"), + asset!("docker/docker-compose.cf-integration.yaml"), + asset!("docker/mcp-conformance-server.Dockerfile"), + asset!("docker/nginx.cf-conformance-proxy.conf"), + 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/locustfile_mcp.py"), + 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"), + asset!("tests/conformance/baselines/2026-07-28/legacy/fixture-direct.yml"), + asset!("tests/conformance/baselines/2026-07-28/modern/built-in-data-plane.yml"), + asset!("tests/conformance/baselines/2026-07-28/modern/client/external-data-plane.yml"), + asset!("tests/conformance/baselines/2026-07-28/modern/external-data-plane.yml"), + asset!("tests/conformance/baselines/2026-07-28/modern/fixture-direct.yml"), +]; + +/// Returns whether `root` contains the complete runtime asset set. +#[must_use] +pub(crate) fn contains_runtime_assets(root: &Path) -> bool { + ASSETS.iter().all(|asset| root.join(asset.path).is_file()) +} + +/// Materializes the embedded runtime files below the integration directory. +/// +/// Existing versioned assets must exactly match the binary. A mismatch fails +/// closed instead of silently running Compose with a mixed asset set. +pub(crate) fn materialize_runtime_assets(integration_dir: &Path) -> Result { + let parent = integration_dir.join("assets"); + let destination = parent.join(env!("CARGO_PKG_VERSION")); + if destination.exists() { + validate_materialized_assets(&destination)?; + return Ok(destination); + } + + fs::create_dir_all(&parent).with_context(|| { + format!( + "failed to create embedded asset directory {}", + parent.display() + ) + })?; + let temporary = parent.join(format!(".tmp-{}", Uuid::new_v4().simple())); + let result = + write_asset_tree(&temporary).and_then(|()| match fs::rename(&temporary, &destination) { + Ok(()) => Ok(()), + Err(_) if destination.exists() => validate_materialized_assets(&destination), + Err(error) => Err(error).with_context(|| { + format!( + "failed to activate embedded runtime assets at {}", + destination.display() + ) + }), + }); + if temporary.exists() { + let _ = fs::remove_dir_all(&temporary); + } + result?; + validate_materialized_assets(&destination)?; + Ok(destination) +} + +fn write_asset_tree(root: &Path) -> Result<()> { + fs::create_dir(root) + .with_context(|| format!("failed to create temporary asset tree {}", root.display()))?; + for asset in ASSETS { + let path = root.join(asset.path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("failed to create embedded asset path {}", parent.display()) + })?; + } + fs::write(&path, asset.contents) + .with_context(|| format!("failed to write embedded asset {}", path.display()))?; + let mut permissions = fs::metadata(&path)?.permissions(); + permissions.set_readonly(true); + fs::set_permissions(&path, permissions)?; + } + fs::write(root.join(COMPLETE_MARKER), env!("CARGO_PKG_VERSION")) + .context("failed to write embedded asset completion marker")?; + Ok(()) +} + +fn validate_materialized_assets(root: &Path) -> Result<()> { + let marker = fs::read_to_string(root.join(COMPLETE_MARKER)).unwrap_or_default(); + if marker != env!("CARGO_PKG_VERSION") { + bail!( + "embedded runtime assets at {} are incomplete; remove that versioned directory and retry", + root.display() + ); + } + for asset in ASSETS { + let path = root.join(asset.path); + let contents = fs::read(&path).with_context(|| { + format!( + "embedded runtime asset {} is missing; remove {} and retry", + path.display(), + root.display() + ) + })?; + if contents != asset.contents { + bail!( + "embedded runtime asset {} does not match this binary; remove {} and retry", + path.display(), + root.display() + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn materializes_and_reuses_complete_assets() { + let directory = tempfile::tempdir().expect("temporary directory"); + let first = materialize_runtime_assets(directory.path()).expect("materialize assets"); + let second = materialize_runtime_assets(directory.path()).expect("reuse assets"); + + assert_eq!(first, second); + assert!(contains_runtime_assets(&first)); + } + + #[test] + fn concurrent_materialization_converges_on_one_tree() { + let directory = tempfile::tempdir().expect("temporary directory"); + let integration_dir = std::sync::Arc::new(directory.path().to_path_buf()); + let workers = (0..4) + .map(|_| { + let integration_dir = integration_dir.clone(); + std::thread::spawn(move || materialize_runtime_assets(&integration_dir)) + }) + .collect::>(); + + let roots = workers + .into_iter() + .map(|worker| { + worker + .join() + .expect("materialization worker should not panic") + .expect("materialization worker should succeed") + }) + .collect::>(); + assert!(roots.windows(2).all(|pair| pair[0] == pair[1])); + assert!(contains_runtime_assets(&roots[0])); + } + + #[test] + fn rejects_corrupted_versioned_assets() { + let directory = tempfile::tempdir().expect("temporary directory"); + let root = materialize_runtime_assets(directory.path()).expect("materialize assets"); + let path = root.join(ASSETS[0].path); + make_writable(&path); + fs::write(&path, b"corrupt").expect("corrupt test asset"); + + let error = materialize_runtime_assets(directory.path()) + .expect_err("corrupted assets must fail closed"); + assert!(error.to_string().contains("does not match this binary")); + } + + #[test] + fn repository_contains_every_embedded_asset() { + assert!(contains_runtime_assets(Path::new(env!( + "CARGO_MANIFEST_DIR" + )))); + } + + #[cfg(unix)] + fn make_writable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o644)) + .expect("make test asset writable"); + } + + #[cfg(not(unix))] + fn make_writable(path: &Path) { + let mut permissions = fs::metadata(path).expect("asset metadata").permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions).expect("make test asset writable"); + } +} diff --git a/crates/platform/src/checkout.rs b/src/infrastructure/checkout.rs similarity index 81% rename from crates/platform/src/checkout.rs rename to src/infrastructure/checkout.rs index 59a6388..8cd7b86 100644 --- a/crates/platform/src/checkout.rs +++ b/src/infrastructure/checkout.rs @@ -7,8 +7,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::Context; -use crate::error::PlatformError; -use crate::process::{CommandSpec, ProcessRunner}; +use crate::infrastructure::error::InfrastructureError; +use crate::infrastructure::process::{CommandSpec, ProcessRunner}; static GENERATED_REPLACEMENT_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -20,7 +20,7 @@ enum CheckoutKind { /// One source checkout managed by the integration harness. #[derive(Clone, PartialEq, Eq)] -pub struct CheckoutRequest { +pub(crate) struct CheckoutRequest { kind: CheckoutKind, directory: PathBuf, repository: OsString, @@ -29,7 +29,7 @@ pub struct CheckoutRequest { impl CheckoutRequest { /// Creates a control-plane checkout request. - pub fn controlplane( + pub(crate) fn controlplane( directory: impl Into, repository: impl Into, reference: impl Into, @@ -43,7 +43,7 @@ impl CheckoutRequest { } /// Creates a dataplane checkout request. - pub fn dataplane( + pub(crate) fn dataplane( directory: impl Into, repository: impl Into, reference: impl Into, @@ -56,44 +56,14 @@ impl CheckoutRequest { } } - /// Returns the checkout directory. - #[must_use] - pub fn directory(&self) -> &Path { - &self.directory - } - - /// Returns the configured source repository. - #[must_use] - pub fn repository(&self) -> &OsStr { - &self.repository - } - - /// Returns the configured source ref. - #[must_use] - pub fn reference(&self) -> &OsStr { - &self.reference - } - fn is_disabled(&self) -> bool { self.kind == CheckoutKind::Dataplane && self.reference.is_empty() } - - fn fetch_warning(&self) -> String { - let repository = self.repository.to_string_lossy(); - match self.kind { - CheckoutKind::Controlplane => { - format!("warning: fetch from {repository} failed; using existing checkout") - } - CheckoutKind::Dataplane => format!( - "warning: fetch from {repository} failed; using existing dataplane checkout" - ), - } - } } /// Whether a requested source checkout was synchronized or intentionally skipped. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CheckoutStatus { +pub(crate) enum CheckoutStatus { /// The checkout was cloned or refreshed and checked out. Updated, /// Dataplane source mode was disabled because its ref was empty. @@ -101,11 +71,10 @@ pub enum CheckoutStatus { } /// Deterministic Git commands for one checkout request. -pub struct CheckoutPlan { +pub(crate) struct CheckoutPlan { generated: bool, clone: CommandSpec, fetch: CommandSpec, - remote_branch_probe: Option, plain_checkout: CommandSpec, remote_checkout: Option, generated_cleanup: Vec, @@ -114,12 +83,13 @@ pub struct CheckoutPlan { impl CheckoutPlan { /// Builds a checkout plan without accessing the filesystem or starting a process. #[must_use] - pub fn new(integration_directory: &Path, request: &CheckoutRequest) -> Self { + pub(crate) fn new(integration_directory: &Path, request: &CheckoutRequest) -> Self { let generated = is_path_within(&request.directory, integration_directory); let clone = clone_command(request, &request.directory); let mut fetch_arguments = vec![ OsString::from("fetch"), OsString::from("-q"), + OsString::from("--no-progress"), OsString::from("--prune"), ]; if generated { @@ -136,8 +106,7 @@ impl CheckoutPlan { OsString::from("-q"), request.reference.clone(), ]); - let (remote_branch_probe, remote_checkout, generated_cleanup) = if generated { - let remote_ref = prefixed("refs/remotes/origin/", &request.reference); + let (remote_checkout, generated_cleanup) = if generated { let remote_target = prefixed("origin/", &request.reference); let cleanup = vec![ git_in(&request.directory).args([ @@ -153,12 +122,6 @@ impl CheckoutPlan { ]), ]; ( - Some(git_in(&request.directory).args([ - OsString::from("show-ref"), - OsString::from("--verify"), - OsString::from("--quiet"), - remote_ref, - ])), Some(git_in(&request.directory).args([ OsString::from("checkout"), OsString::from("-q"), @@ -169,14 +132,13 @@ impl CheckoutPlan { cleanup, ) } else { - (None, None, Vec::new()) + (None, Vec::new()) }; Self { generated, clone, fetch, - remote_branch_probe, plain_checkout, remote_checkout, generated_cleanup, @@ -185,18 +147,12 @@ impl CheckoutPlan { /// Returns whether the normalized checkout path is inside the integration directory. #[must_use] - pub fn is_generated(&self) -> bool { + pub(crate) fn is_generated(&self) -> bool { self.generated } - /// Returns the generated-checkout remote-branch probe, when one is needed. - #[must_use] - pub fn remote_branch_probe(&self) -> Option<&CommandSpec> { - self.remote_branch_probe.as_ref() - } - /// Selects the reset or plain checkout command from the remote probe result. - pub fn checkout_command(&self, remote_branch_exists: bool) -> CommandSpec { + pub(crate) fn checkout_command(&self, remote_branch_exists: bool) -> CommandSpec { if remote_branch_exists { self.remote_checkout .clone() @@ -207,40 +163,34 @@ impl CheckoutPlan { } /// Returns destructive cleanup commands used only for generated checkouts. - pub fn generated_cleanup_commands(&self) -> &[CommandSpec] { + pub(crate) fn generated_cleanup_commands(&self) -> &[CommandSpec] { &self.generated_cleanup } } /// Executes source-checkout plans through an injected process runner. -pub struct CheckoutManager<'runner, Runner: ProcessRunner + ?Sized> { +pub(crate) struct CheckoutManager<'runner, Runner: ProcessRunner + ?Sized> { runner: &'runner Runner, } impl<'runner, Runner: ProcessRunner + ?Sized> CheckoutManager<'runner, Runner> { /// Creates a checkout manager using `runner` for every Git invocation. #[must_use] - pub fn new(runner: &'runner Runner) -> Self { + pub(crate) fn new(runner: &'runner Runner) -> Self { Self { runner } } /// Ensures one configured source checkout exists at the requested ref. - /// - /// Fetch failures append the shell-compatible diagnostic to `warnings`, then - /// checkout continues using locally available refs. This keeps the warning - /// available to the caller even if the subsequent checkout fails. - /// /// # Errors /// /// Returns a failure when the integration directory cannot be created, a /// missing repository cannot be cloned, or the requested ref cannot be /// checked out. - pub fn ensure( + pub(crate) fn ensure( &self, integration_directory: &Path, request: &CheckoutRequest, - warnings: &mut Vec, - ) -> Result { + ) -> Result { if request.is_disabled() { return Ok(CheckoutStatus::Skipped); } @@ -250,7 +200,7 @@ impl<'runner, Runner: ProcessRunner + ?Sized> CheckoutManager<'runner, Runner> { })?; if normalized_paths_equal(&request.directory, integration_directory) { - return Err(PlatformError::from(anyhow::anyhow!( + return Err(InfrastructureError::from(anyhow::anyhow!( "checkout path {:?} must be a child of integration state {:?}, not the integration root itself", request.directory, integration_directory @@ -280,23 +230,19 @@ impl<'runner, Runner: ProcessRunner + ?Sized> CheckoutManager<'runner, Runner> { .capture_stdout(&origin_match_probe(request)) .is_err() { - return Err(PlatformError::from(anyhow::anyhow!( + return Err(InfrastructureError::from(anyhow::anyhow!( "external checkout {:?} origin does not match configured repository {:?}; refusing to mutate the external worktree", request.directory, request.repository ))); } - let fetch_succeeded = self.runner.run(&plan.fetch).is_ok(); - if !fetch_succeeded { - warnings.push(request.fetch_warning()); - } + self.runner.run(&plan.fetch)?; let remote_branch_probe = remote_branch_probe(request); - let remote_branch_exists = (plan.is_generated() || fetch_succeeded) - && self.runner.run(&remote_branch_probe).is_ok(); - if fetch_succeeded && !remote_branch_exists && !self.verified_nonbranch_ref(request)? { - return Err(PlatformError::from(anyhow::anyhow!( + let remote_branch_exists = self.runner.run(&remote_branch_probe).is_ok(); + if !remote_branch_exists && !self.verified_nonbranch_ref(request)? { + return Err(InfrastructureError::from(anyhow::anyhow!( "configured ref {:?} is not a fetched origin branch, tag, or commit for checkout {:?}", request.reference, request.directory @@ -316,7 +262,10 @@ impl<'runner, Runner: ProcessRunner + ?Sized> CheckoutManager<'runner, Runner> { Ok(CheckoutStatus::Updated) } - fn verified_nonbranch_ref(&self, request: &CheckoutRequest) -> Result { + fn verified_nonbranch_ref( + &self, + request: &CheckoutRequest, + ) -> Result { if self.runner.run(&tag_probe(request)).is_ok() { return Ok(true); } @@ -330,7 +279,7 @@ impl<'runner, Runner: ProcessRunner + ?Sized> CheckoutManager<'runner, Runner> { &self, integration_directory: &Path, request: &CheckoutRequest, - ) -> Result<(), PlatformError> { + ) -> Result<(), InfrastructureError> { let staging = unique_generated_sibling(&request.directory, "replacement")?; let backup = unique_generated_sibling(&request.directory, "previous")?; validate_generated_checkout_boundary(integration_directory, &staging)?; @@ -346,23 +295,23 @@ impl<'runner, Runner: ProcessRunner + ?Sized> CheckoutManager<'runner, Runner> { if let Err(error) = fs::rename(&request.directory, &backup) { remove_path_if_present(&staging) .with_context(|| format!("failed to clean fresh generated clone {staging:?}"))?; - return Err(PlatformError::from(anyhow::Error::from(error).context( - format!( + return Err(InfrastructureError::from( + anyhow::Error::from(error).context(format!( "failed to preserve generated checkout {:?} before replacing its repository", request.directory - ), - ))); + )), + )); } if let Err(replace_error) = fs::rename(&staging, &request.directory) { if let Err(restore_error) = fs::rename(&backup, &request.directory) { - return Err(PlatformError::from(anyhow::anyhow!( + return Err(InfrastructureError::from(anyhow::anyhow!( "failed to install fresh generated checkout {:?}: {replace_error}; failed to restore the previous checkout from {backup:?}: {restore_error}", request.directory ))); } remove_path_if_present(&staging) .with_context(|| format!("failed to clean fresh generated clone {staging:?}"))?; - return Err(PlatformError::from( + return Err(InfrastructureError::from( anyhow::Error::from(replace_error).context(format!( "failed to install fresh generated checkout {:?}", request.directory @@ -383,6 +332,7 @@ fn clone_command(request: &CheckoutRequest, directory: &Path) -> CommandSpec { CommandSpec::new("git").args([ OsString::from("clone"), OsString::from("-q"), + OsString::from("--no-progress"), request.repository.clone(), directory.as_os_str().to_owned(), ]) @@ -479,7 +429,7 @@ fn normalized_paths_equal(first: &Path, second: &Path) -> bool { normalize_path(first) == normalize_path(second) } -fn unique_generated_sibling(directory: &Path, role: &str) -> Result { +fn unique_generated_sibling(directory: &Path, role: &str) -> Result { let parent = directory.parent().ok_or_else(|| { anyhow::anyhow!("generated checkout {directory:?} has no parent directory") })?; @@ -500,9 +450,11 @@ fn unique_generated_sibling(directory: &Path, role: &str) -> Result {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate), Err(error) => { - return Err(PlatformError::from(anyhow::Error::from(error).context( - format!("failed to inspect generated replacement path {candidate:?}"), - ))); + return Err(InfrastructureError::from( + anyhow::Error::from(error).context(format!( + "failed to inspect generated replacement path {candidate:?}" + )), + )); } } } @@ -546,7 +498,7 @@ fn normalize_path(path: &Path) -> PathBuf { fn validate_generated_checkout_boundary( integration_directory: &Path, checkout_directory: &Path, -) -> Result<(), PlatformError> { +) -> Result<(), InfrastructureError> { let canonical_integration = fs::canonicalize(integration_directory).with_context(|| { format!("failed to resolve generated integration directory {integration_directory:?}") })?; @@ -562,7 +514,7 @@ fn validate_generated_checkout_boundary( format!("failed to resolve generated checkout path {checkout_directory:?}") })?; if !canonical_ancestor.starts_with(&canonical_integration) { - return Err(PlatformError::from(anyhow::anyhow!( + return Err(InfrastructureError::from(anyhow::anyhow!( "generated checkout path {checkout_directory:?} resolves outside integration state {integration_directory:?}; configure the external checkout path explicitly to preserve its worktree" ))); } diff --git a/crates/platform/tests/checkout.rs b/src/infrastructure/checkout_integration_tests.rs similarity index 83% rename from crates/platform/tests/checkout.rs rename to src/infrastructure/checkout_integration_tests.rs index 2dcf047..cba0d2f 100644 --- a/crates/platform/tests/checkout.rs +++ b/src/infrastructure/checkout_integration_tests.rs @@ -9,11 +9,11 @@ use std::process::Command; use std::os::unix::fs::symlink; use anyhow::anyhow; -use cf_integration_platform::PlatformError; -use cf_integration_platform::checkout::{ +use cf_integration::infrastructure::InfrastructureError; +use cf_integration::infrastructure::checkout::{ CheckoutManager, CheckoutPlan, CheckoutRequest, CheckoutStatus, }; -use cf_integration_platform::process::{ +use cf_integration::infrastructure::process::{ CapturedOutput, CommandSpec, ProcessRunner, SystemProcessRunner, }; @@ -35,30 +35,30 @@ impl RecordingRunner { self.commands.borrow().clone() } - fn record(&self, spec: &CommandSpec) -> Result<(), PlatformError> { + fn record(&self, spec: &CommandSpec) -> Result<(), InfrastructureError> { self.commands.borrow_mut().push(spec.clone()); match self.results.borrow_mut().pop_front().unwrap_or(Ok(())) { Ok(()) => Ok(()), - Err(message) => Err(PlatformError::Native(anyhow!(message))), + Err(message) => Err(InfrastructureError::Native(anyhow!(message))), } } } impl ProcessRunner for RecordingRunner { - fn run(&self, spec: &CommandSpec) -> Result<(), PlatformError> { + fn run(&self, spec: &CommandSpec) -> Result<(), InfrastructureError> { self.record(spec) } - fn capture_stdout(&self, spec: &CommandSpec) -> Result, PlatformError> { + fn capture_stdout(&self, spec: &CommandSpec) -> Result, InfrastructureError> { self.record(spec)?; Ok(Vec::new()) } - fn capture_output(&self, _spec: &CommandSpec) -> Result { + fn capture_output(&self, _spec: &CommandSpec) -> Result { unreachable!("checkout does not capture output") } - fn run_to_log(&self, _spec: &CommandSpec, _log_path: &Path) -> Result<(), PlatformError> { + fn run_to_log(&self, _spec: &CommandSpec, _log_path: &Path) -> Result<(), InfrastructureError> { unreachable!("checkout does not redirect output") } } @@ -89,18 +89,6 @@ fn generated_branch_plan_resets_to_the_matching_remote_head() { let plan = CheckoutPlan::new(integration, &request); assert!(plan.is_generated()); - assert_command( - plan.remote_branch_probe() - .expect("generated checkout should probe its remote branch"), - &[ - OsStr::new("-C"), - directory.as_os_str(), - OsStr::new("show-ref"), - OsStr::new("--verify"), - OsStr::new("--quiet"), - OsStr::new("refs/remotes/origin/main"), - ], - ); assert_command( &plan.checkout_command(true), &[ @@ -166,15 +154,13 @@ fn integration_root_cannot_be_used_as_a_checkout() { let request = CheckoutRequest::controlplane(&integration, "upstream", "main"); let runner = RecordingRunner::default(); let manager = CheckoutManager::new(&runner); - let mut warnings = Vec::new(); let error = manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect_err("the integration state root must never be replaced recursively"); assert!(error.to_string().contains("integration root itself")); assert!(runner.commands().is_empty()); - assert!(warnings.is_empty()); } #[test] @@ -186,7 +172,6 @@ fn external_branch_plan_never_probes_or_resets_the_branch() { let plan = CheckoutPlan::new(integration, &request); assert!(!plan.is_generated()); - assert!(plan.remote_branch_probe().is_none()); assert!(plan.generated_cleanup_commands().is_empty()); assert_command( &plan.checkout_command(true), @@ -230,14 +215,12 @@ fn missing_checkout_is_cloned_before_fetch_and_checkout() { let request = CheckoutRequest::controlplane(&directory, "upstream", "main"); let runner = RecordingRunner::default(); let manager = CheckoutManager::new(&runner); - let mut warnings = Vec::new(); let status = manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect("recorded checkout should succeed"); assert_eq!(status, CheckoutStatus::Updated); - assert!(warnings.is_empty()); assert!(integration.is_dir()); let commands = runner.commands(); assert_eq!(commands.len(), 10); @@ -246,6 +229,7 @@ fn missing_checkout_is_cloned_before_fetch_and_checkout() { &[ OsStr::new("clone"), OsStr::new("-q"), + OsStr::new("--no-progress"), OsStr::new("upstream"), directory.as_os_str(), ], @@ -279,6 +263,7 @@ fn missing_checkout_is_cloned_before_fetch_and_checkout() { directory.as_os_str(), OsStr::new("fetch"), OsStr::new("-q"), + OsStr::new("--no-progress"), OsStr::new("--prune"), OsStr::new("--prune-tags"), OsStr::new("--tags"), @@ -301,28 +286,23 @@ fn missing_checkout_is_cloned_before_fetch_and_checkout() { } #[test] -fn failed_fetch_warns_and_uses_the_existing_generated_remote_ref() { +fn failed_fetch_is_fatal_for_generated_checkouts() { let temporary = tempfile::tempdir().expect("temporary directory should be created"); let integration = temporary.path().join(".integration"); let directory = integration.join("controlplane"); fs::create_dir_all(directory.join(".git")).expect("fake checkout should be created"); let request = CheckoutRequest::controlplane(&directory, "upstream", "main"); - let runner = RecordingRunner::with_results([Ok(()), Ok(()), Err("offline"), Ok(())]); + let runner = RecordingRunner::with_results([Ok(()), Ok(()), Err("offline")]); let manager = CheckoutManager::new(&runner); - let mut warnings = Vec::new(); - let status = manager - .ensure(&integration, &request, &mut warnings) - .expect("an existing ref should still be checked out"); + let failure = manager + .ensure(&integration, &request) + .expect_err("fetch failure must not use a stale local ref"); - assert_eq!(status, CheckoutStatus::Updated); - assert_eq!( - warnings, - ["warning: fetch from upstream failed; using existing checkout"] - ); + assert_eq!(failure.to_string(), "offline"); let commands = runner.commands(); - assert_eq!(commands.len(), 9); - assert!(commands.iter().any(|command| { + assert_eq!(commands.len(), 3); + assert!(!commands.iter().any(|command| { command .arguments() .iter() @@ -331,47 +311,21 @@ fn failed_fetch_warns_and_uses_the_existing_generated_remote_ref() { } #[test] -fn failed_dataplane_fetch_uses_the_dataplane_specific_warning() { - let temporary = tempfile::tempdir().expect("temporary directory should be created"); - let integration = temporary.path().join(".integration"); - let directory = integration.join("dataplane"); - fs::create_dir_all(directory.join(".git")).expect("fake checkout should be created"); - let request = CheckoutRequest::dataplane(&directory, "dataplane-upstream", "main"); - let runner = RecordingRunner::with_results([Ok(()), Ok(()), Err("offline"), Ok(())]); - let manager = CheckoutManager::new(&runner); - let mut warnings = Vec::new(); - - manager - .ensure(&integration, &request, &mut warnings) - .expect("an existing dataplane ref should still be checked out"); - - assert_eq!( - warnings, - ["warning: fetch from dataplane-upstream failed; using existing dataplane checkout"] - ); -} - -#[test] -fn failed_fetch_still_surfaces_an_unknown_local_ref_failure_and_warning() { +fn failed_fetch_is_fatal_for_external_checkouts() { let temporary = tempfile::tempdir().expect("temporary directory should be created"); let integration = temporary.path().join(".integration"); let directory = temporary.path().join("external"); fs::create_dir_all(directory.join(".git")).expect("fake checkout should be created"); let request = CheckoutRequest::controlplane(&directory, "upstream", "missing-ref"); - let runner = RecordingRunner::with_results([Ok(()), Err("offline"), Err("unknown ref")]); + let runner = RecordingRunner::with_results([Ok(()), Err("offline")]); let manager = CheckoutManager::new(&runner); - let mut warnings = Vec::new(); let failure = manager - .ensure(&integration, &request, &mut warnings) - .expect_err("the checkout error must remain fatal"); + .ensure(&integration, &request) + .expect_err("fetch failure must not use a stale external ref"); - assert_eq!(failure.to_string(), "unknown ref"); - assert_eq!( - warnings, - ["warning: fetch from upstream failed; using existing checkout"] - ); - assert_eq!(runner.commands().len(), 3); + assert_eq!(failure.to_string(), "offline"); + assert_eq!(runner.commands().len(), 2); } #[test] @@ -382,14 +336,12 @@ fn dataplane_with_an_empty_ref_is_skipped_without_filesystem_or_process_changes( CheckoutRequest::dataplane(integration.join("dataplane"), "dataplane-upstream", ""); let runner = RecordingRunner::default(); let manager = CheckoutManager::new(&runner); - let mut warnings = Vec::new(); let status = manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect("published-image mode should be a no-op"); assert_eq!(status, CheckoutStatus::Skipped); - assert!(warnings.is_empty()); assert!(runner.commands().is_empty()); assert!(!integration.exists()); } @@ -415,10 +367,13 @@ impl GitFixture { ["config", "user.email", "checkout@example.invalid"], ); git(Some(&seed), ["checkout", "-b", "main"]); + fs::write(seed.join(".gitattributes"), "* text eol=lf\n") + .expect("Git attributes should be written"); fs::write(seed.join("state.txt"), "origin\n").expect("seed file should be written"); - git(Some(&seed), ["add", "state.txt"]); + git(Some(&seed), ["add", ".gitattributes", "state.txt"]); git(Some(&seed), ["commit", "-m", "origin commit"]); git(Some(&seed), ["push", "-u", "origin", "main"]); + git(Some(&origin), ["symbolic-ref", "HEAD", "refs/heads/main"]); Self { _temporary: temporary, root, @@ -439,9 +394,8 @@ fn real_generated_checkout_resets_a_local_branch_to_origin() { let checkout = integration.join("controlplane"); let request = fixture.request(&checkout, "main"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect("initial generated checkout should succeed"); git(Some(&checkout), ["config", "user.name", "Checkout Test"]); git( @@ -458,7 +412,7 @@ fn real_generated_checkout_resets_a_local_branch_to_origin() { .expect("untracked generated file should be created"); manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect("generated checkout refresh should succeed"); let head = git_stdout(&checkout, ["rev-parse", "HEAD"]); @@ -471,7 +425,6 @@ fn real_generated_checkout_resets_a_local_branch_to_origin() { ); assert!(!checkout.join("untracked.txt").exists()); assert!(git_stdout(&checkout, ["status", "--porcelain"]).is_empty()); - assert!(warnings.is_empty()); } #[test] @@ -487,23 +440,14 @@ fn real_generated_checkout_discards_conflicting_wip_before_switching_refs() { let integration = fixture.root.join(".integration"); let checkout = integration.join("controlplane"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure( - &integration, - &fixture.request(&checkout, "main"), - &mut warnings, - ) + .ensure(&integration, &fixture.request(&checkout, "main")) .expect("initial generated checkout should succeed"); fs::write(checkout.join("state.txt"), "conflicting dirty state\n") .expect("generated checkout should be dirtied"); manager - .ensure( - &integration, - &fixture.request(&checkout, "release"), - &mut warnings, - ) + .ensure(&integration, &fixture.request(&checkout, "release")) .expect("generated checkout should clean before switching refs"); assert_eq!( @@ -522,9 +466,8 @@ fn successful_fetch_rejects_a_deleted_remote_branch_instead_of_using_stale_local let checkout = integration.join("controlplane"); let request = fixture.request(&checkout, "obsolete"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect("initial remote branch checkout should succeed"); fs::write( checkout.join("state.txt"), @@ -538,7 +481,7 @@ fn successful_fetch_rejects_a_deleted_remote_branch_instead_of_using_stale_local ); let error = manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect_err("a pruned remote branch must not fall back to a stale local branch"); assert!(error.to_string().contains("not a fetched origin branch")); @@ -556,13 +499,8 @@ fn successful_generated_fetch_rejects_a_deleted_remote_tag() { let integration = fixture.root.join(".integration"); let checkout = integration.join("controlplane"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure( - &integration, - &fixture.request(&checkout, "obsolete-tag"), - &mut warnings, - ) + .ensure(&integration, &fixture.request(&checkout, "obsolete-tag")) .expect("initial remote tag checkout should succeed"); git(Some(&fixture.seed), ["tag", "--delete", "obsolete-tag"]); git( @@ -571,11 +509,7 @@ fn successful_generated_fetch_rejects_a_deleted_remote_tag() { ); let error = manager - .ensure( - &integration, - &fixture.request(&checkout, "obsolete-tag"), - &mut warnings, - ) + .ensure(&integration, &fixture.request(&checkout, "obsolete-tag")) .expect_err("a pruned remote tag must not fall back to stale local state"); assert!(error.to_string().contains("not a fetched origin branch")); @@ -583,7 +517,6 @@ fn successful_generated_fetch_rejects_a_deleted_remote_tag() { &checkout, ["show-ref", "--verify", "--quiet", "refs/tags/obsolete-tag",], )); - assert!(warnings.is_empty()); } #[test] @@ -608,20 +541,14 @@ fn generated_checkout_updates_origin_when_the_configured_repository_changes() { let integration = first.root.join(".integration"); let checkout = integration.join("controlplane"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure( - &integration, - &first.request(&checkout, "main"), - &mut warnings, - ) + .ensure(&integration, &first.request(&checkout, "main")) .expect("initial generated checkout should succeed"); manager .ensure( &integration, &CheckoutRequest::controlplane(&checkout, second.origin.as_os_str(), "main"), - &mut warnings, ) .expect("generated checkout should follow the configured repository"); @@ -656,13 +583,8 @@ fn generated_checkout_rejects_two_offline_origin_change_attempts_without_using_o let integration = first.root.join(".integration"); let checkout = integration.join("controlplane"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure( - &integration, - &first.request(&checkout, "main"), - &mut warnings, - ) + .ensure(&integration, &first.request(&checkout, "main")) .expect("initial generated checkout should succeed"); let first_head = git_stdout(&checkout, ["rev-parse", "HEAD"]); fs::write(checkout.join("state.txt"), "WIP from first repository\n") @@ -673,7 +595,7 @@ fn generated_checkout_rejects_two_offline_origin_change_attempts_without_using_o .expect("second origin should be made unavailable"); for attempt in 1..=2 { - let result = manager.ensure(&integration, &changed_request, &mut warnings); + let result = manager.ensure(&integration, &changed_request); assert!( result.is_err(), "offline changed origin attempt {attempt} must not reuse refs from the previous repository" @@ -690,7 +612,6 @@ fn generated_checkout_rejects_two_offline_origin_change_attempts_without_using_o "WIP from first repository\n" ); } - assert!(warnings.is_empty()); } #[test] @@ -700,14 +621,9 @@ fn generated_checkout_accepts_a_verified_commit_hash() { let integration = fixture.root.join(".integration"); let checkout = integration.join("controlplane"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure( - &integration, - &fixture.request(&checkout, &revision), - &mut warnings, - ) + .ensure(&integration, &fixture.request(&checkout, &revision)) .expect("a fetched commit hash should be a valid checkout target"); assert_eq!(git_stdout(&checkout, ["rev-parse", "HEAD"]), revision); @@ -721,13 +637,8 @@ fn generated_symlink_to_external_checkout_is_rejected_without_touching_wip() { fs::create_dir_all(&integration).expect("integration directory should be created"); let external = fixture.root.join("external-controlplane"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure( - &integration, - &fixture.request(&external, "main"), - &mut warnings, - ) + .ensure(&integration, &fixture.request(&external, "main")) .expect("external checkout should be created"); fs::write(external.join("state.txt"), "external dirty state\n") .expect("external checkout should be dirtied"); @@ -737,11 +648,7 @@ fn generated_symlink_to_external_checkout_is_rejected_without_touching_wip() { symlink(&external, &linked).expect("generated-looking symlink should be created"); let error = manager - .ensure( - &integration, - &fixture.request(&linked, "main"), - &mut warnings, - ) + .ensure(&integration, &fixture.request(&linked, "main")) .expect_err("a generated path resolving outside state must be rejected"); assert!(error.to_string().contains("resolves outside")); @@ -759,9 +666,8 @@ fn real_external_checkout_preserves_its_local_branch_position_after_fetch() { let checkout = fixture.root.join("external-controlplane"); let request = fixture.request(&checkout, "main"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect("initial external checkout should succeed"); git(Some(&checkout), ["config", "user.name", "Checkout Test"]); git( @@ -779,7 +685,7 @@ fn real_external_checkout_preserves_its_local_branch_position_after_fetch() { .expect("external untracked WIP should be created"); manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect("external checkout refresh should succeed"); assert_eq!(git_stdout(&checkout, ["rev-parse", "HEAD"]), local_head); @@ -797,7 +703,6 @@ fn real_external_checkout_preserves_its_local_branch_position_after_fetch() { "refs/tags/external-local-only", ], )); - assert!(warnings.is_empty()); } #[test] @@ -807,13 +712,8 @@ fn external_checkout_rejects_a_repository_mismatch_without_touching_wip() { let integration = first.root.join(".integration"); let checkout = first.root.join("external-controlplane"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure( - &integration, - &first.request(&checkout, "main"), - &mut warnings, - ) + .ensure(&integration, &first.request(&checkout, "main")) .expect("initial external checkout should succeed"); fs::write(checkout.join("state.txt"), "external tracked WIP\n") .expect("external checkout should be dirtied"); @@ -824,7 +724,6 @@ fn external_checkout_rejects_a_repository_mismatch_without_touching_wip() { .ensure( &integration, &CheckoutRequest::controlplane(&checkout, second.origin.as_os_str(), "main"), - &mut warnings, ) .expect_err("external checkout origin mismatch must fail without mutation"); @@ -865,10 +764,9 @@ fn real_external_git_worktree_is_reused_and_preserves_wip() { .expect("untracked worktree file should be created"); let request = fixture.request(&worktree, "local-worktree"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect("an existing Git worktree should be fetched without cloning over it"); assert_eq!( @@ -876,7 +774,6 @@ fn real_external_git_worktree_is_reused_and_preserves_wip() { "worktree tracked WIP\n" ); assert!(worktree.join("untracked.txt").is_file()); - assert!(warnings.is_empty()); } #[test] @@ -888,10 +785,9 @@ fn real_tag_checkout_uses_plain_checkout_and_detaches_head() { let checkout = integration.join("tagged-controlplane"); let request = fixture.request(&checkout, "v1.0.0"); let manager = CheckoutManager::new(&SystemProcessRunner); - let mut warnings = Vec::new(); manager - .ensure(&integration, &request, &mut warnings) + .ensure(&integration, &request) .expect("tag checkout should succeed"); let status = Command::new("git") @@ -909,7 +805,6 @@ fn real_tag_checkout_uses_plain_checkout_and_detaches_head() { git_stdout(&checkout, ["rev-parse", "HEAD"]), git_stdout(&fixture.seed, ["rev-parse", "v1.0.0"]) ); - assert!(warnings.is_empty()); } fn path_text(path: &Path) -> &str { diff --git a/crates/platform/src/compose.rs b/src/infrastructure/compose.rs similarity index 77% rename from crates/platform/src/compose.rs rename to src/infrastructure/compose.rs index 8615ac0..b346339 100644 --- a/crates/platform/src/compose.rs +++ b/src/infrastructure/compose.rs @@ -6,17 +6,15 @@ use std::path::{Path, PathBuf}; use serde_json::Value; -use crate::process::CommandSpec; +use crate::infrastructure::process::CommandSpec; -const LEGACY_IMAGE_PREFIXES: &[&str] = &[ +const LEGACY_FAST_TIME_IMAGE_PREFIXES: &[&str] = &[ "ghcr.io/ibm/fast-time-server:", "ghcr.io/ibm/fast-time-server@", - "mcpgateway/fast-test-server:", - "mcpgateway/fast-test-server@", ]; /// Compose service keys and their public container display names. -pub const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ +pub(crate) const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ ("gateway", "cf-controlplane"), ("migration", "cf-migration"), ("register_fast_time", "cf-register-fast-time"), @@ -29,8 +27,6 @@ pub const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ ("locust", "cf-locust"), ("locust_worker", "cf-locust-worker"), ("locust_token", "cf-locust-token"), - ("fast_test_server", "cf-fast-test-server"), - ("register_fast_test", "cf-register-fast-test"), ("a2a_echo_agent", "cf-a2a-echo-agent"), ("a2a_echo_agent_v0_3_0", "cf-a2a-echo-agent-v0-3-0"), ("register_a2a_echo", "cf-register-a2a-echo"), @@ -42,7 +38,7 @@ pub const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ /// Immutable Compose project invocation. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ComposeProject { +pub(crate) struct ComposeProject { project_name: OsString, files: Vec, profiles: Vec, @@ -51,7 +47,7 @@ pub struct ComposeProject { impl ComposeProject { /// Builds the control-plane plus dataplane overlay project. #[must_use] - pub fn dataplane( + pub(crate) fn dataplane( repository_root: &Path, controlplane_checkout: &Path, project_name: OsString, @@ -59,12 +55,22 @@ impl ComposeProject { ) -> Self { let mut files = vec![ controlplane_checkout.join("docker-compose.yml"), - repository_root.join("docker/docker-compose.cf-controlplane-build-labels.yaml"), - repository_root.join("docker/docker-compose.cf-dataplane.yaml"), - repository_root.join("docker/docker-compose.cf-integration.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-controlplane-build-labels.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-dataplane.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-integration.yaml"), ]; if build_dataplane { - files.push(repository_root.join("docker/docker-compose.cf-dataplane-build.yaml")); + files.push( + repository_root + .join("docker") + .join("docker-compose.cf-dataplane-build.yaml"), + ); } Self { project_name, @@ -75,7 +81,7 @@ impl ComposeProject { /// Builds the stock control-plane-only project. #[must_use] - pub fn controlplane( + pub(crate) fn controlplane( repository_root: &Path, controlplane_checkout: &Path, project_name: OsString, @@ -89,7 +95,9 @@ impl ComposeProject { project_name, files: vec![ controlplane_checkout.join("docker-compose.yml"), - repository_root.join("docker/docker-compose.cf-controlplane-build-labels.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-controlplane-build-labels.yaml"), ], profiles, } @@ -97,19 +105,21 @@ impl ComposeProject { /// Ordered Compose override files. #[must_use] - pub fn files(&self) -> &[PathBuf] { + #[cfg(test)] + pub(crate) fn files(&self) -> &[PathBuf] { &self.files } /// Explicitly enabled Compose profiles. #[must_use] - pub fn profiles(&self) -> &[OsString] { + #[cfg(test)] + pub(crate) fn profiles(&self) -> &[OsString] { &self.profiles } /// Replaces the enabled profile set, primarily for exhaustive cleanup. #[must_use] - pub fn with_profiles(mut self, profiles: I) -> Self + pub(crate) fn with_profiles(mut self, profiles: I) -> Self where I: IntoIterator, S: Into, @@ -124,8 +134,10 @@ impl ComposeProject { /// by the overlay start with the required configuration before the /// profile-gated fixture itself is launched. #[must_use] - pub fn with_conformance_overlay(mut self, repository_root: &Path) -> Self { - let overlay = repository_root.join("docker/docker-compose.cf-conformance.yaml"); + pub(crate) fn with_conformance_overlay(mut self, repository_root: &Path) -> Self { + let overlay = repository_root + .join("docker") + .join("docker-compose.cf-conformance.yaml"); if !self.files.contains(&overlay) { self.files.push(overlay); } @@ -134,8 +146,10 @@ impl ComposeProject { /// Applies the control-plane runtime settings used by conformance runs. #[must_use] - pub fn with_conformance_runtime(mut self, repository_root: &Path) -> Self { - let overlay = repository_root.join("docker/docker-compose.cf-conformance-runtime.yaml"); + pub(crate) fn with_conformance_runtime(mut self, repository_root: &Path) -> Self { + let overlay = repository_root + .join("docker") + .join("docker-compose.cf-conformance-runtime.yaml"); if !self.files.contains(&overlay) { self.files.push(overlay); } @@ -144,7 +158,7 @@ impl ComposeProject { /// Enables the isolated official MCP conformance server fixture. #[must_use] - pub fn with_conformance_fixture(self, repository_root: &Path) -> Self { + pub(crate) fn with_conformance_fixture(self, repository_root: &Path) -> Self { let mut project = self.with_conformance_overlay(repository_root); let profile = OsString::from("conformance"); @@ -156,7 +170,7 @@ impl ComposeProject { } /// Creates a `docker compose` command with project, files, and profiles. - pub fn command(&self, arguments: I) -> CommandSpec + pub(crate) fn command(&self, arguments: I) -> CommandSpec where I: IntoIterator, S: Into, @@ -177,7 +191,7 @@ impl ComposeProject { /// One deterministic integration Compose contract violation. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ContractViolation(String); +pub(crate) struct ContractViolation(String); impl fmt::Display for ContractViolation { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -190,7 +204,7 @@ impl fmt::Display for ContractViolation { /// The order of returned violations is stable and intended for operator-facing /// diagnostics and regression tests. #[must_use] -pub fn validate_integration_contract( +pub(crate) fn validate_integration_contract( rendered: &Value, expected_fast_time_image: &str, ) -> Vec { @@ -229,21 +243,6 @@ pub fn validate_integration_contract( } } - for service_name in ["fast_test_server", "register_fast_test"] { - let Some(service) = services.get(service_name) else { - continue; - }; - let has_profile = service - .get("profiles") - .and_then(Value::as_array) - .is_some_and(|profiles| !profiles.is_empty()); - if !has_profile { - violations.push(violation(format!( - "{service_name} is active in the base integration stack; keep fast-test behind an explicit profile" - ))); - } - } - let mut service_names = services.keys().collect::>(); service_names.sort_unstable(); for service_name in service_names { @@ -251,12 +250,12 @@ pub fn validate_integration_contract( .get("image") .and_then(Value::as_str) .unwrap_or(""); - if LEGACY_IMAGE_PREFIXES + if LEGACY_FAST_TIME_IMAGE_PREFIXES .iter() .any(|prefix| image.starts_with(prefix)) { violations.push(violation(format!( - "{service_name} uses legacy fast-test/time image {image:?}" + "{service_name} uses legacy Fast Time image {image:?}" ))); } } diff --git a/crates/platform/tests/compose.rs b/src/infrastructure/compose_integration_tests.rs similarity index 79% rename from crates/platform/tests/compose.rs rename to src/infrastructure/compose_integration_tests.rs index 8d5d6f2..698edde 100644 --- a/crates/platform/tests/compose.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -3,13 +3,10 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -use cf_integration_platform::compose::{ComposeProject, validate_integration_contract}; +use cf_integration::infrastructure::compose::{ComposeProject, validate_integration_contract}; fn workspace_root() -> &'static Path { Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("platform crate should be nested under crates") } use serde_json::json; @@ -28,15 +25,6 @@ fn valid_config() -> serde_json::Value { "wait for http://fast_time_server:9080/health", "register http://fast_time_server:9080/mcp" ] - }, - "fast_test_server": { - "image": "example/modern:latest", - "labels": {"name": "cf-fast-test-server"}, - "profiles": ["testing"] - }, - "register_fast_test": { - "labels": {"name": "cf-register-fast-test"}, - "profiles": ["testing"] } } }) @@ -64,66 +52,45 @@ fn run_fixture_patch(source: &str) -> (std::process::ExitStatus, String) { (output.status, contents) } -#[test] -fn readme_documents_the_official_conformance_fixture_contract() { - let readme = fs::read_to_string(workspace_root().join("README.md")).expect("read README"); - let normalized = readme.split_whitespace().collect::>().join(" "); - - for fact in [ - "official TypeScript fixture", - "Fast Time remains", - "runs fixture-direct, built-in-data-plane, and external-data-plane lanes", - "c321dd32035556e6769d3724a8ee97d87c3faaac", - "defaults to MCP `2026-07-28`", - "loopback `MCP_CLI_BASE_URL`", - "passes an empty expected-failure file", - "records raw failures without suppression", - "same official fixture", - "--server-era dual", - "fixture-direct lane is the expected incompatible baseline", - ] { - assert!(normalized.contains(fact), "README must document: {fact}"); - } -} - #[test] fn dataplane_compose_files_are_in_override_order() { - let project = ComposeProject::dataplane( - Path::new("/repo"), - Path::new("/checkout"), - OsString::from("cf"), - false, - ); - - assert_eq!( - project.files(), - [ - PathBuf::from("/checkout/docker-compose.yml"), - PathBuf::from("/repo/docker/docker-compose.cf-controlplane-build-labels.yaml"), - PathBuf::from("/repo/docker/docker-compose.cf-dataplane.yaml"), - PathBuf::from("/repo/docker/docker-compose.cf-integration.yaml"), - ] - ); + let repository_root = PathBuf::from("repo"); + let checkout = PathBuf::from("checkout"); + let project = + ComposeProject::dataplane(&repository_root, &checkout, OsString::from("cf"), false); + let expected_files = [ + checkout.join("docker-compose.yml"), + repository_root + .join("docker") + .join("docker-compose.cf-controlplane-build-labels.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-dataplane.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-integration.yaml"), + ]; + + assert_eq!(project.files(), expected_files); assert!(project.profiles().is_empty()); assert_eq!( project.command(["config", "--format", "json"]).arguments(), [ - "compose", - "-p", - "cf", - "-f", - "/checkout/docker-compose.yml", - "-f", - "/repo/docker/docker-compose.cf-controlplane-build-labels.yaml", - "-f", - "/repo/docker/docker-compose.cf-dataplane.yaml", - "-f", - "/repo/docker/docker-compose.cf-integration.yaml", - "config", - "--format", - "json", + OsString::from("compose"), + OsString::from("-p"), + OsString::from("cf"), + OsString::from("-f"), + expected_files[0].as_os_str().to_owned(), + OsString::from("-f"), + expected_files[1].as_os_str().to_owned(), + OsString::from("-f"), + expected_files[2].as_os_str().to_owned(), + OsString::from("-f"), + expected_files[3].as_os_str().to_owned(), + OsString::from("config"), + OsString::from("--format"), + OsString::from("json"), ] - .map(OsString::from) ); } @@ -133,8 +100,8 @@ fn conformance_runtime_matches_the_python_builtin_image_contract() { let compose = fs::read_to_string(root.join("docker/docker-compose.cf-conformance-runtime.yaml")) .expect("read conformance runtime overlay"); - let compose: serde_yaml::Value = - serde_yaml::from_str(&compose).expect("parse conformance runtime overlay"); + let compose: yaml_serde::Value = + yaml_serde::from_str(&compose).expect("parse conformance runtime overlay"); let gateway = &compose["services"]["gateway"]; assert_eq!(gateway["environment"]["GUNICORN_WORKERS"], "1"); @@ -173,12 +140,12 @@ fn shared_metadata_overlay_clears_obsolete_fast_time_arguments() { workspace_root().join("docker/docker-compose.cf-controlplane-build-labels.yaml"), ) .expect("read shared control-plane metadata overlay"); - let overlay: serde_yaml::Value = - serde_yaml::from_str(&compose).expect("parse shared control-plane metadata overlay"); + let overlay: yaml_serde::Value = + yaml_serde::from_str(&compose).expect("parse shared control-plane metadata overlay"); assert_eq!( overlay["services"]["fast_time_server"]["command"], - serde_yaml::Value::Sequence(Vec::new()) + yaml_serde::Value::Sequence(Vec::new()) ); let gateway_environment = overlay["services"]["gateway"]["environment"] .as_mapping() @@ -191,7 +158,7 @@ fn shared_metadata_overlay_clears_obsolete_fast_time_arguments() { "REQUIRE_PASSWORD_CHANGE_FOR_DEFAULT_PASSWORD", ] { assert!( - gateway_environment.contains_key(serde_yaml::Value::String(key.to_owned())), + gateway_environment.contains_key(yaml_serde::Value::String(key.to_owned())), "shared gateway environment must define {key}" ); } @@ -203,18 +170,18 @@ fn compose_overlays_assign_short_container_display_names() { workspace_root().join("docker/docker-compose.cf-controlplane-build-labels.yaml"), ) .expect("read shared control-plane metadata overlay"); - let shared: serde_yaml::Value = - serde_yaml::from_str(&shared).expect("parse shared control-plane metadata overlay"); + let shared: yaml_serde::Value = + yaml_serde::from_str(&shared).expect("parse shared control-plane metadata overlay"); let dataplane = fs::read_to_string(workspace_root().join("docker/docker-compose.cf-dataplane.yaml")) .expect("read dataplane Compose overlay"); - let dataplane: serde_yaml::Value = - serde_yaml::from_str(&dataplane).expect("parse dataplane Compose overlay"); + let dataplane: yaml_serde::Value = + yaml_serde::from_str(&dataplane).expect("parse dataplane Compose overlay"); let conformance = fs::read_to_string(workspace_root().join("docker/docker-compose.cf-conformance.yaml")) .expect("read conformance Compose overlay"); - let conformance: serde_yaml::Value = - serde_yaml::from_str(&conformance).expect("parse conformance Compose overlay"); + let conformance: yaml_serde::Value = + yaml_serde::from_str(&conformance).expect("parse conformance Compose overlay"); for (service, expected_name) in [ ("gateway", "cf-controlplane"), @@ -228,8 +195,6 @@ fn compose_overlays_assign_short_container_display_names() { ("locust", "cf-locust"), ("locust_worker", "cf-locust-worker"), ("locust_token", "cf-locust-token"), - ("fast_test_server", "cf-fast-test-server"), - ("register_fast_test", "cf-register-fast-test"), ("a2a_echo_agent", "cf-a2a-echo-agent"), ("a2a_echo_agent_v0_3_0", "cf-a2a-echo-agent-v0-3-0"), ("register_a2a_echo", "cf-register-a2a-echo"), @@ -261,8 +226,8 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { let root = workspace_root(); let compose = fs::read_to_string(root.join("docker/docker-compose.cf-dataplane.yaml")) .expect("read dataplane Compose overlay"); - let compose: serde_yaml::Value = - serde_yaml::from_str(&compose).expect("parse dataplane Compose overlay"); + let compose: yaml_serde::Value = + yaml_serde::from_str(&compose).expect("parse dataplane Compose overlay"); let environment = compose["services"]["dataplane"]["environment"] .as_mapping() .expect("dataplane environment must be a mapping"); @@ -280,12 +245,12 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { "CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS", ] { assert!( - environment.contains_key(serde_yaml::Value::String(key.to_owned())), + environment.contains_key(yaml_serde::Value::String(key.to_owned())), "dataplane environment must define {key}" ); } assert_eq!( - environment[serde_yaml::Value::String( + environment[yaml_serde::Value::String( "CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY".to_owned() )] .as_str(), @@ -294,12 +259,17 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { ); assert!( environment - [serde_yaml::Value::String("CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS".to_owned())] + [yaml_serde::Value::String("CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS".to_owned())] .as_str() .expect("MCP Host allowlist must be text") .contains(",nginx}"), "the default MCP Host allowlist must accept containerized Locust through nginx" ); + assert_eq!( + compose["services"]["dataplane"]["extra_hosts"][0].as_str(), + Some("host.docker.internal:host-gateway"), + "client conformance must let the dataplane reach the official scenario server" + ); for obsolete in [ "CONTEXTFORGE_GATEWAY_RS_ADDRESS", "CONTEXTFORGE_GATEWAY_RS_REDIS_HOSTNAME", @@ -308,15 +278,15 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { "CONTEXTFORGE_GATEWAY_RS_USER_CONFIG_CACHE_EXPIRY_SECONDS", ] { assert!( - !environment.contains_key(serde_yaml::Value::String(obsolete.to_owned())), + !environment.contains_key(yaml_serde::Value::String(obsolete.to_owned())), "obsolete dataplane environment key must be absent: {obsolete}" ); } let build = fs::read_to_string(root.join("docker/docker-compose.cf-dataplane-build.yaml")) .expect("read dataplane build overlay"); - let build: serde_yaml::Value = - serde_yaml::from_str(&build).expect("parse dataplane build overlay"); + let build: yaml_serde::Value = + yaml_serde::from_str(&build).expect("parse dataplane build overlay"); assert_eq!( build["services"]["dataplane"]["build"]["dockerfile"].as_str(), Some("docker/Dockerfile") @@ -436,14 +406,16 @@ fn conformance_container_inputs_pin_the_runner_revision_and_protocol_fixture() { assert!(patch.contains("isModernEraRequest")); assert!(patch.contains("UnsupportedProtocolVersionError")); - let actual_compose: serde_yaml::Value = - serde_yaml::from_str(&compose).expect("parse conformance Compose overlay"); - let expected_compose: serde_yaml::Value = serde_yaml::from_str( + let actual_compose: yaml_serde::Value = + yaml_serde::from_str(&compose).expect("parse conformance Compose overlay"); + let expected_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: profiles: ["conformance"] image: cf-integration/mcp-conformance-server:0.2.0-alpha.11 @@ -455,7 +427,7 @@ services: restart: "no" environment: PORT: "3000" - MCP_CONFORMANCE_SERVER_ERA: ${CF_CONFORMANCE_SERVER_ERA:-dual} + MCP_CONFORMANCE_SERVER_ERA: ${CF_CONFORMANCE_SERVER_ERA:?Set CF_CONFORMANCE_SERVER_ERA to legacy, modern, or dual} ports: - "127.0.0.1:${CF_CONFORMANCE_PORT:-0}:3000" networks: @@ -515,7 +487,8 @@ fn conformance_fixture_patch_is_fail_closed_and_adds_server_era_routing() { let (status, patched) = run_fixture_patch(&source); assert!(status.success()); assert!(patched.contains(replacement)); - assert!(patched.contains("process.env.MCP_CONFORMANCE_SERVER_ERA ?? 'dual'")); + assert!(patched.contains("process.env.MCP_CONFORMANCE_SERVER_ERA;")); + assert!(!patched.contains("?? 'dual'")); assert!(patched.contains("CONFORMANCE_SERVER_ERA === 'legacy' && isModernEraRequest")); assert!(patched.contains("CONFORMANCE_SERVER_ERA === 'modern'")); assert!(!patched.contains(old)); @@ -599,24 +572,6 @@ fn contract_reports_incorrect_container_display_names() { ); } -#[test] -fn fast_test_services_must_stay_behind_profiles() { - let mut config = valid_config(); - config["services"]["fast_test_server"] - .as_object_mut() - .expect("service object") - .remove("profiles"); - config["services"]["register_fast_test"]["profiles"] = json!([]); - - assert_eq!( - messages(&config), - [ - "fast_test_server is active in the base integration stack; keep fast-test behind an explicit profile", - "register_fast_test is active in the base integration stack; keep fast-test behind an explicit profile", - ] - ); -} - #[test] fn every_legacy_image_prefix_is_rejected_in_sorted_service_order() { let mut config = valid_config(); @@ -627,14 +582,14 @@ fn every_legacy_image_prefix_is_rejected_in_sorted_service_order() { ); services.insert( "alpha".to_owned(), - json!({"image": "mcpgateway/fast-test-server@sha256:abc"}), + json!({"image": "ghcr.io/ibm/fast-time-server@sha256:abc"}), ); assert_eq!( messages(&config), [ - "alpha uses legacy fast-test/time image \"mcpgateway/fast-test-server@sha256:abc\"", - "zulu uses legacy fast-test/time image \"ghcr.io/ibm/fast-time-server:old\"", + "alpha uses legacy Fast Time image \"ghcr.io/ibm/fast-time-server@sha256:abc\"", + "zulu uses legacy Fast Time image \"ghcr.io/ibm/fast-time-server:old\"", ] ); } @@ -673,9 +628,9 @@ fn multiple_violations_have_stable_contract_order() { "command": [], "labels": {"name": "cf-register-fast-time"} }, - "fast_test_server": { + "legacy_time": { "image": "ghcr.io/ibm/fast-time-server:old", - "labels": {"name": "cf-fast-test-server"} + "labels": {"name": "cf-legacy-time"} } } }); @@ -684,8 +639,7 @@ fn multiple_violations_have_stable_contract_order() { messages(&config), [ "fast_time_server is missing from the integration compose config", - "fast_test_server is active in the base integration stack; keep fast-test behind an explicit profile", - "fast_test_server uses legacy fast-test/time image \"ghcr.io/ibm/fast-time-server:old\"", + "legacy_time uses legacy Fast Time image \"ghcr.io/ibm/fast-time-server:old\"", "register_fast_time does not wait for fast_time_server on port 9080", "register_fast_time does not register the streamable HTTP endpoint at /mcp", ] diff --git a/crates/platform/src/config.rs b/src/infrastructure/config.rs similarity index 75% rename from crates/platform/src/config.rs rename to src/infrastructure/config.rs index 813491c..3a9d22c 100644 --- a/crates/platform/src/config.rs +++ b/src/infrastructure/config.rs @@ -11,17 +11,18 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use uuid::Uuid; +use crate::infrastructure::assets::{contains_runtime_assets, materialize_runtime_assets}; + const ROOT_OVERRIDE: &str = "CF_INTEGRATION_ROOT"; -const COMPOSE_FILE: &str = "docker/docker-compose.cf-integration.yaml"; const LOCAL_SECRETS_FILE: &str = "secrets.env"; const REDACTED: &str = ""; /// Environment values supplied without mutating the process environment. -pub type Environment = HashMap; +pub(crate) type Environment = HashMap; /// Source used for a loaded configuration value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ValueOrigin { +pub(crate) enum ValueOrigin { /// Value supplied by the process environment. Process, /// Value loaded from the repository `.env` file. @@ -32,53 +33,53 @@ pub enum ValueOrigin { /// Environment value paired with its source. #[derive(Clone, PartialEq, Eq)] -pub struct SourcedValue { +pub(crate) struct SourcedValue { /// Raw environment value. - pub value: OsString, + pub(crate) value: OsString, /// Source that supplied the value. - pub origin: ValueOrigin, + pub(crate) origin: ValueOrigin, } /// Environment values loaded from the process and optional `.env` file. #[derive(Clone, PartialEq, Eq)] -pub struct LoadedEnvironment { +pub(crate) struct LoadedEnvironment { values: HashMap, warnings: Vec, } -/// Resolved container image and whether the process explicitly overrode it. +/// Resolved container image and whether it is prebuilt. #[derive(Clone, PartialEq, Eq)] -pub struct ImageSetting { +pub(crate) struct ImageSetting { resolved: OsString, - explicitly_set: bool, prebuilt: bool, + tracks_main_revision: bool, } impl ImageSetting { /// Returns the image selected after applying shell-compatible fallbacks. #[must_use] - pub fn resolved(&self) -> &OsStr { + pub(crate) fn resolved(&self) -> &OsStr { &self.resolved } - /// Returns whether the process supplied the image override, including empty values. + /// Returns whether the selected image should be pulled instead of auto-built. #[must_use] - pub fn is_explicitly_set(&self) -> bool { - self.explicitly_set + pub(crate) fn is_prebuilt(&self) -> bool { + self.prebuilt } - /// Returns whether the selected image should be pulled instead of auto-built. + /// Returns whether the image tag must be derived from the fetched main branch. #[must_use] - pub fn is_prebuilt(&self) -> bool { - self.prebuilt + pub(crate) fn tracks_main_revision(&self) -> bool { + self.tracks_main_revision } } /// Derived configuration used by integration commands. #[derive(Clone)] -#[allow(dead_code)] // Retained for same-crate command modules added in later migration tasks. -pub struct AppConfig { - root: PathBuf, +pub(crate) struct AppConfig { + workspace_root: PathBuf, + asset_root: PathBuf, integration_dir: SourcedValue, controlplane_dir: SourcedValue, pub(crate) controlplane_repo: SourcedValue, @@ -106,13 +107,25 @@ pub struct AppConfig { environment: LoadedEnvironment, } -/// Configuration plus non-fatal environment loading warnings. +/// Environment and workspace paths loaded before resolving an action. #[derive(Debug, Clone)] -pub struct ConfigLoad { - /// Fully derived application configuration. - pub config: AppConfig, - /// Non-fatal `.env` parsing warnings. - pub warnings: Vec, +pub(crate) struct ConfigBootstrap { + workspace_root: PathBuf, + root_overridden: bool, + environment: LoadedEnvironment, +} + +/// 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 }; } impl fmt::Debug for SourcedValue { @@ -140,8 +153,8 @@ impl fmt::Debug for ImageSetting { formatter .debug_struct("ImageSetting") .field("resolved", &REDACTED) - .field("explicitly_set", &self.explicitly_set) .field("prebuilt", &self.prebuilt) + .field("tracks_main_revision", &self.tracks_main_revision) .finish() } } @@ -150,7 +163,8 @@ impl fmt::Debug for AppConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("AppConfig") - .field("root", &REDACTED) + .field("workspace_root", &REDACTED) + .field("asset_root", &REDACTED) .field("controlplane_image", &self.controlplane_image) .field("dataplane_image", &self.dataplane_image) .field("environment", &self.environment) @@ -161,32 +175,65 @@ impl fmt::Debug for AppConfig { impl LoadedEnvironment { /// Returns the loaded value for `key`. #[must_use] - pub fn get(&self, key: &OsStr) -> Option<&SourcedValue> { + pub(crate) fn get(&self, key: &OsStr) -> Option<&SourcedValue> { self.values.get(key) } /// Returns non-fatal `.env` parsing warnings. #[must_use] - pub fn warnings(&self) -> &[String] { + pub(crate) fn warnings(&self) -> &[String] { &self.warnings } /// Iterates loaded values and their origins in unspecified order. - pub fn iter(&self) -> impl Iterator { + pub(crate) fn iter(&self) -> impl Iterator { self.values.iter() } } +impl ConfigBootstrap { + /// Loads process values and an optional workspace `.env` without writing files. + pub(crate) fn load(process: &Environment, cwd: &Path) -> Result { + let override_value = process + .get(OsStr::new(ROOT_OVERRIDE)) + .filter(|value| !value.is_empty()); + let workspace_root = override_value + .map(|value| absolute_path(cwd, value)) + .unwrap_or_else(|| cwd.to_path_buf()); + let environment = load_environment(&workspace_root, process)?; + Ok(Self { + workspace_root, + root_overridden: override_value.is_some(), + environment, + }) + } + + /// Returns the merged process and dotenv environment. + #[must_use] + pub(crate) fn environment(&self) -> &LoadedEnvironment { + &self.environment + } + + /// Returns non-fatal dotenv parsing warnings. + #[must_use] + pub(crate) fn warnings(&self) -> &[String] { + self.environment.warnings() + } +} + impl AppConfig { - /// Loads the environment and derives configuration without global mutation. + /// Derives action-specific configuration from a side-effect-free bootstrap. /// /// # Errors /// - /// Returns an error when the repository root cannot be resolved or its - /// existing `.env` file cannot be read. - pub fn load(process: &Environment, executable: &Path, cwd: &Path) -> Result { - let root = resolve_repository_root(process, executable, cwd)?; - let environment = load_environment(&root, process)?; + /// Returns an error when required runtime assets or local secrets cannot be + /// prepared. + pub(crate) fn load( + bootstrap: ConfigBootstrap, + requirements: ConfigRequirements, + ) -> Result { + let root = bootstrap.workspace_root; + let environment = bootstrap.environment; let integration_dir = resolved_path( &root, @@ -196,6 +243,20 @@ impl AppConfig { root.join(".integration").into_os_string(), ), ); + let asset_root = if requirements.runtime { + if contains_runtime_assets(&root) { + root.clone() + } else if bootstrap.root_overridden { + bail!( + "{ROOT_OVERRIDE}={} does not contain the complete runtime asset set", + root.display() + ); + } else { + materialize_runtime_assets(Path::new(&integration_dir.value))? + } + } else { + root.clone() + }; let controlplane_dir = resolved_path( &root, shell_value( @@ -221,11 +282,8 @@ impl AppConfig { "CF_CONTROLPLANE_REPO", OsString::from("https://github.com/IBM/mcp-context-forge.git"), ); - let controlplane_ref = shell_value( - &environment, - "CF_CONTROLPLANE_REF", - OsString::from("v1.0.7"), - ); + let controlplane_ref = + shell_value(&environment, "CF_CONTROLPLANE_REF", OsString::from("main")); let dataplane_repo = shell_value( &environment, "CF_DATAPLANE_REPO", @@ -239,8 +297,9 @@ impl AppConfig { "CF_CONTROLPLANE_PROJECT", OsString::from("cf-controlplane-only"), ); - let local_secrets = if first_nonempty(&environment, "JWT_SECRET_KEY").is_none() - || first_nonempty(&environment, "AUTH_ENCRYPTION_SECRET").is_none() + let local_secrets = if requirements.runtime + && (first_nonempty(&environment, "JWT_SECRET_KEY").is_none() + || first_nonempty(&environment, "AUTH_ENCRYPTION_SECRET").is_none()) { Some(load_or_create_local_secrets(Path::new( &integration_dir.value, @@ -250,6 +309,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 => default_value( &local_secrets .as_ref() @@ -259,6 +319,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 => default_value( &local_secrets .as_ref() @@ -279,10 +340,11 @@ impl AppConfig { "CF_FAST_TIME_SERVER_ID", OsString::from("9779b6698cbd4b4995ee04a4fab38737"), ); - let fast_time_expected_image = first_nonempty(&environment, "CF_FAST_TIME_EXPECTED_IMAGE") - .or_else(|| first_nonempty(&environment, "FAST_TIME_IMAGE")) - .cloned() - .unwrap_or_else(|| default_value("ghcr.io/ibm/cfex-mcp-fast-time-server:latest")); + let fast_time_expected_image = shell_value( + &environment, + "CF_FAST_TIME_EXPECTED_IMAGE", + OsString::from("ghcr.io/ibm/cfex-mcp-fast-time-server:latest"), + ); let base_url = base_url(&environment); let platform_admin_email = shell_value( &environment, @@ -298,194 +360,196 @@ impl AppConfig { let locust_users = present_value(&environment, "LOCUST_USERS", "100"); let locust_spawn_rate = present_value(&environment, "LOCUST_SPAWN_RATE", "10"); let locust_run_time = present_value(&environment, "LOCUST_RUN_TIME", "5m"); - let warnings = environment.warnings.clone(); - - Ok(ConfigLoad { - config: Self { - root, - integration_dir, - controlplane_dir, - controlplane_repo, - controlplane_ref, - dataplane_dir, - dataplane_repo, - dataplane_ref, - integration_project, - controlplane_project, - jwt_secret_key, - auth_encryption_secret, - controlplane_image, - dataplane_image, - dataplane_platform, - compose_build, - fast_time_server_id, - fast_time_expected_image, - base_url, - platform_admin_email, - platform_admin_password, - key_file_password, - locust_users, - locust_spawn_rate, - locust_run_time, - environment, - }, - warnings, + Ok(Self { + workspace_root: root, + asset_root, + integration_dir, + controlplane_dir, + controlplane_repo, + controlplane_ref, + dataplane_dir, + dataplane_repo, + dataplane_ref, + integration_project, + controlplane_project, + jwt_secret_key, + auth_encryption_secret, + controlplane_image, + dataplane_image, + dataplane_platform, + compose_build, + fast_time_server_id, + fast_time_expected_image, + base_url, + platform_admin_email, + platform_admin_password, + key_file_password, + locust_users, + locust_spawn_rate, + locust_run_time, + environment, }) } - /// Returns the resolved integration repository root. + /// Returns the directory used for dotenv, reports, and relative overrides. + #[must_use] + pub(crate) fn root(&self) -> &Path { + &self.workspace_root + } + + /// Returns the root containing Compose overlays and runtime scripts. #[must_use] - pub fn root(&self) -> &Path { - &self.root + pub(crate) fn asset_root(&self) -> &Path { + &self.asset_root } /// Returns the resolved integration runtime directory. #[must_use] - pub fn integration_dir(&self) -> &Path { + pub(crate) fn integration_dir(&self) -> &Path { Path::new(&self.integration_dir.value) } /// Returns the resolved control-plane checkout directory. #[must_use] - pub fn controlplane_dir(&self) -> &Path { + pub(crate) fn controlplane_dir(&self) -> &Path { Path::new(&self.controlplane_dir.value) } /// Returns the resolved dataplane checkout directory. #[must_use] - pub fn dataplane_dir(&self) -> &Path { + pub(crate) fn dataplane_dir(&self) -> &Path { Path::new(&self.dataplane_dir.value) } /// Returns the configured control-plane repository. #[must_use] - pub fn controlplane_repo(&self) -> &SourcedValue { + pub(crate) fn controlplane_repo(&self) -> &SourcedValue { &self.controlplane_repo } /// Returns the configured control-plane revision. #[must_use] - pub fn controlplane_ref(&self) -> &SourcedValue { + pub(crate) fn controlplane_ref(&self) -> &SourcedValue { &self.controlplane_ref } /// Returns the configured dataplane repository. #[must_use] - pub fn dataplane_repo(&self) -> &SourcedValue { + pub(crate) fn dataplane_repo(&self) -> &SourcedValue { &self.dataplane_repo } /// Returns the configured dataplane revision. #[must_use] - pub fn dataplane_ref(&self) -> &SourcedValue { + pub(crate) fn dataplane_ref(&self) -> &SourcedValue { &self.dataplane_ref } /// Returns the integration Compose project name. #[must_use] - pub fn integration_project(&self) -> &SourcedValue { + pub(crate) fn integration_project(&self) -> &SourcedValue { &self.integration_project } /// Returns the control-plane Compose project name. #[must_use] - pub fn controlplane_project(&self) -> &SourcedValue { + pub(crate) fn controlplane_project(&self) -> &SourcedValue { &self.controlplane_project } /// Returns the JWT signing secret setting. #[must_use] - pub fn jwt_secret_key(&self) -> &SourcedValue { + pub(crate) fn jwt_secret_key(&self) -> &SourcedValue { &self.jwt_secret_key } /// Returns the control-plane credential-encryption secret setting. #[must_use] - pub fn auth_encryption_secret(&self) -> &SourcedValue { + pub(crate) fn auth_encryption_secret(&self) -> &SourcedValue { &self.auth_encryption_secret } /// Returns the resolved control-plane image setting. #[must_use] - pub fn controlplane_image(&self) -> &ImageSetting { + pub(crate) fn controlplane_image(&self) -> &ImageSetting { &self.controlplane_image } /// Returns the resolved dataplane image setting. #[must_use] - pub fn dataplane_image(&self) -> &ImageSetting { + pub(crate) fn dataplane_image(&self) -> &ImageSetting { &self.dataplane_image } /// Returns the configured dataplane container platform. #[must_use] - pub fn dataplane_platform(&self) -> &SourcedValue { + pub(crate) fn dataplane_platform(&self) -> &SourcedValue { &self.dataplane_platform } /// Returns the Compose build-mode setting. #[must_use] - pub fn compose_build(&self) -> &SourcedValue { + pub(crate) fn compose_build(&self) -> &SourcedValue { &self.compose_build } /// Returns the Fast Time server identifier setting. #[must_use] - pub fn fast_time_server_id(&self) -> &SourcedValue { + pub(crate) fn fast_time_server_id(&self) -> &SourcedValue { &self.fast_time_server_id } /// Returns the expected Fast Time image setting. #[must_use] - pub fn fast_time_expected_image(&self) -> &SourcedValue { + pub(crate) fn fast_time_expected_image(&self) -> &SourcedValue { &self.fast_time_expected_image } /// Returns the public integration base URL setting. #[must_use] - pub fn base_url(&self) -> &SourcedValue { + pub(crate) fn base_url(&self) -> &SourcedValue { &self.base_url } /// Returns the platform administrator email setting. #[must_use] - pub fn platform_admin_email(&self) -> &SourcedValue { + pub(crate) fn platform_admin_email(&self) -> &SourcedValue { &self.platform_admin_email } /// Returns the bootstrap platform administrator password setting. #[must_use] - pub fn platform_admin_password(&self) -> &SourcedValue { + pub(crate) fn platform_admin_password(&self) -> &SourcedValue { &self.platform_admin_password } /// Returns the private-key password setting. #[must_use] - pub fn key_file_password(&self) -> &SourcedValue { + pub(crate) fn key_file_password(&self) -> &SourcedValue { &self.key_file_password } /// Returns the Locust user-count setting. #[must_use] - pub fn locust_users(&self) -> &SourcedValue { + pub(crate) fn locust_users(&self) -> &SourcedValue { &self.locust_users } /// Returns the Locust spawn-rate setting. #[must_use] - pub fn locust_spawn_rate(&self) -> &SourcedValue { + pub(crate) fn locust_spawn_rate(&self) -> &SourcedValue { &self.locust_spawn_rate } /// Returns the Locust run-time setting. #[must_use] - pub fn locust_run_time(&self) -> &SourcedValue { + pub(crate) fn locust_run_time(&self) -> &SourcedValue { &self.locust_run_time } /// Returns the environment loaded before deriving fallback values. #[must_use] - pub fn environment(&self) -> &LoadedEnvironment { + pub(crate) fn environment(&self) -> &LoadedEnvironment { &self.environment } } @@ -535,24 +599,26 @@ fn prefixed_value(prefix: &str, suffix: &OsStr) -> OsString { } fn controlplane_image(environment: &LoadedEnvironment) -> ImageSetting { - let explicitly_set = is_configured_value(environment, "CF_CONTROLPLANE_IMAGE") - || is_configured_value(environment, "IMAGE_LOCAL"); - let resolved = first_nonempty(environment, "CF_CONTROLPLANE_IMAGE") - .or_else(|| first_nonempty(environment, "IMAGE_LOCAL")) - .map(|value| value.value.clone()) - .unwrap_or_else(|| { + let (resolved, tracks_main_revision) = + if let Some(image) = first_nonempty(environment, "CF_CONTROLPLANE_IMAGE") { + (image.value.clone(), false) + } else { let version = shell_value( environment, "CF_CONTROLPLANE_VERSION", - OsString::from("latest"), + OsString::from("main"), ); - prefixed_value("ghcr.io/ibm/mcp-context-forge:", &version.value) - }); + let tracks_main_revision = version.value == OsStr::new("main"); + ( + prefixed_value("ghcr.io/ibm/mcp-context-forge:", &version.value), + tracks_main_revision, + ) + }; ImageSetting { resolved, - explicitly_set, prebuilt: true, + tracks_main_revision, } } @@ -581,8 +647,8 @@ fn dataplane_image(environment: &LoadedEnvironment, dataplane_ref: &SourcedValue ImageSetting { resolved, - explicitly_set, prebuilt: explicitly_set || dataplane_ref.value.is_empty(), + tracks_main_revision: false, } } @@ -697,7 +763,7 @@ fn base_url(environment: &LoadedEnvironment) -> SourcedValue { /// # Errors /// /// Returns an error when an existing `.env` file cannot be read. -pub fn load_environment(root: &Path, process: &Environment) -> Result { +pub(crate) fn load_environment(root: &Path, process: &Environment) -> Result { let mut values = process .iter() .map(|(key, value)| { @@ -760,63 +826,9 @@ pub fn load_environment(root: &Path, process: &Environment) -> Result Result { - resolve_repository_root_with_manifest( - process, - executable, - cwd, - Path::new(env!("CARGO_MANIFEST_DIR")), - ) -} - -fn resolve_repository_root_with_manifest( - process: &Environment, - executable: &Path, - cwd: &Path, - manifest_root: &Path, -) -> Result { - if let Some(raw) = process.get(OsStr::new(ROOT_OVERRIDE)) - && !raw.is_empty() - { - let candidate = absolute_path(cwd, raw); - if is_repository_root(&candidate) { - return Ok(candidate); - } - } - - if let Some(candidate) = executable.ancestors().find(|path| is_repository_root(path)) { - return Ok(candidate.to_path_buf()); - } - - if let Some(candidate) = cwd.ancestors().find(|path| is_repository_root(path)) { - return Ok(candidate.to_path_buf()); - } - - if let Some(candidate) = manifest_root - .ancestors() - .find(|path| is_repository_root(path)) - { - return Ok(candidate.to_path_buf()); - } - - bail!( - "failed to resolve cf-integration repository root: no candidate contains Cargo.toml and {COMPOSE_FILE}" - ) -} - /// Returns `raw` unchanged when absolute, otherwise joined to `root`. #[must_use] -pub fn absolute_path(root: &Path, raw: &OsStr) -> PathBuf { +pub(crate) fn absolute_path(root: &Path, raw: &OsStr) -> PathBuf { let path = Path::new(raw); if path.is_absolute() { path.to_path_buf() @@ -845,10 +857,6 @@ fn strip_outer_quotes(value: &str) -> &str { } } -fn is_repository_root(path: &Path) -> bool { - path.join("Cargo.toml").is_file() && path.join(COMPOSE_FILE).is_file() -} - #[cfg(test)] mod tests { use super::*; @@ -861,18 +869,12 @@ mod tests { } fn repository_root() -> tempfile::TempDir { - let root = tempfile::tempdir().expect("temporary repository root should be created"); - fs::write(root.path().join("Cargo.toml"), "[package]\n") - .expect("temporary Cargo manifest should be written"); - fs::create_dir_all(root.path().join("docker")) - .expect("temporary docker directory should be created"); - fs::write(root.path().join(COMPOSE_FILE), "services: {}\n") - .expect("temporary Compose file should be written"); - root - } - - fn load_app_config(root: &Path, process: &Environment) -> ConfigLoad { - AppConfig::load(process, &root.join("target/debug/cf-integration"), root) + tempfile::tempdir().expect("temporary repository root should be created") + } + + fn load_app_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") } @@ -882,32 +884,24 @@ mod tests { } #[test] - fn repository_root_failure_describes_required_markers() { + fn read_only_config_does_not_create_runtime_state() { let outside = tempfile::tempdir().expect("temporary directory should be created"); - let executable = outside.path().join("bin/cf-integration"); - let cwd = outside.path().join("work/nested"); - let invalid_manifest_root = outside.path().join("manifest"); - - let error = resolve_repository_root_with_manifest( - &Environment::new(), - &executable, - &cwd, - &invalid_manifest_root, - ) - .expect_err("invalid candidates should fail root resolution"); + let bootstrap = ConfigBootstrap::load(&Environment::new(), outside.path()) + .expect("bootstrap should load"); + let config = AppConfig::load(bootstrap, ConfigRequirements::READ_ONLY) + .expect("read-only config should resolve"); - let message = error.to_string(); - assert!(message.contains("Cargo.toml")); - assert!(message.contains(COMPOSE_FILE)); + assert_eq!(config.root(), outside.path()); + assert!(!outside.path().join(".integration").exists()); } #[test] fn app_config_uses_documented_defaults() { let root = repository_root(); - let config = load_app_config(root.path(), &Environment::new()).config; + let config = load_app_config(root.path(), &Environment::new()); - assert_eq!(config.root, root.path()); + assert_eq!(config.workspace_root, root.path()); assert_sourced( &config.integration_dir, root.path().join(".integration").as_os_str(), @@ -916,7 +910,8 @@ mod tests { assert_sourced( &config.controlplane_dir, root.path() - .join(".integration/mcp-context-forge") + .join(".integration") + .join("mcp-context-forge") .as_os_str(), ValueOrigin::Default, ); @@ -927,13 +922,14 @@ mod tests { ); assert_sourced( &config.controlplane_ref, - OsStr::new("v1.0.7"), + OsStr::new("main"), ValueOrigin::Default, ); assert_sourced( &config.dataplane_dir, root.path() - .join(".integration/contextforge-data-plane") + .join(".integration") + .join("contextforge-data-plane") .as_os_str(), ValueOrigin::Default, ); @@ -959,15 +955,14 @@ mod tests { assert_eq!(config.auth_encryption_secret.value.len(), 64); assert_eq!( config.controlplane_image.resolved, - OsStr::new("ghcr.io/ibm/mcp-context-forge:latest") + OsStr::new("ghcr.io/ibm/mcp-context-forge:main") ); - assert!(!config.controlplane_image.explicitly_set); assert!(config.controlplane_image.prebuilt); + assert!(config.controlplane_image.tracks_main_revision); assert_eq!( config.dataplane_image.resolved, OsStr::new("ghcr.io/contextforge-org/contextforge-data-plane:latest") ); - assert!(!config.dataplane_image.explicitly_set); assert_sourced( &config.dataplane_platform, OsStr::new("auto"), @@ -1044,7 +1039,7 @@ mod tests { ("CF_CONTROLPLANE_REF", ""), ]); - let config = load_app_config(root.path(), &process).config; + let config = load_app_config(root.path(), &process); assert_sourced( &config.controlplane_repo, @@ -1053,7 +1048,7 @@ mod tests { ); assert_sourced( &config.controlplane_ref, - OsStr::new("v1.0.7"), + OsStr::new("main"), ValueOrigin::Default, ); assert_sourced( @@ -1070,54 +1065,51 @@ mod tests { config.controlplane_image.resolved, OsStr::new("dotenv/image:tag") ); - assert!(config.controlplane_image.explicitly_set); } #[test] - fn controlplane_image_uses_primary_then_image_local_then_official_version_default() { + fn controlplane_image_uses_canonical_override_and_ignores_image_local() { let root = repository_root(); let primary = environment(&[ ("CF_CONTROLPLANE_IMAGE", "primary/image:tag"), ("IMAGE_LOCAL", "legacy/image:tag"), ]); - let image_local = environment(&[("IMAGE_LOCAL", "legacy/image:tag")]); - let empty_image_local = - environment(&[("IMAGE_LOCAL", ""), ("CF_CONTROLPLANE_VERSION", "edge")]); + let image_local = environment(&[ + ("IMAGE_LOCAL", "legacy/image:tag"), + ("CF_CONTROLPLANE_VERSION", "edge"), + ]); - let primary_config = load_app_config(root.path(), &primary).config; - let local_config = load_app_config(root.path(), &image_local).config; - let empty_config = load_app_config(root.path(), &empty_image_local).config; + let primary_config = load_app_config(root.path(), &primary); + let local_config = load_app_config(root.path(), &image_local); assert_eq!( primary_config.controlplane_image.resolved, OsStr::new("primary/image:tag") ); - assert!(primary_config.controlplane_image.explicitly_set); assert_eq!( local_config.controlplane_image.resolved, - OsStr::new("legacy/image:tag") - ); - assert!(local_config.controlplane_image.explicitly_set); - assert_eq!( - empty_config.controlplane_image.resolved, OsStr::new("ghcr.io/ibm/mcp-context-forge:edge") ); - assert!(empty_config.controlplane_image.explicitly_set); - assert!(empty_config.controlplane_image.prebuilt); + assert!(local_config.controlplane_image.prebuilt); } #[test] fn generated_local_secrets_are_stable_across_config_loads() { let root = repository_root(); - let first = load_app_config(root.path(), &Environment::new()).config; - let second = load_app_config(root.path(), &Environment::new()).config; + let first = load_app_config(root.path(), &Environment::new()); + let second = load_app_config(root.path(), &Environment::new()); assert_eq!(first.jwt_secret_key, second.jwt_secret_key); assert_eq!(first.auth_encryption_secret, second.auth_encryption_secret); assert_eq!(first.jwt_secret_key.value.len(), 64); assert_eq!(first.auth_encryption_secret.value.len(), 64); - assert!(root.path().join(".integration/secrets.env").is_file()); + assert!( + root.path() + .join(".integration") + .join("secrets.env") + .is_file() + ); } #[test] @@ -1134,7 +1126,7 @@ mod tests { ), ]); - let config = load_app_config(root.path(), &process).config; + let config = load_app_config(root.path(), &process); assert_sourced( &config.jwt_secret_key, @@ -1146,7 +1138,13 @@ mod tests { OsStr::new("configured-auth-secret-1234567890123456789"), ValueOrigin::Process, ); - assert!(!root.path().join(".integration/secrets.env").exists()); + assert!( + !root + .path() + .join(".integration") + .join("secrets.env") + .exists() + ); } #[test] @@ -1160,10 +1158,10 @@ mod tests { let published = environment(&[("CF_DATAPLANE_VERSION", "2.0.0")]); let explicit = environment(&[("CF_DATAPLANE_IMAGE", "direct/image:tag")]); - let source_config = load_app_config(root.path(), &source).config; - let local_config = load_app_config(root.path(), &local).config; - let published_config = load_app_config(root.path(), &published).config; - let explicit_config = load_app_config(root.path(), &explicit).config; + let source_config = load_app_config(root.path(), &source); + let local_config = load_app_config(root.path(), &local); + let published_config = load_app_config(root.path(), &published); + let explicit_config = load_app_config(root.path(), &explicit); assert_eq!( source_config.dataplane_image.resolved, @@ -1181,22 +1179,19 @@ mod tests { explicit_config.dataplane_image.resolved, OsStr::new("direct/image:tag") ); - assert!(explicit_config.dataplane_image.explicitly_set); } #[test] - fn fast_time_image_uses_new_override_then_legacy_override_then_default() { + fn fast_time_image_uses_canonical_override_and_ignores_legacy_input() { let root = repository_root(); let expected = environment(&[ ("CF_FAST_TIME_EXPECTED_IMAGE", "expected/image:tag"), ("FAST_TIME_IMAGE", "legacy/image:tag"), ]); let legacy = environment(&[("FAST_TIME_IMAGE", "legacy/image:tag")]); - let empty = environment(&[("CF_FAST_TIME_EXPECTED_IMAGE", ""), ("FAST_TIME_IMAGE", "")]); - let expected_config = load_app_config(root.path(), &expected).config; - let legacy_config = load_app_config(root.path(), &legacy).config; - let empty_config = load_app_config(root.path(), &empty).config; + let expected_config = load_app_config(root.path(), &expected); + let legacy_config = load_app_config(root.path(), &legacy); assert_sourced( &expected_config.fast_time_expected_image, @@ -1205,11 +1200,6 @@ mod tests { ); assert_sourced( &legacy_config.fast_time_expected_image, - OsStr::new("legacy/image:tag"), - ValueOrigin::Process, - ); - assert_sourced( - &empty_config.fast_time_expected_image, OsStr::new("ghcr.io/ibm/cfex-mcp-fast-time-server:latest"), ValueOrigin::Default, ); @@ -1229,8 +1219,8 @@ mod tests { ("PLATFORM_ADMIN_PASSWORD", "integration-password"), ]); - let direct_config = load_app_config(root.path(), &direct).config; - let fallback_config = load_app_config(root.path(), &port_and_admin).config; + let direct_config = load_app_config(root.path(), &direct); + let fallback_config = load_app_config(root.path(), &port_and_admin); assert_sourced( &direct_config.base_url, @@ -1261,7 +1251,7 @@ mod tests { .expect("dotenv should be written"); let process = environment(&[("LOCUST_USERS", "")]); - let config = load_app_config(root.path(), &process).config; + let config = load_app_config(root.path(), &process); assert_sourced(&config.locust_users, OsStr::new(""), ValueOrigin::Process); assert_sourced( diff --git a/crates/platform/tests/config.rs b/src/infrastructure/config_integration_tests.rs similarity index 77% rename from crates/platform/tests/config.rs rename to src/infrastructure/config_integration_tests.rs index 0e447e8..215b309 100644 --- a/crates/platform/tests/config.rs +++ b/src/infrastructure/config_integration_tests.rs @@ -1,18 +1,12 @@ use std::ffi::{OsStr, OsString}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; -use cf_integration_platform::config::{ - AppConfig, ConfigLoad, Environment, ValueOrigin, absolute_path, load_environment, - resolve_repository_root, +use cf_integration::infrastructure::config::{ + AppConfig, ConfigBootstrap, ConfigRequirements, Environment, ValueOrigin, absolute_path, + load_environment, }; -fn workspace_root() -> &'static Path { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("platform crate should be nested under crates") -} use tempfile::TempDir; const ROOT_OVERRIDE: &str = "CF_INTEGRATION_ROOT"; @@ -39,15 +33,18 @@ fn repository_root() -> TempDir { root } -fn nested_path(root: &Path, suffix: &str) -> PathBuf { - let path = root.join(suffix); - fs::create_dir_all(&path).expect("nested temporary directory should be created"); - path +#[derive(Debug)] +struct TestConfigLoad { + config: AppConfig, + warnings: Vec, } -fn load_app_config(root: &Path, process: &Environment) -> ConfigLoad { - AppConfig::load(process, &root.join("target/debug/cf-integration"), root) - .expect("application config should load") +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) + .expect("application config should load"); + TestConfigLoad { config, warnings } } #[test] @@ -224,76 +221,48 @@ fn absolute_path_keeps_absolute_values_unchanged() { } #[test] -fn repository_root_prefers_process_override() { +fn workspace_root_prefers_process_override() { let process_root = repository_root(); - let executable_root = repository_root(); let cwd_root = repository_root(); - let executable = executable_root.path().join("bin/cf-integration"); - let cwd = nested_path(cwd_root.path(), "work/nested"); let process = Environment::from([( OsString::from(ROOT_OVERRIDE), process_root.path().as_os_str().to_owned(), )]); - let resolved = resolve_repository_root(&process, &executable, &cwd) - .expect("process repository root should resolve"); - - assert_eq!(resolved, process_root.path()); -} - -#[test] -fn repository_root_uses_executable_ancestor_before_cwd() { - let executable_root = repository_root(); - let cwd_root = repository_root(); - let executable = executable_root.path().join("target/debug/cf-integration"); - let cwd = nested_path(cwd_root.path(), "work/nested"); + let bootstrap = + ConfigBootstrap::load(&process, cwd_root.path()).expect("bootstrap should load"); + let config = AppConfig::load(bootstrap, ConfigRequirements::READ_ONLY) + .expect("read-only config should resolve"); - let resolved = resolve_repository_root(&Environment::new(), &executable, &cwd) - .expect("executable ancestor should resolve"); - - assert_eq!(resolved, executable_root.path()); + assert_eq!(config.root(), process_root.path()); } #[test] -fn repository_root_uses_cwd_ancestor_when_executable_has_no_root() { +fn workspace_root_defaults_to_cwd_without_writing_files() { let outside = tempfile::tempdir().expect("temporary directory should be created"); - let cwd_root = repository_root(); - let executable = outside.path().join("bin/cf-integration"); - let cwd = nested_path(cwd_root.path(), "work/nested"); - - let resolved = resolve_repository_root(&Environment::new(), &executable, &cwd) - .expect("cwd ancestor should resolve"); - - assert_eq!(resolved, cwd_root.path()); -} - -#[test] -fn repository_root_uses_validated_compile_time_manifest_fallback() { - let outside = tempfile::tempdir().expect("temporary directory should be created"); - let executable = outside.path().join("bin/cf-integration"); - let cwd = nested_path(outside.path(), "work/nested"); - - let resolved = resolve_repository_root(&Environment::new(), &executable, &cwd) - .expect("compile-time manifest root should resolve"); + let bootstrap = + ConfigBootstrap::load(&Environment::new(), outside.path()).expect("bootstrap should load"); + let config = AppConfig::load(bootstrap, ConfigRequirements::READ_ONLY) + .expect("read-only config should resolve"); - assert_eq!(resolved, workspace_root()); + assert_eq!(config.root(), outside.path()); + assert!(!outside.path().join(".integration").exists()); } #[test] -fn invalid_process_override_falls_through_to_executable_ancestor() { +fn invalid_explicit_runtime_root_fails_closed() { let invalid_root = tempfile::tempdir().expect("temporary directory should be created"); - let executable_root = repository_root(); - let executable = executable_root.path().join("target/debug/cf-integration"); let outside = tempfile::tempdir().expect("temporary directory should be created"); let process = Environment::from([( OsString::from(ROOT_OVERRIDE), invalid_root.path().as_os_str().to_owned(), )]); - let resolved = resolve_repository_root(&process, &executable, outside.path()) - .expect("invalid process override should fall through"); + let bootstrap = ConfigBootstrap::load(&process, outside.path()).expect("bootstrap should load"); + let error = AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + .expect_err("an explicit root without assets must fail"); - assert_eq!(resolved, executable_root.path()); + assert!(error.to_string().contains(ROOT_OVERRIDE)); } #[test] @@ -332,7 +301,7 @@ fn app_config_exposes_resolved_paths_and_image_settings() { loaded.config.controlplane_image().resolved(), OsStr::new("example/controlplane:test") ); - assert!(loaded.config.controlplane_image().is_explicitly_set()); + assert!(!loaded.config.controlplane_image().tracks_main_revision()); } #[test] @@ -351,13 +320,12 @@ fn explicit_empty_process_images_use_fallbacks_but_remain_explicit() { loaded.config.controlplane_image().resolved(), OsStr::new("ghcr.io/ibm/mcp-context-forge:test") ); - assert!(loaded.config.controlplane_image().is_explicitly_set()); assert!(loaded.config.controlplane_image().is_prebuilt()); + assert!(!loaded.config.controlplane_image().tracks_main_revision()); assert_eq!( loaded.config.dataplane_image().resolved(), OsStr::new("contextforge-org/contextforge-data-plane:local") ); - assert!(loaded.config.dataplane_image().is_explicitly_set()); } #[test] diff --git a/crates/platform/src/error.rs b/src/infrastructure/error.rs similarity index 88% rename from crates/platform/src/error.rs rename to src/infrastructure/error.rs index 3dacde9..abcd4c3 100644 --- a/crates/platform/src/error.rs +++ b/src/infrastructure/error.rs @@ -7,7 +7,7 @@ use std::process::ExitStatus; /// A harness error or a direct child-process failure. #[derive(Debug)] -pub enum PlatformError { +pub(crate) enum InfrastructureError { /// A child process exited unsuccessfully. ChildExit { /// Program that was executed. @@ -19,14 +19,14 @@ pub enum PlatformError { Native(anyhow::Error), } -impl PlatformError { +impl InfrastructureError { pub(crate) fn child_exit(program: OsString, status: ExitStatus) -> Self { Self::ChildExit { program, status } } /// Returns the process exit code represented by this failure. #[must_use] - pub fn exit_code(&self) -> i32 { + pub(crate) fn exit_code(&self) -> i32 { match self { Self::ChildExit { status, .. } => child_exit_code(status), Self::Native(_) => 1, @@ -34,13 +34,13 @@ impl PlatformError { } } -impl From for PlatformError { +impl From for InfrastructureError { fn from(error: anyhow::Error) -> Self { Self::Native(error) } } -impl fmt::Display for PlatformError { +impl fmt::Display for InfrastructureError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::ChildExit { program, status } => { @@ -51,7 +51,7 @@ impl fmt::Display for PlatformError { } } -impl Error for PlatformError { +impl Error for InfrastructureError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::ChildExit { .. } => None, diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..66d4551 --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,13 @@ +//! Source, process, configuration, Compose, and stack primitives. + +pub(crate) mod assets; +pub(crate) mod checkout; +pub(crate) mod compose; +pub(crate) mod config; +pub(crate) mod error; +mod mode; +pub(crate) mod process; +pub(crate) mod stack; + +pub(crate) use error::InfrastructureError; +pub(crate) use mode::StackMode; diff --git a/src/infrastructure/mode.rs b/src/infrastructure/mode.rs new file mode 100644 index 0000000..87ce6cb --- /dev/null +++ b/src/infrastructure/mode.rs @@ -0,0 +1,28 @@ +/// Deployment topology managed by the integration harness. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StackMode { + /// Python control plane only. + Controlplane, + /// Python control plane routed through the Rust dataplane. + Dataplane, +} + +impl StackMode { + /// Semantic topology name shown to users. + #[must_use] + pub(crate) const fn topology_label(self) -> &'static str { + match self { + Self::Controlplane => "built-in dataplane", + Self::Dataplane => "external dataplane", + } + } + + /// Canonical physical topology value accepted by stack commands. + #[must_use] + pub(crate) const fn cli_value(self) -> &'static str { + match self { + Self::Controlplane => "controlplane", + Self::Dataplane => "dataplane", + } + } +} diff --git a/crates/platform/src/process.rs b/src/infrastructure/process.rs similarity index 83% rename from crates/platform/src/process.rs rename to src/infrastructure/process.rs index 2685fca..9c7b19b 100644 --- a/crates/platform/src/process.rs +++ b/src/infrastructure/process.rs @@ -12,12 +12,12 @@ use std::process::{Command, ExitStatus, Stdio}; use anyhow::Context; -use crate::error::PlatformError; +use crate::infrastructure::error::InfrastructureError; /// An owned child-process command description. #[must_use = "a command specification does nothing until a process runner executes it"] #[derive(Clone, PartialEq, Eq)] -pub struct CommandSpec { +pub(crate) struct CommandSpec { program: OsString, args: Vec, cwd: Option, @@ -27,7 +27,7 @@ pub struct CommandSpec { impl CommandSpec { /// Creates a command specification for `program`. - pub fn new(program: impl Into) -> Self { + pub(crate) fn new(program: impl Into) -> Self { Self { program: program.into(), args: Vec::new(), @@ -38,13 +38,13 @@ impl CommandSpec { } /// Appends one argument. - pub fn arg(mut self, argument: impl Into) -> Self { + pub(crate) fn arg(mut self, argument: impl Into) -> Self { self.args.push(argument.into()); self } /// Appends multiple arguments. - pub fn args(mut self, arguments: I) -> Self + pub(crate) fn args(mut self, arguments: I) -> Self where I: IntoIterator, S: Into, @@ -54,50 +54,50 @@ impl CommandSpec { } /// Sets the child working directory. - pub fn cwd(mut self, cwd: impl Into) -> Self { + pub(crate) fn cwd(mut self, cwd: impl Into) -> Self { self.cwd = Some(cwd.into()); self } /// Adds or replaces one child environment override. - pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + pub(crate) fn env(mut self, key: impl Into, value: impl Into) -> Self { self.environment.insert(key.into(), value.into()); self } /// Prevents the child from inheriting unspecified parent environment values. - pub fn clear_environment(mut self) -> Self { + pub(crate) fn clear_environment(mut self) -> Self { self.inherit_environment = false; self } /// Returns the program name or path. #[must_use] - pub fn program(&self) -> &OsStr { + pub(crate) fn program(&self) -> &OsStr { &self.program } /// Returns the ordered argument list. #[must_use] - pub fn arguments(&self) -> &[OsString] { + pub(crate) fn arguments(&self) -> &[OsString] { &self.args } /// Returns the configured child working directory. #[must_use] - pub fn working_directory(&self) -> Option<&Path> { + pub(crate) fn working_directory(&self) -> Option<&Path> { self.cwd.as_deref() } /// Returns deterministic child environment overrides. #[must_use] - pub fn environment(&self) -> &BTreeMap { + pub(crate) fn environment(&self) -> &BTreeMap { &self.environment } /// Returns whether unspecified parent environment values are inherited. #[must_use] - pub fn inherits_environment(&self) -> bool { + pub(crate) fn inherits_environment(&self) -> bool { self.inherit_environment } } @@ -120,7 +120,7 @@ impl fmt::Debug for CommandSpec { /// Bytes captured from both child output streams. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct CapturedOutput { +pub(crate) struct CapturedOutput { stdout: Vec, stderr: Vec, } @@ -128,39 +128,40 @@ pub struct CapturedOutput { impl CapturedOutput { /// Creates captured output from owned stream bytes. #[must_use] - pub fn new(stdout: Vec, stderr: Vec) -> Self { + pub(crate) fn new(stdout: Vec, stderr: Vec) -> Self { Self { stdout, stderr } } /// Returns captured standard output bytes. #[must_use] - pub fn stdout(&self) -> &[u8] { + pub(crate) fn stdout(&self) -> &[u8] { &self.stdout } /// Returns captured standard error bytes. #[must_use] - pub fn stderr(&self) -> &[u8] { + pub(crate) fn stderr(&self) -> &[u8] { &self.stderr } /// Splits captured output into owned standard output and error bytes. #[must_use] - pub fn into_parts(self) -> (Vec, Vec) { + #[cfg(test)] + pub(crate) fn into_parts(self) -> (Vec, Vec) { (self.stdout, self.stderr) } } /// Injectable child-process execution boundary. -pub trait ProcessRunner { +pub(crate) trait ProcessRunner { /// Runs with inherited standard output and error. - fn run(&self, spec: &CommandSpec) -> Result<(), PlatformError>; + fn run(&self, spec: &CommandSpec) -> Result<(), InfrastructureError>; /// Runs without blocking the calling async executor thread. fn run_async<'a>( &'a self, spec: &'a CommandSpec, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { Box::pin(async move { self.run(spec) }) } @@ -172,7 +173,7 @@ pub trait ProcessRunner { &'a self, spec: &'a CommandSpec, log_path: &'a Path, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { Box::pin(async move { self.run_to_log(spec, log_path) }) } @@ -183,7 +184,7 @@ pub trait ProcessRunner { &'a self, spec: &'a CommandSpec, mut cancellation: tokio::sync::watch::Receiver, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { Box::pin(async move { let process = self.run_async(spec); tokio::pin!(process); @@ -205,30 +206,30 @@ pub trait ProcessRunner { spec: &'a CommandSpec, cancellation: tokio::sync::watch::Receiver, _log_path: &'a Path, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { self.run_async_cancellable(spec, cancellation) } /// Captures standard output while inheriting standard error. - fn capture_stdout(&self, spec: &CommandSpec) -> Result, PlatformError>; + fn capture_stdout(&self, spec: &CommandSpec) -> Result, InfrastructureError>; /// Captures standard output and error separately. - fn capture_output(&self, spec: &CommandSpec) -> Result; + fn capture_output(&self, spec: &CommandSpec) -> Result; /// Appends standard output and error to one log file. - fn run_to_log(&self, spec: &CommandSpec, log_path: &Path) -> Result<(), PlatformError>; + fn run_to_log(&self, spec: &CommandSpec, log_path: &Path) -> Result<(), InfrastructureError>; } /// Operating-system-backed process runner. #[derive(Debug, Default, Clone, Copy)] -pub struct SystemProcessRunner; +pub(crate) struct SystemProcessRunner; /// Process runner that keeps ordinary child output in one aggregate log. /// /// Explicit per-command log paths remain authoritative, allowing callers to /// retain separate detailed logs for long-running tools. #[derive(Debug, Clone, Copy)] -pub struct LoggingProcessRunner<'a, R> { +pub(crate) struct LoggingProcessRunner<'a, R> { inner: &'a R, log_path: &'a Path, } @@ -236,20 +237,20 @@ pub struct LoggingProcessRunner<'a, R> { impl<'a, R> LoggingProcessRunner<'a, R> { /// Wraps `inner`, redirecting inherited child output to `log_path`. #[must_use] - pub const fn new(inner: &'a R, log_path: &'a Path) -> Self { + pub(crate) const fn new(inner: &'a R, log_path: &'a Path) -> Self { Self { inner, log_path } } } impl ProcessRunner for LoggingProcessRunner<'_, R> { - fn run(&self, spec: &CommandSpec) -> Result<(), PlatformError> { + fn run(&self, spec: &CommandSpec) -> Result<(), InfrastructureError> { self.inner.run_to_log(spec, self.log_path) } fn run_async<'a>( &'a self, spec: &'a CommandSpec, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { self.inner.run_async_to_log(spec, self.log_path) } @@ -257,7 +258,7 @@ impl ProcessRunner for LoggingProcessRunner<'_, R> { &'a self, spec: &'a CommandSpec, log_path: &'a Path, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { self.inner.run_async_to_log(spec, log_path) } @@ -265,7 +266,7 @@ impl ProcessRunner for LoggingProcessRunner<'_, R> { &'a self, spec: &'a CommandSpec, cancellation: tokio::sync::watch::Receiver, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { self.inner .run_async_cancellable_to_log(spec, cancellation, self.log_path) } @@ -275,30 +276,30 @@ impl ProcessRunner for LoggingProcessRunner<'_, R> { spec: &'a CommandSpec, cancellation: tokio::sync::watch::Receiver, log_path: &'a Path, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { self.inner .run_async_cancellable_to_log(spec, cancellation, log_path) } - fn capture_stdout(&self, spec: &CommandSpec) -> Result, PlatformError> { + 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) } - fn capture_output(&self, spec: &CommandSpec) -> Result { + fn capture_output(&self, spec: &CommandSpec) -> Result { let output = self.inner.capture_output(spec)?; append_captured_output(self.log_path, spec, &output)?; Ok(output) } - fn run_to_log(&self, spec: &CommandSpec, log_path: &Path) -> Result<(), PlatformError> { + fn run_to_log(&self, spec: &CommandSpec, log_path: &Path) -> Result<(), InfrastructureError> { self.inner.run_to_log(spec, log_path) } } impl ProcessRunner for SystemProcessRunner { - fn run(&self, spec: &CommandSpec) -> Result<(), PlatformError> { + fn run(&self, spec: &CommandSpec) -> Result<(), InfrastructureError> { let mut command = command(spec); command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); let mut child = command @@ -313,7 +314,7 @@ impl ProcessRunner for SystemProcessRunner { fn run_async<'a>( &'a self, spec: &'a CommandSpec, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { Box::pin(async move { let mut command = tokio::process::Command::from(command(spec)); command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); @@ -332,7 +333,7 @@ impl ProcessRunner for SystemProcessRunner { &'a self, spec: &'a CommandSpec, log_path: &'a Path, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { Box::pin(async move { let (log, stderr_log) = log_handles(log_path, spec)?; let mut command = tokio::process::Command::from(command(spec)); @@ -354,7 +355,7 @@ impl ProcessRunner for SystemProcessRunner { &'a self, spec: &'a CommandSpec, mut cancellation: tokio::sync::watch::Receiver, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { Box::pin(async move { let mut command = tokio::process::Command::from(command(spec)); command @@ -386,7 +387,7 @@ impl ProcessRunner for SystemProcessRunner { spec: &'a CommandSpec, mut cancellation: tokio::sync::watch::Receiver, log_path: &'a Path, - ) -> Pin> + 'a>> { + ) -> Pin> + 'a>> { Box::pin(async move { let log = File::create(log_path).with_context(|| log_context("create", log_path, spec))?; @@ -418,7 +419,7 @@ impl ProcessRunner for SystemProcessRunner { }) } - fn capture_stdout(&self, spec: &CommandSpec) -> Result, PlatformError> { + fn capture_stdout(&self, spec: &CommandSpec) -> Result, InfrastructureError> { let mut command = command(spec); command.stdout(Stdio::piped()).stderr(Stdio::inherit()); let child = command @@ -431,7 +432,7 @@ impl ProcessRunner for SystemProcessRunner { Ok(output.stdout) } - fn capture_output(&self, spec: &CommandSpec) -> Result { + fn capture_output(&self, spec: &CommandSpec) -> Result { let mut command = command(spec); command.stdout(Stdio::piped()).stderr(Stdio::piped()); let child = command @@ -444,7 +445,7 @@ impl ProcessRunner for SystemProcessRunner { Ok(CapturedOutput::new(output.stdout, output.stderr)) } - fn run_to_log(&self, spec: &CommandSpec, log_path: &Path) -> Result<(), PlatformError> { + 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); command @@ -460,7 +461,7 @@ impl ProcessRunner for SystemProcessRunner { } } -fn log_handles(log_path: &Path, spec: &CommandSpec) -> Result<(File, File), PlatformError> { +fn log_handles(log_path: &Path, spec: &CommandSpec) -> Result<(File, File), InfrastructureError> { let log = OpenOptions::new() .create(true) .append(true) @@ -476,7 +477,7 @@ fn append_captured_output( log_path: &Path, spec: &CommandSpec, output: &CapturedOutput, -) -> Result<(), PlatformError> { +) -> Result<(), InfrastructureError> { let mut log = OpenOptions::new() .create(true) .append(true) @@ -501,11 +502,14 @@ fn command(spec: &CommandSpec) -> Command { command } -fn require_success(spec: &CommandSpec, status: ExitStatus) -> Result<(), PlatformError> { +fn require_success(spec: &CommandSpec, status: ExitStatus) -> Result<(), InfrastructureError> { if status.success() { Ok(()) } else { - Err(PlatformError::child_exit(spec.program.to_owned(), status)) + Err(InfrastructureError::child_exit( + spec.program.to_owned(), + status, + )) } } @@ -517,8 +521,8 @@ async fn wait_for_cancellation(cancellation: &mut tokio::sync::watch::Receiver PlatformError { - PlatformError::from(anyhow::anyhow!( +fn cancelled_failure(spec: &CommandSpec) -> InfrastructureError { + InfrastructureError::from(anyhow::anyhow!( "program {:?} cancelled and reaped", spec.program() )) diff --git a/crates/platform/tests/process.rs b/src/infrastructure/process_integration_tests.rs similarity index 94% rename from crates/platform/tests/process.rs rename to src/infrastructure/process_integration_tests.rs index 0b1eb7c..73f8dbf 100644 --- a/crates/platform/tests/process.rs +++ b/src/infrastructure/process_integration_tests.rs @@ -2,10 +2,13 @@ use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Path, PathBuf}; -use cf_integration_platform::PlatformError; -use cf_integration_platform::process::{ - CapturedOutput, CommandSpec, LoggingProcessRunner, ProcessRunner, SystemProcessRunner, +use cf_integration::infrastructure::InfrastructureError; +#[cfg(unix)] +use cf_integration::infrastructure::process::LoggingProcessRunner; +use cf_integration::infrastructure::process::{ + CapturedOutput, CommandSpec, ProcessRunner, SystemProcessRunner, }; +#[cfg(unix)] use tempfile::TempDir; #[cfg(unix)] @@ -25,22 +28,22 @@ fn assert_runner_interface(_runner: &dyn ProcessRunner) {} struct FakeProcessRunner; impl ProcessRunner for FakeProcessRunner { - fn run(&self, _spec: &CommandSpec) -> Result<(), PlatformError> { + fn run(&self, _spec: &CommandSpec) -> Result<(), InfrastructureError> { Ok(()) } - fn capture_stdout(&self, _spec: &CommandSpec) -> Result, PlatformError> { + fn capture_stdout(&self, _spec: &CommandSpec) -> Result, InfrastructureError> { Ok(b"synthetic stdout".to_vec()) } - fn capture_output(&self, _spec: &CommandSpec) -> Result { + fn capture_output(&self, _spec: &CommandSpec) -> Result { Ok(CapturedOutput::new( b"synthetic stdout".to_vec(), b"synthetic stderr".to_vec(), )) } - fn run_to_log(&self, _spec: &CommandSpec, _log_path: &Path) -> Result<(), PlatformError> { + fn run_to_log(&self, _spec: &CommandSpec, _log_path: &Path) -> Result<(), InfrastructureError> { Ok(()) } } @@ -378,7 +381,7 @@ fn capture_output_returns_exact_stdout_and_stderr_bytes() { ); let output = SystemProcessRunner - .capture_output(&CommandSpec::new(script)) + .capture_output(&CommandSpec::new("/bin/sh").arg(script)) .expect("both streams should be captured"); assert_eq!(output.stdout(), b"stdout\n"); @@ -426,18 +429,12 @@ fn missing_program_has_safe_context_and_native_exit_code() { .run(&spec) .expect_err("missing program should fail to spawn"); - assert!(matches!(failure, PlatformError::Native(_))); + assert!(matches!(failure, InfrastructureError::Native(_))); assert_eq!(failure.exit_code(), 1); let message = failure.to_string(); assert!(message.contains("spawn"), "{message}"); - assert!( - message.contains(missing.to_string_lossy().as_ref()), - "{message}" - ); - assert!( - message.contains(directory.path().to_string_lossy().as_ref()), - "{message}" - ); + assert!(message.contains("missing-program"), "{message}"); + assert!(message.contains("cwd"), "{message}"); assert!(!message.contains(SECRET), "{message}"); } @@ -465,7 +462,7 @@ fn child_exit_code_is_preserved_without_process_output() { .run(&CommandSpec::new(script)) .expect_err("exit seven should be represented as a child failure"); - assert!(matches!(failure, PlatformError::ChildExit { .. })); + assert!(matches!(failure, InfrastructureError::ChildExit { .. })); assert_eq!(failure.exit_code(), 7); } @@ -476,16 +473,17 @@ fn signaled_child_maps_to_shell_exit_code() { let script = executable_script(&directory, "sigterm.sh", "kill -TERM $$"); let failure = SystemProcessRunner - .run(&CommandSpec::new(script)) + .run(&CommandSpec::new("/bin/sh").arg(script)) .expect_err("SIGTERM should be represented as a child failure"); - assert!(matches!(failure, PlatformError::ChildExit { .. })); + assert!(matches!(failure, InfrastructureError::ChildExit { .. })); assert_eq!(failure.exit_code(), 143); } #[cfg(windows)] #[test] fn windows_inherited_mode_preserves_success_and_nonzero_exit_codes() { + assert_runner_interface(&SystemProcessRunner); SystemProcessRunner .run(&CommandSpec::new("cmd.exe").args(["/D", "/S", "/C", "exit /b 0"])) .expect("zero exit should succeed"); @@ -494,7 +492,7 @@ fn windows_inherited_mode_preserves_success_and_nonzero_exit_codes() { .run(&CommandSpec::new("cmd.exe").args(["/D", "/S", "/C", "exit /b 7"])) .expect_err("nonzero exit should be represented as a child failure"); - assert!(matches!(failure, PlatformError::ChildExit { .. })); + assert!(matches!(failure, InfrastructureError::ChildExit { .. })); assert_eq!(failure.exit_code(), 7); } diff --git a/crates/platform/src/stack.rs b/src/infrastructure/stack.rs similarity index 78% rename from crates/platform/src/stack.rs rename to src/infrastructure/stack.rs index 9b689de..8a7ac95 100644 --- a/crates/platform/src/stack.rs +++ b/src/infrastructure/stack.rs @@ -5,13 +5,13 @@ use std::ffi::OsString; use std::fmt; use std::str::FromStr; -use crate::StackMode; -use crate::compose::{ComposeProject, SERVICE_DISPLAY_NAMES}; -use crate::process::CommandSpec; +use crate::infrastructure::StackMode; +use crate::infrastructure::compose::{ComposeProject, SERVICE_DISPLAY_NAMES}; +use crate::infrastructure::process::CommandSpec; /// User-selected Compose image build policy. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BuildMode { +pub(crate) enum BuildMode { /// Decide from image availability and checkout revision labels. Auto, /// Always ask Compose to build. @@ -35,7 +35,7 @@ impl FromStr for BuildMode { /// Invalid `CF_COMPOSE_BUILD` value. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct BuildModeParseError(String); +pub(crate) struct BuildModeParseError(String); impl fmt::Display for BuildModeParseError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -51,28 +51,28 @@ impl std::error::Error for BuildModeParseError {} /// Runtime facts needed to resolve automatic image builds. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct BuildInputs { - pub controlplane_image_prebuilt: bool, - pub controlplane_image_present: bool, - pub controlplane_checkout_revision: Option, - pub controlplane_image_revision: Option, - pub include_dataplane: bool, - pub dataplane_source_ref: Option, - pub dataplane_image_present: bool, - pub dataplane_checkout_revision: Option, - pub dataplane_image_revision: Option, +pub(crate) struct BuildInputs { + pub(crate) controlplane_image_prebuilt: bool, + pub(crate) controlplane_image_present: bool, + pub(crate) controlplane_checkout_revision: Option, + pub(crate) controlplane_image_revision: Option, + pub(crate) include_dataplane: bool, + pub(crate) dataplane_source_ref: Option, + pub(crate) dataplane_image_present: bool, + pub(crate) dataplane_checkout_revision: Option, + pub(crate) dataplane_image_revision: Option, } /// Resolved Compose build decision and stable operator diagnostics. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct BuildDecision { - pub build: bool, - pub reasons: Vec, +pub(crate) struct BuildDecision { + pub(crate) build: bool, + pub(crate) reasons: Vec, } /// Resolves `CF_COMPOSE_BUILD` without running Docker or Git. #[must_use] -pub fn resolve_build(mode: BuildMode, inputs: &BuildInputs) -> BuildDecision { +pub(crate) fn resolve_build(mode: BuildMode, inputs: &BuildInputs) -> BuildDecision { match mode { BuildMode::Always => BuildDecision { build: true, @@ -143,7 +143,7 @@ fn matching_revision(checkout: Option<&str>, image: Option<&str>) -> bool { /// Destructive scope of a Compose cleanup. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CleanupKind { +pub(crate) enum CleanupKind { /// Remove containers and networks while retaining volumes. Down, /// Remove containers, networks, and volumes. @@ -152,14 +152,14 @@ pub enum CleanupKind { /// One immutable stack command. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct StackCommandPlan { +pub(crate) struct StackCommandPlan { command: CommandSpec, } impl StackCommandPlan { /// Builds a mode-specific Compose `up` command. #[must_use] - pub fn up( + pub(crate) fn up( project: ComposeProject, mode: StackMode, build: bool, @@ -172,6 +172,7 @@ impl StackCommandPlan { } if build { arguments.push(OsString::from("--build")); + arguments.push(OsString::from("--quiet-build")); } if mode == StackMode::Controlplane && start_locust_ui { arguments.push(OsString::from("--scale")); @@ -182,41 +183,9 @@ impl StackCommandPlan { } } - /// Starts the profile-gated Fast Test fixture and waits for its healthcheck. - #[must_use] - pub fn fast_test_up(project: ComposeProject) -> Self { - Self { - command: project.command([ - "--profile", - "testing", - "up", - "-d", - "--wait", - "--wait-timeout", - "120", - "fast_test_server", - ]), - } - } - - /// Runs the one-shot Fast Test registration job to completion. - #[must_use] - pub fn fast_test_register(project: ComposeProject) -> Self { - Self { - command: project.command([ - "--profile", - "testing", - "run", - "--rm", - "--no-deps", - "register_fast_test", - ]), - } - } - /// Builds a Compose cleanup command. #[must_use] - pub fn cleanup(project: ComposeProject, kind: CleanupKind) -> Self { + pub(crate) fn cleanup(project: ComposeProject, kind: CleanupKind) -> Self { let mut arguments = vec![OsString::from("down")]; if kind == CleanupKind::Reset { arguments.push(OsString::from("--volumes")); @@ -229,7 +198,7 @@ impl StackCommandPlan { /// Builds a Compose service-status command. #[must_use] - pub fn status(project: ComposeProject) -> Self { + pub(crate) fn status(project: ComposeProject) -> Self { Self { command: project.command(["ps"]), } @@ -237,7 +206,7 @@ impl StackCommandPlan { /// Builds a Compose log-follow command, translating the public control-plane service name. #[must_use] - pub fn logs(project: ComposeProject, services: I) -> Self + pub(crate) fn logs(project: ComposeProject, services: I) -> Self where I: IntoIterator, { @@ -250,7 +219,7 @@ impl StackCommandPlan { /// Builds a Compose rendered-config command. #[must_use] - pub fn config(project: ComposeProject, mode: StackMode) -> Self { + pub(crate) fn config(project: ComposeProject, mode: StackMode) -> Self { let arguments = if mode == StackMode::Dataplane { vec![ OsString::from("--profile"), @@ -272,7 +241,7 @@ impl StackCommandPlan { } /// Returns the executable process specification. - pub fn command(&self) -> &CommandSpec { + pub(crate) fn command(&self) -> &CommandSpec { &self.command } } @@ -291,30 +260,30 @@ fn compose_service_name(service: OsString) -> OsString { /// Captured runtime state for one Compose service. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ServiceSnapshot { - pub running: bool, - pub completed_successfully: bool, - pub configured_image: Option, - pub running_image_matches_configured: bool, - pub image_revision: Option, +pub(crate) struct ServiceSnapshot { + pub(crate) running: bool, + pub(crate) completed_successfully: bool, + pub(crate) configured_image: Option, + pub(crate) running_image_matches_configured: bool, + pub(crate) image_revision: Option, } /// Facts used to decide whether a running dataplane stack is current. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct FreshnessSnapshot { - pub services: BTreeMap, - pub controlplane_checkout_revision: Option, - pub dataplane_checkout_revision: Option, - pub controlplane_image_prebuilt: bool, - pub dataplane_source_enabled: bool, - pub expected_controlplane_image: String, - pub expected_dataplane_image: String, - pub expected_fast_time_image: String, +pub(crate) struct FreshnessSnapshot { + pub(crate) services: BTreeMap, + pub(crate) controlplane_checkout_revision: Option, + pub(crate) dataplane_checkout_revision: Option, + pub(crate) controlplane_image_prebuilt: bool, + pub(crate) dataplane_source_enabled: bool, + pub(crate) expected_controlplane_image: String, + pub(crate) expected_dataplane_image: String, + pub(crate) expected_fast_time_image: String, } /// Result of evaluating a running dataplane stack. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum StackFreshness { +pub(crate) enum StackFreshness { /// Every required service, image, and revision is current. Current, /// The first deterministic freshness failure. @@ -324,7 +293,7 @@ pub enum StackFreshness { impl FreshnessSnapshot { /// Evaluates the shell-compatible stack freshness contract. #[must_use] - pub fn evaluate(&self) -> StackFreshness { + pub(crate) fn evaluate(&self) -> StackFreshness { for service in [ "gateway", "dataplane", @@ -380,12 +349,6 @@ impl FreshnessSnapshot { } } - for service in ["fast_test_server", "register_fast_test"] { - if self.services.contains_key(service) { - return stale(format!("stale {service} container exists")); - } - } - if !self.controlplane_image_prebuilt && !service_revision_matches( &self.services, diff --git a/crates/platform/tests/stack.rs b/src/infrastructure/stack_integration_tests.rs similarity index 87% rename from crates/platform/tests/stack.rs rename to src/infrastructure/stack_integration_tests.rs index 7005874..9dc0699 100644 --- a/crates/platform/tests/stack.rs +++ b/src/infrastructure/stack_integration_tests.rs @@ -2,9 +2,9 @@ use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; use std::path::Path; -use cf_integration_platform::StackMode; -use cf_integration_platform::compose::ComposeProject; -use cf_integration_platform::stack::{ +use cf_integration::infrastructure::StackMode; +use cf_integration::infrastructure::compose::ComposeProject; +use cf_integration::infrastructure::stack::{ BuildInputs, BuildMode, CleanupKind, FreshnessSnapshot, ServiceSnapshot, StackCommandPlan, StackFreshness, resolve_build, }; @@ -135,7 +135,7 @@ fn dataplane_up_always_removes_orphans_and_optionally_builds() { assert!(ends_with(&without_build, &["up", "-d", "--remove-orphans"])); assert!(ends_with( &with_build, - &["up", "-d", "--remove-orphans", "--build"] + &["up", "-d", "--remove-orphans", "--build", "--quiet-build"] )); } @@ -164,7 +164,14 @@ fn controlplane_up_does_not_activate_locust_profile_when_ui_is_disabled() { )); assert!(ends_with( &enabled, - &["up", "-d", "--build", "--scale", "locust_worker=3"] + &[ + "up", + "-d", + "--build", + "--quiet-build", + "--scale", + "locust_worker=3" + ] )); } @@ -210,8 +217,6 @@ fn cleanup_status_logs_and_config_use_typed_compose_commands() { OsString::from("cf-locust"), OsString::from("cf-locust-worker"), OsString::from("cf-locust-token"), - OsString::from("cf-fast-test-server"), - OsString::from("cf-register-fast-test"), OsString::from("cf-a2a-echo-agent"), OsString::from("cf-a2a-echo-agent-v0-3-0"), OsString::from("cf-register-a2a-echo"), @@ -237,8 +242,6 @@ fn cleanup_status_logs_and_config_use_typed_compose_commands() { "locust", "locust_worker", "locust_token", - "fast_test_server", - "register_fast_test", "a2a_echo_agent", "a2a_echo_agent_v0_3_0", "register_a2a_echo", @@ -344,7 +347,7 @@ fn current_dataplane_stack_requires_services_images_and_setup_jobs() { } #[test] -fn current_stack_rejects_wrong_fast_time_and_stale_fast_test_containers() { +fn current_stack_rejects_wrong_fast_time_image() { let mut snapshot = current_snapshot(); snapshot .services @@ -355,24 +358,6 @@ fn current_stack_rejects_wrong_fast_time_and_stale_fast_test_containers() { snapshot.evaluate(), StackFreshness::Stale("fast_time_server image differs".to_owned()) ); - - for service in ["fast_test_server", "register_fast_test"] { - let mut snapshot = current_snapshot(); - snapshot.services.insert( - service.to_owned(), - ServiceSnapshot { - running: false, - completed_successfully: true, - configured_image: None, - running_image_matches_configured: true, - image_revision: None, - }, - ); - assert_eq!( - snapshot.evaluate(), - StackFreshness::Stale(format!("stale {service} container exists")) - ); - } } #[test] @@ -423,32 +408,3 @@ fn stack_up_preserves_compose_auto_progress_without_shell_fragments() { .all(|arg| !arg.to_string_lossy().contains("sh -c")) ); } - -#[test] -fn fast_test_fixture_is_started_healthy_then_registered_synchronously() { - let project = project(StackMode::Dataplane); - assert!(ends_with( - &args(StackCommandPlan::fast_test_up(project.clone())), - &[ - "--profile", - "testing", - "up", - "-d", - "--wait", - "--wait-timeout", - "120", - "fast_test_server", - ] - )); - assert!(ends_with( - &args(StackCommandPlan::fast_test_register(project)), - &[ - "--profile", - "testing", - "run", - "--rm", - "--no-deps", - "register_fast_test", - ] - )); -} diff --git a/src/lib.rs b/src/lib.rs index d744c6f..46f02f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,163 @@ -//! Shared support for the `cf-integration` executable. +//! Standalone entrypoint for the `cf-integration` executable. -pub mod app; -pub mod cli; -pub mod error; +#[cfg(test)] +extern crate self as cf_integration; + +use std::process::ExitCode; + +use clap::Parser; + +mod app; +mod cli; +mod conformance; +mod error; +mod infrastructure; +mod mcp; mod output; -pub mod runtime; -pub mod token; +mod performance; +mod runtime; + +use app::resolve_action; +use cli::Cli; +use error::AppFailure; +use infrastructure::config::{AppConfig, ConfigBootstrap, ConfigRequirements, Environment}; +use infrastructure::process::SystemProcessRunner; +pub(crate) use output::{Activity, OutputStyle, TestStatus}; +use runtime::RuntimeDispatcher; + +/// Runs the CLI using the current process arguments and environment. +pub async fn run() -> ExitCode { + let arguments = std::env::args_os().collect::>(); + if conformance::client::is_internal_client_invocation(&arguments) { + return match conformance::client::run_internal_client(&arguments).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{}", OutputStyle::stderr().failure(&format!("{error:#}"))); + ExitCode::FAILURE + } + }; + } + let cli = Cli::parse_from(arguments); + let environment: Environment = std::env::vars_os().collect(); + let cwd = match std::env::current_dir() { + Ok(path) => path, + Err(error) => { + eprintln!( + "{}", + OutputStyle::stderr() + .failure(&format!("failed to determine current directory: {error}")) + ); + return ExitCode::FAILURE; + } + }; + let bootstrap = match ConfigBootstrap::load(&environment, &cwd) { + Ok(loaded) => loaded, + Err(error) => { + eprintln!("{}", OutputStyle::stderr().failure(&format!("{error:#}"))); + return ExitCode::FAILURE; + } + }; + for warning in bootstrap.warnings() { + eprintln!( + "{}", + OutputStyle::stderr().warning(&format!("warning: {warning}")) + ); + } + + let effective_environment = bootstrap + .environment() + .iter() + .map(|(key, value)| (key.clone(), value.value.clone())) + .collect::(); + let action = match resolve_action(cli, &effective_environment) { + Ok(action) => action, + Err(error) => return report_failure(AppFailure::from(error)), + }; + let requirements = if action.requires_runtime_assets() { + ConfigRequirements::RUNTIME + } else { + ConfigRequirements::READ_ONLY + }; + eprintln!("{}", OutputStyle::stderr().info(&action.startup_summary())); + let activity = action + .uses_global_activity() + .then(|| Activity::spinner(action.description())); + let config = match AppConfig::load(bootstrap, requirements) { + Ok(config) => config, + Err(error) => { + if let Some(activity) = activity { + activity.finish(false); + } + return report_failure(AppFailure::from(error)); + } + }; + let runtime = RuntimeDispatcher::new(config, SystemProcessRunner); + let result = runtime.execute(action).await; + if let Some(activity) = activity { + activity.finish(result.is_ok()); + } + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{}", OutputStyle::stderr().failure(&error.to_string())); + exit_code(error.exit_code()) + } + } +} + +fn report_failure(error: AppFailure) -> ExitCode { + eprintln!("{}", OutputStyle::stderr().failure(&error.to_string())); + exit_code(error.exit_code()) +} + +fn exit_code(code: i32) -> ExitCode { + u8::try_from(code) + .map(ExitCode::from) + .unwrap_or(ExitCode::FAILURE) +} -pub use output::OutputStyle; +#[cfg(test)] +#[path = "app_tests.rs"] +mod app_tests; +#[cfg(test)] +#[path = "cli_public_tests.rs"] +mod cli_public_tests; +#[cfg(test)] +#[path = "conformance/fixture_tests.rs"] +mod conformance_fixture_tests; +#[cfg(test)] +#[path = "conformance/results_tests.rs"] +mod conformance_tests; +#[cfg(test)] +#[path = "infrastructure/checkout_integration_tests.rs"] +mod infrastructure_checkout_tests; +#[cfg(test)] +#[path = "infrastructure/compose_integration_tests.rs"] +mod infrastructure_compose_tests; +#[cfg(test)] +#[path = "infrastructure/config_integration_tests.rs"] +mod infrastructure_config_tests; +#[cfg(test)] +#[path = "infrastructure/process_integration_tests.rs"] +mod infrastructure_process_tests; +#[cfg(test)] +#[path = "infrastructure/stack_integration_tests.rs"] +mod infrastructure_stack_tests; +#[cfg(test)] +#[path = "mcp/auth_proxy_integration_tests.rs"] +mod mcp_auth_proxy_tests; +#[cfg(test)] +#[path = "mcp/backend_identity_integration_tests.rs"] +mod mcp_backend_identity_tests; +#[cfg(test)] +#[path = "mcp/gateway_integration_tests.rs"] +mod mcp_gateway_tests; +#[cfg(test)] +#[path = "mcp/protocol_integration_tests.rs"] +mod mcp_protocol_tests; +#[cfg(test)] +#[path = "performance/locust_integration_tests.rs"] +mod performance_locust_tests; +#[cfg(test)] +#[path = "performance/python_adapter_tests.rs"] +mod performance_python_adapter_tests; diff --git a/src/main.rs b/src/main.rs index d108877..4767135 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,77 +1,6 @@ use std::process::ExitCode; -use cf_integration::OutputStyle; -use cf_integration::app::resolve_action; -use cf_integration::cli::Cli; -use cf_integration::error::AppFailure; -use cf_integration::runtime::RuntimeExecutor; -use cf_integration_platform::config::{AppConfig, Environment}; -use cf_integration_platform::process::SystemProcessRunner; -use clap::Parser; - #[tokio::main] async fn main() -> ExitCode { - let cli = Cli::parse(); - let environment: Environment = std::env::vars_os().collect(); - let executable = match std::env::current_exe() { - Ok(path) => path, - Err(error) => { - eprintln!( - "{}", - OutputStyle::stderr().failure(&format!( - "failed to locate cf-integration executable: {error}" - )) - ); - return ExitCode::FAILURE; - } - }; - let cwd = match std::env::current_dir() { - Ok(path) => path, - Err(error) => { - eprintln!( - "{}", - OutputStyle::stderr() - .failure(&format!("failed to determine current directory: {error}")) - ); - return ExitCode::FAILURE; - } - }; - let loaded = match AppConfig::load(&environment, &executable, &cwd) { - Ok(loaded) => loaded, - Err(error) => { - eprintln!("{}", OutputStyle::stderr().failure(&format!("{error:#}"))); - return ExitCode::FAILURE; - } - }; - for warning in &loaded.warnings { - eprintln!( - "{}", - OutputStyle::stderr().warning(&format!("warning: {warning}")) - ); - } - - let effective_environment = loaded - .config - .environment() - .iter() - .map(|(key, value)| (key.clone(), value.value.clone())) - .collect::(); - let mut runtime = RuntimeExecutor::new(loaded.config, SystemProcessRunner); - let result = match resolve_action(cli, &effective_environment) { - Ok(action) => runtime.execute(action).await, - Err(error) => Err(AppFailure::from(error)), - }; - match result { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("{}", OutputStyle::stderr().failure(&error.to_string())); - exit_code(error.exit_code()) - } - } -} - -fn exit_code(code: i32) -> ExitCode { - u8::try_from(code) - .map(ExitCode::from) - .unwrap_or(ExitCode::FAILURE) + cf_integration::run().await } diff --git a/crates/mcp/src/auth_proxy.rs b/src/mcp/auth_proxy.rs similarity index 96% rename from crates/mcp/src/auth_proxy.rs rename to src/mcp/auth_proxy.rs index 280bef5..a16086c 100644 --- a/crates/mcp/src/auth_proxy.rs +++ b/src/mcp/auth_proxy.rs @@ -19,19 +19,19 @@ use tokio::task::JoinHandle; use url::Url; use uuid::Uuid; -use crate::backend_identity::{BackendIdentity, is_dataplane_endpoint}; +use crate::mcp::backend_identity::{BackendIdentity, is_dataplane_endpoint}; const REDACTED: &str = ""; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const LOOPBACK_BIND_ADDRESS: &str = "127.0.0.1:0"; /// Maximum request size buffered before a request is sent to the upstream. -pub const MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024; +pub(crate) const MAX_REQUEST_BODY_BYTES: usize = 4 * 1024 * 1024; /// Failures that can occur while starting or stopping an [`AuthProxy`]. #[derive(Debug, thiserror::Error)] #[non_exhaustive] -pub enum AuthProxyError { +pub(crate) enum AuthProxyError { /// The upstream is not an absolute HTTP(S) URL without credentials or a fragment. #[error("upstream must be an absolute HTTP or HTTPS URL without credentials or a fragment")] InvalidUpstream, @@ -70,7 +70,7 @@ struct ProxyState { /// The generated endpoint contains a random path and is the only accepted path. /// Call [`AuthProxy::shutdown`] to stop accepting connections and wait for active /// connections to finish. -pub struct AuthProxy { +pub(crate) struct AuthProxy { endpoint: Url, shutdown: Option>, task: Option>>, @@ -87,14 +87,14 @@ impl AuthProxy { /// /// Returns an error if the upstream or token is invalid, the HTTP client /// cannot be configured, or the loopback listener cannot be bound. - pub async fn start( + pub(crate) async fn start( upstream: Url, bearer_token: impl AsRef, ) -> Result { Self::start_with_protocol_version(upstream, bearer_token, None).await } - /// Starts a proxy for a routed endpoint backed by the built-in data plane. + /// Starts a proxy for a routed endpoint backed by the built-in dataplane. /// /// Unlike [`Self::start`], this does not require the Rust data-plane /// response marker merely because the endpoint uses `/servers/{id}/mcp`. @@ -102,7 +102,7 @@ impl AuthProxy { /// # Errors /// /// Returns the same errors as [`Self::start`]. - pub async fn start_builtin_data_plane( + pub(crate) async fn start_builtin_data_plane( upstream: Url, bearer_token: impl AsRef, ) -> Result { @@ -114,7 +114,7 @@ impl AuthProxy { /// # Errors /// /// Returns the same errors as [`Self::start`]. - pub async fn start_with_protocol_version( + pub(crate) async fn start_with_protocol_version( upstream: Url, bearer_token: impl AsRef, protocol_version: Option<&str>, @@ -184,7 +184,7 @@ impl AuthProxy { /// Returns the unguessable loopback URL external tools should target. #[must_use] - pub fn url(&self) -> &Url { + pub(crate) fn url(&self) -> &Url { &self.endpoint } @@ -194,7 +194,7 @@ impl AuthProxy { /// /// Returns an error if the HTTP server failed or its task did not complete /// normally. - pub async fn shutdown(mut self) -> Result<(), AuthProxyError> { + pub(crate) async fn shutdown(mut self) -> Result<(), AuthProxyError> { self.signal_shutdown(); let Some(task) = self.task.take() else { return Err(AuthProxyError::Task); diff --git a/crates/mcp/tests/auth_proxy.rs b/src/mcp/auth_proxy_integration_tests.rs similarity index 98% rename from crates/mcp/tests/auth_proxy.rs rename to src/mcp/auth_proxy_integration_tests.rs index d209660..c7ecb59 100644 --- a/crates/mcp/tests/auth_proxy.rs +++ b/src/mcp/auth_proxy_integration_tests.rs @@ -8,7 +8,7 @@ use axum::extract::{Request, State}; use axum::http::header::{AUTHORIZATION, CONNECTION, CONTENT_TYPE, HOST, LOCATION}; use axum::http::{HeaderMap, HeaderValue, Response, StatusCode}; use axum::routing::any; -use cf_integration_mcp::auth_proxy::{AuthProxy, MAX_REQUEST_BODY_BYTES}; +use cf_integration::mcp::auth_proxy::{AuthProxy, MAX_REQUEST_BODY_BYTES}; use reqwest::{Client, Method, Url}; use serde_json::Value; use tokio::net::TcpListener; @@ -504,10 +504,11 @@ async fn shutdown_stops_accepting_connections() { proxy.shutdown().await.expect("proxy should shut down"); - let result = tokio::time::timeout(Duration::from_secs(1), client().get(endpoint).send()) - .await - .expect("connection refusal should not hang"); - assert!(result.is_err()); + let result = tokio::time::timeout(Duration::from_secs(1), client().get(endpoint).send()).await; + assert!( + !matches!(result, Ok(Ok(_))), + "a shut-down proxy must not accept another request" + ); upstream.shutdown().await; } @@ -604,7 +605,7 @@ async fn builtin_data_plane_proxy_allows_a_routed_controlplane_response() { .await; let proxy = AuthProxy::start_builtin_data_plane(upstream.url.clone(), INJECTED_TOKEN) .await - .expect("built-in data-plane proxy should start"); + .expect("built-in dataplane proxy should start"); let response = client() .get(proxy.url().clone()) diff --git a/crates/mcp/src/backend_identity.rs b/src/mcp/backend_identity.rs similarity index 85% rename from crates/mcp/src/backend_identity.rs rename to src/mcp/backend_identity.rs index 035e1dd..140757f 100644 --- a/crates/mcp/src/backend_identity.rs +++ b/src/mcp/backend_identity.rs @@ -4,17 +4,17 @@ use reqwest::header::{HeaderMap, HeaderValue}; use url::Url; /// Response header set by the harness-owned nginx boundary. -pub const BACKEND_HEADER: &str = "x-cf-integration-backend"; +pub(crate) const BACKEND_HEADER: &str = "x-cf-integration-backend"; /// Marker for a response served by `cf-dataplane`. -pub const DATAPLANE_BACKEND: &str = "dataplane"; +pub(crate) const DATAPLANE_BACKEND: &str = "dataplane"; /// Marker for a `/servers/.../mcp` response replayed on the control plane. -pub const CONTROLPLANE_FALLBACK_BACKEND: &str = "controlplane-fallback"; +pub(crate) const CONTROLPLANE_FALLBACK_BACKEND: &str = "controlplane-fallback"; /// Marker for a raw control-plane response in the dataplane topology. -pub const CONTROLPLANE_BACKEND: &str = "controlplane"; +pub(crate) const CONTROLPLANE_BACKEND: &str = "controlplane"; /// Parsed backend identity without retaining untrusted header contents. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BackendIdentity { +pub(crate) enum BackendIdentity { /// The response did not contain the marker. Missing, /// The response came from the dataplane route. @@ -32,7 +32,7 @@ pub enum BackendIdentity { impl BackendIdentity { /// Parses exactly one backend marker from response headers. #[must_use] - pub fn from_headers(headers: &HeaderMap) -> Self { + pub(crate) fn from_headers(headers: &HeaderMap) -> Self { let mut values = headers.get_all(BACKEND_HEADER).iter(); let Some(value) = values.next() else { return Self::Missing; @@ -46,7 +46,7 @@ impl BackendIdentity { /// Returns a static dataplane validation failure without reflecting an /// untrusted header value. #[must_use] - pub const fn dataplane_error(self) -> Option<&'static str> { + pub(crate) const fn dataplane_error(self) -> Option<&'static str> { match self { Self::Dataplane => None, Self::Missing => Some("dataplane response backend marker is missing"), @@ -73,7 +73,7 @@ impl BackendIdentity { /// Returns a safe diagnostic representation of one backend marker value. #[must_use] -pub fn sanitized_backend_value(value: &HeaderValue) -> &'static str { +pub(crate) fn sanitized_backend_value(value: &HeaderValue) -> &'static str { match BackendIdentity::from_value(value) { BackendIdentity::Dataplane => DATAPLANE_BACKEND, BackendIdentity::ControlplaneFallback => CONTROLPLANE_FALLBACK_BACKEND, @@ -86,7 +86,7 @@ pub fn sanitized_backend_value(value: &HeaderValue) -> &'static str { /// Returns whether a URL is the fixed public dataplane MCP route. #[must_use] -pub fn is_dataplane_endpoint(endpoint: &Url) -> bool { +pub(crate) fn is_dataplane_endpoint(endpoint: &Url) -> bool { let Some(mut segments) = endpoint.path_segments() else { return false; }; diff --git a/crates/mcp/tests/backend_identity.rs b/src/mcp/backend_identity_integration_tests.rs similarity index 94% rename from crates/mcp/tests/backend_identity.rs rename to src/mcp/backend_identity_integration_tests.rs index 7052465..22db63d 100644 --- a/crates/mcp/tests/backend_identity.rs +++ b/src/mcp/backend_identity_integration_tests.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::Path; use axum::http::{HeaderMap, HeaderValue}; -use cf_integration_mcp::backend_identity::{ +use cf_integration::mcp::backend_identity::{ BACKEND_HEADER, BackendIdentity, CONTROLPLANE_BACKEND, CONTROLPLANE_FALLBACK_BACKEND, DATAPLANE_BACKEND, sanitized_backend_value, }; @@ -76,10 +76,7 @@ fn backend_identity_errors_and_capture_never_echo_an_untrusted_marker() { #[test] fn dataplane_nginx_replaces_upstream_markers_at_every_public_backend_boundary() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("MCP crate should be nested under crates"); + let root = Path::new(env!("CARGO_MANIFEST_DIR")); let nginx = fs::read_to_string(root.join("docker/nginx.cf-dataplane.conf")) .expect("dataplane nginx configuration should be readable"); diff --git a/crates/mcp/src/gateway.rs b/src/mcp/gateway.rs similarity index 85% rename from crates/mcp/src/gateway.rs rename to src/mcp/gateway.rs index bc2896a..bca0ef1 100644 --- a/crates/mcp/src/gateway.rs +++ b/src/mcp/gateway.rs @@ -5,33 +5,41 @@ use std::fmt; use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use reqwest::{Method, StatusCode}; -use serde_json::{Map, Value}; +#[cfg(test)] +use serde_json::Map; +use serde_json::Value; use thiserror::Error; use url::Url; -use crate::GatewayTopology; +use crate::mcp::GatewayTopology; -use crate::backend_identity::{BACKEND_HEADER, BackendIdentity, sanitized_backend_value}; -use crate::mcp::{ - ACCEPT as MCP_ACCEPT, PROTOCOL_VERSION, initialize_with_id_and_version, jsonrpc_with_id, - parse_mcp_body, +use crate::mcp::backend_identity::{BACKEND_HEADER, BackendIdentity, sanitized_backend_value}; +use crate::mcp::protocol::{ + ACCEPT as MCP_ACCEPT, PROTOCOL_VERSION, is_stateless_protocol, parse_mcp_body, routing_name, }; +#[cfg(test)] +use crate::mcp::protocol::{initialize_with_id_and_version, jsonrpc_with_id}; /// Default MCP protocol version used in request bodies and HTTP headers. -pub const DEFAULT_PROTOCOL_VERSION: &str = PROTOCOL_VERSION; +pub(crate) const DEFAULT_PROTOCOL_VERSION: &str = PROTOCOL_VERSION; /// MCP protocol-version HTTP header. -pub const MCP_PROTOCOL_VERSION: &str = "mcp-protocol-version"; +pub(crate) const MCP_PROTOCOL_VERSION: &str = "mcp-protocol-version"; /// MCP streamable-HTTP session header. -pub const MCP_SESSION_ID: &str = "mcp-session-id"; +pub(crate) const MCP_SESSION_ID: &str = "mcp-session-id"; const JSON_CONTENT_TYPE: &str = "application/json"; const SSE_ACCEPT: &str = "text/event-stream"; const REDACTED: &str = ""; -const MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024; +/// Maximum response body buffered by the MCP client. +pub(crate) const MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024; + +#[cfg(test)] +#[path = "gateway_probe_tests.rs"] +mod probe_tests; /// Controls one request header without changing the client's stored defaults. #[derive(Clone, Default, PartialEq, Eq)] -pub enum HeaderOverride { +pub(crate) enum HeaderOverride { /// Use the client protocol version or response-derived session ID. #[default] Automatic, @@ -53,15 +61,24 @@ impl fmt::Debug for HeaderOverride { #[derive(Clone, Debug, PartialEq)] enum Payload { - Initialize { id: Value }, + #[cfg(test)] + Initialize { + id: Value, + }, Json(Value), + #[cfg(test)] Raw(Vec), + #[cfg(test)] None, } #[derive(Clone, PartialEq)] enum ResponseExpectation { - JsonRpc { id: Value }, + #[cfg(test)] + JsonRpc { + id: Value, + }, + #[cfg(test)] NotificationAccepted, Unchecked, } @@ -69,7 +86,9 @@ enum ResponseExpectation { impl fmt::Debug for ResponseExpectation { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + #[cfg(test)] Self::JsonRpc { .. } => formatter.write_str("JsonRpc { id: }"), + #[cfg(test)] Self::NotificationAccepted => formatter.write_str("NotificationAccepted"), Self::Unchecked => formatter.write_str("Unchecked"), } @@ -78,9 +97,10 @@ impl fmt::Debug for ResponseExpectation { /// One protected HTTP exchange to issue against the public MCP endpoint. #[derive(Clone, PartialEq)] -pub struct GatewayRequest { +pub(crate) struct GatewayRequest { method: Method, payload: Payload, + authorization: HeaderOverride, protocol_version: HeaderOverride, session: HeaderOverride, expectation: ResponseExpectation, @@ -89,15 +109,19 @@ pub struct GatewayRequest { impl fmt::Debug for GatewayRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let payload = match &self.payload { + #[cfg(test)] Payload::Initialize { .. } => "initialize", Payload::Json(_) => "json:", + #[cfg(test)] Payload::Raw(_) => "raw:", + #[cfg(test)] Payload::None => "none", }; formatter .debug_struct("GatewayRequest") .field("method", &self.method) .field("payload", &payload) + .field("authorization", &self.authorization) .field("protocol_version", &self.protocol_version) .field("session", &self.session) .field("expectation", &self.expectation) @@ -108,7 +132,8 @@ impl fmt::Debug for GatewayRequest { impl GatewayRequest { /// Builds an MCP initialize request. #[must_use] - pub fn initialize(id: Value) -> Self { + #[cfg(test)] + pub(crate) fn initialize(id: Value) -> Self { let mut request = Self::post( Payload::Initialize { id: id.clone() }, ResponseExpectation::JsonRpc { id }, @@ -121,13 +146,15 @@ impl GatewayRequest { /// Builds the required `notifications/initialized` notification. #[must_use] - pub fn initialized() -> Self { + #[cfg(test)] + pub(crate) fn initialized() -> Self { Self::notification("notifications/initialized", None) } /// Builds a generic JSON-RPC request whose response must match `id`. #[must_use] - pub fn request(method: &str, params: Option, id: Value) -> Self { + #[cfg(test)] + pub(crate) fn request(method: &str, params: Option, id: Value) -> Self { Self::post( Payload::Json(jsonrpc_with_id(method, params, id.clone())), ResponseExpectation::JsonRpc { id }, @@ -136,7 +163,8 @@ impl GatewayRequest { /// Builds a generic JSON-RPC notification. #[must_use] - pub fn notification(method: &str, params: Option) -> Self { + #[cfg(test)] + pub(crate) fn notification(method: &str, params: Option) -> Self { Self::post( Payload::Json(notification_message(method, params)), ResponseExpectation::NotificationAccepted, @@ -149,10 +177,12 @@ impl GatewayRequest { /// Streamable HTTP GET can remain open indefinitely. The returned exchange /// contains the status and headers with an empty body. #[must_use] - pub fn get() -> Self { + #[cfg(test)] + pub(crate) fn get() -> Self { Self { method: Method::GET, payload: Payload::None, + authorization: HeaderOverride::Automatic, protocol_version: HeaderOverride::Automatic, session: HeaderOverride::Automatic, expectation: ResponseExpectation::Unchecked, @@ -161,10 +191,12 @@ impl GatewayRequest { /// Builds an unchecked streamable-HTTP DELETE request. #[must_use] - pub fn delete() -> Self { + #[cfg(test)] + pub(crate) fn delete() -> Self { Self { method: Method::DELETE, payload: Payload::None, + authorization: HeaderOverride::Automatic, protocol_version: HeaderOverride::Automatic, session: HeaderOverride::Automatic, expectation: ResponseExpectation::Unchecked, @@ -173,31 +205,38 @@ impl GatewayRequest { /// Builds an unchecked JSON POST with an arbitrary, potentially malformed body. #[must_use] - pub fn raw_post(body: impl AsRef<[u8]>) -> Self { + #[cfg(test)] + pub(crate) fn raw_post(body: impl AsRef<[u8]>) -> Self { Self::post( Payload::Raw(body.as_ref().to_vec()), ResponseExpectation::Unchecked, ) } - /// Overrides or omits the configured protocol-version header. + /// Builds an unchecked JSON POST for workflow-level validation. #[must_use] - pub fn protocol_version(mut self, protocol_version: HeaderOverride) -> Self { - self.protocol_version = protocol_version; + pub(crate) fn probe(payload: Value) -> Self { + Self::post(Payload::Json(payload), ResponseExpectation::Unchecked) + } + + /// Overrides or omits the configured authorization header. + #[must_use] + pub(crate) fn authorization(mut self, authorization: HeaderOverride) -> Self { + self.authorization = authorization; self } - /// Overrides or omits the response-derived session header. + /// Overrides or omits the configured protocol-version header. #[must_use] - pub fn session(mut self, session: HeaderOverride) -> Self { - self.session = session; + pub(crate) fn protocol_version(mut self, protocol_version: HeaderOverride) -> Self { + self.protocol_version = protocol_version; self } - /// Disables status and JSON-RPC validation for an intentional negative case. + /// Overrides or omits the response-derived session header. #[must_use] - pub fn unchecked(mut self) -> Self { - self.expectation = ResponseExpectation::Unchecked; + pub(crate) fn session(mut self, session: HeaderOverride) -> Self { + self.session = session; self } @@ -205,6 +244,7 @@ impl GatewayRequest { Self { method: Method::POST, payload, + authorization: HeaderOverride::Automatic, protocol_version: HeaderOverride::Automatic, session: HeaderOverride::Automatic, expectation, @@ -214,7 +254,8 @@ impl GatewayRequest { /// Builds a JSON-RPC 2.0 notification without an `id` member. #[must_use] -pub fn notification_message(method: &str, params: Option) -> Value { +#[cfg(test)] +pub(crate) fn notification_message(method: &str, params: Option) -> Value { let mut payload = Map::new(); payload.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned())); payload.insert("method".to_owned(), Value::String(method.to_owned())); @@ -226,7 +267,7 @@ pub fn notification_message(method: &str, params: Option) -> Value { /// Safe diagnostic snapshot of an outbound HTTP request. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RequestCapture { +pub(crate) struct RequestCapture { mode: GatewayTopology, method: String, url: String, @@ -237,38 +278,29 @@ pub struct RequestCapture { impl RequestCapture { /// Stack mode used for this request. #[must_use] - pub fn mode(&self) -> GatewayTopology { + #[cfg(test)] + pub(crate) fn mode(&self) -> GatewayTopology { self.mode } - /// HTTP method. - #[must_use] - pub fn method(&self) -> &str { - &self.method - } - - /// Fully resolved public endpoint. - #[must_use] - pub fn url(&self) -> &str { - &self.url - } - /// Sanitized headers; authentication, cookies, and sessions are redacted. #[must_use] - pub fn headers(&self) -> &BTreeMap { + #[cfg(test)] + pub(crate) fn headers(&self) -> &BTreeMap { &self.headers } /// Sanitized request body. #[must_use] - pub fn body(&self) -> Option<&str> { + #[cfg(test)] + pub(crate) fn body(&self) -> Option<&str> { self.body.as_deref() } } /// Complete safe diagnostic record for one live gateway exchange. #[derive(Clone, PartialEq)] -pub struct Exchange { +pub(crate) struct Exchange { mode: GatewayTopology, request: RequestCapture, status: u16, @@ -296,69 +328,54 @@ impl fmt::Debug for Exchange { impl Exchange { /// Stack mode used for this exchange. #[must_use] - pub fn mode(&self) -> GatewayTopology { + pub(crate) fn mode(&self) -> GatewayTopology { self.mode } /// Safe outbound request diagnostic. #[must_use] - pub fn request(&self) -> &RequestCapture { + #[cfg(test)] + pub(crate) fn request(&self) -> &RequestCapture { &self.request } /// HTTP status code. #[must_use] - pub fn status(&self) -> u16 { + pub(crate) fn status(&self) -> u16 { self.status } /// Sanitized response headers. #[must_use] - pub fn headers(&self) -> &BTreeMap { + #[cfg(test)] + pub(crate) fn headers(&self) -> &BTreeMap { &self.headers } /// Sanitized response body. This is empty for GET requests because an SSE /// stream can remain open indefinitely and is not consumed by this client. #[must_use] - pub fn body(&self) -> &str { + pub(crate) fn body(&self) -> &str { &self.body } /// Parsed JSON-RPC response, when the body uses JSON or SSE. #[must_use] - pub fn message(&self) -> Option<&Value> { + pub(crate) fn message(&self) -> Option<&Value> { self.message.as_ref() } /// Session returned by this response header, if any. #[must_use] - pub fn session_id(&self) -> Option<&str> { + pub(crate) fn session_id(&self) -> Option<&str> { self.session_id.as_deref() } - - /// Verifies an exact status while retaining the full exchange on failure. - /// - /// # Errors - /// - /// Returns a mode-aware error containing a clone of this exchange when the - /// status differs. - pub fn require_status(&self, expected: u16) -> Result<(), GatewayError> { - if self.status == expected { - return Ok(()); - } - Err(GatewayError::with_exchange( - self.mode, - format!("expected status {expected}, got status {}", self.status), - self.clone(), - )) - } } /// Builder for [`GatewayClient`]. #[must_use = "a gateway client builder does nothing until build() is called"] #[derive(Clone)] -pub struct GatewayClientBuilder { +pub(crate) struct GatewayClientBuilder { mode: GatewayTopology, base_url: String, server_id: String, @@ -384,7 +401,7 @@ impl fmt::Debug for GatewayClientBuilder { impl GatewayClientBuilder { /// Selects a non-default MCP protocol version. - pub fn protocol_version(mut self, protocol_version: impl Into) -> Self { + pub(crate) fn protocol_version(mut self, protocol_version: impl Into) -> Self { self.protocol_version = protocol_version.into(); self } @@ -395,7 +412,7 @@ impl GatewayClientBuilder { /// /// Returns a mode-aware configuration error for an invalid base URL, /// endpoint, bearer token, or protocol-version header. - pub fn build(self) -> Result { + pub(crate) fn build(self) -> Result { if self.bearer_token.is_empty() { return Err(GatewayError::configuration( self.mode, @@ -404,7 +421,7 @@ impl GatewayClientBuilder { } validate_header_value( self.mode, - AUTHORIZATION.as_str(), + "Authorization", &format!("Bearer {}", self.bearer_token), )?; if self.protocol_version.trim().is_empty() { @@ -439,7 +456,7 @@ impl GatewayClientBuilder { /// Stateful client for the protected public MCP gateway route. #[derive(Clone)] -pub struct GatewayClient { +pub(crate) struct GatewayClient { mode: GatewayTopology, endpoint: Url, bearer_token: String, @@ -469,7 +486,7 @@ impl GatewayClient { /// # Errors /// /// Returns a mode-aware configuration error for invalid inputs. - pub fn new( + pub(crate) fn new( mode: GatewayTopology, base_url: &str, server_id: &str, @@ -479,7 +496,7 @@ impl GatewayClient { } /// Starts a configurable gateway client builder. - pub fn builder( + pub(crate) fn builder( mode: GatewayTopology, base_url: &str, server_id: &str, @@ -496,19 +513,14 @@ impl GatewayClient { /// Fixed percent-encoded public MCP endpoint. #[must_use] - pub fn endpoint(&self) -> &Url { + pub(crate) fn endpoint(&self) -> &Url { &self.endpoint } - /// Configured MCP protocol version. - #[must_use] - pub fn protocol_version(&self) -> &str { - &self.protocol_version - } - /// Most recent non-empty session ID received in a successful response. #[must_use] - pub fn session_id(&self) -> Option<&str> { + #[cfg(test)] + pub(crate) fn session_id(&self) -> Option<&str> { self.session_id.as_deref() } @@ -523,7 +535,7 @@ impl GatewayClient { /// /// Returns a mode-aware error with a safe request or full exchange capture /// for header, transport, body parsing, status, or JSON-RPC failures. - pub async fn send(&mut self, request: GatewayRequest) -> Result { + pub(crate) async fn send(&mut self, request: GatewayRequest) -> Result { let body = materialize_payload(self.mode, &request.payload, &self.protocol_version)?; let outbound = self.build_request(&request, body.as_deref())?; let outbound_session = outbound @@ -644,7 +656,6 @@ impl GatewayClient { let mut builder = self .http .request(request.method.clone(), self.endpoint.clone()) - .bearer_auth(&self.bearer_token) .header( ACCEPT, if request.method == Method::GET { @@ -653,6 +664,14 @@ impl GatewayClient { MCP_ACCEPT }, ); + let automatic_authorization = format!("Bearer {}", self.bearer_token); + builder = apply_header( + self.mode, + builder, + "Authorization", + &request.authorization, + Some(&automatic_authorization), + )?; if request.method == Method::POST { builder = builder.header(CONTENT_TYPE, JSON_CONTENT_TYPE); } @@ -663,6 +682,22 @@ impl GatewayClient { &request.protocol_version, Some(&self.protocol_version), )?; + if request.method == Method::POST { + let protocol_version = match &request.protocol_version { + HeaderOverride::Automatic => Some(self.protocol_version.as_str()), + HeaderOverride::Omit => None, + HeaderOverride::Value(value) => Some(value.as_str()), + }; + if protocol_version.is_some_and(is_stateless_protocol) + && let Payload::Json(payload) = &request.payload + && let Some(method) = payload.get("method").and_then(Value::as_str) + { + builder = apply_literal_header(self.mode, builder, "mcp-method", method)?; + if let Some(name) = routing_name(method, payload.get("params")) { + builder = apply_literal_header(self.mode, builder, "mcp-name", name)?; + } + } + } builder = apply_header( self.mode, builder, @@ -689,6 +724,7 @@ impl GatewayClient { exchange: &Exchange, ) -> Result<(), GatewayError> { match expectation { + #[cfg(test)] ResponseExpectation::JsonRpc { id } => { if status != StatusCode::OK { return Err(GatewayError::with_exchange( @@ -715,6 +751,7 @@ impl GatewayClient { GatewayError::with_exchange(self.mode, message, exchange.clone()) })?; } + #[cfg(test)] ResponseExpectation::NotificationAccepted => { if status != StatusCode::ACCEPTED { return Err(GatewayError::with_exchange( @@ -750,6 +787,16 @@ impl GatewayClient { } } +fn apply_literal_header( + mode: GatewayTopology, + builder: reqwest::RequestBuilder, + name: &'static str, + value: &str, +) -> Result { + validate_header_value(mode, name, value)?; + Ok(builder.header(name, value)) +} + async fn bounded_response_body(response: &mut reqwest::Response) -> Result, String> { let mut body = Vec::new(); while let Some(chunk) = response @@ -767,7 +814,7 @@ async fn bounded_response_body(response: &mut reqwest::Response) -> Result GatewayTopology { + #[cfg(test)] + pub(crate) fn mode(&self) -> GatewayTopology { match self { Self::Configuration { mode, .. } | Self::Request { mode, .. } @@ -811,7 +859,8 @@ impl GatewayError { /// Full exchange for response-time failures. #[must_use] - pub fn exchange(&self) -> Option<&Exchange> { + #[cfg(test)] + pub(crate) fn exchange(&self) -> Option<&Exchange> { match self { Self::Exchange { exchange, .. } => Some(exchange), Self::Configuration { .. } | Self::Request { .. } => None, @@ -895,14 +944,18 @@ fn gateway_endpoint( fn materialize_payload( mode: GatewayTopology, payload: &Payload, - protocol_version: &str, + _protocol_version: &str, ) -> Result>, GatewayError> { let value = match payload { - Payload::Initialize { id } => { - Some(initialize_with_id_and_version(id.clone(), protocol_version)) - } + #[cfg(test)] + Payload::Initialize { id } => Some(initialize_with_id_and_version( + id.clone(), + _protocol_version, + )), Payload::Json(value) => Some(value.clone()), + #[cfg(test)] Payload::Raw(body) => return Ok(Some(body.clone())), + #[cfg(test)] Payload::None => None, }; value @@ -1043,6 +1096,7 @@ fn parse_response_body(body: &[u8], headers: &HeaderMap) -> Result .map_err(|_| "response body is not valid JSON or SSE".to_owned()) } +#[cfg(test)] fn validate_jsonrpc_response(message: &Value, expected_id: &Value) -> Result<(), String> { let object = message .as_object() diff --git a/crates/mcp/tests/gateway.rs b/src/mcp/gateway_integration_tests.rs similarity index 99% rename from crates/mcp/tests/gateway.rs rename to src/mcp/gateway_integration_tests.rs index 49f0e63..aea0f9e 100644 --- a/crates/mcp/tests/gateway.rs +++ b/src/mcp/gateway_integration_tests.rs @@ -8,8 +8,8 @@ use axum::body::{Body, Bytes, to_bytes}; use axum::extract::State; use axum::http::{HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode}; use axum::routing::any; -use cf_integration_mcp::GatewayTopology; -use cf_integration_mcp::gateway::{ +use cf_integration::mcp::GatewayTopology; +use cf_integration::mcp::gateway::{ DEFAULT_PROTOCOL_VERSION, GatewayClient, GatewayRequest, HeaderOverride, MCP_PROTOCOL_VERSION, MCP_SESSION_ID, }; diff --git a/crates/mcp/tests/http_transport.rs b/src/mcp/gateway_probe_tests.rs similarity index 86% rename from crates/mcp/tests/http_transport.rs rename to src/mcp/gateway_probe_tests.rs index d8ef8a7..6dbe444 100644 --- a/crates/mcp/tests/http_transport.rs +++ b/src/mcp/gateway_probe_tests.rs @@ -1,12 +1,14 @@ use std::sync::{Arc, Mutex}; +use crate::mcp::backend_identity::is_dataplane_endpoint; +use crate::mcp::probe::{ProbeRequest, ProbeResponse, ProbeTransport}; use axum::Router; use axum::body::{Body, Bytes}; use axum::extract::{Request, State}; use axum::http::{HeaderMap, Response, StatusCode}; use axum::routing::any; -use cf_integration_mcp::http_transport::{MAX_MCP_RESPONSE_BYTES, ReqwestProbeTransport}; -use cf_integration_mcp::probe::{ProbeRequest, ProbeTransport}; + +use super::{GatewayClient, MAX_RESPONSE_BODY_BYTES}; use serde_json::json; use tokio::net::TcpListener; @@ -58,6 +60,30 @@ fn request(url: String) -> ProbeRequest { } } +async fn send(request: ProbeRequest) -> anyhow::Result { + let endpoint = url::Url::parse(&request.url)?; + let mode = if is_dataplane_endpoint(&endpoint) { + crate::mcp::GatewayTopology::Dataplane + } else { + crate::mcp::GatewayTopology::Direct + }; + let server_id = endpoint + .path_segments() + .and_then(|mut segments| { + (segments.next() == Some("servers")) + .then(|| segments.next()) + .flatten() + }) + .unwrap_or("test") + .to_owned(); + let mut base_url = endpoint; + base_url.set_path("/"); + base_url.set_query(None); + base_url.set_fragment(None); + let client = GatewayClient::new(mode, base_url.as_str(), &server_id, "test-secret-token")?; + client.post(request).await +} + #[tokio::test] async fn sends_exact_mcp_headers_and_parses_json_response() { let capture = Capture::default(); @@ -68,11 +94,7 @@ async fn sends_exact_mcp_headers_and_parses_json_response() { ) .await; - let response = ReqwestProbeTransport::new() - .expect("transport") - .post(request(url)) - .await - .expect("request should succeed"); + let response = send(request(url)).await.expect("request should succeed"); assert_eq!(response.status, 200); assert_eq!(response.session_id.as_deref(), Some("session-from-server")); @@ -131,11 +153,7 @@ async fn omits_optional_auth_session_and_protocol_headers() { request.session_id = None; request.protocol_version = None; - ReqwestProbeTransport::new() - .expect("transport") - .post(request) - .await - .expect("request should succeed"); + send(request).await.expect("request should succeed"); let captured = capture.0.lock().expect("capture lock"); assert!(captured[0].0.get("authorization").is_none()); @@ -163,11 +181,7 @@ async fn stateless_requests_send_method_and_target_name_headers() { request.protocol_version = Some("2026-07-28".to_owned()); request.session_id = None; - ReqwestProbeTransport::new() - .expect("transport") - .post(request) - .await - .expect("request should succeed"); + send(request).await.expect("request should succeed"); let captured = capture.0.lock().expect("capture lock"); let headers = &captured[0].0; @@ -205,11 +219,7 @@ async fn parses_blank_delimited_multiline_sse() { } let (url, shutdown) = server(Router::new().route("/mcp", any(sse))).await; - let response = ReqwestProbeTransport::new() - .expect("transport") - .post(request(url)) - .await - .expect("SSE should parse"); + let response = send(request(url)).await.expect("SSE should parse"); assert_eq!( response.message, @@ -229,9 +239,7 @@ async fn non_success_responses_are_returned_without_parsing_untrusted_bodies() { } let (url, shutdown) = server(Router::new().route("/mcp", any(unauthorized))).await; - let response = ReqwestProbeTransport::new() - .expect("transport") - .post(request(url)) + let response = send(request(url)) .await .expect("HTTP status should be returned"); @@ -251,9 +259,7 @@ async fn successful_nonempty_response_requires_mcp_content_type() { } let (url, shutdown) = server(Router::new().route("/mcp", any(wrong_type))).await; - let error = ReqwestProbeTransport::new() - .expect("transport") - .post(request(url)) + let error = send(request(url)) .await .expect_err("wrong content type must fail"); @@ -271,14 +277,12 @@ async fn response_body_is_bounded() { Response::builder() .status(StatusCode::OK) .header("content-type", "application/json") - .body(Body::from(vec![b'x'; MAX_MCP_RESPONSE_BYTES + 1])) + .body(Body::from(vec![b'x'; MAX_RESPONSE_BODY_BYTES + 1])) .expect("response") } let (url, shutdown) = server(Router::new().route("/mcp", any(oversized))).await; - let error = ReqwestProbeTransport::new() - .expect("transport") - .post(request(url)) + let error = send(request(url)) .await .expect_err("oversized response must fail"); @@ -292,9 +296,7 @@ async fn invalid_sensitive_headers_fail_without_leaking_values() { let mut request = request("http://127.0.0.1:9/mcp".to_owned()); request.bearer_token = Some(secret.to_owned()); - let error = ReqwestProbeTransport::new() - .expect("transport") - .post(request) + let error = send(request) .await .expect_err("invalid token header must fail"); @@ -334,9 +336,7 @@ async fn dataplane_transport_accepts_one_exact_backend_marker() { ) .await; - let response = ReqwestProbeTransport::new() - .expect("transport") - .post(request(dataplane_url(&url))) + let response = send(request(dataplane_url(&url))) .await .expect("exact dataplane marker should pass"); @@ -359,9 +359,7 @@ async fn dataplane_transport_rejects_absent_fallback_forged_and_duplicate_marker ) .await; - let error = ReqwestProbeTransport::new() - .expect("transport") - .post(request(dataplane_url(&url))) + let error = send(request(dataplane_url(&url))) .await .expect_err("invalid dataplane identity must fail closed"); let diagnostic = error.to_string(); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000..7d4f7c1 --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,10 @@ +//! MCP transport, gateway, authentication proxy, and probe primitives. + +pub(crate) mod auth_proxy; +pub(crate) mod backend_identity; +pub(crate) mod gateway; +pub(crate) mod probe; +pub(crate) mod protocol; +mod topology; + +pub(crate) use topology::GatewayTopology; diff --git a/crates/mcp/src/probe.rs b/src/mcp/probe.rs similarity index 80% rename from crates/mcp/src/probe.rs rename to src/mcp/probe.rs index 4fe8f44..6178c98 100644 --- a/crates/mcp/src/probe.rs +++ b/src/mcp/probe.rs @@ -5,14 +5,15 @@ use std::io::Write; use std::time::{Duration, Instant}; use anyhow::{Result, anyhow, bail}; -use async_trait::async_trait; use serde_json::{Map, Value, json}; use url::Url; -use crate::GatewayTopology; +use crate::mcp::GatewayTopology; +use crate::{OutputStyle, TestStatus}; -use crate::backend_identity::BackendIdentity; -use crate::mcp::{ +use crate::mcp::backend_identity::BackendIdentity; +use crate::mcp::gateway::{GatewayClient, GatewayRequest, HeaderOverride}; +use crate::mcp::protocol::{ initialize_with_id_and_version, is_stateless_protocol, jsonrpc_with_id, stateless_jsonrpc_with_id, tool_call_args, }; @@ -23,25 +24,31 @@ const TOOLS_LIST_ID: u64 = 2; const TOOL_CALL_ID: u64 = 3; const MIN_RETRY_INTERVAL: Duration = Duration::from_millis(10); +#[cfg(test)] +#[path = "probe_tests.rs"] +mod tests; + /// Runtime values needed by the public MCP probe. #[derive(Clone, PartialEq, Eq)] -pub struct ProbeConfig { +pub(crate) struct ProbeConfig { /// Stack topology whose public route is under test. - pub mode: GatewayTopology, + pub(crate) mode: GatewayTopology, /// Base URL of the public nginx endpoint. - pub base_url: String, + pub(crate) base_url: String, /// Virtual server identifier used in the public MCP route. - pub server_id: String, + pub(crate) server_id: String, /// Bearer token sent by authenticated probe steps. - pub bearer_token: String, + pub(crate) bearer_token: String, /// Maximum time spent waiting for the dataplane publisher configuration. - pub config_timeout: Duration, + pub(crate) config_timeout: Duration, /// Delay between authenticated initialize attempts. - pub retry_interval: Duration, + pub(crate) retry_interval: Duration, /// Maximum time allowed for any individual transport request. - pub request_timeout: Duration, + pub(crate) request_timeout: Duration, /// MCP protocol revision requested during initialization. - pub protocol_version: String, + pub(crate) protocol_version: String, + /// Terminal-aware styling for human-readable probe results. + pub(crate) output_style: OutputStyle, } impl fmt::Debug for ProbeConfig { @@ -62,20 +69,20 @@ impl fmt::Debug for ProbeConfig { /// Transport-neutral MCP POST request issued by the probe flow. #[derive(Clone, PartialEq)] -pub struct ProbeRequest { +pub(crate) struct ProbeRequest { /// Fully resolved public MCP URL. - pub url: String, + pub(crate) url: String, /// JSON-RPC request payload. - pub payload: Value, + pub(crate) payload: Value, /// Optional bearer token; omitted for the negative authentication check. - pub bearer_token: Option, + pub(crate) bearer_token: Option, /// Optional MCP session identifier. - pub session_id: Option, + pub(crate) session_id: Option, /// MCP protocol revision sent in the transport header, when applicable. /// /// Initialize requests omit this header. Requests after initialization use /// the version negotiated by the server. - pub protocol_version: Option, + pub(crate) protocol_version: Option, } impl fmt::Debug for ProbeRequest { @@ -96,15 +103,15 @@ impl fmt::Debug for ProbeRequest { /// Parsed response returned by a [`ProbeTransport`]. #[derive(Clone, PartialEq)] -pub struct ProbeResponse { +pub(crate) struct ProbeResponse { /// HTTP response status. - pub status: u16, + pub(crate) status: u16, /// MCP session identifier response header, when present. - pub session_id: Option, + pub(crate) session_id: Option, /// Parsed JSON-RPC response from either JSON or SSE. - pub message: Option, + pub(crate) message: Option, /// Harness backend identity parsed without retaining untrusted values. - pub backend_identity: BackendIdentity, + pub(crate) backend_identity: BackendIdentity, } impl fmt::Debug for ProbeResponse { @@ -122,7 +129,7 @@ impl fmt::Debug for ProbeResponse { impl ProbeResponse { /// Creates a parsed probe response. #[must_use] - pub fn new(status: u16, session_id: Option, message: Option) -> Self { + pub(crate) fn new(status: u16, session_id: Option, message: Option) -> Self { Self { status, session_id, @@ -133,15 +140,14 @@ impl ProbeResponse { /// Assigns the parsed harness backend identity. #[must_use] - pub fn with_backend_identity(mut self, backend_identity: BackendIdentity) -> Self { + pub(crate) fn with_backend_identity(mut self, backend_identity: BackendIdentity) -> Self { self.backend_identity = backend_identity; self } } /// Async boundary used to send probe requests. -#[async_trait] -pub trait ProbeTransport: Send + Sync { +pub(crate) trait ProbeTransport: Send + Sync { /// Sends one MCP request and returns its parsed response. /// /// # Errors @@ -151,6 +157,46 @@ pub trait ProbeTransport: Send + Sync { async fn post(&self, request: ProbeRequest) -> Result; } +impl ProbeTransport for GatewayClient { + async fn post(&self, request: ProbeRequest) -> Result { + if request.url != self.endpoint().as_str() { + bail!("probe request endpoint does not match the MCP client endpoint"); + } + let authorization = request.bearer_token.map_or(HeaderOverride::Omit, |token| { + HeaderOverride::Value(format!("Bearer {token}")) + }); + let protocol_version = request + .protocol_version + .map_or(HeaderOverride::Omit, HeaderOverride::Value); + let session = request + .session_id + .map_or(HeaderOverride::Omit, HeaderOverride::Value); + let gateway_request = GatewayRequest::probe(request.payload) + .authorization(authorization) + .protocol_version(protocol_version) + .session(session); + let mut client = self.clone(); + let exchange = client.send(gateway_request).await?; + if (200..300).contains(&exchange.status()) + && !exchange.body().is_empty() + && exchange.message().is_none() + { + bail!("unsupported MCP response content type"); + } + let backend_identity = if exchange.mode().requires_dataplane() { + BackendIdentity::Dataplane + } else { + BackendIdentity::Missing + }; + Ok(ProbeResponse::new( + exchange.status(), + exchange.session_id().map(str::to_owned), + exchange.message().cloned(), + ) + .with_backend_identity(backend_identity)) + } +} + /// Runs the end-to-end protocol probe against the public MCP route. /// /// # Errors @@ -158,7 +204,7 @@ pub trait ProbeTransport: Send + Sync { /// Returns an error on transport failures, unexpected HTTP or JSON-RPC /// responses, a missing MCP session ID, an empty tool list, output failures, or /// a tool response whose `isError` field is true. -pub async fn run_probe( +pub(crate) async fn run_probe( transport: &T, config: &ProbeConfig, output: &mut W, @@ -196,9 +242,12 @@ pub async fn run_probe( unauthenticated.status ); } - write_line( + write_probe_result( output, - &format!("auth_negative=PASS status={}", unauthenticated.status), + config, + TestStatus::Pass, + "auth_negative", + &format!("status={}", unauthenticated.status), "failed to write negative authentication result", )?; @@ -231,15 +280,6 @@ pub async fn run_probe( { break response; } - write_line( - output, - &format!( - "initialize=RETRY status={} (waiting for dataplane config)", - response.status - ), - "failed to write initialize retry result", - )?; - let remaining = config.config_timeout.saturating_sub(started.elapsed()); if remaining.is_zero() { break response; @@ -261,12 +301,12 @@ pub async fn run_probe( .session_id .filter(|session_id| !session_id.trim().is_empty()) .ok_or_else(|| anyhow::anyhow!("initialize=FAIL no Mcp-Session-Id header in response"))?; - write_line( + write_probe_result( output, - &format!( - "initialize=PASS status={} session=present", - authenticated.status - ), + config, + TestStatus::Pass, + "initialize", + &format!("status={} session=present", authenticated.status), "failed to write initialize result", )?; @@ -288,9 +328,12 @@ pub async fn run_probe( ) .await?; accepted_empty("initialized", &initialized_response)?; - write_line( + write_probe_result( output, - "initialized=PASS status=202", + config, + TestStatus::Pass, + "initialized", + "status=202", "failed to write initialized notification result", )?; @@ -330,26 +373,24 @@ pub async fn run_probe( }; tool_names.push(name); } - write_line( + write_probe_result( output, - &format!("tools_list=PASS count={}", tool_names.len()), + config, + TestStatus::Pass, + "tools_list", + &format!("count={}", tool_names.len()), "failed to write tools list result", )?; - for name in &tool_names { - write_line( - output, - &format!("tool={}", sanitize_for_output(name)), - "failed to write tool name", - )?; - } - let callable = tool_names .iter() .find_map(|name| tool_call_args(name).map(|arguments| (*name, arguments))); let Some((tool_name, arguments)) = callable else { - write_line( + write_probe_result( output, - "tool_call=SKIP no echo/get_system_time tool available", + config, + TestStatus::Skip, + "tool_call", + "no echo/get_system_time tool available", "failed to write tool call skip result", )?; return Ok(()); @@ -385,9 +426,12 @@ pub async fn run_probe( if is_error { bail!("tool_call=FAIL tool returned error"); } - write_line( + write_probe_result( output, - &format!("tool_call=PASS tool={}", sanitize_for_output(tool_name)), + config, + TestStatus::Pass, + "tool_call", + &format!("tool={}", sanitize_for_output(tool_name)), "failed to write tool call result", )?; @@ -426,9 +470,12 @@ async fn run_stateless_probe( unauthenticated.status ); } - write_line( + write_probe_result( output, - "auth_negative=PASS status=401", + config, + TestStatus::Pass, + "auth_negative", + "status=401", "failed to write negative authentication result", )?; @@ -461,14 +508,6 @@ async fn run_stateless_probe( { break response; } - write_line( - output, - &format!( - "server_discover=RETRY status={} (waiting for dataplane config)", - response.status - ), - "failed to write server discovery retry result", - )?; let remaining = config.config_timeout.saturating_sub(started.elapsed()); if remaining.is_zero() { break response; @@ -507,9 +546,12 @@ async fn run_stateless_probe( { bail!("server_discover=FAIL response is missing required discovery fields"); } - write_line( + write_probe_result( output, - "server_discover=PASS status=200 lifecycle=stateless", + config, + TestStatus::Pass, + "server_discover", + "status=200 lifecycle=stateless", "failed to write server discovery result", )?; @@ -552,26 +594,24 @@ async fn run_stateless_probe( }; tool_names.push(name); } - write_line( + write_probe_result( output, - &format!("tools_list=PASS count={}", tool_names.len()), + config, + TestStatus::Pass, + "tools_list", + &format!("count={}", tool_names.len()), "failed to write tools list result", )?; - for name in &tool_names { - write_line( - output, - &format!("tool={}", sanitize_for_output(name)), - "failed to write tool name", - )?; - } - let callable = tool_names .iter() .find_map(|name| tool_call_args(name).map(|arguments| (*name, arguments))); let Some((tool_name, arguments)) = callable else { - write_line( + write_probe_result( output, - "tool_call=SKIP no echo/get_system_time tool available", + config, + TestStatus::Skip, + "tool_call", + "no echo/get_system_time tool available", "failed to write tool call skip result", )?; return Ok(()); @@ -605,9 +645,12 @@ async fn run_stateless_probe( { bail!("tool_call=FAIL tool returned error or a malformed isError value"); } - write_line( + write_probe_result( output, - &format!("tool_call=PASS tool={}", sanitize_for_output(tool_name)), + config, + TestStatus::Pass, + "tool_call", + &format!("tool={}", sanitize_for_output(tool_name)), "failed to write tool call result", )?; Ok(()) @@ -706,6 +749,26 @@ fn sanitize_for_output(value: &str) -> String { sanitized } +fn write_probe_result( + output: &mut W, + config: &ProbeConfig, + status: TestStatus, + step: &str, + detail: &str, + error: &'static str, +) -> Result<()> { + let name = if detail.is_empty() { + step.to_owned() + } else { + format!("{step} {detail}") + }; + write_line( + output, + &config.output_style.test_result(status, &name, None, None), + error, + ) +} + fn write_line(output: &mut W, line: &str, error: &'static str) -> Result<()> { writeln!(output, "{line}").map_err(|_| anyhow!(error)) } diff --git a/crates/mcp/tests/probe.rs b/src/mcp/probe_tests.rs similarity index 95% rename from crates/mcp/tests/probe.rs rename to src/mcp/probe_tests.rs index 4fa63de..98f0e02 100644 --- a/crates/mcp/tests/probe.rs +++ b/src/mcp/probe_tests.rs @@ -6,14 +6,14 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use anyhow::{Result, anyhow}; -use async_trait::async_trait; -use cf_integration_mcp::GatewayTopology; -use cf_integration_mcp::backend_identity::BackendIdentity; -use cf_integration_mcp::probe::{ - ProbeConfig, ProbeRequest, ProbeResponse, ProbeTransport, run_probe, -}; use serde_json::{Value, json}; +use crate::OutputStyle; +use crate::mcp::GatewayTopology; +use crate::mcp::backend_identity::BackendIdentity; + +use super::{ProbeConfig, ProbeRequest, ProbeResponse, ProbeTransport, run_probe}; + const INITIALIZE_ID: u64 = 1; const TOOLS_LIST_ID: u64 = 2; const TOOL_CALL_ID: u64 = 3; @@ -54,7 +54,6 @@ impl FakeTransport { } } -#[async_trait] impl ProbeTransport for FakeTransport { async fn post(&self, request: ProbeRequest) -> Result { self.requests @@ -84,6 +83,7 @@ fn config() -> ProbeConfig { retry_interval: Duration::ZERO, request_timeout: Duration::from_secs(1), protocol_version: "2025-11-25".to_owned(), + output_style: OutputStyle::plain(), } } @@ -97,6 +97,7 @@ async fn controlplane_mode_uses_the_public_raw_mcp_route() { ]); let mut configured = config(); configured.mode = GatewayTopology::Direct; + configured.output_style = OutputStyle::colored(); let mut output = Vec::new(); run_probe(&transport, &configured, &mut output) @@ -109,6 +110,9 @@ async fn controlplane_mode_uses_the_public_raw_mcp_route() { .iter() .all(|request| request.url == "http://127.0.0.1:8080/mcp") ); + let output = String::from_utf8(output).expect("probe output should be UTF-8"); + assert!(output.contains("\x1b[32m PASS\x1b[0m auth_negative")); + assert!(output.contains("\x1b[33m SKIP\x1b[0m tool_call")); } #[tokio::test] @@ -260,13 +264,13 @@ async fn happy_path_uses_public_route_auth_session_and_deterministic_ids() { let output = String::from_utf8(output).expect("probe output should be UTF-8"); assert!(output.contains("probe url: http://127.0.0.1:8080/servers/server-123/mcp")); - assert!(output.contains("auth_negative=PASS status=401")); - assert!(output.contains("initialize=PASS status=200 session=present")); - assert!(output.contains("initialized=PASS status=202")); + assert!(output.contains("PASS auth_negative status=401")); + assert!(output.contains("PASS initialize status=200 session=present")); + assert!(output.contains("PASS initialized status=202")); assert!(!output.contains("session-abc")); - assert!(output.contains("tools_list=PASS count=1")); - assert!(output.contains("tool=fast_time_echo")); - assert!(output.contains("tool_call=PASS tool=fast_time_echo")); + assert!(output.contains("PASS tools_list count=1")); + assert!(!output.lines().any(|line| line == "tool=fast_time_echo")); + assert!(output.contains("PASS tool_call tool=fast_time_echo")); } #[tokio::test] @@ -284,7 +288,7 @@ async fn forbidden_unauthenticated_response_is_accepted_as_auth_rejection() { .expect("403 should be accepted as an unauthenticated rejection"); let output = String::from_utf8(output).expect("probe output should be UTF-8"); - assert!(output.contains("auth_negative=PASS status=403")); + assert!(output.contains("PASS auth_negative status=403")); } #[tokio::test] @@ -316,9 +320,9 @@ async fn stateless_happy_path_uses_discovery_request_metadata_and_no_session() { == "2026-07-28")); assert_eq!(requests[3].payload["params"]["name"], "fast_time_echo"); let output = String::from_utf8(output).expect("probe output should be UTF-8"); - assert!(output.contains("server_discover=PASS status=200 lifecycle=stateless")); - assert!(!output.contains("initialize=PASS")); - assert!(!output.contains("initialized=PASS")); + assert!(output.contains("PASS server_discover status=200 lifecycle=stateless")); + assert!(!output.contains("PASS initialize")); + assert!(!output.contains("PASS initialized")); } #[tokio::test] @@ -371,7 +375,7 @@ async fn authenticated_initialize_retries_transient_statuses_until_success() { assert_eq!(transport.requests().len(), 6); let output = String::from_utf8(output).expect("probe output should be UTF-8"); - assert_eq!(output.matches("initialize=RETRY").count(), 2); + assert!(!output.contains("RETRY initialize")); } #[tokio::test] @@ -396,7 +400,6 @@ struct AlwaysUnavailableTransport { attempts: AtomicUsize, } -#[async_trait] impl ProbeTransport for AlwaysUnavailableTransport { async fn post(&self, request: ProbeRequest) -> Result { self.attempts.fetch_add(1, Ordering::SeqCst); @@ -781,7 +784,7 @@ async fn malicious_suffix_tool_is_not_called() { assert_eq!(transport.requests().len(), 4); let output = String::from_utf8(output).expect("probe output should be UTF-8"); - assert!(output.contains("tool_call=SKIP no echo/get_system_time tool available")); + assert!(output.contains("SKIP tool_call no echo/get_system_time tool available")); } #[tokio::test] @@ -802,7 +805,7 @@ async fn server_id_is_percent_encoded_as_one_url_path_segment() { } #[tokio::test] -async fn tool_names_are_sanitized_before_writing_output() { +async fn uncalled_tool_names_are_not_written_to_output() { let transport = FakeTransport::new([ ProbeResponse::new(401, None, None), initialize_success(Some("session-output")), @@ -813,11 +816,11 @@ async fn tool_names_are_sanitized_before_writing_output() { run_probe(&transport, &config(), &mut output) .await - .expect("an unknown tool should complete after sanitizing its name"); + .expect("an unknown tool should complete without printing its name"); let output = String::from_utf8(output).expect("probe output should be UTF-8"); - assert!(output.contains("tool=custom\\nforged=PASS\\r")); - assert!(!output.contains("tool=custom\nforged=PASS")); + assert!(!output.contains("custom")); + assert!(!output.contains("forged=PASS")); } #[test] diff --git a/crates/mcp/src/mcp.rs b/src/mcp/protocol.rs similarity index 81% rename from crates/mcp/src/mcp.rs rename to src/mcp/protocol.rs index 21caa6c..d72ebca 100644 --- a/crates/mcp/src/mcp.rs +++ b/src/mcp/protocol.rs @@ -1,24 +1,26 @@ //! Shared MCP streamable-HTTP protocol helpers. use serde_json::{Map, Value, json}; +#[cfg(test)] use uuid::Uuid; -/// Legacy session-oriented MCP protocol version used by the control-plane lane. -pub const PROTOCOL_VERSION: &str = "2025-11-25"; +/// Latest MCP protocol version used when a workflow does not select one explicitly. +pub(crate) const PROTOCOL_VERSION: &str = "2026-07-28"; /// Stateless MCP protocol version used by the modern dataplane lane. -pub const STATELESS_PROTOCOL_VERSION: &str = "2026-07-28"; +pub(crate) const STATELESS_PROTOCOL_VERSION: &str = "2026-07-28"; /// Accepted MCP streamable-HTTP response media types. -pub const ACCEPT: &str = "application/json, text/event-stream"; +pub(crate) const ACCEPT: &str = "application/json, text/event-stream"; /// Builds a JSON-RPC request with a generated v4 UUID string ID. #[must_use] -pub fn jsonrpc(method: &str, params: Option) -> Value { +#[cfg(test)] +pub(crate) fn jsonrpc(method: &str, params: Option) -> Value { jsonrpc_with_id(method, params, Value::String(Uuid::new_v4().to_string())) } /// Builds a deterministic JSON-RPC request with an explicit ID. #[must_use] -pub fn jsonrpc_with_id(method: &str, params: Option, id: Value) -> Value { +pub(crate) fn jsonrpc_with_id(method: &str, params: Option, id: Value) -> Value { let mut payload = Map::new(); payload.insert("jsonrpc".to_owned(), Value::String("2.0".to_owned())); payload.insert("id".to_owned(), id); @@ -31,13 +33,13 @@ pub fn jsonrpc_with_id(method: &str, params: Option, id: Value) -> Value /// Returns whether a date-based MCP revision uses the stateless request lifecycle. #[must_use] -pub fn is_stateless_protocol(protocol_version: &str) -> bool { +pub(crate) fn is_stateless_protocol(protocol_version: &str) -> bool { protocol_version >= STATELESS_PROTOCOL_VERSION } /// Builds the mandatory per-request metadata for stateless MCP requests. #[must_use] -pub fn request_metadata(protocol_version: &str) -> Value { +pub(crate) fn request_metadata(protocol_version: &str) -> Value { json!({ "io.modelcontextprotocol/protocolVersion": protocol_version, "io.modelcontextprotocol/clientInfo": { @@ -50,7 +52,7 @@ pub fn request_metadata(protocol_version: &str) -> Value { /// Adds mandatory stateless metadata to an object-shaped request `params` value. #[must_use] -pub fn with_request_metadata(params: Option, protocol_version: &str) -> Value { +pub(crate) fn with_request_metadata(params: Option, protocol_version: &str) -> Value { let mut params = match params { Some(Value::Object(params)) => params, _ => Map::new(), @@ -70,7 +72,7 @@ pub fn with_request_metadata(params: Option, protocol_version: &str) -> V /// Builds a stateless JSON-RPC request with mandatory per-request metadata. #[must_use] -pub fn stateless_jsonrpc_with_id( +pub(crate) fn stateless_jsonrpc_with_id( method: &str, params: Option, id: Value, @@ -85,7 +87,7 @@ pub fn stateless_jsonrpc_with_id( /// Returns the MCP routing-name header value for name-targeted methods. #[must_use] -pub fn routing_name<'a>(method: &str, params: Option<&'a Value>) -> Option<&'a str> { +pub(crate) fn routing_name<'a>(method: &str, params: Option<&'a Value>) -> Option<&'a str> { let params = params?.as_object()?; match method { "tools/call" | "prompts/get" => params.get("name")?.as_str(), @@ -97,19 +99,21 @@ pub fn routing_name<'a>(method: &str, params: Option<&'a Value>) -> Option<&'a s /// Builds an MCP initialize request with a generated v4 UUID string ID. #[must_use] -pub fn initialize() -> Value { +#[cfg(test)] +pub(crate) fn initialize() -> Value { initialize_with_id(Value::String(Uuid::new_v4().to_string())) } /// Builds a deterministic MCP initialize request with an explicit ID. #[must_use] -pub fn initialize_with_id(id: Value) -> Value { +#[cfg(test)] +pub(crate) fn initialize_with_id(id: Value) -> Value { initialize_with_id_and_version(id, PROTOCOL_VERSION) } /// Builds a deterministic MCP initialize request for an explicit protocol version. #[must_use] -pub fn initialize_with_id_and_version(id: Value, protocol_version: &str) -> Value { +pub(crate) fn initialize_with_id_and_version(id: Value, protocol_version: &str) -> Value { jsonrpc_with_id( "initialize", Some(json!({ @@ -133,7 +137,7 @@ pub fn initialize_with_id_and_version(id: Value, protocol_version: &str) -> Valu /// # Errors /// /// Returns the JSON parser error for a non-empty malformed JSON response. -pub fn parse_mcp_body(body: &str, content_type: &str) -> serde_json::Result> { +pub(crate) fn parse_mcp_body(body: &str, content_type: &str) -> serde_json::Result> { if body.is_empty() { return Ok(None); } @@ -181,7 +185,7 @@ fn flush_sse_event(event_data: &mut String, has_data: &mut bool, message: &mut O /// Returns arguments for tool names the integration harness knows how to call. #[must_use] -pub fn tool_call_args(tool_name: &str) -> Option { +pub(crate) fn tool_call_args(tool_name: &str) -> Option { match tool_name { "echo" | "fast_time_echo" | "fast-time-echo" => Some(json!({"message": "cf-integration"})), "get_system_time" diff --git a/crates/mcp/tests/mcp.rs b/src/mcp/protocol_integration_tests.rs similarity index 98% rename from crates/mcp/tests/mcp.rs rename to src/mcp/protocol_integration_tests.rs index c248693..3a85540 100644 --- a/crates/mcp/tests/mcp.rs +++ b/src/mcp/protocol_integration_tests.rs @@ -1,4 +1,4 @@ -use cf_integration_mcp::mcp::{ +use cf_integration::mcp::protocol::{ ACCEPT, PROTOCOL_VERSION, STATELESS_PROTOCOL_VERSION, initialize, initialize_with_id, initialize_with_id_and_version, is_stateless_protocol, jsonrpc, jsonrpc_with_id, parse_mcp_body, routing_name, stateless_jsonrpc_with_id, tool_call_args, @@ -8,7 +8,7 @@ use uuid::Uuid; #[test] fn protocol_constants_match_the_streamable_http_contract() { - assert_eq!(PROTOCOL_VERSION, "2025-11-25"); + assert_eq!(PROTOCOL_VERSION, "2026-07-28"); assert_eq!(STATELESS_PROTOCOL_VERSION, "2026-07-28"); assert_eq!(ACCEPT, "application/json, text/event-stream"); } @@ -95,7 +95,7 @@ fn deterministic_initialize_has_exact_client_payload() { "id": "init-1", "method": "initialize", "params": { - "protocolVersion": "2025-11-25", + "protocolVersion": "2026-07-28", "capabilities": {}, "clientInfo": { "name": "cf-integration", diff --git a/crates/mcp/src/topology.rs b/src/mcp/topology.rs similarity index 56% rename from crates/mcp/src/topology.rs rename to src/mcp/topology.rs index 50cb2ee..68eecf4 100644 --- a/crates/mcp/src/topology.rs +++ b/src/mcp/topology.rs @@ -1,6 +1,6 @@ /// Public MCP route shape and backend identity expectation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GatewayTopology { +pub(crate) enum GatewayTopology { /// `/mcp` routes directly to the control plane. Direct, /// `/servers/{virtual_host_id}/mcp` routes through the dataplane. @@ -10,16 +10,7 @@ pub enum GatewayTopology { impl GatewayTopology { /// Returns whether responses require trusted dataplane backend identity. #[must_use] - pub const fn requires_dataplane(self) -> bool { + pub(crate) const fn requires_dataplane(self) -> bool { matches!(self, Self::Dataplane) } - - /// Returns the stable user-facing stack label. - #[must_use] - pub const fn report_label(self) -> &'static str { - match self { - Self::Direct => "controlplane", - Self::Dataplane => "dataplane", - } - } } diff --git a/src/output.rs b/src/output.rs index c4f0f89..bbf33a3 100644 --- a/src/output.rs +++ b/src/output.rs @@ -2,6 +2,9 @@ use std::ffi::OsStr; use std::io::IsTerminal as _; +use std::time::Duration; + +use indicatif::{ProgressBar, ProgressStyle}; const ANSI_RESET: &str = "\x1b[0m"; const ANSI_CYAN: &str = "\x1b[36m"; @@ -16,77 +19,234 @@ const ANSI_BOLD_MAGENTA: &str = "\x1b[1;35m"; /// Styles human-readable output according to the target terminal and color environment. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct OutputStyle { +pub(crate) struct OutputStyle { color: bool, } +/// Human-readable status for one test or scenario. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TestStatus { + Pass, + ExpectedFailure, + UnexpectedPass, + Fail, + Skip, + Unknown, +} + +impl TestStatus { + const fn label(self) -> &'static str { + match self { + Self::Pass => "PASS", + Self::ExpectedFailure => "XFAIL", + Self::UnexpectedPass => "XPASS", + Self::Fail => "FAIL", + Self::Skip => "SKIP", + Self::Unknown => "UNKNOWN", + } + } +} + +/// One command or quiet phase whose lifecycle is shown on standard error. +pub(crate) struct Activity { + description: String, + finished: bool, + spinner: Option, +} + +impl Activity { + /// Prints an immediate loading line. + pub(crate) fn start(description: impl Into) -> Self { + let activity = Self { + description: description.into(), + finished: false, + spinner: None, + }; + eprintln!( + "{}", + render_activity_line( + ActivityState::Running, + &activity.description, + OutputStyle::stderr() + ) + ); + activity + } + + /// Starts a continuously animated loading line on terminals. + pub(crate) fn spinner(description: impl Into) -> Self { + let description = description.into(); + if !std::io::stderr().is_terminal() { + return Self::start(description); + } + + let spinner = ProgressBar::new_spinner(); + let template = if OutputStyle::stderr().color { + "{spinner:.cyan} {msg}" + } else { + "{spinner} {msg}" + }; + let style = ProgressStyle::with_template(template) + .expect("activity spinner template must be valid") + .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "); + spinner.set_style(style); + spinner.set_message(description.clone()); + spinner.enable_steady_tick(Duration::from_millis(80)); + + Self { + description, + finished: false, + spinner: Some(spinner), + } + } + + /// Prints a completed phase that did not need a loading line. + pub(crate) fn completed(description: impl AsRef) { + eprintln!( + "{}", + render_activity_line( + ActivityState::Succeeded, + description.as_ref(), + OutputStyle::stderr(), + ) + ); + } + + /// Prints the same description with a green check or red cross. + pub(crate) fn finish(mut self, succeeded: bool) { + if let Some(spinner) = &self.spinner { + spinner.finish_and_clear(); + } + let state = if succeeded { + ActivityState::Succeeded + } else { + ActivityState::Failed + }; + eprintln!( + "{}", + render_activity_line(state, &self.description, OutputStyle::stderr()) + ); + self.finished = true; + } +} + +impl Drop for Activity { + fn drop(&mut self) { + if !self.finished { + if let Some(spinner) = &self.spinner { + spinner.finish_and_clear(); + } + eprintln!( + "{}", + render_activity_line( + ActivityState::Failed, + &self.description, + OutputStyle::stderr(), + ) + ); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActivityState { + Running, + Succeeded, + Failed, +} + impl OutputStyle { /// Resolves styling for standard output. #[must_use] - pub fn stdout() -> Self { + pub(crate) fn stdout() -> Self { Self::resolve(std::io::stdout().is_terminal()) } /// Resolves styling for standard error. #[must_use] - pub fn stderr() -> Self { + pub(crate) fn stderr() -> Self { Self::resolve(std::io::stderr().is_terminal()) } /// Styles informational text. #[must_use] - pub fn info(self, text: &str) -> String { + pub(crate) fn info(self, text: &str) -> String { self.ansi(text, ANSI_CYAN) } /// Styles a prominent informational heading. #[must_use] - pub fn heading(self, text: &str) -> String { + pub(crate) fn heading(self, text: &str) -> String { self.ansi(text, ANSI_BOLD_CYAN) } /// Styles successful output. #[must_use] - pub fn success(self, text: &str) -> String { + pub(crate) fn success(self, text: &str) -> String { self.ansi(text, ANSI_GREEN) } /// Styles a prominent success summary. #[must_use] - pub fn success_heading(self, text: &str) -> String { + pub(crate) fn success_heading(self, text: &str) -> String { self.ansi(text, ANSI_BOLD_GREEN) } /// Styles failure output. #[must_use] - pub fn failure(self, text: &str) -> String { + pub(crate) fn failure(self, text: &str) -> String { self.ansi(text, ANSI_RED) } /// Styles a prominent failure summary. #[must_use] - pub fn failure_heading(self, text: &str) -> String { + pub(crate) fn failure_heading(self, text: &str) -> String { self.ansi(text, ANSI_BOLD_RED) } /// Styles warning or skipped output. #[must_use] - pub fn warning(self, text: &str) -> String { + pub(crate) fn warning(self, text: &str) -> String { self.ansi(text, ANSI_YELLOW) } /// Styles output whose result is unknown. #[must_use] - pub fn unknown(self, text: &str) -> String { + pub(crate) fn unknown(self, text: &str) -> String { self.ansi(text, ANSI_MAGENTA) } /// Styles a prominent summary whose result is unknown. #[must_use] - pub fn unknown_heading(self, text: &str) -> String { + pub(crate) fn unknown_heading(self, text: &str) -> String { self.ansi(text, ANSI_BOLD_MAGENTA) } + /// Renders one aligned, nextest-style test result line. + #[must_use] + pub(crate) fn test_result( + self, + status: TestStatus, + name: &str, + elapsed: Option, + position: Option<(usize, usize)>, + ) -> String { + let label = format!("{:>12}", status.label()); + let label = match status { + TestStatus::Pass => self.success(&label), + TestStatus::ExpectedFailure | TestStatus::Skip => self.warning(&label), + TestStatus::UnexpectedPass | TestStatus::Fail => self.failure(&label), + TestStatus::Unknown => self.unknown(&label), + }; + let elapsed = elapsed.map_or_else(String::new, |elapsed| { + format!(" [{:>8.3}s]", elapsed.as_secs_f64()) + }); + let position = position.map_or_else(String::new, |(current, total)| { + format!(" ({current}/{total})") + }); + format!("{label}{elapsed}{position} {name}") + } + #[cfg(test)] pub(crate) const fn plain() -> Self { Self { color: false } @@ -116,6 +276,15 @@ impl OutputStyle { } } +fn render_activity_line(state: ActivityState, description: &str, style: OutputStyle) -> String { + let marker = match state { + ActivityState::Running => style.info("⠋"), + ActivityState::Succeeded => style.success("✓"), + ActivityState::Failed => style.failure("✗"), + }; + format!("{marker} {description}") +} + fn resolve_color( stream_is_terminal: bool, no_color: bool, @@ -165,4 +334,52 @@ mod tests { assert_eq!(style.warning("SKIP"), "\x1b[33mSKIP\x1b[0m"); assert_eq!(style.unknown("UNKNOWN"), "\x1b[35mUNKNOWN\x1b[0m"); } + + #[test] + fn activity_completion_repeats_the_loading_description_with_semantic_color() { + let style = OutputStyle::colored(); + + assert_eq!( + render_activity_line(ActivityState::Running, "run probe", style), + "\x1b[36m⠋\x1b[0m run probe" + ); + assert_eq!( + render_activity_line(ActivityState::Succeeded, "run probe", style), + "\x1b[32m✓\x1b[0m run probe" + ); + assert_eq!( + render_activity_line(ActivityState::Failed, "run probe", style), + "\x1b[31m✗\x1b[0m run probe" + ); + } + + #[test] + fn nextest_style_statuses_use_expected_result_colors() { + let style = OutputStyle::colored(); + + assert_eq!( + style.test_result( + TestStatus::Pass, + "suite::passes", + Some(Duration::from_millis(25)), + Some((1, 4)), + ), + "\x1b[32m PASS\x1b[0m [ 0.025s] (1/4) suite::passes" + ); + assert!( + style + .test_result(TestStatus::ExpectedFailure, "known", None, None) + .starts_with("\x1b[33m XFAIL\x1b[0m") + ); + assert!( + style + .test_result(TestStatus::UnexpectedPass, "stale", None, None) + .starts_with("\x1b[31m XPASS\x1b[0m") + ); + assert!( + style + .test_result(TestStatus::Fail, "unexpected", None, None) + .starts_with("\x1b[31m FAIL\x1b[0m") + ); + } } diff --git a/crates/load/src/locust.rs b/src/performance/locust.rs similarity index 87% rename from crates/load/src/locust.rs rename to src/performance/locust.rs index 8a39679..bdc4afd 100644 --- a/crates/load/src/locust.rs +++ b/src/performance/locust.rs @@ -7,14 +7,12 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use cf_integration_platform::StackMode; -use cf_integration_platform::compose::ComposeProject; -use cf_integration_platform::config::AppConfig; -use cf_integration_platform::process::CommandSpec; +use crate::infrastructure::StackMode; +use crate::infrastructure::compose::ComposeProject; +use crate::infrastructure::config::AppConfig; +use crate::infrastructure::process::CommandSpec; -use cf_integration_mcp::mcp::PROTOCOL_VERSION; - -use crate::LoadSettings; +use crate::performance::LoadSettings; const LOCUST_ADAPTER_NAME: &str = "locustfile_mcp.py"; const LOCUST_ADAPTER_CONTAINER_PATH: &str = "/mnt/locust-cf/locustfile_mcp.py"; @@ -24,47 +22,19 @@ const REQUEST_TIMEOUT_ERROR: &str = "LOCUST_REQUEST_TIMEOUT_SECONDS must be a finite number greater than zero"; /// Prepared Docker Compose Locust invocation and its host report directory. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct LocustCommand { +pub(crate) struct LocustCommand { command: CommandSpec, report_dir: PathBuf, } impl LocustCommand { - /// Builds a mode-specific Locust invocation and creates its report directory. - /// - /// The caller supplies an already-generated bearer token. This boundary - /// never mints credentials or writes them to disk. - /// - /// # Errors - /// - /// Returns an error for an empty token, a missing dataplane server ID, an - /// invalid request timeout, or when the report directory cannot be created. - pub fn new( - config: &AppConfig, - mode: StackMode, - settings: &LoadSettings, - bearer_token: &str, - server_id: Option<&str>, - ) -> Result { - let protocol_version = - configured_text(config, "MCP_PROTOCOL_VERSION").unwrap_or(PROTOCOL_VERSION); - Self::new_with_protocol_version( - config, - mode, - settings, - bearer_token, - server_id, - protocol_version, - ) - } - /// Builds a Locust invocation with an explicit MCP protocol version. /// /// # Errors /// - /// Returns the same errors as [`Self::new`] and rejects an empty protocol - /// version. - pub fn new_with_protocol_version( + /// Rejects an empty token or protocol version, a missing dataplane server + /// ID, an invalid timeout, or an inaccessible report directory. + pub(crate) fn new_with_protocol_version( config: &AppConfig, mode: StackMode, settings: &LoadSettings, @@ -89,7 +59,8 @@ impl LocustCommand { }; let report_dir = config .integration_dir() - .join("reports/load") + .join("reports") + .join("load") .join(mode_name) .join("locust"); fs::create_dir_all(&report_dir).with_context(|| { @@ -99,13 +70,13 @@ impl LocustCommand { let volume = volume_argument(&report_dir); let project = match mode { StackMode::Dataplane => ComposeProject::dataplane( - config.root(), + config.asset_root(), config.controlplane_dir(), config.integration_project().value.clone(), !config.dataplane_ref().value.is_empty(), ), StackMode::Controlplane => ComposeProject::controlplane( - config.root(), + config.asset_root(), config.controlplane_dir(), config.controlplane_project().value.clone(), controlplane_sso_enabled(config), @@ -124,7 +95,7 @@ impl LocustCommand { OsString::from("locust"), ]), StackMode::Controlplane => { - let adapter_volume = adapter_volume_argument(config.root()); + let adapter_volume = adapter_volume_argument(config.asset_root()); let arguments = vec![ OsString::from("run"), OsString::from("--rm"), @@ -162,7 +133,7 @@ impl LocustCommand { }; let mut command = command - .env("CF_INTEGRATION_ROOT", config.root().as_os_str()) + .env("CF_INTEGRATION_ROOT", config.asset_root().as_os_str()) .env("CF_INTEGRATION_DIR", config.integration_dir().as_os_str()) .env("MCP_STACK_MODE", mode_name) .env("MCPGATEWAY_BEARER_TOKEN", bearer_token) @@ -190,13 +161,13 @@ impl LocustCommand { } /// Returns the child-process specification. - pub fn command(&self) -> &CommandSpec { + pub(crate) fn command(&self) -> &CommandSpec { &self.command } /// Returns the host directory receiving HTML and CSV reports. #[must_use] - pub fn report_dir(&self) -> &Path { + pub(crate) fn report_dir(&self) -> &Path { &self.report_dir } } @@ -208,7 +179,7 @@ impl LocustCommand { /// Returns an error after attempting all removals when an artifact cannot be inspected, /// a tainted artifact cannot be removed, or at least one credential-bearing artifact was /// removed. -pub fn audit_reports(report_dir: &Path, bearer_token: &str) -> Result<()> { +pub(crate) fn audit_reports(report_dir: &Path, bearer_token: &str) -> Result<()> { let mut files = Vec::new(); let mut first_inspection_error = None; collect_report_files(report_dir, &mut files, &mut first_inspection_error); diff --git a/crates/load/tests/load_locust.rs b/src/performance/locust_integration_tests.rs similarity index 83% rename from crates/load/tests/load_locust.rs rename to src/performance/locust_integration_tests.rs index 728ef9b..e8337d9 100644 --- a/crates/load/tests/load_locust.rs +++ b/src/performance/locust_integration_tests.rs @@ -3,9 +3,11 @@ use std::ffi::{OsStr, OsString}; use std::fs; use std::path::Path; -use cf_integration_load::{LoadRequest, LoadSettings, LocustCommand}; -use cf_integration_platform::StackMode; -use cf_integration_platform::config::{AppConfig, Environment}; +use cf_integration::infrastructure::StackMode; +use cf_integration::infrastructure::config::{ + AppConfig, ConfigBootstrap, ConfigRequirements, Environment, +}; +use cf_integration::performance::{LoadRequest, LoadSettings, LocustCommand}; use tempfile::TempDir; fn environment(values: &[(&str, &str)]) -> Environment { @@ -34,9 +36,8 @@ fn repository_root(dotenv: Option<&str>) -> TempDir { } fn config(root: &Path, process: &Environment) -> AppConfig { - AppConfig::load(process, &root.join("target/debug/cf-integration"), root) - .expect("application config should load") - .config + let bootstrap = ConfigBootstrap::load(process, root).expect("bootstrap should load"); + AppConfig::load(bootstrap, ConfigRequirements::RUNTIME).expect("application config should load") } fn args(smoke: bool) -> LoadRequest { @@ -153,17 +154,23 @@ fn dataplane_locust_command_has_exact_compose_shape_and_environment() { let config = config(root.path(), &Environment::new()); let settings = LoadSettings::resolve(&config, &args(false)).expect("settings should resolve"); - let run = LocustCommand::new( + let run = LocustCommand::new_with_protocol_version( &config, StackMode::Dataplane, &settings, "scoped.jwt.value", Some("server-id"), + "2025-11-25", ) .expect("dataplane Locust command should build"); let integration_dir = root.path().join(".integration"); - let report_dir = integration_dir.join("reports/load/dataplane/locust"); + let asset_root = config.asset_root(); + let report_dir = integration_dir + .join("reports") + .join("load") + .join("dataplane") + .join("locust"); assert_eq!(run.report_dir(), report_dir); assert!(run.report_dir().is_dir()); assert_eq!( @@ -174,19 +181,23 @@ fn dataplane_locust_command_has_exact_compose_shape_and_environment() { OsString::from("cf"), OsString::from("-f"), integration_dir - .join("mcp-context-forge/docker-compose.yml") + .join("mcp-context-forge") + .join("docker-compose.yml") .into_os_string(), OsString::from("-f"), - root.path() - .join("docker/docker-compose.cf-controlplane-build-labels.yaml") + asset_root + .join("docker") + .join("docker-compose.cf-controlplane-build-labels.yaml") .into_os_string(), OsString::from("-f"), - root.path() - .join("docker/docker-compose.cf-dataplane.yaml") + asset_root + .join("docker") + .join("docker-compose.cf-dataplane.yaml") .into_os_string(), OsString::from("-f"), - root.path() - .join("docker/docker-compose.cf-integration.yaml") + asset_root + .join("docker") + .join("docker-compose.cf-integration.yaml") .into_os_string(), OsString::from("--profile"), OsString::from("testing"), @@ -201,7 +212,7 @@ fn dataplane_locust_command_has_exact_compose_shape_and_environment() { let expected_environment = HashMap::from([ ("CF_INTEGRATION_DIR", integration_dir.as_os_str()), - ("CF_INTEGRATION_ROOT", root.path().as_os_str()), + ("CF_INTEGRATION_ROOT", asset_root.as_os_str()), ("LOCUST_LOCUSTFILE", OsStr::new("locustfile_mcp.py")), ("LOCUST_MODE", OsStr::new("headless")), ("LOCUST_REQUEST_TIMEOUT_SECONDS", OsStr::new("60")), @@ -315,14 +326,15 @@ fn controlplane_uses_the_same_harness_mcp_adapter_and_does_not_require_server_id ); } assert!(arguments.contains(&OsString::from("/mnt/locust-cf/locustfile_mcp.py"))); + let mut expected_adapter_mount = config + .asset_root() + .join("scripts") + .join("locustfile_mcp.py") + .into_os_string(); + expected_adapter_mount.push(":/mnt/locust-cf/locustfile_mcp.py:ro"); let adapter_mount = arguments .windows(2) - .find(|pair| { - pair[0] == "--volume" - && pair[1] - .to_string_lossy() - .ends_with("/scripts/locustfile_mcp.py:/mnt/locust-cf/locustfile_mcp.py:ro") - }) + .find(|pair| pair[0] == "--volume" && pair[1] == expected_adapter_mount) .expect("control-plane command should mount the harness MCP adapter"); assert_eq!(adapter_mount[0], "--volume"); assert!(arguments.contains(&OsString::from("--only-summary"))); @@ -339,8 +351,15 @@ fn locust_request_timeout_rejects_empty_non_finite_and_non_positive_values() { let settings = LoadSettings::resolve(&config, &args(false)).expect("load settings should resolve"); - let error = LocustCommand::new(&config, StackMode::Controlplane, &settings, "token", None) - .expect_err("invalid request timeout should fail before launch"); + let error = LocustCommand::new_with_protocol_version( + &config, + StackMode::Controlplane, + &settings, + "token", + None, + "2025-11-25", + ) + .expect_err("invalid request timeout should fail before launch"); assert!( error.to_string().contains( @@ -357,13 +376,26 @@ fn dataplane_requires_nonempty_server_id_and_all_modes_require_a_token() { let config = config(root.path(), &Environment::new()); let settings = LoadSettings::resolve(&config, &args(false)).expect("settings should resolve"); - let missing_server = - LocustCommand::new(&config, StackMode::Dataplane, &settings, "token", None) - .expect_err("dataplane server ID should be required"); + let missing_server = LocustCommand::new_with_protocol_version( + &config, + StackMode::Dataplane, + &settings, + "token", + None, + "2025-11-25", + ) + .expect_err("dataplane server ID should be required"); assert!(missing_server.to_string().contains("server ID")); - let missing_token = LocustCommand::new(&config, StackMode::Controlplane, &settings, "", None) - .expect_err("bearer token should be required"); + let missing_token = LocustCommand::new_with_protocol_version( + &config, + StackMode::Controlplane, + &settings, + "", + None, + "2025-11-25", + ) + .expect_err("bearer token should be required"); assert!(missing_token.to_string().contains("bearer token")); } diff --git a/src/performance/mod.rs b/src/performance/mod.rs new file mode 100644 index 0000000..cc3a028 --- /dev/null +++ b/src/performance/mod.rs @@ -0,0 +1,7 @@ +//! Locust load-testing primitives. + +mod locust; +mod settings; + +pub(crate) use locust::{LocustCommand, audit_reports as audit_locust_reports}; +pub(crate) use settings::{LoadRequest, LoadSettings}; diff --git a/crates/load/tests/python_adapters.rs b/src/performance/python_adapter_tests.rs similarity index 96% rename from crates/load/tests/python_adapters.rs rename to src/performance/python_adapter_tests.rs index 025be35..a8d546c 100644 --- a/crates/load/tests/python_adapters.rs +++ b/src/performance/python_adapter_tests.rs @@ -17,11 +17,7 @@ fn scripts_dir() -> PathBuf { } fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("load crate should be nested under the workspace root") - .to_path_buf() + Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() } #[test] @@ -41,7 +37,7 @@ 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:-2025-11-25}")); + assert!(compose.contains("MCP_PROTOCOL_VERSION=${MCP_PROTOCOL_VERSION:-2026-07-28}")); assert!( compose.contains("LOCUST_REQUEST_TIMEOUT_SECONDS=${LOCUST_REQUEST_TIMEOUT_SECONDS:-60}") ); @@ -84,7 +80,7 @@ fn locust_adapter_imports_without_the_removed_helper_and_handles_mcp_bodies() { import json import locustfile_mcp as adapter -assert adapter.PROTOCOL_VERSION == "2025-11-25" +assert adapter.PROTOCOL_VERSION == "2026-07-28" assert adapter.ACCEPT == "application/json, text/event-stream" assert adapter.REQUEST_TIMEOUT_SECONDS == 60.0 assert adapter.mcp_path() == "/servers/server%2Fid/mcp" @@ -120,10 +116,10 @@ for unsafe in ("delete_everything_echo", "prefix-get_system_time", "shell"): user = adapter.MCPGatewayUser.__new__(adapter.MCPGatewayUser) user._session_id = None initialize_headers = user._headers(include_protocol_version=False) -assert "Mcp-Protocol-Version" not in initialize_headers +assert initialize_headers["Mcp-Protocol-Version"] == "2026-07-28" user._session_id = "session-id" request_headers = user._headers() -assert request_headers["Mcp-Protocol-Version"] == "2025-11-25" +assert request_headers["Mcp-Protocol-Version"] == "2026-07-28" assert request_headers["Mcp-Session-Id"] == "session-id" class Total: @@ -142,6 +138,7 @@ assert empty_environment.process_exit_code == 1 "#; let output = Command::new(python()) + .env("PYTHONDONTWRITEBYTECODE", "1") .arg("-c") .arg(code) .env("PYTHONPATH", python_path) @@ -249,6 +246,7 @@ adapter.validate_result("server/discover", { "#; let output = Command::new(python()) + .env("PYTHONDONTWRITEBYTECODE", "1") .arg("-c") .arg(code) .env("PYTHONPATH", python_path) @@ -328,12 +326,14 @@ assert user.client.timeouts == [ "#; let output = Command::new(python()) + .env("PYTHONDONTWRITEBYTECODE", "1") .arg("-c") .arg(code) .env("PYTHONPATH", &python_path) .env("MCP_SERVER_ID", "server-id") .env("MCPGATEWAY_BEARER_TOKEN", "token") .env("LOCUST_REQUEST_TIMEOUT_SECONDS", "2.5") + .env("MCP_PROTOCOL_VERSION", "2025-11-25") .output() .expect("Python adapter timeout check should run"); @@ -346,6 +346,7 @@ assert user.client.timeouts == [ for invalid in ["", "0", "-1", "nan", "inf"] { let output = Command::new(python()) + .env("PYTHONDONTWRITEBYTECODE", "1") .arg("-c") .arg("import locustfile_mcp") .env("PYTHONPATH", &python_path) @@ -450,6 +451,7 @@ assert user.client.response.failures and user.client.response.successes == 0 "#; let output = Command::new(python()) + .env("PYTHONDONTWRITEBYTECODE", "1") .arg("-c") .arg(code) .env("PYTHONPATH", python_path) @@ -536,6 +538,7 @@ assert response.successes == 1 and not response.failures "#; let output = Command::new(python()) + .env("PYTHONDONTWRITEBYTECODE", "1") .arg("-c") .arg(code) .env("PYTHONPATH", python_path) diff --git a/crates/load/src/settings.rs b/src/performance/settings.rs similarity index 90% rename from crates/load/src/settings.rs rename to src/performance/settings.rs index 87a79f9..a21965f 100644 --- a/crates/load/src/settings.rs +++ b/src/performance/settings.rs @@ -3,8 +3,8 @@ use std::ffi::OsStr; use std::num::NonZeroUsize; +use crate::infrastructure::config::{AppConfig, SourcedValue, ValueOrigin}; use anyhow::{Context, Result, bail}; -use cf_integration_platform::config::{AppConfig, SourcedValue, ValueOrigin}; const SMOKE_USERS: &str = "1"; const SMOKE_SPAWN_RATE: &str = "1"; @@ -13,20 +13,20 @@ const RUN_TIME_ERROR: &str = "LOCUST_RUN_TIME must be a positive Locust duration /// User-selected settings before configuration precedence is applied. #[derive(Debug, Clone, PartialEq)] -pub struct LoadRequest { +pub(crate) struct LoadRequest { /// Whether dotenv/default values should use smoke-test replacements. - pub smoke: bool, + pub(crate) smoke: bool, /// Explicit concurrent-user override. - pub users: Option, + pub(crate) users: Option, /// Explicit users-per-second override. - pub spawn_rate: Option, + pub(crate) spawn_rate: Option, /// Explicit engine duration override. - pub run_time: Option, + pub(crate) run_time: Option, } /// Validated load settings after applying CLI, process, dotenv, and default precedence. #[derive(Debug, Clone, PartialEq)] -pub struct LoadSettings { +pub(crate) struct LoadSettings { users: NonZeroUsize, spawn_rate: f64, run_time: String, @@ -43,7 +43,7 @@ impl LoadSettings { /// /// Returns an error for a zero or malformed user count, a non-finite or /// non-positive spawn rate, or an invalid Locust run-time expression. - pub fn resolve(config: &AppConfig, request: &LoadRequest) -> Result { + pub(crate) fn resolve(config: &AppConfig, request: &LoadRequest) -> Result { let users = match request.users { Some(users) => users, None => parse_users(selected_value( @@ -85,19 +85,19 @@ impl LoadSettings { /// Returns the concurrent user count. #[must_use] - pub fn users(&self) -> NonZeroUsize { + pub(crate) fn users(&self) -> NonZeroUsize { self.users } /// Returns the users spawned per second. #[must_use] - pub fn spawn_rate(&self) -> f64 { + pub(crate) fn spawn_rate(&self) -> f64 { self.spawn_rate } /// Returns the validated duration expression. #[must_use] - pub fn run_time(&self) -> &str { + pub(crate) fn run_time(&self) -> &str { &self.run_time } } diff --git a/src/runtime/compliance.rs b/src/runtime/compliance.rs deleted file mode 100644 index f248c1a..0000000 --- a/src/runtime/compliance.rs +++ /dev/null @@ -1,1025 +0,0 @@ -//! Official conformance orchestration. - -use super::*; -use std::fmt::Write as _; -use std::io::{IsTerminal, Write as IoWrite}; -use std::time::Instant; - -use cf_integration_compliance::conformance::{DEFAULT_CONFORMANCE_SUITE, ScenarioOutcome}; - -const CONFORMANCE_SERVER_ERA_ENV: &str = "CF_CONFORMANCE_SERVER_ERA"; - -struct ConformanceProgress { - task: Option>, - terminal: bool, -} - -impl ConformanceProgress { - fn start(description: impl Into) -> Self { - let description = description.into(); - let terminal = std::io::stderr().is_terminal(); - if !terminal { - return Self { - task: None, - terminal, - }; - } - - let task = tokio::spawn(async move { - const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let mut frame = 0; - loop { - { - let mut stderr = std::io::stderr().lock(); - let _ = write!( - stderr, - "\r\x1b[2KConformance {} {description}", - FRAMES[frame % FRAMES.len()] - ); - let _ = stderr.flush(); - } - frame += 1; - tokio::time::sleep(Duration::from_millis(150)).await; - } - }); - Self { - task: Some(task), - terminal, - } - } -} - -impl Drop for ConformanceProgress { - fn drop(&mut self) { - if let Some(task) = self.task.take() { - task.abort(); - } - if self.terminal { - let mut stderr = std::io::stderr().lock(); - let _ = write!(stderr, "\r\x1b[2K"); - let _ = stderr.flush(); - } - } -} - -impl RuntimeExecutor { - fn require_loopback_fixture_base_url(&self) -> AppResult<()> { - let base_url = self.base_url()?; - let url = url::Url::parse(base_url) - .context("MCP_CLI_BASE_URL is not a valid URL") - .map_err(AppFailure::from)?; - let is_loopback = match url.host() { - Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), - Some(url::Host::Ipv4(address)) => address.is_loopback(), - Some(url::Host::Ipv6(address)) => address.is_loopback(), - None => false, - }; - if !is_loopback { - return Err(AppFailure::from(anyhow!( - "official conformance requires a loopback MCP_CLI_BASE_URL" - ))); - } - Ok(()) - } - - pub(super) async fn start_conformance_service( - &self, - topology: StackMode, - server_era: ConformanceServerEra, - ) -> AppResult<()> { - let project = self.conformance_compose_project(topology); - let build = project.command(["build", OFFICIAL_CONFORMANCE_SERVICE]); - let build = self - .compose_environment(build, topology, true)? - .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); - self.runner.run_async(&build).await?; - - let up = project.command([ - "up", - "-d", - "--wait", - OFFICIAL_CONFORMANCE_SERVICE, - OFFICIAL_CONFORMANCE_PROXY_SERVICE, - ]); - let up = self - .compose_environment(up, topology, true)? - .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); - 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)?; - let output = self.runner.capture_stdout(&command)?; - 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)?; - self.runner - .run_async(&remove) - .await - .map_err(AppFailure::from) - } - - pub(super) async fn execute_conformance(&self, action: ConformanceAction) -> AppResult<()> { - match action { - ConformanceAction::Run { - lanes, - spec_version, - server_era, - results_dir, - } => { - let artifact_root = results_dir - .as_deref() - .unwrap_or_else(|| self.config.integration_dir()); - let setup_log = artifact_root.join("conformance/setup.log"); - if let Some(parent) = setup_log.parent() { - fs::create_dir_all(parent) - .with_context(|| { - format!("failed to create conformance log directory {parent:?}") - }) - .map_err(AppFailure::from)?; - } - fs::write(&setup_log, []) - .with_context(|| format!("failed to clear conformance log {setup_log:?}")) - .map_err(AppFailure::from)?; - let quiet_runner = LoggingProcessRunner::new(&self.runner, &setup_log); - let executor = RuntimeExecutor::new(self.config.clone(), quiet_runner); - let result = executor - .run_conformance(&lanes, &spec_version, server_era, results_dir.as_deref()) - .await; - println!( - "{} {}", - OutputStyle::stdout().info(" Setup output"), - setup_log.display() - ); - result - } - ConformanceAction::Report { - results_dir, - output_dir, - } => self.regenerate_conformance_report(results_dir.as_deref(), output_dir.as_deref()), - } - } - - async fn run_conformance( - &self, - lanes: &[ConformanceTarget], - spec_version: &str, - server_era: ConformanceServerEra, - results_dir: Option<&Path>, - ) -> AppResult<()> { - self.run_conformance_with_interrupt(lanes, spec_version, server_era, results_dir, async { - if tokio::signal::ctrl_c().await.is_err() { - std::future::pending::<()>().await; - } - }) - .await - } - - async fn run_conformance_with_interrupt( - &self, - lanes: &[ConformanceTarget], - spec_version: &str, - server_era: ConformanceServerEra, - results_dir: Option<&Path>, - interrupt: I, - ) -> AppResult<()> - where - I: Future, - { - if lanes.is_empty() { - return Err(AppFailure::from(anyhow!( - "at least one conformance lane must be selected" - ))); - } - expected_server_scenarios(DEFAULT_CONFORMANCE_SUITE, spec_version) - .map_err(AppFailure::from)?; - self.require_loopback_fixture_base_url()?; - - let paths = CompliancePaths::new( - results_dir.unwrap_or_else(|| self.config.integration_dir()), - self.config.root().join("reports"), - ); - paths.clear_conformance()?; - - let topologies = conformance_topologies(lanes); - let run_direct = lanes.contains(&ConformanceTarget::Fixture); - let mut direct_complete = false; - let mut failures = Vec::new(); - let mut interrupted = false; - tokio::pin!(interrupt); - let (cancellation_sender, cancellation_receiver) = tokio::sync::watch::channel(false); - - let cleanup_progress = ConformanceProgress::start("clearing prior integration stacks"); - self.cleanup(TopologySelection::All, CleanupKind::Reset)?; - drop(cleanup_progress); - - for topology in topologies { - let target = conformance_target(topology); - let run_routed = lanes.contains(&target); - let stack_progress = ConformanceProgress::start(format!( - "preparing {}", - conformance_topology_label(topology) - )); - let mut topology_failure = self.stack_up_for_conformance(topology, true).await.err(); - drop(stack_progress); - let mut fixture_state = None; - let mut fixture_metadata = None; - let mut fixture_endpoint = None; - let mut service_started = false; - let mut managed_token = None; - - if topology_failure.is_none() { - let fixture_progress = ConformanceProgress::start(format!( - "starting the official fixture for {}", - conformance_topology_label(topology) - )); - let (start_result, start_interrupted) = finish_phase_after_interrupt( - self.start_conformance_service(topology, server_era), - interrupt.as_mut(), - ) - .await; - drop(fixture_progress); - interrupted |= start_interrupted; - match start_result { - Ok(()) => { - service_started = true; - if interrupted { - topology_failure = Some(interrupted_conformance_failure()); - } else { - match self.conformance_fixture_endpoint(topology) { - Ok(endpoint) => { - fixture_endpoint = Some(endpoint); - fixture_metadata = Some(ConformanceFixtureMetadata { - repository: OFFICIAL_CONFORMANCE_REPOSITORY.to_owned(), - revision: OFFICIAL_CONFORMANCE_REVISION.to_owned(), - server_id: OFFICIAL_CONFORMANCE_SERVER_ID.to_owned(), - }); - } - Err(error) => topology_failure = Some(error), - } - } - } - Err(error) => { - topology_failure = Some(if interrupted { - interrupted_conformance_failure() - } else { - error - }); - } - } - } - - if topology_failure.is_none() && run_direct && !direct_complete { - let run_inputs = fixture_endpoint.as_ref().zip(fixture_metadata.as_ref()); - match run_inputs { - Some((endpoint, metadata)) => { - let run = DirectConformanceRun { - endpoint, - spec_version, - server_era, - fixture: metadata, - cancellation: cancellation_receiver.clone(), - }; - let direct = self.run_official_conformance_direct(&run, &paths); - tokio::pin!(direct); - tokio::select! { - result = &mut direct => { - direct_complete = true; - if let Err(error) = result { - let failure = format!("fixture direct: {error}"); - eprintln!( - "{}", - OutputStyle::stderr().failure( - &format!("Conformance failure: {failure}") - ) - ); - failures.push(failure); - } - } - () = interrupt.as_mut() => { - interrupted = true; - cancellation_sender.send_replace(true); - let _ = direct.await; - direct_complete = true; - topology_failure = Some(interrupted_conformance_failure()); - } - } - } - None => { - topology_failure = Some(AppFailure::from(anyhow!( - "successful fixture startup did not retain its direct endpoint" - ))); - } - } - } - - if topology_failure.is_none() && run_routed { - match self.admin_session_token().await.and_then(|token| { - ConformanceFixtureClient::builder(self.base_url()?, token) - .build() - .map_err(AppFailure::from) - }) { - Ok(client) => { - let provision_progress = ConformanceProgress::start(format!( - "registering the official fixture for {}", - conformance_topology_label(topology) - )); - let (provision_result, provision_interrupted) = - finish_phase_after_interrupt( - client.provision(OFFICIAL_CONFORMANCE_BACKEND_URL), - interrupt.as_mut(), - ) - .await; - drop(provision_progress); - interrupted |= provision_interrupted; - match provision_result { - Ok(fixture) => { - if interrupted { - topology_failure = Some(interrupted_conformance_failure()); - } else if topology == StackMode::Dataplane - && let Err(error) = { - let publisher_progress = ConformanceProgress::start( - "waiting for the external data-plane configuration", - ); - let result = self - .wait_for_publisher_snapshot_quiet(&fixture.server_id) - .await; - drop(publisher_progress); - result - } - { - topology_failure = Some(error); - } - fixture_state = Some((client, fixture)); - } - Err(error) => { - topology_failure = Some(if interrupted { - interrupted_conformance_failure() - } else { - AppFailure::from(error) - }); - } - } - } - Err(error) => topology_failure = Some(error), - } - } - - if topology_failure.is_none() && run_routed { - let run_inputs = fixture_state - .as_ref() - .map(|(_, fixture)| fixture) - .zip(fixture_metadata.as_ref()); - match run_inputs { - Some((fixture, metadata)) => match 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, - 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()); - } - } - } - Err(error) => topology_failure = Some(error), - }, - None => { - topology_failure = Some(AppFailure::from(anyhow!( - "successful fixture setup did not retain its runtime state" - ))); - } - } - } - - if let Some(token) = managed_token.as_ref() { - topology_failure = - finish_with_cleanup(topology_failure, self.revoke_managed_token(token).await) - .err(); - } - - if let Some((client, fixture)) = fixture_state { - let api_cleanup = client - .cleanup(Some(&fixture)) - .await - .map_err(AppFailure::from); - let service_cleanup = self.stop_conformance_service(topology).await; - topology_failure = finish_with_cleanup( - topology_failure, - combine_cleanup_results(api_cleanup, service_cleanup), - ) - .err(); - } else if service_started { - topology_failure = finish_with_cleanup( - topology_failure, - self.stop_conformance_service(topology).await, - ) - .err(); - } - - topology_failure = finish_with_cleanup( - topology_failure, - self.cleanup(topology_selection(topology), CleanupKind::Down), - ) - .err(); - if let Some(error) = topology_failure { - let failure = format!("{} topology: {error}", conformance_topology_label(topology)); - eprintln!( - "{}", - OutputStyle::stderr().failure(&format!("Conformance failure: {failure}")) - ); - failures.push(failure); - } - if interrupted { - cancellation_sender.send_replace(true); - break; - } - } - - if !interrupted { - match self.write_comparison_from_artifacts( - &paths, - Some((spec_version, server_era, DEFAULT_CONFORMANCE_SUITE)), - ) { - Ok(path) => println!( - "{} {}", - OutputStyle::stdout().info("Conformance comparison:"), - path.display() - ), - Err(error) => { - let failure = format!("comparison report: {error}"); - eprintln!( - "{}", - OutputStyle::stderr().failure(&format!("Conformance failure: {failure}")) - ); - failures.push(failure); - } - } - } - - if failures.is_empty() { - Ok(()) - } else { - Err(AppFailure::from(anyhow!( - "conformance run completed with failures:\n- {}", - failures.join("\n- ") - ))) - } - } - - async fn run_official_conformance_mode( - &self, - run: &OfficialConformanceRun<'_>, - paths: &CompliancePaths, - ) -> AppResult<()> { - let target = conformance_target(run.topology); - let endpoint = GatewayClient::builder( - GatewayTopology::Dataplane, - self.base_url()?, - run.server_id, - run.token, - ) - .protocol_version(run.spec_version) - .build() - .context("failed to construct the conformance gateway endpoint") - .map_err(AppFailure::from)? - .endpoint() - .clone(); - let proxy = match run.topology { - StackMode::Controlplane => { - AuthProxy::start_builtin_data_plane(endpoint, run.token).await - } - StackMode::Dataplane => AuthProxy::start(endpoint, run.token).await, - } - .context("failed to start the conformance authentication proxy") - .map_err(AppFailure::from)?; - let result = self - .run_official_conformance_target( - &ConformanceTargetRun { - target, - endpoint: proxy.url(), - spec_version: run.spec_version, - server_era: run.server_era, - fixture: run.fixture, - cancellation: run.cancellation.clone(), - }, - paths, - ) - .await; - let shutdown = proxy - .shutdown() - .await - .context("failed to stop the conformance authentication proxy") - .map_err(AppFailure::from); - finish_with_cleanup(result.err(), shutdown) - } - - async fn run_official_conformance_direct( - &self, - run: &DirectConformanceRun<'_>, - paths: &CompliancePaths, - ) -> AppResult<()> { - self.run_official_conformance_target( - &ConformanceTargetRun { - target: ConformanceTarget::Fixture, - endpoint: run.endpoint, - spec_version: run.spec_version, - server_era: run.server_era, - fixture: run.fixture, - cancellation: run.cancellation.clone(), - }, - paths, - ) - .await - } - - async fn run_official_conformance_target( - &self, - run: &ConformanceTargetRun<'_>, - paths: &CompliancePaths, - ) -> AppResult<()> { - let expected_scenarios = - expected_server_scenarios(DEFAULT_CONFORMANCE_SUITE, run.spec_version) - .map_err(AppFailure::from)?; - let lane_paths = paths.conformance_lane(run.target); - remove_file_if_exists(&lane_paths.completion)?; - recreate_directory(&lane_paths.official_results)?; - fs::create_dir_all(&lane_paths.root) - .with_context(|| { - format!( - "failed to create conformance artifact directory {:?}", - lane_paths.root - ) - }) - .map_err(AppFailure::from)?; - fs::write(&lane_paths.expected_failures, "server: []\n") - .with_context(|| { - format!( - "failed to write empty expected-failure file {:?}", - lane_paths.expected_failures - ) - }) - .map_err(AppFailure::from)?; - write_run_metadata( - &lane_paths.metadata, - &ConformanceRunMetadata { - oracle: cf_integration_compliance::conformance::OFFICIAL_CONFORMANCE_PACKAGE - .to_owned(), - target: run.target.label().to_owned(), - spec_version: run.spec_version.to_owned(), - server_era: run.server_era, - suite: DEFAULT_CONFORMANCE_SUITE.to_owned(), - fixture: Some(run.fixture.clone()), - }, - )?; - - let command = allowlisted_npx_environment( - official_server_command( - run.endpoint.as_str(), - DEFAULT_CONFORMANCE_SUITE, - run.spec_version, - &lane_paths.expected_failures, - &lane_paths.official_results, - ) - .cwd(self.config.root()), - ); - let style = OutputStyle::stdout(); - println!( - "{}", - render_conformance_lane_header( - run.target, - expected_scenarios.len(), - run.spec_version, - run.server_era, - style, - ) - ); - let started = Instant::now(); - let runner_progress = ConformanceProgress::start(format!( - "running {} scenarios for {}", - expected_scenarios.len(), - run.target - )); - let process_result = self - .runner - .run_async_cancellable_to_log( - &command, - run.cancellation.clone(), - &lane_paths.runner_log, - ) - .await - .map_err(AppFailure::from); - drop(runner_progress); - - let results = load_server_results(&lane_paths.official_results).map_err(AppFailure::from); - if !conformance_process_completed(&process_result) { - return process_result; - } - let results = results?; - mark_conformance_complete( - &process_result, - &results, - run.target, - DEFAULT_CONFORMANCE_SUITE, - run.spec_version, - &lane_paths.completion, - )?; - println!( - "{}", - render_conformance_lane_results(run.target, &results, started.elapsed(), style) - ); - println!( - "{} {}", - style.info(" Artifacts"), - lane_paths.root.display() - ); - println!( - "{} {}", - style.info(" Full output"), - lane_paths.runner_log.display() - ); - process_result?; - Ok(()) - } -} - -fn render_conformance_lane_header( - target: ConformanceTarget, - scenario_count: usize, - spec_version: &str, - server_era: ConformanceServerEra, - style: OutputStyle, -) -> String { - let divider = style.info("────────────"); - let lane = style.heading(&format!(" MCP conformance lane: {target}")); - format!( - "{divider}\n{lane}\n Starting {scenario_count} scenarios with {} (client {spec_version}, server {server_era})", - cf_integration_compliance::conformance::OFFICIAL_CONFORMANCE_PACKAGE - ) -} - -fn render_conformance_lane_results( - target: ConformanceTarget, - results: &ConformanceResults, - elapsed: Duration, - style: OutputStyle, -) -> String { - let total = results.scenarios.len(); - let mut passed = 0; - let mut failed = 0; - let mut skipped = 0; - let mut ambiguous = 0; - let mut output = String::new(); - - for (index, result) in results.scenarios.values().enumerate() { - let status = match result.outcome_with_trusted_fixture(true) { - ScenarioOutcome::Compliant => { - passed += 1; - style.success(&format!("{:>12}", "PASS")) - } - ScenarioOutcome::NonCompliant | ScenarioOutcome::FixtureFailure => { - failed += 1; - style.failure(&format!("{:>12}", "FAIL")) - } - ScenarioOutcome::NotApplicable => { - skipped += 1; - style.warning(&format!("{:>12}", "SKIP")) - } - ScenarioOutcome::Ambiguous | ScenarioOutcome::Missing => { - ambiguous += 1; - style.unknown(&format!("{:>12}", "UNKNOWN")) - } - }; - let _ = writeln!( - output, - "{status} ({}/{total}) {}", - index + 1, - result.scenario - ); - } - - let divider = style.info("────────────"); - let summary = if failed > 0 { - style.failure_heading("Summary") - } else if ambiguous > 0 { - style.unknown_heading("Summary") - } else { - style.success_heading("Summary") - }; - let _ = write!( - output, - "{divider}\n {summary} [{:>8.3}s] {total} scenarios run for {target}: {passed} passed, {failed} failed, {skipped} skipped, {ambiguous} unknown", - elapsed.as_secs_f64() - ); - output -} - -fn conformance_topologies(lanes: &[ConformanceTarget]) -> Vec { - let mut topologies = Vec::new(); - if lanes.contains(&ConformanceTarget::BuiltInDataPlane) { - topologies.push(StackMode::Controlplane); - } - if lanes.contains(&ConformanceTarget::ExternalDataPlane) { - topologies.push(StackMode::Dataplane); - } - if topologies.is_empty() { - topologies.push(StackMode::Controlplane); - } - topologies -} - -const fn conformance_topology_label(topology: StackMode) -> &'static str { - match topology { - StackMode::Controlplane => "built-in data-plane route", - StackMode::Dataplane => "external data-plane route", - } -} - -fn parse_conformance_fixture_endpoint(output: &[u8]) -> anyhow::Result { - let output = std::str::from_utf8(output).context("Compose fixture port output is not UTF-8")?; - let address = output - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .ok_or_else(|| anyhow!("Compose did not publish the conformance fixture port"))? - .parse::() - .context("Compose returned an invalid conformance fixture address")?; - if !address.ip().is_loopback() { - return Err(anyhow!( - "Compose published the conformance fixture on non-loopback address {}", - address.ip() - )); - } - url::Url::parse(&format!("http://{address}/mcp")) - .context("failed to construct the direct conformance fixture URL") -} - -struct OfficialConformanceRun<'a> { - topology: StackMode, - server_id: &'a str, - token: &'a str, - spec_version: &'a str, - server_era: ConformanceServerEra, - fixture: &'a ConformanceFixtureMetadata, - cancellation: tokio::sync::watch::Receiver, -} - -struct DirectConformanceRun<'a> { - endpoint: &'a url::Url, - spec_version: &'a str, - server_era: ConformanceServerEra, - fixture: &'a ConformanceFixtureMetadata, - cancellation: tokio::sync::watch::Receiver, -} - -struct ConformanceTargetRun<'a> { - target: ConformanceTarget, - endpoint: &'a url::Url, - spec_version: &'a str, - server_era: ConformanceServerEra, - fixture: &'a ConformanceFixtureMetadata, - cancellation: tokio::sync::watch::Receiver, -} - -fn combine_cleanup_results(first: AppResult<()>, second: AppResult<()>) -> AppResult<()> { - match (first, second) { - (Ok(()), Ok(())) => Ok(()), - (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), - (Err(first), Err(second)) => Err(AppFailure::from(anyhow!( - "{first}; additionally conformance service cleanup failed: {second}" - ))), - } -} - -async fn finish_phase_after_interrupt( - operation: F, - interrupt: std::pin::Pin<&mut I>, -) -> (T, bool) -where - F: Future, - I: Future, -{ - tokio::pin!(operation); - tokio::select! { - output = &mut operation => (output, false), - () = interrupt => (operation.await, true), - } -} - -fn interrupted_conformance_failure() -> AppFailure { - AppFailure::from(anyhow!("conformance workflow interrupted by Ctrl-C")) -} - -#[cfg(test)] -mod tests { - use super::*; - use cf_integration_compliance::conformance::{ - CheckStatus, ConformanceCheck, ConformanceScenarioResult, - }; - - fn conformance_result(scenario: &str, status: CheckStatus) -> ConformanceScenarioResult { - ConformanceScenarioResult { - scenario: scenario.to_owned(), - checks: vec![ConformanceCheck { - id: format!("{scenario}-check"), - name: None, - description: None, - status, - timestamp: None, - spec_references: Vec::new(), - error_message: None, - details: None, - metadata: None, - logs: None, - extensions: Default::default(), - }], - source: PathBuf::from(format!("server-{scenario}/checks.json")), - } - } - - fn mixed_conformance_results() -> ConformanceResults { - ConformanceResults { - scenarios: [ - ( - "passing".to_owned(), - conformance_result("passing", CheckStatus::Success), - ), - ( - "failing".to_owned(), - conformance_result("failing", CheckStatus::Failure), - ), - ] - .into_iter() - .collect(), - } - } - - #[test] - fn lane_selection_uses_only_required_stack_topologies() { - assert_eq!( - conformance_topologies(&[ConformanceTarget::Fixture]), - [StackMode::Controlplane] - ); - assert_eq!( - conformance_topologies(&[ - ConformanceTarget::Fixture, - ConformanceTarget::ExternalDataPlane, - ]), - [StackMode::Dataplane] - ); - assert_eq!( - conformance_topologies(&[ - ConformanceTarget::BuiltInDataPlane, - ConformanceTarget::ExternalDataPlane, - ]), - [StackMode::Controlplane, StackMode::Dataplane] - ); - } - - #[test] - fn direct_fixture_endpoint_accepts_only_loopback_bindings() { - assert_eq!( - parse_conformance_fixture_endpoint(b"127.0.0.1:49152\n") - .expect("IPv4 loopback should be accepted") - .as_str(), - "http://127.0.0.1:49152/mcp" - ); - assert_eq!( - parse_conformance_fixture_endpoint(b"[::1]:49153\n") - .expect("IPv6 loopback should be accepted") - .as_str(), - "http://[::1]:49153/mcp" - ); - assert!( - parse_conformance_fixture_endpoint(b"0.0.0.0:49154\n") - .expect_err("wildcard bindings must be rejected") - .to_string() - .contains("non-loopback") - ); - } - - #[test] - fn cleanup_errors_preserve_the_primary_failure_and_cleanup_context() { - let primary = AppFailure::from(anyhow!("runner failed")); - let cleanup = Err(AppFailure::from(anyhow!("cleanup failed"))); - - let error = finish_with_cleanup(Some(primary), cleanup) - .expect_err("both failures must remain visible") - .to_string(); - - assert!(error.contains("runner failed")); - assert!(error.contains("cleanup failed")); - assert!(error.find("runner failed") < error.find("cleanup failed")); - } - - #[test] - fn independent_cleanup_failures_are_combined() { - let error = combine_cleanup_results( - Err(AppFailure::from(anyhow!("API cleanup failed"))), - Err(AppFailure::from(anyhow!("service cleanup failed"))), - ) - .expect_err("both cleanup failures must be returned") - .to_string(); - - assert!(error.contains("API cleanup failed")); - assert!(error.contains("service cleanup failed")); - } - - #[test] - fn conformance_lane_header_names_the_lane_oracle_and_specification() { - assert_eq!( - render_conformance_lane_header( - ConformanceTarget::Fixture, - 40, - "2026-07-28", - ConformanceServerEra::Legacy, - OutputStyle::plain(), - ), - "────────────\n MCP conformance lane: fixture direct\n Starting 40 scenarios with @modelcontextprotocol/conformance@0.2.0-alpha.11 (client 2026-07-28, server legacy)" - ); - } - - #[test] - fn conformance_lane_results_use_one_nextest_style_line_per_scenario() { - let results = mixed_conformance_results(); - - let rendered = render_conformance_lane_results( - ConformanceTarget::ExternalDataPlane, - &results, - Duration::from_millis(1_250), - OutputStyle::plain(), - ); - - assert_eq!( - rendered, - " FAIL (1/2) failing\n PASS (2/2) passing\n────────────\n Summary [ 1.250s] 2 scenarios run for external data-plane route: 1 passed, 1 failed, 0 skipped, 0 unknown" - ); - assert_eq!( - rendered.lines().filter(|line| line.contains(" (")).count(), - 2 - ); - assert!(!rendered.contains("Checks:")); - assert!(!rendered.contains("Running scenario")); - } - - #[test] - fn colored_conformance_output_styles_lane_statuses_and_failed_summary() { - let header = render_conformance_lane_header( - ConformanceTarget::ExternalDataPlane, - 2, - "2026-07-28", - ConformanceServerEra::Modern, - OutputStyle::colored(), - ); - let results = render_conformance_lane_results( - ConformanceTarget::ExternalDataPlane, - &mixed_conformance_results(), - Duration::from_millis(1_250), - OutputStyle::colored(), - ); - - assert!(header.contains("\x1b[36m────────────\x1b[0m")); - assert!( - header.contains("\x1b[1;36m MCP conformance lane: external data-plane route\x1b[0m") - ); - assert!(results.contains("\x1b[31m FAIL\x1b[0m (1/2) failing")); - assert!(results.contains("\x1b[32m PASS\x1b[0m (2/2) passing")); - assert!(results.contains(" \x1b[1;31mSummary\x1b[0m [ 1.250s]")); - } -} diff --git a/src/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs new file mode 100644 index 0000000..14062be --- /dev/null +++ b/src/runtime/conformance/mod.rs @@ -0,0 +1,1558 @@ +//! Official conformance orchestration. + +mod reports; + +use super::*; +use reports::*; +use std::fmt::Write as _; +use std::time::Instant; + +use crate::conformance::DEFAULT_MCP_SPEC_VERSION; +use crate::conformance::results::{DEFAULT_CONFORMANCE_SUITE, ScenarioOutcome}; + +const CLIENT_CONFORMANCE_SERVER_ID: &str = "dataplane-client-conformance"; + +impl RuntimeContext { + fn require_loopback_fixture_base_url(&self) -> AppResult<()> { + let base_url = self.base_url()?; + let url = url::Url::parse(base_url) + .context("MCP_CLI_BASE_URL is not a valid URL") + .map_err(AppFailure::from)?; + let is_loopback = match url.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + None => false, + }; + if !is_loopback { + return Err(AppFailure::from(anyhow!( + "official conformance requires a loopback MCP_CLI_BASE_URL" + ))); + } + Ok(()) + } + + pub(super) async fn start_conformance_service( + &self, + topology: StackMode, + server_era: ConformanceServerEra, + ) -> AppResult<()> { + self.build_conformance_service(topology, server_era).await?; + self.start_conformance_containers(topology, server_era) + .await + } + + pub(super) async fn build_conformance_service( + &self, + topology: StackMode, + server_era: ConformanceServerEra, + ) -> AppResult<()> { + let project = self.conformance_compose_project(topology); + let build = project.command(["build", OFFICIAL_CONFORMANCE_SERVICE]); + let build = self + .compose_environment(build, topology, true)? + .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); + Ok(self.runner.run_async(&build).await?) + } + + pub(super) async fn start_conformance_containers( + &self, + topology: StackMode, + server_era: ConformanceServerEra, + ) -> AppResult<()> { + let project = self.conformance_compose_project(topology); + let up = project.command([ + "up", + "-d", + "--wait", + OFFICIAL_CONFORMANCE_SERVICE, + OFFICIAL_CONFORMANCE_PROXY_SERVICE, + ]); + let up = self + .compose_environment(up, topology, true)? + .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); + 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)?; + let output = self.runner.capture_stdout(&command)?; + 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)?; + self.runner + .run_async(&remove) + .await + .map_err(AppFailure::from) + } + + pub(super) async fn execute_conformance(&self, action: ConformanceAction) -> AppResult<()> { + match action { + ConformanceAction::Run { + lanes, + client_versions, + server_eras, + results_dir, + baseline_dir, + bless, + output_dir, + } => { + let artifact_root = results_dir + .as_deref() + .unwrap_or_else(|| self.config.integration_dir()); + let baseline_root = baseline_dir.unwrap_or_else(|| { + let root = if bless { + self.config.root() + } else { + self.config.asset_root() + }; + root.join("tests/conformance/baselines") + }); + let report_root = output_dir.unwrap_or_else(|| self.config.root().join("reports")); + let mut failures = Vec::new(); + let mut updates = Vec::::new(); + + for (client_version, server_era) in + conformance_matrix(&client_versions, &server_eras) + { + let paths = ConformancePaths::new( + artifact_root, + report_root.clone(), + &client_version, + server_era, + ); + let setup_log = paths.setup_log(); + let setup_result = prepare_setup_log(&setup_log); + if let Err(error) = setup_result { + failures.push(format!("{} setup: {error}", paths.identity())); + continue; + } + let quiet_runner = LoggingProcessRunner::new(&self.runner, &setup_log); + 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) + .await; + let results = if run_result.is_ok() { + executor.load_selected_conformance_results(&paths, &lanes) + } else { + executor.load_completed_conformance_results(&paths, &lanes) + }; + if let Err(error) = run_result { + failures.push(format!( + "{}: {error}\n Setup log: {}", + paths.identity(), + setup_log.display() + )); + } + + let evaluated = results.and_then(|results| { + if results.is_empty() { + return Ok(None); + } + let completed_lanes = results.keys().copied().collect::>(); + match evaluate_baselines( + &results, + &completed_lanes, + &baseline_root, + &client_version, + server_era, + bless, + ) { + Ok(evaluation) => { + println!( + "{}", + render_conformance_results( + &results, + ConformanceDirection::Server, + Some(&evaluation.comparisons), + matrix_started.elapsed(), + OutputStyle::stdout(), + bless, + ) + ); + Ok(Some(evaluation)) + } + Err(error) => { + println!( + "{}", + render_conformance_results( + &results, + ConformanceDirection::Server, + None, + matrix_started.elapsed(), + OutputStyle::stdout(), + bless, + ) + ); + Err(AppFailure::from(error)) + } + } + }); + match evaluated { + Ok(Some(evaluation)) => { + for comparison in &evaluation.comparisons { + let report = paths.baseline_report(comparison.lane); + if let Err(error) = write_baseline_report( + &report, + &client_version, + server_era, + comparison, + ) { + failures.push(format!( + "{} {} baseline report: {error}", + paths.identity(), + comparison.lane.slug() + )); + } + if !bless && !comparison.matches() { + failures.push(format!( + "{} {} baseline mismatch: unexpected={:?}; stale={:?}", + paths.identity(), + comparison.lane.slug(), + comparison.unexpected, + comparison.stale + )); + } + } + updates.extend(evaluation.updates); + } + Ok(None) => {} + Err(error) => { + failures.push(format!("{} baseline gate: {error}", paths.identity())); + } + } + + match executor.load_completed_client_conformance_results(&paths) { + Err(error) => { + failures.push(format!("{} client artifacts: {error}", paths.identity())) + } + Ok(client_results) if !client_results.is_empty() => { + match evaluate_client_baselines( + &client_results, + &[SemanticLane::ExternalDataPlane], + &baseline_root, + &client_version, + server_era, + bless, + ) { + Ok(evaluation) => { + println!( + "{}", + render_conformance_results( + &client_results, + ConformanceDirection::Client, + Some(&evaluation.comparisons), + matrix_started.elapsed(), + OutputStyle::stdout(), + bless, + ) + ); + for comparison in &evaluation.comparisons { + let report = paths.client_baseline_report(comparison.lane); + if let Err(error) = write_client_baseline_report( + &report, + &client_version, + server_era, + comparison, + ) { + failures.push(format!( + "{} client {} baseline report: {error}", + paths.identity(), + comparison.lane.slug() + )); + } + if !bless && !comparison.matches() { + failures.push(format!( + "{} client {} baseline mismatch: unexpected={:?}; stale={:?}", + paths.identity(), + comparison.lane.slug(), + comparison.unexpected, + comparison.stale + )); + } + } + updates.extend(evaluation.updates); + } + Err(error) => { + println!( + "{}", + render_conformance_results( + &client_results, + ConformanceDirection::Client, + None, + matrix_started.elapsed(), + OutputStyle::stdout(), + bless, + ) + ); + failures.push(format!( + "{} client baseline gate: {error}", + paths.identity() + )); + } + } + } + Ok(_) => {} + } + } + + if failures.is_empty() && bless { + bless_baselines_transactionally(&baseline_root, &updates) + .map_err(AppFailure::from)?; + println!( + "{} {}", + OutputStyle::stdout().success("Conformance baselines updated:"), + baseline_root.display() + ); + } + if failures.is_empty() { + Ok(()) + } else { + Err(AppFailure::from(anyhow!( + "conformance failed:\n- {}", + failures.join("\n- ") + ))) + } + } + ConformanceAction::Report { + results_dir, + output_dir, + } => self.regenerate_conformance_report(results_dir.as_deref(), output_dir.as_deref()), + } + } + + async fn run_conformance( + &self, + lanes: &[SemanticLane], + spec_version: &str, + server_era: ConformanceServerEra, + paths: &ConformancePaths, + ) -> 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; + } + }) + .await + } + + async fn run_conformance_with_interrupt( + &self, + lanes: &[SemanticLane], + spec_version: &str, + server_era: ConformanceServerEra, + paths: &ConformancePaths, + interrupt: I, + ) -> AppResult<()> + where + I: Future, + { + if lanes.is_empty() { + return Err(AppFailure::from(anyhow!( + "at least one conformance lane must be selected" + ))); + } + expected_server_scenarios(DEFAULT_CONFORMANCE_SUITE, spec_version) + .map_err(AppFailure::from)?; + self.require_loopback_fixture_base_url()?; + + paths.clear_conformance()?; + + let topologies = conformance_topologies(lanes); + let mut direct_complete = false; + let mut failures = Vec::new(); + let mut interrupted = false; + tokio::pin!(interrupt); + let (cancellation_sender, cancellation_receiver) = tokio::sync::watch::channel(false); + + let cleanup_progress = Activity::spinner("Clear prior integration stacks"); + let cleanup_result = self.cleanup(TopologySelection::All, CleanupKind::Reset); + cleanup_progress.finish(cleanup_result.is_ok()); + cleanup_result?; + + for topology in topologies { + let target = conformance_target(topology); + let run_routed = lanes.contains(&target); + let stack_progress = + Activity::spinner(format!("Prepare {}", topology.topology_label())); + let mut topology_failure = self.stack_up_for_conformance(topology, true).await.err(); + stack_progress.finish(topology_failure.is_none()); + let mut fixture_state = None; + let mut fixture_metadata = None; + let mut fixture_endpoint = None; + let mut service_started = false; + let mut managed_token = None; + + if topology_failure.is_none() { + let fixture_progress = Activity::spinner(format!( + "Start the official fixture for {}", + topology.topology_label() + )); + let (start_result, start_interrupted) = finish_phase_after_interrupt( + self.start_conformance_service(topology, server_era), + interrupt.as_mut(), + ) + .await; + fixture_progress.finish(start_result.is_ok() && !start_interrupted); + interrupted |= start_interrupted; + match start_result { + Ok(()) => { + service_started = true; + if interrupted { + topology_failure = Some(interrupted_conformance_failure()); + } else { + match self.conformance_fixture_endpoint(topology) { + Ok(endpoint) => { + fixture_endpoint = Some(endpoint); + fixture_metadata = Some(ConformanceFixtureMetadata { + repository: OFFICIAL_CONFORMANCE_REPOSITORY.to_owned(), + revision: OFFICIAL_CONFORMANCE_REVISION.to_owned(), + server_id: OFFICIAL_CONFORMANCE_SERVER_ID.to_owned(), + }); + } + Err(error) => topology_failure = Some(error), + } + } + } + Err(error) => { + topology_failure = Some(if interrupted { + interrupted_conformance_failure() + } else { + error + }); + } + } + } + + // Routed baselines subtract findings reproduced by the direct fixture, + // so every selected matrix needs one direct run even when that lane is + // not itself selected for baseline gating. + if topology_failure.is_none() && !direct_complete { + let run_inputs = fixture_endpoint.as_ref().zip(fixture_metadata.as_ref()); + match run_inputs { + Some((endpoint, metadata)) => { + let run = DirectConformanceRun { + endpoint, + spec_version, + server_era, + fixture: metadata, + cancellation: cancellation_receiver.clone(), + }; + let direct = self.run_official_conformance_direct(&run, paths); + tokio::pin!(direct); + tokio::select! { + result = &mut direct => { + direct_complete = true; + if let Err(error) = result { + let failure = format!("fixture direct: {error}"); + failures.push(failure); + } + } + () = interrupt.as_mut() => { + interrupted = true; + cancellation_sender.send_replace(true); + let _ = direct.await; + direct_complete = true; + topology_failure = Some(interrupted_conformance_failure()); + } + } + } + None => { + topology_failure = Some(AppFailure::from(anyhow!( + "successful fixture startup did not retain its direct endpoint" + ))); + } + } + } + + if topology_failure.is_none() && run_routed { + match self.admin_session_token().await.and_then(|token| { + ConformanceFixtureClient::builder(self.base_url()?, token) + .build() + .map_err(AppFailure::from) + }) { + Ok(client) => { + let provision_progress = Activity::spinner(format!( + "Register the official fixture for {}", + topology.topology_label() + )); + let (provision_result, provision_interrupted) = + finish_phase_after_interrupt( + client.provision(OFFICIAL_CONFORMANCE_BACKEND_URL), + interrupt.as_mut(), + ) + .await; + provision_progress + .finish(provision_result.is_ok() && !provision_interrupted); + interrupted |= provision_interrupted; + match provision_result { + Ok(fixture) => { + if interrupted { + topology_failure = Some(interrupted_conformance_failure()); + } else if topology == StackMode::Dataplane + && let Err(error) = { + let publisher_progress = Activity::spinner( + "Wait for the external dataplane configuration", + ); + let result = self + .wait_for_publisher_snapshot(&fixture.server_id) + .await; + publisher_progress.finish(result.is_ok()); + result + } + { + topology_failure = Some(error); + } + fixture_state = Some((client, fixture)); + } + Err(error) => { + topology_failure = Some(if interrupted { + interrupted_conformance_failure() + } else { + AppFailure::from(error) + }); + } + } + } + Err(error) => topology_failure = Some(error), + } + } + + if topology_failure.is_none() && run_routed { + let run_inputs = fixture_state + .as_ref() + .map(|(_, fixture)| fixture) + .zip(fixture_metadata.as_ref()); + match run_inputs { + Some((fixture, metadata)) => match 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, + 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()); + } + } + } + Err(error) => topology_failure = Some(error), + }, + None => { + topology_failure = Some(AppFailure::from(anyhow!( + "successful fixture setup did not retain its runtime state" + ))); + } + } + } + + if let Some(token) = managed_token.as_ref() { + topology_failure = + finish_with_cleanup(topology_failure, self.revoke_managed_token(token).await) + .err(); + } + + if let Some((client, fixture)) = fixture_state { + let api_cleanup = client + .cleanup(Some(&fixture)) + .await + .map_err(AppFailure::from); + let service_cleanup = self.stop_conformance_service(topology).await; + topology_failure = finish_with_cleanup( + topology_failure, + combine_cleanup_results(api_cleanup, service_cleanup), + ) + .err(); + } else if service_started { + topology_failure = finish_with_cleanup( + topology_failure, + self.stop_conformance_service(topology).await, + ) + .err(); + } + + topology_failure = finish_with_cleanup( + topology_failure, + self.cleanup(topology_selection(topology), CleanupKind::Down), + ) + .err(); + if let Some(error) = topology_failure { + let failure = format!("{} topology: {error}", topology.topology_label()); + failures.push(failure); + } + if interrupted { + cancellation_sender.send_replace(true); + break; + } + } + + if !interrupted + && spec_version == DEFAULT_MCP_SPEC_VERSION + && lanes.contains(&SemanticLane::ExternalDataPlane) + { + let client = self.run_external_client_conformance( + spec_version, + server_era, + paths, + cancellation_receiver.clone(), + ); + tokio::pin!(client); + tokio::select! { + result = &mut client => { + if let Err(error) = result { + failures.push(format!("external dataplane client: {error}")); + } + } + () = interrupt.as_mut() => { + interrupted = true; + cancellation_sender.send_replace(true); + let _ = client.await; + failures.push("external dataplane client: conformance workflow interrupted by Ctrl-C".to_owned()); + } + } + } + + if failures.is_empty() + && !interrupted + && [ + SemanticLane::FixtureDirect, + SemanticLane::BuiltInDataPlane, + SemanticLane::ExternalDataPlane, + ] + .iter() + .all(|lane| lanes.contains(lane)) + { + match self.write_comparison_from_artifacts( + paths, + Some((spec_version, server_era, DEFAULT_CONFORMANCE_SUITE)), + ) { + Ok(path) => println!( + "{} {}", + OutputStyle::stdout().info("Conformance comparison:"), + path.display() + ), + Err(error) => { + let failure = format!("comparison report: {error}"); + failures.push(failure); + } + } + } + + if failures.is_empty() { + Ok(()) + } else { + Err(AppFailure::from(anyhow!(failures.join("; ")))) + } + } + + async fn run_official_conformance_mode( + &self, + run: &OfficialConformanceRun<'_>, + paths: &ConformancePaths, + ) -> AppResult<()> { + let target = conformance_target(run.topology); + let endpoint = GatewayClient::builder( + GatewayTopology::Dataplane, + self.base_url()?, + run.server_id, + run.token, + ) + .protocol_version(run.spec_version) + .build() + .context("failed to construct the conformance gateway endpoint") + .map_err(AppFailure::from)? + .endpoint() + .clone(); + let proxy = match run.topology { + StackMode::Controlplane => { + AuthProxy::start_builtin_data_plane(endpoint, run.token).await + } + StackMode::Dataplane => AuthProxy::start(endpoint, run.token).await, + } + .context("failed to start the conformance authentication proxy") + .map_err(AppFailure::from)?; + let result = self + .run_official_conformance_target( + &SemanticLaneRun { + target, + endpoint: proxy.url(), + spec_version: run.spec_version, + server_era: run.server_era, + fixture: run.fixture, + cancellation: run.cancellation.clone(), + }, + paths, + ) + .await; + let shutdown = proxy + .shutdown() + .await + .context("failed to stop the conformance authentication proxy") + .map_err(AppFailure::from); + finish_with_cleanup(result.err(), shutdown) + } + + async fn run_external_client_conformance( + &self, + spec_version: &str, + server_era: ConformanceServerEra, + paths: &ConformancePaths, + cancellation: tokio::sync::watch::Receiver, + ) -> AppResult<()> { + expected_client_scenarios(spec_version).map_err(AppFailure::from)?; + let stack_progress = Activity::spinner("Prepare external dataplane client conformance"); + let stack_result = self + .stack_up_for_conformance(StackMode::Dataplane, true) + .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 { + Ok(issued) => token = Some(issued), + Err(error) => failure = Some(error), + } + } + if failure.is_none() { + let progress = Activity::spinner("Pause the control-plane publisher"); + let result = self.set_control_plane_publisher(false).await; + progress.finish(result.is_ok()); + if result.is_ok() { + publisher_stopped = true; + } + failure = result.err(); + } + if failure.is_none() { + match token.as_ref() { + Some(issued) => { + failure = self + .run_official_client_conformance( + spec_version, + server_era, + &issued.value, + paths, + cancellation, + ) + .await + .err(); + } + None => { + failure = Some(AppFailure::from(anyhow!( + "client conformance token was not available after issuance" + ))); + } + } + } + + if publisher_stopped { + let progress = Activity::spinner("Restore the control-plane publisher"); + let result = self.set_control_plane_publisher(true).await; + progress.finish(result.is_ok()); + failure = finish_with_cleanup(failure, result).err(); + } + if 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), + ) + } + + async fn set_control_plane_publisher(&self, running: bool) -> AppResult<()> { + let project = self.conformance_runtime_project(StackMode::Dataplane); + let command = if running { + project.command(["up", "-d", "--wait", "--no-deps", "gateway"]) + } else { + project.command(["stop", "gateway"]) + }; + let command = self.compose_environment(command, StackMode::Dataplane, true)?; + self.runner + .run_async(&command) + .await + .map_err(AppFailure::from) + } + + async fn run_official_client_conformance( + &self, + spec_version: &str, + server_era: ConformanceServerEra, + token: &str, + paths: &ConformancePaths, + cancellation: tokio::sync::watch::Receiver, + ) -> AppResult<()> { + let expected_scenarios = + expected_client_scenarios(spec_version).map_err(AppFailure::from)?; + let target = SemanticLane::ExternalDataPlane; + let lane_paths = paths.client_conformance_lane(target); + remove_file_if_exists(&lane_paths.completion)?; + recreate_directory(&lane_paths.official_results)?; + fs::create_dir_all(&lane_paths.root) + .with_context(|| { + format!( + "failed to create client-conformance artifact directory {:?}", + lane_paths.root + ) + }) + .map_err(AppFailure::from)?; + fs::write(&lane_paths.expected_failures, "client: []\n") + .with_context(|| { + format!( + "failed to write empty client expected-failure file {:?}", + lane_paths.expected_failures + ) + }) + .map_err(AppFailure::from)?; + write_run_metadata( + &lane_paths.metadata, + &ConformanceRunMetadata { + oracle: crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE.to_owned(), + target: target.label().to_owned(), + direction: ConformanceDirection::Client, + client_version: spec_version.to_owned(), + server_era, + suite: "scoped".to_owned(), + fixture: ConformanceFixtureMetadata { + repository: OFFICIAL_CONFORMANCE_REPOSITORY.to_owned(), + revision: OFFICIAL_CONFORMANCE_REVISION.to_owned(), + server_id: OFFICIAL_CONFORMANCE_SERVER_ID.to_owned(), + }, + }, + )?; + + let compose = self.compose_environment( + self.conformance_runtime_project(StackMode::Dataplane) + .command(std::iter::empty::<&str>()), + StackMode::Dataplane, + true, + )?; + let compose_args = compose + .arguments() + .iter() + .map(|argument| { + argument + .to_str() + .context("client conformance Compose argument is not UTF-8") + }) + .collect::>>() + .and_then(|arguments| { + serde_json::to_string(&arguments) + .context("failed to serialize client conformance Compose arguments") + }) + .map_err(AppFailure::from)?; + let (client_command, client_path) = client_driver_command().map_err(AppFailure::from)?; + let progress = Activity::spinner(format!( + "Run external dataplane client ({} scenarios)", + expected_scenarios.len() + )); + let mut operational_failures = Vec::new(); + for scenario in DEFAULT_CLIENT_CONFORMANCE_SCENARIOS { + let mut command = allowlisted_npx_environment( + official_client_command( + &client_command, + scenario, + spec_version, + &lane_paths.expected_failures, + &lane_paths.official_results, + ) + .cwd(self.config.root()), + ); + for (key, value) in compose.environment() { + command = command.env(key.clone(), value.clone()); + } + command = command + .env(CLIENT_COMPOSE_ARGS_ENV, &compose_args) + .env(CLIENT_BASE_URL_ENV, self.base_url()?) + .env(CLIENT_SERVER_ID_ENV, CLIENT_CONFORMANCE_SERVER_ID) + .env(CLIENT_TOKEN_ENV, token) + .env("PATH", client_path.clone()); + let result = self + .runner + .run_async_cancellable_to_log( + &command, + cancellation.clone(), + &lane_paths.root.join(format!("runner-{scenario}.log")), + ) + .await + .map_err(AppFailure::from); + if !conformance_process_completed(&result) + && let Err(error) = result + { + operational_failures.push(format!("{scenario}: {error}")); + } + } + + let results = load_client_results(&lane_paths.official_results).map_err(AppFailure::from); + let validation = results.and_then(|results| { + validate_scored_results(&results).map_err(AppFailure::from)?; + mark_client_conformance_complete( + &results, + target, + spec_version, + &lane_paths.completion, + )?; + Ok(()) + }); + if let Err(error) = validation { + operational_failures.push(error.to_string()); + } + let result = if operational_failures.is_empty() { + Ok(()) + } else { + Err(AppFailure::from(anyhow!( + "client conformance did not complete: {}", + operational_failures.join("; ") + ))) + }; + progress.finish(result.is_ok()); + result + } + + async fn run_official_conformance_direct( + &self, + run: &DirectConformanceRun<'_>, + paths: &ConformancePaths, + ) -> AppResult<()> { + self.run_official_conformance_target( + &SemanticLaneRun { + target: SemanticLane::FixtureDirect, + endpoint: run.endpoint, + spec_version: run.spec_version, + server_era: run.server_era, + fixture: run.fixture, + cancellation: run.cancellation.clone(), + }, + paths, + ) + .await + } + + async fn run_official_conformance_target( + &self, + run: &SemanticLaneRun<'_>, + paths: &ConformancePaths, + ) -> AppResult<()> { + let expected_scenarios = + expected_server_scenarios(DEFAULT_CONFORMANCE_SUITE, run.spec_version) + .map_err(AppFailure::from)?; + let lane_paths = paths.conformance_lane(run.target); + remove_file_if_exists(&lane_paths.completion)?; + recreate_directory(&lane_paths.official_results)?; + fs::create_dir_all(&lane_paths.root) + .with_context(|| { + format!( + "failed to create conformance artifact directory {:?}", + lane_paths.root + ) + }) + .map_err(AppFailure::from)?; + fs::write(&lane_paths.expected_failures, "server: []\n") + .with_context(|| { + format!( + "failed to write empty expected-failure file {:?}", + lane_paths.expected_failures + ) + }) + .map_err(AppFailure::from)?; + write_run_metadata( + &lane_paths.metadata, + &ConformanceRunMetadata { + oracle: crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE.to_owned(), + target: run.target.label().to_owned(), + direction: ConformanceDirection::Server, + client_version: run.spec_version.to_owned(), + server_era: run.server_era, + suite: DEFAULT_CONFORMANCE_SUITE.to_owned(), + fixture: run.fixture.clone(), + }, + )?; + + let command = allowlisted_npx_environment( + official_server_command( + run.endpoint.as_str(), + DEFAULT_CONFORMANCE_SUITE, + run.spec_version, + &lane_paths.expected_failures, + &lane_paths.official_results, + ) + .cwd(self.config.root()), + ); + let runner_progress = Activity::spinner(format!( + "Run {} ({} scenarios)", + run.target, + expected_scenarios.len() + )); + let process_result = self + .runner + .run_async_cancellable_to_log( + &command, + run.cancellation.clone(), + &lane_paths.runner_log, + ) + .await + .map_err(AppFailure::from); + runner_progress.finish(conformance_process_completed(&process_result)); + + let results = load_server_results(&lane_paths.official_results).map_err(AppFailure::from); + if !conformance_process_completed(&process_result) { + return process_result; + } + let results = results?; + mark_conformance_complete( + &process_result, + &results, + run.target, + DEFAULT_CONFORMANCE_SUITE, + run.spec_version, + &lane_paths.completion, + )?; + Ok(()) + } +} + +fn prepare_setup_log(path: &Path) -> AppResult<()> { + let parent = path + .parent() + .ok_or_else(|| AppFailure::from(anyhow!("conformance setup log has no parent")))?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create conformance log directory {parent:?}")) + .map_err(AppFailure::from)?; + fs::write(path, []) + .with_context(|| format!("failed to clear conformance log {path:?}")) + .map_err(AppFailure::from) +} + +fn conformance_matrix( + client_versions: &[String], + server_eras: &[ConformanceServerEra], +) -> Vec<(String, ConformanceServerEra)> { + client_versions + .iter() + .flat_map(|client_version| { + server_eras + .iter() + .map(|server_era| (client_version.clone(), *server_era)) + }) + .collect() +} + +fn render_conformance_results( + results: &BTreeMap, + direction: ConformanceDirection, + comparisons: Option<&[BaselineComparison]>, + elapsed: Duration, + style: OutputStyle, + blessing: bool, +) -> String { + let mut passed = 0; + let mut expected_failures = 0; + let mut unexpected_passes = 0; + let mut failed = 0; + let mut skipped = 0; + let mut ambiguous = 0; + let mut output = String::new(); + + for (lane, lane_results) in results { + let comparison = comparisons.and_then(|comparisons| { + comparisons + .iter() + .find(|comparison| comparison.lane == *lane) + }); + let total = lane_results.scenarios.len(); + let divider = style.info("────────────"); + let heading = style.heading(&format!(" MCP {direction} conformance results: {lane}")); + let _ = writeln!(output, "{divider}\n{heading}"); + for (index, result) in lane_results.scenarios.values().enumerate() { + let status = conformance_test_status(result, comparison, blessing); + match status { + TestStatus::Pass => passed += 1, + TestStatus::ExpectedFailure => expected_failures += 1, + TestStatus::UnexpectedPass => unexpected_passes += 1, + TestStatus::Fail => failed += 1, + TestStatus::Skip => skipped += 1, + TestStatus::Unknown => ambiguous += 1, + } + let name = format!( + "{}::{}::{}", + direction.label(), + lane.slug(), + result.scenario + ); + let _ = writeln!( + output, + "{}", + style.test_result(status, &name, None, Some((index + 1, total))) + ); + } + } + + let divider = style.info("────────────"); + let summary = if failed > 0 || unexpected_passes > 0 { + style.failure_heading("Summary") + } else if ambiguous > 0 { + style.unknown_heading("Summary") + } else { + style.success_heading("Summary") + }; + let _ = write!( + output, + "{divider}\n {summary} [{:>8.3}s] {passed} passed, {expected_failures} xfailed, {unexpected_passes} xpassed, {failed} failed, {skipped} skipped, {ambiguous} unknown", + elapsed.as_secs_f64() + ); + output +} + +fn conformance_test_status( + result: &crate::conformance::results::ConformanceScenarioResult, + comparison: Option<&BaselineComparison>, + blessing: bool, +) -> TestStatus { + let scenario = result.scenario.as_str(); + if let Some(comparison) = comparison { + if !blessing + && comparison + .unexpected + .iter() + .any(|finding| finding.scenario == scenario) + { + return TestStatus::Fail; + } + if !blessing + && comparison + .stale + .iter() + .any(|finding| finding.scenario == scenario) + { + return TestStatus::UnexpectedPass; + } + if comparison + .actual + .iter() + .any(|finding| finding.scenario == scenario) + { + return TestStatus::ExpectedFailure; + } + } + match result.gated_outcome() { + ScenarioOutcome::Compliant => TestStatus::Pass, + ScenarioOutcome::NonCompliant | ScenarioOutcome::FixtureFailure => { + if comparison.is_some() { + TestStatus::ExpectedFailure + } else { + TestStatus::Fail + } + } + ScenarioOutcome::NotApplicable => TestStatus::Skip, + ScenarioOutcome::Ambiguous | ScenarioOutcome::Missing => TestStatus::Unknown, + } +} + +fn conformance_topologies(lanes: &[SemanticLane]) -> Vec { + let mut topologies = Vec::new(); + if lanes.contains(&SemanticLane::BuiltInDataPlane) { + topologies.push(StackMode::Controlplane); + } + if lanes.contains(&SemanticLane::ExternalDataPlane) { + topologies.push(StackMode::Dataplane); + } + if topologies.is_empty() { + topologies.push(StackMode::Controlplane); + } + topologies +} + +fn parse_conformance_fixture_endpoint(output: &[u8]) -> anyhow::Result { + let output = std::str::from_utf8(output).context("Compose fixture port output is not UTF-8")?; + let address = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .ok_or_else(|| anyhow!("Compose did not publish the conformance fixture port"))? + .parse::() + .context("Compose returned an invalid conformance fixture address")?; + if !address.ip().is_loopback() { + return Err(anyhow!( + "Compose published the conformance fixture on non-loopback address {}", + address.ip() + )); + } + url::Url::parse(&format!("http://{address}/mcp")) + .context("failed to construct the direct conformance fixture URL") +} + +struct OfficialConformanceRun<'a> { + topology: StackMode, + server_id: &'a str, + token: &'a str, + spec_version: &'a str, + server_era: ConformanceServerEra, + fixture: &'a ConformanceFixtureMetadata, + cancellation: tokio::sync::watch::Receiver, +} + +struct DirectConformanceRun<'a> { + endpoint: &'a url::Url, + spec_version: &'a str, + server_era: ConformanceServerEra, + fixture: &'a ConformanceFixtureMetadata, + cancellation: tokio::sync::watch::Receiver, +} + +struct SemanticLaneRun<'a> { + target: SemanticLane, + endpoint: &'a url::Url, + spec_version: &'a str, + server_era: ConformanceServerEra, + fixture: &'a ConformanceFixtureMetadata, + cancellation: tokio::sync::watch::Receiver, +} + +fn combine_cleanup_results(first: AppResult<()>, second: AppResult<()>) -> AppResult<()> { + match (first, second) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Err(first), Err(second)) => Err(AppFailure::from(anyhow!( + "{first}; additionally conformance service cleanup failed: {second}" + ))), + } +} + +async fn finish_phase_after_interrupt( + operation: F, + interrupt: std::pin::Pin<&mut I>, +) -> (T, bool) +where + F: Future, + I: Future, +{ + tokio::pin!(operation); + tokio::select! { + output = &mut operation => (output, false), + () = interrupt => (operation.await, true), + } +} + +fn interrupted_conformance_failure() -> AppFailure { + AppFailure::from(anyhow!("conformance workflow interrupted by Ctrl-C")) +} + +fn client_driver_command() -> anyhow::Result<(String, OsString)> { + let executable = + std::env::current_exe().context("failed to locate the cf-integration binary")?; + let file_name = executable + .file_name() + .and_then(OsStr::to_str) + .context("cf-integration binary name is not UTF-8")?; + if !file_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(anyhow!( + "cf-integration binary name contains characters unsupported by the official client runner" + )); + } + let directory = executable + .parent() + .context("cf-integration binary path has no parent directory")?; + let inherited = std::env::var_os("PATH").context("PATH is required for client conformance")?; + let search_path = std::env::join_paths( + std::iter::once(directory.to_owned()).chain(std::env::split_paths(&inherited)), + ) + .context("failed to prepend cf-integration to the client-conformance PATH")?; + Ok(( + format!("{file_name} {INTERNAL_CLIENT_COMMAND}"), + search_path, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conformance::results::{CheckStatus, ConformanceCheck, ConformanceScenarioResult}; + + fn conformance_result(scenario: &str, status: CheckStatus) -> ConformanceScenarioResult { + ConformanceScenarioResult { + scenario: scenario.to_owned(), + checks: vec![ConformanceCheck { + id: format!("{scenario}-check"), + name: None, + description: None, + status, + timestamp: None, + spec_references: Vec::new(), + error_message: None, + details: None, + metadata: None, + logs: None, + extensions: Default::default(), + }], + source: PathBuf::from(format!("server-{scenario}/checks.json")), + } + } + + fn mixed_conformance_results() -> ConformanceResults { + ConformanceResults { + scenarios: [ + ( + "passing".to_owned(), + conformance_result("passing", CheckStatus::Success), + ), + ( + "failing".to_owned(), + conformance_result("failing", CheckStatus::Failure), + ), + ] + .into_iter() + .collect(), + } + } + + fn scored_finding(scenario: &str) -> crate::conformance::baseline::ScoredFinding { + crate::conformance::baseline::ScoredFinding { + scenario: scenario.to_owned(), + check: "check".to_owned(), + name: String::new(), + status: crate::conformance::baseline::ScoredStatus::Failure, + } + } + + fn result_map(results: ConformanceResults) -> BTreeMap { + [(SemanticLane::ExternalDataPlane, results)] + .into_iter() + .collect() + } + + #[test] + fn lane_selection_uses_only_required_stack_topologies() { + assert_eq!( + conformance_topologies(&[SemanticLane::FixtureDirect]), + [StackMode::Controlplane] + ); + assert_eq!( + conformance_topologies( + &[SemanticLane::FixtureDirect, SemanticLane::ExternalDataPlane,] + ), + [StackMode::Dataplane] + ); + assert_eq!( + conformance_topologies(&[ + SemanticLane::BuiltInDataPlane, + SemanticLane::ExternalDataPlane, + ]), + [StackMode::Controlplane, StackMode::Dataplane] + ); + } + + #[test] + fn direct_fixture_endpoint_accepts_only_loopback_bindings() { + assert_eq!( + parse_conformance_fixture_endpoint(b"127.0.0.1:49152\n") + .expect("IPv4 loopback should be accepted") + .as_str(), + "http://127.0.0.1:49152/mcp" + ); + assert_eq!( + parse_conformance_fixture_endpoint(b"[::1]:49153\n") + .expect("IPv6 loopback should be accepted") + .as_str(), + "http://[::1]:49153/mcp" + ); + assert!( + parse_conformance_fixture_endpoint(b"0.0.0.0:49154\n") + .expect_err("wildcard bindings must be rejected") + .to_string() + .contains("non-loopback") + ); + } + + #[test] + fn cleanup_errors_preserve_the_primary_failure_and_cleanup_context() { + let primary = AppFailure::from(anyhow!("runner failed")); + let cleanup = Err(AppFailure::from(anyhow!("cleanup failed"))); + + let error = finish_with_cleanup(Some(primary), cleanup) + .expect_err("both failures must remain visible") + .to_string(); + + assert!(error.contains("runner failed")); + assert!(error.contains("cleanup failed")); + assert!(error.find("runner failed") < error.find("cleanup failed")); + } + + #[test] + fn independent_cleanup_failures_are_combined() { + let error = combine_cleanup_results( + Err(AppFailure::from(anyhow!("API cleanup failed"))), + Err(AppFailure::from(anyhow!("service cleanup failed"))), + ) + .expect_err("both cleanup failures must be returned") + .to_string(); + + assert!(error.contains("API cleanup failed")); + assert!(error.contains("service cleanup failed")); + } + + #[test] + fn conformance_matrix_is_the_ordered_cartesian_product() { + assert_eq!( + conformance_matrix( + &["2025-11-25".to_owned(), "2026-07-28".to_owned()], + &[ConformanceServerEra::Legacy, ConformanceServerEra::Modern,], + ), + [ + ("2025-11-25".to_owned(), ConformanceServerEra::Legacy), + ("2025-11-25".to_owned(), ConformanceServerEra::Modern), + ("2026-07-28".to_owned(), ConformanceServerEra::Legacy), + ("2026-07-28".to_owned(), ConformanceServerEra::Modern), + ] + ); + } + + #[test] + fn conformance_baseline_results_render_pass_and_expected_failure_per_scenario() { + let results = result_map(mixed_conformance_results()); + let failing = scored_finding("failing"); + let comparison = BaselineComparison { + lane: SemanticLane::ExternalDataPlane, + actual: vec![failing.clone()], + expected: vec![failing], + unexpected: Vec::new(), + stale: Vec::new(), + }; + + let rendered = render_conformance_results( + &results, + ConformanceDirection::Server, + Some(&[comparison]), + Duration::from_millis(1_250), + OutputStyle::plain(), + false, + ); + + assert_eq!( + rendered, + "────────────\n MCP server conformance results: external dataplane\n XFAIL (1/2) server::external-data-plane::failing\n PASS (2/2) server::external-data-plane::passing\n────────────\n Summary [ 1.250s] 1 passed, 1 xfailed, 0 xpassed, 0 failed, 0 skipped, 0 unknown" + ); + } + + #[test] + fn conformance_results_render_without_a_baseline_when_the_gate_cannot_load() { + let rendered = render_conformance_results( + &result_map(mixed_conformance_results()), + ConformanceDirection::Server, + None, + Duration::from_millis(1_250), + OutputStyle::plain(), + false, + ); + + assert_eq!( + rendered, + "────────────\n MCP server conformance results: external dataplane\n FAIL (1/2) server::external-data-plane::failing\n PASS (2/2) server::external-data-plane::passing\n────────────\n Summary [ 1.250s] 1 passed, 0 xfailed, 0 xpassed, 1 failed, 0 skipped, 0 unknown" + ); + } + + #[test] + fn client_conformance_results_render_the_downstream_direction() { + let rendered = render_conformance_results( + &result_map(mixed_conformance_results()), + ConformanceDirection::Client, + None, + Duration::from_millis(1_250), + OutputStyle::plain(), + false, + ); + + assert!(rendered.contains("MCP client conformance results: external dataplane")); + assert!(rendered.contains("client::external-data-plane::failing")); + assert!(rendered.contains("client::external-data-plane::passing")); + } + + #[test] + fn colored_conformance_output_distinguishes_expected_and_unexpected_results() { + let results = result_map(ConformanceResults { + scenarios: [ + ( + "expected".to_owned(), + conformance_result("expected", CheckStatus::Failure), + ), + ( + "passing".to_owned(), + conformance_result("passing", CheckStatus::Success), + ), + ( + "stale".to_owned(), + conformance_result("stale", CheckStatus::Success), + ), + ( + "unexpected".to_owned(), + conformance_result("unexpected", CheckStatus::Failure), + ), + ] + .into_iter() + .collect(), + }); + let expected = scored_finding("expected"); + let unexpected = scored_finding("unexpected"); + let stale = scored_finding("stale"); + let comparison = BaselineComparison { + lane: SemanticLane::ExternalDataPlane, + actual: vec![expected.clone(), unexpected.clone()], + expected: vec![expected, stale.clone()], + unexpected: vec![unexpected], + stale: vec![stale], + }; + let rendered = render_conformance_results( + &results, + ConformanceDirection::Server, + Some(&[comparison]), + Duration::from_millis(1_250), + OutputStyle::colored(), + false, + ); + + assert!(rendered.contains("\x1b[33m XFAIL\x1b[0m")); + assert!(rendered.contains("\x1b[32m PASS\x1b[0m")); + assert!(rendered.contains("\x1b[31m XPASS\x1b[0m")); + assert!(rendered.contains("\x1b[31m FAIL\x1b[0m")); + assert!(rendered.contains(" \x1b[1;31mSummary\x1b[0m [ 1.250s]")); + } +} diff --git a/src/runtime/conformance/reports.rs b/src/runtime/conformance/reports.rs new file mode 100644 index 0000000..7d1e44e --- /dev/null +++ b/src/runtime/conformance/reports.rs @@ -0,0 +1,716 @@ +//! Official conformance artifact paths, loading, and report generation. + +use super::*; + +const CONFORMANCE_COMPLETION_MARKER: &[u8] = b"complete\n"; + +impl RuntimeContext { + pub(super) fn regenerate_conformance_report( + &self, + results_dir: Option<&Path>, + output_dir: Option<&Path>, + ) -> AppResult<()> { + let artifact_root = results_dir.unwrap_or_else(|| self.config.integration_dir()); + let report_root = output_dir + .map(Path::to_owned) + .unwrap_or_else(|| self.config.root().join("reports")); + let runs = discover_conformance_runs(artifact_root, &report_root)?; + let mut failures = Vec::new(); + for paths in runs { + match self.write_comparison_from_artifacts(&paths, None) { + Ok(comparison) => println!( + "{} {}", + OutputStyle::stdout().info("Conformance comparison:"), + comparison.display() + ), + Err(error) => failures.push(format!("{}: {error}", paths.identity())), + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(AppFailure::from(anyhow!( + "conformance report regeneration completed with failures:\n- {}", + failures.join("\n- ") + ))) + } + } + + pub(super) fn write_comparison_from_artifacts( + &self, + paths: &ConformancePaths, + expected_run: Option<(&str, ConformanceServerEra, &str)>, + ) -> AppResult { + let fixture = self.load_conformance_artifact(paths, SemanticLane::FixtureDirect)?; + let built_in = self.load_conformance_artifact(paths, SemanticLane::BuiltInDataPlane)?; + let external = self.load_conformance_artifact(paths, SemanticLane::ExternalDataPlane)?; + if fixture.is_none() && built_in.is_none() && external.is_none() { + return Err(AppFailure::from(anyhow!( + "no official conformance artifacts found beneath {}", + paths.conformance_root.display() + ))); + } + let missing = [ + (SemanticLane::FixtureDirect, fixture.is_none()), + (SemanticLane::BuiltInDataPlane, built_in.is_none()), + (SemanticLane::ExternalDataPlane, external.is_none()), + ] + .into_iter() + .filter_map(|(lane, missing)| missing.then_some(lane.slug())) + .collect::>(); + if !missing.is_empty() { + return Err(AppFailure::from(anyhow!( + "missing conformance lanes for {}: {}", + paths.identity(), + missing.join(", ") + ))); + } + + let fixture = fixture.ok_or_else(|| { + AppFailure::from(anyhow!("missing fixture-direct conformance artifact")) + })?; + let built_in = built_in.ok_or_else(|| { + AppFailure::from(anyhow!("missing built-in dataplane conformance artifact")) + })?; + let external = external.ok_or_else(|| { + AppFailure::from(anyhow!("missing external dataplane conformance artifact")) + })?; + let metadata = compatible_metadata( + Some(&fixture.metadata), + Some(&built_in.metadata), + Some(&external.metadata), + expected_run, + )?; + let scenarios = compare_result_sets(&fixture.results, &built_in.results, &external.results); + let output = paths.report_output.join("mcp-conformance-comparison.md"); + write_comparison_report( + &output, + &ComparisonReport { + client_version: metadata.client_version.clone(), + server_era: metadata.server_era, + suite: metadata.suite.clone(), + fixture: metadata.fixture.clone(), + scenarios, + }, + ) + .map_err(AppFailure::from)?; + Ok(output) + } + + fn load_conformance_artifact( + &self, + paths: &ConformancePaths, + target: SemanticLane, + ) -> AppResult> { + let artifact = paths.conformance_lane(target); + if !artifact.metadata.is_file() + && !artifact.official_results.is_dir() + && !artifact.completion.is_file() + { + return Ok(None); + } + if !artifact.metadata.is_file() + || !artifact.official_results.is_dir() + || !artifact.completion.is_file() + { + return Err(AppFailure::from(anyhow!( + "incomplete conformance artifacts for {target} beneath {}", + artifact.root.display() + ))); + } + verify_completion_marker(&artifact.completion)?; + let metadata = read_run_metadata(&artifact.metadata)?; + if metadata.direction != ConformanceDirection::Server { + return Err(AppFailure::from(anyhow!( + "conformance metadata direction {} does not match server", + metadata.direction + ))); + } + if metadata.target != target.label() { + return Err(AppFailure::from(anyhow!( + "conformance metadata target {:?} does not match {target}", + metadata.target + ))); + } + if metadata.oracle != crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE { + return Err(AppFailure::from(anyhow!( + "conformance artifacts used oracle {:?}, expected {:?}", + metadata.oracle, + crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE + ))); + } + if !is_trusted_official_fixture(&metadata.fixture) { + return Err(AppFailure::from(anyhow!( + "conformance artifacts do not identify the pinned official fixture" + ))); + } + let results = load_server_results(&artifact.official_results).map_err(AppFailure::from)?; + validate_server_scenario_set(&results, &metadata.suite, &metadata.client_version) + .map_err(AppFailure::from)?; + validate_scored_results(&results).map_err(AppFailure::from)?; + Ok(Some(LoadedConformanceArtifact { results, metadata })) + } + + pub(super) fn load_selected_conformance_results( + &self, + paths: &ConformancePaths, + lanes: &[SemanticLane], + ) -> AppResult> { + let results = self.load_completed_conformance_results(paths, lanes)?; + for lane in conformance_evidence_lanes(lanes) { + if !results.contains_key(&lane) { + return Err(AppFailure::from(anyhow!( + "missing required conformance lane {} for {}", + lane.slug(), + paths.identity() + ))); + } + } + Ok(results) + } + + pub(super) fn load_completed_conformance_results( + &self, + paths: &ConformancePaths, + lanes: &[SemanticLane], + ) -> AppResult> { + let mut results = BTreeMap::new(); + for lane in conformance_evidence_lanes(lanes) { + if let Some(artifact) = self.load_conformance_artifact(paths, lane)? { + results.insert(lane, artifact.results); + } + } + Ok(results) + } + + pub(super) fn load_completed_client_conformance_results( + &self, + paths: &ConformancePaths, + ) -> AppResult> { + let lane = SemanticLane::ExternalDataPlane; + let artifact = paths.client_conformance_lane(lane); + if !artifact.metadata.is_file() + && !artifact.official_results.is_dir() + && !artifact.completion.is_file() + { + return Ok(BTreeMap::new()); + } + if !artifact.metadata.is_file() + || !artifact.official_results.is_dir() + || !artifact.completion.is_file() + { + return Err(AppFailure::from(anyhow!( + "incomplete client-conformance artifacts for {lane} beneath {}", + artifact.root.display() + ))); + } + verify_completion_marker(&artifact.completion)?; + let metadata = read_run_metadata(&artifact.metadata)?; + if metadata.direction != ConformanceDirection::Client || metadata.target != lane.label() { + return Err(AppFailure::from(anyhow!( + "client-conformance metadata does not match external dataplane" + ))); + } + if metadata.oracle != crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE + || !is_trusted_official_fixture(&metadata.fixture) + { + return Err(AppFailure::from(anyhow!( + "client-conformance artifacts do not identify the pinned official runner" + ))); + } + let results = load_client_results(&artifact.official_results).map_err(AppFailure::from)?; + validate_client_scenario_set(&results, &metadata.client_version) + .map_err(AppFailure::from)?; + validate_scored_results(&results).map_err(AppFailure::from)?; + Ok(BTreeMap::from([(lane, results)])) + } +} + +fn conformance_evidence_lanes(selected: &[SemanticLane]) -> Vec { + let routed = selected.iter().any(|lane| { + matches!( + lane, + SemanticLane::BuiltInDataPlane | SemanticLane::ExternalDataPlane + ) + }); + [ + SemanticLane::FixtureDirect, + SemanticLane::BuiltInDataPlane, + SemanticLane::ExternalDataPlane, + ] + .into_iter() + .filter(|lane| selected.contains(lane) || (routed && *lane == SemanticLane::FixtureDirect)) + .collect() +} + +#[derive(Debug, Clone)] +pub(super) struct ConformancePaths { + pub(super) conformance_root: PathBuf, + pub(super) report_output: PathBuf, + client_version: String, + server_era: ConformanceServerEra, +} + +impl ConformancePaths { + pub(super) fn new( + artifact_root: &Path, + report_root: PathBuf, + client_version: &str, + server_era: ConformanceServerEra, + ) -> Self { + Self { + conformance_root: artifact_root + .join("conformance") + .join(client_version) + .join(server_era.label()), + report_output: report_root + .join("conformance") + .join(client_version) + .join(server_era.label()), + client_version: client_version.to_owned(), + server_era, + } + } + + pub(super) fn identity(&self) -> String { + format!("client {}, server {}", self.client_version, self.server_era) + } + + pub(super) fn setup_log(&self) -> PathBuf { + self.conformance_root.join("setup.log") + } + + pub(super) fn baseline_report(&self, target: SemanticLane) -> PathBuf { + self.report_output + .join(target.slug()) + .join("baseline-comparison.yml") + } + + pub(super) fn client_baseline_report(&self, target: SemanticLane) -> PathBuf { + self.report_output + .join("client") + .join(target.slug()) + .join("baseline-comparison.yml") + } + + pub(super) fn conformance_lane(&self, target: SemanticLane) -> ConformanceLanePaths { + let root = self.conformance_root.join(target.slug()); + ConformanceLanePaths { + official_results: root.join("official"), + runner_log: root.join("runner.log"), + expected_failures: root.join("expected-failures.yml"), + metadata: root.join("metadata.json"), + completion: root.join("complete"), + root, + } + } + + pub(super) fn client_conformance_lane(&self, target: SemanticLane) -> ConformanceLanePaths { + let root = self.conformance_root.join("client").join(target.slug()); + ConformanceLanePaths { + official_results: root.join("official"), + runner_log: root.join("runner.log"), + expected_failures: root.join("expected-failures.yml"), + metadata: root.join("metadata.json"), + completion: root.join("complete"), + root, + } + } + + pub(super) fn clear_conformance(&self) -> AppResult<()> { + for target in [ + SemanticLane::FixtureDirect, + SemanticLane::BuiltInDataPlane, + SemanticLane::ExternalDataPlane, + ] { + remove_artifact_directory(&self.conformance_lane(target).root)?; + } + remove_artifact_directory( + &self + .client_conformance_lane(SemanticLane::ExternalDataPlane) + .root, + )?; + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub(super) struct ConformanceLanePaths { + pub(super) root: PathBuf, + pub(super) official_results: PathBuf, + pub(super) runner_log: PathBuf, + pub(super) expected_failures: PathBuf, + pub(super) metadata: PathBuf, + pub(super) completion: PathBuf, +} + +struct LoadedConformanceArtifact { + results: ConformanceResults, + metadata: ConformanceRunMetadata, +} + +fn discover_conformance_runs( + artifact_root: &Path, + report_root: &Path, +) -> AppResult> { + let root = artifact_root.join("conformance"); + let version_entries = strict_directories(&root, "client-version")?; + let mut runs = Vec::new(); + for version_entry in version_entries { + let client_version = version_entry + .file_name() + .into_string() + .map_err(|_| AppFailure::from(anyhow!("client-version directory is not UTF-8")))?; + ProtocolVersion::from_str(&client_version).map_err(|error| { + AppFailure::from(anyhow!( + "invalid conformance client-version directory {client_version:?}: {error}" + )) + })?; + for era_entry in strict_directories(&version_entry.path(), "server-era")? { + let label = era_entry + .file_name() + .into_string() + .map_err(|_| AppFailure::from(anyhow!("server-era directory is not UTF-8")))?; + let server_era = ConformanceServerEra::from_label(&label).ok_or_else(|| { + AppFailure::from(anyhow!( + "unknown conformance server-era directory {label:?}" + )) + })?; + runs.push(ConformancePaths::new( + artifact_root, + report_root.to_owned(), + &client_version, + server_era, + )); + } + } + if runs.is_empty() { + return Err(AppFailure::from(anyhow!( + "no partitioned official conformance artifacts found beneath {}", + root.display() + ))); + } + Ok(runs) +} + +fn strict_directories(path: &Path, dimension: &str) -> AppResult> { + let mut entries = fs::read_dir(path) + .with_context(|| format!("failed to read conformance {dimension} directory {path:?}")) + .map_err(AppFailure::from)? + .collect::>>() + .with_context(|| format!("failed to enumerate conformance directory {path:?}")) + .map_err(AppFailure::from)?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in &entries { + let file_type = entry + .file_type() + .with_context(|| format!("failed to inspect conformance entry {:?}", entry.path())) + .map_err(AppFailure::from)?; + if !file_type.is_dir() || file_type.is_symlink() { + return Err(AppFailure::from(anyhow!( + "unexpected non-directory entry in conformance {dimension} directory: {}", + entry.path().display() + ))); + } + } + Ok(entries) +} + +pub(super) fn recreate_directory(path: &Path) -> AppResult<()> { + if path.exists() { + fs::remove_dir_all(path) + .with_context(|| format!("failed to clear result directory {path:?}")) + .map_err(AppFailure::from)?; + } + fs::create_dir_all(path) + .with_context(|| format!("failed to create result directory {path:?}")) + .map_err(AppFailure::from) +} + +pub(super) fn remove_file_if_exists(path: &Path) -> AppResult<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppFailure::from( + anyhow!(error).context(format!("failed to clear completion marker {path:?}")), + )), + } +} + +fn write_completion_marker(path: &Path) -> AppResult<()> { + fs::write(path, CONFORMANCE_COMPLETION_MARKER) + .with_context(|| format!("failed to write conformance completion marker {path:?}")) + .map_err(AppFailure::from) +} + +pub(super) fn conformance_process_completed(process_result: &AppResult<()>) -> bool { + match process_result { + Ok(()) => true, + Err(AppFailure::Infrastructure(InfrastructureError::ChildExit { status, .. })) => { + status.code().is_some() + } + Err(AppFailure::Infrastructure(InfrastructureError::Native(_))) + | Err(AppFailure::Native(_)) => false, + } +} + +pub(super) fn mark_conformance_complete( + process_result: &AppResult<()>, + results: &ConformanceResults, + target: SemanticLane, + suite: &str, + spec_version: &str, + path: &Path, +) -> AppResult { + if !conformance_process_completed(process_result) { + return Ok(false); + } + validate_server_scenario_set(results, suite, spec_version) + .with_context(|| format!("official conformance did not complete for {target}")) + .map_err(AppFailure::from)?; + write_completion_marker(path)?; + Ok(true) +} + +pub(super) fn mark_client_conformance_complete( + results: &ConformanceResults, + target: SemanticLane, + spec_version: &str, + path: &Path, +) -> AppResult<()> { + validate_client_scenario_set(results, spec_version) + .with_context(|| format!("official client conformance did not complete for {target}")) + .map_err(AppFailure::from)?; + write_completion_marker(path) +} + +fn verify_completion_marker(path: &Path) -> AppResult<()> { + let marker = fs::read(path) + .with_context(|| format!("failed to read conformance completion marker {path:?}")) + .map_err(AppFailure::from)?; + if marker != CONFORMANCE_COMPLETION_MARKER { + return Err(AppFailure::from(anyhow!( + "invalid conformance completion marker {path:?}" + ))); + } + Ok(()) +} + +pub(super) fn write_run_metadata(path: &Path, metadata: &ConformanceRunMetadata) -> AppResult<()> { + let serialized = serde_json::to_vec_pretty(metadata) + .context("failed to serialize conformance run metadata") + .map_err(AppFailure::from)?; + fs::write(path, serialized) + .with_context(|| format!("failed to write conformance run metadata {path:?}")) + .map_err(AppFailure::from) +} + +fn read_run_metadata(path: &Path) -> AppResult { + let source = fs::read(path) + .with_context(|| format!("failed to read conformance run metadata {path:?}")) + .map_err(AppFailure::from)?; + serde_json::from_slice(&source) + .with_context(|| format!("failed to parse conformance run metadata {path:?}")) + .map_err(AppFailure::from) +} + +fn compatible_metadata<'a>( + fixture: Option<&'a ConformanceRunMetadata>, + built_in: Option<&'a ConformanceRunMetadata>, + external: Option<&'a ConformanceRunMetadata>, + expected_run: Option<(&str, ConformanceServerEra, &str)>, +) -> AppResult<&'a ConformanceRunMetadata> { + let metadata = fixture.or(built_in).or(external).ok_or_else(|| { + AppFailure::from(anyhow!( + "no conformance metadata is available for reporting" + )) + })?; + for candidate in [fixture, built_in, external].into_iter().flatten() { + if candidate.fixture != metadata.fixture { + return Err(AppFailure::from(anyhow!( + "direct fixture, built-in dataplane, and external dataplane conformance fixture provenance mismatch" + ))); + } + if candidate.client_version != metadata.client_version + || candidate.server_era != metadata.server_era + || candidate.suite != metadata.suite + || candidate.oracle != metadata.oracle + { + return Err(AppFailure::from(anyhow!( + "direct fixture, built-in dataplane, and external dataplane conformance artifacts were produced by incompatible runs" + ))); + } + } + if let Some((spec_version, server_era, suite)) = expected_run + && (metadata.client_version != spec_version + || metadata.server_era != server_era + || metadata.suite != suite) + { + return Err(AppFailure::from(anyhow!( + "conformance artifacts do not match requested client spec version {spec_version:?}, server era {server_era:?}, and suite {suite:?}" + ))); + } + Ok(metadata) +} + +pub(super) const fn conformance_target(topology: StackMode) -> SemanticLane { + match topology { + StackMode::Controlplane => SemanticLane::BuiltInDataPlane, + StackMode::Dataplane => SemanticLane::ExternalDataPlane, + } +} + +fn remove_artifact_directory(path: &Path) -> AppResult<()> { + match fs::remove_dir_all(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AppFailure::from( + anyhow!(error).context(format!("failed to clear conformance artifacts {path:?}")), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conformance::fixture::{ + OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION, + OFFICIAL_CONFORMANCE_SERVER_ID, + }; + use crate::conformance::results::OFFICIAL_CONFORMANCE_PACKAGE; + + fn metadata(target: SemanticLane) -> ConformanceRunMetadata { + ConformanceRunMetadata { + oracle: OFFICIAL_CONFORMANCE_PACKAGE.to_owned(), + target: target.label().to_owned(), + direction: ConformanceDirection::Server, + client_version: "2026-07-28".to_owned(), + server_era: ConformanceServerEra::Dual, + suite: "all".to_owned(), + fixture: ConformanceFixtureMetadata { + repository: OFFICIAL_CONFORMANCE_REPOSITORY.to_owned(), + revision: OFFICIAL_CONFORMANCE_REVISION.to_owned(), + server_id: OFFICIAL_CONFORMANCE_SERVER_ID.to_owned(), + }, + } + } + + #[test] + fn conformance_paths_partition_all_three_lanes() { + let paths = ConformancePaths::new( + Path::new("artifacts"), + PathBuf::from("reports"), + "2026-07-28", + ConformanceServerEra::Modern, + ); + + assert_eq!( + paths.conformance_lane(SemanticLane::FixtureDirect).root, + PathBuf::from("artifacts/conformance/2026-07-28/modern/fixture-direct") + ); + assert_eq!( + paths.conformance_lane(SemanticLane::BuiltInDataPlane).root, + PathBuf::from("artifacts/conformance/2026-07-28/modern/built-in-data-plane") + ); + assert_eq!( + paths.conformance_lane(SemanticLane::ExternalDataPlane).root, + PathBuf::from("artifacts/conformance/2026-07-28/modern/external-data-plane") + ); + assert_eq!( + paths.baseline_report(SemanticLane::BuiltInDataPlane), + PathBuf::from( + "reports/conformance/2026-07-28/modern/built-in-data-plane/baseline-comparison.yml" + ) + ); + } + + #[test] + fn routed_selection_loads_direct_fixture_evidence_without_selecting_its_baseline() { + assert_eq!( + conformance_evidence_lanes(&[SemanticLane::BuiltInDataPlane]), + [SemanticLane::FixtureDirect, SemanticLane::BuiltInDataPlane] + ); + assert_eq!( + conformance_evidence_lanes(&[SemanticLane::ExternalDataPlane]), + [SemanticLane::FixtureDirect, SemanticLane::ExternalDataPlane] + ); + assert_eq!( + conformance_evidence_lanes(&[SemanticLane::FixtureDirect]), + [SemanticLane::FixtureDirect] + ); + } + + #[test] + fn clearing_a_run_removes_every_lane_to_prevent_stale_comparisons() { + let directory = tempfile::tempdir().expect("temporary artifact root"); + let paths = ConformancePaths::new( + directory.path(), + PathBuf::from("reports"), + "2026-07-28", + ConformanceServerEra::Dual, + ); + for target in [ + SemanticLane::FixtureDirect, + SemanticLane::BuiltInDataPlane, + SemanticLane::ExternalDataPlane, + ] { + fs::create_dir_all(paths.conformance_lane(target).root) + .expect("lane directory should be created"); + } + + paths + .clear_conformance() + .expect("all old lanes should be removed"); + + for target in [ + SemanticLane::FixtureDirect, + SemanticLane::BuiltInDataPlane, + SemanticLane::ExternalDataPlane, + ] { + assert!(!paths.conformance_lane(target).root.exists()); + } + } + + #[test] + fn partial_lane_metadata_is_reportable_when_provenance_matches() { + let fixture = metadata(SemanticLane::FixtureDirect); + let dataplane = metadata(SemanticLane::ExternalDataPlane); + + let selected = compatible_metadata( + Some(&fixture), + None, + Some(&dataplane), + Some(("2026-07-28", ConformanceServerEra::Dual, "all")), + ) + .expect("selected lanes should be compatible"); + + assert_eq!(selected.client_version, "2026-07-28"); + } + + #[test] + fn mismatched_fixture_provenance_prevents_cross_lane_comparison() { + let fixture = metadata(SemanticLane::FixtureDirect); + let mut dataplane = metadata(SemanticLane::ExternalDataPlane); + dataplane.fixture.revision = "different".to_owned(); + + let error = compatible_metadata(Some(&fixture), None, Some(&dataplane), None) + .expect_err("mismatched provenance must fail") + .to_string(); + + assert!(error.contains("provenance mismatch")); + assert!(!error.contains("different")); + } + + #[test] + fn mismatched_server_eras_prevent_cross_lane_comparison() { + let fixture = metadata(SemanticLane::FixtureDirect); + let mut dataplane = metadata(SemanticLane::ExternalDataPlane); + dataplane.server_era = ConformanceServerEra::Legacy; + + let error = compatible_metadata(Some(&fixture), None, Some(&dataplane), None) + .expect_err("different server eras must not be compared") + .to_string(); + + assert!(error.contains("incompatible runs")); + } +} diff --git a/src/runtime/control_plane.rs b/src/runtime/control_plane.rs new file mode 100644 index 0000000..98589d2 --- /dev/null +++ b/src/runtime/control_plane.rs @@ -0,0 +1,215 @@ +//! Control-plane authentication and managed credential lifecycle. + +use std::time::Duration; + +use anyhow::{Context, anyhow}; +use reqwest::{Client, StatusCode}; +use serde::Deserialize; + +use crate::infrastructure::config::AppConfig; + +use super::{AppFailure, AppResult, required_text}; + +const MANAGED_TOKEN_DESCRIPTION: &str = "Ephemeral cf-integration dataplane credential"; +pub(super) const CONFORMANCE_TOKEN_DESCRIPTION: &str = + "Ephemeral cf-integration conformance credential"; + +pub(super) struct ManagedBearerToken { + pub(super) value: String, + pub(super) catalog_id: Option, + pub(super) catalog_admin_token: Option, +} + +impl ManagedBearerToken { + pub(super) fn unmanaged(value: String) -> Self { + Self { + value, + catalog_id: None, + catalog_admin_token: None, + } + } +} + +#[derive(Deserialize)] +struct TokenCreateResponse { + token: TokenRecord, + access_token: String, +} + +#[derive(Deserialize)] +struct TokenRecord { + id: String, +} + +#[derive(Deserialize)] +struct AuthenticationResponse { + access_token: String, +} + +/// Owns all control-plane HTTP behavior and credential cleanup. +pub(super) struct ControlPlaneClient { + base_url: url::Url, + admin_email: String, + admin_password: String, + http: Client, +} + +impl ControlPlaneClient { + pub(super) fn new(config: &AppConfig) -> AppResult { + let base_url = + url::Url::parse(required_text(&config.base_url().value, "MCP_CLI_BASE_URL")?) + .context("MCP_CLI_BASE_URL is not a valid URL") + .map_err(AppFailure::from)?; + let admin_email = + required_text(&config.platform_admin_email().value, "PLATFORM_ADMIN_EMAIL")?.to_owned(); + let admin_password = required_text( + &config.platform_admin_password().value, + "PLATFORM_ADMIN_PASSWORD", + )? + .to_owned(); + let http = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build control-plane client") + .map_err(AppFailure::from)?; + Ok(Self { + base_url, + admin_email, + admin_password, + http, + }) + } + + pub(super) async fn admin_session_token(&self) -> AppResult { + let endpoint = self.endpoint("/v1/auth/email/login", "login")?; + let response = self + .http + .post(endpoint) + .json(&serde_json::json!({ + "email": self.admin_email, + "password": self.admin_password, + })) + .send() + .await + .context("control-plane login failed before receiving a response") + .map_err(AppFailure::from)?; + if !response.status().is_success() { + return Err(AppFailure::from(anyhow!( + "control-plane login returned HTTP {}", + response.status().as_u16() + ))); + } + let authenticated: AuthenticationResponse = response + .json() + .await + .context("control-plane login returned an invalid authentication response") + .map_err(AppFailure::from)?; + if authenticated.access_token.is_empty() { + return Err(AppFailure::from(anyhow!( + "control-plane login returned an empty access token" + ))); + } + Ok(authenticated.access_token) + } + + pub(super) async fn issue_dataplane_token( + &self, + server_id: &str, + ) -> AppResult { + self.issue_catalog_token(Some(server_id), MANAGED_TOKEN_DESCRIPTION) + .await + } + + pub(super) async fn issue_conformance_token(&self) -> AppResult { + self.issue_catalog_token(None, CONFORMANCE_TOKEN_DESCRIPTION) + .await + } + + async fn issue_catalog_token( + &self, + server_id: Option<&str>, + description: &str, + ) -> AppResult { + let endpoint = self.endpoint("/v1/tokens", "token catalog")?; + let admin_token = self.admin_session_token().await?; + let mut payload = serde_json::json!({ + "name": format!("cf-integration-{}", uuid::Uuid::new_v4()), + "description": description, + "expires_in_days": 1, + "user_email": self.admin_email, + }); + if let Some(server_id) = server_id { + payload["scope"] = serde_json::json!({ + "server_id": server_id, + "permissions": ["servers.read", "servers.use", "tools.read", "tools.call"], + }); + } + let response = self + .http + .post(endpoint) + .bearer_auth(&admin_token) + .json(&payload) + .send() + .await + .context("token catalog request failed before receiving a response") + .map_err(AppFailure::from)?; + if !response.status().is_success() { + return Err(AppFailure::from(anyhow!( + "token catalog returned HTTP {} while issuing a managed credential", + response.status().as_u16() + ))); + } + let issued: TokenCreateResponse = response + .json() + .await + .context("token catalog returned an invalid credential response") + .map_err(AppFailure::from)?; + if issued.token.id.is_empty() || issued.access_token.is_empty() { + return Err(AppFailure::from(anyhow!( + "token catalog returned an incomplete credential response" + ))); + } + Ok(ManagedBearerToken { + value: issued.access_token, + catalog_id: Some(issued.token.id), + catalog_admin_token: Some(admin_token), + }) + } + + pub(super) async fn revoke(&self, token: &ManagedBearerToken) -> AppResult<()> { + let Some(id) = token.catalog_id.as_deref() else { + return Ok(()); + }; + let admin_token = token.catalog_admin_token.as_deref().ok_or_else(|| { + AppFailure::from(anyhow!( + "managed token is missing its control-plane cleanup credential" + )) + })?; + let endpoint = self.endpoint(&format!("/v1/tokens/{id}"), "token revocation")?; + let response = self + .http + .delete(endpoint) + .bearer_auth(admin_token) + .send() + .await + .context("token revocation failed before receiving a response") + .map_err(AppFailure::from)?; + if response.status().is_success() || response.status() == StatusCode::NOT_FOUND { + Ok(()) + } else { + Err(AppFailure::from(anyhow!( + "token catalog returned HTTP {} while revoking the dataplane credential", + response.status().as_u16() + ))) + } + } + + fn endpoint(&self, path: &str, label: &str) -> AppResult { + self.base_url + .join(path) + .with_context(|| format!("failed to construct control-plane {label} URL")) + .map_err(AppFailure::from) + } +} diff --git a/src/runtime/inspect.rs b/src/runtime/inspect.rs index 4be13e9..481ee27 100644 --- a/src/runtime/inspect.rs +++ b/src/runtime/inspect.rs @@ -17,7 +17,7 @@ pub(super) const NPM_ENV_ALLOWLIST: &[&str] = &[ "NODE_EXTRA_CA_CERTS", ]; -impl RuntimeExecutor { +impl RuntimeContext { pub(super) async fn inspect( &self, mode: StackMode, diff --git a/src/runtime/live.rs b/src/runtime/live/mod.rs similarity index 77% rename from src/runtime/live.rs rename to src/runtime/live/mod.rs index a65b09b..d8d33f6 100644 --- a/src/runtime/live.rs +++ b/src/runtime/live/mod.rs @@ -2,22 +2,21 @@ use super::*; -const FAST_TEST_SERVER_ID: &str = "b8e3f1a2c4d5e6f7a1b2c3d4e5f6a7b8"; const LIVE_ALL_TARGETS: [&str; 3] = [ "test-mcp-protocol-e2e", "test-mcp-rbac", "test-protocol-compliance-gateway", ]; -impl RuntimeExecutor { +impl RuntimeContext { pub(super) async fn run_live( &self, - lane: LiveLane, + lane: SemanticLane, group: LiveGroup, protocol_version: &ProtocolVersion, ) -> AppResult<()> { match lane { - LiveLane::Fixture => { + SemanticLane::FixtureDirect => { if group != LiveGroup::Protocol { return Err(AppFailure::from(anyhow!( "fixture-direct live lane requires the protocol group" @@ -30,11 +29,11 @@ impl RuntimeExecutor { protocol_version, ) } - LiveLane::BuiltInDataPlane => { + SemanticLane::BuiltInDataPlane => { self.run_routed_live(StackMode::Controlplane, group, protocol_version) .await } - LiveLane::ExternalDataPlane => { + SemanticLane::ExternalDataPlane => { self.run_routed_live(StackMode::Dataplane, group, protocol_version) .await } @@ -49,9 +48,6 @@ impl RuntimeExecutor { ) -> AppResult<()> { let server_id = self.default_server_id().to_owned(); self.with_managed_test_target(topology, &server_id, || async { - if live_group_needs_fast_test(group) { - self.ensure_fast_test_fixture(topology).await?; - } match group { LiveGroup::Mcp => { self.run_controlplane_make(topology, "test-mcp-protocol-e2e", protocol_version) @@ -70,39 +66,38 @@ impl RuntimeExecutor { .await } - async fn ensure_fast_test_fixture(&self, topology: StackMode) -> AppResult<()> { - let project = self.compose_project(topology); - for plan in [ - StackCommandPlan::fast_test_up(project.clone()), - StackCommandPlan::fast_test_register(project), - ] { - self.runner.run(&self.compose_environment( - plan.command().clone(), - topology, - true, - )?)?; - } - if topology == StackMode::Dataplane { - self.wait_for_publisher_snapshot(FAST_TEST_SERVER_ID) - .await?; - } - Ok(()) - } - fn run_controlplane_make( &self, topology: StackMode, target: &str, protocol_version: &ProtocolVersion, ) -> AppResult<()> { - let command = CommandSpec::new("make") - .arg("-C") - .arg(self.config.controlplane_dir().as_os_str()) - .arg(target); - let command = self.live_protocol_environment(command, protocol_version)?; - Ok(self - .runner - .run(&self.compose_environment(command, topology, false)?)?) + let started = std::time::Instant::now(); + let result = (|| { + let command = CommandSpec::new("make") + .arg("-C") + .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 status = if result.is_ok() { + TestStatus::Pass + } else { + TestStatus::Fail + }; + println!( + "{}", + OutputStyle::stdout().test_result( + status, + &format!("live::{target}"), + Some(started.elapsed()), + None, + ) + ); + result } fn run_live_all( @@ -130,7 +125,11 @@ impl RuntimeExecutor { .map(|value| value.value.as_os_str()); add_live_protocol_environment( command, - &self.config.root().join("scripts/live_protocol"), + &self + .config + .asset_root() + .join("scripts") + .join("live_protocol"), inherited_python_path, protocol_version.as_str(), ) @@ -158,10 +157,6 @@ fn add_live_protocol_environment( .env("CF_LIVE_MCP_PROTOCOL_VERSION", protocol_version)) } -const fn live_group_needs_fast_test(group: LiveGroup) -> bool { - matches!(group, LiveGroup::Mcp | LiveGroup::All) -} - fn combine_live_results( results: impl IntoIterator)>, ) -> AppResult<()> { @@ -183,14 +178,6 @@ fn combine_live_results( mod tests { use super::*; - #[test] - fn fast_test_is_limited_to_live_groups_that_exercise_its_tools() { - assert!(live_group_needs_fast_test(LiveGroup::Mcp)); - assert!(live_group_needs_fast_test(LiveGroup::All)); - assert!(!live_group_needs_fast_test(LiveGroup::Rbac)); - assert!(!live_group_needs_fast_test(LiveGroup::Protocol)); - } - #[test] fn live_all_is_the_exact_union_of_documented_groups() { assert_eq!( diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 3abf1be..2eba6a6 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -1,5 +1,6 @@ //! Operating-system-backed execution of resolved CLI actions. +use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; use std::fs; use std::future::Future; @@ -7,124 +8,135 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; -use anyhow::{Context, anyhow}; -use cf_integration_compliance::conformance::{ - ComparisonFixtureTrust, ComparisonReport, ConformanceFixtureMetadata, ConformanceResults, - ConformanceRunMetadata, ConformanceServerEra, ConformanceTarget, - compare_result_sets_with_fixture_trust, expected_server_scenarios, is_trusted_official_fixture, - load_server_results, official_server_command, validate_server_scenario_set, - write_comparison_report, +use crate::conformance::baseline::{ + BaselineComparison, BaselineUpdate, bless_baselines_transactionally, evaluate_baselines, + evaluate_client_baselines, validate_scored_results, write_baseline_report, + write_client_baseline_report, +}; +use crate::conformance::client::{ + CLIENT_BASE_URL_ENV, CLIENT_COMPOSE_ARGS_ENV, CLIENT_SERVER_ID_ENV, CLIENT_TOKEN_ENV, + INTERNAL_CLIENT_COMMAND, }; -use cf_integration_compliance::conformance_fixture::{ +use crate::conformance::fixture::{ ConformanceFixtureClient, OFFICIAL_CONFORMANCE_BACKEND_URL, OFFICIAL_CONFORMANCE_PROXY_SERVICE, OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION, OFFICIAL_CONFORMANCE_SERVER_ID, OFFICIAL_CONFORMANCE_SERVICE, }; -use cf_integration_load::{LoadSettings, LocustCommand, audit_locust_reports}; -use cf_integration_mcp::GatewayTopology; -use cf_integration_mcp::auth_proxy::AuthProxy; -use cf_integration_mcp::gateway::GatewayClient; -use cf_integration_mcp::http_transport::ReqwestProbeTransport; -use cf_integration_mcp::mcp::ACCEPT as MCP_ACCEPT; -use cf_integration_mcp::probe::{ProbeConfig, run_probe}; -use cf_integration_platform::checkout::{CheckoutManager, CheckoutRequest}; -use cf_integration_platform::compose::{ComposeProject, validate_integration_contract}; -use cf_integration_platform::config::AppConfig; -use cf_integration_platform::process::{CommandSpec, LoggingProcessRunner, ProcessRunner}; -use cf_integration_platform::stack::{ +use crate::conformance::results::{ + ComparisonReport, ConformanceDirection, ConformanceFixtureMetadata, ConformanceResults, + ConformanceRunMetadata, ConformanceServerEra, DEFAULT_CLIENT_CONFORMANCE_SCENARIOS, + SemanticLane, compare_result_sets, expected_client_scenarios, expected_server_scenarios, + is_trusted_official_fixture, load_client_results, load_server_results, official_client_command, + official_server_command, validate_client_scenario_set, validate_server_scenario_set, + write_comparison_report, +}; +use crate::infrastructure::checkout::{CheckoutManager, CheckoutRequest}; +use crate::infrastructure::compose::{ComposeProject, validate_integration_contract}; +use crate::infrastructure::config::AppConfig; +use crate::infrastructure::process::{CommandSpec, LoggingProcessRunner, ProcessRunner}; +use crate::infrastructure::stack::{ BuildInputs, BuildMode, CleanupKind, FreshnessSnapshot, ServiceSnapshot, StackCommandPlan, StackFreshness, resolve_build, }; -use cf_integration_platform::{PlatformError, StackMode}; -use serde::Deserialize; +use crate::infrastructure::{InfrastructureError, StackMode}; +use crate::mcp::GatewayTopology; +use crate::mcp::auth_proxy::AuthProxy; +use crate::mcp::gateway::GatewayClient; +use crate::mcp::probe::{ProbeConfig, run_probe}; +use crate::mcp::protocol::ACCEPT as MCP_ACCEPT; +use crate::performance::{LoadSettings, LocustCommand, audit_locust_reports}; +use anyhow::{Context, anyhow}; -use crate::OutputStyle; use crate::app::{ - Action, ConformanceAction, DebugAction, LiveLane, ResolvedLoadArgs, StackAction, - selected_topologies, topology_selection, + Action, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, selected_topologies, + topology_selection, }; use crate::cli::{LiveGroup, ProtocolVersion, TokenKind as CliTokenKind, TopologySelection}; use crate::error::AppFailure; +use crate::{Activity, OutputStyle, TestStatus}; type AppResult = std::result::Result; const STACK_READY_TIMEOUT: Duration = Duration::from_secs(90); const STACK_READY_POLL_INTERVAL: Duration = Duration::from_millis(250); const STACK_READY_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); -const MANAGED_TOKEN_DESCRIPTION: &str = "Ephemeral cf-integration dataplane credential"; -const CONFORMANCE_TOKEN_DESCRIPTION: &str = "Ephemeral cf-integration conformance credential"; - -mod compliance; +const CONFORMANCE_SERVER_ERA_ENV: &str = "CF_CONFORMANCE_SERVER_ERA"; +const DEFAULT_CONFORMANCE_SERVER_ERA: ConformanceServerEra = ConformanceServerEra::Modern; +mod conformance; +mod control_plane; mod inspect; mod live; -mod reports; -mod sources; +mod performance; +mod probe; +mod session; mod stack; -mod workloads; +#[cfg(test)] +use control_plane::CONFORMANCE_TOKEN_DESCRIPTION; +use control_plane::{ControlPlaneClient, ManagedBearerToken}; use inspect::*; -use reports::*; -/// Runtime services backed by one loaded configuration and process runner. -pub struct RuntimeExecutor { +/// Shared dependencies borrowed by concrete workflow owners. +struct RuntimeContext { config: AppConfig, runner: R, } -struct ManagedBearerToken { - value: String, - catalog_id: Option, - catalog_admin_token: Option, -} - -#[derive(Deserialize)] -struct TokenCreateResponse { - token: TokenRecord, - access_token: String, -} - -#[derive(Deserialize)] -struct TokenRecord { - id: String, -} - -#[derive(Deserialize)] -struct AuthenticationResponse { - access_token: String, -} - -impl RuntimeExecutor { - /// Creates an executor without starting any process. +impl RuntimeContext { + /// Creates runtime context without starting any process. #[must_use] - pub fn new(config: AppConfig, runner: R) -> Self { + fn new(config: AppConfig, runner: R) -> Self { Self { config, runner } } +} - /// Returns the loaded application configuration. +/// 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 fn config(&self) -> &AppConfig { - &self.config + pub(crate) fn new(config: AppConfig, runner: R) -> Self { + Self { + context: RuntimeContext::new(config, runner), + } } } -impl RuntimeExecutor { - /// Executes one fully resolved operation. - pub async fn execute(&mut self, action: Action) -> AppResult<()> { +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); + +impl RuntimeDispatcher { + /// Dispatches one fully resolved operation through its workflow owner. + pub(crate) async fn execute(&self, action: Action) -> AppResult<()> { match action { - Action::Stack(action) => self.execute_stack(action).await, + Action::Stack(action) => StackWorkflow(&self.context).execute(action).await, Action::Probe { topology, protocol_version, - } => self.run_probe(topology, &protocol_version).await, - Action::Load(args) => self.run_load(args).await, + } => { + ProbeWorkflow(&self.context) + .execute(topology, &protocol_version) + .await + } + Action::Load(args) => PerformanceWorkflow(&self.context).execute(args).await, Action::Live { lane, group, protocol_version, - } => self.run_live(lane, group, &protocol_version).await, - Action::Conformance(action) => self.execute_conformance(action).await, + } => { + LiveWorkflow(&self.context) + .execute(lane, group, &protocol_version) + .await + } + Action::Conformance(action) => ConformanceWorkflow(&self.context).execute(action).await, Action::Debug(DebugAction::Token { kind, server_id }) => { - self.print_token(kind, server_id).await + self.context.print_token(kind, server_id).await } Action::Debug(DebugAction::Inspect { topology, @@ -132,14 +144,54 @@ impl RuntimeExecutor { method, server_id, }) => { - self.inspect(topology, &protocol_version, &method, server_id.as_deref()) + self.context + .inspect(topology, &protocol_version, &method, server_id.as_deref()) .await } } } } -impl RuntimeExecutor { +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, + protocol_version: &ProtocolVersion, + ) -> AppResult<()> { + self.0.run_probe(topology, 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 RuntimeContext { async fn print_token(&self, kind: CliTokenKind, server_id: Option) -> AppResult<()> { let token = match kind { CliTokenKind::Scoped => { @@ -155,10 +207,6 @@ impl RuntimeExecutor { fn default_server_id(&self) -> &str { self.environment_text("MCP_SERVER_ID") .filter(|value| !value.is_empty()) - .or_else(|| { - self.environment_text("MCP_VIRTUAL_SERVER_ID") - .filter(|value| !value.is_empty()) - }) .or_else(|| self.config.fast_time_server_id().value.to_str()) .unwrap_or("9779b6698cbd4b4995ee04a4fab38737") } @@ -176,185 +224,37 @@ impl RuntimeExecutor { .environment_text("MCPGATEWAY_BEARER_TOKEN") .filter(|token| !token.is_empty()) { - return Ok(ManagedBearerToken { - value: token.to_owned(), - catalog_id: None, - catalog_admin_token: None, - }); + return Ok(ManagedBearerToken::unmanaged(token.to_owned())); } if mode == StackMode::Controlplane { - return Ok(ManagedBearerToken { - value: self.admin_session_token().await?, - catalog_id: None, - catalog_admin_token: None, - }); + return Ok(ManagedBearerToken::unmanaged( + self.admin_session_token().await?, + )); } self.issue_dataplane_token(server_id).await } async fn admin_session_token(&self) -> AppResult { - let endpoint = url::Url::parse(self.base_url()?) - .context("MCP_CLI_BASE_URL is not a valid URL") - .and_then(|base| { - base.join("/v1/auth/email/login") - .context("failed to construct control-plane login URL") - }) - .map_err(AppFailure::from)?; - let email = required_text( - &self.config.platform_admin_email().value, - "PLATFORM_ADMIN_EMAIL", - )?; - let password = required_text( - &self.config.platform_admin_password().value, - "PLATFORM_ADMIN_PASSWORD", - )?; - let response = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .timeout(Duration::from_secs(30)) - .build() - .context("failed to build control-plane login client") - .map_err(AppFailure::from)? - .post(endpoint) - .json(&serde_json::json!({"email": email, "password": password})) - .send() + ControlPlaneClient::new(&self.config)? + .admin_session_token() .await - .context("control-plane login failed before receiving a response") - .map_err(AppFailure::from)?; - if !response.status().is_success() { - return Err(AppFailure::from(anyhow!( - "control-plane login returned HTTP {}", - response.status().as_u16() - ))); - } - let authenticated: AuthenticationResponse = response - .json() - .await - .context("control-plane login returned an invalid authentication response") - .map_err(AppFailure::from)?; - if authenticated.access_token.is_empty() { - return Err(AppFailure::from(anyhow!( - "control-plane login returned an empty access token" - ))); - } - Ok(authenticated.access_token) } async fn issue_dataplane_token(&self, server_id: &str) -> AppResult { - self.issue_catalog_token(Some(server_id), MANAGED_TOKEN_DESCRIPTION) + ControlPlaneClient::new(&self.config)? + .issue_dataplane_token(server_id) .await } async fn issue_conformance_token(&self) -> AppResult { - self.issue_catalog_token(None, CONFORMANCE_TOKEN_DESCRIPTION) - .await - } - - async fn issue_catalog_token( - &self, - server_id: Option<&str>, - description: &str, - ) -> AppResult { - let endpoint = url::Url::parse(self.base_url()?) - .context("MCP_CLI_BASE_URL is not a valid URL") - .and_then(|base| { - base.join("/v1/tokens") - .context("failed to construct token catalog URL") - }) - .map_err(AppFailure::from)?; - let admin_token = self.admin_session_token().await?; - let user_email = required_text( - &self.config.platform_admin_email().value, - "PLATFORM_ADMIN_EMAIL", - )?; - let http = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .timeout(Duration::from_secs(30)) - .build() - .context("failed to build token catalog client") - .map_err(AppFailure::from)?; - let mut payload = serde_json::json!({ - "name": format!("cf-integration-{}", uuid::Uuid::new_v4()), - "description": description, - "expires_in_days": 1, - "user_email": user_email, - }); - if let Some(server_id) = server_id { - payload["scope"] = serde_json::json!({ - "server_id": server_id, - "permissions": ["servers.read", "servers.use", "tools.read", "tools.call"], - }); - } - let response = http - .post(endpoint) - .bearer_auth(&admin_token) - .json(&payload) - .send() - .await - .context("token catalog request failed before receiving a response") - .map_err(AppFailure::from)?; - if !response.status().is_success() { - return Err(AppFailure::from(anyhow!( - "token catalog returned HTTP {} while issuing a managed credential", - response.status().as_u16() - ))); - } - let issued: TokenCreateResponse = response - .json() + ControlPlaneClient::new(&self.config)? + .issue_conformance_token() .await - .context("token catalog returned an invalid credential response") - .map_err(AppFailure::from)?; - if issued.token.id.is_empty() || issued.access_token.is_empty() { - return Err(AppFailure::from(anyhow!( - "token catalog returned an incomplete credential response" - ))); - } - Ok(ManagedBearerToken { - value: issued.access_token, - catalog_id: Some(issued.token.id), - catalog_admin_token: Some(admin_token), - }) } async fn revoke_managed_token(&self, token: &ManagedBearerToken) -> AppResult<()> { - let Some(id) = token.catalog_id.as_deref() else { - return Ok(()); - }; - let admin_token = token.catalog_admin_token.as_deref().ok_or_else(|| { - AppFailure::from(anyhow!( - "managed token is missing its control-plane cleanup credential" - )) - })?; - let endpoint = url::Url::parse(self.base_url()?) - .context("MCP_CLI_BASE_URL is not a valid URL") - .and_then(|base| { - base.join(&format!("/v1/tokens/{id}")) - .context("failed to construct token revocation URL") - }) - .map_err(AppFailure::from)?; - let response = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .timeout(Duration::from_secs(30)) - .build() - .context("failed to build token catalog client") - .map_err(AppFailure::from)? - .delete(endpoint) - .bearer_auth(admin_token) - .send() - .await - .context("token revocation failed before receiving a response") - .map_err(AppFailure::from)?; - if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND { - Ok(()) - } else { - Err(AppFailure::from(anyhow!( - "token catalog returned HTTP {} while revoking the dataplane credential", - response.status().as_u16() - ))) - } + ControlPlaneClient::new(&self.config)?.revoke(token).await } } @@ -366,14 +266,25 @@ fn required_text<'a>(value: &'a OsStr, name: &str) -> AppResult<&'a str> { } fn finish_with_cleanup(primary: Option, cleanup: AppResult<()>) -> AppResult<()> { - match (primary, cleanup) { - (None, Ok(())) => Ok(()), - (Some(primary), Ok(())) => Err(primary), - (None, Err(cleanup)) => Err(cleanup), - (Some(primary), Err(cleanup)) => Err(AppFailure::from(anyhow!( - "{primary}; additionally cleanup failed: {cleanup}" - ))), + finish_with_cleanup_failures(primary, cleanup.err().into_iter().collect()) +} + +fn finish_with_cleanup_failures( + primary: Option, + cleanup_failures: Vec, +) -> AppResult<()> { + if cleanup_failures.is_empty() { + return primary.map_or(Ok(()), Err); + } + let mut message = primary.map_or_else( + || "cleanup failed".to_owned(), + |primary| format!("{primary}"), + ); + for cleanup in cleanup_failures { + message.push_str("; additionally cleanup failed: "); + message.push_str(&cleanup.to_string()); } + Err(AppFailure::from(anyhow!(message))) } async fn wait_for_http_endpoint( @@ -395,7 +306,7 @@ async fn wait_for_http_endpoint( if now >= deadline { return Err(AppFailure::from(anyhow!( "{} public MCP endpoint {} was not ready within {:.3}s; last result: {last_failure}", - stack_mode_label(mode), + mode.topology_label(), endpoint, timeout.as_secs_f64() ))); @@ -435,13 +346,6 @@ const fn is_expected_readiness_status(status: reqwest::StatusCode) -> bool { matches!(status.as_u16(), 401 | 403 | 405) } -const fn stack_mode_label(mode: StackMode) -> &'static str { - match mode { - StackMode::Controlplane => "controlplane", - StackMode::Dataplane => "dataplane", - } -} - const fn gateway_topology(mode: StackMode) -> GatewayTopology { match mode { StackMode::Controlplane => GatewayTopology::Direct, @@ -453,13 +357,13 @@ const fn gateway_topology(mode: StackMode) -> GatewayTopology { mod tests { use std::sync::{Arc, Mutex}; + use crate::infrastructure::config::{ConfigBootstrap, ConfigRequirements, Environment}; + use crate::infrastructure::process::SystemProcessRunner; use axum::Router; use axum::body::Body; use axum::extract::{Request, State}; use axum::http::{HeaderMap, Method, Response, StatusCode}; use axum::routing::any; - use cf_integration_platform::config::Environment; - use cf_integration_platform::process::SystemProcessRunner; use serde_json::{Value, json}; use tokio::net::TcpListener; @@ -534,7 +438,8 @@ mod tests { .expect("temporary manifest should be written"); fs::create_dir_all(root.join("docker")).expect("temporary docker directory should exist"); fs::write( - root.join("docker/docker-compose.cf-integration.yaml"), + root.join("docker") + .join("docker-compose.cf-integration.yaml"), "services: {}\n", ) .expect("temporary Compose marker should be written"); @@ -562,13 +467,9 @@ mod tests { .iter() .map(|(key, value)| (OsString::from(key), OsString::from(value))), ); - AppConfig::load( - &environment, - &root.join("target/debug/cf-integration"), - root, - ) - .expect("test application config should load") - .config + let bootstrap = ConfigBootstrap::load(&environment, root).expect("bootstrap should load"); + AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + .expect("test application config should load") } #[tokio::test] @@ -591,7 +492,7 @@ mod tests { ); let root = tempfile::tempdir().expect("temporary repository should be created"); let config = app_config(root.path(), &format!("http://{address}"), &[]); - let runtime = RuntimeExecutor::new(config, SystemProcessRunner); + let runtime = RuntimeContext::new(config, SystemProcessRunner); let token = runtime .managed_bearer_token(StackMode::Dataplane, "server-id") @@ -645,7 +546,7 @@ mod tests { "http://127.0.0.1:9", &[("MCPGATEWAY_BEARER_TOKEN", "caller-token")], ); - let runtime = RuntimeExecutor::new(config, SystemProcessRunner); + let runtime = RuntimeContext::new(config, SystemProcessRunner); let token = runtime .managed_bearer_token(StackMode::Dataplane, "server-id") @@ -680,7 +581,7 @@ mod tests { ); let root = tempfile::tempdir().expect("temporary repository should be created"); let config = app_config(root.path(), &format!("http://{address}"), &[]); - let runtime = RuntimeExecutor::new(config, SystemProcessRunner); + let runtime = RuntimeContext::new(config, SystemProcessRunner); let token = runtime .issue_conformance_token() @@ -701,4 +602,37 @@ mod tests { assert_eq!(requests[1].3["description"], CONFORMANCE_TOKEN_DESCRIPTION); assert!(requests[1].3.get("scope").is_none()); } + + #[test] + fn cleanup_aggregation_preserves_primary_and_every_cleanup_failure() { + let result = finish_with_cleanup_failures( + Some(AppFailure::from(anyhow!("primary failure"))), + vec![ + AppFailure::from(anyhow!("token cleanup failure")), + AppFailure::from(anyhow!("stack cleanup failure")), + ], + ) + .expect_err("cleanup failures should be aggregated"); + let message = result.to_string(); + + assert!(message.contains("primary failure")); + assert!(message.contains("token cleanup failure")); + assert!(message.contains("stack cleanup failure")); + } + + #[test] + fn cleanup_aggregation_reports_every_failure_without_a_primary_error() { + let result = finish_with_cleanup_failures( + None, + vec![ + AppFailure::from(anyhow!("first cleanup failure")), + AppFailure::from(anyhow!("second cleanup failure")), + ], + ) + .expect_err("cleanup failures should fail the session"); + let message = result.to_string(); + + assert!(message.contains("first cleanup failure")); + assert!(message.contains("second cleanup failure")); + } } diff --git a/src/runtime/performance/mod.rs b/src/runtime/performance/mod.rs new file mode 100644 index 0000000..c9f5310 --- /dev/null +++ b/src/runtime/performance/mod.rs @@ -0,0 +1,88 @@ +//! Locust performance workflow orchestration. + +use super::*; + +impl RuntimeContext { + pub(super) async fn run_load(&self, args: ResolvedLoadArgs) -> AppResult<()> { + let settings = + LoadSettings::resolve(&self.config, &args.request).map_err(AppFailure::from)?; + 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_authenticated_target(args.topology, &server_id, |token| async move { + let command = LocustCommand::new_with_protocol_version( + &self.config, + args.topology, + &settings, + &token, + (args.topology == StackMode::Dataplane).then_some(operation_server_id.as_str()), + args.protocol_version.as_str(), + ) + .map_err(AppFailure::from)?; + let command_spec = + self.compose_environment(command.command().clone(), args.topology, true)?; + let output_log = command.report_dir().join("locust.log"); + fs::write(&output_log, []) + .with_context(|| format!("failed to clear Locust output log {output_log:?}")) + .map_err(AppFailure::from)?; + preparation.finish(true); + + let description = format!( + "Running load test ({} users, {}/s, {})", + settings.users(), + settings.spawn_rate(), + settings.run_time(), + ); + let activity = Activity::spinner(description); + let started = std::time::Instant::now(); + let process_result = self + .runner + .run_to_log(&command_spec, &output_log) + .map_err(AppFailure::from); + let result = finalize_locust_run(process_result, command.report_dir(), &token); + let elapsed = started.elapsed(); + activity.finish(result.is_ok()); + + let status = if result.is_ok() { + TestStatus::Pass + } else { + TestStatus::Fail + }; + println!( + "{}", + OutputStyle::stdout().test_result( + status, + &format!("performance::{}", args.topology.topology_label()), + Some(elapsed), + None, + ) + ); + if result.is_ok() { + println!( + "{}", + OutputStyle::stdout().info(&format!( + "Report: {}", + command.report_dir().join("locust_report.html").display() + )) + ); + } else if output_log.is_file() { + eprintln!( + "{}", + OutputStyle::stderr() + .failure(&format!("Load output: {}", output_log.display())) + ); + } + result + }) + .await + } +} + +fn finalize_locust_run( + process_result: AppResult<()>, + report_dir: &Path, + bearer_token: &str, +) -> AppResult<()> { + audit_locust_reports(report_dir, bearer_token).map_err(AppFailure::from)?; + process_result +} diff --git a/src/runtime/probe.rs b/src/runtime/probe.rs new file mode 100644 index 0000000..eee3d34 --- /dev/null +++ b/src/runtime/probe.rs @@ -0,0 +1,45 @@ +//! MCP probe workflow orchestration. + +use super::*; + +impl RuntimeContext { + pub(super) async fn run_probe( + &self, + topology: StackMode, + protocol_version: &ProtocolVersion, + ) -> AppResult<()> { + let server_id = self.default_server_id().to_owned(); + self.with_managed_authenticated_target(topology, &server_id, |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.to_string(), + 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/reports.rs b/src/runtime/reports.rs deleted file mode 100644 index bb948ab..0000000 --- a/src/runtime/reports.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! Official conformance artifact paths, loading, and report generation. - -use super::*; - -const CONFORMANCE_COMPLETION_MARKER: &[u8] = b"complete\n"; - -impl RuntimeExecutor { - pub(super) fn regenerate_conformance_report( - &self, - results_dir: Option<&Path>, - output_dir: Option<&Path>, - ) -> AppResult<()> { - let paths = CompliancePaths::new( - results_dir.unwrap_or_else(|| self.config.integration_dir()), - output_dir - .map(Path::to_owned) - .unwrap_or_else(|| self.config.root().join("reports")), - ); - let comparison = self.write_comparison_from_artifacts(&paths, None)?; - println!( - "{} {}", - OutputStyle::stdout().info("Conformance comparison:"), - comparison.display() - ); - Ok(()) - } - - pub(super) fn write_comparison_from_artifacts( - &self, - paths: &CompliancePaths, - expected_run: Option<(&str, ConformanceServerEra, &str)>, - ) -> AppResult { - let fixture = self.load_conformance_artifact(paths, ConformanceTarget::Fixture)?; - let controlplane = - self.load_conformance_artifact(paths, ConformanceTarget::BuiltInDataPlane)?; - let dataplane = - self.load_conformance_artifact(paths, ConformanceTarget::ExternalDataPlane)?; - if fixture.is_none() && controlplane.is_none() && dataplane.is_none() { - return Err(AppFailure::from(anyhow!( - "no official conformance artifacts found beneath {}", - paths.conformance_root.display() - ))); - } - - let metadata = compatible_metadata( - fixture.as_ref().map(|artifact| &artifact.metadata), - controlplane.as_ref().map(|artifact| &artifact.metadata), - dataplane.as_ref().map(|artifact| &artifact.metadata), - expected_run, - )?; - let empty_results = ConformanceResults::default(); - let scenarios = compare_result_sets_with_fixture_trust( - fixture - .as_ref() - .map_or(&empty_results, |artifact| &artifact.results), - controlplane - .as_ref() - .map_or(&empty_results, |artifact| &artifact.results), - dataplane - .as_ref() - .map_or(&empty_results, |artifact| &artifact.results), - ComparisonFixtureTrust { - fixture: is_trusted_official_fixture( - fixture - .as_ref() - .and_then(|artifact| artifact.metadata.fixture.as_ref()), - ), - controlplane: is_trusted_official_fixture( - controlplane - .as_ref() - .and_then(|artifact| artifact.metadata.fixture.as_ref()), - ), - dataplane: is_trusted_official_fixture( - dataplane - .as_ref() - .and_then(|artifact| artifact.metadata.fixture.as_ref()), - ), - }, - ); - let output = paths.report_output.join("mcp-conformance-comparison.md"); - write_comparison_report( - &output, - &ComparisonReport { - spec_version: metadata.spec_version.clone(), - server_era: metadata.server_era, - suite: metadata.suite.clone(), - fixture: metadata.fixture.clone(), - scenarios, - }, - ) - .map_err(AppFailure::from)?; - Ok(output) - } - - fn load_conformance_artifact( - &self, - paths: &CompliancePaths, - target: ConformanceTarget, - ) -> AppResult> { - let artifact = paths.conformance_lane(target); - if !artifact.metadata.is_file() - && !artifact.official_results.is_dir() - && !artifact.completion.is_file() - { - return Ok(None); - } - if !artifact.metadata.is_file() - || !artifact.official_results.is_dir() - || !artifact.completion.is_file() - { - return Err(AppFailure::from(anyhow!( - "incomplete conformance artifacts for {target} beneath {}", - artifact.root.display() - ))); - } - verify_completion_marker(&artifact.completion)?; - let metadata = read_run_metadata(&artifact.metadata)?; - if metadata.target != target.label() { - return Err(AppFailure::from(anyhow!( - "conformance metadata target {:?} does not match {target}", - metadata.target - ))); - } - if metadata.oracle != cf_integration_compliance::conformance::OFFICIAL_CONFORMANCE_PACKAGE { - return Err(AppFailure::from(anyhow!( - "conformance artifacts used oracle {:?}, expected {:?}", - metadata.oracle, - cf_integration_compliance::conformance::OFFICIAL_CONFORMANCE_PACKAGE - ))); - } - let results = load_server_results(&artifact.official_results).map_err(AppFailure::from)?; - validate_server_scenario_set(&results, &metadata.suite, &metadata.spec_version) - .map_err(AppFailure::from)?; - Ok(Some(LoadedConformanceArtifact { results, metadata })) - } -} - -#[derive(Debug, Clone)] -pub(super) struct CompliancePaths { - pub(super) conformance_root: PathBuf, - pub(super) report_output: PathBuf, -} - -impl CompliancePaths { - pub(super) fn new(artifact_root: &Path, report_output: PathBuf) -> Self { - Self { - conformance_root: artifact_root.join("conformance"), - report_output, - } - } - - pub(super) fn conformance_lane(&self, target: ConformanceTarget) -> ConformanceLanePaths { - let root = self.conformance_root.join(conformance_target_slug(target)); - ConformanceLanePaths { - official_results: root.join("official"), - runner_log: root.join("runner.log"), - expected_failures: root.join("expected-failures.yml"), - metadata: root.join("metadata.json"), - completion: root.join("complete"), - root, - } - } - - pub(super) fn clear_conformance(&self) -> AppResult<()> { - for target in [ - ConformanceTarget::Fixture, - ConformanceTarget::BuiltInDataPlane, - ConformanceTarget::ExternalDataPlane, - ] { - remove_artifact_directory(&self.conformance_lane(target).root)?; - } - Ok(()) - } -} - -#[derive(Debug, Clone)] -pub(super) struct ConformanceLanePaths { - pub(super) root: PathBuf, - pub(super) official_results: PathBuf, - pub(super) runner_log: PathBuf, - pub(super) expected_failures: PathBuf, - pub(super) metadata: PathBuf, - pub(super) completion: PathBuf, -} - -struct LoadedConformanceArtifact { - results: ConformanceResults, - metadata: ConformanceRunMetadata, -} - -pub(super) fn recreate_directory(path: &Path) -> AppResult<()> { - if path.exists() { - fs::remove_dir_all(path) - .with_context(|| format!("failed to clear result directory {path:?}")) - .map_err(AppFailure::from)?; - } - fs::create_dir_all(path) - .with_context(|| format!("failed to create result directory {path:?}")) - .map_err(AppFailure::from) -} - -pub(super) fn remove_file_if_exists(path: &Path) -> AppResult<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(AppFailure::from( - anyhow!(error).context(format!("failed to clear completion marker {path:?}")), - )), - } -} - -fn write_completion_marker(path: &Path) -> AppResult<()> { - fs::write(path, CONFORMANCE_COMPLETION_MARKER) - .with_context(|| format!("failed to write conformance completion marker {path:?}")) - .map_err(AppFailure::from) -} - -pub(super) fn conformance_process_completed(process_result: &AppResult<()>) -> bool { - match process_result { - Ok(()) => true, - Err(AppFailure::Platform(PlatformError::ChildExit { status, .. })) => { - status.code().is_some() - } - Err(AppFailure::Platform(PlatformError::Native(_))) | Err(AppFailure::Native(_)) => false, - } -} - -pub(super) fn mark_conformance_complete( - process_result: &AppResult<()>, - results: &ConformanceResults, - target: ConformanceTarget, - suite: &str, - spec_version: &str, - path: &Path, -) -> AppResult { - if !conformance_process_completed(process_result) { - return Ok(false); - } - validate_server_scenario_set(results, suite, spec_version) - .with_context(|| format!("official conformance did not complete for {target}")) - .map_err(AppFailure::from)?; - write_completion_marker(path)?; - Ok(true) -} - -fn verify_completion_marker(path: &Path) -> AppResult<()> { - let marker = fs::read(path) - .with_context(|| format!("failed to read conformance completion marker {path:?}")) - .map_err(AppFailure::from)?; - if marker != CONFORMANCE_COMPLETION_MARKER { - return Err(AppFailure::from(anyhow!( - "invalid conformance completion marker {path:?}" - ))); - } - Ok(()) -} - -pub(super) fn write_run_metadata(path: &Path, metadata: &ConformanceRunMetadata) -> AppResult<()> { - let serialized = serde_json::to_vec_pretty(metadata) - .context("failed to serialize conformance run metadata") - .map_err(AppFailure::from)?; - fs::write(path, serialized) - .with_context(|| format!("failed to write conformance run metadata {path:?}")) - .map_err(AppFailure::from) -} - -fn read_run_metadata(path: &Path) -> AppResult { - let source = fs::read(path) - .with_context(|| format!("failed to read conformance run metadata {path:?}")) - .map_err(AppFailure::from)?; - serde_json::from_slice(&source) - .with_context(|| format!("failed to parse conformance run metadata {path:?}")) - .map_err(AppFailure::from) -} - -fn compatible_metadata<'a>( - fixture: Option<&'a ConformanceRunMetadata>, - controlplane: Option<&'a ConformanceRunMetadata>, - dataplane: Option<&'a ConformanceRunMetadata>, - expected_run: Option<(&str, ConformanceServerEra, &str)>, -) -> AppResult<&'a ConformanceRunMetadata> { - let metadata = fixture.or(controlplane).or(dataplane).ok_or_else(|| { - AppFailure::from(anyhow!( - "no conformance metadata is available for reporting" - )) - })?; - for candidate in [fixture, controlplane, dataplane].into_iter().flatten() { - if candidate.fixture != metadata.fixture { - return Err(AppFailure::from(anyhow!( - "direct fixture, built-in data-plane, and external data-plane conformance fixture provenance mismatch" - ))); - } - if candidate.spec_version != metadata.spec_version - || candidate.server_era != metadata.server_era - || candidate.suite != metadata.suite - || candidate.oracle != metadata.oracle - { - return Err(AppFailure::from(anyhow!( - "direct fixture, built-in data-plane, and external data-plane conformance artifacts were produced by incompatible runs" - ))); - } - } - if let Some((spec_version, server_era, suite)) = expected_run - && (metadata.spec_version != spec_version - || metadata.server_era != server_era - || metadata.suite != suite) - { - return Err(AppFailure::from(anyhow!( - "conformance artifacts do not match requested client spec version {spec_version:?}, server era {server_era:?}, and suite {suite:?}" - ))); - } - Ok(metadata) -} - -pub(super) const fn conformance_target(topology: StackMode) -> ConformanceTarget { - match topology { - StackMode::Controlplane => ConformanceTarget::BuiltInDataPlane, - StackMode::Dataplane => ConformanceTarget::ExternalDataPlane, - } -} - -const fn conformance_target_slug(target: ConformanceTarget) -> &'static str { - match target { - ConformanceTarget::Fixture => "fixture-direct", - ConformanceTarget::BuiltInDataPlane => "built-in-data-plane", - ConformanceTarget::ExternalDataPlane => "external-data-plane", - } -} - -fn remove_artifact_directory(path: &Path) -> AppResult<()> { - match fs::remove_dir_all(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(AppFailure::from( - anyhow!(error).context(format!("failed to clear conformance artifacts {path:?}")), - )), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cf_integration_compliance::conformance::OFFICIAL_CONFORMANCE_PACKAGE; - use cf_integration_compliance::conformance_fixture::{ - OFFICIAL_CONFORMANCE_REPOSITORY, OFFICIAL_CONFORMANCE_REVISION, - OFFICIAL_CONFORMANCE_SERVER_ID, - }; - - fn metadata(target: ConformanceTarget) -> ConformanceRunMetadata { - ConformanceRunMetadata { - oracle: OFFICIAL_CONFORMANCE_PACKAGE.to_owned(), - target: target.label().to_owned(), - spec_version: "2026-07-28".to_owned(), - server_era: ConformanceServerEra::Dual, - suite: "all".to_owned(), - fixture: Some(ConformanceFixtureMetadata { - repository: OFFICIAL_CONFORMANCE_REPOSITORY.to_owned(), - revision: OFFICIAL_CONFORMANCE_REVISION.to_owned(), - server_id: OFFICIAL_CONFORMANCE_SERVER_ID.to_owned(), - }), - } - } - - #[test] - fn conformance_paths_partition_all_three_lanes() { - let paths = CompliancePaths::new(Path::new("artifacts"), PathBuf::from("reports")); - - assert_eq!( - paths.conformance_lane(ConformanceTarget::Fixture).root, - PathBuf::from("artifacts/conformance/fixture-direct") - ); - assert_eq!( - paths - .conformance_lane(ConformanceTarget::BuiltInDataPlane) - .root, - PathBuf::from("artifacts/conformance/built-in-data-plane") - ); - assert_eq!( - paths - .conformance_lane(ConformanceTarget::ExternalDataPlane) - .root, - PathBuf::from("artifacts/conformance/external-data-plane") - ); - } - - #[test] - fn clearing_a_run_removes_every_lane_to_prevent_stale_comparisons() { - let directory = tempfile::tempdir().expect("temporary artifact root"); - let paths = CompliancePaths::new(directory.path(), PathBuf::from("reports")); - for target in [ - ConformanceTarget::Fixture, - ConformanceTarget::BuiltInDataPlane, - ConformanceTarget::ExternalDataPlane, - ] { - fs::create_dir_all(paths.conformance_lane(target).root) - .expect("lane directory should be created"); - } - - paths - .clear_conformance() - .expect("all old lanes should be removed"); - - for target in [ - ConformanceTarget::Fixture, - ConformanceTarget::BuiltInDataPlane, - ConformanceTarget::ExternalDataPlane, - ] { - assert!(!paths.conformance_lane(target).root.exists()); - } - } - - #[test] - fn partial_lane_metadata_is_reportable_when_provenance_matches() { - let fixture = metadata(ConformanceTarget::Fixture); - let dataplane = metadata(ConformanceTarget::ExternalDataPlane); - - let selected = compatible_metadata( - Some(&fixture), - None, - Some(&dataplane), - Some(("2026-07-28", ConformanceServerEra::Dual, "all")), - ) - .expect("selected lanes should be compatible"); - - assert_eq!(selected.spec_version, "2026-07-28"); - } - - #[test] - fn mismatched_fixture_provenance_prevents_cross_lane_comparison() { - let fixture = metadata(ConformanceTarget::Fixture); - let mut dataplane = metadata(ConformanceTarget::ExternalDataPlane); - dataplane - .fixture - .as_mut() - .expect("fixture metadata should exist") - .revision = "different".to_owned(); - - let error = compatible_metadata(Some(&fixture), None, Some(&dataplane), None) - .expect_err("mismatched provenance must fail") - .to_string(); - - assert!(error.contains("provenance mismatch")); - assert!(!error.contains("different")); - } - - #[test] - fn mismatched_server_eras_prevent_cross_lane_comparison() { - let fixture = metadata(ConformanceTarget::Fixture); - let mut dataplane = metadata(ConformanceTarget::ExternalDataPlane); - dataplane.server_era = ConformanceServerEra::Legacy; - - let error = compatible_metadata(Some(&fixture), None, Some(&dataplane), None) - .expect_err("different server eras must not be compared") - .to_string(); - - assert!(error.contains("incompatible runs")); - } -} diff --git a/src/runtime/session.rs b/src/runtime/session.rs new file mode 100644 index 0000000..58e67fb --- /dev/null +++ b/src/runtime/session.rs @@ -0,0 +1,162 @@ +//! Managed stack, target, and credential session scope shared by workflows. + +use super::*; + +const PUBLISHER_SNAPSHOT_LUA: &str = r#" +for _, key in ipairs(redis.call('KEYS', '*UserConfig*')) do + local value = redis.call('GET', key) + if value then + local decoded, config = pcall(cmsgpack.unpack, value) + if decoded + and type(config) == 'table' + and type(config.virtual_hosts) == 'table' + and config.virtual_hosts[ARGV[1]] ~= nil then + return 1 + end + end +end +return 0 +"#; + +struct ManagedSessionScope<'a, R> { + runtime: &'a RuntimeContext, + topology: StackMode, + token: Option, +} + +impl<'a, R: ProcessRunner> ManagedSessionScope<'a, R> { + fn new(runtime: &'a RuntimeContext, topology: StackMode) -> Self { + Self { + runtime, + topology, + token: None, + } + } + + async fn finish(self, primary: AppResult<()>) -> AppResult<()> { + let mut cleanup_failures = Vec::new(); + 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) + { + cleanup_failures.push(error); + } + finish_with_cleanup_failures(primary.err(), cleanup_failures) + } +} + +impl RuntimeContext { + pub(super) async fn with_managed_test_target( + &self, + topology: StackMode, + server_id: &str, + operation: F, + ) -> AppResult<()> + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let scope = ManagedSessionScope::new(self, topology); + 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), + }; + scope.finish(primary).await + } + + pub(super) async fn with_managed_authenticated_target( + &self, + topology: StackMode, + server_id: &str, + operation: F, + ) -> AppResult<()> + where + F: FnOnce(String) -> Fut, + Fut: Future>, + { + let mut scope = ManagedSessionScope::new(self, topology); + let primary = match self.stack_up(topology, false).await { + Ok(()) => match self.prepare_test_target(topology, server_id).await { + Ok(()) => match self.managed_bearer_token(topology, server_id).await { + Ok(token) => { + let value = token.value.clone(); + scope.token = Some(token); + operation(value).await + } + Err(error) => Err(error), + }, + Err(error) => Err(error), + }, + Err(error) => Err(error), + }; + scope.finish(primary).await + } + + pub(super) async fn prepare_test_target( + &self, + topology: StackMode, + server_id: &str, + ) -> AppResult<()> { + self.ensure_other_stack_stopped(topology)?; + if topology == StackMode::Dataplane { + self.wait_for_publisher_snapshot(server_id).await?; + } + Ok(()) + } + + pub(super) async fn wait_for_publisher_snapshot(&self, server_id: &str) -> AppResult<()> { + let timeout_seconds = self.environment_u64("CF_PUBLISHER_WAIT_SECONDS", 90)?; + let project = required_text( + &self.config.integration_project().value, + "CF_INTEGRATION_PROJECT", + )?; + let redis = self.container_id(project, "redis", false)?.ok_or_else(|| { + AppFailure::from(anyhow!( + "cannot wait for publisher snapshot: the dataplane Redis container is not running" + )) + })?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds); + loop { + let command = CommandSpec::new("docker").args([ + "exec", + redis.as_str(), + "redis-cli", + "EVAL", + PUBLISHER_SNAPSHOT_LUA, + "0", + server_id, + ]); + if self.capture_text(&command)?.as_str() == "1" { + return Ok(()); + } + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(AppFailure::from(anyhow!( + "publisher snapshot did not contain server {server_id} within {timeout_seconds}s; inspect the dataplane publisher and Redis logs" + ))); + } + tokio::time::sleep( + deadline + .saturating_duration_since(now) + .min(Duration::from_secs(2)), + ) + .await; + } + } + + pub(super) fn environment_u64(&self, key: &str, default: u64) -> AppResult { + self.environment_text(key).map_or(Ok(default), |value| { + value + .parse::() + .map_err(|_| AppFailure::from(anyhow!("{key} must be a non-negative integer"))) + }) + } +} diff --git a/src/runtime/stack.rs b/src/runtime/stack/mod.rs similarity index 62% rename from src/runtime/stack.rs rename to src/runtime/stack/mod.rs index e307fce..fc221a0 100644 --- a/src/runtime/stack.rs +++ b/src/runtime/stack/mod.rs @@ -1,20 +1,41 @@ //! Stack lifecycle and Docker Compose orchestration. +mod sources; + use super::*; -impl RuntimeExecutor { +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?; - eprintln!( - "{}", - OutputStyle::stderr().info("Starting the pinned MCP conformance server.") - ); - self.start_conformance_service(topology, ConformanceServerEra::default()) + + let build_log = self + .config + .integration_dir() + .join("logs/conformance-build.log"); + prepare_conformance_build_log(&build_log)?; + 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 build_result = quiet_runtime + .build_conformance_service(topology, DEFAULT_CONFORMANCE_SERVER_ERA) + .await; + build_progress.finish(build_result.is_ok()); + if let Err(error) = build_result { + eprintln!( + "{} {}", + OutputStyle::stderr().failure("Build output:"), + build_log.display() + ); + return Err(error); + } + + self.start_conformance_containers(topology, DEFAULT_CONFORMANCE_SERVER_ERA) .await?; let conformance_endpoint = self.conformance_fixture_endpoint(topology)?; - self.print_stack_endpoints(topology, &conformance_endpoint) + Activity::completed("Integration stack ready"); + self.print_stack_summary(topology, &conformance_endpoint) } StackAction::Down { topology, volumes } => self.cleanup( topology, @@ -63,7 +84,7 @@ impl RuntimeExecutor { } pub(super) async fn stack_up(&self, mode: StackMode, fresh: bool) -> AppResult<()> { - self.stack_up_with_project(mode, fresh, self.compose_project(mode), true) + self.stack_up_with_project(mode, fresh, self.compose_project(mode), false) .await } @@ -132,13 +153,7 @@ impl RuntimeExecutor { if report_progress { println!( "{}", - OutputStyle::stdout().success(&format!( - "{} stack started.", - match mode { - StackMode::Controlplane => "Control-plane", - StackMode::Dataplane => "Dataplane integration", - } - )) + OutputStyle::stdout().success(&format!("{} stack started.", mode.topology_label())) ); } Ok(()) @@ -156,7 +171,7 @@ impl RuntimeExecutor { OutputStyle::stderr().info(&format!( "Waiting up to {}s for the public {} MCP endpoint.", STACK_READY_TIMEOUT.as_secs(), - stack_mode_label(mode) + mode.topology_label() )) ); } @@ -176,7 +191,7 @@ impl RuntimeExecutor { .map(|client| client.endpoint().clone()) } - fn print_stack_endpoints( + fn print_stack_summary( &self, mode: StackMode, conformance_endpoint: &url::Url, @@ -193,29 +208,29 @@ impl RuntimeExecutor { pub(super) fn compose_project(&self, mode: StackMode) -> ComposeProject { let project = match mode { StackMode::Dataplane => ComposeProject::dataplane( - self.config.root(), + self.config.asset_root(), self.config.controlplane_dir(), self.config.integration_project().value.clone(), !self.config.dataplane_ref().value.is_empty(), ), StackMode::Controlplane => ComposeProject::controlplane( - self.config.root(), + self.config.asset_root(), self.config.controlplane_dir(), self.config.controlplane_project().value.clone(), self.environment_flag("CONTROLPLANE_ENABLE_SSO", false), ), }; - project.with_conformance_overlay(self.config.root()) + project.with_conformance_overlay(self.config.asset_root()) } pub(super) fn conformance_compose_project(&self, mode: StackMode) -> ComposeProject { self.conformance_runtime_project(mode) - .with_conformance_fixture(self.config.root()) + .with_conformance_fixture(self.config.asset_root()) } - fn conformance_runtime_project(&self, mode: StackMode) -> ComposeProject { + pub(super) fn conformance_runtime_project(&self, mode: StackMode) -> ComposeProject { self.compose_project(mode) - .with_conformance_runtime(self.config.root()) + .with_conformance_runtime(self.config.asset_root()) } pub(super) fn compose_environment( @@ -224,6 +239,7 @@ impl RuntimeExecutor { mode: StackMode, checkout_labels: bool, ) -> AppResult { + let controlplane_image = self.resolved_controlplane_image()?; let command_environment = command.environment().clone(); let mut command = if command.working_directory().is_some() { command @@ -236,7 +252,7 @@ impl RuntimeExecutor { } } command = command - .env("CF_INTEGRATION_ROOT", self.config.root().as_os_str()) + .env("CF_INTEGRATION_ROOT", self.config.asset_root().as_os_str()) .env( "CF_INTEGRATION_DIR", self.config.integration_dir().as_os_str(), @@ -246,13 +262,11 @@ impl RuntimeExecutor { 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("IMAGE_LOCAL", controlplane_image) .env( - "CF_CONTROLPLANE_IMAGE", - self.config.controlplane_image().resolved().to_owned(), - ) - .env( - "IMAGE_LOCAL", - self.config.controlplane_image().resolved().to_owned(), + "FAST_TIME_IMAGE", + self.config.fast_time_expected_image().value.clone(), ) .env( "CF_DATAPLANE_IMAGE", @@ -277,6 +291,7 @@ impl RuntimeExecutor { "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"), @@ -294,15 +309,21 @@ impl RuntimeExecutor { let needs_docker_cpus = ["GATEWAY_CPU_LIMIT", "GUNICORN_WORKERS"] .into_iter() .any(|key| self.config.environment().get(OsStr::new(key)).is_none()); - let docker_cpus = needs_docker_cpus.then(|| { - self.capture_optional(&CommandSpec::new("docker").args([ + let docker_cpus = if needs_docker_cpus { + let value = self.capture_text(&CommandSpec::new("docker").args([ "info", "--format", "{{.NCPU}}", - ])) - .filter(|value| value.parse::().is_ok_and(|value| value > 0)) - .unwrap_or_else(|| "4".to_owned()) - }); + ]))?; + if !value.parse::().is_ok_and(|value| value > 0) { + return Err(AppFailure::from(anyhow!( + "Docker returned an invalid CPU count" + ))); + } + Some(value) + } else { + None + }; for key in ["GATEWAY_CPU_LIMIT", "GUNICORN_WORKERS"] { if self.config.environment().get(OsStr::new(key)).is_none() { command = command.env(key, docker_cpus.as_deref().unwrap_or("4")); @@ -310,10 +331,7 @@ impl RuntimeExecutor { } for (key, argument) in [("HOST_UID", "-u"), ("HOST_GID", "-g")] { if self.config.environment().get(OsStr::new(key)).is_none() { - let value = self - .capture_optional(&CommandSpec::new("id").arg(argument)) - .filter(|value| value.parse::().is_ok()) - .unwrap_or_else(|| "1000".to_owned()); + let value = self.host_identity(argument)?; command = command.env(key, value); } } @@ -349,37 +367,33 @@ impl RuntimeExecutor { ) -> AppResult { let controlplane_revision = self.git_required(self.config.controlplane_dir(), ["rev-parse", "HEAD"])?; - let controlplane_ref = self - .git_optional( - self.config.controlplane_dir(), - ["symbolic-ref", "--quiet", "--short", "HEAD"], - ) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| { - self.config - .controlplane_ref() - .value - .to_string_lossy() - .into_owned() - }); + let controlplane_branch = + self.git_required(self.config.controlplane_dir(), ["branch", "--show-current"])?; + let controlplane_ref = if controlplane_branch.is_empty() { + self.config + .controlplane_ref() + .value + .to_string_lossy() + .into_owned() + } else { + controlplane_branch + }; command = command .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 reference = self - .git_optional( - self.config.dataplane_dir(), - ["symbolic-ref", "--quiet", "--short", "HEAD"], - ) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| { - self.config - .dataplane_ref() - .value - .to_string_lossy() - .into_owned() - }); + 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); @@ -420,8 +434,9 @@ impl RuntimeExecutor { BuildMode::from_str(setting).map_err(|error| AppFailure::from(anyhow!(error)))?; let controlplane_checkout_revision = Some(self.git_required(self.config.controlplane_dir(), ["rev-parse", "HEAD"])?); + let controlplane_image = self.resolved_controlplane_image()?; let (controlplane_image_present, controlplane_image_revision) = - self.image_state(self.config.controlplane_image().resolved()); + self.image_state(&controlplane_image)?; let dataplane_source = (!self.config.dataplane_ref().value.is_empty()).then(|| { self.config .dataplane_ref() @@ -435,7 +450,7 @@ impl RuntimeExecutor { None }; let (dataplane_image_present, dataplane_image_revision) = - self.image_state(self.config.dataplane_image().resolved()); + self.image_state(self.config.dataplane_image().resolved())?; let decision = resolve_build( mode_setting, &BuildInputs { @@ -461,25 +476,51 @@ impl RuntimeExecutor { Ok(decision.build) } - fn image_state(&self, image: &OsStr) -> (bool, Option) { - let command = CommandSpec::new("docker").args([ + 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([ + "image", + "ls", + "--digests", + "--no-trunc", + "--format", + "{{.Repository}}@{{.Digest}}\t{{.ID}}", + ]))?; + images.lines().find_map(|line| { + let (reference, id) = line.split_once('\t')?; + (reference == image.to_string_lossy()).then(|| id.to_owned()) + }) + } else { + self.capture_text(&CommandSpec::new("docker").args([ + OsString::from("image"), + OsString::from("ls"), + OsString::from("--quiet"), + OsString::from("--no-trunc"), + image.to_owned(), + ]))? + .lines() + .next() + .map(str::to_owned) + }; + let Some(image_id) = image_id else { + return Ok((false, None)); + }; + let revision = self.capture_text(&CommandSpec::new("docker").args([ OsString::from("image"), OsString::from("inspect"), - image.to_owned(), + OsString::from(image_id), OsString::from("--format"), OsString::from("{{ index .Config.Labels \"org.opencontainers.image.revision\" }}"), - ]); - match self.capture_optional(&command) { - Some(revision) => (true, (!revision.is_empty()).then_some(revision)), - None => (false, None), - } + ]))?; + Ok((true, (!revision.is_empty()).then_some(revision))) } fn pull_images(&self, mode: StackMode, build: bool, report_progress: bool) -> AppResult<()> { if !build && self.config.controlplane_image().is_prebuilt() { + let controlplane_image = self.resolved_controlplane_image()?; self.pull_if_changed( "cf-controlplane", - self.config.controlplane_image().resolved(), + &controlplane_image, None, report_progress, )?; @@ -511,31 +552,41 @@ impl RuntimeExecutor { OsString::from("--format"), OsString::from("{{.Manifest.Digest}}"), ]); - let remote_digest = self - .capture_optional(&inspect) - .filter(|value| !value.is_empty()); - let local_exists = self - .capture_optional(&CommandSpec::new("docker").args([ - OsString::from("image"), - OsString::from("inspect"), - image.to_owned(), - OsString::from("--format"), - OsString::from("{{.Id}}"), - ])) - .is_some(); - if let Some(digest) = remote_digest { - let repo_digests = self.capture_optional(&CommandSpec::new("docker").args([ + let local_ids = self.capture_text(&CommandSpec::new("docker").args([ + OsString::from("image"), + OsString::from("ls"), + OsString::from("--quiet"), + OsString::from("--no-trunc"), + image.to_owned(), + ]))?; + let local_exists = !local_ids.is_empty(); + let remote_digest = match self.capture_text(&inspect) { + Ok(digest) => (!digest.is_empty()).then_some(digest), + Err(error) if local_exists => { + if report_progress { + eprintln!( + "{}", + OutputStyle::stderr().warning(&format!( + "{label} remote digest check failed; using the local image: {error}" + )) + ); + } + return Ok(()); + } + Err(error) => return Err(error), + }; + if local_exists && let Some(digest) = remote_digest.as_deref() { + let repo_digests = self.capture_text(&CommandSpec::new("docker").args([ OsString::from("image"), OsString::from("inspect"), image.to_owned(), OsString::from("--format"), OsString::from("{{range .RepoDigests}}{{println .}}{{end}}"), - ])); - if repo_digests.as_deref().is_some_and(|values| { - values - .lines() - .any(|value| value.ends_with(&format!("@{digest}"))) - }) { + ]))?; + if repo_digests + .lines() + .any(|value| value.ends_with(&format!("@{digest}"))) + { if report_progress { println!( "{}", @@ -545,7 +596,7 @@ impl RuntimeExecutor { } return Ok(()); } - } else if local_exists { + } else if local_exists && remote_digest.is_none() { if report_progress { println!( "{}", @@ -569,6 +620,7 @@ impl RuntimeExecutor { } fn integration_freshness(&self) -> AppResult { + let controlplane_image = self.resolved_controlplane_image()?; let project = required_text( &self.config.integration_project().value, "CF_INTEGRATION_PROJECT", @@ -586,24 +638,22 @@ impl RuntimeExecutor { "migration", "register_fast_time", ] { - services.insert(service.to_owned(), self.service_snapshot(project, service)); - } - for service in ["fast_test_server", "register_fast_test"] { - if self.container_id(project, service, true).is_some() { - services.insert(service.to_owned(), self.service_snapshot(project, service)); - } + services.insert(service.to_owned(), self.service_snapshot(project, service)?); } let snapshot = FreshnessSnapshot { services, - controlplane_checkout_revision: self - .git_optional(self.config.controlplane_dir(), ["rev-parse", "HEAD"]), - dataplane_checkout_revision: optional_source_revision(dataplane_source_enabled, || { - self.git_optional(self.config.dataplane_dir(), ["rev-parse", "HEAD"]) - }), + controlplane_checkout_revision: Some( + self.git_required(self.config.controlplane_dir(), ["rev-parse", "HEAD"])?, + ), + dataplane_checkout_revision: if dataplane_source_enabled { + Some(self.git_required(self.config.dataplane_dir(), ["rev-parse", "HEAD"])?) + } else { + None + }, controlplane_image_prebuilt: self.config.controlplane_image().is_prebuilt(), dataplane_source_enabled, expected_controlplane_image: required_text( - self.config.controlplane_image().resolved(), + &controlplane_image, "CF_CONTROLPLANE_IMAGE", )? .to_owned(), @@ -621,43 +671,73 @@ impl RuntimeExecutor { Ok(snapshot.evaluate()) } - fn service_snapshot(&self, project: &str, service: &str) -> ServiceSnapshot { - let running_id = self.container_id(project, service, false); - let all_id = self.container_id(project, service, true); + fn resolved_controlplane_image(&self) -> AppResult { + let setting = self.config.controlplane_image(); + if !setting.tracks_main_revision() { + return Ok(setting.resolved().to_owned()); + } + + let revision = self.git_required( + self.config.controlplane_dir(), + ["rev-parse", "refs/remotes/origin/main"], + )?; + let mut image = OsString::from("ghcr.io/ibm/mcp-context-forge:"); + image.push(revision); + Ok(image) + } + + fn service_snapshot(&self, project: &str, service: &str) -> AppResult { + let running_id = self.container_id(project, service, false)?; + let all_id = self.container_id(project, service, true)?; let configured_image = running_id .as_deref() - .and_then(|id| self.docker_inspect(id, "{{.Config.Image}}")); + .map(|id| self.docker_inspect(id, "{{.Config.Image}}")) + .transpose()?; let running_image = running_id .as_deref() - .and_then(|id| self.docker_inspect(id, "{{.Image}}")); - let expected_image_id = configured_image.as_deref().and_then(|image| { - self.capture_optional( - &CommandSpec::new("docker") - .args(["image", "inspect", image, "--format", "{{.Id}}"]), - ) - }); - let completed_successfully = all_id.as_deref().is_some_and(|id| { - self.docker_inspect(id, "{{.State.Status}}").as_deref() == Some("exited") - && self.docker_inspect(id, "{{.State.ExitCode}}").as_deref() == Some("0") - }); - let image_revision = running_id.as_deref().and_then(|id| { - self.docker_inspect( - id, - "{{ index .Config.Labels \"org.opencontainers.image.revision\" }}", - ) - .filter(|value| !value.is_empty()) - }); - ServiceSnapshot { + .map(|id| self.docker_inspect(id, "{{.Image}}")) + .transpose()?; + let expected_image_id = configured_image + .as_deref() + .map(|image| { + self.capture_text( + &CommandSpec::new("docker") + .args(["image", "inspect", image, "--format", "{{.Id}}"]), + ) + }) + .transpose()?; + let completed_successfully = if let Some(id) = all_id.as_deref() { + self.docker_inspect(id, "{{.State.Status}}")? == "exited" + && self.docker_inspect(id, "{{.State.ExitCode}}")? == "0" + } else { + false + }; + let image_revision = running_id + .as_deref() + .map(|id| { + self.docker_inspect( + id, + "{{ index .Config.Labels \"org.opencontainers.image.revision\" }}", + ) + }) + .transpose()? + .filter(|value| !value.is_empty()); + Ok(ServiceSnapshot { running: running_id.is_some(), completed_successfully, configured_image, running_image_matches_configured: running_image.is_some() && running_image == expected_image_id, image_revision, - } + }) } - pub(super) fn container_id(&self, project: &str, service: &str, all: bool) -> Option { + pub(super) fn container_id( + &self, + project: &str, + service: &str, + all: bool, + ) -> AppResult> { let mut arguments = vec![OsString::from("ps")]; arguments.push(OsString::from(if all { "-aq" } else { "-q" })); arguments.extend([ @@ -666,13 +746,16 @@ impl RuntimeExecutor { OsString::from("--filter"), OsString::from(format!("label=com.docker.compose.service={service}")), ]); - self.capture_optional(&CommandSpec::new("docker").args(arguments)) - .and_then(|value| value.lines().next().map(str::to_owned)) - .filter(|value| !value.is_empty()) + let output = self.capture_text(&CommandSpec::new("docker").args(arguments))?; + Ok(output + .lines() + .next() + .map(str::to_owned) + .filter(|value| !value.is_empty())) } - fn docker_inspect(&self, id: &str, format: &str) -> Option { - self.capture_optional(&CommandSpec::new("docker").args(["inspect", id, "--format", format])) + fn docker_inspect(&self, id: &str, format: &str) -> AppResult { + self.capture_text(&CommandSpec::new("docker").args(["inspect", id, "--format", format])) } pub(super) fn ensure_other_stack_stopped(&self, mode: StackMode) -> AppResult<()> { @@ -682,17 +765,17 @@ impl RuntimeExecutor { &self.config.controlplane_project().value, "CF_CONTROLPLANE_PROJECT", )?, - "control-plane", + StackMode::Controlplane.topology_label(), ), StackMode::Controlplane => ( required_text( &self.config.integration_project().value, "CF_INTEGRATION_PROJECT", )?, - "dataplane integration", + StackMode::Dataplane.topology_label(), ), }; - if self.project_has_running_containers(other) { + if self.project_has_running_containers(other)? { return Err(AppFailure::from(anyhow!( "the {label} stack is running on the same host ports; run `cf-integration stack down --topology all` first" ))); @@ -700,18 +783,36 @@ impl RuntimeExecutor { Ok(()) } - fn project_has_running_containers(&self, project: &str) -> bool { - self.capture_optional(&CommandSpec::new("docker").args([ - "ps", - "-q", - "--filter", - &format!("label=com.docker.compose.project={project}"), - ])) - .is_some_and(|value| !value.is_empty()) + fn project_has_running_containers(&self, project: &str) -> AppResult { + Ok(!self + .capture_text(&CommandSpec::new("docker").args([ + "ps", + "-q", + "--filter", + &format!("label=com.docker.compose.project={project}"), + ]))? + .is_empty()) } pub(super) fn cleanup(&self, selection: TopologySelection, kind: CleanupKind) -> AppResult<()> { - let mut last_failure = None; + self.cleanup_with_output(selection, kind, true) + } + + pub(super) fn cleanup_quiet( + &self, + selection: TopologySelection, + kind: CleanupKind, + ) -> AppResult<()> { + self.cleanup_with_output(selection, kind, false) + } + + fn cleanup_with_output( + &self, + selection: TopologySelection, + kind: CleanupKind, + inherit_output: bool, + ) -> AppResult<()> { + let mut cleanup_failures = Vec::new(); for mode in selected_topologies(selection) { if self .config @@ -722,15 +823,16 @@ impl RuntimeExecutor { let project = self .compose_project(mode) .with_profiles(["testing", "inspector", "sso"]) - .with_conformance_fixture(self.config.root()); + .with_conformance_fixture(self.config.asset_root()); let command = StackCommandPlan::cleanup(project, kind); match self.compose_environment(command.command().clone(), mode, false) { Ok(command) => { - if let Err(error) = self.runner.run(&command) { - last_failure = Some(error.into()); + let result = self.run_cleanup_command(&command, inherit_output); + if let Err(error) = result { + cleanup_failures.push(error.into()); } } - Err(error) => last_failure = Some(error), + Err(error) => cleanup_failures.push(error), } } let project = match mode { @@ -738,38 +840,57 @@ impl RuntimeExecutor { StackMode::Dataplane => &self.config.integration_project().value, }; match required_text(project, "Compose project name") - .and_then(|project| self.remove_project_by_label(project, kind)) + .and_then(|project| self.remove_project_by_label(project, kind, inherit_output)) { Ok(()) => {} - Err(error) => last_failure = Some(error), + Err(error) => cleanup_failures.push(error), } } - match last_failure { - Some(error) => Err(error), - None => Ok(()), - } + finish_with_cleanup_failures(None, cleanup_failures) } - fn remove_project_by_label(&self, project: &str, kind: CleanupKind) -> AppResult<()> { + fn remove_project_by_label( + &self, + project: &str, + kind: CleanupKind, + inherit_output: bool, + ) -> AppResult<()> { let filter = format!("label=com.docker.compose.project={project}"); for id in self.docker_list(["ps", "-aq", "--filter", filter.as_str()])? { - self.runner - .run(&CommandSpec::new("docker").args(["rm", "-f", id.as_str()]))?; + self.run_cleanup_command( + &CommandSpec::new("docker").args(["rm", "-f", id.as_str()]), + inherit_output, + )?; } for id in self.docker_list(["network", "ls", "-q", "--filter", filter.as_str()])? { - let _ = - self.runner - .run(&CommandSpec::new("docker").args(["network", "rm", id.as_str()])); + let _ = self.run_cleanup_command( + &CommandSpec::new("docker").args(["network", "rm", id.as_str()]), + inherit_output, + ); } if kind == CleanupKind::Reset { for id in self.docker_list(["volume", "ls", "-q", "--filter", filter.as_str()])? { - self.runner - .run(&CommandSpec::new("docker").args(["volume", "rm", id.as_str()]))?; + self.run_cleanup_command( + &CommandSpec::new("docker").args(["volume", "rm", id.as_str()]), + inherit_output, + )?; } } Ok(()) } + fn run_cleanup_command( + &self, + command: &CommandSpec, + inherit_output: bool, + ) -> Result<(), InfrastructureError> { + if inherit_output { + self.runner.run(command) + } else { + self.runner.capture_output(command).map(drop) + } + } + fn docker_list(&self, arguments: [&str; N]) -> AppResult> { let output = self .runner @@ -792,14 +913,17 @@ impl RuntimeExecutor { if self.config.dataplane_ref().value.is_empty() { return Ok(OsString::from("linux/amd64")); } - Ok(self - .capture_optional(&CommandSpec::new("docker").args([ - "version", - "--format", - "{{.Server.Os}}/{{.Server.Arch}}", - ])) - .filter(|value| !value.is_empty()) - .map_or_else(|| OsString::from("linux/amd64"), OsString::from)) + let platform = self.capture_text(&CommandSpec::new("docker").args([ + "version", + "--format", + "{{.Server.Os}}/{{.Server.Arch}}", + ]))?; + if platform.is_empty() { + return Err(AppFailure::from(anyhow!( + "Docker returned an empty server platform" + ))); + } + Ok(OsString::from(platform)) } pub(super) fn git_required( @@ -816,22 +940,28 @@ impl RuntimeExecutor { .map_err(AppFailure::from) } - fn git_optional( - &self, - directory: &Path, - arguments: [&str; N], - ) -> Option { - let mut command = CommandSpec::new("git").arg("-C").arg(directory.as_os_str()); - command = command.args(arguments); - self.capture_optional(&command) + pub(super) fn capture_text(&self, command: &CommandSpec) -> AppResult { + let output = self.runner.capture_stdout(command)?; + String::from_utf8(output) + .context("child process returned non-UTF-8 standard output") + .map(|value| value.trim().to_owned()) + .map_err(AppFailure::from) } - pub(super) fn capture_optional(&self, command: &CommandSpec) -> Option { - self.runner - .capture_stdout(command) - .ok() - .and_then(|output| String::from_utf8(output).ok()) - .map(|value| value.trim().to_owned()) + #[cfg(unix)] + fn host_identity(&self, argument: &str) -> AppResult { + let value = self.capture_text(&CommandSpec::new("id").arg(argument))?; + if value.parse::().is_err() { + return Err(AppFailure::from(anyhow!( + "id {argument} returned an invalid host identity" + ))); + } + Ok(value) + } + + #[cfg(not(unix))] + fn host_identity(&self, _argument: &str) -> AppResult { + Ok("1000".to_owned()) } pub(super) fn environment_text(&self, key: &str) -> Option<&str> { @@ -847,29 +977,75 @@ impl RuntimeExecutor { } } -fn optional_source_revision( - source_enabled: bool, - revision: impl FnOnce() -> Option, -) -> Option { - source_enabled.then(revision).flatten() -} - fn format_stack_endpoint_summary( public_origin: &str, public_mcp_endpoint: &url::Url, conformance_endpoint: &url::Url, ) -> String { format!( - "Stack endpoints:\n Gateway/API: {public_origin}\n Public MCP: {public_mcp_endpoint}\n Conformance MCP (direct): {conformance_endpoint}" + "Gateway/API: {public_origin}\nPublic MCP: {public_mcp_endpoint}\nConformance MCP (direct): {conformance_endpoint}" ) } +fn prepare_conformance_build_log(path: &Path) -> AppResult<()> { + let parent = path + .parent() + .ok_or_else(|| AppFailure::from(anyhow!("conformance build log has no parent")))?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create conformance build log directory {parent:?}")) + .map_err(AppFailure::from)?; + fs::write(path, []) + .with_context(|| format!("failed to clear conformance build log {path:?}")) + .map_err(AppFailure::from) +} + +fn with_default_conformance_server_era(command: CommandSpec) -> CommandSpec { + if command + .environment() + .get(OsStr::new(CONFORMANCE_SERVER_ERA_ENV)) + .is_some_and(|value| !value.is_empty()) + { + command + } else { + command.env( + CONFORMANCE_SERVER_ERA_ENV, + DEFAULT_CONFORMANCE_SERVER_ERA.label(), + ) + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn stack_endpoint_summary_lists_public_and_conformance_addresses() { + fn compose_commands_default_to_the_latest_modern_conformance_era() { + let command = with_default_conformance_server_era(CommandSpec::new("docker")); + + assert_eq!( + command + .environment() + .get(OsStr::new(CONFORMANCE_SERVER_ERA_ENV)), + Some(&OsString::from("modern")) + ); + } + + #[test] + fn explicit_conformance_era_is_not_replaced_by_the_stack_default() { + let command = with_default_conformance_server_era( + CommandSpec::new("docker").env(CONFORMANCE_SERVER_ERA_ENV, "legacy"), + ); + + assert_eq!( + command + .environment() + .get(OsStr::new(CONFORMANCE_SERVER_ERA_ENV)), + Some(&OsString::from("legacy")) + ); + } + + #[test] + fn stack_endpoint_summary_preserves_the_three_connection_addresses() { let public_mcp = url::Url::parse("http://127.0.0.1:8080/servers/server-id/mcp").expect("public URL"); let conformance = url::Url::parse("http://127.0.0.1:49152/mcp").expect("conformance URL"); @@ -879,16 +1055,23 @@ mod tests { assert_eq!( summary, - "Stack endpoints:\n Gateway/API: http://127.0.0.1:8080\n Public MCP: http://127.0.0.1:8080/servers/server-id/mcp\n Conformance MCP (direct): http://127.0.0.1:49152/mcp" + "Gateway/API: http://127.0.0.1:8080\nPublic MCP: http://127.0.0.1:8080/servers/server-id/mcp\nConformance MCP (direct): http://127.0.0.1:49152/mcp" ); } #[test] - fn published_image_mode_does_not_query_a_dataplane_checkout() { - let revision = optional_source_revision(false, || { - panic!("published image mode must not query a dataplane checkout") - }); + fn conformance_build_log_is_cleared_before_quiet_execution() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let path = directory.path().join("logs/conformance-build.log"); + fs::create_dir_all(path.parent().expect("log should have a parent")) + .expect("log directory should be created"); + fs::write(&path, "stale output").expect("stale log should be written"); + + prepare_conformance_build_log(&path).expect("conformance build log should be prepared"); - assert_eq!(revision, None); + assert_eq!( + fs::read(path).expect("stack setup log should be readable"), + b"" + ); } } diff --git a/src/runtime/sources.rs b/src/runtime/stack/sources.rs similarity index 81% rename from src/runtime/sources.rs rename to src/runtime/stack/sources.rs index 12f71f2..93d7c82 100644 --- a/src/runtime/sources.rs +++ b/src/runtime/stack/sources.rs @@ -2,14 +2,14 @@ use super::*; -impl RuntimeExecutor { +impl RuntimeContext { pub(super) fn require_mode_sources(&self, mode: StackMode) -> AppResult<()> { let controlplane_compose = self.config.controlplane_dir().join("docker-compose.yml"); if !controlplane_compose.is_file() { return Err(AppFailure::from(anyhow!( "control-plane checkout is unavailable at {}; run `cf-integration stack up --topology {}` first", self.config.controlplane_dir().display(), - stack_mode_label(mode) + mode.cli_value() ))); } if mode == StackMode::Dataplane @@ -32,7 +32,7 @@ impl RuntimeExecutor { Ok(()) } - pub(super) fn ensure_controlplane(&self) -> AppResult<()> { + pub(in crate::runtime) fn ensure_controlplane(&self) -> AppResult<()> { let request = CheckoutRequest::controlplane( self.config.controlplane_dir(), self.config.controlplane_repo().value.clone(), @@ -52,11 +52,8 @@ impl RuntimeExecutor { pub(super) fn ensure_checkout(&self, request: &CheckoutRequest) -> AppResult<()> { let manager = CheckoutManager::new(&self.runner); - let mut warnings = Vec::new(); - let result = manager.ensure(self.config.integration_dir(), request, &mut warnings); - for warning in warnings { - eprintln!("{}", OutputStyle::stderr().warning(&warning)); - } - Ok(result.map(|_| ())?) + Ok(manager + .ensure(self.config.integration_dir(), request) + .map(|_| ())?) } } diff --git a/src/runtime/workloads.rs b/src/runtime/workloads.rs deleted file mode 100644 index 159e0d5..0000000 --- a/src/runtime/workloads.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! Probe and load-test workflows. - -use super::*; - -const PUBLISHER_SNAPSHOT_LUA: &str = r#" -for _, key in ipairs(redis.call('KEYS', '*UserConfig*')) do - local value = redis.call('GET', key) - if value then - local decoded, config = pcall(cmsgpack.unpack, value) - if decoded - and type(config) == 'table' - and type(config.virtual_hosts) == 'table' - and config.virtual_hosts[ARGV[1]] ~= nil then - return 1 - end - end -end -return 0 -"#; - -impl RuntimeExecutor { - pub(super) async fn run_probe( - &self, - topology: StackMode, - protocol_version: &ProtocolVersion, - ) -> AppResult<()> { - let server_id = self.default_server_id().to_owned(); - self.with_managed_authenticated_target(topology, &server_id, |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.to_string(), - }; - let transport = ReqwestProbeTransport::new().map_err(AppFailure::from)?; - let stdout = std::io::stdout(); - let mut output = stdout.lock(); - run_probe(&transport, &config, &mut output) - .await - .map_err(AppFailure::from) - }) - .await - } - - pub(super) async fn run_load(&self, args: ResolvedLoadArgs) -> AppResult<()> { - let server_id = self.default_server_id().to_owned(); - let operation_server_id = server_id.clone(); - self.with_managed_authenticated_target(args.topology, &server_id, |token| async move { - let settings = - LoadSettings::resolve(&self.config, &args.request).map_err(AppFailure::from)?; - let command = LocustCommand::new_with_protocol_version( - &self.config, - args.topology, - &settings, - &token, - (args.topology == StackMode::Dataplane).then_some(operation_server_id.as_str()), - args.protocol_version.as_str(), - ) - .map_err(AppFailure::from)?; - let process_result = self - .runner - .run(&self.compose_environment(command.command().clone(), args.topology, true)?) - .map_err(AppFailure::from); - finalize_locust_run(process_result, command.report_dir(), &token) - }) - .await - } - - pub(super) async fn with_managed_test_target( - &self, - topology: StackMode, - server_id: &str, - operation: F, - ) -> AppResult<()> - where - F: FnOnce() -> Fut, - Fut: Future>, - { - 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), - }; - finish_with_cleanup( - primary.err(), - self.cleanup(topology_selection(topology), CleanupKind::Down), - ) - } - - pub(super) async fn with_managed_authenticated_target( - &self, - topology: StackMode, - server_id: &str, - operation: F, - ) -> AppResult<()> - where - F: FnOnce(String) -> Fut, - Fut: Future>, - { - self.with_managed_test_target(topology, server_id, || async { - let token = self.managed_bearer_token(topology, server_id).await?; - let primary = operation(token.value.clone()).await; - finish_with_cleanup(primary.err(), self.revoke_managed_token(&token).await) - }) - .await - } - - pub(super) async fn prepare_test_target( - &self, - topology: StackMode, - server_id: &str, - ) -> AppResult<()> { - self.ensure_other_stack_stopped(topology)?; - if topology == StackMode::Dataplane { - self.wait_for_publisher_snapshot(server_id).await?; - } - Ok(()) - } - - pub(super) async fn wait_for_publisher_snapshot(&self, server_id: &str) -> AppResult<()> { - self.wait_for_publisher_snapshot_with_status(server_id, true) - .await - } - - pub(super) async fn wait_for_publisher_snapshot_quiet(&self, server_id: &str) -> AppResult<()> { - self.wait_for_publisher_snapshot_with_status(server_id, false) - .await - } - - async fn wait_for_publisher_snapshot_with_status( - &self, - server_id: &str, - report_progress: bool, - ) -> AppResult<()> { - let timeout_seconds = self.environment_u64("CF_PUBLISHER_WAIT_SECONDS", 90)?; - let project = required_text( - &self.config.integration_project().value, - "CF_INTEGRATION_PROJECT", - )?; - let redis = self.container_id(project, "redis", false).ok_or_else(|| { - AppFailure::from(anyhow!( - "cannot wait for publisher snapshot: the dataplane Redis container is not running" - )) - })?; - let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds); - if report_progress { - eprintln!( - "{}", - OutputStyle::stderr().info(&format!( - "Waiting up to {timeout_seconds}s for a publisher snapshot containing server {server_id}." - )) - ); - } - loop { - let command = CommandSpec::new("docker").args([ - "exec", - redis.as_str(), - "redis-cli", - "EVAL", - PUBLISHER_SNAPSHOT_LUA, - "0", - server_id, - ]); - if self.capture_optional(&command).as_deref() == Some("1") { - return Ok(()); - } - let now = tokio::time::Instant::now(); - if now >= deadline { - return Err(AppFailure::from(anyhow!( - "publisher snapshot did not contain server {server_id} within {timeout_seconds}s; inspect the dataplane publisher and Redis logs" - ))); - } - tokio::time::sleep( - deadline - .saturating_duration_since(now) - .min(Duration::from_secs(2)), - ) - .await; - } - } - - fn environment_u64(&self, key: &str, default: u64) -> AppResult { - self.environment_text(key).map_or(Ok(default), |value| { - value - .parse::() - .map_err(|_| AppFailure::from(anyhow!("{key} must be a non-negative integer"))) - }) - } -} - -fn finalize_locust_run( - process_result: AppResult<()>, - report_dir: &Path, - bearer_token: &str, -) -> AppResult<()> { - audit_locust_reports(report_dir, bearer_token).map_err(AppFailure::from)?; - process_result -} diff --git a/src/token.rs b/src/token.rs deleted file mode 100644 index 1e1ddab..0000000 --- a/src/token.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Native JWT generation for control-plane and dataplane requests. - -use std::time::{SystemTime, UNIX_EPOCH}; - -use anyhow::{Context, Result, anyhow}; -use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; -use serde::Serialize; -use uuid::Uuid; - -const ISSUER: &str = "mcpgateway"; -const AUDIENCE: &str = "mcpgateway-api"; -const TTL_SECONDS: u64 = 86_400; -const PERMISSIONS: &[&str] = &["servers.read", "servers.use", "tools.read", "tools.call"]; - -/// Selects the claims emitted for a generated token. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum TokenKind { - /// API token restricted to an optional virtual server. - Scoped { server_id: Option }, - /// Administrative control-plane session token. - Admin, -} - -#[derive(Serialize)] -struct Claims<'a> { - username: &'a str, - sub: &'a str, - jti: String, - token_use: &'static str, - iss: &'static str, - aud: &'static str, - iat: u64, - nbf: u64, - exp: u64, - teams: Option<()>, - user: UserClaims<'a>, - #[serde(skip_serializing_if = "Option::is_none")] - scopes: Option, -} - -#[derive(Serialize)] -struct UserClaims<'a> { - email: &'a str, - full_name: &'static str, - is_admin: bool, - auth_provider: &'static str, -} - -#[derive(Serialize)] -struct Scopes { - server_id: Option, - permissions: &'static [&'static str], - ip_restrictions: &'static [&'static str], - time_restrictions: Option<()>, -} - -/// Generates a token using the current system clock and a random v4 UUID. -/// -/// # Errors -/// -/// Returns an error when the system clock predates the Unix epoch, the -/// expiration timestamp overflows, or JWT encoding fails. -pub fn make_token(secret: &str, subject: &str, kind: TokenKind) -> Result { - let now_epoch_seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("failed to generate token because the system clock predates the Unix epoch")? - .as_secs(); - - make_token_at(secret, subject, kind, now_epoch_seconds, Uuid::new_v4()) -} - -/// Generates a deterministic token from explicit time and UUID inputs. -/// -/// # Errors -/// -/// Returns an error when the expiration timestamp overflows or JWT encoding -/// fails. -pub fn make_token_at( - secret: &str, - subject: &str, - kind: TokenKind, - now_epoch_seconds: u64, - jti: Uuid, -) -> Result { - let expiration = now_epoch_seconds.checked_add(TTL_SECONDS).ok_or_else(|| { - anyhow!("failed to calculate token expiration because the timestamp overflows") - })?; - let (token_use, scopes) = match kind { - TokenKind::Scoped { server_id } => ( - "api", - Some(Scopes { - server_id, - permissions: PERMISSIONS, - ip_restrictions: &[], - time_restrictions: None, - }), - ), - TokenKind::Admin => ("session", None), - }; - let claims = Claims { - username: subject, - sub: subject, - jti: jti.to_string(), - token_use, - iss: ISSUER, - aud: AUDIENCE, - iat: now_epoch_seconds, - nbf: now_epoch_seconds, - exp: expiration, - teams: None, - user: UserClaims { - email: subject, - full_name: "CLI User", - is_admin: true, - auth_provider: "cli", - }, - scopes, - }; - let mut header = Header::new(Algorithm::HS256); - header.typ = Some("JWT".to_owned()); - - encode( - &header, - &claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .context("failed to encode JWT") -} diff --git a/tests/conformance/baselines/2026-07-28/legacy/built-in-data-plane.yml b/tests/conformance/baselines/2026-07-28/legacy/built-in-data-plane.yml new file mode 100644 index 0000000..dc3ec18 --- /dev/null +++ b/tests/conformance/baselines/2026-07-28/legacy/built-in-data-plane.yml @@ -0,0 +1,165 @@ +findings: +- scenario: caching + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: completion-complete + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: http-custom-header-server-validation + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: http-header-validation + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-basic-elicitation + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-basic-list-roots + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-basic-sampling + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-capability-check + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-ignore-extra-params + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-missing-input-response + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-multi-round + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-multiple-input-requests + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-non-tool-request + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-request-state + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-result-type + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-tampered-state + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-unsupported-methods + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: input-required-result-validate-input + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: json-schema-2020-12 + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: prompts-get-embedded-resource + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: prompts-get-simple + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: prompts-get-with-args + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: prompts-get-with-image + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: prompts-list + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: resources-list + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: resources-read-binary + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: resources-read-text + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: resources-templates-read + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: sep-2164-resource-not-found + check: sep-2164-data-uri + name: ResourcesNotFoundDataUri + status: FAILURE +- scenario: sep-2164-resource-not-found + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: server-stateless + check: sep-2575-discover-capabilities-match-handlers + name: DiscoverCapabilitiesMatchHandlers + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-error-jsonrpc-id + name: HttpServerErrorJsonrpcId + status: FAILURE +- scenario: server-stateless + check: sep-2575-server-declares-prompts-in-discover + name: ServerDeclaresPromptsInDiscover + status: FAILURE +- scenario: tools-call-audio + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-embedded-resource + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-error + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-image + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-mixed-content + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-simple-text + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-with-progress + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-list + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE diff --git a/tests/conformance/baselines/2026-07-28/legacy/client/external-data-plane.yml b/tests/conformance/baselines/2026-07-28/legacy/client/external-data-plane.yml new file mode 100644 index 0000000..c112fa8 --- /dev/null +++ b/tests/conformance/baselines/2026-07-28/legacy/client/external-data-plane.yml @@ -0,0 +1,61 @@ +findings: +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_ControlChar + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_CrLf + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_LeadingSpace + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_NonAscii + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_Tab + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_TrailingSpace + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_Whitespace + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-encode-values + name: ClientCustomHeader_Debug + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-encode-values + name: ClientCustomHeader_Priority + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-encode-values + name: ClientCustomHeader_Verbose + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-mirrors-designated-params + name: ClientCustomHeader_EmptyVal + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-mirrors-designated-params + name: ClientCustomHeader_InternalSpace + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-mirrors-designated-params + name: ClientCustomHeader_Method + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-mirrors-designated-params + name: ClientCustomHeader_Region + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-supports-custom-headers + name: ClientSupportsCustomHeaders + status: FAILURE diff --git a/tests/conformance/baselines/2026-07-28/legacy/external-data-plane.yml b/tests/conformance/baselines/2026-07-28/legacy/external-data-plane.yml new file mode 100644 index 0000000..0fe42ce --- /dev/null +++ b/tests/conformance/baselines/2026-07-28/legacy/external-data-plane.yml @@ -0,0 +1,33 @@ +findings: +- scenario: input-required-result-unsupported-methods + check: sep-2322-not-on-unsupported-requests + name: NotOnUnsupportedRequests + status: FAILURE +- scenario: input-required-result-validate-input + check: sep-2322-validate-input-responses + name: ValidateInputResponses + status: WARNING +- scenario: sep-2164-resource-not-found + check: sep-2164-data-uri + name: ResourcesNotFoundDataUri + status: FAILURE +- scenario: server-stateless + check: sep-2575-discover-capabilities-match-handlers + name: DiscoverCapabilitiesMatchHandlers + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-error-jsonrpc-id + name: HttpServerErrorJsonrpcId + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-meta-invalid-400 + name: HttpServerMetaInvalid400 + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-unsupported-version-400 + name: HttpServerUnsupportedVersion400 + status: FAILURE +- scenario: server-stateless + check: sep-2575-server-declares-prompts-in-discover + name: ServerDeclaresPromptsInDiscover + status: FAILURE diff --git a/tests/conformance/baselines/2026-07-28/legacy/fixture-direct.yml b/tests/conformance/baselines/2026-07-28/legacy/fixture-direct.yml new file mode 100644 index 0000000..eafa4ab --- /dev/null +++ b/tests/conformance/baselines/2026-07-28/legacy/fixture-direct.yml @@ -0,0 +1,285 @@ +findings: +- scenario: caching + check: sep-2549-cache-scope-valid + name: CacheScopeValid + status: FAILURE +- scenario: caching + check: sep-2549-prompts-list-caching-hints + name: PromptsListCachingHints + status: FAILURE +- scenario: caching + check: sep-2549-resources-list-caching-hints + name: ResourcesListCachingHints + status: FAILURE +- scenario: caching + check: sep-2549-resources-templates-list-caching-hints + name: ResourcesTemplatesListCachingHints + status: FAILURE +- scenario: caching + check: sep-2549-tools-list-caching-hints + name: ToolsListCachingHints + status: FAILURE +- scenario: caching + check: sep-2549-ttl-non-negative + name: TtlNonNegative + status: FAILURE +- scenario: completion-complete + check: completion-complete + name: CompletionComplete + status: FAILURE +- scenario: dns-rebinding-protection + check: localhost-host-valid-accepted + name: LocalhostHostAccepted + status: FAILURE +- scenario: http-custom-header-server-validation + check: sep-2243-server-custom-setup + name: HttpCustomHeaderServerValidationSetup + status: FAILURE +- scenario: http-custom-header-server-validation + check: sep-2243-server-decode-base64 + name: NotObserved + status: FAILURE +- scenario: http-custom-header-server-validation + check: sep-2243-server-reject-invalid-param-chars + name: NotObserved + status: FAILURE +- scenario: http-custom-header-server-validation + check: sep-2243-server-reject-param-mismatch + name: NotObserved + status: FAILURE +- scenario: http-custom-header-server-validation + check: sep-2243-server-validate-param-match + name: NotObserved + status: FAILURE +- scenario: http-header-validation + check: sep-2243-server-accepts-whitespace-header-value + name: ServerAcceptsWhitespaceHeaderValue + status: FAILURE +- scenario: http-header-validation + check: sep-2243-server-reject-invalid-headers + name: ServerRejectsMismatchedNameHeader + status: FAILURE +- scenario: http-header-validation + check: sep-2243-server-reject-invalid-headers + name: ServerRejectsMissingNameHeader + status: FAILURE +- scenario: http-header-validation + check: sep-2243-server-standard-setup + name: HttpHeaderValidationSetup + status: FAILURE +- scenario: input-required-result-basic-elicitation + check: sep-2322-elicitation-incomplete + name: InputRequiredResultElicitationIncomplete + status: FAILURE +- scenario: input-required-result-basic-list-roots + check: sep-2322-list-roots-incomplete + name: InputRequiredResultListRootsIncomplete + status: FAILURE +- scenario: input-required-result-basic-sampling + check: sep-2322-sampling-incomplete + name: InputRequiredResultSamplingIncomplete + status: FAILURE +- scenario: input-required-result-capability-check + check: sep-2322-respect-client-capabilities + name: RespectClientCapabilities + status: FAILURE +- scenario: input-required-result-ignore-extra-params + check: sep-2322-ignore-unexpected-params + name: IgnoreUnexpectedParams + status: WARNING +- scenario: input-required-result-missing-input-response + check: sep-2322-missing-response-rerequests + name: InputRequiredResultMissingResponseRerequests + status: WARNING +- scenario: input-required-result-multi-round + check: sep-2322-multi-round-r1 + name: InputRequiredResultMultiRoundR1 + status: FAILURE +- scenario: input-required-result-multiple-input-requests + check: sep-2322-multiple-inputs-incomplete + name: InputRequiredResultMultipleInputsIncomplete + status: FAILURE +- scenario: input-required-result-non-tool-request + check: sep-2322-non-tool-incomplete + name: InputRequiredResultNonToolIncomplete + status: FAILURE +- scenario: input-required-result-request-state + check: sep-2322-request-state-incomplete + name: InputRequiredResultRequestStateIncomplete + status: FAILURE +- scenario: input-required-result-result-type + check: sep-2322-result-type-included + name: ResultTypeIncluded + status: FAILURE +- scenario: input-required-result-tampered-state + check: sep-2322-reject-tampered-state + name: RejectTamperedState + status: FAILURE +- scenario: json-schema-2020-12 + check: json-schema-2020-12-error + name: JsonSchema2020_12Error + status: FAILURE +- scenario: prompts-get-embedded-resource + check: prompts-get-embedded-resource + name: PromptsGetEmbeddedResource + status: FAILURE +- scenario: prompts-get-simple + check: prompts-get-simple + name: PromptsGetSimple + status: FAILURE +- scenario: prompts-get-with-args + check: prompts-get-with-args + name: PromptsGetWithArgs + status: FAILURE +- scenario: prompts-get-with-image + check: prompts-get-with-image + name: PromptsGetWithImage + status: FAILURE +- scenario: prompts-list + check: prompts-list + name: PromptsList + status: FAILURE +- scenario: resources-list + check: resources-list + name: ResourcesList + status: FAILURE +- scenario: resources-read-binary + check: resources-read-binary + name: ResourcesReadBinary + status: FAILURE +- scenario: resources-read-text + check: resources-read-text + name: ResourcesReadText + status: FAILURE +- scenario: resources-templates-read + check: resources-templates-read + name: ResourcesTemplateRead + status: FAILURE +- scenario: sep-2164-resource-not-found + check: sep-2164-data-uri + name: ResourcesNotFoundDataUri + status: WARNING +- scenario: sep-2164-resource-not-found + check: sep-2164-error-code + name: ResourcesNotFoundErrorCode + status: WARNING +- scenario: server-sse-multiple-streams + check: server-accepts-multiple-post-streams + name: ServerAcceptsMultiplePostStreams + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-header-mismatch-400 + name: HttpServerHeaderMismatch400 + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-method-not-found-404 + name: HttpServerMethodNotFound404 + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-method-not-found-404-initialize + name: HttpServerMethodNotFound404initialize + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-method-not-found-404-logging-setlevel + name: HttpServerMethodNotFound404loggingsetLevel + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-method-not-found-404-ping + name: HttpServerMethodNotFound404ping + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-method-not-found-404-resources-subscribe + name: HttpServerMethodNotFound404resourcessubscribe + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-method-not-found-404-resources-unsubscribe + name: HttpServerMethodNotFound404resourcesunsubscribe + status: FAILURE +- scenario: server-stateless + check: sep-2575-http-server-no-independent-requests-on-stream + name: HttpServerNoIndependentRequestsOnStream + status: FAILURE +- scenario: server-stateless + check: sep-2575-missing-capability-http-400 + name: MissingCapabilityHttp400 + status: FAILURE +- scenario: server-stateless + check: sep-2575-request-meta-client-info-optional + name: RequestMetaClientInfoOptional + status: FAILURE +- scenario: server-stateless + check: sep-2575-request-meta-invalid-missing-client-capabilities + name: RequestMetaInvalid + status: FAILURE +- scenario: server-stateless + check: sep-2575-request-meta-invalid-missing-meta + name: RequestMetaInvalid + status: FAILURE +- scenario: server-stateless + check: sep-2575-request-meta-invalid-missing-protocol-version + name: RequestMetaInvalid + status: FAILURE +- scenario: server-stateless + check: sep-2575-server-honors-notification-filter + name: ServerHonorsNotificationFilter + status: FAILURE +- scenario: server-stateless + check: sep-2575-server-identifies-in-result-meta + name: ServerIdentifiesInResultMeta + status: WARNING +- scenario: server-stateless + check: sep-2575-server-implements-discover + name: ServerImplementsDiscover + status: FAILURE +- scenario: server-stateless + check: sep-2575-server-no-log-without-loglevel + name: ServerNoLogWithoutLogLevel + status: FAILURE +- scenario: server-stateless + check: sep-2575-server-rejects-undeclared-capability + name: ServerRejectsUndeclaredCapability + status: FAILURE +- scenario: server-stateless + check: sep-2575-server-sends-subscription-ack + name: ServerSendsSubscriptionAck + status: FAILURE +- scenario: server-stateless + check: sep-2575-server-tags-subscription-id + name: ServerTagsSubscriptionId + status: FAILURE +- scenario: server-stateless + check: sep-2575-server-unsupported-version-error + name: ServerUnsupportedVersionError + status: FAILURE +- scenario: tools-call-audio + check: tools-call-audio + name: ToolsCallAudio + status: FAILURE +- scenario: tools-call-embedded-resource + check: tools-call-embedded-resource + name: ToolsCallEmbeddedResource + status: FAILURE +- scenario: tools-call-error + check: tools-call-error + name: ToolsCallError + status: FAILURE +- scenario: tools-call-image + check: tools-call-image + name: ToolsCallImage + status: FAILURE +- scenario: tools-call-mixed-content + check: tools-call-mixed-content + name: ToolsCallMixedContent + status: FAILURE +- scenario: tools-call-simple-text + check: tools-call-simple-text + name: ToolsCallSimpleText + status: FAILURE +- scenario: tools-call-with-progress + check: tools-call-with-progress + name: ToolsCallWithProgress + status: FAILURE +- scenario: tools-list + check: tools-list + name: ToolsList + status: FAILURE diff --git a/tests/conformance/baselines/2026-07-28/modern/built-in-data-plane.yml b/tests/conformance/baselines/2026-07-28/modern/built-in-data-plane.yml new file mode 100644 index 0000000..2cb8db3 --- /dev/null +++ b/tests/conformance/baselines/2026-07-28/modern/built-in-data-plane.yml @@ -0,0 +1 @@ +findings: [] diff --git a/tests/conformance/baselines/2026-07-28/modern/client/external-data-plane.yml b/tests/conformance/baselines/2026-07-28/modern/client/external-data-plane.yml new file mode 100644 index 0000000..c112fa8 --- /dev/null +++ b/tests/conformance/baselines/2026-07-28/modern/client/external-data-plane.yml @@ -0,0 +1,61 @@ +findings: +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_ControlChar + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_CrLf + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_LeadingSpace + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_NonAscii + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_Tab + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_TrailingSpace + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-base64-unsafe + name: ClientCustomHeader_Whitespace + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-encode-values + name: ClientCustomHeader_Debug + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-encode-values + name: ClientCustomHeader_Priority + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-encode-values + name: ClientCustomHeader_Verbose + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-mirrors-designated-params + name: ClientCustomHeader_EmptyVal + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-mirrors-designated-params + name: ClientCustomHeader_InternalSpace + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-mirrors-designated-params + name: ClientCustomHeader_Method + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-mirrors-designated-params + name: ClientCustomHeader_Region + status: FAILURE +- scenario: http-custom-headers + check: sep-2243-client-supports-custom-headers + name: ClientSupportsCustomHeaders + status: FAILURE diff --git a/tests/conformance/baselines/2026-07-28/modern/external-data-plane.yml b/tests/conformance/baselines/2026-07-28/modern/external-data-plane.yml new file mode 100644 index 0000000..2cb8db3 --- /dev/null +++ b/tests/conformance/baselines/2026-07-28/modern/external-data-plane.yml @@ -0,0 +1 @@ +findings: [] diff --git a/tests/conformance/baselines/2026-07-28/modern/fixture-direct.yml b/tests/conformance/baselines/2026-07-28/modern/fixture-direct.yml new file mode 100644 index 0000000..efee5f0 --- /dev/null +++ b/tests/conformance/baselines/2026-07-28/modern/fixture-direct.yml @@ -0,0 +1,93 @@ +findings: +- scenario: http-custom-header-server-validation + check: sep-2243-server-decode-base64 + name: NotTestable + status: FAILURE +- scenario: http-custom-header-server-validation + check: sep-2243-server-no-xmcp-tool + name: HttpCustomHeaderServerNoTool + status: FAILURE +- scenario: http-custom-header-server-validation + check: sep-2243-server-reject-invalid-param-chars + name: NotTestable + status: FAILURE +- scenario: http-custom-header-server-validation + check: sep-2243-server-reject-param-mismatch + name: NotTestable + status: FAILURE +- scenario: http-custom-header-server-validation + check: sep-2243-server-validate-param-match + name: NotTestable + status: FAILURE +- scenario: http-header-validation + check: sep-2243-server-reject-error-code + name: ServerRejectsCaseMismatchValueErrorCode + status: WARNING +- scenario: http-header-validation + check: sep-2243-server-reject-error-code + name: ServerRejectsMismatchedMethodHeaderErrorCode + status: WARNING +- scenario: http-header-validation + check: sep-2243-server-reject-error-code + name: ServerRejectsMismatchedNameHeaderErrorCode + status: WARNING +- scenario: http-header-validation + check: sep-2243-server-reject-error-code + name: ServerRejectsMissingMethodHeaderErrorCode + status: WARNING +- scenario: http-header-validation + check: sep-2243-server-reject-error-code + name: ServerRejectsMissingNameHeaderErrorCode + status: WARNING +- scenario: http-header-validation + check: sep-2243-server-reject-invalid-headers + name: ServerRejectsCaseMismatchValue + status: FAILURE +- scenario: http-header-validation + check: sep-2243-server-reject-invalid-headers + name: ServerRejectsMismatchedMethodHeader + status: FAILURE +- scenario: http-header-validation + check: sep-2243-server-reject-invalid-headers + name: ServerRejectsMismatchedNameHeader + status: FAILURE +- scenario: http-header-validation + check: sep-2243-server-reject-invalid-headers + name: ServerRejectsMissingMethodHeader + status: FAILURE +- scenario: http-header-validation + check: sep-2243-server-reject-invalid-headers + name: ServerRejectsMissingNameHeader + status: FAILURE +- scenario: input-required-result-validate-input + check: sep-2322-validate-input-responses + name: ValidateInputResponses + status: WARNING +- scenario: tools-call-audio + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-embedded-resource + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-error + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-image + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-mixed-content + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-simple-text + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE +- scenario: tools-call-with-progress + check: wire-schema-valid + name: WireSchemaValid + status: FAILURE diff --git a/tests/token.rs b/tests/token.rs deleted file mode 100644 index 04bed02..0000000 --- a/tests/token.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use cf_integration::token::{TokenKind, make_token, make_token_at}; -use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; -use serde_json::{Value, json}; -use uuid::Uuid; - -const SECRET: &str = "my-test-key-but-now-longer-than-32-bytes"; -const SUBJECT: &str = "admin@example.com"; -const NOW: u64 = 1_700_000_000; - -fn fixed_jti() -> Uuid { - Uuid::parse_str("00000000-0000-0000-0000-000000000001").expect("fixed test UUID must be valid") -} - -fn decode_verified(token: &str, secret: &str) -> Value { - let mut validation = Validation::new(Algorithm::HS256); - validation.validate_exp = false; - validation.set_audience(&["mcpgateway-api"]); - validation.set_issuer(&["mcpgateway"]); - - decode::( - token, - &DecodingKey::from_secret(secret.as_bytes()), - &validation, - ) - .expect("token must verify") - .claims -} - -#[test] -fn scoped_token_has_hs256_signature_and_exact_claims() { - let token = make_token_at( - SECRET, - SUBJECT, - TokenKind::Scoped { - server_id: Some("server-123".to_owned()), - }, - NOW, - fixed_jti(), - ) - .expect("token generation must succeed"); - - let header = decode_header(&token).expect("token header must decode"); - assert_eq!(header.alg, Algorithm::HS256); - assert_eq!(header.typ.as_deref(), Some("JWT")); - assert_eq!( - decode_verified(&token, SECRET), - json!({ - "username": SUBJECT, - "sub": SUBJECT, - "jti": "00000000-0000-0000-0000-000000000001", - "token_use": "api", - "iss": "mcpgateway", - "aud": "mcpgateway-api", - "iat": NOW, - "nbf": NOW, - "exp": NOW + 86_400, - "teams": null, - "user": { - "email": SUBJECT, - "full_name": "CLI User", - "is_admin": true, - "auth_provider": "cli" - }, - "scopes": { - "server_id": "server-123", - "permissions": [ - "servers.read", - "servers.use", - "tools.read", - "tools.call" - ], - "ip_restrictions": [], - "time_restrictions": null - } - }) - ); -} - -#[test] -fn scoped_token_preserves_a_null_server_id() { - let token = make_token_at( - SECRET, - SUBJECT, - TokenKind::Scoped { server_id: None }, - NOW, - fixed_jti(), - ) - .expect("token generation must succeed"); - - assert_eq!( - decode_verified(&token, SECRET)["scopes"]["server_id"], - Value::Null - ); -} - -#[test] -fn admin_token_has_session_use_and_omits_scopes() { - let token = make_token_at(SECRET, SUBJECT, TokenKind::Admin, NOW, fixed_jti()) - .expect("token generation must succeed"); - - assert_eq!( - decode_verified(&token, SECRET), - json!({ - "username": SUBJECT, - "sub": SUBJECT, - "jti": "00000000-0000-0000-0000-000000000001", - "token_use": "session", - "iss": "mcpgateway", - "aud": "mcpgateway-api", - "iat": NOW, - "nbf": NOW, - "exp": NOW + 86_400, - "teams": null, - "user": { - "email": SUBJECT, - "full_name": "CLI User", - "is_admin": true, - "auth_provider": "cli" - } - }) - ); -} - -#[test] -fn verification_rejects_a_different_secret() { - let token = make_token_at(SECRET, SUBJECT, TokenKind::Admin, NOW, fixed_jti()) - .expect("token generation must succeed"); - let mut validation = Validation::new(Algorithm::HS256); - validation.validate_exp = false; - validation.set_audience(&["mcpgateway-api"]); - validation.set_issuer(&["mcpgateway"]); - - let result = decode::( - &token, - &DecodingKey::from_secret(b"different-secret"), - &validation, - ); - - assert!(result.is_err()); -} - -#[test] -fn token_generation_rejects_expiration_overflow_without_exposing_inputs() { - let secret = "do-not-expose-this-secret"; - let subject = "do-not-expose-this-subject"; - - let error = make_token_at(secret, subject, TokenKind::Admin, u64::MAX, fixed_jti()) - .expect_err("expiration overflow must fail"); - let message = format!("{error:#}"); - - assert!(message.contains("expiration")); - assert!(!message.contains(secret)); - assert!(!message.contains(subject)); -} - -#[test] -fn production_token_uses_current_time_and_a_v4_uuid() { - let before = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("test clock must be after the Unix epoch") - .as_secs(); - let token = make_token(SECRET, SUBJECT, TokenKind::Admin) - .expect("production token generation must succeed"); - let after = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("test clock must be after the Unix epoch") - .as_secs(); - let claims = decode_verified(&token, SECRET); - - let issued_at = claims["iat"].as_u64().expect("iat must be an integer"); - assert!((before..=after).contains(&issued_at)); - assert_eq!(claims["nbf"], issued_at); - assert_eq!(claims["exp"], issued_at + 86_400); - let jti = Uuid::parse_str(claims["jti"].as_str().expect("jti must be a string")) - .expect("jti must be a UUID"); - assert_eq!(jti.get_version_num(), 4); -} diff --git a/tests/workspace_architecture.rs b/tests/workspace_architecture.rs deleted file mode 100644 index 6b08371..0000000 --- a/tests/workspace_architecture.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::process::Command; - -use serde_json::Value; - -#[test] -fn workspace_packages_and_internal_edges_match_the_architecture() { - let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); - let output = Command::new(cargo) - .args(["metadata", "--no-deps", "--format-version", "1"]) - .current_dir(env!("CARGO_MANIFEST_DIR")) - .output() - .expect("cargo metadata should start"); - assert!(output.status.success(), "cargo metadata failed"); - - let metadata: Value = - serde_json::from_slice(&output.stdout).expect("cargo metadata should return JSON"); - let packages = metadata["packages"] - .as_array() - .expect("metadata packages should be an array"); - let by_id = packages - .iter() - .map(|package| { - ( - package["id"].as_str().expect("package id").to_owned(), - package["name"].as_str().expect("package name").to_owned(), - ) - }) - .collect::>(); - let members = metadata["workspace_members"] - .as_array() - .expect("workspace members should be an array") - .iter() - .map(|id| by_id[id.as_str().expect("workspace member id")].clone()) - .collect::>(); - assert_eq!( - members, - BTreeSet::from([ - "cf-integration".to_owned(), - "cf-integration-platform".to_owned(), - "cf-integration-mcp".to_owned(), - "cf-integration-compliance".to_owned(), - "cf-integration-load".to_owned(), - ]) - ); - - let workspace_names = &members; - let edges = packages - .iter() - .flat_map(|package| { - let source = package["name"].as_str().expect("package name").to_owned(); - package["dependencies"] - .as_array() - .expect("dependencies should be an array") - .iter() - .filter_map(move |dependency| { - let target = dependency["name"].as_str().expect("dependency name"); - workspace_names - .contains(target) - .then(|| (source.clone(), target.to_owned())) - }) - }) - .collect::>(); - assert_eq!( - edges, - BTreeSet::from([ - ( - "cf-integration".to_owned(), - "cf-integration-platform".to_owned(), - ), - ("cf-integration".to_owned(), "cf-integration-mcp".to_owned(),), - ( - "cf-integration".to_owned(), - "cf-integration-compliance".to_owned(), - ), - ( - "cf-integration".to_owned(), - "cf-integration-load".to_owned(), - ), - ( - "cf-integration-compliance".to_owned(), - "cf-integration-platform".to_owned(), - ), - ( - "cf-integration-load".to_owned(), - "cf-integration-platform".to_owned(), - ), - ( - "cf-integration-load".to_owned(), - "cf-integration-mcp".to_owned(), - ), - ]) - ); - - let binary_targets = packages - .iter() - .flat_map(|package| { - let package_name = package["name"].as_str().expect("package name"); - package["targets"] - .as_array() - .expect("targets should be an array") - .iter() - .filter(|target| { - target["kind"] - .as_array() - .expect("target kind should be an array") - .iter() - .any(|kind| kind == "bin") - }) - .map(move |target| { - ( - package_name.to_owned(), - target["name"].as_str().expect("target name").to_owned(), - ) - }) - }) - .collect::>(); - assert_eq!( - binary_targets, - vec![("cf-integration".to_owned(), "cf-integration".to_owned())] - ); -}