feat(wfe-yaml): add YAML workflow definitions with shell executor

Concourse-CI-inspired YAML format for defining workflows. Compiles
to standard WorkflowDefinition + step factories.

Features:
- Schema parsing with serde_yaml (YamlWorkflow, YamlStep, StepConfig)
- ((var.path)) interpolation from config maps at load time
- YAML anchors (&anchor/*alias) fully supported
- Validation at load time (no runtime surprises)
- Shell executor: runs commands via tokio::process, captures stdout,
  parses ##wfe[output name=value] annotations for structured outputs
- Compiler: sequential wiring, parallel blocks, on_failure/on_success/
  ensure hooks, error behavior mapping
- Public API: load_workflow(), load_workflow_from_str()
- 23 tests (schema, interpolation, compiler, e2e)
This commit is contained in:
2026-03-25 21:32:00 +00:00
parent 8d0f83da3c
commit b89563af63
14 changed files with 1377 additions and 1 deletions

View File

@@ -0,0 +1,64 @@
use std::collections::HashMap;
use regex::Regex;
use crate::error::YamlWorkflowError;
/// Resolve `((var.path))` expressions in a YAML string against a config map.
///
/// Dot-path traversal: `((config.database.host))` resolves by walking
/// `config["config"]["database"]["host"]`.
pub fn interpolate(
yaml: &str,
config: &HashMap<String, serde_json::Value>,
) -> Result<String, YamlWorkflowError> {
let re = Regex::new(r"\(\(([a-zA-Z0-9_.]+)\)\)").expect("valid regex");
let mut result = String::with_capacity(yaml.len());
let mut last_end = 0;
for cap in re.captures_iter(yaml) {
let m = cap.get(0).unwrap();
let var_path = &cap[1];
// Resolve the variable path.
let value = resolve_path(var_path, config)?;
result.push_str(&yaml[last_end..m.start()]);
result.push_str(&value);
last_end = m.end();
}
result.push_str(&yaml[last_end..]);
Ok(result)
}
fn resolve_path(
path: &str,
config: &HashMap<String, serde_json::Value>,
) -> Result<String, YamlWorkflowError> {
let parts: Vec<&str> = path.split('.').collect();
if parts.is_empty() {
return Err(YamlWorkflowError::UnresolvedVariable(path.to_string()));
}
// The first segment is the top-level key in the config map.
let root = config
.get(parts[0])
.ok_or_else(|| YamlWorkflowError::UnresolvedVariable(path.to_string()))?;
// Walk remaining segments.
let mut current = root;
for &segment in &parts[1..] {
current = current
.get(segment)
.ok_or_else(|| YamlWorkflowError::UnresolvedVariable(path.to_string()))?;
}
// Convert the final value to a string.
match current {
serde_json::Value::String(s) => Ok(s.clone()),
serde_json::Value::Null => Ok("null".to_string()),
other => Ok(other.to_string()),
}
}