diff --git a/service/internal/runner/condition.go b/service/internal/runner/condition.go new file mode 100644 index 0000000..4d19e12 --- /dev/null +++ b/service/internal/runner/condition.go @@ -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) + } +} diff --git a/service/internal/runner/condition_test.go b/service/internal/runner/condition_test.go new file mode 100644 index 0000000..69e08f6 --- /dev/null +++ b/service/internal/runner/condition_test.go @@ -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))) + } +} diff --git a/service/internal/runner/executor.go b/service/internal/runner/executor.go index bf9271a..b5011b8 100644 --- a/service/internal/runner/executor.go +++ b/service/internal/runner/executor.go @@ -108,12 +108,13 @@ func (executor *Executor) tickSchedules() { // ResolvedAction is the effective action definition after library resolution. type ResolvedAction struct { - Name string - Description string - Kind settings.ActionKind - Body string - Env []settings.ActionEnvVar - Source settings.ActionItemSource + Name string + Description string + Kind settings.ActionKind + Body string + Env []settings.ActionEnvVar + RequiresSudo bool + Source settings.ActionItemSource } // 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{} } return ResolvedAction{ - Name: action.Name, - Description: action.Description, - Kind: action.Kind, - Body: action.Body, - Env: env, - Source: settings.ActionItemSourceLibrary, + Name: action.Name, + Description: action.Description, + Kind: action.Kind, + Body: action.Body, + Env: env, + RequiresSudo: action.RequiresSudo, + Source: settings.ActionItemSourceLibrary, }, nil } } @@ -155,18 +157,54 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol env = []settings.ActionEnvVar{} } return ResolvedAction{ - Name: item.Name, - Description: item.Description, - Kind: item.Kind, - Body: item.Body, - Env: env, - Source: settings.ActionItemSourceLocal, + Name: item.Name, + Description: item.Description, + Kind: item.Kind, + Body: item.Body, + Env: env, + RequiresSudo: item.RequiresSudo, + Source: settings.ActionItemSourceLocal, }, nil default: 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. 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,26 +359,35 @@ func (executor *Executor) runItems( expandedBody, envMap, defaultRunTimeout, + resolved.RequiresSudo, ) default: runErr = fmt.Errorf("unsupported action kind %q", resolved.Kind) } itemResult := settings.ActionItemRunResult{ - ItemID: item.ID, - ActionName: resolved.Name, - Source: string(resolved.Source), - ExitCode: sshResult.ExitCode, - Stdout: sshResult.Stdout, - Stderr: sshResult.Stderr, - StartedAt: itemStarted, - FinishedAt: time.Now().UTC(), + 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, + StartedAt: itemStarted, + FinishedAt: time.Now().UTC(), } if runErr != nil { itemResult.Error = runErr.Error() 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) } diff --git a/service/internal/runner/executor_sudo_test.go b/service/internal/runner/executor_sudo_test.go new file mode 100644 index 0000000..ed8c32d --- /dev/null +++ b/service/internal/runner/executor_sudo_test.go @@ -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) + } +} diff --git a/service/internal/runner/executor_vars_test.go b/service/internal/runner/executor_vars_test.go new file mode 100644 index 0000000..b9729e0 --- /dev/null +++ b/service/internal/runner/executor_vars_test.go @@ -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) + } +} diff --git a/service/internal/runner/vars.go b/service/internal/runner/vars.go new file mode 100644 index 0000000..8bacbc0 --- /dev/null +++ b/service/internal/runner/vars.go @@ -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 + }) +} diff --git a/service/internal/runner/vars_test.go b/service/internal/runner/vars_test.go new file mode 100644 index 0000000..f4c8a74 --- /dev/null +++ b/service/internal/runner/vars_test.go @@ -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") + } +} diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index ac03680..6565362 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -128,15 +128,16 @@ type ActionEnvVar struct { // Action is a named shell command or script that can run on managed nodes. type Action struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Kind ActionKind `json:"kind"` - Body string `json:"body"` - Env []ActionEnvVar `json:"env"` - Builtin bool `json:"builtin"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + 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"` } // ActionStore is the plain JSON payload in actions.json. @@ -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. @@ -213,15 +217,22 @@ const ( // ActionItemRunResult is the captured output of one action within a run. type ActionItemRunResult struct { - ItemID string `json:"item_id"` - ActionName string `json:"action_name"` - Source string `json:"source"` - ExitCode int `json:"exit_code"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - Error string `json:"error,omitempty"` - StartedAt time.Time `json:"started_at"` - FinishedAt time.Time `json:"finished_at"` + 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"` } // ActionGroupRunRecord is one execution of a group (or a single-item manual run).