use std log # Normalize a single step to canonical shape: # { id, name, cmd, deps: list, abort: bool } def normalize-step [step: record]: nothing -> record { let raw_deps = if ($step | columns | any { |c| $c == "depends_on" }) { $step.depends_on } else { [] } let deps = $raw_deps | each { |d| if (($d | describe) | str starts-with "string") { $d } else { $d.step } } let raw_err = if ($step | columns | any { |c| $c == "on_error" }) { $step.on_error } else { "warn" } let abort = if (($raw_err | describe) | str starts-with "string") { $raw_err == "abort" } else { let strategy = if (($raw_err | columns) | any { |c| $c == "strategy" }) { $raw_err.strategy } else { "Continue" } $strategy == "Stop" } let name = if ($step | columns | any { |c| $c == "name" }) { $step.name } else if ($step | columns | any { |c| $c == "action" }) { $step.action } else { $step.id } { id: $step.id, name: $name, cmd: $step.cmd, deps: $deps, abort: $abort } } # Replace {key} placeholders in a string with values from params record. def substitute-params [cmd: string, params: record]: nothing -> string { $params | transpose key val | reduce --fold $cmd { |kv, s| $s | str replace --all $"({$kv.key})" ($kv.val | into string) } } # Topological sort: returns steps in dependency order. def topo-sort [steps: list]: nothing -> list { let max_passes = ($steps | length) + 1 let result = (seq 0 $max_passes) | reduce --fold { done: [], ordered: [] } { |_, acc| let ready = $steps | where { |s| let not_done = not ($acc.done | any { |d| $d == $s.id }) let deps_met = ($s.deps | all { |dep| $acc.done | any { |d| $d == $dep } }) $not_done and $deps_met } if ($ready | is-empty) { $acc } else { { done: ($acc.done | append ($ready | each { |s| $s.id })), ordered: ($acc.ordered | append $ready) } } } $result.ordered } # Execute one step under bash. Returns { step_id, name, status, output }. def run-step [step: record, params: record]: nothing -> record { let cmd = substitute-params $step.cmd $params let r = do { ^bash -c $cmd } | complete let status = if $r.exit_code == 0 { "pass" } else if $step.abort { "fail" } else { "warn" } let output = if ($r.stdout | str trim | is-empty) { $r.stderr } else { $r.stdout } { step_id: $step.id, name: $step.name, status: $status, output: ($output | str trim) } } # Format a single result for console output. def format-result [result: record]: nothing -> string { let icon = match $result.status { "pass" => "v", "warn" => "!", "fail" => "x", "skip" => "-", _ => "?", } $" [($icon)] [($result.status | str upcase)] ($result.name)" } # Main entry point — exported for use in handler scripts. # mode_path: path to the .ncl reflection mode file # params: record of {key: value} substitutions for cmd fields # provisioning_root: absolute path to provisioning/ (used as nickel import path) export def run-reflection-mode [ mode_path: string, params: record, provisioning_root: string, ]: nothing -> list { let export_r = do { ^nickel export --format json --import-path $provisioning_root $mode_path } | complete if $export_r.exit_code != 0 { error make { msg: $"Failed to export '($mode_path)': ($export_r.stderr)" } } let mode = $export_r.stdout | from json if not ($mode | columns | any { |c| $c == "steps" }) { error make { msg: $"Mode '($mode_path)' has no 'steps' field" } } let steps = $mode.steps | each { |s| normalize-step $s } let ordered = topo-sort $steps log info $"Reflection mode: ($mode.id) — ($ordered | length) steps" let results = $ordered | reduce --fold [] { |step, acc| let blocked = $acc | any { |r| $r.status == "fail" and ($step.deps | any { |d| $d == $r.step_id }) } if $blocked { $acc | append { step_id: $step.id, name: $step.name, status: "skip", output: "dependency failed" } } else { let result = run-step $step $params print (format-result $result) $acc | append $result } } $results } # Print summary and exit 1 if any step failed. Exported for handler scripts. export def print-summary-and-exit [results: list]: nothing -> nothing { let fails = $results | where { |r| $r.status == "fail" } let warns = $results | where { |r| $r.status == "warn" } let passes = $results | where { |r| $r.status == "pass" } print "" print $"=== Summary: ($passes | length) pass / ($warns | length) warn / ($fails | length) fail ===" if not ($fails | is-empty) { print "Failed steps:" $fails | each { |r| print $" x ($r.name)\n ($r.output | lines | first | default '')" } exit 1 } }