Files
dd0c/products/06-runbook-automation/agent/src/parser.rs

224 lines
6.1 KiB
Rust
Raw Normal View History

use serde::{Deserialize, Serialize};
use anyhow::{Context, Result};
/// Parsed runbook step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunbookStep {
pub index: usize,
pub description: String,
pub command: String,
#[serde(default = "default_timeout")]
pub timeout_seconds: u64,
#[serde(default)]
pub on_failure: FailureAction,
pub condition: Option<String>,
}
fn default_timeout() -> u64 { 300 }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum FailureAction {
Abort,
Continue,
Retry { max_attempts: u32 },
}
impl Default for FailureAction {
fn default() -> Self { FailureAction::Abort }
}
/// Parsed runbook.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Runbook {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_version")]
pub version: String,
pub steps: Vec<RunbookStep>,
#[serde(default)]
pub variables: std::collections::HashMap<String, VariableSpec>,
}
fn default_version() -> String { "0.1.0".into() }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VariableSpec {
pub description: String,
pub default: Option<String>,
#[serde(default)]
pub required: bool,
}
/// Parse a YAML runbook into structured steps.
///
/// Example YAML:
/// ```yaml
/// name: restart-service
/// description: Restart a stuck ECS service
/// variables:
/// cluster:
/// description: ECS cluster name
/// required: true
/// service:
/// description: ECS service name
/// required: true
/// steps:
/// - description: Check current task count
/// command: "aws ecs describe-services --cluster {{cluster}} --services {{service}}"
/// timeout_seconds: 30
/// - description: Force new deployment
/// command: "aws ecs update-service --cluster {{cluster}} --service {{service}} --force-new-deployment"
/// timeout_seconds: 60
/// on_failure:
/// action: abort
/// - description: Wait for stable
/// command: "aws ecs wait services-stable --cluster {{cluster}} --services {{service}}"
/// timeout_seconds: 300
/// on_failure:
/// action: retry
/// max_attempts: 3
/// ```
pub fn parse_yaml(content: &str) -> Result<Runbook> {
let mut runbook: Runbook = serde_yaml::from_str(content)
.context("Failed to parse runbook YAML")?;
// Assign step indices
for (i, step) in runbook.steps.iter_mut().enumerate() {
step.index = i;
}
// Validate: at least one step
if runbook.steps.is_empty() {
anyhow::bail!("Runbook must have at least one step");
}
Ok(runbook)
}
/// Substitute variables in a command string.
/// Variables use `{{name}}` syntax.
pub fn substitute_variables(
command: &str,
variables: &std::collections::HashMap<String, String>,
) -> Result<String> {
let mut result = command.to_string();
for (key, value) in variables {
result = result.replace(&format!("{{{{{}}}}}", key), value);
}
// Check for unresolved variables
if result.contains("{{") {
let unresolved: Vec<&str> = result
.match_indices("{{")
.filter_map(|(start, _)| {
result[start..].find("}}").map(|end| &result[start..start + end + 2])
})
.collect();
anyhow::bail!("Unresolved variables: {:?}", unresolved);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
const SAMPLE_YAML: &str = r#"
name: restart-service
description: Restart a stuck ECS service
variables:
cluster:
description: ECS cluster name
required: true
service:
description: ECS service name
required: true
steps:
- description: Check current task count
command: "aws ecs describe-services --cluster {{cluster}} --services {{service}}"
timeout_seconds: 30
- description: Force new deployment
command: "aws ecs update-service --cluster {{cluster}} --service {{service}} --force-new-deployment"
timeout_seconds: 60
on_failure:
action: abort
- description: Wait for stable
command: "aws ecs wait services-stable --cluster {{cluster}} --services {{service}}"
timeout_seconds: 300
on_failure:
action: retry
max_attempts: 3
"#;
#[test]
fn test_parse_valid_yaml() {
let runbook = parse_yaml(SAMPLE_YAML).unwrap();
assert_eq!(runbook.name, "restart-service");
assert_eq!(runbook.steps.len(), 3);
assert_eq!(runbook.steps[0].index, 0);
assert_eq!(runbook.steps[2].timeout_seconds, 300);
assert_eq!(runbook.variables.len(), 2);
assert!(runbook.variables.get("cluster").unwrap().required);
}
#[test]
fn test_parse_empty_steps_fails() {
let yaml = "name: empty\nsteps: []";
assert!(parse_yaml(yaml).is_err());
}
#[test]
fn test_parse_invalid_yaml_fails() {
assert!(parse_yaml("not: [valid: yaml: {{").is_err());
}
#[test]
fn test_substitute_variables() {
let mut vars = HashMap::new();
vars.insert("cluster".into(), "prod".into());
vars.insert("service".into(), "api".into());
let result = substitute_variables(
"aws ecs describe-services --cluster {{cluster}} --services {{service}}",
&vars,
).unwrap();
assert_eq!(result, "aws ecs describe-services --cluster prod --services api");
}
#[test]
fn test_unresolved_variable_fails() {
let vars = HashMap::new();
let result = substitute_variables("echo {{missing}}", &vars);
assert!(result.is_err());
}
#[test]
fn test_default_failure_action_is_abort() {
let yaml = r#"
name: simple
steps:
- description: test
command: echo hello
"#;
let runbook = parse_yaml(yaml).unwrap();
assert!(matches!(runbook.steps[0].on_failure, FailureAction::Abort));
}
#[test]
fn test_default_timeout() {
let yaml = r#"
name: simple
steps:
- description: test
command: echo hello
"#;
let runbook = parse_yaml(yaml).unwrap();
assert_eq!(runbook.steps[0].timeout_seconds, 300);
}
}