From 500d923c93a1d8c75c640d2c30541e1a1376a6ad Mon Sep 17 00:00:00 2001 From: Jonas Schlecht Date: Thu, 29 Oct 2026 08:57:42 +0100 Subject: [PATCH 1/2] feat(scf): onboard scf platform describe/list and scf platform quota describe/list Relates to STACKITCLI-484 --- docs/stackit_beta_scf.md | 1 + docs/stackit_beta_scf_platform.md | 36 +++ docs/stackit_beta_scf_platform_describe.md | 43 ++++ docs/stackit_beta_scf_platform_list.md | 44 ++++ docs/stackit_beta_scf_platform_quota.md | 35 +++ ...tackit_beta_scf_platform_quota_describe.md | 44 ++++ docs/stackit_beta_scf_platform_quota_list.md | 43 ++++ .../beta/scf/platform/describe/describe.go | 114 +++++++++ .../scf/platform/describe/describe_test.go | 200 +++++++++++++++ internal/cmd/beta/scf/platform/list/list.go | 151 +++++++++++ .../cmd/beta/scf/platform/list/list_test.go | 222 ++++++++++++++++ internal/cmd/beta/scf/platform/platform.go | 31 +++ .../scf/platform/quota/describe/describe.go | 154 ++++++++++++ .../platform/quota/describe/describe_test.go | 238 ++++++++++++++++++ .../cmd/beta/scf/platform/quota/list/list.go | 120 +++++++++ .../beta/scf/platform/quota/list/list_test.go | 206 +++++++++++++++ internal/cmd/beta/scf/platform/quota/quota.go | 29 +++ internal/cmd/beta/scf/scf.go | 2 + 18 files changed, 1713 insertions(+) create mode 100644 docs/stackit_beta_scf_platform.md create mode 100644 docs/stackit_beta_scf_platform_describe.md create mode 100644 docs/stackit_beta_scf_platform_list.md create mode 100644 docs/stackit_beta_scf_platform_quota.md create mode 100644 docs/stackit_beta_scf_platform_quota_describe.md create mode 100644 docs/stackit_beta_scf_platform_quota_list.md create mode 100644 internal/cmd/beta/scf/platform/describe/describe.go create mode 100644 internal/cmd/beta/scf/platform/describe/describe_test.go create mode 100644 internal/cmd/beta/scf/platform/list/list.go create mode 100644 internal/cmd/beta/scf/platform/list/list_test.go create mode 100644 internal/cmd/beta/scf/platform/platform.go create mode 100644 internal/cmd/beta/scf/platform/quota/describe/describe.go create mode 100644 internal/cmd/beta/scf/platform/quota/describe/describe_test.go create mode 100644 internal/cmd/beta/scf/platform/quota/list/list.go create mode 100644 internal/cmd/beta/scf/platform/quota/list/list_test.go create mode 100644 internal/cmd/beta/scf/platform/quota/quota.go diff --git a/docs/stackit_beta_scf.md b/docs/stackit_beta_scf.md index 056c89eef..bf0e5c60b 100644 --- a/docs/stackit_beta_scf.md +++ b/docs/stackit_beta_scf.md @@ -31,4 +31,5 @@ stackit beta scf [flags] * [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands * [stackit beta scf organization](./stackit_beta_scf_organization.md) - Provides functionality for STACKIT Cloud Foundry organizations +* [stackit beta scf platform](./stackit_beta_scf_platform.md) - Provides functionality for STACKIT Cloud Foundry platforms diff --git a/docs/stackit_beta_scf_platform.md b/docs/stackit_beta_scf_platform.md new file mode 100644 index 000000000..a8dad4831 --- /dev/null +++ b/docs/stackit_beta_scf_platform.md @@ -0,0 +1,36 @@ +## stackit beta scf platform + +Provides functionality for STACKIT Cloud Foundry platforms + +### Synopsis + +Provides functionality for STACKIT Cloud Foundry platforms. + +``` +stackit beta scf platform [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta scf platform" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf](./stackit_beta_scf.md) - Provides functionality for STACKIT Cloud Foundry +* [stackit beta scf platform describe](./stackit_beta_scf_platform_describe.md) - Shows details of a STACKIT Cloud Foundry platform +* [stackit beta scf platform list](./stackit_beta_scf_platform_list.md) - Lists all STACKIT Cloud Foundry platforms +* [stackit beta scf platform quota](./stackit_beta_scf_platform_quota.md) - Shows quotas of STACKIT Cloud Foundry platforms + diff --git a/docs/stackit_beta_scf_platform_describe.md b/docs/stackit_beta_scf_platform_describe.md new file mode 100644 index 000000000..376d82a15 --- /dev/null +++ b/docs/stackit_beta_scf_platform_describe.md @@ -0,0 +1,43 @@ +## stackit beta scf platform describe + +Shows details of a STACKIT Cloud Foundry platform + +### Synopsis + +Shows details of a STACKIT Cloud Foundry platform. + +``` +stackit beta scf platform describe PLATFORM_ID [flags] +``` + +### Examples + +``` + Get details of a STACKIT Cloud Foundry platform with ID "xxx" + $ stackit beta scf platform describe xxx + + Get details of a STACKIT Cloud Foundry platform with ID "xxx" in JSON format + $ stackit beta scf platform describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta scf platform describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf platform](./stackit_beta_scf_platform.md) - Provides functionality for STACKIT Cloud Foundry platforms + diff --git a/docs/stackit_beta_scf_platform_list.md b/docs/stackit_beta_scf_platform_list.md new file mode 100644 index 000000000..8cab4ede0 --- /dev/null +++ b/docs/stackit_beta_scf_platform_list.md @@ -0,0 +1,44 @@ +## stackit beta scf platform list + +Lists all STACKIT Cloud Foundry platforms + +### Synopsis + +Lists all STACKIT Cloud Foundry platforms. The number of fetched platforms is limited to 5000. + +``` +stackit beta scf platform list [flags] +``` + +### Examples + +``` + List all STACKIT Cloud Foundry platforms + $ stackit beta scf platform list + + List up to 10 STACKIT Cloud Foundry platforms + $ stackit beta scf platform list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta scf platform list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf platform](./stackit_beta_scf_platform.md) - Provides functionality for STACKIT Cloud Foundry platforms + diff --git a/docs/stackit_beta_scf_platform_quota.md b/docs/stackit_beta_scf_platform_quota.md new file mode 100644 index 000000000..bbb4ee0b5 --- /dev/null +++ b/docs/stackit_beta_scf_platform_quota.md @@ -0,0 +1,35 @@ +## stackit beta scf platform quota + +Shows quotas of STACKIT Cloud Foundry platforms + +### Synopsis + +Shows quotas of STACKIT Cloud Foundry platforms. + +``` +stackit beta scf platform quota [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta scf platform quota" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf platform](./stackit_beta_scf_platform.md) - Provides functionality for STACKIT Cloud Foundry platforms +* [stackit beta scf platform quota describe](./stackit_beta_scf_platform_quota_describe.md) - Shows details of a STACKIT Cloud Foundry platform quota +* [stackit beta scf platform quota list](./stackit_beta_scf_platform_quota_list.md) - Lists the quotas of a STACKIT Cloud Foundry platform + diff --git a/docs/stackit_beta_scf_platform_quota_describe.md b/docs/stackit_beta_scf_platform_quota_describe.md new file mode 100644 index 000000000..d2fd5d066 --- /dev/null +++ b/docs/stackit_beta_scf_platform_quota_describe.md @@ -0,0 +1,44 @@ +## stackit beta scf platform quota describe + +Shows details of a STACKIT Cloud Foundry platform quota + +### Synopsis + +Shows details of a STACKIT Cloud Foundry platform quota. + +``` +stackit beta scf platform quota describe PLATFORM_ID [flags] +``` + +### Examples + +``` + Get details of the quota with ID "yyy" of a STACKIT Cloud Foundry platform with ID "xxx" + $ stackit beta scf platform quota describe xxx --quota-id yyy + + Get details of the quota with ID "yyy" of a STACKIT Cloud Foundry platform with ID "xxx" in JSON format + $ stackit beta scf platform quota describe xxx --quota-id yyy --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta scf platform quota describe" + --quota-id string The UUID of the quota +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf platform quota](./stackit_beta_scf_platform_quota.md) - Shows quotas of STACKIT Cloud Foundry platforms + diff --git a/docs/stackit_beta_scf_platform_quota_list.md b/docs/stackit_beta_scf_platform_quota_list.md new file mode 100644 index 000000000..065a5e65a --- /dev/null +++ b/docs/stackit_beta_scf_platform_quota_list.md @@ -0,0 +1,43 @@ +## stackit beta scf platform quota list + +Lists the quotas of a STACKIT Cloud Foundry platform + +### Synopsis + +Lists the quotas of a STACKIT Cloud Foundry platform. + +``` +stackit beta scf platform quota list PLATFORM_ID [flags] +``` + +### Examples + +``` + List the quotas of a STACKIT Cloud Foundry platform with ID "xxx" + $ stackit beta scf platform quota list xxx + + List the quotas of a STACKIT Cloud Foundry platform with ID "xxx" in JSON format + $ stackit beta scf platform quota list xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta scf platform quota list" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf platform quota](./stackit_beta_scf_platform_quota.md) - Shows quotas of STACKIT Cloud Foundry platforms + diff --git a/internal/cmd/beta/scf/platform/describe/describe.go b/internal/cmd/beta/scf/platform/describe/describe.go new file mode 100644 index 000000000..6c4d3482b --- /dev/null +++ b/internal/cmd/beta/scf/platform/describe/describe.go @@ -0,0 +1,114 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + platformIdArg = "PLATFORM_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + PlatformId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", platformIdArg), + Short: "Shows details of a STACKIT Cloud Foundry platform", + Long: "Shows details of a STACKIT Cloud Foundry platform.", + Args: args.SingleArg(platformIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a STACKIT Cloud Foundry platform with ID "xxx"`, + `$ stackit beta scf platform describe xxx`), + examples.NewExample( + `Get details of a STACKIT Cloud Foundry platform with ID "xxx" in JSON format`, + `$ stackit beta scf platform describe xxx --output-format json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get STACKIT Cloud Foundry platform: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + platformId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + PlatformId: platformId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiGetPlatformRequest { + return apiClient.GetPlatform(ctx, model.ProjectId, model.Region, model.PlatformId) +} + +func outputResult(p *print.Printer, outputFormat string, platform *scf.Platforms) error { + return p.OutputResult(outputFormat, platform, func() error { + if platform == nil { + return fmt.Errorf("received nil response, could not display details") + } + + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", platform.GetGuid()) + table.AddRow("Name", platform.GetDisplayName()) + table.AddRow("System ID", platform.GetSystemId()) + table.AddRow("Region", platform.GetRegion()) + table.AddRow("API URL", platform.GetApiUrl()) + table.AddRow("Console URL", utils.PtrString(platform.ConsoleUrl)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/scf/platform/describe/describe_test.go b/internal/cmd/beta/scf/platform/describe/describe_test.go new file mode 100644 index 000000000..390cbbe5f --- /dev/null +++ b/internal/cmd/beta/scf/platform/describe/describe_test.go @@ -0,0 +1,200 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.APIClient{ + DefaultAPI: &scf.DefaultAPIService{}, + } + testProjectId = uuid.NewString() + testPlatformId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testPlatformId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + PlatformId: testPlatformId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiGetPlatformRequest)) scf.ApiGetPlatformRequest { + request := testClient.DefaultAPI.GetPlatform(testCtx, testProjectId, testRegion, testPlatformId) + 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: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "platform id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest scf.ApiGetPlatformRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + platform *scf.Platforms + outputFormat string + wantErr bool + }{ + { + name: "default output", + platform: fixturePlatform(), + wantErr: false, + }, + { + name: "json output", + platform: fixturePlatform(), + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + name: "nil platform", + platform: nil, + wantErr: true, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.outputFormat, tt.platform); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func fixturePlatform() *scf.Platforms { + return &scf.Platforms{ + Guid: testPlatformId, + DisplayName: "my-platform", + SystemId: "my-system", + Region: testRegion, + ApiUrl: "https://api.example.com", + ConsoleUrl: new("https://console.example.com"), + } +} diff --git a/internal/cmd/beta/scf/platform/list/list.go b/internal/cmd/beta/scf/platform/list/list.go new file mode 100644 index 000000000..0635f0f5e --- /dev/null +++ b/internal/cmd/beta/scf/platform/list/list.go @@ -0,0 +1,151 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + limitFlag = "limit" + + // maxPageSize is the maximum number of items the API returns per page. 5000 is the maximum the API supports. + maxPageSize = 5000 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all STACKIT Cloud Foundry platforms", + Long: fmt.Sprintf("Lists all STACKIT Cloud Foundry platforms. The number of fetched platforms is limited to %d.", maxPageSize), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all STACKIT Cloud Foundry platforms`, + "$ stackit beta scf platform list"), + examples.NewExample( + `List up to 10 STACKIT Cloud Foundry platforms`, + "$ stackit beta scf platform list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + resp, err := buildRequest(ctx, model, apiClient.DefaultAPI, 1, maxPageSize).Execute() + if err != nil { + return fmt.Errorf("fetch STACKIT Cloud Foundry platforms: %w", err) + } + + // Get projectLabel + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI, page, perPage int64) scf.ApiListPlatformsRequest { + // the API has non AIP-compliant pagination, which cannot be adapted with out SDK pagination utils. + pageSize := int64(perPage) + if model.Limit != nil && *model.Limit < pageSize { + pageSize = *model.Limit + } + return apiClient.ListPlatforms(ctx, model.ProjectId, model.Region).Page(page).PerPage(pageSize) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *scf.PlatformList) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil || len(resp.Resources) == 0 { + p.Outputf("No STACKIT Cloud Foundry platforms found for project %q\n", projectLabel) + return nil + } + if resp.Pagination.TotalPages != nil && *resp.Pagination.TotalPages > int64(1) { + p.Warn("Server responded with more than 1 page. Output will not show all existing platforms.\n") + } + + table := tables.NewTable() + + table.SetTitle("STACKIT Cloud Foundry platforms") + table.SetHeader("ID", "NAME", "REGION", "SYSTEM ID") + table.AddSeparator() + for _, platform := range resp.Resources { + table.AddRow( + platform.Guid, + platform.DisplayName, + platform.Region, + platform.SystemId, + ) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/scf/platform/list/list_test.go b/internal/cmd/beta/scf/platform/list/list_test.go new file mode 100644 index 000000000..8b344abbe --- /dev/null +++ b/internal/cmd/beta/scf/platform/list/list_test.go @@ -0,0 +1,222 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.APIClient{DefaultAPI: &scf.DefaultAPIService{}} + testProjectId = uuid.NewString() + testPlatformId = uuid.NewString() +) + +const ( + testLimit int64 = 10 + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: strconv.FormatInt(testLimit, 10), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: new(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiListPlatformsRequest)) scf.ApiListPlatformsRequest { + request := testClient.DefaultAPI.ListPlatforms(testCtx, testProjectId, testRegion).Page(1).PerPage(testLimit) + 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", + flagValues: map[string]string{}, + isValid: false, + expectedModel: nil, + }, + { + description: "missing project id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + expectedModel: nil, + }, + { + description: "no limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + }, + { + description: "invalid limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + expectedModel: nil, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest scf.ApiListPlatformsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "no limit set", + model: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *scf.ApiListPlatformsRequest) { + request.PerPage(maxPageSize) + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI, 1, testLimit) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, scf.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + platforms *scf.PlatformList + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "nil platforms slice", + args: args{ + projectLabel: testProjectId, + platforms: nil, + }, + wantErr: false, + }, + { + name: "empty platforms slice", + args: args{ + projectLabel: testProjectId, + platforms: &scf.PlatformList{ + Resources: []scf.Platforms{}, + }, + }, + wantErr: false, + }, + { + name: "valid platforms", + args: args{ + projectLabel: testProjectId, + platforms: &scf.PlatformList{ + Resources: []scf.Platforms{ + { + Guid: testPlatformId, + DisplayName: "my-platform", + Region: testRegion, + SystemId: "my-system", + ApiUrl: "https://api.example.com", + }, + }, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.platforms); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/scf/platform/platform.go b/internal/cmd/beta/scf/platform/platform.go new file mode 100644 index 000000000..b25453a7d --- /dev/null +++ b/internal/cmd/beta/scf/platform/platform.go @@ -0,0 +1,31 @@ +package platform + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/platform/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/platform/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/platform/quota" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// NewCmd creates the 'stackit beta scf platform' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "platform", + Short: "Provides functionality for STACKIT Cloud Foundry platforms", + Long: "Provides functionality for STACKIT Cloud Foundry platforms.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(quota.NewCmd(params)) +} diff --git a/internal/cmd/beta/scf/platform/quota/describe/describe.go b/internal/cmd/beta/scf/platform/quota/describe/describe.go new file mode 100644 index 000000000..a7845af58 --- /dev/null +++ b/internal/cmd/beta/scf/platform/quota/describe/describe.go @@ -0,0 +1,154 @@ +package describe + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + platformIdArg = "PLATFORM_ID" + + quotaIdFlag = "quota-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + PlatformId string + QuotaId *string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", platformIdArg), + Short: "Shows details of a STACKIT Cloud Foundry platform quota", + Long: "Shows details of a STACKIT Cloud Foundry platform quota.", + Args: args.SingleArg(platformIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of the quota with ID "yyy" of a STACKIT Cloud Foundry platform with ID "xxx"`, + `$ stackit beta scf platform quota describe xxx --quota-id yyy`), + examples.NewExample( + `Get details of the quota with ID "yyy" of a STACKIT Cloud Foundry platform with ID "xxx" in JSON format`, + `$ stackit beta scf platform quota describe xxx --quota-id yyy --output-format json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get STACKIT Cloud Foundry platform quota: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), quotaIdFlag, "The UUID of the quota") + + err := flags.MarkFlagsRequired(cmd, quotaIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + platformId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + PlatformId: platformId, + QuotaId: flags.FlagToStringPointer(p, cmd, quotaIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiGetPlatformQuotaRequest { + return apiClient.GetPlatformQuota(ctx, model.ProjectId, model.Region, model.PlatformId, *model.QuotaId) +} + +// nullableInt64ToString renders a nullable quota limit. A null value means "unlimited". +func nullableInt64ToString(v scf.NullableInt64) string { + if value := v.Get(); value != nil { + return fmt.Sprintf("%d", *value) + } + return "unlimited" +} + +func outputResult(p *print.Printer, outputFormat string, quota *scf.Quota) error { + return p.OutputResult(outputFormat, quota, func() error { + if quota == nil { + return fmt.Errorf("received nil response, could not display quota details") + } + + apps := quota.GetApps() + domains := quota.GetDomains() + routes := quota.GetRoutes() + services := quota.GetServices() + + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", quota.GetGuid()) + table.AddRow("Name", quota.GetName()) + table.AddRow("Organization ID", utils.PtrString(quota.OrgId)) + table.AddRow("Platform ID", quota.GetPlatformId()) + table.AddRow("Project ID", quota.GetProjectId()) + table.AddRow("Region", quota.GetRegion()) + table.AddRow("Total App Instances", nullableInt64ToString(apps.TotalInstances)) + table.AddRow("Total App Memory (MB)", nullableInt64ToString(apps.TotalMemoryInMb)) + table.AddRow("Per-Process Memory (MB)", nullableInt64ToString(apps.PerProcessMemoryInMb)) + table.AddRow("Per-App Tasks", nullableInt64ToString(apps.PerAppTasks)) + table.AddRow("Log Rate Limit (bytes/s)", nullableInt64ToString(apps.LogRateLimitInBytesPerSecond)) + table.AddRow("Total Domains", nullableInt64ToString(domains.TotalDomains)) + table.AddRow("Total Routes", nullableInt64ToString(routes.TotalRoutes)) + table.AddRow("Total Reserved Ports", nullableInt64ToString(routes.TotalReservedPorts)) + table.AddRow("Paid Services Allowed", services.GetPaidServicesAllowed()) + table.AddRow("Total Service Instances", nullableInt64ToString(services.TotalServiceInstances)) + table.AddRow("Total Service Keys", nullableInt64ToString(services.TotalServiceKeys)) + table.AddRow("Created", quota.CreatedAt.Format(time.DateTime)) + table.AddRow("Updated", quota.UpdatedAt.Format(time.DateTime)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/scf/platform/quota/describe/describe_test.go b/internal/cmd/beta/scf/platform/quota/describe/describe_test.go new file mode 100644 index 000000000..46635f587 --- /dev/null +++ b/internal/cmd/beta/scf/platform/quota/describe/describe_test.go @@ -0,0 +1,238 @@ +package describe + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.DefaultAPIService{} + testProjectId = uuid.NewString() + testPlatformId = uuid.NewString() + testQuotaId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testPlatformId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + quotaIdFlag: testQuotaId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + PlatformId: testPlatformId, + QuotaId: new(testQuotaId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiGetPlatformQuotaRequest)) scf.ApiGetPlatformQuotaRequest { + request := testClient.GetPlatformQuota(testCtx, testProjectId, testRegion, testPlatformId, testQuotaId) + 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: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "platform id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "quota id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, quotaIdFlag) + }), + isValid: false, + }, + { + description: "quota id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[quotaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest scf.ApiGetPlatformQuotaRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + quota *scf.Quota + outputFormat string + wantErr bool + }{ + { + name: "default output", + quota: fixtureQuota(), + wantErr: false, + }, + { + name: "json output", + quota: fixtureQuota(), + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + name: "nil quota", + quota: nil, + wantErr: true, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.outputFormat, tt.quota); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func fixtureQuota() *scf.Quota { + return &scf.Quota{ + Guid: testQuotaId, + Name: "my-quota", + PlatformId: testPlatformId, + ProjectId: testProjectId, + Region: testRegion, + Apps: scf.QuotaApps{ + TotalInstances: *scf.NewNullableInt64(new(int64(10))), + TotalMemoryInMb: *scf.NewNullableInt64(new(int64(1024))), + PerProcessMemoryInMb: *scf.NewNullableInt64(nil), + PerAppTasks: *scf.NewNullableInt64(new(int64(5))), + LogRateLimitInBytesPerSecond: *scf.NewNullableInt64(nil), + }, + Domains: scf.QuotaDomains{ + TotalDomains: *scf.NewNullableInt64(new(int64(2))), + }, + Routes: scf.QuotaRoutes{ + TotalRoutes: *scf.NewNullableInt64(new(int64(20))), + TotalReservedPorts: *scf.NewNullableInt64(nil), + }, + Services: scf.QuotaServices{ + PaidServicesAllowed: true, + TotalServiceInstances: *scf.NewNullableInt64(new(int64(10))), + TotalServiceKeys: *scf.NewNullableInt64(new(int64(10))), + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} diff --git a/internal/cmd/beta/scf/platform/quota/list/list.go b/internal/cmd/beta/scf/platform/quota/list/list.go new file mode 100644 index 000000000..063ff8d8f --- /dev/null +++ b/internal/cmd/beta/scf/platform/quota/list/list.go @@ -0,0 +1,120 @@ +package list + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + platformIdArg = "PLATFORM_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + PlatformId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("list %s", platformIdArg), + Short: "Lists the quotas of a STACKIT Cloud Foundry platform", + Long: "Lists the quotas of a STACKIT Cloud Foundry platform.", + Args: args.SingleArg(platformIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `List the quotas of a STACKIT Cloud Foundry platform with ID "xxx"`, + `$ stackit beta scf platform quota list xxx`), + examples.NewExample( + `List the quotas of a STACKIT Cloud Foundry platform with ID "xxx" in JSON format`, + `$ stackit beta scf platform quota list xxx --output-format json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list STACKIT Cloud Foundry platform quotas: %w", err) + } + + return outputResult(p.Printer, model, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + platformId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + PlatformId: platformId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiListPlatformQuotasRequest { + return apiClient.ListPlatformQuotas(ctx, model.ProjectId, model.Region, model.PlatformId) +} + +func outputResult(p *print.Printer, model *inputModel, quotas []scf.Quota) error { + return p.OutputResult(model.OutputFormat, quotas, func() error { + if len(quotas) == 0 { + p.Outputf("No quotas found for STACKIT Cloud Foundry platform %s\n", model.PlatformId) + return nil + } + + table := tables.NewTable() + + table.SetTitle("STACKIT Cloud Foundry platform quotas") + table.SetHeader("ID", "NAME", "PLATFORM ID", "CREATED") + table.AddSeparator() + for _, quota := range quotas { + table.AddRow( + quota.GetGuid(), + quota.GetName(), + quota.GetPlatformId(), + quota.CreatedAt.Format(time.DateTime), + ) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/scf/platform/quota/list/list_test.go b/internal/cmd/beta/scf/platform/quota/list/list_test.go new file mode 100644 index 000000000..61ee8a139 --- /dev/null +++ b/internal/cmd/beta/scf/platform/quota/list/list_test.go @@ -0,0 +1,206 @@ +package list + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.DefaultAPIService{} + testProjectId = uuid.NewString() + testPlatformId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testPlatformId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + PlatformId: testPlatformId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiListPlatformQuotasRequest)) scf.ApiListPlatformQuotasRequest { + request := testClient.ListPlatformQuotas(testCtx, testProjectId, testRegion, testPlatformId) + 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: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "platform id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest scf.ApiListPlatformQuotasRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + model *inputModel + quotas []scf.Quota + outputFormat string + wantErr bool + }{ + { + name: "default output", + model: fixtureInputModel(), + quotas: fixtureQuotas(), + wantErr: false, + }, + { + name: "json output", + model: fixtureInputModel(), + quotas: fixtureQuotas(), + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + name: "empty list", + model: fixtureInputModel(), + quotas: []scf.Quota{}, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.model, tt.quotas); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func fixtureQuotas() []scf.Quota { + return []scf.Quota{ + { + Guid: uuid.NewString(), + Name: "my-quota", + PlatformId: testPlatformId, + ProjectId: testProjectId, + Region: testRegion, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } +} diff --git a/internal/cmd/beta/scf/platform/quota/quota.go b/internal/cmd/beta/scf/platform/quota/quota.go new file mode 100644 index 000000000..48a34f887 --- /dev/null +++ b/internal/cmd/beta/scf/platform/quota/quota.go @@ -0,0 +1,29 @@ +package quota + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/platform/quota/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/platform/quota/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// NewCmd creates the 'stackit beta scf platform quota' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "quota", + Short: "Shows quotas of STACKIT Cloud Foundry platforms", + Long: "Shows quotas of STACKIT Cloud Foundry platforms.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/beta/scf/scf.go b/internal/cmd/beta/scf/scf.go index 0a27536b5..eccb1f1d4 100644 --- a/internal/cmd/beta/scf/scf.go +++ b/internal/cmd/beta/scf/scf.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/platform" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -24,4 +25,5 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(organization.NewCmd(params)) + cmd.AddCommand(platform.NewCmd(params)) } From 432482e0752454f180c63c8a07918adc3a107139 Mon Sep 17 00:00:00 2001 From: Jonas Schlecht Date: Mon, 2 Nov 2026 08:32:37 +0100 Subject: [PATCH 2/2] refactor(scf): implement PR feedback --- internal/cmd/beta/scf/platform/list/list.go | 8 ++++---- internal/cmd/beta/scf/platform/list/list_test.go | 4 ++-- internal/cmd/beta/scf/platform/quota/list/list_test.go | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/cmd/beta/scf/platform/list/list.go b/internal/cmd/beta/scf/platform/list/list.go index 0635f0f5e..8eb68caca 100644 --- a/internal/cmd/beta/scf/platform/list/list.go +++ b/internal/cmd/beta/scf/platform/list/list.go @@ -60,7 +60,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } // Call API - resp, err := buildRequest(ctx, model, apiClient.DefaultAPI, 1, maxPageSize).Execute() + resp, err := buildRequest(ctx, model, apiClient.DefaultAPI).Execute() if err != nil { return fmt.Errorf("fetch STACKIT Cloud Foundry platforms: %w", err) } @@ -108,13 +108,13 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, return &model, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI, page, perPage int64) scf.ApiListPlatformsRequest { +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiListPlatformsRequest { // the API has non AIP-compliant pagination, which cannot be adapted with out SDK pagination utils. - pageSize := int64(perPage) + pageSize := int64(maxPageSize) if model.Limit != nil && *model.Limit < pageSize { pageSize = *model.Limit } - return apiClient.ListPlatforms(ctx, model.ProjectId, model.Region).Page(page).PerPage(pageSize) + return apiClient.ListPlatforms(ctx, model.ProjectId, model.Region).Page(1).PerPage(pageSize) } func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *scf.PlatformList) error { diff --git a/internal/cmd/beta/scf/platform/list/list_test.go b/internal/cmd/beta/scf/platform/list/list_test.go index 8b344abbe..081d947c1 100644 --- a/internal/cmd/beta/scf/platform/list/list_test.go +++ b/internal/cmd/beta/scf/platform/list/list_test.go @@ -138,13 +138,13 @@ func TestBuildRequest(t *testing.T) { }), isValid: true, expectedRequest: fixtureRequest(func(request *scf.ApiListPlatformsRequest) { - request.PerPage(maxPageSize) + *request = request.PerPage(maxPageSize) }), }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request := buildRequest(testCtx, tt.model, testClient.DefaultAPI, 1, testLimit) + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest, scf.DefaultAPIService{}), diff --git a/internal/cmd/beta/scf/platform/quota/list/list_test.go b/internal/cmd/beta/scf/platform/quota/list/list_test.go index 61ee8a139..f4af48a79 100644 --- a/internal/cmd/beta/scf/platform/quota/list/list_test.go +++ b/internal/cmd/beta/scf/platform/quota/list/list_test.go @@ -184,6 +184,7 @@ func TestOutputResult(t *testing.T) { params := testparams.NewTestParams() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + tt.model.OutputFormat = tt.outputFormat if err := outputResult(params.Printer, tt.model, tt.quotas); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) }