Clarify run-if skip reasons with the resolved variable value.

Failed conditions now explain skips in plain language (e.g. UpdateCount was 0) and truncate long captures so misconfigured apt listings cannot flood the UI.
This commit is contained in:
2026-07-19 21:59:35 +02:00
parent eade2ef0c7
commit 0e06063c1d
8 changed files with 865 additions and 46 deletions
+330
View File
@@ -0,0 +1,330 @@
package runner
import (
"errors"
"fmt"
"strconv"
"strings"
"unicode"
)
// ValidateCondition checks that expr is empty or a supported comparison.
func ValidateCondition(expr string) error {
trimmed := strings.TrimSpace(expr)
if trimmed == "" {
return nil
}
_, err := parseCondition(trimmed)
return err
}
const skipDetailMaxValueLen = 80
// EvaluateCondition evaluates a run-if expression against the current var map.
// Empty expr is always true. Eval failures return false with an error.
// On success, detail is a compact comparison (e.g. "$UpdateCount=3 >= 1").
// On a false result, detail is a human-readable skip explanation.
func EvaluateCondition(expr string, vars map[string]string) (ok bool, detail string, err error) {
trimmed := strings.TrimSpace(expr)
if trimmed == "" {
return true, "", nil
}
parsed, err := parseCondition(trimmed)
if err != nil {
return false, "", err
}
detail = formatEvaluatedCondition(parsed, vars)
leftValue := resolveOperand(parsed.left, vars)
rightValue := resolveOperand(parsed.right, vars)
ok, err = compareOperands(leftValue, parsed.op, rightValue)
if err != nil {
return false, detail, err
}
if !ok {
detail = formatSkipDetail(parsed, vars)
}
return ok, detail, nil
}
func formatEvaluatedCondition(parsed parsedCondition, vars map[string]string) string {
return fmt.Sprintf("%s %s %s",
formatOperandForDisplay(parsed.left, vars),
parsed.op,
formatOperandForDisplay(parsed.right, vars),
)
}
// formatSkipDetail builds a user-facing explanation when a run-if condition is false.
func formatSkipDetail(parsed parsedCondition, vars map[string]string) string {
rightDisplay := formatOperandValueForSkip(parsed.right, vars)
if parsed.left.kind == operandVar {
value := ""
if vars != nil {
value = vars[parsed.left.varName]
}
if strings.TrimSpace(value) == "" {
return fmt.Sprintf("$%s was unset, which does not meet run condition (%s %s)",
parsed.left.varName, parsed.op, rightDisplay)
}
return fmt.Sprintf("$%s was %s, which does not meet run condition (%s %s)",
parsed.left.varName, truncateForSkipDetail(value), parsed.op, rightDisplay)
}
return fmt.Sprintf("run condition not met (%s %s %s)",
formatOperandValueForSkip(parsed.left, vars),
parsed.op,
rightDisplay,
)
}
func formatOperandForDisplay(operand conditionOperand, vars map[string]string) string {
if operand.kind == operandVar {
value := ""
if vars != nil {
value = vars[operand.varName]
}
return fmt.Sprintf("$%s=%s", operand.varName, value)
}
return operand.literal
}
func formatOperandValueForSkip(operand conditionOperand, vars map[string]string) string {
if operand.kind == operandVar {
value := ""
if vars != nil {
value = vars[operand.varName]
}
if strings.TrimSpace(value) == "" {
return "unset"
}
return truncateForSkipDetail(value)
}
return operand.literal
}
func truncateForSkipDetail(value string) string {
trimmed := strings.TrimSpace(value)
runes := []rune(trimmed)
if len(runes) <= skipDetailMaxValueLen {
return trimmed
}
return string(runes[:skipDetailMaxValueLen]) + "…"
}
type conditionOp string
const (
opEQ conditionOp = "=="
opNE conditionOp = "!="
opGE conditionOp = ">="
opLE conditionOp = "<="
opGT conditionOp = ">"
opLT conditionOp = "<"
)
type conditionOperand struct {
kind operandKind
varName string
literal string
isNumeric bool
number float64
}
type operandKind int
const (
operandVar operandKind = iota
operandLiteral
)
type parsedCondition struct {
left conditionOperand
op conditionOp
right conditionOperand
}
func parseCondition(expr string) (parsedCondition, error) {
remaining := strings.TrimSpace(expr)
if strings.HasPrefix(strings.ToLower(remaining), "if ") || strings.EqualFold(remaining, "if") {
if len(remaining) < 3 {
return parsedCondition{}, errors.New("condition is empty after if")
}
remaining = strings.TrimSpace(remaining[2:])
}
if remaining == "" {
return parsedCondition{}, errors.New("condition is empty")
}
left, rest, err := parseOperand(remaining)
if err != nil {
return parsedCondition{}, err
}
rest = strings.TrimSpace(rest)
op, afterOp, err := parseOperator(rest)
if err != nil {
return parsedCondition{}, err
}
right, trailing, err := parseOperand(strings.TrimSpace(afterOp))
if err != nil {
return parsedCondition{}, err
}
if strings.TrimSpace(trailing) != "" {
return parsedCondition{}, fmt.Errorf("unexpected trailing input %q", strings.TrimSpace(trailing))
}
return parsedCondition{left: left, op: op, right: right}, nil
}
func parseOperator(input string) (conditionOp, string, error) {
operators := []conditionOp{opEQ, opNE, opGE, opLE, opGT, opLT}
for _, op := range operators {
prefix := string(op)
if strings.HasPrefix(input, prefix) {
return op, input[len(prefix):], nil
}
}
return "", "", errors.New("expected comparison operator (==, !=, >=, <=, >, <)")
}
func parseOperand(input string) (conditionOperand, string, error) {
input = strings.TrimSpace(input)
if input == "" {
return conditionOperand{}, "", errors.New("expected operand")
}
if input[0] == '$' {
name, rest, err := parseDollarName(input)
if err != nil {
return conditionOperand{}, "", err
}
if err := ValidateVariableName(name); err != nil {
return conditionOperand{}, "", err
}
return conditionOperand{kind: operandVar, varName: name}, rest, nil
}
if input[0] == '"' || input[0] == '\'' {
quote := input[0]
var builder strings.Builder
i := 1
for i < len(input) {
ch := input[i]
if ch == quote {
return conditionOperand{
kind: operandLiteral,
literal: builder.String(),
}, input[i+1:], nil
}
builder.WriteByte(ch)
i++
}
return conditionOperand{}, "", errors.New("unclosed string literal")
}
end := 0
for end < len(input) {
ch := rune(input[end])
if unicode.IsSpace(ch) {
break
}
// Stop before a comparison operator that is not part of a number sign.
if end > 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') {
break
}
if end == 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') {
break
}
end++
}
if end == 0 {
return conditionOperand{}, "", errors.New("expected operand")
}
token := input[:end]
if number, err := strconv.ParseFloat(token, 64); err == nil {
return conditionOperand{
kind: operandLiteral,
literal: token,
isNumeric: true,
number: number,
}, input[end:], nil
}
return conditionOperand{kind: operandLiteral, literal: token}, input[end:], nil
}
func parseDollarName(input string) (string, string, error) {
if !strings.HasPrefix(input, "$") {
return "", "", errors.New("expected $variable")
}
rest := input[1:]
if rest == "" {
return "", "", errors.New("expected variable name after $")
}
if rest[0] == '{' {
closeIdx := strings.IndexByte(rest, '}')
if closeIdx < 0 {
return "", "", errors.New("unclosed ${variable}")
}
name := rest[1:closeIdx]
return name, rest[closeIdx+1:], nil
}
end := 0
for end < len(rest) {
ch := rest[end]
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' {
end++
continue
}
break
}
if end == 0 {
return "", "", errors.New("expected variable name after $")
}
return rest[:end], rest[end:], nil
}
func resolveOperand(operand conditionOperand, vars map[string]string) conditionOperand {
if operand.kind != operandVar {
return operand
}
value := ""
if vars != nil {
value = vars[operand.varName]
}
resolved := conditionOperand{kind: operandLiteral, literal: value}
if number, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil && strings.TrimSpace(value) != "" {
resolved.isNumeric = true
resolved.number = number
}
return resolved
}
func compareOperands(left conditionOperand, op conditionOp, right conditionOperand) (bool, error) {
if left.isNumeric && right.isNumeric {
switch op {
case opEQ:
return left.number == right.number, nil
case opNE:
return left.number != right.number, nil
case opGE:
return left.number >= right.number, nil
case opLE:
return left.number <= right.number, nil
case opGT:
return left.number > right.number, nil
case opLT:
return left.number < right.number, nil
}
}
// Numeric ops with a non-numeric side evaluate to false (e.g. unset >= 1).
if op == opGE || op == opLE || op == opGT || op == opLT {
return false, nil
}
switch op {
case opEQ:
return left.literal == right.literal, nil
case opNE:
return left.literal != right.literal, nil
default:
return false, fmt.Errorf("unsupported operator %q", op)
}
}
+116
View File
@@ -0,0 +1,116 @@
package runner
import (
"strings"
"testing"
)
func TestValidateCondition(t *testing.T) {
if err := ValidateCondition(""); err != nil {
t.Fatalf("empty: %v", err)
}
if err := ValidateCondition("if $UpdateCount >= 1"); err != nil {
t.Fatalf("valid: %v", err)
}
if err := ValidateCondition("$X == \"ok\""); err != nil {
t.Fatalf("string: %v", err)
}
if err := ValidateCondition("not a condition"); err == nil {
t.Fatal("expected invalid condition error")
}
}
func TestEvaluateCondition(t *testing.T) {
vars := map[string]string{"UpdateCount": "3", "Status": "ready"}
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", vars)
if err != nil || !ok {
t.Fatalf("expected true, got %v %v", ok, err)
}
if detail != "$UpdateCount=3 >= 1" {
t.Fatalf("detail = %q", detail)
}
ok, detail, err = EvaluateCondition("$UpdateCount >= 10", vars)
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
wantFail := "$UpdateCount was 3, which does not meet run condition (>= 10)"
if detail != wantFail {
t.Fatalf("detail = %q, want %q", detail, wantFail)
}
ok, detail, err = EvaluateCondition("$Missing >= 1", vars)
if err != nil || ok {
t.Fatalf("unset numeric compare should be false, got %v %v", ok, err)
}
wantUnset := "$Missing was unset, which does not meet run condition (>= 1)"
if detail != wantUnset {
t.Fatalf("detail = %q, want %q", detail, wantUnset)
}
ok, detail, err = EvaluateCondition("$Status == ready", vars)
if err != nil || !ok {
t.Fatalf("string eq expected true, got %v %v", ok, err)
}
if detail != "$Status=ready == ready" {
t.Fatalf("detail = %q", detail)
}
ok, detail, err = EvaluateCondition("", vars)
if err != nil || !ok {
t.Fatalf("empty should be true, got %v %v", ok, err)
}
if detail != "" {
t.Fatalf("empty detail = %q", detail)
}
ok, detail, err = EvaluateCondition("$Status != 'busy'", vars)
if err != nil || !ok {
t.Fatalf("quoted ne expected true, got %v %v", ok, err)
}
if detail != "$Status=ready != busy" {
t.Fatalf("detail = %q", detail)
}
}
func TestEvaluateConditionDetailUnset(t *testing.T) {
ok, detail, err := EvaluateCondition("if $UpdateCount >= 10", map[string]string{})
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
want := "$UpdateCount was unset, which does not meet run condition (>= 10)"
if detail != want {
t.Fatalf("detail = %q, want %q", detail, want)
}
}
func TestEvaluateConditionSkipDetailZero(t *testing.T) {
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", map[string]string{"UpdateCount": "0"})
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
want := "$UpdateCount was 0, which does not meet run condition (>= 1)"
if detail != want {
t.Fatalf("detail = %q, want %q", detail, want)
}
}
func TestEvaluateConditionSkipDetailTruncatesLongValue(t *testing.T) {
longValue := strings.Repeat("x", skipDetailMaxValueLen+20)
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", map[string]string{"UpdateCount": longValue})
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
truncated := truncateForSkipDetail(longValue)
want := "$UpdateCount was " + truncated + ", which does not meet run condition (>= 1)"
if detail != want {
t.Fatalf("detail = %q, want %q", detail, want)
}
if !strings.HasSuffix(truncated, "…") {
t.Fatalf("expected truncation ellipsis in %q", truncated)
}
if len([]rune(truncated)) != skipDetailMaxValueLen+1 {
t.Fatalf("truncated length = %d", len([]rune(truncated)))
}
}
+122 -28
View File
@@ -108,12 +108,13 @@ func (executor *Executor) tickSchedules() {
// ResolvedAction is the effective action definition after library resolution. // ResolvedAction is the effective action definition after library resolution.
type ResolvedAction struct { type ResolvedAction struct {
Name string Name string
Description string Description string
Kind settings.ActionKind Kind settings.ActionKind
Body string Body string
Env []settings.ActionEnvVar Env []settings.ActionEnvVar
Source settings.ActionItemSource RequiresSudo bool
Source settings.ActionItemSource
} }
// ResolveItem resolves a library or local item to an executable action. // ResolveItem resolves a library or local item to an executable action.
@@ -130,12 +131,13 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol
env = []settings.ActionEnvVar{} env = []settings.ActionEnvVar{}
} }
return ResolvedAction{ return ResolvedAction{
Name: action.Name, Name: action.Name,
Description: action.Description, Description: action.Description,
Kind: action.Kind, Kind: action.Kind,
Body: action.Body, Body: action.Body,
Env: env, Env: env,
Source: settings.ActionItemSourceLibrary, RequiresSudo: action.RequiresSudo,
Source: settings.ActionItemSourceLibrary,
}, nil }, nil
} }
} }
@@ -155,18 +157,54 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol
env = []settings.ActionEnvVar{} env = []settings.ActionEnvVar{}
} }
return ResolvedAction{ return ResolvedAction{
Name: item.Name, Name: item.Name,
Description: item.Description, Description: item.Description,
Kind: item.Kind, Kind: item.Kind,
Body: item.Body, Body: item.Body,
Env: env, Env: env,
Source: settings.ActionItemSourceLocal, RequiresSudo: item.RequiresSudo,
Source: settings.ActionItemSourceLocal,
}, nil }, nil
default: default:
return ResolvedAction{}, fmt.Errorf("unknown action item source %q", item.Source) return ResolvedAction{}, fmt.Errorf("unknown action item source %q", item.Source)
} }
} }
// WrapShellWithSudo prepends "sudo -n " when requiresSudo is true.
func WrapShellWithSudo(command string, requiresSudo bool) string {
if !requiresSudo {
return command
}
return "sudo -n " + command
}
// ScriptRemoteCommand is the remote argv used for script actions (body on stdin).
func ScriptRemoteCommand(requiresSudo bool) string {
if requiresSudo {
return "sudo -n bash -s"
}
return "bash -s"
}
// BuildRemoteCommand returns the final SSH exec string for an action (including env exports).
func BuildRemoteCommand(
kind settings.ActionKind,
body string,
requiresSudo bool,
env map[string]string,
) string {
var command string
switch kind {
case settings.ActionKindShell:
command = WrapShellWithSudo(body, requiresSudo)
case settings.ActionKindScript:
command = ScriptRemoteCommand(requiresSudo)
default:
command = body
}
return auth.PrependEnvExports(env, command)
}
// RunGroup executes all items in order, appends a log file entry, and updates LastRunAt. // RunGroup executes all items in order, appends a log file entry, and updates LastRunAt.
func (executor *Executor) RunGroup( func (executor *Executor) RunGroup(
group settings.NodeActionGroup, group settings.NodeActionGroup,
@@ -220,6 +258,7 @@ func (executor *Executor) runItems(
startedAt := time.Now().UTC() startedAt := time.Now().UTC()
results := make([]settings.ActionItemRunResult, 0, len(items)) results := make([]settings.ActionItemRunResult, 0, len(items))
runVars := map[string]string{}
for _, item := range items { for _, item := range items {
itemStarted := time.Now().UTC() itemStarted := time.Now().UTC()
@@ -239,9 +278,47 @@ func (executor *Executor) runItems(
continue continue
} }
runIf := strings.TrimSpace(item.RunIf)
if runIf != "" {
shouldRun, detail, condErr := EvaluateCondition(runIf, runVars)
if condErr != nil || !shouldRun {
skipReason := fmt.Sprintf("%s skipped: run condition was not met", resolved.Name)
if detail != "" {
skipReason = fmt.Sprintf("%s skipped: %s", resolved.Name, detail)
}
if condErr != nil {
skipReason = fmt.Sprintf("%s skipped because condition error: %s", resolved.Name, condErr.Error())
}
results = append(results, settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: resolved.Name,
Source: string(resolved.Source),
ExitCode: 0,
Stdout: "",
Stderr: "",
Skipped: true,
SkipReason: skipReason,
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
})
continue
}
}
rawEnv := make(map[string]string, len(resolved.Env))
for _, envVar := range resolved.Env {
rawEnv[envVar.Name] = envVar.Value
}
envMap := make(map[string]string, len(resolved.Env)) envMap := make(map[string]string, len(resolved.Env))
for _, envVar := range resolved.Env { for _, envVar := range resolved.Env {
envMap[envVar.Name] = envVar.Value expandedValue := auth.ExpandPlaceholders(envVar.Value, auth.PlaceholderContext{
Host: node.Name,
IP: node.HostIP,
Username: node.Username,
Env: rawEnv,
Secrets: map[string]string{},
})
envMap[envVar.Name] = ExpandDollarVars(expandedValue, runVars)
} }
expandedBody := auth.ExpandPlaceholders(resolved.Body, auth.PlaceholderContext{ expandedBody := auth.ExpandPlaceholders(resolved.Body, auth.PlaceholderContext{
@@ -251,6 +328,14 @@ func (executor *Executor) runItems(
Env: envMap, Env: envMap,
Secrets: map[string]string{}, Secrets: map[string]string{},
}) })
expandedBody = ExpandDollarVars(expandedBody, runVars)
remoteCommand := BuildRemoteCommand(
resolved.Kind,
expandedBody,
resolved.RequiresSudo,
envMap,
)
var sshResult auth.SSHRunResult var sshResult auth.SSHRunResult
var runErr error var runErr error
@@ -261,7 +346,7 @@ func (executor *Executor) runItems(
node.Username, node.Username,
privateKey, privateKey,
passphrase, passphrase,
expandedBody, WrapShellWithSudo(expandedBody, resolved.RequiresSudo),
envMap, envMap,
defaultRunTimeout, defaultRunTimeout,
) )
@@ -274,26 +359,35 @@ func (executor *Executor) runItems(
expandedBody, expandedBody,
envMap, envMap,
defaultRunTimeout, defaultRunTimeout,
resolved.RequiresSudo,
) )
default: default:
runErr = fmt.Errorf("unsupported action kind %q", resolved.Kind) runErr = fmt.Errorf("unsupported action kind %q", resolved.Kind)
} }
itemResult := settings.ActionItemRunResult{ itemResult := settings.ActionItemRunResult{
ItemID: item.ID, ItemID: item.ID,
ActionName: resolved.Name, ActionName: resolved.Name,
Source: string(resolved.Source), Source: string(resolved.Source),
ExitCode: sshResult.ExitCode, Kind: resolved.Kind,
Stdout: sshResult.Stdout, RemoteCommand: remoteCommand,
Stderr: sshResult.Stderr, SSHUsername: node.Username,
StartedAt: itemStarted, ExitCode: sshResult.ExitCode,
FinishedAt: time.Now().UTC(), Stdout: sshResult.Stdout,
Stderr: sshResult.Stderr,
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
} }
if runErr != nil { if runErr != nil {
itemResult.Error = runErr.Error() itemResult.Error = runErr.Error()
if itemResult.ExitCode == 0 { if itemResult.ExitCode == 0 {
itemResult.ExitCode = -1 itemResult.ExitCode = -1
} }
} else if setName := strings.TrimSpace(item.SetVariable); setName != "" {
captured := strings.TrimSpace(sshResult.Stdout)
runVars[setName] = captured
itemResult.SetVariable = setName
itemResult.VariableValue = captured
} }
results = append(results, itemResult) results = append(results, itemResult)
} }
@@ -0,0 +1,99 @@
package runner
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestWrapShellWithSudo(t *testing.T) {
if got := WrapShellWithSudo("/usr/bin/systemctl restart myservice", false); got != "/usr/bin/systemctl restart myservice" {
t.Fatalf("without sudo = %q", got)
}
if got := WrapShellWithSudo("/usr/bin/systemctl restart myservice", true); got != "sudo -n /usr/bin/systemctl restart myservice" {
t.Fatalf("with sudo = %q", got)
}
}
func TestScriptRemoteCommand(t *testing.T) {
if got := ScriptRemoteCommand(false); got != "bash -s" {
t.Fatalf("without sudo = %q", got)
}
if got := ScriptRemoteCommand(true); got != "sudo -n bash -s" {
t.Fatalf("with sudo = %q", got)
}
}
func TestBuildRemoteCommand(t *testing.T) {
shellCmd := BuildRemoteCommand(settings.ActionKindShell, "apt update", true, nil)
if shellCmd != "sudo -n apt update" {
t.Fatalf("shell = %q", shellCmd)
}
scriptCmd := BuildRemoteCommand(settings.ActionKindScript, "apt update", true, nil)
if scriptCmd != "sudo -n bash -s" {
t.Fatalf("script = %q", scriptCmd)
}
withEnv := BuildRemoteCommand(
settings.ActionKindShell,
"apt update",
true,
map[string]string{"FOO": "bar"},
)
if withEnv != "export 'FOO'='bar'; sudo -n apt update" {
t.Fatalf("with env = %q", withEnv)
}
}
func TestResolveItemRequiresSudo(t *testing.T) {
library := []settings.Action{
{
ID: "lib-1",
Name: "Restart",
Kind: settings.ActionKindShell,
Body: "/usr/bin/systemctl restart myservice",
RequiresSudo: true,
Env: []settings.ActionEnvVar{},
},
}
resolved, err := ResolveItem(settings.NodeActionItem{
ID: "i1",
Source: settings.ActionItemSourceLibrary,
LibraryActionID: "lib-1",
}, library)
if err != nil {
t.Fatalf("resolve library: %v", err)
}
if !resolved.RequiresSudo || resolved.Body != "/usr/bin/systemctl restart myservice" {
t.Fatalf("library resolved = %#v", resolved)
}
local, err := ResolveItem(settings.NodeActionItem{
ID: "i2",
Source: settings.ActionItemSourceLocal,
Name: "Local restart",
Kind: settings.ActionKindShell,
Body: "/usr/bin/true",
RequiresSudo: true,
}, library)
if err != nil {
t.Fatalf("resolve local: %v", err)
}
if !local.RequiresSudo {
t.Fatalf("local resolved = %#v", local)
}
noSudo, err := ResolveItem(settings.NodeActionItem{
ID: "i3",
Source: settings.ActionItemSourceLocal,
Name: "Uptime",
Kind: settings.ActionKindShell,
Body: "uptime",
}, library)
if err != nil {
t.Fatalf("resolve no-sudo: %v", err)
}
if noSudo.RequiresSudo {
t.Fatalf("expected RequiresSudo false, got %#v", noSudo)
}
}
@@ -0,0 +1,84 @@
package runner
import (
"strings"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestRunScopedVariableSequence(t *testing.T) {
vars := map[string]string{}
// Action 1 captures trimmed stdout into UpdateCount.
captured := strings.TrimSpace("3\n")
vars["UpdateCount"] = captured
if vars["UpdateCount"] != "3" {
t.Fatalf("captured = %q", vars["UpdateCount"])
}
// Action 2 condition passes and body expands.
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", vars)
if err != nil || !ok {
t.Fatalf("condition should pass: ok=%v err=%v", ok, err)
}
if detail != "$UpdateCount=3 >= 1" {
t.Fatalf("detail = %q", detail)
}
body := ExpandDollarVars("echo upgrades=$UpdateCount", vars)
if body != "echo upgrades=3" {
t.Fatalf("body = %q", body)
}
// Action 3 condition fails → skipped.
ok, detail, err = EvaluateCondition("if $UpdateCount >= 10", vars)
if err != nil || ok {
t.Fatalf("condition should fail: ok=%v err=%v", ok, err)
}
wantFail := "$UpdateCount was 3, which does not meet run condition (>= 10)"
if detail != wantFail {
t.Fatalf("detail = %q, want %q", detail, wantFail)
}
// A fresh run starts with an empty map (no leak).
nextRun := map[string]string{}
ok, detail, err = EvaluateCondition("if $UpdateCount >= 1", nextRun)
if err != nil || ok {
t.Fatalf("unset in new run should be false: ok=%v err=%v", ok, err)
}
wantUnset := "$UpdateCount was unset, which does not meet run condition (>= 1)"
if detail != wantUnset {
t.Fatalf("detail = %q, want %q", detail, wantUnset)
}
}
func TestSkipResultShape(t *testing.T) {
item := settings.NodeActionItem{
ID: "i1",
Name: "Upgrade",
RunIf: "if $UpdateCount >= 1",
}
vars := map[string]string{"UpdateCount": "0"}
shouldRun, detail, condErr := EvaluateCondition(item.RunIf, vars)
if condErr != nil {
t.Fatalf("eval: %v", condErr)
}
if shouldRun {
t.Fatal("expected skip")
}
skipReason := item.Name + " skipped: run condition was not met"
if detail != "" {
skipReason = item.Name + " skipped: " + detail
}
result := settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: item.Name,
Skipped: true,
SkipReason: skipReason,
ExitCode: 0,
}
want := "Upgrade skipped: $UpdateCount was 0, which does not meet run condition (>= 1)"
if !result.Skipped || result.SkipReason != want {
t.Fatalf("result = %#v, want SkipReason %q", result, want)
}
}
+52
View File
@@ -0,0 +1,52 @@
package runner
import (
"errors"
"fmt"
"regexp"
"strings"
)
var (
variableNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Matches $Name or ${Name}; only known names in the vars map are replaced.
dollarVarPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
)
// ValidateVariableName checks that name is a valid run-scoped variable identifier.
func ValidateVariableName(name string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errors.New("variable name is required")
}
if trimmed != name {
return errors.New("variable name must not have leading or trailing spaces")
}
if !variableNamePattern.MatchString(trimmed) {
return fmt.Errorf("invalid variable name %q", name)
}
return nil
}
// ExpandDollarVars replaces $Name and ${Name} when Name exists in vars.
// Unknown identifiers are left unchanged for the remote shell.
func ExpandDollarVars(text string, vars map[string]string) string {
if vars == nil || len(vars) == 0 || text == "" {
return text
}
return dollarVarPattern.ReplaceAllStringFunc(text, func(match string) string {
submatches := dollarVarPattern.FindStringSubmatch(match)
if len(submatches) < 3 {
return match
}
name := submatches[1]
if name == "" {
name = submatches[2]
}
value, ok := vars[name]
if !ok {
return match
}
return value
})
}
+33
View File
@@ -0,0 +1,33 @@
package runner
import "testing"
func TestValidateVariableName(t *testing.T) {
if err := ValidateVariableName("UpdateCount"); err != nil {
t.Fatalf("UpdateCount: %v", err)
}
if err := ValidateVariableName("_x"); err != nil {
t.Fatalf("_x: %v", err)
}
if err := ValidateVariableName(""); err == nil {
t.Fatal("expected empty name error")
}
if err := ValidateVariableName("1bad"); err == nil {
t.Fatal("expected invalid name error")
}
if err := ValidateVariableName("bad-name"); err == nil {
t.Fatal("expected invalid name error")
}
}
func TestExpandDollarVars(t *testing.T) {
vars := map[string]string{"UpdateCount": "3", "Name": "web"}
got := ExpandDollarVars("count=$UpdateCount host=${Name}-01 keep=$HOME", vars)
want := "count=3 host=web-01 keep=$HOME"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
if ExpandDollarVars("$Missing", nil) != "$Missing" {
t.Fatal("nil vars should leave tokens unchanged")
}
}
+29 -18
View File
@@ -128,15 +128,16 @@ type ActionEnvVar struct {
// Action is a named shell command or script that can run on managed nodes. // Action is a named shell command or script that can run on managed nodes.
type Action struct { type Action struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Kind ActionKind `json:"kind"` Kind ActionKind `json:"kind"`
Body string `json:"body"` Body string `json:"body"`
Env []ActionEnvVar `json:"env"` Env []ActionEnvVar `json:"env"`
Builtin bool `json:"builtin"` RequiresSudo bool `json:"requires_sudo"`
CreatedAt time.Time `json:"created_at"` Builtin bool `json:"builtin"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
// ActionStore is the plain JSON payload in actions.json. // ActionStore is the plain JSON payload in actions.json.
@@ -184,6 +185,9 @@ type NodeActionItem struct {
Kind ActionKind `json:"kind,omitempty"` Kind ActionKind `json:"kind,omitempty"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
Env []ActionEnvVar `json:"env,omitempty"` Env []ActionEnvVar `json:"env,omitempty"`
RequiresSudo bool `json:"requires_sudo,omitempty"` // local items; library refs inherit from library
SetVariable string `json:"set_variable,omitempty"` // capture trimmed stdout into this run-scoped name
RunIf string `json:"run_if,omitempty"` // e.g. "if $UpdateCount >= 1"
} }
// NodeActionGroup is a named ordered set of actions with one schedule on a node. // NodeActionGroup is a named ordered set of actions with one schedule on a node.
@@ -213,15 +217,22 @@ const (
// ActionItemRunResult is the captured output of one action within a run. // ActionItemRunResult is the captured output of one action within a run.
type ActionItemRunResult struct { type ActionItemRunResult struct {
ItemID string `json:"item_id"` ItemID string `json:"item_id"`
ActionName string `json:"action_name"` ActionName string `json:"action_name"`
Source string `json:"source"` Source string `json:"source"`
ExitCode int `json:"exit_code"` Kind ActionKind `json:"kind,omitempty"`
Stdout string `json:"stdout"` RemoteCommand string `json:"remote_command,omitempty"`
Stderr string `json:"stderr"` SSHUsername string `json:"ssh_username,omitempty"`
Error string `json:"error,omitempty"` ExitCode int `json:"exit_code"`
StartedAt time.Time `json:"started_at"` Stdout string `json:"stdout"`
FinishedAt time.Time `json:"finished_at"` Stderr string `json:"stderr"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
SkipReason string `json:"skip_reason,omitempty"`
SetVariable string `json:"set_variable,omitempty"`
VariableValue string `json:"variable_value,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
} }
// ActionGroupRunRecord is one execution of a group (or a single-item manual run). // ActionGroupRunRecord is one execution of a group (or a single-item manual run).