use std::{sync::Arc, time::Duration}; use thiserror::Error; use tracing::{debug, info}; use super::{ Application, AttachSource, DeploymentPolicy, DynError, ExternalNodeSource, HttpReadinessRequirement, NodeControlCapability, ObservabilityCapability, ScenarioSources, SourceReadinessPolicy, builder_ops::CoreBuilderAccess, expectation::Expectation, runtime::{ context::RunMetrics, orchestration::{SourceModeName, SourceOrchestrationPlan, SourceOrchestrationPlanError}, }, 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_readiness_policy: SourceReadinessPolicy, 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_readiness_policy: SourceReadinessPolicy, source_orchestration_plan: SourceOrchestrationPlan, capabilities: Caps, ) -> Self { Self { deployment, workloads, expectations, duration, expectation_cooldown, deployment_policy, sources, source_readiness_policy, 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] /// 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 } #[must_use] pub const fn source_orchestration_plan(&self) -> &SourceOrchestrationPlan { &self.source_orchestration_plan } #[must_use] pub const fn capabilities(&self) -> &Caps { &self.capabilities } } /// 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, source_readiness_policy: SourceReadinessPolicy, capabilities: Caps, } pub struct ScenarioBuilder { inner: Builder, } pub struct NodeControlScenarioBuilder { inner: Builder, } pub struct ObservabilityScenarioBuilder { inner: Builder, } macro_rules! impl_common_builder_methods { ($builder:ident) => { impl $builder { #[must_use] pub fn map_deployment_provider( self, f: impl FnOnce( Box>, ) -> Box>, ) -> Self { self.map_core_builder(|builder| builder.map_deployment_provider(f)) } #[must_use] pub fn with_deployment_provider( self, deployment_provider: Box>, ) -> Self { self.map_core_builder(|builder| { builder.with_deployment_provider(deployment_provider) }) } #[must_use] pub fn with_deployment_seed(self, seed: DeploymentSeed) -> Self { self.map_core_builder(|builder| builder.with_deployment_seed(seed)) } #[must_use] pub fn with_workload(self, workload: W) -> Self where W: Workload + 'static, { self.map_core_builder(|builder| builder.with_workload(workload)) } #[must_use] pub fn with_workload_boxed(self, workload: Box>) -> Self { self.map_core_builder(|builder| builder.with_workload_boxed(workload)) } #[must_use] pub fn with_expectation(self, expectation: Exp) -> Self where Exp: Expectation + 'static, { self.map_core_builder(|builder| builder.with_expectation(expectation)) } #[must_use] pub fn with_expectation_boxed(self, expectation: Box>) -> Self { self.map_core_builder(|builder| builder.with_expectation_boxed(expectation)) } #[must_use] pub fn with_run_duration(self, duration: Duration) -> Self { self.map_core_builder(|builder| builder.with_run_duration(duration)) } #[must_use] pub fn with_expectation_cooldown(self, cooldown: Duration) -> Self { self.map_core_builder(|builder| builder.with_expectation_cooldown(cooldown)) } #[must_use] pub fn with_http_readiness_requirement( self, requirement: HttpReadinessRequirement, ) -> Self { self.map_core_builder(|builder| { builder.with_http_readiness_requirement(requirement) }) } #[must_use] pub fn with_deployment_policy(self, policy: DeploymentPolicy) -> Self { self.map_core_builder(|builder| builder.with_deployment_policy(policy)) } #[must_use] pub fn with_attach_source(self, attach: AttachSource) -> Self { self.map_core_builder(|builder| builder.with_attach_source(attach)) } #[must_use] pub fn with_external_node(self, node: ExternalNodeSource) -> Self { 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()) } #[must_use] pub fn run_duration(&self) -> Duration { self.core_builder_ref().run_duration() } } }; } impl CoreBuilderAccess for ScenarioBuilder { type Env = E; type Caps = (); fn map_core_builder( mut self, f: impl FnOnce(Builder) -> Builder, ) -> Self { self.inner = f(self.inner); self } fn core_builder_ref(&self) -> &Builder { &self.inner } fn core_builder_mut(&mut self) -> &mut Builder { &mut self.inner } } impl CoreBuilderAccess for NodeControlScenarioBuilder { type Env = E; type Caps = NodeControlCapability; fn map_core_builder( mut self, f: impl FnOnce(Builder) -> Builder, ) -> Self { self.inner = f(self.inner); self } fn core_builder_ref(&self) -> &Builder { &self.inner } fn core_builder_mut(&mut self) -> &mut Builder { &mut self.inner } } impl CoreBuilderAccess for ObservabilityScenarioBuilder { type Env = E; type Caps = ObservabilityCapability; fn map_core_builder( mut self, f: impl FnOnce(Builder) -> Builder, ) -> Self { self.inner = f(self.inner); self } fn core_builder_ref(&self) -> &Builder { &self.inner } fn core_builder_mut(&mut self) -> &mut Builder { &mut self.inner } } impl Builder { #[must_use] /// Start a builder from a topology provider. pub fn new(deployment_provider: Box>) -> Self { Self { deployment_provider, topology_seed: None, workloads: Vec::new(), expectations: Vec::new(), duration: Duration::ZERO, expectation_cooldown: None, deployment_policy: DeploymentPolicy::default(), sources: ScenarioSources::default(), source_readiness_policy: SourceReadinessPolicy::default(), capabilities: Caps::default(), } } } impl ScenarioBuilder { #[must_use] pub fn new(deployment_provider: Box>) -> Self { Self { inner: Builder::new(deployment_provider), } } #[must_use] pub fn enable_node_control(self) -> NodeControlScenarioBuilder { NodeControlScenarioBuilder { inner: self.inner.with_capabilities(NodeControlCapability), } } #[must_use] pub fn enable_observability(self) -> ObservabilityScenarioBuilder { ObservabilityScenarioBuilder { inner: self .inner .with_capabilities(ObservabilityCapability::default()), } } pub fn build(self) -> Result, ScenarioBuildError> { self.inner.build() } pub(crate) fn with_observability( self, observability: ObservabilityCapability, ) -> ObservabilityScenarioBuilder { ObservabilityScenarioBuilder { inner: self.inner.with_capabilities(observability), } } } impl_common_builder_methods!(ScenarioBuilder); impl NodeControlScenarioBuilder { pub fn build(self) -> Result, ScenarioBuildError> { self.inner.build() } } impl_common_builder_methods!(NodeControlScenarioBuilder); 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 /// accumulated builder state. pub fn map_deployment_provider( mut self, f: impl FnOnce( Box>, ) -> Box>, ) -> Self { self.deployment_provider = f(self.deployment_provider); self } #[must_use] /// Replace the topology provider while preserving all accumulated builder /// state. pub fn with_deployment_provider( mut self, deployment_provider: Box>, ) -> Self { self.deployment_provider = deployment_provider; self } #[must_use] /// Internal capability transition helper. pub(crate) fn with_capabilities(self, capabilities: NewCaps) -> Builder { let Self { deployment_provider, topology_seed, workloads, expectations, duration, expectation_cooldown, deployment_policy, sources, source_readiness_policy, .. } = self; Builder { deployment_provider, topology_seed, workloads, expectations, duration, expectation_cooldown, deployment_policy, sources, source_readiness_policy, capabilities, } } #[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); self } #[must_use] pub fn with_workload(mut self, workload: W) -> Self where W: Workload + 'static, { self.add_workload(Box::new(workload)); self } #[must_use] pub fn with_workload_boxed(mut self, workload: Box>) -> Self { self.add_workload(workload); self } #[must_use] /// Add a standalone expectation not tied to a workload. pub fn with_expectation(mut self, expectation: Exp) -> Self where Exp: Expectation + 'static, { self.add_expectation(Box::new(expectation)); self } #[must_use] pub fn with_expectation_boxed(mut self, expectation: Box>) -> Self { self.add_expectation(expectation); self } #[must_use] /// Configure the intended run duration. pub const fn with_run_duration(mut self, duration: Duration) -> Self { self.duration = duration; self } #[must_use] /// Override the expectation cooldown used by the runner. pub const fn with_expectation_cooldown(mut self, cooldown: Duration) -> Self { self.expectation_cooldown = Some(cooldown); self } #[must_use] pub const fn with_http_readiness_requirement( mut self, requirement: HttpReadinessRequirement, ) -> Self { self.deployment_policy.readiness_requirement = requirement; self } #[must_use] pub const fn with_deployment_policy(mut self, policy: DeploymentPolicy) -> Self { self.deployment_policy = policy; self } #[must_use] pub fn with_attach_source(mut self, attach: AttachSource) -> Self { self.sources.set_attach(attach); self } #[must_use] pub fn with_external_node(mut self, node: ExternalNodeSource) -> Self { self.sources.add_external_node(node); 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.set_external_only(); self } fn add_workload(&mut self, workload: Box>) { self.expectations.extend(workload.expectations()); self.workloads.push(workload); } fn add_expectation(&mut self, expectation: Box>) { self.expectations.push(expectation); } #[must_use] /// Finalize the scenario, computing run metrics and initializing /// components. pub fn build(self) -> Result, ScenarioBuildError> { 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); let source_orchestration_plan = build_source_orchestration_plan(parts.sources(), parts.source_readiness_policy)?; initialize_components( &descriptors, &run_metrics, &mut parts.workloads, &mut parts.expectations, )?; let workloads: Vec>> = parts.workloads.into_iter().map(Arc::from).collect(); info!( nodes = descriptors.node_count(), duration_secs = run_plan.duration.as_secs(), workloads = workloads.len(), expectations = parts.expectations.len(), "scenario built" ); Ok(Scenario::new( descriptors, workloads, parts.expectations, run_plan.duration, run_plan.expectation_cooldown, parts.deployment_policy, parts.sources, parts.source_readiness_policy, source_orchestration_plan, parts.capabilities, )) } } struct RunPlan { duration: Duration, expectation_cooldown: Duration, } struct BuilderParts { deployment_provider: Box>, topology_seed: Option, workloads: Vec>>, expectations: Vec>>, duration: Duration, expectation_cooldown: Option, deployment_policy: DeploymentPolicy, sources: ScenarioSources, source_readiness_policy: SourceReadinessPolicy, capabilities: Caps, } impl BuilderParts { fn from_builder(builder: Builder) -> Self { let Builder { deployment_provider, topology_seed, workloads, expectations, duration, expectation_cooldown, deployment_policy, sources, source_readiness_policy, capabilities, .. } = builder; Self { deployment_provider, topology_seed, workloads, expectations, duration, expectation_cooldown, deployment_policy, sources, source_readiness_policy, capabilities, } } fn resolve_deployment(&self) -> Result { self.deployment_provider .build(self.topology_seed.as_ref()) .map_err(ScenarioBuildError::Topology) } fn run_plan(&self) -> RunPlan { RunPlan { duration: enforce_min_duration(self.duration), expectation_cooldown: expectation_cooldown_for(self.expectation_cooldown), } } fn sources(&self) -> &ScenarioSources { &self.sources } } 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) } fn source_plan_error_to_build_error(error: SourceOrchestrationPlanError) -> ScenarioBuildError { match error { SourceOrchestrationPlanError::SourceModeNotWiredYet { mode } => { ScenarioBuildError::SourceModeNotWiredYet { mode: source_mode_name(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 { self.with_capabilities(NodeControlCapability) } #[must_use] pub fn enable_observability(self) -> Builder { self.with_capabilities(ObservabilityCapability::default()) } } 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) }