use anyhow::Result;
use crate::playbook::{Playbook, RuntimeCapabilities};
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub playbook_name: String,
pub errors: Vec<ValidationError>,
pub warnings: Vec<String>,
}
impl ValidationReport {
pub fn is_ok(&self) -> bool {
self.errors.is_empty()
}
}
#[derive(Debug, Clone)]
pub enum ValidationError {
IncompatibleProfile { required: String },
MissingTool { tool: String, supported: Vec<String> },
MissingFeature { feature: String, supported: Vec<String> },
}
pub fn validate_capabilities(
playbook: &Playbook,
runtime: &RuntimeCapabilities,
) -> Result<ValidationReport> {
let mut report = ValidationReport {
playbook_name: playbook.metadata.name.clone(),
errors: Vec::new(),
warnings: Vec::new(),
};
let executor = match &playbook.executor {
Some(e) => e,
None => return Ok(report),
};
match executor.profile.as_str() {
"distributed" => {
if runtime.runtime != "distributed" {
report.errors.push(ValidationError::IncompatibleProfile {
required: "distributed".to_string(),
});
}
}
"local" | "auto" | "" => {
}
other => {
report.warnings.push(format!(
"Unknown executor profile '{}'; proceeding with '{}' runtime",
other, runtime.runtime
));
}
}
if executor.version != runtime.version && !executor.version.is_empty() {
report.warnings.push(format!(
"Playbook requires '{}', runtime provides '{}'. Some features may not work as expected.",
executor.version, runtime.version,
));
}
if let Some(requires) = &executor.requires {
for tool in &requires.tools {
if !runtime.tools.contains(tool) {
report.errors.push(ValidationError::MissingTool {
tool: tool.clone(),
supported: runtime.tools.clone(),
});
}
}
for feature in &requires.features {
if !runtime.features.contains(feature) {
report.errors.push(ValidationError::MissingFeature {
feature: feature.clone(),
supported: runtime.features.clone(),
});
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::playbook::{Executor, ExecutorRequires, Metadata, Playbook};
fn pb(executor: Option<Executor>) -> Playbook {
Playbook {
api_version: "noetl.io/v2".to_string(),
kind: "Playbook".to_string(),
metadata: Metadata {
name: "test".to_string(),
path: None,
},
executor,
workload: None,
workflow: Vec::new(),
}
}
#[test]
fn no_executor_block_is_ok() {
let playbook = pb(None);
let runtime = RuntimeCapabilities::local();
let report = validate_capabilities(&playbook, &runtime).unwrap();
assert!(report.is_ok());
}
#[test]
fn distributed_profile_fails_against_local_runtime() {
let playbook = pb(Some(Executor {
profile: "distributed".to_string(),
version: "".to_string(),
requires: None,
spec: None,
}));
let runtime = RuntimeCapabilities::local();
let report = validate_capabilities(&playbook, &runtime).unwrap();
assert!(!report.is_ok());
assert!(matches!(
report.errors[0],
ValidationError::IncompatibleProfile { .. }
));
}
#[test]
fn missing_tool_is_an_error() {
let playbook = pb(Some(Executor {
profile: "local".to_string(),
version: "noetl-runtime/1".to_string(),
requires: Some(ExecutorRequires {
tools: vec!["nonexistent".to_string()],
features: Vec::new(),
}),
spec: None,
}));
let runtime = RuntimeCapabilities::local();
let report = validate_capabilities(&playbook, &runtime).unwrap();
assert!(!report.is_ok());
assert!(matches!(report.errors[0], ValidationError::MissingTool { .. }));
}
#[test]
fn auto_profile_is_compatible_with_local() {
let playbook = pb(Some(Executor {
profile: "auto".to_string(),
version: "".to_string(),
requires: None,
spec: None,
}));
let runtime = RuntimeCapabilities::local();
let report = validate_capabilities(&playbook, &runtime).unwrap();
assert!(report.is_ok());
}
}