From ecb36b9761aa1e1e1b00e67a04390526c8971054 Mon Sep 17 00:00:00 2001 From: Dillen Padhiar <38965141+dpadhiar@users.noreply.github.com> Date: Thu, 23 Jan 2025 13:09:33 -0800 Subject: [PATCH] feat: add `converge to field` step which improves upon `converge to selector` (#168) * feat: improve converge to selector command Signed-off-by: Dillen Padhiar * feat: update func Signed-off-by: Dillen Padhiar * feat: move new functionality to ConvergeToField step Signed-off-by: Dillen Padhiar * test: update test case Signed-off-by: Dillen Padhiar * feat: add new step to kubedog.go Signed-off-by: Dillen Padhiar * chore: make generate Signed-off-by: Dillen Padhiar * feat: add new unit testing for extractField func Signed-off-by: Dillen Padhiar --------- Signed-off-by: Dillen Padhiar --- docs/syntax.md | 1 + internal/util/util.go | 34 ++++++++ internal/util/util_test.go | 85 +++++++++++++++++++ kubedog.go | 1 + pkg/kube/kube.go | 8 ++ .../unstructured/test/files/resource.yaml | 12 ++- pkg/kube/unstructured/unstructured.go | 59 +++++++++++++ pkg/kube/unstructured/unstructured_test.go | 83 ++++++++++++++++++ 8 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 internal/util/util_test.go diff --git a/docs/syntax.md b/docs/syntax.md index 46a56b4a..04cbcde1 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -20,6 +20,7 @@ Below you will find the step syntax next to the name of the method it utilizes. - ` [I] (create|submit|delete|update|upsert) [the] resource in [the] namespace, the operation should (succeed|fail)` kdt.KubeClientSet.ResourceOperationWithResultInNamespace - ` [the] resource should be (created|deleted)` kdt.KubeClientSet.ResourceShouldBe - ` [the] resource [should] converge to selector ` kdt.KubeClientSet.ResourceShouldConvergeToSelector +- ` [the] resource [should] converge to field ` kdt.KubeClientSet.ResourceShouldConvergeToField - ` [the] resource condition should be ` kdt.KubeClientSet.ResourceConditionShouldBe - ` [I] update [the] resource with set to ` kdt.KubeClientSet.UpdateResourceWithField - ` [I] verify InstanceGroups [are] in "ready" state` kdt.KubeClientSet.VerifyInstanceGroups diff --git a/internal/util/util.go b/internal/util/util.go index b7b55faa..0c4dc3a9 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -21,6 +21,7 @@ import ( "path/filepath" "reflect" "runtime" + "strconv" "strings" "time" @@ -133,3 +134,36 @@ func StructToPrettyString(st interface{}) string { s, _ := json.MarshalIndent(st, "", "\t") return string(s) } + +func ExtractField(data any, path []string) (any, error) { + if len(path) == 0 || data == nil { + return data, nil + } + + currKey := path[0] + + maybeArr := strings.Split(currKey, "[") + if len(maybeArr) >= 2 { + indexStr := strings.TrimSuffix(maybeArr[1], "]") + i, err := strconv.Atoi(indexStr) + if err != nil { + return nil, err + } + + dataAtIdx := data.(map[string]any)[maybeArr[0]] + switch dataAtIdx := dataAtIdx.(type) { + case []interface{}: + return ExtractField(dataAtIdx[i], path[1:]) + default: + return ExtractField(dataAtIdx.([]map[string]any)[i], path[1:]) + } + } + + for key, val := range data.(map[string]any) { + if key == currKey { + return ExtractField(val, path[1:]) + } + } + + return nil, errors.New("field not found") +} diff --git a/internal/util/util_test.go b/internal/util/util_test.go new file mode 100644 index 00000000..da954028 --- /dev/null +++ b/internal/util/util_test.go @@ -0,0 +1,85 @@ +/* +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 util + +import ( + "testing" +) + +var ( + sampleResource = map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "containers": []map[string]any{ + { + "name": "someContainer", + "image": "someImage", + "version": 1.4, + "ports": []map[string]any{ + {"containerPort": 8080}, + {"containerPort": 8940}, + }, + }, + }, + }, + }, + } +) + +func TestExtractField(t *testing.T) { + type args struct { + data any + path []string + expectedValue any + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Positive Test", + args: args{ + data: sampleResource, + path: []string{"spec", "template", "containers[0]", "name"}, + expectedValue: "someContainer", + }, + }, + { + name: "Positive Test - multiple array", + args: args{ + data: sampleResource, + path: []string{"spec", "template", "containers[0]", "ports[1]", "containerPort"}, + expectedValue: 8940, + }, + }, + { + name: "Negative Test", + args: args{ + data: sampleResource, + path: []string{"spec", "path", "doesnt", "exist"}, + expectedValue: nil, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if extractedValue, err := ExtractField(tt.args.data, tt.args.path); (err != nil) != tt.wantErr || extractedValue != tt.args.expectedValue { + t.Errorf("ExtractField() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/kubedog.go b/kubedog.go index 0e3778c2..2f0689d0 100644 --- a/kubedog.go +++ b/kubedog.go @@ -53,6 +53,7 @@ func (kdt *Test) SetScenario(scenario *godog.ScenarioContext) { kdt.scenario.Step(`^(?:I )?(create|submit|delete|update|upsert) (?:the )?resource (\S+) in (?:the )?([^"]*) namespace, the operation should (succeed|fail)$`, kdt.KubeClientSet.ResourceOperationWithResultInNamespace) kdt.scenario.Step(`^(?:the )?resource ([^"]*) should be (created|deleted)$`, kdt.KubeClientSet.ResourceShouldBe) kdt.scenario.Step(`^(?:the )?resource (\S+) (?:should )?converge to selector (\S+)$`, kdt.KubeClientSet.ResourceShouldConvergeToSelector) + kdt.scenario.Step(`^(?:the )?resource (\S+) (?:should )?converge to field (\S+)$`, kdt.KubeClientSet.ResourceShouldConvergeToField) kdt.scenario.Step(`^(?:the )?resource ([^"]*) condition ([^"]*) should be ([^"]*)$`, kdt.KubeClientSet.ResourceConditionShouldBe) kdt.scenario.Step(`^(?:I )?update (?:the )?resource ([^"]*) with ([^"]*) set to ([^"]*)$`, kdt.KubeClientSet.UpdateResourceWithField) kdt.scenario.Step(`^(?:I )?verify InstanceGroups (?:are )?in "ready" state$`, kdt.KubeClientSet.VerifyInstanceGroups) diff --git a/pkg/kube/kube.go b/pkg/kube/kube.go index 8cf913a9..135e9a7f 100644 --- a/pkg/kube/kube.go +++ b/pkg/kube/kube.go @@ -190,6 +190,14 @@ func (kc *ClientSet) ResourceShouldConvergeToSelector(resourceFileName, selector return unstruct.ResourceShouldConvergeToSelector(kc.DynamicInterface, resource, kc.getWaiterConfig(), selector) } +func (kc *ClientSet) ResourceShouldConvergeToField(resourceFileName, selector string) error { + resource, err := unstruct.GetResource(kc.getDiscoveryClient(), kc.config.templateArguments, kc.getResourcePath(resourceFileName)) + if err != nil { + return err + } + return unstruct.ResourceShouldConvergeToField(kc.DynamicInterface, resource, kc.getWaiterConfig(), selector) +} + func (kc *ClientSet) ResourceConditionShouldBe(resourceFileName, conditionType, conditionValue string) error { resource, err := unstruct.GetResource(kc.getDiscoveryClient(), kc.config.templateArguments, kc.getResourcePath(resourceFileName)) if err != nil { diff --git a/pkg/kube/unstructured/test/files/resource.yaml b/pkg/kube/unstructured/test/files/resource.yaml index 644319a9..2a15553a 100644 --- a/pkg/kube/unstructured/test/files/resource.yaml +++ b/pkg/kube/unstructured/test/files/resource.yaml @@ -8,4 +8,14 @@ metadata: status: conditions: - type: someConditionType - status: "True" \ No newline at end of file + status: "True" + replicaCount: 2 +spec: + template: + containers: + - name: someContainer + image: someImage + version: 1.0.0 + ports: + - containerPort: 8080 + - containerPort: 8940 \ No newline at end of file diff --git a/pkg/kube/unstructured/unstructured.go b/pkg/kube/unstructured/unstructured.go index 8c928f7e..7a8236d4 100644 --- a/pkg/kube/unstructured/unstructured.go +++ b/pkg/kube/unstructured/unstructured.go @@ -19,6 +19,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strconv" "strings" "time" @@ -195,6 +196,64 @@ func ResourceShouldBe(dynamicClient dynamic.Interface, resource unstructuredReso } } +func ResourceShouldConvergeToField(dynamicClient dynamic.Interface, resource unstructuredResource, w common.WaiterConfig, selector string) error { + var counter int + + if err := validateDynamicClient(dynamicClient); err != nil { + return err + } + + split := util.DeleteEmpty(strings.Split(selector, "=")) + if len(split) != 2 { + return errors.Errorf("Selector '%s' should meet format '='", selector) + } + + key := split[0] + value := split[1] + + keySlice := util.DeleteEmpty(strings.Split(key, ".")) + if len(keySlice) < 1 { + return errors.Errorf("Found empty 'key' in selector '%s' of form '='", selector) + } + + gvr, unstruct := resource.GVR, resource.Resource + + for { + if counter >= w.GetTries() { + return errors.New("waiter timed out waiting for resource") + } + log.Infof("waiting for resource %v/%v to converge to %v=%v", unstruct.GetNamespace(), unstruct.GetName(), key, value) + retResource, err := dynamicClient.Resource(gvr.Resource).Namespace(unstruct.GetNamespace()).Get(context.Background(), unstruct.GetName(), metav1.GetOptions{}) + if err != nil { + return err + } + + val, err := util.ExtractField(retResource.UnstructuredContent(), keySlice) + if err != nil { + return err + } + var convertedValue any + switch val.(type) { + case int, int64: + convertedValue, err = strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + case string: + convertedValue = value + default: + return errors.New("unknown type") + } + if reflect.DeepEqual(val, convertedValue) { + break + } + counter++ + time.Sleep(w.GetInterval()) + } + + return nil +} + func ResourceShouldConvergeToSelector(dynamicClient dynamic.Interface, resource unstructuredResource, w common.WaiterConfig, selector string) error { var counter int diff --git a/pkg/kube/unstructured/unstructured_test.go b/pkg/kube/unstructured/unstructured_test.go index 2d5d9d64..29e0ae0b 100644 --- a/pkg/kube/unstructured/unstructured_test.go +++ b/pkg/kube/unstructured/unstructured_test.go @@ -519,6 +519,89 @@ func TestResourceShouldConvergeToSelector(t *testing.T) { } } +func TestResourceShouldConvergeToField(t *testing.T) { + type args struct { + dynamicClient dynamic.Interface + resource unstructuredResource + w common.WaiterConfig + selector string + } + resource := getResourceFromYaml(t, getFilePath("resource.yaml")) + labelKey, labelValue := getOneLabel(t, *resource.Resource) + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Positive Test", + args: args{ + dynamicClient: newFakeDynamicClientWithResource(resource), + resource: resource, + selector: ".metadata.labels." + labelKey + "=" + labelValue, + }, + }, + { + name: "Positive Test: array", + args: args{ + dynamicClient: newFakeDynamicClientWithResource(resource), + resource: resource, + selector: ".spec.template.containers[0].image=someImage", + }, + }, + { + name: "Positive Test: non-string value", + args: args{ + dynamicClient: newFakeDynamicClientWithResource(resource), + resource: resource, + selector: ".status.replicaCount=2", + }, + }, + { + name: "Positive Test: array and non-string value", + args: args{ + dynamicClient: newFakeDynamicClientWithResource(resource), + resource: resource, + selector: ".spec.template.containers[0].version=1.0.0", + }, + }, + { + name: "Positive Test: array of array", + args: args{ + dynamicClient: newFakeDynamicClientWithResource(resource), + resource: resource, + selector: ".spec.template.containers[0].ports[1].containerPort=8940", + }, + }, + { + name: "Negative Test: invalid selector", + args: args{ + dynamicClient: newFakeDynamicClientWithResource(resource), + resource: resource, + selector: ".invalid.selector.", + }, + wantErr: true, + }, + { + name: "Negative Test: invalid key", + args: args{ + dynamicClient: newFakeDynamicClientWithResource(resource), + resource: resource, + selector: ".=invalid-key", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.args.w = common.NewWaiterConfig(1, time.Second) + if err := ResourceShouldConvergeToField(tt.args.dynamicClient, tt.args.resource, tt.args.w, tt.args.selector); (err != nil) != tt.wantErr { + t.Errorf("ResourceShouldConvergeToSelector() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func TestResourceConditionShouldBe(t *testing.T) { type args struct { dynamicClient dynamic.Interface