diff --git a/docs/stackit_beta_scf.md b/docs/stackit_beta_scf.md index bf0e5c60b..9b8286075 100644 --- a/docs/stackit_beta_scf.md +++ b/docs/stackit_beta_scf.md @@ -32,4 +32,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 +* [stackit beta scf space](./stackit_beta_scf_space.md) - Provides functionality for STACKIT Cloud Foundry spaces diff --git a/docs/stackit_beta_scf_space.md b/docs/stackit_beta_scf_space.md new file mode 100644 index 000000000..c1506e9e9 --- /dev/null +++ b/docs/stackit_beta_scf_space.md @@ -0,0 +1,39 @@ +## stackit beta scf space + +Provides functionality for STACKIT Cloud Foundry spaces + +### Synopsis + +Provides functionality for STACKIT Cloud Foundry spaces. + +``` +stackit beta scf space [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta scf space" +``` + +### 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 space create](./stackit_beta_scf_space_create.md) - Creates a STACKIT Cloud Foundry space +* [stackit beta scf space delete](./stackit_beta_scf_space_delete.md) - Deletes a STACKIT Cloud Foundry space +* [stackit beta scf space describe](./stackit_beta_scf_space_describe.md) - Shows details of a STACKIT Cloud Foundry space +* [stackit beta scf space list](./stackit_beta_scf_space_list.md) - Lists all STACKIT Cloud Foundry spaces of an organization +* [stackit beta scf space role](./stackit_beta_scf_space_role.md) - Manages roles of STACKIT Cloud Foundry spaces +* [stackit beta scf space update](./stackit_beta_scf_space_update.md) - Updates a STACKIT Cloud Foundry space + diff --git a/docs/stackit_beta_scf_space_create.md b/docs/stackit_beta_scf_space_create.md new file mode 100644 index 000000000..6508456fa --- /dev/null +++ b/docs/stackit_beta_scf_space_create.md @@ -0,0 +1,42 @@ +## stackit beta scf space create + +Creates a STACKIT Cloud Foundry space + +### Synopsis + +Creates a STACKIT Cloud Foundry space in an organization. + +``` +stackit beta scf space create [flags] +``` + +### Examples + +``` + Create a STACKIT Cloud Foundry space with name "my-space" in the organization with ID "xxx" + $ stackit beta scf space create --organization-id xxx --name my-space +``` + +### Options + +``` + -h, --help Help for "stackit beta scf space create" + --name string Name of the space + --organization-id string The UUID of the organization +``` + +### 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 space](./stackit_beta_scf_space.md) - Provides functionality for STACKIT Cloud Foundry spaces + diff --git a/docs/stackit_beta_scf_space_delete.md b/docs/stackit_beta_scf_space_delete.md new file mode 100644 index 000000000..975dccdf3 --- /dev/null +++ b/docs/stackit_beta_scf_space_delete.md @@ -0,0 +1,41 @@ +## stackit beta scf space delete + +Deletes a STACKIT Cloud Foundry space + +### Synopsis + +Deletes a STACKIT Cloud Foundry space. + +``` +stackit beta scf space delete SPACE_ID [flags] +``` + +### Examples + +``` + Delete the STACKIT Cloud Foundry space with ID "yyy" in the organization with ID "xxx" + $ stackit beta scf space delete yyy --organization-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta scf space delete" + --organization-id string The UUID of the organization +``` + +### 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 space](./stackit_beta_scf_space.md) - Provides functionality for STACKIT Cloud Foundry spaces + diff --git a/docs/stackit_beta_scf_space_describe.md b/docs/stackit_beta_scf_space_describe.md new file mode 100644 index 000000000..6db289dce --- /dev/null +++ b/docs/stackit_beta_scf_space_describe.md @@ -0,0 +1,44 @@ +## stackit beta scf space describe + +Shows details of a STACKIT Cloud Foundry space + +### Synopsis + +Shows details of a STACKIT Cloud Foundry space. + +``` +stackit beta scf space describe SPACE_ID [flags] +``` + +### Examples + +``` + Get details of the STACKIT Cloud Foundry space with ID "yyy" in the organization with ID "xxx" + $ stackit beta scf space describe yyy --organization-id xxx + + Get details of the STACKIT Cloud Foundry space with ID "yyy" in the organization with ID "xxx" in JSON format + $ stackit beta scf space describe yyy --organization-id xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta scf space describe" + --organization-id string The UUID of the organization +``` + +### 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 space](./stackit_beta_scf_space.md) - Provides functionality for STACKIT Cloud Foundry spaces + diff --git a/docs/stackit_beta_scf_space_list.md b/docs/stackit_beta_scf_space_list.md new file mode 100644 index 000000000..6e6d199c4 --- /dev/null +++ b/docs/stackit_beta_scf_space_list.md @@ -0,0 +1,45 @@ +## stackit beta scf space list + +Lists all STACKIT Cloud Foundry spaces of an organization + +### Synopsis + +Lists all STACKIT Cloud Foundry spaces of an organization. The number of fetched spaces is limited to 5000. + +``` +stackit beta scf space list [flags] +``` + +### Examples + +``` + List all STACKIT Cloud Foundry spaces of the organization with ID "xxx" + $ stackit beta scf space list --organization-id xxx + + List up to 10 STACKIT Cloud Foundry spaces of the organization with ID "xxx" + $ stackit beta scf space list --organization-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta scf space list" + --limit int Maximum number of entries to list + --organization-id string The UUID of the organization +``` + +### 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 space](./stackit_beta_scf_space.md) - Provides functionality for STACKIT Cloud Foundry spaces + diff --git a/docs/stackit_beta_scf_space_role.md b/docs/stackit_beta_scf_space_role.md new file mode 100644 index 000000000..520dea20d --- /dev/null +++ b/docs/stackit_beta_scf_space_role.md @@ -0,0 +1,34 @@ +## stackit beta scf space role + +Manages roles of STACKIT Cloud Foundry spaces + +### Synopsis + +Manages roles of STACKIT Cloud Foundry spaces. + +``` +stackit beta scf space role [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta scf space role" +``` + +### 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 space](./stackit_beta_scf_space.md) - Provides functionality for STACKIT Cloud Foundry spaces +* [stackit beta scf space role set](./stackit_beta_scf_space_role_set.md) - Assigns a role to a user of a STACKIT Cloud Foundry space + diff --git a/docs/stackit_beta_scf_space_role_set.md b/docs/stackit_beta_scf_space_role_set.md new file mode 100644 index 000000000..2cf1271be --- /dev/null +++ b/docs/stackit_beta_scf_space_role_set.md @@ -0,0 +1,47 @@ +## stackit beta scf space role set + +Assigns a role to a user of a STACKIT Cloud Foundry space + +### Synopsis + +Assigns a role to a user of a STACKIT Cloud Foundry space. The user is identified either by its GUID or by its username. + +``` +stackit beta scf space role set SPACE_ID [flags] +``` + +### Examples + +``` + Assign the role "space_developer" to the user with GUID "zzz" in the STACKIT Cloud Foundry space with ID "yyy" of the organization with ID "xxx" + $ stackit beta scf space role set yyy --organization-id xxx --role space_developer --user-guid zzz + + Assign the role "space_auditor" to the user with username "jane.doe" in the STACKIT Cloud Foundry space with ID "yyy" of the organization with ID "xxx" + $ stackit beta scf space role set yyy --organization-id xxx --role space_auditor --user-name jane.doe +``` + +### Options + +``` + -h, --help Help for "stackit beta scf space role set" + --organization-id string The UUID of the organization + --role string The role to assign to the user (one of: [space_auditor, space_developer, space_manager, space_supporter]) + --user-guid string The GUID of the user to assign the role to + --user-name string The username of the user to assign the role to +``` + +### 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 space role](./stackit_beta_scf_space_role.md) - Manages roles of STACKIT Cloud Foundry spaces + diff --git a/docs/stackit_beta_scf_space_update.md b/docs/stackit_beta_scf_space_update.md new file mode 100644 index 000000000..be24db256 --- /dev/null +++ b/docs/stackit_beta_scf_space_update.md @@ -0,0 +1,42 @@ +## stackit beta scf space update + +Updates a STACKIT Cloud Foundry space + +### Synopsis + +Updates a STACKIT Cloud Foundry space. + +``` +stackit beta scf space update SPACE_ID [flags] +``` + +### Examples + +``` + Rename the STACKIT Cloud Foundry space with ID "yyy" in the organization with ID "xxx" + $ stackit beta scf space update yyy --organization-id xxx --name new-name +``` + +### Options + +``` + -h, --help Help for "stackit beta scf space update" + --name string New name of the space + --organization-id string The UUID of the organization +``` + +### 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 space](./stackit_beta_scf_space.md) - Provides functionality for STACKIT Cloud Foundry spaces + diff --git a/internal/cmd/beta/scf/organization/role/set/set.go b/internal/cmd/beta/scf/organization/role/set/set.go index 54be2e354..26daace79 100644 --- a/internal/cmd/beta/scf/organization/role/set/set.go +++ b/internal/cmd/beta/scf/organization/role/set/set.go @@ -14,6 +14,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/client" + scfUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -66,7 +67,7 @@ func NewCmd(p *types.CmdParams) *cobra.Command { return err } - prompt := fmt.Sprintf("Are you sure you want to assign role %q to user %q in STACKIT Cloud Foundry organization %q?", model.Role, userLabel(model), model.OrganizationId) + prompt := fmt.Sprintf("Are you sure you want to assign role %q to user %q in STACKIT Cloud Foundry organization %q?", model.Role, scfUtils.UserLabel(model.UserGuid, model.UserName), model.OrganizationId) err = p.Printer.PromptForConfirmation(prompt) if err != nil { return err @@ -130,15 +131,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultA func outputResult(p *print.Printer, model *inputModel, resp *scf.OrgRoleResponse) error { return p.OutputResult(model.OutputFormat, resp, func() error { - p.Outputf("Assigned role %q to user %q in STACKIT Cloud Foundry organization %s\n", model.Role, userLabel(model), model.OrganizationId) + p.Outputf("Assigned role %q to user %q in STACKIT Cloud Foundry organization %s\n", model.Role, scfUtils.UserLabel(model.UserGuid, model.UserName), model.OrganizationId) return nil }) } - -// userLabel returns the user identifier given on the command line, either the GUID or the username -func userLabel(model *inputModel) string { - if model.UserGuid != nil { - return *model.UserGuid - } - return utils.PtrString(model.UserName) -} diff --git a/internal/cmd/beta/scf/platform/list/list.go b/internal/cmd/beta/scf/platform/list/list.go index 8eb68caca..f2b15502e 100644 --- a/internal/cmd/beta/scf/platform/list/list.go +++ b/internal/cmd/beta/scf/platform/list/list.go @@ -74,7 +74,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { projectLabel = model.ProjectId } - return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, projectLabel, model.Limit, resp) }, } configureFlags(cmd) @@ -117,13 +117,14 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultA return apiClient.ListPlatforms(ctx, model.ProjectId, model.Region).Page(1).PerPage(pageSize) } -func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *scf.PlatformList) error { +func outputResult(p *print.Printer, outputFormat, projectLabel string, limit *int64, 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) { + limitReached := limit != nil && int64(len(resp.Resources)) >= *limit + if resp.Pagination.TotalPages != nil && *resp.Pagination.TotalPages > int64(1) && !limitReached { p.Warn("Server responded with more than 1 page. Output will not show all existing platforms.\n") } diff --git a/internal/cmd/beta/scf/platform/list/list_test.go b/internal/cmd/beta/scf/platform/list/list_test.go index 081d947c1..d7eb06249 100644 --- a/internal/cmd/beta/scf/platform/list/list_test.go +++ b/internal/cmd/beta/scf/platform/list/list_test.go @@ -3,6 +3,7 @@ package list import ( "context" "strconv" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -11,6 +12,7 @@ import ( 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/print" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) @@ -158,64 +160,64 @@ func TestBuildRequest(t *testing.T) { } func TestOutputResult(t *testing.T) { - type args struct { - outputFormat string - projectLabel string - platforms *scf.PlatformList + resp := &scf.PlatformList{ + Pagination: scf.Pagination{TotalPages: new(int64(2))}, + Resources: []scf.Platforms{ + { + Guid: testPlatformId, + DisplayName: "my-platform", + Region: testRegion, + SystemId: "my-system", + ApiUrl: "https://api.example.com", + }, + }, } tests := []struct { - name string - args args - wantErr bool + name string + outputFormat string + limit *int64 + resp *scf.PlatformList + expectWarn bool }{ { - name: "empty", - args: args{}, - wantErr: false, + name: "default output", + resp: resp, + expectWarn: true, }, { - name: "nil platforms slice", - args: args{ - projectLabel: testProjectId, - platforms: nil, - }, - wantErr: false, + name: "json output", + outputFormat: print.JSONOutputFormat, + resp: resp, }, { - name: "empty platforms slice", - args: args{ - projectLabel: testProjectId, - platforms: &scf.PlatformList{ - Resources: []scf.Platforms{}, - }, - }, - wantErr: false, + name: "limit reached", + limit: new(int64(1)), + resp: resp, }, { - 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, + name: "limit above page size", + limit: new(int64(maxPageSize + 1)), + resp: resp, + expectWarn: true, + }, + { + name: "empty", + resp: &scf.PlatformList{}, + }, + { + name: "nil response", + resp: nil, }, } - 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) + params := testparams.NewTestParams() + if err := outputResult(params.Printer, tt.outputFormat, testProjectId, tt.limit, tt.resp); err != nil { + t.Errorf("outputResult() error = %v", err) + } + warned := strings.Contains(params.Err.String(), "more than 1 page") + if warned != tt.expectWarn { + t.Errorf("expected warning = %t, got %t (stderr: %q)", tt.expectWarn, warned, params.Err.String()) } }) } diff --git a/internal/cmd/beta/scf/scf.go b/internal/cmd/beta/scf/scf.go index eccb1f1d4..77957aff9 100644 --- a/internal/cmd/beta/scf/scf.go +++ b/internal/cmd/beta/scf/scf.go @@ -5,6 +5,7 @@ import ( "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/cmd/beta/scf/space" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -26,4 +27,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)) + cmd.AddCommand(space.NewCmd(params)) } diff --git a/internal/cmd/beta/scf/space/create/create.go b/internal/cmd/beta/scf/space/create/create.go new file mode 100644 index 000000000..b4aa611ff --- /dev/null +++ b/internal/cmd/beta/scf/space/create/create.go @@ -0,0 +1,117 @@ +package create + +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/services/scf/client" + scfUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + organizationIdFlag = "organization-id" + nameFlag = "name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + Name string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a STACKIT Cloud Foundry space", + Long: "Creates a STACKIT Cloud Foundry space in an organization.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a STACKIT Cloud Foundry space with name "my-space" in the organization with ID "xxx"`, + `$ stackit beta scf space create --organization-id xxx --name my-space`), + ), + 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 + } + + organizationLabel := scfUtils.GetOrganizationLabel(ctx, p.Printer, apiClient.DefaultAPI, model.ProjectId, model.Region, model.OrganizationId) + + prompt := fmt.Sprintf("Are you sure you want to create STACKIT Cloud Foundry space %q in organization %q?", model.Name, organizationLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create STACKIT Cloud Foundry space: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, organizationLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "The UUID of the organization") + cmd.Flags().String(nameFlag, "", "Name of the space") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, nameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + Name: flags.FlagToStringValue(p, cmd, nameFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiCreateSpaceRequest { + req := apiClient.CreateSpace(ctx, model.ProjectId, model.Region, model.OrganizationId) + return req.CreateSpacePayload(scf.CreateSpacePayload{Name: model.Name}) +} + +func outputResult(p *print.Printer, outputFormat, organizationLabel string, resp *scf.Space) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil { + p.Outputf("Created STACKIT Cloud Foundry space in organization %q, but no space ID was returned.\n", organizationLabel) + return nil + } + + p.Outputf("Created STACKIT Cloud Foundry space in organization %q. Space ID: %s\n", organizationLabel, resp.Guid) + return nil + }) +} diff --git a/internal/cmd/beta/scf/space/create/create_test.go b/internal/cmd/beta/scf/space/create/create_test.go new file mode 100644 index 000000000..b5bec80b6 --- /dev/null +++ b/internal/cmd/beta/scf/space/create/create_test.go @@ -0,0 +1,152 @@ +package create + +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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testName = "my-space" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.APIClient{ + DefaultAPI: &scf.DefaultAPIService{}, + } + testProjectId = uuid.NewString() + testOrganizationId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrganizationId, + nameFlag: testName, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel() *inputModel { + return &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + OrganizationId: testOrganizationId, + Name: testName, + } +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "organization id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "organization id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + expected := testClient.DefaultAPI.CreateSpace(testCtx, testProjectId, testRegion, testOrganizationId). + CreateSpacePayload(scf.CreateSpacePayload{Name: testName}) + request := buildRequest(testCtx, fixtureInputModel(), testClient.DefaultAPI) + + diff := cmp.Diff(request, expected, + cmp.AllowUnexported(expected), + 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 + outputFormat string + resp *scf.Space + }{ + { + name: "default output", + resp: &scf.Space{Guid: uuid.NewString()}, + }, + { + name: "json output", + outputFormat: print.JSONOutputFormat, + resp: &scf.Space{Guid: uuid.NewString()}, + }, + { + name: "nil response", + resp: nil, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.outputFormat, "org", tt.resp); err != nil { + t.Errorf("outputResult() error = %v", err) + } + }) + } +} diff --git a/internal/cmd/beta/scf/space/delete/delete.go b/internal/cmd/beta/scf/space/delete/delete.go new file mode 100644 index 000000000..bdefb4e4d --- /dev/null +++ b/internal/cmd/beta/scf/space/delete/delete.go @@ -0,0 +1,107 @@ +package delete + +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/services/scf/client" + scfUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + spaceIdArg = "SPACE_ID" + + organizationIdFlag = "organization-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + SpaceId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", spaceIdArg), + Short: "Deletes a STACKIT Cloud Foundry space", + Long: "Deletes a STACKIT Cloud Foundry space.", + Args: args.SingleArg(spaceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete the STACKIT Cloud Foundry space with ID "yyy" in the organization with ID "xxx"`, + `$ stackit beta scf space delete yyy --organization-id xxx`), + ), + 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 + } + + spaceLabel := scfUtils.GetSpaceLabel(ctx, p.Printer, apiClient.DefaultAPI, model.ProjectId, model.Region, model.OrganizationId, model.SpaceId) + + prompt := fmt.Sprintf("Are you sure you want to delete STACKIT Cloud Foundry space %q?", spaceLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + if _, err = req.Execute(); err != nil { + return fmt.Errorf("delete STACKIT Cloud Foundry space: %w", err) + } + + p.Printer.Info("Triggered deletion of STACKIT Cloud Foundry space %q\n", spaceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "The UUID of the organization") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + spaceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + SpaceId: spaceId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiDeleteSpaceRequest { + return apiClient.DeleteSpace(ctx, model.ProjectId, model.Region, model.OrganizationId, model.SpaceId) +} diff --git a/internal/cmd/beta/scf/space/delete/delete_test.go b/internal/cmd/beta/scf/space/delete/delete_test.go new file mode 100644 index 000000000..8a865148a --- /dev/null +++ b/internal/cmd/beta/scf/space/delete/delete_test.go @@ -0,0 +1,122 @@ +package delete + +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/globalflags" + "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() + testOrganizationId = uuid.NewString() + testSpaceId = uuid.NewString() +) + +func fixtureArgValues() []string { + return []string{testSpaceId} +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrganizationId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel() *inputModel { + return &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + OrganizationId: testOrganizationId, + SpaceId: testSpaceId, + } +} + +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: "space id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "organization id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + 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) { + expected := testClient.DefaultAPI.DeleteSpace(testCtx, testProjectId, testRegion, testOrganizationId, testSpaceId) + request := buildRequest(testCtx, fixtureInputModel(), testClient.DefaultAPI) + + diff := cmp.Diff(request, expected, + cmp.AllowUnexported(expected), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} diff --git a/internal/cmd/beta/scf/space/describe/describe.go b/internal/cmd/beta/scf/space/describe/describe.go new file mode 100644 index 000000000..59de7d3cf --- /dev/null +++ b/internal/cmd/beta/scf/space/describe/describe.go @@ -0,0 +1,130 @@ +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 ( + spaceIdArg = "SPACE_ID" + + organizationIdFlag = "organization-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + SpaceId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", spaceIdArg), + Short: "Shows details of a STACKIT Cloud Foundry space", + Long: "Shows details of a STACKIT Cloud Foundry space.", + Args: args.SingleArg(spaceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of the STACKIT Cloud Foundry space with ID "yyy" in the organization with ID "xxx"`, + `$ stackit beta scf space describe yyy --organization-id xxx`), + examples.NewExample( + `Get details of the STACKIT Cloud Foundry space with ID "yyy" in the organization with ID "xxx" in JSON format`, + `$ stackit beta scf space describe yyy --organization-id 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 space: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "The UUID of the organization") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + spaceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + SpaceId: spaceId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiGetSpaceRequest { + return apiClient.GetSpace(ctx, model.ProjectId, model.Region, model.OrganizationId, model.SpaceId) +} + +func outputResult(p *print.Printer, outputFormat string, space *scf.Space) error { + return p.OutputResult(outputFormat, space, func() error { + if space == nil { + return fmt.Errorf("received nil response, could not display details") + } + + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", space.GetGuid()) + table.AddRow("Name", space.GetName()) + table.AddRow("Organization ID", space.GetOrgId()) + table.AddRow("Platform ID", space.GetPlatformId()) + table.AddRow("Project ID", space.GetProjectId()) + table.AddRow("Region", space.GetRegion()) + table.AddRow("Created", space.CreatedAt.Format(time.DateTime)) + table.AddRow("Updated", space.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/space/describe/describe_test.go b/internal/cmd/beta/scf/space/describe/describe_test.go new file mode 100644 index 000000000..453166d28 --- /dev/null +++ b/internal/cmd/beta/scf/space/describe/describe_test.go @@ -0,0 +1,174 @@ +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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "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() + testOrganizationId = uuid.NewString() + testSpaceId = uuid.NewString() +) + +func fixtureArgValues() []string { + return []string{testSpaceId} +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrganizationId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel() *inputModel { + return &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + OrganizationId: testOrganizationId, + SpaceId: testSpaceId, + } +} + +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: "space id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "organization id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "organization id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "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) { + expected := testClient.DefaultAPI.GetSpace(testCtx, testProjectId, testRegion, testOrganizationId, testSpaceId) + request := buildRequest(testCtx, fixtureInputModel(), testClient.DefaultAPI) + + diff := cmp.Diff(request, expected, + cmp.AllowUnexported(expected), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} + +func TestOutputResult(t *testing.T) { + space := &scf.Space{ + Guid: testSpaceId, + Name: "my-space", + OrgId: testOrganizationId, + ProjectId: testProjectId, + Region: testRegion, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + tests := []struct { + name string + space *scf.Space + outputFormat string + wantErr bool + }{ + { + name: "default output", + space: space, + }, + { + name: "json output", + space: space, + outputFormat: print.JSONOutputFormat, + }, + { + name: "nil space", + space: 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.space); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/scf/space/list/list.go b/internal/cmd/beta/scf/space/list/list.go new file mode 100644 index 000000000..cc2465b3d --- /dev/null +++ b/internal/cmd/beta/scf/space/list/list.go @@ -0,0 +1,152 @@ +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/services/scf/client" + scfUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/utils" + "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 ( + organizationIdFlag = "organization-id" + 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 + OrganizationId string + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all STACKIT Cloud Foundry spaces of an organization", + Long: fmt.Sprintf("Lists all STACKIT Cloud Foundry spaces of an organization. The number of fetched spaces is limited to %d.", maxPageSize), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all STACKIT Cloud Foundry spaces of the organization with ID "xxx"`, + "$ stackit beta scf space list --organization-id xxx"), + examples.NewExample( + `List up to 10 STACKIT Cloud Foundry spaces of the organization with ID "xxx"`, + "$ stackit beta scf space list --organization-id xxx --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 spaces: %w", err) + } + + organizationLabel := scfUtils.GetOrganizationLabel(ctx, params.Printer, apiClient.DefaultAPI, model.ProjectId, model.Region, model.OrganizationId) + + return outputResult(params.Printer, model.OutputFormat, model.Limit, organizationLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "The UUID of the organization") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag) + cobra.CheckErr(err) +} + +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, + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI, page, perPage int64) scf.ApiListSpacesRequest { + pageSize := perPage + if model.Limit != nil && *model.Limit < pageSize { + pageSize = *model.Limit + } + return apiClient.ListSpaces(ctx, model.ProjectId, model.Region, model.OrganizationId).Page(page).PerPage(pageSize) +} + +func outputResult(p *print.Printer, outputFormat string, limit *int64, organizationLabel string, resp *scf.SpacesList) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil || len(resp.Resources) == 0 { + p.Outputf("No STACKIT Cloud Foundry spaces found for organization %q\n", organizationLabel) + return nil + } + limitReached := limit != nil && int64(len(resp.Resources)) >= *limit + if resp.Pagination.TotalPages != nil && *resp.Pagination.TotalPages > int64(1) && !limitReached { + p.Warn("Server responded with more than 1 page. Output will not show all existing spaces.\n") + } + + table := tables.NewTable() + + table.SetTitle("STACKIT Cloud Foundry spaces") + table.SetHeader("ID", "NAME", "PLATFORM ID", "CREATED") + table.AddSeparator() + for _, space := range resp.Resources { + table.AddRow( + space.Guid, + space.Name, + space.PlatformId, + utils.ConvertTimePToDateTimeString(&space.CreatedAt), + ) + 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/space/list/list_test.go b/internal/cmd/beta/scf/space/list/list_test.go new file mode 100644 index 000000000..10faf8915 --- /dev/null +++ b/internal/cmd/beta/scf/space/list/list_test.go @@ -0,0 +1,205 @@ +package list + +import ( + "context" + "strings" + "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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "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() + testOrganizationId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrganizationId, + limitFlag: "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, + }, + OrganizationId: testOrganizationId, + Limit: new(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "organization id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest scf.ApiListSpacesRequest + }{ + { + description: "limit below page size", + model: fixtureInputModel(), + expectedRequest: testClient.DefaultAPI.ListSpaces(testCtx, testProjectId, testRegion, testOrganizationId).Page(1).PerPage(10), + }, + { + description: "no limit", + model: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + expectedRequest: testClient.DefaultAPI.ListSpaces(testCtx, testProjectId, testRegion, testOrganizationId).Page(1).PerPage(maxPageSize), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI, 1, maxPageSize) + + 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) { + resp := &scf.SpacesList{ + Pagination: scf.Pagination{TotalPages: new(int64(2))}, + Resources: []scf.Space{{Guid: uuid.NewString(), Name: "my-space", CreatedAt: time.Now()}}, + } + tests := []struct { + name string + outputFormat string + limit *int64 + resp *scf.SpacesList + expectWarn bool + }{ + { + name: "default output", + resp: resp, + expectWarn: true, + }, + { + name: "json output", + outputFormat: print.JSONOutputFormat, + resp: resp, + }, + { + name: "limit reached", + limit: new(int64(1)), + resp: resp, + }, + { + name: "limit above page size", + limit: new(int64(maxPageSize + 1)), + resp: resp, + expectWarn: true, + }, + { + name: "empty", + resp: &scf.SpacesList{}, + }, + { + name: "nil response", + resp: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + if err := outputResult(params.Printer, tt.outputFormat, tt.limit, "org", tt.resp); err != nil { + t.Errorf("outputResult() error = %v", err) + } + warned := strings.Contains(params.Err.String(), "more than 1 page") + if warned != tt.expectWarn { + t.Errorf("expected warning = %t, got %t (stderr: %q)", tt.expectWarn, warned, params.Err.String()) + } + }) + } +} diff --git a/internal/cmd/beta/scf/space/role/role.go b/internal/cmd/beta/scf/space/role/role.go new file mode 100644 index 000000000..463b3c020 --- /dev/null +++ b/internal/cmd/beta/scf/space/role/role.go @@ -0,0 +1,27 @@ +package role + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/space/role/set" + "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 space role' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "role", + Short: "Manages roles of STACKIT Cloud Foundry spaces", + Long: "Manages roles of STACKIT Cloud Foundry spaces.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(set.NewCmd(params)) +} diff --git a/internal/cmd/beta/scf/space/role/set/set.go b/internal/cmd/beta/scf/space/role/set/set.go new file mode 100644 index 000000000..f41be3aef --- /dev/null +++ b/internal/cmd/beta/scf/space/role/set/set.go @@ -0,0 +1,143 @@ +package set + +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/services/scf/client" + scfUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + spaceIdArg = "SPACE_ID" + + organizationIdFlag = "organization-id" + userGuidFlag = "user-guid" + userNameFlag = "user-name" +) + +var roleFlag = flags.StringEnumFlag( + "role", + scf.AllowedSpaceRoleTypeEnumValues, + "The role to assign to the user", +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + SpaceId string + Role scf.SpaceRoleType + UserGuid *string + UserName *string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("set %s", spaceIdArg), + Short: "Assigns a role to a user of a STACKIT Cloud Foundry space", + Long: "Assigns a role to a user of a STACKIT Cloud Foundry space. The user is identified either by its GUID or by its username.", + Args: args.SingleArg(spaceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Assign the role "space_developer" to the user with GUID "zzz" in the STACKIT Cloud Foundry space with ID "yyy" of the organization with ID "xxx"`, + `$ stackit beta scf space role set yyy --organization-id xxx --role space_developer --user-guid zzz`), + examples.NewExample( + `Assign the role "space_auditor" to the user with username "jane.doe" in the STACKIT Cloud Foundry space with ID "yyy" of the organization with ID "xxx"`, + `$ stackit beta scf space role set yyy --organization-id xxx --role space_auditor --user-name jane.doe`), + ), + 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 + } + + spaceLabel := scfUtils.GetSpaceLabel(ctx, p.Printer, apiClient.DefaultAPI, model.ProjectId, model.Region, model.OrganizationId, model.SpaceId) + + prompt := fmt.Sprintf("Are you sure you want to assign role %q to user %q in STACKIT Cloud Foundry space %q?", model.Role, scfUtils.UserLabel(model.UserGuid, model.UserName), spaceLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("set STACKIT Cloud Foundry space role: %w", err) + } + + return outputResult(p.Printer, model, spaceLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + roleFlag.Register(cmd.Flags()) + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "The UUID of the organization") + cmd.Flags().Var(flags.UUIDFlag(), userGuidFlag, "The GUID of the user to assign the role to") + cmd.Flags().String(userNameFlag, "", "The username of the user to assign the role to") + + err := flags.MarkFlagsRequired(cmd, roleFlag.Name(), organizationIdFlag) + cobra.CheckErr(err) + cmd.MarkFlagsOneRequired(userGuidFlag, userNameFlag) + cmd.MarkFlagsMutuallyExclusive(userGuidFlag, userNameFlag) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + spaceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + SpaceId: spaceId, + Role: roleFlag.Get(), + UserGuid: flags.FlagToStringPointer(p, cmd, userGuidFlag), + UserName: flags.FlagToStringPointer(p, cmd, userNameFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiCreateSpaceRoleRequest { + payload := scf.CreateSpaceRolePayload{ + Type: model.Role, + UserGuid: model.UserGuid, + UserName: model.UserName, + } + + req := apiClient.CreateSpaceRole(ctx, model.ProjectId, model.Region, model.OrganizationId, model.SpaceId) + return req.CreateSpaceRolePayload(payload) +} + +func outputResult(p *print.Printer, model *inputModel, spaceLabel string, resp *scf.SpaceRoleCreateResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + p.Outputf("Assigned role %q to user %q in STACKIT Cloud Foundry space %q\n", model.Role, scfUtils.UserLabel(model.UserGuid, model.UserName), spaceLabel) + return nil + }) +} diff --git a/internal/cmd/beta/scf/space/role/set/set_test.go b/internal/cmd/beta/scf/space/role/set/set_test.go new file mode 100644 index 000000000..7ecb2ee03 --- /dev/null +++ b/internal/cmd/beta/scf/space/role/set/set_test.go @@ -0,0 +1,208 @@ +package set + +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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testUserName = "jane.doe" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.APIClient{ + DefaultAPI: &scf.DefaultAPIService{}, + } + testProjectId = uuid.NewString() + testOrganizationId = uuid.NewString() + testSpaceId = uuid.NewString() + testUserGuid = uuid.NewString() +) + +func fixtureArgValues() []string { + return []string{testSpaceId} +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrganizationId, + roleFlag.Name(): string(scf.SPACEROLETYPE_SPACE_DEVELOPER), + userGuidFlag: testUserGuid, + } + 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, + }, + OrganizationId: testOrganizationId, + SpaceId: testSpaceId, + Role: scf.SPACEROLETYPE_SPACE_DEVELOPER, + UserGuid: new(testUserGuid), + } + for _, mod := range mods { + mod(model) + } + return model +} + +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: "user name instead of guid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, userGuidFlag) + flagValues[userNameFlag] = testUserName + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.UserGuid = nil + model.UserName = new(testUserName) + }), + }, + { + description: "user guid and name both set", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[userNameFlag] = testUserName + }), + isValid: false, + }, + { + description: "no user given", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, userGuidFlag) + }), + isValid: false, + }, + { + description: "invalid role", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[roleFlag.Name()] = "organization_manager" + }), + isValid: false, + }, + { + description: "role missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, roleFlag.Name()) + }), + isValid: false, + }, + { + description: "user guid invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[userGuidFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "organization id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "space 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) { + expected := testClient.DefaultAPI.CreateSpaceRole(testCtx, testProjectId, testRegion, testOrganizationId, testSpaceId). + CreateSpaceRolePayload(scf.CreateSpaceRolePayload{ + Type: scf.SPACEROLETYPE_SPACE_DEVELOPER, + UserGuid: new(testUserGuid), + }) + request := buildRequest(testCtx, fixtureInputModel(), testClient.DefaultAPI) + + diff := cmp.Diff(request, expected, + cmp.AllowUnexported(expected), + 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 + model *inputModel + resp *scf.SpaceRoleCreateResponse + wantErr bool + }{ + { + name: "default output", + model: fixtureInputModel(), + resp: &scf.SpaceRoleCreateResponse{}, + wantErr: false, + }, + { + name: "json output", + model: fixtureInputModel(), + resp: &scf.SpaceRoleCreateResponse{}, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.model, "space", tt.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/scf/space/space.go b/internal/cmd/beta/scf/space/space.go new file mode 100644 index 000000000..bb56f69df --- /dev/null +++ b/internal/cmd/beta/scf/space/space.go @@ -0,0 +1,37 @@ +package space + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/space/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/space/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/space/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/space/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/space/role" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/space/update" + "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 space' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "space", + Short: "Provides functionality for STACKIT Cloud Foundry spaces", + Long: "Provides functionality for STACKIT Cloud Foundry spaces.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(role.NewCmd(params)) +} diff --git a/internal/cmd/beta/scf/space/update/update.go b/internal/cmd/beta/scf/space/update/update.go new file mode 100644 index 000000000..939778b97 --- /dev/null +++ b/internal/cmd/beta/scf/space/update/update.go @@ -0,0 +1,123 @@ +package update + +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/services/scf/client" + scfUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + spaceIdArg = "SPACE_ID" + + organizationIdFlag = "organization-id" + nameFlag = "name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + SpaceId string + Name string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", spaceIdArg), + Short: "Updates a STACKIT Cloud Foundry space", + Long: "Updates a STACKIT Cloud Foundry space.", + Args: args.SingleArg(spaceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Rename the STACKIT Cloud Foundry space with ID "yyy" in the organization with ID "xxx"`, + `$ stackit beta scf space update yyy --organization-id xxx --name new-name`), + ), + 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 + } + + spaceLabel := scfUtils.GetSpaceLabel(ctx, p.Printer, apiClient.DefaultAPI, model.ProjectId, model.Region, model.OrganizationId, model.SpaceId) + + prompt := fmt.Sprintf("Are you sure you want to update STACKIT Cloud Foundry space %q?", spaceLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update STACKIT Cloud Foundry space: %w", err) + } + + return outputResult(p.Printer, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "The UUID of the organization") + cmd.Flags().String(nameFlag, "", "New name of the space") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, nameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + spaceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + SpaceId: spaceId, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiUpdateSpaceRequest { + req := apiClient.UpdateSpace(ctx, model.ProjectId, model.Region, model.OrganizationId, model.SpaceId) + return req.UpdateSpacePayload(scf.UpdateSpacePayload{Name: model.Name}) +} + +func outputResult(p *print.Printer, model *inputModel, resp *scf.Space) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + spaceLabel := model.SpaceId + if resp != nil && resp.Name != "" { + spaceLabel = resp.Name + } + p.Outputf("Updated STACKIT Cloud Foundry space %q\n", spaceLabel) + return nil + }) +} diff --git a/internal/cmd/beta/scf/space/update/update_test.go b/internal/cmd/beta/scf/space/update/update_test.go new file mode 100644 index 000000000..6698a20b2 --- /dev/null +++ b/internal/cmd/beta/scf/space/update/update_test.go @@ -0,0 +1,175 @@ +package update + +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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testName = "new-name" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.APIClient{ + DefaultAPI: &scf.DefaultAPIService{}, + } + testProjectId = uuid.NewString() + testOrganizationId = uuid.NewString() + testSpaceId = uuid.NewString() +) + +func fixtureArgValues() []string { + return []string{testSpaceId} +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrganizationId, + nameFlag: testName, + } + 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, + }, + OrganizationId: testOrganizationId, + SpaceId: testSpaceId, + Name: testName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +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: "space id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "organization id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "name missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + 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) { + expected := testClient.DefaultAPI.UpdateSpace(testCtx, testProjectId, testRegion, testOrganizationId, testSpaceId). + UpdateSpacePayload(scf.UpdateSpacePayload{Name: testName}) + request := buildRequest(testCtx, fixtureInputModel(), testClient.DefaultAPI) + + diff := cmp.Diff(request, expected, + cmp.AllowUnexported(expected), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + resp *scf.Space + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + resp: &scf.Space{Guid: testSpaceId}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &scf.Space{Guid: testSpaceId}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/pkg/services/scf/utils/utils.go b/internal/pkg/services/scf/utils/utils.go index 556a37a8f..5b55c83f0 100644 --- a/internal/pkg/services/scf/utils/utils.go +++ b/internal/pkg/services/scf/utils/utils.go @@ -5,6 +5,9 @@ import ( "fmt" scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) func GetOrganizationName(ctx context.Context, apiClient scf.DefaultAPI, projectId, region, organizationId string) (string, error) { @@ -17,3 +20,47 @@ func GetOrganizationName(ctx context.Context, apiClient scf.DefaultAPI, projectI } return resp.Name, nil } + +func GetSpaceName(ctx context.Context, apiClient scf.DefaultAPI, projectId, region, organizationId, spaceId string) (string, error) { + resp, err := apiClient.GetSpace(ctx, projectId, region, organizationId, spaceId).Execute() + if err != nil { + return "", fmt.Errorf("get STACKIT Cloud Foundry space: %w", err) + } + if resp == nil { + return "", fmt.Errorf("get STACKIT Cloud Foundry space: empty response") + } + return resp.Name, nil +} + +// GetOrganizationLabel returns the name of the organization, falling back to its ID if the name can't be determined +func GetOrganizationLabel(ctx context.Context, p *print.Printer, apiClient scf.DefaultAPI, projectId, region, organizationId string) string { + name, err := GetOrganizationName(ctx, apiClient, projectId, region, organizationId) + if err != nil { + p.Debug(print.ErrorLevel, "get organization name: %v", err) + return organizationId + } + if name == "" { + return organizationId + } + return name +} + +// GetSpaceLabel returns the name of the space, falling back to its ID if the name can't be determined +func GetSpaceLabel(ctx context.Context, p *print.Printer, apiClient scf.DefaultAPI, projectId, region, organizationId, spaceId string) string { + name, err := GetSpaceName(ctx, apiClient, projectId, region, organizationId, spaceId) + if err != nil { + p.Debug(print.ErrorLevel, "get space name: %v", err) + return spaceId + } + if name == "" { + return spaceId + } + return name +} + +func UserLabel(userGuid, userName *string) string { + if userGuid != nil { + return *userGuid + } + return utils.PtrString(userName) +} diff --git a/internal/pkg/services/scf/utils/utils_test.go b/internal/pkg/services/scf/utils/utils_test.go index 88da608ff..9c1a3f461 100644 --- a/internal/pkg/services/scf/utils/utils_test.go +++ b/internal/pkg/services/scf/utils/utils_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -24,10 +25,18 @@ const ( type mockSettings struct { getOrganizationFails bool getOrganizationResp *scf.Organization + getSpaceFails bool + getSpaceResp *scf.Space } func newApiMock(s *mockSettings) scf.DefaultAPI { return &scf.DefaultAPIServiceMock{ + GetSpaceExecuteMock: utils.Ptr(func(_ scf.ApiGetSpaceRequest) (*scf.Space, error) { + if s.getSpaceFails { + return nil, fmt.Errorf("could not get space") + } + return s.getSpaceResp, nil + }), GetOrganizationExecuteMock: utils.Ptr(func(_ scf.ApiGetOrganizationRequest) (*scf.Organization, error) { if s.getOrganizationFails { return nil, fmt.Errorf("could not get organization") @@ -89,3 +98,168 @@ func TestGetOrganizationName(t *testing.T) { }) } } + +func TestGetSpaceName(t *testing.T) { + tests := []struct { + description string + getSpaceFails bool + getSpaceResp *scf.Space + isValid bool + expectedOutput string + }{ + { + description: "base", + getSpaceResp: &scf.Space{Name: "space"}, + isValid: true, + expectedOutput: "space", + }, + { + description: "get space fails", + getSpaceFails: true, + isValid: false, + }, + { + description: "nil response", + getSpaceResp: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + settings := &mockSettings{ + getSpaceFails: tt.getSpaceFails, + getSpaceResp: tt.getSpaceResp, + } + + output, err := GetSpaceName(context.Background(), newApiMock(settings), testProjectId, testRegion, testOrganizationId, uuid.NewString()) + + 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 + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + +func TestGetOrganizationLabel(t *testing.T) { + tests := []struct { + description string + getOrganizationFails bool + getOrganizationResp *scf.Organization + expectedOutput string + }{ + { + description: "base", + getOrganizationResp: &scf.Organization{Name: testOrganizationName}, + expectedOutput: testOrganizationName, + }, + { + description: "get organization fails", + getOrganizationFails: true, + expectedOutput: testOrganizationId, + }, + { + description: "empty name", + getOrganizationResp: &scf.Organization{}, + expectedOutput: testOrganizationId, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + settings := &mockSettings{ + getOrganizationFails: tt.getOrganizationFails, + getOrganizationResp: tt.getOrganizationResp, + } + p := print.NewPrinter(nil, nil, nil) + + output := GetOrganizationLabel(context.Background(), p, newApiMock(settings), testProjectId, testRegion, testOrganizationId) + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + +func TestGetSpaceLabel(t *testing.T) { + testSpaceId := uuid.NewString() + tests := []struct { + description string + getSpaceFails bool + getSpaceResp *scf.Space + expectedOutput string + }{ + { + description: "base", + getSpaceResp: &scf.Space{Name: "space"}, + expectedOutput: "space", + }, + { + description: "get space fails", + getSpaceFails: true, + expectedOutput: testSpaceId, + }, + { + description: "empty name", + getSpaceResp: &scf.Space{}, + expectedOutput: testSpaceId, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + settings := &mockSettings{ + getSpaceFails: tt.getSpaceFails, + getSpaceResp: tt.getSpaceResp, + } + p := print.NewPrinter(nil, nil, nil) + + output := GetSpaceLabel(context.Background(), p, newApiMock(settings), testProjectId, testRegion, testOrganizationId, testSpaceId) + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + +func TestUserLabel(t *testing.T) { + tests := []struct { + description string + userGuid *string + userName *string + expectedOutput string + }{ + { + description: "guid", + userGuid: utils.Ptr("guid"), + expectedOutput: "guid", + }, + { + description: "name", + userName: utils.Ptr("name"), + expectedOutput: "name", + }, + { + description: "neither", + expectedOutput: "", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := UserLabel(tt.userGuid, tt.userName) + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +}