diff --git a/pkg/kube/readiness.go b/pkg/kube/readiness.go new file mode 100644 index 000000000..9924413ee --- /dev/null +++ b/pkg/kube/readiness.go @@ -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} + // 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} +// +// 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} +// 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} " 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) + } +} diff --git a/pkg/kube/readiness_test.go b/pkg/kube/readiness_test.go new file mode 100644 index 000000000..3ac9c662e --- /dev/null +++ b/pkg/kube/readiness_test.go @@ -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) + } + }) + } +}