From fb4c58cc489ab17dca303ecd7471054cac70876e Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:20:12 +0100 Subject: [PATCH 01/40] Unify manual cluster control surface --- testing-framework/core/src/runtime/manual.rs | 12 ++--------- .../deployers/local/src/manual/mod.rs | 20 ++++++------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/testing-framework/core/src/runtime/manual.rs b/testing-framework/core/src/runtime/manual.rs index dfa0292..4e8af53 100644 --- a/testing-framework/core/src/runtime/manual.rs +++ b/testing-framework/core/src/runtime/manual.rs @@ -1,15 +1,7 @@ use async_trait::async_trait; -use crate::scenario::{Application, DynError, NodeControlHandle, StartNodeOptions, StartedNode}; +use crate::scenario::{Application, ClusterWaitHandle, NodeControlHandle}; /// Interface for imperative, deployer-backed manual clusters. #[async_trait] -pub trait ManualClusterHandle: NodeControlHandle { - async fn start_node_with( - &self, - name: &str, - options: StartNodeOptions, - ) -> Result, DynError>; - - async fn wait_network_ready(&self) -> Result<(), DynError>; -} +pub trait ManualClusterHandle: NodeControlHandle + ClusterWaitHandle {} diff --git a/testing-framework/deployers/local/src/manual/mod.rs b/testing-framework/deployers/local/src/manual/mod.rs index 36a4019..028a690 100644 --- a/testing-framework/deployers/local/src/manual/mod.rs +++ b/testing-framework/deployers/local/src/manual/mod.rs @@ -1,8 +1,8 @@ use testing_framework_core::{ manual::ManualClusterHandle, scenario::{ - DynError, ExternalNodeSource, NodeClients, NodeControlHandle, ReadinessError, - StartNodeOptions, StartedNode, + ClusterWaitHandle, DynError, ExternalNodeSource, NodeClients, NodeControlHandle, + ReadinessError, StartNodeOptions, StartedNode, }, }; use thiserror::Error; @@ -157,19 +157,11 @@ impl NodeControlHandle for ManualCluster { } #[async_trait::async_trait] -impl ManualClusterHandle for ManualCluster { - async fn start_node_with( - &self, - name: &str, - options: StartNodeOptions, - ) -> Result, DynError> { - self.nodes - .start_node_with(name, options) - .await - .map_err(|err| err.into()) - } - +impl ClusterWaitHandle for ManualCluster { async fn wait_network_ready(&self) -> Result<(), DynError> { self.wait_network_ready().await.map_err(|err| err.into()) } } + +#[async_trait::async_trait] +impl ManualClusterHandle for ManualCluster {} From 365526d23609107b6253b6b9f4ea1a63095203a2 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:21:21 +0100 Subject: [PATCH 02/40] Reduce runtime wait surface --- .../core/src/scenario/runtime/context.rs | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/testing-framework/core/src/scenario/runtime/context.rs b/testing-framework/core/src/scenario/runtime/context.rs index 65f28a5..2eaa256 100644 --- a/testing-framework/core/src/scenario/runtime/context.rs +++ b/testing-framework/core/src/scenario/runtime/context.rs @@ -121,22 +121,12 @@ impl RunContext { self.node_control.clone() } - #[must_use] - pub fn cluster_wait(&self) -> Option>> { - self.cluster_wait.clone() - } - #[must_use] pub const fn controls_nodes(&self) -> bool { self.node_control.is_some() } - #[must_use] - pub const fn can_wait_network_ready(&self) -> bool { - self.cluster_wait.is_some() - } - - pub async fn wait_network_ready(&self) -> Result<(), DynError> { + pub(crate) async fn wait_network_ready(&self) -> Result<(), DynError> { self.require_cluster_wait()?.wait_network_ready().await } @@ -146,7 +136,9 @@ impl RunContext { } fn require_cluster_wait(&self) -> Result>, DynError> { - self.cluster_wait() + self.cluster_wait + .as_ref() + .map(Arc::clone) .ok_or_else(|| RunContextCapabilityError::MissingClusterWait.into()) } } @@ -192,10 +184,6 @@ impl RunHandle { pub fn context(&self) -> &RunContext { &self.run_context } - - pub async fn wait_network_ready(&self) -> Result<(), DynError> { - self.run_context.wait_network_ready().await - } } /// Derived metrics about the current run timing. From 743e31fa3c853aaeac52e9d367c3f3cfad086704 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:22:16 +0100 Subject: [PATCH 03/40] Hide runner context storage details --- testing-framework/core/src/scenario/runtime/runner.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testing-framework/core/src/scenario/runtime/runner.rs b/testing-framework/core/src/scenario/runtime/runner.rs index 652d740..16c9002 100644 --- a/testing-framework/core/src/scenario/runtime/runner.rs +++ b/testing-framework/core/src/scenario/runtime/runner.rs @@ -45,8 +45,8 @@ impl Runner { /// Access the underlying run context. #[must_use] - pub fn context(&self) -> Arc> { - Arc::clone(&self.context) + pub fn context(&self) -> &RunContext { + self.context.as_ref() } pub async fn wait_network_ready(&self) -> Result<(), DynError> { @@ -71,7 +71,7 @@ impl Runner { where Caps: Send + Sync, { - let context = self.context(); + let context = Arc::clone(&self.context); let run_duration = scenario.duration(); let workloads = scenario.workloads().to_vec(); let expectation_count = scenario.expectations().len(); From 034e56efa5b4cf25030a0dece4e74f43472cc0bc Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:24:47 +0100 Subject: [PATCH 04/40] Reduce source-mode leakage in run context --- .../core/src/scenario/runtime/context.rs | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/testing-framework/core/src/scenario/runtime/context.rs b/testing-framework/core/src/scenario/runtime/context.rs index 2eaa256..a136fb4 100644 --- a/testing-framework/core/src/scenario/runtime/context.rs +++ b/testing-framework/core/src/scenario/runtime/context.rs @@ -1,10 +1,7 @@ use std::{sync::Arc, time::Duration}; use super::{metrics::Metrics, node_clients::ClusterClient}; -use crate::scenario::{ - Application, BorrowedNode, ClusterWaitHandle, DynError, ManagedNode, NodeClients, - NodeControlHandle, -}; +use crate::scenario::{Application, ClusterWaitHandle, DynError, NodeClients, NodeControlHandle}; #[derive(Debug, thiserror::Error)] enum RunContextCapabilityError { @@ -71,26 +68,6 @@ impl RunContext { self.node_clients.random_client() } - #[must_use] - pub fn managed_nodes(&self) -> Vec> { - self.node_clients.managed_nodes() - } - - #[must_use] - pub fn borrowed_nodes(&self) -> Vec> { - self.node_clients.borrowed_nodes() - } - - #[must_use] - pub fn find_managed_node(&self, identity: &str) -> Option> { - self.node_clients.find_managed(identity) - } - - #[must_use] - pub fn find_borrowed_node(&self, identity: &str) -> Option> { - self.node_clients.find_borrowed(identity) - } - #[must_use] pub fn feed(&self) -> ::Feed { self.feed.clone() From 23838867c22bff99b0b81c3a6f31be2e5a3d4ed4 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:26:33 +0100 Subject: [PATCH 05/40] Trim node client public surface --- .../core/src/scenario/runtime/node_clients.rs | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/testing-framework/core/src/scenario/runtime/node_clients.rs b/testing-framework/core/src/scenario/runtime/node_clients.rs index 363e80d..ce31dcf 100644 --- a/testing-framework/core/src/scenario/runtime/node_clients.rs +++ b/testing-framework/core/src/scenario/runtime/node_clients.rs @@ -1,6 +1,6 @@ use rand::{seq::SliceRandom as _, thread_rng}; -use super::inventory::{BorrowedNode, ManagedNode, NodeInventory}; +use super::inventory::NodeInventory; use crate::scenario::{Application, DynError}; /// Collection of API clients for the node set. @@ -79,30 +79,6 @@ impl NodeClients { self.inventory.clear(); } - #[must_use] - /// Returns a cloned snapshot of managed node handles. - pub fn managed_nodes(&self) -> Vec> { - self.inventory.managed_nodes() - } - - #[must_use] - /// Returns a cloned snapshot of borrowed node handles. - pub fn borrowed_nodes(&self) -> Vec> { - self.inventory.borrowed_nodes() - } - - #[must_use] - /// Finds a managed node by canonical identity. - pub fn find_managed(&self, identity: &str) -> Option> { - self.inventory.find_managed(identity) - } - - #[must_use] - /// Finds a borrowed node by canonical identity. - pub fn find_borrowed(&self, identity: &str) -> Option> { - self.inventory.find_borrowed(identity) - } - fn shuffled_snapshot(&self) -> Vec { let mut clients = self.snapshot(); clients.shuffle(&mut thread_rng()); From da2f51d46f968f446ab7c7111c38811e4149e19e Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:32:10 +0100 Subject: [PATCH 06/40] Make attach source construction explicit --- .../core/src/scenario/sources/model.rs | 22 +++++++------------ .../deployers/compose/src/deployer/mod.rs | 10 +++++++-- .../compose/src/deployer/orchestrator.rs | 2 +- .../deployers/k8s/src/deployer/mod.rs | 5 ++++- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 0e14091..0a3dc69 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -21,13 +21,10 @@ impl AttachSource { } #[must_use] - pub fn with_namespace(self, namespace: String) -> Self { - match self { - Self::K8s { label_selector, .. } => Self::K8s { - namespace: Some(namespace), - label_selector, - }, - other => other, + pub fn k8s_in_namespace(label_selector: String, namespace: String) -> Self { + Self::K8s { + namespace: Some(namespace), + label_selector, } } @@ -40,13 +37,10 @@ impl AttachSource { } #[must_use] - pub fn with_project(self, project: String) -> Self { - match self { - Self::Compose { services, .. } => Self::Compose { - project: Some(project), - services, - }, - other => other, + pub fn compose_in_project(services: Vec, project: String) -> Self { + Self::Compose { + project: Some(project), + services, } } } diff --git a/testing-framework/deployers/compose/src/deployer/mod.rs b/testing-framework/deployers/compose/src/deployer/mod.rs index 60a88de..2f809c7 100644 --- a/testing-framework/deployers/compose/src/deployer/mod.rs +++ b/testing-framework/deployers/compose/src/deployer/mod.rs @@ -50,7 +50,10 @@ impl ComposeDeploymentMetadata { .project_name() .ok_or(ComposeMetadataError::MissingProjectName)?; - Ok(AttachSource::compose(Vec::new()).with_project(project_name.to_owned())) + Ok(AttachSource::compose_in_project( + Vec::new(), + project_name.to_owned(), + )) } /// Builds an attach source for the same compose project. @@ -62,7 +65,10 @@ impl ComposeDeploymentMetadata { .project_name() .ok_or(ComposeMetadataError::MissingProjectName)?; - Ok(AttachSource::compose(services).with_project(project_name.to_owned())) + Ok(AttachSource::compose_in_project( + services, + project_name.to_owned(), + )) } } diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index 831f3fe..ddd88f0 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -321,7 +321,7 @@ impl DeploymentOrchestrator { fn managed_cluster_wait(&self, project_name: String) -> Arc> { Arc::new(ComposeAttachedClusterWait::::new( compose_runner_host(), - AttachSource::compose(Vec::new()).with_project(project_name), + AttachSource::compose_in_project(Vec::new(), project_name), )) } diff --git a/testing-framework/deployers/k8s/src/deployer/mod.rs b/testing-framework/deployers/k8s/src/deployer/mod.rs index e16ec45..43f0ff5 100644 --- a/testing-framework/deployers/k8s/src/deployer/mod.rs +++ b/testing-framework/deployers/k8s/src/deployer/mod.rs @@ -41,6 +41,9 @@ impl K8sDeploymentMetadata { .label_selector() .ok_or(K8sMetadataError::MissingLabelSelector)?; - Ok(AttachSource::k8s(label_selector.to_owned()).with_namespace(namespace.to_owned())) + Ok(AttachSource::k8s_in_namespace( + label_selector.to_owned(), + namespace.to_owned(), + )) } } From 7e0cdb54f8064277074bca0b51c0c2399d0bf531 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:34:07 +0100 Subject: [PATCH 07/40] Make source orchestration plan opaque --- .../core/src/scenario/definition.rs | 12 ++------ .../src/scenario/runtime/orchestration/mod.rs | 4 +-- .../source_orchestration_plan.rs | 30 +++++++------------ .../runtime/orchestration/source_resolver.rs | 5 ++-- 4 files changed, 17 insertions(+), 34 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index e8b3a4e..44b210c 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -11,7 +11,7 @@ use super::{ expectation::Expectation, runtime::{ context::RunMetrics, - orchestration::{SourceModeName, SourceOrchestrationPlan, SourceOrchestrationPlanError}, + orchestration::{SourceOrchestrationPlan, SourceOrchestrationPlanError}, }, workload::Workload, }; @@ -724,19 +724,11 @@ fn build_source_orchestration_plan( fn source_plan_error_to_build_error(error: SourceOrchestrationPlanError) -> ScenarioBuildError { match error { SourceOrchestrationPlanError::SourceModeNotWiredYet { mode } => { - ScenarioBuildError::SourceModeNotWiredYet { - mode: source_mode_name(mode), - } + ScenarioBuildError::SourceModeNotWiredYet { mode } } } } -const fn source_mode_name(mode: SourceModeName) -> &'static str { - match mode { - SourceModeName::Attached => "Attached", - } -} - impl Builder { #[must_use] pub fn enable_node_control(self) -> Builder { diff --git a/testing-framework/core/src/scenario/runtime/orchestration/mod.rs b/testing-framework/core/src/scenario/runtime/orchestration/mod.rs index 9e71458..a17ce28 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/mod.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/mod.rs @@ -3,9 +3,9 @@ mod source_orchestration_plan; #[allow(dead_code)] mod source_resolver; +pub(crate) use source_orchestration_plan::SourceOrchestrationMode; pub use source_orchestration_plan::{ - ManagedSource, SourceModeName, SourceOrchestrationMode, SourceOrchestrationPlan, - SourceOrchestrationPlanError, + ManagedSource, SourceOrchestrationPlan, SourceOrchestrationPlanError, }; pub use source_resolver::{ build_source_orchestration_plan, orchestrate_sources, orchestrate_sources_with_providers, diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs index dd57ee9..ed72ae3 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs @@ -1,5 +1,3 @@ -use std::fmt; - use crate::scenario::{AttachSource, ExternalNodeSource, ScenarioSources, SourceReadinessPolicy}; /// Explicit descriptor for managed node sourcing. @@ -15,7 +13,7 @@ pub enum ManagedSource { /// This is scaffolding-only and is intentionally not executed by deployers /// yet. #[derive(Clone, Debug, Eq, PartialEq)] -pub enum SourceOrchestrationMode { +pub(crate) enum SourceOrchestrationMode { Managed { managed: ManagedSource, external: Vec, @@ -34,28 +32,15 @@ pub enum SourceOrchestrationMode { /// This captures only mapping-time source intent and readiness policy. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SourceOrchestrationPlan { - pub mode: SourceOrchestrationMode, - pub readiness_policy: SourceReadinessPolicy, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SourceModeName { - Attached, -} - -impl fmt::Display for SourceModeName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Attached => f.write_str("Attached"), - } - } + mode: SourceOrchestrationMode, + readiness_policy: SourceReadinessPolicy, } /// Validation failure while building orchestration plan from sources. #[derive(Debug, thiserror::Error)] pub enum SourceOrchestrationPlanError { #[error("source mode '{mode}' is not wired into deployers yet")] - SourceModeNotWiredYet { mode: SourceModeName }, + SourceModeNotWiredYet { mode: &'static str }, } impl SourceOrchestrationPlan { @@ -71,6 +56,11 @@ impl SourceOrchestrationPlan { }) } + #[must_use] + pub(crate) fn mode(&self) -> &SourceOrchestrationMode { + &self.mode + } + #[must_use] pub fn external_sources(&self) -> &[ExternalNodeSource] { match &self.mode { @@ -94,7 +84,7 @@ mod tests { .expect("attached sources should build a source orchestration plan"); assert!(matches!( - plan.mode, + plan.mode(), SourceOrchestrationMode::Attached { .. } )); } diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs index 6ebd646..8ead379 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs @@ -52,7 +52,7 @@ pub async fn resolve_sources( plan: &SourceOrchestrationPlan, providers: &SourceProviders, ) -> Result, SourceResolveError> { - match &plan.mode { + match plan.mode() { SourceOrchestrationMode::Managed { managed, .. } => { let managed_nodes = providers.managed.provide(managed).await?; let external_nodes = providers.external.provide(plan.external_sources()).await?; @@ -115,7 +115,8 @@ pub async fn orchestrate_sources_with_providers( ) -> Result, DynError> { let resolved = resolve_sources(plan, &providers).await?; - if matches!(plan.mode, SourceOrchestrationMode::Managed { .. }) && resolved.managed.is_empty() { + if matches!(plan.mode(), SourceOrchestrationMode::Managed { .. }) && resolved.managed.is_empty() + { return Err(SourceResolveError::ManagedNodesMissing.into()); } From d2665bdb71c8ee4b3db5cd65be676989cf31114f Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:35:11 +0100 Subject: [PATCH 08/40] Hide runtime construction helpers from docs --- testing-framework/core/src/scenario/runtime/context.rs | 3 +++ testing-framework/core/src/scenario/runtime/runner.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/testing-framework/core/src/scenario/runtime/context.rs b/testing-framework/core/src/scenario/runtime/context.rs index a136fb4..9e40486 100644 --- a/testing-framework/core/src/scenario/runtime/context.rs +++ b/testing-framework/core/src/scenario/runtime/context.rs @@ -24,6 +24,7 @@ pub struct RunContext { impl RunContext { /// Builds a run context from prepared deployment/runtime artifacts. #[must_use] + #[doc(hidden)] pub fn new( descriptors: E::Deployment, node_clients: NodeClients, @@ -48,6 +49,7 @@ impl RunContext { } #[must_use] + #[doc(hidden)] pub fn with_cluster_wait(mut self, cluster_wait: Arc>) -> Self { self.cluster_wait = Some(cluster_wait); self @@ -137,6 +139,7 @@ impl Drop for RunHandle { impl RunHandle { #[must_use] /// Build a handle from owned context and optional cleanup guard. + #[doc(hidden)] pub fn new(context: RunContext, cleanup_guard: Option>) -> Self { Self { run_context: Arc::new(context), diff --git a/testing-framework/core/src/scenario/runtime/runner.rs b/testing-framework/core/src/scenario/runtime/runner.rs index 16c9002..d9e3f5e 100644 --- a/testing-framework/core/src/scenario/runtime/runner.rs +++ b/testing-framework/core/src/scenario/runtime/runner.rs @@ -36,6 +36,7 @@ impl Drop for Runner { impl Runner { /// Construct a runner from the run context and optional cleanup guard. #[must_use] + #[doc(hidden)] pub fn new(context: RunContext, cleanup_guard: Option>) -> Self { Self { context: Arc::new(context), From 0ff1ae1904416eb049dd817f258a3501c76a6834 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:37:01 +0100 Subject: [PATCH 09/40] Trim scenario source mutators --- testing-framework/core/src/scenario/sources/model.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 0a3dc69..903aff1 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -118,7 +118,7 @@ impl ScenarioSources { Self::ExternalOnly { external } } - pub fn add_external_node(&mut self, node: ExternalNodeSource) { + pub(crate) fn add_external_node(&mut self, node: ExternalNodeSource) { match self { Self::Managed { external } | Self::Attached { external, .. } @@ -126,12 +126,12 @@ impl ScenarioSources { } } - pub fn set_attach(&mut self, attach: AttachSource) { + pub(crate) fn set_attach(&mut self, attach: AttachSource) { let external = self.external_nodes().to_vec(); *self = Self::Attached { attach, external }; } - pub fn set_external_only(&mut self) { + pub(crate) fn set_external_only(&mut self) { let external = self.external_nodes().to_vec(); *self = Self::ExternalOnly { external }; } From 74290327a32a28021a2c1f197f8dc817a9190ed9 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:38:10 +0100 Subject: [PATCH 10/40] Encapsulate external node source fields --- logos/runtime/ext/src/lib.rs | 2 +- .../runtime/providers/external_provider.rs | 4 ++-- .../core/src/scenario/sources/model.rs | 14 ++++++++++++-- testing-framework/deployers/local/src/external.rs | 4 ++-- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/logos/runtime/ext/src/lib.rs b/logos/runtime/ext/src/lib.rs index 177823b..8701c6c 100644 --- a/logos/runtime/ext/src/lib.rs +++ b/logos/runtime/ext/src/lib.rs @@ -44,7 +44,7 @@ impl Application for LbcExtEnv { type FeedRuntime = ::FeedRuntime; fn external_node_client(source: &ExternalNodeSource) -> Result { - let base_url = Url::parse(&source.endpoint)?; + let base_url = Url::parse(source.endpoint())?; Ok(NodeHttpClient::from_urls(base_url, None)) } diff --git a/testing-framework/core/src/scenario/runtime/providers/external_provider.rs b/testing-framework/core/src/scenario/runtime/providers/external_provider.rs index 343a35c..1a1beb6 100644 --- a/testing-framework/core/src/scenario/runtime/providers/external_provider.rs +++ b/testing-framework/core/src/scenario/runtime/providers/external_provider.rs @@ -71,11 +71,11 @@ impl ExternalProvider for ApplicationExternalProvider { .map(|source| { E::external_node_client(source) .map(|client| ExternalNode { - identity_hint: Some(source.label.clone()), + identity_hint: Some(source.label().to_string()), client, }) .map_err(|build_error| ExternalProviderError::Build { - source_label: source.label.clone(), + source_label: source.label().to_string(), source: build_error, }) }) diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 903aff1..c107d7c 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -49,8 +49,8 @@ impl AttachSource { /// inventory. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ExternalNodeSource { - pub label: String, - pub endpoint: String, + label: String, + endpoint: String, } impl ExternalNodeSource { @@ -58,6 +58,16 @@ impl ExternalNodeSource { pub fn new(label: String, endpoint: String) -> Self { Self { label, endpoint } } + + #[must_use] + pub fn label(&self) -> &str { + &self.label + } + + #[must_use] + pub fn endpoint(&self) -> &str { + &self.endpoint + } } /// Planned readiness strategy for mixed managed/attached/external sources. diff --git a/testing-framework/deployers/local/src/external.rs b/testing-framework/deployers/local/src/external.rs index 713c5be..696d05e 100644 --- a/testing-framework/deployers/local/src/external.rs +++ b/testing-framework/deployers/local/src/external.rs @@ -35,8 +35,8 @@ pub fn build_external_client( } fn resolve_api_socket(source: &ExternalNodeSource) -> Result { - let source_label = source.label.clone(); - let endpoint = source.endpoint.trim(); + let source_label = source.label().to_string(); + let endpoint = source.endpoint().trim(); if endpoint.is_empty() { return Err(ExternalClientBuildError::EmptyEndpoint { label: source_label, From 6888c18275d5b43e64b4d809cbedf758dc5fed5b Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:40:27 +0100 Subject: [PATCH 11/40] Simplify node client inventory --- testing-framework/core/src/scenario/mod.rs | 5 +- .../src/scenario/runtime/inventory/mod.rs | 2 +- .../runtime/inventory/node_inventory.rs | 314 ++---------------- .../core/src/scenario/runtime/mod.rs | 3 +- .../core/src/scenario/runtime/node_clients.rs | 4 +- 5 files changed, 27 insertions(+), 301 deletions(-) diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index f2e43d6..cc3b300 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -37,9 +37,8 @@ pub use deployment_policy::{CleanupPolicy, DeploymentPolicy, RetryPolicy}; pub use expectation::Expectation; pub use observability::{ObservabilityCapabilityProvider, ObservabilityInputs}; pub use runtime::{ - ApplicationExternalProvider, AttachProvider, AttachProviderError, AttachedNode, BorrowedNode, - BorrowedOrigin, CleanupGuard, Deployer, Feed, FeedHandle, FeedRuntime, - HttpReadinessRequirement, ManagedNode, ManagedSource, NodeClients, NodeHandle, NodeInventory, + ApplicationExternalProvider, AttachProvider, AttachProviderError, AttachedNode, CleanupGuard, + Deployer, Feed, FeedHandle, FeedRuntime, HttpReadinessRequirement, ManagedSource, NodeClients, ReadinessError, RunContext, RunHandle, RunMetrics, Runner, ScenarioError, SourceOrchestrationPlan, SourceProviders, StabilizationConfig, StaticManagedProvider, build_source_orchestration_plan, diff --git a/testing-framework/core/src/scenario/runtime/inventory/mod.rs b/testing-framework/core/src/scenario/runtime/inventory/mod.rs index 6bc0334..575f53f 100644 --- a/testing-framework/core/src/scenario/runtime/inventory/mod.rs +++ b/testing-framework/core/src/scenario/runtime/inventory/mod.rs @@ -1,3 +1,3 @@ mod node_inventory; -pub use node_inventory::{BorrowedNode, BorrowedOrigin, ManagedNode, NodeHandle, NodeInventory}; +pub(crate) use node_inventory::NodeInventory; diff --git a/testing-framework/core/src/scenario/runtime/inventory/node_inventory.rs b/testing-framework/core/src/scenario/runtime/inventory/node_inventory.rs index c45385e..d9b45ce 100644 --- a/testing-framework/core/src/scenario/runtime/inventory/node_inventory.rs +++ b/testing-framework/core/src/scenario/runtime/inventory/node_inventory.rs @@ -1,91 +1,18 @@ -use std::{collections::HashMap, sync::Arc}; +use std::sync::Arc; use parking_lot::RwLock; -use crate::scenario::{Application, DynError, NodeControlHandle, StartNodeOptions, StartedNode}; +use crate::scenario::Application; -/// Origin for borrowed (non-managed) nodes in the runtime inventory. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum BorrowedOrigin { - /// Node discovered from an attached cluster provider. - Attached, - /// Node provided explicitly as an external endpoint. - External, -} - -/// Managed node handle with full lifecycle capabilities. -pub struct ManagedNode { - /// Canonical node identity used for deduplication and lookups. - pub identity: String, - /// Application-specific API client for this node. - pub client: E::NodeClient, -} - -/// Borrowed node handle (attached or external), query-only by default. -pub struct BorrowedNode { - /// Canonical node identity used for deduplication and lookups. - pub identity: String, - /// Application-specific API client for this node. - pub client: E::NodeClient, - /// Borrowed source kind used for diagnostics and selection. - pub origin: BorrowedOrigin, -} - -/// Unified node handle variant used by runtime inventory snapshots. -pub enum NodeHandle { - /// Managed node variant. - Managed(ManagedNode), - /// Borrowed node variant. - Borrowed(BorrowedNode), -} - -impl Clone for ManagedNode { - fn clone(&self) -> Self { - Self { - identity: self.identity.clone(), - client: self.client.clone(), - } - } -} - -impl Clone for BorrowedNode { - fn clone(&self) -> Self { - Self { - identity: self.identity.clone(), - client: self.client.clone(), - origin: self.origin, - } - } -} - -impl Clone for NodeHandle { - fn clone(&self) -> Self { - match self { - Self::Managed(node) => Self::Managed(node.clone()), - Self::Borrowed(node) => Self::Borrowed(node.clone()), - } - } -} - -/// Thread-safe node inventory with identity-based upsert semantics. -pub struct NodeInventory { - inner: Arc>>, -} - -struct NodeInventoryInner { - nodes: Vec>, - indices_by_identity: HashMap, - next_synthetic_id: usize, +/// Thread-safe node client storage used by runtime handles. +pub(crate) struct NodeInventory { + clients: Arc>>, } impl Default for NodeInventory { fn default() -> Self { Self { - inner: Arc::new(RwLock::new(NodeInventoryInner { - nodes: Vec::new(), - indices_by_identity: HashMap::new(), - next_synthetic_id: 0, - })), + clients: Arc::new(RwLock::new(Vec::new())), } } } @@ -93,243 +20,44 @@ impl Default for NodeInventory { impl Clone for NodeInventory { fn clone(&self) -> Self { Self { - inner: Arc::clone(&self.inner), + clients: Arc::clone(&self.clients), } } } impl NodeInventory { #[must_use] - /// Builds an inventory from managed clients. - pub fn from_managed_clients(clients: Vec) -> Self { - let inventory = Self::default(); - - for client in clients { - inventory.add_managed_node(client, None); - } - - inventory - } - - #[must_use] - /// Returns a cloned snapshot of all node clients. - pub fn snapshot_clients(&self) -> Vec { - self.inner.read().nodes.iter().map(clone_client).collect() - } - - #[must_use] - /// Returns cloned managed node handles from the current inventory. - pub fn managed_nodes(&self) -> Vec> { - self.inner - .read() - .nodes - .iter() - .filter_map(|handle| match handle { - NodeHandle::Managed(node) => Some(node.clone()), - NodeHandle::Borrowed(_) => None, - }) - .collect() - } - - #[must_use] - /// Returns cloned borrowed node handles from the current inventory. - pub fn borrowed_nodes(&self) -> Vec> { - self.inner - .read() - .nodes - .iter() - .filter_map(|handle| match handle { - NodeHandle::Managed(_) => None, - NodeHandle::Borrowed(node) => Some(node.clone()), - }) - .collect() - } - - #[must_use] - /// Finds a managed node by canonical identity. - pub fn find_managed(&self, identity: &str) -> Option> { - let guard = self.inner.read(); - match node_by_identity(&guard, identity)? { - NodeHandle::Managed(node) => Some(node.clone()), - NodeHandle::Borrowed(_) => None, + pub(crate) fn from_clients(clients: Vec) -> Self { + Self { + clients: Arc::new(RwLock::new(clients)), } } #[must_use] - /// Finds a borrowed node by canonical identity. - pub fn find_borrowed(&self, identity: &str) -> Option> { - let guard = self.inner.read(); - match node_by_identity(&guard, identity)? { - NodeHandle::Managed(_) => None, - NodeHandle::Borrowed(node) => Some(node.clone()), - } + pub(crate) fn snapshot_clients(&self) -> Vec { + self.clients.read().clone() } #[must_use] - /// Finds any node handle by canonical identity. - pub fn find_node(&self, identity: &str) -> Option> { - let guard = self.inner.read(); - node_by_identity(&guard, identity).cloned() + pub(crate) fn len(&self) -> usize { + self.clients.read().len() } #[must_use] - /// Returns current number of nodes in inventory. - pub fn len(&self) -> usize { - self.inner.read().nodes.len() - } - - #[must_use] - /// Returns true when no nodes are registered. - pub fn is_empty(&self) -> bool { + pub(crate) fn is_empty(&self) -> bool { self.len() == 0 } - /// Clears all nodes and identity indexes. - pub fn clear(&self) { - let mut guard = self.inner.write(); - guard.nodes.clear(); - guard.indices_by_identity.clear(); - guard.next_synthetic_id = 0; + pub(crate) fn clear(&self) { + self.clients.write().clear(); } - /// Adds or replaces a managed node entry using canonical identity - /// resolution. Re-adding the same node identity updates the stored handle. - pub fn add_managed_node(&self, client: E::NodeClient, identity_hint: Option) { - let mut guard = self.inner.write(); - let identity = canonical_identity::(&client, identity_hint, &mut guard); - let handle = NodeHandle::Managed(ManagedNode { - identity: identity.clone(), - client, - }); - upsert_node(&mut guard, identity, handle); + pub(crate) fn add_client(&self, client: E::NodeClient) { + self.clients.write().push(client); } - /// Adds or replaces an attached node entry. - pub fn add_attached_node(&self, client: E::NodeClient, identity_hint: Option) { - self.add_borrowed_node(client, BorrowedOrigin::Attached, identity_hint); - } - - /// Adds or replaces an external static node entry. - pub fn add_external_node(&self, client: E::NodeClient, identity_hint: Option) { - self.add_borrowed_node(client, BorrowedOrigin::External, identity_hint); - } - - /// Executes a synchronous read over a cloned client slice. - pub fn with_clients(&self, f: impl FnOnce(&[E::NodeClient]) -> R) -> R { - let guard = self.inner.read(); - let clients = guard.nodes.iter().map(clone_client).collect::>(); + pub(crate) fn with_clients(&self, f: impl FnOnce(&[E::NodeClient]) -> R) -> R { + let clients = self.clients.read(); f(&clients) } - - fn add_borrowed_node( - &self, - client: E::NodeClient, - origin: BorrowedOrigin, - identity_hint: Option, - ) { - let mut guard = self.inner.write(); - let identity = canonical_identity::(&client, identity_hint, &mut guard); - let handle = NodeHandle::Borrowed(BorrowedNode { - identity: identity.clone(), - client, - origin, - }); - upsert_node(&mut guard, identity, handle); - } -} - -impl ManagedNode { - #[must_use] - /// Returns the node client. - pub const fn client(&self) -> &E::NodeClient { - &self.client - } - - /// Delegates restart to the deployer's control surface for this node name. - pub async fn restart( - &self, - control: &dyn NodeControlHandle, - node_name: &str, - ) -> Result<(), DynError> { - control.restart_node(node_name).await - } - - /// Delegates stop to the deployer's control surface for this node name. - pub async fn stop( - &self, - control: &dyn NodeControlHandle, - node_name: &str, - ) -> Result<(), DynError> { - control.stop_node(node_name).await - } - - /// Delegates dynamic node start with options to the control surface. - pub async fn start_with( - &self, - control: &dyn NodeControlHandle, - node_name: &str, - options: StartNodeOptions, - ) -> Result, DynError> { - control.start_node_with(node_name, options).await - } - - #[must_use] - /// Returns process id if the backend can expose it for this node name. - pub fn pid(&self, control: &dyn NodeControlHandle, node_name: &str) -> Option { - control.node_pid(node_name) - } -} - -impl BorrowedNode { - #[must_use] - /// Returns the node client. - pub const fn client(&self) -> &E::NodeClient { - &self.client - } -} - -fn upsert_node( - inner: &mut NodeInventoryInner, - identity: String, - handle: NodeHandle, -) { - if let Some(existing_index) = inner.indices_by_identity.get(&identity).copied() { - inner.nodes[existing_index] = handle; - return; - } - - let index = inner.nodes.len(); - inner.nodes.push(handle); - inner.indices_by_identity.insert(identity, index); -} - -fn canonical_identity( - _client: &E::NodeClient, - identity_hint: Option, - inner: &mut NodeInventoryInner, -) -> String { - // Priority: explicit hint -> synthetic. - if let Some(identity) = identity_hint.filter(|value| !value.trim().is_empty()) { - return identity; - } - - let synthetic = format!("node:{}", inner.next_synthetic_id); - inner.next_synthetic_id += 1; - - synthetic -} - -fn clone_client(handle: &NodeHandle) -> E::NodeClient { - match handle { - NodeHandle::Managed(node) => node.client.clone(), - NodeHandle::Borrowed(node) => node.client.clone(), - } -} - -fn node_by_identity<'a, E: Application>( - inner: &'a NodeInventoryInner, - identity: &str, -) -> Option<&'a NodeHandle> { - let index = *inner.indices_by_identity.get(identity)?; - inner.nodes.get(index) } diff --git a/testing-framework/core/src/scenario/runtime/mod.rs b/testing-framework/core/src/scenario/runtime/mod.rs index 97bd2d6..5682ccc 100644 --- a/testing-framework/core/src/scenario/runtime/mod.rs +++ b/testing-framework/core/src/scenario/runtime/mod.rs @@ -1,6 +1,6 @@ pub mod context; mod deployer; -pub mod inventory; +mod inventory; pub mod metrics; mod node_clients; pub mod orchestration; @@ -11,7 +11,6 @@ mod runner; use async_trait::async_trait; pub use context::{CleanupGuard, RunContext, RunHandle, RunMetrics}; pub use deployer::{Deployer, ScenarioError}; -pub use inventory::{BorrowedNode, BorrowedOrigin, ManagedNode, NodeHandle, NodeInventory}; pub use node_clients::NodeClients; #[doc(hidden)] pub use orchestration::{ diff --git a/testing-framework/core/src/scenario/runtime/node_clients.rs b/testing-framework/core/src/scenario/runtime/node_clients.rs index ce31dcf..b35a0ed 100644 --- a/testing-framework/core/src/scenario/runtime/node_clients.rs +++ b/testing-framework/core/src/scenario/runtime/node_clients.rs @@ -29,7 +29,7 @@ impl NodeClients { /// Build clients from preconstructed vectors. pub fn new(nodes: Vec) -> Self { Self { - inventory: NodeInventory::from_managed_clients(nodes), + inventory: NodeInventory::from_clients(nodes), } } @@ -72,7 +72,7 @@ impl NodeClients { } pub fn add_node(&self, client: E::NodeClient) { - self.inventory.add_managed_node(client, None); + self.inventory.add_client(client); } pub fn clear(&self) { From 3f8e287c68d3b11ba15309982846f09dbab23067 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:44:25 +0100 Subject: [PATCH 12/40] Make scenario source transitions explicit --- .../core/src/scenario/definition.rs | 6 +++--- .../core/src/scenario/sources/model.rs | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 44b210c..f656a98 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -569,13 +569,13 @@ impl Builder { #[must_use] pub fn with_attach_source(mut self, attach: AttachSource) -> Self { - self.sources.set_attach(attach); + self.sources = self.sources.with_attach(attach); self } #[must_use] pub fn with_external_node(mut self, node: ExternalNodeSource) -> Self { - self.sources.add_external_node(node); + self.sources = self.sources.with_external_node(node); self } @@ -591,7 +591,7 @@ impl Builder { #[must_use] pub fn with_external_only_sources(mut self) -> Self { - self.sources.set_external_only(); + self.sources = self.sources.into_external_only(); self } diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index c107d7c..84dc89f 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -128,22 +128,29 @@ impl ScenarioSources { Self::ExternalOnly { external } } - pub(crate) fn add_external_node(&mut self, node: ExternalNodeSource) { - match self { + #[must_use] + pub fn with_external_node(mut self, node: ExternalNodeSource) -> Self { + match &mut self { Self::Managed { external } | Self::Attached { external, .. } | Self::ExternalOnly { external } => external.push(node), } + + self } - pub(crate) fn set_attach(&mut self, attach: AttachSource) { + #[must_use] + pub fn with_attach(self, attach: AttachSource) -> Self { let external = self.external_nodes().to_vec(); - *self = Self::Attached { attach, external }; + + Self::Attached { attach, external } } - pub(crate) fn set_external_only(&mut self) { + #[must_use] + pub fn into_external_only(self) -> Self { let external = self.external_nodes().to_vec(); - *self = Self::ExternalOnly { external }; + + Self::ExternalOnly { external } } #[must_use] From 3ea3fffd1f2e11291bcb061719e48249e40d3d23 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:45:39 +0100 Subject: [PATCH 13/40] Drop unused source readiness policy --- .../core/src/scenario/definition.rs | 43 +------------------ testing-framework/core/src/scenario/mod.rs | 2 +- .../source_orchestration_plan.rs | 16 +++---- .../runtime/orchestration/source_resolver.rs | 5 +-- .../core/src/scenario/sources/mod.rs | 2 +- .../core/src/scenario/sources/model.rs | 13 ------ 6 files changed, 10 insertions(+), 71 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index f656a98..b16b6a7 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -6,7 +6,6 @@ use tracing::{debug, info}; use super::{ Application, AttachSource, DeploymentPolicy, DynError, ExternalNodeSource, HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, ScenarioSources, - SourceReadinessPolicy, builder_ops::CoreBuilderAccess, expectation::Expectation, runtime::{ @@ -44,7 +43,6 @@ pub struct Scenario { expectation_cooldown: Duration, deployment_policy: DeploymentPolicy, sources: ScenarioSources, - source_readiness_policy: SourceReadinessPolicy, source_orchestration_plan: SourceOrchestrationPlan, capabilities: Caps, } @@ -58,7 +56,6 @@ impl Scenario { expectation_cooldown: Duration, deployment_policy: DeploymentPolicy, sources: ScenarioSources, - source_readiness_policy: SourceReadinessPolicy, source_orchestration_plan: SourceOrchestrationPlan, capabilities: Caps, ) -> Self { @@ -70,7 +67,6 @@ impl Scenario { expectation_cooldown, deployment_policy, sources, - source_readiness_policy, source_orchestration_plan, capabilities, } @@ -116,15 +112,6 @@ impl Scenario { self.deployment_policy } - #[must_use] - /// Selected source readiness policy. - /// - /// This is currently reserved for future mixed-source orchestration and - /// does not change runtime behavior yet. - pub const fn source_readiness_policy(&self) -> SourceReadinessPolicy { - self.source_readiness_policy - } - #[must_use] pub fn sources(&self) -> &ScenarioSources { &self.sources @@ -151,7 +138,6 @@ pub struct Builder { expectation_cooldown: Option, deployment_policy: DeploymentPolicy, sources: ScenarioSources, - source_readiness_policy: SourceReadinessPolicy, capabilities: Caps, } @@ -256,11 +242,6 @@ macro_rules! impl_common_builder_methods { self.map_core_builder(|builder| builder.with_external_node(node)) } - #[must_use] - pub fn with_source_readiness_policy(self, policy: SourceReadinessPolicy) -> Self { - self.map_core_builder(|builder| builder.with_source_readiness_policy(policy)) - } - #[must_use] pub fn with_external_only_sources(self) -> Self { self.map_core_builder(|builder| builder.with_external_only_sources()) @@ -350,7 +331,6 @@ impl Builder { expectation_cooldown: None, deployment_policy: DeploymentPolicy::default(), sources: ScenarioSources::default(), - source_readiness_policy: SourceReadinessPolicy::default(), capabilities: Caps::default(), } } @@ -453,7 +433,6 @@ impl Builder { expectation_cooldown, deployment_policy, sources, - source_readiness_policy, .. } = self; @@ -466,7 +445,6 @@ impl Builder { expectation_cooldown, deployment_policy, sources, - source_readiness_policy, capabilities, } } @@ -579,16 +557,6 @@ impl Builder { self } - #[must_use] - /// Configure source readiness policy metadata. - /// - /// This is currently reserved for future mixed-source orchestration and - /// does not change runtime behavior yet. - pub fn with_source_readiness_policy(mut self, policy: SourceReadinessPolicy) -> Self { - self.source_readiness_policy = policy; - self - } - #[must_use] pub fn with_external_only_sources(mut self) -> Self { self.sources = self.sources.into_external_only(); @@ -612,8 +580,7 @@ impl Builder { let descriptors = parts.resolve_deployment()?; let run_plan = parts.run_plan(); let run_metrics = RunMetrics::new(run_plan.duration); - let source_orchestration_plan = - build_source_orchestration_plan(parts.sources(), parts.source_readiness_policy)?; + let source_orchestration_plan = build_source_orchestration_plan(parts.sources())?; initialize_components( &descriptors, @@ -640,7 +607,6 @@ impl Builder { run_plan.expectation_cooldown, parts.deployment_policy, parts.sources, - parts.source_readiness_policy, source_orchestration_plan, parts.capabilities, )) @@ -661,7 +627,6 @@ struct BuilderParts { expectation_cooldown: Option, deployment_policy: DeploymentPolicy, sources: ScenarioSources, - source_readiness_policy: SourceReadinessPolicy, capabilities: Caps, } @@ -676,7 +641,6 @@ impl BuilderParts { expectation_cooldown, deployment_policy, sources, - source_readiness_policy, capabilities, .. } = builder; @@ -690,7 +654,6 @@ impl BuilderParts { expectation_cooldown, deployment_policy, sources, - source_readiness_policy, capabilities, } } @@ -715,10 +678,8 @@ impl BuilderParts { fn build_source_orchestration_plan( sources: &ScenarioSources, - readiness_policy: SourceReadinessPolicy, ) -> Result { - SourceOrchestrationPlan::try_from_sources(sources, readiness_policy) - .map_err(source_plan_error_to_build_error) + SourceOrchestrationPlan::try_from_sources(sources).map_err(source_plan_error_to_build_error) } fn source_plan_error_to_build_error(error: SourceOrchestrationPlanError) -> ScenarioBuildError { diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index cc3b300..8b570ad 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -51,7 +51,7 @@ pub use runtime::{ wait_for_http_ports_with_host_and_requirement, wait_for_http_ports_with_requirement, wait_http_readiness, wait_until_stable, }; -pub use sources::{AttachSource, ExternalNodeSource, ScenarioSources, SourceReadinessPolicy}; +pub use sources::{AttachSource, ExternalNodeSource, ScenarioSources}; pub use workload::Workload; pub use crate::env::Application; diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs index ed72ae3..f1dd9b9 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs @@ -1,4 +1,4 @@ -use crate::scenario::{AttachSource, ExternalNodeSource, ScenarioSources, SourceReadinessPolicy}; +use crate::scenario::{AttachSource, ExternalNodeSource, ScenarioSources}; /// Explicit descriptor for managed node sourcing. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -33,7 +33,6 @@ pub(crate) enum SourceOrchestrationMode { #[derive(Clone, Debug, Eq, PartialEq)] pub struct SourceOrchestrationPlan { mode: SourceOrchestrationMode, - readiness_policy: SourceReadinessPolicy, } /// Validation failure while building orchestration plan from sources. @@ -46,14 +45,10 @@ pub enum SourceOrchestrationPlanError { impl SourceOrchestrationPlan { pub fn try_from_sources( sources: &ScenarioSources, - readiness_policy: SourceReadinessPolicy, ) -> Result { let mode = mode_from_sources(sources); - Ok(Self { - mode, - readiness_policy, - }) + Ok(Self { mode }) } #[must_use] @@ -74,14 +69,13 @@ impl SourceOrchestrationPlan { #[cfg(test)] mod tests { use super::{SourceOrchestrationMode, SourceOrchestrationPlan}; - use crate::scenario::{AttachSource, ScenarioSources, SourceReadinessPolicy}; + use crate::scenario::{AttachSource, ScenarioSources}; #[test] fn attached_sources_are_planned() { let sources = ScenarioSources::attached(AttachSource::compose(vec!["node-0".to_string()])); - let plan = - SourceOrchestrationPlan::try_from_sources(&sources, SourceReadinessPolicy::AllReady) - .expect("attached sources should build a source orchestration plan"); + let plan = SourceOrchestrationPlan::try_from_sources(&sources) + .expect("attached sources should build a source orchestration plan"); assert!(matches!( plan.mode(), diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs index 8ead379..ebf2d2d 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs @@ -41,10 +41,7 @@ pub enum SourceResolveError { pub fn build_source_orchestration_plan( scenario: &Scenario, ) -> Result { - SourceOrchestrationPlan::try_from_sources( - scenario.sources(), - scenario.source_readiness_policy(), - ) + SourceOrchestrationPlan::try_from_sources(scenario.sources()) } /// Resolves runtime source nodes via unified providers from orchestration plan. diff --git a/testing-framework/core/src/scenario/sources/mod.rs b/testing-framework/core/src/scenario/sources/mod.rs index 3585d8a..98324dd 100644 --- a/testing-framework/core/src/scenario/sources/mod.rs +++ b/testing-framework/core/src/scenario/sources/mod.rs @@ -1,3 +1,3 @@ mod model; -pub use model::{AttachSource, ExternalNodeSource, ScenarioSources, SourceReadinessPolicy}; +pub use model::{AttachSource, ExternalNodeSource, ScenarioSources}; diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 84dc89f..98c60ad 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -70,19 +70,6 @@ impl ExternalNodeSource { } } -/// Planned readiness strategy for mixed managed/attached/external sources. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] -pub enum SourceReadinessPolicy { - /// Phase 1 default: require every known node to pass readiness checks. - #[default] - AllReady, - /// Optional relaxed policy for large/partial environments. - Quorum, - /// Future policy for per-source constraints (for example managed minimum - /// plus overall quorum). - SourceAware, -} - /// Source model that makes invalid managed+attached combinations /// unrepresentable by type. #[derive(Clone, Debug, Eq, PartialEq)] From eeb0573798a098e8ddf748bee5481c09fe650dc5 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:56:12 +0100 Subject: [PATCH 14/40] Route source access through semantic helpers --- .../core/src/scenario/definition.rs | 10 ++++ .../core/src/scenario/sources/model.rs | 40 ++++++++++++++++ .../compose/src/deployer/attach_provider.rs | 28 +++++------ .../compose/src/deployer/orchestrator.rs | 45 +++++++----------- .../k8s/src/deployer/attach_provider.rs | 20 +++----- .../k8s/src/deployer/orchestrator.rs | 47 +++++++++---------- 6 files changed, 109 insertions(+), 81 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index b16b6a7..15c7ea9 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -117,6 +117,16 @@ impl Scenario { &self.sources } + #[must_use] + pub fn attached_source(&self) -> Option<&AttachSource> { + self.sources.attached_source() + } + + #[must_use] + pub fn external_nodes(&self) -> &[ExternalNodeSource] { + self.sources.external_nodes() + } + #[must_use] pub const fn source_orchestration_plan(&self) -> &SourceOrchestrationPlan { &self.source_orchestration_plan diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 98c60ad..5b0ec2c 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -43,6 +43,38 @@ impl AttachSource { services, } } + + #[must_use] + pub fn compose_project(&self) -> Option<&str> { + match self { + Self::Compose { project, .. } => project.as_deref(), + Self::K8s { .. } => None, + } + } + + #[must_use] + pub fn compose_services(&self) -> Option<&[String]> { + match self { + Self::Compose { services, .. } => Some(services), + Self::K8s { .. } => None, + } + } + + #[must_use] + pub fn k8s_namespace(&self) -> Option<&str> { + match self { + Self::K8s { namespace, .. } => namespace.as_deref(), + Self::Compose { .. } => None, + } + } + + #[must_use] + pub fn k8s_label_selector(&self) -> Option<&str> { + match self { + Self::K8s { label_selector, .. } => Some(label_selector), + Self::Compose { .. } => None, + } + } } /// Static external node endpoint that should be included in the runtime @@ -140,6 +172,14 @@ impl ScenarioSources { Self::ExternalOnly { external } } + #[must_use] + pub fn attached_source(&self) -> Option<&AttachSource> { + match self { + Self::Attached { attach, .. } => Some(attach), + Self::Managed { .. } | Self::ExternalOnly { .. } => None, + } + } + #[must_use] pub fn external_nodes(&self) -> &[ExternalNodeSource] { match self { diff --git a/testing-framework/deployers/compose/src/deployer/attach_provider.rs b/testing-framework/deployers/compose/src/deployer/attach_provider.rs index 87358aa..06c8d61 100644 --- a/testing-framework/deployers/compose/src/deployer/attach_provider.rs +++ b/testing-framework/deployers/compose/src/deployer/attach_provider.rs @@ -87,14 +87,15 @@ fn to_discovery_error(source: DynError) -> AttachProviderError { fn compose_attach_request( source: &AttachSource, ) -> Result, AttachProviderError> { - let AttachSource::Compose { project, services } = source else { - return Err(AttachProviderError::UnsupportedSource { - attach_source: source.clone(), - }); - }; + let services = + source + .compose_services() + .ok_or_else(|| AttachProviderError::UnsupportedSource { + attach_source: source.clone(), + })?; - let project = project - .as_deref() + let project = source + .compose_project() .ok_or_else(|| AttachProviderError::Discovery { source: ComposeAttachDiscoveryError::MissingProjectName.into(), })?; @@ -173,13 +174,12 @@ impl ClusterWaitHandle for ComposeAttachedClusterWait } fn compose_wait_request(source: &AttachSource) -> Result, DynError> { - let AttachSource::Compose { project, services } = source else { - return Err("compose cluster wait requires a compose attach source".into()); - }; - - let project = project - .as_deref() - .ok_or(ComposeAttachDiscoveryError::MissingProjectName)?; + let project = source + .compose_project() + .ok_or_else(|| DynError::from("compose cluster wait requires a compose attach source"))?; + let services = source + .compose_services() + .ok_or_else(|| DynError::from("compose cluster wait requires a compose attach source"))?; Ok(ComposeAttachRequest { project, services }) } diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index ddd88f0..c4fdc4f 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -6,9 +6,9 @@ use testing_framework_core::{ ApplicationExternalProvider, AttachSource, CleanupGuard, ClusterWaitHandle, DeploymentPolicy, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, NodeControlHandle, ObservabilityCapabilityProvider, ObservabilityInputs, - RequiresNodeControl, RunContext, Runner, Scenario, ScenarioSources, - SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, - build_source_orchestration_plan, orchestrate_sources_with_providers, + RequiresNodeControl, RunContext, Runner, Scenario, SourceOrchestrationPlan, + SourceProviders, StaticManagedProvider, build_source_orchestration_plan, + orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, }; @@ -214,21 +214,15 @@ impl DeploymentOrchestrator { return Ok(None); } - let ScenarioSources::Attached { attach, .. } = scenario.sources() else { - return Err(ComposeRunnerError::InternalInvariant { + let attach = scenario + .attached_source() + .ok_or(ComposeRunnerError::InternalInvariant { message: "attached node control requested outside attached source mode", - }); - }; + })?; - let AttachSource::Compose { project, .. } = attach else { - return Err(ComposeRunnerError::InternalInvariant { - message: "compose deployer requires compose attach source for node control", - }); - }; - - let Some(project_name) = project - .as_ref() - .map(|value| value.trim()) + let Some(project_name) = attach + .compose_project() + .map(str::trim) .filter(|value| !value.is_empty()) else { return Err(ComposeRunnerError::InternalInvariant { @@ -248,11 +242,11 @@ impl DeploymentOrchestrator { where Caps: Send + Sync, { - let ScenarioSources::Attached { attach, .. } = scenario.sources() else { - return Err(ComposeRunnerError::InternalInvariant { + let attach = scenario + .attached_source() + .ok_or(ComposeRunnerError::InternalInvariant { message: "compose attached cluster wait requested outside attached source mode", - }); - }; + })?; Ok(Arc::new(ComposeAttachedClusterWait::::new( compose_runner_host(), @@ -378,13 +372,10 @@ where E: ComposeDeployEnv, Caps: Send + Sync, { - let project_name = match scenario.sources() { - ScenarioSources::Attached { - attach: AttachSource::Compose { project, .. }, - .. - } => project.clone(), - _ => None, - }; + let project_name = scenario + .attached_source() + .and_then(|attach| attach.compose_project()) + .map(ToOwned::to_owned); ComposeDeploymentMetadata { project_name } } diff --git a/testing-framework/deployers/k8s/src/deployer/attach_provider.rs b/testing-framework/deployers/k8s/src/deployer/attach_provider.rs index b10a24a..bae5692 100644 --- a/testing-framework/deployers/k8s/src/deployer/attach_provider.rs +++ b/testing-framework/deployers/k8s/src/deployer/attach_provider.rs @@ -91,11 +91,7 @@ fn to_discovery_error(source: DynError) -> AttachProviderError { } fn k8s_attach_request(source: &AttachSource) -> Result, AttachProviderError> { - let AttachSource::K8s { - namespace, - label_selector, - } = source - else { + let Some(label_selector) = source.k8s_label_selector() else { return Err(AttachProviderError::UnsupportedSource { attach_source: source.clone(), }); @@ -108,7 +104,7 @@ fn k8s_attach_request(source: &AttachSource) -> Result, Att } Ok(K8sAttachRequest { - namespace: namespace.as_deref().unwrap_or("default"), + namespace: source.k8s_namespace().unwrap_or("default"), label_selector, }) } @@ -247,20 +243,16 @@ impl ClusterWaitHandle for K8sAttachedClusterWait { } fn k8s_wait_request(source: &AttachSource) -> Result, DynError> { - let AttachSource::K8s { - namespace, - label_selector, - } = source - else { - return Err("k8s cluster wait requires a k8s attach source".into()); - }; + let label_selector = source + .k8s_label_selector() + .ok_or_else(|| DynError::from("k8s cluster wait requires a k8s attach source"))?; if label_selector.trim().is_empty() { return Err(K8sAttachDiscoveryError::EmptyLabelSelector.into()); } Ok(K8sAttachRequest { - namespace: namespace.as_deref().unwrap_or("default"), + namespace: source.k8s_namespace().unwrap_or("default"), label_selector, }) } diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index eb39b33..ec1cb80 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -5,12 +5,11 @@ use kube::Client; use reqwest::Url; use testing_framework_core::{ scenario::{ - Application, ApplicationExternalProvider, AttachSource, CleanupGuard, ClusterWaitHandle, - Deployer, DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, - MetricsError, NodeClients, ObservabilityCapabilityProvider, ObservabilityInputs, - RequiresNodeControl, RunContext, Runner, Scenario, ScenarioSources, - SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, - build_source_orchestration_plan, orchestrate_sources_with_providers, + Application, ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, Deployer, + DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, MetricsError, + NodeClients, ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, + RunContext, Runner, Scenario, SourceOrchestrationPlan, SourceProviders, + StaticManagedProvider, build_source_orchestration_plan, orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, }; @@ -250,22 +249,18 @@ where E: K8sDeployEnv, Caps: Send + Sync, { - match scenario.sources() { - ScenarioSources::Attached { - attach: - AttachSource::K8s { - namespace, - label_selector, - }, - .. - } => K8sDeploymentMetadata { - namespace: namespace.clone(), - label_selector: Some(label_selector.clone()), - }, - _ => K8sDeploymentMetadata { - namespace: None, - label_selector: None, - }, + let namespace = scenario + .attached_source() + .and_then(|attach| attach.k8s_namespace()) + .map(ToOwned::to_owned); + let label_selector = scenario + .attached_source() + .and_then(|attach| attach.k8s_label_selector()) + .map(ToOwned::to_owned); + + K8sDeploymentMetadata { + namespace, + label_selector, } } @@ -277,11 +272,11 @@ where E: K8sDeployEnv, Caps: Send + Sync, { - let ScenarioSources::Attached { attach, .. } = scenario.sources() else { - return Err(K8sRunnerError::InternalInvariant { + let attach = scenario + .attached_source() + .ok_or_else(|| K8sRunnerError::InternalInvariant { message: "k8s attached cluster wait requested outside attached source mode".to_owned(), - }); - }; + })?; Ok(Arc::new(K8sAttachedClusterWait::::new( client, From 05b907d8eff0f2a6c84f27e73c7b22c025ec5325 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 13:57:15 +0100 Subject: [PATCH 15/40] Name existing cluster semantics explicitly --- .../tests/compose_attach_node_control.rs | 2 +- .../examples/tests/k8s_attach_node_control.rs | 2 +- .../core/src/scenario/definition.rs | 26 ++++++++++++++++--- .../core/src/scenario/sources/model.rs | 7 ++++- .../compose/src/deployer/orchestrator.rs | 8 +++--- .../k8s/src/deployer/orchestrator.rs | 8 +++--- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/logos/examples/tests/compose_attach_node_control.rs b/logos/examples/tests/compose_attach_node_control.rs index fa3befd..7b3e5fe 100644 --- a/logos/examples/tests/compose_attach_node_control.rs +++ b/logos/examples/tests/compose_attach_node_control.rs @@ -23,7 +23,7 @@ async fn compose_attach_mode_queries_node_api_opt_in() -> Result<()> { let attach_source = metadata.attach_source().map_err(|err| anyhow!("{err}"))?; let attached = ScenarioBuilder::deployment_with(|d| d.with_node_count(1)) .with_run_duration(Duration::from_secs(5)) - .with_attach_source(attach_source) + .with_existing_cluster(attach_source) .build()?; let attached_deployer = LbcComposeDeployer::default(); diff --git a/logos/examples/tests/k8s_attach_node_control.rs b/logos/examples/tests/k8s_attach_node_control.rs index 72b2c9d..f309eee 100644 --- a/logos/examples/tests/k8s_attach_node_control.rs +++ b/logos/examples/tests/k8s_attach_node_control.rs @@ -23,7 +23,7 @@ async fn k8s_attach_mode_queries_node_api_opt_in() -> Result<()> { let attach_source = metadata.attach_source().map_err(|err| anyhow!("{err}"))?; let attached = ScenarioBuilder::deployment_with(|d| d.with_node_count(1)) .with_run_duration(Duration::from_secs(5)) - .with_attach_source(attach_source) + .with_existing_cluster(attach_source) .build()?; let attached_deployer = LbcK8sDeployer::default(); diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 15c7ea9..9739141 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -118,8 +118,14 @@ impl Scenario { } #[must_use] + pub fn existing_cluster(&self) -> Option<&AttachSource> { + self.sources.existing_cluster() + } + + #[must_use] + #[doc(hidden)] pub fn attached_source(&self) -> Option<&AttachSource> { - self.sources.attached_source() + self.existing_cluster() } #[must_use] @@ -243,8 +249,14 @@ macro_rules! impl_common_builder_methods { } #[must_use] + pub fn with_existing_cluster(self, cluster: AttachSource) -> Self { + self.map_core_builder(|builder| builder.with_existing_cluster(cluster)) + } + + #[must_use] + #[doc(hidden)] pub fn with_attach_source(self, attach: AttachSource) -> Self { - self.map_core_builder(|builder| builder.with_attach_source(attach)) + self.with_existing_cluster(attach) } #[must_use] @@ -556,11 +568,17 @@ impl Builder { } #[must_use] - pub fn with_attach_source(mut self, attach: AttachSource) -> Self { - self.sources = self.sources.with_attach(attach); + pub fn with_existing_cluster(mut self, cluster: AttachSource) -> Self { + self.sources = self.sources.with_attach(cluster); self } + #[must_use] + #[doc(hidden)] + pub fn with_attach_source(self, attach: AttachSource) -> Self { + self.with_existing_cluster(attach) + } + #[must_use] pub fn with_external_node(mut self, node: ExternalNodeSource) -> Self { self.sources = self.sources.with_external_node(node); diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 5b0ec2c..df444f6 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -173,7 +173,7 @@ impl ScenarioSources { } #[must_use] - pub fn attached_source(&self) -> Option<&AttachSource> { + pub fn existing_cluster(&self) -> Option<&AttachSource> { match self { Self::Attached { attach, .. } => Some(attach), Self::Managed { .. } | Self::ExternalOnly { .. } => None, @@ -199,6 +199,11 @@ impl ScenarioSources { matches!(self, Self::Attached { .. }) } + #[must_use] + pub const fn uses_existing_cluster(&self) -> bool { + self.is_attached() + } + #[must_use] pub const fn is_external_only(&self) -> bool { matches!(self, Self::ExternalOnly { .. }) diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index c4fdc4f..9619e86 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -71,7 +71,7 @@ impl DeploymentOrchestrator { } })?; - if scenario.sources().is_attached() { + if scenario.sources().uses_existing_cluster() { return self .deploy_attached_only::(scenario, source_plan) .await @@ -215,7 +215,7 @@ impl DeploymentOrchestrator { } let attach = scenario - .attached_source() + .existing_cluster() .ok_or(ComposeRunnerError::InternalInvariant { message: "attached node control requested outside attached source mode", })?; @@ -243,7 +243,7 @@ impl DeploymentOrchestrator { Caps: Send + Sync, { let attach = scenario - .attached_source() + .existing_cluster() .ok_or(ComposeRunnerError::InternalInvariant { message: "compose attached cluster wait requested outside attached source mode", })?; @@ -373,7 +373,7 @@ where Caps: Send + Sync, { let project_name = scenario - .attached_source() + .existing_cluster() .and_then(|attach| attach.compose_project()) .map(ToOwned::to_owned); diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index ec1cb80..a41e8d5 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -179,7 +179,7 @@ where let observability = resolve_observability_inputs(scenario.capabilities())?; - if scenario.sources().is_attached() { + if scenario.sources().uses_existing_cluster() { let runner = deploy_attached_only::(scenario, source_plan, observability).await?; return Ok((runner, attached_metadata(scenario))); } @@ -250,11 +250,11 @@ where Caps: Send + Sync, { let namespace = scenario - .attached_source() + .existing_cluster() .and_then(|attach| attach.k8s_namespace()) .map(ToOwned::to_owned); let label_selector = scenario - .attached_source() + .existing_cluster() .and_then(|attach| attach.k8s_label_selector()) .map(ToOwned::to_owned); @@ -273,7 +273,7 @@ where Caps: Send + Sync, { let attach = scenario - .attached_source() + .existing_cluster() .ok_or_else(|| K8sRunnerError::InternalInvariant { message: "k8s attached cluster wait requested outside attached source mode".to_owned(), })?; From f79eb34a507d98034f0bef5e094e91e0d1feb4d5 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:00:34 +0100 Subject: [PATCH 16/40] Hide raw scenario sources behind semantic accessors --- testing-framework/core/src/scenario/definition.rs | 6 ++++++ .../src/scenario/runtime/orchestration/source_resolver.rs | 2 +- .../deployers/compose/src/deployer/orchestrator.rs | 2 +- .../deployers/k8s/src/deployer/orchestrator.rs | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 9739141..56190db 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -113,6 +113,7 @@ impl Scenario { } #[must_use] + #[doc(hidden)] pub fn sources(&self) -> &ScenarioSources { &self.sources } @@ -122,6 +123,11 @@ impl Scenario { self.sources.existing_cluster() } + #[must_use] + pub const fn uses_existing_cluster(&self) -> bool { + self.sources.uses_existing_cluster() + } + #[must_use] #[doc(hidden)] pub fn attached_source(&self) -> Option<&AttachSource> { diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs index ebf2d2d..d6f822b 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_resolver.rs @@ -41,7 +41,7 @@ pub enum SourceResolveError { pub fn build_source_orchestration_plan( scenario: &Scenario, ) -> Result { - SourceOrchestrationPlan::try_from_sources(scenario.sources()) + Ok(scenario.source_orchestration_plan().clone()) } /// Resolves runtime source nodes via unified providers from orchestration plan. diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index 9619e86..ef93c7f 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -71,7 +71,7 @@ impl DeploymentOrchestrator { } })?; - if scenario.sources().uses_existing_cluster() { + if scenario.uses_existing_cluster() { return self .deploy_attached_only::(scenario, source_plan) .await diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index a41e8d5..1565a40 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -179,7 +179,7 @@ where let observability = resolve_observability_inputs(scenario.capabilities())?; - if scenario.sources().uses_existing_cluster() { + if scenario.uses_existing_cluster() { let runner = deploy_attached_only::(scenario, source_plan, observability).await?; return Ok((runner, attached_metadata(scenario))); } From e04832f62c8215fa1435eb2c158e13a48459454d Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:03:50 +0100 Subject: [PATCH 17/40] Rename metadata attach flow around existing clusters --- .../tests/compose_attach_node_control.rs | 4 +++- .../examples/tests/k8s_attach_node_control.rs | 4 +++- .../deployers/compose/src/deployer/mod.rs | 23 +++++++++++++++---- .../deployers/k8s/src/deployer/mod.rs | 9 ++++++-- .../k8s/src/deployer/orchestrator.rs | 2 +- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/logos/examples/tests/compose_attach_node_control.rs b/logos/examples/tests/compose_attach_node_control.rs index 7b3e5fe..4f909a6 100644 --- a/logos/examples/tests/compose_attach_node_control.rs +++ b/logos/examples/tests/compose_attach_node_control.rs @@ -20,7 +20,9 @@ async fn compose_attach_mode_queries_node_api_opt_in() -> Result<()> { Err(error) => return Err(Error::new(error)), }; - let attach_source = metadata.attach_source().map_err(|err| anyhow!("{err}"))?; + let attach_source = metadata + .existing_cluster() + .map_err(|err| anyhow!("{err}"))?; let attached = ScenarioBuilder::deployment_with(|d| d.with_node_count(1)) .with_run_duration(Duration::from_secs(5)) .with_existing_cluster(attach_source) diff --git a/logos/examples/tests/k8s_attach_node_control.rs b/logos/examples/tests/k8s_attach_node_control.rs index f309eee..47bbe35 100644 --- a/logos/examples/tests/k8s_attach_node_control.rs +++ b/logos/examples/tests/k8s_attach_node_control.rs @@ -20,7 +20,9 @@ async fn k8s_attach_mode_queries_node_api_opt_in() -> Result<()> { Err(error) => return Err(Error::new(error)), }; - let attach_source = metadata.attach_source().map_err(|err| anyhow!("{err}"))?; + let attach_source = metadata + .existing_cluster() + .map_err(|err| anyhow!("{err}"))?; let attached = ScenarioBuilder::deployment_with(|d| d.with_node_count(1)) .with_run_duration(Duration::from_secs(5)) .with_existing_cluster(attach_source) diff --git a/testing-framework/deployers/compose/src/deployer/mod.rs b/testing-framework/deployers/compose/src/deployer/mod.rs index 2f809c7..fd6f44a 100644 --- a/testing-framework/deployers/compose/src/deployer/mod.rs +++ b/testing-framework/deployers/compose/src/deployer/mod.rs @@ -43,9 +43,9 @@ impl ComposeDeploymentMetadata { self.project_name.as_deref() } - /// Builds an attach source for the same compose project using deployer - /// discovery to resolve services. - pub fn attach_source(&self) -> Result { + /// Builds an existing-cluster descriptor for the same compose project + /// using deployer discovery to resolve services. + pub fn existing_cluster(&self) -> Result { let project_name = self .project_name() .ok_or(ComposeMetadataError::MissingProjectName)?; @@ -56,8 +56,8 @@ impl ComposeDeploymentMetadata { )) } - /// Builds an attach source for the same compose project. - pub fn attach_source_for_services( + /// Builds an existing-cluster descriptor for the same compose project. + pub fn existing_cluster_for_services( &self, services: Vec, ) -> Result { @@ -70,6 +70,19 @@ impl ComposeDeploymentMetadata { project_name.to_owned(), )) } + + #[doc(hidden)] + pub fn attach_source(&self) -> Result { + self.existing_cluster() + } + + #[doc(hidden)] + pub fn attach_source_for_services( + &self, + services: Vec, + ) -> Result { + self.existing_cluster_for_services(services) + } } impl Default for ComposeDeployer { diff --git a/testing-framework/deployers/k8s/src/deployer/mod.rs b/testing-framework/deployers/k8s/src/deployer/mod.rs index 43f0ff5..a43b83c 100644 --- a/testing-framework/deployers/k8s/src/deployer/mod.rs +++ b/testing-framework/deployers/k8s/src/deployer/mod.rs @@ -34,8 +34,8 @@ impl K8sDeploymentMetadata { self.label_selector.as_deref() } - /// Builds an attach source for the same k8s deployment scope. - pub fn attach_source(&self) -> Result { + /// Builds an existing-cluster descriptor for the same k8s deployment scope. + pub fn existing_cluster(&self) -> Result { let namespace = self.namespace().ok_or(K8sMetadataError::MissingNamespace)?; let label_selector = self .label_selector() @@ -46,4 +46,9 @@ impl K8sDeploymentMetadata { namespace.to_owned(), )) } + + #[doc(hidden)] + pub fn attach_source(&self) -> Result { + self.existing_cluster() + } } diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index 1565a40..836a892 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -290,7 +290,7 @@ fn managed_cluster_wait( ) -> Result>, K8sRunnerError> { let client = client_from_cluster(cluster)?; let attach_source = metadata - .attach_source() + .existing_cluster() .map_err(|source| K8sRunnerError::SourceOrchestration { source })?; Ok(Arc::new(K8sAttachedClusterWait::::new( From 4d8349679e74e05d2c2a7d5120cfc841f1d38a8b Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:05:28 +0100 Subject: [PATCH 18/40] Rename attach source around existing clusters --- .../core/src/scenario/definition.rs | 14 +++++++------- testing-framework/core/src/scenario/mod.rs | 4 +++- .../orchestration/source_orchestration_plan.rs | 9 +++++---- .../runtime/providers/attach_provider.rs | 8 ++++---- .../core/src/scenario/sources/mod.rs | 4 +++- .../core/src/scenario/sources/model.rs | 13 ++++++++----- .../compose/src/deployer/attach_provider.rs | 14 +++++++------- .../deployers/compose/src/deployer/mod.rs | 14 +++++++------- .../compose/src/deployer/orchestrator.rs | 6 +++--- .../k8s/src/deployer/attach_provider.rs | 16 +++++++++------- .../deployers/k8s/src/deployer/mod.rs | 8 ++++---- 11 files changed, 60 insertions(+), 50 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 56190db..83ebed1 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -4,7 +4,7 @@ use thiserror::Error; use tracing::{debug, info}; use super::{ - Application, AttachSource, DeploymentPolicy, DynError, ExternalNodeSource, + Application, DeploymentPolicy, DynError, ExistingCluster, ExternalNodeSource, HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, ScenarioSources, builder_ops::CoreBuilderAccess, expectation::Expectation, @@ -119,7 +119,7 @@ impl Scenario { } #[must_use] - pub fn existing_cluster(&self) -> Option<&AttachSource> { + pub fn existing_cluster(&self) -> Option<&ExistingCluster> { self.sources.existing_cluster() } @@ -130,7 +130,7 @@ impl Scenario { #[must_use] #[doc(hidden)] - pub fn attached_source(&self) -> Option<&AttachSource> { + pub fn attached_source(&self) -> Option<&ExistingCluster> { self.existing_cluster() } @@ -255,13 +255,13 @@ macro_rules! impl_common_builder_methods { } #[must_use] - pub fn with_existing_cluster(self, cluster: AttachSource) -> Self { + pub fn with_existing_cluster(self, cluster: ExistingCluster) -> Self { self.map_core_builder(|builder| builder.with_existing_cluster(cluster)) } #[must_use] #[doc(hidden)] - pub fn with_attach_source(self, attach: AttachSource) -> Self { + pub fn with_attach_source(self, attach: ExistingCluster) -> Self { self.with_existing_cluster(attach) } @@ -574,14 +574,14 @@ impl Builder { } #[must_use] - pub fn with_existing_cluster(mut self, cluster: AttachSource) -> Self { + pub fn with_existing_cluster(mut self, cluster: ExistingCluster) -> Self { self.sources = self.sources.with_attach(cluster); self } #[must_use] #[doc(hidden)] - pub fn with_attach_source(self, attach: AttachSource) -> Self { + pub fn with_attach_source(self, attach: ExistingCluster) -> Self { self.with_existing_cluster(attach) } diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index 8b570ad..37dc1ef 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -51,7 +51,9 @@ pub use runtime::{ wait_for_http_ports_with_host_and_requirement, wait_for_http_ports_with_requirement, wait_http_readiness, wait_until_stable, }; -pub use sources::{AttachSource, ExternalNodeSource, ScenarioSources}; +#[doc(hidden)] +pub use sources::AttachSource; +pub use sources::{ExistingCluster, ExternalNodeSource, ScenarioSources}; pub use workload::Workload; pub use crate::env::Application; diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs index f1dd9b9..2c10958 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs @@ -1,4 +1,4 @@ -use crate::scenario::{AttachSource, ExternalNodeSource, ScenarioSources}; +use crate::scenario::{ExistingCluster, ExternalNodeSource, ScenarioSources}; /// Explicit descriptor for managed node sourcing. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -19,7 +19,7 @@ pub(crate) enum SourceOrchestrationMode { external: Vec, }, Attached { - attach: AttachSource, + attach: ExistingCluster, external: Vec, }, ExternalOnly { @@ -69,11 +69,12 @@ impl SourceOrchestrationPlan { #[cfg(test)] mod tests { use super::{SourceOrchestrationMode, SourceOrchestrationPlan}; - use crate::scenario::{AttachSource, ScenarioSources}; + use crate::scenario::{ExistingCluster, ScenarioSources}; #[test] fn attached_sources_are_planned() { - let sources = ScenarioSources::attached(AttachSource::compose(vec!["node-0".to_string()])); + let sources = + ScenarioSources::attached(ExistingCluster::compose(vec!["node-0".to_string()])); let plan = SourceOrchestrationPlan::try_from_sources(&sources) .expect("attached sources should build a source orchestration plan"); diff --git a/testing-framework/core/src/scenario/runtime/providers/attach_provider.rs b/testing-framework/core/src/scenario/runtime/providers/attach_provider.rs index d31bd78..b239193 100644 --- a/testing-framework/core/src/scenario/runtime/providers/attach_provider.rs +++ b/testing-framework/core/src/scenario/runtime/providers/attach_provider.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; -use crate::scenario::{Application, AttachSource, DynError}; +use crate::scenario::{Application, DynError, ExistingCluster}; /// Attached node discovered from an existing external cluster source. #[derive(Clone, Debug)] @@ -15,7 +15,7 @@ pub struct AttachedNode { #[derive(Debug, thiserror::Error)] pub enum AttachProviderError { #[error("attach source is not supported by this provider: {attach_source:?}")] - UnsupportedSource { attach_source: AttachSource }, + UnsupportedSource { attach_source: ExistingCluster }, #[error("attach discovery failed: {source}")] Discovery { #[source] @@ -32,7 +32,7 @@ pub trait AttachProvider: Send + Sync { /// Discovers node clients for the requested attach source. async fn discover( &self, - source: &AttachSource, + source: &ExistingCluster, ) -> Result>, AttachProviderError>; } @@ -44,7 +44,7 @@ pub struct NoopAttachProvider; impl AttachProvider for NoopAttachProvider { async fn discover( &self, - source: &AttachSource, + source: &ExistingCluster, ) -> Result>, AttachProviderError> { Err(AttachProviderError::UnsupportedSource { attach_source: source.clone(), diff --git a/testing-framework/core/src/scenario/sources/mod.rs b/testing-framework/core/src/scenario/sources/mod.rs index 98324dd..26075f2 100644 --- a/testing-framework/core/src/scenario/sources/mod.rs +++ b/testing-framework/core/src/scenario/sources/mod.rs @@ -1,3 +1,5 @@ mod model; -pub use model::{AttachSource, ExternalNodeSource, ScenarioSources}; +#[doc(hidden)] +pub use model::AttachSource; +pub use model::{ExistingCluster, ExternalNodeSource, ScenarioSources}; diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index df444f6..8e62419 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -1,6 +1,6 @@ /// Typed attach source for existing clusters. #[derive(Clone, Debug, Eq, PartialEq)] -pub enum AttachSource { +pub enum ExistingCluster { K8s { namespace: Option, label_selector: String, @@ -11,7 +11,7 @@ pub enum AttachSource { }, } -impl AttachSource { +impl ExistingCluster { #[must_use] pub fn k8s(label_selector: String) -> Self { Self::K8s { @@ -77,6 +77,9 @@ impl AttachSource { } } +#[doc(hidden)] +pub type AttachSource = ExistingCluster; + /// Static external node endpoint that should be included in the runtime /// inventory. #[derive(Clone, Debug, Eq, PartialEq)] @@ -110,7 +113,7 @@ pub enum ScenarioSources { external: Vec, }, Attached { - attach: AttachSource, + attach: ExistingCluster, external: Vec, }, ExternalOnly { @@ -135,7 +138,7 @@ impl ScenarioSources { } #[must_use] - pub fn attached(attach: AttachSource) -> Self { + pub fn attached(attach: ExistingCluster) -> Self { Self::Attached { attach, external: Vec::new(), @@ -159,7 +162,7 @@ impl ScenarioSources { } #[must_use] - pub fn with_attach(self, attach: AttachSource) -> Self { + pub fn with_attach(self, attach: ExistingCluster) -> Self { let external = self.external_nodes().to_vec(); Self::Attached { attach, external } diff --git a/testing-framework/deployers/compose/src/deployer/attach_provider.rs b/testing-framework/deployers/compose/src/deployer/attach_provider.rs index 06c8d61..314ae3d 100644 --- a/testing-framework/deployers/compose/src/deployer/attach_provider.rs +++ b/testing-framework/deployers/compose/src/deployer/attach_provider.rs @@ -2,8 +2,8 @@ use std::marker::PhantomData; use async_trait::async_trait; use testing_framework_core::scenario::{ - AttachProvider, AttachProviderError, AttachSource, AttachedNode, ClusterWaitHandle, DynError, - ExternalNodeSource, HttpReadinessRequirement, wait_http_readiness, + AttachProvider, AttachProviderError, AttachedNode, ClusterWaitHandle, DynError, + ExistingCluster, ExternalNodeSource, HttpReadinessRequirement, wait_http_readiness, }; use url::Url; @@ -22,7 +22,7 @@ pub(super) struct ComposeAttachProvider { pub(super) struct ComposeAttachedClusterWait { host: String, - source: AttachSource, + source: ExistingCluster, _env: PhantomData, } @@ -42,7 +42,7 @@ impl ComposeAttachProvider { } impl ComposeAttachedClusterWait { - pub(super) fn new(host: String, source: AttachSource) -> Self { + pub(super) fn new(host: String, source: ExistingCluster) -> Self { Self { host, source, @@ -60,7 +60,7 @@ struct ComposeAttachRequest<'a> { impl AttachProvider for ComposeAttachProvider { async fn discover( &self, - source: &AttachSource, + source: &ExistingCluster, ) -> Result>, AttachProviderError> { let request = compose_attach_request(source)?; let services = resolve_services(request.project, request.services) @@ -85,7 +85,7 @@ fn to_discovery_error(source: DynError) -> AttachProviderError { } fn compose_attach_request( - source: &AttachSource, + source: &ExistingCluster, ) -> Result, AttachProviderError> { let services = source @@ -173,7 +173,7 @@ impl ClusterWaitHandle for ComposeAttachedClusterWait } } -fn compose_wait_request(source: &AttachSource) -> Result, DynError> { +fn compose_wait_request(source: &ExistingCluster) -> Result, DynError> { let project = source .compose_project() .ok_or_else(|| DynError::from("compose cluster wait requires a compose attach source"))?; diff --git a/testing-framework/deployers/compose/src/deployer/mod.rs b/testing-framework/deployers/compose/src/deployer/mod.rs index fd6f44a..66859c1 100644 --- a/testing-framework/deployers/compose/src/deployer/mod.rs +++ b/testing-framework/deployers/compose/src/deployer/mod.rs @@ -9,7 +9,7 @@ use std::marker::PhantomData; use async_trait::async_trait; use testing_framework_core::scenario::{ - AttachSource, CleanupGuard, Deployer, DynError, FeedHandle, ObservabilityCapabilityProvider, + CleanupGuard, Deployer, DynError, ExistingCluster, FeedHandle, ObservabilityCapabilityProvider, RequiresNodeControl, Runner, Scenario, }; @@ -45,12 +45,12 @@ impl ComposeDeploymentMetadata { /// Builds an existing-cluster descriptor for the same compose project /// using deployer discovery to resolve services. - pub fn existing_cluster(&self) -> Result { + pub fn existing_cluster(&self) -> Result { let project_name = self .project_name() .ok_or(ComposeMetadataError::MissingProjectName)?; - Ok(AttachSource::compose_in_project( + Ok(ExistingCluster::compose_in_project( Vec::new(), project_name.to_owned(), )) @@ -60,19 +60,19 @@ impl ComposeDeploymentMetadata { pub fn existing_cluster_for_services( &self, services: Vec, - ) -> Result { + ) -> Result { let project_name = self .project_name() .ok_or(ComposeMetadataError::MissingProjectName)?; - Ok(AttachSource::compose_in_project( + Ok(ExistingCluster::compose_in_project( services, project_name.to_owned(), )) } #[doc(hidden)] - pub fn attach_source(&self) -> Result { + pub fn attach_source(&self) -> Result { self.existing_cluster() } @@ -80,7 +80,7 @@ impl ComposeDeploymentMetadata { pub fn attach_source_for_services( &self, services: Vec, - ) -> Result { + ) -> Result { self.existing_cluster_for_services(services) } } diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index ef93c7f..d9cf18c 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -3,8 +3,8 @@ use std::{env, sync::Arc, time::Duration}; use reqwest::Url; use testing_framework_core::{ scenario::{ - ApplicationExternalProvider, AttachSource, CleanupGuard, ClusterWaitHandle, - DeploymentPolicy, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, + ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, DeploymentPolicy, + ExistingCluster, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, NodeControlHandle, ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, RunContext, Runner, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, build_source_orchestration_plan, @@ -315,7 +315,7 @@ impl DeploymentOrchestrator { fn managed_cluster_wait(&self, project_name: String) -> Arc> { Arc::new(ComposeAttachedClusterWait::::new( compose_runner_host(), - AttachSource::compose_in_project(Vec::new(), project_name), + ExistingCluster::compose_in_project(Vec::new(), project_name), )) } diff --git a/testing-framework/deployers/k8s/src/deployer/attach_provider.rs b/testing-framework/deployers/k8s/src/deployer/attach_provider.rs index bae5692..cfa0dc2 100644 --- a/testing-framework/deployers/k8s/src/deployer/attach_provider.rs +++ b/testing-framework/deployers/k8s/src/deployer/attach_provider.rs @@ -7,8 +7,8 @@ use kube::{ api::{ListParams, ObjectList}, }; use testing_framework_core::scenario::{ - AttachProvider, AttachProviderError, AttachSource, AttachedNode, ClusterWaitHandle, DynError, - ExternalNodeSource, HttpReadinessRequirement, wait_http_readiness, + AttachProvider, AttachProviderError, AttachedNode, ClusterWaitHandle, DynError, + ExistingCluster, ExternalNodeSource, HttpReadinessRequirement, wait_http_readiness, }; use url::Url; @@ -37,7 +37,7 @@ pub(super) struct K8sAttachProvider { pub(super) struct K8sAttachedClusterWait { client: Client, - source: AttachSource, + source: ExistingCluster, _env: PhantomData, } @@ -56,7 +56,7 @@ impl K8sAttachProvider { } impl K8sAttachedClusterWait { - pub(super) fn new(client: Client, source: AttachSource) -> Self { + pub(super) fn new(client: Client, source: ExistingCluster) -> Self { Self { client, source, @@ -69,7 +69,7 @@ impl K8sAttachedClusterWait { impl AttachProvider for K8sAttachProvider { async fn discover( &self, - source: &AttachSource, + source: &ExistingCluster, ) -> Result>, AttachProviderError> { let request = k8s_attach_request(source)?; let services = discover_services(&self.client, request.namespace, request.label_selector) @@ -90,7 +90,9 @@ fn to_discovery_error(source: DynError) -> AttachProviderError { AttachProviderError::Discovery { source } } -fn k8s_attach_request(source: &AttachSource) -> Result, AttachProviderError> { +fn k8s_attach_request( + source: &ExistingCluster, +) -> Result, AttachProviderError> { let Some(label_selector) = source.k8s_label_selector() else { return Err(AttachProviderError::UnsupportedSource { attach_source: source.clone(), @@ -242,7 +244,7 @@ impl ClusterWaitHandle for K8sAttachedClusterWait { } } -fn k8s_wait_request(source: &AttachSource) -> Result, DynError> { +fn k8s_wait_request(source: &ExistingCluster) -> Result, DynError> { let label_selector = source .k8s_label_selector() .ok_or_else(|| DynError::from("k8s cluster wait requires a k8s attach source"))?; diff --git a/testing-framework/deployers/k8s/src/deployer/mod.rs b/testing-framework/deployers/k8s/src/deployer/mod.rs index a43b83c..eae7507 100644 --- a/testing-framework/deployers/k8s/src/deployer/mod.rs +++ b/testing-framework/deployers/k8s/src/deployer/mod.rs @@ -2,7 +2,7 @@ mod attach_provider; mod orchestrator; pub use orchestrator::{K8sDeployer, K8sRunnerError}; -use testing_framework_core::scenario::{AttachSource, DynError}; +use testing_framework_core::scenario::{DynError, ExistingCluster}; /// Kubernetes deployment metadata returned by k8s-specific deployment APIs. #[derive(Clone, Debug, Eq, PartialEq)] @@ -35,20 +35,20 @@ impl K8sDeploymentMetadata { } /// Builds an existing-cluster descriptor for the same k8s deployment scope. - pub fn existing_cluster(&self) -> Result { + pub fn existing_cluster(&self) -> Result { let namespace = self.namespace().ok_or(K8sMetadataError::MissingNamespace)?; let label_selector = self .label_selector() .ok_or(K8sMetadataError::MissingLabelSelector)?; - Ok(AttachSource::k8s_in_namespace( + Ok(ExistingCluster::k8s_in_namespace( label_selector.to_owned(), namespace.to_owned(), )) } #[doc(hidden)] - pub fn attach_source(&self) -> Result { + pub fn attach_source(&self) -> Result { self.existing_cluster() } } From a5230242797380bacaf6263e12b5dadb5aa754ee Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:08:42 +0100 Subject: [PATCH 19/40] Hide raw source types behind semantic scenario API --- testing-framework/core/src/scenario/definition.rs | 15 +++++++++++++++ testing-framework/core/src/scenario/mod.rs | 4 ++-- .../core/src/scenario/sources/mod.rs | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 83ebed1..fe6dad3 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -139,6 +139,21 @@ impl Scenario { self.sources.external_nodes() } + #[must_use] + pub const fn is_managed(&self) -> bool { + self.sources.is_managed() + } + + #[must_use] + pub const fn is_external_only(&self) -> bool { + self.sources.is_external_only() + } + + #[must_use] + pub fn has_external_nodes(&self) -> bool { + !self.sources.external_nodes().is_empty() + } + #[must_use] pub const fn source_orchestration_plan(&self) -> &SourceOrchestrationPlan { &self.source_orchestration_plan diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index 37dc1ef..5fcd013 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -52,8 +52,8 @@ pub use runtime::{ wait_http_readiness, wait_until_stable, }; #[doc(hidden)] -pub use sources::AttachSource; -pub use sources::{ExistingCluster, ExternalNodeSource, ScenarioSources}; +pub use sources::{AttachSource, ScenarioSources}; +pub use sources::{ExistingCluster, ExternalNodeSource}; pub use workload::Workload; pub use crate::env::Application; diff --git a/testing-framework/core/src/scenario/sources/mod.rs b/testing-framework/core/src/scenario/sources/mod.rs index 26075f2..15ddbf0 100644 --- a/testing-framework/core/src/scenario/sources/mod.rs +++ b/testing-framework/core/src/scenario/sources/mod.rs @@ -1,5 +1,5 @@ mod model; #[doc(hidden)] -pub use model::AttachSource; -pub use model::{ExistingCluster, ExternalNodeSource, ScenarioSources}; +pub use model::{AttachSource, ScenarioSources}; +pub use model::{ExistingCluster, ExternalNodeSource}; From f18820b8d1009117bc44b4939bfd72b7cfbedc2d Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:13:45 +0100 Subject: [PATCH 20/40] Hide raw source storage behind existing-cluster API --- .../core/src/scenario/definition.rs | 9 +- testing-framework/core/src/scenario/mod.rs | 2 - .../source_orchestration_plan.rs | 10 +- .../core/src/scenario/sources/mod.rs | 2 +- .../core/src/scenario/sources/model.rs | 119 +++++++++--------- 5 files changed, 64 insertions(+), 78 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index fe6dad3..0eea6f2 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -5,13 +5,14 @@ use tracing::{debug, info}; use super::{ Application, DeploymentPolicy, DynError, ExistingCluster, ExternalNodeSource, - HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, ScenarioSources, + HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, builder_ops::CoreBuilderAccess, expectation::Expectation, runtime::{ context::RunMetrics, orchestration::{SourceOrchestrationPlan, SourceOrchestrationPlanError}, }, + sources::ScenarioSources, workload::Workload, }; use crate::topology::{DeploymentDescriptor, DeploymentProvider, DeploymentSeed, DynTopologyError}; @@ -112,12 +113,6 @@ impl Scenario { self.deployment_policy } - #[must_use] - #[doc(hidden)] - pub fn sources(&self) -> &ScenarioSources { - &self.sources - } - #[must_use] pub fn existing_cluster(&self) -> Option<&ExistingCluster> { self.sources.existing_cluster() diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index 5fcd013..ca358a5 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -51,8 +51,6 @@ pub use runtime::{ wait_for_http_ports_with_host_and_requirement, wait_for_http_ports_with_requirement, wait_http_readiness, wait_until_stable, }; -#[doc(hidden)] -pub use sources::{AttachSource, ScenarioSources}; pub use sources::{ExistingCluster, ExternalNodeSource}; pub use workload::Workload; diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs index 2c10958..c9c99f9 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs @@ -1,4 +1,4 @@ -use crate::scenario::{ExistingCluster, ExternalNodeSource, ScenarioSources}; +use crate::scenario::{ExistingCluster, ExternalNodeSource, sources::ScenarioSources}; /// Explicit descriptor for managed node sourcing. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -43,7 +43,7 @@ pub enum SourceOrchestrationPlanError { } impl SourceOrchestrationPlan { - pub fn try_from_sources( + pub(crate) fn try_from_sources( sources: &ScenarioSources, ) -> Result { let mode = mode_from_sources(sources); @@ -69,12 +69,12 @@ impl SourceOrchestrationPlan { #[cfg(test)] mod tests { use super::{SourceOrchestrationMode, SourceOrchestrationPlan}; - use crate::scenario::{ExistingCluster, ScenarioSources}; + use crate::scenario::{ExistingCluster, sources::ScenarioSources}; #[test] fn attached_sources_are_planned() { - let sources = - ScenarioSources::attached(ExistingCluster::compose(vec!["node-0".to_string()])); + let sources = ScenarioSources::default() + .with_attach(ExistingCluster::compose(vec!["node-0".to_string()])); let plan = SourceOrchestrationPlan::try_from_sources(&sources) .expect("attached sources should build a source orchestration plan"); diff --git a/testing-framework/core/src/scenario/sources/mod.rs b/testing-framework/core/src/scenario/sources/mod.rs index 15ddbf0..506a8aa 100644 --- a/testing-framework/core/src/scenario/sources/mod.rs +++ b/testing-framework/core/src/scenario/sources/mod.rs @@ -1,5 +1,5 @@ mod model; +pub(crate) use model::ScenarioSources; #[doc(hidden)] -pub use model::{AttachSource, ScenarioSources}; pub use model::{ExistingCluster, ExternalNodeSource}; diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 8e62419..566e2ee 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -1,6 +1,11 @@ -/// Typed attach source for existing clusters. +/// Typed descriptor for an existing cluster. #[derive(Clone, Debug, Eq, PartialEq)] -pub enum ExistingCluster { +pub struct ExistingCluster { + kind: ExistingClusterKind, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum ExistingClusterKind { K8s { namespace: Option, label_selector: String, @@ -13,73 +18,86 @@ pub enum ExistingCluster { impl ExistingCluster { #[must_use] + #[doc(hidden)] pub fn k8s(label_selector: String) -> Self { - Self::K8s { - namespace: None, - label_selector, + Self { + kind: ExistingClusterKind::K8s { + namespace: None, + label_selector, + }, } } #[must_use] + #[doc(hidden)] pub fn k8s_in_namespace(label_selector: String, namespace: String) -> Self { - Self::K8s { - namespace: Some(namespace), - label_selector, + Self { + kind: ExistingClusterKind::K8s { + namespace: Some(namespace), + label_selector, + }, } } #[must_use] + #[doc(hidden)] pub fn compose(services: Vec) -> Self { - Self::Compose { - project: None, - services, + Self { + kind: ExistingClusterKind::Compose { + project: None, + services, + }, } } #[must_use] + #[doc(hidden)] pub fn compose_in_project(services: Vec, project: String) -> Self { - Self::Compose { - project: Some(project), - services, + Self { + kind: ExistingClusterKind::Compose { + project: Some(project), + services, + }, } } #[must_use] + #[doc(hidden)] pub fn compose_project(&self) -> Option<&str> { - match self { - Self::Compose { project, .. } => project.as_deref(), - Self::K8s { .. } => None, + match &self.kind { + ExistingClusterKind::Compose { project, .. } => project.as_deref(), + ExistingClusterKind::K8s { .. } => None, } } #[must_use] + #[doc(hidden)] pub fn compose_services(&self) -> Option<&[String]> { - match self { - Self::Compose { services, .. } => Some(services), - Self::K8s { .. } => None, + match &self.kind { + ExistingClusterKind::Compose { services, .. } => Some(services), + ExistingClusterKind::K8s { .. } => None, } } #[must_use] + #[doc(hidden)] pub fn k8s_namespace(&self) -> Option<&str> { - match self { - Self::K8s { namespace, .. } => namespace.as_deref(), - Self::Compose { .. } => None, + match &self.kind { + ExistingClusterKind::K8s { namespace, .. } => namespace.as_deref(), + ExistingClusterKind::Compose { .. } => None, } } #[must_use] + #[doc(hidden)] pub fn k8s_label_selector(&self) -> Option<&str> { - match self { - Self::K8s { label_selector, .. } => Some(label_selector), - Self::Compose { .. } => None, + match &self.kind { + ExistingClusterKind::K8s { label_selector, .. } => Some(label_selector), + ExistingClusterKind::Compose { .. } => None, } } } -#[doc(hidden)] -pub type AttachSource = ExistingCluster; - /// Static external node endpoint that should be included in the runtime /// inventory. #[derive(Clone, Debug, Eq, PartialEq)] @@ -108,7 +126,7 @@ impl ExternalNodeSource { /// Source model that makes invalid managed+attached combinations /// unrepresentable by type. #[derive(Clone, Debug, Eq, PartialEq)] -pub enum ScenarioSources { +pub(crate) enum ScenarioSources { Managed { external: Vec, }, @@ -131,27 +149,7 @@ impl Default for ScenarioSources { impl ScenarioSources { #[must_use] - pub const fn managed() -> Self { - Self::Managed { - external: Vec::new(), - } - } - - #[must_use] - pub fn attached(attach: ExistingCluster) -> Self { - Self::Attached { - attach, - external: Vec::new(), - } - } - - #[must_use] - pub fn external_only(external: Vec) -> Self { - Self::ExternalOnly { external } - } - - #[must_use] - pub fn with_external_node(mut self, node: ExternalNodeSource) -> Self { + pub(crate) fn with_external_node(mut self, node: ExternalNodeSource) -> Self { match &mut self { Self::Managed { external } | Self::Attached { external, .. } @@ -162,21 +160,21 @@ impl ScenarioSources { } #[must_use] - pub fn with_attach(self, attach: ExistingCluster) -> Self { + pub(crate) fn with_attach(self, attach: ExistingCluster) -> Self { let external = self.external_nodes().to_vec(); Self::Attached { attach, external } } #[must_use] - pub fn into_external_only(self) -> Self { + pub(crate) fn into_external_only(self) -> Self { let external = self.external_nodes().to_vec(); Self::ExternalOnly { external } } #[must_use] - pub fn existing_cluster(&self) -> Option<&AttachSource> { + pub(crate) fn existing_cluster(&self) -> Option<&ExistingCluster> { match self { Self::Attached { attach, .. } => Some(attach), Self::Managed { .. } | Self::ExternalOnly { .. } => None, @@ -184,7 +182,7 @@ impl ScenarioSources { } #[must_use] - pub fn external_nodes(&self) -> &[ExternalNodeSource] { + pub(crate) fn external_nodes(&self) -> &[ExternalNodeSource] { match self { Self::Managed { external } | Self::Attached { external, .. } @@ -193,22 +191,17 @@ impl ScenarioSources { } #[must_use] - pub const fn is_managed(&self) -> bool { + pub(crate) const fn is_managed(&self) -> bool { matches!(self, Self::Managed { .. }) } #[must_use] - pub const fn is_attached(&self) -> bool { + pub(crate) const fn uses_existing_cluster(&self) -> bool { matches!(self, Self::Attached { .. }) } #[must_use] - pub const fn uses_existing_cluster(&self) -> bool { - self.is_attached() - } - - #[must_use] - pub const fn is_external_only(&self) -> bool { + pub(crate) const fn is_external_only(&self) -> bool { matches!(self, Self::ExternalOnly { .. }) } } From fbede7f535da53b57b6f45641e3931a537552f13 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:17:56 +0100 Subject: [PATCH 21/40] Confine backend cluster details to deployer adapters --- .../compose/src/deployer/attach_provider.rs | 10 +-- .../deployers/compose/src/deployer/mod.rs | 16 +++++ .../compose/src/deployer/orchestrator.rs | 61 ++++++++----------- .../deployers/compose/src/docker/control.rs | 18 +++++- .../k8s/src/deployer/attach_provider.rs | 10 +-- .../deployers/k8s/src/deployer/mod.rs | 12 ++++ .../k8s/src/deployer/orchestrator.rs | 28 +++------ 7 files changed, 90 insertions(+), 65 deletions(-) diff --git a/testing-framework/deployers/compose/src/deployer/attach_provider.rs b/testing-framework/deployers/compose/src/deployer/attach_provider.rs index 314ae3d..8874fd1 100644 --- a/testing-framework/deployers/compose/src/deployer/attach_provider.rs +++ b/testing-framework/deployers/compose/src/deployer/attach_provider.rs @@ -42,12 +42,14 @@ impl ComposeAttachProvider { } impl ComposeAttachedClusterWait { - pub(super) fn new(host: String, source: ExistingCluster) -> Self { - Self { + pub(super) fn try_new(host: String, source: &ExistingCluster) -> Result { + let _ = compose_wait_request(source)?; + + Ok(Self { host, - source, + source: source.clone(), _env: PhantomData, - } + }) } } diff --git a/testing-framework/deployers/compose/src/deployer/mod.rs b/testing-framework/deployers/compose/src/deployer/mod.rs index 66859c1..2eab7c6 100644 --- a/testing-framework/deployers/compose/src/deployer/mod.rs +++ b/testing-framework/deployers/compose/src/deployer/mod.rs @@ -36,6 +36,22 @@ enum ComposeMetadataError { } impl ComposeDeploymentMetadata { + #[must_use] + pub fn for_project(project_name: String) -> Self { + Self { + project_name: Some(project_name), + } + } + + #[must_use] + pub fn from_existing_cluster(cluster: Option<&ExistingCluster>) -> Self { + Self { + project_name: cluster + .and_then(ExistingCluster::compose_project) + .map(ToOwned::to_owned), + } + } + /// Returns project name when deployment is bound to a specific compose /// project. #[must_use] diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index d9cf18c..29b58c3 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -3,12 +3,11 @@ use std::{env, sync::Arc, time::Duration}; use reqwest::Url; use testing_framework_core::{ scenario::{ - ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, DeploymentPolicy, - ExistingCluster, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, - NodeControlHandle, ObservabilityCapabilityProvider, ObservabilityInputs, - RequiresNodeControl, RunContext, Runner, Scenario, SourceOrchestrationPlan, - SourceProviders, StaticManagedProvider, build_source_orchestration_plan, - orchestrate_sources_with_providers, + ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, DeploymentPolicy, FeedHandle, + FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, NodeControlHandle, + ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, RunContext, + Runner, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, + build_source_orchestration_plan, orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, }; @@ -219,20 +218,10 @@ impl DeploymentOrchestrator { .ok_or(ComposeRunnerError::InternalInvariant { message: "attached node control requested outside attached source mode", })?; + let node_control = ComposeAttachedNodeControl::try_from_existing_cluster(attach) + .map_err(|source| ComposeRunnerError::SourceOrchestration { source })?; - let Some(project_name) = attach - .compose_project() - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return Err(ComposeRunnerError::InternalInvariant { - message: "attached compose mode requires explicit project name for node control", - }); - }; - - Ok(Some(Arc::new(ComposeAttachedNodeControl { - project_name: project_name.to_owned(), - }) as Arc>)) + Ok(Some(Arc::new(node_control) as Arc>)) } fn attached_cluster_wait( @@ -247,11 +236,10 @@ impl DeploymentOrchestrator { .ok_or(ComposeRunnerError::InternalInvariant { message: "compose attached cluster wait requested outside attached source mode", })?; + let cluster_wait = ComposeAttachedClusterWait::::try_new(compose_runner_host(), attach) + .map_err(|source| ComposeRunnerError::SourceOrchestration { source })?; - Ok(Arc::new(ComposeAttachedClusterWait::::new( - compose_runner_host(), - attach.clone(), - ))) + Ok(Arc::new(cluster_wait)) } async fn build_runner( @@ -268,7 +256,8 @@ impl DeploymentOrchestrator { { let telemetry = observability.telemetry_handle()?; let node_control = self.maybe_node_control::(&prepared.environment); - let cluster_wait = self.managed_cluster_wait(project_name); + let cluster_wait = + self.managed_cluster_wait(ComposeDeploymentMetadata::for_project(project_name))?; log_observability_endpoints(&observability); log_profiling_urls(&deployed.host, &deployed.host_ports); @@ -312,11 +301,18 @@ impl DeploymentOrchestrator { }) } - fn managed_cluster_wait(&self, project_name: String) -> Arc> { - Arc::new(ComposeAttachedClusterWait::::new( - compose_runner_host(), - ExistingCluster::compose_in_project(Vec::new(), project_name), - )) + fn managed_cluster_wait( + &self, + metadata: ComposeDeploymentMetadata, + ) -> Result>, ComposeRunnerError> { + let existing_cluster = metadata + .existing_cluster() + .map_err(|source| ComposeRunnerError::SourceOrchestration { source })?; + let cluster_wait = + ComposeAttachedClusterWait::::try_new(compose_runner_host(), &existing_cluster) + .map_err(|source| ComposeRunnerError::SourceOrchestration { source })?; + + Ok(Arc::new(cluster_wait)) } fn log_deploy_start( @@ -372,12 +368,7 @@ where E: ComposeDeployEnv, Caps: Send + Sync, { - let project_name = scenario - .existing_cluster() - .and_then(|attach| attach.compose_project()) - .map(ToOwned::to_owned); - - ComposeDeploymentMetadata { project_name } + ComposeDeploymentMetadata::from_existing_cluster(scenario.existing_cluster()) } struct DeployedNodes { diff --git a/testing-framework/deployers/compose/src/docker/control.rs b/testing-framework/deployers/compose/src/docker/control.rs index 8e98948..90b8a3d 100644 --- a/testing-framework/deployers/compose/src/docker/control.rs +++ b/testing-framework/deployers/compose/src/docker/control.rs @@ -5,7 +5,7 @@ use std::{ use testing_framework_core::{ adjust_timeout, - scenario::{Application, DynError, NodeControlHandle}, + scenario::{Application, DynError, ExistingCluster, NodeControlHandle}, }; use tokio::{process::Command, time::timeout}; use tracing::info; @@ -160,6 +160,22 @@ pub struct ComposeAttachedNodeControl { pub(crate) project_name: String, } +impl ComposeAttachedNodeControl { + pub fn try_from_existing_cluster(source: &ExistingCluster) -> Result { + let Some(project_name) = source + .compose_project() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err("attached compose node control requires explicit project name".into()); + }; + + Ok(Self { + project_name: project_name.to_owned(), + }) + } +} + #[async_trait::async_trait] impl NodeControlHandle for ComposeAttachedNodeControl { async fn restart_node(&self, name: &str) -> Result<(), DynError> { diff --git a/testing-framework/deployers/k8s/src/deployer/attach_provider.rs b/testing-framework/deployers/k8s/src/deployer/attach_provider.rs index cfa0dc2..43925bc 100644 --- a/testing-framework/deployers/k8s/src/deployer/attach_provider.rs +++ b/testing-framework/deployers/k8s/src/deployer/attach_provider.rs @@ -56,12 +56,14 @@ impl K8sAttachProvider { } impl K8sAttachedClusterWait { - pub(super) fn new(client: Client, source: ExistingCluster) -> Self { - Self { + pub(super) fn try_new(client: Client, source: &ExistingCluster) -> Result { + let _ = k8s_wait_request(source)?; + + Ok(Self { client, - source, + source: source.clone(), _env: PhantomData, - } + }) } } diff --git a/testing-framework/deployers/k8s/src/deployer/mod.rs b/testing-framework/deployers/k8s/src/deployer/mod.rs index eae7507..e20fcc5 100644 --- a/testing-framework/deployers/k8s/src/deployer/mod.rs +++ b/testing-framework/deployers/k8s/src/deployer/mod.rs @@ -22,6 +22,18 @@ enum K8sMetadataError { } impl K8sDeploymentMetadata { + #[must_use] + pub fn from_existing_cluster(cluster: Option<&ExistingCluster>) -> Self { + Self { + namespace: cluster + .and_then(ExistingCluster::k8s_namespace) + .map(ToOwned::to_owned), + label_selector: cluster + .and_then(ExistingCluster::k8s_label_selector) + .map(ToOwned::to_owned), + } + } + /// Returns namespace when deployment is bound to a specific namespace. #[must_use] pub fn namespace(&self) -> Option<&str> { diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index 836a892..5fa21fe 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -249,19 +249,7 @@ where E: K8sDeployEnv, Caps: Send + Sync, { - let namespace = scenario - .existing_cluster() - .and_then(|attach| attach.k8s_namespace()) - .map(ToOwned::to_owned); - let label_selector = scenario - .existing_cluster() - .and_then(|attach| attach.k8s_label_selector()) - .map(ToOwned::to_owned); - - K8sDeploymentMetadata { - namespace, - label_selector, - } + K8sDeploymentMetadata::from_existing_cluster(scenario.existing_cluster()) } fn attached_cluster_wait( @@ -277,11 +265,10 @@ where .ok_or_else(|| K8sRunnerError::InternalInvariant { message: "k8s attached cluster wait requested outside attached source mode".to_owned(), })?; + let cluster_wait = K8sAttachedClusterWait::::try_new(client, attach) + .map_err(|source| K8sRunnerError::SourceOrchestration { source })?; - Ok(Arc::new(K8sAttachedClusterWait::::new( - client, - attach.clone(), - ))) + Ok(Arc::new(cluster_wait)) } fn managed_cluster_wait( @@ -292,11 +279,10 @@ fn managed_cluster_wait( let attach_source = metadata .existing_cluster() .map_err(|source| K8sRunnerError::SourceOrchestration { source })?; + let cluster_wait = K8sAttachedClusterWait::::try_new(client, &attach_source) + .map_err(|source| K8sRunnerError::SourceOrchestration { source })?; - Ok(Arc::new(K8sAttachedClusterWait::::new( - client, - attach_source, - ))) + Ok(Arc::new(cluster_wait)) } fn client_from_cluster(cluster: &Option) -> Result { From ad288e742162959ea440ba5a794695b933f6505e Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:22:09 +0100 Subject: [PATCH 22/40] Add semantic existing-cluster constructors --- .../orchestration/source_orchestration_plan.rs | 7 +++++-- .../core/src/scenario/sources/model.rs | 16 ++++++---------- .../deployers/compose/src/deployer/mod.rs | 7 +++---- .../deployers/k8s/src/deployer/mod.rs | 4 ++-- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs index c9c99f9..2c3cb4e 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs @@ -73,8 +73,11 @@ mod tests { #[test] fn attached_sources_are_planned() { - let sources = ScenarioSources::default() - .with_attach(ExistingCluster::compose(vec!["node-0".to_string()])); + let sources = + ScenarioSources::default().with_attach(ExistingCluster::for_compose_services( + "test-project".to_string(), + vec!["node-0".to_string()], + )); let plan = SourceOrchestrationPlan::try_from_sources(&sources) .expect("attached sources should build a source orchestration plan"); diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 566e2ee..a64b010 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -18,8 +18,7 @@ enum ExistingClusterKind { impl ExistingCluster { #[must_use] - #[doc(hidden)] - pub fn k8s(label_selector: String) -> Self { + pub fn for_k8s_selector(label_selector: String) -> Self { Self { kind: ExistingClusterKind::K8s { namespace: None, @@ -29,8 +28,7 @@ impl ExistingCluster { } #[must_use] - #[doc(hidden)] - pub fn k8s_in_namespace(label_selector: String, namespace: String) -> Self { + pub fn for_k8s_selector_in_namespace(namespace: String, label_selector: String) -> Self { Self { kind: ExistingClusterKind::K8s { namespace: Some(namespace), @@ -40,19 +38,17 @@ impl ExistingCluster { } #[must_use] - #[doc(hidden)] - pub fn compose(services: Vec) -> Self { + pub fn for_compose_project(project: String) -> Self { Self { kind: ExistingClusterKind::Compose { - project: None, - services, + project: Some(project), + services: Vec::new(), }, } } #[must_use] - #[doc(hidden)] - pub fn compose_in_project(services: Vec, project: String) -> Self { + pub fn for_compose_services(project: String, services: Vec) -> Self { Self { kind: ExistingClusterKind::Compose { project: Some(project), diff --git a/testing-framework/deployers/compose/src/deployer/mod.rs b/testing-framework/deployers/compose/src/deployer/mod.rs index 2eab7c6..a0c35d5 100644 --- a/testing-framework/deployers/compose/src/deployer/mod.rs +++ b/testing-framework/deployers/compose/src/deployer/mod.rs @@ -66,8 +66,7 @@ impl ComposeDeploymentMetadata { .project_name() .ok_or(ComposeMetadataError::MissingProjectName)?; - Ok(ExistingCluster::compose_in_project( - Vec::new(), + Ok(ExistingCluster::for_compose_project( project_name.to_owned(), )) } @@ -81,9 +80,9 @@ impl ComposeDeploymentMetadata { .project_name() .ok_or(ComposeMetadataError::MissingProjectName)?; - Ok(ExistingCluster::compose_in_project( - services, + Ok(ExistingCluster::for_compose_services( project_name.to_owned(), + services, )) } diff --git a/testing-framework/deployers/k8s/src/deployer/mod.rs b/testing-framework/deployers/k8s/src/deployer/mod.rs index e20fcc5..d97615b 100644 --- a/testing-framework/deployers/k8s/src/deployer/mod.rs +++ b/testing-framework/deployers/k8s/src/deployer/mod.rs @@ -53,9 +53,9 @@ impl K8sDeploymentMetadata { .label_selector() .ok_or(K8sMetadataError::MissingLabelSelector)?; - Ok(ExistingCluster::k8s_in_namespace( - label_selector.to_owned(), + Ok(ExistingCluster::for_k8s_selector_in_namespace( namespace.to_owned(), + label_selector.to_owned(), )) } From 120b8879a472c26ec148c99f4a130df638b7103b Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:27:09 +0100 Subject: [PATCH 23/40] Move runtime assembly out of runner and context --- testing-framework/core/src/scenario/mod.rs | 2 +- .../core/src/scenario/runtime/context.rs | 102 +++++++++++++++--- .../core/src/scenario/runtime/mod.rs | 2 +- .../core/src/scenario/runtime/runner.rs | 7 +- .../compose/src/deployer/orchestrator.rs | 19 ++-- .../k8s/src/deployer/orchestrator.rs | 13 ++- .../local/src/deployer/orchestrator.rs | 19 ++-- 7 files changed, 122 insertions(+), 42 deletions(-) diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index ca358a5..e0486dc 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -39,7 +39,7 @@ pub use observability::{ObservabilityCapabilityProvider, ObservabilityInputs}; pub use runtime::{ ApplicationExternalProvider, AttachProvider, AttachProviderError, AttachedNode, CleanupGuard, Deployer, Feed, FeedHandle, FeedRuntime, HttpReadinessRequirement, ManagedSource, NodeClients, - ReadinessError, RunContext, RunHandle, RunMetrics, Runner, ScenarioError, + ReadinessError, RunContext, RunHandle, RunMetrics, Runner, RuntimeAssembly, ScenarioError, SourceOrchestrationPlan, SourceProviders, StabilizationConfig, StaticManagedProvider, build_source_orchestration_plan, metrics::{ diff --git a/testing-framework/core/src/scenario/runtime/context.rs b/testing-framework/core/src/scenario/runtime/context.rs index 9e40486..cce8c05 100644 --- a/testing-framework/core/src/scenario/runtime/context.rs +++ b/testing-framework/core/src/scenario/runtime/context.rs @@ -21,11 +21,23 @@ pub struct RunContext { cluster_wait: Option>>, } +/// Low-level runtime assembly input used by deployers to build a runnable +/// cluster context. +pub struct RuntimeAssembly { + descriptors: E::Deployment, + node_clients: NodeClients, + run_duration: Duration, + expectation_cooldown: Duration, + telemetry: Metrics, + feed: ::Feed, + node_control: Option>>, + cluster_wait: Option>>, +} + impl RunContext { /// Builds a run context from prepared deployment/runtime artifacts. #[must_use] - #[doc(hidden)] - pub fn new( + pub(crate) fn new( descriptors: E::Deployment, node_clients: NodeClients, run_duration: Duration, @@ -49,8 +61,7 @@ impl RunContext { } #[must_use] - #[doc(hidden)] - pub fn with_cluster_wait(mut self, cluster_wait: Arc>) -> Self { + pub(crate) fn with_cluster_wait(mut self, cluster_wait: Arc>) -> Self { self.cluster_wait = Some(cluster_wait); self } @@ -122,6 +133,79 @@ impl RunContext { } } +impl RuntimeAssembly { + #[must_use] + pub fn new( + descriptors: E::Deployment, + node_clients: NodeClients, + run_duration: Duration, + expectation_cooldown: Duration, + telemetry: Metrics, + feed: ::Feed, + ) -> Self { + Self { + descriptors, + node_clients, + run_duration, + expectation_cooldown, + telemetry, + feed, + node_control: None, + cluster_wait: None, + } + } + + #[must_use] + pub fn with_node_control(mut self, node_control: Arc>) -> Self { + self.node_control = Some(node_control); + self + } + + #[must_use] + pub fn with_cluster_wait(mut self, cluster_wait: Arc>) -> Self { + self.cluster_wait = Some(cluster_wait); + self + } + + #[must_use] + pub fn build_context(self) -> RunContext { + let context = RunContext::new( + self.descriptors, + self.node_clients, + self.run_duration, + self.expectation_cooldown, + self.telemetry, + self.feed, + self.node_control, + ); + + match self.cluster_wait { + Some(cluster_wait) => context.with_cluster_wait(cluster_wait), + None => context, + } + } + + #[must_use] + pub fn build_runner(self, cleanup_guard: Option>) -> super::Runner { + super::Runner::new(self.build_context(), cleanup_guard) + } +} + +impl From> for RuntimeAssembly { + fn from(context: RunContext) -> Self { + Self { + descriptors: context.descriptors, + node_clients: context.node_clients, + run_duration: context.metrics.run_duration(), + expectation_cooldown: context.expectation_cooldown, + telemetry: context.telemetry, + feed: context.feed, + node_control: context.node_control, + cluster_wait: context.cluster_wait, + } + } +} + /// Handle returned by the runner to control the lifecycle of the run. pub struct RunHandle { run_context: Arc>, @@ -137,16 +221,6 @@ impl Drop for RunHandle { } impl RunHandle { - #[must_use] - /// Build a handle from owned context and optional cleanup guard. - #[doc(hidden)] - pub fn new(context: RunContext, cleanup_guard: Option>) -> Self { - Self { - run_context: Arc::new(context), - cleanup_guard, - } - } - #[must_use] /// Build a handle from a shared context reference. pub(crate) fn from_shared( diff --git a/testing-framework/core/src/scenario/runtime/mod.rs b/testing-framework/core/src/scenario/runtime/mod.rs index 5682ccc..33420c3 100644 --- a/testing-framework/core/src/scenario/runtime/mod.rs +++ b/testing-framework/core/src/scenario/runtime/mod.rs @@ -9,7 +9,7 @@ pub mod readiness; mod runner; use async_trait::async_trait; -pub use context::{CleanupGuard, RunContext, RunHandle, RunMetrics}; +pub use context::{CleanupGuard, RunContext, RunHandle, RunMetrics, RuntimeAssembly}; pub use deployer::{Deployer, ScenarioError}; pub use node_clients::NodeClients; #[doc(hidden)] diff --git a/testing-framework/core/src/scenario/runtime/runner.rs b/testing-framework/core/src/scenario/runtime/runner.rs index d9e3f5e..d9bf2b3 100644 --- a/testing-framework/core/src/scenario/runtime/runner.rs +++ b/testing-framework/core/src/scenario/runtime/runner.rs @@ -34,10 +34,11 @@ impl Drop for Runner { } impl Runner { - /// Construct a runner from the run context and optional cleanup guard. #[must_use] - #[doc(hidden)] - pub fn new(context: RunContext, cleanup_guard: Option>) -> Self { + pub(crate) fn new( + context: RunContext, + cleanup_guard: Option>, + ) -> Self { Self { context: Arc::new(context), cleanup_guard, diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index 29b58c3..e8ae1eb 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -6,8 +6,8 @@ use testing_framework_core::{ ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, DeploymentPolicy, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, NodeControlHandle, ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, RunContext, - Runner, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, - build_source_orchestration_plan, orchestrate_sources_with_providers, + Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, + StaticManagedProvider, build_source_orchestration_plan, orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, }; @@ -169,7 +169,7 @@ impl DeploymentOrchestrator { ); let cleanup_guard: Box = Box::new(feed_task); - Ok(Runner::new(context, Some(cleanup_guard))) + Ok(RuntimeAssembly::from(context).build_runner(Some(cleanup_guard))) } fn source_providers(&self, managed_clients: Vec) -> SourceProviders { @@ -283,7 +283,7 @@ impl DeploymentOrchestrator { "compose runtime prepared" ); - Ok(Runner::new(runtime.context, Some(cleanup_guard))) + Ok(RuntimeAssembly::from(runtime.context).build_runner(Some(cleanup_guard))) } fn maybe_node_control( @@ -462,16 +462,21 @@ fn build_run_context( node_control: Option>>, cluster_wait: Arc>, ) -> RunContext { - RunContext::new( + let mut assembly = RuntimeAssembly::new( descriptors, node_clients, run_duration, expectation_cooldown, telemetry, feed, - node_control, ) - .with_cluster_wait(cluster_wait) + .with_cluster_wait(cluster_wait); + + if let Some(node_control) = node_control { + assembly = assembly.with_node_control(node_control); + } + + assembly.build_context() } fn resolve_observability_inputs( diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index 5fa21fe..23b9bc8 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -8,7 +8,7 @@ use testing_framework_core::{ Application, ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, Deployer, DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, MetricsError, NodeClients, ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, - RunContext, Runner, Scenario, SourceOrchestrationPlan, SourceProviders, + RunContext, Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, build_source_orchestration_plan, orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, @@ -230,18 +230,17 @@ where let telemetry = observability.telemetry_handle()?; let (feed, feed_task) = spawn_block_feed_with::(&node_clients).await?; let cluster_wait = attached_cluster_wait::(scenario, client)?; - let context = RunContext::new( + let context = RuntimeAssembly::new( scenario.deployment().clone(), node_clients, scenario.duration(), scenario.expectation_cooldown(), telemetry, feed, - None, ) .with_cluster_wait(cluster_wait); - Ok(Runner::new(context, Some(Box::new(feed_task)))) + Ok(context.build_runner(Some(Box::new(feed_task)))) } fn attached_metadata(scenario: &Scenario) -> K8sDeploymentMetadata @@ -642,7 +641,7 @@ fn finalize_runner( duration_secs, "k8s deployment ready; handing control to scenario runner" ); - Ok(Runner::new(context, Some(cleanup_guard))) + Ok(RuntimeAssembly::from(context).build_runner(Some(cleanup_guard))) } fn take_ready_cluster( @@ -664,16 +663,16 @@ fn build_k8s_run_context( feed: Feed, cluster_wait: Arc>, ) -> RunContext { - RunContext::new( + RuntimeAssembly::new( descriptors, node_clients, duration, expectation_cooldown, telemetry, feed, - None, ) .with_cluster_wait(cluster_wait) + .build_context() } fn endpoint_or_disabled(endpoint: Option<&Url>) -> String { diff --git a/testing-framework/deployers/local/src/deployer/orchestrator.rs b/testing-framework/deployers/local/src/deployer/orchestrator.rs index 28cc01f..bf93859 100644 --- a/testing-framework/deployers/local/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/local/src/deployer/orchestrator.rs @@ -12,8 +12,8 @@ use testing_framework_core::{ scenario::{ Application, CleanupGuard, Deployer, DeploymentPolicy, DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, NodeControlCapability, NodeControlHandle, - RetryPolicy, RunContext, Runner, Scenario, ScenarioError, SourceOrchestrationPlan, - build_source_orchestration_plan, spawn_feed, + RetryPolicy, RunContext, Runner, RuntimeAssembly, Scenario, ScenarioError, + SourceOrchestrationPlan, build_source_orchestration_plan, spawn_feed, }, topology::DeploymentDescriptor, }; @@ -218,7 +218,7 @@ impl ProcessDeployer { let cleanup_guard: Box = Box::new(LocalProcessGuard::::new(nodes, runtime.feed_task)); - Ok(Runner::new(runtime.context, Some(cleanup_guard))) + Ok(RuntimeAssembly::from(runtime.context).build_runner(Some(cleanup_guard))) } async fn deploy_with_node_control( @@ -252,10 +252,7 @@ impl ProcessDeployer { ) .await?; - Ok(Runner::new( - runtime.context, - Some(Box::new(runtime.feed_task)), - )) + Ok(RuntimeAssembly::from(runtime.context).build_runner(Some(Box::new(runtime.feed_task)))) } fn node_control_from( @@ -491,15 +488,19 @@ async fn run_context_for( } let (feed, feed_task) = spawn_feed_with::(&node_clients).await?; - let context = RunContext::new( + let mut assembly = RuntimeAssembly::new( descriptors, node_clients, duration, expectation_cooldown, Metrics::empty(), feed, - node_control, ); + if let Some(node_control) = node_control { + assembly = assembly.with_node_control(node_control); + } + + let context = assembly.build_context(); Ok(RuntimeContext { context, feed_task }) } From 8e6604d23215d64e356df2a307003085c3adb2fb Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:30:22 +0100 Subject: [PATCH 24/40] Hide deployer assembly exports from scenario surface --- testing-framework/core/src/scenario/mod.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index e0486dc..9f927da 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -36,18 +36,21 @@ pub use definition::{Scenario, ScenarioBuildError, ScenarioBuilder}; pub use deployment_policy::{CleanupPolicy, DeploymentPolicy, RetryPolicy}; pub use expectation::Expectation; pub use observability::{ObservabilityCapabilityProvider, ObservabilityInputs}; +#[doc(hidden)] pub use runtime::{ ApplicationExternalProvider, AttachProvider, AttachProviderError, AttachedNode, CleanupGuard, - Deployer, Feed, FeedHandle, FeedRuntime, HttpReadinessRequirement, ManagedSource, NodeClients, - ReadinessError, RunContext, RunHandle, RunMetrics, Runner, RuntimeAssembly, ScenarioError, - SourceOrchestrationPlan, SourceProviders, StabilizationConfig, StaticManagedProvider, - build_source_orchestration_plan, + FeedHandle, ManagedSource, RuntimeAssembly, SourceOrchestrationPlan, SourceProviders, + StaticManagedProvider, build_source_orchestration_plan, orchestrate_sources, + orchestrate_sources_with_providers, resolve_sources, +}; +pub use runtime::{ + Deployer, Feed, FeedRuntime, HttpReadinessRequirement, NodeClients, ReadinessError, RunContext, + RunHandle, RunMetrics, Runner, ScenarioError, StabilizationConfig, metrics::{ CONSENSUS_PROCESSED_BLOCKS, CONSENSUS_TRANSACTIONS_TOTAL, Metrics, MetricsError, PrometheusEndpoint, PrometheusInstantSample, }, - orchestrate_sources, orchestrate_sources_with_providers, resolve_sources, spawn_feed, - wait_for_http_ports, wait_for_http_ports_with_host, + spawn_feed, wait_for_http_ports, wait_for_http_ports_with_host, wait_for_http_ports_with_host_and_requirement, wait_for_http_ports_with_requirement, wait_http_readiness, wait_until_stable, }; From a14d616ee605c5dcda634af78fd7ab5d8899c954 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:32:52 +0100 Subject: [PATCH 25/40] Trim runner-only state from run context API --- testing-framework/core/src/scenario/runtime/context.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/testing-framework/core/src/scenario/runtime/context.rs b/testing-framework/core/src/scenario/runtime/context.rs index cce8c05..6f6faf6 100644 --- a/testing-framework/core/src/scenario/runtime/context.rs +++ b/testing-framework/core/src/scenario/runtime/context.rs @@ -97,22 +97,17 @@ impl RunContext { } #[must_use] - pub const fn expectation_cooldown(&self) -> Duration { + pub(crate) const fn expectation_cooldown(&self) -> Duration { self.expectation_cooldown } - #[must_use] - pub const fn run_metrics(&self) -> RunMetrics { - self.metrics - } - #[must_use] pub fn node_control(&self) -> Option>> { self.node_control.clone() } #[must_use] - pub const fn controls_nodes(&self) -> bool { + pub(crate) const fn controls_nodes(&self) -> bool { self.node_control.is_some() } From 19a0c904c197e4a17e10f0a1b709f5564fb0aa1e Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:37:31 +0100 Subject: [PATCH 26/40] Use runtime assembly directly in deployers --- .../compose/src/deployer/orchestrator.rs | 27 +++++---- .../k8s/src/deployer/orchestrator.rs | 60 +++++++------------ .../local/src/deployer/orchestrator.rs | 19 +++--- 3 files changed, 47 insertions(+), 59 deletions(-) diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index e8ae1eb..3025eaf 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -5,9 +5,9 @@ use testing_framework_core::{ scenario::{ ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, DeploymentPolicy, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, NodeControlHandle, - ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, RunContext, - Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, - StaticManagedProvider, build_source_orchestration_plan, orchestrate_sources_with_providers, + ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, Runner, + RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, + build_source_orchestration_plan, orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, }; @@ -157,7 +157,7 @@ impl DeploymentOrchestrator { let node_control = self.attached_node_control::(scenario)?; let cluster_wait = self.attached_cluster_wait(scenario)?; let (feed, feed_task) = spawn_block_feed_with_retry::(&node_clients).await?; - let context = build_run_context( + let assembly = build_runtime_assembly( scenario.deployment().clone(), node_clients, scenario.duration(), @@ -169,7 +169,7 @@ impl DeploymentOrchestrator { ); let cleanup_guard: Box = Box::new(feed_task); - Ok(RuntimeAssembly::from(context).build_runner(Some(cleanup_guard))) + Ok(assembly.build_runner(Some(cleanup_guard))) } fn source_providers(&self, managed_clients: Vec) -> SourceProviders { @@ -283,7 +283,7 @@ impl DeploymentOrchestrator { "compose runtime prepared" ); - Ok(RuntimeAssembly::from(runtime.context).build_runner(Some(cleanup_guard))) + Ok(runtime.assembly.build_runner(Some(cleanup_guard))) } fn maybe_node_control( @@ -379,7 +379,7 @@ struct DeployedNodes { } struct ComposeRuntime { - context: RunContext, + assembly: RuntimeAssembly, feed_task: FeedHandle, } @@ -408,7 +408,7 @@ async fn build_compose_runtime( .start_block_feed(&node_clients, input.environment) .await?; - let context = build_run_context( + let assembly = build_runtime_assembly( input.descriptors, node_clients, input.duration, @@ -419,7 +419,10 @@ async fn build_compose_runtime( input.cluster_wait, ); - Ok(ComposeRuntime { context, feed_task }) + Ok(ComposeRuntime { + assembly, + feed_task, + }) } async fn deploy_nodes( @@ -452,7 +455,7 @@ async fn deploy_nodes( }) } -fn build_run_context( +fn build_runtime_assembly( descriptors: E::Deployment, node_clients: NodeClients, run_duration: Duration, @@ -461,7 +464,7 @@ fn build_run_context( feed: ::Feed, node_control: Option>>, cluster_wait: Arc>, -) -> RunContext { +) -> RuntimeAssembly { let mut assembly = RuntimeAssembly::new( descriptors, node_clients, @@ -476,7 +479,7 @@ fn build_run_context( assembly = assembly.with_node_control(node_control); } - assembly.build_context() + assembly } fn resolve_observability_inputs( diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index 23b9bc8..f685a7d 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -8,7 +8,7 @@ use testing_framework_core::{ Application, ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, Deployer, DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, MetricsError, NodeClients, ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, - RunContext, Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, + Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, build_source_orchestration_plan, orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, @@ -204,10 +204,10 @@ where runtime.node_clients = resolve_node_clients(&source_plan, source_providers).await?; ensure_non_empty_node_clients(&runtime.node_clients)?; - let parts = build_runner_parts(scenario, deployment.node_count, runtime, cluster_wait); - log_configured_observability(&observability); - maybe_print_endpoints::(&observability, &parts.node_clients); + maybe_print_endpoints::(&observability, &runtime.node_clients); + + let parts = build_runner_parts(scenario, deployment.node_count, runtime, cluster_wait); let runner = finalize_runner::(&mut cluster, parts)?; Ok((runner, metadata)) } @@ -497,15 +497,18 @@ fn build_runner_parts( cluster_wait: Arc>, ) -> K8sRunnerParts { K8sRunnerParts { - descriptors: scenario.deployment().clone(), - node_clients: runtime.node_clients, - duration: scenario.duration(), - expectation_cooldown: scenario.expectation_cooldown(), - telemetry: runtime.telemetry, - feed: runtime.feed, + assembly: build_k8s_runtime_assembly( + scenario.deployment().clone(), + runtime.node_clients, + scenario.duration(), + scenario.expectation_cooldown(), + runtime.telemetry, + runtime.feed, + cluster_wait, + ), feed_task: runtime.feed_task, node_count, - cluster_wait, + duration_secs: scenario.duration().as_secs(), } } @@ -593,15 +596,10 @@ fn maybe_print_endpoints( } struct K8sRunnerParts { - descriptors: E::Deployment, - node_clients: NodeClients, - duration: Duration, - expectation_cooldown: Duration, - telemetry: Metrics, - feed: Feed, + assembly: RuntimeAssembly, feed_task: FeedHandle, node_count: usize, - cluster_wait: Arc>, + duration_secs: u64, } fn finalize_runner( @@ -612,36 +610,21 @@ fn finalize_runner( let (cleanup, port_forwards) = environment.into_cleanup()?; let K8sRunnerParts { - descriptors, - node_clients, - duration, - expectation_cooldown, - telemetry, - feed, + assembly, feed_task, node_count, - cluster_wait, + duration_secs, } = parts; - let duration_secs = duration.as_secs(); let cleanup_guard: Box = Box::new(K8sCleanupGuard::new(cleanup, feed_task, port_forwards)); - let context = build_k8s_run_context( - descriptors, - node_clients, - duration, - expectation_cooldown, - telemetry, - feed, - cluster_wait, - ); info!( nodes = node_count, duration_secs, "k8s deployment ready; handing control to scenario runner" ); - Ok(RuntimeAssembly::from(context).build_runner(Some(cleanup_guard))) + Ok(assembly.build_runner(Some(cleanup_guard))) } fn take_ready_cluster( @@ -654,7 +637,7 @@ fn take_ready_cluster( }) } -fn build_k8s_run_context( +fn build_k8s_runtime_assembly( descriptors: E::Deployment, node_clients: NodeClients, duration: Duration, @@ -662,7 +645,7 @@ fn build_k8s_run_context( telemetry: Metrics, feed: Feed, cluster_wait: Arc>, -) -> RunContext { +) -> RuntimeAssembly { RuntimeAssembly::new( descriptors, node_clients, @@ -672,7 +655,6 @@ fn build_k8s_run_context( feed, ) .with_cluster_wait(cluster_wait) - .build_context() } fn endpoint_or_disabled(endpoint: Option<&Url>) -> String { diff --git a/testing-framework/deployers/local/src/deployer/orchestrator.rs b/testing-framework/deployers/local/src/deployer/orchestrator.rs index bf93859..f931e8b 100644 --- a/testing-framework/deployers/local/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/local/src/deployer/orchestrator.rs @@ -12,8 +12,8 @@ use testing_framework_core::{ scenario::{ Application, CleanupGuard, Deployer, DeploymentPolicy, DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, NodeControlCapability, NodeControlHandle, - RetryPolicy, RunContext, Runner, RuntimeAssembly, Scenario, ScenarioError, - SourceOrchestrationPlan, build_source_orchestration_plan, spawn_feed, + RetryPolicy, Runner, RuntimeAssembly, Scenario, ScenarioError, SourceOrchestrationPlan, + build_source_orchestration_plan, spawn_feed, }, topology::DeploymentDescriptor, }; @@ -218,7 +218,7 @@ impl ProcessDeployer { let cleanup_guard: Box = Box::new(LocalProcessGuard::::new(nodes, runtime.feed_task)); - Ok(RuntimeAssembly::from(runtime.context).build_runner(Some(cleanup_guard))) + Ok(runtime.assembly.build_runner(Some(cleanup_guard))) } async fn deploy_with_node_control( @@ -252,7 +252,9 @@ impl ProcessDeployer { ) .await?; - Ok(RuntimeAssembly::from(runtime.context).build_runner(Some(Box::new(runtime.feed_task)))) + Ok(runtime + .assembly + .build_runner(Some(Box::new(runtime.feed_task)))) } fn node_control_from( @@ -472,7 +474,7 @@ fn log_local_deploy_start(node_count: usize, policy: DeploymentPolicy, has_node_ } struct RuntimeContext { - context: RunContext, + assembly: RuntimeAssembly, feed_task: FeedHandle, } @@ -500,7 +502,8 @@ async fn run_context_for( assembly = assembly.with_node_control(node_control); } - let context = assembly.build_context(); - - Ok(RuntimeContext { context, feed_task }) + Ok(RuntimeContext { + assembly, + feed_task, + }) } From 0911818626d458b664916deadca36cec76a0d196 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:41:28 +0100 Subject: [PATCH 27/40] Name scenario cluster modes explicitly --- .../core/src/scenario/definition.rs | 15 +++++--- testing-framework/core/src/scenario/mod.rs | 2 +- .../source_orchestration_plan.rs | 35 +++++++++++++------ .../core/src/scenario/sources/mod.rs | 2 +- .../core/src/scenario/sources/model.rs | 26 +++++++------- .../compose/src/deployer/orchestrator.rs | 13 +++---- .../k8s/src/deployer/orchestrator.rs | 13 +++---- 7 files changed, 65 insertions(+), 41 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 0eea6f2..c423fb5 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -4,7 +4,7 @@ use thiserror::Error; use tracing::{debug, info}; use super::{ - Application, DeploymentPolicy, DynError, ExistingCluster, ExternalNodeSource, + Application, ClusterMode, DeploymentPolicy, DynError, ExistingCluster, ExternalNodeSource, HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, builder_ops::CoreBuilderAccess, expectation::Expectation, @@ -119,8 +119,8 @@ impl Scenario { } #[must_use] - pub const fn uses_existing_cluster(&self) -> bool { - self.sources.uses_existing_cluster() + pub const fn cluster_mode(&self) -> ClusterMode { + self.sources.cluster_mode() } #[must_use] @@ -134,14 +134,19 @@ impl Scenario { self.sources.external_nodes() } + #[must_use] + pub const fn uses_existing_cluster(&self) -> bool { + matches!(self.cluster_mode(), ClusterMode::ExistingCluster) + } + #[must_use] pub const fn is_managed(&self) -> bool { - self.sources.is_managed() + matches!(self.cluster_mode(), ClusterMode::Managed) } #[must_use] pub const fn is_external_only(&self) -> bool { - self.sources.is_external_only() + matches!(self.cluster_mode(), ClusterMode::ExternalOnly) } #[must_use] diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index 9f927da..431ee7c 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -54,7 +54,7 @@ pub use runtime::{ wait_for_http_ports_with_host_and_requirement, wait_for_http_ports_with_requirement, wait_http_readiness, wait_until_stable, }; -pub use sources::{ExistingCluster, ExternalNodeSource}; +pub use sources::{ClusterMode, ExistingCluster, ExternalNodeSource}; pub use workload::Workload; pub use crate::env::Application; diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs index 2c3cb4e..35bb510 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs @@ -1,4 +1,4 @@ -use crate::scenario::{ExistingCluster, ExternalNodeSource, sources::ScenarioSources}; +use crate::scenario::{ClusterMode, ExistingCluster, ExternalNodeSource, sources::ScenarioSources}; /// Explicit descriptor for managed node sourcing. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -89,17 +89,32 @@ mod tests { } fn mode_from_sources(sources: &ScenarioSources) -> SourceOrchestrationMode { - match sources { - ScenarioSources::Managed { external } => SourceOrchestrationMode::Managed { - managed: ManagedSource::DeployerManaged, - external: external.clone(), + match sources.cluster_mode() { + ClusterMode::Managed => match sources { + ScenarioSources::Managed { external } => SourceOrchestrationMode::Managed { + managed: ManagedSource::DeployerManaged, + external: external.clone(), + }, + ScenarioSources::Attached { .. } | ScenarioSources::ExternalOnly { .. } => { + unreachable!("cluster mode and source storage must stay aligned") + } }, - ScenarioSources::Attached { attach, external } => SourceOrchestrationMode::Attached { - attach: attach.clone(), - external: external.clone(), + ClusterMode::ExistingCluster => match sources { + ScenarioSources::Attached { attach, external } => SourceOrchestrationMode::Attached { + attach: attach.clone(), + external: external.clone(), + }, + ScenarioSources::Managed { .. } | ScenarioSources::ExternalOnly { .. } => { + unreachable!("cluster mode and source storage must stay aligned") + } }, - ScenarioSources::ExternalOnly { external } => SourceOrchestrationMode::ExternalOnly { - external: external.clone(), + ClusterMode::ExternalOnly => match sources { + ScenarioSources::ExternalOnly { external } => SourceOrchestrationMode::ExternalOnly { + external: external.clone(), + }, + ScenarioSources::Managed { .. } | ScenarioSources::Attached { .. } => { + unreachable!("cluster mode and source storage must stay aligned") + } }, } } diff --git a/testing-framework/core/src/scenario/sources/mod.rs b/testing-framework/core/src/scenario/sources/mod.rs index 506a8aa..7f63800 100644 --- a/testing-framework/core/src/scenario/sources/mod.rs +++ b/testing-framework/core/src/scenario/sources/mod.rs @@ -2,4 +2,4 @@ mod model; pub(crate) use model::ScenarioSources; #[doc(hidden)] -pub use model::{ExistingCluster, ExternalNodeSource}; +pub use model::{ClusterMode, ExistingCluster, ExternalNodeSource}; diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index a64b010..63c6c1b 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -119,6 +119,14 @@ impl ExternalNodeSource { } } +/// High-level source mode of a scenario cluster. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClusterMode { + Managed, + ExistingCluster, + ExternalOnly, +} + /// Source model that makes invalid managed+attached combinations /// unrepresentable by type. #[derive(Clone, Debug, Eq, PartialEq)] @@ -187,17 +195,11 @@ impl ScenarioSources { } #[must_use] - pub(crate) const fn is_managed(&self) -> bool { - matches!(self, Self::Managed { .. }) - } - - #[must_use] - pub(crate) const fn uses_existing_cluster(&self) -> bool { - matches!(self, Self::Attached { .. }) - } - - #[must_use] - pub(crate) const fn is_external_only(&self) -> bool { - matches!(self, Self::ExternalOnly { .. }) + pub(crate) const fn cluster_mode(&self) -> ClusterMode { + match self { + Self::Managed { .. } => ClusterMode::Managed, + Self::Attached { .. } => ClusterMode::ExistingCluster, + Self::ExternalOnly { .. } => ClusterMode::ExternalOnly, + } } } diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index 3025eaf..293dd55 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -3,11 +3,12 @@ use std::{env, sync::Arc, time::Duration}; use reqwest::Url; use testing_framework_core::{ scenario::{ - ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, DeploymentPolicy, FeedHandle, - FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, NodeControlHandle, - ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, Runner, - RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, - build_source_orchestration_plan, orchestrate_sources_with_providers, + ApplicationExternalProvider, CleanupGuard, ClusterMode, ClusterWaitHandle, + DeploymentPolicy, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, + NodeControlHandle, ObservabilityCapabilityProvider, ObservabilityInputs, + RequiresNodeControl, Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, + SourceProviders, StaticManagedProvider, build_source_orchestration_plan, + orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, }; @@ -70,7 +71,7 @@ impl DeploymentOrchestrator { } })?; - if scenario.uses_existing_cluster() { + if matches!(scenario.cluster_mode(), ClusterMode::ExistingCluster) { return self .deploy_attached_only::(scenario, source_plan) .await diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index f685a7d..2b177fe 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -5,11 +5,12 @@ use kube::Client; use reqwest::Url; use testing_framework_core::{ scenario::{ - Application, ApplicationExternalProvider, CleanupGuard, ClusterWaitHandle, Deployer, - DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, MetricsError, - NodeClients, ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, - Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, - StaticManagedProvider, build_source_orchestration_plan, orchestrate_sources_with_providers, + Application, ApplicationExternalProvider, CleanupGuard, ClusterMode, ClusterWaitHandle, + Deployer, DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, + MetricsError, NodeClients, ObservabilityCapabilityProvider, ObservabilityInputs, + RequiresNodeControl, Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, + SourceProviders, StaticManagedProvider, build_source_orchestration_plan, + orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, }; @@ -179,7 +180,7 @@ where let observability = resolve_observability_inputs(scenario.capabilities())?; - if scenario.uses_existing_cluster() { + if matches!(scenario.cluster_mode(), ClusterMode::ExistingCluster) { let runner = deploy_attached_only::(scenario, source_plan, observability).await?; return Ok((runner, attached_metadata(scenario))); } From 4c6aea13589cc2fa323a26e0a62053cc7021c9e3 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:42:27 +0100 Subject: [PATCH 28/40] Drop redundant scenario mode booleans --- testing-framework/core/src/scenario/definition.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index c423fb5..4bc70d5 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -134,21 +134,6 @@ impl Scenario { self.sources.external_nodes() } - #[must_use] - pub const fn uses_existing_cluster(&self) -> bool { - matches!(self.cluster_mode(), ClusterMode::ExistingCluster) - } - - #[must_use] - pub const fn is_managed(&self) -> bool { - matches!(self.cluster_mode(), ClusterMode::Managed) - } - - #[must_use] - pub const fn is_external_only(&self) -> bool { - matches!(self.cluster_mode(), ClusterMode::ExternalOnly) - } - #[must_use] pub fn has_external_nodes(&self) -> bool { !self.sources.external_nodes().is_empty() From cf1e6185fa4b427ec765c1e0360844ca573755a0 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:44:58 +0100 Subject: [PATCH 29/40] Add shared cluster control profile semantics --- testing-framework/core/src/runtime/manual.rs | 8 ++- .../core/src/scenario/definition.rs | 9 ++- testing-framework/core/src/scenario/mod.rs | 2 +- .../core/src/scenario/sources/mod.rs | 2 +- .../core/src/scenario/sources/model.rs | 69 +++++++++++++++++++ 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/testing-framework/core/src/runtime/manual.rs b/testing-framework/core/src/runtime/manual.rs index 4e8af53..ac17826 100644 --- a/testing-framework/core/src/runtime/manual.rs +++ b/testing-framework/core/src/runtime/manual.rs @@ -1,7 +1,11 @@ use async_trait::async_trait; -use crate::scenario::{Application, ClusterWaitHandle, NodeControlHandle}; +use crate::scenario::{Application, ClusterControlProfile, ClusterWaitHandle, NodeControlHandle}; /// Interface for imperative, deployer-backed manual clusters. #[async_trait] -pub trait ManualClusterHandle: NodeControlHandle + ClusterWaitHandle {} +pub trait ManualClusterHandle: NodeControlHandle + ClusterWaitHandle { + fn cluster_control_profile(&self) -> ClusterControlProfile { + ClusterControlProfile::ManualControlled + } +} diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 4bc70d5..6824cf3 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -4,8 +4,8 @@ use thiserror::Error; use tracing::{debug, info}; use super::{ - Application, ClusterMode, DeploymentPolicy, DynError, ExistingCluster, ExternalNodeSource, - HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, + Application, ClusterControlProfile, ClusterMode, DeploymentPolicy, DynError, ExistingCluster, + ExternalNodeSource, HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, builder_ops::CoreBuilderAccess, expectation::Expectation, runtime::{ @@ -123,6 +123,11 @@ impl Scenario { self.sources.cluster_mode() } + #[must_use] + pub const fn cluster_control_profile(&self) -> ClusterControlProfile { + self.sources.control_profile() + } + #[must_use] #[doc(hidden)] pub fn attached_source(&self) -> Option<&ExistingCluster> { diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index 431ee7c..dc464dc 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -54,7 +54,7 @@ pub use runtime::{ wait_for_http_ports_with_host_and_requirement, wait_for_http_ports_with_requirement, wait_http_readiness, wait_until_stable, }; -pub use sources::{ClusterMode, ExistingCluster, ExternalNodeSource}; +pub use sources::{ClusterControlProfile, ClusterMode, ExistingCluster, ExternalNodeSource}; pub use workload::Workload; pub use crate::env::Application; diff --git a/testing-framework/core/src/scenario/sources/mod.rs b/testing-framework/core/src/scenario/sources/mod.rs index 7f63800..01e8807 100644 --- a/testing-framework/core/src/scenario/sources/mod.rs +++ b/testing-framework/core/src/scenario/sources/mod.rs @@ -2,4 +2,4 @@ mod model; pub(crate) use model::ScenarioSources; #[doc(hidden)] -pub use model::{ClusterMode, ExistingCluster, ExternalNodeSource}; +pub use model::{ClusterControlProfile, ClusterMode, ExistingCluster, ExternalNodeSource}; diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 63c6c1b..599584c 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -127,6 +127,27 @@ pub enum ClusterMode { ExternalOnly, } +/// High-level control/lifecycle expectation for a cluster surface. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClusterControlProfile { + FrameworkManaged, + ExistingClusterControlled, + ExternalUncontrolled, + ManualControlled, +} + +impl ClusterControlProfile { + #[must_use] + pub const fn framework_owns_lifecycle(self) -> bool { + matches!(self, Self::FrameworkManaged) + } + + #[must_use] + pub const fn supports_node_control(self) -> bool { + !matches!(self, Self::ExternalUncontrolled) + } +} + /// Source model that makes invalid managed+attached combinations /// unrepresentable by type. #[derive(Clone, Debug, Eq, PartialEq)] @@ -202,4 +223,52 @@ impl ScenarioSources { Self::ExternalOnly { .. } => ClusterMode::ExternalOnly, } } + + #[must_use] + pub(crate) const fn control_profile(&self) -> ClusterControlProfile { + match self.cluster_mode() { + ClusterMode::Managed => ClusterControlProfile::FrameworkManaged, + ClusterMode::ExistingCluster => ClusterControlProfile::ExistingClusterControlled, + ClusterMode::ExternalOnly => ClusterControlProfile::ExternalUncontrolled, + } + } +} + +#[cfg(test)] +mod tests { + use super::{ClusterControlProfile, ExistingCluster, ExternalNodeSource, ScenarioSources}; + + #[test] + fn managed_sources_map_to_framework_managed_control() { + assert_eq!( + ScenarioSources::default().control_profile(), + ClusterControlProfile::FrameworkManaged, + ); + } + + #[test] + fn attached_sources_map_to_existing_cluster_control() { + let sources = ScenarioSources::default() + .with_attach(ExistingCluster::for_compose_project("project".to_owned())); + + assert_eq!( + sources.control_profile(), + ClusterControlProfile::ExistingClusterControlled, + ); + } + + #[test] + fn external_only_sources_map_to_uncontrolled_profile() { + let sources = ScenarioSources::default() + .with_external_node(ExternalNodeSource::new( + "node".to_owned(), + "http://node".to_owned(), + )) + .into_external_only(); + + assert_eq!( + sources.control_profile(), + ClusterControlProfile::ExternalUncontrolled, + ); + } } From 7e3531a4b231a33b5725eb056ae8a62b810a6c55 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:52:11 +0100 Subject: [PATCH 30/40] Validate scenario mode guarantees early --- .../core/src/scenario/definition.rs | 107 +++++++++++++++++- .../core/src/scenario/sources/model.rs | 21 ++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 6824cf3..6ec4b32 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -6,6 +6,7 @@ use tracing::{debug, info}; use super::{ Application, ClusterControlProfile, ClusterMode, DeploymentPolicy, DynError, ExistingCluster, ExternalNodeSource, HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, + RequiresNodeControl, builder_ops::CoreBuilderAccess, expectation::Expectation, runtime::{ @@ -614,11 +615,17 @@ impl Builder { #[must_use] /// Finalize the scenario, computing run metrics and initializing /// components. - pub fn build(self) -> Result, ScenarioBuildError> { + pub fn build(self) -> Result, ScenarioBuildError> + where + Caps: RequiresNodeControl, + { let mut parts = BuilderParts::from_builder(self); let descriptors = parts.resolve_deployment()?; let run_plan = parts.run_plan(); let run_metrics = RunMetrics::new(run_plan.duration); + + validate_source_contract::(parts.sources())?; + let source_orchestration_plan = build_source_orchestration_plan(parts.sources())?; initialize_components( @@ -721,6 +728,17 @@ fn build_source_orchestration_plan( SourceOrchestrationPlan::try_from_sources(sources).map_err(source_plan_error_to_build_error) } +fn validate_source_contract(sources: &ScenarioSources) -> Result<(), ScenarioBuildError> +where + Caps: RequiresNodeControl, +{ + validate_external_only_sources(sources)?; + + validate_node_control_profile::(sources)?; + + Ok(()) +} + fn source_plan_error_to_build_error(error: SourceOrchestrationPlanError) -> ScenarioBuildError { match error { SourceOrchestrationPlanError::SourceModeNotWiredYet { mode } => { @@ -729,6 +747,37 @@ fn source_plan_error_to_build_error(error: SourceOrchestrationPlanError) -> Scen } } +fn validate_external_only_sources(sources: &ScenarioSources) -> Result<(), ScenarioBuildError> { + if matches!(sources.cluster_mode(), ClusterMode::ExternalOnly) + && sources.external_nodes().is_empty() + { + return Err(ScenarioBuildError::SourceConfiguration { + message: "external-only scenarios require at least one external node".to_owned(), + }); + } + + Ok(()) +} + +fn validate_node_control_profile(sources: &ScenarioSources) -> Result<(), ScenarioBuildError> +where + Caps: RequiresNodeControl, +{ + let profile = sources.control_profile(); + + if Caps::REQUIRED && !profile.supports_node_control() { + return Err(ScenarioBuildError::SourceConfiguration { + message: format!( + "node control requires a controllable cluster surface, but cluster mode '{}' uses control profile '{}'", + sources.cluster_mode().as_str(), + profile.as_str(), + ), + }); + } + + Ok(()) +} + impl Builder { #[must_use] pub fn enable_node_control(self) -> Builder { @@ -813,3 +862,59 @@ fn expectation_cooldown_for(override_value: Option) -> Duration { fn min_run_duration() -> Duration { Duration::from_secs(MIN_RUN_DURATION_SECS) } + +#[cfg(test)] +mod tests { + use super::{ + ScenarioBuildError, validate_external_only_sources, validate_node_control_profile, + }; + use crate::scenario::{ + ExistingCluster, ExternalNodeSource, NodeControlCapability, sources::ScenarioSources, + }; + + #[test] + fn external_only_requires_external_nodes() { + let error = + validate_external_only_sources(&ScenarioSources::default().into_external_only()) + .expect_err("external-only without nodes should fail"); + + assert!(matches!( + error, + ScenarioBuildError::SourceConfiguration { .. } + )); + assert_eq!( + error.to_string(), + "invalid scenario source configuration: external-only scenarios require at least one external node" + ); + } + + #[test] + fn external_only_rejects_node_control_requirement() { + let sources = ScenarioSources::default() + .with_external_node(ExternalNodeSource::new( + "node-0".to_owned(), + "http://127.0.0.1:1".to_owned(), + )) + .into_external_only(); + let error = validate_node_control_profile::(&sources) + .expect_err("external-only should reject node control"); + + assert!(matches!( + error, + ScenarioBuildError::SourceConfiguration { .. } + )); + assert_eq!( + error.to_string(), + "invalid scenario source configuration: node control requires a controllable cluster surface, but cluster mode 'external-only' uses control profile 'external-uncontrolled'" + ); + } + + #[test] + fn existing_cluster_accepts_node_control_requirement() { + let sources = ScenarioSources::default() + .with_attach(ExistingCluster::for_compose_project("project".to_owned())); + + validate_node_control_profile::(&sources) + .expect("existing cluster should be considered controllable"); + } +} diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 599584c..9391e10 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -127,6 +127,17 @@ pub enum ClusterMode { ExternalOnly, } +impl ClusterMode { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Managed => "managed", + Self::ExistingCluster => "existing-cluster", + Self::ExternalOnly => "external-only", + } + } +} + /// High-level control/lifecycle expectation for a cluster surface. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClusterControlProfile { @@ -137,6 +148,16 @@ pub enum ClusterControlProfile { } impl ClusterControlProfile { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::FrameworkManaged => "framework-managed", + Self::ExistingClusterControlled => "existing-cluster-controlled", + Self::ExternalUncontrolled => "external-uncontrolled", + Self::ManualControlled => "manual-controlled", + } + } + #[must_use] pub const fn framework_owns_lifecycle(self) -> bool { matches!(self, Self::FrameworkManaged) From 898eadf976f09d324082c8934d76cc336a987bbe Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:59:59 +0100 Subject: [PATCH 31/40] Drive runtime stabilization from cluster control semantics --- .../core/src/scenario/runtime/context.rs | 20 ++++++++++++++----- .../core/src/scenario/runtime/runner.rs | 6 +++--- .../compose/src/deployer/orchestrator.rs | 18 +++++++++++------ .../k8s/src/deployer/orchestrator.rs | 10 +++++++--- .../local/src/deployer/orchestrator.rs | 12 +++++++---- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/testing-framework/core/src/scenario/runtime/context.rs b/testing-framework/core/src/scenario/runtime/context.rs index 6f6faf6..438cb57 100644 --- a/testing-framework/core/src/scenario/runtime/context.rs +++ b/testing-framework/core/src/scenario/runtime/context.rs @@ -1,7 +1,9 @@ use std::{sync::Arc, time::Duration}; use super::{metrics::Metrics, node_clients::ClusterClient}; -use crate::scenario::{Application, ClusterWaitHandle, DynError, NodeClients, NodeControlHandle}; +use crate::scenario::{ + Application, ClusterControlProfile, ClusterWaitHandle, DynError, NodeClients, NodeControlHandle, +}; #[derive(Debug, thiserror::Error)] enum RunContextCapabilityError { @@ -15,6 +17,7 @@ pub struct RunContext { node_clients: NodeClients, metrics: RunMetrics, expectation_cooldown: Duration, + cluster_control_profile: ClusterControlProfile, telemetry: Metrics, feed: ::Feed, node_control: Option>>, @@ -28,6 +31,7 @@ pub struct RuntimeAssembly { node_clients: NodeClients, run_duration: Duration, expectation_cooldown: Duration, + cluster_control_profile: ClusterControlProfile, telemetry: Metrics, feed: ::Feed, node_control: Option>>, @@ -42,6 +46,7 @@ impl RunContext { node_clients: NodeClients, run_duration: Duration, expectation_cooldown: Duration, + cluster_control_profile: ClusterControlProfile, telemetry: Metrics, feed: ::Feed, node_control: Option>>, @@ -53,6 +58,7 @@ impl RunContext { node_clients, metrics, expectation_cooldown, + cluster_control_profile, telemetry, feed, node_control, @@ -102,13 +108,13 @@ impl RunContext { } #[must_use] - pub fn node_control(&self) -> Option>> { - self.node_control.clone() + pub const fn cluster_control_profile(&self) -> ClusterControlProfile { + self.cluster_control_profile } #[must_use] - pub(crate) const fn controls_nodes(&self) -> bool { - self.node_control.is_some() + pub fn node_control(&self) -> Option>> { + self.node_control.clone() } pub(crate) async fn wait_network_ready(&self) -> Result<(), DynError> { @@ -135,6 +141,7 @@ impl RuntimeAssembly { node_clients: NodeClients, run_duration: Duration, expectation_cooldown: Duration, + cluster_control_profile: ClusterControlProfile, telemetry: Metrics, feed: ::Feed, ) -> Self { @@ -143,6 +150,7 @@ impl RuntimeAssembly { node_clients, run_duration, expectation_cooldown, + cluster_control_profile, telemetry, feed, node_control: None, @@ -169,6 +177,7 @@ impl RuntimeAssembly { self.node_clients, self.run_duration, self.expectation_cooldown, + self.cluster_control_profile, self.telemetry, self.feed, self.node_control, @@ -193,6 +202,7 @@ impl From> for RuntimeAssembly { node_clients: context.node_clients, run_duration: context.metrics.run_duration(), expectation_cooldown: context.expectation_cooldown, + cluster_control_profile: context.cluster_control_profile, telemetry: context.telemetry, feed: context.feed, node_control: context.node_control, diff --git a/testing-framework/core/src/scenario/runtime/runner.rs b/testing-framework/core/src/scenario/runtime/runner.rs index d9bf2b3..146aed7 100644 --- a/testing-framework/core/src/scenario/runtime/runner.rs +++ b/testing-framework/core/src/scenario/runtime/runner.rs @@ -192,10 +192,10 @@ impl Runner { } fn settle_wait_duration(context: &RunContext) -> Option { - let has_node_control = context.controls_nodes(); + let control_profile = context.cluster_control_profile(); let configured_wait = context.expectation_cooldown(); - if configured_wait.is_zero() && !has_node_control { + if configured_wait.is_zero() && !control_profile.supports_node_control() { return None; } @@ -233,7 +233,7 @@ impl Runner { fn cooldown_duration(context: &RunContext) -> Option { // Managed environments need a minimum cooldown so feed and expectations // observe stabilized state. - let needs_stabilization = context.controls_nodes(); + let needs_stabilization = context.cluster_control_profile().framework_owns_lifecycle(); let mut wait = context.expectation_cooldown(); diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index 293dd55..aa4f9c1 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -3,12 +3,12 @@ use std::{env, sync::Arc, time::Duration}; use reqwest::Url; use testing_framework_core::{ scenario::{ - ApplicationExternalProvider, CleanupGuard, ClusterMode, ClusterWaitHandle, - DeploymentPolicy, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, - NodeControlHandle, ObservabilityCapabilityProvider, ObservabilityInputs, - RequiresNodeControl, Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, - SourceProviders, StaticManagedProvider, build_source_orchestration_plan, - orchestrate_sources_with_providers, + ApplicationExternalProvider, CleanupGuard, ClusterControlProfile, ClusterMode, + ClusterWaitHandle, DeploymentPolicy, FeedHandle, FeedRuntime, HttpReadinessRequirement, + Metrics, NodeClients, NodeControlHandle, ObservabilityCapabilityProvider, + ObservabilityInputs, RequiresNodeControl, Runner, RuntimeAssembly, Scenario, + SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, + build_source_orchestration_plan, orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, }; @@ -163,6 +163,7 @@ impl DeploymentOrchestrator { node_clients, scenario.duration(), scenario.expectation_cooldown(), + scenario.cluster_control_profile(), observability.telemetry_handle()?, feed, node_control, @@ -269,6 +270,7 @@ impl DeploymentOrchestrator { descriptors: prepared.descriptors.clone(), duration: scenario.duration(), expectation_cooldown: scenario.expectation_cooldown(), + cluster_control_profile: scenario.cluster_control_profile(), telemetry, environment: &mut prepared.environment, node_control, @@ -389,6 +391,7 @@ struct RuntimeBuildInput<'a, E: ComposeDeployEnv> { descriptors: E::Deployment, duration: Duration, expectation_cooldown: Duration, + cluster_control_profile: ClusterControlProfile, telemetry: Metrics, environment: &'a mut StackEnvironment, node_control: Option>>, @@ -414,6 +417,7 @@ async fn build_compose_runtime( node_clients, input.duration, input.expectation_cooldown, + input.cluster_control_profile, input.telemetry, feed, input.node_control, @@ -461,6 +465,7 @@ fn build_runtime_assembly( node_clients: NodeClients, run_duration: Duration, expectation_cooldown: Duration, + cluster_control_profile: ClusterControlProfile, telemetry: Metrics, feed: ::Feed, node_control: Option>>, @@ -471,6 +476,7 @@ fn build_runtime_assembly( node_clients, run_duration, expectation_cooldown, + cluster_control_profile, telemetry, feed, ) diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index 2b177fe..67652be 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -5,9 +5,9 @@ use kube::Client; use reqwest::Url; use testing_framework_core::{ scenario::{ - Application, ApplicationExternalProvider, CleanupGuard, ClusterMode, ClusterWaitHandle, - Deployer, DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, - MetricsError, NodeClients, ObservabilityCapabilityProvider, ObservabilityInputs, + Application, ApplicationExternalProvider, CleanupGuard, ClusterControlProfile, ClusterMode, + ClusterWaitHandle, Deployer, DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, + Metrics, MetricsError, NodeClients, ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, build_source_orchestration_plan, orchestrate_sources_with_providers, @@ -236,6 +236,7 @@ where node_clients, scenario.duration(), scenario.expectation_cooldown(), + scenario.cluster_control_profile(), telemetry, feed, ) @@ -503,6 +504,7 @@ fn build_runner_parts( runtime.node_clients, scenario.duration(), scenario.expectation_cooldown(), + scenario.cluster_control_profile(), runtime.telemetry, runtime.feed, cluster_wait, @@ -643,6 +645,7 @@ fn build_k8s_runtime_assembly( node_clients: NodeClients, duration: Duration, expectation_cooldown: Duration, + cluster_control_profile: ClusterControlProfile, telemetry: Metrics, feed: Feed, cluster_wait: Arc>, @@ -652,6 +655,7 @@ fn build_k8s_runtime_assembly( node_clients, duration, expectation_cooldown, + cluster_control_profile, telemetry, feed, ) diff --git a/testing-framework/deployers/local/src/deployer/orchestrator.rs b/testing-framework/deployers/local/src/deployer/orchestrator.rs index f931e8b..8ac6e08 100644 --- a/testing-framework/deployers/local/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/local/src/deployer/orchestrator.rs @@ -10,10 +10,10 @@ use std::{ use async_trait::async_trait; use testing_framework_core::{ scenario::{ - Application, CleanupGuard, Deployer, DeploymentPolicy, DynError, FeedHandle, FeedRuntime, - HttpReadinessRequirement, Metrics, NodeClients, NodeControlCapability, NodeControlHandle, - RetryPolicy, Runner, RuntimeAssembly, Scenario, ScenarioError, SourceOrchestrationPlan, - build_source_orchestration_plan, spawn_feed, + Application, CleanupGuard, ClusterControlProfile, Deployer, DeploymentPolicy, DynError, + FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, + NodeControlCapability, NodeControlHandle, RetryPolicy, Runner, RuntimeAssembly, Scenario, + ScenarioError, SourceOrchestrationPlan, build_source_orchestration_plan, spawn_feed, }, topology::DeploymentDescriptor, }; @@ -211,6 +211,7 @@ impl ProcessDeployer { node_clients, scenario.duration(), scenario.expectation_cooldown(), + scenario.cluster_control_profile(), None, ) .await?; @@ -248,6 +249,7 @@ impl ProcessDeployer { node_clients, scenario.duration(), scenario.expectation_cooldown(), + scenario.cluster_control_profile(), Some(node_control), ) .await?; @@ -483,6 +485,7 @@ async fn run_context_for( node_clients: NodeClients, duration: Duration, expectation_cooldown: Duration, + cluster_control_profile: ClusterControlProfile, node_control: Option>>, ) -> Result, ProcessDeployerError> { if node_clients.is_empty() { @@ -495,6 +498,7 @@ async fn run_context_for( node_clients, duration, expectation_cooldown, + cluster_control_profile, Metrics::empty(), feed, ); From aa838ecca96fad0a3cae09e7f5b653dcf54f6364 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:03:03 +0100 Subject: [PATCH 32/40] Separate attached semantics from node control capability --- testing-framework/core/src/scenario/definition.rs | 6 +++--- .../core/src/scenario/runtime/runner.rs | 4 ++-- .../core/src/scenario/sources/model.rs | 13 ++++--------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 6ec4b32..9fd1d1c 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -765,10 +765,10 @@ where { let profile = sources.control_profile(); - if Caps::REQUIRED && !profile.supports_node_control() { + if Caps::REQUIRED && matches!(profile, ClusterControlProfile::ExternalUncontrolled) { return Err(ScenarioBuildError::SourceConfiguration { message: format!( - "node control requires a controllable cluster surface, but cluster mode '{}' uses control profile '{}'", + "node control is not available for cluster mode '{}' with control profile '{}'", sources.cluster_mode().as_str(), profile.as_str(), ), @@ -905,7 +905,7 @@ mod tests { )); assert_eq!( error.to_string(), - "invalid scenario source configuration: node control requires a controllable cluster surface, but cluster mode 'external-only' uses control profile 'external-uncontrolled'" + "invalid scenario source configuration: node control is not available for cluster mode 'external-only' with control profile 'external-uncontrolled'" ); } diff --git a/testing-framework/core/src/scenario/runtime/runner.rs b/testing-framework/core/src/scenario/runtime/runner.rs index 146aed7..216cd97 100644 --- a/testing-framework/core/src/scenario/runtime/runner.rs +++ b/testing-framework/core/src/scenario/runtime/runner.rs @@ -192,10 +192,10 @@ impl Runner { } fn settle_wait_duration(context: &RunContext) -> Option { - let control_profile = context.cluster_control_profile(); + let has_node_control = context.node_control().is_some(); let configured_wait = context.expectation_cooldown(); - if configured_wait.is_zero() && !control_profile.supports_node_control() { + if configured_wait.is_zero() && !has_node_control { return None; } diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index 9391e10..e720524 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -142,7 +142,7 @@ impl ClusterMode { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClusterControlProfile { FrameworkManaged, - ExistingClusterControlled, + ExistingClusterAttached, ExternalUncontrolled, ManualControlled, } @@ -152,7 +152,7 @@ impl ClusterControlProfile { pub const fn as_str(self) -> &'static str { match self { Self::FrameworkManaged => "framework-managed", - Self::ExistingClusterControlled => "existing-cluster-controlled", + Self::ExistingClusterAttached => "existing-cluster-attached", Self::ExternalUncontrolled => "external-uncontrolled", Self::ManualControlled => "manual-controlled", } @@ -162,11 +162,6 @@ impl ClusterControlProfile { pub const fn framework_owns_lifecycle(self) -> bool { matches!(self, Self::FrameworkManaged) } - - #[must_use] - pub const fn supports_node_control(self) -> bool { - !matches!(self, Self::ExternalUncontrolled) - } } /// Source model that makes invalid managed+attached combinations @@ -249,7 +244,7 @@ impl ScenarioSources { pub(crate) const fn control_profile(&self) -> ClusterControlProfile { match self.cluster_mode() { ClusterMode::Managed => ClusterControlProfile::FrameworkManaged, - ClusterMode::ExistingCluster => ClusterControlProfile::ExistingClusterControlled, + ClusterMode::ExistingCluster => ClusterControlProfile::ExistingClusterAttached, ClusterMode::ExternalOnly => ClusterControlProfile::ExternalUncontrolled, } } @@ -274,7 +269,7 @@ mod tests { assert_eq!( sources.control_profile(), - ClusterControlProfile::ExistingClusterControlled, + ClusterControlProfile::ExistingClusterAttached, ); } From 1268607a68229adcdd2c657de0337f68ca044183 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:06:38 +0100 Subject: [PATCH 33/40] Align attach wording with existing-cluster mode --- .../scenario/runtime/providers/attach_provider.rs | 8 ++++---- .../compose/src/deployer/attach_provider.rs | 12 ++++++------ .../deployers/compose/src/deployer/orchestrator.rs | 12 ++++++------ .../deployers/compose/src/docker/control.rs | 2 +- .../deployers/k8s/src/deployer/attach_provider.rs | 6 +++--- .../deployers/k8s/src/deployer/orchestrator.rs | 11 ++++++----- 6 files changed, 26 insertions(+), 25 deletions(-) diff --git a/testing-framework/core/src/scenario/runtime/providers/attach_provider.rs b/testing-framework/core/src/scenario/runtime/providers/attach_provider.rs index b239193..aadb31c 100644 --- a/testing-framework/core/src/scenario/runtime/providers/attach_provider.rs +++ b/testing-framework/core/src/scenario/runtime/providers/attach_provider.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use crate::scenario::{Application, DynError, ExistingCluster}; -/// Attached node discovered from an existing external cluster source. +/// Node discovered from an existing cluster descriptor. #[derive(Clone, Debug)] pub struct AttachedNode { /// Optional stable identity hint used by runtime inventory dedup logic. @@ -14,7 +14,7 @@ pub struct AttachedNode { /// Errors returned by attach providers while discovering attached nodes. #[derive(Debug, thiserror::Error)] pub enum AttachProviderError { - #[error("attach source is not supported by this provider: {attach_source:?}")] + #[error("existing cluster descriptor is not supported by this provider: {attach_source:?}")] UnsupportedSource { attach_source: ExistingCluster }, #[error("attach discovery failed: {source}")] Discovery { @@ -23,13 +23,13 @@ pub enum AttachProviderError { }, } -/// Internal adapter interface for discovering pre-existing nodes. +/// Internal adapter interface for discovering nodes in an existing cluster. /// /// This is scaffolding-only in phase 1 and is intentionally not wired into /// deployer runtime orchestration yet. #[async_trait] pub trait AttachProvider: Send + Sync { - /// Discovers node clients for the requested attach source. + /// Discovers node clients for the requested existing cluster. async fn discover( &self, source: &ExistingCluster, diff --git a/testing-framework/deployers/compose/src/deployer/attach_provider.rs b/testing-framework/deployers/compose/src/deployer/attach_provider.rs index 8874fd1..24c16d6 100644 --- a/testing-framework/deployers/compose/src/deployer/attach_provider.rs +++ b/testing-framework/deployers/compose/src/deployer/attach_provider.rs @@ -176,12 +176,12 @@ impl ClusterWaitHandle for ComposeAttachedClusterWait } fn compose_wait_request(source: &ExistingCluster) -> Result, DynError> { - let project = source - .compose_project() - .ok_or_else(|| DynError::from("compose cluster wait requires a compose attach source"))?; - let services = source - .compose_services() - .ok_or_else(|| DynError::from("compose cluster wait requires a compose attach source"))?; + let project = source.compose_project().ok_or_else(|| { + DynError::from("compose cluster wait requires a compose existing-cluster descriptor") + })?; + let services = source.compose_services().ok_or_else(|| { + DynError::from("compose cluster wait requires a compose existing-cluster descriptor") + })?; Ok(ComposeAttachRequest { project, services }) } diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index aa4f9c1..8dca52f 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -73,9 +73,9 @@ impl DeploymentOrchestrator { if matches!(scenario.cluster_mode(), ClusterMode::ExistingCluster) { return self - .deploy_attached_only::(scenario, source_plan) + .deploy_existing_cluster::(scenario, source_plan) .await - .map(|runner| (runner, attached_metadata(scenario))); + .map(|runner| (runner, existing_cluster_metadata(scenario))); } let deployment = scenario.deployment(); @@ -138,7 +138,7 @@ impl DeploymentOrchestrator { )) } - async fn deploy_attached_only( + async fn deploy_existing_cluster( &self, scenario: &Scenario, source_plan: SourceOrchestrationPlan, @@ -218,7 +218,7 @@ impl DeploymentOrchestrator { let attach = scenario .existing_cluster() .ok_or(ComposeRunnerError::InternalInvariant { - message: "attached node control requested outside attached source mode", + message: "existing-cluster node control requested outside existing-cluster mode", })?; let node_control = ComposeAttachedNodeControl::try_from_existing_cluster(attach) .map_err(|source| ComposeRunnerError::SourceOrchestration { source })?; @@ -236,7 +236,7 @@ impl DeploymentOrchestrator { let attach = scenario .existing_cluster() .ok_or(ComposeRunnerError::InternalInvariant { - message: "compose attached cluster wait requested outside attached source mode", + message: "compose cluster wait requested outside existing-cluster mode", })?; let cluster_wait = ComposeAttachedClusterWait::::try_new(compose_runner_host(), attach) .map_err(|source| ComposeRunnerError::SourceOrchestration { source })?; @@ -366,7 +366,7 @@ impl DeploymentOrchestrator { } } -fn attached_metadata(scenario: &Scenario) -> ComposeDeploymentMetadata +fn existing_cluster_metadata(scenario: &Scenario) -> ComposeDeploymentMetadata where E: ComposeDeployEnv, Caps: Send + Sync, diff --git a/testing-framework/deployers/compose/src/docker/control.rs b/testing-framework/deployers/compose/src/docker/control.rs index 90b8a3d..799b633 100644 --- a/testing-framework/deployers/compose/src/docker/control.rs +++ b/testing-framework/deployers/compose/src/docker/control.rs @@ -155,7 +155,7 @@ impl NodeControlHandle for ComposeNodeControl { } } -/// Node control handle for compose attached mode. +/// Node control handle for compose existing-cluster mode. pub struct ComposeAttachedNodeControl { pub(crate) project_name: String, } diff --git a/testing-framework/deployers/k8s/src/deployer/attach_provider.rs b/testing-framework/deployers/k8s/src/deployer/attach_provider.rs index 43925bc..bdfa1fe 100644 --- a/testing-framework/deployers/k8s/src/deployer/attach_provider.rs +++ b/testing-framework/deployers/k8s/src/deployer/attach_provider.rs @@ -247,9 +247,9 @@ impl ClusterWaitHandle for K8sAttachedClusterWait { } fn k8s_wait_request(source: &ExistingCluster) -> Result, DynError> { - let label_selector = source - .k8s_label_selector() - .ok_or_else(|| DynError::from("k8s cluster wait requires a k8s attach source"))?; + let label_selector = source.k8s_label_selector().ok_or_else(|| { + DynError::from("k8s cluster wait requires a k8s existing-cluster descriptor") + })?; if label_selector.trim().is_empty() { return Err(K8sAttachDiscoveryError::EmptyLabelSelector.into()); diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index 67652be..d14e373 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -181,8 +181,9 @@ where let observability = resolve_observability_inputs(scenario.capabilities())?; if matches!(scenario.cluster_mode(), ClusterMode::ExistingCluster) { - let runner = deploy_attached_only::(scenario, source_plan, observability).await?; - return Ok((runner, attached_metadata(scenario))); + let runner = + deploy_existing_cluster::(scenario, source_plan, observability).await?; + return Ok((runner, existing_cluster_metadata(scenario))); } let deployment = build_k8s_deployment::(deployer, scenario, &observability).await?; @@ -213,7 +214,7 @@ where Ok((runner, metadata)) } -async fn deploy_attached_only( +async fn deploy_existing_cluster( scenario: &Scenario, source_plan: SourceOrchestrationPlan, observability: ObservabilityInputs, @@ -245,7 +246,7 @@ where Ok(context.build_runner(Some(Box::new(feed_task)))) } -fn attached_metadata(scenario: &Scenario) -> K8sDeploymentMetadata +fn existing_cluster_metadata(scenario: &Scenario) -> K8sDeploymentMetadata where E: K8sDeployEnv, Caps: Send + Sync, @@ -264,7 +265,7 @@ where let attach = scenario .existing_cluster() .ok_or_else(|| K8sRunnerError::InternalInvariant { - message: "k8s attached cluster wait requested outside attached source mode".to_owned(), + message: "k8s cluster wait requested outside existing-cluster mode".to_owned(), })?; let cluster_wait = K8sAttachedClusterWait::::try_new(client, attach) .map_err(|source| K8sRunnerError::SourceOrchestration { source })?; From 6405d31ebdc79d97742342ea0c627d07082196f6 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:13:47 +0100 Subject: [PATCH 34/40] Validate deployer support for cluster modes --- .../compose/src/deployer/orchestrator.rs | 66 +++++++++++++++++-- .../k8s/src/deployer/orchestrator.rs | 61 +++++++++++++++-- .../local/src/deployer/orchestrator.rs | 47 ++++++++++++- 3 files changed, 162 insertions(+), 12 deletions(-) diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index 8dca52f..e18a16f 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -3,11 +3,11 @@ use std::{env, sync::Arc, time::Duration}; use reqwest::Url; use testing_framework_core::{ scenario::{ - ApplicationExternalProvider, CleanupGuard, ClusterControlProfile, ClusterMode, - ClusterWaitHandle, DeploymentPolicy, FeedHandle, FeedRuntime, HttpReadinessRequirement, - Metrics, NodeClients, NodeControlHandle, ObservabilityCapabilityProvider, - ObservabilityInputs, RequiresNodeControl, Runner, RuntimeAssembly, Scenario, - SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, + Application, ApplicationExternalProvider, CleanupGuard, ClusterControlProfile, ClusterMode, + ClusterWaitHandle, DeploymentPolicy, DynError, ExistingCluster, FeedHandle, FeedRuntime, + HttpReadinessRequirement, Metrics, NodeClients, NodeControlHandle, + ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, Runner, + RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, build_source_orchestration_plan, orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, @@ -64,6 +64,12 @@ impl DeploymentOrchestrator { where Caps: RequiresNodeControl + ObservabilityCapabilityProvider + Send + Sync, { + validate_supported_cluster_mode(scenario).map_err(|source| { + ComposeRunnerError::SourceOrchestration { + source: source.into(), + } + })?; + // Source planning is currently resolved here before deployer-specific setup. let source_plan = build_source_orchestration_plan(scenario).map_err(|source| { ComposeRunnerError::SourceOrchestration { @@ -366,6 +372,56 @@ impl DeploymentOrchestrator { } } +fn validate_supported_cluster_mode( + scenario: &Scenario, +) -> Result<(), DynError> { + if !matches!(scenario.cluster_mode(), ClusterMode::ExistingCluster) { + return Ok(()); + } + + let cluster = scenario + .existing_cluster() + .ok_or_else(|| DynError::from("existing-cluster mode requires an existing cluster"))?; + + ensure_compose_existing_cluster(cluster) +} + +fn ensure_compose_existing_cluster(cluster: &ExistingCluster) -> Result<(), DynError> { + if cluster.compose_project().is_some() && cluster.compose_services().is_some() { + return Ok(()); + } + + Err("compose deployer requires a compose existing-cluster descriptor".into()) +} + +#[cfg(test)] +mod tests { + use testing_framework_core::scenario::ExistingCluster; + + use super::ensure_compose_existing_cluster; + + #[test] + fn compose_cluster_validator_accepts_compose_descriptor() { + ensure_compose_existing_cluster(&ExistingCluster::for_compose_project( + "project".to_owned(), + )) + .expect("compose descriptor should be accepted"); + } + + #[test] + fn compose_cluster_validator_rejects_k8s_descriptor() { + let error = ensure_compose_existing_cluster(&ExistingCluster::for_k8s_selector( + "app=node".to_owned(), + )) + .expect_err("k8s descriptor should be rejected"); + + assert_eq!( + error.to_string(), + "compose deployer requires a compose existing-cluster descriptor" + ); + } +} + fn existing_cluster_metadata(scenario: &Scenario) -> ComposeDeploymentMetadata where E: ComposeDeployEnv, diff --git a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs index d14e373..83ba257 100644 --- a/testing-framework/deployers/k8s/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/k8s/src/deployer/orchestrator.rs @@ -6,11 +6,11 @@ use reqwest::Url; use testing_framework_core::{ scenario::{ Application, ApplicationExternalProvider, CleanupGuard, ClusterControlProfile, ClusterMode, - ClusterWaitHandle, Deployer, DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, - Metrics, MetricsError, NodeClients, ObservabilityCapabilityProvider, ObservabilityInputs, - RequiresNodeControl, Runner, RuntimeAssembly, Scenario, SourceOrchestrationPlan, - SourceProviders, StaticManagedProvider, build_source_orchestration_plan, - orchestrate_sources_with_providers, + ClusterWaitHandle, Deployer, DynError, ExistingCluster, FeedHandle, FeedRuntime, + HttpReadinessRequirement, Metrics, MetricsError, NodeClients, + ObservabilityCapabilityProvider, ObservabilityInputs, RequiresNodeControl, Runner, + RuntimeAssembly, Scenario, SourceOrchestrationPlan, SourceProviders, StaticManagedProvider, + build_source_orchestration_plan, orchestrate_sources_with_providers, }, topology::DeploymentDescriptor, }; @@ -171,6 +171,9 @@ where E: K8sDeployEnv, Caps: ObservabilityCapabilityProvider + Send + Sync, { + validate_supported_cluster_mode(scenario) + .map_err(|source| K8sRunnerError::SourceOrchestration { source })?; + // Source planning is currently resolved here before deployer-specific setup. let source_plan = build_source_orchestration_plan(scenario).map_err(|source| { K8sRunnerError::SourceOrchestration { @@ -273,6 +276,54 @@ where Ok(Arc::new(cluster_wait)) } +fn validate_supported_cluster_mode( + scenario: &Scenario, +) -> Result<(), DynError> { + if !matches!(scenario.cluster_mode(), ClusterMode::ExistingCluster) { + return Ok(()); + } + + let cluster = scenario + .existing_cluster() + .ok_or_else(|| DynError::from("existing-cluster mode requires an existing cluster"))?; + + ensure_k8s_existing_cluster(cluster) +} + +fn ensure_k8s_existing_cluster(cluster: &ExistingCluster) -> Result<(), DynError> { + if cluster.k8s_label_selector().is_some() { + return Ok(()); + } + + Err("k8s deployer requires a k8s existing-cluster descriptor".into()) +} + +#[cfg(test)] +mod tests { + use testing_framework_core::scenario::ExistingCluster; + + use super::ensure_k8s_existing_cluster; + + #[test] + fn k8s_cluster_validator_accepts_k8s_descriptor() { + ensure_k8s_existing_cluster(&ExistingCluster::for_k8s_selector("app=node".to_owned())) + .expect("k8s descriptor should be accepted"); + } + + #[test] + fn k8s_cluster_validator_rejects_compose_descriptor() { + let error = ensure_k8s_existing_cluster(&ExistingCluster::for_compose_project( + "project".to_owned(), + )) + .expect_err("compose descriptor should be rejected"); + + assert_eq!( + error.to_string(), + "k8s deployer requires a k8s existing-cluster descriptor" + ); + } +} + fn managed_cluster_wait( cluster: &Option, metadata: &K8sDeploymentMetadata, diff --git a/testing-framework/deployers/local/src/deployer/orchestrator.rs b/testing-framework/deployers/local/src/deployer/orchestrator.rs index 8ac6e08..5af3d2e 100644 --- a/testing-framework/deployers/local/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/local/src/deployer/orchestrator.rs @@ -10,8 +10,8 @@ use std::{ use async_trait::async_trait; use testing_framework_core::{ scenario::{ - Application, CleanupGuard, ClusterControlProfile, Deployer, DeploymentPolicy, DynError, - FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, + Application, CleanupGuard, ClusterControlProfile, ClusterMode, Deployer, DeploymentPolicy, + DynError, FeedHandle, FeedRuntime, HttpReadinessRequirement, Metrics, NodeClients, NodeControlCapability, NodeControlHandle, RetryPolicy, Runner, RuntimeAssembly, Scenario, ScenarioError, SourceOrchestrationPlan, build_source_orchestration_plan, spawn_feed, }, @@ -187,6 +187,8 @@ impl ProcessDeployer { &self, scenario: &Scenario, ) -> Result, ProcessDeployerError> { + validate_supported_cluster_mode(scenario)?; + // Source planning is currently resolved here before node spawn/runtime setup. let source_plan = build_source_orchestration_plan(scenario).map_err(|source| { ProcessDeployerError::SourceOrchestration { @@ -226,6 +228,8 @@ impl ProcessDeployer { &self, scenario: &Scenario, ) -> Result, ProcessDeployerError> { + validate_supported_cluster_mode(scenario)?; + // Source planning is currently resolved here before node spawn/runtime setup. let source_plan = build_source_orchestration_plan(scenario).map_err(|source| { ProcessDeployerError::SourceOrchestration { @@ -313,6 +317,22 @@ impl ProcessDeployer { } } +fn validate_supported_cluster_mode( + scenario: &Scenario, +) -> Result<(), ProcessDeployerError> { + ensure_local_cluster_mode(scenario.cluster_mode()) +} + +fn ensure_local_cluster_mode(mode: ClusterMode) -> Result<(), ProcessDeployerError> { + if matches!(mode, ClusterMode::ExistingCluster) { + return Err(ProcessDeployerError::SourceOrchestration { + source: DynError::from("local deployer does not support existing-cluster mode"), + }); + } + + Ok(()) +} + fn merge_source_clients_for_local( source_plan: &SourceOrchestrationPlan, node_clients: NodeClients, @@ -340,6 +360,29 @@ fn build_retry_execution_config( (retry_policy, execution) } +#[cfg(test)] +mod tests { + use testing_framework_core::scenario::ClusterMode; + + use super::ensure_local_cluster_mode; + + #[test] + fn local_cluster_validator_accepts_managed_mode() { + ensure_local_cluster_mode(ClusterMode::Managed).expect("managed mode should be accepted"); + } + + #[test] + fn local_cluster_validator_rejects_existing_cluster_mode() { + let error = ensure_local_cluster_mode(ClusterMode::ExistingCluster) + .expect_err("existing-cluster mode should be rejected"); + + assert_eq!( + error.to_string(), + "source orchestration failed: local deployer does not support existing-cluster mode" + ); + } +} + async fn run_retry_attempt( descriptors: &E::Deployment, execution: RetryExecutionConfig, From feeafa4eaf3047c45ca1bf3c831b7c32aefb7f40 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:14:47 +0100 Subject: [PATCH 35/40] Add source orchestration contract tests --- .../source_orchestration_plan.rs | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs index 35bb510..2dd84bd 100644 --- a/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs +++ b/testing-framework/core/src/scenario/runtime/orchestration/source_orchestration_plan.rs @@ -69,7 +69,19 @@ impl SourceOrchestrationPlan { #[cfg(test)] mod tests { use super::{SourceOrchestrationMode, SourceOrchestrationPlan}; - use crate::scenario::{ExistingCluster, sources::ScenarioSources}; + use crate::scenario::{ExistingCluster, ExternalNodeSource, sources::ScenarioSources}; + + #[test] + fn managed_sources_are_planned() { + let plan = SourceOrchestrationPlan::try_from_sources(&ScenarioSources::default()) + .expect("managed sources should build a source orchestration plan"); + + assert!(matches!( + plan.mode(), + SourceOrchestrationMode::Managed { .. } + )); + assert!(plan.external_sources().is_empty()); + } #[test] fn attached_sources_are_planned() { @@ -86,6 +98,46 @@ mod tests { SourceOrchestrationMode::Attached { .. } )); } + + #[test] + fn attached_sources_keep_external_nodes() { + let sources = ScenarioSources::default() + .with_attach(ExistingCluster::for_compose_project( + "test-project".to_string(), + )) + .with_external_node(ExternalNodeSource::new( + "external-0".to_owned(), + "http://127.0.0.1:1".to_owned(), + )); + let plan = SourceOrchestrationPlan::try_from_sources(&sources) + .expect("attached sources with external nodes should build"); + + assert!(matches!( + plan.mode(), + SourceOrchestrationMode::Attached { .. } + )); + assert_eq!(plan.external_sources().len(), 1); + assert_eq!(plan.external_sources()[0].label(), "external-0"); + } + + #[test] + fn external_only_sources_are_planned() { + let sources = ScenarioSources::default() + .with_external_node(ExternalNodeSource::new( + "external-0".to_owned(), + "http://127.0.0.1:1".to_owned(), + )) + .into_external_only(); + let plan = SourceOrchestrationPlan::try_from_sources(&sources) + .expect("external-only sources should build a source orchestration plan"); + + assert!(matches!( + plan.mode(), + SourceOrchestrationMode::ExternalOnly { .. } + )); + assert_eq!(plan.external_sources().len(), 1); + assert_eq!(plan.external_sources()[0].label(), "external-0"); + } } fn mode_from_sources(sources: &ScenarioSources) -> SourceOrchestrationMode { From 8721f58d682992d3e49e7450c5ddda4ee7ea24bf Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:06:38 +0100 Subject: [PATCH 36/40] Align attach wording with existing-cluster mode --- testing-framework/deployers/compose/src/deployer/orchestrator.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/testing-framework/deployers/compose/src/deployer/orchestrator.rs b/testing-framework/deployers/compose/src/deployer/orchestrator.rs index e18a16f..554db9b 100644 --- a/testing-framework/deployers/compose/src/deployer/orchestrator.rs +++ b/testing-framework/deployers/compose/src/deployer/orchestrator.rs @@ -421,7 +421,6 @@ mod tests { ); } } - fn existing_cluster_metadata(scenario: &Scenario) -> ComposeDeploymentMetadata where E: ComposeDeployEnv, From 6ad6ff33c46d7e11fc70b0cb37d6a452ad67a83f Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:18:25 +0100 Subject: [PATCH 37/40] Add batch external-node builder helpers --- .../examples/tests/external_sources_local.rs | 3 +- .../core/src/scenario/definition.rs | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/logos/examples/tests/external_sources_local.rs b/logos/examples/tests/external_sources_local.rs index af16b9d..fbf36f2 100644 --- a/logos/examples/tests/external_sources_local.rs +++ b/logos/examples/tests/external_sources_local.rs @@ -145,8 +145,7 @@ async fn scenario_managed_plus_external_sources_are_orchestrated() -> Result<()> let mut scenario = ScenarioBuilder::new(Box::new(deployment_builder)) .with_run_duration(Duration::from_secs(5)) - .with_external_node(seed_cluster.external_sources()[0].clone()) - .with_external_node(seed_cluster.external_sources()[1].clone()) + .with_external_nodes(seed_cluster.external_sources().to_vec()) .build()?; let deployer = ProcessDeployer::::default(); diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 9fd1d1c..19fb3c7 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -276,11 +276,27 @@ macro_rules! impl_common_builder_methods { self.map_core_builder(|builder| builder.with_external_node(node)) } + #[must_use] + pub fn with_external_nodes( + self, + nodes: impl IntoIterator, + ) -> Self { + self.map_core_builder(|builder| builder.with_external_nodes(nodes)) + } + #[must_use] pub fn with_external_only_sources(self) -> Self { self.map_core_builder(|builder| builder.with_external_only_sources()) } + #[must_use] + pub fn with_external_only_nodes( + self, + nodes: impl IntoIterator, + ) -> Self { + self.map_core_builder(|builder| builder.with_external_only_nodes(nodes)) + } + #[must_use] pub fn run_duration(&self) -> Duration { self.core_builder_ref().run_duration() @@ -597,12 +613,32 @@ impl Builder { self } + #[must_use] + pub fn with_external_nodes( + mut self, + nodes: impl IntoIterator, + ) -> Self { + for node in nodes { + self.sources = self.sources.with_external_node(node); + } + + self + } + #[must_use] pub fn with_external_only_sources(mut self) -> Self { self.sources = self.sources.into_external_only(); self } + #[must_use] + pub fn with_external_only_nodes( + self, + nodes: impl IntoIterator, + ) -> Self { + self.with_external_only_sources().with_external_nodes(nodes) + } + fn add_workload(&mut self, workload: Box>) { self.expectations.extend(workload.expectations()); self.workloads.push(workload); From ef9428ba4828eb02bc496028bb4dabc4dcdf701a Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:25:01 +0100 Subject: [PATCH 38/40] Let builders derive existing clusters from metadata --- .../tests/compose_attach_node_control.rs | 6 ++--- .../examples/tests/k8s_attach_node_control.rs | 6 ++--- .../core/src/scenario/definition.rs | 24 +++++++++++++++++-- testing-framework/core/src/scenario/mod.rs | 4 +++- .../core/src/scenario/sources/mod.rs | 4 +++- .../core/src/scenario/sources/model.rs | 19 +++++++++++++++ .../deployers/compose/src/deployer/mod.rs | 16 +++++++++++-- .../deployers/k8s/src/deployer/mod.rs | 14 ++++++++++- 8 files changed, 78 insertions(+), 15 deletions(-) diff --git a/logos/examples/tests/compose_attach_node_control.rs b/logos/examples/tests/compose_attach_node_control.rs index 4f909a6..5c36326 100644 --- a/logos/examples/tests/compose_attach_node_control.rs +++ b/logos/examples/tests/compose_attach_node_control.rs @@ -20,12 +20,10 @@ async fn compose_attach_mode_queries_node_api_opt_in() -> Result<()> { Err(error) => return Err(Error::new(error)), }; - let attach_source = metadata - .existing_cluster() - .map_err(|err| anyhow!("{err}"))?; let attached = ScenarioBuilder::deployment_with(|d| d.with_node_count(1)) .with_run_duration(Duration::from_secs(5)) - .with_existing_cluster(attach_source) + .with_existing_cluster_from(&metadata) + .map_err(|err| anyhow!("{err}"))? .build()?; let attached_deployer = LbcComposeDeployer::default(); diff --git a/logos/examples/tests/k8s_attach_node_control.rs b/logos/examples/tests/k8s_attach_node_control.rs index 47bbe35..66785f8 100644 --- a/logos/examples/tests/k8s_attach_node_control.rs +++ b/logos/examples/tests/k8s_attach_node_control.rs @@ -20,12 +20,10 @@ async fn k8s_attach_mode_queries_node_api_opt_in() -> Result<()> { Err(error) => return Err(Error::new(error)), }; - let attach_source = metadata - .existing_cluster() - .map_err(|err| anyhow!("{err}"))?; let attached = ScenarioBuilder::deployment_with(|d| d.with_node_count(1)) .with_run_duration(Duration::from_secs(5)) - .with_existing_cluster(attach_source) + .with_existing_cluster_from(&metadata) + .map_err(|err| anyhow!("{err}"))? .build()?; let attached_deployer = LbcK8sDeployer::default(); diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 19fb3c7..4f4d4ac 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -5,8 +5,8 @@ use tracing::{debug, info}; use super::{ Application, ClusterControlProfile, ClusterMode, DeploymentPolicy, DynError, ExistingCluster, - ExternalNodeSource, HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, - RequiresNodeControl, + ExternalNodeSource, HttpReadinessRequirement, IntoExistingCluster, NodeControlCapability, + ObservabilityCapability, RequiresNodeControl, builder_ops::CoreBuilderAccess, expectation::Expectation, runtime::{ @@ -265,6 +265,16 @@ macro_rules! impl_common_builder_methods { self.map_core_builder(|builder| builder.with_existing_cluster(cluster)) } + #[must_use] + pub fn with_existing_cluster_from( + self, + cluster: impl IntoExistingCluster, + ) -> Result { + let cluster = cluster.into_existing_cluster()?; + + Ok(self.with_existing_cluster(cluster)) + } + #[must_use] #[doc(hidden)] pub fn with_attach_source(self, attach: ExistingCluster) -> Self { @@ -601,6 +611,16 @@ impl Builder { self } + #[must_use] + pub fn with_existing_cluster_from( + self, + cluster: impl IntoExistingCluster, + ) -> Result { + let cluster = cluster.into_existing_cluster()?; + + Ok(self.with_existing_cluster(cluster)) + } + #[must_use] #[doc(hidden)] pub fn with_attach_source(self, attach: ExistingCluster) -> Self { diff --git a/testing-framework/core/src/scenario/mod.rs b/testing-framework/core/src/scenario/mod.rs index dc464dc..4089111 100644 --- a/testing-framework/core/src/scenario/mod.rs +++ b/testing-framework/core/src/scenario/mod.rs @@ -54,7 +54,9 @@ pub use runtime::{ wait_for_http_ports_with_host_and_requirement, wait_for_http_ports_with_requirement, wait_http_readiness, wait_until_stable, }; -pub use sources::{ClusterControlProfile, ClusterMode, ExistingCluster, ExternalNodeSource}; +pub use sources::{ + ClusterControlProfile, ClusterMode, ExistingCluster, ExternalNodeSource, IntoExistingCluster, +}; pub use workload::Workload; pub use crate::env::Application; diff --git a/testing-framework/core/src/scenario/sources/mod.rs b/testing-framework/core/src/scenario/sources/mod.rs index 01e8807..68a9fa4 100644 --- a/testing-framework/core/src/scenario/sources/mod.rs +++ b/testing-framework/core/src/scenario/sources/mod.rs @@ -2,4 +2,6 @@ mod model; pub(crate) use model::ScenarioSources; #[doc(hidden)] -pub use model::{ClusterControlProfile, ClusterMode, ExistingCluster, ExternalNodeSource}; +pub use model::{ + ClusterControlProfile, ClusterMode, ExistingCluster, ExternalNodeSource, IntoExistingCluster, +}; diff --git a/testing-framework/core/src/scenario/sources/model.rs b/testing-framework/core/src/scenario/sources/model.rs index e720524..ea48b1d 100644 --- a/testing-framework/core/src/scenario/sources/model.rs +++ b/testing-framework/core/src/scenario/sources/model.rs @@ -1,3 +1,5 @@ +use crate::scenario::DynError; + /// Typed descriptor for an existing cluster. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ExistingCluster { @@ -94,6 +96,23 @@ impl ExistingCluster { } } +/// Converts a value into an existing-cluster descriptor. +pub trait IntoExistingCluster { + fn into_existing_cluster(self) -> Result; +} + +impl IntoExistingCluster for ExistingCluster { + fn into_existing_cluster(self) -> Result { + Ok(self) + } +} + +impl IntoExistingCluster for &ExistingCluster { + fn into_existing_cluster(self) -> Result { + Ok(self.clone()) + } +} + /// Static external node endpoint that should be included in the runtime /// inventory. #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/testing-framework/deployers/compose/src/deployer/mod.rs b/testing-framework/deployers/compose/src/deployer/mod.rs index a0c35d5..9771a9a 100644 --- a/testing-framework/deployers/compose/src/deployer/mod.rs +++ b/testing-framework/deployers/compose/src/deployer/mod.rs @@ -9,8 +9,8 @@ use std::marker::PhantomData; use async_trait::async_trait; use testing_framework_core::scenario::{ - CleanupGuard, Deployer, DynError, ExistingCluster, FeedHandle, ObservabilityCapabilityProvider, - RequiresNodeControl, Runner, Scenario, + CleanupGuard, Deployer, DynError, ExistingCluster, FeedHandle, IntoExistingCluster, + ObservabilityCapabilityProvider, RequiresNodeControl, Runner, Scenario, }; use crate::{env::ComposeDeployEnv, errors::ComposeRunnerError, lifecycle::cleanup::RunnerCleanup}; @@ -100,6 +100,18 @@ impl ComposeDeploymentMetadata { } } +impl IntoExistingCluster for ComposeDeploymentMetadata { + fn into_existing_cluster(self) -> Result { + self.existing_cluster() + } +} + +impl IntoExistingCluster for &ComposeDeploymentMetadata { + fn into_existing_cluster(self) -> Result { + self.existing_cluster() + } +} + impl Default for ComposeDeployer { fn default() -> Self { Self::new() diff --git a/testing-framework/deployers/k8s/src/deployer/mod.rs b/testing-framework/deployers/k8s/src/deployer/mod.rs index d97615b..33d74dd 100644 --- a/testing-framework/deployers/k8s/src/deployer/mod.rs +++ b/testing-framework/deployers/k8s/src/deployer/mod.rs @@ -2,7 +2,7 @@ mod attach_provider; mod orchestrator; pub use orchestrator::{K8sDeployer, K8sRunnerError}; -use testing_framework_core::scenario::{DynError, ExistingCluster}; +use testing_framework_core::scenario::{DynError, ExistingCluster, IntoExistingCluster}; /// Kubernetes deployment metadata returned by k8s-specific deployment APIs. #[derive(Clone, Debug, Eq, PartialEq)] @@ -64,3 +64,15 @@ impl K8sDeploymentMetadata { self.existing_cluster() } } + +impl IntoExistingCluster for K8sDeploymentMetadata { + fn into_existing_cluster(self) -> Result { + self.existing_cluster() + } +} + +impl IntoExistingCluster for &K8sDeploymentMetadata { + fn into_existing_cluster(self) -> Result { + self.existing_cluster() + } +} From 54a1592d97c39d4dd390081dbb7651eba8f75d87 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:29:52 +0100 Subject: [PATCH 39/40] Rename external-only builder entry point --- testing-framework/core/src/scenario/definition.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index 4f4d4ac..ff69ac5 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -295,8 +295,8 @@ macro_rules! impl_common_builder_methods { } #[must_use] - pub fn with_external_only_sources(self) -> Self { - self.map_core_builder(|builder| builder.with_external_only_sources()) + pub fn with_external_only(self) -> Self { + self.map_core_builder(|builder| builder.with_external_only()) } #[must_use] @@ -646,7 +646,7 @@ impl Builder { } #[must_use] - pub fn with_external_only_sources(mut self) -> Self { + pub fn with_external_only(mut self) -> Self { self.sources = self.sources.into_external_only(); self } @@ -656,7 +656,13 @@ impl Builder { self, nodes: impl IntoIterator, ) -> Self { - self.with_external_only_sources().with_external_nodes(nodes) + self.with_external_only().with_external_nodes(nodes) + } + + #[must_use] + #[doc(hidden)] + pub fn with_external_only_sources(self) -> Self { + self.with_external_only() } fn add_workload(&mut self, workload: Box>) { From 8efba317cc1c733e74b90aa9ec83a5923b25b241 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:32:54 +0100 Subject: [PATCH 40/40] Shift capability builders to with_* phrasing --- .../src/chaos_workloads_random_restart.rs | 2 +- .../src/dsl_cheat_sheet_workload_chaos.rs | 2 +- ...examples_advanced_aggressive_chaos_test.rs | 2 +- .../src/examples_chaos_resilience.rs | 2 +- .../testing_philosophy_determinism_first.rs | 2 +- logos/examples/src/bin/compose_runner.rs | 2 +- logos/examples/src/bin/k8s_runner.rs | 2 +- logos/examples/tests/dynamic_join.rs | 4 +-- .../examples/tests/local_deployer_restart.rs | 2 +- logos/runtime/ext/src/scenario/mod.rs | 4 +-- .../core/src/scenario/builder_ext.rs | 6 ++-- .../core/src/scenario/definition.rs | 34 ++++++++++++++++--- 12 files changed, 44 insertions(+), 20 deletions(-) diff --git a/logos/examples/doc-snippets/src/chaos_workloads_random_restart.rs b/logos/examples/doc-snippets/src/chaos_workloads_random_restart.rs index d351c20..78ff50a 100644 --- a/logos/examples/doc-snippets/src/chaos_workloads_random_restart.rs +++ b/logos/examples/doc-snippets/src/chaos_workloads_random_restart.rs @@ -7,7 +7,7 @@ use crate::SnippetResult; pub fn random_restart_plan() -> SnippetResult> { ScenarioBuilder::topology_with(|t| t.network_star().nodes(2)) - .enable_node_control() + .with_node_control() .with_workload(RandomRestartWorkload::new( Duration::from_secs(45), // min delay Duration::from_secs(75), // max delay diff --git a/logos/examples/doc-snippets/src/dsl_cheat_sheet_workload_chaos.rs b/logos/examples/doc-snippets/src/dsl_cheat_sheet_workload_chaos.rs index fa3d963..58b2172 100644 --- a/logos/examples/doc-snippets/src/dsl_cheat_sheet_workload_chaos.rs +++ b/logos/examples/doc-snippets/src/dsl_cheat_sheet_workload_chaos.rs @@ -8,7 +8,7 @@ use crate::SnippetResult; pub fn chaos_plan() -> SnippetResult> { ScenarioBuilder::topology_with(|t| t.network_star().nodes(3)) - .enable_node_control() // Enable node control capability + .with_node_control() // Enable node control capability .chaos_with(|c| { c.restart() // Random restart chaos .min_delay(Duration::from_secs(30)) // Min time between restarts diff --git a/logos/examples/doc-snippets/src/examples_advanced_aggressive_chaos_test.rs b/logos/examples/doc-snippets/src/examples_advanced_aggressive_chaos_test.rs index bb9e927..b3f18a3 100644 --- a/logos/examples/doc-snippets/src/examples_advanced_aggressive_chaos_test.rs +++ b/logos/examples/doc-snippets/src/examples_advanced_aggressive_chaos_test.rs @@ -7,7 +7,7 @@ use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt}; pub async fn aggressive_chaos_test() -> Result<()> { let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().nodes(4)) - .enable_node_control() + .with_node_control() .wallets(50) .transactions_with(|txs| txs.rate(10).users(20)) .chaos_with(|c| { diff --git a/logos/examples/doc-snippets/src/examples_chaos_resilience.rs b/logos/examples/doc-snippets/src/examples_chaos_resilience.rs index 7545260..ba8fc49 100644 --- a/logos/examples/doc-snippets/src/examples_chaos_resilience.rs +++ b/logos/examples/doc-snippets/src/examples_chaos_resilience.rs @@ -7,7 +7,7 @@ use testing_framework_workflows::{ChaosBuilderExt, ScenarioBuilderExt}; pub async fn chaos_resilience() -> Result<()> { let mut plan = ScenarioBuilder::topology_with(|t| t.network_star().nodes(4)) - .enable_node_control() + .with_node_control() .wallets(20) .transactions_with(|txs| txs.rate(3).users(10)) .chaos_with(|c| { diff --git a/logos/examples/doc-snippets/src/testing_philosophy_determinism_first.rs b/logos/examples/doc-snippets/src/testing_philosophy_determinism_first.rs index 160db8c..1053c08 100644 --- a/logos/examples/doc-snippets/src/testing_philosophy_determinism_first.rs +++ b/logos/examples/doc-snippets/src/testing_philosophy_determinism_first.rs @@ -16,7 +16,7 @@ pub fn determinism_first() -> SnippetResult<()> { // Separate: chaos test (introduces randomness) let _chaos_plan = ScenarioBuilder::topology_with(|t| t.network_star().nodes(3)) - .enable_node_control() + .with_node_control() .chaos_with(|c| { c.restart() .min_delay(Duration::from_secs(30)) diff --git a/logos/examples/src/bin/compose_runner.rs b/logos/examples/src/bin/compose_runner.rs index 25cecf5..2254ca8 100644 --- a/logos/examples/src/bin/compose_runner.rs +++ b/logos/examples/src/bin/compose_runner.rs @@ -41,7 +41,7 @@ async fn run_compose_case(nodes: usize, run_duration: Duration) -> Result<()> { t.with_network_layout(Libp2pNetworkLayout::Star) .with_node_count(nodes) }) - .enable_node_control() + .with_node_control() .with_run_duration(run_duration) .with_deployment_seed(seed) .initialize_wallet( diff --git a/logos/examples/src/bin/k8s_runner.rs b/logos/examples/src/bin/k8s_runner.rs index ff0e554..8e2dba6 100644 --- a/logos/examples/src/bin/k8s_runner.rs +++ b/logos/examples/src/bin/k8s_runner.rs @@ -37,7 +37,7 @@ async fn run_k8s_case(nodes: usize, run_duration: Duration) -> Result<()> { t.with_network_layout(Libp2pNetworkLayout::Star) .with_node_count(nodes) }) - .enable_observability() + .with_observability() .with_run_duration(run_duration) .with_deployment_seed(seed) .initialize_wallet( diff --git a/logos/examples/tests/dynamic_join.rs b/logos/examples/tests/dynamic_join.rs index ba88ee1..93a3f3c 100644 --- a/logos/examples/tests/dynamic_join.rs +++ b/logos/examples/tests/dynamic_join.rs @@ -115,7 +115,7 @@ async fn dynamic_join_reaches_consensus_liveness() -> Result<()> { t.with_network_layout(Libp2pNetworkLayout::Star) .with_node_count(2) }) - .enable_node_control() + .with_node_control() .with_workload(JoinNodeWorkload::new("joiner")) .with_expectation(lb_framework::workloads::ConsensusLiveness::::default()) .with_run_duration(Duration::from_secs(60)) @@ -135,7 +135,7 @@ async fn dynamic_join_with_peers_reaches_consensus_liveness() -> Result<()> { t.with_network_layout(Libp2pNetworkLayout::Star) .with_node_count(2) }) - .enable_node_control() + .with_node_control() .with_workload(JoinNodeWithPeersWorkload::new( "joiner", vec!["node-0".to_string()], diff --git a/logos/examples/tests/local_deployer_restart.rs b/logos/examples/tests/local_deployer_restart.rs index 10649f3..078a1c0 100644 --- a/logos/examples/tests/local_deployer_restart.rs +++ b/logos/examples/tests/local_deployer_restart.rs @@ -12,7 +12,7 @@ use tracing_subscriber::fmt::try_init; async fn local_restart_node() -> Result<()> { let _ = try_init(); let mut scenario = ScenarioBuilder::deployment_with(|t| t.with_node_count(1)) - .enable_node_control() + .with_node_control() .with_run_duration(Duration::from_secs(1)) .build()?; diff --git a/logos/runtime/ext/src/scenario/mod.rs b/logos/runtime/ext/src/scenario/mod.rs index a25297d..50c976d 100644 --- a/logos/runtime/ext/src/scenario/mod.rs +++ b/logos/runtime/ext/src/scenario/mod.rs @@ -63,7 +63,7 @@ impl CoreBuilderExt for ScenarioBuilder { impl CoreBuilderExt for NodeControlScenarioBuilder { fn deployment_with(f: impl FnOnce(DeploymentBuilder) -> DeploymentBuilder) -> Self { - ScenarioBuilder::deployment_with(f).enable_node_control() + ScenarioBuilder::deployment_with(f).with_node_control() } fn with_wallet_config(self, wallet: WalletConfig) -> Self { @@ -82,7 +82,7 @@ impl CoreBuilderExt for NodeControlScenarioBuilder { impl CoreBuilderExt for ObservabilityScenarioBuilder { fn deployment_with(f: impl FnOnce(DeploymentBuilder) -> DeploymentBuilder) -> Self { - ScenarioBuilder::deployment_with(f).enable_observability() + ScenarioBuilder::deployment_with(f).with_observability() } fn with_wallet_config(self, wallet: WalletConfig) -> Self { diff --git a/testing-framework/core/src/scenario/builder_ext.rs b/testing-framework/core/src/scenario/builder_ext.rs index 9ffca85..b771c2e 100644 --- a/testing-framework/core/src/scenario/builder_ext.rs +++ b/testing-framework/core/src/scenario/builder_ext.rs @@ -95,7 +95,7 @@ impl ObservabilityBuilderExt for ScenarioBuilder { type Env = E; fn with_metrics_query_url(self, url: Url) -> ObservabilityScenarioBuilder { - self.with_observability(single_url_observability(Some(url), None, None)) + self.with_observability_capability(single_url_observability(Some(url), None, None)) } fn with_metrics_query_url_str(self, url: &str) -> ObservabilityScenarioBuilder { @@ -112,7 +112,7 @@ impl ObservabilityBuilderExt for ScenarioBuilder { } fn with_metrics_otlp_ingest_url(self, url: Url) -> ObservabilityScenarioBuilder { - self.with_observability(single_url_observability(None, Some(url), None)) + self.with_observability_capability(single_url_observability(None, Some(url), None)) } fn with_metrics_otlp_ingest_url_str(self, url: &str) -> ObservabilityScenarioBuilder { @@ -129,7 +129,7 @@ impl ObservabilityBuilderExt for ScenarioBuilder { } fn with_grafana_url(self, url: Url) -> ObservabilityScenarioBuilder { - self.with_observability(single_url_observability(None, None, Some(url))) + self.with_observability_capability(single_url_observability(None, None, Some(url))) } fn with_grafana_url_str(self, url: &str) -> ObservabilityScenarioBuilder { diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition.rs index ff69ac5..0aa2350 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition.rs @@ -405,14 +405,20 @@ impl ScenarioBuilder { } #[must_use] - pub fn enable_node_control(self) -> NodeControlScenarioBuilder { + pub fn with_node_control(self) -> NodeControlScenarioBuilder { NodeControlScenarioBuilder { inner: self.inner.with_capabilities(NodeControlCapability), } } #[must_use] - pub fn enable_observability(self) -> ObservabilityScenarioBuilder { + #[doc(hidden)] + pub fn enable_node_control(self) -> NodeControlScenarioBuilder { + self.with_node_control() + } + + #[must_use] + pub fn with_observability(self) -> ObservabilityScenarioBuilder { ObservabilityScenarioBuilder { inner: self .inner @@ -420,11 +426,17 @@ impl ScenarioBuilder { } } + #[must_use] + #[doc(hidden)] + pub fn enable_observability(self) -> ObservabilityScenarioBuilder { + self.with_observability() + } + pub fn build(self) -> Result, ScenarioBuildError> { self.inner.build() } - pub(crate) fn with_observability( + pub(crate) fn with_observability_capability( self, observability: ObservabilityCapability, ) -> ObservabilityScenarioBuilder { @@ -842,14 +854,26 @@ where impl Builder { #[must_use] - pub fn enable_node_control(self) -> Builder { + pub fn with_node_control(self) -> Builder { self.with_capabilities(NodeControlCapability) } #[must_use] - pub fn enable_observability(self) -> Builder { + #[doc(hidden)] + pub fn enable_node_control(self) -> Builder { + self.with_node_control() + } + + #[must_use] + pub fn with_observability(self) -> Builder { self.with_capabilities(ObservabilityCapability::default()) } + + #[must_use] + #[doc(hidden)] + pub fn enable_observability(self) -> Builder { + self.with_observability() + } } fn initialize_components(