From 0578af79470d986259825b377b96a69da3378189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 9 May 2024 16:48:04 +0100 Subject: [PATCH 1/9] Add --used and --unused flags to `load-balancer list` (#308) * initial implementation * add testing, finish functionality * generate docs, minor improvements * more testing * refactor implementation, simplify RunE * remove unused func * address PR comments * generate-docs * change filtercredentials to use enum for operation type * address PR comments --- ...load-balancer_observability-credentials.md | 2 +- ...balancer_observability-credentials_list.md | 18 +- .../observability-credentials/list/list.go | 58 ++- .../list/list_test.go | 86 ++++ .../target-pool/add-target/add_target_test.go | 4 + .../remove-target/remove_target_test.go | 4 + .../pkg/services/load-balancer/utils/utils.go | 108 +++++ .../load-balancer/utils/utils_test.go | 369 +++++++++++++++++- 8 files changed, 632 insertions(+), 17 deletions(-) diff --git a/docs/stackit_load-balancer_observability-credentials.md b/docs/stackit_load-balancer_observability-credentials.md index 111523c5f..c11fbfd0a 100644 --- a/docs/stackit_load-balancer_observability-credentials.md +++ b/docs/stackit_load-balancer_observability-credentials.md @@ -32,6 +32,6 @@ stackit load-balancer observability-credentials [flags] * [stackit load-balancer observability-credentials add](./stackit_load-balancer_observability-credentials_add.md) - Adds observability credentials to Load Balancer * [stackit load-balancer observability-credentials delete](./stackit_load-balancer_observability-credentials_delete.md) - Deletes observability credentials for Load Balancer * [stackit load-balancer observability-credentials describe](./stackit_load-balancer_observability-credentials_describe.md) - Shows details of observability credentials for Load Balancer -* [stackit load-balancer observability-credentials list](./stackit_load-balancer_observability-credentials_list.md) - Lists all observability credentials for Load Balancer +* [stackit load-balancer observability-credentials list](./stackit_load-balancer_observability-credentials_list.md) - Lists observability credentials for Load Balancer * [stackit load-balancer observability-credentials update](./stackit_load-balancer_observability-credentials_update.md) - Updates observability credentials for Load Balancer diff --git a/docs/stackit_load-balancer_observability-credentials_list.md b/docs/stackit_load-balancer_observability-credentials_list.md index ee183e01f..581aa37a8 100644 --- a/docs/stackit_load-balancer_observability-credentials_list.md +++ b/docs/stackit_load-balancer_observability-credentials_list.md @@ -1,10 +1,10 @@ ## stackit load-balancer observability-credentials list -Lists all observability credentials for Load Balancer +Lists observability credentials for Load Balancer ### Synopsis -Lists all observability credentials for Load Balancer. +Lists observability credentials for Load Balancer. ``` stackit load-balancer observability-credentials list [flags] @@ -13,13 +13,19 @@ stackit load-balancer observability-credentials list [flags] ### Examples ``` - List all observability credentials for Load Balancer + List all Load Balancer observability credentials $ stackit load-balancer observability-credentials list - List all observability credentials for Load Balancer in JSON format + List all observability credentials being used by Load Balancer + $ stackit load-balancer observability-credentials list --used + + List all observability credentials not being used by Load Balancer + $ stackit load-balancer observability-credentials list --unused + + List all Load Balancer observability credentials in JSON format $ stackit load-balancer observability-credentials list --output-format json - List up to 10 observability credentials for Load Balancer + List up to 10 Load Balancer observability credentials $ stackit load-balancer observability-credentials list --limit 10 ``` @@ -28,6 +34,8 @@ stackit load-balancer observability-credentials list [flags] ``` -h, --help Help for "stackit load-balancer observability-credentials list" --limit int Maximum number of entries to list + --unused List only credentials not being used by a Load Balancer + --used List only credentials being used by a Load Balancer ``` ### Options inherited from parent commands diff --git a/internal/cmd/load-balancer/observability-credentials/list/list.go b/internal/cmd/load-balancer/observability-credentials/list/list.go index 21cb4dcad..237ef420c 100644 --- a/internal/cmd/load-balancer/observability-credentials/list/list.go +++ b/internal/cmd/load-balancer/observability-credentials/list/list.go @@ -13,6 +13,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" "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-cli/internal/pkg/tables" "github.com/spf13/cobra" @@ -22,28 +23,38 @@ import ( const ( instanceIdFlag = "instance-id" limitFlag = "limit" + usedFlag = "used" + unusedFlag = "unused" ) type inputModel struct { *globalflags.GlobalFlagModel - Limit *int64 + Limit *int64 + Used bool + Unused bool } func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "Lists all observability credentials for Load Balancer", - Long: "Lists all observability credentials for Load Balancer.", + Short: "Lists observability credentials for Load Balancer", + Long: "Lists observability credentials for Load Balancer.", Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `List all observability credentials for Load Balancer`, + `List all Load Balancer observability credentials`, "$ stackit load-balancer observability-credentials list"), examples.NewExample( - `List all observability credentials for Load Balancer in JSON format`, + `List all observability credentials being used by Load Balancer`, + "$ stackit load-balancer observability-credentials list --used"), + examples.NewExample( + `List all observability credentials not being used by Load Balancer`, + "$ stackit load-balancer observability-credentials list --unused"), + examples.NewExample( + `List all Load Balancer observability credentials in JSON format`, "$ stackit load-balancer observability-credentials list --output-format json"), examples.NewExample( - `List up to 10 observability credentials for Load Balancer`, + `List up to 10 Load Balancer observability credentials`, "$ stackit load-balancer observability-credentials list --limit 10"), ), RunE: func(cmd *cobra.Command, args []string) error { @@ -72,13 +83,23 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("list Load Balancer observability credentials: %w", err) } credentialsPtr := resp.Credentials - if credentialsPtr == nil || (credentialsPtr != nil && len(*credentialsPtr) == 0) { + if credentialsPtr == nil || len(*credentialsPtr) == 0 { p.Info("No observability credentials found for Load Balancer on project %q\n", projectLabel) return nil } credentials := *credentialsPtr + filterOp, err := getFilterOp(model.Used, model.Unused) + if err != nil { + return err + } + + credentials, err = utils.FilterCredentials(ctx, apiClient, credentials, model.ProjectId, filterOp) + if err != nil { + return fmt.Errorf("filter credentials: %w", err) + } + // Truncate output if model.Limit != nil && len(credentials) > int(*model.Limit) { credentials = credentials[:*model.Limit] @@ -92,6 +113,10 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Bool(usedFlag, false, "List only credentials being used by a Load Balancer") + cmd.Flags().Bool(unusedFlag, false, "List only credentials not being used by a Load Balancer") + + cmd.MarkFlagsMutuallyExclusive(usedFlag, unusedFlag) } func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { @@ -111,6 +136,8 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { model := inputModel{ GlobalFlagModel: globalFlags, Limit: limit, + Used: flags.FlagToBoolValue(p, cmd, usedFlag), + Unused: flags.FlagToBoolValue(p, cmd, unusedFlag), } if p.IsVerbosityDebug() { @@ -155,3 +182,20 @@ func outputResult(p *print.Printer, outputFormat string, credentials []loadbalan return nil } } + +func getFilterOp(used, unused bool) (int, error) { + // should not happen, cobra handles this + if used && unused { + return 0, fmt.Errorf("used and unused flags are mutually exclusive") + } + + if !used && !unused { + return utils.OP_FILTER_NOP, nil + } + + if used { + return utils.OP_FILTER_USED, nil + } + + return utils.OP_FILTER_UNUSED, nil +} diff --git a/internal/cmd/load-balancer/observability-credentials/list/list_test.go b/internal/cmd/load-balancer/observability-credentials/list/list_test.go index 9c564117e..736adf134 100644 --- a/internal/cmd/load-balancer/observability-credentials/list/list_test.go +++ b/internal/cmd/load-balancer/observability-credentials/list/list_test.go @@ -6,6 +6,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + lbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -108,6 +109,34 @@ func TestParseInput(t *testing.T) { }), isValid: false, }, + { + description: "used", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[usedFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Used = true + }), + }, + { + description: "unused", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[unusedFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Unused = true + }), + }, + { + description: "used and unused", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[usedFlag] = "true" + flagValues[unusedFlag] = "true" + }), + isValid: false, + }, } for _, tt := range tests { @@ -137,6 +166,14 @@ func TestParseInput(t *testing.T) { t.Fatalf("error validating flags: %v", err) } + err = cmd.ValidateFlagGroups() + 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 { @@ -183,3 +220,52 @@ func TestBuildRequest(t *testing.T) { }) } } + +func TestGetFilterOp(t *testing.T) { + tests := []struct { + description string + used bool + unused bool + expectedFilterOp int + isValid bool + }{ + { + description: "used", + used: true, + expectedFilterOp: lbUtils.OP_FILTER_USED, + isValid: true, + }, + { + description: "unused", + unused: true, + expectedFilterOp: lbUtils.OP_FILTER_UNUSED, + isValid: true, + }, + { + description: "used and unused", + used: true, + unused: true, + isValid: false, + }, + { + description: "neither used nor unused", + expectedFilterOp: lbUtils.OP_FILTER_NOP, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + filterOp, err := getFilterOp(tt.used, tt.unused) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error getting filter op: %v", err) + } + if filterOp != tt.expectedFilterOp { + t.Fatalf("Data does not match: %d", filterOp) + } + }) + } +} 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 index 128e2c60a..8120dc497 100644 --- 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 @@ -57,6 +57,10 @@ func (m *loadBalancerClientMocked) UpdateTargetPool(ctx context.Context, project return testClient.UpdateTargetPool(ctx, projectId, loadBalancerName, targetPoolName) } +func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _ string) (*loadbalancer.ListLoadBalancersResponse, error) { + return nil, nil +} + func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ testIP, diff --git a/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go b/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go index bec625abd..ec31f13e2 100644 --- a/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go +++ b/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go @@ -57,6 +57,10 @@ func (m *loadBalancerClientMocked) UpdateTargetPool(ctx context.Context, project return testClient.UpdateTargetPool(ctx, projectId, loadBalancerName, targetPoolName) } +func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _ string) (*loadbalancer.ListLoadBalancersResponse, error) { + return nil, nil +} + func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ testIP, diff --git a/internal/pkg/services/load-balancer/utils/utils.go b/internal/pkg/services/load-balancer/utils/utils.go index 0f173b251..1ad93ce18 100644 --- a/internal/pkg/services/load-balancer/utils/utils.go +++ b/internal/pkg/services/load-balancer/utils/utils.go @@ -3,14 +3,23 @@ package utils import ( "context" "fmt" + "slices" + "sort" "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) +const ( + OP_FILTER_NOP = iota + OP_FILTER_USED + OP_FILTER_UNUSED +) + 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 + ListLoadBalancersExecute(ctx context.Context, projectId string) (*loadbalancer.ListLoadBalancersResponse, error) } func GetCredentialsDisplayName(ctx context.Context, apiClient LoadBalancerClient, projectId, credentialsRef string) (string, error) { @@ -130,3 +139,102 @@ func GetTargetName(ctx context.Context, apiClient LoadBalancerClient, projectId, } return "", fmt.Errorf("target not found") } + +// GetUsedObsCredentials returns a list of credentials that are used by load balancers for observability metrics or logs. +// It goes through all load balancers and checks what observability credentials are being used, then returns a list of those credentials. +func GetUsedObsCredentials(ctx context.Context, apiClient LoadBalancerClient, allCredentials []loadbalancer.CredentialsResponse, projectId string) ([]loadbalancer.CredentialsResponse, error) { + var usedCredentialsSlice []loadbalancer.CredentialsResponse + + loadBalancers, err := apiClient.ListLoadBalancersExecute(ctx, projectId) + if err != nil { + return nil, fmt.Errorf("list load balancers: %w", err) + } + if loadBalancers == nil || loadBalancers.LoadBalancers == nil { + return usedCredentialsSlice, nil + } + + var usedCredentialsRefs []string + for _, loadBalancer := range *loadBalancers.LoadBalancers { + if loadBalancer.Options == nil || loadBalancer.Options.Observability == nil { + continue + } + + if loadBalancer.Options != nil && loadBalancer.Options.Observability != nil && loadBalancer.Options.Observability.Logs != nil && loadBalancer.Options.Observability.Logs.CredentialsRef != nil { + usedCredentialsRefs = append(usedCredentialsRefs, *loadBalancer.Options.Observability.Logs.CredentialsRef) + } + if loadBalancer.Options != nil && loadBalancer.Options.Observability != nil && loadBalancer.Options.Observability.Metrics != nil && loadBalancer.Options.Observability.Metrics.CredentialsRef != nil { + usedCredentialsRefs = append(usedCredentialsRefs, *loadBalancer.Options.Observability.Metrics.CredentialsRef) + } + } + + usedCredentialsMap := make(map[string]loadbalancer.CredentialsResponse) + for _, credential := range allCredentials { + if credential.CredentialsRef == nil { + continue + } + ref := *credential.CredentialsRef + if slices.Contains(usedCredentialsRefs, ref) { + usedCredentialsMap[ref] = credential + } + } + + for _, credential := range usedCredentialsMap { + usedCredentialsSlice = append(usedCredentialsSlice, credential) + } + + // sort credentials by reference to make output deterministic + sort.Slice(usedCredentialsSlice, func(i, j int) bool { + return *usedCredentialsSlice[i].CredentialsRef < *usedCredentialsSlice[j].CredentialsRef + }) + + return usedCredentialsSlice, nil +} + +// GetUnusedObsCredentials returns a list of credentials that are not used by any load balancer for observability metrics or logs. +// It compares the list of all credentials with the list of used credentials and returns a list of credentials that are not used. +func GetUnusedObsCredentials(usedCredentials, allCredentials []loadbalancer.CredentialsResponse) []loadbalancer.CredentialsResponse { + var unusedCredentials []loadbalancer.CredentialsResponse + usedCredentialsRefs := make(map[string]bool) + for _, credential := range usedCredentials { + if credential.CredentialsRef != nil { + usedCredentialsRefs[*credential.CredentialsRef] = true + } + } + + for _, credential := range allCredentials { + if credential.CredentialsRef == nil { + continue + } + if !usedCredentialsRefs[*credential.CredentialsRef] { + unusedCredentials = append(unusedCredentials, credential) + } + } + + return unusedCredentials +} + +// FilterCredentials filters a list of credentials based on the used and unused flags. +// If used is true, it returns only the credentials that are used by load balancers for observability metrics or logs. +// If unused is true, it returns only the credentials that are not used by any load balancer for observability metrics or logs. +// If both used and unused are true, it returns an error. +// If both used and unused are false, it returns the original list of credentials. +func FilterCredentials(ctx context.Context, client LoadBalancerClient, allCredentials []loadbalancer.CredentialsResponse, projectId string, filterOp int) ([]loadbalancer.CredentialsResponse, error) { + // check that filter OP is valid + if filterOp != OP_FILTER_USED && filterOp != OP_FILTER_UNUSED && filterOp != OP_FILTER_NOP { + return nil, fmt.Errorf("invalid filter operation") + } + + if filterOp == OP_FILTER_NOP { + return allCredentials, nil + } + + usedCredentials, err := GetUsedObsCredentials(ctx, client, allCredentials, projectId) + if err != nil { + return nil, fmt.Errorf("get used observability credentials: %w", err) + } + + if filterOp == OP_FILTER_UNUSED { + return GetUnusedObsCredentials(usedCredentials, allCredentials), nil + } + return usedCredentials, nil +} diff --git a/internal/pkg/services/load-balancer/utils/utils_test.go b/internal/pkg/services/load-balancer/utils/utils_test.go index 2d4d96c0d..93b8f6ff3 100644 --- a/internal/pkg/services/load-balancer/utils/utils_test.go +++ b/internal/pkg/services/load-balancer/utils/utils_test.go @@ -15,6 +15,7 @@ import ( var ( testProjectId = uuid.NewString() + testCtx = context.Background() ) const ( @@ -24,10 +25,12 @@ const ( ) type loadBalancerClientMocked struct { - getCredentialsFails bool - getCredentialsResp *loadbalancer.GetCredentialsResponse - getLoadBalancerFails bool - getLoadBalancerResp *loadbalancer.LoadBalancer + getCredentialsFails bool + getCredentialsResp *loadbalancer.GetCredentialsResponse + getLoadBalancerFails bool + getLoadBalancerResp *loadbalancer.LoadBalancer + listLoadBalancersFails bool + listLoadBalancersResp *loadbalancer.ListLoadBalancersResponse } func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) { @@ -44,6 +47,13 @@ func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, return m.getLoadBalancerResp, nil } +func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _ string) (*loadbalancer.ListLoadBalancersResponse, error) { + if m.listLoadBalancersFails { + return nil, fmt.Errorf("could not list load balancers") + } + return m.listLoadBalancersResp, nil +} + func (m *loadBalancerClientMocked) UpdateTargetPool(_ context.Context, _, _, _ string) loadbalancer.ApiUpdateTargetPoolRequest { return loadbalancer.ApiUpdateTargetPoolRequest{} } @@ -79,6 +89,18 @@ func fixtureLoadBalancer(mods ...func(*loadbalancer.LoadBalancer)) *loadbalancer }, }, }, + Options: &loadbalancer.LoadBalancerOptions{ + Observability: &loadbalancer.LoadbalancerOptionObservability{ + Logs: &loadbalancer.LoadbalancerOptionLogs{ + CredentialsRef: utils.Ptr("credentials-ref-1"), + PushUrl: utils.Ptr("https://logs.stackit.cloud"), + }, + Metrics: &loadbalancer.LoadbalancerOptionMetrics{ + CredentialsRef: utils.Ptr("credentials-ref-2"), + PushUrl: utils.Ptr("https://metrics.stackit.cloud"), + }, + }, + }, } for _, mod := range mods { @@ -87,6 +109,32 @@ func fixtureLoadBalancer(mods ...func(*loadbalancer.LoadBalancer)) *loadbalancer return &lb } +func fixtureCredentials(mod ...func([]loadbalancer.CredentialsResponse)) []loadbalancer.CredentialsResponse { + credentials := []loadbalancer.CredentialsResponse{ + { + CredentialsRef: utils.Ptr("credentials-ref-1"), + DisplayName: utils.Ptr("credentials-1"), + Username: utils.Ptr("user-1"), + }, + { + CredentialsRef: utils.Ptr("credentials-ref-2"), + DisplayName: utils.Ptr("credentials-2"), + Username: utils.Ptr("user-2"), + }, + { + CredentialsRef: utils.Ptr("credentials-ref-3"), + DisplayName: utils.Ptr("credentials-3"), + Username: utils.Ptr("user-3"), + }, + } + + for _, m := range mod { + m(credentials) + } + + return credentials +} + func fixtureTargets(mod ...func(*[]loadbalancer.Target)) *[]loadbalancer.Target { targets := &[]loadbalancer.Target{ { @@ -793,3 +841,316 @@ func TestGetTargetName(t *testing.T) { }) } } + +func TestGetUsedObsCredentials(t *testing.T) { + tests := []struct { + description string + allCredentials []loadbalancer.CredentialsResponse + listLoadBalancersFails bool + listLoadBalancersResp *loadbalancer.ListLoadBalancersResponse + isValid bool + expectedOutput []loadbalancer.CredentialsResponse + }{ + { + description: "base", + allCredentials: fixtureCredentials(), + listLoadBalancersResp: &loadbalancer.ListLoadBalancersResponse{ + LoadBalancers: &[]loadbalancer.LoadBalancer{ + *fixtureLoadBalancer(), + }, + }, + isValid: true, + expectedOutput: []loadbalancer.CredentialsResponse{ + { + DisplayName: utils.Ptr("credentials-1"), + CredentialsRef: utils.Ptr("credentials-ref-1"), + Username: utils.Ptr("user-1"), + }, + { + DisplayName: utils.Ptr("credentials-2"), + CredentialsRef: utils.Ptr("credentials-ref-2"), + Username: utils.Ptr("user-2"), + }, + }, + }, + { + description: "repeated credentials in different load balancers", + allCredentials: fixtureCredentials(), + listLoadBalancersResp: &loadbalancer.ListLoadBalancersResponse{ + LoadBalancers: &[]loadbalancer.LoadBalancer{ + *fixtureLoadBalancer(), + *fixtureLoadBalancer(), + }, + }, + isValid: true, + expectedOutput: []loadbalancer.CredentialsResponse{ + { + DisplayName: utils.Ptr("credentials-1"), + CredentialsRef: utils.Ptr("credentials-ref-1"), + Username: utils.Ptr("user-1"), + }, + { + DisplayName: utils.Ptr("credentials-2"), + CredentialsRef: utils.Ptr("credentials-ref-2"), + Username: utils.Ptr("user-2"), + }, + }, + }, + { + description: "no repeated credentials in different load balancers", + allCredentials: fixtureCredentials(), + listLoadBalancersResp: &loadbalancer.ListLoadBalancersResponse{ + LoadBalancers: &[]loadbalancer.LoadBalancer{ + *fixtureLoadBalancer(), + *fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + lb.Options.Observability.Logs.CredentialsRef = utils.Ptr("credentials-ref-3") + lb.Options.Observability.Metrics.CredentialsRef = utils.Ptr("credentials-ref-3") + }), + }, + }, + isValid: true, + expectedOutput: fixtureCredentials(), + }, + { + description: "no load balancers, no credentials", + listLoadBalancersResp: &loadbalancer.ListLoadBalancersResponse{}, + isValid: true, + expectedOutput: nil, + }, + { + description: "no load balancers", + allCredentials: fixtureCredentials(), + listLoadBalancersResp: &loadbalancer.ListLoadBalancersResponse{}, + isValid: true, + expectedOutput: nil, + }, + { + description: "no credentials", + allCredentials: []loadbalancer.CredentialsResponse{}, + listLoadBalancersResp: &loadbalancer.ListLoadBalancersResponse{ + LoadBalancers: &[]loadbalancer.LoadBalancer{ + *fixtureLoadBalancer(), + }, + }, + isValid: true, + expectedOutput: nil, + }, + { + description: "list load balancers fails", + listLoadBalancersFails: true, + isValid: false, + }, + { + description: "no observability options", + allCredentials: fixtureCredentials(), + listLoadBalancersResp: &loadbalancer.ListLoadBalancersResponse{ + LoadBalancers: &[]loadbalancer.LoadBalancer{ + *fixtureLoadBalancer(func(lb *loadbalancer.LoadBalancer) { + lb.Options = nil + }), + }, + }, + isValid: true, + expectedOutput: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + listLoadBalancersFails: tt.listLoadBalancersFails, + listLoadBalancersResp: tt.listLoadBalancersResp, + } + + output, err := GetUsedObsCredentials(testCtx, client, tt.allCredentials, testProjectId) + + 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 TestGetUnusedObsCredentials(t *testing.T) { + tests := []struct { + description string + allCredentials []loadbalancer.CredentialsResponse + usedCredentials []loadbalancer.CredentialsResponse + isValid bool + expectedOutput []loadbalancer.CredentialsResponse + }{ + { + description: "base", + allCredentials: fixtureCredentials(), + usedCredentials: []loadbalancer.CredentialsResponse{ + { + DisplayName: utils.Ptr("credentials-1"), + CredentialsRef: utils.Ptr("credentials-ref-1"), + Username: utils.Ptr("user-1"), + }, + }, + isValid: true, + expectedOutput: []loadbalancer.CredentialsResponse{ + { + DisplayName: utils.Ptr("credentials-2"), + CredentialsRef: utils.Ptr("credentials-ref-2"), + Username: utils.Ptr("user-2"), + }, + { + DisplayName: utils.Ptr("credentials-3"), + CredentialsRef: utils.Ptr("credentials-ref-3"), + Username: utils.Ptr("user-3"), + }, + }, + }, + { + description: "no used credentials", + allCredentials: fixtureCredentials(), + usedCredentials: nil, + isValid: true, + expectedOutput: fixtureCredentials(), + }, + { + description: "no credentials", + allCredentials: []loadbalancer.CredentialsResponse{}, + usedCredentials: []loadbalancer.CredentialsResponse{ + { + DisplayName: utils.Ptr("credentials-1"), + CredentialsRef: utils.Ptr("credentials-ref-1"), + Username: utils.Ptr("user-1"), + }, + }, + isValid: true, + expectedOutput: nil, + }, + { + description: "no used credentials, no credentials", + allCredentials: []loadbalancer.CredentialsResponse{}, + usedCredentials: nil, + isValid: true, + expectedOutput: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := GetUnusedObsCredentials(tt.usedCredentials, tt.allCredentials) + + diff := cmp.Diff(output, tt.expectedOutput) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestFilterCredentials(t *testing.T) { + tests := []struct { + description string + filterOp int + allCredentials []loadbalancer.CredentialsResponse + listLoadBalancersResp *loadbalancer.ListLoadBalancersResponse + listLoadBalancersFails bool + expectedCredentials []loadbalancer.CredentialsResponse + isValid bool + }{ + { + description: "unfiltered credentials", + filterOp: OP_FILTER_NOP, + allCredentials: fixtureCredentials(), + expectedCredentials: fixtureCredentials(), + isValid: true, + }, + { + description: "used credentials", + filterOp: OP_FILTER_USED, + allCredentials: fixtureCredentials(), + listLoadBalancersResp: &loadbalancer.ListLoadBalancersResponse{ + LoadBalancers: &[]loadbalancer.LoadBalancer{ + *fixtureLoadBalancer(), + }, + }, + expectedCredentials: []loadbalancer.CredentialsResponse{ + { + CredentialsRef: utils.Ptr("credentials-ref-1"), + DisplayName: utils.Ptr("credentials-1"), + Username: utils.Ptr("user-1"), + }, + { + CredentialsRef: utils.Ptr("credentials-ref-2"), + DisplayName: utils.Ptr("credentials-2"), + Username: utils.Ptr("user-2"), + }, + }, + isValid: true, + }, + { + description: "unused credentials", + filterOp: OP_FILTER_UNUSED, + allCredentials: fixtureCredentials(), + listLoadBalancersResp: &loadbalancer.ListLoadBalancersResponse{ + LoadBalancers: &[]loadbalancer.LoadBalancer{ + *fixtureLoadBalancer(), + }, + }, + expectedCredentials: []loadbalancer.CredentialsResponse{ + { + CredentialsRef: utils.Ptr("credentials-ref-3"), + DisplayName: utils.Ptr("credentials-3"), + Username: utils.Ptr("user-3"), + }, + }, + isValid: true, + }, + { + description: "no credentials", + filterOp: OP_FILTER_NOP, + allCredentials: []loadbalancer.CredentialsResponse{}, + expectedCredentials: []loadbalancer.CredentialsResponse{}, + isValid: true, + }, + { + description: "list load balancers fails", + filterOp: OP_FILTER_USED, + listLoadBalancersFails: true, + isValid: false, + }, + { + description: "invalid filter operation", + filterOp: 999, + allCredentials: fixtureCredentials(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &loadBalancerClientMocked{ + listLoadBalancersResp: tt.listLoadBalancersResp, + listLoadBalancersFails: tt.listLoadBalancersFails, + } + filteredCredentials, err := FilterCredentials(testCtx, client, tt.allCredentials, testProjectId, tt.filterOp) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error filtering credentials: %v", err) + } + + diff := cmp.Diff(filteredCredentials, tt.expectedCredentials) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} From 05eb3d6747083b8f3a22218b3d992a2b7762476b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Fri, 10 May 2024 11:11:46 +0100 Subject: [PATCH 2/9] Onboard `load-balancer observability-credentials cleanup` (#311) * command implementation, add testing * rename var, generate docs * address PR comments --- ...load-balancer_observability-credentials.md | 1 + ...ancer_observability-credentials_cleanup.md | 39 ++++ .../cleanup/cleanup.go | 140 +++++++++++ .../cleanup/cleanup_test.go | 221 ++++++++++++++++++ .../observability-credentials.go | 2 + 5 files changed, 403 insertions(+) create mode 100644 docs/stackit_load-balancer_observability-credentials_cleanup.md create mode 100644 internal/cmd/load-balancer/observability-credentials/cleanup/cleanup.go create mode 100644 internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_test.go diff --git a/docs/stackit_load-balancer_observability-credentials.md b/docs/stackit_load-balancer_observability-credentials.md index c11fbfd0a..4ccba5c45 100644 --- a/docs/stackit_load-balancer_observability-credentials.md +++ b/docs/stackit_load-balancer_observability-credentials.md @@ -30,6 +30,7 @@ stackit load-balancer observability-credentials [flags] * [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer * [stackit load-balancer observability-credentials add](./stackit_load-balancer_observability-credentials_add.md) - Adds observability credentials to Load Balancer +* [stackit load-balancer observability-credentials cleanup](./stackit_load-balancer_observability-credentials_cleanup.md) - Deletes observability credentials unused by any Load Balancer * [stackit load-balancer observability-credentials delete](./stackit_load-balancer_observability-credentials_delete.md) - Deletes observability credentials for Load Balancer * [stackit load-balancer observability-credentials describe](./stackit_load-balancer_observability-credentials_describe.md) - Shows details of observability credentials for Load Balancer * [stackit load-balancer observability-credentials list](./stackit_load-balancer_observability-credentials_list.md) - Lists observability credentials for Load Balancer diff --git a/docs/stackit_load-balancer_observability-credentials_cleanup.md b/docs/stackit_load-balancer_observability-credentials_cleanup.md new file mode 100644 index 000000000..e994d0803 --- /dev/null +++ b/docs/stackit_load-balancer_observability-credentials_cleanup.md @@ -0,0 +1,39 @@ +## stackit load-balancer observability-credentials cleanup + +Deletes observability credentials unused by any Load Balancer + +### Synopsis + +Deletes observability credentials unused by any Load Balancer. + +``` +stackit load-balancer observability-credentials cleanup [flags] +``` + +### Examples + +``` + Delete observability credentials unused by any Load Balancer + $ stackit load-balancer observability-credentials cleanup +``` + +### Options + +``` + -h, --help Help for "stackit load-balancer observability-credentials cleanup" +``` + +### 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 observability-credentials](./stackit_load-balancer_observability-credentials.md) - Provides functionality for Load Balancer observability credentials + diff --git a/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup.go b/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup.go new file mode 100644 index 000000000..2192b4591 --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup.go @@ -0,0 +1,140 @@ +package cleanup + +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/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/load-balancer/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "cleanup", + Short: "Deletes observability credentials unused by any Load Balancer", + Long: "Deletes observability credentials unused by any Load Balancer.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Delete observability credentials unused by any Load Balancer`, + "$ stackit load-balancer observability-credentials cleanup"), + ), + RunE: func(cmd *cobra.Command, args []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 + } + + listReq := buildListCredentialsRequest(ctx, model, apiClient) + resp, err := listReq.Execute() + if err != nil { + return fmt.Errorf("list Load Balancer observability credentials: %w", err) + } + + var credentials []loadbalancer.CredentialsResponse + if resp.Credentials != nil && len(*resp.Credentials) > 0 { + credentials, err = utils.FilterCredentials(ctx, apiClient, *resp.Credentials, model.ProjectId, utils.OP_FILTER_UNUSED) + if err != nil { + return fmt.Errorf("filter Load Balancer observability credentials: %w", err) + } + } + + if len(credentials) == 0 { + p.Info("No unused observability credentials found on project %q\n", projectLabel) + return nil + } + + if !model.AssumeYes { + prompt := "Will delete the following unused observability credentials: \n" + for _, credential := range credentials { + if credential.DisplayName == nil || credential.Username == nil { + return fmt.Errorf("list unused Load Balancer observability credentials: credentials %q missing display name or username", *credential.CredentialsRef) + } + name := *credential.DisplayName + username := *credential.Username + prompt += fmt.Sprintf(" - %s (username: %q)\n", name, username) + } + prompt += fmt.Sprintf("Are you sure you want to delete unused observability credentials on project %q? (This cannot be undone)", projectLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + for _, credential := range credentials { + if credential.CredentialsRef == nil { + return fmt.Errorf("delete Load Balancer observability credentials: missing credentials reference") + } + credentialsRef := *credential.CredentialsRef + // Call API + req := buildDeleteCredentialRequest(ctx, model, apiClient, credentialsRef) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete Load Balancer observability credentials: %w", err) + } + } + + p.Info("Deleted unused Load Balancer observability credentials on project %q\n", projectLabel) + 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 buildDeleteCredentialRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient, credentialsRef string) loadbalancer.ApiDeleteCredentialsRequest { + req := apiClient.DeleteCredentials(ctx, model.ProjectId, credentialsRef) + return req +} + +func buildListCredentialsRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiListCredentialsRequest { + req := apiClient.ListCredentials(ctx, model.ProjectId) + return req +} diff --git a/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_test.go b/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_test.go new file mode 100644 index 000000000..ffa7a07e7 --- /dev/null +++ b/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_test.go @@ -0,0 +1,221 @@ +package cleanup + +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/loadbalancer" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +const testCredentialsRef = "credentials-1" + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &loadbalancer.APIClient{} +var testProjectId = uuid.NewString() + +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, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureDeleteCredentialRequest(mods ...func(request *loadbalancer.ApiDeleteCredentialsRequest)) loadbalancer.ApiDeleteCredentialsRequest { + request := testClient.DeleteCredentials(testCtx, testProjectId, testCredentialsRef) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureListCredentialsRequest(mods ...func(request *loadbalancer.ApiListCredentialsRequest)) loadbalancer.ApiListCredentialsRequest { + request := testClient.ListCredentials(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no flag 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) + 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) + 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 TestBuildDeleteCredentialRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest loadbalancer.ApiDeleteCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureDeleteCredentialRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildDeleteCredentialRequest(testCtx, tt.model, testClient, testCredentialsRef) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestListCredentialsRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest loadbalancer.ApiListCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureListCredentialsRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildListCredentialsRequest(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/load-balancer/observability-credentials/observability-credentials.go b/internal/cmd/load-balancer/observability-credentials/observability-credentials.go index 1c7dcec9c..9ad8500d1 100644 --- a/internal/cmd/load-balancer/observability-credentials/observability-credentials.go +++ b/internal/cmd/load-balancer/observability-credentials/observability-credentials.go @@ -2,6 +2,7 @@ package credentials import ( "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/add" + "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/cleanup" "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/list" @@ -32,4 +33,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(delete.NewCmd(p)) cmd.AddCommand(update.NewCmd(p)) cmd.AddCommand(list.NewCmd(p)) + cmd.AddCommand(cleanup.NewCmd(p)) } From 3ab3f6f8cdf214505cd93fe8af86ebe0f7bbd8d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Fri, 10 May 2024 16:12:18 +0100 Subject: [PATCH 3/9] fix no credentials listing (#313) --- .../observability-credentials/list/list.go | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/internal/cmd/load-balancer/observability-credentials/list/list.go b/internal/cmd/load-balancer/observability-credentials/list/list.go index 237ef420c..f71ab67a9 100644 --- a/internal/cmd/load-balancer/observability-credentials/list/list.go +++ b/internal/cmd/load-balancer/observability-credentials/list/list.go @@ -83,21 +83,29 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("list Load Balancer observability credentials: %w", err) } credentialsPtr := resp.Credentials - if credentialsPtr == nil || len(*credentialsPtr) == 0 { - p.Info("No observability credentials found for Load Balancer on project %q\n", projectLabel) - return nil - } - credentials := *credentialsPtr - - filterOp, err := getFilterOp(model.Used, model.Unused) - if err != nil { - return err + var credentials []loadbalancer.CredentialsResponse + if credentialsPtr != nil && len(*credentialsPtr) > 0 { + credentials = *credentialsPtr + filterOp, err := getFilterOp(model.Used, model.Unused) + if err != nil { + return err + } + credentials, err = utils.FilterCredentials(ctx, apiClient, credentials, model.ProjectId, filterOp) + if err != nil { + return fmt.Errorf("filter credentials: %w", err) + } } - credentials, err = utils.FilterCredentials(ctx, apiClient, credentials, model.ProjectId, filterOp) - if err != nil { - return fmt.Errorf("filter credentials: %w", err) + if len(credentials) == 0 { + opLabel := "No " + if model.Used { + opLabel += "used" + } else if model.Unused { + opLabel += "unused" + } + p.Info("%s observability credentials found for Load Balancer on project %q\n", opLabel, projectLabel) + return nil } // Truncate output From 442d19c9e24bce3cfc45aa62901b5dba825cc5ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Palet?= Date: Mon, 13 May 2024 09:31:14 +0100 Subject: [PATCH 4/9] Integrate WinGet distribution in release pipeline (#305) * Integrate WinGet distribution in release pipeline * Add comment regarding skipping prereleases * Fix link --- .goreleaser.yaml | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 041ddccf5..2a1791612 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -101,7 +101,7 @@ brews: email: noreply@stackit.de homepage: "https://github.com/stackitcloud/stackit-cli" description: "A command-line interface to manage STACKIT resources.\nThis CLI is in a BETA state. More services and functionality will be supported soon." - folder: Formula + directory: Formula license: "Apache-2.0" # If set to auto, the release will not be uploaded to the homebrew tap repo # if the tag has a prerelease indicator (e.g. v0.0.1-alpha1) @@ -125,3 +125,26 @@ snapcrafts: grade: devel # Whether to publish the Snap to the store publish: true + +winget: + - name: stackit + publisher: stackitcloud + short_description: A command-line interface to manage STACKIT resources. + license: Apache-2.0 + publisher_support_url: "https://github.com/stackitcloud/stackit-cli/issues" + package_identifier: stackitcloud.stackit + homepage: "https://github.com/stackitcloud/stackit-cli" + # If set to auto, the release will not be uploaded to the homebrew tap repo + # if the tag has a prerelease indicator (e.g. v0.0.1-alpha1) + # Temporarily not skipping prereleases to test integration with Winget + # skip_upload: auto + repository: + owner: stackitcloud + name: winget-pkgs + pull_request: + enabled: true + draft: true + base: + owner: microsoft + name: winget-pkgs + branch: master \ No newline at end of file From 0a20759bc37c148c604018987a3b91ca65fa2f8d Mon Sep 17 00:00:00 2001 From: Vicente Pinto Date: Mon, 13 May 2024 10:10:17 +0100 Subject: [PATCH 5/9] Configure table titles (#314) * Update tables * Configure colors in the less pager * Fix title wrapping, add titles to lb * Re-add -w argument and add titles to mongodb and pgflex options --- .../cmd/load-balancer/describe/describe.go | 7 +++++-- internal/cmd/mongodbflex/options/options.go | 5 ++++- internal/cmd/postgresflex/options/options.go | 5 ++++- internal/cmd/ske/options/options.go | 15 ++++++++++----- internal/pkg/print/print.go | 8 +++++++- internal/pkg/tables/tables.go | 19 +++++++++++++++++++ 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/cmd/load-balancer/describe/describe.go b/internal/cmd/load-balancer/describe/describe.go index fe28b86d7..ff6defaf0 100644 --- a/internal/cmd/load-balancer/describe/describe.go +++ b/internal/cmd/load-balancer/describe/describe.go @@ -162,6 +162,7 @@ func renderLoadBalancer(loadBalancer *loadbalancer.LoadBalancer) string { } table := tables.NewTable() + table.SetTitle("Load Balancer") table.AddRow("NAME", *loadBalancer.Name) table.AddSeparator() table.AddRow("STATE", *loadBalancer.Status) @@ -182,7 +183,8 @@ func renderLoadBalancer(loadBalancer *loadbalancer.LoadBalancer) string { func renderListeners(listeners []loadbalancer.Listener) string { table := tables.NewTable() - table.SetHeader("LISTENER NAME", "PORT", "PROTOCOL", "TARGET POOL") + table.SetTitle("Listeners") + table.SetHeader("NAME", "PORT", "PROTOCOL", "TARGET POOL") for i := range listeners { listener := listeners[i] table.AddRow(*listener.Name, *listener.Port, *listener.Protocol, *listener.TargetPool) @@ -192,7 +194,8 @@ func renderListeners(listeners []loadbalancer.Listener) string { func renderTargetPools(targetPools []loadbalancer.TargetPool) string { table := tables.NewTable() - table.SetHeader("TARGET POOL NAME", "PORT", "TARGETS") + table.SetTitle("Target Pools") + table.SetHeader("NAME", "PORT", "TARGETS") for _, targetPool := range targetPools { table.AddRow(*targetPool.Name, *targetPool.TargetPort, len(*targetPool.Targets)) } diff --git a/internal/cmd/mongodbflex/options/options.go b/internal/cmd/mongodbflex/options/options.go index 5811b0c09..217756194 100644 --- a/internal/cmd/mongodbflex/options/options.go +++ b/internal/cmd/mongodbflex/options/options.go @@ -222,6 +222,7 @@ func renderFlavors(flavors []mongodbflex.HandlersInfraFlavor) string { } table := tables.NewTable() + table.SetTitle("Flavors") table.SetHeader("ID", "CPU", "MEMORY", "DESCRIPTION", "VALID INSTANCE TYPES") for i := range flavors { f := flavors[i] @@ -236,6 +237,7 @@ func renderVersions(versions []string) string { } table := tables.NewTable() + table.SetTitle("Versions") table.SetHeader("VERSION") for i := range versions { v := versions[i] @@ -251,7 +253,8 @@ func renderStorages(resp *mongodbflex.ListStoragesResponse) string { storageClasses := *resp.StorageClasses table := tables.NewTable() - table.SetHeader("MIN STORAGE", "MAX STORAGE", "STORAGE CLASS") + table.SetTitle("Storages") + table.SetHeader("MINIMUM", "MAXIMUM", "STORAGE CLASS") for i := range storageClasses { sc := storageClasses[i] table.AddRow(*resp.StorageRange.Min, *resp.StorageRange.Max, sc) diff --git a/internal/cmd/postgresflex/options/options.go b/internal/cmd/postgresflex/options/options.go index c245a453c..7341f4ea3 100644 --- a/internal/cmd/postgresflex/options/options.go +++ b/internal/cmd/postgresflex/options/options.go @@ -222,6 +222,7 @@ func renderFlavors(flavors []postgresflex.Flavor) string { } table := tables.NewTable() + table.SetTitle("Flavors") table.SetHeader("ID", "CPU", "MEMORY", "DESCRIPTION") for i := range flavors { f := flavors[i] @@ -236,6 +237,7 @@ func renderVersions(versions []string) string { } table := tables.NewTable() + table.SetTitle("Versions") table.SetHeader("VERSION") for i := range versions { v := versions[i] @@ -251,7 +253,8 @@ func renderStorages(resp *postgresflex.ListStoragesResponse) string { storageClasses := *resp.StorageClasses table := tables.NewTable() - table.SetHeader("MIN STORAGE", "MAX STORAGE", "STORAGE CLASS") + table.SetTitle("Storages") + table.SetHeader("MINIMUM", "MAXIMUM", "STORAGE CLASS") for i := range storageClasses { sc := storageClasses[i] table.AddRow(*resp.StorageRange.Min, *resp.StorageRange.Max, sc) diff --git a/internal/cmd/ske/options/options.go b/internal/cmd/ske/options/options.go index 74b651ab2..b41682e08 100644 --- a/internal/cmd/ske/options/options.go +++ b/internal/cmd/ske/options/options.go @@ -181,7 +181,8 @@ func renderAvailabilityZones(resp *ske.ProviderOptions) string { zones := *resp.AvailabilityZones table := tables.NewTable() - table.SetHeader("AVAILABILITY ZONES") + table.SetTitle("Availability Zones") + table.SetHeader("ZONE") for i := range zones { z := zones[i] table.AddRow(*z.Name) @@ -193,7 +194,8 @@ func renderKubernetesVersions(resp *ske.ProviderOptions) (string, error) { versions := *resp.KubernetesVersions table := tables.NewTable() - table.SetHeader("KUBERNETES VERSION", "STATE", "EXPIRATION DATE", "FEATURE GATES") + table.SetTitle("Kubernetes Versions") + table.SetHeader("VERSION", "STATE", "EXPIRATION DATE", "FEATURE GATES") for i := range versions { v := versions[i] featureGate, err := json.Marshal(*v.FeatureGates) @@ -213,7 +215,8 @@ func renderMachineImages(resp *ske.ProviderOptions) string { images := *resp.MachineImages table := tables.NewTable() - table.SetHeader("MACHINE IMAGE NAME", "VERSION", "STATE", "EXPIRATION DATE", "SUPPORTED CRI") + table.SetTitle("Machine Images") + table.SetHeader("NAME", "VERSION", "STATE", "EXPIRATION DATE", "SUPPORTED CRI") for i := range images { image := images[i] versions := *image.Versions @@ -241,7 +244,8 @@ func renderMachineTypes(resp *ske.ProviderOptions) string { types := *resp.MachineTypes table := tables.NewTable() - table.SetHeader("MACHINE TYPE", "CPU", "MEMORY") + table.SetTitle("Machine Types") + table.SetHeader("TYPE", "CPU", "MEMORY") for i := range types { t := types[i] table.AddRow(*t.Name, *t.Cpu, *t.Memory) @@ -253,7 +257,8 @@ func renderVolumeTypes(resp *ske.ProviderOptions) string { types := *resp.VolumeTypes table := tables.NewTable() - table.SetHeader("VOLUME TYPE") + table.SetTitle("Volume Types") + table.SetHeader("TYPE") for i := range types { z := types[i] table.AddRow(*z.Name) diff --git a/internal/pkg/print/print.go b/internal/pkg/print/print.go index 2bdd483cc..aa08cc2f6 100644 --- a/internal/pkg/print/print.go +++ b/internal/pkg/print/print.go @@ -171,7 +171,13 @@ func (p *Printer) PagerDisplay(content string) error { if outputFormat == NoneOutputFormat { return nil } - pagerCmd := exec.Command("less", "-F", "-S", "-w") + + // less arguments + // -F: exits if the entire file fits on the first screen + // -S: disables line wrapping + // -w: highlight the first line after moving one full page down + // -R: interprets ANSI color and style sequences + pagerCmd := exec.Command("less", "-F", "-S", "-w", "-R") pagerCmd.Stdin = strings.NewReader(content) pagerCmd.Stdout = p.Cmd.OutOrStdout() diff --git a/internal/pkg/tables/tables.go b/internal/pkg/tables/tables.go index a60de1fc8..de8e0b397 100644 --- a/internal/pkg/tables/tables.go +++ b/internal/pkg/tables/tables.go @@ -6,6 +6,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" ) type Table struct { @@ -20,6 +21,20 @@ func NewTable() Table { } } +// Sets the title of the table +func (t *Table) SetTitle(title string) { + t.table.SetTitle(title) + + // prevent title wrapping by setting the width of the first column to the length of the title + // this is a workaround for a bug in the tables pkg, see https://github.com/jedib0t/go-pretty/issues/135 + t.table.SetColumnConfigs([]table.ColumnConfig{ + { + Number: 1, + WidthMin: len(title), + }, + }) +} + // Sets the header of the table func (t *Table) SetHeader(header ...interface{}) { t.table.AppendHeader(table.Row(header)) @@ -47,6 +62,10 @@ func (t *Table) EnableAutoMergeOnColumns(columns ...int) { // Returns the table rendered func (t *Table) Render() string { t.table.SetStyle(table.StyleLight) + + t.table.Style().Title = table.TitleOptionsBlackOnCyan + t.table.Style().Title.Align = text.AlignCenter + t.table.Style().Options.DrawBorder = false t.table.Style().Options.SeparateRows = false t.table.Style().Options.SeparateColumns = true From ca58ad43f9540348c6765d94a5209afd24fe0a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Palet?= Date: Mon, 13 May 2024 11:08:30 +0100 Subject: [PATCH 6/9] Add table title to config list --- internal/cmd/config/list/list.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/config/list/list.go b/internal/cmd/config/list/list.go index 8b890aa4a..d6c3e2055 100644 --- a/internal/cmd/config/list/list.go +++ b/internal/cmd/config/list/list.go @@ -74,7 +74,7 @@ func outputResult(p *print.Printer, outputFormat string, configData map[string]a switch outputFormat { case print.JSONOutputFormat: if activeProfile != "" { - configData["active_profile"] = activeProfile + configData["profile"] = activeProfile } details, err := json.MarshalIndent(configData, "", " ") if err != nil { @@ -83,9 +83,6 @@ func outputResult(p *print.Printer, outputFormat string, configData map[string]a p.Outputln(string(details)) return nil default: - if activeProfile != "" { - p.Outputf("\n ACTIVE PROFILE: %s\n", activeProfile) - } // Sort the config options by key configKeys := make([]string, 0, len(configData)) @@ -95,6 +92,9 @@ func outputResult(p *print.Printer, outputFormat string, configData map[string]a sort.Strings(configKeys) table := tables.NewTable() + if activeProfile != "" { + table.SetTitle(fmt.Sprintf("Profile: %q", activeProfile)) + } table.SetHeader("NAME", "VALUE") for _, key := range configKeys { value := configData[key] From b9bd78432edbcf8a47ff145a58e90dc16bba939b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Palet?= Date: Mon, 13 May 2024 11:09:16 +0100 Subject: [PATCH 7/9] Fixes and improvements to profiles functionality --- internal/cmd/config/profile/set/set.go | 7 ++++--- internal/cmd/config/profile/unset/unset.go | 2 +- internal/cmd/root.go | 2 +- internal/pkg/config/config.go | 6 ++++-- internal/pkg/config/profiles.go | 16 +++++++++++++--- internal/pkg/print/print.go | 12 ++++++++---- internal/pkg/print/print_test.go | 7 +++---- 7 files changed, 34 insertions(+), 18 deletions(-) diff --git a/internal/cmd/config/profile/set/set.go b/internal/cmd/config/profile/set/set.go index 1ab1cac0e..9dd3172e9 100644 --- a/internal/cmd/config/profile/set/set.go +++ b/internal/cmd/config/profile/set/set.go @@ -25,10 +25,11 @@ func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("set %s", profileArg), Short: "Set a CLI configuration profile", - Long: fmt.Sprintf("%s\n%s\n%s\n%s", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s", "Set a CLI configuration profile as the active profile.", `The profile to be used can be managed via the STACKIT_CLI_PROFILE environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands.`, "The environment variable takes precedence over what is set via the commands.", + "A new profile is created automatically if it does not exist.", "When no profile is set, the default profile is used.", ), Args: args.SingleArg(profileArg, nil), @@ -43,12 +44,12 @@ func NewCmd(p *print.Printer) *cobra.Command { return err } - err = config.SetProfile(model.Profile) + err = config.SetProfile(p, model.Profile) if err != nil { return fmt.Errorf("set profile: %w", err) } - p.Info("Profile %q set successfully as the active profile\n", model.Profile) + p.Info("Successfully set active profile to %q\n", model.Profile) return nil }, } diff --git a/internal/cmd/config/profile/unset/unset.go b/internal/cmd/config/profile/unset/unset.go index 61ea77607..339439262 100644 --- a/internal/cmd/config/profile/unset/unset.go +++ b/internal/cmd/config/profile/unset/unset.go @@ -26,7 +26,7 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit config profile unset"), ), RunE: func(cmd *cobra.Command, args []string) error { - err := config.UnsetProfile() + err := config.UnsetProfile(p) if err != nil { return fmt.Errorf("unset profile: %w", err) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 93b88ad1e..491e700c6 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -53,7 +53,7 @@ func NewRootCmd(version, date string, p *print.Printer) *cobra.Command { p.Debug(print.DebugLevel, "arguments: %s", argsString) configFilePath := viper.ConfigFileUsed() - p.Debug(print.DebugLevel, "using config file: %s", configFilePath) + p.Debug(print.DebugLevel, "will persist and read config from: %s", configFilePath) activeProfile, err := config.GetProfile() if err != nil { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 209710e08..8982352a8 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -76,6 +76,7 @@ var ConfigKeys = []string{ SKECustomEndpointKey, } +var defaultConfigFolderPath string var configFolderPath string var profileFilePath string @@ -83,12 +84,13 @@ func InitConfig() { configDir, err := os.UserConfigDir() cobra.CheckErr(err) - configFolderPath = filepath.Join(configDir, configFolder) // Default config folder - profileFilePath = filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", profileFileName, profileFileExtension)) // Profile file path is in the default config folder + defaultConfigFolderPath = filepath.Join(configDir, configFolder) + profileFilePath = filepath.Join(defaultConfigFolderPath, fmt.Sprintf("%s.%s", profileFileName, profileFileExtension)) // Profile file path is in the default config folder configProfile, err := GetProfile() cobra.CheckErr(err) + configFolderPath = defaultConfigFolderPath if configProfile != "" { configFolderPath = filepath.Join(configFolderPath, configProfile) // If a profile is set, use the profile config folder } diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go index c581b95a1..e8644afea 100644 --- a/internal/pkg/config/profiles.go +++ b/internal/pkg/config/profiles.go @@ -7,6 +7,7 @@ import ( "regexp" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) // GetProfile returns the current profile to be used by the CLI. @@ -38,27 +39,36 @@ func GetProfile() (string, error) { } // SetProfile sets the profile to be used by the CLI. -func SetProfile(profile string) error { +func SetProfile(p *print.Printer, profile string) error { err := ValidateProfile(profile) if err != nil { return fmt.Errorf("validate profile: %w", err) } - profileFilePath := filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", profileFileName, profileFileExtension)) err = os.WriteFile(profileFilePath, []byte(profile), os.ModePerm) if err != nil { return fmt.Errorf("write profile to file: %w", err) } + p.Debug(print.DebugLevel, "persisted new active profile in: %s", profileFilePath) + + configFolderPath = filepath.Join(defaultConfigFolderPath, profile) + err = createFolderIfNotExists(configFolderPath) + if err != nil { + return fmt.Errorf("create config folder: %w", err) + } + p.Debug(print.DebugLevel, "created folder for the new profile: %s", configFolderPath) + return nil } // UnsetProfile removes the profile file. // If the profile file does not exist, it does nothing. -func UnsetProfile() error { +func UnsetProfile(p *print.Printer) error { err := os.Remove(profileFilePath) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove profile file: %w", err) } + p.Debug(print.DebugLevel, "removed active profile file: %s", profileFilePath) return nil } diff --git a/internal/pkg/print/print.go b/internal/pkg/print/print.go index aa08cc2f6..f9f7ce00a 100644 --- a/internal/pkg/print/print.go +++ b/internal/pkg/print/print.go @@ -15,7 +15,7 @@ import ( "github.com/mattn/go-colorable" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "golang.org/x/term" ) @@ -27,6 +27,10 @@ const ( WarningLevel Level = "warning" ErrorLevel Level = "error" + // Needed to avoid import cycle + // Originally defined in "internal/pkg/config/config.go" + outputFormatKey = "output-format" + JSONOutputFormat = "json" PrettyOutputFormat = "pretty" NoneOutputFormat = "none" @@ -53,7 +57,7 @@ func NewPrinter() *Printer { // Print an output using Printf to the defined output (falling back to Stderr if not set). // If output format is set to none, it does nothing func (p *Printer) Outputf(msg string, args ...any) { - outputFormat := viper.GetString(config.OutputFormatKey) + outputFormat := viper.GetString(outputFormatKey) if outputFormat == NoneOutputFormat { return } @@ -63,7 +67,7 @@ func (p *Printer) Outputf(msg string, args ...any) { // Print an output using Println to the defined output (falling back to Stderr if not set). // If output format is set to none, it does nothing func (p *Printer) Outputln(msg string) { - outputFormat := viper.GetString(config.OutputFormatKey) + outputFormat := viper.GetString(outputFormatKey) if outputFormat == NoneOutputFormat { return } @@ -167,7 +171,7 @@ func (p *Printer) PromptForPassword(prompt string) (string, error) { // Shows the content in the command's stdout using the "less" command // If output format is set to none, it does nothing func (p *Printer) PagerDisplay(content string) error { - outputFormat := viper.GetString(config.OutputFormatKey) + outputFormat := viper.GetString(outputFormatKey) if outputFormat == NoneOutputFormat { return nil } diff --git a/internal/pkg/print/print_test.go b/internal/pkg/print/print_test.go index 712288639..f5841de37 100644 --- a/internal/pkg/print/print_test.go +++ b/internal/pkg/print/print_test.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/stackitcloud/stackit-cli/internal/pkg/config" ) func TestOutputf(t *testing.T) { @@ -66,7 +65,7 @@ func TestOutputf(t *testing.T) { viper.Reset() if tt.outputFormatNone { - viper.Set(config.OutputFormatKey, NoneOutputFormat) + viper.Set(outputFormatKey, NoneOutputFormat) } if len(tt.args) == 0 { @@ -137,7 +136,7 @@ func TestOutputln(t *testing.T) { viper.Reset() if tt.outputFormatNone { - viper.Set(config.OutputFormatKey, NoneOutputFormat) + viper.Set(outputFormatKey, NoneOutputFormat) } p.Outputln(tt.message) @@ -201,7 +200,7 @@ func TestPagerDisplay(t *testing.T) { viper.Reset() if tt.outputFormatNone { - viper.Set(config.OutputFormatKey, NoneOutputFormat) + viper.Set(outputFormatKey, NoneOutputFormat) } err := p.PagerDisplay(tt.content) From 170d8e6952c6a70c0ae359b2db1bbd1ab571b7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Palet?= Date: Mon, 13 May 2024 11:30:38 +0100 Subject: [PATCH 8/9] Adjustments after review --- internal/cmd/root.go | 6 +++--- internal/pkg/config/profiles.go | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 491e700c6..8f4193142 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -53,7 +53,7 @@ func NewRootCmd(version, date string, p *print.Printer) *cobra.Command { p.Debug(print.DebugLevel, "arguments: %s", argsString) configFilePath := viper.ConfigFileUsed() - p.Debug(print.DebugLevel, "will persist and read config from: %s", configFilePath) + p.Debug(print.DebugLevel, "configuration is persisted and read from: %s", configFilePath) activeProfile, err := config.GetProfile() if err != nil { @@ -62,11 +62,11 @@ func NewRootCmd(version, date string, p *print.Printer) *cobra.Command { if activeProfile == "" { activeProfile = "(no active profile, the default profile configuration will be used)" } - p.Debug(print.DebugLevel, "active config profile: %s", activeProfile) + p.Debug(print.DebugLevel, "active configuration profile: %s", activeProfile) configKeys := viper.AllSettings() configKeysStr := print.BuildDebugStrFromMap(configKeys) - p.Debug(print.DebugLevel, "config keys: %s", configKeysStr) + p.Debug(print.DebugLevel, "configuration keys: %s", configKeysStr) return nil }, diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go index e8644afea..219fa879c 100644 --- a/internal/pkg/config/profiles.go +++ b/internal/pkg/config/profiles.go @@ -57,6 +57,7 @@ func SetProfile(p *print.Printer, profile string) error { return fmt.Errorf("create config folder: %w", err) } p.Debug(print.DebugLevel, "created folder for the new profile: %s", configFolderPath) + p.Debug(print.DebugLevel, "profile %q is now active", profile) return nil } From 1f6f80d5ec7cd380e7a654a374fb753f2e8da319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Palet?= Date: Mon, 13 May 2024 11:30:54 +0100 Subject: [PATCH 9/9] Generate docs --- docs/stackit_config.md | 13 +++++---- docs/stackit_config_profile.md | 37 ++++++++++++++++++++++++ docs/stackit_config_profile_set.md | 43 ++++++++++++++++++++++++++++ docs/stackit_config_profile_unset.md | 40 ++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 docs/stackit_config_profile.md create mode 100644 docs/stackit_config_profile_set.md create mode 100644 docs/stackit_config_profile_unset.md diff --git a/docs/stackit_config.md b/docs/stackit_config.md index ed8212d87..fedd6f2a3 100644 --- a/docs/stackit_config.md +++ b/docs/stackit_config.md @@ -4,12 +4,12 @@ Provides functionality for CLI configuration options ### Synopsis -Provides functionality for CLI configuration options -The configuration is stored in a file in the user's config directory, which is OS dependent. -Windows: %APPDATA%\stackit -Linux: $XDG_CONFIG_HOME/stackit -macOS: $HOME/Library/Application Support/stackit -The configuration file is named `cli-config.json` and is created automatically in your first CLI run. +Provides functionality for CLI configuration options. +You can set and unset different configuration options via the "stackit config set" and "stackit config unset" commands. + +Additionally, you can configure the CLI to use different profiles, each with its own configuration. +Additional profiles can be configured via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands. +The environment variable takes precedence over what is set via the commands. ``` stackit config [flags] @@ -35,6 +35,7 @@ stackit config [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit config list](./stackit_config_list.md) - Lists the current CLI configuration values +* [stackit config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles * [stackit config set](./stackit_config_set.md) - Sets CLI configuration options * [stackit config unset](./stackit_config_unset.md) - Unsets CLI configuration options diff --git a/docs/stackit_config_profile.md b/docs/stackit_config_profile.md new file mode 100644 index 000000000..1c46aeb73 --- /dev/null +++ b/docs/stackit_config_profile.md @@ -0,0 +1,37 @@ +## stackit config profile + +Manage the CLI configuration profiles + +### Synopsis + +Manage the CLI configuration profiles. +The profile to be used can be managed via the "STACKIT_CLI_PROFILE" environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands. +The environment variable takes precedence over what is set via the commands. +When no profile is set, the default profile is used. + +``` +stackit config profile [flags] +``` + +### Options + +``` + -h, --help Help for "stackit config profile" +``` + +### 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 config](./stackit_config.md) - Provides functionality for CLI configuration options +* [stackit config profile set](./stackit_config_profile_set.md) - Set a CLI configuration profile +* [stackit config profile unset](./stackit_config_profile_unset.md) - Unset the current active CLI configuration profile + diff --git a/docs/stackit_config_profile_set.md b/docs/stackit_config_profile_set.md new file mode 100644 index 000000000..bcc9725a6 --- /dev/null +++ b/docs/stackit_config_profile_set.md @@ -0,0 +1,43 @@ +## stackit config profile set + +Set a CLI configuration profile + +### Synopsis + +Set a CLI configuration profile as the active profile. +The profile to be used can be managed via the STACKIT_CLI_PROFILE environment variable or using the "stackit config profile set PROFILE" and "stackit config profile unset" commands. +The environment variable takes precedence over what is set via the commands. +A new profile is created automatically if it does not exist. +When no profile is set, the default profile is used. + +``` +stackit config profile set PROFILE [flags] +``` + +### Examples + +``` + Set the configuration profile "my-profile" as the active profile + $ stackit config profile set my-profile +``` + +### Options + +``` + -h, --help Help for "stackit config profile set" +``` + +### 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 config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles + diff --git a/docs/stackit_config_profile_unset.md b/docs/stackit_config_profile_unset.md new file mode 100644 index 000000000..0beda0f67 --- /dev/null +++ b/docs/stackit_config_profile_unset.md @@ -0,0 +1,40 @@ +## stackit config profile unset + +Unset the current active CLI configuration profile + +### Synopsis + +Unset the current active CLI configuration profile. +When no profile is set, the default profile will be used. + +``` +stackit config profile unset [flags] +``` + +### Examples + +``` + Unset the currently active configuration profile. The default profile will be used. + $ stackit config profile unset +``` + +### Options + +``` + -h, --help Help for "stackit config profile unset" +``` + +### 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 config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles +