feat(spec): Task 4 — custom readiness evaluation with JSONPath expressions

Adds EvaluateCustomReadiness to pkg/kube for helm.sh/readiness-success
and helm.sh/readiness-failure annotation evaluation. Supports ==, !=,
<, <=, >, >= operators against string, numeric, and boolean field values
using {.fieldPath} JSONPath syntax scoped to .status.
pull/31992/head
caretak3r 8 months ago
parent d153a42231
commit 56787a1bae

@ -0,0 +1,256 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package kube
import (
"bytes"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/util/jsonpath"
)
const (
// AnnotationReadinessSuccess declares custom readiness success conditions.
// Value is a newline-separated list of JSONPath expressions of the form:
// {.fieldPath} <operator> <value>
// If ANY condition is true, the resource is considered ready.
AnnotationReadinessSuccess = "helm.sh/readiness-success"
// AnnotationReadinessFailure declares custom readiness failure conditions.
// Value is a newline-separated list of JSONPath expressions.
// If ANY condition is true, the resource is considered failed.
// Failure conditions take precedence over success conditions.
AnnotationReadinessFailure = "helm.sh/readiness-failure"
)
// ReadinessStatus represents the evaluated readiness of a resource.
type ReadinessStatus int
const (
// ReadinessPending means neither success nor failure conditions are met.
ReadinessPending ReadinessStatus = iota
// ReadinessReady means at least one success condition is true.
ReadinessReady
// ReadinessFailed means at least one failure condition is true.
ReadinessFailed
)
func (r ReadinessStatus) String() string {
switch r {
case ReadinessPending:
return "Pending"
case ReadinessReady:
return "Ready"
case ReadinessFailed:
return "Failed"
default:
return "Unknown"
}
}
// EvaluateCustomReadiness evaluates custom readiness conditions from annotations.
//
// Both successExprs and failureExprs are slices of expression strings in the form:
//
// {.fieldPath} <operator> <value>
//
// The JSONPath is scoped to .status — {.phase} evaluates against .status.phase.
//
// Returns:
// - ReadinessStatus: the evaluated status (Pending/Ready/Failed)
// - useKstatus bool: true when custom evaluation is not applicable and the caller
// should fall back to kstatus. This happens when either successExprs or
// failureExprs is nil/empty (only one annotation present).
// - error: parsing errors (not "condition not met", which returns Pending)
//
// Evaluation order: failure conditions are checked first. If any failure condition
// is true → Failed. Then success conditions — if any is true → Ready. Otherwise → Pending.
func EvaluateCustomReadiness(obj *unstructured.Unstructured, successExprs, failureExprs []string) (ReadinessStatus, bool, error) {
hasSuccess := len(successExprs) > 0
hasFailure := len(failureExprs) > 0
if !hasSuccess || !hasFailure {
// Only one annotation present — fall back to kstatus with a warning.
if hasSuccess || hasFailure {
slog.Warn("only one custom readiness annotation present; falling back to kstatus",
"resource", obj.GetName(),
"hasReadinessSuccess", hasSuccess,
"hasReadinessFailure", hasFailure,
)
}
return ReadinessPending, true, nil
}
// Get .status as map for JSONPath evaluation.
statusObj, found, err := unstructured.NestedMap(obj.Object, "status")
if err != nil || !found {
// Missing status — treat as not-ready, not an error.
return ReadinessPending, false, nil
}
// Wrap status in a map to allow {.fieldName} queries.
statusWrapper := map[string]interface{}{"status": statusObj}
// Check failure conditions first (precedence over success).
for _, expr := range failureExprs {
met, err := evaluateExpression(statusWrapper, expr)
if err != nil {
return ReadinessPending, false, fmt.Errorf("evaluating failure expression %q: %w", expr, err)
}
if met {
return ReadinessFailed, false, nil
}
}
// Check success conditions — OR semantics (any true → ready).
for _, expr := range successExprs {
met, err := evaluateExpression(statusWrapper, expr)
if err != nil {
return ReadinessPending, false, fmt.Errorf("evaluating success expression %q: %w", expr, err)
}
if met {
return ReadinessReady, false, nil
}
}
return ReadinessPending, false, nil
}
// evaluateExpression evaluates a single readiness expression against obj.
// Expression format: {.fieldPath} <operator> <value>
// Returns true if the condition is met, false if not met or field is missing.
func evaluateExpression(obj map[string]interface{}, expr string) (bool, error) {
path, op, rawVal, err := parseReadinessExpression(expr)
if err != nil {
return false, err
}
// Build a JSONPath query against statusWrapper (path like ".phase" → "status.phase")
jp := jsonpath.New("readiness")
// JSONPath template: {.status.fieldName}
template := "{.status" + path + "}"
if err := jp.Parse(template); err != nil {
return false, fmt.Errorf("invalid JSONPath %q: %w", template, err)
}
var buf bytes.Buffer
if err := jp.Execute(&buf, obj); err != nil {
// Field not found — treat as not-ready (not an error).
return false, nil
}
actualVal := buf.String()
return compareValues(actualVal, op, rawVal)
}
// operatorRegexp matches a comparison operator with surrounding optional whitespace
// at the beginning of a string: ==, !=, <=, >=, <, >
var operatorRegexp = regexp.MustCompile(`^(==|!=|<=|>=|<|>)\s+`)
// parseReadinessExpression parses "{.fieldPath} <operator> <value>" into components.
// Returns the JSONPath path (e.g., ".phase"), operator (e.g., "=="), and raw value string.
func parseReadinessExpression(expr string) (path, op, val string, err error) {
// Find the JSONPath portion: {.something}
expr = strings.TrimSpace(expr)
if !strings.HasPrefix(expr, "{") {
return "", "", "", fmt.Errorf("expression must start with {.path}: %q", expr)
}
closeBrace := strings.Index(expr, "}")
if closeBrace < 0 {
return "", "", "", fmt.Errorf("expression missing closing }: %q", expr)
}
// path is contents of {}, e.g. ".phase"
path = expr[1:closeBrace]
rest := strings.TrimSpace(expr[closeBrace+1:])
// Find operator at start of rest
loc := operatorRegexp.FindStringSubmatchIndex(rest)
if loc == nil {
return "", "", "", fmt.Errorf("expression missing operator (==, !=, <, <=, >, >=): %q", expr)
}
op = rest[loc[2]:loc[3]] // capture group 1
val = strings.TrimSpace(rest[loc[1]:])
return path, op, val, nil
}
// compareValues compares actual (string from JSONPath) to expected (raw annotation value).
// Supports string, numeric, and boolean comparisons.
func compareValues(actual, op, expected string) (bool, error) {
// Remove quotes from expected string values.
expectedTrimmed := strings.Trim(expected, `"'`)
actualTrimmed := actual
// Try numeric comparison.
actualFloat, actualIsNum := tryParseFloat(actualTrimmed)
expectedFloat, expectedIsNum := tryParseFloat(expectedTrimmed)
if actualIsNum && expectedIsNum {
return compareNumeric(actualFloat, op, expectedFloat)
}
// Boolean comparison.
if expected == "true" || expected == "false" {
actualBool := actual == "true"
expectedBool := expected == "true"
switch op {
case "==":
return actualBool == expectedBool, nil
case "!=":
return actualBool != expectedBool, nil
default:
return false, fmt.Errorf("operator %q not supported for boolean values", op)
}
}
// String comparison.
switch op {
case "==":
return actualTrimmed == expectedTrimmed, nil
case "!=":
return actualTrimmed != expectedTrimmed, nil
default:
return false, fmt.Errorf("operator %q not supported for string values", op)
}
}
func tryParseFloat(s string) (float64, bool) {
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
}
func compareNumeric(a float64, op string, b float64) (bool, error) {
switch op {
case "==":
return a == b, nil
case "!=":
return a != b, nil
case "<":
return a < b, nil
case "<=":
return a <= b, nil
case ">":
return a > b, nil
case ">=":
return a >= b, nil
default:
return false, fmt.Errorf("unknown operator %q", op)
}
}

@ -0,0 +1,224 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package kube
import (
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func makeUnstructured(statusFields map[string]interface{}) *unstructured.Unstructured {
obj := &unstructured.Unstructured{}
obj.SetAPIVersion("v1")
obj.SetKind("ConfigMap")
obj.SetName("test-resource")
if statusFields != nil {
if err := unstructured.SetNestedField(obj.Object, statusFields, "status"); err != nil {
panic(err)
}
}
return obj
}
func TestEvaluateReadiness_BothNil_UsesKstatus(t *testing.T) {
obj := makeUnstructured(nil)
result, useKstatus, err := EvaluateCustomReadiness(obj, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !useKstatus {
t.Error("expected useKstatus=true when no custom conditions")
}
_ = result
}
func TestEvaluateReadiness_OnlySuccess_FallsBackToKstatus(t *testing.T) {
obj := makeUnstructured(nil)
successExprs := []string{`{.ready} == true`}
result, useKstatus, err := EvaluateCustomReadiness(obj, successExprs, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !useKstatus {
t.Error("expected useKstatus=true (single annotation → fallback)")
}
_ = result
}
func TestEvaluateReadiness_OnlyFailure_FallsBackToKstatus(t *testing.T) {
obj := makeUnstructured(nil)
failureExprs := []string{`{.failed} == true`}
result, useKstatus, err := EvaluateCustomReadiness(obj, nil, failureExprs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !useKstatus {
t.Error("expected useKstatus=true (single annotation → fallback)")
}
_ = result
}
func TestEvaluateReadiness_SuccessTrue(t *testing.T) {
obj := makeUnstructured(map[string]interface{}{
"succeeded": int64(1),
})
successExprs := []string{`{.succeeded} == 1`}
failureExprs := []string{`{.failed} == true`}
result, useKstatus, err := EvaluateCustomReadiness(obj, successExprs, failureExprs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if useKstatus {
t.Error("expected useKstatus=false when both annotations provided")
}
if result != ReadinessReady {
t.Errorf("expected Ready, got %v", result)
}
}
func TestEvaluateReadiness_FailurePrecedesSuccess(t *testing.T) {
// Both failure AND success conditions match. Failure takes precedence.
obj := makeUnstructured(map[string]interface{}{
"succeeded": int64(1),
"failed": true,
})
successExprs := []string{`{.succeeded} == 1`}
failureExprs := []string{`{.failed} == true`}
result, useKstatus, err := EvaluateCustomReadiness(obj, successExprs, failureExprs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if useKstatus {
t.Error("expected useKstatus=false")
}
if result != ReadinessFailed {
t.Errorf("expected Failed (failure takes precedence), got %v", result)
}
}
func TestEvaluateReadiness_NeitherConditionMet(t *testing.T) {
obj := makeUnstructured(map[string]interface{}{
"succeeded": int64(0),
})
successExprs := []string{`{.succeeded} == 1`}
failureExprs := []string{`{.failed} == true`}
result, useKstatus, err := EvaluateCustomReadiness(obj, successExprs, failureExprs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if useKstatus {
t.Error("expected useKstatus=false")
}
if result != ReadinessPending {
t.Errorf("expected Pending (neither condition met), got %v", result)
}
}
func TestEvaluateReadiness_ORSemantics_AnySuccessTrue(t *testing.T) {
// OR semantics: if ANY success condition is true → ready
obj := makeUnstructured(map[string]interface{}{
"phase": "Succeeded",
"another": "nope",
})
successExprs := []string{
`{.another} == "yes"`, // false
`{.phase} == "Succeeded"`, // true
}
failureExprs := []string{`{.failed} == true`}
result, _, err := EvaluateCustomReadiness(obj, successExprs, failureExprs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != ReadinessReady {
t.Errorf("expected Ready (OR semantics), got %v", result)
}
}
func TestEvaluateReadiness_MissingStatusField(t *testing.T) {
// .nonexistent doesn't exist — should return Pending, not error
obj := makeUnstructured(map[string]interface{}{})
successExprs := []string{`{.nonexistent} == "yes"`}
failureExprs := []string{`{.failed} == true`}
result, _, err := EvaluateCustomReadiness(obj, successExprs, failureExprs)
if err != nil {
t.Fatalf("expected no error for missing field, got: %v", err)
}
if result != ReadinessPending {
t.Errorf("expected Pending for missing field, got %v", result)
}
}
func TestEvaluateReadiness_StringComparison(t *testing.T) {
obj := makeUnstructured(map[string]interface{}{
"phase": "Running",
})
successExprs := []string{`{.phase} == "Running"`}
failureExprs := []string{`{.phase} == "Failed"`}
result, _, err := EvaluateCustomReadiness(obj, successExprs, failureExprs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != ReadinessReady {
t.Errorf("expected Ready, got %v", result)
}
}
func TestEvaluateReadiness_NotEqualOperator(t *testing.T) {
obj := makeUnstructured(map[string]interface{}{
"phase": "Running",
})
successExprs := []string{`{.phase} != "Failed"`}
failureExprs := []string{`{.phase} == "Failed"`}
result, _, err := EvaluateCustomReadiness(obj, successExprs, failureExprs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != ReadinessReady {
t.Errorf("expected Ready, got %v", result)
}
}
func TestParseReadinessExpression(t *testing.T) {
tests := []struct {
expr string
wantPath string
wantOp string
wantVal string
}{
{`{.phase} == "Running"`, ".phase", "==", `"Running"`},
{`{.count} >= 3`, ".count", ">=", "3"},
{`{.ready} != false`, ".ready", "!=", "false"},
}
for _, tt := range tests {
t.Run(tt.expr, func(t *testing.T) {
path, op, val, err := parseReadinessExpression(tt.expr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if path != tt.wantPath {
t.Errorf("path: want %q, got %q", tt.wantPath, path)
}
if op != tt.wantOp {
t.Errorf("op: want %q, got %q", tt.wantOp, op)
}
if val != tt.wantVal {
t.Errorf("val: want %q, got %q", tt.wantVal, val)
}
})
}
}
Loading…
Cancel
Save