#!/bin/bash # Tree parser utilities for YAML cookbook definitions # # Usage: # source "$(dirname "${BASH_SOURCE[0]}")/tree-parser.sh" # # Provides: # - tree_parse() - Parse a tree YAML file # - tree_get_step() - Get a specific step definition # - tree_get_deps() - Get dependencies for a step # - tree_execution_order() - Get topological sort of steps # - tree_step_ready() - Check if step's dependencies are satisfied # - tree_expand_template() - Expand Go template variables # - tree_list() - List all available trees # - tree_get_teardown() - Get teardown steps # Trees directory TREES_DIR="${TREES_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../trees}" # Check for yq _tree_check_yq() { if ! command -v yq &> /dev/null; then echo "Error: yq is required but not installed" >&2 echo "Install with: brew install yq" >&2 return 1 fi } # Get tree file path _tree_path() { local tree_name="$1" echo "$TREES_DIR/${tree_name}.yaml" } # Parse a tree YAML file # Arguments: tree_name # Returns: tree JSON on stdout # Example: tree_json=$(tree_parse "landing-page") tree_parse() { local tree_name="$1" local path path="$(_tree_path "$tree_name")" _tree_check_yq || return 1 if [[ ! -f "$path" ]]; then echo "Error: Tree '$tree_name' not found at $path" >&2 return 1 fi yq -o=json "$path" } # Get tree metadata # Arguments: tree_name # Returns: JSON with name, description, version tree_get_meta() { local tree_name="$1" local tree tree=$(tree_parse "$tree_name") || return 1 echo "$tree" | jq '{name: .name, description: .description, version: .version}' } # Get default vars from tree # Arguments: tree_name # Returns: JSON object of default vars tree_get_default_vars() { local tree_name="$1" local tree tree=$(tree_parse "$tree_name") || return 1 echo "$tree" | jq '.vars // {}' } # Get a specific step definition # Arguments: tree_name step_name # Returns: step JSON on stdout # Example: step=$(tree_get_step "landing-page" "create-project") tree_get_step() { local tree_name="$1" local step_name="$2" local tree tree=$(tree_parse "$tree_name") || return 1 local step step=$(echo "$tree" | jq --arg step "$step_name" '.steps[$step] // null') if [[ "$step" == "null" ]]; then echo "Error: Step '$step_name' not found in tree '$tree_name'" >&2 return 1 fi # Add step name to the JSON for convenience echo "$step" | jq --arg name "$step_name" '. + {name: $name}' } # Get all step names # Arguments: tree_name # Returns: newline-separated list of step names tree_get_steps() { local tree_name="$1" local tree tree=$(tree_parse "$tree_name") || return 1 echo "$tree" | jq -r '.steps | keys[]' } # Get dependencies for a step # Arguments: tree_name step_name # Returns: newline-separated list of dependency step names # Example: deps=$(tree_get_deps "landing-page" "add-component") tree_get_deps() { local tree_name="$1" local step_name="$2" local step step=$(tree_get_step "$tree_name" "$step_name") || return 1 echo "$step" | jq -r '.depends_on // [] | .[]' } # Get topological sort of steps (execution order) # Arguments: tree_name # Returns: newline-separated list of step names in execution order # Example: order=$(tree_execution_order "landing-page") tree_execution_order() { local tree_name="$1" local tree tree=$(tree_parse "$tree_name") || return 1 # Kahn's algorithm for topological sort # Build adjacency list and in-degree count local steps_json steps_json=$(echo "$tree" | jq '.steps') # Use jq to compute the topological order echo "$steps_json" | jq -r ' # Build in-degree map and adjacency list . as $steps | (keys | map({key: ., value: 0}) | from_entries) as $initial_degrees | reduce keys[] as $step ( {degrees: $initial_degrees, adj: {}}; . as $state | ($steps[$step].depends_on // []) as $deps | reduce $deps[] as $dep ( $state; .degrees[$step] += 1 | .adj[$dep] = ((.adj[$dep] // []) + [$step]) ) ) | # Kahns algorithm . as $graph | { result: [], queue: [$graph.degrees | to_entries | .[] | select(.value == 0) | .key], degrees: $graph.degrees, adj: $graph.adj } | until(.queue | length == 0; .queue[0] as $node | .result += [$node] | .queue = .queue[1:] | reduce ((.adj[$node] // [])[] ) as $neighbor ( .; .degrees[$neighbor] -= 1 | if .degrees[$neighbor] == 0 then .queue += [$neighbor] else . end ) ) | .result[] ' } # Check if a step's dependencies are satisfied # Arguments: tree_name step_name completed_steps_json # Returns: 0 if ready, 1 if not # Example: if tree_step_ready "landing-page" "add-component" '["create-project"]'; then ... tree_step_ready() { local tree_name="$1" local step_name="$2" local completed_steps="$3" # JSON array of completed step names local deps deps=$(tree_get_deps "$tree_name" "$step_name") || return 1 if [[ -z "$deps" ]]; then return 0 # No dependencies, always ready fi # Check each dependency while IFS= read -r dep; do if ! echo "$completed_steps" | jq -e --arg d "$dep" 'index($d) != null' > /dev/null; then return 1 # Dependency not satisfied fi done <<< "$deps" return 0 } # Expand Go template variables in a string # Arguments: template_string vars_json outputs_json # Returns: expanded string # Example: result=$(tree_expand_template "{{ .vars.project_name }}" '{"project_name":"test"}' '{}') tree_expand_template() { local template="$1" local vars_json="${2:-"{}"}" local outputs_json="${3:-"{}"}" # Build context for template expansion local context context=$(jq -n \ --argjson vars "$vars_json" \ --argjson outputs "$outputs_json" \ '{vars: $vars, outputs: $outputs}') # Simple template expansion using sed # Handle {{ .vars.NAME }} patterns local result="$template" # Extract and replace all {{ .vars.xxx }} patterns while [[ "$result" =~ \{\{[[:space:]]*\.vars\.([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\}\} ]]; do local var_name="${BASH_REMATCH[1]}" local var_value var_value=$(echo "$vars_json" | jq -r ".[\"$var_name\"] // \"\"") result="${result//\{\{ .vars.$var_name \}\}/$var_value}" result="${result//\{\{.vars.$var_name\}\}/$var_value}" done # Extract and replace all {{ .outputs.step.key }} patterns while [[ "$result" =~ \{\{[[:space:]]*\.outputs\.([a-zA-Z_][a-zA-Z0-9_-]*)\.([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\}\} ]]; do local step_name="${BASH_REMATCH[1]}" local key_name="${BASH_REMATCH[2]}" local out_value out_value=$(echo "$outputs_json" | jq -r ".[\"$step_name\"][\"$key_name\"] // \"\"") result="${result//\{\{ .outputs.$step_name.$key_name \}\}/$out_value}" result="${result//\{\{.outputs.$step_name.$key_name\}\}/$out_value}" done echo "$result" } # Expand templates in a step definition # Arguments: step_json vars_json outputs_json # Returns: step JSON with templates expanded tree_expand_step() { local step_json="$1" local vars_json="${2:-"{}"}" local outputs_json="${3:-"{}"}" # Convert step to string and expand templates local step_str step_str=$(echo "$step_json" | jq -c '.') local expanded expanded=$(tree_expand_template "$step_str" "$vars_json" "$outputs_json") echo "$expanded" } # List all available trees # Returns: newline-separated list of tree names # Example: trees=$(tree_list) tree_list() { _tree_check_yq || return 1 for f in "$TREES_DIR"/*.yaml; do [[ -e "$f" ]] || continue basename "$f" .yaml done } # List trees with descriptions # Returns: formatted list of trees with descriptions tree_list_detail() { _tree_check_yq || return 1 for f in "$TREES_DIR"/*.yaml; do [[ -e "$f" ]] || continue local name name=$(basename "$f" .yaml) local desc desc=$(yq -r '.description // "No description"' "$f" 2>/dev/null) printf " %-20s %s\n" "$name" "$desc" done } # Get teardown steps # Arguments: tree_name # Returns: JSON array of teardown steps # Example: teardown=$(tree_get_teardown "landing-page") tree_get_teardown() { local tree_name="$1" local tree tree=$(tree_parse "$tree_name") || return 1 echo "$tree" | jq '.teardown // []' } # Get step action type # Arguments: step_json # Returns: action type string tree_step_action() { local step_json="$1" echo "$step_json" | jq -r '.action // "unknown"' } # Get step on_error behavior # Arguments: step_json # Returns: "fail" or "continue" tree_step_on_error() { local step_json="$1" echo "$step_json" | jq -r '.on_error // "fail"' } # Get output extraction rules from step # Arguments: step_json # Returns: JSON array of output rules [{name, jq_path}] tree_step_outputs() { local step_json="$1" # outputs can be: # - array of {key: jq_path} # Example: [{project_id: ".data.name"}] echo "$step_json" | jq ' .outputs // [] | map(to_entries | .[0] | {name: .key, jq_path: .value}) ' } # Validate tree YAML # Arguments: tree_name # Returns: 0 if valid, 1 with error messages if not tree_validate() { local tree_name="$1" local tree tree=$(tree_parse "$tree_name") || return 1 local errors=() # Check required fields local name name=$(echo "$tree" | jq -r '.name // ""') if [[ -z "$name" ]]; then errors+=("Missing required field: name") fi # Check that all steps have action field local steps_without_action steps_without_action=$(echo "$tree" | jq -r '.steps | to_entries | .[] | select(.value.action == null) | .key') if [[ -n "$steps_without_action" ]]; then while IFS= read -r step; do errors+=("Step '$step' missing required field: action") done <<< "$steps_without_action" fi # Check that dependencies reference existing steps local all_steps all_steps=$(echo "$tree" | jq -r '.steps | keys') local invalid_deps invalid_deps=$(echo "$tree" | jq -r --argjson all_steps "$all_steps" ' .steps | to_entries | .[] | .key as $step | (.value.depends_on // [])[] | select(. as $dep | $all_steps | index($dep) == null) | "\($step) depends on non-existent step: \(.)" ') if [[ -n "$invalid_deps" ]]; then while IFS= read -r err; do errors+=("$err") done <<< "$invalid_deps" fi # Check for cycles (execution_order will fail if there are cycles) if ! tree_execution_order "$tree_name" > /dev/null 2>&1; then errors+=("Dependency cycle detected") fi # Report errors if [[ ${#errors[@]} -gt 0 ]]; then echo "Validation errors in tree '$tree_name':" >&2 for err in "${errors[@]}"; do echo " - $err" >&2 done return 1 fi return 0 }