From 7e3531a4b231a33b5725eb056ae8a62b810a6c55 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 14:52:11 +0100 Subject: [PATCH] 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)