diff --git a/CHANGELOG.md b/CHANGELOG.md index cf474ce..979ae07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,54 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Changed + +- Simplified runtime dispatch and shared authenticated workflow setup, removing + forwarding wrappers while preserving token revocation and stack cleanup. + +### Fixed + +- Removed control-plane checkout, worker-configuration, and secret-generation + dependencies from standalone workflows, including installed-binary load tests. +- Kept captured command data, including test tokens, out of conformance setup + logs while retaining helper diagnostics when stdout capture fails. +- Stopped the entire conformance matrix after Ctrl-C cleanup and prevented + interrupted runs from updating baselines. +- Discover standalone conformance routes and input schemas from the running + fixture, including paginated diagnostic tools. Fixture discovery errors stop + setup before a baseline can be blessed. +- Made the dataplane config writer available to normal external client conformance + and preserved schemas for its scenario tools. +- Accepted empty pagination cursors and legacy SSE keepalives during discovery, + and used the fixture's protocol era when configuring backends for clients from + a different era. +- Rejected `live --standalone`, which previously reported live-group success after + only a probe. Isolated route checks remain available through `probe --standalone`. + +## [0.3.1] - 2026-09-04 + +### Added + +- Added global standalone external-dataplane mode across stack, probe, load, + conformance, and debug-token workflows, backed by mocked Redis, + ephemeral RSA authentication, and no control-plane services. + +### Changed + +- Published every standalone route through the running dataplane's serializer + so mocked Redis snapshots always use that image's current MessagePack schema. +- Added protocol-mode selection to `stack up`. +- Left the independent ClickStack service running during explicit stack cleanup + as well as managed workflow cleanup. + +### Fixed + +- Avoided a Linux `ETXTBSY` race in the native process-runner test. +- Embedded the complete standalone and observability asset set in installed + binaries and updated standalone authentication for the dataplane JWKS model. +- Skipped unsupported external-dataplane catalog fan-out during standalone + probes and load tests while retaining routed tool-call coverage. + ## [0.3.0] - 2026-09-03 ### Added @@ -27,7 +75,7 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - Reused the external conformance stack between compatible server and client phases while preserving setup, execution, and cleanup failures. - Moved ClickStack into an independent Compose lifecycle so managed test cleanup - leaves telemetry available for inspection; explicit `stack down` removes it. + leaves telemetry available for inspection. - Made ClickStack the default for non-performance workflows and an explicit opt-in for load tests to avoid skewing benchmark results. @@ -68,7 +116,8 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - Added builtin and external dataplane routing through reusable Docker Compose overlays. -[Unreleased]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/contextforge-org/contextforge-dev-tools/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/contextforge-org/contextforge-dev-tools/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 8d2e0f9..32b710b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,7 +177,7 @@ dependencies = [ [[package]] name = "cf-integration" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index f8cf40d..35cd8cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cf-integration" -version = "0.3.0" +version = "0.3.1" edition = "2024" rust-version = "1.97" license = "Apache-2.0" @@ -18,9 +18,9 @@ include = [ "/src/**", "/docker/**", "/scripts/locustfile_mcp.py", - "/scripts/prepare_standalone_config.py", "/scripts/live_protocol/sitecustomize.py", - "/scripts/conformance/write_client_config.py", + "/scripts/conformance/write_dataplane_config.mjs", + "/scripts/standalone/generate_auth_key.mjs", "/tests/conformance/baselines/**", "/README.md", "/CHANGELOG.md", diff --git a/README.md b/README.md index c78b5b8..b39ab0b 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ # cf-integration -`cf-integration` runs ContextForge stacks and tests against the built-in Python -dataplane or the external Rust dataplane. It manages Docker Compose, source -checkouts, MCP probes, Locust load tests, upstream live tests, and official MCP -conformance runs. +`cf-integration` manages ContextForge Docker stacks and runs probes, load tests, +live gateway checks, and official MCP conformance. Routed traffic uses one of +two lanes: -`/servers/{virtual_host_id}/mcp` routes through `cf-dataplane`; raw `/mcp`, -UI, and API traffic route to `cf-controlplane`. The external dataplane fails -closed and never falls back to the built-in dataplane. +- `builtin`: the Python dataplane in `cf-controlplane`. +- `external`: the Rust `cf-dataplane`. -## Install and requirements +`/servers/{virtual_host_id}/mcp` routes to the external dataplane. Raw `/mcp` +and control-plane UI/API traffic route to `cf-controlplane`; there is no +fallback between them. + +## Install ```bash cargo binstall cf-integration @@ -17,175 +19,144 @@ cargo binstall cf-integration cargo install cf-integration --locked ``` -To run the current checkout, put `cargo run --` before any command: - -```bash -cargo run -- probe --lane external --protocol-version modern -``` - -Runtime requirements are Docker with Compose v2, Git, and Node.js 22.7.5 or -newer with `npx`. Load tests also need the control-plane checkout's Python and -Locust dependencies. Rust 1.97 is required only to compile the CLI or a local -source image. +Use `cargo run --` before a command when running this checkout. Runtime use +requires Docker Compose v2, Git, and Node.js 22.7.5 or newer. Rust 1.97 is +needed only to compile the CLI or a local dataplane image. -Published images are used by default. Set `CF_DATAPLANE_REF` to build an -external dataplane source ref. +Published images are the default. Set `CF_DATAPLANE_REF` to build and test a +local dataplane ref. -## Common selectors +## Selection -Wherever `--lane` is accepted, use these values: +Routed commands accept `--lane builtin|external`; `external` is the default. +Conformance and protocol-only live tests also accept `fixture-direct`. +`stack down` accepts `all`. No command accepts the old `--topology` option. -- `builtin`: Python built-in dataplane. -- `external`: external Rust dataplane. -- `fixture-direct`: reference fixture without ContextForge; available only to - conformance and `live --group protocol`. +Commands that exercise MCP accept `--protocol-version modern|legacy`: -Routed commands default to `CF_MCP_LANE`, then `external`, and run one lane -at a time. Run them once per lane when comparing `builtin` and `external`. -`stack down` also accepts `all`; conformance accepts repeated `--lane` -options. No command accepts `--topology`. +- `modern`: current per-request, stateless MCP. +- `legacy`: current initialization-based MCP. -`probe`, `load`, `live`, and `debug inspect` accept -`--protocol-version modern|legacy`: +The CLI deliberately does not expose dated wire revisions. Defaults may be set +with `CF_MCP_LANE` and `MCP_PROTOCOL_VERSION`. -- `modern`: latest per-request, stateless MCP revision. -- `legacy`: latest initialization-based MCP revision. +Add the global `--standalone` flag to run the external lane without any control +plane. Standalone mode starts Redis, the Rust dataplane, nginx, and the required +test fixture. It generates an ephemeral RSA key, obtains a test token from the +dataplane's local tool endpoint, validates it through the dataplane's loopback +JWKS endpoint, and publishes a fresh config through the dataplane serializer. +Redis therefore always contains the schema understood by the image under test. +Standalone commands also work from an installed binary without control-plane +checkouts or generated control-plane secrets. +Routes and tool schemas are discovered from every catalog page of the running +fixture, including the selected protocol era's diagnostic tools and prompts. -The default is `MCP_PROTOCOL_VERSION`, then `modern`. Dated revisions are -internal wire values, not operational CLI options. +Use `cf-integration --help` for the complete interface. -Use `--help` at any level for the authoritative interface, such as -`cf-integration stack --help` or `cf-integration load --help`. +## Stack -## Commands +Use a persistent stack for manual testing: -Test workflows prepare and clean up their required stack. Use `stack` when you -want a persistent stack for manual work. +```bash +cf-integration stack up --lane builtin --protocol-version modern +cf-integration stack up --lane external --protocol-version legacy --fresh +cf-integration stack up --lane external --protocol-version legacy --standalone -### `stack` +cf-integration stack status --lane external --standalone +cf-integration stack logs --lane external --standalone +cf-integration stack logs --lane external --standalone dataplane nginx +cf-integration stack config --lane external --standalone -```bash -# Start one lane -cf-integration stack up --lane builtin -cf-integration stack up --lane external --fresh - -# Inspect one lane -cf-integration stack status --lane external -cf-integration stack logs --lane external -cf-integration stack logs --lane external nginx -cf-integration stack config --lane external - -# Stop one or both lanes -cf-integration stack down --lane builtin +cf-integration stack down --lane external --standalone cf-integration stack down --lane all cf-integration stack down --lane all --volumes ``` -`up --fresh` removes existing volumes before starting. `logs` follows all -services unless service names are supplied. `config` prints merged Compose -configuration. `down --volumes` also removes persistent volumes. - -ClickStack starts by default for `stack`, `probe`, `live`, `conformance`, and -`debug inspect`. Open the no-login HyperDX UI at to -inspect traces and metrics. Managed test cleanup leaves ClickStack running, so -the UI remains available after a command finishes; `stack down --lane all` -removes it. The external dataplane exports the exact -HTTP counters, latency histograms, in-flight gauge, and body sizes recorded by -its `HttpMetricsLayer`. HyperDX opens on live application logs. For metrics, -open **Chart Explorer**, select the **Metrics** data source and a metric, then -click **Run**. Run a load for at least 60 seconds so its 30-second cumulative -export interval produces the two samples needed for a non-zero chart. All -telemetry storage is ephemeral and disappears when ClickStack is removed. +`up --fresh` and `down --volumes` remove the selected stack's volumes. +ClickStack has an independent lifecycle and is intentionally left running by +all stack and managed-test cleanup. -### `probe` +## Probe -Probe one public MCP route, including discovery or initialization, -`tools/list`, a safe `tools/call`, authentication, and backend identity. +Probe authentication, protocol lifecycle, backend identity, catalog selection, +and a safe routed tool call: ```bash -cf-integration probe [--lane builtin|external] \ - [--protocol-version modern|legacy] +cf-integration probe --lane builtin --protocol-version modern +cf-integration probe --lane external --protocol-version legacy +cf-integration probe --lane external --protocol-version legacy --standalone ``` -### `load` +For standalone external runs, the known catalog comes from the mocked Redis +snapshot because the Rust dataplane intentionally does not implement fan-out +`tools/list`. -Run Locust against one public MCP route: +## Load ```bash -cf-integration load [--lane builtin|external] \ - [--protocol-version modern|legacy] [--standalone] [--smoke] \ - [--observability] [--users N] [--spawn-rate N] [--run-time DURATION] - # Compare both lanes for two minutes cf-integration load --lane builtin --protocol-version legacy \ --users 10 --spawn-rate 2 --run-time 2m cf-integration load --lane external --protocol-version legacy \ --users 10 --spawn-rate 2 --run-time 2m -# Measure only the external dataplane request path -cargo run -- load --lane external --protocol-version legacy --standalone \ +# Isolate the external dataplane and mocked Redis +cf-integration load --lane external --protocol-version legacy --standalone \ --users 10 --spawn-rate 2 --run-time 2m -# Inspect traces and native HTTP metrics while using the mocked Redis snapshot -cargo run -- load --lane external --protocol-version legacy --standalone \ +# Include telemetry when diagnostic value matters more than benchmark purity +cf-integration load --lane external --protocol-version modern --standalone \ --observability --users 10 --spawn-rate 2 --run-time 2m ``` -`--smoke` selects a short smoke workload. Duration accepts positive `h`, -`m`, and `s` groups such as `2m30s` or `1h30m`. Defaults come from -`LOCUST_USERS`, `LOCUST_SPAWN_RATE`, and `LOCUST_RUN_TIME`. -Observability is disabled for load tests by default to avoid skewing -performance results; pass `--observability` when diagnostics are more important -than an uncontaminated benchmark. - -`--standalone` is valid only with `--lane external`. Each run starts the full -stack to issue a scoped token, starts an isolated current-protocol MCP fixture, -then stops the control-plane gateway before Locust begins. A fresh mock config -for the token subject is written through the running dataplane's own serializer -on every run, so Redis receives the dataplane's current MessagePack schema. The -snapshot is non-expiring for the load duration; traffic does not depend on the -control-plane publisher or its schema-sync timing. +`--smoke` selects a short workload. Durations accept ordered positive `h`, `m`, +and `s` groups such as `2m30s`. Defaults are `100` users, `10` users/s, and +`5m`, overridable with `LOCUST_USERS`, `LOCUST_SPAWN_RATE`, and +`LOCUST_RUN_TIME`. Observability is opt-in for load tests to avoid skew. -### `live` +## Live gateway checks -Run the managed upstream control-plane test groups: `mcp` for Fast Time MCP -routes, `rbac` for authorization and transports, `protocol` for -protocol-specific behavior, or `all` (the default). +Groups are `mcp`, `rbac`, `protocol`, and `all` (default): ```bash -cf-integration live [--lane fixture-direct|builtin|external] \ - [--protocol-version modern|legacy] [--group mcp|rbac|protocol|all] - cf-integration live --lane builtin --protocol-version legacy --group all -cf-integration live --lane fixture-direct \ - --protocol-version legacy --group protocol +cf-integration live --lane external --protocol-version modern --group all +cf-integration live --lane fixture-direct --protocol-version legacy --group protocol ``` -### `conformance` +Routed runs execute the upstream control-plane live suites. `live --standalone` +is unsupported because these suites require the control plane. Use +`cf-integration probe --lane external --standalone` for isolated authentication, +protocol, and routed tool-call checks. + +## Conformance -`run` executes the pinned official suite and compares it with checked-in -baselines. With no options it runs all three lanes using a modern client against -legacy and modern fixture servers. +`run` executes the pinned official MCP suite and compares results with checked-in +baselines. With no selectors it runs all three lanes with a modern client +against legacy and modern fixtures. ```bash cf-integration conformance run -# Repeat selectors to build a matrix cf-integration conformance run \ --lane fixture-direct --lane builtin --lane external \ --client-era legacy --client-era modern \ --server-era legacy --server-era modern -# Replace selected baselines only after every selected run succeeds -cf-integration conformance run --server-era dual --bless +cf-integration conformance run --lane external --standalone \ + --client-era modern --server-era modern + +cf-integration conformance run --lane external --standalone \ + --client-era modern --server-era modern --bless ``` `--client-era` and `--server-era` accept `legacy`, `modern`, or `dual`. -`--results-dir`, `--baseline-dir`, and `--output-dir` override artifact -locations. +`--bless` replaces only the selected baselines and only after every selected +run succeeds. `--standalone` permits the external lane only. +Ctrl-C finishes cleanup for the active run, skips the remaining matrix entries, +and leaves baselines unchanged. -`report` regenerates Markdown comparisons from existing results without -running the suite: +Regenerate Markdown from existing results without running tests: ```bash cf-integration conformance report @@ -193,46 +164,36 @@ cf-integration conformance report \ --results-dir .integration/conformance --output-dir reports/conformance ``` -### `debug` - -`inspect` runs an MCP Inspector method against one routed lane. The method -defaults to `tools/list`, and the server defaults to the Fast Time fixture. +## Debug ```bash cf-integration debug inspect --lane external \ --protocol-version modern --method tools/list cf-integration debug inspect --lane builtin \ --protocol-version legacy --server-id -``` -`token` prints a token from an already-running control plane. `scoped` -creates the minimum catalog token used by public MCP tests; `admin` creates a -platform-admin session token. `--server-id` is valid only for `scoped`. - -```bash cf-integration debug token --kind scoped cf-integration debug token --kind scoped --server-id cf-integration debug token --kind admin -``` -## Configuration and artifacts +# Issue a token from an already-running standalone external stack +cf-integration debug token --kind scoped --standalone +``` -Copy `.env.example` to `.env`; process environment values override it. +`inspect` uses the official MCP Inspector. Control-plane tokens are revoked when +the workflow owns them; caller-supplied `MCPGATEWAY_BEARER_TOKEN` values are +never revoked. -| Variable | Purpose | Default | -| --- | --- | --- | -| `CF_MCP_LANE` | Routed lane | `external` | -| `MCP_PROTOCOL_VERSION` | Protocol mode | `modern` | -| `CF_INTEGRATION_DIR` | Checkouts, state, and load reports | `.integration` | -| `CF_DATAPLANE_REF` | Optional local dataplane Git ref | unset | -| `LOCUST_*` | Users, spawn rate, and duration | `100`, `10`, `5m` | +## Observability and artifacts -See [`.env.example`](.env.example) for every setting. Missing Compose secrets -are generated under `CF_INTEGRATION_DIR`. Workflow-created tokens are revoked -during cleanup; a caller-supplied `MCPGATEWAY_BEARER_TOKEN` is never revoked. +ClickStack starts by default for stack, probe, live, conformance, and Inspector +workflows. Open the no-login HyperDX UI at . Logs open by +default; for metrics use **Chart Explorer**, choose the **Metrics** source and a +metric such as `http.server.request.duration`, then run the query. Allow at +least 60 seconds of traffic for multiple 30-second cumulative exports. +Telemetry storage is ephemeral inside ClickStack. -Installed binaries embed their runtime assets. Set `CF_INTEGRATION_ROOT` to -force a developer checkout. Load reports default below -`CF_INTEGRATION_DIR/reports/load`; conformance results below -`CF_INTEGRATION_DIR/conformance`; and conformance Markdown below -`reports/conformance`. +Load reports are written below `CF_INTEGRATION_DIR/reports/load`, conformance +results below `CF_INTEGRATION_DIR/conformance`, and comparison Markdown below +`reports/conformance`. Copy [`.env.example`](.env.example) for the complete +configuration list. Process environment values override `.env`. diff --git a/docker/docker-compose.cf-conformance-controlplane.yaml b/docker/docker-compose.cf-conformance-controlplane.yaml new file mode 100644 index 0000000..3072da7 --- /dev/null +++ b/docker/docker-compose.cf-conformance-controlplane.yaml @@ -0,0 +1,4 @@ +services: + gateway: + environment: + GATEWAY_TOOL_NAME_SEPARATOR: "_" diff --git a/docker/docker-compose.cf-conformance.yaml b/docker/docker-compose.cf-conformance.yaml index ca54f47..9209f1e 100644 --- a/docker/docker-compose.cf-conformance.yaml +++ b/docker/docker-compose.cf-conformance.yaml @@ -1,12 +1,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: networks: - mcpnet + mcp_conformance_proxy: profiles: ["conformance"] image: nginx:1.30.4-alpine3.24 diff --git a/docker/docker-compose.cf-dataplane-config.yaml b/docker/docker-compose.cf-dataplane-config.yaml new file mode 100644 index 0000000..c557e41 --- /dev/null +++ b/docker/docker-compose.cf-dataplane-config.yaml @@ -0,0 +1,14 @@ +services: + config_writer: + profiles: ["helpers"] + image: node:22-bookworm-slim + labels: + name: cf-dataplane-config-writer + restart: "no" + networks: + - mcpnet + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_dataplane_config.mjs:/opt/contextforge-integration/write_dataplane_config.mjs:ro + entrypoint: + - node + - /opt/contextforge-integration/write_dataplane_config.mjs diff --git a/docker/docker-compose.cf-dataplane-standalone.yaml b/docker/docker-compose.cf-dataplane-standalone.yaml new file mode 100644 index 0000000..c92b154 --- /dev/null +++ b/docker/docker-compose.cf-dataplane-standalone.yaml @@ -0,0 +1,120 @@ +# Minimal external-dataplane stack for standalone workflows. The control plane +# is deliberately absent: the dataplane issues a test token from an ephemeral +# RSA key and serializes the mocked per-user routing snapshot into Redis. + +services: + auth_keygen: + image: node:22-bookworm-slim + labels: + name: cf-dataplane-auth-keygen + restart: "no" + network_mode: none + volumes: + - standalone_auth:/keys + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/standalone/generate_auth_key.mjs:/opt/contextforge-integration/generate_auth_key.mjs:ro + command: + - node + - /opt/contextforge-integration/generate_auth_key.mjs + - /keys/jwt.key + + redis: + image: redis:8.2.8-alpine3.22 + labels: + name: cf-redis + restart: "no" + command: + - redis-server + - --save + - "" + - --appendonly + - "no" + expose: + - "6379" + networks: + - mcpnet + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 2s + + dataplane: + image: ${CF_DATAPLANE_IMAGE:?Set CF_DATAPLANE_IMAGE to the cf-dataplane image tag} + pull_policy: ${CF_DATAPLANE_PULL_POLICY:-always} + platform: ${CF_DATAPLANE_PLATFORM:?Set CF_DATAPLANE_PLATFORM to the cf-dataplane image platform} + labels: + name: cf-dataplane + restart: "no" + networks: + mcpnet: + aliases: + - cf-dataplane + extra_hosts: + - host.docker.internal:host-gateway + expose: + - "4445" + command: + - --token-verification-private-key + - /keys/jwt.key + volumes: + - standalone_auth:/keys:ro + environment: + CONTEXTFORGE_DATA_PLANE_ADDRESS: 0.0.0.0:4445 + CONTEXTFORGE_DATA_PLANE_REDIS_HOSTNAME: redis + CONTEXTFORGE_DATA_PLANE_REDIS_PORT: "6379" + CONTEXTFORGE_DATA_PLANE_REDIS_CONNECTION_MODE: plain-text + # The current dataplane validates test tokens against its own + # loopback-only JWKS tool endpoint, keeping auth entirely local. + CONTEXTFORGE_DATA_PLANE_JWKS_URL: http://127.0.0.1:4445/contextforge-rs/admin/.well-known/jwks.json + CONTEXTFORGE_DATA_PLANE_UPSTREAM_CONNECTION_MODE: plain-text-or-tls + CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_HOSTS: ${CF_DATAPLANE_MCP_ALLOWED_HOSTS:-127.0.0.1:${NGINX_PORT:-8080},localhost:${NGINX_PORT:-8080},nginx} + CONTEXTFORGE_GATEWAY_RS_MCP_ALLOWED_ORIGINS: ${CF_DATAPLANE_MCP_ALLOWED_ORIGINS:-http://127.0.0.1:${NGINX_PORT:-8080},http://localhost:${NGINX_PORT:-8080}} + CONTEXTFORGE_DATA_PLANE_USER_CONFIG_CACHE_EXPIRY_SECONDS: "0" + RUST_LOG: ${CF_DATAPLANE_LOG:-info} + depends_on: + auth_keygen: + condition: service_completed_successfully + redis: + condition: service_healthy + + nginx: + image: nginx:1.30.4-alpine3.24 + labels: + name: cf-nginx + restart: "no" + ports: + - "${NGINX_PORT:-8080}:80" + environment: + MCP_SERVER_ID: ${MCP_SERVER_ID:-9779b6698cbd4b4995ee04a4fab38737} + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/docker/nginx.cf-dataplane-standalone.conf.template:/etc/nginx/templates/default.conf.template:ro + networks: + - mcpnet + depends_on: + dataplane: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health >/dev/null"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 2s + + locust: + profiles: ["performance"] + image: locustio/locust:2.46.2 + labels: + name: cf-locust + restart: "no" + user: "${HOST_UID:-1000}:${HOST_GID:-1000}" + networks: + - mcpnet + volumes: + - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/locustfile_mcp.py:/mnt/locust-cf/locustfile_mcp.py:ro + +networks: + mcpnet: + +volumes: + standalone_auth: diff --git a/docker/docker-compose.cf-dataplane.yaml b/docker/docker-compose.cf-dataplane.yaml index eafa479..ef92be8 100644 --- a/docker/docker-compose.cf-dataplane.yaml +++ b/docker/docker-compose.cf-dataplane.yaml @@ -27,9 +27,6 @@ services: # Requires a control-plane image with configurable publisher interval; # older images ignore the variable (60s behavior). DATAPLANE_PUBLISHER_INTERVAL_SECONDS: ${CF_DATAPLANE_PUBLISHER_INTERVAL_SECONDS:-2} - volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/prepare_standalone_config.py:/opt/contextforge-integration/prepare_standalone_config.py:ro - dataplane: image: ${CF_DATAPLANE_IMAGE:?Set CF_DATAPLANE_IMAGE to the cf-dataplane image tag} pull_policy: ${CF_DATAPLANE_PULL_POLICY:-always} diff --git a/docker/docker-compose.cf-integration.yaml b/docker/docker-compose.cf-integration.yaml index 1ca0515..026052e 100644 --- a/docker/docker-compose.cf-integration.yaml +++ b/docker/docker-compose.cf-integration.yaml @@ -12,37 +12,6 @@ services: register_fast_time: condition: service_completed_successfully - # A current-protocol upstream for standalone dataplane load tests. The - # profile keeps it out of normal stack, live, and probe workflows. - standalone_load_backend: - profiles: ["standalone-load"] - image: cf-integration/mcp-conformance-server:0.2.0-alpha.11 - build: - context: ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root} - dockerfile: docker/mcp-conformance-server.Dockerfile - labels: - name: cf-standalone-load-backend - restart: "no" - environment: - PORT: "3000" - MCP_CONFORMANCE_SERVER_ERA: modern - expose: - - "3000" - networks: - mcpnet: - aliases: - - mcp_conformance_server - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:3000/mcp').then(response => { if (response.status !== 400) process.exit(1); }).catch(() => process.exit(1)) - interval: 2s - timeout: 2s - retries: 30 - start_period: 2s - locust: volumes: # Harness locustfile with streamable-HTTP content negotiation; the diff --git a/docker/nginx.cf-dataplane-standalone.conf.template b/docker/nginx.cf-dataplane-standalone.conf.template new file mode 100644 index 0000000..37ec5e8 --- /dev/null +++ b/docker/nginx.cf-dataplane-standalone.conf.template @@ -0,0 +1,49 @@ +server { + listen 80; + server_name localhost; + resolver 127.0.0.11 valid=10s ipv6=off; + + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Authorization $http_authorization; + proxy_set_header Mcp-Session-Id $http_mcp_session_id; + proxy_set_header Mcp-Protocol-Version $http_mcp_protocol_version; + proxy_set_header Mcp-Method $http_mcp_method; + proxy_set_header Mcp-Name $http_mcp_name; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header Connection ""; + proxy_hide_header X-CF-Integration-Backend; + + location = /health { + default_type application/json; + add_header X-ContextForge-MCP-Transport-Mounted rust always; + return 200 '{"status":"ok","mode":"standalone"}'; + } + + location ~ ^/mcp/?$ { + proxy_pass http://dataplane:4445/contextforge-rs/servers/${MCP_SERVER_ID}/mcp$is_args$args; + proxy_request_buffering on; + proxy_buffering off; + proxy_cache off; + add_header X-Accel-Buffering "no" always; + add_header X-CF-Integration-Backend dataplane always; + proxy_connect_timeout 30s; + proxy_send_timeout 1h; + proxy_read_timeout 1h; + } + + location ~ ^/servers/([^/]+)/mcp/?$ { + proxy_pass http://dataplane:4445/contextforge-rs/servers/$1/mcp$is_args$args; + proxy_request_buffering on; + proxy_buffering off; + proxy_cache off; + add_header X-Accel-Buffering "no" always; + add_header X-CF-Integration-Backend dataplane always; + proxy_connect_timeout 30s; + proxy_send_timeout 1h; + proxy_read_timeout 1h; + } +} diff --git a/scripts/conformance/write_client_config.py b/scripts/conformance/write_client_config.py deleted file mode 100644 index 7fa9d9b..0000000 --- a/scripts/conformance/write_client_config.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -"""Publish one isolated upstream-client conformance route for the dataplane.""" - -from __future__ import annotations - -import base64 -import json -import os -import sys -from urllib.parse import urlparse - -import msgpack -import redis - - -def token_subject(token: str) -> str: - parts = token.split(".") - if len(parts) != 3: - raise SystemExit("MCP_CONFORMANCE_TOKEN is not a JWT") - payload = parts[1] + ("=" * (-len(parts[1]) % 4)) - try: - claims = json.loads(base64.urlsafe_b64decode(payload)) - except (ValueError, json.JSONDecodeError) as error: - raise SystemExit("MCP_CONFORMANCE_TOKEN has invalid claims") from error - subject = claims.get("sub") - if not isinstance(subject, str) or not subject: - raise SystemExit("MCP_CONFORMANCE_TOKEN has no string subject") - return subject - - -def main() -> None: - if len(sys.argv) != 4: - raise SystemExit( - "usage: write_client_config.py " - ) - virtual_host_id, backend_url, tool_names_json = sys.argv[1:] - token = os.environ.get("MCP_CONFORMANCE_TOKEN") - redis_url = os.environ.get("REDIS_URL") - if not token: - raise SystemExit("MCP_CONFORMANCE_TOKEN is required") - if not redis_url: - raise SystemExit("REDIS_URL is required") - if not virtual_host_id: - raise SystemExit("virtual-host-id must not be empty") - - parsed_url = urlparse(backend_url) - if parsed_url.scheme not in {"http", "https"} or not parsed_url.hostname: - raise SystemExit("backend-url must be an absolute HTTP(S) URL") - tool_names = json.loads(tool_names_json) - if ( - not isinstance(tool_names, list) - or not tool_names - or not all(isinstance(name, str) and name for name in tool_names) - ): - raise SystemExit("tool-names-json must be a non-empty JSON string array") - backend_name = "conformance-backend" - config = { - "virtual_hosts": { - virtual_host_id: { - "backends": { - backend_name: { - "name": backend_name, - "url": backend_url, - "mcp_protocol_version": "2026-07-28", - "passthrough_headers": [], - "add_headers": {}, - "remove_headers": [], - "tool_name_aliases": [ - { - "downstream_prefixed_name": name, - "upstream_name": name, - } - for name in tool_names - ], - "resource_uri_aliases": [], - "prompt_name_aliases": [], - "completion": {}, - "tool_schemas": {name: {} for name in tool_names}, - } - } - } - } - } - key = msgpack.dumps(("UserConfig", token_subject(token)), use_bin_type=True) - value = msgpack.dumps(config, use_bin_type=True) - redis.Redis.from_url(redis_url, decode_responses=False).set(key, value, ex=600) - - -if __name__ == "__main__": - main() diff --git a/scripts/conformance/write_dataplane_config.mjs b/scripts/conformance/write_dataplane_config.mjs new file mode 100644 index 0000000..c32fbb5 --- /dev/null +++ b/scripts/conformance/write_dataplane_config.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** Publish one conformance route through the dataplane's current serializer. */ +import { setTimeout } from 'node:timers/promises'; +import { realpathSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const DATAPLANE_CONFIG_URL = + 'http://dataplane:4445/contextforge-rs/admin/userconfigs'; +const DATAPLANE_TOKEN_URL = + 'http://dataplane:4445/contextforge-rs/admin/tokens'; +/** Discover the pinned fixture instead of maintaining a second, incomplete catalog. */ +export async function fixtureCatalog(backendUrl, protocolVersion) { + let requestId = 0; + let sessionId; + const modern = protocolVersion >= '2026-07-28'; + const clientInfo = { name: 'cf-integration-config', version: '1.0' }; + async function rpc(method, params = {}, notification = false) { + if (modern) params = { ...params, _meta: { + 'io.modelcontextprotocol/protocolVersion': protocolVersion, + 'io.modelcontextprotocol/clientInfo': clientInfo, + 'io.modelcontextprotocol/clientCapabilities': {}, + } }; + const id = notification ? undefined : ++requestId; + const response = await fetch(backendUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(method === 'initialize' ? {} : { 'mcp-protocol-version': protocolVersion }), + ...(modern ? { 'mcp-method': method } : {}), + ...(sessionId ? { 'mcp-session-id': sessionId } : {}), + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) throw new Error(`fixture ${method} failed: HTTP ${response.status}`); + sessionId = response.headers.get('mcp-session-id') ?? sessionId; + if (notification) { await response.body?.cancel(); return; } + const body = await response.text(); + const messages = response.headers.get('content-type')?.startsWith('text/event-stream') + ? body.split(/\r?\n\r?\n/).map((event) => + event.split(/\r?\n/).filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()).join('\n')) + .filter((data) => data.trim()).map((data) => JSON.parse(data)) + : [JSON.parse(body)]; + const message = messages.find((message) => message.id === id); + if (!message || message.error || !message.result) { + throw new Error(`fixture ${method} did not return a successful result`); + } + return message.result; + } + async function list(method, key) { + const items = []; + const cursors = new Set(); + let cursor; + do { + const page = await rpc(method, cursor === undefined ? {} : { cursor }); + if (!Array.isArray(page[key])) throw new Error(`fixture ${method} has no ${key} array`); + items.push(...page[key]); + cursor = page.nextCursor ?? undefined; + if (cursor !== undefined && (typeof cursor !== 'string' || cursors.has(cursor))) { + throw new Error(`fixture ${method} returned an invalid or repeated cursor`); + } + cursors.add(cursor); + } while (cursor !== undefined); + return items; + } + try { + if (modern) await rpc('server/discover'); + else { + await rpc('initialize', { protocolVersion, capabilities: {}, clientInfo }); + await rpc('notifications/initialized', {}, true); + } + const tools = await list('tools/list', 'tools'); + if (!tools.length || tools.some((tool) => !tool.name || !tool.inputSchema + || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema))) { + throw new Error('fixture tools must include names and input schemas'); + } + return { + tools: tools.map((tool) => tool.name), + toolSchemas: Object.fromEntries(tools.map((tool) => [tool.name, tool.inputSchema])), + resources: (await list('resources/list', 'resources')).map((resource) => resource.uri), + resourceTemplates: (await list('resources/templates/list', 'resourceTemplates')).map((resource) => resource.uriTemplate), + prompts: (await list('prompts/list', 'prompts')).map((prompt) => prompt.name), + }; + } finally { + if (sessionId) await fetch(backendUrl, { + method: 'DELETE', + headers: { 'mcp-session-id': sessionId, 'mcp-protocol-version': protocolVersion }, + signal: AbortSignal.timeout(10000), + }).then((response) => response.body?.cancel()); + } +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function tokenSubject(token) { + const parts = token.split('.'); + if (parts.length !== 3) fail('MCP_CONFORMANCE_TOKEN is not a JWT'); + let claims; + try { + claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + } catch { + fail('MCP_CONFORMANCE_TOKEN has invalid claims'); + } + if (typeof claims.sub !== 'string' || claims.sub.length === 0) { + fail('MCP_CONFORMANCE_TOKEN has no string subject'); + } + return claims.sub; +} + +function stringArray(value, label) { + let parsed; + try { + parsed = JSON.parse(value); + } catch { + fail(`${label} must be valid JSON`); + } + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string' || !item)) { + fail(`${label} must be a JSON string array`); + } + return parsed; +} + +function routes(names, backendName) { + return Object.fromEntries( + names.map((name) => [name, { backend_name: backendName, upstream_name: name }]), + ); +} + +function config(serverId, backendUrl, protocolVersion, catalogs) { + const backendName = 'conformance-backend'; + return { + virtual_hosts: { + [serverId]: { + backends: { + [backendName]: { + name: backendName, + url: backendUrl, + mcp_protocol_version: protocolVersion, + passthrough_headers: [], + add_headers: {}, + remove_headers: [], + completion: {}, + tool_schemas: catalogs.toolSchemas, + }, + }, + tools: routes(catalogs.tools, backendName), + resources: routes(catalogs.resources, backendName), + resource_templates: routes(catalogs.resourceTemplates, backendName), + prompts: routes(catalogs.prompts, backendName), + }, + }, + }; +} + +async function publish(subject, body) { + const endpoint = `${DATAPLANE_CONFIG_URL}/${encodeURIComponent(subject)}`; + let lastError = 'dataplane did not respond'; + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(2000), + }); + if (response.status === 202) return; + lastError = `HTTP ${response.status}: ${(await response.text()).slice(0, 512)}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await setTimeout(500); + } + fail(`dataplane config serializer was unavailable: ${lastError}`); +} + +async function issueToken(tenantId, userId) { + const endpoint = `${DATAPLANE_TOKEN_URL}/${encodeURIComponent(tenantId)}/${encodeURIComponent(userId)}`; + let lastError = 'dataplane did not respond'; + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + const response = await fetch(endpoint, { signal: AbortSignal.timeout(2000) }); + const body = await response.text(); + if (response.ok && body.split('.').length === 3) return body; + lastError = `HTTP ${response.status}: ${body.slice(0, 512)}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await setTimeout(500); + } + fail(`dataplane token helper was unavailable: ${lastError}`); +} + +async function main() { + const [mode, ...args] = process.argv.slice(2); + if (mode === 'token') { + const [tenantId, userId] = args; + if (!tenantId) fail('tenant-id must not be empty'); + if (!userId) fail('user-id must not be empty'); + process.stdout.write(`${await issueToken(tenantId, userId)}\n`); + return; + } + if (!['fixture', 'client'].includes(mode)) { + fail('mode must be token, fixture, or client'); + } + const [serverId, backendUrl, protocolVersion, toolNamesJson] = args; + if (!serverId) fail('virtual-host-id must not be empty'); + try { + const parsed = new URL(backendUrl); + if (!['http:', 'https:'].includes(parsed.protocol)) fail('backend-url must use HTTP(S)'); + } catch { + fail('backend-url must be an absolute HTTP(S) URL'); + } + if (!protocolVersion) fail('protocol-version must not be empty'); + const token = process.env.MCP_CONFORMANCE_TOKEN; + if (!token) fail('MCP_CONFORMANCE_TOKEN is required'); + + const tools = mode === 'client' ? stringArray(toolNamesJson, 'tool-names-json') : undefined; + const catalogs = mode === 'fixture' + ? await fixtureCatalog(backendUrl, protocolVersion) + : { + tools, + toolSchemas: Object.fromEntries(tools.map((name) => [name, {}])), + resources: [], + resourceTemplates: [], + prompts: [], + }; + await publish( + tokenSubject(token), + config(serverId, backendUrl, protocolVersion, catalogs), + ); + process.stdout.write(`${JSON.stringify(catalogs.tools)}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main(); diff --git a/scripts/prepare_standalone_config.py b/scripts/prepare_standalone_config.py deleted file mode 100644 index 456d17d..0000000 --- a/scripts/prepare_standalone_config.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -"""Publish an isolated load fixture through the dataplane's own serializer.""" - -from __future__ import annotations - -import base64 -import json -import os -import sys -from urllib.error import HTTPError, URLError -from urllib.parse import quote -from urllib.request import Request, urlopen - -DATAPLANE_CONFIG_URL = ( - "http://dataplane:4445/contextforge-rs/admin/userconfigs/{subject}" -) -BACKEND_URL = "http://mcp_conformance_server:3000/mcp" -BACKEND_NAME = "standalone-load" -TOOL_NAMES = ["test_simple_text"] - - -def token_subject(token: str) -> str: - parts = token.split(".") - if len(parts) != 3: - raise SystemExit("MCPGATEWAY_BEARER_TOKEN is not a JWT") - payload = parts[1] + ("=" * (-len(parts[1]) % 4)) - try: - claims = json.loads(base64.urlsafe_b64decode(payload)) - except (ValueError, json.JSONDecodeError) as error: - raise SystemExit("MCPGATEWAY_BEARER_TOKEN has invalid claims") from error - subject = claims.get("sub") - if not isinstance(subject, str) or not subject: - raise SystemExit("MCPGATEWAY_BEARER_TOKEN has no string subject") - return subject - - -def prepare_config(server_id: str, protocol_version: str) -> dict: - if not server_id: - raise SystemExit("virtual-host-id must not be empty") - routes = { - name: {"backend_name": BACKEND_NAME, "upstream_name": name} - for name in TOOL_NAMES - } - return { - "virtual_hosts": { - server_id: { - "backends": { - BACKEND_NAME: { - "name": BACKEND_NAME, - "url": BACKEND_URL, - "mcp_protocol_version": protocol_version, - "passthrough_headers": [], - "add_headers": {}, - "remove_headers": [], - "tool_name_aliases": [ - { - "downstream_prefixed_name": name, - "upstream_name": name, - } - for name in TOOL_NAMES - ], - "resource_uri_aliases": [], - "prompt_name_aliases": [], - "completion": {}, - "tool_schemas": {name: {} for name in TOOL_NAMES}, - } - }, - # Published dataplane images can trail the control-plane schema. - # The dataplane serializer ignores fields it does not understand, - # so publish both routing shapes while the two releases overlap. - "tools": routes, - "resources": {}, - "resource_templates": {}, - "prompts": {}, - } - } - } - - -def publish_config(subject: str, config: dict) -> None: - endpoint = DATAPLANE_CONFIG_URL.format(subject=quote(subject, safe="")) - request = Request( - endpoint, - data=json.dumps(config).encode(), - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urlopen(request, timeout=30) as response: - if response.status != 202: - raise SystemExit( - f"dataplane config serializer returned HTTP {response.status}" - ) - except HTTPError as error: - detail = error.read(512).decode(errors="replace").strip() - raise SystemExit( - f"dataplane config serializer returned HTTP {error.code}: {detail}" - ) from error - except URLError as error: - raise SystemExit(f"dataplane config serializer is unavailable: {error.reason}") from error - - -def main() -> None: - import msgpack - import redis - - if len(sys.argv) != 3: - raise SystemExit( - "usage: prepare_standalone_config.py " - ) - server_id, protocol_version = sys.argv[1:] - token = os.environ.get("MCPGATEWAY_BEARER_TOKEN", "") - if not token: - raise SystemExit("MCPGATEWAY_BEARER_TOKEN is required") - subject = token_subject(token) - key = msgpack.dumps(("UserConfig", subject), use_bin_type=True) - client = redis.Redis.from_url( - os.environ.get("REDIS_URL", "redis://redis:6379/0"), - decode_responses=False, - ) - publish_config(subject, prepare_config(server_id, protocol_version)) - if client.ttl(key) != -1: - raise SystemExit("dataplane serializer did not persist the Redis snapshot") - print(json.dumps(TOOL_NAMES, separators=(",", ":"))) - - -if __name__ == "__main__": - main() diff --git a/scripts/standalone/generate_auth_key.mjs b/scripts/standalone/generate_auth_key.mjs new file mode 100644 index 0000000..70e6c86 --- /dev/null +++ b/scripts/standalone/generate_auth_key.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/** Generate the ephemeral RSA key used by the standalone dataplane stack. */ + +import { generateKeyPairSync } from 'node:crypto'; +import { chmodSync, existsSync, writeFileSync } from 'node:fs'; + +const [outputPath] = process.argv.slice(2); +if (!outputPath) { + process.stderr.write('output-path is required\n'); + process.exit(1); +} + +if (existsSync(outputPath)) { + chmodSync(outputPath, 0o600); + process.exit(0); +} + +const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}); + +writeFileSync(outputPath, privateKey, { encoding: 'utf8', mode: 0o600 }); +chmodSync(outputPath, 0o600); diff --git a/src/app.rs b/src/app.rs index 6bcee5e..54cc65c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,7 @@ use crate::conformance::profile::{ }; use crate::conformance::results::{ConformanceServerEra, SemanticLane}; use crate::infrastructure::StackMode; -use crate::infrastructure::config::Environment; +use crate::infrastructure::config::{ConfigRequirements, Environment}; use crate::performance::LoadRequest; use anyhow::{Result, bail}; @@ -27,6 +27,7 @@ pub(crate) enum Action { Stack(StackAction), Probe { topology: StackMode, + standalone: bool, protocol_version: ProtocolVersion, }, Load(ResolvedLoadArgs), @@ -47,9 +48,9 @@ impl Action { match self { Self::Stack(StackAction::Up { .. }) => "stack up", Self::Stack(StackAction::Down { .. }) => "stack down", - Self::Stack(StackAction::Status(_)) => "stack status", + Self::Stack(StackAction::Status { .. }) => "stack status", Self::Stack(StackAction::Logs { .. }) => "stack logs", - Self::Stack(StackAction::Config(_)) => "stack config", + Self::Stack(StackAction::Config { .. }) => "stack config", Self::Probe { .. } => "probe", Self::Load(_) => "load test", Self::Live { .. } => "live tests", @@ -70,18 +71,18 @@ impl Action { Self::Stack(action) => action.startup_summary(), Self::Probe { topology, + standalone, protocol_version, } | Self::Debug(DebugAction::Inspect { topology, + standalone, protocol_version, .. - }) => lane_and_protocol(*topology, protocol_version), + }) => target_summary(*topology, *standalone, protocol_version), Self::Load(args) => { - let mut summary = lane_and_protocol(args.topology, &args.protocol_version); - if args.standalone { - summary.push_str("\nControl plane: disabled during load"); - } + let mut summary = + target_summary(args.topology, args.standalone, &args.protocol_version); if args.observability { summary.push_str("\nObservability: ClickStack enabled during load"); } @@ -97,20 +98,31 @@ impl Action { ), Self::Conformance(ConformanceAction::Run { lanes, + standalone, client_eras, server_eras, .. - }) => format!( - "Lane: {}\nClient era: {}\nServer era: {}", - join_lane_labels(lanes), - join_client_eras(client_eras), - join_server_eras(server_eras), - ), + }) => { + let mut summary = format!( + "Lane: {}\nClient era: {}\nServer era: {}", + join_lane_labels(lanes), + join_client_eras(client_eras), + join_server_eras(server_eras), + ); + if *standalone { + summary.push_str("\nControl plane: disabled; Redis config: mocked"); + } + summary + } Self::Conformance(ConformanceAction::Report { .. }) => String::from( "Lane: recorded conformance results\nClient era: recorded conformance results\nServer era: recorded conformance results", ), - Self::Debug(DebugAction::Token { .. }) => { - String::from("Lane: not applicable (token only)") + Self::Debug(DebugAction::Token { standalone, .. }) => { + if *standalone { + String::from("Lane: external\nControl plane: disabled; Redis config: mocked") + } else { + String::from("Lane: not applicable (token only)") + } } Self::Ci(CiAction::PrepareImage { .. }) => { String::from("CI operation: prepare prebuilt image") @@ -133,15 +145,34 @@ impl Action { ) } - /// Returns whether this operation needs Compose overlays or runtime scripts. + /// Filesystem resources needed by the resolved operation. #[must_use] - pub(crate) const fn requires_runtime_assets(&self) -> bool { - !matches!( - self, + pub(crate) fn config_requirements(&self) -> ConfigRequirements { + let standalone = match self { Self::Conformance(ConformanceAction::Report { .. }) - | Self::Debug(DebugAction::Token { .. }) - | Self::Ci(_) - ) + | Self::Ci(_) + | Self::Debug(DebugAction::Token { + standalone: false, .. + }) => { + return ConfigRequirements::ReadOnly; + } + Self::Probe { standalone, .. } + | Self::Debug(DebugAction::Inspect { standalone, .. }) + | Self::Debug(DebugAction::Token { standalone, .. }) + | Self::Conformance(ConformanceAction::Run { standalone, .. }) + | Self::Stack(StackAction::Up { standalone, .. }) + | Self::Stack(StackAction::Down { standalone, .. }) + | Self::Stack(StackAction::Status { standalone, .. }) + | Self::Stack(StackAction::Logs { standalone, .. }) + | Self::Stack(StackAction::Config { standalone, .. }) => *standalone, + Self::Load(args) => args.standalone, + Self::Live { .. } => false, + }; + if standalone { + ConfigRequirements::StandaloneRuntime + } else { + ConfigRequirements::Runtime + } } } @@ -149,9 +180,9 @@ impl StackAction { fn startup_summary(&self) -> String { let lane = match self { Self::Up { topology, .. } - | Self::Status(topology) + | Self::Status { topology, .. } | Self::Logs { topology, .. } - | Self::Config(topology) => topology.lane_label().to_owned(), + | Self::Config { topology, .. } => topology.lane_label().to_owned(), Self::Down { lane, .. } => match lane { LaneSelection::Builtin => StackMode::Controlplane.lane_label().to_owned(), LaneSelection::External => StackMode::Dataplane.lane_label().to_owned(), @@ -162,22 +193,41 @@ impl StackAction { ), }, }; - if matches!(self, Self::Up { .. }) { - format!( - "Lane: {lane}\nProtocol version: {}", - ProtocolVersion::default() - ) - } else { - format!("Lane: {lane}") - } + let mut summary = match self { + Self::Up { + protocol_version, .. + } => format!("Lane: {lane}\nProtocol version: {protocol_version}"), + _ => format!("Lane: {lane}"), + }; + let standalone = match self { + Self::Up { standalone, .. } + | Self::Down { standalone, .. } + | Self::Status { standalone, .. } + | Self::Logs { standalone, .. } + | Self::Config { standalone, .. } => *standalone, + }; + append_standalone_summary(&mut summary, standalone); + summary } } -fn lane_and_protocol(topology: StackMode, protocol_version: &ProtocolVersion) -> String { - format!( +fn target_summary( + topology: StackMode, + standalone: bool, + protocol_version: &ProtocolVersion, +) -> String { + let mut summary = format!( "Lane: {}\nProtocol version: {protocol_version}", topology.lane_label() - ) + ); + append_standalone_summary(&mut summary, standalone); + summary +} + +fn append_standalone_summary(summary: &mut String, standalone: bool) { + if standalone { + summary.push_str("\nControl plane: disabled; Redis config: mocked"); + } } fn join_lane_labels(lanes: &[SemanticLane]) -> String { @@ -216,18 +266,28 @@ fn join_server_eras(server_eras: &[ConformanceServerEra]) -> String { pub(crate) enum StackAction { Up { topology: StackMode, + protocol_version: ProtocolVersion, fresh: bool, + standalone: bool, }, Down { lane: LaneSelection, volumes: bool, + standalone: bool, + }, + Status { + topology: StackMode, + standalone: bool, }, - Status(StackMode), Logs { topology: StackMode, services: Vec, + standalone: bool, + }, + Config { + topology: StackMode, + standalone: bool, }, - Config(StackMode), } /// Fully resolved load-test options. @@ -245,6 +305,7 @@ pub(crate) struct ResolvedLoadArgs { pub(crate) enum ConformanceAction { Run { lanes: Vec, + standalone: bool, client_eras: Vec, client_versions: Vec, server_eras: Vec, @@ -264,6 +325,7 @@ pub(crate) enum ConformanceAction { pub(crate) enum DebugAction { Inspect { topology: StackMode, + standalone: bool, protocol_version: ProtocolVersion, method: String, server_id: Option, @@ -271,6 +333,7 @@ pub(crate) enum DebugAction { Token { kind: TokenKind, server_id: Option, + standalone: bool, }, } @@ -298,12 +361,17 @@ pub(crate) enum CiAction { /// Returns an error when a command needs `CF_MCP_LANE` and its value is neither /// `builtin` nor `external`. pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result { + let standalone = cli.standalone; match cli.command { - Command::Stack(args) => resolve_stack(args.command, environment).map(Action::Stack), + Command::Stack(args) => { + resolve_stack(args.command, environment, standalone).map(Action::Stack) + } Command::Probe(args) => { let topology = resolve_lane(args.lane, environment)?; + validate_standalone_lane(standalone, topology)?; Ok(Action::Probe { topology, + standalone, protocol_version: resolve_protocol_version( args.protocol_version, environment, @@ -313,9 +381,7 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result { let topology = resolve_lane(args.target.lane, environment)?; - if args.standalone && topology != StackMode::Dataplane { - bail!("--standalone requires --lane external"); - } + validate_standalone_lane(standalone, topology)?; Ok(Action::Load(ResolvedLoadArgs { topology, protocol_version: resolve_protocol_version( @@ -323,7 +389,7 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Result { let lane = resolve_live_lane(args.target.lane, environment)?; + if standalone { + bail!( + "live suites require the control plane; use probe --standalone for isolated external route checks" + ); + } if lane == SemanticLane::FixtureDirect && args.group != LiveGroup::Protocol { bail!("--lane fixture-direct requires --group protocol"); } @@ -351,8 +422,17 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Ok(Action::Conformance(match args.command { ConformanceCommand::Run(args) => { let (client_eras, client_versions) = resolve_client_eras(args.client_era); + let lanes = if standalone && args.lane.is_empty() { + vec![SemanticLane::ExternalDataPlane] + } else { + resolve_lanes(args.lane.into_iter().map(Into::into)) + }; + if standalone && lanes != [SemanticLane::ExternalDataPlane] { + bail!("--standalone requires --lane external as the only lane"); + } ConformanceAction::Run { - lanes: resolve_lanes(args.lane.into_iter().map(Into::into)), + lanes, + standalone, client_eras, client_versions, server_eras: resolve_server_eras(args.server_era), @@ -362,16 +442,23 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result ConformanceAction::Report { - results_dir: args.results_dir, - output_dir: args.output_dir, - }, + ConformanceCommand::Report(args) => { + if standalone { + bail!("--standalone is not valid for conformance report"); + } + ConformanceAction::Report { + results_dir: args.results_dir, + output_dir: args.output_dir, + } + } })), Command::Debug(args) => Ok(Action::Debug(match args.command { DebugCommand::Inspect(args) => { let topology = resolve_lane(args.target.lane, environment)?; + validate_standalone_lane(standalone, topology)?; DebugAction::Inspect { topology, + standalone, protocol_version: resolve_protocol_version( args.target.protocol_version, environment, @@ -388,36 +475,42 @@ pub(crate) fn resolve_action(cli: Cli, environment: &Environment) -> Result Ok(Action::Ci(match args.command { - CiCommand::PrepareImage(args) => { - let mut components = args.binary.components(); - if !matches!(components.next(), Some(Component::Normal(_))) - || components.next().is_some() - { - bail!("--binary must be one filename at the artifact root"); - } - let repository = args - .repository - .or_else(|| environment_utf8(environment, "GITHUB_REPOSITORY")) - .filter(|value| !value.is_empty()) - .ok_or_else(|| anyhow::anyhow!("set --repository or GITHUB_REPOSITORY"))?; - CiAction::PrepareImage { - artifact: args.artifact, - binary: args.binary, - image: args.image, - repository, - revision: args.revision, - dockerfile: args.dockerfile, - target: args.target, - download_dir: args.download_dir, - } + Command::Ci(args) => { + if standalone { + bail!("--standalone is not valid for CI commands"); } - CiCommand::PrepareRelease => CiAction::PrepareRelease, - CiCommand::SelectRelease => CiAction::SelectRelease, - })), + Ok(Action::Ci(match args.command { + CiCommand::PrepareImage(args) => { + let mut components = args.binary.components(); + if !matches!(components.next(), Some(Component::Normal(_))) + || components.next().is_some() + { + bail!("--binary must be one filename at the artifact root"); + } + let repository = args + .repository + .or_else(|| environment_utf8(environment, "GITHUB_REPOSITORY")) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("set --repository or GITHUB_REPOSITORY"))?; + CiAction::PrepareImage { + artifact: args.artifact, + binary: args.binary, + image: args.image, + repository, + revision: args.revision, + dockerfile: args.dockerfile, + target: args.target, + download_dir: args.download_dir, + } + } + CiCommand::PrepareRelease => CiAction::PrepareRelease, + CiCommand::SelectRelease => CiAction::SelectRelease, + })) + } } } @@ -461,29 +554,76 @@ fn resolve_protocol_version( .map_err(|error| anyhow::anyhow!("invalid {PROTOCOL_VERSION_ENV}: {error}")) } -fn resolve_stack(command: StackCommand, environment: &Environment) -> Result { +fn resolve_stack( + command: StackCommand, + environment: &Environment, + standalone: bool, +) -> Result { match command { - StackCommand::Up(args) => Ok(StackAction::Up { - topology: resolve_lane(args.lane, environment)?, - fresh: args.fresh, - }), - StackCommand::Down(args) => Ok(StackAction::Down { - lane: args.lane.unwrap_or(LaneSelection::All), - volumes: args.volumes, - }), + StackCommand::Up(args) => { + let topology = resolve_lane(args.target.lane, environment)?; + validate_standalone_lane(standalone, topology)?; + Ok(StackAction::Up { + topology, + protocol_version: resolve_protocol_version( + args.target.protocol_version, + environment, + ProtocolVersion::default(), + )?, + fresh: args.fresh, + standalone, + }) + } + StackCommand::Down(args) => { + let lane = args.lane.unwrap_or(if standalone { + LaneSelection::External + } else { + LaneSelection::All + }); + if standalone && lane != LaneSelection::External { + bail!("--standalone requires --lane external"); + } + Ok(StackAction::Down { + lane, + volumes: args.volumes, + standalone, + }) + } StackCommand::Status(args) => { - Ok(StackAction::Status(resolve_lane(args.lane, environment)?)) + let topology = resolve_lane(args.lane, environment)?; + validate_standalone_lane(standalone, topology)?; + Ok(StackAction::Status { + topology, + standalone, + }) + } + StackCommand::Logs(args) => { + let topology = resolve_lane(args.lane, environment)?; + validate_standalone_lane(standalone, topology)?; + Ok(StackAction::Logs { + topology, + services: args.services, + standalone, + }) } - StackCommand::Logs(args) => Ok(StackAction::Logs { - topology: resolve_lane(args.lane, environment)?, - services: args.services, - }), StackCommand::Config(args) => { - Ok(StackAction::Config(resolve_lane(args.lane, environment)?)) + let topology = resolve_lane(args.lane, environment)?; + validate_standalone_lane(standalone, topology)?; + Ok(StackAction::Config { + topology, + standalone, + }) } } } +fn validate_standalone_lane(standalone: bool, topology: StackMode) -> Result<()> { + if standalone && topology != StackMode::Dataplane { + bail!("--standalone requires --lane external"); + } + Ok(()) +} + fn resolve_lanes(lanes: impl IntoIterator) -> Vec { let selected = lanes.into_iter().collect::>(); let all = [ diff --git a/src/app_tests.rs b/src/app_tests.rs index c5da693..aaa3ea1 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -7,7 +7,7 @@ use cf_integration::app::{ use cf_integration::cli::{Cli, LaneSelection, LiveGroup, ProtocolVersion, TokenKind}; use cf_integration::conformance::results::{ConformanceServerEra, SemanticLane}; use cf_integration::infrastructure::StackMode; -use cf_integration::infrastructure::config::Environment; +use cf_integration::infrastructure::config::{ConfigRequirements, Environment}; use cf_integration::performance::LoadRequest; use clap::Parser; @@ -73,7 +73,7 @@ fn ci_image_preparation_is_read_only_until_execution() { assert!(matches!(&action, Action::Ci(CiAction::PrepareImage { .. }))); assert_eq!(action.description(), "prepare prebuilt CI image"); - assert!(!action.requires_runtime_assets()); + assert_eq!(action.config_requirements(), ConfigRequirements::ReadOnly); } #[test] @@ -212,6 +212,7 @@ fn lane_precedence_is_cli_then_environment_then_external() { action(&["cf-integration", "probe"], &[]), Action::Probe { topology: StackMode::Dataplane, + standalone: false, protocol_version: ProtocolVersion::default(), } ); @@ -219,6 +220,7 @@ fn lane_precedence_is_cli_then_environment_then_external() { action(&["cf-integration", "probe"], &[("CF_MCP_LANE", "builtin")],), Action::Probe { topology: StackMode::Controlplane, + standalone: false, protocol_version: ProtocolVersion::default(), } ); @@ -236,6 +238,7 @@ fn lane_precedence_is_cli_then_environment_then_external() { ), Action::Probe { topology: StackMode::Dataplane, + standalone: false, protocol_version: ProtocolVersion::Legacy, } ); @@ -284,7 +287,9 @@ fn stack_actions_resolve_freshness_and_volume_cleanup() { ), Action::Stack(StackAction::Up { topology: StackMode::Controlplane, + protocol_version: ProtocolVersion::Modern, fresh: true, + standalone: false, }) ); assert_eq!( @@ -292,8 +297,38 @@ fn stack_actions_resolve_freshness_and_volume_cleanup() { Action::Stack(StackAction::Down { lane: LaneSelection::All, volumes: true, + standalone: false, + }) + ); +} + +#[test] +fn stack_up_resolves_the_semantic_protocol_mode() { + let resolved = action( + &[ + "cf-integration", + "stack", + "up", + "--standalone", + "--protocol-version", + "legacy", + ], + &[], + ); + + assert_eq!( + resolved, + Action::Stack(StackAction::Up { + topology: StackMode::Dataplane, + protocol_version: ProtocolVersion::Legacy, + fresh: false, + standalone: true, }) ); + assert_eq!( + resolved.startup_summary(), + "Lane: external\nProtocol version: legacy\nControl plane: disabled; Redis config: mocked" + ); } #[test] @@ -361,7 +396,7 @@ fn standalone_load_is_external_only() { ); assert_eq!( standalone.startup_summary(), - "Lane: external\nProtocol version: modern\nControl plane: disabled during load" + "Lane: external\nProtocol version: modern\nControl plane: disabled; Redis config: mocked" ); let cli = Cli::try_parse_from([ @@ -466,6 +501,7 @@ fn conformance_defaults_to_all_three_ordered_lanes() { SemanticLane::BuiltInDataPlane, SemanticLane::ExternalDataPlane, ], + standalone: false, client_eras: vec![ConformanceServerEra::Modern], client_versions: vec!["2026-07-28".to_owned()], server_eras: vec![ConformanceServerEra::Legacy, ConformanceServerEra::Modern], @@ -513,6 +549,7 @@ fn conformance_lanes_are_deduplicated_and_normalized() { ), Action::Conformance(ConformanceAction::Run { lanes: vec![SemanticLane::FixtureDirect, SemanticLane::ExternalDataPlane,], + standalone: false, client_eras: vec![ConformanceServerEra::Legacy, ConformanceServerEra::Modern], client_versions: vec![ "2025-06-18".to_owned(), @@ -528,6 +565,61 @@ fn conformance_lanes_are_deduplicated_and_normalized() { ); } +#[test] +fn standalone_conformance_is_external_only() { + let standalone = action( + &[ + "cf-integration", + "conformance", + "run", + "--lane", + "external", + "--standalone", + ], + &[], + ); + let Action::Conformance(ConformanceAction::Run { + lanes, + standalone: enabled, + .. + }) = &standalone + else { + panic!("expected conformance run"); + }; + assert_eq!(lanes, &[SemanticLane::ExternalDataPlane]); + assert!(enabled); + assert!( + standalone + .startup_summary() + .contains("Control plane: disabled; Redis config: mocked") + ); + + let implied = action( + &["cf-integration", "conformance", "run", "--standalone"], + &[], + ); + let Action::Conformance(ConformanceAction::Run { lanes, .. }) = implied else { + panic!("expected conformance run"); + }; + assert_eq!(lanes, [SemanticLane::ExternalDataPlane]); + + let arguments = [ + "cf-integration", + "conformance", + "run", + "--lane", + "builtin", + "--standalone", + ]; + let cli = Cli::try_parse_from(arguments).expect("CLI should parse standalone mode"); + let error = resolve_action(cli, &Environment::new()) + .expect_err("standalone conformance must reject non-external lane selections"); + assert_eq!( + error.to_string(), + "--standalone requires --lane external as the only lane" + ); +} + #[test] fn conformance_report_is_official_only() { assert_eq!( @@ -551,7 +643,7 @@ fn conformance_report_is_official_only() { } #[test] -fn only_report_and_token_actions_skip_runtime_assets() { +fn only_report_and_controlplane_token_actions_skip_runtime_assets() { let report = action(&["cf-integration", "conformance", "report"], &[]); let token = action( &["cf-integration", "debug", "token", "--kind", "admin"], @@ -561,10 +653,49 @@ fn only_report_and_token_actions_skip_runtime_assets() { &["cf-integration", "stack", "status", "--lane", "external"], &[], ); + let standalone_token = action( + &[ + "cf-integration", + "debug", + "token", + "--kind", + "scoped", + "--standalone", + ], + &[], + ); + + assert_eq!(report.config_requirements(), ConfigRequirements::ReadOnly); + assert_eq!(token.config_requirements(), ConfigRequirements::ReadOnly); + assert_eq!(stack.config_requirements(), ConfigRequirements::Runtime); + assert_eq!( + standalone_token.config_requirements(), + ConfigRequirements::StandaloneRuntime + ); +} - assert!(!report.requires_runtime_assets()); - assert!(!token.requires_runtime_assets()); - assert!(stack.requires_runtime_assets()); +#[test] +fn standalone_workflows_do_not_request_controlplane_secrets() { + for command in [ + vec!["stack", "up"], + vec!["stack", "status"], + vec!["stack", "config"], + vec!["stack", "down"], + vec!["stack", "logs"], + vec!["probe"], + vec!["load"], + vec!["conformance", "run"], + vec!["debug", "token", "--kind", "scoped"], + ] { + let arguments: Vec<_> = ["cf-integration", "--standalone"] + .into_iter() + .chain(command) + .collect(); + assert_eq!( + action(&arguments, &[]).config_requirements(), + ConfigRequirements::StandaloneRuntime + ); + } } #[test] @@ -585,6 +716,7 @@ fn debug_token_and_inspector_remain_explicit_non_gate_operations() { Action::Debug(DebugAction::Token { kind: TokenKind::Scoped, server_id: Some("server-1".to_owned()), + standalone: false, }) ); assert_eq!( @@ -604,6 +736,7 @@ fn debug_token_and_inspector_remain_explicit_non_gate_operations() { ), Action::Debug(DebugAction::Inspect { topology: StackMode::Controlplane, + standalone: false, protocol_version: ProtocolVersion::Legacy, method: "prompts/list".to_owned(), server_id: None, @@ -627,3 +760,27 @@ fn admin_token_rejects_a_server_scope() { .expect_err("admin token server restriction must not be discarded"); assert!(error.to_string().contains("only valid with --kind scoped")); } + +#[test] +fn standalone_live_rejects_every_group_before_runtime_setup() { + for group in ["mcp", "rbac", "protocol", "all"] { + let cli = Cli::try_parse_from([ + "cf-integration", + "live", + "--lane", + "external", + "--standalone", + "--group", + group, + ]) + .expect("CLI parses before workflow validation"); + let error = + resolve_action(cli, &Environment::new()).expect_err("standalone live is unsupported"); + assert!( + error + .to_string() + .contains("live suites require the control plane") + ); + assert!(error.to_string().contains("probe --standalone")); + } +} diff --git a/src/cli.rs b/src/cli.rs index 5f30daa..f79f75f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -79,6 +79,10 @@ fn parse_run_time(value: &str) -> Result { #[derive(Debug, Clone, PartialEq, Parser)] #[command(name = "cf-integration", version, arg_required_else_help = true)] pub(crate) struct Cli { + /// Run the external dataplane with mocked Redis and no control plane. + #[arg(long, global = true)] + pub(crate) standalone: bool, + /// Workflow to run. #[command(subcommand)] pub(crate) command: Command, @@ -185,9 +189,9 @@ pub(crate) enum StackCommand { /// Options for starting one stack. #[derive(Debug, Clone, PartialEq, Eq, Args)] pub(crate) struct StackUpArgs { - /// Execution lane; defaults to CF_MCP_LANE, then external. - #[arg(long, value_enum)] - pub(crate) lane: Option, + /// Routed lane and protocol-version selection. + #[command(flatten)] + pub(crate) target: RoutedWorkflowTargetArgs, /// Remove existing stack volumes before starting. #[arg(long)] @@ -286,10 +290,6 @@ pub(crate) struct LoadArgs { #[command(flatten)] pub(crate) target: RoutedWorkflowTargetArgs, - /// Stop the control plane during an external-dataplane load test. - #[arg(long)] - pub(crate) standalone: bool, - /// Enable the ClickStack observability UI during the load test. #[arg(long)] pub(crate) observability: bool, diff --git a/src/cli_public_tests.rs b/src/cli_public_tests.rs index 7d9d9b2..1d0566f 100644 --- a/src/cli_public_tests.rs +++ b/src/cli_public_tests.rs @@ -158,7 +158,8 @@ fn stack_up_and_down_make_destructive_behavior_explicit() { else { panic!("expected stack up") }; - assert_eq!(up.lane, Some(CliRoutedLane::External)); + assert_eq!(up.target.lane, Some(CliRoutedLane::External)); + assert_eq!(up.target.protocol_version, None); assert!(up.fresh); let Command::Stack(StackArgs { @@ -207,7 +208,6 @@ fn stack_logs_preserve_service_arguments() { fn load_keeps_validated_locust_settings() { let Command::Load(LoadArgs { target, - standalone, observability, users, spawn_rate, @@ -229,7 +229,6 @@ fn load_keeps_validated_locust_settings() { }; assert_eq!(target.lane, None); assert_eq!(target.protocol_version, None); - assert!(!standalone); assert!(!observability); assert_eq!(users, Some(2)); assert_eq!(spawn_rate, Some(0.5)); @@ -243,20 +242,19 @@ fn load_keeps_validated_locust_settings() { #[test] fn load_accepts_standalone_external_dataplane_mode() { - let Command::Load(args) = parse(&[ + let cli = parse(&[ "cf-integration", "load", "--lane", "external", "--standalone", - ]) - .command - else { + ]); + assert!(cli.standalone); + let Command::Load(args) = cli.command else { panic!("expected load") }; assert_eq!(args.target.lane, Some(CliRoutedLane::External)); - assert!(args.standalone); } #[test] @@ -382,6 +380,20 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { } let common = ["--lane", "builtin", "--protocol-version", "legacy"]; + let Command::Stack(StackArgs { + command: StackCommand::Up(stack), + }) = parse( + &["cf-integration", "stack", "up"] + .into_iter() + .chain(common) + .collect::>(), + ) + .command + else { + panic!("expected stack-up workflow") + }; + assert_routed_target(&stack.target); + let Command::Probe(probe) = parse( &["cf-integration", "probe"] .into_iter() @@ -435,6 +447,30 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { assert_routed_target(&inspect.target); } +#[test] +fn standalone_is_global_across_operational_commands() { + for arguments in [ + vec!["cf-integration", "stack", "up", "--standalone"], + vec!["cf-integration", "stack", "status", "--standalone"], + vec!["cf-integration", "probe", "--standalone"], + vec!["cf-integration", "load", "--standalone"], + vec!["cf-integration", "conformance", "run", "--standalone"], + vec![ + "cf-integration", + "debug", + "token", + "--kind", + "scoped", + "--standalone", + ], + ] { + assert!( + parse(&arguments).standalone, + "missing global flag for {arguments:?}" + ); + } +} + #[test] fn routed_workflows_reject_the_fixture_lane_during_parsing() { for arguments in [ @@ -455,9 +491,11 @@ fn routed_workflows_reject_the_fixture_lane_during_parsing() { #[test] fn conformance_defaults_to_all_lanes_and_july_revision_at_resolution_time() { + let cli = parse(&["cf-integration", "conformance", "run"]); + assert!(!cli.standalone); let Command::Conformance(ConformanceArgs { command: ConformanceCommand::Run(args), - }) = parse(&["cf-integration", "conformance", "run"]).command + }) = cli.command else { panic!("expected conformance run") }; @@ -550,6 +588,26 @@ fn conformance_accepts_repeatable_exact_lanes_and_protocol_eras() { ]); } +#[test] +fn conformance_accepts_standalone_external_mode() { + let cli = parse(&[ + "cf-integration", + "conformance", + "run", + "--lane", + "external", + "--standalone", + ]); + assert!(cli.standalone); + let Command::Conformance(ConformanceArgs { + command: ConformanceCommand::Run(args), + }) = cli.command + else { + panic!("expected conformance run") + }; + assert_eq!(args.lane, [CliLane::External]); +} + #[test] fn root_version_flag_reports_the_package_version() { let error = Cli::try_parse_from(["cf-integration", "--version"]) diff --git a/src/conformance/client.rs b/src/conformance/client.rs index b747700..abf510e 100644 --- a/src/conformance/client.rs +++ b/src/conformance/client.rs @@ -21,7 +21,6 @@ pub(crate) const CLIENT_DRIVER_FAILURE_PREFIX: &str = "client conformance driver const SCENARIO_ENV: &str = "MCP_CONFORMANCE_SCENARIO"; const PROTOCOL_VERSION_ENV: &str = "MCP_CONFORMANCE_PROTOCOL_VERSION"; const CONTEXT_ENV: &str = "MCP_CONFORMANCE_CONTEXT"; -const CONFIG_WRITER: &str = "/opt/contextforge-conformance/write_client_config.py"; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -56,7 +55,7 @@ pub(crate) async fn run_internal_client(arguments: &[OsString]) -> Result<()> { let base_url = required_environment(CLIENT_BASE_URL_ENV)?; let tool_calls = scenario_tool_calls(&scenario)?; let backend_url = container_backend_url(scenario_server_url)?; - publish_scenario_config(&backend_url, &server_id, &tool_calls)?; + publish_scenario_config(&backend_url, &server_id, &protocol_version, &tool_calls)?; let mut client = GatewayClient::builder(GatewayTopology::Dataplane, &base_url, &server_id, &token) @@ -146,6 +145,7 @@ fn container_backend_url(value: &str) -> Result { fn publish_scenario_config( backend_url: &str, server_id: &str, + protocol_version: &str, tool_calls: &[ToolCall], ) -> Result<()> { let serialized_args = required_environment(CLIENT_COMPOSE_ARGS_ENV)?; @@ -166,12 +166,11 @@ fn publish_scenario_config( "--no-deps", "-e", CLIENT_TOKEN_ENV, - "--entrypoint", - "python3", - "gateway", - CONFIG_WRITER, + "config_writer", + "client", server_id, backend_url, + protocol_version, &tool_names, ]); SystemProcessRunner diff --git a/src/infrastructure/assets.rs b/src/infrastructure/assets.rs index 02d57e6..866fa27 100644 --- a/src/infrastructure/assets.rs +++ b/src/infrastructure/assets.rs @@ -24,20 +24,27 @@ macro_rules! asset { const ASSETS: &[EmbeddedAsset] = &[ asset!("docker/docker-compose.cf-conformance-fixture.yaml"), + asset!("docker/docker-compose.cf-conformance-controlplane.yaml"), asset!("docker/docker-compose.cf-conformance-runtime.yaml"), asset!("docker/docker-compose.cf-conformance.yaml"), asset!("docker/docker-compose.cf-controlplane-build-labels.yaml"), + asset!("docker/docker-compose.cf-controlplane-observability.yaml"), asset!("docker/docker-compose.cf-dataplane-build.yaml"), + asset!("docker/docker-compose.cf-dataplane-config.yaml"), + asset!("docker/docker-compose.cf-dataplane-observability.yaml"), + asset!("docker/docker-compose.cf-dataplane-standalone.yaml"), asset!("docker/docker-compose.cf-dataplane.yaml"), asset!("docker/docker-compose.cf-integration.yaml"), + asset!("docker/docker-compose.cf-telemetry.yaml"), asset!("docker/mcp-conformance-server.Dockerfile"), asset!("docker/nginx.cf-conformance-proxy.conf"), asset!("docker/nginx.cf-dataplane.conf"), + asset!("docker/nginx.cf-dataplane-standalone.conf.template"), asset!("docker/patch-mcp-conformance-hosts.mjs"), asset!("scripts/live_protocol/sitecustomize.py"), - asset!("scripts/conformance/write_client_config.py"), + asset!("scripts/conformance/write_dataplane_config.mjs"), asset!("scripts/locustfile_mcp.py"), - asset!("scripts/prepare_standalone_config.py"), + asset!("scripts/standalone/generate_auth_key.mjs"), asset!("tests/conformance/baselines/2026-07-28/legacy/built-in-data-plane.yml"), asset!("tests/conformance/baselines/2026-07-28/legacy/client/external-data-plane.yml"), asset!("tests/conformance/baselines/2026-07-28/legacy/external-data-plane.yml"), diff --git a/src/infrastructure/compose.rs b/src/infrastructure/compose.rs index f7246e4..cadff54 100644 --- a/src/infrastructure/compose.rs +++ b/src/infrastructure/compose.rs @@ -15,6 +15,7 @@ const LEGACY_FAST_TIME_IMAGE_PREFIXES: &[&str] = &[ /// Compose service keys and their public container display names. pub(crate) const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ + ("auth_keygen", "cf-dataplane-auth-keygen"), ("gateway", "cf-controlplane"), ("migration", "cf-migration"), ("register_fast_time", "cf-register-fast-time"), @@ -24,6 +25,7 @@ pub(crate) const SERVICE_DISPLAY_NAMES: &[(&str, &str)] = &[ ("pgbouncer", "cf-pgbouncer"), ("redis", "cf-redis"), ("dataplane", "cf-dataplane"), + ("config_writer", "cf-dataplane-config-writer"), ("locust", "cf-locust"), ("locust_worker", "cf-locust-worker"), ("locust_token", "cf-locust-token"), @@ -93,6 +95,38 @@ impl ComposeProject { repository_root .join("docker") .join("docker-compose.cf-integration.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-dataplane-config.yaml"), + ]; + if build_dataplane { + files.push( + repository_root + .join("docker") + .join("docker-compose.cf-dataplane-build.yaml"), + ); + } + Self { + project_name, + files, + profiles: Vec::new(), + } + } + + /// Builds the minimal external-dataplane project used by standalone workflows. + #[must_use] + pub(crate) fn standalone_dataplane( + repository_root: &Path, + project_name: OsString, + build_dataplane: bool, + ) -> Self { + let mut files = vec![ + repository_root + .join("docker") + .join("docker-compose.cf-dataplane-standalone.yaml"), + repository_root + .join("docker") + .join("docker-compose.cf-dataplane-config.yaml"), ]; if build_dataplane { files.push( @@ -179,6 +213,18 @@ impl ComposeProject { self } + /// Applies conformance-only settings to the control-plane service. + #[must_use] + pub(crate) fn with_controlplane_conformance_overlay(mut self, repository_root: &Path) -> Self { + let overlay = repository_root + .join("docker") + .join("docker-compose.cf-conformance-controlplane.yaml"); + if !self.files.contains(&overlay) { + self.files.push(overlay); + } + self + } + /// Applies the control-plane runtime settings used by conformance runs. #[must_use] pub(crate) fn with_conformance_runtime(mut self, repository_root: &Path) -> Self { @@ -191,7 +237,7 @@ impl ComposeProject { self } - /// Enables the local ClickStack instance and OTLP exporters for routed services. + /// Enables OTLP exporters for routed services; ClickStack runs independently. #[must_use] pub(crate) fn with_observability( mut self, @@ -205,12 +251,19 @@ impl ComposeProject { self.files.push(controlplane); } if include_dataplane { - let overlay = repository_root - .join("docker") - .join("docker-compose.cf-dataplane-observability.yaml"); - if !self.files.contains(&overlay) { - self.files.push(overlay); - } + self = self.with_dataplane_observability(repository_root); + } + self + } + + /// Enables only the external dataplane OTLP exporters. + #[must_use] + pub(crate) fn with_dataplane_observability(mut self, repository_root: &Path) -> Self { + let overlay = repository_root + .join("docker") + .join("docker-compose.cf-dataplane-observability.yaml"); + if !self.files.contains(&overlay) { + self.files.push(overlay); } self } diff --git a/src/infrastructure/compose_integration_tests.rs b/src/infrastructure/compose_integration_tests.rs index 1f2270a..70fb388 100644 --- a/src/infrastructure/compose_integration_tests.rs +++ b/src/infrastructure/compose_integration_tests.rs @@ -69,6 +69,7 @@ fn dataplane_compose_files_are_in_override_order() { repository_root .join("docker") .join("docker-compose.cf-integration.yaml"), + repository_root.join("docker/docker-compose.cf-dataplane-config.yaml"), ]; assert_eq!(project.files(), expected_files); @@ -87,6 +88,8 @@ fn dataplane_compose_files_are_in_override_order() { expected_files[2].as_os_str().to_owned(), OsString::from("-f"), expected_files[3].as_os_str().to_owned(), + OsString::from("-f"), + expected_files[4].as_os_str().to_owned(), OsString::from("config"), OsString::from("--format"), OsString::from("json"), @@ -246,16 +249,7 @@ fn dataplane_overlays_track_the_current_image_build_and_environment_contract() { let environment = compose["services"]["dataplane"]["environment"] .as_mapping() .expect("dataplane environment must be a mapping"); - let gateway_volumes = compose["services"]["gateway"]["volumes"] - .as_sequence() - .expect("gateway volumes must be a sequence"); - assert!(gateway_volumes.iter().any(|volume| { - volume.as_str().is_some_and(|volume| { - volume.ends_with( - "/scripts/prepare_standalone_config.py:/opt/contextforge-integration/prepare_standalone_config.py:ro", - ) - }) - })); + assert!(compose["services"]["gateway"]["volumes"].is_null()); for key in [ "CONTEXTFORGE_DATA_PLANE_ADDRESS", @@ -357,6 +351,115 @@ fn source_dataplane_adds_build_overlay_last() { ); } +#[test] +fn standalone_dataplane_has_no_controlplane_compose_inputs() { + let project = + ComposeProject::standalone_dataplane(Path::new("/repo"), OsString::from("project"), true); + + assert_eq!( + project.files(), + [ + PathBuf::from("/repo/docker/docker-compose.cf-dataplane-standalone.yaml"), + PathBuf::from("/repo/docker/docker-compose.cf-dataplane-config.yaml"), + PathBuf::from("/repo/docker/docker-compose.cf-dataplane-build.yaml"), + ] + ); + assert!( + project + .files() + .iter() + .all(|file| !file.to_string_lossy().contains("controlplane")) + ); +} + +#[test] +fn both_external_projects_provide_the_client_conformance_config_writer() { + let root = workspace_root(); + for project in [ + ComposeProject::dataplane( + root, + Path::new("/checkout"), + OsString::from("normal"), + false, + ), + ComposeProject::standalone_dataplane(root, OsString::from("standalone"), false), + ] { + let helpers: Vec<_> = project + .files() + .iter() + .filter_map(|file| { + let source = fs::read_to_string(file).ok()?; + let compose: yaml_serde::Value = + yaml_serde::from_str(&source).expect("Compose YAML"); + let service = &compose["services"]["config_writer"]; + (!service.is_null()).then(|| service.clone()) + }) + .collect(); + assert_eq!( + helpers.len(), + 1, + "each external project needs exactly one writer" + ); + assert_eq!(helpers[0]["profiles"][0].as_str(), Some("helpers")); + assert_eq!(helpers[0]["networks"][0].as_str(), Some("mcpnet")); + assert_eq!( + helpers[0]["entrypoint"][1].as_str(), + Some("/opt/contextforge-integration/write_dataplane_config.mjs") + ); + } +} + +#[test] +fn standalone_dataplane_owns_ephemeral_jwks_auth_and_mock_helpers() { + let root = workspace_root(); + let compose = + fs::read_to_string(root.join("docker/docker-compose.cf-dataplane-standalone.yaml")) + .expect("read standalone dataplane Compose file"); + let compose: yaml_serde::Value = + yaml_serde::from_str(&compose).expect("parse standalone dataplane Compose file"); + let services = compose["services"] + .as_mapping() + .expect("standalone services must be a mapping"); + + assert_eq!(services.len(), 5); + assert!(compose["services"]["gateway"].is_null()); + assert_eq!( + compose["services"]["auth_keygen"]["network_mode"].as_str(), + Some("none") + ); + assert_eq!( + compose["services"]["dataplane"]["environment"]["CONTEXTFORGE_DATA_PLANE_JWKS_URL"] + .as_str(), + Some("http://127.0.0.1:4445/contextforge-rs/admin/.well-known/jwks.json") + ); + assert_eq!( + compose["services"]["dataplane"]["command"][1].as_str(), + Some("/keys/jwt.key") + ); + assert!( + compose["services"]["dataplane"]["environment"]["CONTEXTFORGE_DATA_PLANE_TOKEN_SECRET"] + .is_null() + ); + assert!( + compose["services"]["dataplane"]["environment"] + ["CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY"] + .is_null() + ); + assert_eq!( + compose["services"]["nginx"]["healthcheck"]["test"][1].as_str(), + Some("wget -q -O - http://127.0.0.1/health >/dev/null") + ); + assert_eq!( + compose["services"]["locust"]["profiles"][0].as_str(), + Some("performance") + ); + assert_eq!( + compose["services"]["locust"]["image"].as_str(), + Some("locustio/locust:2.46.2") + ); + assert!(compose["services"]["locust"]["environment"]["JWT_SECRET_KEY"].is_null()); +} + #[test] fn conformance_fixture_is_an_explicit_overlay_and_profile() { let default_project = ComposeProject::dataplane( @@ -598,6 +701,9 @@ fn conformance_container_inputs_pin_the_runner_revision_and_protocol_fixture() { .expect("read standalone conformance fixture Compose file"); let routed_compose = fs::read_to_string(root.join("docker/docker-compose.cf-conformance.yaml")) .expect("read routed conformance Compose overlay"); + let controlplane_compose = + fs::read_to_string(root.join("docker/docker-compose.cf-conformance-controlplane.yaml")) + .expect("read control-plane conformance Compose overlay"); assert!(dockerfile.contains("FROM node:22-bookworm-slim")); assert!( @@ -679,11 +785,6 @@ services: let expected_routed_compose: yaml_serde::Value = yaml_serde::from_str( r#" services: - gateway: - environment: - GATEWAY_TOOL_NAME_SEPARATOR: "_" - volumes: - - ${CF_INTEGRATION_ROOT:?Set CF_INTEGRATION_ROOT to the integration harness root}/scripts/conformance/write_client_config.py:/opt/contextforge-conformance/write_client_config.py:ro mcp_conformance_server: networks: - mcpnet @@ -705,6 +806,20 @@ services: .expect("parse expected routed conformance Compose overlay"); assert_eq!(actual_routed_compose, expected_routed_compose); + let actual_controlplane_compose: yaml_serde::Value = + yaml_serde::from_str(&controlplane_compose) + .expect("parse control-plane conformance Compose overlay"); + let expected_controlplane_compose: yaml_serde::Value = yaml_serde::from_str( + r#" +services: + gateway: + environment: + GATEWAY_TOOL_NAME_SEPARATOR: "_" +"#, + ) + .expect("parse expected control-plane conformance Compose overlay"); + assert_eq!(actual_controlplane_compose, expected_controlplane_compose); + let proxy = fs::read_to_string(root.join("docker/nginx.cf-conformance-proxy.conf")) .expect("read conformance proxy config"); assert!(proxy.contains("proxy_pass http://mcp_conformance_server:3000;")); diff --git a/src/infrastructure/config.rs b/src/infrastructure/config.rs index 4afb3df..dedb3e3 100644 --- a/src/infrastructure/config.rs +++ b/src/infrastructure/config.rs @@ -144,15 +144,13 @@ pub(crate) struct ConfigBootstrap { /// Filesystem resources required by a resolved action. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ConfigRequirements { - runtime: bool, -} - -impl ConfigRequirements { - /// Configuration for report and token operations that must not write files. - pub(crate) const READ_ONLY: Self = Self { runtime: false }; - /// Configuration for operations backed by Compose or runtime scripts. - pub(crate) const RUNTIME: Self = Self { runtime: true }; +pub(crate) enum ConfigRequirements { + /// Resolve configuration without creating files. + ReadOnly, + /// Materialize runtime assets and control-plane secrets. + Runtime, + /// Materialize runtime assets without control-plane secrets. + StandaloneRuntime, } impl fmt::Debug for SourcedValue { @@ -271,7 +269,7 @@ impl AppConfig { root.join(".integration").into_os_string(), ), ); - let asset_root = if requirements.runtime { + let asset_root = if requirements != ConfigRequirements::ReadOnly { if contains_runtime_assets(&root) { root.clone() } else if bootstrap.root_overridden { @@ -325,7 +323,7 @@ impl AppConfig { "CF_CONTROLPLANE_PROJECT", OsString::from("cf-controlplane-only"), ); - let local_secrets = if requirements.runtime + let local_secrets = if requirements == ConfigRequirements::Runtime && (first_nonempty(&environment, "JWT_SECRET_KEY").is_none() || first_nonempty(&environment, "AUTH_ENCRYPTION_SECRET").is_none()) { @@ -337,7 +335,7 @@ impl AppConfig { }; let jwt_secret_key = match first_nonempty(&environment, "JWT_SECRET_KEY") { Some(value) => value.clone(), - None if !requirements.runtime => default_value(""), + None if requirements != ConfigRequirements::Runtime => default_value(""), None => default_value( &local_secrets .as_ref() @@ -347,7 +345,7 @@ impl AppConfig { }; let auth_encryption_secret = match first_nonempty(&environment, "AUTH_ENCRYPTION_SECRET") { Some(value) => value.clone(), - None if !requirements.runtime => default_value(""), + None if requirements != ConfigRequirements::Runtime => default_value(""), None => default_value( &local_secrets .as_ref() @@ -927,7 +925,7 @@ mod tests { fn load_app_config(root: &Path, process: &Environment) -> AppConfig { let bootstrap = ConfigBootstrap::load(process, root).expect("bootstrap should load"); - AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect("application config should load") } @@ -941,13 +939,27 @@ mod tests { let outside = tempfile::tempdir().expect("temporary directory should be created"); let bootstrap = ConfigBootstrap::load(&Environment::new(), outside.path()) .expect("bootstrap should load"); - let config = AppConfig::load(bootstrap, ConfigRequirements::READ_ONLY) + let config = AppConfig::load(bootstrap, ConfigRequirements::ReadOnly) .expect("read-only config should resolve"); assert_eq!(config.root(), outside.path()); assert!(!outside.path().join(".integration").exists()); } + #[test] + fn standalone_config_materializes_assets_without_controlplane_secrets() { + let outside = tempfile::tempdir().expect("temporary directory"); + let bootstrap = + ConfigBootstrap::load(&Environment::new(), outside.path()).expect("bootstrap"); + let config = AppConfig::load(bootstrap, ConfigRequirements::StandaloneRuntime) + .expect("standalone runtime config"); + assert!(contains_runtime_assets(config.asset_root())); + assert!(config.jwt_secret_key().value.is_empty()); + assert!(config.auth_encryption_secret().value.is_empty()); + assert!(!config.integration_dir().join(LOCAL_SECRETS_FILE).exists()); + assert!(!config.controlplane_dir().exists()); + } + #[test] fn app_config_uses_documented_defaults() { let root = repository_root(); @@ -1263,7 +1275,7 @@ mod tests { let bootstrap = ConfigBootstrap::load(&process, root.path()).expect("bootstrap should load"); - let error = AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + let error = AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect_err("an unknown pull policy must fail"); assert_eq!( diff --git a/src/infrastructure/config_integration_tests.rs b/src/infrastructure/config_integration_tests.rs index 215b309..d496be5 100644 --- a/src/infrastructure/config_integration_tests.rs +++ b/src/infrastructure/config_integration_tests.rs @@ -42,7 +42,7 @@ struct TestConfigLoad { fn load_app_config(root: &Path, process: &Environment) -> TestConfigLoad { let bootstrap = ConfigBootstrap::load(process, root).expect("bootstrap should load"); let warnings = bootstrap.warnings().to_vec(); - let config = AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + let config = AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect("application config should load"); TestConfigLoad { config, warnings } } @@ -231,7 +231,7 @@ fn workspace_root_prefers_process_override() { let bootstrap = ConfigBootstrap::load(&process, cwd_root.path()).expect("bootstrap should load"); - let config = AppConfig::load(bootstrap, ConfigRequirements::READ_ONLY) + let config = AppConfig::load(bootstrap, ConfigRequirements::ReadOnly) .expect("read-only config should resolve"); assert_eq!(config.root(), process_root.path()); @@ -242,7 +242,7 @@ fn workspace_root_defaults_to_cwd_without_writing_files() { let outside = tempfile::tempdir().expect("temporary directory should be created"); let bootstrap = ConfigBootstrap::load(&Environment::new(), outside.path()).expect("bootstrap should load"); - let config = AppConfig::load(bootstrap, ConfigRequirements::READ_ONLY) + let config = AppConfig::load(bootstrap, ConfigRequirements::ReadOnly) .expect("read-only config should resolve"); assert_eq!(config.root(), outside.path()); @@ -259,7 +259,7 @@ fn invalid_explicit_runtime_root_fails_closed() { )]); let bootstrap = ConfigBootstrap::load(&process, outside.path()).expect("bootstrap should load"); - let error = AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + let error = AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect_err("an explicit root without assets must fail"); assert!(error.to_string().contains(ROOT_OVERRIDE)); diff --git a/src/infrastructure/process.rs b/src/infrastructure/process.rs index 9c7b19b..28022f3 100644 --- a/src/infrastructure/process.rs +++ b/src/infrastructure/process.rs @@ -134,6 +134,7 @@ impl CapturedOutput { /// Returns captured standard output bytes. #[must_use] + #[cfg(test)] pub(crate) fn stdout(&self) -> &[u8] { &self.stdout } @@ -213,6 +214,17 @@ pub(crate) trait ProcessRunner { /// Captures standard output while inheriting standard error. fn capture_stdout(&self, spec: &CommandSpec) -> Result, InfrastructureError>; + /// Returns command data on stdout and appends diagnostics on stderr to a log. + fn capture_stdout_to_log( + &self, + spec: &CommandSpec, + log_path: &Path, + ) -> Result, InfrastructureError> { + let output = self.capture_output(spec)?; + append_captured_stderr(log_path, spec, &output)?; + Ok(output.stdout) + } + /// Captures standard output and error separately. fn capture_output(&self, spec: &CommandSpec) -> Result; @@ -282,14 +294,20 @@ impl ProcessRunner for LoggingProcessRunner<'_, R> { } fn capture_stdout(&self, spec: &CommandSpec) -> Result, InfrastructureError> { - let output = self.inner.capture_output(spec)?; - append_captured_output(self.log_path, spec, &output)?; - Ok(output.stdout) + self.inner.capture_stdout_to_log(spec, self.log_path) + } + + fn capture_stdout_to_log( + &self, + spec: &CommandSpec, + log_path: &Path, + ) -> Result, InfrastructureError> { + self.inner.capture_stdout_to_log(spec, log_path) } fn capture_output(&self, spec: &CommandSpec) -> Result { let output = self.inner.capture_output(spec)?; - append_captured_output(self.log_path, spec, &output)?; + append_captured_stderr(self.log_path, spec, &output)?; Ok(output) } @@ -445,6 +463,27 @@ impl ProcessRunner for SystemProcessRunner { Ok(CapturedOutput::new(output.stdout, output.stderr)) } + fn capture_stdout_to_log( + &self, + spec: &CommandSpec, + log_path: &Path, + ) -> Result, InfrastructureError> { + let log = OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .with_context(|| log_context("open", log_path, spec))?; + let output = command(spec) + .stdout(Stdio::piped()) + .stderr(Stdio::from(log)) + .output() + .with_context(|| operation_context("capture", spec))?; + // stderr is already durable even when the command fails. stdout can + // contain credentials or structured data and must never enter the log. + require_success(spec, output.status)?; + Ok(output.stdout) + } + fn run_to_log(&self, spec: &CommandSpec, log_path: &Path) -> Result<(), InfrastructureError> { let (log, stderr_log) = log_handles(log_path, spec)?; let mut command = command(spec); @@ -473,7 +512,7 @@ fn log_handles(log_path: &Path, spec: &CommandSpec) -> Result<(File, File), Infr Ok((log, stderr_log)) } -fn append_captured_output( +fn append_captured_stderr( log_path: &Path, spec: &CommandSpec, output: &CapturedOutput, @@ -483,8 +522,7 @@ fn append_captured_output( .append(true) .open(log_path) .with_context(|| log_context("open", log_path, spec))?; - log.write_all(output.stdout()) - .and_then(|()| log.write_all(output.stderr())) + log.write_all(output.stderr()) .with_context(|| log_context("append output to", log_path, spec))?; Ok(()) } diff --git a/src/infrastructure/process_integration_tests.rs b/src/infrastructure/process_integration_tests.rs index 73f8dbf..6ccf7e9 100644 --- a/src/infrastructure/process_integration_tests.rs +++ b/src/infrastructure/process_integration_tests.rs @@ -3,7 +3,6 @@ use std::fs; use std::path::{Path, PathBuf}; use cf_integration::infrastructure::InfrastructureError; -#[cfg(unix)] use cf_integration::infrastructure::process::LoggingProcessRunner; use cf_integration::infrastructure::process::{ CapturedOutput, CommandSpec, ProcessRunner, SystemProcessRunner, @@ -25,6 +24,53 @@ use std::os::unix::fs::PermissionsExt; fn assert_runner_interface(_runner: &dyn ProcessRunner) {} +#[test] +fn captured_data_is_returned_without_becoming_diagnostic_output() { + let directory = tempfile::tempdir().expect("temporary directory"); + let log = directory.path().join("setup.log"); + let runner = LoggingProcessRunner::new(&FakeProcessRunner, &log); + let command = CommandSpec::new("helper"); + assert_eq!( + runner.capture_stdout(&command).expect("stdout"), + b"synthetic stdout" + ); + assert_eq!( + runner.capture_output(&command).expect("output").stdout(), + b"synthetic stdout" + ); + let contents = fs::read_to_string(log).expect("diagnostic log"); + assert!(!contents.contains("synthetic stdout")); + assert_eq!(contents, "synthetic stderrsynthetic stderr"); +} + +#[cfg(unix)] +#[test] +fn captured_credentials_stay_private_and_failed_commands_keep_diagnostics() { + let directory = tempfile::tempdir().expect("temporary directory"); + for exit_code in [0, 7] { + let log = directory.path().join(format!("{exit_code}.log")); + let runner = LoggingProcessRunner::new(&SystemProcessRunner, &log); + let output = runner.capture_stdout(&CommandSpec::new("/bin/sh").args([ + "-c", + &format!("printf 'private-token'; printf 'helper diagnostic' >&2; exit {exit_code}"), + ])); + if exit_code == 0 { + assert_eq!(output.expect("successful capture"), b"private-token"); + } else { + assert!( + !output + .expect_err("failed helper") + .to_string() + .contains("private-token") + ); + } + assert_eq!( + fs::read_to_string(log).expect("diagnostic log"), + "helper diagnostic" + ); + } +} + struct FakeProcessRunner; impl ProcessRunner for FakeProcessRunner { @@ -62,7 +108,7 @@ async fn logging_runner_hides_ordinary_output_in_an_aggregate_log() { let runner = LoggingProcessRunner::new(&system, &log_path); runner - .run_async(&CommandSpec::new(script)) + .run_async(&CommandSpec::new("/bin/sh").arg(script)) .await .expect("logged child should succeed"); diff --git a/src/infrastructure/stack.rs b/src/infrastructure/stack.rs index aaa3801..298afc6 100644 --- a/src/infrastructure/stack.rs +++ b/src/infrastructure/stack.rs @@ -185,6 +185,7 @@ impl StackCommandPlan { /// Builds a Compose command that stops one service without removing it. #[must_use] + #[cfg(test)] pub(crate) fn stop_service(project: ComposeProject, service: &str) -> Self { Self { command: project.command(["stop", "--timeout", "5", service]), @@ -193,6 +194,7 @@ impl StackCommandPlan { /// Builds a Compose command that restarts one previously stopped service. #[must_use] + #[cfg(test)] pub(crate) fn start_service(project: ComposeProject, service: &str) -> Self { Self { command: project.command(["start", service]), @@ -201,6 +203,7 @@ impl StackCommandPlan { /// Builds a Compose command that restarts one service without its dependencies. #[must_use] + #[cfg(test)] pub(crate) fn restart_service(project: ComposeProject, service: &str) -> Self { Self { command: project.command(["restart", "--timeout", "5", service]), diff --git a/src/lib.rs b/src/lib.rs index 4762520..f682938 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,10 +20,10 @@ mod runtime; use app::resolve_action; use cli::Cli; use error::AppFailure; -use infrastructure::config::{AppConfig, ConfigBootstrap, ConfigRequirements, Environment}; +use infrastructure::config::{AppConfig, ConfigBootstrap, Environment}; use infrastructure::process::SystemProcessRunner; pub(crate) use output::{Activity, OutputStyle, TestStatus}; -use runtime::RuntimeDispatcher; +use runtime::RuntimeContext; /// Runs the CLI using the current process arguments and environment. pub async fn run() -> ExitCode { @@ -77,11 +77,7 @@ pub async fn run() -> ExitCode { Ok(action) => action, Err(error) => return report_failure(AppFailure::from(error)), }; - let requirements = if action.requires_runtime_assets() { - ConfigRequirements::RUNTIME - } else { - ConfigRequirements::READ_ONLY - }; + let requirements = action.config_requirements(); eprintln!("{}", OutputStyle::stderr().info(&action.startup_summary())); let activity = action .uses_global_activity() @@ -95,7 +91,7 @@ pub async fn run() -> ExitCode { return report_failure(AppFailure::from(error)); } }; - let runtime = RuntimeDispatcher::new(config, SystemProcessRunner); + let runtime = RuntimeContext::new(config, SystemProcessRunner); let result = runtime.execute(action).await; if let Some(activity) = activity { activity.finish(result.is_ok()); diff --git a/src/mcp/probe.rs b/src/mcp/probe.rs index 6178c98..bf8a4a4 100644 --- a/src/mcp/probe.rs +++ b/src/mcp/probe.rs @@ -47,6 +47,11 @@ pub(crate) struct ProbeConfig { pub(crate) request_timeout: Duration, /// MCP protocol revision requested during initialization. pub(crate) protocol_version: String, + /// Tool names supplied by standalone mocked configuration. + /// + /// A nonempty value bypasses `tools/list`, which the external dataplane + /// deliberately does not implement, while still exercising routed calls. + pub(crate) tool_names: Vec, /// Terminal-aware styling for human-readable probe results. pub(crate) output_style: OutputStyle, } @@ -63,6 +68,7 @@ impl fmt::Debug for ProbeConfig { .field("retry_interval", &self.retry_interval) .field("request_timeout", &self.request_timeout) .field("protocol_version", &self.protocol_version) + .field("tool_name_count", &self.tool_names.len()) .finish() } } @@ -337,53 +343,29 @@ pub(crate) async fn run_probe( "failed to write initialized notification result", )?; - let tools_response = post_with_timeout( - transport, - ProbeRequest { - url: url.clone(), - payload: jsonrpc_with_id("tools/list", Some(json!({})), json!(TOOLS_LIST_ID)), - bearer_token: Some(config.bearer_token.clone()), - session_id: Some(session_id.clone()), - protocol_version: Some(negotiated_version.clone()), - }, - config.request_timeout, - "tools_list", - config.mode, - ) - .await?; - let tools_result = result_of("tools_list", &tools_response, TOOLS_LIST_ID)?; - let tools = tools_result - .get("tools") - .and_then(Value::as_array) - .ok_or_else(|| { - anyhow::anyhow!("tools_list=FAIL unexpected response: missing tools array") - })?; - if tools.is_empty() { - bail!("tools_list=FAIL no tools returned"); - } - let mut tool_names = Vec::with_capacity(tools.len()); - for tool in tools { - let Some(name) = tool - .as_object() - .and_then(|tool| tool.get("name")) - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - else { - bail!("tools_list=FAIL every tool must have a nonempty name"); - }; - tool_names.push(name); - } - write_probe_result( - output, - config, - TestStatus::Pass, - "tools_list", - &format!("count={}", tool_names.len()), - "failed to write tools list result", - )?; + let tool_names = if config.tool_names.is_empty() { + let tools_response = post_with_timeout( + transport, + ProbeRequest { + url: url.clone(), + payload: jsonrpc_with_id("tools/list", Some(json!({})), json!(TOOLS_LIST_ID)), + bearer_token: Some(config.bearer_token.clone()), + session_id: Some(session_id.clone()), + protocol_version: Some(negotiated_version.clone()), + }, + config.request_timeout, + "tools_list", + config.mode, + ) + .await?; + listed_tool_names(&tools_response, output, config)? + } else { + write_mocked_tool_catalog(output, config)?; + config.tool_names.clone() + }; let callable = tool_names .iter() - .find_map(|name| tool_call_args(name).map(|arguments| (*name, arguments))); + .find_map(|name| tool_call_args(name).map(|arguments| (name.as_str(), arguments))); let Some((tool_name, arguments)) = callable else { write_probe_result( output, @@ -555,56 +537,34 @@ async fn run_stateless_probe( "failed to write server discovery result", )?; - let tools_response = post_with_timeout( - transport, - ProbeRequest { - url: url.clone(), - payload: stateless_jsonrpc_with_id( - "tools/list", - Some(json!({})), - json!(TOOLS_LIST_ID), - &config.protocol_version, - ), - bearer_token: Some(config.bearer_token.clone()), - session_id: None, - protocol_version: Some(config.protocol_version.clone()), - }, - config.request_timeout, - "tools_list", - config.mode, - ) - .await?; - let tools_result = result_of("tools_list", &tools_response, TOOLS_LIST_ID)?; - let tools = tools_result - .get("tools") - .and_then(Value::as_array) - .ok_or_else(|| anyhow!("tools_list=FAIL unexpected response: missing tools array"))?; - if tools.is_empty() { - bail!("tools_list=FAIL no tools returned"); - } - let mut tool_names = Vec::with_capacity(tools.len()); - for tool in tools { - let Some(name) = tool - .as_object() - .and_then(|tool| tool.get("name")) - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - else { - bail!("tools_list=FAIL every tool must have a nonempty name"); - }; - tool_names.push(name); - } - write_probe_result( - output, - config, - TestStatus::Pass, - "tools_list", - &format!("count={}", tool_names.len()), - "failed to write tools list result", - )?; + let tool_names = if config.tool_names.is_empty() { + let tools_response = post_with_timeout( + transport, + ProbeRequest { + url: url.clone(), + payload: stateless_jsonrpc_with_id( + "tools/list", + Some(json!({})), + json!(TOOLS_LIST_ID), + &config.protocol_version, + ), + bearer_token: Some(config.bearer_token.clone()), + session_id: None, + protocol_version: Some(config.protocol_version.clone()), + }, + config.request_timeout, + "tools_list", + config.mode, + ) + .await?; + listed_tool_names(&tools_response, output, config)? + } else { + write_mocked_tool_catalog(output, config)?; + config.tool_names.clone() + }; let callable = tool_names .iter() - .find_map(|name| tool_call_args(name).map(|arguments| (*name, arguments))); + .find_map(|name| tool_call_args(name).map(|arguments| (name.as_str(), arguments))); let Some((tool_name, arguments)) = callable else { write_probe_result( output, @@ -656,6 +616,52 @@ async fn run_stateless_probe( Ok(()) } +fn listed_tool_names( + response: &ProbeResponse, + output: &mut W, + config: &ProbeConfig, +) -> Result> { + let tools_result = result_of("tools_list", response, TOOLS_LIST_ID)?; + let tools = tools_result + .get("tools") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("tools_list=FAIL unexpected response: missing tools array"))?; + if tools.is_empty() { + bail!("tools_list=FAIL no tools returned"); + } + let tool_names = tools + .iter() + .map(|tool| { + tool.as_object() + .and_then(|tool| tool.get("name")) + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| anyhow!("tools_list=FAIL every tool must have a nonempty name")) + }) + .collect::>>()?; + write_probe_result( + output, + config, + TestStatus::Pass, + "tools_list", + &format!("count={}", tool_names.len()), + "failed to write tools list result", + )?; + Ok(tool_names) +} + +fn write_mocked_tool_catalog(output: &mut W, config: &ProbeConfig) -> Result<()> { + write_probe_result( + output, + config, + TestStatus::Pass, + "tools_catalog", + &format!("count={} source=mocked-redis", config.tool_names.len()), + "failed to write mocked tool catalog result", + ) +} + async fn post_with_timeout( transport: &T, request: ProbeRequest, diff --git a/src/mcp/probe_tests.rs b/src/mcp/probe_tests.rs index 98f0e02..302bb2e 100644 --- a/src/mcp/probe_tests.rs +++ b/src/mcp/probe_tests.rs @@ -83,6 +83,7 @@ fn config() -> ProbeConfig { retry_interval: Duration::ZERO, request_timeout: Duration::from_secs(1), protocol_version: "2025-11-25".to_owned(), + tool_names: Vec::new(), output_style: OutputStyle::plain(), } } @@ -325,6 +326,37 @@ async fn stateless_happy_path_uses_discovery_request_metadata_and_no_session() { assert!(!output.contains("PASS initialized")); } +#[tokio::test] +async fn mocked_catalog_bypasses_unsupported_fanout_and_calls_a_known_tool() { + let transport = FakeTransport::new([ + ProbeResponse::new(401, None, None), + discover_success(), + call_success(), + ]); + let mut configured = config(); + configured.protocol_version = "2026-07-28".to_owned(); + configured.tool_names = vec!["test_simple_text".to_owned()]; + let mut output = Vec::new(); + + run_probe(&transport, &configured, &mut output) + .await + .expect("mocked catalog probe should succeed without tools/list"); + + let requests = transport.requests(); + assert_eq!(requests.len(), 3); + assert!( + requests + .iter() + .all(|request| request.payload["method"] != "tools/list") + ); + assert_eq!(requests[2].payload["method"], "tools/call"); + assert_eq!(requests[2].payload["params"]["name"], "test_simple_text"); + assert_eq!(requests[2].payload["params"]["arguments"], json!({})); + let output = String::from_utf8(output).expect("probe output should be UTF-8"); + assert!(output.contains("PASS tools_catalog count=1 source=mocked-redis")); + assert!(output.contains("PASS tool_call tool=test_simple_text")); +} + #[tokio::test] async fn requested_version_drives_initialize_payload_and_negotiated_version_drives_headers() { let transport = FakeTransport::new([ diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs index c26efe2..6e9e2db 100644 --- a/src/mcp/protocol.rs +++ b/src/mcp/protocol.rs @@ -202,6 +202,7 @@ fn flush_sse_event(event_data: &mut String, has_data: &mut bool, message: &mut O #[must_use] pub(crate) fn tool_call_args(tool_name: &str) -> Option { match tool_name { + "test_simple_text" => Some(json!({})), "echo" | "fast_time_echo" | "fast-time-echo" => Some(json!({"message": "cf-integration"})), "get_system_time" | "get-system-time" diff --git a/src/performance/locust.rs b/src/performance/locust.rs index bdc4afd..cdf4e96 100644 --- a/src/performance/locust.rs +++ b/src/performance/locust.rs @@ -36,6 +36,7 @@ impl LocustCommand { /// ID, an invalid timeout, or an inaccessible report directory. pub(crate) fn new_with_protocol_version( config: &AppConfig, + project: ComposeProject, mode: StackMode, settings: &LoadSettings, bearer_token: &str, @@ -68,69 +69,44 @@ impl LocustCommand { })?; let volume = volume_argument(&report_dir); - let project = match mode { - StackMode::Dataplane => ComposeProject::dataplane( - config.asset_root(), - config.controlplane_dir(), - config.integration_project().value.clone(), - !config.dataplane_ref().value.is_empty(), - ), - StackMode::Controlplane => ComposeProject::controlplane( - config.asset_root(), - config.controlplane_dir(), - config.controlplane_project().value.clone(), - controlplane_sso_enabled(config), - ), - }; - - let command = match mode { - StackMode::Dataplane => project.command([ - OsString::from("--profile"), - OsString::from("testing"), - OsString::from("run"), - OsString::from("--rm"), - OsString::from("--no-deps"), - OsString::from("--volume"), - volume, - OsString::from("locust"), - ]), - StackMode::Controlplane => { - let adapter_volume = adapter_volume_argument(config.asset_root()); - let arguments = vec![ - OsString::from("run"), - OsString::from("--rm"), - OsString::from("--no-deps"), - OsString::from("--volume"), - volume, - OsString::from("--volume"), - adapter_volume, - OsString::from("-e"), - OsString::from("MCPGATEWAY_BEARER_TOKEN"), - OsString::from("-e"), - OsString::from("MCP_STACK_MODE"), - OsString::from("-e"), - OsString::from(REQUEST_TIMEOUT_ENV), - OsString::from("-e"), - OsString::from("MCP_PROTOCOL_VERSION"), - OsString::from("-e"), - OsString::from("MCP_TOOL_NAMES"), - OsString::from("--entrypoint"), - OsString::from("locust"), - OsString::from("locust"), - OsString::from("-f"), - OsString::from(LOCUST_ADAPTER_CONTAINER_PATH), - OsString::from("--host=http://nginx:80"), - OsString::from(format!("--users={}", settings.users().get())), - OsString::from(format!("--spawn-rate={}", settings.spawn_rate())), - OsString::from(format!("--run-time={}", settings.run_time())), - OsString::from("--headless"), - OsString::from("--html=/mnt/reports/locust_report.html"), - OsString::from("--csv=/mnt/reports/locust"), - OsString::from("--only-summary"), - ]; - project.command(arguments) - } - }; + let adapter_volume = adapter_volume_argument(config.asset_root()); + let arguments = vec![ + OsString::from("run"), + OsString::from("--rm"), + OsString::from("--no-deps"), + OsString::from("--volume"), + volume, + OsString::from("--volume"), + adapter_volume, + OsString::from("-e"), + OsString::from("MCPGATEWAY_BEARER_TOKEN"), + OsString::from("-e"), + OsString::from("MCP_STACK_MODE"), + OsString::from("-e"), + OsString::from(REQUEST_TIMEOUT_ENV), + OsString::from("-e"), + OsString::from("MCP_PROTOCOL_VERSION"), + OsString::from("-e"), + OsString::from("MCP_TOOL_NAMES"), + OsString::from("-e"), + OsString::from("MCP_SKIP_TOOL_LIST"), + OsString::from("-e"), + OsString::from("MCP_SERVER_ID"), + OsString::from("--entrypoint"), + OsString::from("locust"), + OsString::from("locust"), + OsString::from("-f"), + OsString::from(LOCUST_ADAPTER_CONTAINER_PATH), + OsString::from("--host=http://nginx:80"), + OsString::from(format!("--users={}", settings.users().get())), + OsString::from(format!("--spawn-rate={}", settings.spawn_rate())), + OsString::from(format!("--run-time={}", settings.run_time())), + OsString::from("--headless"), + OsString::from("--html=/mnt/reports/locust_report.html"), + OsString::from("--csv=/mnt/reports/locust"), + OsString::from("--only-summary"), + ]; + let command = project.command(arguments); let mut command = command .env("CF_INTEGRATION_ROOT", config.asset_root().as_os_str()) @@ -150,7 +126,9 @@ impl LocustCommand { } StackMode::Controlplane => { let tool_names = configured_text(config, "MCP_TOOL_NAMES").unwrap_or(""); - command = command.env("MCP_TOOL_NAMES", tool_names); + command = command + .env("MCP_TOOL_NAMES", tool_names) + .env("MCP_SERVER_ID", ""); } } @@ -309,14 +287,6 @@ fn adapter_volume_argument(root: &Path) -> OsString { argument } -fn controlplane_sso_enabled(config: &AppConfig) -> bool { - config - .environment() - .get(OsStr::new("CONTROLPLANE_ENABLE_SSO")) - .and_then(|value| value.value.to_str()) - .is_some_and(|value| matches!(value, "true" | "1")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/performance/locust_integration_tests.rs b/src/performance/locust_integration_tests.rs index e8337d9..431ab57 100644 --- a/src/performance/locust_integration_tests.rs +++ b/src/performance/locust_integration_tests.rs @@ -4,6 +4,7 @@ use std::fs; use std::path::Path; use cf_integration::infrastructure::StackMode; +use cf_integration::infrastructure::compose::ComposeProject; use cf_integration::infrastructure::config::{ AppConfig, ConfigBootstrap, ConfigRequirements, Environment, }; @@ -37,7 +38,7 @@ fn repository_root(dotenv: Option<&str>) -> TempDir { fn config(root: &Path, process: &Environment) -> AppConfig { let bootstrap = ConfigBootstrap::load(process, root).expect("bootstrap should load"); - AppConfig::load(bootstrap, ConfigRequirements::RUNTIME).expect("application config should load") + AppConfig::load(bootstrap, ConfigRequirements::Runtime).expect("application config should load") } fn args(smoke: bool) -> LoadRequest { @@ -49,6 +50,23 @@ fn args(smoke: bool) -> LoadRequest { } } +fn project(config: &AppConfig, mode: StackMode) -> ComposeProject { + match mode { + StackMode::Dataplane => ComposeProject::dataplane( + config.asset_root(), + config.controlplane_dir(), + config.integration_project().value.clone(), + !config.dataplane_ref().value.is_empty(), + ), + StackMode::Controlplane => ComposeProject::controlplane( + config.asset_root(), + config.controlplane_dir(), + config.controlplane_project().value.clone(), + false, + ), + } +} + #[test] fn full_load_uses_configured_defaults() { let root = repository_root(None); @@ -156,6 +174,7 @@ fn dataplane_locust_command_has_exact_compose_shape_and_environment() { let run = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Dataplane), StackMode::Dataplane, &settings, "scoped.jwt.value", @@ -173,41 +192,23 @@ fn dataplane_locust_command_has_exact_compose_shape_and_environment() { .join("locust"); assert_eq!(run.report_dir(), report_dir); assert!(run.report_dir().is_dir()); - assert_eq!( - run.command().arguments(), - [ - OsString::from("compose"), - OsString::from("-p"), - OsString::from("cf"), - OsString::from("-f"), - integration_dir - .join("mcp-context-forge") - .join("docker-compose.yml") - .into_os_string(), - OsString::from("-f"), - asset_root - .join("docker") - .join("docker-compose.cf-controlplane-build-labels.yaml") - .into_os_string(), - OsString::from("-f"), - asset_root - .join("docker") - .join("docker-compose.cf-dataplane.yaml") - .into_os_string(), - OsString::from("-f"), - asset_root - .join("docker") - .join("docker-compose.cf-integration.yaml") - .into_os_string(), - OsString::from("--profile"), - OsString::from("testing"), - OsString::from("run"), - OsString::from("--rm"), - OsString::from("--no-deps"), - OsString::from("--volume"), - volume_argument(&report_dir), - OsString::from("locust"), - ] + let arguments = run.command().arguments(); + assert!( + arguments + .windows(3) + .any(|values| values == ["run", "--rm", "--no-deps"]) + ); + assert!(arguments.contains(&volume_argument(&report_dir))); + assert!(arguments.contains(&OsString::from("/mnt/locust-cf/locustfile_mcp.py"))); + assert!( + arguments + .windows(2) + .any(|values| values == ["-e", "MCP_SKIP_TOOL_LIST"]) + ); + assert!( + arguments + .windows(2) + .any(|values| values == ["--entrypoint", "locust"]) ); let expected_environment = HashMap::from([ @@ -253,6 +254,7 @@ fn controlplane_uses_the_same_harness_mcp_adapter_and_does_not_require_server_id let run = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Controlplane), StackMode::Controlplane, &settings, "admin.jwt.value", @@ -296,10 +298,9 @@ fn controlplane_uses_the_same_harness_mcp_adapter_and_does_not_require_server_id .get(OsStr::new("MCP_TOOL_NAMES")), Some(&OsString::from("safe_time,safe_echo")) ); - assert!( - !run.command() - .environment() - .contains_key(OsStr::new("MCP_SERVER_ID")) + assert_eq!( + run.command().environment().get(OsStr::new("MCP_SERVER_ID")), + Some(&OsString::new()) ); let arguments = run.command().arguments(); let entrypoint = arguments @@ -353,6 +354,7 @@ fn locust_request_timeout_rejects_empty_non_finite_and_non_positive_values() { let error = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Controlplane), StackMode::Controlplane, &settings, "token", @@ -378,6 +380,7 @@ fn dataplane_requires_nonempty_server_id_and_all_modes_require_a_token() { let missing_server = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Dataplane), StackMode::Dataplane, &settings, "token", @@ -389,6 +392,7 @@ fn dataplane_requires_nonempty_server_id_and_all_modes_require_a_token() { let missing_token = LocustCommand::new_with_protocol_version( &config, + project(&config, StackMode::Controlplane), StackMode::Controlplane, &settings, "", diff --git a/src/performance/python_adapter_tests.rs b/src/performance/python_adapter_tests.rs index a96e67d..00c0fa5 100644 --- a/src/performance/python_adapter_tests.rs +++ b/src/performance/python_adapter_tests.rs @@ -21,65 +21,23 @@ fn workspace_root() -> PathBuf { } #[test] -fn locust_adapter_and_compose_overlay_do_not_reference_the_removed_helper() { - let root = workspace_root(); - let adapter = fs::read_to_string(root.join("scripts/locustfile_mcp.py")) - .expect("Locust adapter should be readable"); - let compose = fs::read_to_string(root.join("docker/docker-compose.cf-integration.yaml")) - .expect("Compose overlay should be readable"); - - assert!(!adapter.contains("mcp_http")); - assert!(adapter.contains("allow_redirects=False")); - assert!(adapter.contains("timeout=REQUEST_TIMEOUT_SECONDS")); - assert!(adapter.contains("self.client.trust_env = False")); - assert!(!compose.contains("mcp_http.py")); - assert!( - !compose.contains("JWT_SECRET_KEY="), - "the load container receives a bearer token and must not receive the signing key" - ); - assert!(compose.contains("MCP_PROTOCOL_VERSION=${MCP_PROTOCOL_VERSION:-2026-07-28}")); - assert!(compose.contains("standalone_load_backend:")); - assert!(compose.contains("profiles: [\"standalone-load\"]")); - assert!(compose.contains("MCP_CONFORMANCE_SERVER_ERA: modern")); - assert!( - compose.contains("LOCUST_REQUEST_TIMEOUT_SECONDS=${LOCUST_REQUEST_TIMEOUT_SECONDS:-60}") - ); -} - -#[test] -fn standalone_config_helper_uses_token_subject_and_selected_protocol() { - let code = r#" -import base64 -import json -import prepare_standalone_config as helper - -claims = base64.urlsafe_b64encode(json.dumps({"sub": "user-123"}).encode()).decode().rstrip("=") -assert helper.token_subject(f"header.{claims}.signature") == "user-123" - -prepared = helper.prepare_config("server-123", "2026-07-28") -backend = prepared["virtual_hosts"]["server-123"]["backends"]["standalone-load"] -assert backend["url"] == "http://mcp_conformance_server:3000/mcp" -assert backend["mcp_protocol_version"] == "2026-07-28" -assert backend["tool_name_aliases"] == [{"downstream_prefixed_name": "test_simple_text", "upstream_name": "test_simple_text"}] -assert backend["tool_schemas"] == {"test_simple_text": {}} -virtual_host = prepared["virtual_hosts"]["server-123"] -assert virtual_host["tools"] == {"test_simple_text": {"backend_name": "standalone-load", "upstream_name": "test_simple_text"}} -assert virtual_host["resources"] == {} -assert virtual_host["resource_templates"] == {} -assert virtual_host["prompts"] == {} -"#; - let output = Command::new(python()) - .arg("-c") - .arg(code) - .env("PYTHONPATH", scripts_dir()) - .output() - .expect("Python helper test should run"); +fn standalone_config_writer_has_valid_javascript_syntax() { + for script in [ + "conformance/write_dataplane_config.mjs", + "standalone/generate_auth_key.mjs", + ] { + let output = Command::new("node") + .arg("--check") + .arg(scripts_dir().join(script)) + .output() + .expect("Node standalone-helper syntax check should run"); - assert!( - output.status.success(), - "standalone config helper failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + assert!( + output.status.success(), + "standalone helper syntax check failed for {script}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } } fn locust_stub() -> TempDir { @@ -111,7 +69,7 @@ def task(_weight): } #[test] -fn locust_adapter_imports_without_the_removed_helper_and_handles_mcp_bodies() { +fn locust_adapter_imports_and_handles_mcp_bodies() { let stub = locust_stub(); let python_path = std::env::join_paths([stub.path(), scripts_dir().as_path()]) .expect("Python path should join"); @@ -304,7 +262,7 @@ adapter.validate_result("server/discover", { } #[test] -fn locust_adapter_validates_and_applies_timeout_to_every_request() { +fn locust_adapter_applies_timeouts_and_disables_redirects_and_environment_proxies() { let stub = locust_stub(); let python_path = std::env::join_paths([stub.path(), scripts_dir().as_path()]) .expect("Python path should join"); @@ -340,20 +298,25 @@ class FakeClient: def __init__(self): self.timeouts = [] - def post(self, _path, *, data, timeout, **_kwargs): + def post(self, _path, *, data, timeout, allow_redirects, **_kwargs): + assert allow_redirects is False self.timeouts.append(("POST", timeout)) payload = json.loads(data) if "id" in payload: return FakeResponse({"jsonrpc": "2.0", "id": payload["id"], "result": {}}) return FakeResponse(status=202) - def delete(self, _path, *, timeout, **_kwargs): + def delete(self, _path, *, timeout, allow_redirects, **_kwargs): + assert allow_redirects is False self.timeouts.append(("DELETE", timeout)) return FakeResponse(status=204) user = adapter.MCPGatewayUser.__new__(adapter.MCPGatewayUser) user._session_id = "session" user.client = FakeClient() +user.on_start() +assert user.client.trust_env is False +user.client.timeouts.clear() assert user._mcp_request("ping", None, name="ping") == {} user._mcp_notification("notifications/initialized", None, name="initialized") user.on_stop() @@ -593,3 +556,137 @@ assert response.successes == 1 and not response.failures String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn standalone_fixture_catalog_preserves_discovered_routes_and_schemas() { + let script = r#" +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +const scriptPath = process.argv[1]; +process.argv[1] = undefined; +const { fixtureCatalog } = await import(pathToFileURL(scriptPath).href); +const schema = { type: 'object', properties: { value: { type: 'string', 'x-mcp-header': 'Value' } } }; +const calls = []; +globalThis.fetch = async (_, options) => { + const request = JSON.parse(options.body); + calls.push(request); + assert.equal(request.params._meta['io.modelcontextprotocol/protocolVersion'], '2026-07-28'); + assert.equal(options.headers['mcp-method'], request.method); + let result; + switch (request.method) { + case 'server/discover': result = {}; break; + case 'tools/list': result = request.params.cursor !== undefined + ? { tools: [{ name: 'new_diagnostic_tool', inputSchema: schema }], nextCursor: null } + : { tools: [{ name: 'first', inputSchema: {} }], nextCursor: '' }; break; + case 'resources/list': result = { resources: [{ uri: 'test://new-resource' }] }; break; + case 'resources/templates/list': result = { resourceTemplates: [{ uriTemplate: 'test://new/{id}' }] }; break; + case 'prompts/list': result = { prompts: [{ name: 'new_prompt' }] }; break; + default: assert.fail(request.method); + } + const message = JSON.stringify({ jsonrpc: '2.0', id: request.id, result }); + return new Response(request.params.cursor !== undefined ? `event: message\ndata: ${message}\n\n` : message, + { headers: { 'content-type': request.params.cursor !== undefined ? 'text/event-stream' : 'application/json' } }); +}; +const catalog = await fixtureCatalog('http://fixture/mcp', '2026-07-28'); +assert.deepEqual(catalog.tools, ['first', 'new_diagnostic_tool']); +assert.deepEqual(catalog.toolSchemas.new_diagnostic_tool, schema); +assert.deepEqual(catalog.resources, ['test://new-resource']); +assert.deepEqual(catalog.resourceTemplates, ['test://new/{id}']); +assert.deepEqual(catalog.prompts, ['new_prompt']); +assert.equal(calls.filter((r) => r.method === 'tools/list').length, 2); + +globalThis.fetch = async (_, options) => { + const request = JSON.parse(options.body); + return Response.json({ id: request.id, error: { code: -32603, message: 'fixture failed' } }); +}; +await assert.rejects(fixtureCatalog('http://fixture/mcp', '2026-07-28'), /successful result/); + +globalThis.fetch = async (_, options) => { + const request = JSON.parse(options.body); + return Response.json({ id: request.id, result: request.method === 'server/discover' ? {} : { + tools: [{ name: 'first', inputSchema: {} }], nextCursor: 'repeated', + }}); +}; +await assert.rejects(fixtureCatalog('http://fixture/mcp', '2026-07-28'), /repeated cursor/); + +const legacyMethods = []; +globalThis.fetch = async (_, options) => { + if (options.method === 'DELETE') { + assert.equal(options.headers['mcp-session-id'], 'legacy-session'); + legacyMethods.push('DELETE'); + return new Response(null, { status: 204 }); + } + const request = JSON.parse(options.body); + legacyMethods.push(request.method); + assert.equal(options.headers['mcp-method'], undefined); + assert.equal(request.params._meta, undefined); + let result; + if (request.method === 'initialize') { + assert.equal(options.headers['mcp-protocol-version'], undefined); + assert.equal(request.params.protocolVersion, '2025-11-25'); + result = { protocolVersion: '2025-11-25' }; + } else { + assert.equal(options.headers['mcp-protocol-version'], '2025-11-25'); + assert.equal(options.headers['mcp-session-id'], 'legacy-session'); + if (request.method === 'notifications/initialized') return new Response(null, { status: 202 }); + result = request.method === 'tools/list' ? { tools: [{ name: 'legacy_tool', inputSchema: schema }] } + : request.method === 'resources/list' ? { resources: [] } + : request.method === 'resources/templates/list' ? { resourceTemplates: [] } + : { prompts: [] }; + } + const message = JSON.stringify({ id: request.id, result }); + return new Response(`event: message\ndata:\n\nevent: message\ndata: ${message}\n\n`, { + headers: { 'content-type': 'text/event-stream', 'mcp-session-id': 'legacy-session' }, + }); +}; +const legacyCatalog = await fixtureCatalog('http://fixture/mcp', '2025-11-25'); +assert.deepEqual(legacyCatalog.tools, ['legacy_tool']); +assert.deepEqual(legacyCatalog.toolSchemas.legacy_tool, schema); +assert.deepEqual(legacyMethods, ['initialize', 'notifications/initialized', 'tools/list', + 'resources/list', 'resources/templates/list', 'prompts/list', 'DELETE']); +"#; + let output = Command::new("node") + .args(["--input-type=module", "--eval", script]) + .arg(scripts_dir().join("conformance/write_dataplane_config.mjs")) + .output() + .expect("Node fixture catalog test runs"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn client_config_writer_publishes_a_schema_for_each_scenario_tool() { + let script = r#" +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +const scriptPath = process.argv[1]; +process.argv = ['node', scriptPath, 'client', 'scenario-server', 'http://fixture/mcp', + '2026-07-28', '["metadata_probe","add_numbers"]']; +process.env.MCP_CONFORMANCE_TOKEN = `header.${Buffer.from('{"sub":"scenario-user"}').toString('base64url')}.signature`; +let published = false; +globalThis.fetch = async (url, options) => { + assert.ok(url.endsWith('/userconfigs/scenario-user')); + assert.equal(options.method, 'POST'); + const host = JSON.parse(options.body).virtual_hosts['scenario-server']; + assert.deepEqual(Object.keys(host.tools), ['metadata_probe', 'add_numbers']); + assert.deepEqual(host.backends['conformance-backend'].tool_schemas, { metadata_probe: {}, add_numbers: {} }); + published = true; + return new Response(null, { status: 202 }); +}; +await import(pathToFileURL(scriptPath).href); +assert.ok(published); +"#; + let output = Command::new("node") + .args(["--input-type=module", "--eval", script]) + .arg(scripts_dir().join("conformance/write_dataplane_config.mjs")) + .output() + .expect("Node config writer test runs"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/src/runtime/conformance/mod.rs b/src/runtime/conformance/mod.rs index 70aea68..326515f 100644 --- a/src/runtime/conformance/mod.rs +++ b/src/runtime/conformance/mod.rs @@ -175,9 +175,12 @@ impl RuntimeContext { &self, topology: StackMode, server_era: ConformanceServerEra, + standalone: bool, + observability: bool, ) -> AppResult<()> { - self.build_conformance_service(topology, server_era).await?; - self.start_conformance_containers(topology, server_era) + self.build_conformance_service(topology, server_era, standalone, observability) + .await?; + self.start_conformance_containers(topology, server_era, standalone, observability) .await } @@ -185,11 +188,13 @@ impl RuntimeContext { &self, topology: StackMode, server_era: ConformanceServerEra, + standalone: bool, + observability: bool, ) -> AppResult<()> { - let project = self.conformance_compose_project(topology); + let project = self.routed_conformance_project(topology, standalone, observability); let build = project.command(["build", OFFICIAL_CONFORMANCE_SERVICE]); let build = self - .compose_environment(build, topology, true)? + .target_environment(build, topology, standalone)? .env(CONFORMANCE_SERVER_ERA_ENV, server_era.label()); Ok(self.runner.run_async(&build).await?) } @@ -198,8 +203,10 @@ impl RuntimeContext { &self, topology: StackMode, server_era: ConformanceServerEra, + standalone: bool, + observability: bool, ) -> AppResult<()> { - let project = self.conformance_compose_project(topology); + let project = self.routed_conformance_project(topology, standalone, observability); let up = project.command([ "up", "-d", @@ -208,41 +215,90 @@ impl RuntimeContext { OFFICIAL_CONFORMANCE_PROXY_SERVICE, ]); let up = self - .compose_environment(up, topology, true)? + .target_environment(up, topology, standalone)? .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)?; + pub(super) fn conformance_fixture_endpoint( + &self, + topology: StackMode, + standalone: bool, + observability: bool, + ) -> AppResult { + let command = self + .routed_conformance_project(topology, standalone, observability) + .command(["port", OFFICIAL_CONFORMANCE_SERVICE, "3000"]); + let command = self.target_environment(command, topology, standalone)?; 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)?; + async fn stop_conformance_service( + &self, + topology: StackMode, + standalone: bool, + observability: bool, + ) -> AppResult<()> { + let remove = self + .routed_conformance_project(topology, standalone, observability) + .command([ + "rm", + "--stop", + "--force", + OFFICIAL_CONFORMANCE_PROXY_SERVICE, + OFFICIAL_CONFORMANCE_SERVICE, + ]); + let remove = self.target_environment(remove, topology, standalone)?; self.runner .run_async(&remove) .await .map_err(AppFailure::from) } + fn routed_conformance_project( + &self, + topology: StackMode, + standalone: bool, + observability: bool, + ) -> ComposeProject { + if standalone { + debug_assert_eq!(topology, StackMode::Dataplane); + self.standalone_conformance_compose_project(observability) + } else { + self.conformance_compose_project(topology) + } + } + pub(super) async fn execute_conformance(&self, action: ConformanceAction) -> AppResult<()> { + self.execute_conformance_with_interrupt(action, async { + if tokio::signal::ctrl_c().await.is_err() { + std::future::pending::<()>().await; + } + }) + .await + } + + async fn execute_conformance_with_interrupt( + &self, + action: ConformanceAction, + interrupt: I, + ) -> AppResult<()> + where + I: Future, + { + // Cancellation belongs to the matrix even if an entry cannot write its + // final artifacts and returns an error instead of an outcome. + let interruption_requested = std::cell::Cell::new(false); + let interrupt = async { + interrupt.await; + interruption_requested.set(true); + }; + tokio::pin!(interrupt); match action { ConformanceAction::Run { lanes, + standalone, client_eras: _, client_versions, server_eras, @@ -287,7 +343,14 @@ impl RuntimeContext { let executor = RuntimeContext::new(self.config.clone(), quiet_runner); let matrix_started = Instant::now(); let run_result = executor - .run_conformance(&lanes, &client_version, server_era, &paths) + .run_conformance_with_interrupt( + &lanes, + &client_version, + server_era, + &paths, + standalone, + interrupt.as_mut(), + ) .await; let run_completed = matches!( &run_result, @@ -568,6 +631,10 @@ impl RuntimeContext { setup_log.display() ); } + if interruption_requested.get() { + reported_failure = true; + break; + } } let baselines_updated = finalize_conformance_execution( @@ -593,27 +660,13 @@ impl RuntimeContext { } } - 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, + standalone: bool, interrupt: I, ) -> AppResult where @@ -713,7 +766,11 @@ impl RuntimeContext { if !topologies.is_empty() && !interrupted { let cleanup_progress = Activity::spinner("Clear prior integration stacks"); - let cleanup_result = self.cleanup(LaneSelection::All, CleanupKind::Reset); + let cleanup_result = if standalone { + self.cleanup_standalone_dataplane(CleanupKind::Reset) + } else { + self.cleanup(LaneSelection::All, CleanupKind::Reset) + }; cleanup_progress.finish(cleanup_result.is_ok()); if let Err(error) = cleanup_result { failures.push(ConformanceOperationalFailure::server( @@ -728,10 +785,15 @@ impl RuntimeContext { if interrupted { break; } + let standalone_topology = standalone && topology == StackMode::Dataplane; let target = conformance_target(topology); let run_routed = lanes.contains(&target); let stack_progress = Activity::spinner(format!("Prepare {}", topology.lane_label())); - let mut topology_failure = self.stack_up_for_conformance(topology, true).await.err(); + let mut topology_failure = if standalone_topology { + self.stack_up_standalone_dataplane(true, true).await.err() + } else { + self.stack_up_for_conformance(topology, true).await.err() + }; let stack_started = topology_failure.is_none(); stack_progress.finish(topology_failure.is_none()); let mut fixture_state = None; @@ -745,7 +807,7 @@ impl RuntimeContext { topology.lane_label() )); let (start_result, start_interrupted) = finish_phase_after_interrupt( - self.start_conformance_service(topology, server_era), + self.start_conformance_service(topology, server_era, standalone_topology, true), interrupt.as_mut(), ) .await; @@ -774,7 +836,7 @@ impl RuntimeContext { } } - if topology_failure.is_none() && run_routed { + if topology_failure.is_none() && run_routed && !standalone_topology { match self.admin_session_token().await.and_then(|token| { ConformanceFixtureClient::builder(self.base_url()?, token) .build() @@ -830,41 +892,64 @@ impl RuntimeContext { } if topology_failure.is_none() && run_routed { - let run_inputs = fixture_state - .as_ref() - .map(|(_, fixture)| fixture) - .zip(fixture_metadata.as_ref()); + let server_id = if standalone_topology { + Some(OFFICIAL_CONFORMANCE_SERVER_ID) + } else { + fixture_state + .as_ref() + .map(|(_, fixture)| fixture.server_id.as_str()) + }; + let run_inputs = server_id.zip(fixture_metadata.as_ref()); match run_inputs { - Some((fixture, metadata)) => match self.issue_conformance_token().await { + Some((server_id, metadata)) => match if standalone_topology { + self.standalone_dataplane_token(true) + } else { + self.issue_conformance_token().await + } { Ok(token) => { managed_token = Some(token); let token = managed_token .as_ref() .expect("managed token was just stored"); - let tests = async { - self.run_official_conformance_mode( - &OfficialConformanceRun { - topology, - server_id: &fixture.server_id, - token: &token.value, - spec_version, - server_era, - fixture: metadata, - cancellation: cancellation_receiver.clone(), - }, - paths, + if standalone_topology + && let Err(error) = self.publish_standalone_conformance_config( + server_id, + server_era + .protocol_versions() + .last() + .expect("fixture has supported protocols"), + &token.value, + true, ) - .await - .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()); + { + topology_failure = Some(error); + } + if topology_failure.is_none() { + let tests = async { + self.run_official_conformance_mode( + &OfficialConformanceRun { + topology, + server_id, + token: &token.value, + spec_version, + server_era, + fixture: metadata, + cancellation: cancellation_receiver.clone(), + }, + paths, + ) + .await + .err() + }; + tokio::pin!(tests); + tokio::select! { + failure = &mut tests => topology_failure = failure, + () = interrupt.as_mut() => { + interrupted = true; + cancellation_sender.send_replace(true); + let _ = tests.await; + topology_failure = Some(interrupted_conformance_failure()); + } } } } @@ -878,7 +963,7 @@ impl RuntimeContext { } } - if let Some(token) = managed_token.as_ref() { + if !standalone_topology && let Some(token) = managed_token.as_ref() { topology_failure = finish_with_cleanup(topology_failure, self.revoke_managed_token(token).await) .err(); @@ -889,7 +974,9 @@ impl RuntimeContext { .cleanup(Some(&fixture)) .await .map_err(AppFailure::from); - let service_cleanup = self.stop_conformance_service(topology).await; + let service_cleanup = self + .stop_conformance_service(topology, standalone_topology, true) + .await; topology_failure = finish_with_cleanup( topology_failure, combine_cleanup_results(api_cleanup, service_cleanup), @@ -898,7 +985,8 @@ impl RuntimeContext { } else if service_started { topology_failure = finish_with_cleanup( topology_failure, - self.stop_conformance_service(topology).await, + self.stop_conformance_service(topology, standalone_topology, true) + .await, ) .err(); } @@ -906,11 +994,12 @@ impl RuntimeContext { if can_reuse_external_stack(topology, stack_started, interrupted, run_external_client) { external_stack_retained = true; } else { - topology_failure = finish_with_cleanup( - topology_failure, - self.cleanup(topology_selection(topology), CleanupKind::Down), - ) - .err(); + let cleanup = if standalone_topology { + self.cleanup_standalone_dataplane(CleanupKind::Down) + } else { + self.cleanup(topology_selection(topology), CleanupKind::Down) + }; + topology_failure = finish_with_cleanup(topology_failure, cleanup).err(); } if let Some(error) = topology_failure { failures.push(ConformanceOperationalFailure::server( @@ -932,6 +1021,7 @@ impl RuntimeContext { paths, cancellation_receiver.clone(), external_stack_retained, + standalone, ); tokio::pin!(client); tokio::select! { @@ -1049,6 +1139,7 @@ impl RuntimeContext { paths: &ConformancePaths, cancellation: tokio::sync::watch::Receiver, reuse_stack: bool, + standalone: bool, ) -> AppResult<()> { let progress = if reuse_stack { "Reuse external dataplane for client conformance" @@ -1056,21 +1147,28 @@ impl RuntimeContext { "Prepare external dataplane client conformance" }; let stack_progress = Activity::spinner(progress); - let stack_result = self - .stack_up_for_conformance(StackMode::Dataplane, !reuse_stack) - .await; + let stack_result = if standalone { + self.stack_up_standalone_dataplane(!reuse_stack, true).await + } else { + self.stack_up_for_conformance(StackMode::Dataplane, !reuse_stack) + .await + }; stack_progress.finish(stack_result.is_ok()); let mut failure = stack_result.err(); let mut token = None; let mut publisher_stopped = false; if failure.is_none() { - match self.issue_conformance_token().await { + match if standalone { + self.standalone_dataplane_token(true) + } else { + self.issue_conformance_token().await + } { Ok(issued) => token = Some(issued), Err(error) => failure = Some(error), } } - if failure.is_none() { + if failure.is_none() && !standalone { let progress = Activity::spinner("Pause the control-plane publisher"); let result = self.set_control_plane_publisher(false).await; progress.finish(result.is_ok()); @@ -1089,6 +1187,7 @@ impl RuntimeContext { &issued.value, paths, cancellation, + standalone, ) .await .err(); @@ -1107,13 +1206,15 @@ impl RuntimeContext { progress.finish(result.is_ok()); failure = finish_with_cleanup(failure, result).err(); } - if let Some(token) = token.as_ref() { + if !standalone && let Some(token) = token.as_ref() { failure = finish_with_cleanup(failure, self.revoke_managed_token(token).await).err(); } - finish_with_cleanup( - failure, - self.cleanup(topology_selection(StackMode::Dataplane), CleanupKind::Down), - ) + let cleanup = if standalone { + self.cleanup_standalone_dataplane(CleanupKind::Down) + } else { + self.cleanup(topology_selection(StackMode::Dataplane), CleanupKind::Down) + }; + finish_with_cleanup(failure, cleanup) } async fn set_control_plane_publisher(&self, running: bool) -> AppResult<()> { @@ -1130,6 +1231,46 @@ impl RuntimeContext { .map_err(AppFailure::from) } + pub(super) fn publish_standalone_conformance_config( + &self, + server_id: &str, + protocol_version: &str, + token: &str, + observability: bool, + ) -> AppResult> { + let progress = Activity::spinner("Publish mocked Redis conformance configuration"); + let command = self + .standalone_conformance_compose_project(observability) + .command([ + "run", + "--rm", + "--no-deps", + "-e", + CLIENT_TOKEN_ENV, + "config_writer", + "fixture", + server_id, + OFFICIAL_CONFORMANCE_BACKEND_URL, + protocol_version, + ]); + let command = self + .standalone_dataplane_environment(command, true)? + .env(CLIENT_TOKEN_ENV, token); + let result = self.capture_text(&command).and_then(|output| { + let tool_names = serde_json::from_str::>(&output) + .context("standalone config helper returned invalid tool names") + .map_err(AppFailure::from)?; + if tool_names.is_empty() { + return Err(AppFailure::from(anyhow!( + "standalone Redis config for server {server_id} contains no tools" + ))); + } + Ok(tool_names) + }); + progress.finish(result.is_ok()); + result + } + async fn run_official_client_conformance( &self, spec_version: &str, @@ -1137,6 +1278,7 @@ impl RuntimeContext { token: &str, paths: &ConformancePaths, cancellation: tokio::sync::watch::Receiver, + standalone: bool, ) -> AppResult<()> { let expected_scenarios = expected_client_scenarios(spec_version).map_err(AppFailure::from)?; @@ -1177,12 +1319,23 @@ impl RuntimeContext { }, )?; - let compose = self.compose_environment( + let compose_project = if standalone { + self.standalone_conformance_compose_project(true) + } else { self.conformance_runtime_project(StackMode::Dataplane) - .command(std::iter::empty::<&str>()), - StackMode::Dataplane, - true, - )?; + }; + let compose = if standalone { + self.standalone_dataplane_environment( + compose_project.command(std::iter::empty::<&str>()), + true, + )? + } else { + self.compose_environment( + compose_project.command(std::iter::empty::<&str>()), + StackMode::Dataplane, + true, + )? + }; let compose_args = compose .arguments() .iter() @@ -1800,6 +1953,99 @@ mod tests { use super::*; use crate::conformance::results::{CheckStatus, ConformanceCheck, ConformanceScenarioResult}; + #[derive(Default)] + struct MatrixRunner(std::cell::RefCell>); + + impl ProcessRunner for MatrixRunner { + fn run(&self, spec: &CommandSpec) -> Result<(), InfrastructureError> { + self.0.borrow_mut().push(spec.clone()); + Ok(()) + } + fn run_async_to_log<'a>( + &'a self, + spec: &'a CommandSpec, + _: &'a Path, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + self.run(spec)?; + // Leave setup pending long enough for the injected interrupt. + tokio::task::yield_now().await; + Ok(()) + }) + } + fn capture_stdout(&self, _: &CommandSpec) -> Result, InfrastructureError> { + Ok(b"test-docker".to_vec()) + } + fn capture_output( + &self, + _: &CommandSpec, + ) -> Result { + Ok(crate::infrastructure::process::CapturedOutput::new( + b"test-docker".to_vec(), + Vec::new(), + )) + } + fn run_to_log(&self, spec: &CommandSpec, _: &Path) -> Result<(), InfrastructureError> { + self.run(spec) + } + } + + #[tokio::test] + async fn interruption_finishes_cleanup_and_stops_the_entire_matrix_without_blessing() { + use crate::infrastructure::config::{ConfigBootstrap, ConfigRequirements, Environment}; + let directory = tempfile::tempdir().expect("temporary root"); + let config = AppConfig::load( + ConfigBootstrap::load(&Environment::new(), directory.path()).expect("bootstrap"), + ConfigRequirements::StandaloneRuntime, + ) + .expect("config"); + let runtime = RuntimeContext::new(config, MatrixRunner::default()); + let artifacts = directory.path().join("results"); + let baselines = directory.path().join("baselines"); + fs::create_dir(&baselines).expect("baseline directory"); + let sentinel = baselines.join("existing.yml"); + fs::write(&sentinel, "unchanged").expect("existing baseline"); + let result = runtime + .execute_conformance_with_interrupt( + ConformanceAction::Run { + lanes: vec![SemanticLane::FixtureDirect], + standalone: true, + client_eras: vec![ConformanceServerEra::Modern], + client_versions: vec![DEFAULT_MCP_SPEC_VERSION.to_owned()], + server_eras: vec![ConformanceServerEra::Modern, ConformanceServerEra::Legacy], + results_dir: Some(artifacts.clone()), + baseline_dir: Some(baselines.clone()), + bless: true, + output_dir: Some(directory.path().join("reports")), + }, + std::future::ready(()), + ) + .await; + assert!(result.is_err(), "interrupt must fail the run"); + assert!( + artifacts + .join("conformance/2026-07-28/modern/setup.log") + .is_file() + ); + assert!( + !artifacts.join("conformance/2026-07-28/legacy").exists(), + "next matrix entry started after cancellation" + ); + assert_eq!(fs::read_to_string(sentinel).expect("baseline"), "unchanged"); + assert_eq!(fs::read_dir(baselines).expect("baselines").count(), 1); + let commands = runtime.runner.0.borrow(); + let build = commands + .iter() + .position(|command| command.arguments().contains(&OsString::from("build"))) + .expect("fixture setup started"); + assert!( + commands[build + 1..] + .iter() + .any(|command| command.arguments().contains(&OsString::from("down"))), + "fixture cleanup was skipped" + ); + } + fn conformance_result(scenario: &str, status: CheckStatus) -> ConformanceScenarioResult { ConformanceScenarioResult { scenario: scenario.to_owned(), diff --git a/src/runtime/inspect.rs b/src/runtime/inspect.rs index 97e9d13..1286ea9 100644 --- a/src/runtime/inspect.rs +++ b/src/runtime/inspect.rs @@ -21,6 +21,7 @@ impl RuntimeContext { pub(super) async fn inspect( &self, mode: StackMode, + standalone: bool, protocol_version: &ProtocolVersion, method: &str, server_id: Option<&str>, @@ -29,40 +30,47 @@ impl RuntimeContext { .unwrap_or_else(|| self.default_server_id()) .to_owned(); let operation_server_id = server_id.clone(); - self.with_managed_authenticated_target(mode, &server_id, false, |token, _| async move { - let endpoint = GatewayClient::new( - gateway_topology(mode), - self.base_url()?, - &operation_server_id, - &token, - ) - .context("failed to construct the Inspector gateway endpoint") - .map_err(AppFailure::from)? - .endpoint() - .clone(); - let proxy = AuthProxy::start_with_protocol_version( - endpoint, - &token, - Some(protocol_version.wire_version()), - ) - .await - .context("failed to start the Inspector authentication proxy") - .map_err(AppFailure::from)?; - let command = allowlisted_npx_environment( - inspector_command(proxy.url().as_str(), method).cwd(self.config.root()), - ); - let process_result = self - .runner - .run_async(&command) + self.with_managed_authenticated_target( + mode, + &server_id, + standalone, + true, + protocol_version, + |token, _| async move { + let endpoint = GatewayClient::new( + gateway_topology(mode), + self.base_url()?, + &operation_server_id, + &token, + ) + .context("failed to construct the Inspector gateway endpoint") + .map_err(AppFailure::from)? + .endpoint() + .clone(); + let proxy = AuthProxy::start_with_protocol_version( + endpoint, + &token, + Some(protocol_version.wire_version()), + ) .await - .map_err(AppFailure::from); - let shutdown_result = proxy - .shutdown() - .await - .context("failed to stop the Inspector authentication proxy") - .map_err(AppFailure::from); - finish_with_cleanup(process_result.err(), shutdown_result) - }) + .context("failed to start the Inspector authentication proxy") + .map_err(AppFailure::from)?; + let command = allowlisted_npx_environment( + inspector_command(proxy.url().as_str(), method).cwd(self.config.root()), + ); + let process_result = self + .runner + .run_async(&command) + .await + .map_err(AppFailure::from); + let shutdown_result = proxy + .shutdown() + .await + .context("failed to stop the Inspector authentication proxy") + .map_err(AppFailure::from); + finish_with_cleanup(process_result.err(), shutdown_result) + }, + ) .await } } diff --git a/src/runtime/live/mod.rs b/src/runtime/live/mod.rs index ab5109c..c6e2d23 100644 --- a/src/runtime/live/mod.rs +++ b/src/runtime/live/mod.rs @@ -48,20 +48,13 @@ impl RuntimeContext { ) -> AppResult<()> { let server_id = self.default_server_id().to_owned(); self.with_managed_test_target(topology, &server_id, || async { - match group { - LiveGroup::Mcp => { - self.run_controlplane_make(topology, "test-mcp-protocol-e2e", protocol_version) - } - LiveGroup::Rbac => { - self.run_controlplane_make(topology, "test-mcp-rbac", protocol_version) - } - LiveGroup::Protocol => self.run_controlplane_make( - topology, - "test-protocol-compliance-gateway", - protocol_version, - ), - LiveGroup::All => self.run_live_all(topology, protocol_version), - } + let target = match group { + LiveGroup::Mcp => "test-mcp-protocol-e2e", + LiveGroup::Rbac => "test-mcp-rbac", + LiveGroup::Protocol => "test-protocol-compliance-gateway", + LiveGroup::All => return self.run_live_all(topology, protocol_version), + }; + self.run_controlplane_make(topology, target, protocol_version) }) .await } @@ -79,9 +72,8 @@ impl RuntimeContext { .arg(self.config.controlplane_dir().as_os_str()) .arg(target); let command = self.live_protocol_environment(command, protocol_version)?; - self.runner - .run(&self.compose_environment(command, topology, false)?) - .map_err(AppFailure::from) + let command = self.compose_environment(command, topology, false)?; + self.runner.run(&command).map_err(AppFailure::from) })(); let status = if result.is_ok() { TestStatus::Pass diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 678789d..3f14bfc 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -49,8 +49,8 @@ use crate::performance::{LoadSettings, LocustCommand, audit_locust_reports}; use anyhow::{Context, anyhow}; use crate::app::{ - Action, CiAction, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, - selected_topologies, topology_selection, + Action, ConformanceAction, DebugAction, ResolvedLoadArgs, StackAction, selected_topologies, + topology_selection, }; use crate::cli::{LaneSelection, LiveGroup, ProtocolVersion, TokenKind as CliTokenKind}; use crate::error::AppFailure; @@ -78,8 +78,8 @@ use control_plane::CONFORMANCE_TOKEN_DESCRIPTION; use control_plane::{ControlPlaneClient, ManagedBearerToken}; use inspect::*; -/// Shared dependencies borrowed by concrete workflow owners. -struct RuntimeContext { +/// Runtime dependencies and execution of resolved CLI actions. +pub(crate) struct RuntimeContext { config: AppConfig, runner: R, controlplane_image: OnceLock, @@ -88,7 +88,7 @@ struct RuntimeContext { impl RuntimeContext { /// Creates runtime context without starting any process. #[must_use] - fn new(config: AppConfig, runner: R) -> Self { + pub(crate) fn new(config: AppConfig, runner: R) -> Self { Self { config, runner, @@ -97,117 +97,60 @@ impl RuntimeContext { } } -/// Small CLI action dispatcher backed by concrete workflow owners. -pub(crate) struct RuntimeDispatcher { - context: RuntimeContext, -} - -impl RuntimeDispatcher { - /// Creates a dispatcher without starting any process. - #[must_use] - pub(crate) fn new(config: AppConfig, runner: R) -> Self { - Self { - context: RuntimeContext::new(config, runner), - } - } -} - -struct StackWorkflow<'a, R>(&'a RuntimeContext); -struct ProbeWorkflow<'a, R>(&'a RuntimeContext); -struct PerformanceWorkflow<'a, R>(&'a RuntimeContext); -struct LiveWorkflow<'a, R>(&'a RuntimeContext); -struct ConformanceWorkflow<'a, R>(&'a RuntimeContext); -struct CiWorkflow<'a, R>(&'a RuntimeContext); - -impl RuntimeDispatcher { - /// Dispatches one fully resolved operation through its workflow owner. +impl RuntimeContext { + /// Executes one fully resolved operation. pub(crate) async fn execute(&self, action: Action) -> AppResult<()> { match action { - Action::Stack(action) => StackWorkflow(&self.context).execute(action).await, + Action::Stack(action) => self.execute_stack(action).await, Action::Probe { topology, + standalone, protocol_version, } => { - ProbeWorkflow(&self.context) - .execute(topology, &protocol_version) + self.run_probe(topology, standalone, &protocol_version) .await } - Action::Load(args) => PerformanceWorkflow(&self.context).execute(args).await, + Action::Load(args) => self.run_load(args).await, Action::Live { lane, group, protocol_version, - } => { - LiveWorkflow(&self.context) - .execute(lane, group, &protocol_version) - .await - } - Action::Conformance(action) => ConformanceWorkflow(&self.context).execute(action).await, - Action::Ci(action) => CiWorkflow(&self.context).execute(action).await, - Action::Debug(DebugAction::Token { kind, server_id }) => { - self.context.print_token(kind, server_id).await - } + } => self.run_live(lane, group, &protocol_version).await, + Action::Conformance(action) => self.execute_conformance(action).await, + Action::Ci(action) => self.execute_ci(action).await, + Action::Debug(DebugAction::Token { + kind, + server_id, + standalone, + }) => self.print_token(kind, server_id, standalone).await, Action::Debug(DebugAction::Inspect { topology, + standalone, protocol_version, method, server_id, }) => { - self.context - .inspect(topology, &protocol_version, &method, server_id.as_deref()) - .await + self.inspect( + topology, + standalone, + &protocol_version, + &method, + server_id.as_deref(), + ) + .await } } } -} - -impl<'a, R: ProcessRunner> StackWorkflow<'a, R> { - async fn execute(&self, action: StackAction) -> AppResult<()> { - self.0.execute_stack(action).await - } -} - -impl<'a, R: ProcessRunner> ProbeWorkflow<'a, R> { - async fn execute( + async fn print_token( &self, - topology: StackMode, - protocol_version: &ProtocolVersion, + kind: CliTokenKind, + server_id: Option, + standalone: bool, ) -> 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<'a, R: ProcessRunner> CiWorkflow<'a, R> { - async fn execute(&self, action: CiAction) -> AppResult<()> { - self.0.execute_ci(action).await - } -} - -impl RuntimeContext { - async fn print_token(&self, kind: CliTokenKind, server_id: Option) -> AppResult<()> { + if standalone { + println!("{}", self.standalone_dataplane_token(true)?.value); + return Ok(()); + } let token = match kind { CliTokenKind::Scoped => { let server_id = server_id.unwrap_or_else(|| self.default_server_id().to_owned()); @@ -483,7 +426,7 @@ mod tests { .map(|(key, value)| (OsString::from(key), OsString::from(value))), ); let bootstrap = ConfigBootstrap::load(&environment, root).expect("bootstrap should load"); - AppConfig::load(bootstrap, ConfigRequirements::RUNTIME) + AppConfig::load(bootstrap, ConfigRequirements::Runtime) .expect("test application config should load") } diff --git a/src/runtime/performance/mod.rs b/src/runtime/performance/mod.rs index fed45d2..e1b5721 100644 --- a/src/runtime/performance/mod.rs +++ b/src/runtime/performance/mod.rs @@ -9,14 +9,22 @@ impl RuntimeContext { let server_id = self.default_server_id().to_owned(); let operation_server_id = server_id.clone(); let preparation = Activity::spinner("Preparing performance stack"); - self.with_managed_performance_target( + self.with_managed_authenticated_target( args.topology, &server_id, args.standalone, args.observability, + &args.protocol_version, |token, standalone_tool_names| async move { + let project = if args.standalone { + self.standalone_dataplane_project(args.observability) + .with_profiles(["performance"]) + } else { + self.performance_compose_project(args.topology, args.observability) + }; let command = LocustCommand::new_with_protocol_version( &self.config, + project, args.topology, &settings, &token, @@ -24,8 +32,11 @@ impl RuntimeContext { args.protocol_version.wire_version(), ) .map_err(AppFailure::from)?; - let mut command_spec = - self.compose_environment(command.command().clone(), args.topology, true)?; + let mut command_spec = self.target_environment( + command.command().clone(), + args.topology, + args.standalone, + )?; if args.standalone { command_spec = command_spec .env("MCP_TOOL_NAMES", standalone_tool_names.join(",")) diff --git a/src/runtime/probe.rs b/src/runtime/probe.rs index 1f2f50a..980ad0d 100644 --- a/src/runtime/probe.rs +++ b/src/runtime/probe.rs @@ -6,40 +6,49 @@ impl RuntimeContext { pub(super) async fn run_probe( &self, topology: StackMode, + standalone: bool, protocol_version: &ProtocolVersion, ) -> AppResult<()> { let server_id = self.default_server_id().to_owned(); - self.with_managed_authenticated_target(topology, &server_id, false, |token, _| async { - let config = ProbeConfig { - mode: gateway_topology(topology), - base_url: self.base_url()?.to_owned(), - server_id: server_id.clone(), - bearer_token: token, - config_timeout: Duration::from_secs( - self.environment_u64("CF_PROBE_CONFIG_TIMEOUT", 120)?, - ), - retry_interval: Duration::from_secs(5), - request_timeout: Duration::from_secs( - self.environment_u64("CF_PROBE_REQUEST_TIMEOUT", 30)?, - ), - protocol_version: protocol_version.wire_version().to_owned(), - output_style: OutputStyle::stdout(), - }; - let transport = GatewayClient::builder( - config.mode, - &config.base_url, - &config.server_id, - &config.bearer_token, - ) - .protocol_version(config.protocol_version.clone()) - .build() - .map_err(|error| AppFailure::from(anyhow!(error)))?; - let stdout = std::io::stdout(); - let mut output = stdout.lock(); - run_probe(&transport, &config, &mut output) - .await - .map_err(AppFailure::from) - }) + self.with_managed_authenticated_target( + topology, + &server_id, + standalone, + true, + protocol_version, + |token, tool_names| async { + let config = ProbeConfig { + mode: gateway_topology(topology), + base_url: self.base_url()?.to_owned(), + server_id: server_id.clone(), + bearer_token: token, + config_timeout: Duration::from_secs( + self.environment_u64("CF_PROBE_CONFIG_TIMEOUT", 120)?, + ), + retry_interval: Duration::from_secs(5), + request_timeout: Duration::from_secs( + self.environment_u64("CF_PROBE_REQUEST_TIMEOUT", 30)?, + ), + protocol_version: protocol_version.wire_version().to_owned(), + tool_names, + output_style: OutputStyle::stdout(), + }; + let transport = GatewayClient::builder( + config.mode, + &config.base_url, + &config.server_id, + &config.bearer_token, + ) + .protocol_version(config.protocol_version.clone()) + .build() + .map_err(|error| AppFailure::from(anyhow!(error)))?; + let stdout = std::io::stdout(); + let mut output = stdout.lock(); + run_probe(&transport, &config, &mut output) + .await + .map_err(AppFailure::from) + }, + ) .await } } diff --git a/src/runtime/session.rs b/src/runtime/session.rs index 4f56b0a..ada8425 100644 --- a/src/runtime/session.rs +++ b/src/runtime/session.rs @@ -17,6 +17,8 @@ for _, key in ipairs(redis.call('KEYS', '*UserConfig*')) do end return 0 "#; +const STANDALONE_TENANT_ID: &str = "cf-integration"; +const STANDALONE_USER_ID: &str = "cf-integration@example.invalid"; struct ManagedSessionScope<'a, R> { runtime: &'a RuntimeContext, @@ -37,24 +39,18 @@ impl<'a, R: ProcessRunner> ManagedSessionScope<'a, R> { async fn finish(self, primary: AppResult<()>) -> AppResult<()> { let mut cleanup_failures = Vec::new(); - if self.standalone - && self - .token - .as_ref() - .is_some_and(|token| token.catalog_id.is_some()) - && let Err(error) = self.runtime.restore_control_plane_gateway().await - { - cleanup_failures.push(error); - } if let Some(token) = self.token.as_ref() && let Err(error) = self.runtime.revoke_managed_token(token).await { cleanup_failures.push(error); } - if let Err(error) = self - .runtime - .cleanup_quiet(topology_selection(self.topology), CleanupKind::Down) - { + let cleanup = if self.standalone { + self.runtime.cleanup_standalone_dataplane(CleanupKind::Down) + } else { + self.runtime + .cleanup_quiet(topology_selection(self.topology), CleanupKind::Down) + }; + if let Err(error) = cleanup { cleanup_failures.push(error); } finish_with_cleanup_failures(primary.err(), cleanup_failures) @@ -62,6 +58,33 @@ impl<'a, R: ProcessRunner> ManagedSessionScope<'a, R> { } impl RuntimeContext { + pub(super) fn standalone_dataplane_token( + &self, + observability: bool, + ) -> AppResult { + let command = self.standalone_dataplane_project(observability).command([ + "run", + "--rm", + "--no-deps", + "config_writer", + "token", + STANDALONE_TENANT_ID, + STANDALONE_USER_ID, + ]); + let command = self.standalone_dataplane_environment(command, true)?; + let output = self.runner.capture_stdout(&command)?; + let token = std::str::from_utf8(&output) + .context("standalone dataplane token helper returned non-UTF-8 output") + .map_err(AppFailure::from)? + .trim(); + if token.split('.').count() != 3 { + return Err(AppFailure::from(anyhow!( + "standalone dataplane token helper returned an invalid JWT" + ))); + } + Ok(ManagedBearerToken::unmanaged(token.to_owned())) + } + pub(super) async fn with_managed_test_target( &self, topology: StackMode, @@ -73,68 +96,22 @@ impl RuntimeContext { Fut: Future>, { let scope = ManagedSessionScope::new(self, topology, false); - let primary = match self.stack_up(topology, false).await { - Ok(()) => match self.prepare_test_target(topology, server_id).await { - Ok(()) => operation().await, - Err(error) => Err(error), - }, - Err(error) => Err(error), - }; + let primary = async { + self.stack_up(topology, false).await?; + self.prepare_test_target(topology, server_id).await?; + operation().await + } + .await; scope.finish(primary).await } pub(super) async fn with_managed_authenticated_target( - &self, - topology: StackMode, - server_id: &str, - standalone: bool, - operation: F, - ) -> AppResult<()> - where - F: FnOnce(String, Vec) -> Fut, - Fut: Future>, - { - self.with_managed_authenticated_target_project( - topology, - server_id, - standalone, - self.compose_project(topology), - true, - operation, - ) - .await - } - - pub(super) async fn with_managed_performance_target( &self, topology: StackMode, server_id: &str, standalone: bool, observability: bool, - operation: F, - ) -> AppResult<()> - where - F: FnOnce(String, Vec) -> Fut, - Fut: Future>, - { - self.with_managed_authenticated_target_project( - topology, - server_id, - standalone, - self.performance_compose_project(topology, observability), - observability, - operation, - ) - .await - } - - async fn with_managed_authenticated_target_project( - &self, - topology: StackMode, - server_id: &str, - standalone: bool, - project: ComposeProject, - observability: bool, + protocol_version: &ProtocolVersion, operation: F, ) -> AppResult<()> where @@ -147,48 +124,49 @@ impl RuntimeContext { ))); } let mut scope = ManagedSessionScope::new(self, topology, standalone); - let primary = match self - .stack_up_with_project(topology, false, project, false, observability) - .await - { - Ok(()) => match self - .prepare_authenticated_target(topology, server_id, standalone) - .await - { - Ok(()) => match self.managed_bearer_token(topology, server_id).await { - Ok(token) => { - let value = token.value.clone(); - scope.token = Some(token); - let tool_names = if standalone { - self.isolate_external_dataplane(server_id, &value).await - } else { - Ok(Vec::new()) - }; - match tool_names { - Ok(tool_names) => operation(value, tool_names).await, - Err(error) => Err(error), - } - } - Err(error) => Err(error), - }, - Err(error) => Err(error), - }, - Err(error) => Err(error), - }; + let primary = async { + let token = if standalone { + self.stack_up_standalone_dataplane(false, observability) + .await?; + self.start_standalone_fixture(protocol_version, observability) + .await?; + self.standalone_dataplane_token(observability)? + } else { + let project = self.performance_compose_project(topology, observability); + self.stack_up_with_project(topology, false, project, false, observability) + .await?; + self.prepare_test_target(topology, server_id).await?; + self.managed_bearer_token(topology, server_id).await? + }; + let value = token.value.clone(); + scope.token = Some(token); + let tool_names = if standalone { + self.publish_standalone_conformance_config( + server_id, + protocol_version.wire_version(), + &value, + observability, + )? + } else { + Vec::new() + }; + operation(value, tool_names).await + } + .await; scope.finish(primary).await } - async fn prepare_authenticated_target( + async fn start_standalone_fixture( &self, - topology: StackMode, - server_id: &str, - standalone: bool, + protocol_version: &ProtocolVersion, + observability: bool, ) -> AppResult<()> { - if standalone { - self.ensure_other_stack_stopped(topology)?; - return Ok(()); - } - self.prepare_test_target(topology, server_id).await + let server_era = match protocol_version { + ProtocolVersion::Modern => ConformanceServerEra::Modern, + ProtocolVersion::Legacy => ConformanceServerEra::Legacy, + }; + self.start_conformance_service(StackMode::Dataplane, server_era, true, observability) + .await } pub(super) async fn prepare_test_target( @@ -235,70 +213,6 @@ impl RuntimeContext { } } - async fn isolate_external_dataplane( - &self, - server_id: &str, - token: &str, - ) -> AppResult> { - let project = self.compose_project(StackMode::Dataplane); - let command = project.command([ - "--profile", - "standalone-load", - "up", - "--detach", - "--wait", - "standalone_load_backend", - ]); - let command = self.compose_environment(command, StackMode::Dataplane, true)?; - self.runner.run(&command)?; - let command = StackCommandPlan::stop_service(project.clone(), "gateway"); - let command = - self.compose_environment(command.command().clone(), StackMode::Dataplane, true)?; - self.runner.run(&command)?; - let command = project.command([ - "run", - "--rm", - "--no-deps", - "-e", - "MCPGATEWAY_BEARER_TOKEN", - "--entrypoint", - "python3", - "gateway", - "/opt/contextforge-integration/prepare_standalone_config.py", - server_id, - ProtocolVersion::Modern.wire_version(), - ]); - let command = self - .compose_environment(command, StackMode::Dataplane, true)? - .env("MCPGATEWAY_BEARER_TOKEN", token); - let tool_names = self.capture_text(&command)?; - let tool_names = serde_json::from_str::>(&tool_names) - .context("standalone config helper returned invalid tool names") - .map_err(AppFailure::from)?; - if tool_names.is_empty() { - return Err(AppFailure::from(anyhow!( - "standalone Redis config for server {server_id} contains no tools" - ))); - } - let command = StackCommandPlan::restart_service(project, "dataplane"); - let command = - self.compose_environment(command.command().clone(), StackMode::Dataplane, true)?; - self.runner.run(&command)?; - self.wait_for_public_endpoint(StackMode::Dataplane, false) - .await?; - Ok(tool_names) - } - - async fn restore_control_plane_gateway(&self) -> AppResult<()> { - let project = self.compose_project(StackMode::Dataplane); - let command = StackCommandPlan::start_service(project, "gateway"); - let command = - self.compose_environment(command.command().clone(), StackMode::Dataplane, true)?; - self.runner.run(&command)?; - self.wait_for_public_endpoint(StackMode::Controlplane, false) - .await - } - fn dataplane_redis_container(&self) -> AppResult { let project = required_text( &self.config.integration_project().value, diff --git a/src/runtime/stack/mod.rs b/src/runtime/stack/mod.rs index 04f00ed..6e40fa1 100644 --- a/src/runtime/stack/mod.rs +++ b/src/runtime/stack/mod.rs @@ -9,8 +9,17 @@ const COMPOSE_PROTOCOL_VERSION_ENV: &str = "MCP_PROTOCOL_VERSION"; impl RuntimeContext { pub(super) async fn execute_stack(&self, action: StackAction) -> AppResult<()> { match action { - StackAction::Up { topology, fresh } => { - self.stack_up_for_conformance(topology, fresh).await?; + StackAction::Up { + topology, + protocol_version, + fresh, + standalone, + } => { + if standalone { + self.stack_up_standalone_dataplane(fresh, true).await?; + } else { + self.stack_up_for_conformance(topology, fresh).await?; + } let build_log = self .config @@ -20,8 +29,12 @@ impl RuntimeContext { let quiet_runner = LoggingProcessRunner::new(&self.runner, &build_log); let quiet_runtime = RuntimeContext::new(self.config.clone(), quiet_runner); let build_progress = Activity::spinner("Building conformance image"); + let server_era = match protocol_version { + ProtocolVersion::Modern => ConformanceServerEra::Modern, + ProtocolVersion::Legacy => ConformanceServerEra::Legacy, + }; let build_result = quiet_runtime - .build_conformance_service(topology, DEFAULT_CONFORMANCE_SERVER_ERA) + .build_conformance_service(topology, server_era, standalone, true) .await; build_progress.finish(build_result.is_ok()); if let Err(error) = build_result { @@ -33,59 +46,114 @@ impl RuntimeContext { return Err(error); } - self.start_conformance_containers(topology, DEFAULT_CONFORMANCE_SERVER_ERA) + self.start_conformance_containers(topology, server_era, standalone, true) .await?; - let conformance_endpoint = self.conformance_fixture_endpoint(topology)?; + if standalone { + let token = self.standalone_dataplane_token(true)?; + self.publish_standalone_conformance_config( + self.default_server_id(), + protocol_version.wire_version(), + &token.value, + true, + )?; + } + let conformance_endpoint = + self.conformance_fixture_endpoint(topology, standalone, true)?; Activity::completed("Integration stack ready"); self.print_stack_summary(topology, &conformance_endpoint) } - StackAction::Down { lane, volumes } => { + StackAction::Down { + lane, + volumes, + standalone, + } => { let kind = if volumes { CleanupKind::Reset } else { CleanupKind::Down }; - let primary = self.cleanup(lane, kind).err(); - finish_with_cleanup(primary, self.cleanup_observability(kind, true)) + if standalone { + self.cleanup_standalone_dataplane(kind) + } else { + self.cleanup(lane, kind) + } } - StackAction::Status(mode) => { - self.require_mode_sources(mode)?; - let command = StackCommandPlan::status(self.conformance_compose_project(mode)); - Ok(self.runner.run(&self.compose_environment( - command.command().clone(), - mode, - true, - )?)?) + StackAction::Status { + topology, + standalone, + } => { + let project = self.stack_command_project(topology, standalone)?; + let command = StackCommandPlan::status(project); + let command = + self.target_environment(command.command().clone(), topology, standalone)?; + Ok(self.runner.run(&command)?) } StackAction::Logs { - topology: mode, + topology, services, + standalone, } => { - self.require_mode_sources(mode)?; + let project = self.stack_command_project(topology, standalone)?; + let command = StackCommandPlan::logs(project, services); let command = - StackCommandPlan::logs(self.conformance_compose_project(mode), services); - Ok(self.runner.run(&self.compose_environment( - command.command().clone(), - mode, - true, - )?)?) + self.target_environment(command.command().clone(), topology, standalone)?; + Ok(self.runner.run(&command)?) } - StackAction::Config(mode) => { - self.require_mode_sources(mode)?; - if mode == StackMode::Dataplane { + StackAction::Config { + topology, + standalone, + } => { + if !standalone { + self.require_mode_sources(topology)?; + } + if topology == StackMode::Dataplane && !standalone { self.validate_compose_contract()?; } - let command = - StackCommandPlan::config(self.conformance_compose_project(mode), mode); - Ok(self.runner.run(&self.compose_environment( - command.command().clone(), - mode, - true, - )?)?) + let project = self.stack_command_project(topology, standalone)?; + let command = if standalone { + project.command(["config", "--no-interpolate", "--no-env-resolution"]) + } else { + StackCommandPlan::config(project, topology) + .command() + .clone() + }; + let command = self.target_environment(command, topology, standalone)?; + Ok(self.runner.run(&command)?) } } } + fn stack_command_project( + &self, + topology: StackMode, + standalone: bool, + ) -> AppResult { + if standalone { + if topology != StackMode::Dataplane { + return Err(AppFailure::from(anyhow!( + "standalone mode requires the external lane" + ))); + } + Ok(self.standalone_conformance_compose_project(true)) + } else { + self.require_mode_sources(topology)?; + Ok(self.conformance_compose_project(topology)) + } + } + + pub(super) fn target_environment( + &self, + command: CommandSpec, + topology: StackMode, + standalone: bool, + ) -> AppResult { + if standalone { + self.standalone_dataplane_environment(command, true) + } else { + self.compose_environment(command, topology, true) + } + } + pub(super) async fn stack_up(&self, mode: StackMode, fresh: bool) -> AppResult<()> { self.stack_up_with_project(mode, fresh, self.compose_project(mode), false, true) .await @@ -106,6 +174,56 @@ impl RuntimeContext { .await } + pub(super) async fn stack_up_standalone_dataplane( + &self, + fresh: bool, + observability: bool, + ) -> AppResult<()> { + if !self.config.dataplane_ref().value.is_empty() { + self.ensure_dataplane()?; + } + + let build = self.resolve_standalone_dataplane_build()?; + if !build && self.config.dataplane_ref().value.is_empty() { + let platform = self.dataplane_platform()?; + self.pull_if_changed( + "cf-dataplane", + self.config.dataplane_image().resolved(), + Some(platform.as_os_str()), + self.config.dataplane_image().pull_policy(), + false, + )?; + } + if fresh { + self.cleanup_standalone_dataplane(CleanupKind::Reset)?; + } + self.ensure_other_stack_stopped(StackMode::Dataplane)?; + if observability { + self.start_observability()?; + } + + let mut arguments = vec![ + OsString::from("up"), + OsString::from("-d"), + OsString::from("--remove-orphans"), + ]; + if build { + arguments.push(OsString::from("--build")); + } + arguments.extend( + ["redis", "dataplane", "nginx"] + .into_iter() + .map(OsString::from), + ); + let command = self + .standalone_dataplane_project(observability) + .command(arguments); + let command = self.standalone_dataplane_environment(command, true)?; + self.runner.run_async(&command).await?; + self.wait_for_public_endpoint(StackMode::Dataplane, false) + .await + } + pub(super) async fn stack_up_with_project( &self, mode: StackMode, @@ -270,7 +388,30 @@ impl RuntimeContext { self.environment_flag("CONTROLPLANE_ENABLE_SSO", false), ), }; - project.with_conformance_overlay(self.config.asset_root()) + project + .with_conformance_overlay(self.config.asset_root()) + .with_controlplane_conformance_overlay(self.config.asset_root()) + } + + pub(super) fn standalone_dataplane_project(&self, observability: bool) -> ComposeProject { + let project = ComposeProject::standalone_dataplane( + self.config.asset_root(), + self.config.integration_project().value.clone(), + !self.config.dataplane_ref().value.is_empty(), + ); + if observability { + project.with_dataplane_observability(self.config.asset_root()) + } else { + project + } + } + + pub(super) fn standalone_conformance_compose_project( + &self, + observability: bool, + ) -> ComposeProject { + self.standalone_dataplane_project(observability) + .with_conformance_fixture(self.config.asset_root()) } pub(super) fn conformance_compose_project(&self, mode: StackMode) -> ComposeProject { @@ -332,6 +473,23 @@ impl RuntimeContext { ) } + pub(super) fn standalone_dataplane_environment( + &self, + command: CommandSpec, + checkout_labels: bool, + ) -> AppResult { + let command = self.base_compose_environment(command)?; + let command = self.dataplane_environment(command)?; + let command = self + .host_identity_environment(command)? + .env("MCP_SERVER_ID", self.default_server_id()); + if checkout_labels && !self.config.dataplane_ref().value.is_empty() { + self.add_dataplane_checkout_labels(command) + } else { + Ok(command) + } + } + pub(super) fn compose_environment( &self, command: CommandSpec, @@ -339,24 +497,11 @@ impl RuntimeContext { 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 - } else { - command.cwd(self.config.root()) - }; - for (key, value) in self.config.environment().iter() { - if !command_environment.contains_key(key) { - command = command.env(key.clone(), value.value.clone()); - } - } - if let Some(protocol_version) = compose_protocol_version( - &command_environment, - self.environment_text(COMPOSE_PROTOCOL_VERSION_ENV), - )? { - command = command.env(COMPOSE_PROTOCOL_VERSION_ENV, protocol_version); + let mut command = self.base_compose_environment(command)?; + if mode == StackMode::Dataplane { + command = self.dataplane_environment(command)?; } - let (controlplane_pull_policy, dataplane_pull_policy) = compose_pull_policies( + let (controlplane_pull_policy, _) = compose_pull_policies( mode, false, !self.config.dataplane_ref().value.is_empty(), @@ -364,17 +509,10 @@ impl RuntimeContext { self.config.dataplane_image().pull_policy(), ); command = command - .env("CF_INTEGRATION_ROOT", self.config.asset_root().as_os_str()) - .env("CF_OBSERVABILITY_NETWORK", self.observability_network()) - .env( - "CF_INTEGRATION_DIR", - self.config.integration_dir().as_os_str(), - ) .env( "CF_CONTROLPLANE_DIR", self.config.controlplane_dir().as_os_str(), ) - .env("CF_DATAPLANE_DIR", self.config.dataplane_dir().as_os_str()) .env("CF_CONTROLPLANE_IMAGE", controlplane_image.clone()) .env("CF_CONTROLPLANE_PULL_POLICY", controlplane_pull_policy) .env("IMAGE_LOCAL", controlplane_image) @@ -382,18 +520,11 @@ impl RuntimeContext { "FAST_TIME_IMAGE", self.config.fast_time_expected_image().value.clone(), ) - .env( - "CF_DATAPLANE_IMAGE", - self.config.dataplane_image().resolved().to_owned(), - ) - .env("CF_DATAPLANE_PULL_POLICY", dataplane_pull_policy) - .env("CF_DATAPLANE_PLATFORM", self.dataplane_platform()?) .env("JWT_SECRET_KEY", self.config.jwt_secret_key().value.clone()) .env( "AUTH_ENCRYPTION_SECRET", self.config.auth_encryption_secret().value.clone(), ) - .env("MCP_CLI_BASE_URL", self.config.base_url().value.clone()) .env( "PLATFORM_ADMIN_EMAIL", self.config.platform_admin_email().value.clone(), @@ -406,7 +537,6 @@ impl RuntimeContext { "KEY_FILE_PASSWORD", self.config.key_file_password().value.clone(), ); - command = with_default_conformance_server_era(command); for (key, default) in [ ("PASSWORD_CHANGE_ENFORCEMENT_ENABLED", "false"), @@ -444,12 +574,7 @@ impl RuntimeContext { command = command.env(key, docker_cpus.as_deref().unwrap_or("4")); } } - for (key, argument) in [("HOST_UID", "-u"), ("HOST_GID", "-g")] { - if self.config.environment().get(OsStr::new(key)).is_none() { - let value = self.host_identity(argument)?; - command = command.env(key, value); - } - } + command = self.host_identity_environment(command)?; if self .config .environment() @@ -475,6 +600,64 @@ impl RuntimeContext { Ok(command) } + fn base_compose_environment(&self, command: CommandSpec) -> AppResult { + let command_environment = command.environment().clone(); + let mut command = if command.working_directory().is_some() { + command + } else { + command.cwd(self.config.root()) + }; + for (key, value) in self.config.environment().iter() { + if !command_environment.contains_key(key) { + command = command.env(key.clone(), value.value.clone()); + } + } + if let Some(protocol_version) = compose_protocol_version( + &command_environment, + self.environment_text(COMPOSE_PROTOCOL_VERSION_ENV), + )? { + command = command.env(COMPOSE_PROTOCOL_VERSION_ENV, protocol_version); + } + Ok(with_default_conformance_server_era( + command + .env("CF_INTEGRATION_ROOT", self.config.asset_root().as_os_str()) + .env( + "CF_INTEGRATION_DIR", + self.config.integration_dir().as_os_str(), + ) + .env("CF_OBSERVABILITY_NETWORK", self.observability_network()) + .env("MCP_CLI_BASE_URL", self.config.base_url().value.clone()), + )) + } + + fn dataplane_environment(&self, command: CommandSpec) -> AppResult { + let (_, pull_policy) = compose_pull_policies( + StackMode::Dataplane, + false, + !self.config.dataplane_ref().value.is_empty(), + self.config.controlplane_image().pull_policy(), + self.config.dataplane_image().pull_policy(), + ); + Ok(command + .env("CF_DATAPLANE_DIR", self.config.dataplane_dir().as_os_str()) + .env( + "CF_DATAPLANE_IMAGE", + self.config.dataplane_image().resolved().to_owned(), + ) + .env("CF_DATAPLANE_PULL_POLICY", pull_policy) + .env("CF_DATAPLANE_PLATFORM", self.dataplane_platform()?)) + } + + fn host_identity_environment(&self, mut command: CommandSpec) -> AppResult { + for (key, argument) in [("HOST_UID", "-u"), ("HOST_GID", "-g")] { + if self.config.environment().get(OsStr::new(key)).is_none() { + let value = self.host_identity(argument)?; + command = command.env(key, value); + } + } + Ok(command) + } + fn add_checkout_labels( &self, mut command: CommandSpec, @@ -497,25 +680,29 @@ impl RuntimeContext { .env("CF_CONTROLPLANE_CHECKOUT_REVISION", controlplane_revision) .env("CF_CONTROLPLANE_CHECKOUT_REF", controlplane_ref); if mode == StackMode::Dataplane && !self.config.dataplane_ref().value.is_empty() { - let revision = self.git_required(self.config.dataplane_dir(), ["rev-parse", "HEAD"])?; - let branch = - self.git_required(self.config.dataplane_dir(), ["branch", "--show-current"])?; - let reference = if branch.is_empty() { - self.config - .dataplane_ref() - .value - .to_string_lossy() - .into_owned() - } else { - branch - }; - command = command - .env("CF_DATAPLANE_CHECKOUT_REVISION", revision) - .env("CF_DATAPLANE_CHECKOUT_REF", reference); + command = self.add_dataplane_checkout_labels(command)?; } Ok(command) } + fn add_dataplane_checkout_labels(&self, command: CommandSpec) -> AppResult { + let revision = self.git_required(self.config.dataplane_dir(), ["rev-parse", "HEAD"])?; + let branch = + self.git_required(self.config.dataplane_dir(), ["branch", "--show-current"])?; + let reference = if branch.is_empty() { + self.config + .dataplane_ref() + .value + .to_string_lossy() + .into_owned() + } else { + branch + }; + Ok(command + .env("CF_DATAPLANE_CHECKOUT_REVISION", revision) + .env("CF_DATAPLANE_CHECKOUT_REF", reference)) + } + fn validate_compose_contract(&self) -> AppResult<()> { let command = self .compose_project(StackMode::Dataplane) @@ -591,6 +778,41 @@ impl RuntimeContext { Ok(decision.build) } + fn resolve_standalone_dataplane_build(&self) -> AppResult { + let setting = required_text(&self.config.compose_build().value, "CF_COMPOSE_BUILD")?; + let mode = + BuildMode::from_str(setting).map_err(|error| AppFailure::from(anyhow!(error)))?; + let source_ref = (!self.config.dataplane_ref().value.is_empty()).then(|| { + self.config + .dataplane_ref() + .value + .to_string_lossy() + .into_owned() + }); + let checkout_revision = if source_ref.is_some() { + Some(self.git_required(self.config.dataplane_dir(), ["rev-parse", "HEAD"])?) + } else { + None + }; + let (image_present, image_revision) = + self.image_state(self.config.dataplane_image().resolved())?; + Ok(resolve_build( + mode, + &BuildInputs { + controlplane_image_prebuilt: true, + controlplane_image_present: true, + controlplane_checkout_revision: None, + controlplane_image_revision: None, + include_dataplane: true, + dataplane_source_ref: source_ref, + dataplane_image_present: image_present, + dataplane_checkout_revision: checkout_revision, + dataplane_image_revision: image_revision, + }, + ) + .build) + } + fn image_state(&self, image: &OsStr) -> AppResult<(bool, Option)> { let image_id = if image.to_string_lossy().contains("@sha256:") { let images = self.capture_text(&CommandSpec::new("docker").args([ @@ -945,6 +1167,23 @@ impl RuntimeContext { self.cleanup_with_output(selection, kind, false) } + pub(super) fn cleanup_standalone_dataplane(&self, kind: CleanupKind) -> AppResult<()> { + let project = self + .standalone_conformance_compose_project(true) + .with_profiles(["conformance"]); + let command = StackCommandPlan::cleanup(project, kind); + let command = self.standalone_dataplane_environment(command.command().clone(), false)?; + let primary = self + .run_cleanup_command(&command, true) + .map_err(AppFailure::from) + .err(); + let project = required_text( + &self.config.integration_project().value, + "CF_INTEGRATION_PROJECT", + )?; + finish_with_cleanup(primary, self.remove_project_by_label(project, kind, true)) + } + fn cleanup_with_output( &self, selection: LaneSelection, @@ -988,24 +1227,6 @@ impl RuntimeContext { finish_with_cleanup_failures(None, cleanup_failures) } - fn cleanup_observability(&self, kind: CleanupKind, inherit_output: bool) -> AppResult<()> { - let project = self.observability_compose_project(); - let command = StackCommandPlan::cleanup(project, kind); - let command = self.observability_environment(command.command().clone()); - let primary = self - .run_cleanup_command(&command, inherit_output) - .map_err(AppFailure::from) - .err(); - let project = format!( - "{}-observability", - self.config.integration_project().value.to_string_lossy() - ); - finish_with_cleanup( - primary, - self.remove_project_by_label(&project, kind, inherit_output), - ) - } - fn remove_project_by_label( &self, project: &str, @@ -1257,6 +1478,89 @@ fn with_default_conformance_server_era(command: CommandSpec) -> CommandSpec { mod tests { use super::*; + struct NoProcesses; + + impl ProcessRunner for NoProcesses { + fn run(&self, spec: &CommandSpec) -> Result<(), InfrastructureError> { + panic!("unexpected setup process: {spec:?}"); + } + fn capture_stdout(&self, spec: &CommandSpec) -> Result, InfrastructureError> { + panic!("unexpected setup process: {spec:?}"); + } + fn capture_output( + &self, + spec: &CommandSpec, + ) -> Result { + panic!("unexpected setup process: {spec:?}"); + } + fn run_to_log(&self, spec: &CommandSpec, _: &Path) -> Result<(), InfrastructureError> { + panic!("unexpected setup process: {spec:?}"); + } + } + + #[test] + fn standalone_environment_needs_no_controlplane_checkout_or_worker_setup() { + use crate::infrastructure::config::{ConfigBootstrap, ConfigRequirements, Environment}; + let directory = tempfile::tempdir().expect("temporary root"); + let environment = Environment::from([ + ("CF_DATAPLANE_PLATFORM".into(), "linux/amd64".into()), + ("CF_DATAPLANE_IMAGE".into(), "test/dataplane".into()), + ("CF_DATAPLANE_PULL_POLICY".into(), "never".into()), + ("HOST_UID".into(), "123".into()), + ("HOST_GID".into(), "456".into()), + ("MCP_PROTOCOL_VERSION".into(), "modern".into()), + ]); + let config = AppConfig::load( + ConfigBootstrap::load(&environment, directory.path()).expect("bootstrap"), + ConfigRequirements::StandaloneRuntime, + ) + .expect("standalone config"); + assert!(!config.controlplane_dir().exists()); + let runtime = RuntimeContext::new(config, NoProcesses); + let working_directory = directory.path().join("caller"); + let command = runtime + .target_environment( + CommandSpec::new("docker") + .cwd(&working_directory) + .env(COMPOSE_PROTOCOL_VERSION_ENV, "2025-11-25") + .env(CONFORMANCE_SERVER_ERA_ENV, "legacy"), + StackMode::Dataplane, + true, + ) + .expect("standalone environment"); + let values = command.environment(); + assert_eq!( + command.working_directory(), + Some(working_directory.as_path()) + ); + for (key, expected) in [ + ("CF_DATAPLANE_IMAGE", "test/dataplane"), + ("CF_DATAPLANE_PULL_POLICY", "never"), + ("HOST_UID", "123"), + ("HOST_GID", "456"), + (COMPOSE_PROTOCOL_VERSION_ENV, "2025-11-25"), + (CONFORMANCE_SERVER_ERA_ENV, "legacy"), + ] { + assert_eq!(values.get(OsStr::new(key)), Some(&OsString::from(expected))); + } + for key in [ + "CF_CONTROLPLANE_DIR", + "CF_CONTROLPLANE_IMAGE", + "CF_CONTROLPLANE_CHECKOUT_REVISION", + "JWT_SECRET_KEY", + "AUTH_ENCRYPTION_SECRET", + "GUNICORN_WORKERS", + "GATEWAY_CPU_LIMIT", + "PLATFORM_ADMIN_PASSWORD", + "FAST_TIME_IMAGE", + ] { + assert!( + !values.contains_key(OsStr::new(key)), + "standalone inherited control-plane setup: {key}" + ); + } + } + #[test] fn main_tracking_selects_the_newest_published_controlplane_commit_image() { let newest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; diff --git a/src/runtime/stack/sources.rs b/src/runtime/stack/sources.rs index e97249c..c9d3aab 100644 --- a/src/runtime/stack/sources.rs +++ b/src/runtime/stack/sources.rs @@ -41,7 +41,7 @@ impl RuntimeContext { self.ensure_checkout(&request) } - fn ensure_dataplane(&self) -> AppResult<()> { + pub(super) fn ensure_dataplane(&self) -> AppResult<()> { let request = CheckoutRequest::dataplane( self.config.dataplane_dir(), self.config.dataplane_repo().value.clone(),