mirror of https://github.com/helm/helm
OrderedWaitStrategy, custom readiness evaluation via .status-scoped JSONPath expressions (helm.sh/readiness-success / -failure), and the status reader that layers custom readiness over kstatus. Refs: HIP-0025 Signed-off-by: Rohit Gudi <50377477+caretak3r@users.noreply.github.com>
parent
c18503939e
commit
57ef3b41be
@ -0,0 +1,54 @@
|
||||
/*
|
||||
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"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClientGetWaiterWithOptions_OrderedWaitStrategy(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
waiter, err := client.GetWaiterWithOptions(OrderedWaitStrategy)
|
||||
require.NoError(t, err)
|
||||
assert.IsType(t, &statusWaiter{}, waiter)
|
||||
}
|
||||
|
||||
func TestClientGetWaiterWithOptions_UnknownStrategyListsOrdered(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
waiter, err := client.GetWaiterWithOptions(WaitStrategy("bogus"))
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, waiter)
|
||||
assert.Contains(t, err.Error(), string(OrderedWaitStrategy))
|
||||
}
|
||||
|
||||
func TestNewStatusWatcherCustomReadinessFlag(t *testing.T) {
|
||||
client := newTestClient(t)
|
||||
|
||||
w, err := client.newStatusWatcher(WithCustomReadinessStatusReader())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, w.customReadiness)
|
||||
assert.Empty(t, w.readers, "custom readiness must not inject a reader into the shared reader list")
|
||||
|
||||
w, err = client.newStatusWatcher()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, w.customReadiness)
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
/*
|
||||
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 "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
|
||||
"github.com/fluxcd/cli-utils/pkg/object"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
type customReadinessStatusReader struct {
|
||||
logger *slog.Logger
|
||||
// fallback is the reader chain the enclosing wait method would use if
|
||||
// custom readiness were disabled. Resources that do not carry both
|
||||
// readiness annotations are delegated to it, so built-in readers (the
|
||||
// composite Deployment/ReplicaSet/StatefulSet readers, the Job reader in
|
||||
// WaitWithJobs) and caller-supplied readers keep their semantics.
|
||||
fallback engine.StatusReader
|
||||
// warnedExpressions dedups authoring-mistake warnings so a bad expression
|
||||
// logs once per resource rather than on every watch event. Keys are
|
||||
// "<ObjMetadata.String()>|<expression>". The reader is invoked
|
||||
// concurrently by the status watcher, hence sync.Map.
|
||||
warnedExpressions sync.Map
|
||||
}
|
||||
|
||||
// newCustomReadinessStatusReader wraps fallback with per-resource custom
|
||||
// readiness evaluation. fallback must be non-nil and must support every
|
||||
// GroupKind this reader can receive (every chain built in statuswait.go
|
||||
// terminates in a generic reader that supports all GroupKinds).
|
||||
func newCustomReadinessStatusReader(logger *slog.Logger, fallback engine.StatusReader) engine.StatusReader {
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
return &customReadinessStatusReader{logger: logger, fallback: fallback}
|
||||
}
|
||||
|
||||
// Supports returns true for every GroupKind: annotated resources of any kind
|
||||
// are evaluated here, and everything else is delegated to the fallback chain.
|
||||
func (*customReadinessStatusReader) Supports(schema.GroupKind) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *customReadinessStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) {
|
||||
u := &unstructured.Unstructured{}
|
||||
u.SetGroupVersionKind(schema.GroupVersionKind{
|
||||
Group: resource.GroupKind.Group,
|
||||
Kind: resource.GroupKind.Kind,
|
||||
})
|
||||
if err := reader.Get(ctx, client.ObjectKey{Namespace: resource.Namespace, Name: resource.Name}, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.readStatus(ctx, reader, resource, u)
|
||||
}
|
||||
|
||||
func (r *customReadinessStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
|
||||
identifier, err := object.RuntimeToObjMeta(resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.readStatus(ctx, reader, identifier, resource)
|
||||
}
|
||||
|
||||
func (r *customReadinessStatusReader) readStatus(ctx context.Context, reader engine.ClusterReader, identifier object.ObjMetadata, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
|
||||
annotations := resource.GetAnnotations()
|
||||
|
||||
successExprs, err := parseReadinessExpressions(annotations[AnnotationReadinessSuccess])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %s for %s/%s: %w", AnnotationReadinessSuccess, identifier.Namespace, identifier.Name, err)
|
||||
}
|
||||
failureExprs, err := parseReadinessExpressions(annotations[AnnotationReadinessFailure])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing %s for %s/%s: %w", AnnotationReadinessFailure, identifier.Namespace, identifier.Name, err)
|
||||
}
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(resource, successExprs, failureExprs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if useKstatus {
|
||||
// Missing or partial annotations: this resource does not opt in to
|
||||
// custom readiness, so the default reader chain decides its status.
|
||||
return r.fallback.ReadStatusForObject(ctx, reader, resource)
|
||||
}
|
||||
|
||||
for _, w := range warnings {
|
||||
key := identifier.String() + "|" + w.Expression
|
||||
if _, alreadyWarned := r.warnedExpressions.LoadOrStore(key, struct{}{}); alreadyWarned {
|
||||
continue
|
||||
}
|
||||
r.logger.Warn("custom readiness expression cannot be evaluated; treating condition as not met",
|
||||
"kind", identifier.GroupKind.Kind,
|
||||
"namespace", identifier.Namespace,
|
||||
"name", identifier.Name,
|
||||
"expression", w.Expression,
|
||||
"detail", w.Detail,
|
||||
)
|
||||
}
|
||||
|
||||
st, message := status.InProgressStatus, "waiting for custom readiness conditions"
|
||||
switch result {
|
||||
case ReadinessReady:
|
||||
st, message = status.CurrentStatus, "custom readiness conditions met"
|
||||
case ReadinessFailed:
|
||||
st, message = status.FailedStatus, "custom readiness failure condition met"
|
||||
default:
|
||||
// ReadinessPending (and any future status) keep the in-progress
|
||||
// defaults; name any skipped expression so an eventual wait timeout
|
||||
// error is self-explanatory.
|
||||
if len(warnings) > 0 {
|
||||
message = fmt.Sprintf("waiting for custom readiness conditions (%d expression(s) skipped: %s)", len(warnings), warnings[0].Detail)
|
||||
}
|
||||
}
|
||||
return &event.ResourceStatus{
|
||||
Identifier: identifier,
|
||||
Status: st,
|
||||
Message: message,
|
||||
}, nil
|
||||
}
|
||||
@ -0,0 +1,617 @@
|
||||
/*
|
||||
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"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/clusterreader"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
|
||||
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
|
||||
"github.com/fluxcd/cli-utils/pkg/object"
|
||||
"github.com/fluxcd/cli-utils/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/dynamic/fake"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
)
|
||||
|
||||
func TestStatusWaitWithCustomReadinessReader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
manifest string
|
||||
expectErrStrs []string
|
||||
notExpectErrStrs []string
|
||||
}{
|
||||
{
|
||||
name: "custom readiness makes resource current",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: ready-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Ready
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "custom readiness failure stops wait",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: failed-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Failed
|
||||
`,
|
||||
expectErrStrs: []string{"resource ConfigMap/default/failed-config not ready. status: Failed"},
|
||||
},
|
||||
{
|
||||
name: "resources without annotations fall back to kstatus",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: current-config
|
||||
namespace: default
|
||||
`,
|
||||
},
|
||||
{
|
||||
// END-TO-END pin for hip-0025-gy6: an ordering operator on a
|
||||
// non-numeric value must leave the resource InProgress (and let
|
||||
// the wait time out per-resource) — NOT kill the status reporter
|
||||
// and abort the whole batch.
|
||||
name: "ordering operator on non-numeric value does not abort the wait",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: bad-ordering
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} > \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Running
|
||||
`,
|
||||
expectErrStrs: []string{"resource ConfigMap/default/bad-ordering not ready. status: InProgress", "skipped"},
|
||||
notExpectErrStrs: []string{"failed to compute object status"},
|
||||
},
|
||||
{
|
||||
name: "ordering on numeric string value compares numerically",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: numeric-string
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} >= 5"]'
|
||||
helm.sh/readiness-failure: '["{.failed} >= 1"]'
|
||||
status:
|
||||
phase: "5"
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "incomparable expression skipped when another success condition is met",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: mixed-success
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} > \"Ready\"", "{.phase} == \"Running\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Running
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Partial annotations (only one of success/failure): custom
|
||||
// evaluation must NOT engage; the resource is judged by the
|
||||
// default chain (a bare ConfigMap is Current), even though the
|
||||
// success expression is unmet.
|
||||
name: "partial annotation falls back to default readiness",
|
||||
manifest: `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: partial-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
status:
|
||||
phase: NotReady
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newTestClient(t)
|
||||
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
|
||||
fakeMapper := testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap"))
|
||||
waiter := statusWaiter{
|
||||
client: fakeClient,
|
||||
restMapper: fakeMapper,
|
||||
customReadiness: true,
|
||||
}
|
||||
|
||||
objs := getRuntimeObjFromManifests(t, []string{tt.manifest})
|
||||
for _, obj := range objs {
|
||||
u := obj.(*unstructured.Unstructured)
|
||||
gvr := getGVR(t, fakeMapper, u)
|
||||
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
|
||||
}
|
||||
|
||||
resourceList := getResourceListFromRuntimeObjs(t, c, objs)
|
||||
err := waiter.Wait(resourceList, time.Second)
|
||||
if tt.expectErrStrs == nil {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
for _, expected := range tt.expectErrStrs {
|
||||
assert.Contains(t, err.Error(), expected)
|
||||
}
|
||||
for _, unexpected := range tt.notExpectErrStrs {
|
||||
assert.NotContains(t, err.Error(), unexpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCustomReadinessStatusReader(t *testing.T) {
|
||||
opts := &waitOptions{}
|
||||
WithCustomReadinessStatusReader()(opts)
|
||||
assert.True(t, opts.enableCustomReadinessStatusReader)
|
||||
}
|
||||
|
||||
func TestCustomReadinessStatusReaderReadStatusForObject(t *testing.T) {
|
||||
reader := newCustomReadinessStatusReader(nil, statusreaders.NewDefaultStatusReader(testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap"))))
|
||||
u := &unstructured.Unstructured{}
|
||||
u.SetAPIVersion("v1")
|
||||
u.SetKind("ConfigMap")
|
||||
u.SetName("ready-config")
|
||||
u.SetNamespace("default")
|
||||
u.SetAnnotations(map[string]string{
|
||||
AnnotationReadinessSuccess: `["{.phase} == \"Ready\""]`,
|
||||
AnnotationReadinessFailure: `["{.phase} == \"Failed\""]`,
|
||||
})
|
||||
require.NoError(t, unstructured.SetNestedField(u.Object, "Ready", "status", "phase"))
|
||||
|
||||
result, err := reader.ReadStatusForObject(context.Background(), nil, u)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, status.CurrentStatus, result.Status)
|
||||
}
|
||||
|
||||
func TestCustomReadinessStatusReaderWarnsOnceForIncomparableExpression(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
reader := newCustomReadinessStatusReader(slog.New(slog.NewTextHandler(&buf, nil)), statusreaders.NewDefaultStatusReader(testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("ConfigMap"))))
|
||||
|
||||
u := &unstructured.Unstructured{}
|
||||
u.SetAPIVersion("v1")
|
||||
u.SetKind("ConfigMap")
|
||||
u.SetName("bad-ordering")
|
||||
u.SetNamespace("default")
|
||||
u.SetAnnotations(map[string]string{
|
||||
AnnotationReadinessSuccess: `["{.phase} > \"Ready\""]`,
|
||||
AnnotationReadinessFailure: `["{.failed} >= 1"]`,
|
||||
})
|
||||
require.NoError(t, unstructured.SetNestedField(u.Object, "Running", "status", "phase"))
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
result, err := reader.ReadStatusForObject(context.Background(), nil, u)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, status.InProgressStatus, result.Status)
|
||||
assert.Contains(t, result.Message, "skipped")
|
||||
assert.Contains(t, result.Message, "ordering operators")
|
||||
}
|
||||
|
||||
logged := buf.String()
|
||||
assert.Equal(t, 1, strings.Count(logged, "treating condition as not met"),
|
||||
"warning must be deduplicated to once per resource+expression")
|
||||
assert.Contains(t, logged, "bad-ordering")
|
||||
assert.Contains(t, logged, "ordering operators")
|
||||
}
|
||||
|
||||
// The composite Deployment reader is the fallback observable: it populates
|
||||
// GeneratedResources with the child ReplicaSets it aggregated, which neither
|
||||
// the generic kstatus reader nor a bare status.Compute ever does. (In
|
||||
// cli-utils v1.2.2 the composite readers compute the same top-level status as
|
||||
// status.Compute, so GeneratedResources is the distinguishing signal that the
|
||||
// default chain — not an internal kstatus shortcut — handled the resource.)
|
||||
func TestCustomReadinessReaderDelegatesToFallbackChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deploymentYAML := func(annotations string) string {
|
||||
return `
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web
|
||||
namespace: default
|
||||
generation: 1` + annotations + `
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web
|
||||
status:
|
||||
observedGeneration: 1
|
||||
replicas: 1
|
||||
updatedReplicas: 1
|
||||
readyReplicas: 1
|
||||
availableReplicas: 1
|
||||
conditions:
|
||||
- type: Available
|
||||
status: "True"
|
||||
reason: MinimumReplicasAvailable
|
||||
`
|
||||
}
|
||||
replicaSetManifest := `
|
||||
apiVersion: apps/v1
|
||||
kind: ReplicaSet
|
||||
metadata:
|
||||
name: web-abc123
|
||||
namespace: default
|
||||
generation: 1
|
||||
labels:
|
||||
app: web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: web
|
||||
status:
|
||||
observedGeneration: 1
|
||||
replicas: 1
|
||||
readyReplicas: 1
|
||||
availableReplicas: 1
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
annotations string
|
||||
expectStatus status.Status
|
||||
expectGeneratedResults bool
|
||||
}{
|
||||
{
|
||||
// No annotations: must be delegated to the composite chain.
|
||||
name: "non-annotated deployment handled by composite reader",
|
||||
annotations: "",
|
||||
expectStatus: status.CurrentStatus,
|
||||
expectGeneratedResults: true,
|
||||
},
|
||||
{
|
||||
// One annotation only: partial config must also delegate.
|
||||
name: "partial-annotated deployment handled by composite reader",
|
||||
annotations: `
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.readyReplicas} >= 1"]'`,
|
||||
expectStatus: status.CurrentStatus,
|
||||
expectGeneratedResults: true,
|
||||
},
|
||||
{
|
||||
// Both annotations: custom evaluation engages (unmet expression
|
||||
// keeps it InProgress) and the fallback chain is bypassed.
|
||||
name: "fully annotated deployment evaluated by expressions",
|
||||
annotations: `
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.readyReplicas} >= 2"]'
|
||||
helm.sh/readiness-failure: '["{.failed} >= 1"]'`,
|
||||
expectStatus: status.InProgressStatus,
|
||||
expectGeneratedResults: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fakeMapper := testutil.NewFakeRESTMapper(
|
||||
appsv1.SchemeGroupVersion.WithKind("Deployment"),
|
||||
appsv1.SchemeGroupVersion.WithKind("ReplicaSet"),
|
||||
v1.SchemeGroupVersion.WithKind("Pod"),
|
||||
)
|
||||
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
|
||||
objs := getRuntimeObjFromManifests(t, []string{deploymentYAML(tt.annotations), replicaSetManifest})
|
||||
for _, obj := range objs {
|
||||
u := obj.(*unstructured.Unstructured)
|
||||
gvr := getGVR(t, fakeMapper, u)
|
||||
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
|
||||
}
|
||||
clusterReader := &clusterreader.DynamicClusterReader{
|
||||
DynamicClient: fakeClient,
|
||||
Mapper: fakeMapper,
|
||||
}
|
||||
reader := newCustomReadinessStatusReader(nil, statusreaders.NewStatusReader(fakeMapper))
|
||||
|
||||
deployment := objs[0].(*unstructured.Unstructured)
|
||||
result, err := reader.ReadStatusForObject(context.Background(), clusterReader, deployment)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectStatus, result.Status)
|
||||
if tt.expectGeneratedResults {
|
||||
require.NotEmpty(t, result.GeneratedResources,
|
||||
"fallback composite reader must aggregate the child ReplicaSet")
|
||||
assert.Equal(t, "web-abc123", result.GeneratedResources[0].Identifier.Name)
|
||||
} else {
|
||||
assert.Empty(t, result.GeneratedResources)
|
||||
assert.Contains(t, result.Message, "custom readiness")
|
||||
}
|
||||
|
||||
// The identifier-based entry point must route identically.
|
||||
id, err := object.RuntimeToObjMeta(deployment)
|
||||
require.NoError(t, err)
|
||||
byID, err := reader.ReadStatus(context.Background(), clusterReader, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectStatus, byID.Status)
|
||||
assert.Equal(t, tt.expectGeneratedResults, len(byID.GeneratedResources) > 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A plain (non-annotated) Job in a batch that enables custom readiness must
|
||||
// keep helm's completion gating: helm's job reader holds a started Job
|
||||
// InProgress until a Complete/Failed condition, while plain kstatus reports a
|
||||
// started Job as Current. Before the fix the custom readiness reader shadowed
|
||||
// the job reader and the wait returned early.
|
||||
func TestStatusWaitWithJobsMixedCustomReadiness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startedJobManifest := `
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: slow-job
|
||||
namespace: default
|
||||
generation: 1
|
||||
status:
|
||||
startTime: 2025-02-06T16:34:20-05:00
|
||||
active: 1
|
||||
ready: 1
|
||||
`
|
||||
completeJobManifest := `
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: slow-job
|
||||
namespace: default
|
||||
generation: 1
|
||||
status:
|
||||
succeeded: 1
|
||||
conditions:
|
||||
- type: Complete
|
||||
status: "True"
|
||||
`
|
||||
readyConfigMapManifest := `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: gated-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Ready
|
||||
`
|
||||
pendingConfigMapManifest := `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: gated-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Pending
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
manifests []string
|
||||
expectErrStrs []string
|
||||
notExpectErrStrs []string
|
||||
}{
|
||||
{
|
||||
name: "plain job keeps completion gating in mixed batch",
|
||||
manifests: []string{startedJobManifest, readyConfigMapManifest},
|
||||
expectErrStrs: []string{
|
||||
"resource Job/default/slow-job not ready. status: InProgress",
|
||||
},
|
||||
notExpectErrStrs: []string{"ConfigMap"},
|
||||
},
|
||||
{
|
||||
name: "annotated resource keeps expression gating in mixed batch",
|
||||
manifests: []string{completeJobManifest, pendingConfigMapManifest},
|
||||
expectErrStrs: []string{
|
||||
"resource ConfigMap/default/gated-config not ready. status: InProgress",
|
||||
},
|
||||
notExpectErrStrs: []string{"Job/default/slow-job"},
|
||||
},
|
||||
{
|
||||
name: "batch completes when job is complete and expression met",
|
||||
manifests: []string{completeJobManifest, readyConfigMapManifest},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newTestClient(t)
|
||||
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
|
||||
fakeMapper := testutil.NewFakeRESTMapper(
|
||||
batchv1.SchemeGroupVersion.WithKind("Job"),
|
||||
v1.SchemeGroupVersion.WithKind("ConfigMap"),
|
||||
)
|
||||
waiter := statusWaiter{
|
||||
client: fakeClient,
|
||||
restMapper: fakeMapper,
|
||||
customReadiness: true,
|
||||
}
|
||||
objs := getRuntimeObjFromManifests(t, tt.manifests)
|
||||
for _, obj := range objs {
|
||||
u := obj.(*unstructured.Unstructured)
|
||||
gvr := getGVR(t, fakeMapper, u)
|
||||
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
|
||||
}
|
||||
resourceList := getResourceListFromRuntimeObjs(t, c, objs)
|
||||
err := waiter.WaitWithJobs(resourceList, 2*time.Second)
|
||||
if tt.expectErrStrs == nil {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
for _, expected := range tt.expectErrStrs {
|
||||
assert.Contains(t, err.Error(), expected)
|
||||
}
|
||||
for _, unexpected := range tt.notExpectErrStrs {
|
||||
assert.NotContains(t, err.Error(), unexpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WatchUntilReady is the hook path: hook readiness must be governed by the
|
||||
// hook readers (helm's job/pod readers + always-ready for everything else),
|
||||
// never by the readiness annotations, even when custom readiness is enabled
|
||||
// on the waiter. Two directions:
|
||||
// - a started-but-incomplete hook Job must still time out (kstatus would
|
||||
// wrongly report a started Job as Current);
|
||||
// - an annotated non-Job hook must complete even though its success
|
||||
// expression is unmet (annotations are ignored for hooks).
|
||||
func TestWatchUntilReadyIgnoresCustomReadiness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startedJobManifest := `
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: hook-job
|
||||
namespace: default
|
||||
generation: 1
|
||||
status:
|
||||
startTime: 2025-02-06T16:34:20-05:00
|
||||
active: 1
|
||||
`
|
||||
completeJobManifest := `
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: hook-job
|
||||
namespace: default
|
||||
generation: 1
|
||||
status:
|
||||
succeeded: 1
|
||||
conditions:
|
||||
- type: Complete
|
||||
status: "True"
|
||||
`
|
||||
annotatedConfigMapManifest := `
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: hook-config
|
||||
namespace: default
|
||||
annotations:
|
||||
helm.sh/readiness-success: '["{.phase} == \"Ready\""]'
|
||||
helm.sh/readiness-failure: '["{.phase} == \"Failed\""]'
|
||||
status:
|
||||
phase: Pending
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
manifests []string
|
||||
expectErrStrs []string
|
||||
}{
|
||||
{
|
||||
name: "incomplete hook job still gated by hook job reader",
|
||||
manifests: []string{startedJobManifest},
|
||||
expectErrStrs: []string{
|
||||
"resource Job/default/hook-job not ready. status: InProgress",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "annotated hook resource ignores readiness annotations",
|
||||
manifests: []string{annotatedConfigMapManifest},
|
||||
},
|
||||
{
|
||||
name: "complete hook job succeeds",
|
||||
manifests: []string{completeJobManifest},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newTestClient(t)
|
||||
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
|
||||
fakeMapper := testutil.NewFakeRESTMapper(
|
||||
batchv1.SchemeGroupVersion.WithKind("Job"),
|
||||
v1.SchemeGroupVersion.WithKind("ConfigMap"),
|
||||
)
|
||||
waiter := statusWaiter{
|
||||
client: fakeClient,
|
||||
restMapper: fakeMapper,
|
||||
customReadiness: true,
|
||||
}
|
||||
objs := getRuntimeObjFromManifests(t, tt.manifests)
|
||||
for _, obj := range objs {
|
||||
u := obj.(*unstructured.Unstructured)
|
||||
gvr := getGVR(t, fakeMapper, u)
|
||||
require.NoError(t, fakeClient.Tracker().Create(gvr, u, u.GetNamespace()))
|
||||
}
|
||||
resourceList := getResourceListFromRuntimeObjs(t, c, objs)
|
||||
err := waiter.WatchUntilReady(resourceList, 2*time.Second)
|
||||
if tt.expectErrStrs == nil {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
for _, expected := range tt.expectErrStrs {
|
||||
assert.Contains(t, err.Error(), expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,368 @@
|
||||
/*
|
||||
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 "helm.sh/helm/v4/pkg/kube"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/util/jsonpath"
|
||||
|
||||
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
||||
)
|
||||
|
||||
// The custom-readiness annotation keys' canonical home is
|
||||
// pkg/release/v1/util, next to the other sequencing annotations, so the pure
|
||||
// plan builder (pkg/release/v1/sequence) can read them without importing
|
||||
// pkg/kube. These value-identical aliases keep existing kube callers working.
|
||||
const (
|
||||
// AnnotationReadinessSuccess declares custom readiness success conditions.
|
||||
AnnotationReadinessSuccess = releaseutil.AnnotationReadinessSuccess
|
||||
|
||||
// AnnotationReadinessFailure declares custom readiness failure conditions.
|
||||
// Failure conditions take precedence over success conditions.
|
||||
AnnotationReadinessFailure = releaseutil.AnnotationReadinessFailure
|
||||
)
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
|
||||
// ExpressionWarning describes a readiness expression that could not be
|
||||
// evaluated against the observed object state and was therefore treated as
|
||||
// "condition not met" rather than failing the whole wait.
|
||||
type ExpressionWarning struct {
|
||||
// Expression is the original annotation expression, e.g. `{.phase} > "Ready"`.
|
||||
Expression string
|
||||
// Detail is a human-readable reason, including the observed value.
|
||||
Detail string
|
||||
}
|
||||
|
||||
// errIncomparableOrdering marks a comparison that cannot succeed against the
|
||||
// observed values: an ordering operator (<, <=, >, >=) applied to operands
|
||||
// that are not both numeric. The actual value's type is only known at runtime,
|
||||
// so lint cannot always catch this. EvaluateCustomReadiness downgrades it to
|
||||
// an ExpressionWarning — the expression counts as "condition not met",
|
||||
// mirroring how a missing status key is treated — instead of returning an
|
||||
// error that would abort the entire wait.
|
||||
var errIncomparableOrdering = errors.New("ordering operators (<, <=, >, >=) require numeric values")
|
||||
|
||||
// EvaluateCustomReadiness evaluates custom readiness expressions against a
|
||||
// resource's .status field.
|
||||
//
|
||||
// When both successExprs and failureExprs are empty or nil, or when only one
|
||||
// side is provided, the caller should fall back to kstatus and useKstatus will
|
||||
// be returned as true.
|
||||
//
|
||||
// Expressions that cannot be evaluated against the observed values (an
|
||||
// ordering operator applied to non-numeric operands) are treated as
|
||||
// "condition not met" and reported in the returned warnings rather than as an
|
||||
// error. Expressions after the first met condition are not evaluated. All
|
||||
// other evaluation problems (invalid JSONPath, unsupported operator) are
|
||||
// returned as errors.
|
||||
func EvaluateCustomReadiness(obj *unstructured.Unstructured, successExprs, failureExprs []string) (ReadinessStatus, bool, []ExpressionWarning, error) {
|
||||
hasSuccess := len(successExprs) > 0
|
||||
hasFailure := len(failureExprs) > 0
|
||||
|
||||
if !hasSuccess || !hasFailure {
|
||||
// Partial annotations: fall back to kstatus. The warning for this
|
||||
// case is emitted once per batch by warnIfPartialReadinessAnnotations
|
||||
// in the sequencing layer, not here (which runs on every poll tick).
|
||||
return ReadinessPending, true, nil, nil
|
||||
}
|
||||
|
||||
statusObj, found, err := unstructured.NestedMap(obj.Object, "status")
|
||||
if err != nil || !found {
|
||||
return ReadinessPending, false, nil, nil
|
||||
}
|
||||
|
||||
statusWrapper := map[string]any{"status": statusObj}
|
||||
|
||||
var warnings []ExpressionWarning
|
||||
|
||||
for _, expr := range failureExprs {
|
||||
met, err := evaluateExpression(statusWrapper, expr)
|
||||
if err != nil {
|
||||
if errors.Is(err, errIncomparableOrdering) {
|
||||
warnings = append(warnings, ExpressionWarning{Expression: expr, Detail: err.Error()})
|
||||
continue
|
||||
}
|
||||
return ReadinessPending, false, warnings, fmt.Errorf("evaluating failure expression %q: %w", expr, err)
|
||||
}
|
||||
if met {
|
||||
return ReadinessFailed, false, warnings, nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, expr := range successExprs {
|
||||
met, err := evaluateExpression(statusWrapper, expr)
|
||||
if err != nil {
|
||||
if errors.Is(err, errIncomparableOrdering) {
|
||||
warnings = append(warnings, ExpressionWarning{Expression: expr, Detail: err.Error()})
|
||||
continue
|
||||
}
|
||||
return ReadinessPending, false, warnings, fmt.Errorf("evaluating success expression %q: %w", expr, err)
|
||||
}
|
||||
if met {
|
||||
return ReadinessReady, false, warnings, nil
|
||||
}
|
||||
}
|
||||
|
||||
return ReadinessPending, false, warnings, nil
|
||||
}
|
||||
|
||||
func parseReadinessExpressions(annotation string) ([]string, error) {
|
||||
annotation = strings.TrimSpace(annotation)
|
||||
if annotation == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var exprs []string
|
||||
if err := json.Unmarshal([]byte(annotation), &exprs); err != nil {
|
||||
return nil, fmt.Errorf("parsing readiness annotation JSON: %w", err)
|
||||
}
|
||||
|
||||
for i, expr := range exprs {
|
||||
exprs[i] = strings.TrimSpace(expr)
|
||||
}
|
||||
|
||||
return exprs, nil
|
||||
}
|
||||
|
||||
// ValidateReadinessExpressions parses a readiness annotation value (a JSON
|
||||
// array of "{<jsonpath>} <op> <value>" expressions) and verifies that every
|
||||
// expression is well-formed and compiles to a valid JSONPath. It does not
|
||||
// evaluate anything against a live object — it is the static-validation entry
|
||||
// point used by `helm lint` so malformed expressions are caught at author time
|
||||
// rather than silently failing readiness at install time. An empty or absent
|
||||
// annotation is valid (returns nil). It also rejects ordering operators
|
||||
// (<, <=, >, >=) whose comparison value is not numeric, since such a
|
||||
// comparison can never evaluate at runtime.
|
||||
func ValidateReadinessExpressions(annotation string) error {
|
||||
exprs, err := parseReadinessExpressions(annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, expr := range exprs {
|
||||
path, op, val, err := parseReadinessExpression(expr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template, err := readinessJSONPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := jsonpath.New("readiness").Parse(template); err != nil {
|
||||
return fmt.Errorf("invalid JSONPath %q: %w", template, err)
|
||||
}
|
||||
// An ordering comparison can only ever succeed numerically, and the
|
||||
// literal's type is statically known: a non-numeric comparison value
|
||||
// with <, <=, >, >= is a definite authoring error.
|
||||
switch op {
|
||||
case "<", "<=", ">", ">=":
|
||||
if _, ok := tryParseFloat(trimReadinessValue(val)); !ok {
|
||||
return fmt.Errorf("expression %q: ordering operator %q requires a numeric comparison value, got %s", expr, op, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func evaluateExpression(obj map[string]any, expr string) (bool, error) {
|
||||
path, op, rawVal, err := parseReadinessExpression(expr)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
template, err := readinessJSONPath(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
jp := jsonpath.New("readiness")
|
||||
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 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return compareValues(strings.TrimSpace(buf.String()), op, rawVal)
|
||||
}
|
||||
|
||||
func readinessJSONPath(path string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(path, ".status.") || strings.HasPrefix(path, ".status["):
|
||||
// Already rooted at .status — avoid double-prefixing
|
||||
return "{" + path + "}", nil
|
||||
case strings.HasPrefix(path, "."):
|
||||
return "{.status" + path + "}", nil
|
||||
case strings.HasPrefix(path, "["):
|
||||
return "{.status" + path + "}", nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid JSONPath %q: path must start with . or [", path)
|
||||
}
|
||||
}
|
||||
|
||||
// parseReadinessExpression parses "{<jsonpath>} <operator> <value>" into its parts.
|
||||
func parseReadinessExpression(expr string) (path, op, val string, err error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return "", "", "", errors.New("expression cannot be empty")
|
||||
}
|
||||
if !strings.HasPrefix(expr, "{") {
|
||||
return "", "", "", fmt.Errorf("expression must start with {<jsonpath>}: %q", expr)
|
||||
}
|
||||
|
||||
closeBrace := strings.Index(expr, "}")
|
||||
if closeBrace < 0 {
|
||||
return "", "", "", fmt.Errorf("expression missing closing }: %q", expr)
|
||||
}
|
||||
|
||||
path = strings.TrimSpace(expr[1:closeBrace])
|
||||
if path == "" {
|
||||
return "", "", "", fmt.Errorf("expression missing JSONPath: %q", expr)
|
||||
}
|
||||
|
||||
rest := strings.TrimSpace(expr[closeBrace+1:])
|
||||
if rest == "" {
|
||||
return "", "", "", fmt.Errorf("expression missing operator and value: %q", expr)
|
||||
}
|
||||
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) == 0 {
|
||||
return "", "", "", fmt.Errorf("expression missing operator: %q", expr)
|
||||
}
|
||||
|
||||
switch parts[0] {
|
||||
case "==", "!=", "<", "<=", ">", ">=":
|
||||
op = parts[0]
|
||||
default:
|
||||
return "", "", "", fmt.Errorf("unsupported operator %q in expression %q", parts[0], expr)
|
||||
}
|
||||
|
||||
val = strings.TrimSpace(rest[len(op):])
|
||||
if val == "" {
|
||||
return "", "", "", fmt.Errorf("expression missing comparison value: %q", expr)
|
||||
}
|
||||
|
||||
return path, op, val, nil
|
||||
}
|
||||
|
||||
func compareValues(actual, op, expected string) (bool, error) {
|
||||
actual = strings.TrimSpace(actual)
|
||||
expected = trimReadinessValue(strings.TrimSpace(expected))
|
||||
|
||||
if actualFloat, ok := tryParseFloat(actual); ok {
|
||||
if expectedFloat, ok := tryParseFloat(expected); ok {
|
||||
return compareNumeric(actualFloat, op, expectedFloat)
|
||||
}
|
||||
}
|
||||
|
||||
if actualBool, actualIsBool := tryParseBool(actual); actualIsBool {
|
||||
if expectedBool, expectedIsBool := tryParseBool(expected); expectedIsBool {
|
||||
switch op {
|
||||
case "==":
|
||||
return actualBool == expectedBool, nil
|
||||
case "!=":
|
||||
return actualBool != expectedBool, nil
|
||||
default:
|
||||
return false, fmt.Errorf("cannot compare %q %s %q: %w", actual, op, expected, errIncomparableOrdering)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "==":
|
||||
return actual == expected, nil
|
||||
case "!=":
|
||||
return actual != expected, nil
|
||||
default:
|
||||
return false, fmt.Errorf("cannot compare %q %s %q: %w", actual, op, expected, errIncomparableOrdering)
|
||||
}
|
||||
}
|
||||
|
||||
func trimReadinessValue(value string) string {
|
||||
if len(value) >= 2 {
|
||||
if value[0] == '"' && value[len(value)-1] == '"' {
|
||||
return value[1 : len(value)-1]
|
||||
}
|
||||
if value[0] == '\'' && value[len(value)-1] == '\'' {
|
||||
return value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func tryParseFloat(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func tryParseBool(value string) (bool, bool) {
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
return parsed, err == nil
|
||||
}
|
||||
|
||||
func compareNumeric(actual float64, op string, expected float64) (bool, error) {
|
||||
switch op {
|
||||
case "==":
|
||||
return actual == expected, nil
|
||||
case "!=":
|
||||
return actual != expected, nil
|
||||
case "<":
|
||||
return actual < expected, nil
|
||||
case "<=":
|
||||
return actual <= expected, nil
|
||||
case ">":
|
||||
return actual > expected, nil
|
||||
case ">=":
|
||||
return actual >= expected, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown operator %q", op)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,526 @@
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func makeUnstructuredWithStatus(t *testing.T, statusFields map[string]any) *unstructured.Unstructured {
|
||||
t.Helper()
|
||||
|
||||
obj := &unstructured.Unstructured{}
|
||||
obj.SetAPIVersion("v1")
|
||||
obj.SetKind("ConfigMap")
|
||||
obj.SetName("test-resource")
|
||||
|
||||
if statusFields != nil {
|
||||
require.NoError(t, unstructured.SetNestedField(obj.Object, statusFields, "status"))
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_BothNil_UsesKstatus(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, nil)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(obj, nil, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
|
||||
result, useKstatus, _, err = EvaluateCustomReadiness(obj, []string{}, []string{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_OnlySuccess_FallsBackToKstatus(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, nil)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(obj, []string{`{.ready} == true`}, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_OnlyFailure_FallsBackToKstatus(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, nil)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(obj, nil, []string{`{.failed} == true`})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_SuccessTrue(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"succeeded": int64(1),
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.succeeded} == 1`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_FailurePrecedesSuccess(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"succeeded": int64(1),
|
||||
"failed": int64(1),
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.succeeded} == 1`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessFailed, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_ORSemantics_AnySuccessTrue(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"phase": "Succeeded",
|
||||
"another": "nope",
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.another} == "yes"`, `{.phase} == "Succeeded"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_NeitherConditionMet(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"succeeded": int64(0),
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.succeeded} == 1`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_MissingStatusField(t *testing.T) {
|
||||
t.Run("missing status object", func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, nil)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} == "Running"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
})
|
||||
|
||||
t.Run("missing status field", func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} == "Running"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_NumericComparisonOperators(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
status map[string]any
|
||||
}{
|
||||
{name: "equal", expr: `{.succeeded} == 1`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "not equal", expr: `{.succeeded} != 2`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "less than", expr: `{.succeeded} < 2`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "less than or equal", expr: `{.succeeded} <= 1`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "greater than", expr: `{.succeeded} > 0`, status: map[string]any{"succeeded": int64(1)}},
|
||||
{name: "greater than or equal", expr: `{.succeeded} >= 1`, status: map[string]any{"succeeded": int64(1)}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, tt.status)
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{tt.expr},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_BooleanComparison(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
status map[string]any
|
||||
}{
|
||||
{name: "boolean equals", expr: `{.ready} == true`, status: map[string]any{"ready": true}},
|
||||
{name: "boolean not equals", expr: `{.ready} != false`, status: map[string]any{"ready": true}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, tt.status)
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{tt.expr},
|
||||
[]string{`{.failed} == true`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_StringComparison(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"phase": "Running",
|
||||
})
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} == "Running"`},
|
||||
[]string{`{.phase} == "Failed"`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
|
||||
result, useKstatus, warnings, err = EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} != "Failed"`},
|
||||
[]string{`{.phase} == "Failed"`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_InvalidJSONPath(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"phase": "Running",
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase[} == "Running"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
assert.ErrorContains(t, err, "invalid JSONPath")
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_InvalidOperator(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{
|
||||
"phase": "Running",
|
||||
})
|
||||
|
||||
result, useKstatus, _, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} <> "Running"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
assert.ErrorContains(t, err, "unsupported operator")
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
}
|
||||
|
||||
func TestParseReadinessExpression(t *testing.T) {
|
||||
t.Run("valid expressions", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantPath string
|
||||
wantOp string
|
||||
wantVal string
|
||||
}{
|
||||
{name: "string equality", expr: `{.phase} == "Running"`, wantPath: ".phase", wantOp: "==", wantVal: `"Running"`},
|
||||
{name: "numeric greater than or equal", expr: `{.count} >= 3`, wantPath: ".count", wantOp: ">=", wantVal: "3"},
|
||||
{name: "boolean not equal", expr: `{.ready} != false`, wantPath: ".ready", wantOp: "!=", wantVal: "false"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path, op, val, err := parseReadinessExpression(tt.expr)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantPath, path)
|
||||
assert.Equal(t, tt.wantOp, op)
|
||||
assert.Equal(t, tt.wantVal, val)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid expressions", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
wantErrPart string
|
||||
}{
|
||||
{name: "missing opening brace", expr: `.phase == "Running"`, wantErrPart: "must start"},
|
||||
{name: "missing closing brace", expr: `{.phase == "Running"`, wantErrPart: "missing closing"},
|
||||
{name: "missing operator", expr: `{.phase} "Running"`, wantErrPart: "unsupported operator"},
|
||||
{name: "unsupported operator", expr: `{.phase} <> "Running"`, wantErrPart: "unsupported operator"},
|
||||
{name: "missing value", expr: `{.phase} ==`, wantErrPart: "missing comparison value"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, _, err := parseReadinessExpression(tt.expr)
|
||||
assert.ErrorContains(t, err, tt.wantErrPart)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadinessJSONPath(t *testing.T) {
|
||||
// Locks the contract that paths already rooted at .status do NOT get
|
||||
// .status double-prepended. Chart authors commonly write
|
||||
// `{.status.phase}` (the canonical form in K8s docs); without the
|
||||
// guard, the effective JSONPath becomes `.status.status.phase` and
|
||||
// the readiness gate silently fails to find any value.
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "already rooted at .status. — no double-prepend", path: ".status.phase", want: "{.status.phase}"},
|
||||
{name: "already rooted at .status[ — no double-prepend", path: `.status['observedGeneration']`, want: `{.status['observedGeneration']}`},
|
||||
{name: "non-status dot path gets .status prefix", path: ".phase", want: "{.status.phase}"},
|
||||
{name: "bracket-only path gets .status prefix", path: "[0]", want: "{.status[0]}"},
|
||||
{name: "invalid path rejected", path: "phase", wantErr: "must start with . or ["},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := readinessJSONPath(tt.path)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadinessExpressions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
annotation string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty annotation is valid", annotation: ""},
|
||||
{name: "well-formed single expression", annotation: `["{.status.phase} == \"Ready\""]`},
|
||||
{name: "well-formed multiple expressions", annotation: `["{.status.succeeded} >= 1", "{.status.ready} == true"]`},
|
||||
{name: "not a JSON array", annotation: `not-json`, wantErr: "parsing readiness annotation JSON"},
|
||||
{name: "missing closing brace", annotation: `["{.status.succeeded >= 1"]`, wantErr: "missing closing"},
|
||||
{name: "path not rooted at . or [", annotation: `["{status.phase} == \"Ready\""]`, wantErr: "must start with . or ["},
|
||||
{name: "ordering operator with quoted string value", annotation: `["{.status.phase} > \"Running\""]`, wantErr: "requires a numeric comparison value"},
|
||||
{name: "ordering operator with boolean value", annotation: `["{.status.ready} >= true"]`, wantErr: "requires a numeric comparison value"},
|
||||
{name: "ordering operator with quoted numeric value is valid", annotation: `["{.status.replicas} >= \"3\""]`},
|
||||
{name: "ordering operator with unquoted non-numeric value", annotation: `["{.status.phase} > Running"]`, wantErr: "requires a numeric comparison value"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateReadinessExpressions(tt.annotation)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadinessExpressionsJSON(t *testing.T) {
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
exprs, err := parseReadinessExpressions(`[]`)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, exprs)
|
||||
})
|
||||
|
||||
t.Run("malformed annotation JSON", func(t *testing.T) {
|
||||
_, err := parseReadinessExpressions(`["{.ready} == true"`)
|
||||
assert.ErrorContains(t, err, "parsing readiness annotation JSON")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_OrderingOnNonNumericString_TreatedAsNotMet(t *testing.T) {
|
||||
for _, op := range []string{"<", "<=", ">", ">="} {
|
||||
t.Run(op, func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"})
|
||||
expr := fmt.Sprintf(`{.phase} %s "Ready"`, op)
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{expr},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
require.Len(t, warnings, 1)
|
||||
assert.Equal(t, expr, warnings[0].Expression)
|
||||
assert.Contains(t, warnings[0].Detail, "ordering operators")
|
||||
assert.Contains(t, warnings[0].Detail, `"Running"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_IncomparableFailureExpression_Symmetric(t *testing.T) {
|
||||
// A type-mismatched FAILURE expression must mean "failure condition not
|
||||
// met" — not "failed" and not an error — so a satisfied success
|
||||
// expression still marks the resource ready, with the mistake surfaced
|
||||
// as a warning.
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"})
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} == "Running"`},
|
||||
[]string{`{.phase} > "Failed"`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
require.Len(t, warnings, 1)
|
||||
assert.Equal(t, `{.phase} > "Failed"`, warnings[0].Expression)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_NumericStringCoercionPreserved(t *testing.T) {
|
||||
// A string status value that parses as a number keeps comparing
|
||||
// numerically — only genuinely non-numeric values changed behavior.
|
||||
tests := []struct{ name, expr string }{
|
||||
{name: "unquoted numeric literal", expr: `{.phase} >= 5`},
|
||||
{name: "quoted numeric literal", expr: `{.phase} >= "5"`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "5"})
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{tt.expr},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Equal(t, ReadinessReady, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_OrderingOnBooleans_TreatedAsNotMet(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"ready": true})
|
||||
|
||||
result, useKstatus, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.ready} >= true`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, useKstatus)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
require.Len(t, warnings, 1)
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness_AllSuccessExpressionsIncomparable_Pending(t *testing.T) {
|
||||
obj := makeUnstructuredWithStatus(t, map[string]any{"phase": "Running"})
|
||||
|
||||
result, _, warnings, err := EvaluateCustomReadiness(
|
||||
obj,
|
||||
[]string{`{.phase} > "Ready"`, `{.phase} < "Ready"`},
|
||||
[]string{`{.failed} >= 1`},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ReadinessPending, result)
|
||||
assert.Len(t, warnings, 2)
|
||||
}
|
||||
|
||||
func TestCompareValues_OrderingOnNonNumeric_ReturnsSentinel(t *testing.T) {
|
||||
met, err := compareValues("Running", ">", `"Ready"`)
|
||||
require.ErrorIs(t, err, errIncomparableOrdering)
|
||||
assert.False(t, met)
|
||||
|
||||
// A numeric actual against a non-numeric literal is equally incomparable.
|
||||
met, err = compareValues("5", ">", `"high"`)
|
||||
require.ErrorIs(t, err, errIncomparableOrdering)
|
||||
assert.False(t, met)
|
||||
}
|
||||
Loading…
Reference in new issue