From 513c0a03786b7f8665087abcb4cb436fd13f3f56 Mon Sep 17 00:00:00 2001 From: Mani Bindra Date: Thu, 14 Sep 2023 12:24:44 -0700 Subject: [PATCH] Add field-selector argument to installations list (#2888) * Add field-selector argument to installations list Signed-off-by: Maninderjit Bindra --------- Signed-off-by: Maninderjit Bindra --- CONTRIBUTORS.md | 1 + cmd/porter/installations.go | 1 + pkg/porter/list.go | 77 +++++++++ pkg/porter/list_test.go | 301 ++++++++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8891ecb46..e3692421b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -86,3 +86,4 @@ and we will add you. **All** contributors belong here. 💯 * [Troy Connor](https://github.com/troy0820) * [Phill Gibson](https://github.com/phillipgibson) * [Ludvig Liljenberg](https://github.com/ludfjig) +* [Maninderjit Bindra](https://github.com/manisbindra) diff --git a/cmd/porter/installations.go b/cmd/porter/installations.go index 37f684ba5..2d9daedc2 100644 --- a/cmd/porter/installations.go +++ b/cmd/porter/installations.go @@ -74,6 +74,7 @@ Optional output formats include json and yaml.`, "Skip the number of installations by a certain amount. Defaults to 0.") f.Int64Var(&opts.Limit, "limit", 0, "Limit the number of installations by a certain amount. Defaults to 0.") + f.StringVar(&opts.FieldSelector, "field-selector", "", "Selector (field query) to filter on, supports '=' (e.g. --field-selector bundle.version=0.2.0,status.action=install). All fields from the json output are supported.") return cmd } diff --git a/pkg/porter/list.go b/pkg/porter/list.go index a88109f75..ae39c0e82 100644 --- a/pkg/porter/list.go +++ b/pkg/porter/list.go @@ -13,6 +13,8 @@ import ( "get.porter.sh/porter/pkg/storage" "get.porter.sh/porter/pkg/tracing" dtprinter "github.com/carolynvs/datetime-printer" + + "reflect" ) const ( @@ -34,6 +36,7 @@ type ListOptions struct { Labels []string Skip int64 Limit int64 + FieldSelector string } func (o *ListOptions) Validate() error { @@ -246,9 +249,21 @@ func (p *Porter) ListInstallations(ctx context.Context, opts ListOptions) (Displ } var displayInstallations DisplayInstallations + var fieldSelectorMap map[string]string + if opts.FieldSelector != "" { + fieldSelectorMap, err = parseFieldSelector(opts.FieldSelector) + if err != nil { + return nil, err + } + } + for _, installation := range installations { di := NewDisplayInstallation(installation) + if opts.FieldSelector != "" && !doesInstallationMatchFieldSelectors(di, fieldSelectorMap) { + continue + } displayInstallations = append(displayInstallations, di) + } sort.Sort(sort.Reverse(displayInstallations)) @@ -322,3 +337,65 @@ func getDisplayInstallationStatus(installation storage.Installation) string { return status } + +// Split the fieldSelector into a map of fields and values +// e.g. "bundle.version=0.2.0,status.action=install" => map[string]string{"bundle.version": "0.2.0", "status.action": "install"} +func parseFieldSelector(fieldSelector string) (map[string]string, error) { + fieldSelectorMap := make(map[string]string) + for _, field := range strings.Split(fieldSelector, ",") { + fieldParts := strings.Split(field, "=") + if len(fieldParts) != 2 { + return nil, fmt.Errorf("invalid field selector: %s", fieldSelector) + } + fieldSelectorMap[fieldParts[0]] = fieldParts[1] + } + + return fieldSelectorMap, nil +} + +// Check if the installation matches the field selectors +func doesInstallationMatchFieldSelectors(installation DisplayInstallation, fieldSelectorMap map[string]string) bool { + for field, value := range fieldSelectorMap { + if !installationHasFieldWithValue(installation, field, value) { + return false + } + } + return true +} + +// Check if the installation has the field with the value +// e.g. installationHasFieldWithValue(installation, "bundle.version", "0.2.0") => true if installation.Bundle.Version (for which json tag is bunde.version) == "0.2.0" +func installationHasFieldWithValue(installation DisplayInstallation, fieldJsonTagPath string, value string) bool { + + fieldJsonTagPathParts := strings.Split(fieldJsonTagPath, ".") + current := reflect.ValueOf(installation) + + for _, fieldJsonTagPart := range fieldJsonTagPathParts { + if current.Kind() != reflect.Struct { + return false + } + field := getFieldByJSONTag(current, fieldJsonTagPart) + if !field.IsValid() { + return false + } + current = field + } + + return reflect.DeepEqual(current.Interface(), value) +} + +// Return the reflect.value based on the field's json tag +func getFieldByJSONTag(value reflect.Value, fieldJsonTag string) reflect.Value { + for i := 0; i < value.NumField(); i++ { + field := value.Type().Field(i) + + reflectTag := field.Tag.Get("json") + if strings.Contains(reflectTag, ",") { + reflectTag = strings.Split(reflectTag, ",")[0] + } + if reflectTag == fieldJsonTag { + return value.Field(i) + } + } + return reflect.Value{} +} diff --git a/pkg/porter/list_test.go b/pkg/porter/list_test.go index 974e60557..e20cb8c56 100644 --- a/pkg/porter/list_test.go +++ b/pkg/porter/list_test.go @@ -11,6 +11,8 @@ import ( "get.porter.sh/porter/pkg/storage" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "reflect" ) func TestNewDisplayInstallation(t *testing.T) { @@ -104,6 +106,84 @@ func TestPorter_ListInstallations(t *testing.T) { }) } +func TestPorter_ListInstallationsWithFieldSelector(t *testing.T) { + ctx := context.Background() + p := NewTestPorter(t) + defer p.Close() + + i1 := storage.NewInstallation("", "installation1") + i1.Bundle.Version = "0.2.0" + i1.Status.Action = "install" + i1.Status.ResultStatus = "succeeded" + + i2 := storage.NewInstallation("", "installation2") + i2.Bundle.Version = "0.2.1" + i2.Status.Action = "install" + i2.Status.ResultStatus = "succeeded" + + i3 := storage.NewInstallation("", "installation3") + i3.Bundle.Version = "0.2.0" + i3.Status.Action = "install" + i3.Status.ResultStatus = "failed" + + i4 := storage.NewInstallation("", "installation4") + i4.Bundle.Version = "0.2.5" + i4.Status.Action = "install" + + i5 := storage.NewInstallation("", "installation5") + i5.Bundle.Version = "0.1.0" + i5.Status.Action = "install" + i5.Status.ResultStatus = "succeeded" + + p.TestInstallations.CreateInstallation(i1) + p.TestInstallations.CreateInstallation(i2) + p.TestInstallations.CreateInstallation(i3) + p.TestInstallations.CreateInstallation(i4) + p.TestInstallations.CreateInstallation(i5) + + t.Run("blank field selector", func(t *testing.T) { + opts := ListOptions{FieldSelector: ""} + results, err := p.ListInstallations(ctx, opts) + require.NoError(t, err) + assert.Len(t, results, 5) + }) + + t.Run("top level field selectors", func(t *testing.T) { + opts := ListOptions{FieldSelector: "name=installation1"} + results, err := p.ListInstallations(ctx, opts) + require.NoError(t, err) + assert.Len(t, results, 1) + }) + + t.Run("multi level field selectors", func(t *testing.T) { + opts := ListOptions{FieldSelector: "name=installation1,bundle.version=0.2.0,status.action=install,status.resultStatus=succeeded"} + results, err := p.ListInstallations(ctx, opts) + require.NoError(t, err) + assert.Len(t, results, 1) + + opts = ListOptions{FieldSelector: "status.action=install,status.resultStatus=succeeded"} + results, err = p.ListInstallations(ctx, opts) + require.NoError(t, err) + assert.Len(t, results, 3) + + opts = ListOptions{FieldSelector: "status.resultStatus=failed"} + results, err = p.ListInstallations(ctx, opts) + require.NoError(t, err) + assert.Len(t, results, 1) + + opts = ListOptions{FieldSelector: "status.resultStatus"} + results, err = p.ListInstallations(ctx, opts) + require.Error(t, err) + assert.Len(t, results, 0) + + opts = ListOptions{FieldSelector: "status.resultStatus=xyz"} + results, err = p.ListInstallations(ctx, opts) + require.NoError(t, err) + assert.Len(t, results, 0) + + }) +} + func TestDisplayInstallation_ConvertToInstallation(t *testing.T) { cp := storage.NewTestInstallationProvider(t) defer cp.Close() @@ -270,3 +350,224 @@ func TestPorter_getDisplayInstallationStatus(t *testing.T) { displayInstallationStatus = getDisplayInstallationStatus(installation) require.Equal(t, "running customaction", displayInstallationStatus) } + +func Test_parseFieldSelector(t *testing.T) { + tests := []struct { + name string + fieldSelector string + want map[string]string + wantErr bool + expectedErrMsg string + }{ + { + name: "valid field selector", + fieldSelector: "bundle.version=0.2.0,status.action=install", + want: map[string]string{ + "bundle.version": "0.2.0", + "status.action": "install", + }, + wantErr: false, + }, + { + name: "invalid field selector", + fieldSelector: "bundle.version=0.2.0,status.action", + want: nil, + wantErr: true, + expectedErrMsg: "invalid field selector: bundle.version=0.2.0,status.action", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseFieldSelector(tt.fieldSelector) + if (err != nil) != tt.wantErr { + t.Errorf("parseFieldSelector() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseFieldSelector() = %v, want %v", got, tt.want) + } + if err != nil && err.Error() != tt.expectedErrMsg { + t.Errorf("parseFieldSelector() error message = %v, expected error message %v", err.Error(), tt.expectedErrMsg) + } + }) + } +} + +func Test_doesInstallationMatchFieldSelectors(t *testing.T) { + type args struct { + installation DisplayInstallation + fieldSelectorMap map[string]string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "installation matches field selectors", + args: args{ + installation: DisplayInstallation{ + Name: "wordpress", + Status: storage.InstallationStatus{ + Action: "install", + ResultStatus: "succeeded", + }, + Bundle: storage.OCIReferenceParts{ + Version: "0.2.0", + }, + }, + fieldSelectorMap: map[string]string{ + "name": "wordpress", + "status.action": "install", + "status.resultStatus": "succeeded", + "bundle.version": "0.2.0", + }, + }, + want: true, + }, + { + name: "installation matches field selectors", + args: args{ + installation: DisplayInstallation{ + Name: "wordpress", + Status: storage.InstallationStatus{ + Action: "install", + ResultStatus: "succeeded", + }, + Bundle: storage.OCIReferenceParts{ + Version: "0.2.1", + }, + }, + fieldSelectorMap: map[string]string{ + "name": "wordpress", + "status.action": "install", + "status.resultStatus": "succeeded", + "bundle.version": "0.2.0", + }, + }, + want: false, + }, + { + name: "installation matches field selectors", + args: args{ + installation: DisplayInstallation{ + Name: "wordpress", + Status: storage.InstallationStatus{ + Action: "install", + ResultStatus: "failed", + }, + Bundle: storage.OCIReferenceParts{ + Version: "0.2.0", + }, + }, + fieldSelectorMap: map[string]string{ + "name": "wordpress", + "status.action": "install", + "status.resultStatus": "succeeded", + "bundle.version": "0.2.0", + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := doesInstallationMatchFieldSelectors(tt.args.installation, tt.args.fieldSelectorMap); got != tt.want { + t.Errorf("doesInstallationMatchFieldSelectors() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_installationHasFieldWithValue(t *testing.T) { + type args struct { + installation DisplayInstallation + fieldJsonTagPath string + value string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "installation has field with value", + args: args{ + installation: DisplayInstallation{ + Name: "wordpress", + Status: storage.InstallationStatus{ + Action: "install", + ResultStatus: "succeeded", + }, + Bundle: storage.OCIReferenceParts{ + Version: "0.2.0", + }, + }, + fieldJsonTagPath: "name", + value: "wordpress", + }, + want: true, + }, + { + name: "installation has nested field with value", + args: args{ + installation: DisplayInstallation{ + Name: "wordpress", + Status: storage.InstallationStatus{ + Action: "install", + ResultStatus: "succeeded", + }, + Bundle: storage.OCIReferenceParts{ + Version: "0.2.0", + }, + }, + fieldJsonTagPath: "bundle.version", + value: "0.2.0", + }, + want: true, + }, + { + name: "installation does not have field with value", + args: args{ + installation: DisplayInstallation{ + Name: "wordpress", + Status: storage.InstallationStatus{ + Action: "install", + ResultStatus: "succeeded", + }, + Bundle: storage.OCIReferenceParts{ + Version: "0.2.0", + }, + }, + fieldJsonTagPath: "bundle.version", + value: "0.2.1", + }, + want: false, + }, + { + name: "installation does not have field with value", + args: args{ + installation: DisplayInstallation{ + Name: "wordpress", + Status: storage.InstallationStatus{ + Action: "install", + ResultStatus: "succeeded", + }, + Bundle: storage.OCIReferenceParts{ + Version: "0.2.0", + }, + }, + fieldJsonTagPath: "xyz", + value: "123", + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := installationHasFieldWithValue(tt.args.installation, tt.args.fieldJsonTagPath, tt.args.value); got != tt.want { + t.Errorf("installationHasFieldWithValue() = %v, want %v", got, tt.want) + } + }) + } +}