diff --git a/docs/stackit_load-balancer.md b/docs/stackit_load-balancer.md index 4d8a0fd2c..e4bff615a 100644 --- a/docs/stackit_load-balancer.md +++ b/docs/stackit_load-balancer.md @@ -36,5 +36,6 @@ stackit load-balancer [flags] * [stackit load-balancer list](./stackit_load-balancer_list.md) - Lists all Load Balancers * [stackit load-balancer observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials * [stackit load-balancer quota](./stackit_load-balancer_quota.md) - Shows the configured Load Balancer quota +* [stackit load-balancer target-pool](./stackit_load-balancer_target-pool.md) - Provides functionality for target pools * [stackit load-balancer update](./stackit_load-balancer_update.md) - Updates a Load Balancer diff --git a/docs/stackit_load-balancer_target-pool.md b/docs/stackit_load-balancer_target-pool.md new file mode 100644 index 000000000..e72d26734 --- /dev/null +++ b/docs/stackit_load-balancer_target-pool.md @@ -0,0 +1,33 @@ +## stackit load-balancer target-pool + +Provides functionality for target pools + +### Synopsis + +Provides functionality for target pools. + +``` +stackit load-balancer target-pool [flags] +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer target-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"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer +* [stackit load-balancer target-pool add-target](./stackit_load-balancer_target-pool_add-target.md) - Adds a target to a target pool + diff --git a/docs/stackit_load-balancer_target-pool_add-target.md b/docs/stackit_load-balancer_target-pool_add-target.md new file mode 100644 index 000000000..28aaf2202 --- /dev/null +++ b/docs/stackit_load-balancer_target-pool_add-target.md @@ -0,0 +1,42 @@ +## stackit load-balancer target-pool add-target + +Adds a target to a target pool + +### Synopsis + +Adds a target to a target pool. + +``` +stackit load-balancer target-pool add-target LOAD_BALANCER_NAME [flags] +``` + +### Examples + +``` + Add a target to target pool "my-target-pool" of load balancer with name "my-load-balancer" + $ stackit load-balancer target-pool add-target my-load-balancer --target-pool-name my-target-pool --target-name my-new-target --ip 1.2.3.4 +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer target-pool add-target" + --ip string Target IP. Must by unique within a target pool. Must be a valid IPv4 or IPv6 + --target-name string Target name + --target-pool-name string Target pool name +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit load-balancer target-pool](./stackit_load-balancer_target-pool.md) - Provides functionality for target pools + diff --git a/internal/cmd/load-balancer/describe/describe.go b/internal/cmd/load-balancer/describe/describe.go index ccbaaab8a..610d31e40 100644 --- a/internal/cmd/load-balancer/describe/describe.go +++ b/internal/cmd/load-balancer/describe/describe.go @@ -73,10 +73,21 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu return nil, &errors.ProjectIdError{} } - return &inputModel{ + model := inputModel{ GlobalFlagModel: globalFlags, LoadBalancerName: loadBalancerName, - }, nil + } + + 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 *loadbalancer.APIClient) loadbalancer.ApiGetLoadBalancerRequest { diff --git a/internal/cmd/load-balancer/describe/describe_test.go b/internal/cmd/load-balancer/describe/describe_test.go index 1da8ed6e9..33960ecca 100644 --- a/internal/cmd/load-balancer/describe/describe_test.go +++ b/internal/cmd/load-balancer/describe/describe_test.go @@ -5,6 +5,7 @@ import ( "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" @@ -124,7 +125,8 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd(nil) + p := print.NewPrinter() + cmd := NewCmd(p) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -156,7 +158,7 @@ func TestParseInput(t *testing.T) { t.Fatalf("error validating flags: %v", err) } - model, err := parseInput(nil, cmd, tt.argValues) + model, err := parseInput(p, cmd, tt.argValues) if err != nil { if !tt.isValid { return diff --git a/internal/cmd/load-balancer/list/list.go b/internal/cmd/load-balancer/list/list.go index 0b45191a5..d5b568216 100644 --- a/internal/cmd/load-balancer/list/list.go +++ b/internal/cmd/load-balancer/list/list.go @@ -107,10 +107,21 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { } } - return &inputModel{ + model := inputModel{ GlobalFlagModel: globalFlags, Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), - }, nil + } + + 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 *loadbalancer.APIClient) loadbalancer.ApiListLoadBalancersRequest { diff --git a/internal/cmd/load-balancer/list/list_test.go b/internal/cmd/load-balancer/list/list_test.go index 5f115fe77..0f035066d 100644 --- a/internal/cmd/load-balancer/list/list_test.go +++ b/internal/cmd/load-balancer/list/list_test.go @@ -5,12 +5,12 @@ import ( "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/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) @@ -112,14 +112,13 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := &cobra.Command{} + p := print.NewPrinter() + cmd := NewCmd(p) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) } - configureFlags(cmd) - for flag, value := range tt.flagValues { err := cmd.Flags().Set(flag, value) if err != nil { @@ -138,7 +137,7 @@ func TestParseInput(t *testing.T) { t.Fatalf("error validating flags: %v", err) } - model, err := parseInput(nil, cmd) + model, err := parseInput(p, cmd) if err != nil { if !tt.isValid { return diff --git a/internal/cmd/load-balancer/load_balancer.go b/internal/cmd/load-balancer/load_balancer.go index 37beb8aed..c6f6be2f5 100644 --- a/internal/cmd/load-balancer/load_balancer.go +++ b/internal/cmd/load-balancer/load_balancer.go @@ -8,6 +8,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/list" observabilitycredentials "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials" "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/quota" + targetpool "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/target-pool" "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -31,12 +32,13 @@ func NewCmd(p *print.Printer) *cobra.Command { } func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(describe.NewCmd(p)) + cmd.AddCommand(create.NewCmd(p)) cmd.AddCommand(delete.NewCmd(p)) + cmd.AddCommand(describe.NewCmd(p)) + cmd.AddCommand(generatepayload.NewCmd(p)) cmd.AddCommand(list.NewCmd(p)) cmd.AddCommand(quota.NewCmd(p)) - cmd.AddCommand(generatepayload.NewCmd(p)) cmd.AddCommand(observabilitycredentials.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) + cmd.AddCommand(targetpool.NewCmd(p)) cmd.AddCommand(update.NewCmd(p)) } diff --git a/internal/cmd/load-balancer/quota/quota.go b/internal/cmd/load-balancer/quota/quota.go index eeda0511f..7762e78bc 100644 --- a/internal/cmd/load-balancer/quota/quota.go +++ b/internal/cmd/load-balancer/quota/quota.go @@ -63,9 +63,20 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { return nil, &errors.ProjectIdError{} } - return &inputModel{ + model := inputModel{ GlobalFlagModel: globalFlags, - }, nil + } + + 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 *loadbalancer.APIClient) loadbalancer.ApiGetQuotaRequest { diff --git a/internal/cmd/load-balancer/quota/quota_test.go b/internal/cmd/load-balancer/quota/quota_test.go index c43244241..47bc747c9 100644 --- a/internal/cmd/load-balancer/quota/quota_test.go +++ b/internal/cmd/load-balancer/quota/quota_test.go @@ -5,6 +5,7 @@ import ( "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" @@ -89,7 +90,8 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd(nil) + p := print.NewPrinter() + cmd := NewCmd(p) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -113,7 +115,7 @@ func TestParseInput(t *testing.T) { t.Fatalf("error validating flags: %v", err) } - model, err := parseInput(nil, cmd) + model, err := parseInput(p, cmd) if err != nil { if !tt.isValid { return diff --git a/internal/cmd/load-balancer/target-pool/add-target/add_target.go b/internal/cmd/load-balancer/target-pool/add-target/add_target.go new file mode 100644 index 000000000..b94549793 --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/add-target/add_target.go @@ -0,0 +1,147 @@ +package addtarget + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/spf13/cobra" +) + +const ( + loadBalancerNameArg = "LOAD_BALANCER_NAME" + + targetPoolNameFlag = "target-pool-name" + targetNameFlag = "target-name" + ipFlag = "ip" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + LoadBalancerName string + TargetPoolName string + TargetName string + Ip string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("add-target %s", loadBalancerNameArg), + Short: "Adds a target to a target pool", + Long: "Adds a target to a target pool.", + Args: args.SingleArg(loadBalancerNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Add a target to target pool "my-target-pool" of load balancer with name "my-load-balancer"`, + "$ stackit load-balancer target-pool add-target my-load-balancer --target-pool-name my-target-pool --target-name my-new-target --ip 1.2.3.4"), + ), + 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 add a target with IP %q to target pool %q of load balancer %q?", model.Ip, model.TargetPoolName, model.LoadBalancerName) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + _, err = req.Execute() + if err != nil { + return fmt.Errorf("add target to target pool: %w", err) + } + + p.Info("Added target to target pool of load balancer %q\n", model.LoadBalancerName) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(targetPoolNameFlag, "", "Target pool name") + cmd.Flags().String(targetNameFlag, "", "Target name") + cmd.Flags().String(ipFlag, "", "Target IP. Must by unique within a target pool. Must be a valid IPv4 or IPv6") + + err := flags.MarkFlagsRequired(cmd, targetPoolNameFlag, targetNameFlag, ipFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + lbName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LoadBalancerName: lbName, + TargetPoolName: cmd.Flag(targetPoolNameFlag).Value.String(), + TargetName: cmd.Flag(targetNameFlag).Value.String(), + Ip: cmd.Flag(ipFlag).Value.String(), + } + + 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 utils.LoadBalancerClient) (loadbalancer.ApiUpdateTargetPoolRequest, error) { + req := apiClient.UpdateTargetPool(ctx, model.ProjectId, model.LoadBalancerName, model.TargetPoolName) + + targetPool, err := utils.GetLoadBalancerTargetPool(ctx, apiClient, model.ProjectId, model.LoadBalancerName, model.TargetPoolName) + if err != nil { + return req, fmt.Errorf("get load balancer target pool: %w", err) + } + + newTarget := &loadbalancer.Target{ + DisplayName: &model.TargetName, + Ip: &model.Ip, + } + err = utils.AddTargetToTargetPool(targetPool, newTarget) + if err != nil { + return req, fmt.Errorf("add target to target pool: %w", err) + } + + payload := utils.ToPayloadTargetPool(targetPool) + if payload == nil { + return req, fmt.Errorf("nil payload") + } + + return req.UpdateTargetPoolPayload(*payload), nil +} diff --git a/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go b/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go new file mode 100644 index 000000000..60fbbafd1 --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go @@ -0,0 +1,424 @@ +package addtarget + +import ( + "context" + "fmt" + "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/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &loadbalancer.APIClient{} + testProjectId = uuid.NewString() +) + +const ( + testLoadBalancerName = "my-load-balancer" + testTargetPoolName = "target-pool-1" + testTargetName = "my-target" + testIp = "1.2.3.4" +) + +type loadBalancerClientMocked struct { + getCredentialsFails bool + getCredentialsResp *loadbalancer.GetCredentialsResponse + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer +} + +func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) { + if m.getCredentialsFails { + return nil, fmt.Errorf("could not get credentials") + } + return m.getCredentialsResp, nil +} + +func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _ string) (*loadbalancer.LoadBalancer, error) { + if m.getLoadBalancerFails { + return nil, fmt.Errorf("could not get load balancer") + } + return m.getLoadBalancerResp, nil +} + +func (m *loadBalancerClientMocked) UpdateTargetPool(ctx context.Context, projectId, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest { + return testClient.UpdateTargetPool(ctx, projectId, loadBalancerName, targetPoolName) +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testLoadBalancerName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + targetPoolNameFlag: testTargetPoolName, + targetNameFlag: testTargetName, + ipFlag: testIp, + } + 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, + }, + LoadBalancerName: testLoadBalancerName, + TargetPoolName: testTargetPoolName, + TargetName: testTargetName, + Ip: testIp, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureTargets() *[]loadbalancer.Target { + return &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("4.3.2.1"), + }, + } +} + +func fixtureLoadBalancer(mods ...func(*loadbalancer.LoadBalancer)) *loadbalancer.LoadBalancer { + lb := loadbalancer.LoadBalancer{ + Name: utils.Ptr(testLoadBalancerName), + TargetPools: &[]loadbalancer.TargetPool{ + { + Name: utils.Ptr(testTargetPoolName), + Targets: fixtureTargets(), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + }, + { + Name: utils.Ptr("target-pool-2"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("6.7.8.9"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("9.8.7.6"), + }, + }, + }, + }, + } + + for _, mod := range mods { + mod(&lb) + } + return &lb +} + +func fixturePayload(mods ...func(payload *loadbalancer.UpdateTargetPoolPayload)) *loadbalancer.UpdateTargetPoolPayload { + payload := &loadbalancer.UpdateTargetPoolPayload{ + Name: utils.Ptr("target-pool-1"), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + Targets: fixtureTargets(), + } + + for _, mod := range mods { + mod(payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *loadbalancer.ApiUpdateTargetPoolRequest)) loadbalancer.ApiUpdateTargetPoolRequest { + request := testClient.UpdateTargetPool(testCtx, testProjectId, testLoadBalancerName, testTargetPoolName) + request = request.UpdateTargetPoolPayload(*fixturePayload()) + 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 arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "load balancer name empty", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "target pool name missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, targetPoolNameFlag) + }), + isValid: false, + }, + { + description: "target name missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, targetNameFlag) + }), + isValid: false, + }, + { + description: "ip missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, ipFlag) + }), + 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.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 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 + isValid bool + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer + expectedRequest loadbalancer.ApiUpdateTargetPoolRequest + }{ + { + description: "base", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: true, + expectedRequest: fixtureRequest(func(request *loadbalancer.ApiUpdateTargetPoolRequest) { + payload := fixturePayload(func(payload *loadbalancer.UpdateTargetPoolPayload) { + payload.Targets = &[]loadbalancer.Target{ + (*fixtureTargets())[0], + (*fixtureTargets())[1], + { + DisplayName: utils.Ptr(testTargetName), + Ip: utils.Ptr(testIp), + }, + } + }) + *request = request.UpdateTargetPoolPayload(*payload) + }), + }, + { + description: "empty targets", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + (*lb.TargetPools)[0].Targets = &[]loadbalancer.Target{} + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *loadbalancer.ApiUpdateTargetPoolRequest) { + payload := fixturePayload(func(payload *loadbalancer.UpdateTargetPoolPayload) { + payload.Targets = &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr(testTargetName), + Ip: utils.Ptr(testIp), + }, + } + }) + *request = request.UpdateTargetPoolPayload(*payload) + }), + }, + { + description: "nil targets", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + (*lb.TargetPools)[0].Targets = nil + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *loadbalancer.ApiUpdateTargetPoolRequest) { + payload := fixturePayload(func(payload *loadbalancer.UpdateTargetPoolPayload) { + payload.Targets = &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr(testTargetName), + Ip: utils.Ptr(testIp), + }, + } + }) + *request = request.UpdateTargetPoolPayload(*payload) + }), + }, + { + description: "get load balancer fails", + model: fixtureInputModel(), + getLoadBalancerFails: true, + isValid: false, + }, + { + description: "target pool not found", + model: fixtureInputModel( + func(model *inputModel) { + model.TargetPoolName = "not-existent" + }), + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: false, + }, + { + description: "nil target pool", + model: fixtureInputModel(), + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + *lb.TargetPools = nil + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + getLoadBalancerFails: tt.getLoadBalancerFails, + getLoadBalancerResp: tt.getLoadBalancerResp, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building 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) + } + }) + } +} diff --git a/internal/cmd/load-balancer/target-pool/target_pool.go b/internal/cmd/load-balancer/target-pool/target_pool.go new file mode 100644 index 000000000..4bbbe1a91 --- /dev/null +++ b/internal/cmd/load-balancer/target-pool/target_pool.go @@ -0,0 +1,26 @@ +package targetpool + +import ( + addtarget "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/target-pool/add-target" + "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: "target-pool", + Short: "Provides functionality for target pools", + Long: "Provides functionality for target pools.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(addtarget.NewCmd(p)) +} diff --git a/internal/pkg/services/load-balancer/utils/utils.go b/internal/pkg/services/load-balancer/utils/utils.go index df0dc654f..4a500ff03 100644 --- a/internal/pkg/services/load-balancer/utils/utils.go +++ b/internal/pkg/services/load-balancer/utils/utils.go @@ -9,6 +9,8 @@ import ( type LoadBalancerClient interface { GetCredentialsExecute(ctx context.Context, projectId, credentialsRef string) (*loadbalancer.GetCredentialsResponse, error) + GetLoadBalancerExecute(ctx context.Context, projectId, name string) (*loadbalancer.LoadBalancer, error) + UpdateTargetPool(ctx context.Context, projectId, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest } func GetCredentialsDisplayName(ctx context.Context, apiClient LoadBalancerClient, projectId, credentialsRef string) (string, error) { @@ -18,3 +20,53 @@ func GetCredentialsDisplayName(ctx context.Context, apiClient LoadBalancerClient } return *resp.Credential.DisplayName, nil } + +func GetLoadBalancerTargetPool(ctx context.Context, apiClient LoadBalancerClient, projectId, loadBalancerName, targetPoolName string) (*loadbalancer.TargetPool, error) { + resp, err := apiClient.GetLoadBalancerExecute(ctx, projectId, loadBalancerName) + if err != nil { + return nil, fmt.Errorf("get load balancer: %w", err) + } + + if resp == nil { + return nil, fmt.Errorf("no load balancer found") + } + if resp.TargetPools == nil { + return nil, fmt.Errorf("no target pools found") + } + + for _, targetPool := range *resp.TargetPools { + if targetPool.Name != nil && *targetPool.Name == targetPoolName { + return &targetPool, nil + } + } + + return nil, fmt.Errorf("target pool not found") +} + +func AddTargetToTargetPool(targetPool *loadbalancer.TargetPool, target *loadbalancer.Target) error { + if targetPool == nil { + return fmt.Errorf("target pool is nil") + } + if target == nil { + return fmt.Errorf("target is nil") + } + if targetPool.Targets == nil { + targetPool.Targets = &[]loadbalancer.Target{*target} + return nil + } + *targetPool.Targets = append(*targetPool.Targets, *target) + return nil +} + +func ToPayloadTargetPool(targetPool *loadbalancer.TargetPool) *loadbalancer.UpdateTargetPoolPayload { + if targetPool == nil { + return nil + } + return &loadbalancer.UpdateTargetPoolPayload{ + Name: targetPool.Name, + ActiveHealthCheck: targetPool.ActiveHealthCheck, + SessionPersistence: targetPool.SessionPersistence, + TargetPort: targetPool.TargetPort, + Targets: targetPool.Targets, + } +} diff --git a/internal/pkg/services/load-balancer/utils/utils_test.go b/internal/pkg/services/load-balancer/utils/utils_test.go index b0b6574f1..df2a96e0d 100644 --- a/internal/pkg/services/load-balancer/utils/utils_test.go +++ b/internal/pkg/services/load-balancer/utils/utils_test.go @@ -7,22 +7,27 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" ) var ( - testProjectId = uuid.NewString() - testCredentialsRef = "credentials-test" + testProjectId = uuid.NewString() ) const ( - testCredentialsDisplayName = "name" + testCredentialsRef = "credentials-ref" + testCredentialsDisplayName = "credentials-name" + testLoadBalancerName = "my-load-balancer" ) type loadBalancerClientMocked struct { - getCredentialsFails bool - getCredentialsResp *loadbalancer.GetCredentialsResponse + getCredentialsFails bool + getCredentialsResp *loadbalancer.GetCredentialsResponse + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer } func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) { @@ -32,6 +37,56 @@ func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ return m.getCredentialsResp, nil } +func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _ string) (*loadbalancer.LoadBalancer, error) { + if m.getLoadBalancerFails { + return nil, fmt.Errorf("could not get load balancer") + } + return m.getLoadBalancerResp, nil +} + +func (m *loadBalancerClientMocked) UpdateTargetPool(_ context.Context, _, _, _ string) loadbalancer.ApiUpdateTargetPoolRequest { + return loadbalancer.ApiUpdateTargetPoolRequest{} +} + +func fixtureLoadBalancer(mods ...func(*loadbalancer.LoadBalancer)) *loadbalancer.LoadBalancer { + lb := loadbalancer.LoadBalancer{ + Name: utils.Ptr(testLoadBalancerName), + TargetPools: &[]loadbalancer.TargetPool{ + { + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("4.3.2.1"), + }, + }, + }, + { + Name: utils.Ptr("target-pool-2"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("6.7.8.9"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("9.8.7.6"), + }, + }, + }, + }, + } + + for _, mod := range mods { + mod(&lb) + } + return &lb +} + func TestGetCredentialsDisplayName(t *testing.T) { tests := []struct { description string @@ -81,3 +136,270 @@ func TestGetCredentialsDisplayName(t *testing.T) { }) } } + +func TestGetLoadBalancerTargetPool(t *testing.T) { + tests := []struct { + description string + targetPoolName string + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer + isValid bool + expectedOutput *loadbalancer.TargetPool + }{ + { + description: "base", + targetPoolName: "target-pool-1", + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: true, + expectedOutput: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("4.3.2.1"), + }, + }, + }, + }, + { + description: "target pool not found", + targetPoolName: "target-pool-non-existent", + getLoadBalancerResp: fixtureLoadBalancer(), + isValid: false, + }, + { + description: "no target pools", + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + lb.TargetPools = &[]loadbalancer.TargetPool{} + }), + isValid: false, + }, + { + description: "nil target pools", + getLoadBalancerResp: fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + lb.TargetPools = nil + }), + isValid: false, + }, + { + description: "get load balancer fails", + getLoadBalancerFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + getLoadBalancerFails: tt.getLoadBalancerFails, + getLoadBalancerResp: tt.getLoadBalancerResp, + } + + output, err := GetLoadBalancerTargetPool(context.Background(), client, testProjectId, testLoadBalancerName, tt.targetPoolName) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + diff := cmp.Diff(output, tt.expectedOutput) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestAddTargetToTargetPool(t *testing.T) { + tests := []struct { + description string + targetPool *loadbalancer.TargetPool + target *loadbalancer.Target + isValid bool + expectedTargetPool *loadbalancer.TargetPool + }{ + { + description: "base", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + target: &loadbalancer.Target{ + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("6.6.6.6"), + }, + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + { + DisplayName: utils.Ptr("target-2"), + Ip: utils.Ptr("6.6.6.6"), + }, + }, + }, + }, + { + description: "no target pool targets", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{}, + }, + target: &loadbalancer.Target{ + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + }, + }, + }, + { + description: "nil target pool targets", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: nil, + }, + target: &loadbalancer.Target{ + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + isValid: true, + expectedTargetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + }, + }, + }, + { + description: "nil target pool", + targetPool: nil, + target: &loadbalancer.Target{ + DisplayName: utils.Ptr("target-3"), + Ip: utils.Ptr("2.2.2.2"), + }, + expectedTargetPool: nil, + }, + { + description: "nil new target", + targetPool: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + target: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := AddTargetToTargetPool(tt.targetPool, tt.target) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + diff := cmp.Diff(tt.targetPool, tt.expectedTargetPool) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestToPayloadTargetPool(t *testing.T) { + tests := []struct { + description string + input *loadbalancer.TargetPool + expected *loadbalancer.UpdateTargetPoolPayload + }{ + { + description: "base", + input: &loadbalancer.TargetPool{ + Name: utils.Ptr("target-pool-1"), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + expected: &loadbalancer.UpdateTargetPoolPayload{ + Name: utils.Ptr("target-pool-1"), + ActiveHealthCheck: &loadbalancer.ActiveHealthCheck{ + UnhealthyThreshold: utils.Ptr(int64(3)), + }, + SessionPersistence: &loadbalancer.SessionPersistence{ + UseSourceIpAddress: utils.Ptr(true), + }, + TargetPort: utils.Ptr(int64(80)), + Targets: &[]loadbalancer.Target{ + { + DisplayName: utils.Ptr("target-1"), + Ip: utils.Ptr("1.2.3.4"), + }, + }, + }, + }, + { + description: "nil target pool", + input: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := ToPayloadTargetPool(tt.input) + + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Errorf("expected output to be %+v, got %+v", tt.expected, output) + } + }) + } +}