diff --git a/docs/stackit.md b/docs/stackit.md index e00b8b8ac..7e04cd05a 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -59,5 +59,6 @@ stackit [flags] * [stackit server](./stackit_server.md) - Provides functionality for servers * [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts * [stackit ske](./stackit_ske.md) - Provides functionality for SKE +* [stackit valkey](./stackit_valkey.md) - Provides functionality for Valkey * [stackit volume](./stackit_volume.md) - Provides functionality for volumes diff --git a/docs/stackit_valkey.md b/docs/stackit_valkey.md new file mode 100644 index 000000000..77511dd42 --- /dev/null +++ b/docs/stackit_valkey.md @@ -0,0 +1,36 @@ +## stackit valkey + +Provides functionality for Valkey + +### Synopsis + +Provides functionality for Valkey. + +``` +stackit valkey [flags] +``` + +### Options + +``` + -h, --help Help for "stackit valkey" +``` + +### 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](./stackit.md) - Manage STACKIT resources using the command line +* [stackit valkey credentials](./stackit_valkey_credentials.md) - Provides functionality for Valkey credentials +* [stackit valkey instance](./stackit_valkey_instance.md) - Provides functionality for Valkey instances +* [stackit valkey plans](./stackit_valkey_plans.md) - Lists all Valkey service plans + diff --git a/docs/stackit_valkey_credentials.md b/docs/stackit_valkey_credentials.md new file mode 100644 index 000000000..ed791aeb7 --- /dev/null +++ b/docs/stackit_valkey_credentials.md @@ -0,0 +1,37 @@ +## stackit valkey credentials + +Provides functionality for Valkey credentials + +### Synopsis + +Provides functionality for Valkey credentials. + +``` +stackit valkey credentials [flags] +``` + +### Options + +``` + -h, --help Help for "stackit valkey credentials" +``` + +### 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 valkey](./stackit_valkey.md) - Provides functionality for Valkey +* [stackit valkey credentials create](./stackit_valkey_credentials_create.md) - Creates credentials for a Valkey instance +* [stackit valkey credentials delete](./stackit_valkey_credentials_delete.md) - Deletes credentials of a Valkey instance +* [stackit valkey credentials describe](./stackit_valkey_credentials_describe.md) - Shows details of credentials of a Valkey instance +* [stackit valkey credentials list](./stackit_valkey_credentials_list.md) - Lists all credentials' IDs for a Valkey instance + diff --git a/docs/stackit_valkey_credentials_create.md b/docs/stackit_valkey_credentials_create.md new file mode 100644 index 000000000..60e23c87d --- /dev/null +++ b/docs/stackit_valkey_credentials_create.md @@ -0,0 +1,45 @@ +## stackit valkey credentials create + +Creates credentials for a Valkey instance + +### Synopsis + +Creates credentials (username and password) for a Valkey instance. + +``` +stackit valkey credentials create [flags] +``` + +### Examples + +``` + Create credentials for a Valkey instance with ID "xxx" + $ stackit beta valkey credentials create --instance-id xxx + + Create credentials for a Valkey instance and show the password in the output + $ stackit beta valkey credentials create --instance-id xxx --show-password +``` + +### Options + +``` + -h, --help Help for "stackit valkey credentials create" + --instance-id string Instance ID + -s, --show-password Show password in output +``` + +### 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 valkey credentials](./stackit_valkey_credentials.md) - Provides functionality for Valkey credentials + diff --git a/docs/stackit_valkey_credentials_delete.md b/docs/stackit_valkey_credentials_delete.md new file mode 100644 index 000000000..d036811cb --- /dev/null +++ b/docs/stackit_valkey_credentials_delete.md @@ -0,0 +1,41 @@ +## stackit valkey credentials delete + +Deletes credentials of a Valkey instance + +### Synopsis + +Deletes credentials of a Valkey instance. + +``` +stackit valkey credentials delete CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Delete credentials with ID "xxx" of a Valkey instance with ID "yyy" + $ stackit beta valkey credentials delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit valkey credentials delete" + --instance-id string Instance ID +``` + +### 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 valkey credentials](./stackit_valkey_credentials.md) - Provides functionality for Valkey credentials + diff --git a/docs/stackit_valkey_credentials_describe.md b/docs/stackit_valkey_credentials_describe.md new file mode 100644 index 000000000..0b602098f --- /dev/null +++ b/docs/stackit_valkey_credentials_describe.md @@ -0,0 +1,44 @@ +## stackit valkey credentials describe + +Shows details of credentials of a Valkey instance + +### Synopsis + +Shows details of credentials of a Valkey instance. The password will be shown in plain text in the output. + +``` +stackit valkey credentials describe CREDENTIALS_ID [flags] +``` + +### Examples + +``` + Get details of credentials with ID "xxx" from a Valkey instance with ID "yyy" + $ stackit beta valkey credentials describe xxx --instance-id yyy + + Get details of credentials with ID "xxx" from a Valkey instance with ID "yyy" in JSON format + $ stackit beta valkey credentials describe xxx --instance-id yyy --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit valkey credentials describe" + --instance-id string Instance ID +``` + +### 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 valkey credentials](./stackit_valkey_credentials.md) - Provides functionality for Valkey credentials + diff --git a/docs/stackit_valkey_credentials_list.md b/docs/stackit_valkey_credentials_list.md new file mode 100644 index 000000000..fe685d1cc --- /dev/null +++ b/docs/stackit_valkey_credentials_list.md @@ -0,0 +1,48 @@ +## stackit valkey credentials list + +Lists all credentials' IDs for a Valkey instance + +### Synopsis + +Lists all credentials' IDs for a Valkey instance. + +``` +stackit valkey credentials list [flags] +``` + +### Examples + +``` + List all credentials' IDs for a Valkey instance with ID "xxx" + $ stackit beta valkey credentials list --instance-id xxx + + List all credentials' IDs for a Valkey instance with ID "xxx" in JSON format + $ stackit beta valkey credentials list --instance-id xxx --output-format json + + List up to 10 credentials' IDs for a Valkey instance with ID "xxx" + $ stackit beta valkey credentials list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit valkey credentials list" + --instance-id string Instance ID + --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 valkey credentials](./stackit_valkey_credentials.md) - Provides functionality for Valkey credentials + diff --git a/docs/stackit_valkey_instance.md b/docs/stackit_valkey_instance.md new file mode 100644 index 000000000..e5ca637a4 --- /dev/null +++ b/docs/stackit_valkey_instance.md @@ -0,0 +1,38 @@ +## stackit valkey instance + +Provides functionality for Valkey instances + +### Synopsis + +Provides functionality for Valkey instances. + +``` +stackit valkey instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit valkey instance" +``` + +### 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 valkey](./stackit_valkey.md) - Provides functionality for Valkey +* [stackit valkey instance create](./stackit_valkey_instance_create.md) - Creates a Valkey instance +* [stackit valkey instance delete](./stackit_valkey_instance_delete.md) - Deletes a Valkey instance +* [stackit valkey instance describe](./stackit_valkey_instance_describe.md) - Shows details of a Valkey instance +* [stackit valkey instance list](./stackit_valkey_instance_list.md) - Lists all Valkey instances +* [stackit valkey instance update](./stackit_valkey_instance_update.md) - Updates a Valkey instance + diff --git a/docs/stackit_valkey_instance_create.md b/docs/stackit_valkey_instance_create.md new file mode 100644 index 000000000..fad08cdff --- /dev/null +++ b/docs/stackit_valkey_instance_create.md @@ -0,0 +1,59 @@ +## stackit valkey instance create + +Creates a Valkey instance + +### Synopsis + +Creates a Valkey instance. + +``` +stackit valkey instance create [flags] +``` + +### Examples + +``` + Create a Valkey instance with name "my-instance" and specify plan by name and version + $ stackit beta valkey instance create --name my-instance --plan-name stackit-keyvalue-1.2.10-replica --version 8 + + Create a Valkey instance with name "my-instance" and specify plan by ID + $ stackit beta valkey instance create --name my-instance --plan-id xxx + + Create a Valkey instance with name "my-instance" and specify IP range which is allowed to access it + $ stackit beta valkey instance create --name my-instance --plan-id xxx --acl 1.2.3.0/24 +``` + +### Options + +``` + --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) + --enable-monitoring Enable monitoring + --graphite string Graphite host + -h, --help Help for "stackit valkey instance create" + --metrics-frequency int32 Metrics frequency in seconds + --metrics-prefix string Metrics prefix + --min-replicas-to-write int32 Minimum number of replicas that must acknowledge a write for it to be accepted + --monitoring-instance-id string Monitoring instance ID + -n, --name string Instance name + --plan-id string Plan ID + --plan-name string Plan name + --repl-backlog-size string Replication backlog size (e.g. "1mb") + --syslog strings Syslog + --version string Instance Valkey version +``` + +### 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 valkey instance](./stackit_valkey_instance.md) - Provides functionality for Valkey instances + diff --git a/docs/stackit_valkey_instance_delete.md b/docs/stackit_valkey_instance_delete.md new file mode 100644 index 000000000..ff2ff151b --- /dev/null +++ b/docs/stackit_valkey_instance_delete.md @@ -0,0 +1,40 @@ +## stackit valkey instance delete + +Deletes a Valkey instance + +### Synopsis + +Deletes a Valkey instance. + +``` +stackit valkey instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a Valkey instance with ID "xxx" + $ stackit beta valkey instance delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit valkey instance 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 valkey instance](./stackit_valkey_instance.md) - Provides functionality for Valkey instances + diff --git a/docs/stackit_valkey_instance_describe.md b/docs/stackit_valkey_instance_describe.md new file mode 100644 index 000000000..ed20c9877 --- /dev/null +++ b/docs/stackit_valkey_instance_describe.md @@ -0,0 +1,43 @@ +## stackit valkey instance describe + +Shows details of a Valkey instance + +### Synopsis + +Shows details of a Valkey instance. + +``` +stackit valkey instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of a Valkey instance with ID "xxx" + $ stackit beta valkey instance describe xxx + + Get details of a Valkey instance with ID "xxx" in JSON format + $ stackit beta valkey instance describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit valkey instance 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 valkey instance](./stackit_valkey_instance.md) - Provides functionality for Valkey instances + diff --git a/docs/stackit_valkey_instance_list.md b/docs/stackit_valkey_instance_list.md new file mode 100644 index 000000000..ef63197aa --- /dev/null +++ b/docs/stackit_valkey_instance_list.md @@ -0,0 +1,47 @@ +## stackit valkey instance list + +Lists all Valkey instances + +### Synopsis + +Lists all Valkey instances. + +``` +stackit valkey instance list [flags] +``` + +### Examples + +``` + List all Valkey instances + $ stackit beta valkey instance list + + List all Valkey instances in JSON format + $ stackit beta valkey instance list --output-format json + + List up to 10 Valkey instances + $ stackit beta valkey instance list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit valkey instance 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 valkey instance](./stackit_valkey_instance.md) - Provides functionality for Valkey instances + diff --git a/docs/stackit_valkey_instance_update.md b/docs/stackit_valkey_instance_update.md new file mode 100644 index 000000000..04c246363 --- /dev/null +++ b/docs/stackit_valkey_instance_update.md @@ -0,0 +1,58 @@ +## stackit valkey instance update + +Updates a Valkey instance + +### Synopsis + +Updates a Valkey instance. + +``` +stackit valkey instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the plan of a Valkey instance with ID "xxx" by plan ID + $ stackit beta valkey instance update xxx --plan-id yyy + + Update the plan of a Valkey instance with ID "xxx" by name and version + $ stackit beta valkey instance update xxx --plan-name stackit-keyvalue-1.2.10-replica --version 8 + + Update the range of IPs allowed to access a Valkey instance with ID "xxx" + $ stackit beta valkey instance update xxx --acl 1.2.3.0/24 +``` + +### Options + +``` + --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) + --enable-monitoring Enable monitoring + --graphite string Graphite host + -h, --help Help for "stackit valkey instance update" + --metrics-frequency int32 Metrics frequency in seconds + --metrics-prefix string Metrics prefix + --min-replicas-to-write int32 Minimum number of replicas that must acknowledge a write for it to be accepted + --monitoring-instance-id string Monitoring instance ID + --plan-id string Plan ID + --plan-name string Plan name + --repl-backlog-size string Replication backlog size (e.g. "1mb") + --syslog strings Syslog + --version string Instance Valkey version +``` + +### 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 valkey instance](./stackit_valkey_instance.md) - Provides functionality for Valkey instances + diff --git a/docs/stackit_valkey_plans.md b/docs/stackit_valkey_plans.md new file mode 100644 index 000000000..b3ed9dc0e --- /dev/null +++ b/docs/stackit_valkey_plans.md @@ -0,0 +1,47 @@ +## stackit valkey plans + +Lists all Valkey service plans + +### Synopsis + +Lists all Valkey service plans. + +``` +stackit valkey plans [flags] +``` + +### Examples + +``` + Lists all Valkey service plans + $ stackit valkey plans + + List all Valkey service plans in JSON format + $ stackit valkey plans --output-format json + + List up to 10 Valkey service plans + $ stackit valkey plans --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit valkey plans" + --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 valkey](./stackit_valkey.md) - Provides functionality for Valkey + diff --git a/go.mod b/go.mod index e1f0dc5bb..137c24141 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.0 github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.0 github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.16.1 + github.com/stackitcloud/stackit-sdk-go/services/valkey v0.2.0 github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0 github.com/zalando/go-keyring v0.2.8 golang.org/x/mod v0.38.0 diff --git a/go.sum b/go.sum index a619b552d..e4f7f9e0e 100644 --- a/go.sum +++ b/go.sum @@ -652,6 +652,8 @@ github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.0 h1:eu9PhRdL2GkXpHtf4 github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.0/go.mod h1:TbqmZhLMofmfl+HhVl6oHYcI3zvXTm1vRjN3A/fOkM4= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.16.1 h1:pjFVw2Tc0cSfAzmyZRiLknuKSADs7nPXNUyrM216CwQ= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.16.1/go.mod h1:AiUoMAqQcOlMgDtkVJlqI7P/VGD5xjN3dYjERGnwN/M= +github.com/stackitcloud/stackit-sdk-go/services/valkey v0.2.0 h1:RP9ITvjsPhA2+Xi9MhqYP9xkEsURyxYmRB3JKXIB5zc= +github.com/stackitcloud/stackit-sdk-go/services/valkey v0.2.0/go.mod h1:etzt/a723p327dqha1V72cCoBWjUBS2DtHy3ZTac0SA= github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0 h1:LMgbzhPunuelsIsfyEj/5O/aYfNcg/eGHsnZ7AZOhYg= github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0/go.mod h1:toIjQk1dhxdUFVyCWJJja0w/0nFpDid8MWX0ukQfvfo= github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 72d41c1c9..a1683c139 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -40,6 +40,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server" serviceaccount "github.com/stackitcloud/stackit-cli/internal/cmd/service-account" "github.com/stackitcloud/stackit-cli/internal/cmd/ske" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey" "github.com/stackitcloud/stackit-cli/internal/cmd/volume" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -203,6 +204,7 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(affinityGroups.NewCmd(params)) cmd.AddCommand(git.NewCmd(params)) cmd.AddCommand(kms.NewCmd(params)) + cmd.AddCommand(valkey.NewCmd(params)) } // traverseCommands calls f for c and all of its children. diff --git a/internal/cmd/valkey/credentials/create/create.go b/internal/cmd/valkey/credentials/create/create.go new file mode 100644 index 000000000..2d48d6336 --- /dev/null +++ b/internal/cmd/valkey/credentials/create/create.go @@ -0,0 +1,163 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/client" + valkeyUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + instanceIdFlag = "instance-id" + showPasswordFlag = "show-password" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + ShowPassword bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates credentials for a Valkey instance", + Long: "Creates credentials (username and password) for a Valkey instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create credentials for a Valkey instance with ID "xxx"`, + "$ stackit beta valkey credentials create --instance-id xxx"), + examples.NewExample( + `Create credentials for a Valkey instance and show the password in the output`, + "$ stackit beta valkey credentials create --instance-id xxx --show-password"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := valkeyUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Valkey credentials: %w", err) + } + + credentialsId := resp.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating credentials", func() error { + resp, err = wait.CreateCredentialsWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, credentialsId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for Valkey credentials creation: %w", err) + } + } + + return outputResult(params.Printer, model, instanceLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().BoolP(showPasswordFlag, "s", false, "Show password in output") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + ShowPassword: flags.FlagToBoolValue(p, cmd, showPasswordFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient valkey.DefaultAPI) valkey.ApiCreateCredentialsRequest { + // No payload — the empty body in the OpenAPI spec is a known issue + return apiClient.CreateCredentials(ctx, model.ProjectId, model.Region, model.InstanceId) +} + +func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *valkey.CredentialsResponse) error { + if resp == nil { + return fmt.Errorf("no response defined") + } + + if !model.ShowPassword { + if resp.Raw == nil { + resp.Raw = &valkey.RawCredentials{Credentials: valkey.Credentials{}} + } + resp.Raw.Credentials.Password = "hidden" + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s credentials for instance %q. Credentials ID: %s\n\n", operationState, instanceLabel, resp.Id) + if resp.Raw != nil { + if username := resp.Raw.Credentials.Username; username != "" { + p.Outputf("Username: %s\n", username) + } + if !model.ShowPassword { + p.Outputf("Password: \n") + } else { + p.Outputf("Password: %s\n", resp.Raw.Credentials.Password) + } + p.Outputf("Host: %s\n", resp.Raw.Credentials.Host) + p.Outputf("Port: %s\n", utils.PtrString(resp.Raw.Credentials.Port)) + } + p.Outputf("URI: %s\n", resp.Uri) + return nil + }) +} diff --git a/internal/cmd/valkey/credentials/create/create_test.go b/internal/cmd/valkey/credentials/create/create_test.go new file mode 100644 index 000000000..1f9e36a3a --- /dev/null +++ b/internal/cmd/valkey/credentials/create/create_test.go @@ -0,0 +1,251 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + InstanceId: testInstanceId, + ShowPassword: false, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiCreateCredentialsRequest)) valkey.ApiCreateCredentialsRequest { + request := testClient.DefaultAPI.CreateCredentials(testCtx, testProjectId, testRegion, testInstanceId) + 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 show-password", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[showPasswordFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ShowPassword = true + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, []string{}, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest valkey.ApiCreateCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + model *inputModel + instanceLabel string + resp *valkey.CredentialsResponse + wantErr bool + }{ + { + name: "nil response", + model: fixtureInputModel(), + instanceLabel: "example-instance", + resp: nil, + wantErr: true, + }, + { + name: "response without raw credentials", + model: fixtureInputModel(), + instanceLabel: "example-instance", + resp: &valkey.CredentialsResponse{ + Id: "creds-id", + Uri: "redis://host:6379", + }, + wantErr: false, + }, + { + name: "response with raw credentials, password hidden", + model: fixtureInputModel(), + instanceLabel: "example-instance", + resp: &valkey.CredentialsResponse{ + Id: "creds-id", + Uri: "redis://host:6379", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "host", + Username: "user", + Password: "secret", + Port: new(int32(6379)), + }, + }, + }, + wantErr: false, + }, + { + name: "response with raw credentials, password shown", + model: fixtureInputModel(func(model *inputModel) { + model.ShowPassword = true + }), + instanceLabel: "example-instance", + resp: &valkey.CredentialsResponse{ + Id: "creds-id", + Uri: "redis://host:6379", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "host", + Username: "user", + Password: "secret", + Port: new(int32(6379)), + }, + }, + }, + wantErr: false, + }, + { + name: "async mode", + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + instanceLabel: "example-instance", + resp: &valkey.CredentialsResponse{ + Id: "creds-id", + Uri: "redis://host:6379", + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.model, tt.instanceLabel, tt.resp); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/valkey/credentials/credentials.go b/internal/cmd/valkey/credentials/credentials.go new file mode 100644 index 000000000..26b776a64 --- /dev/null +++ b/internal/cmd/valkey/credentials/credentials.go @@ -0,0 +1,32 @@ +package credentials + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/credentials/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/credentials/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/credentials/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/credentials/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "credentials", + Short: "Provides functionality for Valkey credentials", + Long: "Provides functionality for Valkey credentials.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/valkey/credentials/delete/delete.go b/internal/cmd/valkey/credentials/delete/delete.go new file mode 100644 index 000000000..798a96fdc --- /dev/null +++ b/internal/cmd/valkey/credentials/delete/delete.go @@ -0,0 +1,118 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/client" + valkeyUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + credentialsIdArg = "CREDENTIALS_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + CredentialsId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", credentialsIdArg), + Short: "Deletes credentials of a Valkey instance", + Long: "Deletes credentials of a Valkey instance.", + Args: args.SingleArg(credentialsIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete credentials with ID "xxx" of a Valkey instance with ID "yyy"`, + "$ stackit beta valkey credentials delete xxx --instance-id yyy"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := valkeyUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + credentialsLabel, err := valkeyUtils.GetCredentialsUsername(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.CredentialsId, model.Region) + if err != nil || credentialsLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get credentials username: %v", err) + credentialsLabel = model.CredentialsId + } + + prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete Valkey credentials: %w", err) + } + + params.Printer.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialsId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + CredentialsId: credentialsId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient valkey.DefaultAPI) valkey.ApiDeleteCredentialsRequest { + return apiClient.DeleteCredentials(ctx, model.ProjectId, model.Region, model.InstanceId, model.CredentialsId) +} diff --git a/internal/cmd/valkey/credentials/delete/delete_test.go b/internal/cmd/valkey/credentials/delete/delete_test.go new file mode 100644 index 000000000..3aee669f9 --- /dev/null +++ b/internal/cmd/valkey/credentials/delete/delete_test.go @@ -0,0 +1,183 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testCredentialsId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{testCredentialsId} + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + InstanceId: testInstanceId, + CredentialsId: testCredentialsId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiDeleteCredentialsRequest)) valkey.ApiDeleteCredentialsRequest { + request := testClient.DefaultAPI.DeleteCredentials(testCtx, testProjectId, testRegion, testInstanceId, testCredentialsId) + 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 values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "credentials id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest valkey.ApiDeleteCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/valkey/credentials/describe/describe.go b/internal/cmd/valkey/credentials/describe/describe.go new file mode 100644 index 000000000..f0fd004ba --- /dev/null +++ b/internal/cmd/valkey/credentials/describe/describe.go @@ -0,0 +1,127 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + credentialsIdArg = "CREDENTIALS_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + CredentialsId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", credentialsIdArg), + Short: "Shows details of credentials of a Valkey instance", + Long: "Shows details of credentials of a Valkey instance. The password will be shown in plain text in the output.", + Args: args.SingleArg(credentialsIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of credentials with ID "xxx" from a Valkey instance with ID "yyy"`, + "$ stackit beta valkey credentials describe xxx --instance-id yyy"), + examples.NewExample( + `Get details of credentials with ID "xxx" from a Valkey instance with ID "yyy" in JSON format`, + "$ stackit beta valkey credentials describe xxx --instance-id yyy --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe Valkey credentials: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + credentialsId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + CredentialsId: credentialsId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient valkey.DefaultAPI) valkey.ApiGetCredentialsRequest { + return apiClient.GetCredentials(ctx, model.ProjectId, model.Region, model.InstanceId, model.CredentialsId) +} + +func outputResult(p *print.Printer, outputFormat string, credentials *valkey.CredentialsResponse) error { + return p.OutputResult(outputFormat, credentials, func() error { + if credentials == nil { + return fmt.Errorf("no credentials found") + } + table := tables.NewTable() + table.AddRow("ID", credentials.Id) + table.AddSeparator() + if credentials.Raw != nil { + if username := credentials.Raw.Credentials.Username; username != "" { + table.AddRow("USERNAME", username) + table.AddSeparator() + } + table.AddRow("PASSWORD", credentials.Raw.Credentials.Password) + table.AddSeparator() + table.AddRow("URI", utils.PtrString(credentials.Raw.Credentials.Uri)) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/valkey/credentials/describe/describe_test.go b/internal/cmd/valkey/credentials/describe/describe_test.go new file mode 100644 index 000000000..0cd80a996 --- /dev/null +++ b/internal/cmd/valkey/credentials/describe/describe_test.go @@ -0,0 +1,244 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testCredentialsId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{testCredentialsId} + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + InstanceId: testInstanceId, + CredentialsId: testCredentialsId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiGetCredentialsRequest)) valkey.ApiGetCredentialsRequest { + request := testClient.DefaultAPI.GetCredentials(testCtx, testProjectId, testRegion, testInstanceId, testCredentialsId) + 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 values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "credentials id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest valkey.ApiGetCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + credentials *valkey.CredentialsResponse + wantErr bool + }{ + { + name: "nil credentials", + credentials: nil, + wantErr: true, + }, + { + name: "credentials without raw", + credentials: &valkey.CredentialsResponse{ + Id: testCredentialsId, + Uri: "redis://host:6379", + }, + wantErr: false, + }, + { + name: "credentials with raw", + credentials: &valkey.CredentialsResponse{ + Id: testCredentialsId, + Uri: "redis://host:6379", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "host", + Username: "user", + Password: "secret", + Uri: new("redis://user:secret@host:6379"), + }, + }, + }, + wantErr: false, + }, + { + name: "credentials with raw but empty username", + credentials: &valkey.CredentialsResponse{ + Id: testCredentialsId, + Uri: "redis://host:6379", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "host", + Password: "secret", + }, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, "", tt.credentials); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/valkey/credentials/list/list.go b/internal/cmd/valkey/credentials/list/list.go new file mode 100644 index 000000000..f8f5185b4 --- /dev/null +++ b/internal/cmd/valkey/credentials/list/list.go @@ -0,0 +1,145 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/client" + valkeyUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + instanceIdFlag = "instance-id" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all credentials' IDs for a Valkey instance", + Long: "Lists all credentials' IDs for a Valkey instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all credentials' IDs for a Valkey instance with ID "xxx"`, + "$ stackit beta valkey credentials list --instance-id xxx"), + examples.NewExample( + `List all credentials' IDs for a Valkey instance with ID "xxx" in JSON format`, + "$ stackit beta valkey credentials list --instance-id xxx --output-format json"), + examples.NewExample( + `List up to 10 credentials' IDs for a Valkey instance with ID "xxx"`, + "$ stackit beta valkey credentials list --instance-id xxx --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Valkey credentials: %w", err) + } + credentials := resp.CredentialsList + + // Truncate output + if model.Limit != nil && len(credentials) > int(*model.Limit) { + credentials = credentials[:*model.Limit] + } + + instanceLabel := model.InstanceId + if len(credentials) == 0 { + instanceLabel, err = valkeyUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, instanceLabel, credentials) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient valkey.DefaultAPI) valkey.ApiListCredentialsRequest { + return apiClient.ListCredentials(ctx, model.ProjectId, model.Region, model.InstanceId) +} + +func outputResult(p *print.Printer, outputFormat, instanceLabel string, credentials []valkey.CredentialsListItem) error { + return p.OutputResult(outputFormat, credentials, func() error { + if len(credentials) == 0 { + p.Outputf("No credentials found for instance %q\n", instanceLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID") + for i := range credentials { + table.AddRow(credentials[i].Id) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/valkey/credentials/list/list_test.go b/internal/cmd/valkey/credentials/list/list_test.go new file mode 100644 index 000000000..769034866 --- /dev/null +++ b/internal/cmd/valkey/credentials/list/list_test.go @@ -0,0 +1,229 @@ +package list + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiListCredentialsRequest)) valkey.ApiListCredentialsRequest { + request := testClient.DefaultAPI.ListCredentials(testCtx, testProjectId, testRegion, testInstanceId) + 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] = "10" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(10)) + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid: zero", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "limit invalid: 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, parseInput, tt.expectedModel, []string{}, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest valkey.ApiListCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + instanceLabel string + credentials []valkey.CredentialsListItem + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty credentials", + args: args{ + instanceLabel: "example-instance", + credentials: []valkey.CredentialsListItem{}, + }, + wantErr: false, + }, + { + name: "single item", + args: args{ + instanceLabel: "example-instance", + credentials: []valkey.CredentialsListItem{ + {Id: uuid.NewString()}, + }, + }, + wantErr: false, + }, + { + name: "multiple items", + args: args{ + instanceLabel: "example-instance", + credentials: []valkey.CredentialsListItem{ + {Id: uuid.NewString()}, + {Id: uuid.NewString()}, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, "", tt.args.instanceLabel, tt.args.credentials); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/valkey/instance/create/create.go b/internal/cmd/valkey/instance/create/create.go new file mode 100644 index 000000000..b8389485f --- /dev/null +++ b/internal/cmd/valkey/instance/create/create.go @@ -0,0 +1,266 @@ +package create + +import ( + "context" + "errors" + "fmt" + "strings" + + "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/valkey/client" + valkeyUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api/wait" +) + +const ( + instanceNameFlag = "name" + enableMonitoringFlag = "enable-monitoring" + graphiteFlag = "graphite" + metricsFrequencyFlag = "metrics-frequency" + metricsPrefixFlag = "metrics-prefix" + monitoringInstanceIdFlag = "monitoring-instance-id" + sgwAclFlag = "acl" + syslogFlag = "syslog" + planIdFlag = "plan-id" + planNameFlag = "plan-name" + versionFlag = "version" + + minReplicasToWriteFlag = "min-replicas-to-write" + replBacklogSizeFlag = "repl-backlog-size" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + PlanName string + Version string + + InstanceName string + EnableMonitoring *bool + Graphite *string + MetricsFrequency *int32 + MetricsPrefix *string + MonitoringInstanceId *string + SgwAcl *[]string + Syslog []string + PlanId *string + + MinReplicasToWrite *int32 + ReplBacklogSize *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a Valkey instance", + Long: "Creates a Valkey instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a Valkey instance with name "my-instance" and specify plan by name and version`, + "$ stackit beta valkey instance create --name my-instance --plan-name stackit-keyvalue-1.2.10-replica --version 8"), + examples.NewExample( + `Create a Valkey instance with name "my-instance" and specify plan by ID`, + "$ stackit beta valkey instance create --name my-instance --plan-id xxx"), + examples.NewExample( + `Create a Valkey instance with name "my-instance" and specify IP range which is allowed to access it`, + "$ stackit beta valkey instance create --name my-instance --plan-id xxx --acl 1.2.3.0/24"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a Valkey instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req, err := buildRequest(ctx, model, apiClient.DefaultAPI) + if err != nil { + if _, ok := errors.AsType[*cliErr.DSAInvalidPlanError](err); !ok { + return fmt.Errorf("build Valkey instance creation request: %w", err) + } + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Valkey instance: %w", err) + } + instanceId := resp.InstanceId + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for Valkey instance creation: %w", err) + } + } + + return outputResult(params.Printer, model, projectLabel, instanceId, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(instanceNameFlag, "n", "", "Instance name") + cmd.Flags().Bool(enableMonitoringFlag, false, "Enable monitoring") + cmd.Flags().String(graphiteFlag, "", "Graphite host") + cmd.Flags().Int32(metricsFrequencyFlag, 0, "Metrics frequency in seconds") + cmd.Flags().String(metricsPrefixFlag, "", "Metrics prefix") + cmd.Flags().Var(flags.UUIDFlag(), monitoringInstanceIdFlag, "Monitoring instance ID") + cmd.Flags().Var(flags.CIDRSliceFlag(), sgwAclFlag, "List of IP networks in CIDR notation which are allowed to access this instance") + cmd.Flags().StringSlice(syslogFlag, []string{}, "Syslog") + cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID") + cmd.Flags().String(planNameFlag, "", "Plan name") + cmd.Flags().String(versionFlag, "", "Instance Valkey version") + + cmd.Flags().Int32(minReplicasToWriteFlag, 0, "Minimum number of replicas that must acknowledge a write for it to be accepted") + cmd.Flags().String(replBacklogSizeFlag, "", "Replication backlog size (e.g. \"1mb\")") + + err := flags.MarkFlagsRequired(cmd, instanceNameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + planId := flags.FlagToStringPointer(p, cmd, planIdFlag) + planName := flags.FlagToStringValue(p, cmd, planNameFlag) + version := flags.FlagToStringValue(p, cmd, versionFlag) + + if planId == nil && (planName == "" || version == "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + } + } + if planId != nil && (planName != "" || version != "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceName: flags.FlagToStringValue(p, cmd, instanceNameFlag), + EnableMonitoring: flags.FlagToBoolPointer(p, cmd, enableMonitoringFlag), + MonitoringInstanceId: flags.FlagToStringPointer(p, cmd, monitoringInstanceIdFlag), + Graphite: flags.FlagToStringPointer(p, cmd, graphiteFlag), + MetricsFrequency: flags.FlagToInt32Pointer(p, cmd, metricsFrequencyFlag), + MetricsPrefix: flags.FlagToStringPointer(p, cmd, metricsPrefixFlag), + SgwAcl: flags.FlagToStringSlicePointer(p, cmd, sgwAclFlag), + Syslog: flags.FlagToStringSliceValue(p, cmd, syslogFlag), + PlanId: planId, + PlanName: planName, + Version: version, + + MinReplicasToWrite: flags.FlagToInt32Pointer(p, cmd, minReplicasToWriteFlag), + ReplBacklogSize: flags.FlagToStringPointer(p, cmd, replBacklogSizeFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient valkey.DefaultAPI) (valkey.ApiCreateInstanceRequest, error) { + req := apiClient.CreateInstance(ctx, model.ProjectId, model.Region) + + var planId string + var err error + + offerings, err := apiClient.ListOfferings(ctx, model.ProjectId, model.Region).Execute() + if err != nil { + return req, fmt.Errorf("get Valkey offerings: %w", err) + } + + if model.PlanId == nil { + foundPlanId, err := valkeyUtils.LoadPlanId(model.PlanName, model.Version, offerings) + if err != nil { + if _, ok := errors.AsType[*cliErr.DSAInvalidPlanError](err); !ok { + return req, fmt.Errorf("load plan ID: %w", err) + } + return req, err + } + planId = *foundPlanId + } else { + err := valkeyUtils.ValidatePlanId(*model.PlanId, offerings) + if err != nil { + return req, err + } + planId = *model.PlanId + } + + var sgwAcl *string + if model.SgwAcl != nil { + sgwAcl = new(strings.Join(*model.SgwAcl, ",")) + } + + req = req.CreateInstancePayload(valkey.CreateInstancePayload{ + InstanceName: model.InstanceName, + Parameters: &valkey.InstanceParameters{ + EnableMonitoring: model.EnableMonitoring, + Graphite: model.Graphite, + MonitoringInstanceId: model.MonitoringInstanceId, + MetricsFrequency: model.MetricsFrequency, + MetricsPrefix: model.MetricsPrefix, + SgwAcl: sgwAcl, + Syslog: model.Syslog, + MinReplicasToWrite: model.MinReplicasToWrite, + ReplBacklogSize: model.ReplBacklogSize, + }, + PlanId: planId, + }) + return req, nil +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel, instanceId string, resp *valkey.CreateInstanceResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if model == nil { + return fmt.Errorf("no model passed") + } + if resp == nil { + return fmt.Errorf("no response defined") + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, instanceId) + return nil + }) +} diff --git a/internal/cmd/valkey/instance/create/create_test.go b/internal/cmd/valkey/instance/create/create_test.go new file mode 100644 index 000000000..dfe2e0862 --- /dev/null +++ b/internal/cmd/valkey/instance/create/create_test.go @@ -0,0 +1,497 @@ +package create + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} + testProjectId = uuid.NewString() + testPlanId = uuid.NewString() + testMonitoringId = uuid.NewString() +) + +type mockSettings struct { + returnError bool + listOfferingsResp *valkey.ListOfferingsResponse +} + +func newAPIMock(s mockSettings) valkey.DefaultAPI { + return &valkey.DefaultAPIServiceMock{ + ListOfferingsExecuteMock: new(func(_ valkey.ApiListOfferingsRequest) (*valkey.ListOfferingsResponse, error) { + if s.returnError { + return nil, fmt.Errorf("list offerings failed") + } + return s.listOfferingsResp, nil + }), + } +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceNameFlag: "example-name", + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringId, + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, + minReplicasToWriteFlag: "2", + replBacklogSizeFlag: "10mb", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + InstanceName: "example-name", + EnableMonitoring: new(true), + Graphite: new("example-graphite"), + MetricsFrequency: new(int32(100)), + MetricsPrefix: new("example-prefix"), + MonitoringInstanceId: new(testMonitoringId), + SgwAcl: new([]string{"198.51.100.14/24"}), + Syslog: []string{"example-syslog"}, + PlanId: new(testPlanId), + MinReplicasToWrite: new(int32(2)), + ReplBacklogSize: new("10mb"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiCreateInstanceRequest)) valkey.ApiCreateInstanceRequest { + request := testClient.DefaultAPI.CreateInstance(testCtx, testProjectId, testRegion) + request = request.CreateInstancePayload(valkey.CreateInstancePayload{ + InstanceName: "example-name", + Parameters: &valkey.InstanceParameters{ + EnableMonitoring: new(true), + Graphite: new("example-graphite"), + MetricsFrequency: new(int32(100)), + MetricsPrefix: new("example-prefix"), + MonitoringInstanceId: new(testMonitoringId), + SgwAcl: new("198.51.100.14/24"), + Syslog: []string{"example-syslog"}, + MinReplicasToWrite: new(int32(2)), + ReplBacklogSize: new("10mb"), + }, + PlanId: testPlanId, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + sgwAclValues []string + syslogValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with plan name and version", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + flagValues[versionFlag] = "7" + delete(flagValues, planIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + model.PlanName = "plan-name" + model.Version = "7" + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: "example-name", + planIdFlag: testPlanId, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceName: "example-name", + PlanId: new(testPlanId), + }, + }, + { + description: "zero values", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + planIdFlag: testPlanId, + instanceNameFlag: "", + enableMonitoringFlag: "false", + graphiteFlag: "", + metricsFrequencyFlag: "0", + metricsPrefixFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + PlanId: new(testPlanId), + InstanceName: "", + EnableMonitoring: new(false), + Graphite: new(""), + MetricsFrequency: new(int32(0)), + MetricsPrefix: new(""), + }, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid with plan ID, plan name and version", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + flagValues[versionFlag] = "7" + }), + isValid: false, + }, + { + description: "invalid with plan ID and plan name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + }), + isValid: false, + }, + { + description: "invalid with plan name only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + delete(flagValues, planIdFlag) + }), + isValid: false, + }, + { + description: "repeated acl flags", + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = new( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated acl flag with list value", + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24,198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = new( + append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated syslog flags", + flagValues: fixtureFlagValues(), + syslogValues: []string{"example-syslog-1", "example-syslog-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Syslog = append(model.Syslog, "example-syslog-1", "example-syslog-2") + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + sgwAclFlag: tt.sgwAclValues, + syslogFlag: tt.syslogValues, + }, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest valkey.ApiCreateInstanceRequest + getOfferingsFails bool + getOfferingsResp *valkey.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getOfferingsResp: &valkey.ListOfferingsResponse{ + Offerings: []valkey.Offering{ + { + Version: "example-version", + Plans: []valkey.Plan{ + { + Name: "example-plan-name", + Id: testPlanId, + }, + }, + }, + }, + }, + }, + { + description: "use plan name and version", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + expectedRequest: fixtureRequest(), + getOfferingsResp: &valkey.ListOfferingsResponse{ + Offerings: []valkey.Offering{ + { + Version: "example-version", + Plans: []valkey.Plan{ + { + Name: "example-plan-name", + Id: testPlanId, + }, + }, + }, + }, + }, + }, + { + description: "get offerings fails", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + getOfferingsFails: true, + isValid: false, + }, + { + description: "plan name not found", + model: fixtureInputModel( + func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }, + ), + getOfferingsResp: &valkey.ListOfferingsResponse{ + Offerings: []valkey.Offering{ + { + Version: "example-version", + Plans: []valkey.Plan{ + { + Name: "other-plan-name", + Id: testPlanId, + }, + }, + }, + }, + }, + isValid: false, + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + PlanId: new(testPlanId), + }, + getOfferingsResp: &valkey.ListOfferingsResponse{ + Offerings: []valkey.Offering{ + { + Version: "example-version", + Plans: []valkey.Plan{ + { + Name: "example-plan-name", + Id: testPlanId, + }, + }, + }, + }, + }, + expectedRequest: testClient.DefaultAPI.CreateInstance(testCtx, testProjectId, testRegion). + CreateInstancePayload(valkey.CreateInstancePayload{PlanId: testPlanId, Parameters: &valkey.InstanceParameters{}}), + }, + { + description: "acl is joined into single string", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + }, + PlanId: new(testPlanId), + SgwAcl: new([]string{"10.0.0.0/8", "192.168.1.0/24"}), + }, + expectedRequest: testClient.DefaultAPI.CreateInstance(testCtx, testProjectId, testRegion). + CreateInstancePayload(valkey.CreateInstancePayload{ + PlanId: testPlanId, + Parameters: &valkey.InstanceParameters{ + SgwAcl: new("10.0.0.0/8,192.168.1.0/24"), + }, + }), + getOfferingsResp: &valkey.ListOfferingsResponse{ + Offerings: []valkey.Offering{ + { + Version: "example-version", + Plans: []valkey.Plan{ + { + Name: "example-plan-name", + Id: testPlanId, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := mockSettings{ + returnError: tt.getOfferingsFails, + listOfferingsResp: tt.getOfferingsResp, + } + request, err := buildRequest(testCtx, tt.model, newAPIMock(client)) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + cmp.FilterPath(func(p cmp.Path) bool { + return p.String() == "ApiService" + }, cmp.Ignore()), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + instanceId string + resp *valkey.CreateInstanceResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + projectLabel: "", + instanceId: testMonitoringId, + resp: &valkey.CreateInstanceResponse{}, + }, + wantErr: false, + }, + { + name: "nil response", + args: args{ + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + projectLabel: "", + instanceId: testMonitoringId, + }, + wantErr: true, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.projectLabel, tt.args.instanceId, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/valkey/instance/delete/delete.go b/internal/cmd/valkey/instance/delete/delete.go new file mode 100644 index 000000000..32abbbe6f --- /dev/null +++ b/internal/cmd/valkey/instance/delete/delete.go @@ -0,0 +1,116 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api/wait" + + "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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/client" + valkeyUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Deletes a Valkey instance", + Long: "Deletes a Valkey instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a Valkey instance with ID "xxx"`, + "$ stackit beta valkey instance delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := valkeyUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete Valkey instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for Valkey instance deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Outputf("%s instance %q\n", operationState, instanceLabel) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *valkey.APIClient) valkey.ApiDeleteInstanceRequest { + return apiClient.DefaultAPI.DeleteInstance(ctx, model.ProjectId, model.Region, model.InstanceId) +} diff --git a/internal/cmd/valkey/instance/delete/delete_test.go b/internal/cmd/valkey/instance/delete/delete_test.go new file mode 100644 index 000000000..1d041a741 --- /dev/null +++ b/internal/cmd/valkey/instance/delete/delete_test.go @@ -0,0 +1,178 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + 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, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiDeleteInstanceRequest)) valkey.ApiDeleteInstanceRequest { + request := testClient.DefaultAPI.DeleteInstance(testCtx, testProjectId, testRegion, testInstanceId) + 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 values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + 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, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + 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 valkey.ApiDeleteInstanceRequest + }{ + { + 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, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/valkey/instance/describe/describe.go b/internal/cmd/valkey/instance/describe/describe.go new file mode 100644 index 000000000..560dff023 --- /dev/null +++ b/internal/cmd/valkey/instance/describe/describe.go @@ -0,0 +1,128 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + aclParameterKey = "sgw_acl" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Shows details of a Valkey instance", + Long: "Shows details of a Valkey instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a Valkey instance with ID "xxx"`, + "$ stackit beta valkey instance describe xxx"), + examples.NewExample( + `Get details of a Valkey instance with ID "xxx" in JSON format`, + "$ stackit beta valkey instance describe xxx --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read Valkey instance: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *valkey.APIClient) valkey.ApiGetInstanceRequest { + return apiClient.DefaultAPI.GetInstance(ctx, model.ProjectId, model.Region, model.InstanceId) +} + +func outputResult(p *print.Printer, outputFormat string, instance *valkey.Instance) error { + return p.OutputResult(outputFormat, instance, func() error { + if instance == nil { + return fmt.Errorf("no instance passed") + } + + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(instance.InstanceId)) + table.AddSeparator() + table.AddRow("NAME", instance.Name) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(instance.Status)) + table.AddSeparator() + table.AddRow("PLAN ID", instance.PlanId) + table.AddSeparator() + table.AddRow("PLAN NAME", instance.PlanName) + table.AddSeparator() + table.AddRow("OFFERING NAME", instance.OfferingName) + table.AddSeparator() + table.AddRow("OFFERING VERSION", instance.OfferingVersion) + table.AddSeparator() + table.AddRow("LAST OPERATION TYPE", string(instance.LastOperation.Type)) + table.AddSeparator() + table.AddRow("LAST OPERATION STATE", string(instance.LastOperation.State)) + if acl, ok := instance.Parameters[aclParameterKey].(string); ok && acl != "" { + table.AddSeparator() + table.AddRow("ACL", acl) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/valkey/instance/describe/describe_test.go b/internal/cmd/valkey/instance/describe/describe_test.go new file mode 100644 index 000000000..a8f9cdd79 --- /dev/null +++ b/internal/cmd/valkey/instance/describe/describe_test.go @@ -0,0 +1,245 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + 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, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiGetInstanceRequest)) valkey.ApiGetInstanceRequest { + request := testClient.DefaultAPI.GetInstance(testCtx, testProjectId, testRegion, testInstanceId) + 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 values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + 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, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + 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 valkey.ApiGetInstanceRequest + }{ + { + 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, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + instance *valkey.Instance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty instance", + args: args{ + instance: &valkey.Instance{}, + }, + wantErr: false, + }, + { + name: "nil instance", + args: args{}, + wantErr: true, + }, + { + name: "populated instance with ACL", + args: args{ + instance: &valkey.Instance{ + InstanceId: new(testInstanceId), + Name: "example-instance", + Status: new(valkey.InstanceStatus("active")), + PlanId: "example-plan-id", + PlanName: "example-plan-name", + OfferingName: "Valkey", + OfferingVersion: "7", + LastOperation: valkey.InstanceLastOperation{ + Type: "create", + State: "succeeded", + }, + Parameters: map[string]interface{}{ + aclParameterKey: "10.0.0.0/8", + }, + }, + }, + wantErr: false, + }, + { + name: "instance with nil parameter value", + args: args{ + instance: &valkey.Instance{ + Parameters: map[string]interface{}{ + aclParameterKey: nil, + }, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/valkey/instance/instance.go b/internal/cmd/valkey/instance/instance.go new file mode 100644 index 000000000..bb0566190 --- /dev/null +++ b/internal/cmd/valkey/instance/instance.go @@ -0,0 +1,34 @@ +package instance + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/instance/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for Valkey instances", + Long: "Provides functionality for Valkey instances.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/valkey/instance/list/list.go b/internal/cmd/valkey/instance/list/list.go new file mode 100644 index 000000000..ac03fadba --- /dev/null +++ b/internal/cmd/valkey/instance/list/list.go @@ -0,0 +1,145 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "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/valkey/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Valkey instances", + Long: "Lists all Valkey instances.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Valkey instances`, + "$ stackit beta valkey instance list"), + examples.NewExample( + `List all Valkey instances in JSON format`, + "$ stackit beta valkey instance list --output-format json"), + examples.NewExample( + `List up to 10 Valkey instances`, + "$ stackit beta valkey instance list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Valkey instances: %w", err) + } + instances := resp.Instances + + // Truncate output + if model.Limit != nil && len(instances) > int(*model.Limit) { + instances = instances[:*model.Limit] + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &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 +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *valkey.APIClient) valkey.ApiListInstancesRequest { + return apiClient.DefaultAPI.ListInstances(ctx, model.ProjectId, model.Region) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []valkey.Instance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "LAST OPERATION TYPE", "LAST OPERATION STATE") + for i := range instances { + instance := instances[i] + table.AddRow( + utils.PtrString(instance.InstanceId), + instance.Name, + string(instance.LastOperation.Type), + string(instance.LastOperation.State), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/valkey/instance/list/list_test.go b/internal/cmd/valkey/instance/list/list_test.go new file mode 100644 index 000000000..462c28e96 --- /dev/null +++ b/internal/cmd/valkey/instance/list/list_test.go @@ -0,0 +1,206 @@ +package list + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} + testProjectId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Limit: new(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiListInstancesRequest)) valkey.ApiListInstancesRequest { + request := testClient.DefaultAPI.ListInstances(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: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + 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 valkey.ApiListInstancesRequest + }{ + { + 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, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + instances []valkey.Instance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "slice with populated element", + args: args{ + instances: []valkey.Instance{ + { + InstanceId: new("instance-id"), + Name: "example-instance", + LastOperation: valkey.InstanceLastOperation{ + Type: "create", + State: "succeeded", + }, + }, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, "dummy-project-label", tt.args.instances); (err != nil) != tt.wantErr { + t.Errorf("TestOutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/valkey/instance/update/update.go b/internal/cmd/valkey/instance/update/update.go new file mode 100644 index 000000000..a3364cf54 --- /dev/null +++ b/internal/cmd/valkey/instance/update/update.go @@ -0,0 +1,270 @@ +package update + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/client" + valkeyUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/valkey/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + enableMonitoringFlag = "enable-monitoring" + graphiteFlag = "graphite" + metricsFrequencyFlag = "metrics-frequency" + metricsPrefixFlag = "metrics-prefix" + monitoringInstanceIdFlag = "monitoring-instance-id" + sgwAclFlag = "acl" + syslogFlag = "syslog" + planIdFlag = "plan-id" + planNameFlag = "plan-name" + versionFlag = "version" + + minReplicasToWriteFlag = "min-replicas-to-write" + replBacklogSizeFlag = "repl-backlog-size" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + PlanName string + Version string + + EnableMonitoring *bool + Graphite *string + MetricsFrequency *int32 + MetricsPrefix *string + MonitoringInstanceId *string + SgwAcl *[]string + Syslog []string + PlanId *string + + MinReplicasToWrite *int32 + ReplBacklogSize *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", instanceIdArg), + Short: "Updates a Valkey instance", + Long: "Updates a Valkey instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the plan of a Valkey instance with ID "xxx" by plan ID`, + "$ stackit beta valkey instance update xxx --plan-id yyy"), + examples.NewExample( + `Update the plan of a Valkey instance with ID "xxx" by name and version`, + "$ stackit beta valkey instance update xxx --plan-name stackit-keyvalue-1.2.10-replica --version 8"), + examples.NewExample( + `Update the range of IPs allowed to access a Valkey instance with ID "xxx"`, + "$ stackit beta valkey instance update xxx --acl 1.2.3.0/24"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := valkeyUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req, err := buildRequest(ctx, model, apiClient.DefaultAPI) + if err != nil { + if _, ok := errors.AsType[*cliErr.DSAInvalidPlanError](err); !ok { + return fmt.Errorf("build Valkey instance update request: %w", err) + } + return err + } + err = req.Execute() + if err != nil { + return fmt.Errorf("update Valkey instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for Valkey instance update: %w", err) + } + } + + return outputResult(params.Printer, model, instanceLabel) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(enableMonitoringFlag, false, "Enable monitoring") + cmd.Flags().String(graphiteFlag, "", "Graphite host") + cmd.Flags().Int32(metricsFrequencyFlag, 0, "Metrics frequency in seconds") + cmd.Flags().String(metricsPrefixFlag, "", "Metrics prefix") + cmd.Flags().Var(flags.UUIDFlag(), monitoringInstanceIdFlag, "Monitoring instance ID") + cmd.Flags().Var(flags.CIDRSliceFlag(), sgwAclFlag, "List of IP networks in CIDR notation which are allowed to access this instance") + cmd.Flags().StringSlice(syslogFlag, []string{}, "Syslog") + cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID") + cmd.Flags().String(planNameFlag, "", "Plan name") + cmd.Flags().String(versionFlag, "", "Instance Valkey version") + + cmd.Flags().Int32(minReplicasToWriteFlag, 0, "Minimum number of replicas that must acknowledge a write for it to be accepted") + cmd.Flags().String(replBacklogSizeFlag, "", "Replication backlog size (e.g. \"1mb\")") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + enableMonitoring := flags.FlagToBoolPointer(p, cmd, enableMonitoringFlag) + monitoringInstanceId := flags.FlagToStringPointer(p, cmd, monitoringInstanceIdFlag) + graphite := flags.FlagToStringPointer(p, cmd, graphiteFlag) + metricsFrequency := flags.FlagToInt32Pointer(p, cmd, metricsFrequencyFlag) + metricsPrefix := flags.FlagToStringPointer(p, cmd, metricsPrefixFlag) + sgwAcl := flags.FlagToStringSlicePointer(p, cmd, sgwAclFlag) + syslog := flags.FlagToStringSliceValue(p, cmd, syslogFlag) + planId := flags.FlagToStringPointer(p, cmd, planIdFlag) + planName := flags.FlagToStringValue(p, cmd, planNameFlag) + version := flags.FlagToStringValue(p, cmd, versionFlag) + minReplicasToWrite := flags.FlagToInt32Pointer(p, cmd, minReplicasToWriteFlag) + replBacklogSize := flags.FlagToStringPointer(p, cmd, replBacklogSizeFlag) + + if planId != nil && (planName != "" || version != "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + } + } + if planId == nil && (planName == "") != (version == "") { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + } + } + + if enableMonitoring == nil && monitoringInstanceId == nil && + graphite == nil && metricsFrequency == nil && metricsPrefix == nil && + sgwAcl == nil && len(syslog) == 0 && planId == nil && planName == "" && version == "" && + minReplicasToWrite == nil && replBacklogSize == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + EnableMonitoring: enableMonitoring, + MonitoringInstanceId: monitoringInstanceId, + Graphite: graphite, + MetricsFrequency: metricsFrequency, + MetricsPrefix: metricsPrefix, + SgwAcl: sgwAcl, + Syslog: syslog, + PlanId: planId, + PlanName: planName, + Version: version, + MinReplicasToWrite: minReplicasToWrite, + ReplBacklogSize: replBacklogSize, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient valkey.DefaultAPI) (valkey.ApiPartialUpdateInstanceRequest, error) { + req := apiClient.PartialUpdateInstance(ctx, model.ProjectId, model.Region, model.InstanceId) + + var planId *string + + // Only call ListOfferings when plan selection is requested + if model.PlanId != nil || model.PlanName != "" { + offerings, err := apiClient.ListOfferings(ctx, model.ProjectId, model.Region).Execute() + if err != nil { + return req, fmt.Errorf("get Valkey offerings: %w", err) + } + + if model.PlanId != nil { + if err := valkeyUtils.ValidatePlanId(*model.PlanId, offerings); err != nil { + return req, err + } + planId = model.PlanId + } else { + foundPlanId, err := valkeyUtils.LoadPlanId(model.PlanName, model.Version, offerings) + if err != nil { + if _, ok := errors.AsType[*cliErr.DSAInvalidPlanError](err); !ok { + return req, fmt.Errorf("load plan ID: %w", err) + } + return req, err + } + planId = foundPlanId + } + } + + var sgwAcl *string + if model.SgwAcl != nil { + sgwAcl = new(strings.Join(*model.SgwAcl, ",")) + } + + req = req.PartialUpdateInstancePayload(valkey.PartialUpdateInstancePayload{ + Parameters: &valkey.InstanceParameters{ + EnableMonitoring: model.EnableMonitoring, + Graphite: model.Graphite, + MonitoringInstanceId: model.MonitoringInstanceId, + MetricsFrequency: model.MetricsFrequency, + MetricsPrefix: model.MetricsPrefix, + SgwAcl: sgwAcl, + Syslog: model.Syslog, + MinReplicasToWrite: model.MinReplicasToWrite, + ReplBacklogSize: model.ReplBacklogSize, + }, + PlanId: planId, + }) + return req, nil +} + +func outputResult(p *print.Printer, model *inputModel, instanceLabel string) error { + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Info("%s instance %q\n", operationState, instanceLabel) + return nil +} diff --git a/internal/cmd/valkey/instance/update/update_test.go b/internal/cmd/valkey/instance/update/update_test.go new file mode 100644 index 000000000..001c7d9d5 --- /dev/null +++ b/internal/cmd/valkey/instance/update/update_test.go @@ -0,0 +1,438 @@ +package update + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testPlanId = uuid.NewString() + testMonitoringId = uuid.NewString() +) + +type mockSettings struct { + returnError bool + listOfferingsResp *valkey.ListOfferingsResponse +} + +func newAPIMock(s mockSettings) valkey.DefaultAPI { + return &valkey.DefaultAPIServiceMock{ + ListOfferingsExecuteMock: new(func(_ valkey.ApiListOfferingsRequest) (*valkey.ListOfferingsResponse, error) { + if s.returnError { + return nil, fmt.Errorf("list offerings failed") + } + return s.listOfferingsResp, nil + }), + } +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringId, + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, + minReplicasToWriteFlag: "2", + replBacklogSizeFlag: "10mb", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + InstanceId: testInstanceId, + PlanId: new(testPlanId), + EnableMonitoring: new(true), + Graphite: new("example-graphite"), + MetricsFrequency: new(int32(100)), + MetricsPrefix: new("example-prefix"), + MonitoringInstanceId: new(testMonitoringId), + SgwAcl: new([]string{"198.51.100.14/24"}), + Syslog: []string{"example-syslog"}, + MinReplicasToWrite: new(int32(2)), + ReplBacklogSize: new("10mb"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiPartialUpdateInstanceRequest)) valkey.ApiPartialUpdateInstanceRequest { + request := testClient.DefaultAPI.PartialUpdateInstance(testCtx, testProjectId, testRegion, testInstanceId) + request = request.PartialUpdateInstancePayload(valkey.PartialUpdateInstancePayload{ + PlanId: new(testPlanId), + Parameters: &valkey.InstanceParameters{ + EnableMonitoring: new(true), + Graphite: new("example-graphite"), + MetricsFrequency: new(int32(100)), + MetricsPrefix: new("example-prefix"), + MonitoringInstanceId: new(testMonitoringId), + SgwAcl: new("198.51.100.14/24"), + Syslog: []string{"example-syslog"}, + MinReplicasToWrite: new(int32(2)), + ReplBacklogSize: new("10mb"), + }, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + sgwAclValues []string + syslogValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with plan name and version", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + flagValues[versionFlag] = "8" + delete(flagValues, planIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + model.PlanName = "plan-name" + model.Version = "8" + }), + }, + { + description: "no plan selection", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, planIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + }), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + 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, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid plan: id and name", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + }), + isValid: false, + }, + { + description: "invalid plan: id and version", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[versionFlag] = "8" + }), + isValid: false, + }, + { + description: "invalid plan: name only", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[planNameFlag] = "plan-name" + delete(flagValues, planIdFlag) + }), + isValid: false, + }, + { + description: "empty update", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: false, + }, + { + description: "repeated acl flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + sgwAclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SgwAcl = new(append(*model.SgwAcl, "198.51.100.14/24", "198.51.100.14/32")) + }), + }, + { + description: "repeated syslog flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + syslogValues: []string{"example-syslog-1", "example-syslog-2"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Syslog = append(model.Syslog, "example-syslog-1", "example-syslog-2") + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + sgwAclFlag: tt.sgwAclValues, + syslogFlag: tt.syslogValues, + }, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest valkey.ApiPartialUpdateInstanceRequest + getOfferingsFails bool + getOfferingsResp *valkey.ListOfferingsResponse + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + getOfferingsResp: &valkey.ListOfferingsResponse{ + Offerings: []valkey.Offering{ + { + Version: "example-version", + Plans: []valkey.Plan{ + { + Name: "example-plan-name", + Id: testPlanId, + }, + }, + }, + }, + }, + isValid: true, + }, + { + description: "use plan name and version", + model: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }), + expectedRequest: fixtureRequest(), + getOfferingsResp: &valkey.ListOfferingsResponse{ + Offerings: []valkey.Offering{ + { + Version: "example-version", + Plans: []valkey.Plan{ + { + Name: "example-plan-name", + Id: testPlanId, + }, + }, + }, + }, + }, + isValid: true, + }, + { + description: "no plan selection", + model: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + }), + expectedRequest: fixtureRequest(func(request *valkey.ApiPartialUpdateInstanceRequest) { + *request = request.PartialUpdateInstancePayload(valkey.PartialUpdateInstancePayload{ + PlanId: nil, + Parameters: &valkey.InstanceParameters{ + EnableMonitoring: new(true), + Graphite: new("example-graphite"), + MetricsFrequency: new(int32(100)), + MetricsPrefix: new("example-prefix"), + MonitoringInstanceId: new(testMonitoringId), + SgwAcl: new("198.51.100.14/24"), + Syslog: []string{"example-syslog"}, + MinReplicasToWrite: new(int32(2)), + ReplBacklogSize: new("10mb"), + }, + }) + }), + isValid: true, + }, + { + description: "get offerings fails", + model: fixtureInputModel(), + getOfferingsFails: true, + isValid: false, + }, + { + description: "plan name not found", + model: fixtureInputModel(func(model *inputModel) { + model.PlanId = nil + model.PlanName = "example-plan-name" + model.Version = "example-version" + }), + getOfferingsResp: &valkey.ListOfferingsResponse{ + Offerings: []valkey.Offering{ + { + Version: "example-version", + Plans: []valkey.Plan{ + { + Name: "other-plan-name", + Id: testPlanId, + }, + }, + }, + }, + }, + isValid: false, + }, + { + description: "acl is joined into single string", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + }, + InstanceId: testInstanceId, + SgwAcl: new([]string{"10.0.0.0/8", "192.168.1.0/24"}), + }, + expectedRequest: testClient.DefaultAPI.PartialUpdateInstance(testCtx, testProjectId, testRegion, testInstanceId). + PartialUpdateInstancePayload(valkey.PartialUpdateInstancePayload{ + Parameters: &valkey.InstanceParameters{ + SgwAcl: new("10.0.0.0/8,192.168.1.0/24"), + }, + }), + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + apiMock := newAPIMock(mockSettings{ + returnError: tt.getOfferingsFails, + listOfferingsResp: tt.getOfferingsResp, + }) + request, err := buildRequest(testCtx, tt.model, apiMock) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + cmp.FilterPath(func(p cmp.Path) bool { + return p.String() == "ApiService" + }, cmp.Ignore()), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/valkey/plans/plans.go b/internal/cmd/valkey/plans/plans.go new file mode 100644 index 000000000..2a9a0486d --- /dev/null +++ b/internal/cmd/valkey/plans/plans.go @@ -0,0 +1,149 @@ +package plans + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "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/valkey/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Lists all Valkey service plans", + Long: "Lists all Valkey service plans.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + "Lists all Valkey service plans", + "$ stackit valkey plans", + ), + examples.NewExample( + `List all Valkey service plans in JSON format`, + "$ stackit valkey plans --output-format json"), + examples.NewExample( + `List up to 10 Valkey service plans`, + "$ stackit valkey plans --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + apiclient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiclient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Valkey service plans: %w", err) + } + plans := resp.Offerings + + // Truncate output + if model.Limit != nil && len(plans) > int(*model.Limit) { + plans = plans[:*model.Limit] + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, plans) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &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 +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *valkey.APIClient) valkey.ApiListOfferingsRequest { + req := apiClient.DefaultAPI.ListOfferings(ctx, model.ProjectId, model.Region) + return req +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, plans []valkey.Offering) error { + return p.OutputResult(outputFormat, plans, func() error { + if len(plans) == 0 { + p.Outputf("No plans found for project %q\n", projectLabel) + return nil + } + table := tables.NewTable() + table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") + for i := range plans { + o := plans[i] + if o.Plans != nil { + for j := range o.Plans { + plan := (o.Plans)[j] + table.AddRow( + o.Name, + o.Version, + plan.Id, + plan.Name, + plan.Description, + ) + } + table.AddSeparator() + } + } + table.EnableAutoMergeOnColumns(1, 2) + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/valkey/plans/plans_test.go b/internal/cmd/valkey/plans/plans_test.go new file mode 100644 index 000000000..29677a77b --- /dev/null +++ b/internal/cmd/valkey/plans/plans_test.go @@ -0,0 +1,186 @@ +package plans + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &valkey.APIClient{DefaultAPI: &valkey.DefaultAPIService{}} +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Limit: new(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *valkey.ApiListOfferingsRequest)) valkey.ApiListOfferingsRequest { + request := testClient.DefaultAPI.ListOfferings(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: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + 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 valkey.ApiListOfferingsRequest + }{ + { + 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, valkey.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + const testProjectLabel = "test-project" + type args struct { + outputFormat string + plans []valkey.Offering + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "slice with empty elements", + args: args{ + plans: []valkey.Offering{ + {}, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, testProjectLabel, tt.args.plans); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/valkey/valkey.go b/internal/cmd/valkey/valkey.go new file mode 100644 index 000000000..016bc7ae0 --- /dev/null +++ b/internal/cmd/valkey/valkey.go @@ -0,0 +1,30 @@ +package valkey + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/credentials" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/instance" + "github.com/stackitcloud/stackit-cli/internal/cmd/valkey/plans" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "valkey", + Short: "Provides functionality for Valkey", + Long: "Provides functionality for Valkey.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(credentials.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) + cmd.AddCommand(instance.NewCmd(params)) +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index effb68051..ec33f3506 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -54,6 +54,7 @@ const ( CDNCustomEndpointKey = "cdn_custom_endpoint" IntakeCustomEndpointKey = "intake_custom_endpoint" LogsCustomEndpointKey = "logs_custom_endpoint" + ValkeyCustomEndpointKey = "valkey_custom_endpoint" VPNCustomEndpointKey = "vpn_custom_endpoint" ProjectNameKey = "project_name" @@ -122,6 +123,7 @@ var ConfigKeys = []string{ ServiceEnablementCustomEndpointKey, SfsCustomEndpointKey, TokenCustomEndpointKey, + ValkeyCustomEndpointKey, VPNCustomEndpointKey, } @@ -214,6 +216,7 @@ func setConfigDefaults() { viper.SetDefault(AlbCustomEndpoint, "") viper.SetDefault(LogsCustomEndpointKey, "") viper.SetDefault(CDNCustomEndpointKey, "") + viper.SetDefault(ValkeyCustomEndpointKey, "") viper.SetDefault(VPNCustomEndpointKey, "") } diff --git a/internal/pkg/services/valkey/client/client.go b/internal/pkg/services/valkey/client/client.go new file mode 100644 index 000000000..51b5dc0fb --- /dev/null +++ b/internal/pkg/services/valkey/client/client.go @@ -0,0 +1,14 @@ +package client + +import ( + "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/spf13/viper" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*valkey.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ValkeyCustomEndpointKey), false, genericclient.CreateApiClient[*valkey.APIClient](valkey.NewAPIClient)) +} diff --git a/internal/pkg/services/valkey/utils/utils.go b/internal/pkg/services/valkey/utils/utils.go new file mode 100644 index 000000000..362d265ec --- /dev/null +++ b/internal/pkg/services/valkey/utils/utils.go @@ -0,0 +1,82 @@ +package utils + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" +) + +const ( + service = "valkey" +) + +func ValidatePlanId(planId string, offerings *valkey.ListOfferingsResponse) error { + for _, offer := range offerings.GetOfferings() { + for _, plan := range offer.Plans { + if strings.EqualFold(plan.Id, planId) { + return nil + } + } + } + + return &errors.DSAInvalidPlanError{ + Service: service, + Details: fmt.Sprintf("You provided plan ID %q, which is invalid.", planId), + } +} + +func LoadPlanId(planName, version string, offerings *valkey.ListOfferingsResponse) (*string, error) { + availableVersions := "" + availablePlanNames := "" + isValidVersion := false + for _, offer := range offerings.GetOfferings() { + if !strings.EqualFold(offer.Version, version) { + availableVersions = fmt.Sprintf("%s\n- %s", availableVersions, offer.Version) + continue + } + isValidVersion = true + + for _, plan := range offer.Plans { + if strings.EqualFold(plan.Name, planName) { + return &plan.Id, nil + } + availablePlanNames = fmt.Sprintf("%s\n- %s", availablePlanNames, plan.Name) + } + } + + if !isValidVersion { + details := fmt.Sprintf("You provided version %q, which is invalid. Available versions are: %s", version, availableVersions) + return nil, &errors.DSAInvalidPlanError{ + Service: service, + Details: details, + } + } + details := fmt.Sprintf("You provided plan_name %q for version %s, which is invalid. Available plan names for that version are: %s", planName, version, availablePlanNames) + return nil, &errors.DSAInvalidPlanError{ + Service: service, + Details: details, + } +} + +func GetInstanceName(ctx context.Context, apiClient valkey.DefaultAPI, projectId, instanceId, region string) (string, error) { + resp, err := apiClient.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + return "", fmt.Errorf("get Valkey instance: %w", err) + } + return resp.Name, nil +} + +func GetCredentialsUsername(ctx context.Context, apiClient valkey.DefaultAPI, projectId, instanceId, credentialsId, region string) (string, error) { + resp, err := apiClient.GetCredentials(ctx, projectId, region, instanceId, credentialsId).Execute() + if err != nil { + return "", fmt.Errorf("get Valkey credentials: %w", err) + } + if resp.Raw == nil { + return "", nil + } + return resp.Raw.Credentials.Username, nil +}