diff --git a/pkg/cmd/wait/create.go b/pkg/cmd/wait/create.go new file mode 100644 index 00000000..1d5eca7e --- /dev/null +++ b/pkg/cmd/wait/create.go @@ -0,0 +1,33 @@ +/* +Copyright 2024 The Kubernetes 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 wait + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/resource" +) + +// IsCreated is a condition func for waiting for something to be created +func IsCreated(ctx context.Context, info *resource.Info, o *WaitOptions) (runtime.Object, bool, error) { + if len(info.Name) == 0 || info.Object == nil { + return nil, false, fmt.Errorf("resource name must be provided") + } + return info.Object, true, nil +} diff --git a/pkg/cmd/wait/wait.go b/pkg/cmd/wait/wait.go index 51894af9..9097ea12 100644 --- a/pkg/cmd/wait/wait.go +++ b/pkg/cmd/wait/wait.go @@ -30,6 +30,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericiooptions" "k8s.io/cli-runtime/pkg/printers" @@ -186,11 +187,16 @@ func (flags *WaitFlags) ToOptions(args []string) (*WaitOptions, error) { } func conditionFuncFor(condition string, errOut io.Writer) (ConditionFunc, error) { - if strings.ToLower(condition) == "delete" { + lowercaseCond := strings.ToLower(condition) + switch { + case lowercaseCond == "delete": return IsDeleted, nil - } - if strings.HasPrefix(condition, "condition=") { - conditionName := condition[len("condition="):] + + case lowercaseCond == "create": + return IsCreated, nil + + case strings.HasPrefix(lowercaseCond, "condition="): + conditionName := lowercaseCond[len("condition="):] conditionValue := "true" if equalsIndex := strings.Index(conditionName, "="); equalsIndex != -1 { conditionValue = conditionName[equalsIndex+1:] @@ -202,9 +208,9 @@ func conditionFuncFor(condition string, errOut io.Writer) (ConditionFunc, error) conditionStatus: conditionValue, errOut: errOut, }.IsConditionMet, nil - } - if strings.HasPrefix(condition, "jsonpath=") { - jsonPathInput := strings.TrimPrefix(condition, "jsonpath=") + + case strings.HasPrefix(lowercaseCond, "jsonpath="): + jsonPathInput := strings.TrimPrefix(lowercaseCond, "jsonpath=") jsonPathExp, jsonPathValue, err := processJSONPathInput(jsonPathInput) if err != nil { return nil, err @@ -312,6 +318,31 @@ func (o *WaitOptions) RunWait() error { ctx, cancel := watchtools.ContextWithOptionalTimeout(context.Background(), o.Timeout) defer cancel() + if strings.ToLower(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, + // or functions from ResourceBuilder for parsing those. Lastly, this poll + // should be replaced with a ListWatch cache. + if err := wait.PollUntilContextTimeout(ctx, 500*time.Millisecond, o.Timeout, true, func(context.Context) (done bool, err error) { + visitErr := o.ResourceFinder.Do().Visit(func(info *resource.Info, err error) error { + return nil + }) + if apierrors.IsNotFound(visitErr) { + return false, nil + } + if visitErr != nil { + return false, visitErr + } + return true, nil + }); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("%s", wait.ErrWaitTimeout.Error()) // nolint:staticcheck // SA1019 + } + return err + } + } + visitCount := 0 visitFunc := func(info *resource.Info, err error) error { if err != nil { diff --git a/pkg/cmd/wait/wait_test.go b/pkg/cmd/wait/wait_test.go index bb03160f..e4824217 100644 --- a/pkg/cmd/wait/wait_test.go +++ b/pkg/cmd/wait/wait_test.go @@ -24,6 +24,8 @@ import ( "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -76,7 +78,7 @@ spec: memory: 128Mi requests: cpu: 250m - memory: 64Mi + memory: 64Mi terminationMessagePath: /dev/termination-log terminationMessagePolicy: File volumeMounts: @@ -983,6 +985,77 @@ func TestWaitForCondition(t *testing.T) { } } +func TestWaitForCreate(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 + infosErr error + fakeClient func() *dynamicfakeclient.FakeDynamicClient + timeout time.Duration + + expectedErr string + }{ + { + name: "missing resource, should hit timeout", + infosErr: apierrors.NewNotFound(schema.GroupResource{Group: "group", Resource: "theresource"}, "name-foo"), + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + return dynamicfakeclient.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping) + }, + timeout: 1 * time.Second, + expectedErr: "timed out waiting for the condition", + }, + { + name: "wait should succeed", + infos: []*resource.Info{ + { + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"}, + }, + Object: &corev1.Pod{}, // the resource type is irrelevant here + Name: "name-foo", + Namespace: "ns-foo", + }, + }, + fakeClient: func() *dynamicfakeclient.FakeDynamicClient { + return dynamicfakeclient.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping) + }, + timeout: 1 * time.Second, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fakeClient := test.fakeClient() + o := &WaitOptions{ + ResourceFinder: genericclioptions.NewSimpleFakeResourceFinder(test.infos...).WithError(test.infosErr), + DynamicClient: fakeClient, + Timeout: test.timeout, + + Printer: printers.NewDiscardingPrinter(), + ConditionFn: IsCreated, + ForCondition: "create", + IOStreams: genericiooptions.NewTestIOStreamsDiscard(), + } + err := o.RunWait() + 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()) + } + } + }) + } +} + func TestWaitForDeletionIgnoreNotFound(t *testing.T) { scheme := runtime.NewScheme() listMapping := map[schema.GroupVersionResource]string{