mirror of
https://github.com/kubernetes/kubernetes.git
synced 2026-05-28 04:04:39 -04:00
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:
parent
4c2036030a
commit
d37765936d
4 changed files with 385 additions and 84 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
test/cmd/wait.sh
110
test/cmd/wait.sh
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue