diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 23097f8ca..c099db5b3 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -42,6 +42,7 @@ 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 kms](./stackit_beta_kms.md) - Provides functionality for KMS * [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 9b07e46b5..1b4549617 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 --kms-custom-endpoint string KMS 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 diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 9d4c83088..cfe34ab0b 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 --kms-custom-endpoint KMS 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 diff --git a/go.mod b/go.mod index aff55ecea..36c488755 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.1 github.com/stackitcloud/stackit-sdk-go/services/git v0.9.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.2.0 + github.com/stackitcloud/stackit-sdk-go/services/intake v0.3.0 github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.2 github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.1 github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.2.1 diff --git a/go.sum b/go.sum index ce063455b..edd2192ab 100644 --- a/go.sum +++ b/go.sum @@ -573,6 +573,8 @@ github.com/stackitcloud/stackit-sdk-go/services/git v0.9.0 h1:zuoJnsLnjxdQcQbs7g github.com/stackitcloud/stackit-sdk-go/services/git v0.9.0/go.mod h1:AXFfYBJZIW1o0W0zZEb/proQMhMsb3Nn5E1htS8NDPE= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.2.0 h1:bkvq3Y4OcGyCR5N69tyY7ZTcrVN+htGTa2ZBJL5BQTQ= github.com/stackitcloud/stackit-sdk-go/services/iaas v1.2.0/go.mod h1:tg1rHvgFRG9gNCYnTepZFaSCBahhLLjkIb3nRbtyjDQ= +github.com/stackitcloud/stackit-sdk-go/services/intake v0.3.0 h1:OdY3eXn98hWIhZBH8qQTFsYP6cag8B29UdwTjMwAjfw= +github.com/stackitcloud/stackit-sdk-go/services/intake v0.3.0/go.mod h1:mf+DQwwTVfc8MD0vwTNPzin2unKAIyQRYywv3wUyH38= github.com/stackitcloud/stackit-sdk-go/services/kms v1.1.0 h1:bgMV+hi/530htdSMAyRBrltR7Czinb56HQBT36wnVMU= github.com/stackitcloud/stackit-sdk-go/services/kms v1.1.0/go.mod h1:QswbIN9TUYELIwkjO+Bu8Nk/FGcd3kZYDlf2n0PdFAk= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.6.0 h1:q33ZaCBVEBUsnMDxYyuJKtJvGcE5nKgvuPed3s8zXNI= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index b026da770..a60570613 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/kms" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/cmd/params" @@ -39,5 +40,6 @@ 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)) cmd.AddCommand(kms.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..1a6ed58ec --- /dev/null +++ b/internal/cmd/beta/intake/runner/create/create.go @@ -0,0 +1,170 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/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 NewCmd(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") + _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, resp.GetId()).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake Runner creation: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model, 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, model *inputModel, projectLabel string, resp *intake.IntakeRunnerResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered creation of Intake Runner for project %q, but no runner ID was returned.\n", projectLabel) + return nil + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s Intake Runner for project %q. Runner ID: %s\n", operationState, 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..6ddcaa4a1 --- /dev/null +++ b/internal/cmd/beta/intake/runner/create/create_test.go @@ -0,0 +1,293 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "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/testutils" + "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 + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + 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) { + parseInputWrapper := func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + } + testutils.TestParseInput(t, NewCmd, parseInputWrapper, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +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 { + model *inputModel + projectLabel string + resp *intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + projectLabel: "my-project", + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "default output - async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + projectLabel: "my-project", + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: nil, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/runner/delete/delete.go b/internal/cmd/beta/intake/runner/delete/delete.go new file mode 100644 index 000000000..e74551a7d --- /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/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" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" +) + +const ( + runnerIdArg = "RUNNER_ID" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + RunnerId string +} + +// NewCmd creates a new cobra command for deleting an Intake Runner +func NewCmd(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") + _, err = wait.DeleteIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.RunnerId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake Runner deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Printer.Outputf("%s stackit Intake Runner %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..b99edac92 --- /dev/null +++ b/internal/cmd/beta/intake/runner/delete/delete_test.go @@ -0,0 +1,155 @@ +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/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest 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..b5ba12ae7 --- /dev/null +++ b/internal/cmd/beta/intake/runner/describe/describe.go @@ -0,0 +1,117 @@ +package describe + +import ( + "context" + "fmt" + + "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 NewCmd(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") + } + return p.OutputResult(outputFormat, runner, func() error { + 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..9235ac723 --- /dev/null +++ b/internal/cmd/beta/intake/runner/describe/describe_test.go @@ -0,0 +1,192 @@ +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-cli/internal/pkg/testutils" + "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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest 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 = NewCmd(¶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..d3df6751f --- /dev/null +++ b/internal/cmd/beta/intake/runner/list/list.go @@ -0,0 +1,152 @@ +package list + +import ( + "context" + "fmt" + + "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 +} + +// NewCmd creates a new cobra command for listing Intake Runners +func NewCmd(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 { + return p.OutputResult(outputFormat, runners, func() error { + 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..582adab2c --- /dev/null +++ b/internal/cmd/beta/intake/runner/list/list_test.go @@ -0,0 +1,197 @@ +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/spf13/cobra" + "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/testutils" + "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 + argValues []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) { + testutils.TestParseInput(t, NewCmd, func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +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 = NewCmd(¶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..0708b4ae2 --- /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" + "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.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(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..9ebb4a81e --- /dev/null +++ b/internal/cmd/beta/intake/runner/update/update.go @@ -0,0 +1,176 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "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" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" +) + +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 NewCmd(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") + _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.RunnerId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake Runner update: %w", err) + } + s.Stop() + } + + return outputResult(p.Printer, model, 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, model *inputModel, projectLabel string, resp *intake.IntakeRunnerResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered update of Intake Runner for project %q, but no runner ID was returned.\n", projectLabel) + return nil + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Outputf("%s Intake Runner for project %q. Runner ID: %s\n", operationState, 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..9b4ae7afa --- /dev/null +++ b/internal/cmd/beta/intake/runner/update/update_test.go @@ -0,0 +1,277 @@ +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/testutils" + "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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest 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 { + model *inputModel + projectLabel string + resp *intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + projectLabel: "my-project", + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "default output - async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + projectLabel: "my-project", + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: nil, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index e694e8901..7487f8ca3 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -47,6 +47,7 @@ const ( sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint" iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" + intakeCustomEndpointFlag = "intake-custom-endpoint" ) type inputModel struct { @@ -161,6 +162,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) @@ -219,6 +221,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, _ []string) (*inputModel, error) { diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 28ed935b9..359248096 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -51,6 +51,7 @@ const ( sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint" iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" + intakeCustomEndpointFlag = "intake-custom-endpoint" ) type inputModel struct { @@ -89,6 +90,7 @@ type inputModel struct { SQLServerFlexCustomEndpoint bool IaaSCustomEndpoint bool TokenCustomEndpoint bool + IntakeCustomEndpoint bool } func NewCmd(params *params.CmdParams) *cobra.Command { @@ -212,6 +214,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 { @@ -260,6 +265,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 { @@ -299,6 +305,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 e48dff384..12eb2424f 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -44,6 +44,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) @@ -84,6 +85,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { SQLServerFlexCustomEndpoint: true, IaaSCustomEndpoint: true, TokenCustomEndpoint: true, + IntakeCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -140,6 +142,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 9a03ac3b8..89cc4decb 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -48,6 +48,7 @@ const ( IaaSCustomEndpointKey = "iaas_custom_endpoint" TokenCustomEndpointKey = "token_custom_endpoint" GitCustomEndpointKey = "git_custom_endpoint" + IntakeCustomEndpointKey = "intake_custom_endpoint" ProjectNameKey = "project_name" DefaultProfileName = "default" @@ -108,6 +109,7 @@ var ConfigKeys = []string{ IaaSCustomEndpointKey, TokenCustomEndpointKey, GitCustomEndpointKey, + IntakeCustomEndpointKey, AlbCustomEndpoint, } @@ -195,6 +197,7 @@ func setConfigDefaults() { viper.SetDefault(IaaSCustomEndpointKey, "") viper.SetDefault(TokenCustomEndpointKey, "") viper.SetDefault(GitCustomEndpointKey, "") + viper.SetDefault(IntakeCustomEndpointKey, "") viper.SetDefault(AlbCustomEndpoint, "") } diff --git a/internal/pkg/services/intake/client/client.go b/internal/pkg/services/intake/client/client.go new file mode 100644 index 000000000..efb8d0cfd --- /dev/null +++ b/internal/pkg/services/intake/client/client.go @@ -0,0 +1,14 @@ +package client + +import ( + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "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) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.IntakeCustomEndpointKey), true, genericclient.CreateApiClient[*intake.APIClient](intake.NewAPIClient)) +}