Skip to main content

noetl_executor/
capabilities.rs

1//! Capability validation — checks whether a playbook's required
2//! `executor.requires.{tools, features}` set is satisfied by a
3//! runtime's advertised [`crate::playbook::RuntimeCapabilities`].
4//!
5//! Extracted from `repos/cli/src/playbook_runner.rs` lines 142-211
6//! in R-1.1 PR-2b per § H.10.3 of Appendix H of the global hybrid
7//! cloud blueprint.
8
9use anyhow::Result;
10
11use crate::playbook::{Playbook, RuntimeCapabilities};
12
13/// Outcome of validating a playbook against runtime capabilities.
14///
15/// The validator returns a [`ValidationReport`] instead of just an
16/// `anyhow::bail!` so the caller (CLI's `playbook_runner.rs` today,
17/// the worker's load-playbook step tomorrow) can format the failure
18/// against its own context — e.g. the CLI includes the playbook path
19/// in the error message; the worker may include the execution_id.
20#[derive(Debug, Clone)]
21pub struct ValidationReport {
22    pub playbook_name: String,
23    pub errors: Vec<ValidationError>,
24    pub warnings: Vec<String>,
25}
26
27impl ValidationReport {
28    pub fn is_ok(&self) -> bool {
29        self.errors.is_empty()
30    }
31}
32
33#[derive(Debug, Clone)]
34pub enum ValidationError {
35    /// Playbook requires a different executor profile (e.g. "distributed").
36    IncompatibleProfile { required: String },
37    /// Playbook requires a tool kind the runtime does not support.
38    MissingTool { tool: String, supported: Vec<String> },
39    /// Playbook requires a feature the runtime does not support.
40    MissingFeature { feature: String, supported: Vec<String> },
41}
42
43/// Validate that the given playbook's `executor.requires` set is
44/// satisfied by the supplied runtime capabilities.  Returns a
45/// [`ValidationReport`] describing every mismatch; the caller decides
46/// whether to bail on the first error or report them all.
47pub fn validate_capabilities(
48    playbook: &Playbook,
49    runtime: &RuntimeCapabilities,
50) -> Result<ValidationReport> {
51    let mut report = ValidationReport {
52        playbook_name: playbook.metadata.name.clone(),
53        errors: Vec::new(),
54        warnings: Vec::new(),
55    };
56
57    let executor = match &playbook.executor {
58        Some(e) => e,
59        // No executor block — playbook makes no demands; runtime is fine.
60        None => return Ok(report),
61    };
62
63    // Profile compatibility.
64    match executor.profile.as_str() {
65        "distributed" => {
66            if runtime.runtime != "distributed" {
67                report.errors.push(ValidationError::IncompatibleProfile {
68                    required: "distributed".to_string(),
69                });
70            }
71        }
72        "local" | "auto" | "" => {
73            // Compatible with both runtimes.
74        }
75        other => {
76            report.warnings.push(format!(
77                "Unknown executor profile '{}'; proceeding with '{}' runtime",
78                other, runtime.runtime
79            ));
80        }
81    }
82
83    // Version compatibility.
84    if executor.version != runtime.version && !executor.version.is_empty() {
85        report.warnings.push(format!(
86            "Playbook requires '{}', runtime provides '{}'.  Some features may not work as expected.",
87            executor.version, runtime.version,
88        ));
89    }
90
91    // Required tools + features.
92    if let Some(requires) = &executor.requires {
93        for tool in &requires.tools {
94            if !runtime.tools.contains(tool) {
95                report.errors.push(ValidationError::MissingTool {
96                    tool: tool.clone(),
97                    supported: runtime.tools.clone(),
98                });
99            }
100        }
101        for feature in &requires.features {
102            if !runtime.features.contains(feature) {
103                report.errors.push(ValidationError::MissingFeature {
104                    feature: feature.clone(),
105                    supported: runtime.features.clone(),
106                });
107            }
108        }
109    }
110
111    Ok(report)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::playbook::{Executor, ExecutorRequires, Metadata, Playbook};
118
119    fn pb(executor: Option<Executor>) -> Playbook {
120        Playbook {
121            api_version: "noetl.io/v2".to_string(),
122            kind: "Playbook".to_string(),
123            metadata: Metadata {
124                name: "test".to_string(),
125                path: None,
126            },
127            executor,
128            workload: None,
129            workflow: Vec::new(),
130        }
131    }
132
133    #[test]
134    fn no_executor_block_is_ok() {
135        let playbook = pb(None);
136        let runtime = RuntimeCapabilities::local();
137        let report = validate_capabilities(&playbook, &runtime).unwrap();
138        assert!(report.is_ok());
139    }
140
141    #[test]
142    fn distributed_profile_fails_against_local_runtime() {
143        let playbook = pb(Some(Executor {
144            profile: "distributed".to_string(),
145            version: "".to_string(),
146            requires: None,
147            spec: None,
148        }));
149        let runtime = RuntimeCapabilities::local();
150        let report = validate_capabilities(&playbook, &runtime).unwrap();
151        assert!(!report.is_ok());
152        assert!(matches!(
153            report.errors[0],
154            ValidationError::IncompatibleProfile { .. }
155        ));
156    }
157
158    #[test]
159    fn missing_tool_is_an_error() {
160        let playbook = pb(Some(Executor {
161            profile: "local".to_string(),
162            version: "noetl-runtime/1".to_string(),
163            requires: Some(ExecutorRequires {
164                tools: vec!["nonexistent".to_string()],
165                features: Vec::new(),
166            }),
167            spec: None,
168        }));
169        let runtime = RuntimeCapabilities::local();
170        let report = validate_capabilities(&playbook, &runtime).unwrap();
171        assert!(!report.is_ok());
172        assert!(matches!(report.errors[0], ValidationError::MissingTool { .. }));
173    }
174
175    #[test]
176    fn auto_profile_is_compatible_with_local() {
177        let playbook = pb(Some(Executor {
178            profile: "auto".to_string(),
179            version: "".to_string(),
180            requires: None,
181            spec: None,
182        }));
183        let runtime = RuntimeCapabilities::local();
184        let report = validate_capabilities(&playbook, &runtime).unwrap();
185        assert!(report.is_ok());
186    }
187}