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.
331 lines
8.5 KiB
Go
331 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|