diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index b58eb067a..248bf091d 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -42,5 +42,6 @@ stackit beta [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake * [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex diff --git a/docs/stackit_beta_intake.md b/docs/stackit_beta_intake.md new file mode 100644 index 000000000..f44d3c12d --- /dev/null +++ b/docs/stackit_beta_intake.md @@ -0,0 +1,34 @@ +## stackit beta intake + +Provides functionality for intake + +### Synopsis + +Provides functionality for intake. + +``` +stackit beta intake [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta intake" +``` + +### 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 intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_runner.md b/docs/stackit_beta_intake_runner.md new file mode 100644 index 000000000..7d5c60ff3 --- /dev/null +++ b/docs/stackit_beta_intake_runner.md @@ -0,0 +1,38 @@ +## stackit beta intake runner + +Provides functionality for Intake Runners + +### Synopsis + +Provides functionality for Intake Runners. + +``` +stackit beta intake runner [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta intake runner" +``` + +### 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 intake](./stackit_beta_intake.md) - Provides functionality for intake +* [stackit beta intake runner create](./stackit_beta_intake_runner_create.md) - Creates a new Intake Runner +* [stackit beta intake runner delete](./stackit_beta_intake_runner_delete.md) - Deletes an Intake Runner +* [stackit beta intake runner describe](./stackit_beta_intake_runner_describe.md) - Shows details of an Intake Runner +* [stackit beta intake runner list](./stackit_beta_intake_runner_list.md) - Lists all Intake Runners +* [stackit beta intake runner update](./stackit_beta_intake_runner_update.md) - Updates an Intake Runner + diff --git a/docs/stackit_beta_intake_runner_create.md b/docs/stackit_beta_intake_runner_create.md new file mode 100644 index 000000000..8903cef9d --- /dev/null +++ b/docs/stackit_beta_intake_runner_create.md @@ -0,0 +1,48 @@ +## stackit beta intake runner create + +Creates a new Intake Runner + +### Synopsis + +Creates a new Intake Runner. + +``` +stackit beta intake runner create [flags] +``` + +### Examples + +``` + Create a new Intake Runner with a display name and message capacity limits + $ stackit beta intake runner create --display-name my-runner --max-message-size-kib 1000 --max-messages-per-hour 5000 + + Create a new Intake Runner with a description and labels + $ stackit beta intake runner create --display-name my-runner --max-message-size-kib 1000 --max-messages-per-hour 5000 --description "Main runner for production" --labels="env=prod,team=billing" +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake runner create" + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2" (default []) + --max-message-size-kib int Maximum message size in KiB + --max-messages-per-hour int Maximum number of messages per hour +``` + +### 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 intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_runner_delete.md b/docs/stackit_beta_intake_runner_delete.md new file mode 100644 index 000000000..0fa94ae5f --- /dev/null +++ b/docs/stackit_beta_intake_runner_delete.md @@ -0,0 +1,40 @@ +## stackit beta intake runner delete + +Deletes an Intake Runner + +### Synopsis + +Deletes an Intake Runner. + +``` +stackit beta intake runner delete RUNNER_ID [flags] +``` + +### Examples + +``` + Delete an Intake Runner with ID "xxx" + $ stackit beta intake runner delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta intake runner 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 intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_runner_describe.md b/docs/stackit_beta_intake_runner_describe.md new file mode 100644 index 000000000..11814b10d --- /dev/null +++ b/docs/stackit_beta_intake_runner_describe.md @@ -0,0 +1,43 @@ +## stackit beta intake runner describe + +Shows details of an Intake Runner + +### Synopsis + +Shows details of an Intake Runner. + +``` +stackit beta intake runner describe RUNNER_ID [flags] +``` + +### Examples + +``` + Get details of an Intake Runner with ID "xxx" + $ stackit beta intake runner describe xxx + + Get details of an Intake Runner with ID "xxx" in JSON format + $ stackit beta intake runner describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta intake runner 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 intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_runner_list.md b/docs/stackit_beta_intake_runner_list.md new file mode 100644 index 000000000..aaf5c9e59 --- /dev/null +++ b/docs/stackit_beta_intake_runner_list.md @@ -0,0 +1,47 @@ +## stackit beta intake runner list + +Lists all Intake Runners + +### Synopsis + +Lists all Intake Runners for the current project. + +``` +stackit beta intake runner list [flags] +``` + +### Examples + +``` + List all Intake Runners + $ stackit beta intake runner list + + List all Intake Runners in JSON format + $ stackit beta intake runner list --output-format json + + List up to 5 Intake Runners + $ stackit beta intake runner list --limit 5 +``` + +### Options + +``` + -h, --help Help for "stackit beta intake runner 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 intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_runner_update.md b/docs/stackit_beta_intake_runner_update.md new file mode 100644 index 000000000..d02cb7c84 --- /dev/null +++ b/docs/stackit_beta_intake_runner_update.md @@ -0,0 +1,48 @@ +## stackit beta intake runner update + +Updates an Intake Runner + +### Synopsis + +Updates an Intake Runner. Only the specified fields are updated. + +``` +stackit beta intake runner update RUNNER_ID [flags] +``` + +### Examples + +``` + Update the display name of an Intake Runner with ID "xxx" + $ stackit beta intake runner update xxx --display-name "new-runner-name" + + Update the message capacity limits for an Intake Runner with ID "xxx" + $ stackit beta intake runner update xxx --max-message-size-kib 1000 --max-messages-per-hour 10000 +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake runner update" + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2". (default []) + --max-message-size-kib int Maximum message size in KiB. Note: Overall message capacity cannot be decreased. + --max-messages-per-hour int Maximum number of messages per hour. Note: Overall message capacity cannot be decreased. +``` + +### 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 intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index b1abf5662..880184af2 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -36,6 +36,7 @@ stackit config set [flags] --iaas-custom-endpoint string IaaS API base URL, used in calls to this API --identity-provider-custom-client-id string Identity Provider client ID, used for user authentication --identity-provider-custom-well-known-configuration string Identity Provider well-known OpenID configuration URL, used for user authentication + --intake-custom-endpoint string Intake API base URL, used in calls to this API --load-balancer-custom-endpoint string Load Balancer API base URL, used in calls to this API --logme-custom-endpoint string LogMe API base URL, used in calls to this API --mariadb-custom-endpoint string MariaDB API base URL, used in calls to this API diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 4a48b759e..65ef2fd97 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -34,6 +34,7 @@ stackit config unset [flags] --iaas-custom-endpoint IaaS API base URL. If unset, uses the default base URL --identity-provider-custom-client-id Identity Provider client ID, used for user authentication --identity-provider-custom-well-known-configuration Identity Provider well-known OpenID configuration URL. If unset, uses the default identity provider + --intake-custom-endpoint Intake API base URL. If unset, uses the default base URL --load-balancer-custom-endpoint Load Balancer API base URL. If unset, uses the default base URL --logme-custom-endpoint LogMe API base URL. If unset, uses the default base URL --mariadb-custom-endpoint MariaDB API base URL. If unset, uses the default base URL diff --git a/go.mod b/go.mod index d152751e2..8927c824c 100644 --- a/go.mod +++ b/go.mod @@ -181,6 +181,7 @@ require ( github.com/sonatard/noctx v0.1.0 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect + github.com/stackitcloud/stackit-sdk-go/services/intake v0.2.0 // indirect github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect diff --git a/go.sum b/go.sum index a896800c1..a081dd0d3 100644 --- a/go.sum +++ b/go.sum @@ -573,6 +573,8 @@ github.com/stackitcloud/stackit-sdk-go/services/git v0.8.0 h1:/weT7P5Uwy1Qlhw0Ni github.com/stackitcloud/stackit-sdk-go/services/git v0.8.0/go.mod h1:AXFfYBJZIW1o0W0zZEb/proQMhMsb3Nn5E1htS8NDPE= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.31.0 h1:dnEjyapuv8WwRN5vE2z6+4/+ZqQTBx+bX27x2nOF7Jw= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.31.0/go.mod h1:854gnLR92NvAbJAA1xZEumrtNh1DoBP1FXTMvhwYA6w= +github.com/stackitcloud/stackit-sdk-go/services/intake v0.2.0 h1:p/zi4VPoCQWk7/2ubi3hxsqiaye41x/Pl3GXYbPkYOY= +github.com/stackitcloud/stackit-sdk-go/services/intake v0.2.0/go.mod h1:jOArPjNRkwv4487+9ab3dRG+lM09leu5FiRohbQs9Z4= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.6.0 h1:q33ZaCBVEBUsnMDxYyuJKtJvGcE5nKgvuPed3s8zXNI= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.6.0/go.mod h1:20QOZ3rBC9wTGgzXzLz9M6YheX0VaxWE0/JI+s8On7k= github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.1 h1:hv5WrRU9rN6Jx4OwdOGJRyaQrfA9p1tzEoQK6/CDyoA= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 5a007b87e..e6d111215 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/cmd/params" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -38,4 +39,5 @@ func NewCmd(params *params.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(alb.NewCmd(params)) + cmd.AddCommand(intake.NewCmd(params)) } diff --git a/internal/cmd/beta/intake/intake.go b/internal/cmd/beta/intake/intake.go new file mode 100644 index 000000000..96533f29c --- /dev/null +++ b/internal/cmd/beta/intake/intake.go @@ -0,0 +1,26 @@ +package intake + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// NewCmd creates the 'stackit intake' command +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "intake", + Short: "Provides functionality for intake", + Long: "Provides functionality for intake.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand(runner.NewCmd(params)) +} diff --git a/internal/cmd/beta/intake/runner/create/create.go b/internal/cmd/beta/intake/runner/create/create.go new file mode 100644 index 000000000..03b182739 --- /dev/null +++ b/internal/cmd/beta/intake/runner/create/create.go @@ -0,0 +1,184 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/wait" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + displayNameFlag = "display-name" + maxMessageSizeKiBFlag = "max-message-size-kib" + maxMessagesPerHourFlag = "max-messages-per-hour" + descriptionFlag = "description" + labelFlag = "labels" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + DisplayName *string + MaxMessageSizeKiB *int64 + MaxMessagesPerHour *int64 + Description *string + Labels *map[string]string +} + +func NewCreateCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new Intake Runner", + Long: "Creates a new Intake Runner.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new Intake Runner with a display name and message capacity limits`, + `$ stackit beta intake runner create --display-name my-runner --max-message-size-kib 1000 --max-messages-per-hour 5000`), + examples.NewExample( + `Create a new Intake Runner with a description and labels`, + `$ stackit beta intake runner create --display-name my-runner --max-message-size-kib 1000 --max-messages-per-hour 5000 --description "Main runner for production" --labels="env=prod,team=billing"`), + ), + 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 + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create an Intake Runner for project %q?", projectLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Intake Runner: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Creating STACKIT Intake Runner instance") + _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, resp.GetId()).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance creation: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Int64(maxMessageSizeKiBFlag, 0, "Maximum message size in KiB") + cmd.Flags().Int64(maxMessagesPerHourFlag, 0, "Maximum number of messages per hour") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelFlag, nil, "Labels in key=value format, separated by commas. Example: --labels \"key1=value1,key2=value2\"") + + err := flags.MarkFlagsRequired(cmd, displayNameFlag, maxMessageSizeKiBFlag, maxMessagesPerHourFlag) + 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, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + MaxMessageSizeKiB: flags.FlagToInt64Pointer(p, cmd, maxMessageSizeKiBFlag), + MaxMessagesPerHour: flags.FlagToInt64Pointer(p, cmd, maxMessagesPerHourFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiCreateIntakeRunnerRequest { + // Start building the request by calling the base method with path parameters + req := apiClient.CreateIntakeRunner(ctx, model.ProjectId, model.Region) + + // Create the payload struct with data from the input model + payload := intake.CreateIntakeRunnerPayload{ + DisplayName: model.DisplayName, + MaxMessageSizeKiB: model.MaxMessageSizeKiB, + MaxMessagesPerHour: model.MaxMessagesPerHour, + Description: model.Description, + Labels: model.Labels, + } + // Attach the payload to the request builder + req = req.CreateIntakeRunnerPayload(payload) + + return req +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *intake.IntakeRunnerResponse) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal instance: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal instance: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + if resp == nil { + p.Outputf("Created Intake Runner for project %q, but no runner ID was returned.\n", projectLabel) + return nil + } + p.Outputf("Created Intake Runner for project %q. Runner ID: %s\n", projectLabel, utils.PtrString(resp.Id)) + return nil + } +} diff --git a/internal/cmd/beta/intake/runner/create/create_test.go b/internal/cmd/beta/intake/runner/create/create_test.go new file mode 100644 index 000000000..ca5052918 --- /dev/null +++ b/internal/cmd/beta/intake/runner/create/create_test.go @@ -0,0 +1,304 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testDisplayName = "testrunner" + testMaxMessageSizeKiB = int64(1024) + testMaxMessagesPerHour = int64(10000) + testDescription = "This is a test runner" + testLabelsString = "env=test,team=dev" +) + +var ( + // testCtx dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + + testLabels = map[string]string{"env": "test", "team": "dev"} +) + +// 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, + displayNameFlag: testDisplayName, + maxMessageSizeKiBFlag: "1024", + maxMessagesPerHourFlag: "10000", + descriptionFlag: testDescription, + labelFlag: testLabelsString, + } + 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, + }, + DisplayName: utils.Ptr(testDisplayName), + MaxMessageSizeKiB: utils.Ptr(testMaxMessageSizeKiB), + MaxMessagesPerHour: utils.Ptr(testMaxMessagesPerHour), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureCreatePayload generates a CreateIntakeRunnerPayload for tests +func fixtureCreatePayload(mods ...func(payload *intake.CreateIntakeRunnerPayload)) intake.CreateIntakeRunnerPayload { + payload := intake.CreateIntakeRunnerPayload{ + DisplayName: utils.Ptr(testDisplayName), + MaxMessageSizeKiB: utils.Ptr(testMaxMessageSizeKiB), + MaxMessagesPerHour: utils.Ptr(testMaxMessagesPerHour), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiCreateIntakeRunnerRequest)) intake.ApiCreateIntakeRunnerRequest { + request := testClient.CreateIntakeRunner(testCtx, testProjectId, testRegion) + request = request.CreateIntakeRunnerPayload(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: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "display name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "max message size missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, maxMessageSizeKiBFlag) + }), + isValid: false, + }, + { + description: "max messages per hour missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, maxMessagesPerHourFlag) + }), + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + maxMessageSizeKiBFlag: "1024", + maxMessagesPerHourFlag: "10000", + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCreateCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiCreateIntakeRunnerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optionals", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *intake.ApiCreateIntakeRunnerRequest) { + *request = (*request).CreateIntakeRunnerPayload(fixtureCreatePayload(func(payload *intake.CreateIntakeRunnerPayload) { + payload.Description = nil + payload.Labels = nil + })) + }), + }, + } + + 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), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + runnerId string + resp *intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", projectLabel: "my-project", runnerId: "runner-id-123", resp: &intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}}, + wantErr: false, + }, + { + name: "nil response", + args: args{outputFormat: print.JSONOutputFormat, resp: nil}, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{outputFormat: "default", resp: nil}, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCreateCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, 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/intake/runner/delete/delete.go b/internal/cmd/beta/intake/runner/delete/delete.go new file mode 100644 index 000000000..d7c6a9114 --- /dev/null +++ b/internal/cmd/beta/intake/runner/delete/delete.go @@ -0,0 +1,115 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/wait" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +const ( + runnerIdArg = "RUNNER_ID" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + RunnerId string +} + +// NewDeleteCmd creates a new cobra command for deleting an Intake Runner +func NewDeleteCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", runnerIdArg), + Short: "Deletes an Intake Runner", + Long: "Deletes an Intake Runner.", + Args: args.SingleArg(runnerIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an Intake Runner with ID "xxx"`, + `$ stackit beta intake runner 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 + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete Intake Runner %q?", model.RunnerId) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err = req.Execute(); err != nil { + return fmt.Errorf("delete Intake Runner: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Deleting STACKIT Intake Runner instance") + _, err = wait.DeleteIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.RunnerId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Printer.Info("%s stackit Intake Runner instance %s \n", operationState, model.RunnerId) + + 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) { + runnerId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + RunnerId: runnerId, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to delete an Intake Runner +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiDeleteIntakeRunnerRequest { + req := apiClient.DeleteIntakeRunner(ctx, model.ProjectId, model.Region, model.RunnerId) + return req +} diff --git a/internal/cmd/beta/intake/runner/delete/delete_test.go b/internal/cmd/beta/intake/runner/delete/delete_test.go new file mode 100644 index 000000000..0ce29136a --- /dev/null +++ b/internal/cmd/beta/intake/runner/delete/delete_test.go @@ -0,0 +1,203 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + // testCtx is a dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient is a mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testRunnerId = uuid.NewString() +) + +// fixtureArgValues generates a slice of arguments for tests +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRunnerId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// 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, + } + 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, + }, + RunnerId: testRunnerId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiDeleteIntakeRunnerRequest)) intake.ApiDeleteIntakeRunnerRequest { + request := testClient.DeleteIntakeRunner(testCtx, testProjectId, testRegion, testRunnerId) + 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: "runner id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewDeleteCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiDeleteIntakeRunnerRequest + }{ + { + 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), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/intake/runner/describe/describe.go b/internal/cmd/beta/intake/runner/describe/describe.go new file mode 100644 index 000000000..53f738338 --- /dev/null +++ b/internal/cmd/beta/intake/runner/describe/describe.go @@ -0,0 +1,137 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + runnerIdArg = "RUNNER_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + RunnerId string +} + +func NewDescribeCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", runnerIdArg), + Short: "Shows details of an Intake Runner", + Long: "Shows details of an Intake Runner.", + Args: args.SingleArg(runnerIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an Intake Runner with ID "xxx"`, + `$ stackit beta intake runner describe xxx`), + examples.NewExample( + `Get details of an Intake Runner with ID "xxx" in JSON format`, + `$ stackit beta intake runner 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 to get a single runner + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Intake Runner: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + runnerId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + RunnerId: runnerId, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to get a single Intake Runner +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiGetIntakeRunnerRequest { + req := apiClient.GetIntakeRunner(ctx, model.ProjectId, model.Region, model.RunnerId) + return req +} + +// outputResult formats the API response and prints it to the console +func outputResult(p *print.Printer, outputFormat string, runner *intake.IntakeRunnerResponse) error { + if runner == nil { + return fmt.Errorf("received nil runner, could not display details") + } + + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(runner, "", " ") + if err != nil { + return fmt.Errorf("marshal Intake Runner: %w", err) + } + p.Outputln(string(details)) + return nil + + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(runner, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal Intake Runner: %w", err) + } + p.Outputln(string(details)) + return nil + + default: + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + table.AddRow("ID", runner.GetId()) + table.AddRow("Name", runner.GetDisplayName()) + table.AddRow("State", runner.GetState()) + table.AddRow("Created", runner.GetCreateTime()) + table.AddRow("Labels", runner.GetLabels()) + table.AddRow("Description", runner.GetDescription()) + table.AddRow("Max Message Size (KiB)", runner.GetMaxMessageSizeKiB()) + table.AddRow("Max Messages/Hour", runner.GetMaxMessagesPerHour()) + table.AddRow("Ingestion URI", runner.GetUri()) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + } +} diff --git a/internal/cmd/beta/intake/runner/describe/describe_test.go b/internal/cmd/beta/intake/runner/describe/describe_test.go new file mode 100644 index 000000000..9439fa385 --- /dev/null +++ b/internal/cmd/beta/intake/runner/describe/describe_test.go @@ -0,0 +1,238 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testRunnerId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRunnerId, + } + 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, + }, + RunnerId: testRunnerId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiGetIntakeRunnerRequest)) intake.ApiGetIntakeRunnerRequest { + request := testClient.GetIntakeRunner(testCtx, testProjectId, testRegion, testRunnerId) + 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: "runner id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewDescribeCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiGetIntakeRunnerRequest + }{ + { + 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), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + runner *intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", runner: &intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, runner: &intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, runner: &intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "nil runner", + args: args{runner: nil}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewDescribeCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.runner); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/runner/list/list.go b/internal/cmd/beta/intake/runner/list/list.go new file mode 100644 index 000000000..dfb35dfbd --- /dev/null +++ b/internal/cmd/beta/intake/runner/list/list.go @@ -0,0 +1,171 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +const ( + limitFlag = "limit" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +// NewListCmd creates a new cobra command for listing Intake Runners +func NewListCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Intake Runners", + Long: "Lists all Intake Runners for the current project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Intake Runners`, + `$ stackit beta intake runner list`), + examples.NewExample( + `List all Intake Runners in JSON format`, + `$ stackit beta intake runner list --output-format json`), + examples.NewExample( + `List up to 5 Intake Runners`, + `$ stackit beta intake runner list --limit 5`), + ), + 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 + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Intake Runners: %w", err) + } + runners := resp.GetIntakeRunners() + + // Truncate output + if model.Limit != nil && len(runners) > int(*model.Limit) { + runners = runners[:*model.Limit] + } + + projectLabel := model.ProjectId + if len(runners) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(p.Printer, model.OutputFormat, projectLabel, runners) + }, + } + configureFlags(cmd) + return cmd +} + +// configureFlags adds the --limit flag to the command +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +// parseInput parses the command flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to list Intake Runners +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiListIntakeRunnersRequest { + req := apiClient.ListIntakeRunners(ctx, model.ProjectId, model.Region) + // Note: we do support API pagination, but for consistency with other services, we fetch all items and apply + // client-side limit. + // A more advanced implementation could use the --limit flag to set the API's PageSize. + return req +} + +// outputResult formats the API response and prints it to the console +func outputResult(p *print.Printer, outputFormat, projectLabel string, runners []intake.IntakeRunnerResponse) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(runners, "", " ") + if err != nil { + return fmt.Errorf("marshal Intake Runner list: %w", err) + } + p.Outputln(string(details)) + return nil + + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(runners, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal Intake Runner list: %w", err) + } + p.Outputln(string(details)) + return nil + + default: + if len(runners) == 0 { + p.Outputf("No intake runners found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + + table.SetHeader("ID", "NAME", "STATE") + for _, runner := range runners { + table.AddRow( + runner.GetId(), + runner.GetDisplayName(), + runner.GetState(), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + } +} diff --git a/internal/cmd/beta/intake/runner/list/list_test.go b/internal/cmd/beta/intake/runner/list/list_test.go new file mode 100644 index 000000000..ac47cbe45 --- /dev/null +++ b/internal/cmd/beta/intake/runner/list/list_test.go @@ -0,0 +1,231 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testLimit = int64(5) +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() +) + +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, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiListIntakeRunnersRequest)) intake.ApiListIntakeRunnersRequest { + request := testClient.ListIntakeRunners(testCtx, testProjectId, testRegion) + 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: "with limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = strconv.FormatInt(testLimit, 10) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(testLimit) + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "limit is zero", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "limit is negative", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewListCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiListIntakeRunnersRequest + }{ + { + 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), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + runners []intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", runners: []intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, runners: []intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "empty slice", + args: args{runners: []intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "nil slice", + args: args{runners: nil}, + wantErr: false, + }, + { + name: "empty intake runner in slice", + args: args{ + runners: []intake.IntakeRunnerResponse{{}}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewListCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, "dummy-projectlabel", tt.args.runners); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/runner/runner.go b/internal/cmd/beta/intake/runner/runner.go new file mode 100644 index 000000000..42e3bae4b --- /dev/null +++ b/internal/cmd/beta/intake/runner/runner.go @@ -0,0 +1,31 @@ +package runner + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/create" + delete2 "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/update" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "runner", + Short: "Provides functionality for Intake Runners", + Long: "Provides functionality for Intake Runners.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + // Pass the params down to each action command + cmd.AddCommand(create.NewCreateCmd(params)) + cmd.AddCommand(delete2.NewDeleteCmd(params)) + cmd.AddCommand(describe.NewDescribeCmd(params)) + cmd.AddCommand(list.NewListCmd(params)) + cmd.AddCommand(update.NewUpdateCmd(params)) + + return cmd +} diff --git a/internal/cmd/beta/intake/runner/update/update.go b/internal/cmd/beta/intake/runner/update/update.go new file mode 100644 index 000000000..86cec311a --- /dev/null +++ b/internal/cmd/beta/intake/runner/update/update.go @@ -0,0 +1,190 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/wait" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +const ( + runnerIdArg = "RUNNER_ID" +) + +const ( + displayNameFlag = "display-name" + maxMessageSizeKiBFlag = "max-message-size-kib" + maxMessagesPerHourFlag = "max-messages-per-hour" + descriptionFlag = "description" + labelFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + RunnerId string + DisplayName *string + MaxMessageSizeKiB *int64 + MaxMessagesPerHour *int64 + Description *string + Labels *map[string]string +} + +func NewUpdateCmd(p *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", runnerIdArg), + Short: "Updates an Intake Runner", + Long: "Updates an Intake Runner. Only the specified fields are updated.", + Args: args.SingleArg(runnerIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of an Intake Runner with ID "xxx"`, + `$ stackit beta intake runner update xxx --display-name "new-runner-name"`), + examples.NewExample( + `Update the message capacity limits for an Intake Runner with ID "xxx"`, + `$ stackit beta intake runner update xxx --max-message-size-kib 1000 --max-messages-per-hour 10000`), + ), + 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 + } + + 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 + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update Intake Runner: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p.Printer) + s.Start("Updating STACKIT Intake Runner instance") + _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.RunnerId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance creation: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Int64(maxMessageSizeKiBFlag, 0, "Maximum message size in KiB. Note: Overall message capacity cannot be decreased.") + cmd.Flags().Int64(maxMessagesPerHourFlag, 0, "Maximum number of messages per hour. Note: Overall message capacity cannot be decreased.") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelFlag, nil, `Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2".`) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + runnerId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + RunnerId: runnerId, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + MaxMessageSizeKiB: flags.FlagToInt64Pointer(p, cmd, maxMessageSizeKiBFlag), + MaxMessagesPerHour: flags.FlagToInt64Pointer(p, cmd, maxMessagesPerHourFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + } + + if model.DisplayName == nil && model.MaxMessageSizeKiB == nil && model.MaxMessagesPerHour == nil && model.Description == nil && model.Labels == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiUpdateIntakeRunnerRequest { + req := apiClient.UpdateIntakeRunner(ctx, model.ProjectId, model.Region, model.RunnerId) + + payload := intake.UpdateIntakeRunnerPayload{} + if model.DisplayName != nil { + payload.DisplayName = model.DisplayName + } + if model.MaxMessageSizeKiB != nil { + payload.MaxMessageSizeKiB = model.MaxMessageSizeKiB + } + if model.MaxMessagesPerHour != nil { + payload.MaxMessagesPerHour = model.MaxMessagesPerHour + } + if model.Description != nil { + payload.Description = model.Description + } + if model.Labels != nil { + payload.Labels = model.Labels + } + + req = req.UpdateIntakeRunnerPayload(payload) + return req +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *intake.IntakeRunnerResponse) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal instance: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal instance: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + if resp == nil { + p.Outputf("Updated Intake Runner for project %q, but no runner ID was returned.\n", projectLabel) + return nil + } + p.Outputf("Updated Intake Runner for project %q. Runner ID: %s\n", projectLabel, utils.PtrString(resp.Id)) + return nil + } +} diff --git a/internal/cmd/beta/intake/runner/update/update_test.go b/internal/cmd/beta/intake/runner/update/update_test.go new file mode 100644 index 000000000..c8e0a071d --- /dev/null +++ b/internal/cmd/beta/intake/runner/update/update_test.go @@ -0,0 +1,301 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testRunnerId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRunnerId, + } + 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, + displayNameFlag: "new-runner-name", + } + 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, + }, + RunnerId: testRunnerId, + DisplayName: utils.Ptr("new-runner-name"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiUpdateIntakeRunnerRequest)) intake.ApiUpdateIntakeRunnerRequest { + request := testClient.UpdateIntakeRunner(testCtx, testProjectId, testRegion, testRunnerId) + payload := intake.UpdateIntakeRunnerPayload{ + DisplayName: utils.Ptr("new-runner-name"), + } + request = request.UpdateIntakeRunnerPayload(payload) + 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 update flags provided", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: false, + }, + { + description: "update all fields", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[maxMessageSizeKiBFlag] = "2048" + flagValues[maxMessagesPerHourFlag] = "10000" + flagValues[descriptionFlag] = "new description" + flagValues[labelFlag] = "env=prod,team=sre" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.MaxMessageSizeKiB = utils.Ptr(int64(2048)) + model.MaxMessagesPerHour = utils.Ptr(int64(10000)) + model.Description = utils.Ptr("new description") + model.Labels = utils.Ptr(map[string]string{"env": "prod", "team": "sre"}) + }), + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewUpdateCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiUpdateIntakeRunnerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "update description and labels", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + model.Description = utils.Ptr("new-desc") + model.Labels = utils.Ptr(map[string]string{"key": "value"}) + }), + expectedRequest: fixtureRequest(func(request *intake.ApiUpdateIntakeRunnerRequest) { + payload := intake.UpdateIntakeRunnerPayload{ + Description: utils.Ptr("new-desc"), + Labels: utils.Ptr(map[string]string{"key": "value"}), + } + *request = (*request).UpdateIntakeRunnerPayload(payload) + }), + }, + { + description: "update all fields", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = utils.Ptr("another-name") + model.MaxMessageSizeKiB = utils.Ptr(int64(4096)) + model.MaxMessagesPerHour = utils.Ptr(int64(20000)) + model.Description = utils.Ptr("final-desc") + model.Labels = utils.Ptr(map[string]string{"a": "b"}) + }), + expectedRequest: fixtureRequest(func(request *intake.ApiUpdateIntakeRunnerRequest) { + payload := intake.UpdateIntakeRunnerPayload{ + DisplayName: utils.Ptr("another-name"), + MaxMessageSizeKiB: utils.Ptr(int64(4096)), + MaxMessagesPerHour: utils.Ptr(int64(20000)), + Description: utils.Ptr("final-desc"), + Labels: utils.Ptr(map[string]string{"a": "b"}), + } + *request = (*request).UpdateIntakeRunnerPayload(payload) + }), + }, + } + + 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), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + runnerId string + resp *intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", projectLabel: "my-project", runnerId: "runner-id-123", resp: &intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}}, + wantErr: false, + }, + { + name: "nil response", + args: args{outputFormat: print.JSONOutputFormat, resp: nil}, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{outputFormat: "default", resp: nil}, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewUpdateCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, 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/intake/wait/wait.go b/internal/cmd/beta/intake/wait/wait.go new file mode 100644 index 000000000..1d76c3cd9 --- /dev/null +++ b/internal/cmd/beta/intake/wait/wait.go @@ -0,0 +1,162 @@ +package wait + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/core/wait" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +type APIClientInterface interface { + GetIntakeRunnerExecute(ctx context.Context, projectId, region, intakeRunnerId string) (*intake.IntakeRunnerResponse, error) + GetIntakeExecute(ctx context.Context, projectId, region, intakeId string) (*intake.IntakeResponse, error) + GetIntakeUserExecute(ctx context.Context, projectId, region, intakeId, intakeUserId string) (*intake.IntakeUserResponse, error) +} + +func CreateOrUpdateIntakeRunnerWaitHandler(ctx context.Context, a APIClientInterface, projectId, region, intakeRunnerId string) *wait.AsyncActionHandler[intake.IntakeRunnerResponse] { + handler := wait.New(func() (waitFinished bool, response *intake.IntakeRunnerResponse, err error) { + runner, err := a.GetIntakeRunnerExecute(ctx, projectId, region, intakeRunnerId) + if err != nil { + return false, nil, err + } + + if runner == nil { + return false, nil, fmt.Errorf("API returned a nil response for Intake Runner %s", intakeRunnerId) + } + + if runner.Id == nil || runner.State == nil { + return false, nil, fmt.Errorf("could not get ID or State from response for Intake Runner %s", intakeRunnerId) + } + + if *runner.Id == intakeRunnerId && *runner.State == intake.INTAKERUNNERRESPONSESTATE_ACTIVE { + return true, runner, nil + } + + // The API does not have a dedicated failure state for this resource, + // so we rely on the timeout for cases where it never becomes active. + return false, nil, nil + }) + handler.SetTimeout(15 * time.Minute) + return handler +} + +func DeleteIntakeRunnerWaitHandler(ctx context.Context, a APIClientInterface, projectId, region, intakeRunnerId string) *wait.AsyncActionHandler[intake.IntakeRunnerResponse] { + handler := wait.New(func() (waitFinished bool, response *intake.IntakeRunnerResponse, err error) { + _, err = a.GetIntakeRunnerExecute(ctx, projectId, region, intakeRunnerId) + if err == nil { + // Resource still exists + return false, nil, nil + } + + var oapiError *oapierror.GenericOpenAPIError + if errors.As(err, &oapiError) { + if oapiError.StatusCode == http.StatusNotFound { + // Success: Resource is gone + return true, nil, nil + } + } + // An unexpected error occurred + return false, nil, err + }) + handler.SetTimeout(15 * time.Minute) + return handler +} + +func CreateOrUpdateIntakeWaitHandler(ctx context.Context, a APIClientInterface, projectId, region, intakeId string) *wait.AsyncActionHandler[intake.IntakeResponse] { + handler := wait.New(func() (waitFinished bool, response *intake.IntakeResponse, err error) { + ik, err := a.GetIntakeExecute(ctx, projectId, region, intakeId) + if err != nil { + return false, nil, err + } + + if ik == nil { + return false, nil, fmt.Errorf("API returned a nil response for Intake %s", intakeId) + } + + if ik.Id == nil || ik.State == nil { + return false, nil, fmt.Errorf("could not get ID or State from response for Intake %s", intakeId) + } + + if *ik.Id == intakeId && *ik.State == intake.INTAKERESPONSESTATE_ACTIVE { + return true, ik, nil + } + + if *ik.Id == intakeId && *ik.State == intake.INTAKERESPONSESTATE_FAILED { + return true, ik, fmt.Errorf("create/update failed for Intake %s", intakeId) + } + + return false, nil, nil + }) + handler.SetTimeout(10 * time.Minute) + return handler +} + +func DeleteIntakeWaitHandler(ctx context.Context, a APIClientInterface, projectId, region, intakeId string) *wait.AsyncActionHandler[intake.IntakeResponse] { + handler := wait.New(func() (waitFinished bool, response *intake.IntakeResponse, err error) { + _, err = a.GetIntakeExecute(ctx, projectId, region, intakeId) + if err == nil { + return false, nil, nil + } + + var oapiError *oapierror.GenericOpenAPIError + if errors.As(err, &oapiError) { + if oapiError.StatusCode == http.StatusNotFound { + return true, nil, nil + } + } + return false, nil, err + }) + handler.SetTimeout(10 * time.Minute) + return handler +} + +func CreateOrUpdateIntakeUserWaitHandler(ctx context.Context, a APIClientInterface, projectId, region, intakeId, intakeUserId string) *wait.AsyncActionHandler[intake.IntakeUserResponse] { + handler := wait.New(func() (waitFinished bool, response *intake.IntakeUserResponse, err error) { + user, err := a.GetIntakeUserExecute(ctx, projectId, region, intakeId, intakeUserId) + if err != nil { + return false, nil, err + } + + if user == nil { + return false, nil, fmt.Errorf("API returned a nil response for Intake User %s", intakeUserId) + } + + if user.Id == nil || user.State == nil { + return false, nil, fmt.Errorf("could not get ID or State from response for Intake User %s", intakeUserId) + } + + if *user.Id == intakeUserId && *user.State == intake.INTAKEUSERRESPONSESTATE_ACTIVE { + return true, user, nil + } + + // The API does not have a dedicated failure state for this resource, we rely on the timeout for cases where + // it never becomes active. + return false, nil, nil + }) + handler.SetTimeout(5 * time.Minute) + return handler +} + +func DeleteIntakeUserWaitHandler(ctx context.Context, a APIClientInterface, projectId, region, intakeId, intakeUserId string) *wait.AsyncActionHandler[intake.IntakeUserResponse] { + handler := wait.New(func() (waitFinished bool, response *intake.IntakeUserResponse, err error) { + _, err = a.GetIntakeUserExecute(ctx, projectId, region, intakeId, intakeUserId) + if err == nil { + return false, nil, nil + } + + var oapiError *oapierror.GenericOpenAPIError + if errors.As(err, &oapiError) { + if oapiError.StatusCode == http.StatusNotFound { + return true, nil, nil + } + } + return false, nil, err + }) + handler.SetTimeout(5 * time.Minute) + return handler +} diff --git a/internal/cmd/beta/intake/wait/wait_test.go b/internal/cmd/beta/intake/wait/wait_test.go new file mode 100644 index 000000000..ef1b677b5 --- /dev/null +++ b/internal/cmd/beta/intake/wait/wait_test.go @@ -0,0 +1,515 @@ +package wait + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +// apiClientMocked is a mock of the API client +type apiClientMocked struct { + getRunnerFails bool + getIntakeFails bool + getUserFails bool + getErrorCode int + returnRunner bool + returnIntake bool + returnUser bool + intakeRunnerResponse *intake.IntakeRunnerResponse + intakeResponse *intake.IntakeResponse + intakeUserResponse *intake.IntakeUserResponse +} + +func (a *apiClientMocked) GetIntakeRunnerExecute(_ context.Context, _, _, _ string) (*intake.IntakeRunnerResponse, error) { + if a.getRunnerFails { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: a.getErrorCode, + } + } + if !a.returnRunner { + return nil, nil + } + return a.intakeRunnerResponse, nil +} + +func (a *apiClientMocked) GetIntakeExecute(_ context.Context, _, _, _ string) (*intake.IntakeResponse, error) { + if a.getIntakeFails { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: a.getErrorCode, + } + } + if !a.returnIntake { + return nil, nil + } + return a.intakeResponse, nil +} + +func (a *apiClientMocked) GetIntakeUserExecute(_ context.Context, _, _, _, _ string) (*intake.IntakeUserResponse, error) { + if a.getUserFails { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: a.getErrorCode, + } + } + if !a.returnUser { + return nil, nil + } + return a.intakeUserResponse, nil +} + +var ( + PROJECT_ID = uuid.NewString() + REGION = "eu01" + INTAKE_RUNNER_ID = uuid.NewString() + INTAKE_ID = uuid.NewString() + INTAKE_USER_ID = uuid.NewString() +) + +func TestCreateOrUpdateIntakeRunnerWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + getErrorCode int + wantErr bool + wantResp bool + returnRunner bool + intakeRunnerResponse *intake.IntakeRunnerResponse + }{ + { + desc: "succeeded", + getFails: false, + wantErr: false, + wantResp: true, + returnRunner: true, + intakeRunnerResponse: &intake.IntakeRunnerResponse{ + Id: utils.Ptr(INTAKE_RUNNER_ID), + State: utils.Ptr(intake.INTAKERUNNERRESPONSESTATE_ACTIVE), + }, + }, + { + desc: "get fails", + getFails: true, + getErrorCode: http.StatusInternalServerError, + wantErr: true, + wantResp: false, + returnRunner: false, + }, + { + desc: "timeout", + getFails: false, + wantErr: true, + wantResp: false, + returnRunner: true, + intakeRunnerResponse: &intake.IntakeRunnerResponse{ + Id: utils.Ptr(INTAKE_RUNNER_ID), + State: utils.Ptr(intake.INTAKERUNNERRESPONSESTATE_RECONCILING), + }, + }, + { + desc: "nil response", + getFails: false, + wantErr: true, + wantResp: false, + returnRunner: false, + }, + { + desc: "nil id in response", + getFails: false, + wantErr: true, + wantResp: false, + returnRunner: true, + intakeRunnerResponse: &intake.IntakeRunnerResponse{ + State: utils.Ptr(intake.INTAKERUNNERRESPONSESTATE_RECONCILING), + }, + }, + { + desc: "nil state in response", + getFails: false, + wantErr: true, + wantResp: false, + returnRunner: true, + intakeRunnerResponse: &intake.IntakeRunnerResponse{ + Id: utils.Ptr(INTAKE_RUNNER_ID), + }, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + apiClient := &apiClientMocked{ + getRunnerFails: tt.getFails, + getErrorCode: tt.getErrorCode, + returnRunner: tt.returnRunner, + intakeRunnerResponse: tt.intakeRunnerResponse, + } + + var wantResp *intake.IntakeRunnerResponse + if tt.wantResp { + wantResp = tt.intakeRunnerResponse + } + + handler := CreateOrUpdateIntakeRunnerWaitHandler(context.Background(), apiClient, PROJECT_ID, REGION, INTAKE_RUNNER_ID) + got, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + if !cmp.Equal(got, wantResp) { + t.Fatalf("handler got = %v, want %v", got, wantResp) + } + }) + } +} + +func TestDeleteIntakeRunnerWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + getErrorCode int + wantErr bool + returnRunner bool + }{ + { + desc: "succeeded", + getFails: true, + getErrorCode: http.StatusNotFound, + wantErr: false, + returnRunner: false, + }, + { + desc: "get fails", + getFails: true, + getErrorCode: http.StatusInternalServerError, + wantErr: true, + returnRunner: false, + }, + { + desc: "timeout", + getFails: false, + wantErr: true, + returnRunner: true, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + apiClient := &apiClientMocked{ + getRunnerFails: tt.getFails, + getErrorCode: tt.getErrorCode, + returnRunner: tt.returnRunner, + intakeRunnerResponse: &intake.IntakeRunnerResponse{ // This is only used in the timeout case + Id: utils.Ptr(INTAKE_RUNNER_ID), + }, + } + handler := DeleteIntakeRunnerWaitHandler(context.Background(), apiClient, PROJECT_ID, REGION, INTAKE_RUNNER_ID) + _, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestCreateOrUpdateIntakeWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + getErrorCode int + wantErr bool + wantResp bool + returnIntake bool + intakeResponse *intake.IntakeResponse + }{ + { + desc: "succeeded", + getFails: false, + wantErr: false, + wantResp: true, + returnIntake: true, + intakeResponse: &intake.IntakeResponse{ + Id: utils.Ptr(INTAKE_ID), + State: utils.Ptr(intake.INTAKERESPONSESTATE_ACTIVE), + }, + }, + { + desc: "failed state", + getFails: false, + wantErr: true, + wantResp: true, + returnIntake: true, + intakeResponse: &intake.IntakeResponse{ + Id: utils.Ptr(INTAKE_ID), + State: utils.Ptr(intake.INTAKERESPONSESTATE_FAILED), + }, + }, + { + desc: "get fails", + getFails: true, + getErrorCode: http.StatusInternalServerError, + wantErr: true, + wantResp: false, + returnIntake: false, + }, + { + desc: "timeout", + getFails: false, + wantErr: true, + wantResp: false, + returnIntake: true, + intakeResponse: &intake.IntakeResponse{ + Id: utils.Ptr(INTAKE_ID), + State: utils.Ptr(intake.INTAKERESPONSESTATE_RECONCILING), + }, + }, + { + desc: "nil response", + getFails: false, + wantErr: true, + wantResp: false, + returnIntake: false, + }, + { + desc: "nil id in response", + getFails: false, + wantErr: true, + wantResp: false, + returnIntake: true, + intakeResponse: &intake.IntakeResponse{ + State: utils.Ptr(intake.INTAKERESPONSESTATE_RECONCILING), + }, + }, + { + desc: "nil state in response", + getFails: false, + wantErr: true, + wantResp: false, + returnIntake: true, + intakeResponse: &intake.IntakeResponse{ + Id: utils.Ptr(INTAKE_ID), + }, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + apiClient := &apiClientMocked{ + getIntakeFails: tt.getFails, + getErrorCode: tt.getErrorCode, + returnIntake: tt.returnIntake, + intakeResponse: tt.intakeResponse, + } + + var wantResp *intake.IntakeResponse + if tt.wantResp { + wantResp = tt.intakeResponse + } + + handler := CreateOrUpdateIntakeWaitHandler(context.Background(), apiClient, PROJECT_ID, REGION, INTAKE_ID) + got, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + if !cmp.Equal(got, wantResp) { + t.Fatalf("handler got = %v, want %v", got, wantResp) + } + }) + } +} + +func TestDeleteIntakeWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + getErrorCode int + wantErr bool + returnIntake bool + }{ + { + desc: "succeeded", + getFails: true, + getErrorCode: http.StatusNotFound, + wantErr: false, + returnIntake: false, + }, + { + desc: "get fails", + getFails: true, + getErrorCode: http.StatusInternalServerError, + wantErr: true, + returnIntake: false, + }, + { + desc: "timeout", + getFails: false, + wantErr: true, + returnIntake: true, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + apiClient := &apiClientMocked{ + getIntakeFails: tt.getFails, + getErrorCode: tt.getErrorCode, + returnIntake: tt.returnIntake, + intakeResponse: &intake.IntakeResponse{ + Id: utils.Ptr(INTAKE_ID), + }, + } + handler := DeleteIntakeWaitHandler(context.Background(), apiClient, PROJECT_ID, REGION, INTAKE_ID) + _, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestCreateOrUpdateIntakeUserWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + getErrorCode int + wantErr bool + wantResp bool + returnUser bool + intakeUserResponse *intake.IntakeUserResponse + }{ + { + desc: "succeeded", + getFails: false, + wantErr: false, + wantResp: true, + returnUser: true, + intakeUserResponse: &intake.IntakeUserResponse{ + Id: utils.Ptr(INTAKE_USER_ID), + State: utils.Ptr(intake.INTAKEUSERRESPONSESTATE_ACTIVE), + }, + }, + { + desc: "get fails", + getFails: true, + getErrorCode: http.StatusInternalServerError, + wantErr: true, + wantResp: false, + returnUser: false, + }, + { + desc: "timeout", + getFails: false, + wantErr: true, + wantResp: false, + returnUser: true, + intakeUserResponse: &intake.IntakeUserResponse{ + Id: utils.Ptr(INTAKE_USER_ID), + State: utils.Ptr(intake.INTAKEUSERRESPONSESTATE_RECONCILING), + }, + }, + { + desc: "nil response", + getFails: false, + wantErr: true, + wantResp: false, + returnUser: false, + }, + { + desc: "nil id in response", + getFails: false, + wantErr: true, + wantResp: false, + returnUser: true, + intakeUserResponse: &intake.IntakeUserResponse{ + State: utils.Ptr(intake.INTAKEUSERRESPONSESTATE_RECONCILING), + }, + }, + { + desc: "nil state in response", + getFails: false, + wantErr: true, + wantResp: false, + returnUser: true, + intakeUserResponse: &intake.IntakeUserResponse{ + Id: utils.Ptr(INTAKE_USER_ID), + }, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + apiClient := &apiClientMocked{ + getUserFails: tt.getFails, + getErrorCode: tt.getErrorCode, + returnUser: tt.returnUser, + intakeUserResponse: tt.intakeUserResponse, + } + + var wantResp *intake.IntakeUserResponse + if tt.wantResp { + wantResp = tt.intakeUserResponse + } + + handler := CreateOrUpdateIntakeUserWaitHandler(context.Background(), apiClient, PROJECT_ID, REGION, INTAKE_ID, INTAKE_USER_ID) + got, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + if !cmp.Equal(got, wantResp) { + t.Fatalf("handler got = %v, want %v", got, wantResp) + } + }) + } +} + +func TestDeleteIntakeUserWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + getErrorCode int + wantErr bool + returnUser bool + }{ + { + desc: "succeeded", + getFails: true, + getErrorCode: http.StatusNotFound, + wantErr: false, + returnUser: false, + }, + { + desc: "get fails", + getFails: true, + getErrorCode: http.StatusInternalServerError, + wantErr: true, + returnUser: false, + }, + { + desc: "timeout", + getFails: false, + wantErr: true, + returnUser: true, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + apiClient := &apiClientMocked{ + getUserFails: tt.getFails, + getErrorCode: tt.getErrorCode, + returnUser: tt.returnUser, + intakeUserResponse: &intake.IntakeUserResponse{ + Id: utils.Ptr(INTAKE_USER_ID), + }, + } + handler := DeleteIntakeUserWaitHandler(context.Background(), apiClient, PROJECT_ID, REGION, INTAKE_ID, INTAKE_USER_ID) + _, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 841f8e66e..7b5fe0112 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -46,6 +46,7 @@ const ( sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint" iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" + intakeCustomEndpointFlag = "intake-custom-endpoint" ) type inputModel struct { @@ -159,6 +160,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(sqlServerFlexCustomEndpointFlag, "", "SQLServer Flex API base URL, used in calls to this API") cmd.Flags().String(iaasCustomEndpointFlag, "", "IaaS API base URL, used in calls to this API") cmd.Flags().String(tokenCustomEndpointFlag, "", "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().String(intakeCustomEndpointFlag, "", "Intake API base URL, used in calls to this API") err := viper.BindPFlag(config.SessionTimeLimitKey, cmd.Flags().Lookup(sessionTimeLimitFlag)) cobra.CheckErr(err) @@ -215,6 +217,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.TokenCustomEndpointKey, cmd.Flags().Lookup(tokenCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.IntakeCustomEndpointKey, cmd.Flags().Lookup(intakeCustomEndpointFlag)) + cobra.CheckErr(err) } func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index e7b6fd6fa..9a1460353 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -50,6 +50,7 @@ const ( sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint" iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" + intakeCustomEndpointFlag = "intake-custom-endpoint" ) type inputModel struct { @@ -87,6 +88,7 @@ type inputModel struct { SQLServerFlexCustomEndpoint bool IaaSCustomEndpoint bool TokenCustomEndpoint bool + IntakeCustomEndpoint bool } func NewCmd(params *params.CmdParams) *cobra.Command { @@ -207,6 +209,9 @@ func NewCmd(params *params.CmdParams) *cobra.Command { if model.TokenCustomEndpoint { viper.Set(config.TokenCustomEndpointKey, "") } + if model.IntakeCustomEndpoint { + viper.Set(config.IntakeCustomEndpointKey, "") + } err := config.Write() if err != nil { @@ -254,6 +259,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(sqlServerFlexCustomEndpointFlag, false, "SQLServer Flex API base URL. If unset, uses the default base URL") cmd.Flags().Bool(iaasCustomEndpointFlag, false, "IaaS API base URL. If unset, uses the default base URL") 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") } func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { @@ -292,6 +298,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { SQLServerFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, sqlServerFlexCustomEndpointFlag), IaaSCustomEndpoint: flags.FlagToBoolValue(p, cmd, iaasCustomEndpointFlag), TokenCustomEndpoint: flags.FlagToBoolValue(p, cmd, tokenCustomEndpointFlag), + IntakeCustomEndpoint: flags.FlagToBoolValue(p, cmd, intakeCustomEndpointFlag), } p.DebugInputModel(model) diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 246696f15..af15ebd32 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -43,6 +43,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool sqlServerFlexCustomEndpointFlag: true, iaasCustomEndpointFlag: true, tokenCustomEndpointFlag: true, + intakeCustomEndpointFlag: true, } for _, mod := range mods { mod(flagValues) @@ -82,6 +83,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { SQLServerFlexCustomEndpoint: true, IaaSCustomEndpoint: true, TokenCustomEndpoint: true, + IntakeCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -137,6 +139,7 @@ func TestParseInput(t *testing.T) { model.SQLServerFlexCustomEndpoint = false model.IaaSCustomEndpoint = false model.TokenCustomEndpoint = false + model.IntakeCustomEndpoint = false }), }, { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 957d7c475..d5e1b38dd 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -46,6 +46,7 @@ const ( IaaSCustomEndpointKey = "iaas_custom_endpoint" TokenCustomEndpointKey = "token_custom_endpoint" GitCustomEndpointKey = "git_custom_endpoint" + IntakeCustomEndpointKey = "intake_custom_endpoint" ProjectNameKey = "project_name" DefaultProfileName = "default" @@ -105,6 +106,7 @@ var ConfigKeys = []string{ IaaSCustomEndpointKey, TokenCustomEndpointKey, GitCustomEndpointKey, + IntakeCustomEndpointKey, } var defaultConfigFolderPath string @@ -190,6 +192,7 @@ func setConfigDefaults() { viper.SetDefault(IaaSCustomEndpointKey, "") viper.SetDefault(TokenCustomEndpointKey, "") viper.SetDefault(GitCustomEndpointKey, "") + viper.SetDefault(IntakeCustomEndpointKey, "") } func getConfigFilePath(configFolder string) string { diff --git a/internal/pkg/services/intake/client/client.go b/internal/pkg/services/intake/client/client.go new file mode 100644 index 000000000..750f01b7f --- /dev/null +++ b/internal/pkg/services/intake/client/client.go @@ -0,0 +1,48 @@ +package client + +import ( + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/intake" +) + +// ConfigureClient creates and configures a new Intake API client +func ConfigureClient(p *print.Printer, cliVersion string) (*intake.APIClient, error) { + authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) + if err != nil { + p.Debug(print.ErrorLevel, "configure authentication: %v", err) + return nil, &errors.AuthError{} + } + + region := viper.GetString(config.RegionKey) + cfgOptions := []sdkConfig.ConfigurationOption{ + utils.UserAgentConfigOption(cliVersion), + sdkConfig.WithRegion(region), + authCfgOption, + } + + customEndpoint := viper.GetString(config.IntakeCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + + apiClient, err := intake.NewAPIClient(cfgOptions...) + if err != nil { + p.Debug(print.ErrorLevel, "create new API client: %v", err) + return nil, &errors.AuthError{} + } + + return apiClient, nil +}