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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user