From b530c0ecbf532d83663e64c703d8dead4b03d627 Mon Sep 17 00:00:00 2001 From: andrussal Date: Sun, 8 Mar 2026 15:47:13 +0100 Subject: [PATCH] Split scenario definition into focused modules --- .../{definition.rs => definition/builder.rs} | 423 ++---------------- .../core/src/scenario/definition/mod.rs | 8 + .../core/src/scenario/definition/model.rs | 203 +++++++++ .../src/scenario/definition/validation.rs | 199 ++++++++ 4 files changed, 438 insertions(+), 395 deletions(-) rename testing-framework/core/src/scenario/{definition.rs => definition/builder.rs} (59%) create mode 100644 testing-framework/core/src/scenario/definition/mod.rs create mode 100644 testing-framework/core/src/scenario/definition/model.rs create mode 100644 testing-framework/core/src/scenario/definition/validation.rs diff --git a/testing-framework/core/src/scenario/definition.rs b/testing-framework/core/src/scenario/definition/builder.rs similarity index 59% rename from testing-framework/core/src/scenario/definition.rs rename to testing-framework/core/src/scenario/definition/builder.rs index 0aa2350..fe5b861 100644 --- a/testing-framework/core/src/scenario/definition.rs +++ b/testing-framework/core/src/scenario/definition/builder.rs @@ -1,184 +1,48 @@ use std::{sync::Arc, time::Duration}; -use thiserror::Error; -use tracing::{debug, info}; +use tracing::info; use super::{ - Application, ClusterControlProfile, ClusterMode, DeploymentPolicy, DynError, ExistingCluster, - ExternalNodeSource, HttpReadinessRequirement, IntoExistingCluster, NodeControlCapability, - ObservabilityCapability, RequiresNodeControl, - builder_ops::CoreBuilderAccess, - expectation::Expectation, - runtime::{ - context::RunMetrics, - orchestration::{SourceOrchestrationPlan, SourceOrchestrationPlanError}, + model::{Scenario, ScenarioBuildError}, + validation::{ + build_source_orchestration_plan, enforce_min_duration, expectation_cooldown_for, + initialize_components, validate_source_contract, }, - sources::ScenarioSources, - workload::Workload, }; -use crate::topology::{DeploymentDescriptor, DeploymentProvider, DeploymentSeed, DynTopologyError}; - -const MIN_EXPECTATION_FALLBACK_SECS: u64 = 10; -const MIN_RUN_DURATION_SECS: u64 = 10; - -#[derive(Debug, Error)] -pub enum ScenarioBuildError { - #[error("topology build failed: {0}")] - Topology(#[source] DynTopologyError), - #[error("workload '{name}' failed to initialize")] - WorkloadInit { name: String, source: DynError }, - #[error("expectation '{name}' failed to initialize")] - ExpectationInit { name: String, source: DynError }, - #[error("invalid scenario source configuration: {message}")] - SourceConfiguration { message: String }, - #[error("scenario source mode '{mode}' is not wired into deployers yet")] - SourceModeNotWiredYet { mode: &'static str }, -} - -/// Immutable scenario definition used by the runner, workloads, and -/// expectations. -pub struct Scenario { - deployment: E::Deployment, - workloads: Vec>>, - expectations: Vec>>, - duration: Duration, - expectation_cooldown: Duration, - deployment_policy: DeploymentPolicy, - sources: ScenarioSources, - source_orchestration_plan: SourceOrchestrationPlan, - capabilities: Caps, -} - -impl Scenario { - fn new( - deployment: E::Deployment, - workloads: Vec>>, - expectations: Vec>>, - duration: Duration, - expectation_cooldown: Duration, - deployment_policy: DeploymentPolicy, - sources: ScenarioSources, - source_orchestration_plan: SourceOrchestrationPlan, - capabilities: Caps, - ) -> Self { - Self { - deployment, - workloads, - expectations, - duration, - expectation_cooldown, - deployment_policy, - sources, - source_orchestration_plan, - capabilities, - } - } - - #[must_use] - pub fn deployment(&self) -> &E::Deployment { - &self.deployment - } - - #[must_use] - pub fn workloads(&self) -> &[Arc>] { - &self.workloads - } - - #[must_use] - pub fn expectations(&self) -> &[Box>] { - &self.expectations - } - - #[must_use] - pub fn expectations_mut(&mut self) -> &mut [Box>] { - &mut self.expectations - } - - #[must_use] - pub const fn duration(&self) -> Duration { - self.duration - } - - #[must_use] - pub const fn expectation_cooldown(&self) -> Duration { - self.expectation_cooldown - } - - #[must_use] - pub const fn http_readiness_requirement(&self) -> HttpReadinessRequirement { - self.deployment_policy.readiness_requirement - } - - #[must_use] - pub const fn deployment_policy(&self) -> DeploymentPolicy { - self.deployment_policy - } - - #[must_use] - pub fn existing_cluster(&self) -> Option<&ExistingCluster> { - self.sources.existing_cluster() - } - - #[must_use] - pub const fn cluster_mode(&self) -> ClusterMode { - 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> { - self.existing_cluster() - } - - #[must_use] - pub fn external_nodes(&self) -> &[ExternalNodeSource] { - self.sources.external_nodes() - } - - #[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 - } - - #[must_use] - pub const fn capabilities(&self) -> &Caps { - &self.capabilities - } -} +use crate::{ + scenario::{ + Application, DeploymentPolicy, DynError, ExistingCluster, ExternalNodeSource, + HttpReadinessRequirement, IntoExistingCluster, NodeControlCapability, + ObservabilityCapability, RequiresNodeControl, builder_ops::CoreBuilderAccess, + expectation::Expectation, runtime::context::RunMetrics, sources::ScenarioSources, + workload::Workload, + }, + topology::{DeploymentDescriptor, DeploymentProvider, DeploymentSeed}, +}; /// Scenario builder entry point. pub struct Builder { - deployment_provider: Box>, - topology_seed: Option, - workloads: Vec>>, - expectations: Vec>>, - duration: Duration, - expectation_cooldown: Option, - deployment_policy: DeploymentPolicy, - sources: ScenarioSources, - capabilities: Caps, + pub(super) deployment_provider: Box>, + pub(super) topology_seed: Option, + pub(super) workloads: Vec>>, + pub(super) expectations: Vec>>, + pub(super) duration: Duration, + pub(super) expectation_cooldown: Option, + pub(super) deployment_policy: DeploymentPolicy, + pub(super) sources: ScenarioSources, + pub(super) capabilities: Caps, } pub struct ScenarioBuilder { - inner: Builder, + pub(super) inner: Builder, } pub struct NodeControlScenarioBuilder { - inner: Builder, + pub(super) inner: Builder, } pub struct ObservabilityScenarioBuilder { - inner: Builder, + pub(super) inner: Builder, } macro_rules! impl_common_builder_methods { @@ -432,10 +296,6 @@ impl ScenarioBuilder { self.with_observability() } - pub fn build(self) -> Result, ScenarioBuildError> { - self.inner.build() - } - pub(crate) fn with_observability_capability( self, observability: ObservabilityCapability, @@ -447,27 +307,15 @@ impl ScenarioBuilder { } impl_common_builder_methods!(ScenarioBuilder); - -impl NodeControlScenarioBuilder { - pub fn build(self) -> Result, ScenarioBuildError> { - self.inner.build() - } -} - impl_common_builder_methods!(NodeControlScenarioBuilder); +impl_common_builder_methods!(ObservabilityScenarioBuilder); impl ObservabilityScenarioBuilder { - pub fn build(self) -> Result, ScenarioBuildError> { - self.inner.build() - } - pub(crate) fn capabilities_mut(&mut self) -> &mut ObservabilityCapability { self.inner.capabilities_mut() } } -impl_common_builder_methods!(ObservabilityScenarioBuilder); - impl Builder { #[must_use] /// Transform the existing deployment provider while preserving all @@ -521,36 +369,6 @@ impl Builder { } } - #[must_use] - pub const fn capabilities(&self) -> &Caps { - &self.capabilities - } - - #[must_use] - pub const fn capabilities_mut(&mut self) -> &mut Caps { - &mut self.capabilities - } - - #[must_use] - pub const fn run_duration(&self) -> Duration { - self.duration - } - - #[must_use] - pub const fn expectation_cooldown_override(&self) -> Option { - self.expectation_cooldown - } - - #[must_use] - pub const fn http_readiness_requirement(&self) -> HttpReadinessRequirement { - self.deployment_policy.readiness_requirement - } - - #[must_use] - pub const fn deployment_policy(&self) -> DeploymentPolicy { - self.deployment_policy - } - #[must_use] pub fn with_deployment_seed(mut self, seed: DeploymentSeed) -> Self { self.topology_seed = Some(seed); @@ -796,62 +614,6 @@ impl BuilderParts { } } -fn build_source_orchestration_plan( - sources: &ScenarioSources, -) -> Result { - 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 } => { - ScenarioBuildError::SourceModeNotWiredYet { mode } - } - } -} - -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 && matches!(profile, ClusterControlProfile::ExternalUncontrolled) { - return Err(ScenarioBuildError::SourceConfiguration { - message: format!( - "node control is not available for cluster mode '{}' with control profile '{}'", - sources.cluster_mode().as_str(), - profile.as_str(), - ), - }); - } - - Ok(()) -} - impl Builder { #[must_use] pub fn with_node_control(self) -> Builder { @@ -875,132 +637,3 @@ impl Builder { self.with_observability() } } - -fn initialize_components( - descriptors: &E::Deployment, - run_metrics: &RunMetrics, - workloads: &mut [Box>], - expectations: &mut [Box>], -) -> Result<(), ScenarioBuildError> { - initialize_workloads(descriptors, run_metrics, workloads)?; - - initialize_expectations(descriptors, run_metrics, expectations)?; - - Ok(()) -} - -fn initialize_workloads( - descriptors: &E::Deployment, - run_metrics: &RunMetrics, - workloads: &mut [Box>], -) -> Result<(), ScenarioBuildError> { - for workload in workloads { - debug!(workload = workload.name(), "initializing workload"); - let name = workload.name().to_owned(); - - workload - .init(descriptors, run_metrics) - .map_err(|source| workload_init_error(name, source))?; - } - - Ok(()) -} - -fn initialize_expectations( - descriptors: &E::Deployment, - run_metrics: &RunMetrics, - expectations: &mut [Box>], -) -> Result<(), ScenarioBuildError> { - for expectation in expectations { - debug!(expectation = expectation.name(), "initializing expectation"); - let name = expectation.name().to_owned(); - - expectation - .init(descriptors, run_metrics) - .map_err(|source| expectation_init_error(name, source))?; - } - - Ok(()) -} - -fn workload_init_error(name: String, source: DynError) -> ScenarioBuildError { - ScenarioBuildError::WorkloadInit { name, source } -} - -fn expectation_init_error(name: String, source: DynError) -> ScenarioBuildError { - ScenarioBuildError::ExpectationInit { name, source } -} - -fn enforce_min_duration(requested: Duration) -> Duration { - let min_duration = min_run_duration(); - - requested.max(min_duration) -} - -fn default_expectation_cooldown() -> Duration { - Duration::from_secs(MIN_EXPECTATION_FALLBACK_SECS) -} - -fn expectation_cooldown_for(override_value: Option) -> Duration { - override_value.unwrap_or_else(default_expectation_cooldown) -} - -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 is not available for cluster mode 'external-only' with 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/definition/mod.rs b/testing-framework/core/src/scenario/definition/mod.rs new file mode 100644 index 0000000..a0e3514 --- /dev/null +++ b/testing-framework/core/src/scenario/definition/mod.rs @@ -0,0 +1,8 @@ +mod builder; +mod model; +mod validation; + +pub use builder::{ + Builder, NodeControlScenarioBuilder, ObservabilityScenarioBuilder, ScenarioBuilder, +}; +pub use model::{Scenario, ScenarioBuildError}; diff --git a/testing-framework/core/src/scenario/definition/model.rs b/testing-framework/core/src/scenario/definition/model.rs new file mode 100644 index 0000000..d0c792a --- /dev/null +++ b/testing-framework/core/src/scenario/definition/model.rs @@ -0,0 +1,203 @@ +use std::{sync::Arc, time::Duration}; + +use thiserror::Error; + +use super::builder::Builder; +use crate::{ + scenario::{ + Application, ClusterControlProfile, ClusterMode, DeploymentPolicy, DynError, + ExistingCluster, ExternalNodeSource, HttpReadinessRequirement, expectation::Expectation, + runtime::orchestration::SourceOrchestrationPlan, sources::ScenarioSources, + workload::Workload, + }, + topology::DynTopologyError, +}; + +#[derive(Debug, Error)] +pub enum ScenarioBuildError { + #[error("topology build failed: {0}")] + Topology(#[source] DynTopologyError), + #[error("workload '{name}' failed to initialize")] + WorkloadInit { name: String, source: DynError }, + #[error("expectation '{name}' failed to initialize")] + ExpectationInit { name: String, source: DynError }, + #[error("invalid scenario source configuration: {message}")] + SourceConfiguration { message: String }, + #[error("scenario source mode '{mode}' is not wired into deployers yet")] + SourceModeNotWiredYet { mode: &'static str }, +} + +/// Immutable scenario definition used by the runner, workloads, and +/// expectations. +pub struct Scenario { + deployment: E::Deployment, + workloads: Vec>>, + expectations: Vec>>, + duration: Duration, + expectation_cooldown: Duration, + deployment_policy: DeploymentPolicy, + sources: ScenarioSources, + source_orchestration_plan: SourceOrchestrationPlan, + capabilities: Caps, +} + +impl Scenario { + pub(super) fn new( + deployment: E::Deployment, + workloads: Vec>>, + expectations: Vec>>, + duration: Duration, + expectation_cooldown: Duration, + deployment_policy: DeploymentPolicy, + sources: ScenarioSources, + source_orchestration_plan: SourceOrchestrationPlan, + capabilities: Caps, + ) -> Self { + Self { + deployment, + workloads, + expectations, + duration, + expectation_cooldown, + deployment_policy, + sources, + source_orchestration_plan, + capabilities, + } + } + + #[must_use] + pub fn deployment(&self) -> &E::Deployment { + &self.deployment + } + + #[must_use] + pub fn workloads(&self) -> &[Arc>] { + &self.workloads + } + + #[must_use] + pub fn expectations(&self) -> &[Box>] { + &self.expectations + } + + #[must_use] + pub fn expectations_mut(&mut self) -> &mut [Box>] { + &mut self.expectations + } + + #[must_use] + pub const fn duration(&self) -> Duration { + self.duration + } + + #[must_use] + pub const fn expectation_cooldown(&self) -> Duration { + self.expectation_cooldown + } + + #[must_use] + pub const fn http_readiness_requirement(&self) -> HttpReadinessRequirement { + self.deployment_policy.readiness_requirement + } + + #[must_use] + pub const fn deployment_policy(&self) -> DeploymentPolicy { + self.deployment_policy + } + + #[must_use] + pub fn existing_cluster(&self) -> Option<&ExistingCluster> { + self.sources.existing_cluster() + } + + #[must_use] + pub const fn cluster_mode(&self) -> ClusterMode { + 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> { + self.existing_cluster() + } + + #[must_use] + pub fn external_nodes(&self) -> &[ExternalNodeSource] { + self.sources.external_nodes() + } + + #[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 + } + + #[must_use] + pub const fn capabilities(&self) -> &Caps { + &self.capabilities + } +} + +impl super::builder::ScenarioBuilder { + pub fn build(self) -> Result, ScenarioBuildError> { + self.inner.build() + } +} + +impl super::builder::NodeControlScenarioBuilder { + pub fn build( + self, + ) -> Result, ScenarioBuildError> { + self.inner.build() + } +} + +impl super::builder::ObservabilityScenarioBuilder { + pub fn build( + self, + ) -> Result, ScenarioBuildError> { + self.inner.build() + } +} + +impl Builder { + #[must_use] + pub const fn capabilities(&self) -> &Caps { + &self.capabilities + } + + #[must_use] + pub const fn capabilities_mut(&mut self) -> &mut Caps { + &mut self.capabilities + } + + #[must_use] + pub const fn run_duration(&self) -> Duration { + self.duration + } + + #[must_use] + pub const fn expectation_cooldown_override(&self) -> Option { + self.expectation_cooldown + } + + #[must_use] + pub const fn http_readiness_requirement(&self) -> HttpReadinessRequirement { + self.deployment_policy.readiness_requirement + } + + #[must_use] + pub const fn deployment_policy(&self) -> DeploymentPolicy { + self.deployment_policy + } +} diff --git a/testing-framework/core/src/scenario/definition/validation.rs b/testing-framework/core/src/scenario/definition/validation.rs new file mode 100644 index 0000000..86876b2 --- /dev/null +++ b/testing-framework/core/src/scenario/definition/validation.rs @@ -0,0 +1,199 @@ +use std::time::Duration; + +use tracing::debug; + +use super::model::ScenarioBuildError; +use crate::scenario::{ + Application, ClusterControlProfile, ClusterMode, DynError, RequiresNodeControl, + expectation::Expectation, + runtime::{ + context::RunMetrics, + orchestration::{SourceOrchestrationPlan, SourceOrchestrationPlanError}, + }, + sources::ScenarioSources, + workload::Workload, +}; + +const MIN_EXPECTATION_FALLBACK_SECS: u64 = 10; +const MIN_RUN_DURATION_SECS: u64 = 10; + +pub(super) fn build_source_orchestration_plan( + sources: &ScenarioSources, +) -> Result { + SourceOrchestrationPlan::try_from_sources(sources).map_err(source_plan_error_to_build_error) +} + +pub(super) 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 } => { + ScenarioBuildError::SourceModeNotWiredYet { mode } + } + } +} + +pub(super) fn initialize_components( + descriptors: &E::Deployment, + run_metrics: &RunMetrics, + workloads: &mut [Box>], + expectations: &mut [Box>], +) -> Result<(), ScenarioBuildError> { + initialize_workloads(descriptors, run_metrics, workloads)?; + initialize_expectations(descriptors, run_metrics, expectations)?; + Ok(()) +} + +fn initialize_workloads( + descriptors: &E::Deployment, + run_metrics: &RunMetrics, + workloads: &mut [Box>], +) -> Result<(), ScenarioBuildError> { + for workload in workloads { + debug!(workload = workload.name(), "initializing workload"); + let name = workload.name().to_owned(); + + workload + .init(descriptors, run_metrics) + .map_err(|source| workload_init_error(name, source))?; + } + + Ok(()) +} + +fn initialize_expectations( + descriptors: &E::Deployment, + run_metrics: &RunMetrics, + expectations: &mut [Box>], +) -> Result<(), ScenarioBuildError> { + for expectation in expectations { + debug!(expectation = expectation.name(), "initializing expectation"); + let name = expectation.name().to_owned(); + + expectation + .init(descriptors, run_metrics) + .map_err(|source| expectation_init_error(name, source))?; + } + + Ok(()) +} + +fn workload_init_error(name: String, source: DynError) -> ScenarioBuildError { + ScenarioBuildError::WorkloadInit { name, source } +} + +fn expectation_init_error(name: String, source: DynError) -> ScenarioBuildError { + ScenarioBuildError::ExpectationInit { name, source } +} + +pub(super) fn enforce_min_duration(requested: Duration) -> Duration { + requested.max(min_run_duration()) +} + +fn default_expectation_cooldown() -> Duration { + Duration::from_secs(MIN_EXPECTATION_FALLBACK_SECS) +} + +pub(super) fn expectation_cooldown_for(override_value: Option) -> Duration { + override_value.unwrap_or_else(default_expectation_cooldown) +} + +fn min_run_duration() -> Duration { + Duration::from_secs(MIN_RUN_DURATION_SECS) +} + +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 && matches!(profile, ClusterControlProfile::ExternalUncontrolled) { + return Err(ScenarioBuildError::SourceConfiguration { + message: format!( + "node control is not available for cluster mode '{}' with control profile '{}'", + sources.cluster_mode().as_str(), + profile.as_str(), + ), + }); + } + + Ok(()) +} + +#[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 is not available for cluster mode 'external-only' with 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"); + } +}