From 72b837c3d6030ad0e6fc92057b967358ac11d2cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Mon, 11 Mar 2024 17:35:20 +0000 Subject: [PATCH 1/6] Onboard Argus: plans command, config (set and unset) and client (#147) --- docs/stackit.md | 1 + docs/stackit_argus.md | 32 ++++ docs/stackit_argus_plans.md | 45 +++++ docs/stackit_config_set.md | 1 + docs/stackit_config_unset.md | 1 + go.mod | 1 + go.sum | 2 + internal/cmd/argus/argus.go | 25 +++ internal/cmd/argus/plans/plans.go | 144 +++++++++++++++ internal/cmd/argus/plans/plans_test.go | 185 +++++++++++++++++++ internal/cmd/config/set/set.go | 6 +- internal/cmd/config/unset/unset.go | 7 + internal/cmd/config/unset/unset_test.go | 13 ++ internal/cmd/root.go | 2 + internal/pkg/config/config.go | 3 + internal/pkg/services/argus/client/client.go | 37 ++++ 16 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 docs/stackit_argus.md create mode 100644 docs/stackit_argus_plans.md create mode 100644 internal/cmd/argus/argus.go create mode 100644 internal/cmd/argus/plans/plans.go create mode 100644 internal/cmd/argus/plans/plans_test.go create mode 100644 internal/pkg/services/argus/client/client.go diff --git a/docs/stackit.md b/docs/stackit.md index 5f5f6f242..7c7484487 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -25,6 +25,7 @@ stackit [flags] ### SEE ALSO +* [stackit argus](./stackit_argus.md) - Provides functionality for Argus * [stackit auth](./stackit_auth.md) - Provides authentication functionality * [stackit config](./stackit_config.md) - Provides functionality for CLI configuration options * [stackit curl](./stackit_curl.md) - Executes an authenticated HTTP request to an endpoint diff --git a/docs/stackit_argus.md b/docs/stackit_argus.md new file mode 100644 index 000000000..c4c7415bb --- /dev/null +++ b/docs/stackit_argus.md @@ -0,0 +1,32 @@ +## stackit argus + +Provides functionality for Argus + +### Synopsis + +Provides functionality for Argus. + +``` +stackit argus [flags] +``` + +### Options + +``` + -h, --help Help for "stackit argus" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit argus plans](./stackit_argus_plans.md) - Lists all Argus service plans + diff --git a/docs/stackit_argus_plans.md b/docs/stackit_argus_plans.md new file mode 100644 index 000000000..ef4f1f1ad --- /dev/null +++ b/docs/stackit_argus_plans.md @@ -0,0 +1,45 @@ +## stackit argus plans + +Lists all Argus service plans + +### Synopsis + +Lists all Argus service plans. + +``` +stackit argus plans [flags] +``` + +### Examples + +``` + List all Argus service plans + $ stackit argus plans + + List all Argus service plans in JSON format + $ stackit argus plans --output-format json + + List up to 10 Argus service plans + $ stackit argus plans --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit argus plans" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit argus](./stackit_argus.md) - Provides functionality for Argus + diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index 096dd2aa2..3b92975dc 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -29,6 +29,7 @@ stackit config set [flags] ### Options ``` + --argus-custom-endpoint string Argus API base URL, used in calls to this API --authorization-custom-endpoint string Authorization API base URL, used in calls to this API --dns-custom-endpoint string DNS API base URL, used in calls to this API -h, --help Help for "stackit config set" diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 35f65a296..b69ab2449 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -26,6 +26,7 @@ stackit config unset [flags] ### Options ``` + --argus-custom-endpoint Argus API base URL. If unset, uses the default base URL --async Configuration option to run commands asynchronously --authorization-custom-endpoint Authorization API base URL. If unset, uses the default base URL --dns-custom-endpoint DNS API base URL. If unset, uses the default base URL diff --git a/go.mod b/go.mod index dfcd4646f..3c4a8d708 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect + github.com/stackitcloud/stackit-sdk-go/services/argus v0.9.5 github.com/stackitcloud/stackit-sdk-go/services/logme v0.10.1 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.10.1 github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.8.6 diff --git a/go.sum b/go.sum index a999fcdba..65b1e7cbb 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stackitcloud/stackit-sdk-go/core v0.10.0 h1:IcY8xa/6wo8EhRE9mpCvz4EtTkkoiIa2ZwPHuc5zGyw= github.com/stackitcloud/stackit-sdk-go/core v0.10.0/go.mod h1:B5dkVm2HlBRG7liBVIFNqncDb6TUHnJ7t0GsKhAFuRk= +github.com/stackitcloud/stackit-sdk-go/services/argus v0.9.5 h1:RLsA2jO9sMNpn7NYdtFyumY5Vj4n4WtBm2J2NBKlsnw= +github.com/stackitcloud/stackit-sdk-go/services/argus v0.9.5/go.mod h1:lzGbqwV0hqeX/kUvaaFTgjOJRxUlsZ911TX1YAcKwqc= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.1.1 h1:h7dCaBlbU34WSGuEXREmCdCzQafZgdXDZuairAzeuo8= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.1.1/go.mod h1:V+wTIfuJRV8PiSOfMX6GCTaHWltGaLCz8ImOKeHIaIA= github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.4 h1:n/X2pVdETDXGHk+vCsg0p3b2zGxSRMJ065to/aAoncg= diff --git a/internal/cmd/argus/argus.go b/internal/cmd/argus/argus.go new file mode 100644 index 000000000..67ec57e7e --- /dev/null +++ b/internal/cmd/argus/argus.go @@ -0,0 +1,25 @@ +package argus + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/plans" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "argus", + Short: "Provides functionality for Argus", + Long: "Provides functionality for Argus.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(plans.NewCmd()) +} diff --git a/internal/cmd/argus/plans/plans.go b/internal/cmd/argus/plans/plans.go new file mode 100644 index 000000000..a74e5c534 --- /dev/null +++ b/internal/cmd/argus/plans/plans.go @@ -0,0 +1,144 @@ +package plans + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Lists all Argus service plans", + Long: "Lists all Argus service plans.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Argus service plans`, + "$ stackit argus plans"), + examples.NewExample( + `List all Argus service plans in JSON format`, + "$ stackit argus plans --output-format json"), + examples.NewExample( + `List up to 10 Argus service plans`, + "$ stackit argus plans --limit 10"), + ), + 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 + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Argus service plans: %w", err) + } + plans := *resp.Plans + if len(plans) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No plans found for project %q\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(plans) > int(*model.Limit) { + plans = plans[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, plans) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiListPlansRequest { + req := apiClient.ListPlans(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, plans []argus.Plan) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(plans, "", " ") + if err != nil { + return fmt.Errorf("marshal Argus plans: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "PLAN NAME", "DESCRIPTION") + for i := range plans { + o := plans[i] + table.AddRow(*o.Id, *o.Name, *o.Description) + table.AddSeparator() + } + table.EnableAutoMergeOnColumns(1) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/argus/plans/plans_test.go b/internal/cmd/argus/plans/plans_test.go new file mode 100644 index 000000000..d7072ab48 --- /dev/null +++ b/internal/cmd/argus/plans/plans_test.go @@ -0,0 +1,185 @@ +package plans + +import ( + "context" + "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/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &argus.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiListPlansRequest)) argus.ApiListPlansRequest { + request := testClient.ListPlans(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + 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 TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest argus.ApiListPlansRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index a24be106c..485a1ef9b 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -18,6 +18,7 @@ import ( const ( sessionTimeLimitFlag = "session-time-limit" + argusCustomEndpointFlag = "argus-custom-endpoint" authorizationCustomEndpointFlag = "authorization-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" @@ -92,6 +93,7 @@ func NewCmd() *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().String(sessionTimeLimitFlag, "", "Maximum time before authentication is required again. After this time, you will be prompted to login again to execute commands that require authentication. Can't be larger than 24h. Requires authentication after being set to take effect. Examples: 3h, 5h30m40s (BETA: currently values greater than 2h have no effect)") + cmd.Flags().String(argusCustomEndpointFlag, "", "Argus API base URL, used in calls to this API") cmd.Flags().String(authorizationCustomEndpointFlag, "", "Authorization API base URL, used in calls to this API") cmd.Flags().String(dnsCustomEndpointFlag, "", "DNS API base URL, used in calls to this API") cmd.Flags().String(logMeCustomEndpointFlag, "", "LogMe API base URL, used in calls to this API") @@ -107,7 +109,9 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(serviceAccountCustomEndpointFlag, "", "Service Account API base URL, used in calls to this API") cmd.Flags().String(skeCustomEndpointFlag, "", "SKE API base URL, used in calls to this API") - err := viper.BindPFlag(config.AuthorizationCustomEndpointKey, cmd.Flags().Lookup(authorizationCustomEndpointFlag)) + err := viper.BindPFlag(config.ArgusCustomEndpointKey, cmd.Flags().Lookup(argusCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.AuthorizationCustomEndpointKey, cmd.Flags().Lookup(authorizationCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.DNSCustomEndpointKey, cmd.Flags().Lookup(dnsCustomEndpointFlag)) cobra.CheckErr(err) diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index f0c7fe3be..ac9e63a64 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -20,6 +20,7 @@ const ( sessionTimeLimitFlag = "session-time-limit" + argusCustomEndpointFlag = "argus-custom-endpoint" authorizationCustomEndpointFlag = "authorization-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" @@ -43,6 +44,7 @@ type inputModel struct { SessionTimeLimit bool + ArgusCustomEndpoint bool AuthorizationCustomEndpoint bool DNSCustomEndpoint bool LogMeCustomEndpoint bool @@ -93,6 +95,9 @@ func NewCmd() *cobra.Command { viper.Set(config.SessionTimeLimitKey, config.SessionTimeLimitDefault) } + if model.ArgusCustomEndpoint { + viper.Set(config.ArgusCustomEndpointKey, "") + } if model.AuthorizationCustomEndpoint { viper.Set(config.AuthorizationCustomEndpointKey, "") } @@ -154,6 +159,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(sessionTimeLimitFlag, false, fmt.Sprintf("Maximum time before authentication is required again. If unset, defaults to %s", config.SessionTimeLimitDefault)) + cmd.Flags().Bool(argusCustomEndpointFlag, false, "Argus API base URL. If unset, uses the default base URL") cmd.Flags().Bool(authorizationCustomEndpointFlag, false, "Authorization API base URL. If unset, uses the default base URL") cmd.Flags().Bool(dnsCustomEndpointFlag, false, "DNS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(logMeCustomEndpointFlag, false, "LogMe API base URL. If unset, uses the default base URL") @@ -177,6 +183,7 @@ func parseInput(cmd *cobra.Command) *inputModel { ProjectId: flags.FlagToBoolValue(cmd, projectIdFlag), SessionTimeLimit: flags.FlagToBoolValue(cmd, sessionTimeLimitFlag), + ArgusCustomEndpoint: flags.FlagToBoolValue(cmd, argusCustomEndpointFlag), AuthorizationCustomEndpoint: flags.FlagToBoolValue(cmd, authorizationCustomEndpointFlag), DNSCustomEndpoint: flags.FlagToBoolValue(cmd, dnsCustomEndpointFlag), LogMeCustomEndpoint: flags.FlagToBoolValue(cmd, logMeCustomEndpointFlag), diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index a6b92e526..f68717953 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -12,6 +12,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool projectIdFlag: true, outputFormatFlag: true, + argusCustomEndpointFlag: true, authorizationCustomEndpointFlag: true, dnsCustomEndpointFlag: true, logMeCustomEndpointFlag: true, @@ -36,6 +37,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { ProjectId: true, OutputFormat: true, + ArgusCustomEndpoint: true, AuthorizationCustomEndpoint: true, DNSCustomEndpoint: true, LogMeCustomEndpoint: true, @@ -76,6 +78,7 @@ func TestParseInput(t *testing.T) { model.ProjectId = false model.OutputFormat = false + model.ArgusCustomEndpoint = false model.AuthorizationCustomEndpoint = false model.DNSCustomEndpoint = false model.LogMeCustomEndpoint = false @@ -110,6 +113,16 @@ func TestParseInput(t *testing.T) { model.OutputFormat = false }), }, + { + description: "argus custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[argusCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ArgusCustomEndpoint = false + }), + }, { description: "dns custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bfb9a1f12..726bbd180 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus" "github.com/stackitcloud/stackit-cli/internal/cmd/auth" "github.com/stackitcloud/stackit-cli/internal/cmd/config" "github.com/stackitcloud/stackit-cli/internal/cmd/curl" @@ -84,6 +85,7 @@ func configureFlags(cmd *cobra.Command) error { } func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(argus.NewCmd()) cmd.AddCommand(auth.NewCmd()) cmd.AddCommand(config.NewCmd()) cmd.AddCommand(curl.NewCmd()) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 82baa091a..b94b82127 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -16,6 +16,7 @@ const ( ProjectIdKey = "project_id" SessionTimeLimitKey = "session_time_limit" + ArgusCustomEndpointKey = "argus_custom_endpoint" AuthorizationCustomEndpointKey = "authorization_custom_endpoint" DNSCustomEndpointKey = "dns_custom_endpoint" LogMeCustomEndpointKey = "logme_custom_endpoint" @@ -56,6 +57,7 @@ var ConfigKeys = []string{ OpenSearchCustomEndpointKey, PostgresFlexCustomEndpointKey, ResourceManagerEndpointKey, + ArgusCustomEndpointKey, AuthorizationCustomEndpointKey, MongoDBFlexCustomEndpointKey, RabbitMQCustomEndpointKey, @@ -128,6 +130,7 @@ func setConfigDefaults() { viper.SetDefault(ProjectIdKey, "") viper.SetDefault(SessionTimeLimitKey, SessionTimeLimitDefault) viper.SetDefault(DNSCustomEndpointKey, "") + viper.SetDefault(ArgusCustomEndpointKey, "") viper.SetDefault(AuthorizationCustomEndpointKey, "") viper.SetDefault(MongoDBFlexCustomEndpointKey, "") viper.SetDefault(ObjectStorageCustomEndpointKey, "") diff --git a/internal/pkg/services/argus/client/client.go b/internal/pkg/services/argus/client/client.go new file mode 100644 index 000000000..ebf90f114 --- /dev/null +++ b/internal/pkg/services/argus/client/client.go @@ -0,0 +1,37 @@ +package client + +import ( + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +func ConfigureClient(cmd *cobra.Command) (*argus.APIClient, error) { + var err error + var apiClient *argus.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01")) + + customEndpoint := viper.GetString(config.ArgusCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = argus.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} From 668a4b35f071510b2fdbcc311ea5e504cc4bdab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Mon, 11 Mar 2024 18:12:35 +0000 Subject: [PATCH 2/6] Onboard Argus (instance): create command and utils (#133) * Onboard Argus instance: create command init * create command: complete functionality and start testing * create command: added testing for utils * update utils error message * Add custom Argus error and fix create command testing * fix error formatting in utils * fix error formatting --- docs/stackit_argus.md | 1 + docs/stackit_argus_instance.md | 32 ++ docs/stackit_argus_instance_create.md | 44 +++ internal/cmd/argus/argus.go | 2 + internal/cmd/argus/instance/create/create.go | 191 ++++++++++ .../cmd/argus/instance/create/create_test.go | 332 ++++++++++++++++++ internal/cmd/argus/instance/instance.go | 25 ++ internal/pkg/errors/errors.go | 43 +++ internal/pkg/services/argus/utils/utils.go | 69 ++++ .../pkg/services/argus/utils/utils_test.go | 222 ++++++++++++ 10 files changed, 961 insertions(+) create mode 100644 docs/stackit_argus_instance.md create mode 100644 docs/stackit_argus_instance_create.md create mode 100644 internal/cmd/argus/instance/create/create.go create mode 100644 internal/cmd/argus/instance/create/create_test.go create mode 100644 internal/cmd/argus/instance/instance.go create mode 100644 internal/pkg/services/argus/utils/utils.go create mode 100644 internal/pkg/services/argus/utils/utils_test.go diff --git a/docs/stackit_argus.md b/docs/stackit_argus.md index c4c7415bb..dce4469af 100644 --- a/docs/stackit_argus.md +++ b/docs/stackit_argus.md @@ -28,5 +28,6 @@ stackit argus [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances * [stackit argus plans](./stackit_argus_plans.md) - Lists all Argus service plans diff --git a/docs/stackit_argus_instance.md b/docs/stackit_argus_instance.md new file mode 100644 index 000000000..f1549edff --- /dev/null +++ b/docs/stackit_argus_instance.md @@ -0,0 +1,32 @@ +## stackit argus instance + +Provides functionality for Argus instances + +### Synopsis + +Provides functionality for Argus instances. + +``` +stackit argus instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit argus instance" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit argus](./stackit_argus.md) - Provides functionality for Argus +* [stackit argus instance create](./stackit_argus_instance_create.md) - Creates an Argus instance + diff --git a/docs/stackit_argus_instance_create.md b/docs/stackit_argus_instance_create.md new file mode 100644 index 000000000..ad53baa75 --- /dev/null +++ b/docs/stackit_argus_instance_create.md @@ -0,0 +1,44 @@ +## stackit argus instance create + +Creates an Argus instance + +### Synopsis + +Creates an Argus instance. + +``` +stackit argus instance create [flags] +``` + +### Examples + +``` + Create an Argus instance with name "my-instance" and specify plan by name + $ stackit argus instance create --name my-instance --plan-name Monitoring-Starter-EU01 + + Create an Argus instance with name "my-instance" and specify plan by ID + $ stackit argus instance create --name my-instance --plan-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit argus instance create" + -n, --name string Instance name + --plan-id string Plan ID + --plan-name string Plan name +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances + diff --git a/internal/cmd/argus/argus.go b/internal/cmd/argus/argus.go index 67ec57e7e..7609c9cfb 100644 --- a/internal/cmd/argus/argus.go +++ b/internal/cmd/argus/argus.go @@ -1,6 +1,7 @@ package argus import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/argus/plans" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -22,4 +23,5 @@ func NewCmd() *cobra.Command { func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(plans.NewCmd()) + cmd.AddCommand(instance.NewCmd()) } diff --git a/internal/cmd/argus/instance/create/create.go b/internal/cmd/argus/instance/create/create.go new file mode 100644 index 000000000..352e67022 --- /dev/null +++ b/internal/cmd/argus/instance/create/create.go @@ -0,0 +1,191 @@ +package create + +import ( + "context" + "errors" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" + "github.com/stackitcloud/stackit-sdk-go/services/argus/wait" +) + +const ( + instanceNameFlag = "name" + planIdFlag = "plan-id" + planNameFlag = "plan-name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + PlanName string + + InstanceName *string + PlanId *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates an Argus instance", + Long: "Creates an Argus instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an Argus instance with name "my-instance" and specify plan by name`, + "$ stackit argus instance create --name my-instance --plan-name Monitoring-Starter-EU01"), + examples.NewExample( + `Create an Argus instance with name "my-instance" and specify plan by ID`, + "$ stackit argus instance create --name my-instance --plan-id xxx"), + ), + 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 + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create an Argus instance for project %q?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + var argusInvalidPlanError *cliErr.ArgusInvalidPlanError + if !errors.As(err, &argusInvalidPlanError) { + return fmt.Errorf("build Argus instance creation request: %w", err) + } + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Argus instance: %w", err) + } + instanceId := *resp.InstanceId + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Creating instance") + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, instanceId, model.ProjectId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for Argus instance creation: %w", err) + } + s.Stop() + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + cmd.Printf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, instanceId) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(instanceNameFlag, "n", "", "Instance name") + cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID") + cmd.Flags().String(planNameFlag, "", "Plan name") + + err := flags.MarkFlagsRequired(cmd, instanceNameFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + planId := flags.FlagToStringPointer(cmd, planIdFlag) + planName := flags.FlagToStringValue(cmd, planNameFlag) + + if planId == nil && (planName == "") { + return nil, &cliErr.ArgusInputPlanError{ + Cmd: cmd, + } + } + if planId != nil && (planName != "") { + return nil, &cliErr.ArgusInputPlanError{ + Cmd: cmd, + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceName: flags.FlagToStringPointer(cmd, instanceNameFlag), + PlanId: planId, + PlanName: planName, + }, nil +} + +type argusClient interface { + CreateInstance(ctx context.Context, projectId string) argus.ApiCreateInstanceRequest + ListPlansExecute(ctx context.Context, projectId string) (*argus.PlansResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient argusClient) (argus.ApiCreateInstanceRequest, error) { + req := apiClient.CreateInstance(ctx, model.ProjectId) + + var planId *string + var err error + + plans, err := apiClient.ListPlansExecute(ctx, model.ProjectId) + if err != nil { + return req, fmt.Errorf("get Argus plans: %w", err) + } + + if model.PlanId == nil { + planId, err = argusUtils.LoadPlanId(model.PlanName, plans) + if err != nil { + var argusInvalidPlanError *cliErr.ArgusInvalidPlanError + if !errors.As(err, &argusInvalidPlanError) { + return req, fmt.Errorf("load plan ID: %w", err) + } + return req, err + } + } else { + err := argusUtils.ValidatePlanId(*model.PlanId, plans) + if err != nil { + return req, err + } + planId = model.PlanId + } + + req = req.CreateInstancePayload(argus.CreateInstancePayload{ + Name: model.InstanceName, + PlanId: planId, + }) + return req, nil +} diff --git a/internal/cmd/argus/instance/create/create_test.go b/internal/cmd/argus/instance/create/create_test.go new file mode 100644 index 000000000..155d6d9fc --- /dev/null +++ b/internal/cmd/argus/instance/create/create_test.go @@ -0,0 +1,332 @@ +package create + +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/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &argus.APIClient{} + +type argusClientMocked struct { + returnError bool + listPlansReponse *argus.PlansResponse +} + +func (c *argusClientMocked) CreateInstance(ctx context.Context, projectId string) argus.ApiCreateInstanceRequest { + return testClient.CreateInstance(ctx, projectId) +} + +func (c *argusClientMocked) ListPlansExecute(_ context.Context, _ string) (*argus.PlansResponse, error) { + if c.returnError { + return nil, fmt.Errorf("list plans failed") + } + return c.listPlansReponse, nil +} + +var testProjectId = uuid.NewString() +var testPlanId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: "example-name", + planIdFlag: testPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceName: utils.Ptr("example-name"), + PlanId: utils.Ptr(testPlanId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiCreateInstanceRequest)) argus.ApiCreateInstanceRequest { + request := testClient.CreateInstance(testCtx, testProjectId) + request = request.CreateInstancePayload(argus.CreateInstancePayload{ + Name: utils.Ptr("example-name"), + PlanId: utils.Ptr(testPlanId), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePlansResponse(mods ...func(response *argus.PlansResponse)) *argus.PlansResponse { + response := &argus.PlansResponse{ + Plans: &[]argus.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testPlanId), + }, + }, + } + for _, mod := range mods { + mod(response) + } + return response +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with plan name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + delete(flagValues, planIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + model.PlanName = "plan-name" + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "zero values", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + planIdFlag: testPlanId, + instanceNameFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + PlanId: utils.Ptr(testPlanId), + InstanceName: utils.Ptr(""), + }, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid with plan ID and plan name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + }), + isValid: 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 TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest argus.ApiCreateInstanceRequest + getPlansFails bool + getPlansReponse *argus.PlansResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getPlansReponse: fixturePlansResponse(), + isValid: true, + }, + { + description: "use plan name", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + }, + ), + expectedRequest: fixtureRequest(), + getPlansReponse: fixturePlansResponse(), + isValid: true, + }, + { + description: "get plans fails", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + }, + ), + getPlansFails: true, + isValid: false, + }, + { + description: "plan name not found", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "non-existent-plan" + }, + ), + getPlansReponse: fixturePlansResponse(), + isValid: false, + }, + { + description: "plan id not found", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = utils.Ptr(uuid.NewString()) + }, + ), + getPlansReponse: fixturePlansResponse(), + isValid: false, + }, + { + description: "plan id, no instance name", + model: fixtureInputModel( + func(model *inputModel) { + model.InstanceName = nil + }, + ), + getPlansReponse: fixturePlansResponse(), + expectedRequest: testClient.CreateInstance(testCtx, testProjectId). + CreateInstancePayload(argus.CreateInstancePayload{PlanId: utils.Ptr(testPlanId)}), + isValid: true, + }, + { + description: "plan name, no instance name", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.InstanceName = nil + }, + ), + getPlansReponse: fixturePlansResponse(), + expectedRequest: testClient.CreateInstance(testCtx, testProjectId). + CreateInstancePayload(argus.CreateInstancePayload{PlanId: utils.Ptr(testPlanId)}), + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &argusClientMocked{ + returnError: tt.getPlansFails, + listPlansReponse: tt.getPlansReponse, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + if !tt.isValid { + t.Fatal("expected error but none thrown") + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/argus/instance/instance.go b/internal/cmd/argus/instance/instance.go new file mode 100644 index 000000000..8d8e6b451 --- /dev/null +++ b/internal/cmd/argus/instance/instance.go @@ -0,0 +1,25 @@ +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/create" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for Argus instances", + Long: "Provides functionality for Argus instances.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) +} diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index a9753f5e9..aeaafaca0 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -36,6 +36,24 @@ Please double check if they are correctly configured. For more details run: $ stackit auth activate-service-account -h` + ARGUS_INVALID_INPUT_PLAN = `the instance plan was not correctly provided. + +Either provide the plan ID: + $ %[1]s --plan-id [flags] + +or provide plan name: + $ %[1]s --plan-name [flags] + +For more details on the available plans, run: + $ stackit %[2]s plans` + + ARGUS_INVALID_PLAN = `the provided instance plan is not valid. + + %s + + For more details on the available plans, run: + $ stackit %s plans` + DSA_INVALID_INPUT_PLAN = `the instance plan was not correctly provided. Either provide the plan ID: @@ -121,6 +139,31 @@ func (e *ActivateServiceAccountError) Error() string { return FAILED_SERVICE_ACCOUNT_ACTIVATION } +type ArgusInputPlanError struct { + Cmd *cobra.Command + Args []string +} + +func (e *ArgusInputPlanError) Error() string { + fullCommandPath := e.Cmd.CommandPath() + if len(e.Args) > 0 { + fullCommandPath = fmt.Sprintf("%s %s", fullCommandPath, strings.Join(e.Args, " ")) + } + // Assumes a structure of the form "stackit " + service := e.Cmd.Parent().Parent().Use + + return fmt.Sprintf(ARGUS_INVALID_INPUT_PLAN, fullCommandPath, service) +} + +type ArgusInvalidPlanError struct { + Service string + Details string +} + +func (e *ArgusInvalidPlanError) Error() string { + return fmt.Sprintf(ARGUS_INVALID_PLAN, e.Details, e.Service) +} + type DSAInputPlanError struct { Cmd *cobra.Command Args []string diff --git a/internal/pkg/services/argus/utils/utils.go b/internal/pkg/services/argus/utils/utils.go new file mode 100644 index 000000000..b1e62e857 --- /dev/null +++ b/internal/pkg/services/argus/utils/utils.go @@ -0,0 +1,69 @@ +package utils + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +const ( + service = "argus" +) + +func ValidatePlanId(planId string, resp *argus.PlansResponse) error { + if resp == nil { + return fmt.Errorf("no Argus plans provided") + } + + for i := range *resp.Plans { + plan := (*resp.Plans)[i] + if plan.Id != nil && strings.EqualFold(*plan.Id, planId) { + return nil + } + } + + return &errors.ArgusInvalidPlanError{ + Service: service, + Details: fmt.Sprintf("You provided plan ID %q, which is invalid.", planId), + } +} + +func LoadPlanId(planName string, resp *argus.PlansResponse) (*string, error) { + availablePlanNames := "" + if resp == nil { + return nil, fmt.Errorf("no Argus plans provided") + } + + for i := range *resp.Plans { + plan := (*resp.Plans)[i] + if plan.Name == nil { + continue + } + if strings.EqualFold(*plan.Name, planName) && plan.Id != nil { + return plan.Id, nil + } + availablePlanNames = fmt.Sprintf("%s\n- %s", availablePlanNames, *plan.Name) + } + + details := fmt.Sprintf("You provided plan name %q, which is invalid. Available plan names are: %s", planName, availablePlanNames) + return nil, &errors.ArgusInvalidPlanError{ + Service: service, + Details: details, + } +} + +type ArgusClient interface { + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*argus.Instance, error) +} + +func GetInstanceName(ctx context.Context, apiClient ArgusClient, projectId, instanceId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) + if err != nil { + return "", fmt.Errorf("get Argus instance: %w", err) + } + return *resp.Name, nil +} diff --git a/internal/pkg/services/argus/utils/utils_test.go b/internal/pkg/services/argus/utils/utils_test.go new file mode 100644 index 000000000..a585661f0 --- /dev/null +++ b/internal/pkg/services/argus/utils/utils_test.go @@ -0,0 +1,222 @@ +package utils + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testPlanId = uuid.NewString() +) + +const ( + testInstanceName = "instance" + testPlanName = "Plan-Name-01" +) + +var testPlansResponse = argus.PlansResponse{ + Plans: &[]argus.Plan{ + { + Id: utils.Ptr(testPlanId), + Name: utils.Ptr(testPlanName), + }, + }, +} + +type argusClientMocked struct { + getInstanceFails bool + getInstanceResp *argus.Instance +} + +func (m *argusClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*argus.Instance, error) { + if m.getInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return m.getInstanceResp, nil +} +func TestGetInstanceName(t *testing.T) { + tests := []struct { + description string + getInstanceFails bool + getInstanceResp *argus.Instance + isValid bool + expectedOutput string + }{ + { + description: "base", + getInstanceResp: &argus.Instance{ + Name: utils.Ptr(testInstanceName), + }, + isValid: true, + expectedOutput: testInstanceName, + }, + { + description: "get instance fails", + getInstanceFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &argusClientMocked{ + getInstanceFails: tt.getInstanceFails, + getInstanceResp: tt.getInstanceResp, + } + + output, err := GetInstanceName(context.Background(), client, testProjectId, testInstanceId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + +func TestLoadPlanId(t *testing.T) { + tests := []struct { + description string + planName string + plansResponse *argus.PlansResponse + isValid bool + expectedOutput string + }{ + { + description: "base case", + planName: testPlanName, + plansResponse: utils.Ptr(testPlansResponse), + expectedOutput: testPlanId, + isValid: true, + }, + { + description: "different casing", + planName: strings.ToLower(testPlanName), + plansResponse: utils.Ptr(testPlansResponse), + expectedOutput: testPlanId, + isValid: true, + }, + { + description: "empty plan name", + planName: "", + plansResponse: utils.Ptr(testPlansResponse), + isValid: false, + }, + { + description: "unexisting plan name", + planName: "another plan name", + plansResponse: utils.Ptr(testPlansResponse), + isValid: false, + }, + { + description: "unable to fetch plans", + isValid: false, + }, + { + description: "no available plans", + planName: testPlanName, + plansResponse: &argus.PlansResponse{ + Plans: &[]argus.Plan{}, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := LoadPlanId(tt.planName, tt.plansResponse) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if *output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, *output) + } + }) + } +} + +func TestValidatePlanId(t *testing.T) { + tests := []struct { + description string + planId string + plansResponse *argus.PlansResponse + isValid bool + }{ + { + description: "base case", + planId: testPlanId, + plansResponse: utils.Ptr(testPlansResponse), + isValid: true, + }, + { + description: "different casing", + planId: strings.ToLower(testPlanId), + plansResponse: utils.Ptr(testPlansResponse), + isValid: true, + }, + { + description: "empty plan id", + planId: "", + plansResponse: utils.Ptr(testPlansResponse), + isValid: false, + }, + { + description: "unexisting plan id", + planId: uuid.NewString(), + plansResponse: utils.Ptr(testPlansResponse), + isValid: false, + }, + { + description: "unable to fetch plans", + isValid: false, + }, + { + description: "no available plans", + planId: testPlanId, + plansResponse: &argus.PlansResponse{ + Plans: &[]argus.Plan{}, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := ValidatePlanId(tt.planId, tt.plansResponse) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + }) + } +} From 39b02c177b270e7f88f916e75595765ed5fea0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Tue, 12 Mar 2024 17:11:56 +0000 Subject: [PATCH 3/6] Onboard Argus (instance): update command (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Onboard Argus Instance: update command * update command: support no plan info provided * update command: testing * fix linting * update command: handle empty instance name * update documentation * update command: handle empty name update and corner cases * fix linting * update command: improve error message * address comments in PR * Update internal/cmd/argus/instance/update/update.go Co-authored-by: João Palet * fix linting --------- Co-authored-by: João Palet --- docs/stackit_argus_instance.md | 1 + docs/stackit_argus_instance_update.md | 47 ++ .../cmd/argus/instance/create/create_test.go | 50 +- internal/cmd/argus/instance/instance.go | 2 + internal/cmd/argus/instance/update/update.go | 213 +++++++++ .../cmd/argus/instance/update/update_test.go | 443 ++++++++++++++++++ internal/pkg/services/argus/utils/utils.go | 6 +- .../pkg/services/argus/utils/utils_test.go | 10 +- 8 files changed, 739 insertions(+), 33 deletions(-) create mode 100644 docs/stackit_argus_instance_update.md create mode 100644 internal/cmd/argus/instance/update/update.go create mode 100644 internal/cmd/argus/instance/update/update_test.go diff --git a/docs/stackit_argus_instance.md b/docs/stackit_argus_instance.md index f1549edff..8c579a4d8 100644 --- a/docs/stackit_argus_instance.md +++ b/docs/stackit_argus_instance.md @@ -29,4 +29,5 @@ stackit argus instance [flags] * [stackit argus](./stackit_argus.md) - Provides functionality for Argus * [stackit argus instance create](./stackit_argus_instance_create.md) - Creates an Argus instance +* [stackit argus instance update](./stackit_argus_instance_update.md) - Updates an Argus instance diff --git a/docs/stackit_argus_instance_update.md b/docs/stackit_argus_instance_update.md new file mode 100644 index 000000000..9ce600d11 --- /dev/null +++ b/docs/stackit_argus_instance_update.md @@ -0,0 +1,47 @@ +## stackit argus instance update + +Updates an Argus instance + +### Synopsis + +Updates an Argus instance. + +``` +stackit argus instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the plan of an Argus instance with ID "xxx" by specifying the plan ID + $ stackit argus instance update xxx --plan-id yyy + + Update the plan of an Argus instance with ID "xxx" by specifying the plan name + $ stackit argus instance update xxx --plan-name yyy + + Update the name of an Argus instance with ID "xxx" + $ stackit argus instance update xxx --name new-instance-name +``` + +### Options + +``` + -h, --help Help for "stackit argus instance update" + --name string Instance name + --plan-id string Plan ID + --plan-name string Plan name +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances + diff --git a/internal/cmd/argus/instance/create/create_test.go b/internal/cmd/argus/instance/create/create_test.go index 155d6d9fc..e75827f86 100644 --- a/internal/cmd/argus/instance/create/create_test.go +++ b/internal/cmd/argus/instance/create/create_test.go @@ -22,8 +22,8 @@ var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &argus.APIClient{} type argusClientMocked struct { - returnError bool - listPlansReponse *argus.PlansResponse + returnError bool + listPlansResponse *argus.PlansResponse } func (c *argusClientMocked) CreateInstance(ctx context.Context, projectId string) argus.ApiCreateInstanceRequest { @@ -34,7 +34,7 @@ func (c *argusClientMocked) ListPlansExecute(_ context.Context, _ string) (*argu if c.returnError { return nil, fmt.Errorf("list plans failed") } - return c.listPlansReponse, nil + return c.listPlansResponse, nil } var testProjectId = uuid.NewString() @@ -216,19 +216,19 @@ func TestParseInput(t *testing.T) { func TestBuildRequest(t *testing.T) { tests := []struct { - description string - model *inputModel - expectedRequest argus.ApiCreateInstanceRequest - getPlansFails bool - getPlansReponse *argus.PlansResponse - isValid bool + description string + model *inputModel + expectedRequest argus.ApiCreateInstanceRequest + getPlansFails bool + getPlansResponse *argus.PlansResponse + isValid bool }{ { - description: "base", - model: fixtureInputModel(), - expectedRequest: fixtureRequest(), - getPlansReponse: fixturePlansResponse(), - isValid: true, + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getPlansResponse: fixturePlansResponse(), + isValid: true, }, { description: "use plan name", @@ -238,9 +238,9 @@ func TestBuildRequest(t *testing.T) { model.PlanName = "example-plan-name" }, ), - expectedRequest: fixtureRequest(), - getPlansReponse: fixturePlansResponse(), - isValid: true, + expectedRequest: fixtureRequest(), + getPlansResponse: fixturePlansResponse(), + isValid: true, }, { description: "get plans fails", @@ -261,8 +261,8 @@ func TestBuildRequest(t *testing.T) { model.PlanName = "non-existent-plan" }, ), - getPlansReponse: fixturePlansResponse(), - isValid: false, + getPlansResponse: fixturePlansResponse(), + isValid: false, }, { description: "plan id not found", @@ -271,8 +271,8 @@ func TestBuildRequest(t *testing.T) { model.PlanId = utils.Ptr(uuid.NewString()) }, ), - getPlansReponse: fixturePlansResponse(), - isValid: false, + getPlansResponse: fixturePlansResponse(), + isValid: false, }, { description: "plan id, no instance name", @@ -281,7 +281,7 @@ func TestBuildRequest(t *testing.T) { model.InstanceName = nil }, ), - getPlansReponse: fixturePlansResponse(), + getPlansResponse: fixturePlansResponse(), expectedRequest: testClient.CreateInstance(testCtx, testProjectId). CreateInstancePayload(argus.CreateInstancePayload{PlanId: utils.Ptr(testPlanId)}), isValid: true, @@ -295,7 +295,7 @@ func TestBuildRequest(t *testing.T) { model.InstanceName = nil }, ), - getPlansReponse: fixturePlansResponse(), + getPlansResponse: fixturePlansResponse(), expectedRequest: testClient.CreateInstance(testCtx, testProjectId). CreateInstancePayload(argus.CreateInstancePayload{PlanId: utils.Ptr(testPlanId)}), isValid: true, @@ -305,8 +305,8 @@ func TestBuildRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { client := &argusClientMocked{ - returnError: tt.getPlansFails, - listPlansReponse: tt.getPlansReponse, + returnError: tt.getPlansFails, + listPlansResponse: tt.getPlansResponse, } request, err := buildRequest(testCtx, tt.model, client) if err != nil { diff --git a/internal/cmd/argus/instance/instance.go b/internal/cmd/argus/instance/instance.go index 8d8e6b451..82b8054a8 100644 --- a/internal/cmd/argus/instance/instance.go +++ b/internal/cmd/argus/instance/instance.go @@ -2,6 +2,7 @@ package instance import ( "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -22,4 +23,5 @@ func NewCmd() *cobra.Command { func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(update.NewCmd()) } diff --git a/internal/cmd/argus/instance/update/update.go b/internal/cmd/argus/instance/update/update.go new file mode 100644 index 000000000..3bd4cd774 --- /dev/null +++ b/internal/cmd/argus/instance/update/update.go @@ -0,0 +1,213 @@ +package update + +import ( + "context" + "errors" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "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/services/argus/client" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" + "github.com/stackitcloud/stackit-sdk-go/services/argus/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + instanceNameFlag = "name" + planIdFlag = "plan-id" + planNameFlag = "plan-name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + PlanName string + + InstanceName *string + PlanId *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", instanceIdArg), + Short: "Updates an Argus instance", + Long: "Updates an Argus instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the plan of an Argus instance with ID "xxx" by specifying the plan ID`, + "$ stackit argus instance update xxx --plan-id yyy"), + examples.NewExample( + `Update the plan of an Argus instance with ID "xxx" by specifying the plan name`, + "$ stackit argus instance update xxx --plan-name yyy"), + examples.NewExample( + `Update the name of an Argus instance with ID "xxx"`, + "$ stackit argus instance update xxx --name new-instance-name"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) + if err != nil || instanceLabel == "" { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + var argusInvalidPlanError *cliErr.ArgusInvalidPlanError + if !errors.As(err, &argusInvalidPlanError) { + return fmt.Errorf("build Argus instance update request: %w", err) + } + return err + } + + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update Argus instance: %w", err) + } + instanceId := model.InstanceId + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Updating instance") + _, err = wait.UpdateInstanceWaitHandler(ctx, apiClient, instanceId, model.ProjectId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for Argus instance update: %w", err) + } + s.Stop() + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + cmd.Printf("%s instance %q\n", operationState, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID") + cmd.Flags().String(planNameFlag, "", "Plan name") + cmd.Flags().String(instanceNameFlag, "", "Instance name") +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + planId := flags.FlagToStringPointer(cmd, planIdFlag) + planName := flags.FlagToStringValue(cmd, planNameFlag) + instanceName := flags.FlagToStringPointer(cmd, instanceNameFlag) + + if planId != nil && (planName != "") { + return nil, &cliErr.ArgusInputPlanError{ + Cmd: cmd, + } + } + + if planId == nil && planName == "" && instanceName == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + PlanId: planId, + PlanName: planName, + InstanceName: instanceName, + }, nil +} + +type argusClient interface { + UpdateInstance(ctx context.Context, instanceId, projectId string) argus.ApiUpdateInstanceRequest + ListPlansExecute(ctx context.Context, projectId string) (*argus.PlansResponse, error) + GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient argusClient) (argus.ApiUpdateInstanceRequest, error) { + req := apiClient.UpdateInstance(ctx, model.InstanceId, model.ProjectId) + + var err error + + plans, err := apiClient.ListPlansExecute(ctx, model.ProjectId) + if err != nil { + return req, fmt.Errorf("get Argus plans: %w", err) + } + + currentInstance, err := apiClient.GetInstanceExecute(ctx, model.InstanceId, model.ProjectId) + if err != nil { + return req, fmt.Errorf("get Argus instance: %w", err) + } + + payload := argus.UpdateInstancePayload{ + PlanId: currentInstance.PlanId, + Name: currentInstance.Name, + } + + if model.PlanId == nil && model.PlanName != "" { + payload.PlanId, err = argusUtils.LoadPlanId(model.PlanName, plans) + if err != nil { + var argusInvalidPlanError *cliErr.ArgusInvalidPlanError + if !errors.As(err, &argusInvalidPlanError) { + return req, fmt.Errorf("load plan ID: %w", err) + } + return req, err + } + } else if model.PlanId != nil && model.PlanName == "" { + err := argusUtils.ValidatePlanId(*model.PlanId, plans) + if err != nil { + var argusInvalidPlanError *cliErr.ArgusInvalidPlanError + if !errors.As(err, &argusInvalidPlanError) { + return req, fmt.Errorf("validate plan ID: %w", err) + } + return req, err + } + payload.PlanId = model.PlanId + } + + if model.InstanceName != nil { + payload.Name = model.InstanceName + } + + req = req.UpdateInstancePayload(payload) + return req, nil +} diff --git a/internal/cmd/argus/instance/update/update_test.go b/internal/cmd/argus/instance/update/update_test.go new file mode 100644 index 000000000..5a2ab1d33 --- /dev/null +++ b/internal/cmd/argus/instance/update/update_test.go @@ -0,0 +1,443 @@ +package update + +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/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &argus.APIClient{} + +type argusClientMocked struct { + listPlansError bool + listPlansResponse *argus.PlansResponse + getInstanceError bool + getInstanceResponse *argus.GetInstanceResponse +} + +func (c *argusClientMocked) UpdateInstance(ctx context.Context, instanceId, projectId string) argus.ApiUpdateInstanceRequest { + return testClient.UpdateInstance(ctx, instanceId, projectId) +} + +func (c *argusClientMocked) ListPlansExecute(_ context.Context, _ string) (*argus.PlansResponse, error) { + if c.listPlansError { + return nil, fmt.Errorf("list flavors failed") + } + return c.listPlansResponse, nil +} + +func (c *argusClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*argus.GetInstanceResponse, error) { + if c.getInstanceError { + return nil, fmt.Errorf("get instance failed") + } + return c.getInstanceResponse, nil +} + +const ( + testInstanceName = "example-instance-name" +) + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testPlanId = uuid.NewString() + testNewPlanId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + planIdFlag: testNewPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + PlanId: utils.Ptr(testNewPlanId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiUpdateInstanceRequest)) argus.ApiUpdateInstanceRequest { + request := testClient.UpdateInstance(testCtx, testInstanceId, testProjectId) + request = request.UpdateInstancePayload(argus.UpdateInstancePayload{ + PlanId: utils.Ptr(testNewPlanId), + Name: utils.Ptr(testInstanceName), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePlansResponse(mods ...func(response *argus.PlansResponse)) *argus.PlansResponse { + response := &argus.PlansResponse{ + Plans: &[]argus.Plan{ + { + Name: utils.Ptr("example-plan-name"), + Id: utils.Ptr(testNewPlanId), + }, + }, + } + for _, mod := range mods { + mod(response) + } + return response +} + +func fixtureGetInstanceResponse(mods ...func(response *argus.GetInstanceResponse)) *argus.GetInstanceResponse { + response := &argus.GetInstanceResponse{ + PlanId: utils.Ptr(testPlanId), + Name: utils.Ptr(testInstanceName), + } + for _, mod := range mods { + mod(response) + } + return response +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with plan name", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + delete(flagValues, planIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + model.PlanName = "plan-name" + }), + }, + { + description: "with new instance name", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceNameFlag] = "new-instance-name" + delete(flagValues, planIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + model.PlanName = "" + model.InstanceName = utils.Ptr("new-instance-name") + }), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid with plan ID and plan name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + }), + isValid: 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + 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 TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest argus.ApiUpdateInstanceRequest + getPlansFails bool + getPlansResponse *argus.PlansResponse + getInstanceFails bool + getInstanceResponse *argus.GetInstanceResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getPlansResponse: fixturePlansResponse(), + getInstanceResponse: fixtureGetInstanceResponse(), + isValid: true, + }, + { + description: "use plan name", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + }, + ), + expectedRequest: fixtureRequest(), + getPlansResponse: fixturePlansResponse(), + getInstanceResponse: fixtureGetInstanceResponse(), + isValid: true, + }, + { + description: "get plans fails", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + }, + ), + getPlansFails: true, + isValid: false, + }, + { + description: "plan name not found", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "non-existent-plan" + }, + ), + getPlansResponse: fixturePlansResponse(), + getInstanceResponse: fixtureGetInstanceResponse(), + isValid: false, + }, + { + description: "plan id not found", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = utils.Ptr(uuid.NewString()) + }, + ), + getPlansResponse: fixturePlansResponse(), + getInstanceResponse: fixtureGetInstanceResponse(), + isValid: false, + }, + { + description: "plan id, no instance name", + model: fixtureInputModel( + func(model *inputModel) { + model.InstanceName = nil + }, + ), + getPlansResponse: fixturePlansResponse(), + getInstanceResponse: fixtureGetInstanceResponse(), + expectedRequest: fixtureRequest(), + isValid: true, + }, + { + description: "plan name, no instance name", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.InstanceName = nil + }, + ), + getPlansResponse: fixturePlansResponse(), + getInstanceResponse: fixtureGetInstanceResponse(), + expectedRequest: fixtureRequest(), + isValid: true, + }, + { + description: "instance name, no plan info", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "" + model.InstanceName = utils.Ptr("new-instance-name") + }, + ), + getInstanceResponse: fixtureGetInstanceResponse(), + expectedRequest: fixtureRequest(). + UpdateInstancePayload(argus.UpdateInstancePayload{ + PlanId: utils.Ptr(testPlanId), + Name: utils.Ptr("new-instance-name"), + }), + isValid: true, + }, + { + description: "instance name, no plan info, get instance fails", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "" + model.InstanceName = utils.Ptr("new-instance-name") + }, + ), + getInstanceFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &argusClientMocked{ + listPlansError: tt.getPlansFails, + listPlansResponse: tt.getPlansResponse, + getInstanceError: tt.getInstanceFails, + getInstanceResponse: tt.getInstanceResponse, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + if !tt.isValid { + t.Fatal("expected error but none thrown") + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/pkg/services/argus/utils/utils.go b/internal/pkg/services/argus/utils/utils.go index b1e62e857..e530c187b 100644 --- a/internal/pkg/services/argus/utils/utils.go +++ b/internal/pkg/services/argus/utils/utils.go @@ -57,11 +57,11 @@ func LoadPlanId(planName string, resp *argus.PlansResponse) (*string, error) { } type ArgusClient interface { - GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*argus.Instance, error) + GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) } -func GetInstanceName(ctx context.Context, apiClient ArgusClient, projectId, instanceId string) (string, error) { - resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) +func GetInstanceName(ctx context.Context, apiClient ArgusClient, instanceId, projectId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, instanceId, projectId) if err != nil { return "", fmt.Errorf("get Argus instance: %w", err) } diff --git a/internal/pkg/services/argus/utils/utils_test.go b/internal/pkg/services/argus/utils/utils_test.go index a585661f0..8adf5de1b 100644 --- a/internal/pkg/services/argus/utils/utils_test.go +++ b/internal/pkg/services/argus/utils/utils_test.go @@ -34,10 +34,10 @@ var testPlansResponse = argus.PlansResponse{ type argusClientMocked struct { getInstanceFails bool - getInstanceResp *argus.Instance + getInstanceResp *argus.GetInstanceResponse } -func (m *argusClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*argus.Instance, error) { +func (m *argusClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*argus.GetInstanceResponse, error) { if m.getInstanceFails { return nil, fmt.Errorf("could not get instance") } @@ -47,13 +47,13 @@ func TestGetInstanceName(t *testing.T) { tests := []struct { description string getInstanceFails bool - getInstanceResp *argus.Instance + getInstanceResp *argus.GetInstanceResponse isValid bool expectedOutput string }{ { description: "base", - getInstanceResp: &argus.Instance{ + getInstanceResp: &argus.GetInstanceResponse{ Name: utils.Ptr(testInstanceName), }, isValid: true, @@ -73,7 +73,7 @@ func TestGetInstanceName(t *testing.T) { getInstanceResp: tt.getInstanceResp, } - output, err := GetInstanceName(context.Background(), client, testProjectId, testInstanceId) + output, err := GetInstanceName(context.Background(), client, testInstanceId, testProjectId) if tt.isValid && err != nil { t.Errorf("failed on valid input") From 3623ada6ee433f3dc59d03056d34cceaf6e9dfc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Wed, 13 Mar 2024 12:53:54 +0000 Subject: [PATCH 4/6] Onboard Argus (instance): delete command (#151) * Onboard Argus Instance: delete command * delete command: add docs --- docs/stackit_argus_instance.md | 1 + docs/stackit_argus_instance_delete.md | 38 ++++ internal/cmd/argus/instance/delete/delete.go | 114 ++++++++++ .../cmd/argus/instance/delete/delete_test.go | 215 ++++++++++++++++++ internal/cmd/argus/instance/instance.go | 2 + 5 files changed, 370 insertions(+) create mode 100644 docs/stackit_argus_instance_delete.md create mode 100644 internal/cmd/argus/instance/delete/delete.go create mode 100644 internal/cmd/argus/instance/delete/delete_test.go diff --git a/docs/stackit_argus_instance.md b/docs/stackit_argus_instance.md index 8c579a4d8..660143c76 100644 --- a/docs/stackit_argus_instance.md +++ b/docs/stackit_argus_instance.md @@ -29,5 +29,6 @@ stackit argus instance [flags] * [stackit argus](./stackit_argus.md) - Provides functionality for Argus * [stackit argus instance create](./stackit_argus_instance_create.md) - Creates an Argus instance +* [stackit argus instance delete](./stackit_argus_instance_delete.md) - Deletes an Argus instance * [stackit argus instance update](./stackit_argus_instance_update.md) - Updates an Argus instance diff --git a/docs/stackit_argus_instance_delete.md b/docs/stackit_argus_instance_delete.md new file mode 100644 index 000000000..c49d5ec41 --- /dev/null +++ b/docs/stackit_argus_instance_delete.md @@ -0,0 +1,38 @@ +## stackit argus instance delete + +Deletes an Argus instance + +### Synopsis + +Deletes an Argus instance. + +``` +stackit argus instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete an Argus instance with ID "xxx" + $ stackit argus instance delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit argus instance delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances + diff --git a/internal/cmd/argus/instance/delete/delete.go b/internal/cmd/argus/instance/delete/delete.go new file mode 100644 index 000000000..345fc7758 --- /dev/null +++ b/internal/cmd/argus/instance/delete/delete.go @@ -0,0 +1,114 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" + "github.com/stackitcloud/stackit-sdk-go/services/argus/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Deletes an Argus instance", + Long: "Deletes an Argus instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an Argus instance with ID "xxx"`, + "$ stackit argus instance delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete Argus instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Deleting instance") + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.InstanceId, model.ProjectId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for Argus instance deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + cmd.Printf("%s instance %q\n", operationState, instanceLabel) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiDeleteInstanceRequest { + req := apiClient.DeleteInstance(ctx, model.InstanceId, model.ProjectId) + return req +} diff --git a/internal/cmd/argus/instance/delete/delete_test.go b/internal/cmd/argus/instance/delete/delete_test.go new file mode 100644 index 000000000..5508651cb --- /dev/null +++ b/internal/cmd/argus/instance/delete/delete_test.go @@ -0,0 +1,215 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &argus.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiDeleteInstanceRequest)) argus.ApiDeleteInstanceRequest { + request := testClient.DeleteInstance(testCtx, testInstanceId, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %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 TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest argus.ApiDeleteInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/argus/instance/instance.go b/internal/cmd/argus/instance/instance.go index 82b8054a8..e6b3717fc 100644 --- a/internal/cmd/argus/instance/instance.go +++ b/internal/cmd/argus/instance/instance.go @@ -2,6 +2,7 @@ package instance import ( "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -24,4 +25,5 @@ func NewCmd() *cobra.Command { func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(create.NewCmd()) cmd.AddCommand(update.NewCmd()) + cmd.AddCommand(delete.NewCmd()) } From f0fc4242fe30845fe1da38ed15f165d7ad6f0d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Fri, 15 Mar 2024 13:18:59 +0100 Subject: [PATCH 5/6] Onboard Argus (instance): list and describe commands (#155) * Onboard Argus Instance: list command * Onboard Argus Instance: describe command * describe command: dont display name if empty * describe command: update output * describe command: remove additional check * describe command: add grafana url * add docs and address comments --- docs/stackit_argus_instance.md | 2 + docs/stackit_argus_instance_describe.md | 41 ++++ docs/stackit_argus_instance_list.md | 45 ++++ .../cmd/argus/instance/describe/describe.go | 127 +++++++++++ .../argus/instance/describe/describe_test.go | 215 ++++++++++++++++++ internal/cmd/argus/instance/instance.go | 4 + internal/cmd/argus/instance/list/list.go | 142 ++++++++++++ internal/cmd/argus/instance/list/list_test.go | 185 +++++++++++++++ 8 files changed, 761 insertions(+) create mode 100644 docs/stackit_argus_instance_describe.md create mode 100644 docs/stackit_argus_instance_list.md create mode 100644 internal/cmd/argus/instance/describe/describe.go create mode 100644 internal/cmd/argus/instance/describe/describe_test.go create mode 100644 internal/cmd/argus/instance/list/list.go create mode 100644 internal/cmd/argus/instance/list/list_test.go diff --git a/docs/stackit_argus_instance.md b/docs/stackit_argus_instance.md index 660143c76..48adf9968 100644 --- a/docs/stackit_argus_instance.md +++ b/docs/stackit_argus_instance.md @@ -30,5 +30,7 @@ stackit argus instance [flags] * [stackit argus](./stackit_argus.md) - Provides functionality for Argus * [stackit argus instance create](./stackit_argus_instance_create.md) - Creates an Argus instance * [stackit argus instance delete](./stackit_argus_instance_delete.md) - Deletes an Argus instance +* [stackit argus instance describe](./stackit_argus_instance_describe.md) - Shows details of an Argus instance +* [stackit argus instance list](./stackit_argus_instance_list.md) - Lists all Argus instances * [stackit argus instance update](./stackit_argus_instance_update.md) - Updates an Argus instance diff --git a/docs/stackit_argus_instance_describe.md b/docs/stackit_argus_instance_describe.md new file mode 100644 index 000000000..a32807df0 --- /dev/null +++ b/docs/stackit_argus_instance_describe.md @@ -0,0 +1,41 @@ +## stackit argus instance describe + +Shows details of an Argus instance + +### Synopsis + +Shows details of an Argus instance. + +``` +stackit argus instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of an Argus instance with ID "xxx" + $ stackit argus instance describe xxx + + Get details of an Argus instance with ID "xxx" in a table format + $ stackit argus instance describe xxx --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit argus instance describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances + diff --git a/docs/stackit_argus_instance_list.md b/docs/stackit_argus_instance_list.md new file mode 100644 index 000000000..4cdbdc76b --- /dev/null +++ b/docs/stackit_argus_instance_list.md @@ -0,0 +1,45 @@ +## stackit argus instance list + +Lists all Argus instances + +### Synopsis + +Lists all Argus instances. + +``` +stackit argus instance list [flags] +``` + +### Examples + +``` + List all Argus instances + $ stackit argus instance list + + List all Argus instances in JSON format + $ stackit argus instance list --output-format json + + List up to 10 Argus instances + $ stackit argus instance list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit argus instance list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances + diff --git a/internal/cmd/argus/instance/describe/describe.go b/internal/cmd/argus/instance/describe/describe.go new file mode 100644 index 000000000..a87826061 --- /dev/null +++ b/internal/cmd/argus/instance/describe/describe.go @@ -0,0 +1,127 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Shows details of an Argus instance", + Long: "Shows details of an Argus instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an Argus instance with ID "xxx"`, + "$ stackit argus instance describe xxx"), + examples.NewExample( + `Get details of an Argus instance with ID "xxx" in a table format`, + "$ stackit argus instance describe xxx --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read Argus instance: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiGetInstanceRequest { + req := apiClient.GetInstance(ctx, model.InstanceId, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instance *argus.GetInstanceResponse) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + + table := tables.NewTable() + table.AddRow("ID", *instance.Id) + table.AddSeparator() + table.AddRow("NAME", *instance.Name) + table.AddSeparator() + table.AddRow("STATUS", *instance.Status) + table.AddSeparator() + table.AddRow("PLAN NAME", *instance.PlanName) + table.AddSeparator() + table.AddRow("METRIC SAMPLES (PER MIN)", *instance.Instance.Plan.TotalMetricSamples) + table.AddSeparator() + table.AddRow("LOGS (GB)", *instance.Instance.Plan.LogsStorage) + table.AddSeparator() + table.AddRow("TRACES (GB)", *instance.Instance.Plan.TracesStorage) + table.AddSeparator() + table.AddRow("NOTIFICATION RULES", *instance.Instance.Plan.AlertRules) + table.AddSeparator() + table.AddRow("GRAFANA USERS", *instance.Instance.Plan.GrafanaGlobalUsers) + table.AddSeparator() + table.AddRow("GRAFANA URL", *instance.Instance.GrafanaUrl) + table.AddSeparator() + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(instance, "", " ") + if err != nil { + return fmt.Errorf("marshal Argus instance: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/argus/instance/describe/describe_test.go b/internal/cmd/argus/instance/describe/describe_test.go new file mode 100644 index 000000000..0799f8989 --- /dev/null +++ b/internal/cmd/argus/instance/describe/describe_test.go @@ -0,0 +1,215 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &argus.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiGetInstanceRequest)) argus.ApiGetInstanceRequest { + request := testClient.GetInstance(testCtx, testInstanceId, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %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 TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest argus.ApiGetInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/argus/instance/instance.go b/internal/cmd/argus/instance/instance.go index e6b3717fc..629c43ad9 100644 --- a/internal/cmd/argus/instance/instance.go +++ b/internal/cmd/argus/instance/instance.go @@ -3,6 +3,8 @@ package instance import ( "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/create" "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -26,4 +28,6 @@ func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(create.NewCmd()) cmd.AddCommand(update.NewCmd()) cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(describe.NewCmd()) } diff --git a/internal/cmd/argus/instance/list/list.go b/internal/cmd/argus/instance/list/list.go new file mode 100644 index 000000000..702172ed2 --- /dev/null +++ b/internal/cmd/argus/instance/list/list.go @@ -0,0 +1,142 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Argus instances", + Long: "Lists all Argus instances.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Argus instances`, + "$ stackit argus instance list"), + examples.NewExample( + `List all Argus instances in JSON format`, + "$ stackit argus instance list --output-format json"), + examples.NewExample( + `List up to 10 Argus instances`, + "$ stackit argus instance list --limit 10"), + ), + 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 + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Argus instances: %w", err) + } + instances := *resp.Instances + if len(instances) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No instances found for project %q\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(instances) > int(*model.Limit) { + instances = instances[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, instances) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiListInstancesRequest { + req := apiClient.ListInstances(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instances []argus.ProjectInstanceFull) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(instances, "", " ") + if err != nil { + return fmt.Errorf("marshal Argus instance list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "PLAN", "STATUS") + for i := range instances { + instance := instances[i] + table.AddRow(*instance.Id, *instance.Name, *instance.PlanName, *instance.Status) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/argus/instance/list/list_test.go b/internal/cmd/argus/instance/list/list_test.go new file mode 100644 index 000000000..1f40779d7 --- /dev/null +++ b/internal/cmd/argus/instance/list/list_test.go @@ -0,0 +1,185 @@ +package list + +import ( + "context" + "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/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &argus.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiListInstancesRequest)) argus.ApiListInstancesRequest { + request := testClient.ListInstances(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + 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 TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest argus.ApiListInstancesRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} From b7f14084a04acbdc8f7d2bf1c8d6a55fff3d3506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Fri, 15 Mar 2024 18:30:05 +0100 Subject: [PATCH 6/6] update command: improve examples, add name shorthand flag (#160) --- docs/stackit_argus_instance_update.md | 4 ++-- internal/cmd/argus/instance/update/update.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/stackit_argus_instance_update.md b/docs/stackit_argus_instance_update.md index 9ce600d11..8fcae4c69 100644 --- a/docs/stackit_argus_instance_update.md +++ b/docs/stackit_argus_instance_update.md @@ -17,7 +17,7 @@ stackit argus instance update INSTANCE_ID [flags] $ stackit argus instance update xxx --plan-id yyy Update the plan of an Argus instance with ID "xxx" by specifying the plan name - $ stackit argus instance update xxx --plan-name yyy + $ stackit argus instance update xxx --plan-name Frontend-Starter-EU01 Update the name of an Argus instance with ID "xxx" $ stackit argus instance update xxx --name new-instance-name @@ -27,7 +27,7 @@ stackit argus instance update INSTANCE_ID [flags] ``` -h, --help Help for "stackit argus instance update" - --name string Instance name + -n, --name string Instance name --plan-id string Plan ID --plan-name string Plan name ``` diff --git a/internal/cmd/argus/instance/update/update.go b/internal/cmd/argus/instance/update/update.go index 3bd4cd774..a1d807dad 100644 --- a/internal/cmd/argus/instance/update/update.go +++ b/internal/cmd/argus/instance/update/update.go @@ -50,7 +50,7 @@ func NewCmd() *cobra.Command { "$ stackit argus instance update xxx --plan-id yyy"), examples.NewExample( `Update the plan of an Argus instance with ID "xxx" by specifying the plan name`, - "$ stackit argus instance update xxx --plan-name yyy"), + "$ stackit argus instance update xxx --plan-name Frontend-Starter-EU01"), examples.NewExample( `Update the name of an Argus instance with ID "xxx"`, "$ stackit argus instance update xxx --name new-instance-name"), @@ -123,7 +123,7 @@ func NewCmd() *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID") cmd.Flags().String(planNameFlag, "", "Plan name") - cmd.Flags().String(instanceNameFlag, "", "Instance name") + cmd.Flags().StringP(instanceNameFlag, "n", "", "Instance name") } func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) {