Skip to content

Commit aaf1fb5

Browse files
committed
Add --for=create option to kubectl wait
1 parent 6eec9d6 commit aaf1fb5

File tree

4 files changed

+154
-9
lines changed

4 files changed

+154
-9
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package wait
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
"k8s.io/apimachinery/pkg/runtime"
24+
"k8s.io/cli-runtime/pkg/resource"
25+
)
26+
27+
// IsCreated is a condition func for waiting for something to be created
28+
func IsCreated(ctx context.Context, info *resource.Info, o *WaitOptions) (runtime.Object, bool, error) {
29+
if len(info.Name) == 0 || info.Object == nil {
30+
return nil, false, fmt.Errorf("resource name must be provided")
31+
}
32+
return info.Object, true, nil
33+
}

staging/src/k8s.io/kubectl/pkg/cmd/wait/wait.go

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"k8s.io/apimachinery/pkg/runtime"
3131
"k8s.io/apimachinery/pkg/runtime/schema"
3232
"k8s.io/apimachinery/pkg/types"
33+
"k8s.io/apimachinery/pkg/util/wait"
3334
"k8s.io/cli-runtime/pkg/genericclioptions"
3435
"k8s.io/cli-runtime/pkg/genericiooptions"
3536
"k8s.io/cli-runtime/pkg/printers"
@@ -186,11 +187,16 @@ func (flags *WaitFlags) ToOptions(args []string) (*WaitOptions, error) {
186187
}
187188

188189
func conditionFuncFor(condition string, errOut io.Writer) (ConditionFunc, error) {
189-
if strings.ToLower(condition) == "delete" {
190+
lowercaseCond := strings.ToLower(condition)
191+
switch {
192+
case lowercaseCond == "delete":
190193
return IsDeleted, nil
191-
}
192-
if strings.HasPrefix(condition, "condition=") {
193-
conditionName := condition[len("condition="):]
194+
195+
case lowercaseCond == "create":
196+
return IsCreated, nil
197+
198+
case strings.HasPrefix(lowercaseCond, "condition="):
199+
conditionName := lowercaseCond[len("condition="):]
194200
conditionValue := "true"
195201
if equalsIndex := strings.Index(conditionName, "="); equalsIndex != -1 {
196202
conditionValue = conditionName[equalsIndex+1:]
@@ -202,9 +208,9 @@ func conditionFuncFor(condition string, errOut io.Writer) (ConditionFunc, error)
202208
conditionStatus: conditionValue,
203209
errOut: errOut,
204210
}.IsConditionMet, nil
205-
}
206-
if strings.HasPrefix(condition, "jsonpath=") {
207-
jsonPathInput := strings.TrimPrefix(condition, "jsonpath=")
211+
212+
case strings.HasPrefix(lowercaseCond, "jsonpath="):
213+
jsonPathInput := strings.TrimPrefix(lowercaseCond, "jsonpath=")
208214
jsonPathExp, jsonPathValue, err := processJSONPathInput(jsonPathInput)
209215
if err != nil {
210216
return nil, err
@@ -312,6 +318,31 @@ func (o *WaitOptions) RunWait() error {
312318
ctx, cancel := watchtools.ContextWithOptionalTimeout(context.Background(), o.Timeout)
313319
defer cancel()
314320

321+
if strings.ToLower(o.ForCondition) == "create" {
322+
// TODO(soltysh): this is not ideal solution, because we're polling every .5s,
323+
// and we have to use ResourceFinder, which contains the resource name.
324+
// In the long run, we should expose resource information from ResourceFinder,
325+
// or functions from ResourceBuilder for parsing those. Lastly, this poll
326+
// should be replaced with a ListWatch cache.
327+
if err := wait.PollUntilContextTimeout(ctx, 500*time.Millisecond, o.Timeout, true, func(context.Context) (done bool, err error) {
328+
visitErr := o.ResourceFinder.Do().Visit(func(info *resource.Info, err error) error {
329+
return nil
330+
})
331+
if apierrors.IsNotFound(visitErr) {
332+
return false, nil
333+
}
334+
if visitErr != nil {
335+
return false, visitErr
336+
}
337+
return true, nil
338+
}); err != nil {
339+
if errors.Is(err, context.DeadlineExceeded) {
340+
return fmt.Errorf("%s", wait.ErrWaitTimeout.Error()) // nolint:staticcheck // SA1019
341+
}
342+
return err
343+
}
344+
}
345+
315346
visitCount := 0
316347
visitFunc := func(info *resource.Info, err error) error {
317348
if err != nil {

staging/src/k8s.io/kubectl/pkg/cmd/wait/wait_test.go

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import (
2424

2525
"github.com/stretchr/testify/require"
2626

27+
corev1 "k8s.io/api/core/v1"
28+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2729
"k8s.io/apimachinery/pkg/api/meta"
2830
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2931
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -76,7 +78,7 @@ spec:
7678
memory: 128Mi
7779
requests:
7880
cpu: 250m
79-
memory: 64Mi
81+
memory: 64Mi
8082
terminationMessagePath: /dev/termination-log
8183
terminationMessagePolicy: File
8284
volumeMounts:
@@ -983,6 +985,77 @@ func TestWaitForCondition(t *testing.T) {
983985
}
984986
}
985987

988+
func TestWaitForCreate(t *testing.T) {
989+
scheme := runtime.NewScheme()
990+
listMapping := map[schema.GroupVersionResource]string{
991+
{Group: "group", Version: "version", Resource: "theresource"}: "TheKindList",
992+
}
993+
994+
tests := []struct {
995+
name string
996+
infos []*resource.Info
997+
infosErr error
998+
fakeClient func() *dynamicfakeclient.FakeDynamicClient
999+
timeout time.Duration
1000+
1001+
expectedErr string
1002+
}{
1003+
{
1004+
name: "missing resource, should hit timeout",
1005+
infosErr: apierrors.NewNotFound(schema.GroupResource{Group: "group", Resource: "theresource"}, "name-foo"),
1006+
fakeClient: func() *dynamicfakeclient.FakeDynamicClient {
1007+
return dynamicfakeclient.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping)
1008+
},
1009+
timeout: 1 * time.Second,
1010+
expectedErr: "timed out waiting for the condition",
1011+
},
1012+
{
1013+
name: "wait should succeed",
1014+
infos: []*resource.Info{
1015+
{
1016+
Mapping: &meta.RESTMapping{
1017+
Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "theresource"},
1018+
},
1019+
Object: &corev1.Pod{}, // the resource type is irrelevant here
1020+
Name: "name-foo",
1021+
Namespace: "ns-foo",
1022+
},
1023+
},
1024+
fakeClient: func() *dynamicfakeclient.FakeDynamicClient {
1025+
return dynamicfakeclient.NewSimpleDynamicClientWithCustomListKinds(scheme, listMapping)
1026+
},
1027+
timeout: 1 * time.Second,
1028+
},
1029+
}
1030+
for _, test := range tests {
1031+
t.Run(test.name, func(t *testing.T) {
1032+
fakeClient := test.fakeClient()
1033+
o := &WaitOptions{
1034+
ResourceFinder: genericclioptions.NewSimpleFakeResourceFinder(test.infos...).WithError(test.infosErr),
1035+
DynamicClient: fakeClient,
1036+
Timeout: test.timeout,
1037+
1038+
Printer: printers.NewDiscardingPrinter(),
1039+
ConditionFn: IsCreated,
1040+
ForCondition: "create",
1041+
IOStreams: genericiooptions.NewTestIOStreamsDiscard(),
1042+
}
1043+
err := o.RunWait()
1044+
switch {
1045+
case err == nil && len(test.expectedErr) == 0:
1046+
case err != nil && len(test.expectedErr) == 0:
1047+
t.Fatal(err)
1048+
case err == nil && len(test.expectedErr) != 0:
1049+
t.Fatalf("missing: %q", test.expectedErr)
1050+
case err != nil && len(test.expectedErr) != 0:
1051+
if !strings.Contains(err.Error(), test.expectedErr) {
1052+
t.Fatalf("expected %q, got %q", test.expectedErr, err.Error())
1053+
}
1054+
}
1055+
})
1056+
}
1057+
}
1058+
9861059
func TestWaitForDeletionIgnoreNotFound(t *testing.T) {
9871060
scheme := runtime.NewScheme()
9881061
listMapping := map[schema.GroupVersionResource]string{

test/cmd/wait.sh

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ run_wait_tests() {
2626

2727
create_and_use_new_namespace
2828

29-
### Wait for deletion using --all flag
29+
# wait --for=create should time out
30+
set +o errexit
31+
# Command: Wait with jsonpath support fields not exist in the first place
32+
output_message=$(kubectl wait --for=create deploy/test-1 --timeout=1s 2>&1)
33+
set -o errexit
34+
35+
# Post-Condition: Wait failed
36+
kube::test::if_has_string "${output_message}" 'timed out waiting for the condition'
3037

3138
# create test data
3239
kubectl create deployment test-1 --image=busybox
@@ -120,3 +127,4 @@ EOF
120127
set +o nounset
121128
set +o errexit
122129
}
130+

0 commit comments

Comments
 (0)