diff --git a/README.md b/README.md index 0f4f82377..642c66057 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Below you can find a list of the STACKIT services already available in the CLI ( | RabbitMQ | `rabbitmq` | :white_check_mark: | | Redis | `redis` | :white_check_mark: | | Resource Manager | `project` | :white_check_mark: | +| STACKIT Cloud Foundry | `beta scf` | :white_check_mark: (beta) | | Secrets Manager | `secrets-manager` | :white_check_mark: | | Server Backup Management | `server backup` | :white_check_mark: | | Server Command (Run Command) | `server command` | :white_check_mark: | diff --git a/docs/stackit_beta_scf_organization.md b/docs/stackit_beta_scf_organization.md index 58be89974..e2eee8500 100644 --- a/docs/stackit_beta_scf_organization.md +++ b/docs/stackit_beta_scf_organization.md @@ -34,5 +34,9 @@ stackit beta scf organization [flags] * [stackit beta scf organization delete](./stackit_beta_scf_organization_delete.md) - Deletes a STACKIT Cloud Foundry organization * [stackit beta scf organization describe](./stackit_beta_scf_organization_describe.md) - Shows details of a STACKIT Cloud Foundry organization * [stackit beta scf organization list](./stackit_beta_scf_organization_list.md) - Lists all STACKIT Cloud Foundry organizations +* [stackit beta scf organization manager](./stackit_beta_scf_organization_manager.md) - Manages the organization manager of STACKIT Cloud Foundry organizations +* [stackit beta scf organization quota](./stackit_beta_scf_organization_quota.md) - Manages quotas of STACKIT Cloud Foundry organizations +* [stackit beta scf organization role](./stackit_beta_scf_organization_role.md) - Manages roles of STACKIT Cloud Foundry organizations * [stackit beta scf organization update](./stackit_beta_scf_organization_update.md) - Updates a STACKIT Cloud Foundry organization +* [stackit beta scf organization usage](./stackit_beta_scf_organization_usage.md) - Shows the usage summary of a STACKIT Cloud Foundry organization diff --git a/docs/stackit_beta_scf_organization_manager.md b/docs/stackit_beta_scf_organization_manager.md new file mode 100644 index 000000000..8abf857ab --- /dev/null +++ b/docs/stackit_beta_scf_organization_manager.md @@ -0,0 +1,36 @@ +## stackit beta scf organization manager + +Manages the organization manager of STACKIT Cloud Foundry organizations + +### Synopsis + +Manages the organization manager of STACKIT Cloud Foundry organizations. + +``` +stackit beta scf organization manager [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization manager" +``` + +### 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 organization](./stackit_beta_scf_organization.md) - Provides functionality for STACKIT Cloud Foundry organizations +* [stackit beta scf organization manager create](./stackit_beta_scf_organization_manager_create.md) - Creates an organization manager for a STACKIT Cloud Foundry organization +* [stackit beta scf organization manager delete](./stackit_beta_scf_organization_manager_delete.md) - Deletes the organization manager of a STACKIT Cloud Foundry organization +* [stackit beta scf organization manager describe](./stackit_beta_scf_organization_manager_describe.md) - Shows details of the organization manager of a STACKIT Cloud Foundry organization + diff --git a/docs/stackit_beta_scf_organization_manager_create.md b/docs/stackit_beta_scf_organization_manager_create.md new file mode 100644 index 000000000..232c9a5a9 --- /dev/null +++ b/docs/stackit_beta_scf_organization_manager_create.md @@ -0,0 +1,41 @@ +## stackit beta scf organization manager create + +Creates an organization manager for a STACKIT Cloud Foundry organization + +### Synopsis + +Creates an organization manager for a STACKIT Cloud Foundry organization. +The username and password are generated by the API and shown only once in the command output. + +``` +stackit beta scf organization manager create ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Create an organization manager for a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization manager create xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization manager create" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf organization manager](./stackit_beta_scf_organization_manager.md) - Manages the organization manager of STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_manager_delete.md b/docs/stackit_beta_scf_organization_manager_delete.md new file mode 100644 index 000000000..ef6541c25 --- /dev/null +++ b/docs/stackit_beta_scf_organization_manager_delete.md @@ -0,0 +1,40 @@ +## stackit beta scf organization manager delete + +Deletes the organization manager of a STACKIT Cloud Foundry organization + +### Synopsis + +Deletes the organization manager of a STACKIT Cloud Foundry organization. + +``` +stackit beta scf organization manager delete ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Delete the organization manager of a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization manager delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization manager delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf organization manager](./stackit_beta_scf_organization_manager.md) - Manages the organization manager of STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_manager_describe.md b/docs/stackit_beta_scf_organization_manager_describe.md new file mode 100644 index 000000000..125ccef4b --- /dev/null +++ b/docs/stackit_beta_scf_organization_manager_describe.md @@ -0,0 +1,43 @@ +## stackit beta scf organization manager describe + +Shows details of the organization manager of a STACKIT Cloud Foundry organization + +### Synopsis + +Shows details of the organization manager of a STACKIT Cloud Foundry organization. + +``` +stackit beta scf organization manager describe ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Get details of the organization manager of a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization manager describe xxx + + Get details of the organization manager of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format + $ stackit beta scf organization manager describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization manager 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 organization manager](./stackit_beta_scf_organization_manager.md) - Manages the organization manager of STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_quota.md b/docs/stackit_beta_scf_organization_quota.md new file mode 100644 index 000000000..82272f3e7 --- /dev/null +++ b/docs/stackit_beta_scf_organization_quota.md @@ -0,0 +1,36 @@ +## stackit beta scf organization quota + +Manages quotas of STACKIT Cloud Foundry organizations + +### Synopsis + +Manages quotas of STACKIT Cloud Foundry organizations. + +``` +stackit beta scf organization quota [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization 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 organization](./stackit_beta_scf_organization.md) - Provides functionality for STACKIT Cloud Foundry organizations +* [stackit beta scf organization quota describe](./stackit_beta_scf_organization_quota_describe.md) - Shows details of a STACKIT Cloud Foundry organization quota +* [stackit beta scf organization quota list](./stackit_beta_scf_organization_quota_list.md) - Lists the quotas of a STACKIT Cloud Foundry organization +* [stackit beta scf organization quota set](./stackit_beta_scf_organization_quota_set.md) - Applies a quota to a STACKIT Cloud Foundry organization + diff --git a/docs/stackit_beta_scf_organization_quota_describe.md b/docs/stackit_beta_scf_organization_quota_describe.md new file mode 100644 index 000000000..3029e36be --- /dev/null +++ b/docs/stackit_beta_scf_organization_quota_describe.md @@ -0,0 +1,44 @@ +## stackit beta scf organization quota describe + +Shows details of a STACKIT Cloud Foundry organization quota + +### Synopsis + +Shows details of a STACKIT Cloud Foundry organization quota. A value of "unlimited" means no limit is set. + +``` +stackit beta scf organization quota describe ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Get details of the quota with ID "yyy" of a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization quota describe xxx --quota-id yyy + + Get details of the quota with ID "yyy" of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format + $ stackit beta scf organization quota describe xxx --quota-id yyy --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization 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 organization quota](./stackit_beta_scf_organization_quota.md) - Manages quotas of STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_quota_list.md b/docs/stackit_beta_scf_organization_quota_list.md new file mode 100644 index 000000000..984e77372 --- /dev/null +++ b/docs/stackit_beta_scf_organization_quota_list.md @@ -0,0 +1,43 @@ +## stackit beta scf organization quota list + +Lists the quotas of a STACKIT Cloud Foundry organization + +### Synopsis + +Lists the quotas of a STACKIT Cloud Foundry organization. + +``` +stackit beta scf organization quota list ORGANIZATION_ID [flags] +``` + +### Examples + +``` + List the quotas of a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization quota list xxx + + List the quotas of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format + $ stackit beta scf organization quota list xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization 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 organization quota](./stackit_beta_scf_organization_quota.md) - Manages quotas of STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_quota_set.md b/docs/stackit_beta_scf_organization_quota_set.md new file mode 100644 index 000000000..5e2b548a0 --- /dev/null +++ b/docs/stackit_beta_scf_organization_quota_set.md @@ -0,0 +1,41 @@ +## stackit beta scf organization quota set + +Applies a quota to a STACKIT Cloud Foundry organization + +### Synopsis + +Applies an existing quota to a STACKIT Cloud Foundry organization. The quota limits are not modified by this command. + +``` +stackit beta scf organization quota set ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Apply the quota with ID "yyy" to the STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization quota set xxx --quota-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization quota set" + --quota-id string The UUID of the quota to apply +``` + +### 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 organization quota](./stackit_beta_scf_organization_quota.md) - Manages quotas of STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_role.md b/docs/stackit_beta_scf_organization_role.md new file mode 100644 index 000000000..0d5f9e308 --- /dev/null +++ b/docs/stackit_beta_scf_organization_role.md @@ -0,0 +1,34 @@ +## stackit beta scf organization role + +Manages roles of STACKIT Cloud Foundry organizations + +### Synopsis + +Manages roles of STACKIT Cloud Foundry organizations. + +``` +stackit beta scf organization role [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization 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 organization](./stackit_beta_scf_organization.md) - Provides functionality for STACKIT Cloud Foundry organizations +* [stackit beta scf organization role set](./stackit_beta_scf_organization_role_set.md) - Assigns a role to a user of a STACKIT Cloud Foundry organization + diff --git a/docs/stackit_beta_scf_organization_role_set.md b/docs/stackit_beta_scf_organization_role_set.md new file mode 100644 index 000000000..69c71ee3a --- /dev/null +++ b/docs/stackit_beta_scf_organization_role_set.md @@ -0,0 +1,46 @@ +## stackit beta scf organization role set + +Assigns a role to a user of a STACKIT Cloud Foundry organization + +### Synopsis + +Assigns a role to a user of a STACKIT Cloud Foundry organization. The user is identified either by its GUID or by its username. + +``` +stackit beta scf organization role set ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Assign the role "organization_manager" to the user with GUID "yyy" in the STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization role set xxx --role organization_manager --user-guid yyy + + Assign the role "organization_auditor" to the user with username "jane.doe" in the STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization role set xxx --role organization_auditor --user-name jane.doe +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization role set" + --role string The role to assign to the user (one of: [organization_user, organization_auditor, organization_manager, organization_billing_manager]) + --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 organization role](./stackit_beta_scf_organization_role.md) - Manages roles of STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_usage.md b/docs/stackit_beta_scf_organization_usage.md new file mode 100644 index 000000000..499d37f05 --- /dev/null +++ b/docs/stackit_beta_scf_organization_usage.md @@ -0,0 +1,43 @@ +## stackit beta scf organization usage + +Shows the usage summary of a STACKIT Cloud Foundry organization + +### Synopsis + +Shows the usage summary of a STACKIT Cloud Foundry organization. + +``` +stackit beta scf organization usage ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Show the usage summary of a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization usage xxx + + Show the usage summary of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format + $ stackit beta scf organization usage xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization usage" +``` + +### 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 organization](./stackit_beta_scf_organization.md) - Provides functionality for STACKIT Cloud Foundry organizations + diff --git a/internal/cmd/beta/scf/organization/manager/create/create.go b/internal/cmd/beta/scf/organization/manager/create/create.go new file mode 100644 index 000000000..55773de25 --- /dev/null +++ b/internal/cmd/beta/scf/organization/manager/create/create.go @@ -0,0 +1,123 @@ +package create + +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 ( + orgIdArg = "ORGANIZATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("create %s", orgIdArg), + Short: "Creates an organization manager for a STACKIT Cloud Foundry organization", + Long: "Creates an organization manager for a STACKIT Cloud Foundry organization.\n" + + "The username and password are generated by the API and shown only once in the command output.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Create an organization manager for a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization manager create 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 + } + + prompt := fmt.Sprintf("Are you sure you want to create an organization manager for STACKIT Cloud Foundry organization %q?", model.OrganizationId) + 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 organization manager: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + organizationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: organizationId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiCreateOrgManagerRequest { + return apiClient.CreateOrgManager(ctx, model.ProjectId, model.Region, model.OrganizationId) +} + +func outputResult(p *print.Printer, outputFormat string, manager *scf.OrgManagerResponse) error { + return p.OutputResult(outputFormat, manager, func() error { + if manager == nil { + return fmt.Errorf("received nil response, could not display organization manager") + } + + p.Outputf("Created organization manager for STACKIT Cloud Foundry organization %q. The password is shown only once.\n", manager.GetOrgId()) + + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("Username", manager.GetUsername()) + table.AddRow("Password", manager.GetPassword()) + table.AddRow("ID", manager.GetGuid()) + table.AddRow("Organization ID", manager.GetOrgId()) + table.AddRow("Platform ID", manager.GetPlatformId()) + table.AddRow("Project ID", manager.GetProjectId()) + table.AddRow("Region", manager.GetRegion()) + table.AddRow("Created", manager.CreatedAt.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/organization/manager/create/create_test.go b/internal/cmd/beta/scf/organization/manager/create/create_test.go new file mode 100644 index 000000000..083cb9a97 --- /dev/null +++ b/internal/cmd/beta/scf/organization/manager/create/create_test.go @@ -0,0 +1,202 @@ +package create + +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() + testOrganizationId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testOrganizationId, + } + 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, + }, + OrganizationId: testOrganizationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiCreateOrgManagerRequest)) scf.ApiCreateOrgManagerRequest { + request := testClient.CreateOrgManager(testCtx, testProjectId, testRegion, testOrganizationId) + 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: "organization 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.ApiCreateOrgManagerRequest + }{ + { + 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 + manager *scf.OrgManagerResponse + outputFormat string + wantErr bool + }{ + { + name: "default output", + manager: fixtureOrgManagerResponse(), + wantErr: false, + }, + { + name: "json output", + manager: fixtureOrgManagerResponse(), + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + name: "nil manager", + manager: 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.manager); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func fixtureOrgManagerResponse() *scf.OrgManagerResponse { + return &scf.OrgManagerResponse{ + Guid: uuid.NewString(), + OrgId: testOrganizationId, + Password: "super-secret", + PlatformId: uuid.NewString(), + ProjectId: testProjectId, + Region: testRegion, + Username: "org-manager", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} diff --git a/internal/cmd/beta/scf/organization/manager/delete/delete.go b/internal/cmd/beta/scf/organization/manager/delete/delete.go new file mode 100644 index 000000000..53a65a4c7 --- /dev/null +++ b/internal/cmd/beta/scf/organization/manager/delete/delete.go @@ -0,0 +1,92 @@ +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/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/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + orgIdArg = "ORGANIZATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", orgIdArg), + Short: "Deletes the organization manager of a STACKIT Cloud Foundry organization", + Long: "Deletes the organization manager of a STACKIT Cloud Foundry organization.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete the organization manager of a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization manager delete 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 + } + + prompt := fmt.Sprintf("Are you sure you want to delete the organization manager of STACKIT Cloud Foundry organization %q?", model.OrganizationId) + 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 organization manager: %w", err) + } + + p.Printer.Info("Deleted organization manager of STACKIT Cloud Foundry organization %s\n", model.OrganizationId) + + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + organizationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: organizationId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiDeleteOrgManagerRequest { + return apiClient.DeleteOrgManager(ctx, model.ProjectId, model.Region, model.OrganizationId) +} diff --git a/internal/cmd/beta/scf/organization/manager/delete/delete_test.go b/internal/cmd/beta/scf/organization/manager/delete/delete_test.go new file mode 100644 index 000000000..7d4541000 --- /dev/null +++ b/internal/cmd/beta/scf/organization/manager/delete/delete_test.go @@ -0,0 +1,150 @@ +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.DefaultAPIService{} + testProjectId = uuid.NewString() + testOrganizationId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testOrganizationId, + } + 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, + }, + OrganizationId: testOrganizationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiDeleteOrgManagerRequest)) scf.ApiDeleteOrgManagerRequest { + request := testClient.DeleteOrgManager(testCtx, testProjectId, testRegion, testOrganizationId) + 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: "organization 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.ApiDeleteOrgManagerRequest + }{ + { + 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) + } + }) + } +} diff --git a/internal/cmd/beta/scf/organization/manager/describe/describe.go b/internal/cmd/beta/scf/organization/manager/describe/describe.go new file mode 100644 index 000000000..347244518 --- /dev/null +++ b/internal/cmd/beta/scf/organization/manager/describe/describe.go @@ -0,0 +1,117 @@ +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/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 ( + orgIdArg = "ORGANIZATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", orgIdArg), + Short: "Shows details of the organization manager of a STACKIT Cloud Foundry organization", + Long: "Shows details of the organization manager of a STACKIT Cloud Foundry organization.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of the organization manager of a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization manager describe xxx`), + examples.NewExample( + `Get details of the organization manager of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format`, + `$ stackit beta scf organization manager 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 organization manager: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + organizationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: organizationId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiGetOrgManagerRequest { + return apiClient.GetOrgManager(ctx, model.ProjectId, model.Region, model.OrganizationId) +} + +func outputResult(p *print.Printer, outputFormat string, manager *scf.OrgManager) error { + return p.OutputResult(outputFormat, manager, func() error { + if manager == nil { + return fmt.Errorf("received nil response, could not display organization manager") + } + + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", manager.GetGuid()) + table.AddRow("Username", manager.GetUsername()) + table.AddRow("Organization ID", manager.GetOrgId()) + table.AddRow("Platform ID", manager.GetPlatformId()) + table.AddRow("Project ID", manager.GetProjectId()) + table.AddRow("Region", manager.GetRegion()) + table.AddRow("Created", manager.CreatedAt.Format(time.DateTime)) + table.AddRow("Updated", manager.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/organization/manager/describe/describe_test.go b/internal/cmd/beta/scf/organization/manager/describe/describe_test.go new file mode 100644 index 000000000..e1ee84fb8 --- /dev/null +++ b/internal/cmd/beta/scf/organization/manager/describe/describe_test.go @@ -0,0 +1,201 @@ +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() + testOrganizationId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testOrganizationId, + } + 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, + }, + OrganizationId: testOrganizationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiGetOrgManagerRequest)) scf.ApiGetOrgManagerRequest { + request := testClient.GetOrgManager(testCtx, testProjectId, testRegion, testOrganizationId) + 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: "organization 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.ApiGetOrgManagerRequest + }{ + { + 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 + manager *scf.OrgManager + outputFormat string + wantErr bool + }{ + { + name: "default output", + manager: fixtureOrgManager(), + wantErr: false, + }, + { + name: "json output", + manager: fixtureOrgManager(), + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + name: "nil manager", + manager: 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.manager); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func fixtureOrgManager() *scf.OrgManager { + return &scf.OrgManager{ + Guid: uuid.NewString(), + OrgId: testOrganizationId, + PlatformId: uuid.NewString(), + ProjectId: testProjectId, + Region: testRegion, + Username: "org-manager", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} diff --git a/internal/cmd/beta/scf/organization/manager/manager.go b/internal/cmd/beta/scf/organization/manager/manager.go new file mode 100644 index 000000000..ffc8bd856 --- /dev/null +++ b/internal/cmd/beta/scf/organization/manager/manager.go @@ -0,0 +1,31 @@ +package manager + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/manager/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/manager/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/manager/describe" + "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 organization manager' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "manager", + Short: "Manages the organization manager of STACKIT Cloud Foundry organizations", + Long: "Manages the organization manager of STACKIT Cloud Foundry organizations.", + 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(describe.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) +} diff --git a/internal/cmd/beta/scf/organization/organization.go b/internal/cmd/beta/scf/organization/organization.go index b133dd278..4ce3f16bd 100644 --- a/internal/cmd/beta/scf/organization/organization.go +++ b/internal/cmd/beta/scf/organization/organization.go @@ -7,7 +7,11 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/manager" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/quota" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/role" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/update" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/usage" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -32,4 +36,8 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(delete.NewCmd(params)) cmd.AddCommand(describe.NewCmd(params)) cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(usage.NewCmd(params)) + cmd.AddCommand(manager.NewCmd(params)) + cmd.AddCommand(role.NewCmd(params)) + cmd.AddCommand(quota.NewCmd(params)) } diff --git a/internal/cmd/beta/scf/organization/quota/describe/describe.go b/internal/cmd/beta/scf/organization/quota/describe/describe.go new file mode 100644 index 000000000..ecfa7e40f --- /dev/null +++ b/internal/cmd/beta/scf/organization/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 ( + orgIdArg = "ORGANIZATION_ID" + + quotaIdFlag = "quota-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + QuotaId *string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", orgIdArg), + Short: "Shows details of a STACKIT Cloud Foundry organization quota", + Long: "Shows details of a STACKIT Cloud Foundry organization quota. A value of \"unlimited\" means no limit is set.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of the quota with ID "yyy" of a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization quota describe xxx --quota-id yyy`), + examples.NewExample( + `Get details of the quota with ID "yyy" of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format`, + `$ stackit beta scf organization 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 organization 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) { + organizationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: organizationId, + QuotaId: flags.FlagToStringPointer(p, cmd, quotaIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiGetOrganizationQuotaRequest { + return apiClient.GetOrganizationQuota(ctx, model.ProjectId, model.Region, model.OrganizationId, *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/organization/quota/describe/describe_test.go b/internal/cmd/beta/scf/organization/quota/describe/describe_test.go new file mode 100644 index 000000000..b3c5608a5 --- /dev/null +++ b/internal/cmd/beta/scf/organization/quota/describe/describe_test.go @@ -0,0 +1,239 @@ +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() + testOrganizationId = uuid.NewString() + testQuotaId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testOrganizationId, + } + 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, + }, + OrganizationId: testOrganizationId, + QuotaId: new(testQuotaId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiGetOrganizationQuotaRequest)) scf.ApiGetOrganizationQuotaRequest { + request := testClient.GetOrganizationQuota(testCtx, testProjectId, testRegion, testOrganizationId, 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: "organization 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.ApiGetOrganizationQuotaRequest + }{ + { + 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", + OrgId: new(testOrganizationId), + PlatformId: uuid.NewString(), + 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/organization/quota/list/list.go b/internal/cmd/beta/scf/organization/quota/list/list.go new file mode 100644 index 000000000..e48e36e95 --- /dev/null +++ b/internal/cmd/beta/scf/organization/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 ( + orgIdArg = "ORGANIZATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("list %s", orgIdArg), + Short: "Lists the quotas of a STACKIT Cloud Foundry organization", + Long: "Lists the quotas of a STACKIT Cloud Foundry organization.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `List the quotas of a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization quota list xxx`), + examples.NewExample( + `List the quotas of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format`, + `$ stackit beta scf organization 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 organization quotas: %w", err) + } + + return outputResult(p.Printer, model, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + organizationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: organizationId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiListOrganizationQuotasRequest { + return apiClient.ListOrganizationQuotas(ctx, model.ProjectId, model.Region, model.OrganizationId) +} + +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 organization %s\n", model.OrganizationId) + return nil + } + + table := tables.NewTable() + + table.SetTitle("STACKIT Cloud Foundry organization 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/organization/quota/list/list_test.go b/internal/cmd/beta/scf/organization/quota/list/list_test.go new file mode 100644 index 000000000..ec0065db1 --- /dev/null +++ b/internal/cmd/beta/scf/organization/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() + testOrganizationId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testOrganizationId, + } + 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, + }, + OrganizationId: testOrganizationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiListOrganizationQuotasRequest)) scf.ApiListOrganizationQuotasRequest { + request := testClient.ListOrganizationQuotas(testCtx, testProjectId, testRegion, testOrganizationId) + 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: "organization 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.ApiListOrganizationQuotasRequest + }{ + { + 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: uuid.NewString(), + ProjectId: testProjectId, + Region: testRegion, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } +} diff --git a/internal/cmd/beta/scf/organization/quota/quota.go b/internal/cmd/beta/scf/organization/quota/quota.go new file mode 100644 index 000000000..42fd0c261 --- /dev/null +++ b/internal/cmd/beta/scf/organization/quota/quota.go @@ -0,0 +1,31 @@ +package quota + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/quota/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/quota/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/quota/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 organization quota' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "quota", + Short: "Manages quotas of STACKIT Cloud Foundry organizations", + Long: "Manages quotas of STACKIT Cloud Foundry organizations.", + 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(set.NewCmd(params)) +} diff --git a/internal/cmd/beta/scf/organization/quota/set/set.go b/internal/cmd/beta/scf/organization/quota/set/set.go new file mode 100644 index 000000000..4a4e33772 --- /dev/null +++ b/internal/cmd/beta/scf/organization/quota/set/set.go @@ -0,0 +1,115 @@ +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" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + orgIdArg = "ORGANIZATION_ID" + + quotaIdFlag = "quota-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + QuotaId *string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("set %s", orgIdArg), + Short: "Applies a quota to a STACKIT Cloud Foundry organization", + Long: "Applies an existing quota to a STACKIT Cloud Foundry organization. The quota limits are not modified by this command.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Apply the quota with ID "yyy" to the STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization quota set xxx --quota-id yyy`), + ), + 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 + } + + prompt := fmt.Sprintf("Are you sure you want to apply quota %q to STACKIT Cloud Foundry organization %q?", *model.QuotaId, model.OrganizationId) + 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("apply quota to STACKIT Cloud Foundry organization: %w", err) + } + + return outputResult(p.Printer, model) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), quotaIdFlag, "The UUID of the quota to apply") + + err := flags.MarkFlagsRequired(cmd, quotaIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + organizationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: organizationId, + QuotaId: flags.FlagToStringPointer(p, cmd, quotaIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiApplyOrganizationQuotaRequest { + payload := scf.ApplyOrganizationQuotaPayload{ + QuotaId: *model.QuotaId, + } + + req := apiClient.ApplyOrganizationQuota(ctx, model.ProjectId, model.Region, model.OrganizationId) + return req.ApplyOrganizationQuotaPayload(payload) +} + +func outputResult(p *print.Printer, model *inputModel) error { + return p.OutputResult(model.OutputFormat, nil, func() error { + p.Outputf("Applied quota %s to STACKIT Cloud Foundry organization %s\n", *model.QuotaId, model.OrganizationId) + return nil + }) +} diff --git a/internal/cmd/beta/scf/organization/quota/set/set_test.go b/internal/cmd/beta/scf/organization/quota/set/set_test.go new file mode 100644 index 000000000..1d1782cc2 --- /dev/null +++ b/internal/cmd/beta/scf/organization/quota/set/set_test.go @@ -0,0 +1,196 @@ +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/testparams" + + "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.DefaultAPIService{} + testProjectId = uuid.NewString() + testOrganizationId = uuid.NewString() + testQuotaId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testOrganizationId, + } + 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, + }, + OrganizationId: testOrganizationId, + QuotaId: new(testQuotaId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiApplyOrganizationQuotaRequest)) scf.ApiApplyOrganizationQuotaRequest { + request := testClient.ApplyOrganizationQuota(testCtx, testProjectId, testRegion, testOrganizationId). + ApplyOrganizationQuotaPayload(scf.ApplyOrganizationQuotaPayload{ + QuotaId: 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: "organization 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.ApiApplyOrganizationQuotaRequest + }{ + { + 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 + wantErr bool + }{ + { + name: "default output", + model: fixtureInputModel(), + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.model); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/scf/organization/role/role.go b/internal/cmd/beta/scf/organization/role/role.go new file mode 100644 index 000000000..9a6f64ae4 --- /dev/null +++ b/internal/cmd/beta/scf/organization/role/role.go @@ -0,0 +1,27 @@ +package role + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/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 organization role' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "role", + Short: "Manages roles of STACKIT Cloud Foundry organizations", + Long: "Manages roles of STACKIT Cloud Foundry organizations.", + 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/organization/role/set/set.go b/internal/cmd/beta/scf/organization/role/set/set.go new file mode 100644 index 000000000..54be2e354 --- /dev/null +++ b/internal/cmd/beta/scf/organization/role/set/set.go @@ -0,0 +1,144 @@ +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" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + orgIdArg = "ORGANIZATION_ID" + + userGuidFlag = "user-guid" + userNameFlag = "user-name" +) + +var roleFlag = flags.StringEnumFlag( + "role", + scf.AllowedOrgRoleTypeEnumValues, + "The role to assign to the user", +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + Role scf.OrgRoleType + UserGuid *string + UserName *string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("set %s", orgIdArg), + Short: "Assigns a role to a user of a STACKIT Cloud Foundry organization", + Long: "Assigns a role to a user of a STACKIT Cloud Foundry organization. The user is identified either by its GUID or by its username.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Assign the role "organization_manager" to the user with GUID "yyy" in the STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization role set xxx --role organization_manager --user-guid yyy`), + examples.NewExample( + `Assign the role "organization_auditor" to the user with username "jane.doe" in the STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization role set xxx --role organization_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 + } + + 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) + 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 organization role: %w", err) + } + + return outputResult(p.Printer, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + roleFlag.Register(cmd.Flags()) + 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()) + cobra.CheckErr(err) + cmd.MarkFlagsOneRequired(userGuidFlag, userNameFlag) + cmd.MarkFlagsMutuallyExclusive(userGuidFlag, userNameFlag) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + organizationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: organizationId, + 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.ApiCreateOrgRoleRequest { + payload := scf.CreateOrgRolePayload{ + Type: model.Role, + UserGuid: model.UserGuid, + UserName: model.UserName, + } + + req := apiClient.CreateOrgRole(ctx, model.ProjectId, model.Region, model.OrganizationId) + return req.CreateOrgRolePayload(payload) +} + +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) + 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/organization/role/set/set_test.go b/internal/cmd/beta/scf/organization/role/set/set_test.go new file mode 100644 index 000000000..be57fc338 --- /dev/null +++ b/internal/cmd/beta/scf/organization/role/set/set_test.go @@ -0,0 +1,265 @@ +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/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() + testOrganizationId = uuid.NewString() + testUserGuid = uuid.NewString() + testUserName = "john.stackit" + testRole = string(scf.ORGROLETYPE_ORGANIZATION_MANAGER) +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testOrganizationId, + } + 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, + roleFlag.Name(): testRole, + 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, + Role: scf.OrgRoleType(testRole), + UserGuid: new(testUserGuid), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiCreateOrgRoleRequest)) scf.ApiCreateOrgRoleRequest { + request := testClient.CreateOrgRole(testCtx, testProjectId, testRegion, testOrganizationId). + CreateOrgRolePayload(scf.CreateOrgRolePayload{ + Type: scf.OrgRoleType(testRole), + UserGuid: new(testUserGuid), + }) + 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 with user guid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with user name", + 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: "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: "organization id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "role missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, roleFlag.Name()) + }), + isValid: false, + }, + { + description: "role invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[roleFlag.Name()] = "invalid" + }), + isValid: false, + }, + { + description: "user guid and user name given", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[userNameFlag] = testUserName + }), + isValid: false, + }, + { + description: "neither user guid nor user name given", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, userGuidFlag) + }), + 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.ApiCreateOrgRoleRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "with user name", + model: fixtureInputModel(func(model *inputModel) { + model.UserGuid = nil + model.UserName = new(testUserName) + }), + expectedRequest: fixtureRequest(func(request *scf.ApiCreateOrgRoleRequest) { + *request = request.CreateOrgRolePayload(scf.CreateOrgRolePayload{ + Type: scf.OrgRoleType(testRole), + UserName: new(testUserName), + }) + }), + }, + } + + 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 + resp *scf.OrgRoleResponse + outputFormat string + wantErr bool + }{ + { + name: "default output", + model: fixtureInputModel(), + resp: fixtureOrgRoleResponse(), + wantErr: false, + }, + { + name: "json output", + model: fixtureInputModel(), + resp: fixtureOrgRoleResponse(), + outputFormat: print.JSONOutputFormat, + 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.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func fixtureOrgRoleResponse() *scf.OrgRoleResponse { + return &scf.OrgRoleResponse{ + Guid: uuid.NewString(), + OrgId: testOrganizationId, + PlatformId: uuid.NewString(), + ProjectId: testProjectId, + Region: testRegion, + Type: scf.OrgRoleType(testRole), + } +} diff --git a/internal/cmd/beta/scf/organization/usage/usage.go b/internal/cmd/beta/scf/organization/usage/usage.go new file mode 100644 index 000000000..ef6e6c727 --- /dev/null +++ b/internal/cmd/beta/scf/organization/usage/usage.go @@ -0,0 +1,122 @@ +package usage + +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 ( + orgIdArg = "ORGANIZATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("usage %s", orgIdArg), + Short: "Shows the usage summary of a STACKIT Cloud Foundry organization", + Long: "Shows the usage summary of a STACKIT Cloud Foundry organization.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Show the usage summary of a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization usage xxx`), + examples.NewExample( + `Show the usage summary of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format`, + `$ stackit beta scf organization usage 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 organization usage summary: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + organizationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: organizationId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiGetOrganizationUsageSummaryRequest { + return apiClient.GetOrganizationUsageSummary(ctx, model.ProjectId, model.Region, model.OrganizationId) +} + +func outputResult(p *print.Printer, outputFormat string, summary *scf.OrganizationUsageSummary) error { + return p.OutputResult(outputFormat, summary, func() error { + if summary == nil { + return fmt.Errorf("received nil response, could not display usage summary") + } + + usage := summary.GetUsageSummary() + + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("Organization ID", summary.GetOrgId()) + table.AddRow("Platform ID", summary.GetPlatformId()) + table.AddRow("Project ID", summary.GetProjectId()) + table.AddRow("Region", summary.GetRegion()) + table.AddRow("Domains", usage.GetDomains()) + table.AddRow("Memory (MB)", usage.GetMemoryInMb()) + table.AddRow("Per-App Tasks", usage.GetPerAppTasks()) + table.AddRow("Reserved Ports", usage.GetReservedPorts()) + table.AddRow("Routes", usage.GetRoutes()) + table.AddRow("Service Instances", usage.GetServiceInstances()) + table.AddRow("Service Keys", usage.GetServiceKeys()) + table.AddRow("Started Instances", usage.GetStartedInstances()) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/scf/organization/usage/usage_test.go b/internal/cmd/beta/scf/organization/usage/usage_test.go new file mode 100644 index 000000000..64728874a --- /dev/null +++ b/internal/cmd/beta/scf/organization/usage/usage_test.go @@ -0,0 +1,206 @@ +package usage + +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.DefaultAPIService{} + testProjectId = uuid.NewString() + testOrganizationId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testOrganizationId, + } + 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, + }, + OrganizationId: testOrganizationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiGetOrganizationUsageSummaryRequest)) scf.ApiGetOrganizationUsageSummaryRequest { + request := testClient.GetOrganizationUsageSummary(testCtx, testProjectId, testRegion, testOrganizationId) + 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: "organization 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.ApiGetOrganizationUsageSummaryRequest + }{ + { + 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 + summary *scf.OrganizationUsageSummary + outputFormat string + wantErr bool + }{ + { + name: "default output", + summary: fixtureUsageSummary(), + wantErr: false, + }, + { + name: "json output", + summary: fixtureUsageSummary(), + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + name: "nil summary", + summary: 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.summary); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func fixtureUsageSummary() *scf.OrganizationUsageSummary { + return &scf.OrganizationUsageSummary{ + OrgId: testOrganizationId, + PlatformId: uuid.NewString(), + ProjectId: testProjectId, + Region: testRegion, + UsageSummary: scf.UsageSummary{ + Domains: 1, + MemoryInMb: 1024, + PerAppTasks: 2, + ReservedPorts: 0, + Routes: 3, + ServiceInstances: 4, + ServiceKeys: 5, + StartedInstances: 6, + }, + } +}