Validate scenario mode guarantees early

This commit is contained in:
andrussal 2026-03-08 14:52:11 +01:00
parent cf1e6185fa
commit 7e3531a4b2
2 changed files with 127 additions and 1 deletions

View File

@ -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<E: Application, Caps> Builder<E, Caps> {
#[must_use]
/// Finalize the scenario, computing run metrics and initializing
/// components.
pub fn build(self) -> Result<Scenario<E, Caps>, ScenarioBuildError> {
pub fn build(self) -> Result<Scenario<E, Caps>, 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::<Caps>(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<Caps>(sources: &ScenarioSources) -> Result<(), ScenarioBuildError>
where
Caps: RequiresNodeControl,
{
validate_external_only_sources(sources)?;
validate_node_control_profile::<Caps>(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<Caps>(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<E: Application> Builder<E, ()> {
#[must_use]
pub fn enable_node_control(self) -> Builder<E, NodeControlCapability> {
@ -813,3 +862,59 @@ fn expectation_cooldown_for(override_value: Option<Duration>) -> 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::<NodeControlCapability>(&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::<NodeControlCapability>(&sources)
.expect("existing cluster should be considered controllable");
}
}

View File

@ -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)