diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0683fc8..d5f0781 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,6 +92,9 @@ jobs: - runner: windows-2025 target: x86_64-pc-windows-msvc binary: cf-integration.exe + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + binary: cf-integration.exe runs-on: ${{ matrix.runner }} permissions: attestations: write @@ -107,10 +110,23 @@ jobs: persist-credentials: false - name: Install Rust toolchain - run: rustup toolchain install 1.97.0 --profile minimal + shell: bash + env: + TARGET: ${{ matrix.target }} + run: rustup toolchain install 1.97.0 --profile minimal --target "$TARGET" - name: Build release binary - run: cargo build --release --locked --bin cf-integration + shell: bash + env: + TARGET: ${{ matrix.target }} + run: cargo build --release --locked --bin cf-integration --target "$TARGET" + + - name: Smoke test release binary + shell: bash + env: + BINARY: ${{ matrix.binary }} + TARGET: ${{ matrix.target }} + run: '"target/$TARGET/release/$BINARY" --help' - name: Package release binary shell: bash @@ -119,7 +135,7 @@ jobs: TARGET: ${{ matrix.target }} run: | mkdir -p dist/package - cp "target/release/$BINARY" dist/package/ + cp "target/$TARGET/release/$BINARY" dist/package/ archive="cf-integration-$TARGET.tgz" tar -C dist/package -czf "dist/$archive" "$BINARY" if command -v sha256sum >/dev/null; then diff --git a/README.md b/README.md index 92b966b..0836891 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,11 @@ cargo binstall cf-integration cf-integration --help ``` -The release archives cover x86-64 and ARM64 Linux, x86-64 and Apple Silicon -macOS, and x86-64 Windows. `cargo-binstall` is required because Cargo's native -`cargo install` command always compiles a crate locally. The CLI still uses the -tracked Compose overlays and scripts at runtime, so run it from this repository -checkout or set `CF_INTEGRATION_ROOT` to the checkout path. +The release archives cover x86-64 and ARM64 Linux, macOS, and Windows. +`cargo-binstall` is required because Cargo's native `cargo install` command +always compiles a crate locally. The CLI still uses the tracked Compose overlays +and scripts at runtime, so run it from this repository checkout or set +`CF_INTEGRATION_ROOT` to the checkout path. The checked-in `rust-toolchain.toml` selects Rust 1.97.0 with rustfmt and Clippy. To build and install the locked CLI from this checkout instead: @@ -78,10 +78,12 @@ Test server on demand. ## Lanes and protocol versions -Probe, load, live, and Inspector use the same target options: -`--lane controlplane|dataplane` and `--protocol-version YYYY-MM-DD`. -`controlplane` targets the stock control-plane topology and raw `/mcp`; -`dataplane` targets nginx, the Rust dataplane, and the virtual-server route. +Probe, load, and Inspector use `--lane controlplane|dataplane` plus +`--protocol-version YYYY-MM-DD`. `controlplane` targets the stock physical +control-plane topology and raw `/mcp`; `dataplane` targets nginx, the Rust +dataplane, and the virtual-server route. Live and conformance use semantic +workflow lanes: `fixture-direct`, `built-in-data-plane`, and +`external-data-plane`. Single-lane commands resolve their lane in this order: @@ -93,10 +95,12 @@ They resolve the protocol version from explicit `--protocol-version`, then `MCP_PROTOCOL_VERSION`, then `2025-11-25`. That session-oriented default is the working contract of the current `latest` dataplane image. Pass `--protocol-version 2026-07-28` explicitly to exercise the implemented -stateless readiness path as the future architecture lands. Live protocol tests -and conformance also accept `fixture-direct`; other workflows reject it -because they have no direct-fixture execution path. Conformance defaults to -all three lanes and its pinned `2026-07-28` protocol version. +stateless readiness path as the future architecture lands. For live and +conformance, a resolved `controlplane` stack selects `built-in-data-plane`, +while a resolved `dataplane` stack selects `external-data-plane`. Other +workflows reject `fixture-direct` because they have no direct-fixture execution +path. Conformance defaults to all three lanes and its pinned `2026-07-28` +protocol version. `--topology` remains a compatibility alias for `--lane` on workflows. Conformance also retains `--client-version` and `--spec-version` as aliases for @@ -231,10 +235,10 @@ generated artifacts for credential leakage. Run the control-plane repository's live gateway tests against either topology: ```bash -cf-integration live --lane dataplane --group mcp -cf-integration live --lane dataplane --group rbac -cf-integration live --lane dataplane --group protocol -cf-integration live --lane dataplane --group all +cf-integration live --lane external-data-plane --group mcp +cf-integration live --lane external-data-plane --group rbac +cf-integration live --lane external-data-plane --group protocol +cf-integration live --lane external-data-plane --group all # Run the upstream protocol suite directly against its reference fixture. cf-integration live \ @@ -277,9 +281,16 @@ It always: - provisions the pinned official fixture; - runs every applicable official server scenario; - defaults to MCP `2026-07-28`; -- runs fixture-direct, controlplane, and dataplane lanes; +- runs fixture-direct, built-in-data-plane, and external-data-plane lanes; +- routes both gateway lanes through `/servers/{virtual_host_id}/mcp` using the + same unscoped ephemeral catalog-token contract as the control-plane job; +- disables rate limiting and embedded Rust MCP handling, and uses one Gunicorn + worker, matching the control-plane conformance job; +- builds the control plane with `ENABLE_RUST=false` and + `ENABLE_RUST_MCP_RMCP=false` when `CF_COMPOSE_BUILD=true`; - passes an empty expected-failure file to the official runner; - records raw failures without suppression; +- hides setup and runner output in artifact logs while showing live progress; - removes temporary API resources, fixture services, and stacks; - writes a comparison report even when a lane reports protocol failures. @@ -323,15 +334,16 @@ dual-era fallback. The three lanes are: 1. official oracle directly to the official TypeScript fixture; -2. official oracle through the control-plane public MCP route; -3. official oracle through nginx and the Rust dataplane route. +2. official oracle through the routed Python built-in data-plane endpoint; +3. official oracle through the same route backed by the external Rust data + plane. Select exact lanes by repeating `--lane`: ```bash cf-integration conformance run \ --lane fixture-direct \ - --lane dataplane + --lane external-data-plane ``` Supported client revisions are explicit and use the same pinned runner and diff --git a/crates/compliance/src/conformance.rs b/crates/compliance/src/conformance.rs index ad32639..48a238e 100644 --- a/crates/compliance/src/conformance.rs +++ b/crates/compliance/src/conformance.rs @@ -90,10 +90,10 @@ impl fmt::Display for ConformanceServerEra { pub enum ConformanceTarget { /// Official oracle connected directly to the pinned TypeScript fixture. Fixture, - /// Official oracle routed through the Python control plane. - Controlplane, - /// Official oracle routed through the Rust dataplane. - Dataplane, + /// Official oracle routed through the Python built-in data plane. + BuiltInDataPlane, + /// Official oracle routed through the external Rust data plane. + ExternalDataPlane, } impl ConformanceTarget { @@ -102,8 +102,8 @@ impl ConformanceTarget { pub const fn label(self) -> &'static str { match self { Self::Fixture => "fixture direct", - Self::Controlplane => "control-plane", - Self::Dataplane => "dataplane", + Self::BuiltInDataPlane => "built-in data-plane route", + Self::ExternalDataPlane => "external data-plane route", } } } @@ -723,13 +723,13 @@ pub enum ComparisonClassification { AllCompliant, /// Only the direct fixture run fails. FixtureOnlyFailure, - /// Only the control-plane path fails. + /// Only the built-in data-plane route fails. ControlplaneOnlyFailure, - /// Only the dataplane path fails. + /// Only the external data-plane route fails. DataplaneOnlyFailure, - /// The direct fixture and control-plane path fail. + /// The direct fixture and built-in data-plane route fail. FixtureAndControlplaneFailure, - /// The direct fixture and dataplane path fail. + /// The direct fixture and external data-plane route fail. FixtureAndDataplaneFailure, /// Both gateway paths fail while the direct fixture passes. GatewaysOnlyFailure, @@ -764,10 +764,10 @@ impl ComparisonClassification { match self { Self::AllCompliant => "all compliant", Self::FixtureOnlyFailure => "fixture-only failure", - Self::ControlplaneOnlyFailure => "control-plane only failure", - Self::DataplaneOnlyFailure => "dataplane only failure", - Self::FixtureAndControlplaneFailure => "fixture + control-plane failure", - Self::FixtureAndDataplaneFailure => "fixture + dataplane failure", + Self::ControlplaneOnlyFailure => "built-in data-plane only failure", + Self::DataplaneOnlyFailure => "external data-plane only failure", + Self::FixtureAndControlplaneFailure => "fixture + built-in data-plane failure", + Self::FixtureAndDataplaneFailure => "fixture + external data-plane failure", Self::GatewaysOnlyFailure => "both gateways only failure", Self::SharedFailure => "shared failure", Self::FixtureFailure => "fixture failure", @@ -777,7 +777,7 @@ impl ComparisonClassification { } } -/// Classifies direct-fixture, control-plane, and dataplane scenario outcomes. +/// Classifies direct-fixture, built-in, and external data-plane route outcomes. #[must_use] pub fn classify_outcomes( fixture: ScenarioOutcome, @@ -1012,12 +1012,12 @@ pub fn render_comparison_markdown(report: &ComparisonReport) -> String { |scenario: &ScenarioComparison| scenario.fixture_failed_checks, ), ( - "Control plane", + "Built-in data-plane route", |scenario: &ScenarioComparison| scenario.controlplane, |scenario: &ScenarioComparison| scenario.controlplane_failed_checks, ), ( - "Dataplane", + "External data-plane route", |scenario: &ScenarioComparison| scenario.dataplane, |scenario: &ScenarioComparison| scenario.dataplane_failed_checks, ), @@ -1069,7 +1069,7 @@ pub fn render_comparison_markdown(report: &ComparisonReport) -> String { output.push_str("\n## Scenarios\n\n"); output.push_str( - "| Scenario | Fixture direct | Control plane | Dataplane | Classification | Specification references |\n", + "| Scenario | Fixture direct | Built-in data-plane route | External data-plane route | Classification | Specification references |\n", ); output.push_str("|---|---|---|---|---|---|\n"); let mut scenarios: Vec<_> = report.scenarios.iter().collect(); diff --git a/crates/compliance/tests/conformance.rs b/crates/compliance/tests/conformance.rs index 4f2a2be..081ebde 100644 --- a/crates/compliance/tests/conformance.rs +++ b/crates/compliance/tests/conformance.rs @@ -410,8 +410,8 @@ fn report_renders_raw_counts_and_no_expected_failure_column() { assert!(markdown.contains("- Client specification: `2026-07-28`")); assert!(markdown.contains("- Upstream server era: `modern`")); - assert!(markdown.contains("| Control plane | 0 | 1 | 27 |")); - assert!(markdown.contains("| Dataplane | 0 | 1 | 28 |")); + assert!(markdown.contains("| Built-in data-plane route | 0 | 1 | 27 |")); + assert!(markdown.contains("| External data-plane route | 0 | 1 | 28 |")); assert!(markdown.contains("server\\|stateless")); assert!(!markdown.contains("Expected by")); diff --git a/crates/mcp/src/auth_proxy.rs b/crates/mcp/src/auth_proxy.rs index 76260fa..280bef5 100644 --- a/crates/mcp/src/auth_proxy.rs +++ b/crates/mcp/src/auth_proxy.rs @@ -94,6 +94,21 @@ impl AuthProxy { Self::start_with_protocol_version(upstream, bearer_token, None).await } + /// Starts a proxy for a routed endpoint backed by the built-in data plane. + /// + /// Unlike [`Self::start`], this does not require the Rust data-plane + /// response marker merely because the endpoint uses `/servers/{id}/mcp`. + /// + /// # Errors + /// + /// Returns the same errors as [`Self::start`]. + pub async fn start_builtin_data_plane( + upstream: Url, + bearer_token: impl AsRef, + ) -> Result { + Self::start_configured(upstream, bearer_token, None, false).await + } + /// Starts a proxy that also rewrites MCP initialize requests to one version. /// /// # Errors @@ -103,6 +118,22 @@ impl AuthProxy { upstream: Url, bearer_token: impl AsRef, protocol_version: Option<&str>, + ) -> Result { + let require_dataplane_backend = is_dataplane_endpoint(&upstream); + Self::start_configured( + upstream, + bearer_token, + protocol_version, + require_dataplane_backend, + ) + .await + } + + async fn start_configured( + upstream: Url, + bearer_token: impl AsRef, + protocol_version: Option<&str>, + require_dataplane_backend: bool, ) -> Result { validate_upstream(&upstream)?; let mut authorization = HeaderValue::from_str(&format!("Bearer {}", bearer_token.as_ref())) @@ -125,7 +156,7 @@ impl AuthProxy { .map_err(|_| AuthProxyError::EndpointConfiguration)?; let state = Arc::new(ProxyState { - require_dataplane_backend: is_dataplane_endpoint(&upstream), + require_dataplane_backend, upstream, authorization, proxy_path, diff --git a/crates/mcp/tests/auth_proxy.rs b/crates/mcp/tests/auth_proxy.rs index 2253bde..d209660 100644 --- a/crates/mcp/tests/auth_proxy.rs +++ b/crates/mcp/tests/auth_proxy.rs @@ -590,3 +590,33 @@ async fn dataplane_proxy_requires_one_exact_backend_marker_before_forwarding() { upstream.shutdown().await; } } + +#[tokio::test] +async fn builtin_data_plane_proxy_allows_a_routed_controlplane_response() { + let upstream = TestServer::start( + Router::new() + .route("/servers/test/mcp", any(backend_marker_handler)) + .with_state(BackendMarkerResponse { + markers: vec!["controlplane"], + }), + "servers/test/mcp", + ) + .await; + let proxy = AuthProxy::start_builtin_data_plane(upstream.url.clone(), INJECTED_TOKEN) + .await + .expect("built-in data-plane proxy should start"); + + let response = client() + .get(proxy.url().clone()) + .send() + .await + .expect("routed built-in response should be forwarded"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.text().await.expect("body should read"), + "private-upstream-body" + ); + + proxy.shutdown().await.expect("proxy should shut down"); + upstream.shutdown().await; +} diff --git a/crates/platform/src/compose.rs b/crates/platform/src/compose.rs index 2e52ece..8615ac0 100644 --- a/crates/platform/src/compose.rs +++ b/crates/platform/src/compose.rs @@ -132,6 +132,16 @@ impl ComposeProject { self } + /// Applies the control-plane runtime settings used by conformance runs. + #[must_use] + pub fn with_conformance_runtime(mut self, repository_root: &Path) -> Self { + let overlay = repository_root.join("docker/docker-compose.cf-conformance-runtime.yaml"); + if !self.files.contains(&overlay) { + self.files.push(overlay); + } + self + } + /// Enables the isolated official MCP conformance server fixture. #[must_use] pub fn with_conformance_fixture(self, repository_root: &Path) -> Self { diff --git a/crates/platform/src/process.rs b/crates/platform/src/process.rs index 44e5ae0..2685fca 100644 --- a/crates/platform/src/process.rs +++ b/crates/platform/src/process.rs @@ -3,8 +3,9 @@ use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; use std::fmt; -use std::fs::OpenOptions; +use std::fs::{File, OpenOptions}; use std::future::Future; +use std::io::Write; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::process::{Command, ExitStatus, Stdio}; @@ -163,6 +164,18 @@ pub trait ProcessRunner { Box::pin(async move { self.run(spec) }) } + /// Runs asynchronously with both output streams appended to one log file. + /// + /// The default is intended for fake runners; system-backed runners avoid + /// blocking the async executor while the child is active. + fn run_async_to_log<'a>( + &'a self, + spec: &'a CommandSpec, + log_path: &'a Path, + ) -> Pin> + 'a>> { + Box::pin(async move { self.run_to_log(spec, log_path) }) + } + /// Runs asynchronously and returns after cancellation is observed. /// /// The default is intended for fake runners without owned OS children. @@ -210,6 +223,80 @@ pub trait ProcessRunner { #[derive(Debug, Default, Clone, Copy)] pub struct SystemProcessRunner; +/// Process runner that keeps ordinary child output in one aggregate log. +/// +/// Explicit per-command log paths remain authoritative, allowing callers to +/// retain separate detailed logs for long-running tools. +#[derive(Debug, Clone, Copy)] +pub struct LoggingProcessRunner<'a, R> { + inner: &'a R, + log_path: &'a Path, +} + +impl<'a, R> LoggingProcessRunner<'a, R> { + /// Wraps `inner`, redirecting inherited child output to `log_path`. + #[must_use] + pub const fn new(inner: &'a R, log_path: &'a Path) -> Self { + Self { inner, log_path } + } +} + +impl ProcessRunner for LoggingProcessRunner<'_, R> { + fn run(&self, spec: &CommandSpec) -> Result<(), PlatformError> { + self.inner.run_to_log(spec, self.log_path) + } + + fn run_async<'a>( + &'a self, + spec: &'a CommandSpec, + ) -> Pin> + 'a>> { + self.inner.run_async_to_log(spec, self.log_path) + } + + fn run_async_to_log<'a>( + &'a self, + spec: &'a CommandSpec, + log_path: &'a Path, + ) -> Pin> + 'a>> { + self.inner.run_async_to_log(spec, log_path) + } + + fn run_async_cancellable<'a>( + &'a self, + spec: &'a CommandSpec, + cancellation: tokio::sync::watch::Receiver, + ) -> Pin> + 'a>> { + self.inner + .run_async_cancellable_to_log(spec, cancellation, self.log_path) + } + + fn run_async_cancellable_to_log<'a>( + &'a self, + spec: &'a CommandSpec, + cancellation: tokio::sync::watch::Receiver, + log_path: &'a Path, + ) -> Pin> + 'a>> { + self.inner + .run_async_cancellable_to_log(spec, cancellation, log_path) + } + + fn capture_stdout(&self, spec: &CommandSpec) -> Result, PlatformError> { + let output = self.inner.capture_output(spec)?; + append_captured_output(self.log_path, spec, &output)?; + Ok(output.stdout) + } + + fn capture_output(&self, spec: &CommandSpec) -> Result { + let output = self.inner.capture_output(spec)?; + append_captured_output(self.log_path, spec, &output)?; + Ok(output) + } + + fn run_to_log(&self, spec: &CommandSpec, log_path: &Path) -> Result<(), PlatformError> { + self.inner.run_to_log(spec, log_path) + } +} + impl ProcessRunner for SystemProcessRunner { fn run(&self, spec: &CommandSpec) -> Result<(), PlatformError> { let mut command = command(spec); @@ -241,6 +328,28 @@ impl ProcessRunner for SystemProcessRunner { }) } + fn run_async_to_log<'a>( + &'a self, + spec: &'a CommandSpec, + log_path: &'a Path, + ) -> Pin> + 'a>> { + Box::pin(async move { + let (log, stderr_log) = log_handles(log_path, spec)?; + let mut command = tokio::process::Command::from(command(spec)); + command + .stdout(Stdio::from(log)) + .stderr(Stdio::from(stderr_log)); + let mut child = command + .spawn() + .with_context(|| operation_context("spawn", spec))?; + let status = child + .wait() + .await + .with_context(|| operation_context("wait for", spec))?; + require_success(spec, status) + }) + } + fn run_async_cancellable<'a>( &'a self, spec: &'a CommandSpec, @@ -279,12 +388,8 @@ impl ProcessRunner for SystemProcessRunner { log_path: &'a Path, ) -> Pin> + 'a>> { Box::pin(async move { - let log = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(log_path) - .with_context(|| log_context("open", log_path, spec))?; + let log = + File::create(log_path).with_context(|| log_context("create", log_path, spec))?; let stderr_log = log .try_clone() .with_context(|| log_context("clone handle for", log_path, spec))?; @@ -340,14 +445,7 @@ impl ProcessRunner for SystemProcessRunner { } fn run_to_log(&self, spec: &CommandSpec, log_path: &Path) -> Result<(), PlatformError> { - let log = OpenOptions::new() - .create(true) - .append(true) - .open(log_path) - .with_context(|| log_context("open", log_path, spec))?; - let stderr_log = log - .try_clone() - .with_context(|| log_context("clone handle for", log_path, spec))?; + let (log, stderr_log) = log_handles(log_path, spec)?; let mut command = command(spec); command .stdout(Stdio::from(log)) @@ -362,6 +460,34 @@ impl ProcessRunner for SystemProcessRunner { } } +fn log_handles(log_path: &Path, spec: &CommandSpec) -> Result<(File, File), PlatformError> { + let log = OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .with_context(|| log_context("open", log_path, spec))?; + let stderr_log = log + .try_clone() + .with_context(|| log_context("clone handle for", log_path, spec))?; + Ok((log, stderr_log)) +} + +fn append_captured_output( + log_path: &Path, + spec: &CommandSpec, + output: &CapturedOutput, +) -> Result<(), PlatformError> { + let mut log = OpenOptions::new() + .create(true) + .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())) + .with_context(|| log_context("append output to", log_path, spec))?; + Ok(()) +} + fn command(spec: &CommandSpec) -> Command { let mut command = Command::new(spec.program()); command.args(spec.arguments()); diff --git a/crates/platform/tests/compose.rs b/crates/platform/tests/compose.rs index 9d0907a..8d5d6f2 100644 --- a/crates/platform/tests/compose.rs +++ b/crates/platform/tests/compose.rs @@ -72,7 +72,7 @@ fn readme_documents_the_official_conformance_fixture_contract() { for fact in [ "official TypeScript fixture", "Fast Time remains", - "runs fixture-direct, controlplane, and dataplane lanes", + "runs fixture-direct, built-in-data-plane, and external-data-plane lanes", "c321dd32035556e6769d3724a8ee97d87c3faaac", "defaults to MCP `2026-07-28`", "loopback `MCP_CLI_BASE_URL`", @@ -127,6 +127,46 @@ fn dataplane_compose_files_are_in_override_order() { ); } +#[test] +fn conformance_runtime_matches_the_python_builtin_image_contract() { + let root = workspace_root(); + let compose = + fs::read_to_string(root.join("docker/docker-compose.cf-conformance-runtime.yaml")) + .expect("read conformance runtime overlay"); + let compose: serde_yaml::Value = + serde_yaml::from_str(&compose).expect("parse conformance runtime overlay"); + + let gateway = &compose["services"]["gateway"]; + assert_eq!(gateway["environment"]["GUNICORN_WORKERS"], "1"); + assert_eq!(gateway["environment"]["RATE_LIMITING_ENABLED"], "false"); + assert_eq!(gateway["environment"]["RUST_MCP_MODE"], "off"); + for service in ["gateway", "migration"] { + assert_eq!( + compose["services"][service]["build"]["args"]["ENABLE_RUST"], + "false" + ); + assert_eq!( + compose["services"][service]["build"]["args"]["ENABLE_RUST_MCP_RMCP"], + "false" + ); + } + + let project = ComposeProject::controlplane( + Path::new("/repo"), + Path::new("/checkout"), + OsString::from("cf"), + false, + ) + .with_conformance_overlay(Path::new("/repo")) + .with_conformance_runtime(Path::new("/repo")); + assert_eq!( + project.files().last(), + Some(&PathBuf::from( + "/repo/docker/docker-compose.cf-conformance-runtime.yaml" + )) + ); +} + #[test] fn shared_metadata_overlay_clears_obsolete_fast_time_arguments() { let compose = fs::read_to_string( diff --git a/crates/platform/tests/process.rs b/crates/platform/tests/process.rs index d11a138..0b1eb7c 100644 --- a/crates/platform/tests/process.rs +++ b/crates/platform/tests/process.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use cf_integration_platform::PlatformError; use cf_integration_platform::process::{ - CapturedOutput, CommandSpec, ProcessRunner, SystemProcessRunner, + CapturedOutput, CommandSpec, LoggingProcessRunner, ProcessRunner, SystemProcessRunner, }; use tempfile::TempDir; @@ -45,6 +45,35 @@ impl ProcessRunner for FakeProcessRunner { } } +#[cfg(unix)] +#[tokio::test(flavor = "current_thread")] +async fn logging_runner_hides_ordinary_output_in_an_aggregate_log() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = executable_script( + &directory, + "aggregate-log.sh", + "printf 'ordinary stdout\\n'; printf 'ordinary stderr\\n' >&2", + ); + let log_path = directory.path().join("setup.log"); + let system = SystemProcessRunner; + let runner = LoggingProcessRunner::new(&system, &log_path); + + runner + .run_async(&CommandSpec::new(script)) + .await + .expect("logged child should succeed"); + + let log = fs::read(&log_path).expect("aggregate log should be readable"); + assert!( + log.windows(b"ordinary stdout\n".len()) + .any(|part| part == b"ordinary stdout\n") + ); + assert!( + log.windows(b"ordinary stderr\n".len()) + .any(|part| part == b"ordinary stderr\n") + ); +} + #[cfg(unix)] fn executable_script(directory: &TempDir, name: &str, body: &str) -> PathBuf { let path = directory.path().join(name); diff --git a/docker/docker-compose.cf-conformance-runtime.yaml b/docker/docker-compose.cf-conformance-runtime.yaml new file mode 100644 index 0000000..0ce57cc --- /dev/null +++ b/docker/docker-compose.cf-conformance-runtime.yaml @@ -0,0 +1,16 @@ +services: + gateway: + environment: + GUNICORN_WORKERS: "1" + RATE_LIMITING_ENABLED: "false" + RUST_MCP_MODE: "off" + build: + args: + ENABLE_RUST: "false" + ENABLE_RUST_MCP_RMCP: "false" + + migration: + build: + args: + ENABLE_RUST: "false" + ENABLE_RUST_MCP_RMCP: "false" diff --git a/reports/mcp-conformance-comparison.md b/reports/mcp-conformance-comparison.md index 7a6eb8e..977ea2f 100644 --- a/reports/mcp-conformance-comparison.md +++ b/reports/mcp-conformance-comparison.md @@ -11,8 +11,8 @@ | Target | Compliant scenarios | Failed scenarios | Failed checks | Fixture failures | Not applicable | Ambiguous | Missing | |---|---:|---:|---:|---:|---:|---:|---:| | Fixture direct | 31 | 9 | 17 | 0 | 0 | 0 | 0 | -| Control plane | 0 | 40 | 110 | 0 | 0 | 0 | 0 | -| Dataplane | 7 | 33 | 50 | 0 | 0 | 0 | 0 | +| Built-in data-plane route | 0 | 40 | 110 | 0 | 0 | 0 | 0 | +| External data-plane route | 7 | 33 | 50 | 0 | 0 | 0 | 0 | ## Comparison summary @@ -20,10 +20,10 @@ |---|---:| | all compliant | 0 | | fixture-only failure | 0 | -| control-plane only failure | 7 | -| dataplane only failure | 0 | -| fixture + control-plane failure | 0 | -| fixture + dataplane failure | 0 | +| built-in data-plane only failure | 7 | +| external data-plane only failure | 0 | +| fixture + built-in data-plane failure | 0 | +| fixture + external data-plane failure | 0 | | both gateways only failure | 24 | | shared failure | 9 | | fixture failure | 0 | @@ -32,27 +32,27 @@ ## Scenarios -| Scenario | Fixture direct | Control plane | Dataplane | Classification | Specification references | +| Scenario | Fixture direct | Built-in data-plane route | External data-plane route | Classification | Specification references | |---|---|---|---|---|---| | caching | compliant | failure | failure | both gateways only failure | [MCP-Caching](https://modelcontextprotocol.io/specification/draft/server/utilities/caching)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) | | completion-complete | compliant | failure | failure | both gateways only failure | [MCP-Completion](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/completion)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | -| dns-rebinding-protection | compliant | failure | compliant | control-plane only failure | [MCP-DNS-Rebinding-Protection](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise)
[MCP-Transport-Security](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning) | +| dns-rebinding-protection | compliant | failure | compliant | built-in data-plane only failure | [MCP-DNS-Rebinding-Protection](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise)
[MCP-Transport-Security](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning) | | http-custom-header-server-validation | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2243-Custom-Headers](https://modelcontextprotocol.io/specification/draft/basic/transports#server-behavior-for-custom-headers) | | http-header-validation | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[RFC-9110-5.5-Field-Values](https://www.rfc-editor.org/rfc/rfc9110#section-5.5)
[SEP-2243-Case-Sensitivity](https://modelcontextprotocol.io/specification/draft/basic/transports#case-sensitivity)
[SEP-2243-Server-Validation](https://modelcontextprotocol.io/specification/draft/basic/transports#server-validation) | | input-required-result-basic-elicitation | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | input-required-result-basic-list-roots | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | input-required-result-basic-sampling | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | input-required-result-capability-check | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | -| input-required-result-ignore-extra-params | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | -| input-required-result-missing-input-response | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-ignore-extra-params | compliant | failure | compliant | built-in data-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-missing-input-response | compliant | failure | compliant | built-in data-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | input-required-result-multi-round | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | input-required-result-multiple-input-requests | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | input-required-result-non-tool-request | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | input-required-result-request-state | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | input-required-result-result-type | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | input-required-result-tampered-state | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | -| input-required-result-unsupported-methods | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | -| input-required-result-validate-input | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-unsupported-methods | compliant | failure | compliant | built-in data-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | +| input-required-result-validate-input | compliant | failure | compliant | built-in data-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2322](https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr) | | json-schema-2020-12 | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-1613](https://github.com/modelcontextprotocol/specification/pull/655)
[SEP-2106](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2106) | | prompts-get-embedded-resource | compliant | failure | failure | both gateways only failure | [MCP-Prompts-Embedded-Resources](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#embedded-resources)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | | prompts-get-simple | compliant | failure | failure | both gateways only failure | [MCP-Prompts-Get](https://modelcontextprotocol.io/specification/2025-06-18/server/prompts#getting-prompts)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | @@ -63,8 +63,8 @@ | resources-read-binary | compliant | failure | failure | both gateways only failure | [MCP-Resources-Read](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#reading-resources)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | | resources-read-text | compliant | failure | failure | both gateways only failure | [MCP-Resources-Read](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#reading-resources)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | | resources-templates-read | compliant | failure | failure | both gateways only failure | [MCP-Resources-Templates](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#resource-templates)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | -| sep-2164-resource-not-found | compliant | failure | compliant | control-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2164](https://modelcontextprotocol.io/specification/draft/server/resources#error-handling) | -| server-sse-multiple-streams | compliant | failure | compliant | control-plane only failure | [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699) | +| sep-2164-resource-not-found | compliant | failure | compliant | built-in data-plane only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[SEP-2164](https://modelcontextprotocol.io/specification/draft/server/resources#error-handling) | +| server-sse-multiple-streams | compliant | failure | compliant | built-in data-plane only failure | [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699) | | server-stateless | compliant | failure | failure | both gateways only failure | [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575) | | tools-call-audio | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | | tools-call-embedded-resource | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | @@ -73,4 +73,4 @@ | tools-call-mixed-content | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | | tools-call-simple-text | failure | failure | failure | shared failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-Call](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) | | tools-call-with-progress | failure | failure | failure | shared failure | [MCP-Progress](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/progress)
[MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json) | -| tools-list | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-List](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools)
[MCP-Tools-List](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools)
[SEP-986](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names) | +| tools-list | compliant | failure | failure | both gateways only failure | [MCP-Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json)
[MCP-Tools-List](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools)
[MCP-Tools-List](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names)
[SEP-986](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names) | diff --git a/src/app.rs b/src/app.rs index a84dfe8..e7a62e6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,8 +67,8 @@ pub struct ResolvedLoadArgs { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LiveLane { Fixture, - Controlplane, - Dataplane, + BuiltInDataPlane, + ExternalDataPlane, } /// Fully resolved official conformance operation. @@ -195,11 +195,11 @@ pub fn resolve_action(cli: Cli, environment: &Environment) -> Result { fn resolve_live_lane(lane: Option, environment: &Environment) -> Result { Ok(match lane { Some(CliLane::FixtureDirect) => LiveLane::Fixture, - Some(CliLane::Controlplane) => LiveLane::Controlplane, - Some(CliLane::Dataplane) => LiveLane::Dataplane, + Some(CliLane::BuiltInDataPlane) => LiveLane::BuiltInDataPlane, + Some(CliLane::ExternalDataPlane) => LiveLane::ExternalDataPlane, None => match resolve_topology(None, environment)? { - StackMode::Controlplane => LiveLane::Controlplane, - StackMode::Dataplane => LiveLane::Dataplane, + StackMode::Controlplane => LiveLane::BuiltInDataPlane, + StackMode::Dataplane => LiveLane::ExternalDataPlane, }, }) } @@ -254,8 +254,8 @@ fn resolve_lanes(lanes: impl IntoIterator) -> Vec>(); let all = [ ConformanceTarget::Fixture, - ConformanceTarget::Controlplane, - ConformanceTarget::Dataplane, + ConformanceTarget::BuiltInDataPlane, + ConformanceTarget::ExternalDataPlane, ]; if selected.is_empty() { all.into_iter().collect() diff --git a/src/cli.rs b/src/cli.rs index 7596387..d50a912 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -264,10 +264,10 @@ pub enum CliLane { /// Run directly against the workflow's reference fixture. #[value(alias = "fixture")] FixtureDirect, - /// Run against the Python control-plane stack. - Controlplane, - /// Run through nginx and the Rust dataplane. - Dataplane, + /// Run the routed endpoint through the Python built-in data plane. + BuiltInDataPlane, + /// Run the routed endpoint through the external Rust data plane. + ExternalDataPlane, } /// Upstream live-test group. @@ -372,8 +372,8 @@ impl From for cf_integration_compliance::conformance::ConformanceTarget fn from(lane: CliLane) -> Self { match lane { CliLane::FixtureDirect => Self::Fixture, - CliLane::Controlplane => Self::Controlplane, - CliLane::Dataplane => Self::Dataplane, + CliLane::BuiltInDataPlane => Self::BuiltInDataPlane, + CliLane::ExternalDataPlane => Self::ExternalDataPlane, } } } diff --git a/src/runtime/compliance.rs b/src/runtime/compliance.rs index ad5c4b8..f248c1a 100644 --- a/src/runtime/compliance.rs +++ b/src/runtime/compliance.rs @@ -2,12 +2,66 @@ use super::*; use std::fmt::Write as _; +use std::io::{IsTerminal, Write as IoWrite}; use std::time::Instant; use cf_integration_compliance::conformance::{DEFAULT_CONFORMANCE_SUITE, ScenarioOutcome}; const CONFORMANCE_SERVER_ERA_ENV: &str = "CF_CONFORMANCE_SERVER_ERA"; +struct ConformanceProgress { + task: Option>, + terminal: bool, +} + +impl ConformanceProgress { + fn start(description: impl Into) -> Self { + let description = description.into(); + let terminal = std::io::stderr().is_terminal(); + if !terminal { + return Self { + task: None, + terminal, + }; + } + + let task = tokio::spawn(async move { + const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let mut frame = 0; + loop { + { + let mut stderr = std::io::stderr().lock(); + let _ = write!( + stderr, + "\r\x1b[2KConformance {} {description}", + FRAMES[frame % FRAMES.len()] + ); + let _ = stderr.flush(); + } + frame += 1; + tokio::time::sleep(Duration::from_millis(150)).await; + } + }); + Self { + task: Some(task), + terminal, + } + } +} + +impl Drop for ConformanceProgress { + fn drop(&mut self) { + if let Some(task) = self.task.take() { + task.abort(); + } + if self.terminal { + let mut stderr = std::io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K"); + let _ = stderr.flush(); + } + } +} + impl RuntimeExecutor { fn require_loopback_fixture_base_url(&self) -> AppResult<()> { let base_url = self.base_url()?; @@ -87,8 +141,31 @@ impl RuntimeExecutor { server_era, results_dir, } => { - self.run_conformance(&lanes, &spec_version, server_era, results_dir.as_deref()) - .await + let artifact_root = results_dir + .as_deref() + .unwrap_or_else(|| self.config.integration_dir()); + let setup_log = artifact_root.join("conformance/setup.log"); + if let Some(parent) = setup_log.parent() { + fs::create_dir_all(parent) + .with_context(|| { + format!("failed to create conformance log directory {parent:?}") + }) + .map_err(AppFailure::from)?; + } + fs::write(&setup_log, []) + .with_context(|| format!("failed to clear conformance log {setup_log:?}")) + .map_err(AppFailure::from)?; + let quiet_runner = LoggingProcessRunner::new(&self.runner, &setup_log); + let executor = RuntimeExecutor::new(self.config.clone(), quiet_runner); + let result = executor + .run_conformance(&lanes, &spec_version, server_era, results_dir.as_deref()) + .await; + println!( + "{} {}", + OutputStyle::stdout().info(" Setup output"), + setup_log.display() + ); + result } ConformanceAction::Report { results_dir, @@ -146,10 +223,19 @@ impl RuntimeExecutor { tokio::pin!(interrupt); let (cancellation_sender, cancellation_receiver) = tokio::sync::watch::channel(false); + let cleanup_progress = ConformanceProgress::start("clearing prior integration stacks"); + self.cleanup(TopologySelection::All, CleanupKind::Reset)?; + drop(cleanup_progress); + for topology in topologies { let target = conformance_target(topology); let run_routed = lanes.contains(&target); - let mut topology_failure = self.stack_up(topology, true).await.err(); + let stack_progress = ConformanceProgress::start(format!( + "preparing {}", + conformance_topology_label(topology) + )); + let mut topology_failure = self.stack_up_for_conformance(topology, true).await.err(); + drop(stack_progress); let mut fixture_state = None; let mut fixture_metadata = None; let mut fixture_endpoint = None; @@ -157,11 +243,16 @@ impl RuntimeExecutor { let mut managed_token = None; if topology_failure.is_none() { + let fixture_progress = ConformanceProgress::start(format!( + "starting the official fixture for {}", + conformance_topology_label(topology) + )); let (start_result, start_interrupted) = finish_phase_after_interrupt( self.start_conformance_service(topology, server_era), interrupt.as_mut(), ) .await; + drop(fixture_progress); interrupted |= start_interrupted; match start_result { Ok(()) => { @@ -243,20 +334,33 @@ impl RuntimeExecutor { .map_err(AppFailure::from) }) { Ok(client) => { + let provision_progress = ConformanceProgress::start(format!( + "registering the official fixture for {}", + conformance_topology_label(topology) + )); let (provision_result, provision_interrupted) = finish_phase_after_interrupt( client.provision(OFFICIAL_CONFORMANCE_BACKEND_URL), interrupt.as_mut(), ) .await; + drop(provision_progress); interrupted |= provision_interrupted; match provision_result { Ok(fixture) => { if interrupted { topology_failure = Some(interrupted_conformance_failure()); } else if topology == StackMode::Dataplane - && let Err(error) = - self.wait_for_publisher_snapshot(&fixture.server_id).await + && let Err(error) = { + let publisher_progress = ConformanceProgress::start( + "waiting for the external data-plane configuration", + ); + let result = self + .wait_for_publisher_snapshot_quiet(&fixture.server_id) + .await; + drop(publisher_progress); + result + } { topology_failure = Some(error); } @@ -281,46 +385,41 @@ impl RuntimeExecutor { .map(|(_, fixture)| fixture) .zip(fixture_metadata.as_ref()); match run_inputs { - Some((fixture, metadata)) => { - match self - .managed_bearer_token(topology, &fixture.server_id) - .await - { - Ok(token) => { - managed_token = Some(token); - let token = managed_token - .as_ref() - .expect("managed token was just stored"); - let tests = async { - self.run_official_conformance_mode( - &OfficialConformanceRun { - topology, - server_id: &fixture.server_id, - token: &token.value, - spec_version, - server_era, - fixture: metadata, - cancellation: cancellation_receiver.clone(), - }, - &paths, - ) - .await - .err() - }; - tokio::pin!(tests); - tokio::select! { - failure = &mut tests => topology_failure = failure, - () = interrupt.as_mut() => { - interrupted = true; - cancellation_sender.send_replace(true); - let _ = tests.await; - topology_failure = Some(interrupted_conformance_failure()); - } + Some((fixture, metadata)) => match self.issue_conformance_token().await { + Ok(token) => { + managed_token = Some(token); + let token = managed_token + .as_ref() + .expect("managed token was just stored"); + let tests = async { + self.run_official_conformance_mode( + &OfficialConformanceRun { + topology, + server_id: &fixture.server_id, + token: &token.value, + spec_version, + server_era, + fixture: metadata, + cancellation: cancellation_receiver.clone(), + }, + &paths, + ) + .await + .err() + }; + tokio::pin!(tests); + tokio::select! { + failure = &mut tests => topology_failure = failure, + () = interrupt.as_mut() => { + interrupted = true; + cancellation_sender.send_replace(true); + let _ = tests.await; + topology_failure = Some(interrupted_conformance_failure()); } } - Err(error) => topology_failure = Some(error), } - } + Err(error) => topology_failure = Some(error), + }, None => { topology_failure = Some(AppFailure::from(anyhow!( "successful fixture setup did not retain its runtime state" @@ -411,7 +510,7 @@ impl RuntimeExecutor { ) -> AppResult<()> { let target = conformance_target(run.topology); let endpoint = GatewayClient::builder( - gateway_topology(run.topology), + GatewayTopology::Dataplane, self.base_url()?, run.server_id, run.token, @@ -422,10 +521,14 @@ impl RuntimeExecutor { .map_err(AppFailure::from)? .endpoint() .clone(); - let proxy = AuthProxy::start(endpoint, run.token) - .await - .context("failed to start the conformance authentication proxy") - .map_err(AppFailure::from)?; + let proxy = match run.topology { + StackMode::Controlplane => { + AuthProxy::start_builtin_data_plane(endpoint, run.token).await + } + StackMode::Dataplane => AuthProxy::start(endpoint, run.token).await, + } + .context("failed to start the conformance authentication proxy") + .map_err(AppFailure::from)?; let result = self .run_official_conformance_target( &ConformanceTargetRun { @@ -528,6 +631,11 @@ impl RuntimeExecutor { ) ); let started = Instant::now(); + let runner_progress = ConformanceProgress::start(format!( + "running {} scenarios for {}", + expected_scenarios.len(), + run.target + )); let process_result = self .runner .run_async_cancellable_to_log( @@ -537,6 +645,7 @@ impl RuntimeExecutor { ) .await .map_err(AppFailure::from); + drop(runner_progress); let results = load_server_results(&lane_paths.official_results).map_err(AppFailure::from); if !conformance_process_completed(&process_result) { @@ -643,10 +752,10 @@ fn render_conformance_lane_results( fn conformance_topologies(lanes: &[ConformanceTarget]) -> Vec { let mut topologies = Vec::new(); - if lanes.contains(&ConformanceTarget::Controlplane) { + if lanes.contains(&ConformanceTarget::BuiltInDataPlane) { topologies.push(StackMode::Controlplane); } - if lanes.contains(&ConformanceTarget::Dataplane) { + if lanes.contains(&ConformanceTarget::ExternalDataPlane) { topologies.push(StackMode::Dataplane); } if topologies.is_empty() { @@ -657,8 +766,8 @@ fn conformance_topologies(lanes: &[ConformanceTarget]) -> Vec { const fn conformance_topology_label(topology: StackMode) -> &'static str { match topology { - StackMode::Controlplane => "controlplane", - StackMode::Dataplane => "dataplane", + StackMode::Controlplane => "built-in data-plane route", + StackMode::Dataplane => "external data-plane route", } } @@ -788,13 +897,16 @@ mod tests { [StackMode::Controlplane] ); assert_eq!( - conformance_topologies(&[ConformanceTarget::Fixture, ConformanceTarget::Dataplane,]), + conformance_topologies(&[ + ConformanceTarget::Fixture, + ConformanceTarget::ExternalDataPlane, + ]), [StackMode::Dataplane] ); assert_eq!( conformance_topologies(&[ - ConformanceTarget::Controlplane, - ConformanceTarget::Dataplane, + ConformanceTarget::BuiltInDataPlane, + ConformanceTarget::ExternalDataPlane, ]), [StackMode::Controlplane, StackMode::Dataplane] ); @@ -868,7 +980,7 @@ mod tests { let results = mixed_conformance_results(); let rendered = render_conformance_lane_results( - ConformanceTarget::Dataplane, + ConformanceTarget::ExternalDataPlane, &results, Duration::from_millis(1_250), OutputStyle::plain(), @@ -876,7 +988,7 @@ mod tests { assert_eq!( rendered, - " FAIL (1/2) failing\n PASS (2/2) passing\n────────────\n Summary [ 1.250s] 2 scenarios run for dataplane: 1 passed, 1 failed, 0 skipped, 0 unknown" + " FAIL (1/2) failing\n PASS (2/2) passing\n────────────\n Summary [ 1.250s] 2 scenarios run for external data-plane route: 1 passed, 1 failed, 0 skipped, 0 unknown" ); assert_eq!( rendered.lines().filter(|line| line.contains(" (")).count(), @@ -889,21 +1001,23 @@ mod tests { #[test] fn colored_conformance_output_styles_lane_statuses_and_failed_summary() { let header = render_conformance_lane_header( - ConformanceTarget::Dataplane, + ConformanceTarget::ExternalDataPlane, 2, "2026-07-28", ConformanceServerEra::Modern, OutputStyle::colored(), ); let results = render_conformance_lane_results( - ConformanceTarget::Dataplane, + ConformanceTarget::ExternalDataPlane, &mixed_conformance_results(), Duration::from_millis(1_250), OutputStyle::colored(), ); assert!(header.contains("\x1b[36m────────────\x1b[0m")); - assert!(header.contains("\x1b[1;36m MCP conformance lane: dataplane\x1b[0m")); + assert!( + header.contains("\x1b[1;36m MCP conformance lane: external data-plane route\x1b[0m") + ); assert!(results.contains("\x1b[31m FAIL\x1b[0m (1/2) failing")); assert!(results.contains("\x1b[32m PASS\x1b[0m (2/2) passing")); assert!(results.contains(" \x1b[1;31mSummary\x1b[0m [ 1.250s]")); diff --git a/src/runtime/live.rs b/src/runtime/live.rs index 5e0ba3a..a65b09b 100644 --- a/src/runtime/live.rs +++ b/src/runtime/live.rs @@ -30,11 +30,11 @@ impl RuntimeExecutor { protocol_version, ) } - LiveLane::Controlplane => { + LiveLane::BuiltInDataPlane => { self.run_routed_live(StackMode::Controlplane, group, protocol_version) .await } - LiveLane::Dataplane => { + LiveLane::ExternalDataPlane => { self.run_routed_live(StackMode::Dataplane, group, protocol_version) .await } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index ac305bc..3abf1be 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -30,7 +30,7 @@ use cf_integration_mcp::probe::{ProbeConfig, run_probe}; use cf_integration_platform::checkout::{CheckoutManager, CheckoutRequest}; use cf_integration_platform::compose::{ComposeProject, validate_integration_contract}; use cf_integration_platform::config::AppConfig; -use cf_integration_platform::process::{CommandSpec, ProcessRunner}; +use cf_integration_platform::process::{CommandSpec, LoggingProcessRunner, ProcessRunner}; use cf_integration_platform::stack::{ BuildInputs, BuildMode, CleanupKind, FreshnessSnapshot, ServiceSnapshot, StackCommandPlan, StackFreshness, resolve_build, @@ -52,6 +52,7 @@ const STACK_READY_TIMEOUT: Duration = Duration::from_secs(90); const STACK_READY_POLL_INTERVAL: Duration = Duration::from_millis(250); const STACK_READY_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); const MANAGED_TOKEN_DESCRIPTION: &str = "Ephemeral cf-integration dataplane credential"; +const CONFORMANCE_TOKEN_DESCRIPTION: &str = "Ephemeral cf-integration conformance credential"; mod compliance; mod inspect; @@ -241,6 +242,20 @@ impl RuntimeExecutor { } async fn issue_dataplane_token(&self, server_id: &str) -> AppResult { + self.issue_catalog_token(Some(server_id), MANAGED_TOKEN_DESCRIPTION) + .await + } + + async fn issue_conformance_token(&self) -> AppResult { + self.issue_catalog_token(None, CONFORMANCE_TOKEN_DESCRIPTION) + .await + } + + async fn issue_catalog_token( + &self, + server_id: Option<&str>, + description: &str, + ) -> AppResult { let endpoint = url::Url::parse(self.base_url()?) .context("MCP_CLI_BASE_URL is not a valid URL") .and_then(|base| { @@ -260,26 +275,29 @@ impl RuntimeExecutor { .build() .context("failed to build token catalog client") .map_err(AppFailure::from)?; + let mut payload = serde_json::json!({ + "name": format!("cf-integration-{}", uuid::Uuid::new_v4()), + "description": description, + "expires_in_days": 1, + "user_email": user_email, + }); + if let Some(server_id) = server_id { + payload["scope"] = serde_json::json!({ + "server_id": server_id, + "permissions": ["servers.read", "servers.use", "tools.read", "tools.call"], + }); + } let response = http .post(endpoint) .bearer_auth(&admin_token) - .json(&serde_json::json!({ - "name": format!("cf-integration-{}", uuid::Uuid::new_v4()), - "description": MANAGED_TOKEN_DESCRIPTION, - "expires_in_days": 1, - "user_email": user_email, - "scope": { - "server_id": server_id, - "permissions": ["servers.read", "servers.use", "tools.read", "tools.call"], - }, - })) + .json(&payload) .send() .await .context("token catalog request failed before receiving a response") .map_err(AppFailure::from)?; if !response.status().is_success() { return Err(AppFailure::from(anyhow!( - "token catalog returned HTTP {} while issuing a dataplane credential", + "token catalog returned HTTP {} while issuing a managed credential", response.status().as_u16() ))); } @@ -641,4 +659,46 @@ mod tests { .await .expect("caller token cleanup should be a no-op"); } + + #[tokio::test] + async fn conformance_tokens_match_the_unscoped_controlplane_lane_contract() { + let capture = Capture::default(); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("token catalog listener should bind"); + let address = listener + .local_addr() + .expect("token catalog listener should have an address"); + let server = tokio::spawn( + axum::serve( + listener, + Router::new() + .fallback(any(token_catalog)) + .with_state(capture.clone()), + ) + .into_future(), + ); + let root = tempfile::tempdir().expect("temporary repository should be created"); + let config = app_config(root.path(), &format!("http://{address}"), &[]); + let runtime = RuntimeExecutor::new(config, SystemProcessRunner); + + let token = runtime + .issue_conformance_token() + .await + .expect("conformance token should be issued"); + runtime + .revoke_managed_token(&token) + .await + .expect("conformance token should be revoked"); + server.abort(); + + let requests = capture + .0 + .lock() + .expect("token capture lock should not be poisoned"); + assert_eq!(requests.len(), 3); + assert_eq!(requests[1].1, "/v1/tokens"); + assert_eq!(requests[1].3["description"], CONFORMANCE_TOKEN_DESCRIPTION); + assert!(requests[1].3.get("scope").is_none()); + } } diff --git a/src/runtime/reports.rs b/src/runtime/reports.rs index 99a61f0..bb948ab 100644 --- a/src/runtime/reports.rs +++ b/src/runtime/reports.rs @@ -32,8 +32,9 @@ impl RuntimeExecutor { ) -> AppResult { let fixture = self.load_conformance_artifact(paths, ConformanceTarget::Fixture)?; let controlplane = - self.load_conformance_artifact(paths, ConformanceTarget::Controlplane)?; - let dataplane = self.load_conformance_artifact(paths, ConformanceTarget::Dataplane)?; + self.load_conformance_artifact(paths, ConformanceTarget::BuiltInDataPlane)?; + let dataplane = + self.load_conformance_artifact(paths, ConformanceTarget::ExternalDataPlane)?; if fixture.is_none() && controlplane.is_none() && dataplane.is_none() { return Err(AppFailure::from(anyhow!( "no official conformance artifacts found beneath {}", @@ -163,8 +164,8 @@ impl CompliancePaths { pub(super) fn clear_conformance(&self) -> AppResult<()> { for target in [ ConformanceTarget::Fixture, - ConformanceTarget::Controlplane, - ConformanceTarget::Dataplane, + ConformanceTarget::BuiltInDataPlane, + ConformanceTarget::ExternalDataPlane, ] { remove_artifact_directory(&self.conformance_lane(target).root)?; } @@ -286,7 +287,7 @@ fn compatible_metadata<'a>( for candidate in [fixture, controlplane, dataplane].into_iter().flatten() { if candidate.fixture != metadata.fixture { return Err(AppFailure::from(anyhow!( - "direct fixture, control-plane, and dataplane conformance fixture provenance mismatch" + "direct fixture, built-in data-plane, and external data-plane conformance fixture provenance mismatch" ))); } if candidate.spec_version != metadata.spec_version @@ -295,7 +296,7 @@ fn compatible_metadata<'a>( || candidate.oracle != metadata.oracle { return Err(AppFailure::from(anyhow!( - "direct fixture, control-plane, and dataplane conformance artifacts were produced by incompatible runs" + "direct fixture, built-in data-plane, and external data-plane conformance artifacts were produced by incompatible runs" ))); } } @@ -313,16 +314,16 @@ fn compatible_metadata<'a>( pub(super) const fn conformance_target(topology: StackMode) -> ConformanceTarget { match topology { - StackMode::Controlplane => ConformanceTarget::Controlplane, - StackMode::Dataplane => ConformanceTarget::Dataplane, + StackMode::Controlplane => ConformanceTarget::BuiltInDataPlane, + StackMode::Dataplane => ConformanceTarget::ExternalDataPlane, } } const fn conformance_target_slug(target: ConformanceTarget) -> &'static str { match target { ConformanceTarget::Fixture => "fixture-direct", - ConformanceTarget::Controlplane => "controlplane", - ConformanceTarget::Dataplane => "dataplane", + ConformanceTarget::BuiltInDataPlane => "built-in-data-plane", + ConformanceTarget::ExternalDataPlane => "external-data-plane", } } @@ -369,12 +370,16 @@ mod tests { PathBuf::from("artifacts/conformance/fixture-direct") ); assert_eq!( - paths.conformance_lane(ConformanceTarget::Controlplane).root, - PathBuf::from("artifacts/conformance/controlplane") + paths + .conformance_lane(ConformanceTarget::BuiltInDataPlane) + .root, + PathBuf::from("artifacts/conformance/built-in-data-plane") ); assert_eq!( - paths.conformance_lane(ConformanceTarget::Dataplane).root, - PathBuf::from("artifacts/conformance/dataplane") + paths + .conformance_lane(ConformanceTarget::ExternalDataPlane) + .root, + PathBuf::from("artifacts/conformance/external-data-plane") ); } @@ -384,8 +389,8 @@ mod tests { let paths = CompliancePaths::new(directory.path(), PathBuf::from("reports")); for target in [ ConformanceTarget::Fixture, - ConformanceTarget::Controlplane, - ConformanceTarget::Dataplane, + ConformanceTarget::BuiltInDataPlane, + ConformanceTarget::ExternalDataPlane, ] { fs::create_dir_all(paths.conformance_lane(target).root) .expect("lane directory should be created"); @@ -397,8 +402,8 @@ mod tests { for target in [ ConformanceTarget::Fixture, - ConformanceTarget::Controlplane, - ConformanceTarget::Dataplane, + ConformanceTarget::BuiltInDataPlane, + ConformanceTarget::ExternalDataPlane, ] { assert!(!paths.conformance_lane(target).root.exists()); } @@ -407,7 +412,7 @@ mod tests { #[test] fn partial_lane_metadata_is_reportable_when_provenance_matches() { let fixture = metadata(ConformanceTarget::Fixture); - let dataplane = metadata(ConformanceTarget::Dataplane); + let dataplane = metadata(ConformanceTarget::ExternalDataPlane); let selected = compatible_metadata( Some(&fixture), @@ -423,7 +428,7 @@ mod tests { #[test] fn mismatched_fixture_provenance_prevents_cross_lane_comparison() { let fixture = metadata(ConformanceTarget::Fixture); - let mut dataplane = metadata(ConformanceTarget::Dataplane); + let mut dataplane = metadata(ConformanceTarget::ExternalDataPlane); dataplane .fixture .as_mut() @@ -441,7 +446,7 @@ mod tests { #[test] fn mismatched_server_eras_prevent_cross_lane_comparison() { let fixture = metadata(ConformanceTarget::Fixture); - let mut dataplane = metadata(ConformanceTarget::Dataplane); + let mut dataplane = metadata(ConformanceTarget::ExternalDataPlane); dataplane.server_era = ConformanceServerEra::Legacy; let error = compatible_metadata(Some(&fixture), None, Some(&dataplane), None) diff --git a/src/runtime/stack.rs b/src/runtime/stack.rs index 6bb993b..e307fce 100644 --- a/src/runtime/stack.rs +++ b/src/runtime/stack.rs @@ -6,7 +6,7 @@ impl RuntimeExecutor { pub(super) async fn execute_stack(&self, action: StackAction) -> AppResult<()> { match action { StackAction::Up { topology, fresh } => { - self.stack_up(topology, fresh).await?; + self.stack_up_for_conformance(topology, fresh).await?; eprintln!( "{}", OutputStyle::stderr().info("Starting the pinned MCP conformance server.") @@ -63,25 +63,47 @@ impl RuntimeExecutor { } pub(super) async fn stack_up(&self, mode: StackMode, fresh: bool) -> AppResult<()> { + self.stack_up_with_project(mode, fresh, self.compose_project(mode), true) + .await + } + + pub(super) async fn stack_up_for_conformance( + &self, + mode: StackMode, + fresh: bool, + ) -> AppResult<()> { + self.stack_up_with_project(mode, fresh, self.conformance_runtime_project(mode), false) + .await + } + + async fn stack_up_with_project( + &self, + mode: StackMode, + fresh: bool, + project: ComposeProject, + report_progress: bool, + ) -> AppResult<()> { self.ensure_mode_sources(mode)?; if mode == StackMode::Dataplane { self.validate_compose_contract()?; } - let build = self.resolve_build(mode)?; - self.pull_images(mode)?; + let build = self.resolve_build(mode, report_progress)?; + self.pull_images(mode, build, report_progress)?; if mode == StackMode::Dataplane && !fresh && !self.environment_flag("CF_FORCE_STACK_RESTART", false) && !build && self.integration_freshness()? == StackFreshness::Current { - println!( - "{}", - OutputStyle::stdout() - .success("Integration stack already current; skipping Docker Compose up.") - ); - return self.wait_for_public_endpoint(mode).await; + if report_progress { + println!( + "{}", + OutputStyle::stdout() + .success("Integration stack already current; skipping Docker Compose up.") + ); + } + return self.wait_for_public_endpoint(mode, report_progress).await; } if fresh { @@ -103,39 +125,41 @@ impl RuntimeExecutor { .map_err(|_| { AppFailure::from(anyhow!("CONTROLPLANE_LOCUST_WORKERS must be an integer")) })?; - let command = StackCommandPlan::up( - self.compose_project(mode), - mode, - build, - start_locust, - locust_workers, - ); + let command = StackCommandPlan::up(project, mode, build, start_locust, locust_workers); self.runner .run(&self.compose_environment(command.command().clone(), mode, true)?)?; - self.wait_for_public_endpoint(mode).await?; - println!( - "{}", - OutputStyle::stdout().success(&format!( - "{} stack started.", - match mode { - StackMode::Controlplane => "Control-plane", - StackMode::Dataplane => "Dataplane integration", - } - )) - ); + self.wait_for_public_endpoint(mode, report_progress).await?; + if report_progress { + println!( + "{}", + OutputStyle::stdout().success(&format!( + "{} stack started.", + match mode { + StackMode::Controlplane => "Control-plane", + StackMode::Dataplane => "Dataplane integration", + } + )) + ); + } Ok(()) } - async fn wait_for_public_endpoint(&self, mode: StackMode) -> AppResult<()> { + async fn wait_for_public_endpoint( + &self, + mode: StackMode, + report_progress: bool, + ) -> AppResult<()> { let endpoint = self.public_mcp_endpoint(mode)?; - eprintln!( - "{}", - OutputStyle::stderr().info(&format!( - "Waiting up to {}s for the public {} MCP endpoint.", - STACK_READY_TIMEOUT.as_secs(), - stack_mode_label(mode) - )) - ); + if report_progress { + eprintln!( + "{}", + OutputStyle::stderr().info(&format!( + "Waiting up to {}s for the public {} MCP endpoint.", + STACK_READY_TIMEOUT.as_secs(), + stack_mode_label(mode) + )) + ); + } wait_for_http_endpoint(&endpoint, mode, STACK_READY_TIMEOUT).await } @@ -185,10 +209,15 @@ impl RuntimeExecutor { } pub(super) fn conformance_compose_project(&self, mode: StackMode) -> ComposeProject { - self.compose_project(mode) + self.conformance_runtime_project(mode) .with_conformance_fixture(self.config.root()) } + fn conformance_runtime_project(&self, mode: StackMode) -> ComposeProject { + self.compose_project(mode) + .with_conformance_runtime(self.config.root()) + } + pub(super) fn compose_environment( &self, command: CommandSpec, @@ -385,7 +414,7 @@ impl RuntimeExecutor { ))) } - fn resolve_build(&self, mode: StackMode) -> AppResult { + fn resolve_build(&self, mode: StackMode, report_progress: bool) -> AppResult { let setting = required_text(&self.config.compose_build().value, "CF_COMPOSE_BUILD")?; let mode_setting = BuildMode::from_str(setting).map_err(|error| AppFailure::from(anyhow!(error)))?; @@ -421,11 +450,13 @@ impl RuntimeExecutor { dataplane_image_revision, }, ); - for reason in decision.reasons { - println!( - "{}", - OutputStyle::stdout().info(&format!("CF_COMPOSE_BUILD: {reason}")) - ); + if report_progress { + for reason in decision.reasons { + println!( + "{}", + OutputStyle::stdout().info(&format!("CF_COMPOSE_BUILD: {reason}")) + ); + } } Ok(decision.build) } @@ -444,12 +475,13 @@ impl RuntimeExecutor { } } - fn pull_images(&self, mode: StackMode) -> AppResult<()> { - if self.config.controlplane_image().is_prebuilt() { + fn pull_images(&self, mode: StackMode, build: bool, report_progress: bool) -> AppResult<()> { + if !build && self.config.controlplane_image().is_prebuilt() { self.pull_if_changed( "cf-controlplane", self.config.controlplane_image().resolved(), None, + report_progress, )?; } if mode == StackMode::Dataplane && self.config.dataplane_ref().value.is_empty() { @@ -458,6 +490,7 @@ impl RuntimeExecutor { "cf-dataplane", self.config.dataplane_image().resolved(), Some(platform.as_os_str()), + report_progress, )?; } Ok(()) @@ -468,6 +501,7 @@ impl RuntimeExecutor { label: &str, image: &OsStr, platform: Option<&OsStr>, + report_progress: bool, ) -> AppResult<()> { let inspect = CommandSpec::new("docker").args([ OsString::from("buildx"), @@ -502,20 +536,24 @@ impl RuntimeExecutor { .lines() .any(|value| value.ends_with(&format!("@{digest}"))) }) { + if report_progress { + println!( + "{}", + OutputStyle::stdout() + .success(&format!("{label} image digest unchanged: {digest}")) + ); + } + return Ok(()); + } + } else if local_exists { + if report_progress { println!( "{}", - OutputStyle::stdout() - .success(&format!("{label} image digest unchanged: {digest}")) + OutputStyle::stdout().warning(&format!( + "{label} remote digest unavailable; using local image." + )) ); - return Ok(()); } - } else if local_exists { - println!( - "{}", - OutputStyle::stdout().warning(&format!( - "{label} remote digest unavailable; using local image." - )) - ); return Ok(()); } diff --git a/src/runtime/workloads.rs b/src/runtime/workloads.rs index 5950439..159e0d5 100644 --- a/src/runtime/workloads.rs +++ b/src/runtime/workloads.rs @@ -128,6 +128,20 @@ impl RuntimeExecutor { } pub(super) async fn wait_for_publisher_snapshot(&self, server_id: &str) -> AppResult<()> { + self.wait_for_publisher_snapshot_with_status(server_id, true) + .await + } + + pub(super) async fn wait_for_publisher_snapshot_quiet(&self, server_id: &str) -> AppResult<()> { + self.wait_for_publisher_snapshot_with_status(server_id, false) + .await + } + + async fn wait_for_publisher_snapshot_with_status( + &self, + server_id: &str, + report_progress: bool, + ) -> AppResult<()> { let timeout_seconds = self.environment_u64("CF_PUBLISHER_WAIT_SECONDS", 90)?; let project = required_text( &self.config.integration_project().value, @@ -139,12 +153,14 @@ impl RuntimeExecutor { )) })?; let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds); - eprintln!( - "{}", - OutputStyle::stderr().info(&format!( - "Waiting up to {timeout_seconds}s for a publisher snapshot containing server {server_id}." - )) - ); + if report_progress { + eprintln!( + "{}", + OutputStyle::stderr().info(&format!( + "Waiting up to {timeout_seconds}s for a publisher snapshot containing server {server_id}." + )) + ); + } loop { let command = CommandSpec::new("docker").args([ "exec", diff --git a/tests/cli.rs b/tests/cli.rs index 62a3e40..0811246 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -216,7 +216,7 @@ fn live_defaults_to_all_and_accepts_the_main_harness_groups() { "cf-integration", "live", "--topology", - "dataplane", + "external-data-plane", "--group", name, ]) @@ -224,7 +224,7 @@ fn live_defaults_to_all_and_accepts_the_main_harness_groups() { else { panic!("expected live workflow") }; - assert_eq!(args.target.lane, Some(CliLane::Dataplane)); + assert_eq!(args.target.lane, Some(CliLane::ExternalDataPlane)); assert_eq!(args.group, expected); } } @@ -281,7 +281,7 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { } fn assert_fixture_target(target: &WorkflowTargetArgs) { - assert_eq!(target.lane, Some(CliLane::Controlplane)); + assert_eq!(target.lane, Some(CliLane::BuiltInDataPlane)); assert_eq!( target.protocol_version, Some( @@ -317,12 +317,14 @@ fn operational_workflows_share_canonical_lane_and_protocol_version_flags() { }; assert_routed_target(&load.target); - let Command::Live(live) = parse( - &["cf-integration", "live"] - .into_iter() - .chain(common) - .collect::>(), - ) + let Command::Live(live) = parse(&[ + "cf-integration", + "live", + "--lane", + "built-in-data-plane", + "--protocol-version", + "2025-06-18", + ]) .command else { panic!("expected live workflow") @@ -392,7 +394,7 @@ fn conformance_accepts_repeatable_exact_lanes_and_supported_revisions() { "--lane", "fixture-direct", "--lane", - "dataplane", + "external-data-plane", "--protocol-version", "2025-11-25", "--server-era", @@ -402,7 +404,10 @@ fn conformance_accepts_repeatable_exact_lanes_and_supported_revisions() { else { panic!("expected conformance run") }; - assert_eq!(args.lane, [CliLane::FixtureDirect, CliLane::Dataplane]); + assert_eq!( + args.lane, + [CliLane::FixtureDirect, CliLane::ExternalDataPlane] + ); assert_eq!( args.protocol_version, "2025-11-25" diff --git a/tests/dispatch.rs b/tests/dispatch.rs index 67b504d..337d92d 100644 --- a/tests/dispatch.rs +++ b/tests/dispatch.rs @@ -145,7 +145,7 @@ fn live_resolves_lane_group_and_protocol_version() { ], ), Action::Live { - lane: LiveLane::Controlplane, + lane: LiveLane::BuiltInDataPlane, group: LiveGroup::Mcp, protocol_version: "2025-06-18" .parse::() @@ -210,8 +210,8 @@ fn conformance_defaults_to_all_three_ordered_lanes() { Action::Conformance(ConformanceAction::Run { lanes: vec![ ConformanceTarget::Fixture, - ConformanceTarget::Controlplane, - ConformanceTarget::Dataplane, + ConformanceTarget::BuiltInDataPlane, + ConformanceTarget::ExternalDataPlane, ], spec_version: "2026-07-28".to_owned(), server_era: ConformanceServerEra::Dual, @@ -229,11 +229,11 @@ fn conformance_lanes_are_deduplicated_and_normalized() { "conformance", "run", "--lane", - "dataplane", + "external-data-plane", "--lane", "fixture-direct", "--lane", - "dataplane", + "external-data-plane", "--protocol-version", "2025-06-18", "--server-era", @@ -244,7 +244,10 @@ fn conformance_lanes_are_deduplicated_and_normalized() { &[], ), Action::Conformance(ConformanceAction::Run { - lanes: vec![ConformanceTarget::Fixture, ConformanceTarget::Dataplane], + lanes: vec![ + ConformanceTarget::Fixture, + ConformanceTarget::ExternalDataPlane, + ], spec_version: "2025-06-18".to_owned(), server_era: ConformanceServerEra::Modern, results_dir: Some(PathBuf::from("results")),