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)))
}
}
+96 -2
View File
@@ -113,6 +113,7 @@ type ResolvedAction struct {
Kind settings.ActionKind
Body string
Env []settings.ActionEnvVar
RequiresSudo bool
Source settings.ActionItemSource
}
@@ -135,6 +136,7 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol
Kind: action.Kind,
Body: action.Body,
Env: env,
RequiresSudo: action.RequiresSudo,
Source: settings.ActionItemSourceLibrary,
}, nil
}
@@ -160,6 +162,7 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol
Kind: item.Kind,
Body: item.Body,
Env: env,
RequiresSudo: item.RequiresSudo,
Source: settings.ActionItemSourceLocal,
}, nil
default:
@@ -167,6 +170,41 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol
}
}
// 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.
func (executor *Executor) RunGroup(
group settings.NodeActionGroup,
@@ -220,6 +258,7 @@ func (executor *Executor) runItems(
startedAt := time.Now().UTC()
results := make([]settings.ActionItemRunResult, 0, len(items))
runVars := map[string]string{}
for _, item := range items {
itemStarted := time.Now().UTC()
@@ -239,9 +278,47 @@ func (executor *Executor) runItems(
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))
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{
@@ -251,6 +328,14 @@ func (executor *Executor) runItems(
Env: envMap,
Secrets: map[string]string{},
})
expandedBody = ExpandDollarVars(expandedBody, runVars)
remoteCommand := BuildRemoteCommand(
resolved.Kind,
expandedBody,
resolved.RequiresSudo,
envMap,
)
var sshResult auth.SSHRunResult
var runErr error
@@ -261,7 +346,7 @@ func (executor *Executor) runItems(
node.Username,
privateKey,
passphrase,
expandedBody,
WrapShellWithSudo(expandedBody, resolved.RequiresSudo),
envMap,
defaultRunTimeout,
)
@@ -274,6 +359,7 @@ func (executor *Executor) runItems(
expandedBody,
envMap,
defaultRunTimeout,
resolved.RequiresSudo,
)
default:
runErr = fmt.Errorf("unsupported action kind %q", resolved.Kind)
@@ -283,6 +369,9 @@ func (executor *Executor) runItems(
ItemID: item.ID,
ActionName: resolved.Name,
Source: string(resolved.Source),
Kind: resolved.Kind,
RemoteCommand: remoteCommand,
SSHUsername: node.Username,
ExitCode: sshResult.ExitCode,
Stdout: sshResult.Stdout,
Stderr: sshResult.Stderr,
@@ -294,6 +383,11 @@ func (executor *Executor) runItems(
if itemResult.ExitCode == 0 {
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)
}
@@ -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")
}
}
+11
View File
@@ -134,6 +134,7 @@ type Action struct {
Kind ActionKind `json:"kind"`
Body string `json:"body"`
Env []ActionEnvVar `json:"env"`
RequiresSudo bool `json:"requires_sudo"`
Builtin bool `json:"builtin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -184,6 +185,9 @@ type NodeActionItem struct {
Kind ActionKind `json:"kind,omitempty"`
Body string `json:"body,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.
@@ -216,10 +220,17 @@ type ActionItemRunResult struct {
ItemID string `json:"item_id"`
ActionName string `json:"action_name"`
Source string `json:"source"`
Kind ActionKind `json:"kind,omitempty"`
RemoteCommand string `json:"remote_command,omitempty"`
SSHUsername string `json:"ssh_username,omitempty"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
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"`
}