kubectl wait: Support multiple conditions (#136855)

* kubectl wait: Support multiple conditions

* Error out when --for is not passed

* Add examples for AND'ing and OR'ing multiple conditions
This commit is contained in:
Arda Güçlü 2026-03-04 21:00:27 +03:00 committed by GitHub
parent 4c2036030a
commit d37765936d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 385 additions and 84 deletions

View file

@ -464,7 +464,7 @@ func (o *DeleteOptions) DeleteResult(r *resource.Result) error {
Timeout: effectiveTimeout,
Printer: printers.NewDiscardingPrinter(),
ConditionFn: cmdwait.IsDeleted,
ConditionFn: []cmdwait.ConditionFunc{cmdwait.IsDeleted},
IOStreams: o.IOStreams,
}
err = waitOptions.RunWaitContext(context.Background())

View file

@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"io"
"slices"
"strings"
"time"
@ -79,7 +80,15 @@ var (
# Wait for the pod "busybox1" to be deleted, with a timeout of 60s, after having issued the "delete" command
kubectl delete pod/busybox1
kubectl wait --for=delete pod/busybox1 --timeout=60s`))
kubectl wait --for=delete pod/busybox1 --timeout=60s
# Wait for pod "busybox1" to be created AND reach the "Ready" status condition
kubectl wait --for=condition=Ready --for=create pod/busybox1
# Wait for pod "busybox1" to reach the "Ready" status OR for its containers to report a "False" readiness state
until kubectl wait pod/busybox1 --for=condition=Ready --timeout=1s 2>/dev/null || \
kubectl wait pod/busybox1 --for=condition=ContainersReady=False --timeout=1s 2>/dev/null; \
do echo "Checking conditions..."; sleep 1; done`))
)
// errNoMatchingResources is returned when there is no resources matching a query.
@ -94,7 +103,7 @@ type WaitFlags struct {
ResourceBuilderFlags *genericclioptions.ResourceBuilderFlags
Timeout time.Duration
ForCondition string
ForCondition []string
genericiooptions.IOStreams
}
@ -147,7 +156,7 @@ func (flags *WaitFlags) AddFlags(cmd *cobra.Command) {
flags.ResourceBuilderFlags.AddFlags(cmd.Flags())
cmd.Flags().DurationVar(&flags.Timeout, "timeout", flags.Timeout, "The length of time to wait before giving up. Zero means check once and don't wait, negative means wait for a week.")
cmd.Flags().StringVar(&flags.ForCondition, "for", flags.ForCondition, "The condition to wait on: [create|delete|condition=condition-name[=condition-value]|jsonpath='{JSONPath expression}'=[JSONPath value]]. The default condition-value is true. Condition values are compared after Unicode simple case folding, which is a more general form of case-insensitivity.")
cmd.Flags().StringArrayVar(&flags.ForCondition, "for", flags.ForCondition, "The condition to wait on: [create|delete|condition=condition-name[=condition-value]|jsonpath='{JSONPath expression}'=[JSONPath value]]. The default condition-value is true. Condition values are compared after Unicode simple case folding, which is a more general form of case-insensitivity. Multiple conditions are supported and AND'ed to each other in a sequential order. If --for=create is passed, it is always waited first.")
}
// ToOptions converts from CLI inputs to runtime inputs
@ -165,7 +174,7 @@ func (flags *WaitFlags) ToOptions(args []string) (*WaitOptions, error) {
if err != nil {
return nil, err
}
conditionFn, err := conditionFuncFor(flags.ForCondition, flags.ErrOut)
conditionFn, err := conditionFuncsFor(flags.ForCondition, flags.ErrOut)
if err != nil {
return nil, err
}
@ -189,48 +198,56 @@ func (flags *WaitFlags) ToOptions(args []string) (*WaitOptions, error) {
return o, nil
}
func conditionFuncFor(condition string, errOut io.Writer) (ConditionFunc, error) {
lowercaseCond := strings.ToLower(condition)
switch {
case lowercaseCond == "delete":
return IsDeleted, nil
func conditionFuncsFor(conditions []string, errOut io.Writer) ([]ConditionFunc, error) {
var condFuncs []ConditionFunc
for _, cond := range conditions {
lowercaseCond := strings.ToLower(cond)
switch {
case lowercaseCond == "delete":
condFuncs = append(condFuncs, IsDeleted)
case lowercaseCond == "create":
return IsCreated, nil
case lowercaseCond == "create":
condFuncs = append(condFuncs, IsCreated)
case strings.HasPrefix(condition, "condition="):
conditionName := strings.TrimPrefix(condition, "condition=")
conditionValue := "true"
if equalsIndex := strings.Index(conditionName, "="); equalsIndex != -1 {
conditionValue = conditionName[equalsIndex+1:]
conditionName = conditionName[0:equalsIndex]
case strings.HasPrefix(cond, "condition="):
conditionName := strings.TrimPrefix(cond, "condition=")
conditionValue := "true"
if equalsIndex := strings.Index(conditionName, "="); equalsIndex != -1 {
conditionValue = conditionName[equalsIndex+1:]
conditionName = conditionName[0:equalsIndex]
}
condFuncs = append(condFuncs, ConditionalWait{
conditionName: conditionName,
conditionStatus: conditionValue,
errOut: errOut,
}.IsConditionMet)
case strings.HasPrefix(cond, "jsonpath="):
jsonPathInput := strings.TrimPrefix(cond, "jsonpath=")
jsonPathExp, jsonPathValue, err := processJSONPathInput(jsonPathInput)
if err != nil {
return nil, err
}
j, err := newJSONPathParser(jsonPathExp)
if err != nil {
return nil, err
}
condFuncs = append(condFuncs, JSONPathWait{
matchAnyValue: jsonPathValue == "",
jsonPathValue: jsonPathValue,
jsonPathParser: j,
errOut: errOut,
}.IsJSONPathConditionMet)
default:
return nil, fmt.Errorf("unrecognized condition: %q", cond)
}
return ConditionalWait{
conditionName: conditionName,
conditionStatus: conditionValue,
errOut: errOut,
}.IsConditionMet, nil
case strings.HasPrefix(condition, "jsonpath="):
jsonPathInput := strings.TrimPrefix(condition, "jsonpath=")
jsonPathExp, jsonPathValue, err := processJSONPathInput(jsonPathInput)
if err != nil {
return nil, err
}
j, err := newJSONPathParser(jsonPathExp)
if err != nil {
return nil, err
}
return JSONPathWait{
matchAnyValue: jsonPathValue == "",
jsonPathValue: jsonPathValue,
jsonPathParser: j,
errOut: errOut,
}.IsJSONPathConditionMet, nil
}
if condFuncs == nil {
return nil, fmt.Errorf("unrecognized condition: %q", conditions)
}
return nil, fmt.Errorf("unrecognized condition: %q", condition)
return condFuncs, nil
}
// newJSONPathParser will create a new JSONPath parser based on the jsonPathExpression
@ -306,10 +323,10 @@ type WaitOptions struct {
UIDMap UIDMap
DynamicClient dynamic.Interface
Timeout time.Duration
ForCondition string
ForCondition []string
Printer printers.ResourcePrinter
ConditionFn ConditionFunc
ConditionFn []ConditionFunc
genericiooptions.IOStreams
}
@ -325,7 +342,7 @@ func (o *WaitOptions) RunWait() error {
func (o *WaitOptions) RunWaitContext(ctx context.Context) error {
ctx, cancel := watchtools.ContextWithOptionalTimeout(ctx, o.Timeout)
defer cancel()
if strings.ToLower(o.ForCondition) == "create" {
if containsCondition(o.ForCondition, "create") {
// TODO(soltysh): this is not ideal solution, because we're polling every .5s,
// and we have to use ResourceFinder, which contains the resource name.
// In the long run, we should expose resource information from ResourceFinder,
@ -359,18 +376,21 @@ func (o *WaitOptions) RunWaitContext(ctx context.Context) error {
}
visitCount++
finalObject, success, err := o.ConditionFn(ctx, info, o)
if success {
o.Printer.PrintObj(finalObject, o.Out)
return nil
for _, condFn := range o.ConditionFn {
finalObject, success, err := condFn(ctx, info, o)
if success {
o.Printer.PrintObj(finalObject, o.Out) //nolint:errcheck
continue
}
if err == nil {
return fmt.Errorf("%v unsatisfied for unknown reason", finalObject)
}
return err
}
if err == nil {
return fmt.Errorf("%v unsatisfied for unknown reason", finalObject)
}
return err
return nil
}
visitor := o.ResourceFinder.Do()
isForDelete := strings.ToLower(o.ForCondition) == "delete"
isForDelete := containsCondition(o.ForCondition, "delete")
if visitor, ok := visitor.(*resource.Result); ok && isForDelete {
visitor.IgnoreErrors(apierrors.IsNotFound)
}
@ -384,3 +404,9 @@ func (o *WaitOptions) RunWaitContext(ctx context.Context) error {
}
return err
}
func containsCondition(conditions []string, condition string) bool {
return slices.ContainsFunc(conditions, func(cond string) bool {
return strings.ToLower(cond) == condition
})
}

View file

@ -596,7 +596,7 @@ func TestWaitForDeletion(t *testing.T) {
Timeout: test.timeout,
Printer: printers.NewDiscardingPrinter(),
ConditionFn: IsDeleted,
ConditionFn: []ConditionFunc{IsDeleted},
IOStreams: genericiooptions.NewTestIOStreamsDiscard(),
}
err := o.RunWaitContext(t.Context())
@ -1006,7 +1006,7 @@ func TestWaitForCondition(t *testing.T) {
Timeout: test.timeout,
Printer: printers.NewDiscardingPrinter(),
ConditionFn: ConditionalWait{conditionName: "the-condition", conditionStatus: "status-value", errOut: io.Discard}.IsConditionMet,
ConditionFn: []ConditionFunc{ConditionalWait{conditionName: "the-condition", conditionStatus: "status-value", errOut: io.Discard}.IsConditionMet},
IOStreams: genericiooptions.NewTestIOStreamsDiscard(),
}
err := o.RunWaitContext(t.Context())
@ -1076,8 +1076,8 @@ func TestWaitForCreate(t *testing.T) {
Timeout: test.timeout,
Printer: printers.NewDiscardingPrinter(),
ConditionFn: IsCreated,
ForCondition: "create",
ConditionFn: []ConditionFunc{IsCreated},
ForCondition: []string{"create"},
IOStreams: genericiooptions.NewTestIOStreamsDiscard(),
}
err := o.RunWaitContext(t.Context())
@ -1108,9 +1108,9 @@ func TestWaitForDeletionIgnoreNotFound(t *testing.T) {
ResourceFinder: genericclioptions.NewSimpleFakeResourceFinder(infos...),
DynamicClient: fakeClient,
Printer: printers.NewDiscardingPrinter(),
ConditionFn: IsDeleted,
ConditionFn: []ConditionFunc{IsDeleted},
IOStreams: genericiooptions.NewTestIOStreamsDiscard(),
ForCondition: "delete",
ForCondition: []string{"delete"},
}
err := o.RunWaitContext(t.Context())
if err != nil {
@ -1359,11 +1359,11 @@ func TestWaitForDifferentJSONPathExpression(t *testing.T) {
Timeout: 1 * time.Second,
Printer: printers.NewDiscardingPrinter(),
ConditionFn: JSONPathWait{
ConditionFn: []ConditionFunc{JSONPathWait{
matchAnyValue: test.matchAnyValue,
jsonPathValue: test.jsonPathValue,
jsonPathParser: j,
errOut: io.Discard}.IsJSONPathConditionMet,
errOut: io.Discard}.IsJSONPathConditionMet},
IOStreams: genericiooptions.NewTestIOStreamsDiscard(),
}
@ -1624,9 +1624,9 @@ func TestWaitForJSONPathCondition(t *testing.T) {
Timeout: test.timeout,
Printer: printers.NewDiscardingPrinter(),
ConditionFn: JSONPathWait{
ConditionFn: []ConditionFunc{JSONPathWait{
jsonPathValue: test.jsonPathValue,
jsonPathParser: j, errOut: io.Discard}.IsJSONPathConditionMet,
jsonPathParser: j, errOut: io.Discard}.IsJSONPathConditionMet},
IOStreams: genericiooptions.NewTestIOStreamsDiscard(),
}
@ -1651,99 +1651,114 @@ func TestWaitForJSONPathCondition(t *testing.T) {
func TestConditionFuncFor(t *testing.T) {
tests := []struct {
name string
condition string
condition []string
expectedErr string
}{
{
name: "jsonpath missing JSONPath expression",
condition: "jsonpath=",
condition: []string{"jsonpath="},
expectedErr: "jsonpath expression cannot be empty",
},
{
name: "jsonpath check for condition without value",
condition: "jsonpath={.metadata.name}",
condition: []string{"jsonpath={.metadata.name}"},
expectedErr: None,
},
{
name: "jsonpath check for condition without value relaxed parsing",
condition: "jsonpath=abc",
condition: []string{"jsonpath=abc"},
expectedErr: None,
},
{
name: "jsonpath check for expression and value",
condition: "jsonpath={.metadata.name}=foo-b6699dcfb-rnv7t",
condition: []string{"jsonpath={.metadata.name}=foo-b6699dcfb-rnv7t"},
expectedErr: None,
},
{
name: "jsonpath check for expression and value relaxed parsing",
condition: "jsonpath=.metadata.name=foo-b6699dcfb-rnv7t",
condition: []string{"jsonpath=.metadata.name=foo-b6699dcfb-rnv7t"},
expectedErr: None,
},
{
name: "jsonpath selecting based on condition",
condition: `jsonpath={.status.containerStatuses[?(@.name=="foo")].ready}=True`,
condition: []string{`jsonpath={.status.containerStatuses[?(@.name=="foo")].ready}=True`},
expectedErr: None,
},
{
name: "jsonpath selecting based on condition relaxed parsing",
condition: "jsonpath=status.conditions[?(@.type==\"Available\")].status=True",
condition: []string{"jsonpath=status.conditions[?(@.type==\"Available\")].status=True"},
expectedErr: None,
},
{
name: "jsonpath selecting based on condition without value",
condition: `jsonpath={.status.containerStatuses[?(@.name=="foo")].ready}`,
condition: []string{`jsonpath={.status.containerStatuses[?(@.name=="foo")].ready}`},
expectedErr: None,
},
{
name: "jsonpath selecting based on condition without value relaxed parsing",
condition: `jsonpath=.status.containerStatuses[?(@.name=="foo")].ready`,
condition: []string{`jsonpath=.status.containerStatuses[?(@.name=="foo")].ready`},
expectedErr: None,
},
{
name: "jsonpath invalid expression with repeated '='",
condition: "jsonpath={.metadata.name}='test=wrong'",
condition: []string{"jsonpath={.metadata.name}='test=wrong'"},
expectedErr: "jsonpath wait format must be --for=jsonpath='{.status.readyReplicas}'=3 or --for=jsonpath='{.status.readyReplicas}'",
},
{
name: "jsonpath undefined value after '='",
condition: "jsonpath={.metadata.name}=",
condition: []string{"jsonpath={.metadata.name}="},
expectedErr: "jsonpath wait has to have a value after equal sign",
},
{
name: "jsonpath complex expressions not supported",
condition: "jsonpath={.status.conditions[?(@.type==\"Failed\"||@.type==\"Complete\")].status}=True",
condition: []string{"jsonpath={.status.conditions[?(@.type==\"Failed\"||@.type==\"Complete\")].status}=True"},
expectedErr: "unrecognized character in action: U+007C '|'",
},
{
name: "jsonpath invalid expression",
condition: "jsonpath={=True",
condition: []string{"jsonpath={=True"},
expectedErr: "unexpected path string, expected a 'name1.name2' or '.name1.name2' or '{name1.name2}' or " +
"'{.name1.name2}'",
},
{
name: "condition delete",
condition: "delete",
condition: []string{"delete"},
expectedErr: None,
},
{
name: "condition true",
condition: "condition=hello",
condition: []string{"condition=hello"},
expectedErr: None,
},
{
name: "condition with value",
condition: "condition=hello=world",
condition: []string{"condition=hello=world"},
expectedErr: None,
},
{
name: "unrecognized condition",
condition: "cond=invalid",
condition: []string{"cond=invalid"},
expectedErr: "unrecognized condition: \"cond=invalid\"",
},
{
name: "multiple conditions - two status conditions",
condition: []string{"condition=Ready", "condition=Available"},
expectedErr: None,
},
{
name: "multiple conditions - condition and jsonpath",
condition: []string{"condition=Ready", "jsonpath={.status.phase}=Running"},
expectedErr: None,
},
{
name: "multiple conditions - two jsonpaths",
condition: []string{"jsonpath={.status.phase}=Running", "jsonpath={.metadata.name}=foo"},
expectedErr: None,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := conditionFuncFor(test.condition, io.Discard)
_, err := conditionFuncsFor(test.condition, io.Discard)
switch {
case err == nil && test.expectedErr != None:
t.Fatalf("expected error %q, got nil", test.expectedErr)
@ -1757,3 +1772,155 @@ func TestConditionFuncFor(t *testing.T) {
})
}
}
// TestWaitForMultipleConditions tests that multiple conditions are evaluated with AND logic.
func TestWaitForMultipleConditions(t *testing.T) {
scheme := runtime.NewScheme()
listMapping := map[schema.GroupVersionResource]string{
{Group: "group", Version: "version", Resource: "theresource"}: "TheKindList",
}
tests := []struct {
name string
infos []*resource.Info
fakeClient func() *dynamicfakeclient.FakeDynamicClient
timeout time.Duration
expectedErr string
}{
{
name: "both conditions met - success",
infos: []*resource.Info{
{
Mapping: &meta.RESTMapping{
Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"},
},
Name: "name-foo",
Namespace: "ns-foo",
},
},
fakeClient: func() *dynamicfakeclient.FakeDynamicClient {
fakeClient := dynamicfakeclient.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping)
fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) {
obj := newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")
obj = addCondition(obj, "Ready", "True")
obj = addCondition(obj, "Available", "True")
return true, newUnstructuredList(obj), nil
})
return fakeClient
},
timeout: 10 * time.Second,
},
{
name: "only first condition met - failure",
infos: []*resource.Info{
{
Mapping: &meta.RESTMapping{
Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"},
},
Name: "name-foo",
Namespace: "ns-foo",
},
},
fakeClient: func() *dynamicfakeclient.FakeDynamicClient {
fakeClient := dynamicfakeclient.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping)
fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) {
obj := newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")
obj = addCondition(obj, "Ready", "True")
obj = addCondition(obj, "Available", "False")
return true, newUnstructuredList(obj), nil
})
return fakeClient
},
timeout: 1 * time.Second,
expectedErr: "timed out waiting for the condition",
},
{
name: "conditions met via watch - success",
infos: []*resource.Info{
{
Mapping: &meta.RESTMapping{
Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"},
},
Name: "name-foo",
Namespace: "ns-foo",
},
},
fakeClient: func() *dynamicfakeclient.FakeDynamicClient {
fakeClient := dynamicfakeclient.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping)
fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) {
obj := newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")
obj = addCondition(obj, "Ready", "False")
return true, newUnstructuredList(obj), nil
})
fakeClient.PrependWatchReactor("theresource", func(action clienttesting.Action) (handled bool, ret watch.Interface, err error) {
fakeWatch := watch.NewRaceFreeFake()
obj := newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")
obj = addCondition(obj, "Ready", "True")
obj = addCondition(obj, "Available", "True")
fakeWatch.Action(watch.Modified, obj)
return true, fakeWatch, nil
})
return fakeClient
},
timeout: 10 * time.Second,
},
{
name: "mixed condition types - condition and jsonpath both met",
infos: []*resource.Info{
{
Mapping: &meta.RESTMapping{
Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"},
},
Name: "name-foo",
Namespace: "ns-foo",
},
},
fakeClient: func() *dynamicfakeclient.FakeDynamicClient {
fakeClient := dynamicfakeclient.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping)
fakeClient.PrependReactor("list", "theresource", func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) {
obj := newUnstructured("group/version", "TheKind", "ns-foo", "name-foo")
obj = addCondition(obj, "Ready", "True")
unstructured.SetNestedField(obj.Object, "Running", "status", "phase") //nolint:errcheck
return true, newUnstructuredList(obj), nil
})
return fakeClient
},
timeout: 10 * time.Second,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fakeClient := test.fakeClient()
conditionFuncs, err := conditionFuncsFor([]string{"condition=Ready", "condition=Available"}, io.Discard)
require.NoError(t, err)
// For the mixed condition test, use different condition functions
if strings.Contains(test.name, "mixed condition") {
conditionFuncs, err = conditionFuncsFor([]string{"condition=Ready", "jsonpath={.status.phase}=Running"}, io.Discard)
require.NoError(t, err)
}
o := &WaitOptions{
ResourceFinder: genericclioptions.NewSimpleFakeResourceFinder(test.infos...),
DynamicClient: fakeClient,
Timeout: test.timeout,
Printer: printers.NewDiscardingPrinter(),
ConditionFn: conditionFuncs,
IOStreams: genericiooptions.NewTestIOStreamsDiscard(),
}
err = o.RunWaitContext(t.Context())
switch {
case err == nil && len(test.expectedErr) == 0:
case err != nil && len(test.expectedErr) == 0:
t.Fatal(err)
case err == nil && len(test.expectedErr) != 0:
t.Fatalf("missing: %q", test.expectedErr)
case err != nil && len(test.expectedErr) != 0:
if !strings.Contains(err.Error(), test.expectedErr) {
t.Fatalf("expected %q, got %q", test.expectedErr, err.Error())
}
}
})
}
}

View file

@ -42,7 +42,7 @@ run_wait_tests() {
# Post-Condition: deployments exists
kube::test::get_object_assert "deployments" "{{range .items}}{{.metadata.name}},{{end}}" 'test-1,test-2,'
# wait with jsonpath will timout for busybox deployment
# wait with jsonpath will timeout for busybox deployment
set +o errexit
# Command: Wait with jsonpath support fields not exist in the first place
output_message=$(kubectl wait --for=jsonpath=.status.readyReplicas=1 deploy/test-1 2>&1)
@ -173,6 +173,114 @@ EOF
# Clean deployment
kubectl delete deployment test-3
kube::log::status "Testing kubectl wait with multiple conditions"
# Test 1: Wait for deployment creation AND available condition together
# Create deployment in background
( sleep 2 && cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-multiple
labels:
app: test-multiple
spec:
replicas: 1
selector:
matchLabels:
app: test-multiple
template:
metadata:
labels:
app: test-multiple
spec:
containers:
- name: bb
image: busybox
command: ["/bin/sh", "-c", "sleep infinity"]
EOF
) &
# Command: Wait for deployment to be created AND have replicas spec set
output_message=$(kubectl wait --for=create --for=jsonpath=.status.unavailableReplicas=1 deploy -l app=test-multiple --timeout=30s)
# Post-Condition: Wait was successful
kube::test::if_has_string "${output_message}" 'test-multiple condition met'
# Clean up
kubectl delete deployment test-multiple
# Test 2: Multiple status conditions
# Create a deployment that will eventually have replicas available
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: multi-condition-test
spec:
replicas: 1
selector:
matchLabels:
app: multi-condition-test
template:
metadata:
labels:
app: multi-condition-test
spec:
containers:
- name: bb
image: busybox
command: ["/bin/sh", "-c", "sleep infinity"]
EOF
# Wait for both Available AND Progressing conditions
output_message=$(kubectl wait deployment/multi-condition-test \
--for=jsonpath=.status.unavailableReplicas=1 \
--for=condition=Progressing \
--timeout=30s )
# Post-Condition: Both conditions met
kube::test::if_has_string "${output_message}" 'multi-condition-test condition met'
# Clean up
kubectl delete deployment multi-condition-test
# Test 4: One condition fails - should timeout (validates AND logic)
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: partial-condition-test
spec:
replicas: 1
selector:
matchLabels:
app: partial-test
template:
metadata:
labels:
app: partial-test
spec:
containers:
- name: bb
image: busybox
command: ["/bin/sh", "-c", "sleep infinity"]
EOF
set +o errexit
# Wait for Available condition AND non-existent replicas count (should timeout)
output_message=$(kubectl wait deployment/partial-condition-test \
--for=condition=Progressing \
--for=jsonpath='{.status.replicas}'=2 \
--timeout=10s 2>&1)
set -o errexit
# Post-Condition: Should timeout because second condition not met
kube::test::if_has_string "${output_message}" 'timed out waiting for the condition'
# Clean up
kubectl delete deployment partial-condition-test
set +o nounset
set +o errexit
}