diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 588c444e6..db3d6e41e 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -46,6 +46,7 @@ stackit beta [flags] * [stackit beta cdn](./stackit_beta_cdn.md) - Manage CDN resources * [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for Edge Cloud services. * [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake +* [stackit beta scf](./stackit_beta_scf.md) - Provides functionality for STACKIT Cloud Foundry * [stackit beta volume](./stackit_beta_volume.md) - Provides functionality for Volume * [stackit beta vpn](./stackit_beta_vpn.md) - Provides functionality for VPN diff --git a/docs/stackit_beta_scf.md b/docs/stackit_beta_scf.md new file mode 100644 index 000000000..056c89eef --- /dev/null +++ b/docs/stackit_beta_scf.md @@ -0,0 +1,34 @@ +## stackit beta scf + +Provides functionality for STACKIT Cloud Foundry + +### Synopsis + +Provides functionality for STACKIT Cloud Foundry. + +``` +stackit beta scf [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta scf" +``` + +### 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](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta scf organization](./stackit_beta_scf_organization.md) - Provides functionality for STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization.md b/docs/stackit_beta_scf_organization.md new file mode 100644 index 000000000..58be89974 --- /dev/null +++ b/docs/stackit_beta_scf_organization.md @@ -0,0 +1,38 @@ +## stackit beta scf organization + +Provides functionality for STACKIT Cloud Foundry organizations + +### Synopsis + +Provides functionality for STACKIT Cloud Foundry organizations. + +``` +stackit beta scf organization [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf](./stackit_beta_scf.md) - Provides functionality for STACKIT Cloud Foundry +* [stackit beta scf organization create](./stackit_beta_scf_organization_create.md) - Creates a STACKIT Cloud Foundry organization +* [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 update](./stackit_beta_scf_organization_update.md) - Updates a STACKIT Cloud Foundry organization + diff --git a/docs/stackit_beta_scf_organization_create.md b/docs/stackit_beta_scf_organization_create.md new file mode 100644 index 000000000..9df8632be --- /dev/null +++ b/docs/stackit_beta_scf_organization_create.md @@ -0,0 +1,45 @@ +## stackit beta scf organization create + +Creates a STACKIT Cloud Foundry organization + +### Synopsis + +Creates a STACKIT Cloud Foundry organization. + +``` +stackit beta scf organization create [flags] +``` + +### Examples + +``` + Create a STACKIT Cloud Foundry organization with name "my-org" + $ stackit beta scf organization create --name my-org + + Create a STACKIT Cloud Foundry organization with name "my-org" on platform "xxx" + $ stackit beta scf organization create --name my-org --platform-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization create" + --name string Name of the organization + --platform-id string The UUID of the platform to create the organization on +``` + +### 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/docs/stackit_beta_scf_organization_delete.md b/docs/stackit_beta_scf_organization_delete.md new file mode 100644 index 000000000..cb98cb780 --- /dev/null +++ b/docs/stackit_beta_scf_organization_delete.md @@ -0,0 +1,40 @@ +## stackit beta scf organization delete + +Deletes a STACKIT Cloud Foundry organization + +### Synopsis + +Deletes a STACKIT Cloud Foundry organization. + +``` +stackit beta scf organization delete ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Delete a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization 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](./stackit_beta_scf_organization.md) - Provides functionality for STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_describe.md b/docs/stackit_beta_scf_organization_describe.md new file mode 100644 index 000000000..f2942ebff --- /dev/null +++ b/docs/stackit_beta_scf_organization_describe.md @@ -0,0 +1,43 @@ +## stackit beta scf organization describe + +Shows details of a STACKIT Cloud Foundry organization + +### Synopsis + +Shows details of a STACKIT Cloud Foundry organization. + +``` +stackit beta scf organization describe ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Get details of a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization describe xxx + + Get details of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format + $ stackit beta scf organization describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization 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](./stackit_beta_scf_organization.md) - Provides functionality for STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_list.md b/docs/stackit_beta_scf_organization_list.md new file mode 100644 index 000000000..22fb040e0 --- /dev/null +++ b/docs/stackit_beta_scf_organization_list.md @@ -0,0 +1,44 @@ +## stackit beta scf organization list + +Lists all STACKIT Cloud Foundry organizations + +### Synopsis + +Lists all STACKIT Cloud Foundry organizations. The number of fetched organizations is limited to 5000. + +``` +stackit beta scf organization list [flags] +``` + +### Examples + +``` + List all STACKIT Cloud Foundry organizations + $ stackit beta scf organization list + + List up to 10 STACKIT Cloud Foundry organizations + $ stackit beta scf organization list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta scf organization](./stackit_beta_scf_organization.md) - Provides functionality for STACKIT Cloud Foundry organizations + diff --git a/docs/stackit_beta_scf_organization_update.md b/docs/stackit_beta_scf_organization_update.md new file mode 100644 index 000000000..0ae921ef6 --- /dev/null +++ b/docs/stackit_beta_scf_organization_update.md @@ -0,0 +1,45 @@ +## stackit beta scf organization update + +Updates a STACKIT Cloud Foundry organization + +### Synopsis + +Updates a STACKIT Cloud Foundry organization. Only the specified fields are updated. + +``` +stackit beta scf organization update ORGANIZATION_ID [flags] +``` + +### Examples + +``` + Update the name of a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization update xxx --name new-name + + Suspend a STACKIT Cloud Foundry organization with ID "xxx" + $ stackit beta scf organization update xxx --suspended +``` + +### Options + +``` + -h, --help Help for "stackit beta scf organization update" + --name string Name of the organization + --suspended Whether the organization is suspended +``` + +### 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/docs/stackit_config_set.md b/docs/stackit_config_set.md index 9f21f4095..e53de9c0e 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -56,6 +56,7 @@ stackit config set [flags] --redis-custom-endpoint string Redis API base URL, used in calls to this API --resource-manager-custom-endpoint string Resource Manager API base URL, used in calls to this API --runcommand-custom-endpoint string Run Command API base URL, used in calls to this API + --scf-custom-endpoint string STACKIT Cloud Foundry API base URL, used in calls to this API --secrets-manager-custom-endpoint string Secrets Manager API base URL, used in calls to this API --server-osupdate-custom-endpoint string Server Update Management API base URL, used in calls to this API --serverbackup-custom-endpoint string Server Backup API base URL, used in calls to this API diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 5f64ad2e4..b2ed52a05 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -58,6 +58,7 @@ stackit config unset [flags] --region Region --resource-manager-custom-endpoint Resource Manager API base URL. If unset, uses the default base URL --runcommand-custom-endpoint Server Command base URL. If unset, uses the default base URL + --scf-custom-endpoint STACKIT Cloud Foundry API base URL. If unset, uses the default base URL --secrets-manager-custom-endpoint Secrets Manager API base URL. If unset, uses the default base URL --server-osupdate-custom-endpoint Server Update Management base URL. If unset, uses the default base URL --serverbackup-custom-endpoint Server Backup base URL. If unset, uses the default base URL diff --git a/go.mod b/go.mod index b4f4f37a7..2ea08d64a 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.15.0 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.27.0 github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.11.0 + github.com/stackitcloud/stackit-sdk-go/services/scf v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.20.0 github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.8.0 github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.6.0 diff --git a/go.sum b/go.sum index a7aa2c099..90b2a145d 100644 --- a/go.sum +++ b/go.sum @@ -658,6 +658,8 @@ github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.27.0 h1:lZl9X github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.27.0/go.mod h1:IsId3wbkp5oaO0JEaHZpZnbVW6KFHlAptwB+1iG3v3w= github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.11.0 h1:EVeeGzvyiCN4VbInHIWXZtL8hPsVzZIdnfXjgyZzUf0= github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.11.0/go.mod h1:KQLunYwad9IhSbnXuhkKAldVG9LNxCzIYHR8vMJlKQI= +github.com/stackitcloud/stackit-sdk-go/services/scf v0.11.0 h1:tdRyQFfGHFiXNhURdHSdesh7ym3y2R9A39B4ywj2Coc= +github.com/stackitcloud/stackit-sdk-go/services/scf v0.11.0/go.mod h1:SvwAqAKOa4ZSEsSypsFYb6KS9Kvuk8vL4DZw4mpY7b8= github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.20.0 h1:D4/yjPk0GMbp5X96luY3wGEkFJ8RjfLJsIQhHh2ZdwI= github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.20.0/go.mod h1:yV1dcbGaZfdzwcJqjsRNpZ4VVDsQETAAYj2/MCPr4RE= github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.8.0 h1:iFiKTlug6QfLtUAtPKcJe6NX4Jr9HZ84vJNgvra0Wew= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 18de146a7..7934f4631 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -10,6 +10,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/volume" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -47,6 +48,7 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(albwaf.NewCmd(params)) cmd.AddCommand(edge.NewCmd(params)) cmd.AddCommand(intake.NewCmd(params)) + cmd.AddCommand(scf.NewCmd(params)) cmd.AddCommand(cdn.NewCmd(params)) cmd.AddCommand(vpn.NewCmd(params)) } diff --git a/internal/cmd/beta/scf/organization/create/create.go b/internal/cmd/beta/scf/organization/create/create.go new file mode 100644 index 000000000..2c06e5bff --- /dev/null +++ b/internal/cmd/beta/scf/organization/create/create.go @@ -0,0 +1,129 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + nameFlag = "name" + platformIdFlag = "platform-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name *string + PlatformId *string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a STACKIT Cloud Foundry organization", + Long: "Creates a STACKIT Cloud Foundry organization.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a STACKIT Cloud Foundry organization with name "my-org"`, + `$ stackit beta scf organization create --name my-org`), + examples.NewExample( + `Create a STACKIT Cloud Foundry organization with name "my-org" on platform "xxx"`, + `$ stackit beta scf organization create --name my-org --platform-id xxx`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a STACKIT Cloud Foundry organization for project %q?", projectLabel) + 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: %w", err) + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Name of the organization") + cmd.Flags().Var(flags.UUIDFlag(), platformIdFlag, "The UUID of the platform to create the organization on") + + err := flags.MarkFlagsRequired(cmd, nameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + PlatformId: flags.FlagToStringPointer(p, cmd, platformIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiCreateOrganizationRequest { + payload := scf.CreateOrganizationPayload{ + Name: *model.Name, + PlatformId: model.PlatformId, + } + + req := apiClient.CreateOrganization(ctx, model.ProjectId, model.Region) + return req.CreateOrganizationPayload(payload) +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *scf.OrganizationCreateResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Created STACKIT Cloud Foundry organization for project %q, but no organization ID was returned.\n", projectLabel) + return nil + } + + p.Outputf("Created STACKIT Cloud Foundry organization for project %q. Organization ID: %s\n", projectLabel, resp.Guid) + return nil + }) +} diff --git a/internal/cmd/beta/scf/organization/create/create_test.go b/internal/cmd/beta/scf/organization/create/create_test.go new file mode 100644 index 000000000..57bc4355c --- /dev/null +++ b/internal/cmd/beta/scf/organization/create/create_test.go @@ -0,0 +1,247 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + 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" + testName = "my-org" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.APIClient{ + DefaultAPI: &scf.DefaultAPIService{}, + } + testProjectId = uuid.NewString() + testPlatformId = uuid.NewString() +) + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + nameFlag: testName, + platformIdFlag: testPlatformId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Name: new(testName), + PlatformId: new(testPlatformId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureCreatePayload generates a CreateOrganizationPayload for tests +func fixtureCreatePayload(mods ...func(payload *scf.CreateOrganizationPayload)) scf.CreateOrganizationPayload { + payload := scf.CreateOrganizationPayload{ + Name: testName, + PlatformId: new(testPlatformId), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *scf.ApiCreateOrganizationRequest)) scf.ApiCreateOrganizationRequest { + request := testClient.DefaultAPI.CreateOrganization(testCtx, testProjectId, testRegion) + request = request.CreateOrganizationPayload(fixtureCreatePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "platform id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[platformIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + nameFlag: testName, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PlatformId = nil + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest scf.ApiCreateOrganizationRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no platform id", + model: fixtureInputModel(func(model *inputModel) { + model.PlatformId = nil + }), + expectedRequest: fixtureRequest(func(request *scf.ApiCreateOrganizationRequest) { + *request = request.CreateOrganizationPayload(fixtureCreatePayload(func(payload *scf.CreateOrganizationPayload) { + payload.PlatformId = nil + })) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + resp *scf.OrganizationCreateResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + projectLabel: "my-project", + resp: &scf.OrganizationCreateResponse{Guid: "org-id-123"}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &scf.OrganizationCreateResponse{Guid: "org-id-123"}, + }, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: nil, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/scf/organization/delete/delete.go b/internal/cmd/beta/scf/organization/delete/delete.go new file mode 100644 index 000000000..fe9c4f878 --- /dev/null +++ b/internal/cmd/beta/scf/organization/delete/delete.go @@ -0,0 +1,111 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api/wait" + + "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/spinner" + "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 a STACKIT Cloud Foundry organization", + Long: "Deletes a STACKIT Cloud Foundry organization.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization 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 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: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Deleting STACKIT Cloud Foundry organization", func() error { + _, err = wait.DeleteOrganizationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.OrganizationId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for STACKIT Cloud Foundry organization deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Printer.Info("%s STACKIT Cloud Foundry organization %s\n", operationState, model.OrganizationId) + + return nil + }, + } + return cmd +} + +// parseInput parses the command arguments and flags into a standardized model +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 +} + +// buildRequest creates the API request to delete a STACKIT Cloud Foundry organization +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiDeleteOrganizationRequest { + return apiClient.DeleteOrganization(ctx, model.ProjectId, model.Region, model.OrganizationId) +} diff --git a/internal/cmd/beta/scf/organization/delete/delete_test.go b/internal/cmd/beta/scf/organization/delete/delete_test.go new file mode 100644 index 000000000..2c16463c4 --- /dev/null +++ b/internal/cmd/beta/scf/organization/delete/delete_test.go @@ -0,0 +1,152 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.APIClient{ + DefaultAPI: &scf.DefaultAPIService{}, + } + testProjectId = uuid.NewString() + testOrganizationId = uuid.NewString() +) + +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.ApiDeleteOrganizationRequest)) scf.ApiDeleteOrganizationRequest { + request := testClient.DefaultAPI.DeleteOrganization(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.ApiDeleteOrganizationRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/scf/organization/describe/describe.go b/internal/cmd/beta/scf/organization/describe/describe.go new file mode 100644 index 000000000..af6677858 --- /dev/null +++ b/internal/cmd/beta/scf/organization/describe/describe.go @@ -0,0 +1,119 @@ +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 a STACKIT Cloud Foundry organization", + Long: "Shows details of a STACKIT Cloud Foundry organization.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization describe xxx`), + examples.NewExample( + `Get details of a STACKIT Cloud Foundry organization with ID "xxx" in JSON format`, + `$ stackit beta scf organization 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: %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.ApiGetOrganizationRequest { + return apiClient.GetOrganization(ctx, model.ProjectId, model.Region, model.OrganizationId) +} + +func outputResult(p *print.Printer, outputFormat string, organization *scf.Organization) error { + return p.OutputResult(outputFormat, organization, func() error { + if organization == nil { + return fmt.Errorf("received nil response, could not display details") + } + + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", organization.GetGuid()) + table.AddRow("Name", organization.GetName()) + table.AddRow("Platform ID", organization.GetPlatformId()) + table.AddRow("Project ID", organization.GetProjectId()) + table.AddRow("Quota ID", organization.GetQuotaId()) + table.AddRow("Region", organization.GetRegion()) + table.AddRow("Status", organization.GetStatus()) + table.AddRow("Suspended", organization.GetSuspended()) + table.AddRow("Created", organization.CreatedAt.Format(time.DateTime)) + table.AddRow("Updated", organization.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/describe/describe_test.go b/internal/cmd/beta/scf/organization/describe/describe_test.go new file mode 100644 index 000000000..b58a09a4a --- /dev/null +++ b/internal/cmd/beta/scf/organization/describe/describe_test.go @@ -0,0 +1,205 @@ +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.APIClient{ + DefaultAPI: &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.ApiGetOrganizationRequest)) scf.ApiGetOrganizationRequest { + request := testClient.DefaultAPI.GetOrganization(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.ApiGetOrganizationRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + organization *scf.Organization + outputFormat string + wantErr bool + }{ + { + name: "default output", + organization: fixtureOrganization(), + wantErr: false, + }, + { + name: "json output", + organization: fixtureOrganization(), + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + name: "nil organization", + organization: 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.organization); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func fixtureOrganization() *scf.Organization { + return &scf.Organization{ + Guid: testOrganizationId, + Name: "my-org", + PlatformId: uuid.NewString(), + ProjectId: testProjectId, + QuotaId: uuid.NewString(), + Region: testRegion, + Status: "active", + Suspended: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} diff --git a/internal/cmd/beta/scf/organization/list/list.go b/internal/cmd/beta/scf/organization/list/list.go new file mode 100644 index 000000000..077a36126 --- /dev/null +++ b/internal/cmd/beta/scf/organization/list/list.go @@ -0,0 +1,152 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" + + // maxPageSize is the maximum number of items the API returns per page. 5000 is the maximum the API supports. + maxPageSize = 5000 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all STACKIT Cloud Foundry organizations", + Long: fmt.Sprintf("Lists all STACKIT Cloud Foundry organizations. The number of fetched organizations is limited to %d.", maxPageSize), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all STACKIT Cloud Foundry organizations`, + "$ stackit beta scf organization list"), + examples.NewExample( + `List up to 10 STACKIT Cloud Foundry organizations`, + "$ stackit beta scf organization list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + resp, err := buildRequest(ctx, model, apiClient.DefaultAPI, 1, maxPageSize).Execute() + if err != nil { + return fmt.Errorf("fetch STACKIT Cloud Foundry organizations: %w", err) + } + + // Get projectLabel + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI, page, perPage int64) scf.ApiListOrganizationsRequest { + pageSize := int64(perPage) + if model.Limit != nil && *model.Limit < pageSize { + pageSize = *model.Limit + } + return apiClient.ListOrganizations(ctx, model.ProjectId, model.Region).WithDetails(true).Page(page).PerPage(pageSize) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *scf.OrganizationsList) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil || len(resp.Resources) == 0 { + p.Outputf("No STACKIT Cloud Foundry organizations found for project %q\n", projectLabel) + return nil + } + if resp.Pagination.TotalPages != nil && *resp.Pagination.TotalPages > int64(1) { + p.Warn("Server responded with more than 1 page. Output will not show all existing organizations.\n") + } + + table := tables.NewTable() + + table.SetTitle("STACKIT Cloud Foundry organizations") + table.SetHeader("ID", "NAME", "STATUS", "PLATFORM ID", "CREATED") + table.AddSeparator() + for _, organization := range resp.Resources { + table.AddRow( + organization.Guid, + utils.PtrString(organization.Name), + organization.Status, + organization.PlatformId, + utils.ConvertTimePToDateTimeString(organization.CreatedAt), + ) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/scf/organization/list/list_test.go b/internal/cmd/beta/scf/organization/list/list_test.go new file mode 100644 index 000000000..65762518b --- /dev/null +++ b/internal/cmd/beta/scf/organization/list/list_test.go @@ -0,0 +1,234 @@ +package list + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.APIClient{DefaultAPI: &scf.DefaultAPIService{}} + testProjectId = uuid.NewString() + testOrganization = uuid.NewString() +) + +const ( + testLimit int64 = 10 + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: strconv.FormatInt(testLimit, 10), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: new(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *scf.ApiListOrganizationsRequest)) scf.ApiListOrganizationsRequest { + request := testClient.DefaultAPI.ListOrganizations(testCtx, testProjectId, testRegion).WithDetails(true).Page(1).PerPage(testLimit) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + expectedModel: nil, + }, + { + description: "missing project id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + expectedModel: nil, + }, + { + description: "no limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + }, + { + description: "invalid limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + expectedModel: nil, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest scf.ApiListOrganizationsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "no limit set", + model: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *scf.ApiListOrganizationsRequest) { + request.PerPage(maxPageSize) + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI, 1, testLimit) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, scf.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + organizations *scf.OrganizationsList + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "nil organizations slice", + args: args{ + projectLabel: testProjectId, + organizations: nil, + }, + wantErr: false, + }, + { + name: "empty organizations slice", + args: args{ + projectLabel: testProjectId, + organizations: &scf.OrganizationsList{ + Resources: []scf.OrganizationsListItem{}, + }, + }, + wantErr: false, + }, + { + name: "empty organization struct", + args: args{ + projectLabel: testProjectId, + organizations: &scf.OrganizationsList{ + Resources: []scf.OrganizationsListItem{ + {}, + }, + }, + }, + wantErr: false, + }, + { + name: "valid organizations", + args: args{ + projectLabel: testProjectId, + organizations: &scf.OrganizationsList{ + Resources: []scf.OrganizationsListItem{ + { + Guid: testOrganization, + Name: new("my-org"), + Status: "active", + CreatedAt: new(time.Now()), + }, + }, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.organizations); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/scf/organization/organization.go b/internal/cmd/beta/scf/organization/organization.go new file mode 100644 index 000000000..b133dd278 --- /dev/null +++ b/internal/cmd/beta/scf/organization/organization.go @@ -0,0 +1,35 @@ +package organization + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization/create" + "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/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// NewCmd creates the 'stackit beta scf organization' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "organization", + Short: "Provides functionality for STACKIT Cloud Foundry organizations", + Long: "Provides functionality for 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(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/beta/scf/organization/update/update.go b/internal/cmd/beta/scf/organization/update/update.go new file mode 100644 index 000000000..af66728d1 --- /dev/null +++ b/internal/cmd/beta/scf/organization/update/update.go @@ -0,0 +1,133 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/client" + scfUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/scf/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + orgIdArg = "ORGANIZATION_ID" + + nameFlag = "name" + suspendedFlag = "suspended" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + Name *string + Suspended *bool +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", orgIdArg), + Short: "Updates a STACKIT Cloud Foundry organization", + Long: "Updates a STACKIT Cloud Foundry organization. Only the specified fields are updated.", + Args: args.SingleArg(orgIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the name of a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization update xxx --name new-name`), + examples.NewExample( + `Suspend a STACKIT Cloud Foundry organization with ID "xxx"`, + `$ stackit beta scf organization update xxx --suspended`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + organizationLabel, err := scfUtils.GetOrganizationName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.OrganizationId) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get organization name: %v", err) + organizationLabel = model.OrganizationId + } else if organizationLabel == "" { + organizationLabel = model.OrganizationId + } + + prompt := fmt.Sprintf("Are you sure you want to update STACKIT Cloud Foundry organization %q?", organizationLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update STACKIT Cloud Foundry organization: %w", err) + } + + return outputResult(p.Printer, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Name of the organization") + cmd.Flags().Bool(suspendedFlag, false, "Whether the organization is suspended") +} + +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, + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + Suspended: flags.FlagToBoolPointer(p, cmd, suspendedFlag), + } + + if model.Name == nil && model.Suspended == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient scf.DefaultAPI) scf.ApiUpdateOrganizationRequest { + payload := scf.UpdateOrganizationPayload{ + Name: model.Name, + Suspended: model.Suspended, + } + + req := apiClient.UpdateOrganization(ctx, model.ProjectId, model.Region, model.OrganizationId) + return req.UpdateOrganizationPayload(payload) +} + +func outputResult(p *print.Printer, model *inputModel, resp *scf.Organization) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + p.Outputf("Updated STACKIT Cloud Foundry organization %q\n", model.OrganizationId) + return nil + }) +} diff --git a/internal/cmd/beta/scf/organization/update/update_test.go b/internal/cmd/beta/scf/organization/update/update_test.go new file mode 100644 index 000000000..298a3eb6c --- /dev/null +++ b/internal/cmd/beta/scf/organization/update/update_test.go @@ -0,0 +1,269 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/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" + testName = "my-org" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &scf.APIClient{ + DefaultAPI: &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, + nameFlag: testName, + suspendedFlag: "true", + } + 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, + Name: new(testName), + Suspended: new(true), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureUpdatePayload(mods ...func(payload *scf.UpdateOrganizationPayload)) scf.UpdateOrganizationPayload { + payload := scf.UpdateOrganizationPayload{ + Name: new(testName), + Suspended: new(true), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *scf.ApiUpdateOrganizationRequest)) scf.ApiUpdateOrganizationRequest { + request := testClient.DefaultAPI.UpdateOrganization(testCtx, testProjectId, testRegion, testOrganizationId) + request = request.UpdateOrganizationPayload(fixtureUpdatePayload()) + 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: "name only", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + nameFlag: testName, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Suspended = nil + }), + }, + { + description: "suspended only", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + suspendedFlag: "false", + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + model.Suspended = new(false) + }), + }, + { + description: "no fields to update", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + 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.ApiUpdateOrganizationRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "name only", + model: fixtureInputModel(func(model *inputModel) { + model.Suspended = nil + }), + expectedRequest: fixtureRequest(func(request *scf.ApiUpdateOrganizationRequest) { + *request = request.UpdateOrganizationPayload(fixtureUpdatePayload(func(payload *scf.UpdateOrganizationPayload) { + payload.Suspended = nil + })) + }), + }, + { + description: "suspended only", + model: fixtureInputModel(func(model *inputModel) { + model.Name = nil + }), + expectedRequest: fixtureRequest(func(request *scf.ApiUpdateOrganizationRequest) { + *request = request.UpdateOrganizationPayload(fixtureUpdatePayload(func(payload *scf.UpdateOrganizationPayload) { + payload.Name = nil + })) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + resp *scf.Organization + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + resp: &scf.Organization{Guid: testOrganizationId}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &scf.Organization{Guid: testOrganizationId}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/scf/scf.go b/internal/cmd/beta/scf/scf.go new file mode 100644 index 000000000..0a27536b5 --- /dev/null +++ b/internal/cmd/beta/scf/scf.go @@ -0,0 +1,27 @@ +package scf + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/scf/organization" + "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' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "scf", + Short: "Provides functionality for STACKIT Cloud Foundry", + Long: "Provides functionality for STACKIT Cloud Foundry.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(organization.NewCmd(params)) +} diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 194291a3d..477695495 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -55,6 +55,7 @@ const ( intakeCustomEndpointFlag = "intake-custom-endpoint" logsCustomEndpointFlag = "logs-custom-endpoint" sfsCustomEndpointFlag = "sfs-custom-endpoint" + scfCustomEndpointFlag = "scf-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" ) @@ -178,6 +179,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(intakeCustomEndpointFlag, "", "Intake API base URL, used in calls to this API") cmd.Flags().String(logsCustomEndpointFlag, "", "Logs API base URL, used in calls to this API") cmd.Flags().String(sfsCustomEndpointFlag, "", "SFS API base URL, used in calls to this API") + cmd.Flags().String(scfCustomEndpointFlag, "", "STACKIT Cloud Foundry API base URL, used in calls to this API") cmd.Flags().String(cdnCustomEndpointFlag, "", "CDN API base URL, used in calls to this API") cmd.Flags().String(vpnCustomEndpointFlag, "", "VPN API base URL, used in calls to this API") @@ -252,6 +254,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.SfsCustomEndpointKey, cmd.Flags().Lookup(sfsCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.ScfCustomEndpointKey, cmd.Flags().Lookup(scfCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.CDNCustomEndpointKey, cmd.Flags().Lookup(cdnCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.VPNCustomEndpointKey, cmd.Flags().Lookup(vpnCustomEndpointFlag)) diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 5aff526eb..57600ecea 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -50,6 +50,7 @@ const ( serverBackupCustomEndpointFlag = "serverbackup-custom-endpoint" serverOsUpdateCustomEndpointFlag = "server-osupdate-custom-endpoint" runCommandCustomEndpointFlag = "runcommand-custom-endpoint" + scfCustomEndpointFlag = "scf-custom-endpoint" sfsCustomEndpointFlag = "sfs-custom-endpoint" skeCustomEndpointFlag = "ske-custom-endpoint" sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint" @@ -103,6 +104,7 @@ type inputModel struct { RunCommandCustomEndpoint bool ServiceAccountCustomEndpoint bool ServiceEnablementCustomEndpoint bool + ScfCustomEndpoint bool SfsCustomEndpoint bool SKECustomEndpoint bool SQLServerFlexCustomEndpoint bool @@ -257,6 +259,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if model.LogsCustomEndpoint { viper.Set(config.LogsCustomEndpointKey, "") } + if model.ScfCustomEndpoint { + viper.Set(config.ScfCustomEndpointKey, "") + } if model.SfsCustomEndpoint { viper.Set(config.SfsCustomEndpointKey, "") } @@ -321,6 +326,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(tokenCustomEndpointFlag, false, "Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication.") cmd.Flags().Bool(intakeCustomEndpointFlag, false, "Intake API base URL. If unset, uses the default base URL") cmd.Flags().Bool(logsCustomEndpointFlag, false, "Logs API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(scfCustomEndpointFlag, false, "STACKIT Cloud Foundry API base URL. If unset, uses the default base URL") cmd.Flags().Bool(sfsCustomEndpointFlag, false, "SFS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(cdnCustomEndpointFlag, false, "Custom CDN endpoint URL. If unset, uses the default base URL") cmd.Flags().Bool(vpnCustomEndpointFlag, false, "VPN API base URL. If unset, uses the default base URL") @@ -364,6 +370,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { ServerOsUpdateCustomEndpoint: flags.FlagToBoolValue(p, cmd, serverOsUpdateCustomEndpointFlag), RunCommandCustomEndpoint: flags.FlagToBoolValue(p, cmd, runCommandCustomEndpointFlag), SKECustomEndpoint: flags.FlagToBoolValue(p, cmd, skeCustomEndpointFlag), + ScfCustomEndpoint: flags.FlagToBoolValue(p, cmd, scfCustomEndpointFlag), SfsCustomEndpoint: flags.FlagToBoolValue(p, cmd, sfsCustomEndpointFlag), SQLServerFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, sqlServerFlexCustomEndpointFlag), IaaSAlphaCustomEndpoint: flags.FlagToBoolValue(p, cmd, iaasAlphaCustomEndpointFlag), diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 46a41ee21..55008562d 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -42,6 +42,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool serverBackupCustomEndpointFlag: true, serverOsUpdateCustomEndpointFlag: true, runCommandCustomEndpointFlag: true, + scfCustomEndpointFlag: true, sfsCustomEndpointFlag: true, skeCustomEndpointFlag: true, sqlServerFlexCustomEndpointFlag: true, @@ -92,6 +93,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { ServerBackupCustomEndpoint: true, ServerOsUpdateCustomEndpoint: true, RunCommandCustomEndpoint: true, + ScfCustomEndpoint: true, SfsCustomEndpoint: true, SKECustomEndpoint: true, SQLServerFlexCustomEndpoint: true, @@ -158,6 +160,7 @@ func TestParseInput(t *testing.T) { model.ServerBackupCustomEndpoint = false model.ServerOsUpdateCustomEndpoint = false model.RunCommandCustomEndpoint = false + model.ScfCustomEndpoint = false model.SfsCustomEndpoint = false model.SKECustomEndpoint = false model.SQLServerFlexCustomEndpoint = false @@ -300,6 +303,16 @@ func TestParseInput(t *testing.T) { model.ServiceAccountCustomEndpoint = false }), }, + { + description: "scf custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[scfCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ScfCustomEndpoint = false + }), + }, { description: "sfs custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index db2441f6d..56c53899b 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -38,6 +38,7 @@ const ( OpenSearchCustomEndpointKey = "opensearch_custom_endpoint" PostgresFlexCustomEndpointKey = "postgresflex_custom_endpoint" RabbitMQCustomEndpointKey = "rabbitmq_custom_endpoint" + ScfCustomEndpointKey = "scf_custom_endpoint" // Deprecated: Will be removed after 2027-08-31. RedisCustomEndpointKey = "redis_custom_endpoint" ResourceManagerEndpointKey = "resource_manager_custom_endpoint" @@ -121,6 +122,7 @@ var ConfigKeys = []string{ RedisCustomEndpointKey, ResourceManagerEndpointKey, RunCommandCustomEndpointKey, + ScfCustomEndpointKey, SKECustomEndpointKey, SQLServerFlexCustomEndpointKey, SecretsManagerCustomEndpointKey, @@ -215,6 +217,7 @@ func setConfigDefaults() { viper.SetDefault(ServerBackupCustomEndpointKey, "") viper.SetDefault(ServerOsUpdateCustomEndpointKey, "") viper.SetDefault(RunCommandCustomEndpointKey, "") + viper.SetDefault(ScfCustomEndpointKey, "") viper.SetDefault(SKECustomEndpointKey, "") viper.SetDefault(SQLServerFlexCustomEndpointKey, "") viper.SetDefault(IaaSAlphaCustomEndpointKey, "") diff --git a/internal/pkg/services/scf/client/client.go b/internal/pkg/services/scf/client/client.go new file mode 100644 index 000000000..fbe02895f --- /dev/null +++ b/internal/pkg/services/scf/client/client.go @@ -0,0 +1,15 @@ +package client + +import ( + "github.com/spf13/viper" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +// ConfigureClient creates and configures a new SCF API client +func ConfigureClient(p *print.Printer, cliVersion string) (*scf.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ScfCustomEndpointKey), false, genericclient.CreateApiClient[*scf.APIClient](scf.NewAPIClient)) +} diff --git a/internal/pkg/services/scf/utils/utils.go b/internal/pkg/services/scf/utils/utils.go new file mode 100644 index 000000000..556a37a8f --- /dev/null +++ b/internal/pkg/services/scf/utils/utils.go @@ -0,0 +1,19 @@ +package utils + +import ( + "context" + "fmt" + + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" +) + +func GetOrganizationName(ctx context.Context, apiClient scf.DefaultAPI, projectId, region, organizationId string) (string, error) { + resp, err := apiClient.GetOrganization(ctx, projectId, region, organizationId).Execute() + if err != nil { + return "", fmt.Errorf("get STACKIT Cloud Foundry organization: %w", err) + } + if resp == nil { + return "", fmt.Errorf("get STACKIT Cloud Foundry organization: empty response") + } + return resp.Name, nil +} diff --git a/internal/pkg/services/scf/utils/utils_test.go b/internal/pkg/services/scf/utils/utils_test.go new file mode 100644 index 000000000..88da608ff --- /dev/null +++ b/internal/pkg/services/scf/utils/utils_test.go @@ -0,0 +1,91 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + scf "github.com/stackitcloud/stackit-sdk-go/services/scf/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var ( + testProjectId = uuid.NewString() + testOrganizationId = uuid.NewString() +) + +const ( + testOrganizationName = "organization" + testRegion = "eu01" +) + +type mockSettings struct { + getOrganizationFails bool + getOrganizationResp *scf.Organization +} + +func newApiMock(s *mockSettings) scf.DefaultAPI { + return &scf.DefaultAPIServiceMock{ + GetOrganizationExecuteMock: utils.Ptr(func(_ scf.ApiGetOrganizationRequest) (*scf.Organization, error) { + if s.getOrganizationFails { + return nil, fmt.Errorf("could not get organization") + } + return s.getOrganizationResp, nil + }), + } +} + +func TestGetOrganizationName(t *testing.T) { + tests := []struct { + description string + getOrganizationFails bool + getOrganizationResp *scf.Organization + isValid bool + expectedOutput string + }{ + { + description: "base", + getOrganizationResp: &scf.Organization{ + Name: testOrganizationName, + }, + isValid: true, + expectedOutput: testOrganizationName, + }, + { + description: "get organization fails", + getOrganizationFails: true, + isValid: false, + }, + { + description: "nil response", + getOrganizationResp: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + settings := &mockSettings{ + getOrganizationFails: tt.getOrganizationFails, + getOrganizationResp: tt.getOrganizationResp, + } + + output, err := GetOrganizationName(context.Background(), newApiMock(settings), testProjectId, testRegion, testOrganizationId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +}