diff --git a/docs/stackit_beta_alb.md b/docs/stackit_beta_alb.md index eaa733d1c..a4e8d9866 100644 --- a/docs/stackit_beta_alb.md +++ b/docs/stackit_beta_alb.md @@ -34,7 +34,10 @@ stackit beta alb [flags] * [stackit beta alb delete](./stackit_beta_alb_delete.md) - Deletes an application loadbalancer * [stackit beta alb describe](./stackit_beta_alb_describe.md) - Describes an application loadbalancer * [stackit beta alb list](./stackit_beta_alb_list.md) - Lists albs +* [stackit beta alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials +* [stackit beta alb plans](./stackit_beta_alb_plans.md) - Lists the application load balancer plans * [stackit beta alb pool](./stackit_beta_alb_pool.md) - Manages target pools for application loadbalancers +* [stackit beta alb quotas](./stackit_beta_alb_quotas.md) - Shows the application load balancer quotas * [stackit beta alb template](./stackit_beta_alb_template.md) - creates configuration templates to use for resource creation * [stackit beta alb update](./stackit_beta_alb_update.md) - Updates an application loadbalancer diff --git a/docs/stackit_beta_alb_observability-credentials.md b/docs/stackit_beta_alb_observability-credentials.md new file mode 100644 index 000000000..f704d001a --- /dev/null +++ b/docs/stackit_beta_alb_observability-credentials.md @@ -0,0 +1,38 @@ +## stackit beta alb observability-credentials + +Provides functionality for application loadbalancer credentials + +### Synopsis + +Provides functionality for application loadbalancer credentials + +``` +stackit beta alb observability-credentials [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta alb observability-credentials" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers +* [stackit beta alb observability-credentials add](./stackit_beta_alb_observability-credentials_add.md) - Adds observability credentials to an application load balancer +* [stackit beta alb observability-credentials delete](./stackit_beta_alb_observability-credentials_delete.md) - Deletes credentials +* [stackit beta alb observability-credentials describe](./stackit_beta_alb_observability-credentials_describe.md) - Describes observability credentials for the Application Load Balancer +* [stackit beta alb observability-credentials list](./stackit_beta_alb_observability-credentials_list.md) - Lists all credentials +* [stackit beta alb observability-credentials update](./stackit_beta_alb_observability-credentials_update.md) - Update credentials + diff --git a/docs/stackit_beta_alb_observability-credentials_add.md b/docs/stackit_beta_alb_observability-credentials_add.md new file mode 100644 index 000000000..9e4e544cc --- /dev/null +++ b/docs/stackit_beta_alb_observability-credentials_add.md @@ -0,0 +1,43 @@ +## stackit beta alb observability-credentials add + +Adds observability credentials to an application load balancer + +### Synopsis + +Adds observability credentials (username and password) to an application load balancer. The credentials can be for Observability or another monitoring tool. + +``` +stackit beta alb observability-credentials add [flags] +``` + +### Examples + +``` + Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag + $ stackit beta alb observability-credentials add --username xxx --password @./password.txt --display-name yyy +``` + +### Options + +``` + -d, --displayname string Displayname for the credentials + -h, --help Help for "stackit beta alb observability-credentials add" + --password string Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt). + -u, --username string Username for the credentials +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials + diff --git a/docs/stackit_beta_alb_observability-credentials_delete.md b/docs/stackit_beta_alb_observability-credentials_delete.md new file mode 100644 index 000000000..8cdbdde27 --- /dev/null +++ b/docs/stackit_beta_alb_observability-credentials_delete.md @@ -0,0 +1,40 @@ +## stackit beta alb observability-credentials delete + +Deletes credentials + +### Synopsis + +Deletes credentials. + +``` +stackit beta alb observability-credentials delete CREDENTIAL_REF [flags] +``` + +### Examples + +``` + Delete credential with name "credential-12345" + $ stackit beta alb observability-credentials delete credential-12345 +``` + +### Options + +``` + -h, --help Help for "stackit beta alb observability-credentials 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials + diff --git a/docs/stackit_beta_alb_observability-credentials_describe.md b/docs/stackit_beta_alb_observability-credentials_describe.md new file mode 100644 index 000000000..9eafb8d93 --- /dev/null +++ b/docs/stackit_beta_alb_observability-credentials_describe.md @@ -0,0 +1,40 @@ +## stackit beta alb observability-credentials describe + +Describes observability credentials for the Application Load Balancer + +### Synopsis + +Describes observability credentials for the Application Load Balancer. + +``` +stackit beta alb observability-credentials describe CREDENTIAL_REF [flags] +``` + +### Examples + +``` + Get details about credentials with name "credential-12345" + $ stackit beta alb observability-credentials describe credential-12345 +``` + +### Options + +``` + -h, --help Help for "stackit beta alb observability-credentials 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials + diff --git a/docs/stackit_beta_alb_observability-credentials_list.md b/docs/stackit_beta_alb_observability-credentials_list.md new file mode 100644 index 000000000..9a71757b2 --- /dev/null +++ b/docs/stackit_beta_alb_observability-credentials_list.md @@ -0,0 +1,47 @@ +## stackit beta alb observability-credentials list + +Lists all credentials + +### Synopsis + +Lists all credentials. + +``` +stackit beta alb observability-credentials list [flags] +``` + +### Examples + +``` + Lists all credentials + $ stackit beta alb observability-credentials list + + Lists all credentials in JSON format + $ stackit beta alb observability-credentials list --output-format json + + Lists up to 10 credentials + $ stackit beta alb observability-credentials list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta alb observability-credentials list" + --limit int Number of credentials 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials + diff --git a/docs/stackit_beta_alb_observability-credentials_update.md b/docs/stackit_beta_alb_observability-credentials_update.md new file mode 100644 index 000000000..61994726d --- /dev/null +++ b/docs/stackit_beta_alb_observability-credentials_update.md @@ -0,0 +1,43 @@ +## stackit beta alb observability-credentials update + +Update credentials + +### Synopsis + +Update credentials. + +``` +stackit beta alb observability-credentials update CREDENTIAL_REF_ARG [flags] +``` + +### Examples + +``` + Update the password of observability credentials of Application Load Balancer with credentials reference "credentials-xxx", by providing the path to a file with the new password as flag + $ stackit beta alb observability-credentials update credentials-xxx --username user1 --displayname user1 --password @./new-password.txt +``` + +### Options + +``` + -d, --displayname string Displayname for the credentials + -h, --help Help for "stackit beta alb observability-credentials update" + --password string Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt). + -u, --username string Username for the credentials +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials + diff --git a/docs/stackit_beta_alb_plans.md b/docs/stackit_beta_alb_plans.md new file mode 100644 index 000000000..3e46e1185 --- /dev/null +++ b/docs/stackit_beta_alb_plans.md @@ -0,0 +1,40 @@ +## stackit beta alb plans + +Lists the application load balancer plans + +### Synopsis + +Lists the available application load balancer plans. + +``` +stackit beta alb plans [flags] +``` + +### Examples + +``` + List all application load balancer plans + $ stackit beta alb plans +``` + +### Options + +``` + -h, --help Help for "stackit beta alb plans" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers + diff --git a/docs/stackit_beta_alb_quotas.md b/docs/stackit_beta_alb_quotas.md new file mode 100644 index 000000000..26f9168ce --- /dev/null +++ b/docs/stackit_beta_alb_quotas.md @@ -0,0 +1,40 @@ +## stackit beta alb quotas + +Shows the application load balancer quotas + +### Synopsis + +Shows the application load balancer quotas for the application load balancers. + +``` +stackit beta alb quotas [flags] +``` + +### Examples + +``` + List all application load balancer quotas + $ stackit beta alb quotas +``` + +### Options + +``` + -h, --help Help for "stackit beta alb quotas" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers + diff --git a/go.mod b/go.mod index ab60850c5..e8c9959d9 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/spf13/pflag v1.0.6 github.com/spf13/viper v1.20.1 github.com/stackitcloud/stackit-sdk-go/core v0.17.1 + github.com/stackitcloud/stackit-sdk-go/services/alb v0.2.1 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.1 github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.1 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.0 @@ -52,7 +53,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/stackitcloud/stackit-sdk-go/services/alb v0.2.1 // indirect github.com/x448/float16 v0.8.4 // indirect ) diff --git a/internal/cmd/beta/alb/alb.go b/internal/cmd/beta/alb/alb.go index edb78ebbe..94f082da8 100644 --- a/internal/cmd/beta/alb/alb.go +++ b/internal/cmd/beta/alb/alb.go @@ -5,7 +5,10 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/list" + observabilitycredentials "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/plans" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/pool" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/quotas" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/template" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -34,8 +37,11 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { template.NewCmd(p), create.NewCmd(p), update.NewCmd(p), + observabilitycredentials.NewCmd(p), describe.NewCmd(p), delete.NewCmd(p), pool.NewCmd(p), + plans.NewCmd(p), + quotas.NewCmd(p), ) } diff --git a/internal/cmd/beta/alb/observability-credentials/add/add.go b/internal/cmd/beta/alb/observability-credentials/add/add.go new file mode 100644 index 000000000..806af5e13 --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/add/add.go @@ -0,0 +1,146 @@ +package add + +import ( + "context" + "encoding/json" + "fmt" + + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +const ( + usernameFlag = "username" + displaynameFlag = "displayname" + passwordFlag = "password" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Username *string + Displayname *string + Password *string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "add", + Short: "Adds observability credentials to an application load balancer", + Long: "Adds observability credentials (username and password) to an application load balancer. The credentials can be for Observability or another monitoring tool.", + Args: cobra.NoArgs, + Example: examples.Build( + examples.NewExample( + `Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag`, + "$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --display-name yyy"), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := "Are your sure you want to add credentials?" + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("add credential: %w", err) + } + + return outputResult(p, model.GlobalFlagModel.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(usernameFlag, "u", "", "Username for the credentials") + cmd.Flags().StringP(displaynameFlag, "d", "", "Displayname for the credentials") + cmd.Flags().Var(flags.ReadFromFileFlag(), passwordFlag, `Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).`) + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, usernameFlag, displaynameFlag)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Username: flags.FlagToStringPointer(p, cmd, usernameFlag), + Displayname: flags.FlagToStringPointer(p, cmd, displaynameFlag), + Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string fo debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiCreateCredentialsRequest { + req := apiClient.CreateCredentials(ctx, model.ProjectId, model.Region) + payload := alb.CreateCredentialsPayload{ + DisplayName: model.Displayname, + Password: model.Password, + Username: model.Username, + } + return req.CreateCredentialsPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, item *alb.CreateCredentialsResponse) error { + if item == nil { + return fmt.Errorf("no credential found") + } + + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(item, "", " ") + if err != nil { + return fmt.Errorf("marshal credential: %w", err) + } + p.Outputln(string(details)) + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(item, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal credential: %w", err) + } + p.Outputln(string(details)) + default: + if item.Credential != nil { + p.Outputf("Created credential %s\n", + utils.PtrString(item.Credential.CredentialsRef), + ) + } + } + return nil +} diff --git a/internal/cmd/beta/alb/observability-credentials/add/add_test.go b/internal/cmd/beta/alb/observability-credentials/add/add_test.go new file mode 100644 index 000000000..a84b1405b --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/add/add_test.go @@ -0,0 +1,213 @@ +package add + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &alb.APIClient{} + +var ( + testProjectId = uuid.NewString() + testRegion = "eu01" + testDisplayname = "displayname" + testUsername = "testuser" + testPassword = "testpassword" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + usernameFlag: testUsername, + displaynameFlag: testDisplayname, + passwordFlag: testPassword, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + Username: &testUsername, + Displayname: &testDisplayname, + Password: &testPassword, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiCreateCredentialsRequest)) alb.ApiCreateCredentialsRequest { + request := testClient.CreateCredentials(testCtx, testProjectId, testRegion) + request = request.CreateCredentialsPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *alb.CreateCredentialsPayload)) alb.CreateCredentialsPayload { + payload := alb.CreateCredentialsPayload{ + DisplayName: &testDisplayname, + Password: &testPassword, + Username: &testUsername, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 alb.ApiCreateCredentialsRequest + }{ + { + 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), + cmp.AllowUnexported(alb.NullableString{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + item *alb.CreateCredentialsResponse + outputFormat string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + item: nil, + outputFormat: "", + }, + wantErr: true, + }, + { + name: "base", + args: args{ + item: &alb.CreateCredentialsResponse{}, + outputFormat: "", + }, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(p) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.item); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/alb/observability-credentials/delete/delete.go b/internal/cmd/beta/alb/observability-credentials/delete/delete.go new file mode 100644 index 000000000..e71a6fee4 --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/delete/delete.go @@ -0,0 +1,98 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +const ( + credentialRefArg = "CREDENTIAL_REF" // nolint:gosec // false alert, these are not valid credentials +) + +type inputModel struct { + *globalflags.GlobalFlagModel + CredentialsRef string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", credentialRefArg), + Short: "Deletes credentials", + Long: "Deletes credentials.", + Args: args.SingleArg(credentialRefArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete credential with name "credential-12345"`, + "$ stackit beta alb observability-credentials delete credential-12345", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete credentials %q?", model.CredentialsRef) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete credential: %w", err) + } + + p.Info("Deleted credential %q\n", model.CredentialsRef) + + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialRef := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + CredentialsRef: credentialRef, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiDeleteCredentialsRequest { + return apiClient.DeleteCredentials(ctx, model.ProjectId, model.Region, model.CredentialsRef) +} diff --git a/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go b/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go new file mode 100644 index 000000000..6f5ff0dd8 --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go @@ -0,0 +1,192 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testRegion = "eu01" + testClient = &alb.APIClient{} + testCredentialRef = "credential-12345" +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testCredentialRef, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + CredentialsRef: testCredentialRef, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiDeleteCredentialsRequest)) alb.ApiDeleteCredentialsRequest { + request := testClient.DeleteCredentials(testCtx, testProjectId, testRegion, testCredentialRef) + 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{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: false, + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: true, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, 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 alb.ApiDeleteCredentialsRequest + }{ + { + 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/beta/alb/observability-credentials/describe/describe.go b/internal/cmd/beta/alb/observability-credentials/describe/describe.go new file mode 100644 index 000000000..091d08ef2 --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/describe/describe.go @@ -0,0 +1,133 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +const ( + credentialRefArg = "CREDENTIAL_REF" // nolint:gosec // false alert, these are not valid credentials +) + +type inputModel struct { + *globalflags.GlobalFlagModel + CredentialRef string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", credentialRefArg), + Short: "Describes observability credentials for the Application Load Balancer", + Long: "Describes observability credentials for the Application Load Balancer.", + Args: args.SingleArg(credentialRefArg, nil), + Example: examples.Build( + examples.NewExample( + `Get details about credentials with name "credential-12345"`, + "$ stackit beta alb observability-credentials describe credential-12345", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read credentials: %w", err) + } + + if credential := resp; credential != nil && credential.Credential != nil { + return outputResult(p, model.OutputFormat, *credential.Credential) + } + p.Outputln("No credentials found.") + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + credentialRef := inputArgs[0] + model := inputModel{ + GlobalFlagModel: globalFlags, + CredentialRef: credentialRef, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiGetCredentialsRequest { + return apiClient.GetCredentials(ctx, model.ProjectId, model.Region, model.CredentialRef) +} + +func outputResult(p *print.Printer, outputFormat string, response alb.CredentialsResponse) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(response, "", " ") + + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(response, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.AddRow("CREDENTIAL REF", utils.PtrString(response.CredentialsRef)) + table.AddSeparator() + table.AddRow("DISPLAYNAME", utils.PtrString(response.DisplayName)) + table.AddSeparator() + table.AddRow("UESRNAME", utils.PtrString(response.Username)) + table.AddSeparator() + table.AddRow("REGION", utils.PtrString(response.Region)) + table.AddSeparator() + + p.Outputln(table.Render()) + } + + return nil +} diff --git a/internal/cmd/beta/alb/observability-credentials/describe/describe_test.go b/internal/cmd/beta/alb/observability-credentials/describe/describe_test.go new file mode 100644 index 000000000..1d4f201a5 --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/describe/describe_test.go @@ -0,0 +1,224 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testRegion = "eu01" + testClient = &alb.APIClient{} + testCredentialRef = "credential-12345" +) + +func fixtureArgValues(mods ...func(argVales []string)) []string { + argVales := []string{ + testCredentialRef, + } + for _, m := range mods { + m(argVales) + } + return argVales +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + CredentialRef: testCredentialRef, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiGetCredentialsRequest)) alb.ApiGetCredentialsRequest { + request := testClient.GetCredentials(testCtx, testProjectId, testRegion, testCredentialRef) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argsValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argsValues: []string{}, + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: false, + }, + { + description: "no arg values", + argsValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argsValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: true, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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.argsValues) + 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(p, cmd, tt.argsValues) + 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 + expectedResult alb.ApiGetCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedResult: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedResult, + cmp.AllowUnexported(tt.expectedResult), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + showOnlyPublicKey bool + response alb.CredentialsResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + outputFormat: "", + showOnlyPublicKey: false, + response: alb.CredentialsResponse{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(p) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.response); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/alb/observability-credentials/list/list.go b/internal/cmd/beta/alb/observability-credentials/list/list.go new file mode 100644 index 000000000..d08a9026e --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/list/list.go @@ -0,0 +1,163 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all credentials", + Long: "Lists all credentials.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all credentials`, + "$ stackit beta alb observability-credentials list", + ), + examples.NewExample( + `Lists all credentials in JSON format`, + "$ stackit beta alb observability-credentials list --output-format json", + ), + examples.NewExample( + `Lists up to 10 credentials`, + "$ stackit beta alb observability-credentials list --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list credentials: %w", err) + } + + if resp.Credentials == nil || len(*resp.Credentials) == 0 { + p.Info("No credentials found\n") + return nil + } + + items := *resp.Credentials + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(p, model.OutputFormat, items) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Number of credentials to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.InfoLevel, modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiListCredentialsRequest { + req := apiClient.ListCredentials(ctx, model.ProjectId, model.Region) + return req +} + +func outputResult(p *print.Printer, outputFormat string, items []alb.CredentialsResponse) error { + if items == nil { + p.Outputln("no credentials found") + return nil + } + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + p.Outputln(string(details)) + + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + p.Outputln(string(details)) + + default: + table := tables.NewTable() + table.SetHeader("CREDENTIAL REF", "DISPLAYNAME", "USERNAME", "REGION") + + for _, item := range items { + table.AddRow( + utils.PtrString(item.CredentialsRef), + utils.PtrString(item.DisplayName), + utils.PtrString(item.Username), + utils.PtrString(item.Region), + ) + } + + p.Outputln(table.Render()) + } + return nil +} diff --git a/internal/cmd/beta/alb/observability-credentials/list/list_test.go b/internal/cmd/beta/alb/observability-credentials/list/list_test.go new file mode 100644 index 000000000..f3c305298 --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/list/list_test.go @@ -0,0 +1,226 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "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/alb" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testClient = &alb.APIClient{} + + testProjectId = uuid.NewString() + testRegion = "eu01" + testLimit = int64(64) +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: strconv.FormatInt(testLimit, 10), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + Limit: utils.Ptr(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiListCredentialsRequest)) alb.ApiListCredentialsRequest { + request := testClient.ListCredentials(testCtx, testProjectId, testRegion) + 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{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: true, + expectedModel: fixtureInputModel(func(inputModel *inputModel) { + inputModel.Limit = nil + }), + }, + { + description: "withoutLimit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, "limit") + }), + isValid: true, + expectedModel: fixtureInputModel(func(inputModel *inputModel) { + inputModel.Limit = nil + }), + }, + { + description: "invalid limit 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "invalid limit 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "label selector empty", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatal("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 alb.ApiListCredentialsRequest + }{ + { + 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("request does not match: %s", diff) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + response []alb.CredentialsResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + outputFormat: "", + response: []alb.CredentialsResponse{ + {}, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(p) + + if err := outputResult(p, tt.args.outputFormat, tt.args.response); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/alb/observability-credentials/observability-credentials.go b/internal/cmd/beta/alb/observability-credentials/observability-credentials.go new file mode 100644 index 000000000..04dee2407 --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/observability-credentials.go @@ -0,0 +1,33 @@ +package credentials + +import ( + "github.com/spf13/cobra" + + add "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/add" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "observability-credentials", + Short: "Provides functionality for application loadbalancer credentials", + Long: "Provides functionality for application loadbalancer credentials", + Args: cobra.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(add.NewCmd(p)) + cmd.AddCommand(delete.NewCmd(p)) + cmd.AddCommand(describe.NewCmd(p)) + cmd.AddCommand(list.NewCmd(p)) + cmd.AddCommand(update.NewCmd(p)) +} diff --git a/internal/cmd/beta/alb/observability-credentials/update/update.go b/internal/cmd/beta/alb/observability-credentials/update/update.go new file mode 100644 index 000000000..03a9fe8ad --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/update/update.go @@ -0,0 +1,161 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +const ( + usernameFlag = "username" + displaynameFlag = "displayname" + passwordFlag = "password" + credentialRefArg = "CREDENTIAL_REF_ARG" //nolint:gosec // false alert, these are not valid credentials +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Username *string + Displayname *string + Password *string + CredentialsRef *string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", credentialRefArg), + Short: "Update credentials", + Long: "Update credentials.", + Args: args.SingleArg(credentialRefArg, nil), + Example: examples.Build( + examples.NewExample( + `Update the password of observability credentials of Application Load Balancer with credentials reference "credentials-xxx", by providing the path to a file with the new password as flag`, + "$ stackit beta alb observability-credentials update credentials-xxx --username user1 --displayname user1 --password @./new-password.txt"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model := parseInput(p, cmd, args) + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + req, err := buildRequest(ctx, &model, apiClient) + if err != nil { + return err + } + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update credential %q for %q?", *model.CredentialsRef, projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return fmt.Errorf("update credential: %w", err) + } + } + + // Call API + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update credential: %w", err) + } + if resp == nil { + return fmt.Errorf("response is nil") + } + + return outputResult(p, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(usernameFlag, "u", "", "Username for the credentials") + cmd.Flags().StringP(displaynameFlag, "d", "", "Displayname for the credentials") + cmd.Flags().Var(flags.ReadFromFileFlag(), passwordFlag, `Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).`) + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, displaynameFlag, usernameFlag)) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) (req alb.ApiUpdateCredentialsRequest, err error) { + req = apiClient.UpdateCredentials(ctx, model.ProjectId, model.Region, *model.CredentialsRef) + + payload := alb.UpdateCredentialsPayload{ + DisplayName: model.Displayname, + Password: model.Password, + Username: model.Username, + } + + if model.Displayname == nil && model.Username == nil { + return req, fmt.Errorf("no attribute to change passed") + } + + return req.UpdateCredentialsPayload(payload), nil +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) inputModel { + model := inputModel{ + GlobalFlagModel: globalflags.Parse(p, cmd), + Username: flags.FlagToStringPointer(p, cmd, usernameFlag), + Displayname: flags.FlagToStringPointer(p, cmd, displaynameFlag), + CredentialsRef: &inputArgs[0], + Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return model +} + +func outputResult(p *print.Printer, model inputModel, response *alb.UpdateCredentialsResponse) error { + var outputFormat string + if model.GlobalFlagModel != nil { + outputFormat = model.GlobalFlagModel.OutputFormat + } + if response == nil { + return fmt.Errorf("no response passewd") + } + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(response.Credential, "", " ") + if err != nil { + return fmt.Errorf("marshal credential: %w", err) + } + p.Outputln(string(details)) + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(response.Credential, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal credential: %w", err) + } + p.Outputln(string(details)) + default: + p.Outputf("Updated credential %q\n", utils.PtrString(model.CredentialsRef)) + } + return nil +} diff --git a/internal/cmd/beta/alb/observability-credentials/update/update_test.go b/internal/cmd/beta/alb/observability-credentials/update/update_test.go new file mode 100644 index 000000000..bdfab0080 --- /dev/null +++ b/internal/cmd/beta/alb/observability-credentials/update/update_test.go @@ -0,0 +1,227 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &alb.APIClient{} + +var ( + testProjectId = uuid.NewString() + testRegion = "eu01" + testCredentialRef = "credential-12345" + testDisplayname = "displayname" + testUsername = "testuser" + testPassword = "testpassword" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + usernameFlag: testUsername, + displaynameFlag: testDisplayname, + passwordFlag: testPassword, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) inputModel { + model := inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + Username: &testUsername, + Displayname: &testDisplayname, + CredentialsRef: &testCredentialRef, + Password: &testPassword, + } + for _, mod := range mods { + mod(&model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiUpdateCredentialsRequest)) alb.ApiUpdateCredentialsRequest { + request := testClient.UpdateCredentials(testCtx, testProjectId, testRegion, testCredentialRef) + request = request.UpdateCredentialsPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *alb.UpdateCredentialsPayload)) alb.UpdateCredentialsPayload { + payload := alb.UpdateCredentialsPayload{ + DisplayName: &testDisplayname, + Password: &testPassword, + Username: &testUsername, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + args []string + isValid bool + expectedModel inputModel + }{ + { + description: "base", + args: []string{testCredentialRef}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + args: []string{testCredentialRef}, + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: false, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Username = nil + model.Displayname = nil + }), + }, + { + description: "required values", + args: []string{testCredentialRef}, + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + usernameFlag: testUsername, + displaynameFlag: testDisplayname, + passwordFlag: testPassword, + }, + isValid: true, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + 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 := parseInput(p, cmd, tt.args) + + 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 alb.ApiUpdateCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, &tt.model, testClient) + if err != nil { + t.Fatalf("cannot build request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(alb.NullableString{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + item *alb.UpdateCredentialsResponse + model inputModel + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + item: nil, + model: fixtureInputModel(), + }, + wantErr: true, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(p) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.item); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/alb/plans/plans.go b/internal/cmd/beta/alb/plans/plans.go new file mode 100644 index 000000000..13719baac --- /dev/null +++ b/internal/cmd/beta/alb/plans/plans.go @@ -0,0 +1,146 @@ +package plans + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Lists the application load balancer plans", + Long: "Lists the available application load balancer plans.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all application load balancer plans`, + `$ stackit beta alb plans`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + if err != nil { + p.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("list plans: %w", err) + } + + if items := response.ValidPlans; items == nil || len(*items) == 0 { + p.Info("No plans found for project %q", projectLabel) + } else { + if err := outputResult(p, model.OutputFormat, *items); err != nil { + return fmt.Errorf("output plans: %w", err) + } + } + + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiListPlansRequest { + request := apiClient.ListPlans(ctx, model.Region) + + return request +} + +func outputResult(p *print.Printer, outputFormat string, items []alb.PlanDetails) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("marshal plans: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal plans: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("PLAN ID", "NAME", "FLAVOR", "MAX CONNS", "DESCRIPTION") + for _, item := range items { + table.AddRow(utils.PtrString(item.PlanId), + utils.PtrString(item.Name), + utils.PtrString(item.FlavorName), + utils.PtrString(item.MaxConnections), + utils.Truncate(item.Description, 70), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/alb/plans/plans_test.go b/internal/cmd/beta/alb/plans/plans_test.go new file mode 100644 index 000000000..3184f5696 --- /dev/null +++ b/internal/cmd/beta/alb/plans/plans_test.go @@ -0,0 +1,203 @@ +package plans + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &alb.APIClient{} + testProjectId = uuid.NewString() + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Region: testRegion, Verbosity: globalflags.VerbosityDefault}, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiListPlansRequest)) alb.ApiListPlansRequest { + request := testClient.ListPlans(testCtx, testRegion) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot 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) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, 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 alb.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), + cmpopts.IgnoreFields(alb.ApiListLoadBalancersRequest{}, "ctx"), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + items []alb.PlanDetails + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + outputFormat: "", + items: []alb.PlanDetails{}, + }, + wantErr: false, + }, + { + name: "output format json", + args: args{ + outputFormat: print.JSONOutputFormat, + items: []alb.PlanDetails{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(p) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.items); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/alb/quotas/quotas.go b/internal/cmd/beta/alb/quotas/quotas.go new file mode 100644 index 000000000..07bc2eb21 --- /dev/null +++ b/internal/cmd/beta/alb/quotas/quotas.go @@ -0,0 +1,132 @@ +package quotas + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "quotas", + Short: "Shows the application load balancer quotas", + Long: "Shows the application load balancer quotas for the application load balancers.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all application load balancer quotas`, + `$ stackit beta alb quotas`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("get quotas: %w", err) + } + + if response == nil { + p.Outputln("no quotas found") + return nil + } + + if err := outputResult(p, model.OutputFormat, *response); err != nil { + return fmt.Errorf("output loadbalancers: %w", err) + } + + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiGetQuotaRequest { + request := apiClient.GetQuota(ctx, model.ProjectId, model.Region) + + return request +} + +func outputResult(p *print.Printer, outputFormat string, response alb.GetQuotaResponse) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(response, "", " ") + if err != nil { + return fmt.Errorf("marshal quotas: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(response, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal quotas: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.AddRow("REGION", utils.PtrString(response.Region)) + table.AddSeparator() + table.AddRow("MAX LOADBALANCERS", utils.PtrString(response.MaxLoadBalancers)) + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/alb/quotas/quotas_test.go b/internal/cmd/beta/alb/quotas/quotas_test.go new file mode 100644 index 000000000..b840d9ef0 --- /dev/null +++ b/internal/cmd/beta/alb/quotas/quotas_test.go @@ -0,0 +1,203 @@ +package quotas + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &alb.APIClient{} + testProjectId = uuid.NewString() + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Region: testRegion, Verbosity: globalflags.VerbosityDefault}, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiGetQuotaRequest)) alb.ApiGetQuotaRequest { + request := testClient.GetQuota(testCtx, testProjectId, testRegion) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot 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) + } + } + + if err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, 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 alb.ApiGetQuotaRequest + }{ + { + 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), + cmpopts.IgnoreFields(alb.ApiListLoadBalancersRequest{}, "ctx"), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + response alb.GetQuotaResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + outputFormat: "", + response: alb.GetQuotaResponse{}, + }, + wantErr: false, + }, + { + name: "output format json", + args: args{ + outputFormat: print.JSONOutputFormat, + response: alb.GetQuotaResponse{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(p) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.response); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/pkg/utils/strings.go b/internal/pkg/utils/strings.go index 36cfda139..401287fa1 100644 --- a/internal/pkg/utils/strings.go +++ b/internal/pkg/utils/strings.go @@ -2,6 +2,7 @@ package utils import ( "strings" + "unicode/utf8" ) // JoinStringKeys concatenates the string keys of a map, each separatore by the @@ -33,3 +34,25 @@ func JoinStringPtr(vals *[]string, sep string) string { } return strings.Join(*vals, sep) } + +// Truncate trims the passed string (if it is not nil). If the input string is +// longer than the given length, it is truncated to _maxLen_ and a ellipsis (…) +// is attached. Therefore the resulting string has at most length _maxLen-1_ +func Truncate(s *string, maxLen int) string { + if s == nil { + return "" + } + + if utf8.RuneCountInString(*s) > maxLen { + var builder strings.Builder + for i, r := range *s { + if i >= maxLen { + break + } + builder.WriteRune(r) + } + builder.WriteRune('…') + return builder.String() + } + return *s +} diff --git a/internal/pkg/utils/strings_test.go b/internal/pkg/utils/strings_test.go new file mode 100644 index 000000000..a7fb023bc --- /dev/null +++ b/internal/pkg/utils/strings_test.go @@ -0,0 +1,32 @@ +package utils + +import ( + "testing" + + "github.com/stackitcloud/stackit-sdk-go/core/utils" +) + +func TestTruncate(t *testing.T) { + type args struct { + s *string + maxLen int + } + tests := []struct { + name string + args args + want string + }{ + {"nil string", args{nil, 3}, ""}, + {"empty string", args{utils.Ptr(""), 10}, ""}, + {"length below maxlength", args{utils.Ptr("foo"), 10}, "foo"}, + {"exactly maxlength", args{utils.Ptr("foo"), 3}, "foo"}, + {"above maxlength", args{utils.Ptr("foobarbaz"), 3}, "foo…"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Truncate(tt.args.s, tt.args.maxLen); got != tt.want { + t.Errorf("Truncate() = %v, want %v", got, tt.want) + } + }) + } +}