diff --git a/internal/cmd/postgresflex/options/options.go b/internal/cmd/postgresflex/options/options.go new file mode 100644 index 00000000..327521e5 --- /dev/null +++ b/internal/cmd/postgresflex/options/options.go @@ -0,0 +1,250 @@ +package options + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/pager" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" +) + +const ( + flavorsFlag = "flavors" + versionsFlag = "versions" + storagesFlag = "storages" + flavorIdFlag = "flavor-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Flavors bool + Versions bool + Storages bool + FlavorId *string +} + +type options struct { + Flavors *[]postgresflex.Flavor `json:"flavors,omitempty"` + Versions *[]string `json:"versions,omitempty"` + Storages *flavorStorages `json:"flavorStorages,omitempty"` +} + +type flavorStorages struct { + FlavorId string `json:"flavorId"` + Storages *postgresflex.ListStoragesResponse `json:"storages"` +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "options", + Short: "Lists PostgreSQL Flex options", + Long: "Lists PostgreSQL Flex options (flavors, versions and storages for a given flavor)\nPass one or more flags to filter what categories are shown.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List PostgreSQL Flex flavors options`, + "$ stackit postgresflex options --flavors"), + examples.NewExample( + `List PostgreSQL Flex available versions`, + "$ stackit postgresflex options --versions"), + examples.NewExample( + `List PostgreSQL Flex storage options for a given flavor. The flavor ID can be retrieved by running "$ stackit postgresflex options --flavors"`, + "$ stackit postgresflex options --storages --flavor-id "), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + err = buildAndExecuteRequest(ctx, cmd, model, apiClient) + if err != nil { + return fmt.Errorf("get PostgreSQL Flex options: %w", err) + } + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(flavorsFlag, false, "Lists supported flavors") + cmd.Flags().Bool(versionsFlag, false, "Lists supported versions") + cmd.Flags().Bool(storagesFlag, false, "Lists supported storages for a given flavor") + cmd.Flags().String(flavorIdFlag, "", `The flavor ID to show storages for. Only relevant when "--storages" is passed`) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + flavors := flags.FlagToBoolValue(cmd, flavorsFlag) + versions := flags.FlagToBoolValue(cmd, versionsFlag) + storages := flags.FlagToBoolValue(cmd, storagesFlag) + flavorId := flags.FlagToStringPointer(cmd, flavorIdFlag) + + if !flavors && !versions && !storages { + return nil, fmt.Errorf("%s\n\n%s", + "please specify at least one category for which to list the available options.", + "Get details on the available flags by re-running your command with the --help flag.") + } + + if storages && flavorId == nil { + return nil, fmt.Errorf("%s\n\n%s\n%s", + `please specify a flavor ID to show storages for by setting the flag "--flavor-id ".`, + "You can get the available flavor IDs by running:", + " $ stackit postgresflex options --flavors") + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Flavors: flavors, + Versions: versions, + Storages: storages, + FlavorId: flags.FlagToStringPointer(cmd, flavorIdFlag), + }, nil +} + +type postgresFlexOptionsClient interface { + ListFlavorsExecute(ctx context.Context, projectId string) (*postgresflex.ListFlavorsResponse, error) + ListVersionsExecute(ctx context.Context, projectId string) (*postgresflex.ListVersionsResponse, error) + ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*postgresflex.ListStoragesResponse, error) +} + +func buildAndExecuteRequest(ctx context.Context, cmd *cobra.Command, model *inputModel, apiClient postgresFlexOptionsClient) error { + var flavors *postgresflex.ListFlavorsResponse + var versions *postgresflex.ListVersionsResponse + var storages *postgresflex.ListStoragesResponse + var err error + + if model.Flavors { + flavors, err = apiClient.ListFlavorsExecute(ctx, model.ProjectId) + if err != nil { + return fmt.Errorf("get PostgreSQL Flex flavors: %w", err) + } + } + if model.Versions { + versions, err = apiClient.ListVersionsExecute(ctx, model.ProjectId) + if err != nil { + return fmt.Errorf("get PostgreSQL Flex versions: %w", err) + } + } + if model.Storages { + storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *model.FlavorId) + if err != nil { + return fmt.Errorf("get PostgreSQL Flex storages: %w", err) + } + } + + return outputResult(cmd, model, flavors, versions, storages) +} + +func outputResult(cmd *cobra.Command, model *inputModel, flavors *postgresflex.ListFlavorsResponse, versions *postgresflex.ListVersionsResponse, storages *postgresflex.ListStoragesResponse) error { + options := &options{} + if flavors != nil { + options.Flavors = flavors.Flavors + } + if versions != nil { + options.Versions = versions.Versions + } + if storages != nil && model.FlavorId != nil { + options.Storages = &flavorStorages{ + FlavorId: *model.FlavorId, + Storages: storages, + } + } + + switch model.OutputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(options, "", " ") + if err != nil { + return fmt.Errorf("marshal PostgreSQL Flex options: %w", err) + } + cmd.Println(string(details)) + return nil + default: + return outputResultAsTable(cmd, model, options) + } +} + +func outputResultAsTable(cmd *cobra.Command, model *inputModel, options *options) error { + content := "" + if model.Flavors { + content += renderFlavors(*options.Flavors) + } + if model.Versions { + content += renderVersions(*options.Versions) + } + if model.Storages { + content += renderStorages(options.Storages.Storages) + } + + err := pager.Display(cmd, content) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + + return nil +} + +func renderFlavors(flavors []postgresflex.Flavor) string { + if len(flavors) == 0 { + return "" + } + + table := tables.NewTable() + table.SetHeader("ID", "CPU", "MEMORY", "DESCRIPTION") + for i := range flavors { + f := flavors[i] + table.AddRow(*f.Id, *f.Cpu, *f.Memory, *f.Description) + } + return table.Render() +} + +func renderVersions(versions []string) string { + if len(versions) == 0 { + return "" + } + + table := tables.NewTable() + table.SetHeader("VERSION") + for i := range versions { + v := versions[i] + table.AddRow(v) + } + return table.Render() +} + +func renderStorages(resp *postgresflex.ListStoragesResponse) string { + if resp.StorageClasses == nil || len(*resp.StorageClasses) == 0 { + return "" + } + storageClasses := *resp.StorageClasses + + table := tables.NewTable() + table.SetHeader("MIN STORAGE", "MAX STORAGE", "STORAGE CLASS") + for i := range storageClasses { + sc := storageClasses[i] + table.AddRow(*resp.StorageRange.Min, *resp.StorageRange.Max, sc) + } + table.EnableAutoMergeOnColumns(1, 2, 3) + return table.Render() +} diff --git a/internal/cmd/postgresflex/options/options_test.go b/internal/cmd/postgresflex/options/options_test.go new file mode 100644 index 00000000..acb6c2a8 --- /dev/null +++ b/internal/cmd/postgresflex/options/options_test.go @@ -0,0 +1,320 @@ +package options + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + +type postgresFlexClientMocked struct { + listFlavorsFails bool + listVersionsFails bool + listStoragesFails bool + + listFlavorsCalled bool + listVersionsCalled bool + listStoragesCalled bool +} + +func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*postgresflex.ListFlavorsResponse, error) { + c.listFlavorsCalled = true + if c.listFlavorsFails { + return nil, fmt.Errorf("list flavors failed") + } + return utils.Ptr(postgresflex.ListFlavorsResponse{ + Flavors: utils.Ptr([]postgresflex.Flavor{}), + }), nil +} + +func (c *postgresFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*postgresflex.ListVersionsResponse, error) { + c.listVersionsCalled = true + if c.listVersionsFails { + return nil, fmt.Errorf("list versions failed") + } + return utils.Ptr(postgresflex.ListVersionsResponse{ + Versions: utils.Ptr([]string{}), + }), nil +} + +func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*postgresflex.ListStoragesResponse, error) { + c.listStoragesCalled = true + if c.listStoragesFails { + return nil, fmt.Errorf("list storages failed") + } + return utils.Ptr(postgresflex.ListStoragesResponse{ + StorageClasses: utils.Ptr([]string{}), + StorageRange: &postgresflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }), nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + flavorsFlag: "true", + versionsFlag: "true", + storagesFlag: "true", + flavorIdFlag: "2.4", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModelAllFalse(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + Flavors: false, + Versions: false, + Storages: false, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureInputModelAllTrue(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + Flavors: true, + Versions: true, + Storages: true, + FlavorId: utils.Ptr("2.4"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "all values", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModelAllTrue(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "some values 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[storagesFlag] = "false" + delete(flagValues, flavorIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModelAllFalse(func(model *inputModel) { + model.Flavors = true + model.Versions = true + }), + }, + { + description: "some values 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, flavorsFlag) + delete(flagValues, versionsFlag) + flagValues[storagesFlag] = "true" + flagValues[flavorIdFlag] = "2.4" + }), + isValid: true, + expectedModel: fixtureInputModelAllFalse(func(model *inputModel) { + model.Storages = true + model.FlavorId = utils.Ptr("2.4") + }), + }, + { + description: "storages without flavor-id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, flavorIdFlag) + }), + isValid: false, + }, + { + description: "flavor-id without storage", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, storagesFlag) + }), + isValid: true, + expectedModel: fixtureInputModelAllTrue(func(model *inputModel) { + model.Storages = false + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildAndExecuteRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + listFlavorsFails bool + listVersionsFails bool + listStoragesFails bool + expectListFlavorsCalled bool + expectListVersionsCalled bool + expectListStoragesCalled bool + }{ + { + description: "all values", + model: fixtureInputModelAllTrue(), + isValid: true, + expectListFlavorsCalled: true, + expectListVersionsCalled: true, + expectListStoragesCalled: true, + }, + { + description: "no values", + model: fixtureInputModelAllFalse(), + isValid: true, + expectListFlavorsCalled: false, + expectListVersionsCalled: false, + expectListStoragesCalled: false, + }, + { + description: "only flavors", + model: fixtureInputModelAllFalse(func(model *inputModel) { model.Flavors = true }), + isValid: true, + expectListFlavorsCalled: true, + }, + { + description: "only versions", + model: fixtureInputModelAllFalse(func(model *inputModel) { model.Versions = true }), + isValid: true, + expectListVersionsCalled: true, + }, + { + description: "only storages", + model: fixtureInputModelAllFalse(func(model *inputModel) { + model.Storages = true + model.FlavorId = utils.Ptr("2.4") + }), + isValid: true, + expectListStoragesCalled: true, + }, + { + description: "list flavors fails", + model: fixtureInputModelAllTrue(), + isValid: false, + listFlavorsFails: true, + expectListFlavorsCalled: true, + expectListVersionsCalled: false, + expectListStoragesCalled: false, + }, + { + description: "list versions fails", + model: fixtureInputModelAllTrue(), + isValid: false, + listVersionsFails: true, + expectListFlavorsCalled: true, + expectListVersionsCalled: true, + expectListStoragesCalled: false, + }, + { + description: "list storages fails", + model: fixtureInputModelAllTrue(), + isValid: false, + listStoragesFails: true, + expectListFlavorsCalled: true, + expectListVersionsCalled: true, + expectListStoragesCalled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + client := &postgresFlexClientMocked{ + listFlavorsFails: tt.listFlavorsFails, + listVersionsFails: tt.listVersionsFails, + listStoragesFails: tt.listStoragesFails, + } + + err := buildAndExecuteRequest(testCtx, cmd, tt.model, client) + if err != nil && tt.isValid { + t.Fatalf("error building and executing request: %v", err) + } + if err == nil && !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + if tt.expectListFlavorsCalled != client.listFlavorsCalled { + t.Fatalf("expected listFlavorsCalled to be %v, got %v", tt.expectListFlavorsCalled, client.listFlavorsCalled) + } + if tt.expectListVersionsCalled != client.listVersionsCalled { + t.Fatalf("expected listVersionsCalled to be %v, got %v", tt.expectListVersionsCalled, client.listVersionsCalled) + } + if tt.expectListStoragesCalled != client.listStoragesCalled { + t.Fatalf("expected listStoragesCalled to be %v, got %v", tt.expectListStoragesCalled, client.listStoragesCalled) + } + }) + } +} diff --git a/internal/cmd/postgresflex/postgresflex.go b/internal/cmd/postgresflex/postgresflex.go index 24674b1e..3f74e35c 100644 --- a/internal/cmd/postgresflex/postgresflex.go +++ b/internal/cmd/postgresflex/postgresflex.go @@ -2,6 +2,7 @@ package postgresflex import ( "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/instance" + "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/options" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/user" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -25,4 +26,5 @@ func NewCmd() *cobra.Command { func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(instance.NewCmd()) cmd.AddCommand(user.NewCmd()) + cmd.AddCommand(options.NewCmd()) }