diff --git a/cmd/scw/testdata/test-all-usage-redis-cluster-connect-usage.golden b/cmd/scw/testdata/test-all-usage-redis-cluster-connect-usage.golden new file mode 100644 index 0000000000..ecfde0cbbc --- /dev/null +++ b/cmd/scw/testdata/test-all-usage-redis-cluster-connect-usage.golden @@ -0,0 +1,29 @@ +šŸŽ²šŸŽ²šŸŽ² EXIT CODE: 0 šŸŽ²šŸŽ²šŸŽ² +🟄🟄🟄 STDERRļøļø šŸŸ„šŸŸ„šŸŸ„ļø +Connect to a Redis cluster using locally installed redis-cli. The command will check if redis-cli is installed, download the certificate if TLS is enabled, and prompt for the password. + +USAGE: + scw redis cluster connect [arg=value ...] + +EXAMPLES: + Connect to a Redis cluster + scw redis cluster connect 11111111-1111-1111-1111-111111111111 + + Connect to a Redis cluster via private network + scw redis cluster connect 11111111-1111-1111-1111-111111111111 private-network=true + +ARGS: + [private-network=false] Connect by the private network endpoint attached. + cluster-id UUID of the cluster + [cli-redis] Command line tool to use, default to redis-cli + [cli-args] Additional arguments to pass to redis-cli + [zone=fr-par-1] Zone to target. If none is passed will use default zone from the config (fr-par-1 | fr-par-2 | nl-ams-1 | nl-ams-2 | pl-waw-1 | pl-waw-2) + +FLAGS: + -h, --help help for connect + +GLOBAL FLAGS: + -c, --config string The path to the config file + -D, --debug Enable debug mode + -o, --output string Output format: json or human, see 'scw help output' for more info (default "human") + -p, --profile string The config profile to use diff --git a/cmd/scw/testdata/test-all-usage-redis-cluster-usage.golden b/cmd/scw/testdata/test-all-usage-redis-cluster-usage.golden index a0ef829d13..863fd334a5 100644 --- a/cmd/scw/testdata/test-all-usage-redis-cluster-usage.golden +++ b/cmd/scw/testdata/test-all-usage-redis-cluster-usage.golden @@ -6,6 +6,7 @@ USAGE: scw redis cluster AVAILABLE COMMANDS: + connect Connect to a Redis cluster using locally installed redis-cli create Create a Redisā„¢ Database Instance delete Delete a Redisā„¢ Database Instance get Get a Redisā„¢ Database Instance diff --git a/docs/commands/redis.md b/docs/commands/redis.md index 24f03c771c..d2ff8f1faf 100644 --- a/docs/commands/redis.md +++ b/docs/commands/redis.md @@ -9,6 +9,7 @@ This API allows you to manage your Managed Databases for Redisā„¢. - [Set ACL rules for a cluster](#set-acl-rules-for-a-cluster) - [Update an ACL rule for a Redisā„¢ Database Instance (network rule)](#update-an-acl-rule-for-a-redisā„¢-database-instance-(network-rule)) - [Cluster management commands](#cluster-management-commands) + - [Connect to a Redis cluster using locally installed redis-cli](#connect-to-a-redis-cluster-using-locally-installed-redis-cli) - [Create a Redisā„¢ Database Instance](#create-a-redisā„¢-database-instance) - [Delete a Redisā„¢ Database Instance](#delete-a-redisā„¢-database-instance) - [Get a Redisā„¢ Database Instance](#get-a-redisā„¢-database-instance) @@ -153,6 +154,44 @@ scw redis acl update [arg=value ...] A Redisā„¢ Database Instance, also known as a Redisā„¢ cluster, consists of either one standalone node or a cluster composed of three to six nodes. The cluster uses partitioning to split the keyspace. Each partition is replicated and can be reassigned or elected as the primary when necessary. Standalone mode creates a standalone database provisioned on a single node. +### Connect to a Redis cluster using locally installed redis-cli + +Connect to a Redis cluster using locally installed redis-cli. The command will check if redis-cli is installed, download the certificate if TLS is enabled, and prompt for the password. + +**Usage:** + +``` +scw redis cluster connect [arg=value ...] +``` + + +**Args:** + +| Name | | Description | +|------|---|-------------| +| private-network | Default: `false` | Connect by the private network endpoint attached. | +| cluster-id | Required | UUID of the cluster | +| cli-redis | | Command line tool to use, default to redis-cli | +| cli-args | | Additional arguments to pass to redis-cli | +| zone | Default: `fr-par-1`
One of: `fr-par-1`, `fr-par-2`, `nl-ams-1`, `nl-ams-2`, `pl-waw-1`, `pl-waw-2` | Zone to target. If none is passed will use default zone from the config | + + +**Examples:** + + +Connect to a Redis cluster +``` +scw redis cluster connect 11111111-1111-1111-1111-111111111111 +``` + +Connect to a Redis cluster via private network +``` +scw redis cluster connect 11111111-1111-1111-1111-111111111111 private-network=true +``` + + + + ### Create a Redisā„¢ Database Instance Create a new Redisā„¢ Database Instance (Redisā„¢ cluster). You must set the `zone`, `project_id`, `version`, `node_type`, `user_name` and `password` parameters. Optionally you can define `acl_rules`, `endpoints`, `tls_enabled` and `cluster_settings`. diff --git a/internal/namespaces/redis/v1/custom.go b/internal/namespaces/redis/v1/custom.go index 9d4e79271e..552ba86cd4 100644 --- a/internal/namespaces/redis/v1/custom.go +++ b/internal/namespaces/redis/v1/custom.go @@ -13,7 +13,7 @@ func GetCommands() *core.Commands { human.RegisterMarshalerFunc(redis.Cluster{}, redisClusterGetMarshalerFunc) - cmds.Merge(core.NewCommands(clusterWaitCommand())) + cmds.Merge(core.NewCommands(clusterWaitCommand(), clusterConnectCommand())) cmds.MustFind("redis", "cluster", "create").Override(clusterCreateBuilder) cmds.MustFind("redis", "cluster", "delete").Override(clusterDeleteBuilder) cmds.MustFind("redis", "acl", "add").Override(ACLAddListBuilder) diff --git a/internal/namespaces/redis/v1/custom_cluster.go b/internal/namespaces/redis/v1/custom_cluster.go index b3eedafc5f..7c2ae62e08 100644 --- a/internal/namespaces/redis/v1/custom_cluster.go +++ b/internal/namespaces/redis/v1/custom_cluster.go @@ -3,13 +3,20 @@ package redis import ( "context" "errors" + "fmt" + "io" "net/http" + "os" + "os/exec" + "path/filepath" "reflect" + "strconv" "strings" "time" "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/core/human" + "github.com/scaleway/scaleway-cli/v2/internal/interactive" "github.com/scaleway/scaleway-sdk-go/api/redis/v1" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -316,3 +323,222 @@ func autoCompleteNodeType( return suggestions } + +type clusterConnectArgs struct { + Zone scw.Zone + PrivateNetwork bool + ClusterID string + CliRedis *string + CliArgs []string +} + +const ( + errorMessagePublicEndpointNotFound = "public endpoint not found" + errorMessagePrivateEndpointNotFound = "private endpoint not found" + errorMessageEndpointNotFound = "any endpoint is associated on your cluster" + errorMessageRedisCliNotFound = "redis-cli is not installed. Please install redis-cli to use this command" +) + +func getPublicEndpoint(endpoints []*redis.Endpoint) (*redis.Endpoint, error) { + for _, e := range endpoints { + if e.PublicNetwork != nil { + return e, nil + } + } + + return nil, fmt.Errorf("%s", errorMessagePublicEndpointNotFound) +} + +func getPrivateEndpoint(endpoints []*redis.Endpoint) (*redis.Endpoint, error) { + for _, e := range endpoints { + if e.PrivateNetwork != nil { + return e, nil + } + } + + return nil, fmt.Errorf("%s", errorMessagePrivateEndpointNotFound) +} + +func checkRedisCliInstalled(cliRedis string) error { + cmd := exec.Command(cliRedis, "--version") //nolint:gosec + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s", errorMessageRedisCliNotFound) + } + + return nil +} + +func getRedisZones() []scw.Zone { + // Get zones dynamically from the Redis API SDK + // We create a minimal client just to access the Zones() method + // which doesn't require authentication + client := &scw.Client{} + api := redis.NewAPI(client) + + return api.Zones() +} + +func clusterConnectCommand() *core.Command { + return &core.Command{ + Namespace: "redis", + Resource: "cluster", + Verb: "connect", + Short: "Connect to a Redis cluster using locally installed redis-cli", + Long: "Connect to a Redis cluster using locally installed redis-cli. The command will check if redis-cli is installed, download the certificate if TLS is enabled, and prompt for the password.", + ArgsType: reflect.TypeOf(clusterConnectArgs{}), + ArgSpecs: core.ArgSpecs{ + { + Name: "private-network", + Short: `Connect by the private network endpoint attached.`, + Required: false, + Default: core.DefaultValueSetter("false"), + }, + { + Name: "cluster-id", + Short: `UUID of the cluster`, + Required: true, + Positional: true, + }, + { + Name: "cli-redis", + Short: "Command line tool to use, default to redis-cli", + }, + { + Name: "cli-args", + Short: "Additional arguments to pass to redis-cli", + Required: false, + }, + core.ZoneArgSpec(getRedisZones()...), + }, + Run: func(ctx context.Context, argsI any) (any, error) { + args := argsI.(*clusterConnectArgs) + + cliRedis := "redis-cli" + if args.CliRedis != nil { + cliRedis = *args.CliRedis + } + + if err := checkRedisCliInstalled(cliRedis); err != nil { + return nil, err + } + + client := core.ExtractClient(ctx) + api := redis.NewAPI(client) + cluster, err := api.GetCluster(&redis.GetClusterRequest{ + Zone: args.Zone, + ClusterID: args.ClusterID, + }) + if err != nil { + return nil, err + } + + if len(cluster.Endpoints) == 0 { + return nil, fmt.Errorf("%s", errorMessageEndpointNotFound) + } + + var endpoint *redis.Endpoint + switch { + case args.PrivateNetwork: + endpoint, err = getPrivateEndpoint(cluster.Endpoints) + if err != nil { + return nil, err + } + default: + endpoint, err = getPublicEndpoint(cluster.Endpoints) + if err != nil { + return nil, err + } + } + + if len(endpoint.IPs) == 0 { + return nil, errors.New("endpoint has no IP addresses") + } + + port := endpoint.Port + + var certPath string + if cluster.TLSEnabled { + certResp, err := api.GetClusterCertificate(&redis.GetClusterCertificateRequest{ + Zone: args.Zone, + ClusterID: args.ClusterID, + }) + if err != nil { + return nil, fmt.Errorf("failed to get certificate: %w", err) + } + + certContent, err := io.ReadAll(certResp.Content) + if err != nil { + return nil, fmt.Errorf("failed to read certificate content: %w", err) + } + + tmpDir := os.TempDir() + certPath = filepath.Join(tmpDir, fmt.Sprintf("redis-cert-%s.crt", args.ClusterID)) + if err := os.WriteFile(certPath, certContent, 0o600); err != nil { + return nil, fmt.Errorf("failed to write certificate: %w", err) + } + defer func() { + if err := os.Remove(certPath); err != nil { + core.ExtractLogger(ctx).Debugf("failed to remove certificate file: %v", err) + } + }() + } + + password, err := interactive.PromptPasswordWithConfig(&interactive.PromptPasswordConfig{ + Ctx: ctx, + Prompt: "Password", + }) + if err != nil { + return nil, fmt.Errorf("failed to get password: %w", err) + } + + hostStr := endpoint.IPs[0].String() + cmdArgs := []string{ + cliRedis, + "-h", hostStr, + "-p", strconv.FormatUint(uint64(port), 10), + "-a", password, + } + + if cluster.TLSEnabled { + cmdArgs = append(cmdArgs, "--tls", "--cert", certPath) + } + + if cluster.UserName != "" { + cmdArgs = append(cmdArgs, "--user", cluster.UserName) + } + + // Add any additional arguments passed by the user + if len(args.CliArgs) > 0 { + cmdArgs = append(cmdArgs, args.CliArgs...) + } + + cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) //nolint:gosec + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + core.ExtractLogger(ctx).Debugf("executing: %s\n", cmd.Args) + + if err := cmd.Run(); err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + return nil, &core.CliError{Empty: true, Code: exitError.ExitCode()} + } + + return nil, err + } + + return &core.SuccessResult{ + Empty: true, + }, nil + }, + Examples: []*core.Example{ + { + Short: "Connect to a Redis cluster", + Raw: `scw redis cluster connect 11111111-1111-1111-1111-111111111111`, + }, + { + Short: "Connect to a Redis cluster via private network", + Raw: `scw redis cluster connect 11111111-1111-1111-1111-111111111111 private-network=true`, + }, + }, + } +}