From ad04303dee988e66dc992ca6601136641993a2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Mon, 7 Apr 2025 15:18:05 +0200 Subject: [PATCH 01/22] feat(alb): list command --- go.mod | 1 + go.sum | 2 + internal/cmd/beta/alb/alb.go | 29 +++ internal/cmd/beta/alb/list/list.go | 174 +++++++++++++++ internal/cmd/beta/alb/list/list_test.go | 208 ++++++++++++++++++ internal/cmd/beta/beta.go | 2 + internal/pkg/services/alb/client/client.go | 46 ++++ internal/pkg/services/alb/utils/utils.go | 5 + internal/pkg/services/alb/utils/utils_test.go | 5 + 9 files changed, 472 insertions(+) create mode 100644 internal/cmd/beta/alb/alb.go create mode 100644 internal/cmd/beta/alb/list/list.go create mode 100644 internal/cmd/beta/alb/list/list_test.go create mode 100644 internal/pkg/services/alb/client/client.go create mode 100644 internal/pkg/services/alb/utils/utils.go create mode 100644 internal/pkg/services/alb/utils/utils_test.go diff --git a/go.mod b/go.mod index 540ee75f3..ab60850c5 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ 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/go.sum b/go.sum index 8f4665360..3e7029eac 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,8 @@ github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stackitcloud/stackit-sdk-go/core v0.17.1 h1:TTrVoB1lERd/qfWzpe6HpwCJSjtaGnUI7UE7ITb5IT0= github.com/stackitcloud/stackit-sdk-go/core v0.17.1/go.mod h1:8KIw3czdNJ9sdil9QQimxjR6vHjeINFrRv0iZ67wfn0= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.2.1 h1:7VOUNsnNBL2iQsZ3lJogNzKx4lrYEawDYllcXV1gEik= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.2.1/go.mod h1:J2/Jk+leR6AjBotd7USJJzX+AEIHH11yxnmx+6ciJEk= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.1 h1:2lq6SG8qOgPOx2OIA5Bca8mwRSlect3Yljk57bXqd5I= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.1/go.mod h1:in9kC4GIBU5DpzXKFDL7RDl0fKyvN/RUIc7YbyWYEUA= github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.1 h1:W5zQhg/nA2RVSkUtRjsGcJMdYlOicoE5gBGE9zMT9Eo= diff --git a/internal/cmd/beta/alb/alb.go b/internal/cmd/beta/alb/alb.go new file mode 100644 index 000000000..6bdcd445e --- /dev/null +++ b/internal/cmd/beta/alb/alb.go @@ -0,0 +1,29 @@ +package alb + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "alb", + Short: "Manages application loadbalancers", + Long: "Manage the lifecycle of application loadbalancers.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand( + list.NewCmd(p), + ) +} diff --git a/internal/cmd/beta/alb/list/list.go b/internal/cmd/beta/alb/list/list.go new file mode 100644 index 000000000..398b1c630 --- /dev/null +++ b/internal/cmd/beta/alb/list/list.go @@ -0,0 +1,174 @@ +package list + +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/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/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +const ( + labelSelectorFlag = "label-selector" + limitFlag = "limit" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists albs", + Long: "Lists application load balancers.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all load balancers`, + `$ stackit beta alb list`, + ), + examples.NewExample( + `List the first 10 application load balancers`, + `$ stackit beta alb 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 + } + + 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 load balancerse: %w", err) + } + + if items := response.LoadBalancers; items != nil && len(*items) == 0 { + p.Info("No load balancers found for project %q", projectLabel) + } else { + if model.Limit != nil && len(*items) > int(*model.Limit) { + *items = (*items)[:*model.Limit] + } + if err := outputResult(p, model.OutputFormat, *items); err != nil { + return fmt.Errorf("output loadbalancers: %w", err) + } + } + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + 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.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiListLoadBalancersRequest { + request := apiClient.ListLoadBalancers(ctx, model.ProjectId, model.Region) + + return request +} +func outputResult(p *print.Printer, outputFormat string, items []alb.LoadBalancer) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("marshal loadbalancer list: %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 loadbalancer list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("NAME", "EXTERNAL ADDRESS", "REGION", "STATUS", "VERSION") + for _, item := range items { + table.AddRow(utils.PtrString(item.Name), + utils.PtrString(item.ExternalAddress), + utils.PtrString(item.Region), + utils.PtrString(item.Status), + utils.PtrString(item.Version), + utils.PtrString(item.ExternalAddress), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/alb/list/list_test.go b/internal/cmd/beta/alb/list/list_test.go new file mode 100644 index 000000000..e4ae8cf0b --- /dev/null +++ b/internal/cmd/beta/alb/list/list_test.go @@ -0,0 +1,208 @@ +package list + +import ( + "context" + "strconv" + "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" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &alb.APIClient{} + testProjectId = uuid.NewString() + testRegion = "eu01" + testLimit int64 = 10 +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: strconv.Itoa(int(testLimit)), + } + 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}, + Limit: &testLimit, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiListLoadBalancersRequest)) alb.ApiListLoadBalancersRequest { + request := testClient.ListLoadBalancers(context.Background(), 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, 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, + }, + } + + 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.ApiListLoadBalancersRequest + }{ + { + 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.LoadBalancer + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + outputFormat: "", + items: []alb.LoadBalancer{}, + }, + wantErr: false, + }, + { + name: "output format json", + args: args{ + outputFormat: print.JSONOutputFormat, + items: []alb.LoadBalancer{}, + }, + 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/beta.go b/internal/cmd/beta/beta.go index ed6873b0e..7a845ca1b 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -36,4 +37,5 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(sqlserverflex.NewCmd(p)) + cmd.AddCommand(alb.NewCmd(p)) } diff --git a/internal/pkg/services/alb/client/client.go b/internal/pkg/services/alb/client/client.go new file mode 100644 index 000000000..c29f9eddb --- /dev/null +++ b/internal/pkg/services/alb/client/client.go @@ -0,0 +1,46 @@ +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/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +func ConfigureClient(p *print.Printer) (*alb.APIClient, error) { + var err error + var apiClient *alb.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) + if err != nil { + p.Debug(print.ErrorLevel, "configure authentication: %v", err) + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption) + + customEndpoint := viper.GetString(config.IaaSCustomEndpointKey) + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } else { + cfgOptions = append(cfgOptions, authCfgOption) + } + + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + + apiClient, err = alb.NewAPIClient(cfgOptions...) + if err != nil { + p.Debug(print.ErrorLevel, "create new API client: %v", err) + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/alb/utils/utils.go b/internal/pkg/services/alb/utils/utils.go new file mode 100644 index 000000000..3c5744952 --- /dev/null +++ b/internal/pkg/services/alb/utils/utils.go @@ -0,0 +1,5 @@ +package utils + +type AlbClient interface { +} + diff --git a/internal/pkg/services/alb/utils/utils_test.go b/internal/pkg/services/alb/utils/utils_test.go new file mode 100644 index 000000000..03a40b064 --- /dev/null +++ b/internal/pkg/services/alb/utils/utils_test.go @@ -0,0 +1,5 @@ +package utils + +type AlbClientMocked struct { +} + From 8d8c6018972cf6de4b03c659e577f4f034dab7ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:51:41 +0200 Subject: [PATCH 02/22] feat(alb): bugfix --- internal/cmd/beta/alb/list/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/beta/alb/list/list.go b/internal/cmd/beta/alb/list/list.go index 398b1c630..7480d66dd 100644 --- a/internal/cmd/beta/alb/list/list.go +++ b/internal/cmd/beta/alb/list/list.go @@ -75,7 +75,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("list load balancerse: %w", err) } - if items := response.LoadBalancers; items != nil && len(*items) == 0 { + if items := response.LoadBalancers; items == nil || len(*items) == 0 { p.Info("No load balancers found for project %q", projectLabel) } else { if model.Limit != nil && len(*items) > int(*model.Limit) { From 2d319e4f64db1f5a0dfe4d9f81c28467bd6a2ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:51:51 +0200 Subject: [PATCH 03/22] feat(alb): add template support --- internal/cmd/beta/alb/alb.go | 2 + internal/cmd/beta/alb/template/template.go | 111 ++++++++++++++ internal/cmd/beta/alb/template/template.json | 125 ++++++++++++++++ .../cmd/beta/alb/template/template_test.go | 140 ++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 internal/cmd/beta/alb/template/template.go create mode 100644 internal/cmd/beta/alb/template/template.json create mode 100644 internal/cmd/beta/alb/template/template_test.go diff --git a/internal/cmd/beta/alb/alb.go b/internal/cmd/beta/alb/alb.go index 6bdcd445e..8b13682c0 100644 --- a/internal/cmd/beta/alb/alb.go +++ b/internal/cmd/beta/alb/alb.go @@ -2,6 +2,7 @@ package alb import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/template" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -25,5 +26,6 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand( list.NewCmd(p), + template.NewCmd(p), ) } diff --git a/internal/cmd/beta/alb/template/template.go b/internal/cmd/beta/alb/template/template.go new file mode 100644 index 000000000..eb1cfa46b --- /dev/null +++ b/internal/cmd/beta/alb/template/template.go @@ -0,0 +1,111 @@ +package template + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + "os" + + "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/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +const ( + formatFlag = "format" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Format *string +} + +//go:embed template.json +var template []byte + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "template", + Short: "create an alb template", + Long: "creates a json or yaml template file for creating/updating an application loadbalancer.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Creat a yaml template`, + `$ stackit beta alb template --format=yaml`, + ), + examples.NewExample( + `Creat a json template`, + `$ stackit beta alb template --format=json`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + var reader io.Reader + if model.Format == nil || *model.Format == "json" { + reader = bytes.NewReader(template) + } else if *model.Format == "yaml" { + var target alb.CreateLoadBalancerPayload + if err := json.Unmarshal(template, &target); err != nil { + return fmt.Errorf("cannot unmarshal template: %w", err) + } + data, err := yaml.Marshal(&target) + if err != nil { + return fmt.Errorf("cannot marshal template to yaml: %w", err) + } + reader = bytes.NewReader(data) + } else { + return fmt.Errorf("invalid format %q defined. Must be 'json' or 'yaml'", *model.Format) + } + io.Copy(os.Stdout, reader) + + _, _ = ctx, model + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().VarP(flags.EnumFlag(true, "json", "json", "yaml"), formatFlag, "f", "Defines the output format (yaml or json), default is json") +} + +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, + Format: flags.FlagToStringPointer(p, cmd, formatFlag), + } + + 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 +} diff --git a/internal/cmd/beta/alb/template/template.json b/internal/cmd/beta/alb/template/template.json new file mode 100644 index 000000000..b81294c66 --- /dev/null +++ b/internal/cmd/beta/alb/template/template.json @@ -0,0 +1,125 @@ +{ + "externalAddress": "10.100.42.1", + "listeners": [ + { + "displayName": "listener1", + "http": {}, + "https": { + "certificateConfig": { + "certificateIds": [ + "cert-1", + "cert-2", + "cert-3" + ] + } + }, + "port": 443, + "protocol": "PROTOCOL_HTTPS", + "rules": [ + { + "host": "front.facing.host", + "http": { + "subRules": [ + { + "cookiePersistence": { + "name": "cookie1", + "ttl": "120s" + }, + "headers": [ + { + "name": "testheader1", + "exactMatch": "X-test-header1" + }, + { + "name": "testheader2", + "exactMatch": "X-test-header2" + }, + { + "name": "testheader3", + "exactMatch": "X-test-header3" + } + ], + "pathPrefix": "/foo", + "queryParameters": [ + { + "name": "query-param", + "exactMatch": "q" + }, + { + "name": "region", + "exactMatch": "region" + } + ], + "targetPool": "my-target-pool", + "webSocket": false + } + ] + } + } + ] + } + ], + "name": "my-load-balancer", + "networks": [ + { + "networkId": "00000000-0000-0000-0000-000000000000", + "role": "ROLE_LISTENERS_AND_TARGETS" + }, + { + "networkId": "00000000-0000-0000-0000-000000000001", + "role": "ROLE_LISTENERS_AND_TARGETS" + } + ], + "options": { + "accessControl": { + "allowedSourceRanges": [ + "192.168.42.0-192.168.42.10", + "192.168.54.0-192.168.54.10" + ] + }, + "ephemeralAddress": true, + "observability": { + "logs": { + "credentialsRef": "my-credentials", + "pushUrl": "https://my.observability.host//loki/api/v1/push" + }, + "metrics": { + "credentialsRef": "my-credentials", + "pushUrl": "https://my.observability.host///api/v1/receive" + } + }, + "privateNetworkOnly": true + }, + "planId": "p10", + "targetPools": [ + { + "activeHealthCheck": { + "healthyThreshold": 3, + "httpHealthChecks": { + "okStatuses": [ + "200", + "204" + ], + "path": "/health" + }, + "interval": "10s", + "intervalJitter": "3s", + "timeout": "5s", + "unhealthyThreshold": 1 + }, + "name": "my-target-pool", + "targetPort": 5732, + "targets": [ + { + "displayName": "my-target1", + "ip": "192.11.2.5" + } + ], + "tlsConfig": { + "customCa": "my.private.ca", + "enabled": true, + "skipCertificateValidation": false + } + } + ] +} diff --git a/internal/cmd/beta/alb/template/template_test.go b/internal/cmd/beta/alb/template/template_test.go new file mode 100644 index 000000000..b422d77de --- /dev/null +++ b/internal/cmd/beta/alb/template/template_test.go @@ -0,0 +1,140 @@ +package template + +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/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &alb.APIClient{} + testProjectId = uuid.NewString() + testRegion = "eu01" + testLimit int64 = 10 +) + +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, Region: testRegion, Verbosity: globalflags.VerbosityDefault}, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiListLoadBalancersRequest)) alb.ApiListLoadBalancersRequest { + request := testClient.ListLoadBalancers(context.Background(), 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, 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, + }, + } + + 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) + } + }) + } +} + + From 18f1e07fb36e22a09bd7231a9ddd7e4c7b7c32f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Tue, 8 Apr 2025 10:16:36 +0200 Subject: [PATCH 04/22] feat(alb): create command --- internal/cmd/beta/alb/alb.go | 2 + internal/cmd/beta/alb/create/create.go | 198 +++++++++++++ internal/cmd/beta/alb/create/create_test.go | 260 ++++++++++++++++++ .../beta/alb/create/testdata/testconfig.json | 125 +++++++++ internal/cmd/beta/alb/list/list_test.go | 13 +- .../cmd/beta/alb/template/template_test.go | 13 +- 6 files changed, 596 insertions(+), 15 deletions(-) create mode 100644 internal/cmd/beta/alb/create/create.go create mode 100644 internal/cmd/beta/alb/create/create_test.go create mode 100644 internal/cmd/beta/alb/create/testdata/testconfig.json diff --git a/internal/cmd/beta/alb/alb.go b/internal/cmd/beta/alb/alb.go index 8b13682c0..985ae0e06 100644 --- a/internal/cmd/beta/alb/alb.go +++ b/internal/cmd/beta/alb/alb.go @@ -1,6 +1,7 @@ package alb import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/create" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/list" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/template" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -27,5 +28,6 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand( list.NewCmd(p), template.NewCmd(p), + create.NewCmd(p), ) } diff --git a/internal/cmd/beta/alb/create/create.go b/internal/cmd/beta/alb/create/create.go new file mode 100644 index 000000000..2237ba466 --- /dev/null +++ b/internal/cmd/beta/alb/create/create.go @@ -0,0 +1,198 @@ +package create + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/goccy/go-yaml" + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "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/alb" + "github.com/stackitcloud/stackit-sdk-go/services/alb/wait" +) + +const ( + configurationFlag = "configuration" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Configuration *string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates an application loadbalancer", + Long: "Creates an application loadbalancer.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an application loadbalancer from a configuration file`, + "$ stackit beta alb create --configuration my-loadbalancer.json"), + ), + 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 + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create an application loadbalancer for project %q?", projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create application loadbalancer: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Creating zone") + _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for loadbalancer creation: %w", err) + } + s.Stop() + } + + return outputResult(p, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(configurationFlag, "c", "", "filename of the input configuration file") + err := flags.MarkFlagsRequired(cmd, configurationFlag) + cobra.CheckErr(err) +} + +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, + Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag), + } + + 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) (req alb.ApiCreateLoadBalancerRequest, err error) { + req = apiClient.CreateLoadBalancer(ctx, model.ProjectId, model.Region) + payload, err := readPayload(ctx, model) + if err != nil { + return req, err + } + req = req.CreateLoadBalancerPayload(payload) + return req, nil +} + +func readPayload(ctx context.Context, model *inputModel) (payload alb.CreateLoadBalancerPayload, err error) { + if model.Configuration == nil { + return payload, fmt.Errorf("no configuration file defined") + } + file, err := os.Open(*model.Configuration) + if err != nil { + return payload, fmt.Errorf("cannot open configuration file %q: %w", *model.Configuration, err) + } + defer file.Close() + + if strings.HasSuffix(*model.Configuration, ".yaml") { + decoder := yaml.NewDecoder(bufio.NewReader(file), yaml.UseJSONUnmarshaler()) + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("cannot deserialize yaml configuration from %q: %w", *model.Configuration, err) + } + } else if strings.HasSuffix(*model.Configuration, ".json") { + decoder := json.NewDecoder(bufio.NewReader(file)) + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("cannot deserialize json configuration from %q: %w", *model.Configuration, err) + } + } else { + return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", err) + } + + return payload, nil +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *alb.LoadBalancer) error { + if resp == nil { + return fmt.Errorf("create loadbalancer response is empty") + } + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal loadbalancer: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal loadbalancer: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s application loadbalancer for %q. Name: %s\n", operationState, projectLabel, utils.PtrString(resp.Name)) + return nil + } +} diff --git a/internal/cmd/beta/alb/create/create_test.go b/internal/cmd/beta/alb/create/create_test.go new file mode 100644 index 000000000..27d1d1790 --- /dev/null +++ b/internal/cmd/beta/alb/create/create_test.go @@ -0,0 +1,260 @@ +package create + +import ( + "context" + _ "embed" + "encoding/json" + "log" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "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/stackitcloud/stackit-sdk-go/services/alb" +) + +//go:embed testdata/testconfig.json +var testConfiguration []byte + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &alb.APIClient{} +var testProjectId = uuid.NewString() +var testRegion = "eu01" +var testConfig = "testdata/testconfig.json" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + configurationFlag: testConfig, + 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, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Configuration: utils.Ptr(testConfig), + } + for _, mod := range mods { + mod(model) + } + return model +} +func fixturePayload(mods ...func(payload *alb.CreateLoadBalancerPayload)) (payload alb.CreateLoadBalancerPayload) { + if err := json.Unmarshal(testConfiguration, &payload); err != nil { + log.Panicf("cannot deserialize test configuration: %v", err) + } + for _, f := range mods { + f(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *alb.ApiCreateLoadBalancerRequest)) alb.ApiCreateLoadBalancerRequest { + request := testClient.CreateLoadBalancer(testCtx, testProjectId, testRegion) + + request = request.CreateLoadBalancerPayload(fixturePayload()) + 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: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + configurationFlag: testConfig, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Configuration: &testConfig, + }, + }, + { + 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, + }, + } + + 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.ApiCreateLoadBalancerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Configuration: &testConfig, + }, + expectedRequest: testClient. + CreateLoadBalancer(context.Background(), testProjectId, testRegion). + CreateLoadBalancerPayload(fixturePayload()), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("canno create request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.IgnoreFields(alb.ApiCreateLoadBalancerRequest{}, "ctx"), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + resp *alb.LoadBalancer + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "only zone response as argument", + args: args{ + model: fixtureInputModel(), + resp: &alb.LoadBalancer{}, + }, + 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.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/alb/create/testdata/testconfig.json b/internal/cmd/beta/alb/create/testdata/testconfig.json new file mode 100644 index 000000000..b81294c66 --- /dev/null +++ b/internal/cmd/beta/alb/create/testdata/testconfig.json @@ -0,0 +1,125 @@ +{ + "externalAddress": "10.100.42.1", + "listeners": [ + { + "displayName": "listener1", + "http": {}, + "https": { + "certificateConfig": { + "certificateIds": [ + "cert-1", + "cert-2", + "cert-3" + ] + } + }, + "port": 443, + "protocol": "PROTOCOL_HTTPS", + "rules": [ + { + "host": "front.facing.host", + "http": { + "subRules": [ + { + "cookiePersistence": { + "name": "cookie1", + "ttl": "120s" + }, + "headers": [ + { + "name": "testheader1", + "exactMatch": "X-test-header1" + }, + { + "name": "testheader2", + "exactMatch": "X-test-header2" + }, + { + "name": "testheader3", + "exactMatch": "X-test-header3" + } + ], + "pathPrefix": "/foo", + "queryParameters": [ + { + "name": "query-param", + "exactMatch": "q" + }, + { + "name": "region", + "exactMatch": "region" + } + ], + "targetPool": "my-target-pool", + "webSocket": false + } + ] + } + } + ] + } + ], + "name": "my-load-balancer", + "networks": [ + { + "networkId": "00000000-0000-0000-0000-000000000000", + "role": "ROLE_LISTENERS_AND_TARGETS" + }, + { + "networkId": "00000000-0000-0000-0000-000000000001", + "role": "ROLE_LISTENERS_AND_TARGETS" + } + ], + "options": { + "accessControl": { + "allowedSourceRanges": [ + "192.168.42.0-192.168.42.10", + "192.168.54.0-192.168.54.10" + ] + }, + "ephemeralAddress": true, + "observability": { + "logs": { + "credentialsRef": "my-credentials", + "pushUrl": "https://my.observability.host//loki/api/v1/push" + }, + "metrics": { + "credentialsRef": "my-credentials", + "pushUrl": "https://my.observability.host///api/v1/receive" + } + }, + "privateNetworkOnly": true + }, + "planId": "p10", + "targetPools": [ + { + "activeHealthCheck": { + "healthyThreshold": 3, + "httpHealthChecks": { + "okStatuses": [ + "200", + "204" + ], + "path": "/health" + }, + "interval": "10s", + "intervalJitter": "3s", + "timeout": "5s", + "unhealthyThreshold": 1 + }, + "name": "my-target-pool", + "targetPort": 5732, + "targets": [ + { + "displayName": "my-target1", + "ip": "192.11.2.5" + } + ], + "tlsConfig": { + "customCa": "my.private.ca", + "enabled": true, + "skipCertificateValidation": false + } + } + ] +} diff --git a/internal/cmd/beta/alb/list/list_test.go b/internal/cmd/beta/alb/list/list_test.go index e4ae8cf0b..77c915266 100644 --- a/internal/cmd/beta/alb/list/list_test.go +++ b/internal/cmd/beta/alb/list/list_test.go @@ -14,8 +14,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/alb" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var ( @@ -28,8 +26,9 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: strconv.Itoa(int(testLimit)), + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: strconv.Itoa(int(testLimit)), } for _, mod := range mods { mod(flagValues) @@ -77,21 +76,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, diff --git a/internal/cmd/beta/alb/template/template_test.go b/internal/cmd/beta/alb/template/template_test.go index b422d77de..9f81f9a6c 100644 --- a/internal/cmd/beta/alb/template/template_test.go +++ b/internal/cmd/beta/alb/template/template_test.go @@ -12,8 +12,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/alb" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var ( @@ -26,7 +24,8 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -73,21 +72,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,5 +135,3 @@ func TestParseInput(t *testing.T) { }) } } - - From 55827aaf56cab39acc6852bd04ba16a0ad4af92d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:49:11 +0200 Subject: [PATCH 05/22] feat(alb): fix testcases --- internal/cmd/beta/alb/create/create.go | 5 ++--- internal/cmd/beta/alb/create/create_test.go | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/cmd/beta/alb/create/create.go b/internal/cmd/beta/alb/create/create.go index 2237ba466..230536457 100644 --- a/internal/cmd/beta/alb/create/create.go +++ b/internal/cmd/beta/alb/create/create.go @@ -85,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { s := spinner.New(p) - s.Start("Creating zone") + s.Start("Creating loadbalancer") _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).WaitWithContext(ctx) if err != nil { return fmt.Errorf("wait for loadbalancer creation: %w", err) @@ -135,8 +135,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie if err != nil { return req, err } - req = req.CreateLoadBalancerPayload(payload) - return req, nil + return req.CreateLoadBalancerPayload(payload), nil } func readPayload(ctx context.Context, model *inputModel) (payload alb.CreateLoadBalancerPayload, err error) { diff --git a/internal/cmd/beta/alb/create/create_test.go b/internal/cmd/beta/alb/create/create_test.go index 27d1d1790..d77fb2022 100644 --- a/internal/cmd/beta/alb/create/create_test.go +++ b/internal/cmd/beta/alb/create/create_test.go @@ -199,7 +199,7 @@ func TestBuildRequest(t *testing.T) { Configuration: &testConfig, }, expectedRequest: testClient. - CreateLoadBalancer(context.Background(), testProjectId, testRegion). + CreateLoadBalancer(testCtx, testProjectId, testRegion). CreateLoadBalancerPayload(fixturePayload()), }, } @@ -214,7 +214,6 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), - cmpopts.IgnoreFields(alb.ApiCreateLoadBalancerRequest{}, "ctx"), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -240,7 +239,7 @@ func TestOutputResult(t *testing.T) { wantErr: true, }, { - name: "only zone response as argument", + name: "empty response as argument", args: args{ model: fixtureInputModel(), resp: &alb.LoadBalancer{}, From 55daff4921bafd5f820719d845f79327ae3a0779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:49:23 +0200 Subject: [PATCH 06/22] feat(alb): add update --- internal/cmd/beta/alb/alb.go | 2 + .../beta/alb/update/testdata/testconfig.json | 125 +++++++++ internal/cmd/beta/alb/update/update.go | 197 +++++++++++++ internal/cmd/beta/alb/update/update_test.go | 262 ++++++++++++++++++ 4 files changed, 586 insertions(+) create mode 100644 internal/cmd/beta/alb/update/testdata/testconfig.json create mode 100644 internal/cmd/beta/alb/update/update.go create mode 100644 internal/cmd/beta/alb/update/update_test.go diff --git a/internal/cmd/beta/alb/alb.go b/internal/cmd/beta/alb/alb.go index 985ae0e06..018dbee45 100644 --- a/internal/cmd/beta/alb/alb.go +++ b/internal/cmd/beta/alb/alb.go @@ -4,6 +4,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/create" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/list" "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" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -29,5 +30,6 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { list.NewCmd(p), template.NewCmd(p), create.NewCmd(p), + update.NewCmd(p), ) } diff --git a/internal/cmd/beta/alb/update/testdata/testconfig.json b/internal/cmd/beta/alb/update/testdata/testconfig.json new file mode 100644 index 000000000..b81294c66 --- /dev/null +++ b/internal/cmd/beta/alb/update/testdata/testconfig.json @@ -0,0 +1,125 @@ +{ + "externalAddress": "10.100.42.1", + "listeners": [ + { + "displayName": "listener1", + "http": {}, + "https": { + "certificateConfig": { + "certificateIds": [ + "cert-1", + "cert-2", + "cert-3" + ] + } + }, + "port": 443, + "protocol": "PROTOCOL_HTTPS", + "rules": [ + { + "host": "front.facing.host", + "http": { + "subRules": [ + { + "cookiePersistence": { + "name": "cookie1", + "ttl": "120s" + }, + "headers": [ + { + "name": "testheader1", + "exactMatch": "X-test-header1" + }, + { + "name": "testheader2", + "exactMatch": "X-test-header2" + }, + { + "name": "testheader3", + "exactMatch": "X-test-header3" + } + ], + "pathPrefix": "/foo", + "queryParameters": [ + { + "name": "query-param", + "exactMatch": "q" + }, + { + "name": "region", + "exactMatch": "region" + } + ], + "targetPool": "my-target-pool", + "webSocket": false + } + ] + } + } + ] + } + ], + "name": "my-load-balancer", + "networks": [ + { + "networkId": "00000000-0000-0000-0000-000000000000", + "role": "ROLE_LISTENERS_AND_TARGETS" + }, + { + "networkId": "00000000-0000-0000-0000-000000000001", + "role": "ROLE_LISTENERS_AND_TARGETS" + } + ], + "options": { + "accessControl": { + "allowedSourceRanges": [ + "192.168.42.0-192.168.42.10", + "192.168.54.0-192.168.54.10" + ] + }, + "ephemeralAddress": true, + "observability": { + "logs": { + "credentialsRef": "my-credentials", + "pushUrl": "https://my.observability.host//loki/api/v1/push" + }, + "metrics": { + "credentialsRef": "my-credentials", + "pushUrl": "https://my.observability.host///api/v1/receive" + } + }, + "privateNetworkOnly": true + }, + "planId": "p10", + "targetPools": [ + { + "activeHealthCheck": { + "healthyThreshold": 3, + "httpHealthChecks": { + "okStatuses": [ + "200", + "204" + ], + "path": "/health" + }, + "interval": "10s", + "intervalJitter": "3s", + "timeout": "5s", + "unhealthyThreshold": 1 + }, + "name": "my-target-pool", + "targetPort": 5732, + "targets": [ + { + "displayName": "my-target1", + "ip": "192.11.2.5" + } + ], + "tlsConfig": { + "customCa": "my.private.ca", + "enabled": true, + "skipCertificateValidation": false + } + } + ] +} diff --git a/internal/cmd/beta/alb/update/update.go b/internal/cmd/beta/alb/update/update.go new file mode 100644 index 000000000..b8ba1b323 --- /dev/null +++ b/internal/cmd/beta/alb/update/update.go @@ -0,0 +1,197 @@ +package update + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/goccy/go-yaml" + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "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/alb" + "github.com/stackitcloud/stackit-sdk-go/services/alb/wait" +) + +const ( + configurationFlag = "configuration" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Configuration *string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates an application loadbalancer", + Long: "Updates an application loadbalancer.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Update an application loadbalancer from a configuration file`, + "$ stackit beta alb update --configuration my-loadbalancer.json"), + ), + 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 + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update an application loadbalancer for project %q?", projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update application loadbalancer: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("updating loadbalancer") + _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for loadbalancer creation: %w", err) + } + s.Stop() + } + + return outputResult(p, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(configurationFlag, "c", "", "filename of the input configuration file") + err := flags.MarkFlagsRequired(cmd, configurationFlag) + cobra.CheckErr(err) +} + +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, + Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag), + } + + 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) (req alb.ApiUpdateLoadBalancerRequest, err error) { + payload, err := readPayload(ctx, model) + if err != nil { + return req, err + } + req = apiClient.UpdateLoadBalancer(ctx, model.ProjectId, model.Region, *payload.Name) + return req.UpdateLoadBalancerPayload(payload), nil +} + +func readPayload(ctx context.Context, model *inputModel) (payload alb.UpdateLoadBalancerPayload, err error) { + if model.Configuration == nil { + return payload, fmt.Errorf("no configuration file defined") + } + file, err := os.Open(*model.Configuration) + if err != nil { + return payload, fmt.Errorf("cannot open configuration file %q: %w", *model.Configuration, err) + } + defer file.Close() + + if strings.HasSuffix(*model.Configuration, ".yaml") { + decoder := yaml.NewDecoder(bufio.NewReader(file), yaml.UseJSONUnmarshaler()) + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("cannot deserialize yaml configuration from %q: %w", *model.Configuration, err) + } + } else if strings.HasSuffix(*model.Configuration, ".json") { + decoder := json.NewDecoder(bufio.NewReader(file)) + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("cannot deserialize json configuration from %q: %w", *model.Configuration, err) + } + } else { + return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", err) + } + + return payload, nil +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *alb.LoadBalancer) error { + if resp == nil { + return fmt.Errorf("update loadbalancer response is empty") + } + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal loadbalancer: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal loadbalancer: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + operationState := "Updated" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s application loadbalancer for %q. Name: %s\n", operationState, projectLabel, utils.PtrString(resp.Name)) + return nil + } +} diff --git a/internal/cmd/beta/alb/update/update_test.go b/internal/cmd/beta/alb/update/update_test.go new file mode 100644 index 000000000..176a130a3 --- /dev/null +++ b/internal/cmd/beta/alb/update/update_test.go @@ -0,0 +1,262 @@ +package update + +import ( + "context" + _ "embed" + "encoding/json" + "log" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "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/stackitcloud/stackit-sdk-go/services/alb" +) + +//go:embed testdata/testconfig.json +var testConfiguration []byte + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &alb.APIClient{} + testProjectId = uuid.NewString() + testRegion = "eu01" + testLoadBalancer = "my-load-balancer" + testConfig = "testdata/testconfig.json" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + configurationFlag: testConfig, + 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, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Configuration: utils.Ptr(testConfig), + } + for _, mod := range mods { + mod(model) + } + return model +} +func fixturePayload(mods ...func(payload *alb.UpdateLoadBalancerPayload)) (payload alb.UpdateLoadBalancerPayload) { + if err := json.Unmarshal(testConfiguration, &payload); err != nil { + log.Panicf("cannot deserialize test configuration: %v", err) + } + for _, f := range mods { + f(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *alb.ApiUpdateLoadBalancerRequest)) alb.ApiUpdateLoadBalancerRequest { + request := testClient.UpdateLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancer) + + request = request.UpdateLoadBalancerPayload(fixturePayload()) + 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: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + configurationFlag: testConfig, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Configuration: &testConfig, + }, + }, + { + 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, + }, + } + + 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.ApiUpdateLoadBalancerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Configuration: &testConfig, + }, + expectedRequest: testClient. + UpdateLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancer). + UpdateLoadBalancerPayload(fixturePayload()), + }, + } + + 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 create request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + resp *alb.LoadBalancer + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty response as argument", + args: args{ + model: fixtureInputModel(), + resp: &alb.LoadBalancer{}, + }, + 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.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From 8229c6fe00d43dde96924234ff3861405acea66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Tue, 8 Apr 2025 17:07:33 +0200 Subject: [PATCH 07/22] feat(alb): add describe command --- internal/cmd/beta/alb/describe/describe.go | 144 +++++++++++ .../cmd/beta/alb/describe/describe_test.go | 224 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 internal/cmd/beta/alb/describe/describe.go create mode 100644 internal/cmd/beta/alb/describe/describe_test.go diff --git a/internal/cmd/beta/alb/describe/describe.go b/internal/cmd/beta/alb/describe/describe.go new file mode 100644 index 000000000..fd547da6a --- /dev/null +++ b/internal/cmd/beta/alb/describe/describe.go @@ -0,0 +1,144 @@ +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 ( + loadbalancerNameArg = "LOADBALANCER_NAME_ARG" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", loadbalancerNameArg), + Short: "Describes an application loadbalancer", + Long: "Describes an application loadbalancer.", + Args: args.SingleArg(loadbalancerNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Get details about an application loadbalancer with name "my-load-balancer"`, + "$ stackit beta alb describe my-load-balancer", + ), + ), + 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 loadbalancer: %w", err) + } + + if loadbalancer := resp; loadbalancer != nil { + return outputResult(p, model.OutputFormat, *loadbalancer) + } + p.Outputln("No load balancer found.") + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + loadbalancerName := inputArgs[0] + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: loadbalancerName, + } + + 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.ApiGetLoadBalancerRequest { + return apiClient.GetLoadBalancer(ctx, model.ProjectId, model.Region, model.Name) +} + +func outputResult(p *print.Printer, outputFormat string, response alb.LoadBalancer) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(response, "", " ") + + if err != nil { + return fmt.Errorf("marshal loadbalancer: %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 loadbalancer: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.AddRow("EXTERNAL ADDRESS", utils.PtrString(response.ExternalAddress)) + table.AddSeparator() + var numErrors int + if response.Errors != nil { + numErrors = len(*response.Errors) + } + table.AddRow("NUMBER OF ERRORS", numErrors) + table.AddSeparator() + table.AddRow("PLAN ID", utils.PtrString(response.PlanId)) + table.AddSeparator() + table.AddRow("REGION", utils.PtrString(response.Region)) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(response.Status)) + table.AddSeparator() + table.AddRow("VERSION", utils.PtrString(response.Version)) + + p.Outputln(table.Render()) + } + + return nil +} diff --git a/internal/cmd/beta/alb/describe/describe_test.go b/internal/cmd/beta/alb/describe/describe_test.go new file mode 100644 index 000000000..8ab382e46 --- /dev/null +++ b/internal/cmd/beta/alb/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{} + testLoadBalancerName = "my-test-loadbalancer" +) + +func fixtureArgValues(mods ...func(argVales []string)) []string { + argVales := []string{ + testLoadBalancerName, + } + 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, + }, + Name: testLoadBalancerName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiGetLoadBalancerRequest)) alb.ApiGetLoadBalancerRequest { + request := testClient.GetLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancerName) + 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.ApiGetLoadBalancerRequest + }{ + { + 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.LoadBalancer + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + outputFormat: "", + showOnlyPublicKey: false, + response: alb.LoadBalancer{}, + }, + 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) + } + }) + } +} From 51443fa6ab89163b3f2f3e39f1a379128098c232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Tue, 8 Apr 2025 17:13:07 +0200 Subject: [PATCH 08/22] feat(alb): delete command --- internal/cmd/beta/alb/alb.go | 4 + internal/cmd/beta/alb/delete/delete.go | 92 ++++++++++ internal/cmd/beta/alb/delete/delete_test.go | 192 ++++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 internal/cmd/beta/alb/delete/delete.go create mode 100644 internal/cmd/beta/alb/delete/delete_test.go diff --git a/internal/cmd/beta/alb/alb.go b/internal/cmd/beta/alb/alb.go index 018dbee45..180c711ec 100644 --- a/internal/cmd/beta/alb/alb.go +++ b/internal/cmd/beta/alb/alb.go @@ -2,6 +2,8 @@ package alb import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/create" + "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" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/template" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/update" @@ -31,5 +33,7 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { template.NewCmd(p), create.NewCmd(p), update.NewCmd(p), + describe.NewCmd(p), + delete.NewCmd(p), ) } diff --git a/internal/cmd/beta/alb/delete/delete.go b/internal/cmd/beta/alb/delete/delete.go new file mode 100644 index 000000000..dbefb86b2 --- /dev/null +++ b/internal/cmd/beta/alb/delete/delete.go @@ -0,0 +1,92 @@ +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 ( + loadbalancerNameArg = "LOADBALANCER_NAME_ARG" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", loadbalancerNameArg), + Short: "Deletes an application loadbalancer", + Long: "Deletes an application loadbalancer.", + Args: args.SingleArg(loadbalancerNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete an application loadbalancer with name "my-load-balancer"`, + "$ stackit beta alb delete my-load-balancer", + ), + ), + 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) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete loadbalancer: %w", err) + } + + p.Outputln("Load balancer deleted.") + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + loadbalancerName := inputArgs[0] + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: loadbalancerName, + } + + 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.ApiDeleteLoadBalancerRequest { + return apiClient.DeleteLoadBalancer(ctx, model.ProjectId, model.Region, model.Name) +} diff --git a/internal/cmd/beta/alb/delete/delete_test.go b/internal/cmd/beta/alb/delete/delete_test.go new file mode 100644 index 000000000..3bc65bfd1 --- /dev/null +++ b/internal/cmd/beta/alb/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/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{} + testLoadBalancerName = "my-test-loadbalancer" +) + +func fixtureArgValues(mods ...func(argVales []string)) []string { + argVales := []string{ + testLoadBalancerName, + } + 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, + }, + Name: testLoadBalancerName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *alb.ApiDeleteLoadBalancerRequest)) alb.ApiDeleteLoadBalancerRequest { + request := testClient.DeleteLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancerName) + 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.ApiDeleteLoadBalancerRequest + }{ + { + 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) + } + }) + } +} From 75ff4171b28ac04f71bfbb7a7f280ae0f2f71655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Tue, 8 Apr 2025 17:31:14 +0200 Subject: [PATCH 09/22] feat(alb): fix linter issues --- internal/cmd/beta/alb/create/create.go | 6 +++--- internal/cmd/beta/alb/delete/delete.go | 2 +- internal/cmd/beta/alb/describe/describe.go | 8 ++------ .../cmd/beta/alb/describe/describe_test.go | 4 ++-- internal/cmd/beta/alb/template/template.go | 4 +++- .../cmd/beta/alb/template/template_test.go | 19 ++----------------- internal/cmd/beta/alb/update/update.go | 6 +++--- internal/cmd/beta/beta.go | 2 +- internal/pkg/services/alb/utils/utils.go | 1 - internal/pkg/services/alb/utils/utils_test.go | 1 - 10 files changed, 17 insertions(+), 36 deletions(-) diff --git a/internal/cmd/beta/alb/create/create.go b/internal/cmd/beta/alb/create/create.go index 230536457..df1008924 100644 --- a/internal/cmd/beta/alb/create/create.go +++ b/internal/cmd/beta/alb/create/create.go @@ -138,7 +138,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie return req.CreateLoadBalancerPayload(payload), nil } -func readPayload(ctx context.Context, model *inputModel) (payload alb.CreateLoadBalancerPayload, err error) { +func readPayload(_ context.Context, model *inputModel) (payload alb.CreateLoadBalancerPayload, err error) { if model.Configuration == nil { return payload, fmt.Errorf("no configuration file defined") } @@ -146,7 +146,7 @@ func readPayload(ctx context.Context, model *inputModel) (payload alb.CreateLoad if err != nil { return payload, fmt.Errorf("cannot open configuration file %q: %w", *model.Configuration, err) } - defer file.Close() + defer file.Close() // nolint:errcheck // at this point close errors are not relevant anymore if strings.HasSuffix(*model.Configuration, ".yaml") { decoder := yaml.NewDecoder(bufio.NewReader(file), yaml.UseJSONUnmarshaler()) @@ -159,7 +159,7 @@ func readPayload(ctx context.Context, model *inputModel) (payload alb.CreateLoad return payload, fmt.Errorf("cannot deserialize json configuration from %q: %w", *model.Configuration, err) } } else { - return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", err) + return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", *model.Configuration) } return payload, nil diff --git a/internal/cmd/beta/alb/delete/delete.go b/internal/cmd/beta/alb/delete/delete.go index dbefb86b2..631bd6d5c 100644 --- a/internal/cmd/beta/alb/delete/delete.go +++ b/internal/cmd/beta/alb/delete/delete.go @@ -63,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return cmd } -func configureFlags(cmd *cobra.Command) { +func configureFlags(_ *cobra.Command) { } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { diff --git a/internal/cmd/beta/alb/describe/describe.go b/internal/cmd/beta/alb/describe/describe.go index fd547da6a..46a65abef 100644 --- a/internal/cmd/beta/alb/describe/describe.go +++ b/internal/cmd/beta/alb/describe/describe.go @@ -61,19 +61,15 @@ func NewCmd(p *print.Printer) *cobra.Command { } if loadbalancer := resp; loadbalancer != nil { - return outputResult(p, model.OutputFormat, *loadbalancer) + return outputResult(p, model.OutputFormat, loadbalancer) } p.Outputln("No load balancer found.") return nil }, } - configureFlags(cmd) return cmd } -func configureFlags(cmd *cobra.Command) { -} - func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) @@ -99,7 +95,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie return apiClient.GetLoadBalancer(ctx, model.ProjectId, model.Region, model.Name) } -func outputResult(p *print.Printer, outputFormat string, response alb.LoadBalancer) error { +func outputResult(p *print.Printer, outputFormat string, response *alb.LoadBalancer) error { switch outputFormat { case print.JSONOutputFormat: details, err := json.MarshalIndent(response, "", " ") diff --git a/internal/cmd/beta/alb/describe/describe_test.go b/internal/cmd/beta/alb/describe/describe_test.go index 8ab382e46..0f621c72e 100644 --- a/internal/cmd/beta/alb/describe/describe_test.go +++ b/internal/cmd/beta/alb/describe/describe_test.go @@ -195,7 +195,7 @@ func Test_outputResult(t *testing.T) { type args struct { outputFormat string showOnlyPublicKey bool - response alb.LoadBalancer + response *alb.LoadBalancer } tests := []struct { name string @@ -207,7 +207,7 @@ func Test_outputResult(t *testing.T) { args: args{ outputFormat: "", showOnlyPublicKey: false, - response: alb.LoadBalancer{}, + response: &alb.LoadBalancer{}, }, wantErr: false, }, diff --git a/internal/cmd/beta/alb/template/template.go b/internal/cmd/beta/alb/template/template.go index eb1cfa46b..46759e371 100644 --- a/internal/cmd/beta/alb/template/template.go +++ b/internal/cmd/beta/alb/template/template.go @@ -71,7 +71,9 @@ func NewCmd(p *print.Printer) *cobra.Command { } else { return fmt.Errorf("invalid format %q defined. Must be 'json' or 'yaml'", *model.Format) } - io.Copy(os.Stdout, reader) + if _, err := io.Copy(os.Stdout, reader); err != nil { + return fmt.Errorf("cannot write output: %w", err) + } _, _ = ctx, model diff --git a/internal/cmd/beta/alb/template/template_test.go b/internal/cmd/beta/alb/template/template_test.go index 9f81f9a6c..c528c230f 100644 --- a/internal/cmd/beta/alb/template/template_test.go +++ b/internal/cmd/beta/alb/template/template_test.go @@ -1,7 +1,6 @@ package template import ( - "context" "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -9,17 +8,11 @@ import ( "github.com/google/go-cmp/cmp" "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" - testLimit int64 = 10 + testProjectId = uuid.NewString() + testRegion = "eu01" ) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { @@ -43,14 +36,6 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } -func fixtureRequest(mods ...func(request *alb.ApiListLoadBalancersRequest)) alb.ApiListLoadBalancersRequest { - request := testClient.ListLoadBalancers(context.Background(), testProjectId, testRegion) - for _, mod := range mods { - mod(&request) - } - return request -} - func TestParseInput(t *testing.T) { tests := []struct { description string diff --git a/internal/cmd/beta/alb/update/update.go b/internal/cmd/beta/alb/update/update.go index b8ba1b323..72c163392 100644 --- a/internal/cmd/beta/alb/update/update.go +++ b/internal/cmd/beta/alb/update/update.go @@ -138,7 +138,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie return req.UpdateLoadBalancerPayload(payload), nil } -func readPayload(ctx context.Context, model *inputModel) (payload alb.UpdateLoadBalancerPayload, err error) { +func readPayload(_ context.Context, model *inputModel) (payload alb.UpdateLoadBalancerPayload, err error) { if model.Configuration == nil { return payload, fmt.Errorf("no configuration file defined") } @@ -146,7 +146,7 @@ func readPayload(ctx context.Context, model *inputModel) (payload alb.UpdateLoad if err != nil { return payload, fmt.Errorf("cannot open configuration file %q: %w", *model.Configuration, err) } - defer file.Close() + defer file.Close() // nolint:errcheck // at this point close errors are not relevant anymore if strings.HasSuffix(*model.Configuration, ".yaml") { decoder := yaml.NewDecoder(bufio.NewReader(file), yaml.UseJSONUnmarshaler()) @@ -159,7 +159,7 @@ func readPayload(ctx context.Context, model *inputModel) (payload alb.UpdateLoad return payload, fmt.Errorf("cannot deserialize json configuration from %q: %w", *model.Configuration, err) } } else { - return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", err) + return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", *model.Configuration) } return payload, nil diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 7a845ca1b..37b5e4bd9 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -3,8 +3,8 @@ package beta import ( "fmt" - "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/print" diff --git a/internal/pkg/services/alb/utils/utils.go b/internal/pkg/services/alb/utils/utils.go index 3c5744952..7a32e1986 100644 --- a/internal/pkg/services/alb/utils/utils.go +++ b/internal/pkg/services/alb/utils/utils.go @@ -2,4 +2,3 @@ package utils type AlbClient interface { } - diff --git a/internal/pkg/services/alb/utils/utils_test.go b/internal/pkg/services/alb/utils/utils_test.go index 03a40b064..489b7a2c5 100644 --- a/internal/pkg/services/alb/utils/utils_test.go +++ b/internal/pkg/services/alb/utils/utils_test.go @@ -2,4 +2,3 @@ package utils type AlbClientMocked struct { } - From efd1725a4ded59e49273b235dd54a0e5959ff238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:00:35 +0200 Subject: [PATCH 10/22] feat(alb): code cleanup --- internal/cmd/beta/alb/template/template.go | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/internal/cmd/beta/alb/template/template.go b/internal/cmd/beta/alb/template/template.go index 46759e371..c7a536934 100644 --- a/internal/cmd/beta/alb/template/template.go +++ b/internal/cmd/beta/alb/template/template.go @@ -1,12 +1,9 @@ package template import ( - "bytes" - "context" _ "embed" "encoding/json" "fmt" - "io" "os" "github.com/goccy/go-yaml" @@ -30,7 +27,7 @@ type inputModel struct { } //go:embed template.json -var template []byte +var template string func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ @@ -49,33 +46,25 @@ func NewCmd(p *print.Printer) *cobra.Command { ), ), RunE: func(cmd *cobra.Command, _ []string) error { - ctx := context.Background() model, err := parseInput(p, cmd) if err != nil { return err } - var reader io.Reader if model.Format == nil || *model.Format == "json" { - reader = bytes.NewReader(template) + p.Outputln(template) } else if *model.Format == "yaml" { var target alb.CreateLoadBalancerPayload - if err := json.Unmarshal(template, &target); err != nil { + if err := json.Unmarshal([]byte(template), &target); err != nil { return fmt.Errorf("cannot unmarshal template: %w", err) } - data, err := yaml.Marshal(&target) - if err != nil { + encoder := yaml.NewEncoder(os.Stdout, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if encoder.Encode(target); err != nil { return fmt.Errorf("cannot marshal template to yaml: %w", err) } - reader = bytes.NewReader(data) } else { return fmt.Errorf("invalid format %q defined. Must be 'json' or 'yaml'", *model.Format) } - if _, err := io.Copy(os.Stdout, reader); err != nil { - return fmt.Errorf("cannot write output: %w", err) - } - - _, _ = ctx, model return nil }, From 724f304a2423fc4ffb9594a0e3824a83b0427ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:14:35 +0200 Subject: [PATCH 11/22] feat(alb): template support for pool --- .../{template.json => template-alb.json} | 0 .../cmd/beta/alb/template/template-pool.json | 28 +++++++++ internal/cmd/beta/alb/template/template.go | 39 +++++++++---- .../cmd/beta/alb/template/template_test.go | 58 +++++++++++++++++++ 4 files changed, 115 insertions(+), 10 deletions(-) rename internal/cmd/beta/alb/template/{template.json => template-alb.json} (100%) create mode 100644 internal/cmd/beta/alb/template/template-pool.json diff --git a/internal/cmd/beta/alb/template/template.json b/internal/cmd/beta/alb/template/template-alb.json similarity index 100% rename from internal/cmd/beta/alb/template/template.json rename to internal/cmd/beta/alb/template/template-alb.json diff --git a/internal/cmd/beta/alb/template/template-pool.json b/internal/cmd/beta/alb/template/template-pool.json new file mode 100644 index 000000000..4dda3730c --- /dev/null +++ b/internal/cmd/beta/alb/template/template-pool.json @@ -0,0 +1,28 @@ +{ + "activeHealthCheck": { + "healthyThreshold": 1, + "httpHealthChecks": { + "okStatuses": [ + "string" + ], + "path": "string" + }, + "interval": "3s", + "intervalJitter": "3s", + "timeout": "3s", + "unhealthyThreshold": 1 + }, + "name": "my-target-pool", + "targetPort": 5732, + "targets": [ + { + "displayName": "my-target", + "ip": "192.0.2.5" + } + ], + "tlsConfig": { + "customCa": "string", + "enabled": true, + "skipCertificateValidation": true + } + } \ No newline at end of file diff --git a/internal/cmd/beta/alb/template/template.go b/internal/cmd/beta/alb/template/template.go index c7a536934..e7b7f1a86 100644 --- a/internal/cmd/beta/alb/template/template.go +++ b/internal/cmd/beta/alb/template/template.go @@ -19,30 +19,36 @@ import ( const ( formatFlag = "format" + typeFlag = "type" ) type inputModel struct { *globalflags.GlobalFlagModel Format *string + Type *string } -//go:embed template.json -var template string +var ( + //go:embed template-alb.json + templateAlb string + //go:embed template-pool.json + templatePool string +) func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: "template", - Short: "create an alb template", - Long: "creates a json or yaml template file for creating/updating an application loadbalancer.", + Short: "creates configuration templates to use for resource creation", + Long: "creates a json or yaml template file for creating/updating an application loadbalancer or target pool.", Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `Creat a yaml template`, - `$ stackit beta alb template --format=yaml`, + `Create a yaml template`, + `$ stackit beta alb template --format=yaml --type alb`, ), examples.NewExample( - `Creat a json template`, - `$ stackit beta alb template --format=json`, + `Create a json template`, + `$ stackit beta alb template --format=json --type pool`, ), ), RunE: func(cmd *cobra.Command, _ []string) error { @@ -51,10 +57,21 @@ func NewCmd(p *print.Printer) *cobra.Command { return err } + var ( + template string + target any + ) + if model.Type != nil && *model.Type == "pool" { + template = templatePool + target = alb.CreateLoadBalancerPayload{} + } else { + template = templateAlb + target = alb.UpdateTargetPoolPayload{} + } + if model.Format == nil || *model.Format == "json" { p.Outputln(template) } else if *model.Format == "yaml" { - var target alb.CreateLoadBalancerPayload if err := json.Unmarshal([]byte(template), &target); err != nil { return fmt.Errorf("cannot unmarshal template: %w", err) } @@ -75,7 +92,8 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().VarP(flags.EnumFlag(true, "json", "json", "yaml"), formatFlag, "f", "Defines the output format (yaml or json), default is json") + cmd.Flags().VarP(flags.EnumFlag(true, "json", "json", "yaml"), formatFlag, "f", "Defines the output format ('yaml' or 'json'), default is 'json'") + cmd.Flags().VarP(flags.EnumFlag(true, "alb", "alb", "pool"), typeFlag, "t", "Defines the output type ('alb' or 'pool'), default is 'alb'") } func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { @@ -87,6 +105,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { model := inputModel{ GlobalFlagModel: globalFlags, Format: flags.FlagToStringPointer(p, cmd, formatFlag), + Type: flags.FlagToStringPointer(p, cmd, typeFlag), } if p.IsVerbosityDebug() { diff --git a/internal/cmd/beta/alb/template/template_test.go b/internal/cmd/beta/alb/template/template_test.go index c528c230f..3ca5404e8 100644 --- a/internal/cmd/beta/alb/template/template_test.go +++ b/internal/cmd/beta/alb/template/template_test.go @@ -5,6 +5,7 @@ import ( "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/uuid" @@ -75,6 +76,63 @@ func TestParseInput(t *testing.T) { }), isValid: false, }, + { + description: "alb with yaml", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[formatFlag] = "yaml" + flagValues[typeFlag] = "alb" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Format = utils.Ptr("yaml") + model.Type = utils.Ptr("alb") + }), + }, { + description: "alb with yaml", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[formatFlag] = "yaml" + flagValues[typeFlag] = "alb" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Format = utils.Ptr("yaml") + model.Type = utils.Ptr("alb") + }), + }, { + description: "alb with json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[formatFlag] = "json" + flagValues[typeFlag] = "alb" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Format = utils.Ptr("json") + model.Type = utils.Ptr("alb") + }), + }, { + description: "pool with yaml", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[formatFlag] = "yaml" + flagValues[typeFlag] = "pool" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Format = utils.Ptr("yaml") + model.Type = utils.Ptr("pool") + }), + }, + { + description: "pool with json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[formatFlag] = "json" + flagValues[typeFlag] = "pool" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Format = utils.Ptr("json") + model.Type = utils.Ptr("pool") + }), + }, } for _, tt := range tests { From 409d6a75286215d1d62e6e2e00712d6e3538e4d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Wed, 9 Apr 2025 09:36:01 +0200 Subject: [PATCH 12/22] feat(alb): added target pool update --- internal/cmd/beta/alb/alb.go | 2 + internal/cmd/beta/alb/pool/pool.go | 27 ++ .../alb/pool/update/testdata/testconfig.json | 28 ++ internal/cmd/beta/alb/pool/update/update.go | 192 ++++++++++++ .../cmd/beta/alb/pool/update/update_test.go | 273 ++++++++++++++++++ internal/cmd/beta/alb/template/template.go | 2 +- internal/cmd/beta/alb/update/update.go | 4 +- 7 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/beta/alb/pool/pool.go create mode 100644 internal/cmd/beta/alb/pool/update/testdata/testconfig.json create mode 100644 internal/cmd/beta/alb/pool/update/update.go create mode 100644 internal/cmd/beta/alb/pool/update/update_test.go diff --git a/internal/cmd/beta/alb/alb.go b/internal/cmd/beta/alb/alb.go index 180c711ec..edb78ebbe 100644 --- a/internal/cmd/beta/alb/alb.go +++ b/internal/cmd/beta/alb/alb.go @@ -5,6 +5,7 @@ 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" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/pool" "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" @@ -35,5 +36,6 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { update.NewCmd(p), describe.NewCmd(p), delete.NewCmd(p), + pool.NewCmd(p), ) } diff --git a/internal/cmd/beta/alb/pool/pool.go b/internal/cmd/beta/alb/pool/pool.go new file mode 100644 index 000000000..5d8d5fd41 --- /dev/null +++ b/internal/cmd/beta/alb/pool/pool.go @@ -0,0 +1,27 @@ +package pool + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/pool/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "pool", + Short: "Manages target pools for application loadbalancers", + Long: "Manage the lifecycle of target pools for application loadbalancers.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(update.NewCmd(p)) +} diff --git a/internal/cmd/beta/alb/pool/update/testdata/testconfig.json b/internal/cmd/beta/alb/pool/update/testdata/testconfig.json new file mode 100644 index 000000000..0486c66f4 --- /dev/null +++ b/internal/cmd/beta/alb/pool/update/testdata/testconfig.json @@ -0,0 +1,28 @@ +{ + "activeHealthCheck": { + "healthyThreshold": 1, + "httpHealthChecks": { + "okStatuses": [ + "string" + ], + "path": "string" + }, + "interval": "3s", + "intervalJitter": "3s", + "timeout": "3s", + "unhealthyThreshold": 1 + }, + "name": "my-target-pool", + "targetPort": 5732, + "targets": [ + { + "displayName": "my-target", + "ip": "192.0.2.5" + } + ], + "tlsConfig": { + "customCa": "string", + "enabled": true, + "skipCertificateValidation": true + } +} \ No newline at end of file diff --git a/internal/cmd/beta/alb/pool/update/update.go b/internal/cmd/beta/alb/pool/update/update.go new file mode 100644 index 000000000..ec2ef43bd --- /dev/null +++ b/internal/cmd/beta/alb/pool/update/update.go @@ -0,0 +1,192 @@ +package update + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/goccy/go-yaml" + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +const ( + configurationFlag = "configuration" + albNameFlag = "name" + poolNameFlag = "pool" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Configuration *string + AlbName *string + Poolname *string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates an application target pool", + Long: "Updates an application target pool.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Update an application target pool from a configuration file`, + "$ stackit beta alb update --configuration my-target pool.json"), + ), + 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 + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update an application target pool for project %q?", projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update application target pool: %w", err) + } + + return outputResult(p, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(configurationFlag, "c", "", "filename of the input configuration file") + cmd.Flags().StringP(albNameFlag, "n", "", "name of the target pool name to update") + cmd.Flags().StringP(poolNameFlag, "t", "", "name of the target pool to update") + err := flags.MarkFlagsRequired(cmd, configurationFlag, albNameFlag, poolNameFlag) + cobra.CheckErr(err) +} + +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, + Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag), + AlbName: flags.FlagToStringPointer(p, cmd, albNameFlag), + Poolname: flags.FlagToStringPointer(p, cmd, poolNameFlag), + } + + 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) (req alb.ApiUpdateTargetPoolRequest, err error) { + payload, err := readPayload(ctx, model) + if err != nil { + return req, err + } + req = apiClient.UpdateTargetPool(ctx, model.ProjectId, model.Region, *model.AlbName, *model.Poolname) + return req.UpdateTargetPoolPayload(payload), nil +} + +func readPayload(_ context.Context, model *inputModel) (payload alb.UpdateTargetPoolPayload, err error) { + if model.Configuration == nil { + return payload, fmt.Errorf("no configuration file defined") + } + file, err := os.Open(*model.Configuration) + if err != nil { + return payload, fmt.Errorf("cannot open configuration file %q: %w", *model.Configuration, err) + } + defer file.Close() // nolint:errcheck // at this point close errors are not relevant anymore + + if strings.HasSuffix(*model.Configuration, ".yaml") { + decoder := yaml.NewDecoder(bufio.NewReader(file), yaml.UseJSONUnmarshaler()) + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("cannot deserialize yaml configuration from %q: %w", *model.Configuration, err) + } + } else if strings.HasSuffix(*model.Configuration, ".json") { + decoder := json.NewDecoder(bufio.NewReader(file)) + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("cannot deserialize json configuration from %q: %w", *model.Configuration, err) + } + } else { + return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", *model.Configuration) + } + + return payload, nil +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *alb.TargetPool) error { + if resp == nil { + return fmt.Errorf("update target pool response is empty") + } + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal target pool: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal target pool: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Outputf("%s application target pool for %q. Name: %s\n", operationState, projectLabel, utils.PtrString(resp.Name)) + return nil + } +} diff --git a/internal/cmd/beta/alb/pool/update/update_test.go b/internal/cmd/beta/alb/pool/update/update_test.go new file mode 100644 index 000000000..ec87f9fd5 --- /dev/null +++ b/internal/cmd/beta/alb/pool/update/update_test.go @@ -0,0 +1,273 @@ +package update + +import ( + "context" + _ "embed" + "encoding/json" + "log" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "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/stackitcloud/stackit-sdk-go/services/alb" +) + +//go:embed testdata/testconfig.json +var testConfiguration []byte + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &alb.APIClient{} + testProjectId = uuid.NewString() + testRegion = "eu01" + testLoadBalancer = "my-load-balancer" + testPool = "my-target" + testConfig = "testdata/testconfig.json" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + configurationFlag: testConfig, + globalflags.RegionFlag: testRegion, + albNameFlag: testLoadBalancer, + poolNameFlag: testPool, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Configuration: utils.Ptr(testConfig), + AlbName: &testLoadBalancer, + Poolname: &testPool, + } + for _, mod := range mods { + mod(model) + } + return model +} +func fixturePayload(mods ...func(payload *alb.UpdateTargetPoolPayload)) (payload alb.UpdateTargetPoolPayload) { + if err := json.Unmarshal(testConfiguration, &payload); err != nil { + log.Panicf("cannot deserialize test configuration: %v", err) + } + for _, f := range mods { + f(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *alb.ApiUpdateTargetPoolRequest)) alb.ApiUpdateTargetPoolRequest { + request := testClient.UpdateTargetPool(testCtx, testProjectId, testRegion, testLoadBalancer, testPool) + + request = request.UpdateTargetPoolPayload(fixturePayload()) + 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: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + configurationFlag: testConfig, + albNameFlag: testLoadBalancer, + poolNameFlag: testPool, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Configuration: &testConfig, + AlbName: &testLoadBalancer, + Poolname: &testPool, + }, + }, + { + 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, + }, + } + + 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.ApiUpdateTargetPoolRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Configuration: &testConfig, + AlbName: &testLoadBalancer, + Poolname: &testPool, + }, + expectedRequest: testClient. + UpdateTargetPool(testCtx, testProjectId, testRegion, testLoadBalancer, testPool). + UpdateTargetPoolPayload(fixturePayload()), + }, + } + + 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 create request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + resp *alb.TargetPool + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty response as argument", + args: args{ + model: fixtureInputModel(), + resp: &alb.TargetPool{}, + }, + 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.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/alb/template/template.go b/internal/cmd/beta/alb/template/template.go index e7b7f1a86..1e4b2477d 100644 --- a/internal/cmd/beta/alb/template/template.go +++ b/internal/cmd/beta/alb/template/template.go @@ -76,7 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("cannot unmarshal template: %w", err) } encoder := yaml.NewEncoder(os.Stdout, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if encoder.Encode(target); err != nil { + if err := encoder.Encode(target); err != nil { return fmt.Errorf("cannot marshal template to yaml: %w", err) } } else { diff --git a/internal/cmd/beta/alb/update/update.go b/internal/cmd/beta/alb/update/update.go index 72c163392..ed0034614 100644 --- a/internal/cmd/beta/alb/update/update.go +++ b/internal/cmd/beta/alb/update/update.go @@ -88,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command { s.Start("updating loadbalancer") _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).WaitWithContext(ctx) if err != nil { - return fmt.Errorf("wait for loadbalancer creation: %w", err) + return fmt.Errorf("wait for loadbalancer update: %w", err) } s.Stop() } @@ -189,7 +189,7 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp default: operationState := "Updated" if model.Async { - operationState = "Triggered creation of" + operationState = "Triggered update of" } p.Outputf("%s application loadbalancer for %q. Name: %s\n", operationState, projectLabel, utils.PtrString(resp.Name)) return nil From baf91fb93e27a3b3abf1ccd7f3659c20eea2ec26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:20:18 +0200 Subject: [PATCH 13/22] feat(alb): add list of errors to describe output --- internal/cmd/beta/alb/describe/describe.go | 9 +++++++++ internal/cmd/beta/alb/list/list.go | 1 - 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/cmd/beta/alb/describe/describe.go b/internal/cmd/beta/alb/describe/describe.go index 46a65abef..4c926976b 100644 --- a/internal/cmd/beta/alb/describe/describe.go +++ b/internal/cmd/beta/alb/describe/describe.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -132,6 +133,14 @@ func outputResult(p *print.Printer, outputFormat string, response *alb.LoadBalan table.AddRow("STATUS", utils.PtrString(response.Status)) table.AddSeparator() table.AddRow("VERSION", utils.PtrString(response.Version)) + if response.Errors != nil { + table.AddSeparator() + var builder strings.Builder + for _, err := range *response.Errors { + builder.WriteString(fmt.Sprintf("[%s] %s\n", utils.PtrString(err.Type), utils.PtrString(err.Description))) + } + table.AddRow("ERRORS", builder.String()) + } p.Outputln(table.Render()) } diff --git a/internal/cmd/beta/alb/list/list.go b/internal/cmd/beta/alb/list/list.go index 7480d66dd..743fd5da8 100644 --- a/internal/cmd/beta/alb/list/list.go +++ b/internal/cmd/beta/alb/list/list.go @@ -161,7 +161,6 @@ func outputResult(p *print.Printer, outputFormat string, items []alb.LoadBalance utils.PtrString(item.Region), utils.PtrString(item.Status), utils.PtrString(item.Version), - utils.PtrString(item.ExternalAddress), ) } err := table.Display(p) From be5af754b8617c93f635ab747bc36d478d4314cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:47:39 +0200 Subject: [PATCH 14/22] feat(alb): Include number of errors --- internal/cmd/beta/alb/list/list.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/cmd/beta/alb/list/list.go b/internal/cmd/beta/alb/list/list.go index 743fd5da8..4050e8791 100644 --- a/internal/cmd/beta/alb/list/list.go +++ b/internal/cmd/beta/alb/list/list.go @@ -154,13 +154,18 @@ func outputResult(p *print.Printer, outputFormat string, items []alb.LoadBalance return nil default: table := tables.NewTable() - table.SetHeader("NAME", "EXTERNAL ADDRESS", "REGION", "STATUS", "VERSION") + table.SetHeader("NAME", "EXTERNAL ADDRESS", "REGION", "STATUS", "VERSION", "ERRORS") for _, item := range items { + var errNo int + if item.Errors != nil { + errNo = len(*item.Errors) + } table.AddRow(utils.PtrString(item.Name), utils.PtrString(item.ExternalAddress), utils.PtrString(item.Region), utils.PtrString(item.Status), utils.PtrString(item.Version), + errNo, ) } err := table.Display(p) From e814b500569c8732affb066d9c1352bcf8a7bb68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:48:05 +0200 Subject: [PATCH 15/22] feat(alb): retrieve the existing loadbalancer config to evaluate the current version --- internal/cmd/beta/alb/update/update.go | 32 +++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/cmd/beta/alb/update/update.go b/internal/cmd/beta/alb/update/update.go index ed0034614..6c6b8d5d9 100644 --- a/internal/cmd/beta/alb/update/update.go +++ b/internal/cmd/beta/alb/update/update.go @@ -32,6 +32,7 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel Configuration *string + Version *string } func NewCmd(p *print.Printer) *cobra.Command { @@ -72,6 +73,11 @@ func NewCmd(p *print.Printer) *cobra.Command { } } + // for updates of an existing ALB the current version must be passed to the request + model.Version, err = getCurrentAlbVersion(ctx, apiClient, model) + if err != nil { + return err + } // Call API req, err := buildRequest(ctx, model, apiClient) if err != nil { @@ -86,7 +92,8 @@ func NewCmd(p *print.Printer) *cobra.Command { if !model.Async { s := spinner.New(p) s.Start("updating loadbalancer") - _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).WaitWithContext(ctx) + _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name). + WaitWithContext(ctx) if err != nil { return fmt.Errorf("wait for loadbalancer update: %w", err) } @@ -129,11 +136,34 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { return &model, nil } +func getCurrentAlbVersion(ctx context.Context, apiClient *alb.APIClient, model *inputModel) (*string, error) { + // use the configuration file to find the name of the loadbalancer + updatePayload, err := readPayload(ctx, model) + if err != nil { + return nil, err + } + if updatePayload.Name == nil { + return nil, fmt.Errorf("no name found in configuration") + } + if err != nil { + return nil, err + } + resp, err := apiClient.GetLoadBalancer(ctx, model.ProjectId, model.Region, *updatePayload.Name).Execute() + if err != nil { + return nil, err + } + return resp.Version, nil +} + func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) (req alb.ApiUpdateLoadBalancerRequest, err error) { payload, err := readPayload(ctx, model) if err != nil { return req, err } + if payload.Name == nil { + return req, fmt.Errorf("no name found in loadbalancer configuration") + } + payload.Version = model.Version req = apiClient.UpdateLoadBalancer(ctx, model.ProjectId, model.Region, *payload.Name) return req.UpdateLoadBalancerPayload(payload), nil } From 8db9bb6be563c17cb7b0b644848dc19e20a777bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Thu, 10 Apr 2025 14:22:32 +0200 Subject: [PATCH 16/22] feat(alb): switch source of templates to yaml to allow comments --- .../cmd/beta/alb/template/template-alb.json | 125 ------------------ .../cmd/beta/alb/template/template-alb.yaml | 91 +++++++++++++ .../cmd/beta/alb/template/template-pool.json | 28 ---- .../cmd/beta/alb/template/template-pool.yaml | 19 +++ internal/cmd/beta/alb/template/template.go | 12 +- 5 files changed, 116 insertions(+), 159 deletions(-) delete mode 100644 internal/cmd/beta/alb/template/template-alb.json create mode 100644 internal/cmd/beta/alb/template/template-alb.yaml delete mode 100644 internal/cmd/beta/alb/template/template-pool.json create mode 100644 internal/cmd/beta/alb/template/template-pool.yaml diff --git a/internal/cmd/beta/alb/template/template-alb.json b/internal/cmd/beta/alb/template/template-alb.json deleted file mode 100644 index b81294c66..000000000 --- a/internal/cmd/beta/alb/template/template-alb.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "externalAddress": "10.100.42.1", - "listeners": [ - { - "displayName": "listener1", - "http": {}, - "https": { - "certificateConfig": { - "certificateIds": [ - "cert-1", - "cert-2", - "cert-3" - ] - } - }, - "port": 443, - "protocol": "PROTOCOL_HTTPS", - "rules": [ - { - "host": "front.facing.host", - "http": { - "subRules": [ - { - "cookiePersistence": { - "name": "cookie1", - "ttl": "120s" - }, - "headers": [ - { - "name": "testheader1", - "exactMatch": "X-test-header1" - }, - { - "name": "testheader2", - "exactMatch": "X-test-header2" - }, - { - "name": "testheader3", - "exactMatch": "X-test-header3" - } - ], - "pathPrefix": "/foo", - "queryParameters": [ - { - "name": "query-param", - "exactMatch": "q" - }, - { - "name": "region", - "exactMatch": "region" - } - ], - "targetPool": "my-target-pool", - "webSocket": false - } - ] - } - } - ] - } - ], - "name": "my-load-balancer", - "networks": [ - { - "networkId": "00000000-0000-0000-0000-000000000000", - "role": "ROLE_LISTENERS_AND_TARGETS" - }, - { - "networkId": "00000000-0000-0000-0000-000000000001", - "role": "ROLE_LISTENERS_AND_TARGETS" - } - ], - "options": { - "accessControl": { - "allowedSourceRanges": [ - "192.168.42.0-192.168.42.10", - "192.168.54.0-192.168.54.10" - ] - }, - "ephemeralAddress": true, - "observability": { - "logs": { - "credentialsRef": "my-credentials", - "pushUrl": "https://my.observability.host//loki/api/v1/push" - }, - "metrics": { - "credentialsRef": "my-credentials", - "pushUrl": "https://my.observability.host///api/v1/receive" - } - }, - "privateNetworkOnly": true - }, - "planId": "p10", - "targetPools": [ - { - "activeHealthCheck": { - "healthyThreshold": 3, - "httpHealthChecks": { - "okStatuses": [ - "200", - "204" - ], - "path": "/health" - }, - "interval": "10s", - "intervalJitter": "3s", - "timeout": "5s", - "unhealthyThreshold": 1 - }, - "name": "my-target-pool", - "targetPort": 5732, - "targets": [ - { - "displayName": "my-target1", - "ip": "192.11.2.5" - } - ], - "tlsConfig": { - "customCa": "my.private.ca", - "enabled": true, - "skipCertificateValidation": false - } - } - ] -} diff --git a/internal/cmd/beta/alb/template/template-alb.yaml b/internal/cmd/beta/alb/template/template-alb.yaml new file mode 100644 index 000000000..be04ffb99 --- /dev/null +++ b/internal/cmd/beta/alb/template/template-alb.yaml @@ -0,0 +1,91 @@ +name: my-load-balancer +# public ip, must be removed when ephemeral address option is true +externalAddress: 123.123.123.123 + +# public listening interfaces of the loadbalancer +listeners: + - displayName: listener1 + # for plain http the https block must be removed + # http: {} + https: + certificateConfig: + certificateIds: + - cert-1 + - cert-2 + - cert-3 + port: 443 + # protocal may be PROTOCOL_HTTPS or PROTOCOL_HTTP + protocol: PROTOCOL_HTTPS + rules: + # fqdn of the virtual host of the load balancer + - host: front.facing.host + http: + subRules: + - cookiePersistence: + name: cookie1 + ttl: 120s + headers: + - name: testheader1 + exactMatch: X-test-header1 + - name: testheader2 + exactMatch: X-test-header2 + - name: testheader3 + exactMatch: X-test-header3 + pathPrefix: /foo + queryParameters: + - name: query-param + exactMatch: q + - name: region + exactMatch: region + targetPool: my-target-pool + webSocket: false +networks: + - networkId: 00000000-0000-0000-0000-000000000000 + role: ROLE_LISTENERS_AND_TARGETS + - networkId: 00000000-0000-0000-0000-000000000001 + role: ROLE_LISTENERS_AND_TARGETS +options: + accessControl: + # which host may access the loadbalancer in prefix notation + allowedSourceRanges: + - 10.100.42.0/24 + ephemeralAddress: true + privateNetworkOnly: true + + # Enable observability features. Appropriate credentials must be made + # available using the credentials endpoint + # observability: + # logs: + # credentialsRef: my-credentials + # pushUrl: https://my.observability.host//loki/api/v1/push + # metrics: + # credentialsRef: my-credentials + # pushUrl: https://my.observability.host///api/v1/receive + +planId: p10 + +# definition of the backend servers +targetPools: + - name: my-target-pool + activeHealthCheck: + healthyThreshold: 3 + httpHealthChecks: + okStatuses: + - "200" + path: /health + interval: 10s + intervalJitter: 3s + timeout: 5s + unhealthyThreshold: 1 + targetPort: 5732 + targets: + # configuration of the backend servers + - displayName: my-target1 + ip: 192.11.2.5 + # if the backend servers must be accessed via TLS the following block + # allows defining the TLS configuration + # tlsConfig: + # # A PEM and base64 encoded certificate + # customCa: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlEbkRDQ0EwS2dBd0lCQWdJUkFNUmhrL3pZWkh5UUVDRjQwd3JrTHMwd0NnWUlLb1pJemowRUF3SXdPekVMDQpNQWtHQTFVRUJoTUNWVk14SGpBY0JnTlZCQW9URlVkdmIyZHNaU0JVY25WemRDQlRaWEoyYVdObGN6RU1NQW9HDQpBMVVFQXhNRFYwVXlNQjRYRFRJMU1ETXlNREV4TWpFeU0xb1hEVEkxTURZeE1qRXhNakV5TWxvd0ZqRVVNQklHDQpBMVVFQXd3TEtpNW5iMjluYkdVdVpHVXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBUUtNUldLDQpyZTV2RUtkVkNVam9oSlJxSitZdnJ6Mmg2RlpwTGo4SkpHTUlEajh1VlNzY0MvNG9jOURtRDI1c0ZhWnhlUytRDQozcVpGU1FkamxLTGdaRTdPbzRJQ1NqQ0NBa1l3RGdZRFZSMFBBUUgvQkFRREFnZUFNQk1HQTFVZEpRUU1NQW9HDQpDQ3NHQVFVRkJ3TUJNQXdHQTFVZEV3RUIvd1FDTUFBd0hRWURWUjBPQkJZRUZHQVY3ekQvT0Nvd1RrTmhjNGN0DQpEM2lpZ0FnTE1COEdBMVVkSXdRWU1CYUFGSFcreEhldWlmWkVOMzNQc1dnZkhScnIzRFJaTUZnR0NDc0dBUVVGDQpCd0VCQkV3d1NqQWhCZ2dyQmdFRkJRY3dBWVlWYUhSMGNEb3ZMMjh1Y0d0cExtZHZiMmN2ZDJVeU1DVUdDQ3NHDQpBUVVGQnpBQ2hobG9kSFJ3T2k4dmFTNXdhMmt1WjI5dlp5OTNaVEl1WTNKME1DRUdBMVVkRVFRYU1CaUNDeW91DQpaMjl2WjJ4bExtUmxnZ2xuYjI5bmJHVXVaR1V3RXdZRFZSMGdCQXd3Q2pBSUJnWm5nUXdCQWdFd05nWURWUjBmDQpCQzh3TFRBcm9DbWdKNFlsYUhSMGNEb3ZMMk11Y0d0cExtZHZiMmN2ZDJVeUwyUlVUVE10TUdod1YyWkZMbU55DQpiRENDQVFVR0Npc0dBUVFCMW5rQ0JBSUVnZllFZ2ZNQThRQjNBRTUxb3lkY21oRERPRnRzMU44L1V1c2Q4T0NPDQpHNDFwd0xINlpMRmltam5mQUFBQmxiT0FTK29BQUFRREFFZ3dSZ0loQU1YWWVmZDZyNWZWUXpKRHRWQmNjcjdsDQpDZ3RyQi84NHY0MnRyMU1HbXFSRUFpRUE5RHRMNFpGK1RPN2Q1bWprUTNjc2NCUWwxY2xXMmp1eHlKTXVTeDFMDQp5TzRBZGdEZ2tyUDhEQjNJNTJnMkg5NWh1WlpOQ2xKNEdZcHkxbkxFc0UybGJXOVVCQUFBQVpXemdFendBQUFFDQpBd0JITUVVQ0lHNElvTjRHYW03RGgzeVR4MlF2aTA0dS9HRy9IRExTK2V0K21ycVdJQ1l5QWlFQWhHdkNjaFF2DQpCdFVHMDd0bHAvRzRrdkZ3eG01YklSUzJ5ZzBYNU5Obkl4b3dDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWhBTnpXDQptQU1Jbjl2dmQyN1Q0UVNBR0FkS2xUdDlncnNxa1dtbUxCcUM3Z1NqQWlBbzBjREZYbGFJZ3FqV0tFM043VmdPDQpqYWthT1lZcnZmdnVqT0pmTUxwNzlBPT0NCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0NCg== + # enabled: true + # skipCertificateValidation: false diff --git a/internal/cmd/beta/alb/template/template-pool.json b/internal/cmd/beta/alb/template/template-pool.json deleted file mode 100644 index 4dda3730c..000000000 --- a/internal/cmd/beta/alb/template/template-pool.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "activeHealthCheck": { - "healthyThreshold": 1, - "httpHealthChecks": { - "okStatuses": [ - "string" - ], - "path": "string" - }, - "interval": "3s", - "intervalJitter": "3s", - "timeout": "3s", - "unhealthyThreshold": 1 - }, - "name": "my-target-pool", - "targetPort": 5732, - "targets": [ - { - "displayName": "my-target", - "ip": "192.0.2.5" - } - ], - "tlsConfig": { - "customCa": "string", - "enabled": true, - "skipCertificateValidation": true - } - } \ No newline at end of file diff --git a/internal/cmd/beta/alb/template/template-pool.yaml b/internal/cmd/beta/alb/template/template-pool.yaml new file mode 100644 index 000000000..0105821c0 --- /dev/null +++ b/internal/cmd/beta/alb/template/template-pool.yaml @@ -0,0 +1,19 @@ +activeHealthCheck: + healthyThreshold: 1 + httpHealthChecks: + okStatuses: + - string + path: string + interval: 3s + intervalJitter: 3s + timeout: 3s + unhealthyThreshold: 1 +name: my-target-pool +targetPort: 5732 +targets: + - displayName: my-target + ip: 192.0.2.5 +tlsConfig: + customCa: string + enabled: true + skipCertificateValidation: true diff --git a/internal/cmd/beta/alb/template/template.go b/internal/cmd/beta/alb/template/template.go index 1e4b2477d..63bee6924 100644 --- a/internal/cmd/beta/alb/template/template.go +++ b/internal/cmd/beta/alb/template/template.go @@ -29,9 +29,9 @@ type inputModel struct { } var ( - //go:embed template-alb.json + //go:embed template-alb.yaml templateAlb string - //go:embed template-pool.json + //go:embed template-pool.yaml templatePool string ) @@ -69,13 +69,13 @@ func NewCmd(p *print.Printer) *cobra.Command { target = alb.UpdateTargetPoolPayload{} } - if model.Format == nil || *model.Format == "json" { + if model.Format == nil || *model.Format == "yaml" { p.Outputln(template) - } else if *model.Format == "yaml" { - if err := json.Unmarshal([]byte(template), &target); err != nil { + } else if *model.Format == "json" { + if err := yaml.Unmarshal([]byte(template), &target); err != nil { return fmt.Errorf("cannot unmarshal template: %w", err) } - encoder := yaml.NewEncoder(os.Stdout, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + encoder := json.NewEncoder(os.Stdout) if err := encoder.Encode(target); err != nil { return fmt.Errorf("cannot marshal template to yaml: %w", err) } From e34bb0e6e9a46ac0d5a6c2b8bd018ec676ddeff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Thu, 10 Apr 2025 15:11:21 +0200 Subject: [PATCH 17/22] feat(alb): corrected template for target pool --- .../cmd/beta/alb/template/template-pool.yaml | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/cmd/beta/alb/template/template-pool.yaml b/internal/cmd/beta/alb/template/template-pool.yaml index 0105821c0..5867af22d 100644 --- a/internal/cmd/beta/alb/template/template-pool.yaml +++ b/internal/cmd/beta/alb/template/template-pool.yaml @@ -2,18 +2,22 @@ activeHealthCheck: healthyThreshold: 1 httpHealthChecks: okStatuses: - - string - path: string + - "200" + path: /health interval: 3s intervalJitter: 3s timeout: 3s unhealthyThreshold: 1 name: my-target-pool -targetPort: 5732 +targetPort: 4000 targets: - displayName: my-target - ip: 192.0.2.5 -tlsConfig: - customCa: string - enabled: true - skipCertificateValidation: true + ip: 10.0.1.155 + # if the backend servers must be accessed via TLS the following block + # allows defining the TLS configuration + # tlsConfig: + # # A PEM and base64 encoded certificate + # customCa: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlEbkRDQ0EwS2dBd0lCQWdJUkFNUmhrL3pZWkh5UUVDRjQwd3JrTHMwd0NnWUlLb1pJemowRUF3SXdPekVMDQpNQWtHQTFVRUJoTUNWVk14SGpBY0JnTlZCQW9URlVkdmIyZHNaU0JVY25WemRDQlRaWEoyYVdObGN6RU1NQW9HDQpBMVVFQXhNRFYwVXlNQjRYRFRJMU1ETXlNREV4TWpFeU0xb1hEVEkxTURZeE1qRXhNakV5TWxvd0ZqRVVNQklHDQpBMVVFQXd3TEtpNW5iMjluYkdVdVpHVXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBUUtNUldLDQpyZTV2RUtkVkNVam9oSlJxSitZdnJ6Mmg2RlpwTGo4SkpHTUlEajh1VlNzY0MvNG9jOURtRDI1c0ZhWnhlUytRDQozcVpGU1FkamxLTGdaRTdPbzRJQ1NqQ0NBa1l3RGdZRFZSMFBBUUgvQkFRREFnZUFNQk1HQTFVZEpRUU1NQW9HDQpDQ3NHQVFVRkJ3TUJNQXdHQTFVZEV3RUIvd1FDTUFBd0hRWURWUjBPQkJZRUZHQVY3ekQvT0Nvd1RrTmhjNGN0DQpEM2lpZ0FnTE1COEdBMVVkSXdRWU1CYUFGSFcreEhldWlmWkVOMzNQc1dnZkhScnIzRFJaTUZnR0NDc0dBUVVGDQpCd0VCQkV3d1NqQWhCZ2dyQmdFRkJRY3dBWVlWYUhSMGNEb3ZMMjh1Y0d0cExtZHZiMmN2ZDJVeU1DVUdDQ3NHDQpBUVVGQnpBQ2hobG9kSFJ3T2k4dmFTNXdhMmt1WjI5dlp5OTNaVEl1WTNKME1DRUdBMVVkRVFRYU1CaUNDeW91DQpaMjl2WjJ4bExtUmxnZ2xuYjI5bmJHVXVaR1V3RXdZRFZSMGdCQXd3Q2pBSUJnWm5nUXdCQWdFd05nWURWUjBmDQpCQzh3TFRBcm9DbWdKNFlsYUhSMGNEb3ZMMk11Y0d0cExtZHZiMmN2ZDJVeUwyUlVUVE10TUdod1YyWkZMbU55DQpiRENDQVFVR0Npc0dBUVFCMW5rQ0JBSUVnZllFZ2ZNQThRQjNBRTUxb3lkY21oRERPRnRzMU44L1V1c2Q4T0NPDQpHNDFwd0xINlpMRmltam5mQUFBQmxiT0FTK29BQUFRREFFZ3dSZ0loQU1YWWVmZDZyNWZWUXpKRHRWQmNjcjdsDQpDZ3RyQi84NHY0MnRyMU1HbXFSRUFpRUE5RHRMNFpGK1RPN2Q1bWprUTNjc2NCUWwxY2xXMmp1eHlKTXVTeDFMDQp5TzRBZGdEZ2tyUDhEQjNJNTJnMkg5NWh1WlpOQ2xKNEdZcHkxbkxFc0UybGJXOVVCQUFBQVpXemdFendBQUFFDQpBd0JITUVVQ0lHNElvTjRHYW03RGgzeVR4MlF2aTA0dS9HRy9IRExTK2V0K21ycVdJQ1l5QWlFQWhHdkNjaFF2DQpCdFVHMDd0bHAvRzRrdkZ3eG01YklSUzJ5ZzBYNU5Obkl4b3dDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWhBTnpXDQptQU1Jbjl2dmQyN1Q0UVNBR0FkS2xUdDlncnNxa1dtbUxCcUM3Z1NqQWlBbzBjREZYbGFJZ3FqV0tFM043VmdPDQpqYWthT1lZcnZmdnVqT0pmTUxwNzlBPT0NCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0NCg== + # enabled: true + # skipCertificateValidation: false + From c4e2559b3d66b9baee6759031ca2172444ffa5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Thu, 10 Apr 2025 15:11:34 +0200 Subject: [PATCH 18/22] feat(alb): retrieve target pool name from configuration file --- internal/cmd/beta/alb/pool/update/update.go | 15 +++++++-------- internal/cmd/beta/alb/pool/update/update_test.go | 7 +------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/internal/cmd/beta/alb/pool/update/update.go b/internal/cmd/beta/alb/pool/update/update.go index ec2ef43bd..752d49bf5 100644 --- a/internal/cmd/beta/alb/pool/update/update.go +++ b/internal/cmd/beta/alb/pool/update/update.go @@ -26,14 +26,12 @@ import ( const ( configurationFlag = "configuration" albNameFlag = "name" - poolNameFlag = "pool" ) type inputModel struct { *globalflags.GlobalFlagModel Configuration *string AlbName *string - Poolname *string } func NewCmd(p *print.Printer) *cobra.Command { @@ -44,8 +42,8 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `Update an application target pool from a configuration file`, - "$ stackit beta alb update --configuration my-target pool.json"), + `Update an application target pool from a configuration file (the name of the pool is read from the file)`, + "$ stackit beta alb update --configuration my-target-pool.json --name my-load-balancer"), ), RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() @@ -94,8 +92,7 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().StringP(configurationFlag, "c", "", "filename of the input configuration file") cmd.Flags().StringP(albNameFlag, "n", "", "name of the target pool name to update") - cmd.Flags().StringP(poolNameFlag, "t", "", "name of the target pool to update") - err := flags.MarkFlagsRequired(cmd, configurationFlag, albNameFlag, poolNameFlag) + err := flags.MarkFlagsRequired(cmd, configurationFlag, albNameFlag) cobra.CheckErr(err) } @@ -109,7 +106,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag), AlbName: flags.FlagToStringPointer(p, cmd, albNameFlag), - Poolname: flags.FlagToStringPointer(p, cmd, poolNameFlag), } if p.IsVerbosityDebug() { @@ -129,7 +125,10 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie if err != nil { return req, err } - req = apiClient.UpdateTargetPool(ctx, model.ProjectId, model.Region, *model.AlbName, *model.Poolname) + if payload.Name == nil { + return req, fmt.Errorf("update target pool: no poolname provided") + } + req = apiClient.UpdateTargetPool(ctx, model.ProjectId, model.Region, *model.AlbName, *payload.Name) return req.UpdateTargetPoolPayload(payload), nil } diff --git a/internal/cmd/beta/alb/pool/update/update_test.go b/internal/cmd/beta/alb/pool/update/update_test.go index ec87f9fd5..b9b9ec080 100644 --- a/internal/cmd/beta/alb/pool/update/update_test.go +++ b/internal/cmd/beta/alb/pool/update/update_test.go @@ -29,7 +29,7 @@ var ( testProjectId = uuid.NewString() testRegion = "eu01" testLoadBalancer = "my-load-balancer" - testPool = "my-target" + testPool = "my-target-pool" testConfig = "testdata/testconfig.json" ) @@ -39,7 +39,6 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st configurationFlag: testConfig, globalflags.RegionFlag: testRegion, albNameFlag: testLoadBalancer, - poolNameFlag: testPool, } for _, mod := range mods { mod(flagValues) @@ -56,7 +55,6 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { }, Configuration: utils.Ptr(testConfig), AlbName: &testLoadBalancer, - Poolname: &testPool, } for _, mod := range mods { mod(model) @@ -107,7 +105,6 @@ func TestParseInput(t *testing.T) { projectIdFlag: testProjectId, configurationFlag: testConfig, albNameFlag: testLoadBalancer, - poolNameFlag: testPool, }, isValid: true, expectedModel: &inputModel{ @@ -117,7 +114,6 @@ func TestParseInput(t *testing.T) { }, Configuration: &testConfig, AlbName: &testLoadBalancer, - Poolname: &testPool, }, }, { @@ -210,7 +206,6 @@ func TestBuildRequest(t *testing.T) { }, Configuration: &testConfig, AlbName: &testLoadBalancer, - Poolname: &testPool, }, expectedRequest: testClient. UpdateTargetPool(testCtx, testProjectId, testRegion, testLoadBalancer, testPool). From df0449ec2dd40338f2a673ed25743038ef24d0c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:08:59 +0200 Subject: [PATCH 19/22] feat(alb): updated documentation --- docs/stackit_beta.md | 1 + docs/stackit_beta_alb.md | 40 +++++++++++++++++++++++++ docs/stackit_beta_alb_create.md | 41 +++++++++++++++++++++++++ docs/stackit_beta_alb_delete.md | 40 +++++++++++++++++++++++++ docs/stackit_beta_alb_describe.md | 40 +++++++++++++++++++++++++ docs/stackit_beta_alb_list.md | 44 +++++++++++++++++++++++++++ docs/stackit_beta_alb_pool.md | 34 +++++++++++++++++++++ docs/stackit_beta_alb_pool_update.md | 42 ++++++++++++++++++++++++++ docs/stackit_beta_alb_template.md | 45 ++++++++++++++++++++++++++++ docs/stackit_beta_alb_update.md | 41 +++++++++++++++++++++++++ 10 files changed, 368 insertions(+) create mode 100644 docs/stackit_beta_alb.md create mode 100644 docs/stackit_beta_alb_create.md create mode 100644 docs/stackit_beta_alb_delete.md create mode 100644 docs/stackit_beta_alb_describe.md create mode 100644 docs/stackit_beta_alb_list.md create mode 100644 docs/stackit_beta_alb_pool.md create mode 100644 docs/stackit_beta_alb_pool_update.md create mode 100644 docs/stackit_beta_alb_template.md create mode 100644 docs/stackit_beta_alb_update.md diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 59bbbc485..b58eb067a 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -41,5 +41,6 @@ stackit beta [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers * [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex diff --git a/docs/stackit_beta_alb.md b/docs/stackit_beta_alb.md new file mode 100644 index 000000000..eaa733d1c --- /dev/null +++ b/docs/stackit_beta_alb.md @@ -0,0 +1,40 @@ +## stackit beta alb + +Manages application loadbalancers + +### Synopsis + +Manage the lifecycle of application loadbalancers. + +``` +stackit beta alb [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta alb" +``` + +### 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](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta alb create](./stackit_beta_alb_create.md) - Creates an application loadbalancer +* [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 pool](./stackit_beta_alb_pool.md) - Manages target pools for application loadbalancers +* [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_create.md b/docs/stackit_beta_alb_create.md new file mode 100644 index 000000000..877d31d05 --- /dev/null +++ b/docs/stackit_beta_alb_create.md @@ -0,0 +1,41 @@ +## stackit beta alb create + +Creates an application loadbalancer + +### Synopsis + +Creates an application loadbalancer. + +``` +stackit beta alb create [flags] +``` + +### Examples + +``` + Create an application loadbalancer from a configuration file + $ stackit beta alb create --configuration my-loadbalancer.json +``` + +### Options + +``` + -c, --configuration string filename of the input configuration file + -h, --help Help for "stackit beta alb create" +``` + +### 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_delete.md b/docs/stackit_beta_alb_delete.md new file mode 100644 index 000000000..a83567e86 --- /dev/null +++ b/docs/stackit_beta_alb_delete.md @@ -0,0 +1,40 @@ +## stackit beta alb delete + +Deletes an application loadbalancer + +### Synopsis + +Deletes an application loadbalancer. + +``` +stackit beta alb delete LOADBALANCER_NAME_ARG [flags] +``` + +### Examples + +``` + Delete an application loadbalancer with name "my-load-balancer" + $ stackit beta alb delete my-load-balancer +``` + +### Options + +``` + -h, --help Help for "stackit beta alb 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](./stackit_beta_alb.md) - Manages application loadbalancers + diff --git a/docs/stackit_beta_alb_describe.md b/docs/stackit_beta_alb_describe.md new file mode 100644 index 000000000..a1ec7628a --- /dev/null +++ b/docs/stackit_beta_alb_describe.md @@ -0,0 +1,40 @@ +## stackit beta alb describe + +Describes an application loadbalancer + +### Synopsis + +Describes an application loadbalancer. + +``` +stackit beta alb describe LOADBALANCER_NAME_ARG [flags] +``` + +### Examples + +``` + Get details about an application loadbalancer with name "my-load-balancer" + $ stackit beta alb describe my-load-balancer +``` + +### Options + +``` + -h, --help Help for "stackit beta alb 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](./stackit_beta_alb.md) - Manages application loadbalancers + diff --git a/docs/stackit_beta_alb_list.md b/docs/stackit_beta_alb_list.md new file mode 100644 index 000000000..639b541dd --- /dev/null +++ b/docs/stackit_beta_alb_list.md @@ -0,0 +1,44 @@ +## stackit beta alb list + +Lists albs + +### Synopsis + +Lists application load balancers. + +``` +stackit beta alb list [flags] +``` + +### Examples + +``` + List all load balancers + $ stackit beta alb list + + List the first 10 application load balancers + $ stackit beta alb list --limit=10 +``` + +### Options + +``` + -h, --help Help for "stackit beta alb list" + --limit int Limit the output to the first n elements +``` + +### 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_pool.md b/docs/stackit_beta_alb_pool.md new file mode 100644 index 000000000..1553d06bc --- /dev/null +++ b/docs/stackit_beta_alb_pool.md @@ -0,0 +1,34 @@ +## stackit beta alb pool + +Manages target pools for application loadbalancers + +### Synopsis + +Manage the lifecycle of target pools for application loadbalancers. + +``` +stackit beta alb pool [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta alb pool" +``` + +### 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 pool update](./stackit_beta_alb_pool_update.md) - Updates an application target pool + diff --git a/docs/stackit_beta_alb_pool_update.md b/docs/stackit_beta_alb_pool_update.md new file mode 100644 index 000000000..7cf38fe0d --- /dev/null +++ b/docs/stackit_beta_alb_pool_update.md @@ -0,0 +1,42 @@ +## stackit beta alb pool update + +Updates an application target pool + +### Synopsis + +Updates an application target pool. + +``` +stackit beta alb pool update [flags] +``` + +### Examples + +``` + Update an application target pool from a configuration file (the name of the pool is read from the file) + $ stackit beta alb update --configuration my-target-pool.json --name my-load-balancer +``` + +### Options + +``` + -c, --configuration string filename of the input configuration file + -h, --help Help for "stackit beta alb pool update" + -n, --name string name of the target pool name to update +``` + +### 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 pool](./stackit_beta_alb_pool.md) - Manages target pools for application loadbalancers + diff --git a/docs/stackit_beta_alb_template.md b/docs/stackit_beta_alb_template.md new file mode 100644 index 000000000..e914e3024 --- /dev/null +++ b/docs/stackit_beta_alb_template.md @@ -0,0 +1,45 @@ +## stackit beta alb template + +creates configuration templates to use for resource creation + +### Synopsis + +creates a json or yaml template file for creating/updating an application loadbalancer or target pool. + +``` +stackit beta alb template [flags] +``` + +### Examples + +``` + Create a yaml template + $ stackit beta alb template --format=yaml --type alb + + Create a json template + $ stackit beta alb template --format=json --type pool +``` + +### Options + +``` + -f, --format string Defines the output format ('yaml' or 'json'), default is 'json' (default "json") + -h, --help Help for "stackit beta alb template" + -t, --type string Defines the output type ('alb' or 'pool'), default is 'alb' (default "alb") +``` + +### 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_update.md b/docs/stackit_beta_alb_update.md new file mode 100644 index 000000000..16ba83f5e --- /dev/null +++ b/docs/stackit_beta_alb_update.md @@ -0,0 +1,41 @@ +## stackit beta alb update + +Updates an application loadbalancer + +### Synopsis + +Updates an application loadbalancer. + +``` +stackit beta alb update [flags] +``` + +### Examples + +``` + Update an application loadbalancer from a configuration file + $ stackit beta alb update --configuration my-loadbalancer.json +``` + +### Options + +``` + -c, --configuration string filename of the input configuration file + -h, --help Help for "stackit beta alb update" +``` + +### 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 + From b0ed6eaa0cc7b996629fe8b7f75c85f03b6bc9b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Fri, 11 Apr 2025 08:24:13 +0200 Subject: [PATCH 20/22] feat(alb): add missing delete confirmation --- internal/cmd/beta/alb/delete/delete.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/cmd/beta/alb/delete/delete.go b/internal/cmd/beta/alb/delete/delete.go index 631bd6d5c..78d552a7f 100644 --- a/internal/cmd/beta/alb/delete/delete.go +++ b/internal/cmd/beta/alb/delete/delete.go @@ -8,6 +8,7 @@ import ( "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/spf13/cobra" @@ -48,6 +49,20 @@ func NewCmd(p *print.Printer) *cobra.Command { 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 delete the application loadbalancer %q for project %q?", model.Name, projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + // Call API req := buildRequest(ctx, model, apiClient) _, err = req.Execute() From 4080d5a357d2bb0a83f8f3a3227adf587adf11f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= <152157960+bahkauv70@users.noreply.github.com> Date: Mon, 14 Apr 2025 16:18:36 +0200 Subject: [PATCH 21/22] feat(alb): review findings --- docs/stackit_beta_alb_create.md | 2 +- docs/stackit_beta_alb_describe.md | 2 +- docs/stackit_beta_alb_pool_update.md | 4 +- docs/stackit_beta_alb_update.md | 2 +- internal/cmd/beta/alb/create/create.go | 2 +- internal/cmd/beta/alb/delete/delete.go | 6 +- internal/cmd/beta/alb/describe/describe.go | 121 ++++++++++++++---- internal/cmd/beta/alb/pool/update/update.go | 4 +- .../cmd/beta/alb/template/template-alb.yaml | 2 +- .../cmd/beta/alb/template/template-pool.yaml | 2 +- internal/cmd/beta/alb/template/template.go | 5 +- internal/cmd/beta/alb/update/update.go | 2 +- 12 files changed, 112 insertions(+), 42 deletions(-) diff --git a/docs/stackit_beta_alb_create.md b/docs/stackit_beta_alb_create.md index 877d31d05..d33b66ab2 100644 --- a/docs/stackit_beta_alb_create.md +++ b/docs/stackit_beta_alb_create.md @@ -20,7 +20,7 @@ stackit beta alb create [flags] ### Options ``` - -c, --configuration string filename of the input configuration file + -c, --configuration string Filename of the input configuration file -h, --help Help for "stackit beta alb create" ``` diff --git a/docs/stackit_beta_alb_describe.md b/docs/stackit_beta_alb_describe.md index a1ec7628a..008ba874c 100644 --- a/docs/stackit_beta_alb_describe.md +++ b/docs/stackit_beta_alb_describe.md @@ -4,7 +4,7 @@ Describes an application loadbalancer ### Synopsis -Describes an application loadbalancer. +Describes an application alb. ``` stackit beta alb describe LOADBALANCER_NAME_ARG [flags] diff --git a/docs/stackit_beta_alb_pool_update.md b/docs/stackit_beta_alb_pool_update.md index 7cf38fe0d..156452e11 100644 --- a/docs/stackit_beta_alb_pool_update.md +++ b/docs/stackit_beta_alb_pool_update.md @@ -20,9 +20,9 @@ stackit beta alb pool update [flags] ### Options ``` - -c, --configuration string filename of the input configuration file + -c, --configuration string Filename of the input configuration file -h, --help Help for "stackit beta alb pool update" - -n, --name string name of the target pool name to update + -n, --name string Name of the target pool name to update ``` ### Options inherited from parent commands diff --git a/docs/stackit_beta_alb_update.md b/docs/stackit_beta_alb_update.md index 16ba83f5e..36c4a8dd7 100644 --- a/docs/stackit_beta_alb_update.md +++ b/docs/stackit_beta_alb_update.md @@ -20,7 +20,7 @@ stackit beta alb update [flags] ### Options ``` - -c, --configuration string filename of the input configuration file + -c, --configuration string Filename of the input configuration file -h, --help Help for "stackit beta alb update" ``` diff --git a/internal/cmd/beta/alb/create/create.go b/internal/cmd/beta/alb/create/create.go index df1008924..462d4e381 100644 --- a/internal/cmd/beta/alb/create/create.go +++ b/internal/cmd/beta/alb/create/create.go @@ -101,7 +101,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().StringP(configurationFlag, "c", "", "filename of the input configuration file") + cmd.Flags().StringP(configurationFlag, "c", "", "Filename of the input configuration file") err := flags.MarkFlagsRequired(cmd, configurationFlag) cobra.CheckErr(err) } diff --git a/internal/cmd/beta/alb/delete/delete.go b/internal/cmd/beta/alb/delete/delete.go index 78d552a7f..c3709e8a9 100644 --- a/internal/cmd/beta/alb/delete/delete.go +++ b/internal/cmd/beta/alb/delete/delete.go @@ -70,17 +70,13 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete loadbalancer: %w", err) } - p.Outputln("Load balancer deleted.") + p.Outputf("Load balancer %q deleted.", model.Name) return nil }, } - configureFlags(cmd) return cmd } -func configureFlags(_ *cobra.Command) { -} - func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) diff --git a/internal/cmd/beta/alb/describe/describe.go b/internal/cmd/beta/alb/describe/describe.go index 4c926976b..8a1403585 100644 --- a/internal/cmd/beta/alb/describe/describe.go +++ b/internal/cmd/beta/alb/describe/describe.go @@ -33,7 +33,7 @@ func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", loadbalancerNameArg), Short: "Describes an application loadbalancer", - Long: "Describes an application loadbalancer.", + Long: "Describes an application alb.", Args: args.SingleArg(loadbalancerNameArg, nil), Example: examples.Build( examples.NewExample( @@ -117,33 +117,104 @@ func outputResult(p *print.Printer, outputFormat string, response *alb.LoadBalan return nil default: - table := tables.NewTable() - table.AddRow("EXTERNAL ADDRESS", utils.PtrString(response.ExternalAddress)) - table.AddSeparator() - var numErrors int - if response.Errors != nil { - numErrors = len(*response.Errors) - } - table.AddRow("NUMBER OF ERRORS", numErrors) - table.AddSeparator() - table.AddRow("PLAN ID", utils.PtrString(response.PlanId)) - table.AddSeparator() - table.AddRow("REGION", utils.PtrString(response.Region)) - table.AddSeparator() - table.AddRow("STATUS", utils.PtrString(response.Status)) - table.AddSeparator() - table.AddRow("VERSION", utils.PtrString(response.Version)) - if response.Errors != nil { - table.AddSeparator() - var builder strings.Builder - for _, err := range *response.Errors { - builder.WriteString(fmt.Sprintf("[%s] %s\n", utils.PtrString(err.Type), utils.PtrString(err.Description))) - } - table.AddRow("ERRORS", builder.String()) + if err := outputResultAsTable(p, response); err != nil { + return err } + } - p.Outputln(table.Render()) + return nil +} + +func outputResultAsTable(p *print.Printer, loadbalancer *alb.LoadBalancer) error { + content := []tables.Table{} + + content = append(content, buildLoadBalancerTable(loadbalancer)) + + if loadbalancer.Listeners != nil { + content = append(content, buildListenersTable(*loadbalancer.Listeners)) + } + + if loadbalancer.TargetPools != nil { + content = append(content, buildTargetPoolsTable(*loadbalancer.TargetPools)) + } + + err := tables.DisplayTables(p, content) + if err != nil { + return fmt.Errorf("display output: %w", err) } return nil } + +func buildLoadBalancerTable(loadbalancer *alb.LoadBalancer) tables.Table { + acl := []string{} + privateAccessOnly := false + if loadbalancer.Options != nil { + if loadbalancer.Options.AccessControl != nil && loadbalancer.Options.AccessControl.AllowedSourceRanges != nil { + acl = *loadbalancer.Options.AccessControl.AllowedSourceRanges + } + + if loadbalancer.Options.PrivateNetworkOnly != nil { + privateAccessOnly = *loadbalancer.Options.PrivateNetworkOnly + } + } + + networkId := "-" + if loadbalancer.Networks != nil && len(*loadbalancer.Networks) > 0 { + networks := *loadbalancer.Networks + networkId = *networks[0].NetworkId + } + + externalAddress := utils.PtrStringDefault(loadbalancer.ExternalAddress, "-") + + errorDescriptions := []string{} + if loadbalancer.Errors != nil && len((*loadbalancer.Errors)) > 0 { + for _, err := range *loadbalancer.Errors { + errorDescriptions = append(errorDescriptions, *err.Description) + } + } + + table := tables.NewTable() + table.SetTitle("Load Balancer") + table.AddRow("NAME", utils.PtrString(loadbalancer.Name)) + table.AddSeparator() + table.AddRow("STATE", utils.PtrString(loadbalancer.Status)) + table.AddSeparator() + if len(errorDescriptions) > 0 { + table.AddRow("ERROR DESCRIPTIONS", strings.Join(errorDescriptions, "\n")) + table.AddSeparator() + } + table.AddRow("PRIVATE ACCESS ONLY", privateAccessOnly) + table.AddSeparator() + table.AddRow("ATTACHED PUBLIC IP", externalAddress) + table.AddSeparator() + table.AddRow("ATTACHED NETWORK ID", networkId) + table.AddSeparator() + table.AddRow("ACL", acl) + return table +} + +func buildListenersTable(listeners []alb.Listener) tables.Table { + table := tables.NewTable() + table.SetTitle("Listeners") + table.SetHeader("NAME", "PORT", "PROTOCOL", "TARGET POOL") + for i := range listeners { + listener := listeners[i] + table.AddRow( + utils.PtrString(listener.Name), + utils.PtrString(listener.Port), + utils.PtrString(listener.Protocol), + ) + } + return table +} + +func buildTargetPoolsTable(targetPools []alb.TargetPool) tables.Table { + table := tables.NewTable() + table.SetTitle("Target Pools") + table.SetHeader("NAME", "PORT", "TARGETS") + for _, targetPool := range targetPools { + table.AddRow(utils.PtrString(targetPool.Name), utils.PtrString(targetPool.TargetPort), len(*targetPool.Targets)) + } + return table +} diff --git a/internal/cmd/beta/alb/pool/update/update.go b/internal/cmd/beta/alb/pool/update/update.go index 752d49bf5..e8b2aa2fa 100644 --- a/internal/cmd/beta/alb/pool/update/update.go +++ b/internal/cmd/beta/alb/pool/update/update.go @@ -90,8 +90,8 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().StringP(configurationFlag, "c", "", "filename of the input configuration file") - cmd.Flags().StringP(albNameFlag, "n", "", "name of the target pool name to update") + cmd.Flags().StringP(configurationFlag, "c", "", "Filename of the input configuration file") + cmd.Flags().StringP(albNameFlag, "n", "", "Name of the target pool name to update") err := flags.MarkFlagsRequired(cmd, configurationFlag, albNameFlag) cobra.CheckErr(err) } diff --git a/internal/cmd/beta/alb/template/template-alb.yaml b/internal/cmd/beta/alb/template/template-alb.yaml index be04ffb99..541aaf3de 100644 --- a/internal/cmd/beta/alb/template/template-alb.yaml +++ b/internal/cmd/beta/alb/template/template-alb.yaml @@ -86,6 +86,6 @@ targetPools: # allows defining the TLS configuration # tlsConfig: # # A PEM and base64 encoded certificate - # customCa: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlEbkRDQ0EwS2dBd0lCQWdJUkFNUmhrL3pZWkh5UUVDRjQwd3JrTHMwd0NnWUlLb1pJemowRUF3SXdPekVMDQpNQWtHQTFVRUJoTUNWVk14SGpBY0JnTlZCQW9URlVkdmIyZHNaU0JVY25WemRDQlRaWEoyYVdObGN6RU1NQW9HDQpBMVVFQXhNRFYwVXlNQjRYRFRJMU1ETXlNREV4TWpFeU0xb1hEVEkxTURZeE1qRXhNakV5TWxvd0ZqRVVNQklHDQpBMVVFQXd3TEtpNW5iMjluYkdVdVpHVXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBUUtNUldLDQpyZTV2RUtkVkNVam9oSlJxSitZdnJ6Mmg2RlpwTGo4SkpHTUlEajh1VlNzY0MvNG9jOURtRDI1c0ZhWnhlUytRDQozcVpGU1FkamxLTGdaRTdPbzRJQ1NqQ0NBa1l3RGdZRFZSMFBBUUgvQkFRREFnZUFNQk1HQTFVZEpRUU1NQW9HDQpDQ3NHQVFVRkJ3TUJNQXdHQTFVZEV3RUIvd1FDTUFBd0hRWURWUjBPQkJZRUZHQVY3ekQvT0Nvd1RrTmhjNGN0DQpEM2lpZ0FnTE1COEdBMVVkSXdRWU1CYUFGSFcreEhldWlmWkVOMzNQc1dnZkhScnIzRFJaTUZnR0NDc0dBUVVGDQpCd0VCQkV3d1NqQWhCZ2dyQmdFRkJRY3dBWVlWYUhSMGNEb3ZMMjh1Y0d0cExtZHZiMmN2ZDJVeU1DVUdDQ3NHDQpBUVVGQnpBQ2hobG9kSFJ3T2k4dmFTNXdhMmt1WjI5dlp5OTNaVEl1WTNKME1DRUdBMVVkRVFRYU1CaUNDeW91DQpaMjl2WjJ4bExtUmxnZ2xuYjI5bmJHVXVaR1V3RXdZRFZSMGdCQXd3Q2pBSUJnWm5nUXdCQWdFd05nWURWUjBmDQpCQzh3TFRBcm9DbWdKNFlsYUhSMGNEb3ZMMk11Y0d0cExtZHZiMmN2ZDJVeUwyUlVUVE10TUdod1YyWkZMbU55DQpiRENDQVFVR0Npc0dBUVFCMW5rQ0JBSUVnZllFZ2ZNQThRQjNBRTUxb3lkY21oRERPRnRzMU44L1V1c2Q4T0NPDQpHNDFwd0xINlpMRmltam5mQUFBQmxiT0FTK29BQUFRREFFZ3dSZ0loQU1YWWVmZDZyNWZWUXpKRHRWQmNjcjdsDQpDZ3RyQi84NHY0MnRyMU1HbXFSRUFpRUE5RHRMNFpGK1RPN2Q1bWprUTNjc2NCUWwxY2xXMmp1eHlKTXVTeDFMDQp5TzRBZGdEZ2tyUDhEQjNJNTJnMkg5NWh1WlpOQ2xKNEdZcHkxbkxFc0UybGJXOVVCQUFBQVpXemdFendBQUFFDQpBd0JITUVVQ0lHNElvTjRHYW03RGgzeVR4MlF2aTA0dS9HRy9IRExTK2V0K21ycVdJQ1l5QWlFQWhHdkNjaFF2DQpCdFVHMDd0bHAvRzRrdkZ3eG01YklSUzJ5ZzBYNU5Obkl4b3dDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWhBTnpXDQptQU1Jbjl2dmQyN1Q0UVNBR0FkS2xUdDlncnNxa1dtbUxCcUM3Z1NqQWlBbzBjREZYbGFJZ3FqV0tFM043VmdPDQpqYWthT1lZcnZmdnVqT0pmTUxwNzlBPT0NCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0NCg== + # customCa: LS0t... # enabled: true # skipCertificateValidation: false diff --git a/internal/cmd/beta/alb/template/template-pool.yaml b/internal/cmd/beta/alb/template/template-pool.yaml index 5867af22d..d3a56af9b 100644 --- a/internal/cmd/beta/alb/template/template-pool.yaml +++ b/internal/cmd/beta/alb/template/template-pool.yaml @@ -17,7 +17,7 @@ targets: # allows defining the TLS configuration # tlsConfig: # # A PEM and base64 encoded certificate - # customCa: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlEbkRDQ0EwS2dBd0lCQWdJUkFNUmhrL3pZWkh5UUVDRjQwd3JrTHMwd0NnWUlLb1pJemowRUF3SXdPekVMDQpNQWtHQTFVRUJoTUNWVk14SGpBY0JnTlZCQW9URlVkdmIyZHNaU0JVY25WemRDQlRaWEoyYVdObGN6RU1NQW9HDQpBMVVFQXhNRFYwVXlNQjRYRFRJMU1ETXlNREV4TWpFeU0xb1hEVEkxTURZeE1qRXhNakV5TWxvd0ZqRVVNQklHDQpBMVVFQXd3TEtpNW5iMjluYkdVdVpHVXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBUUtNUldLDQpyZTV2RUtkVkNVam9oSlJxSitZdnJ6Mmg2RlpwTGo4SkpHTUlEajh1VlNzY0MvNG9jOURtRDI1c0ZhWnhlUytRDQozcVpGU1FkamxLTGdaRTdPbzRJQ1NqQ0NBa1l3RGdZRFZSMFBBUUgvQkFRREFnZUFNQk1HQTFVZEpRUU1NQW9HDQpDQ3NHQVFVRkJ3TUJNQXdHQTFVZEV3RUIvd1FDTUFBd0hRWURWUjBPQkJZRUZHQVY3ekQvT0Nvd1RrTmhjNGN0DQpEM2lpZ0FnTE1COEdBMVVkSXdRWU1CYUFGSFcreEhldWlmWkVOMzNQc1dnZkhScnIzRFJaTUZnR0NDc0dBUVVGDQpCd0VCQkV3d1NqQWhCZ2dyQmdFRkJRY3dBWVlWYUhSMGNEb3ZMMjh1Y0d0cExtZHZiMmN2ZDJVeU1DVUdDQ3NHDQpBUVVGQnpBQ2hobG9kSFJ3T2k4dmFTNXdhMmt1WjI5dlp5OTNaVEl1WTNKME1DRUdBMVVkRVFRYU1CaUNDeW91DQpaMjl2WjJ4bExtUmxnZ2xuYjI5bmJHVXVaR1V3RXdZRFZSMGdCQXd3Q2pBSUJnWm5nUXdCQWdFd05nWURWUjBmDQpCQzh3TFRBcm9DbWdKNFlsYUhSMGNEb3ZMMk11Y0d0cExtZHZiMmN2ZDJVeUwyUlVUVE10TUdod1YyWkZMbU55DQpiRENDQVFVR0Npc0dBUVFCMW5rQ0JBSUVnZllFZ2ZNQThRQjNBRTUxb3lkY21oRERPRnRzMU44L1V1c2Q4T0NPDQpHNDFwd0xINlpMRmltam5mQUFBQmxiT0FTK29BQUFRREFFZ3dSZ0loQU1YWWVmZDZyNWZWUXpKRHRWQmNjcjdsDQpDZ3RyQi84NHY0MnRyMU1HbXFSRUFpRUE5RHRMNFpGK1RPN2Q1bWprUTNjc2NCUWwxY2xXMmp1eHlKTXVTeDFMDQp5TzRBZGdEZ2tyUDhEQjNJNTJnMkg5NWh1WlpOQ2xKNEdZcHkxbkxFc0UybGJXOVVCQUFBQVpXemdFendBQUFFDQpBd0JITUVVQ0lHNElvTjRHYW03RGgzeVR4MlF2aTA0dS9HRy9IRExTK2V0K21ycVdJQ1l5QWlFQWhHdkNjaFF2DQpCdFVHMDd0bHAvRzRrdkZ3eG01YklSUzJ5ZzBYNU5Obkl4b3dDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWhBTnpXDQptQU1Jbjl2dmQyN1Q0UVNBR0FkS2xUdDlncnNxa1dtbUxCcUM3Z1NqQWlBbzBjREZYbGFJZ3FqV0tFM043VmdPDQpqYWthT1lZcnZmdnVqT0pmTUxwNzlBPT0NCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0NCg== + # customCa: LS0... # enabled: true # skipCertificateValidation: false diff --git a/internal/cmd/beta/alb/template/template.go b/internal/cmd/beta/alb/template/template.go index 63bee6924..18d0cd427 100644 --- a/internal/cmd/beta/alb/template/template.go +++ b/internal/cmd/beta/alb/template/template.go @@ -14,6 +14,7 @@ import ( "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/utils" "github.com/stackitcloud/stackit-sdk-go/services/alb" ) @@ -64,9 +65,11 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Type != nil && *model.Type == "pool" { template = templatePool target = alb.CreateLoadBalancerPayload{} - } else { + } else if model.Type == nil || *model.Type == "alb" { template = templateAlb target = alb.UpdateTargetPoolPayload{} + } else { + return fmt.Errorf("invalid type %q", utils.PtrString(model.Type)) } if model.Format == nil || *model.Format == "yaml" { diff --git a/internal/cmd/beta/alb/update/update.go b/internal/cmd/beta/alb/update/update.go index 6c6b8d5d9..546f89990 100644 --- a/internal/cmd/beta/alb/update/update.go +++ b/internal/cmd/beta/alb/update/update.go @@ -108,7 +108,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().StringP(configurationFlag, "c", "", "filename of the input configuration file") + cmd.Flags().StringP(configurationFlag, "c", "", "Filename of the input configuration file") err := flags.MarkFlagsRequired(cmd, configurationFlag) cobra.CheckErr(err) } From 0eebe645dd3b0a4f44c2c006437b45eacd7b6dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Schmitz?= Date: Mon, 14 Apr 2025 16:36:50 +0200 Subject: [PATCH 22/22] Feat/stackitcli 184 onboarding alb credentials (#696) * feat(credentials): list command * feat(credentials): commands working * feat(credentials): corrected chaining of commands * feat(credentials): fixed linter issues * feat(credentials): command implementation * feat(credentials): code cleanup * feat(credentials): updated documentation * feat(credentials): bug fixes * feat(credentials): updated docs * feat(credentials): fixed testcases * feat(credentials): code cleanup * Feat/stackitcli 183 onboarding alb project api (#700) * feat(credentials) * feat(credentials): code cleanup * feat(credentials): updated documentation * feat(credentials): bug fixes * feat(credentials): updated docs * feat(credentials): fixed testcases * feat(credentials): code cleanup * feat(alb project): support for quota and plans * feat(alb project): updated documentation * feat(alb project): review findings * feat(credentials): Integrated review findings * feat(credentials): more review findings --- docs/stackit_beta_alb.md | 3 + ...ckit_beta_alb_observability-credentials.md | 38 +++ ..._beta_alb_observability-credentials_add.md | 43 ++++ ...ta_alb_observability-credentials_delete.md | 40 +++ ..._alb_observability-credentials_describe.md | 40 +++ ...beta_alb_observability-credentials_list.md | 47 ++++ ...ta_alb_observability-credentials_update.md | 43 ++++ docs/stackit_beta_alb_plans.md | 40 +++ docs/stackit_beta_alb_quotas.md | 40 +++ go.mod | 2 +- internal/cmd/beta/alb/alb.go | 6 + .../alb/observability-credentials/add/add.go | 146 +++++++++++ .../observability-credentials/add/add_test.go | 213 ++++++++++++++++ .../delete/delete.go | 98 ++++++++ .../delete/delete_test.go | 192 +++++++++++++++ .../describe/describe.go | 133 ++++++++++ .../describe/describe_test.go | 224 +++++++++++++++++ .../observability-credentials/list/list.go | 163 +++++++++++++ .../list/list_test.go | 226 +++++++++++++++++ .../observability-credentials.go | 33 +++ .../update/update.go | 161 +++++++++++++ .../update/update_test.go | 227 ++++++++++++++++++ internal/cmd/beta/alb/plans/plans.go | 146 +++++++++++ internal/cmd/beta/alb/plans/plans_test.go | 203 ++++++++++++++++ internal/cmd/beta/alb/quotas/quotas.go | 132 ++++++++++ internal/cmd/beta/alb/quotas/quotas_test.go | 203 ++++++++++++++++ internal/pkg/utils/strings.go | 23 ++ internal/pkg/utils/strings_test.go | 32 +++ 28 files changed, 2896 insertions(+), 1 deletion(-) create mode 100644 docs/stackit_beta_alb_observability-credentials.md create mode 100644 docs/stackit_beta_alb_observability-credentials_add.md create mode 100644 docs/stackit_beta_alb_observability-credentials_delete.md create mode 100644 docs/stackit_beta_alb_observability-credentials_describe.md create mode 100644 docs/stackit_beta_alb_observability-credentials_list.md create mode 100644 docs/stackit_beta_alb_observability-credentials_update.md create mode 100644 docs/stackit_beta_alb_plans.md create mode 100644 docs/stackit_beta_alb_quotas.md create mode 100644 internal/cmd/beta/alb/observability-credentials/add/add.go create mode 100644 internal/cmd/beta/alb/observability-credentials/add/add_test.go create mode 100644 internal/cmd/beta/alb/observability-credentials/delete/delete.go create mode 100644 internal/cmd/beta/alb/observability-credentials/delete/delete_test.go create mode 100644 internal/cmd/beta/alb/observability-credentials/describe/describe.go create mode 100644 internal/cmd/beta/alb/observability-credentials/describe/describe_test.go create mode 100644 internal/cmd/beta/alb/observability-credentials/list/list.go create mode 100644 internal/cmd/beta/alb/observability-credentials/list/list_test.go create mode 100644 internal/cmd/beta/alb/observability-credentials/observability-credentials.go create mode 100644 internal/cmd/beta/alb/observability-credentials/update/update.go create mode 100644 internal/cmd/beta/alb/observability-credentials/update/update_test.go create mode 100644 internal/cmd/beta/alb/plans/plans.go create mode 100644 internal/cmd/beta/alb/plans/plans_test.go create mode 100644 internal/cmd/beta/alb/quotas/quotas.go create mode 100644 internal/cmd/beta/alb/quotas/quotas_test.go create mode 100644 internal/pkg/utils/strings_test.go 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) + } + }) + } +}