feat(wfe-yaml): add task file includes with cycle detection and config override

This commit is contained in:
2026-03-26 17:22:02 +00:00
parent 1f14c9ac9a
commit 04c52c8158

View File

@@ -6,13 +6,24 @@ pub mod schema;
pub mod types; pub mod types;
pub mod validation; pub mod validation;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::path::Path;
use serde::de::Error as _; use serde::de::Error as _;
use serde::Deserialize;
use crate::compiler::CompiledWorkflow; use crate::compiler::CompiledWorkflow;
use crate::error::YamlWorkflowError; use crate::error::YamlWorkflowError;
/// Top-level YAML file with optional includes.
#[derive(Deserialize)]
pub struct YamlWorkflowFileWithIncludes {
#[serde(default)]
pub include: Vec<String>,
#[serde(flatten)]
pub file: schema::YamlWorkflowFile,
}
/// Load workflows from a YAML file path, applying variable interpolation. /// Load workflows from a YAML file path, applying variable interpolation.
/// Returns a Vec of compiled workflows (supports multi-workflow files). /// Returns a Vec of compiled workflows (supports multi-workflow files).
pub fn load_workflow( pub fn load_workflow(
@@ -76,6 +87,116 @@ pub fn load_single_workflow_from_str(
Ok(workflows.remove(0)) Ok(workflows.remove(0))
} }
/// Load workflows from a YAML string, resolving `include:` paths relative to `base_path`.
///
/// Processing:
/// 1. Parse the main YAML to get `include:` paths.
/// 2. For each include path, load and parse that YAML file.
/// 3. Merge workflow specs from included files into the main file's specs.
/// 4. Main file's workflows take precedence over included ones (by ID).
/// 5. Proceed with normal validation + compilation.
pub fn load_workflow_with_includes(
yaml: &str,
base_path: &Path,
config: &HashMap<String, serde_json::Value>,
) -> Result<Vec<CompiledWorkflow>, YamlWorkflowError> {
let mut visited = HashSet::new();
let canonical = base_path
.canonicalize()
.unwrap_or_else(|_| base_path.to_path_buf());
visited.insert(canonical.to_string_lossy().to_string());
let interpolated = interpolation::interpolate(yaml, config)?;
let raw_value: serde_yaml::Value = serde_yaml::from_str(&interpolated)?;
let merged_value = yaml_merge_keys::merge_keys_serde(raw_value)
.map_err(|e| {
YamlWorkflowError::Parse(serde_yaml::Error::custom(format!(
"merge key resolution failed: {e}"
)))
})?;
let with_includes: YamlWorkflowFileWithIncludes = serde_yaml::from_value(merged_value)?;
let mut main_specs = resolve_workflow_specs(with_includes.file)?;
// Process includes.
for include_path_str in &with_includes.include {
let include_path = base_path.parent().unwrap_or(base_path).join(include_path_str);
load_includes_recursive(
&include_path,
config,
&mut main_specs,
&mut visited,
)?;
}
// Main file takes precedence: included specs are only added if their ID
// isn't already present. This is handled by load_includes_recursive.
validation::validate_multi(&main_specs)?;
let mut results = Vec::with_capacity(main_specs.len());
for spec in &main_specs {
results.push(compiler::compile(spec)?);
}
Ok(results)
}
fn load_includes_recursive(
path: &Path,
config: &HashMap<String, serde_json::Value>,
specs: &mut Vec<schema::WorkflowSpec>,
visited: &mut HashSet<String>,
) -> Result<(), YamlWorkflowError> {
let canonical = path
.canonicalize()
.map_err(|e| {
YamlWorkflowError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Include file not found: {}: {e}", path.display()),
))
})?;
let canonical_str = canonical.to_string_lossy().to_string();
if !visited.insert(canonical_str.clone()) {
return Err(YamlWorkflowError::Validation(format!(
"Circular include detected: '{}'",
path.display()
)));
}
let yaml = std::fs::read_to_string(&canonical)?;
let interpolated = interpolation::interpolate(&yaml, config)?;
let raw_value: serde_yaml::Value = serde_yaml::from_str(&interpolated)?;
let merged_value = yaml_merge_keys::merge_keys_serde(raw_value)
.map_err(|e| {
YamlWorkflowError::Parse(serde_yaml::Error::custom(format!(
"merge key resolution failed: {e}"
)))
})?;
let with_includes: YamlWorkflowFileWithIncludes = serde_yaml::from_value(merged_value)?;
let included_specs = resolve_workflow_specs(with_includes.file)?;
// Existing IDs in main specs take precedence.
let existing_ids: HashSet<String> = specs.iter().map(|s| s.id.clone()).collect();
for spec in included_specs {
if !existing_ids.contains(&spec.id) {
specs.push(spec);
}
}
// Recurse into nested includes.
for nested_include in &with_includes.include {
let nested_path = canonical.parent().unwrap_or(&canonical).join(nested_include);
load_includes_recursive(&nested_path, config, specs, visited)?;
}
Ok(())
}
/// Resolve a YamlWorkflowFile into a list of WorkflowSpecs. /// Resolve a YamlWorkflowFile into a list of WorkflowSpecs.
fn resolve_workflow_specs( fn resolve_workflow_specs(
file: schema::YamlWorkflowFile, file: schema::YamlWorkflowFile,