Skip to content

Commit ba5dd25

Browse files
authored
feat(redis): add cluster connect command (#5211)
1 parent 35aa2aa commit ba5dd25

File tree

5 files changed

+296
-1
lines changed

5 files changed

+296
-1
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
2+
🟥🟥🟥 STDERR️️ 🟥🟥🟥️
3+
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.
4+
5+
USAGE:
6+
scw redis cluster connect <cluster-id ...> [arg=value ...]
7+
8+
EXAMPLES:
9+
Connect to a Redis cluster
10+
scw redis cluster connect 11111111-1111-1111-1111-111111111111
11+
12+
Connect to a Redis cluster via private network
13+
scw redis cluster connect 11111111-1111-1111-1111-111111111111 private-network=true
14+
15+
ARGS:
16+
[private-network=false] Connect by the private network endpoint attached.
17+
cluster-id UUID of the cluster
18+
[cli-redis] Command line tool to use, default to redis-cli
19+
[cli-args] Additional arguments to pass to redis-cli
20+
[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)
21+
22+
FLAGS:
23+
-h, --help help for connect
24+
25+
GLOBAL FLAGS:
26+
-c, --config string The path to the config file
27+
-D, --debug Enable debug mode
28+
-o, --output string Output format: json or human, see 'scw help output' for more info (default "human")
29+
-p, --profile string The config profile to use

cmd/scw/testdata/test-all-usage-redis-cluster-usage.golden

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ USAGE:
66
scw redis cluster <command>
77

88
AVAILABLE COMMANDS:
9+
connect Connect to a Redis cluster using locally installed redis-cli
910
create Create a Redis™ Database Instance
1011
delete Delete a Redis™ Database Instance
1112
get Get a Redis™ Database Instance

docs/commands/redis.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This API allows you to manage your Managed Databases for Redis™.
99
- [Set ACL rules for a cluster](#set-acl-rules-for-a-cluster)
1010
- [Update an ACL rule for a Redis™ Database Instance (network rule)](#update-an-acl-rule-for-a-redis™-database-instance-(network-rule))
1111
- [Cluster management commands](#cluster-management-commands)
12+
- [Connect to a Redis cluster using locally installed redis-cli](#connect-to-a-redis-cluster-using-locally-installed-redis-cli)
1213
- [Create a Redis™ Database Instance](#create-a-redis™-database-instance)
1314
- [Delete a Redis™ Database Instance](#delete-a-redis™-database-instance)
1415
- [Get a Redis™ Database Instance](#get-a-redis™-database-instance)
@@ -153,6 +154,44 @@ scw redis acl update <acl-id ...> [arg=value ...]
153154
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.
154155

155156

157+
### Connect to a Redis cluster using locally installed redis-cli
158+
159+
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.
160+
161+
**Usage:**
162+
163+
```
164+
scw redis cluster connect <cluster-id ...> [arg=value ...]
165+
```
166+
167+
168+
**Args:**
169+
170+
| Name | | Description |
171+
|------|---|-------------|
172+
| private-network | Default: `false` | Connect by the private network endpoint attached. |
173+
| cluster-id | Required | UUID of the cluster |
174+
| cli-redis | | Command line tool to use, default to redis-cli |
175+
| cli-args | | Additional arguments to pass to redis-cli |
176+
| zone | Default: `fr-par-1`<br />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 |
177+
178+
179+
**Examples:**
180+
181+
182+
Connect to a Redis cluster
183+
```
184+
scw redis cluster connect 11111111-1111-1111-1111-111111111111
185+
```
186+
187+
Connect to a Redis cluster via private network
188+
```
189+
scw redis cluster connect 11111111-1111-1111-1111-111111111111 private-network=true
190+
```
191+
192+
193+
194+
156195
### Create a Redis™ Database Instance
157196

158197
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`.

internal/namespaces/redis/v1/custom.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ func GetCommands() *core.Commands {
1313

1414
human.RegisterMarshalerFunc(redis.Cluster{}, redisClusterGetMarshalerFunc)
1515

16-
cmds.Merge(core.NewCommands(clusterWaitCommand()))
16+
cmds.Merge(core.NewCommands(clusterWaitCommand(), clusterConnectCommand()))
1717
cmds.MustFind("redis", "cluster", "create").Override(clusterCreateBuilder)
1818
cmds.MustFind("redis", "cluster", "delete").Override(clusterDeleteBuilder)
1919
cmds.MustFind("redis", "acl", "add").Override(ACLAddListBuilder)

internal/namespaces/redis/v1/custom_cluster.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@ package redis
33
import (
44
"context"
55
"errors"
6+
"fmt"
7+
"io"
68
"net/http"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
712
"reflect"
13+
"strconv"
814
"strings"
915
"time"
1016

1117
"github.com/scaleway/scaleway-cli/v2/core"
1218
"github.com/scaleway/scaleway-cli/v2/core/human"
19+
"github.com/scaleway/scaleway-cli/v2/internal/interactive"
1320
"github.com/scaleway/scaleway-sdk-go/api/redis/v1"
1421
"github.com/scaleway/scaleway-sdk-go/scw"
1522
)
@@ -316,3 +323,222 @@ func autoCompleteNodeType(
316323

317324
return suggestions
318325
}
326+
327+
type clusterConnectArgs struct {
328+
Zone scw.Zone
329+
PrivateNetwork bool
330+
ClusterID string
331+
CliRedis *string
332+
CliArgs []string
333+
}
334+
335+
const (
336+
errorMessagePublicEndpointNotFound = "public endpoint not found"
337+
errorMessagePrivateEndpointNotFound = "private endpoint not found"
338+
errorMessageEndpointNotFound = "any endpoint is associated on your cluster"
339+
errorMessageRedisCliNotFound = "redis-cli is not installed. Please install redis-cli to use this command"
340+
)
341+
342+
func getPublicEndpoint(endpoints []*redis.Endpoint) (*redis.Endpoint, error) {
343+
for _, e := range endpoints {
344+
if e.PublicNetwork != nil {
345+
return e, nil
346+
}
347+
}
348+
349+
return nil, fmt.Errorf("%s", errorMessagePublicEndpointNotFound)
350+
}
351+
352+
func getPrivateEndpoint(endpoints []*redis.Endpoint) (*redis.Endpoint, error) {
353+
for _, e := range endpoints {
354+
if e.PrivateNetwork != nil {
355+
return e, nil
356+
}
357+
}
358+
359+
return nil, fmt.Errorf("%s", errorMessagePrivateEndpointNotFound)
360+
}
361+
362+
func checkRedisCliInstalled(cliRedis string) error {
363+
cmd := exec.Command(cliRedis, "--version") //nolint:gosec
364+
if err := cmd.Run(); err != nil {
365+
return fmt.Errorf("%s", errorMessageRedisCliNotFound)
366+
}
367+
368+
return nil
369+
}
370+
371+
func getRedisZones() []scw.Zone {
372+
// Get zones dynamically from the Redis API SDK
373+
// We create a minimal client just to access the Zones() method
374+
// which doesn't require authentication
375+
client := &scw.Client{}
376+
api := redis.NewAPI(client)
377+
378+
return api.Zones()
379+
}
380+
381+
func clusterConnectCommand() *core.Command {
382+
return &core.Command{
383+
Namespace: "redis",
384+
Resource: "cluster",
385+
Verb: "connect",
386+
Short: "Connect to a Redis cluster using locally installed redis-cli",
387+
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.",
388+
ArgsType: reflect.TypeOf(clusterConnectArgs{}),
389+
ArgSpecs: core.ArgSpecs{
390+
{
391+
Name: "private-network",
392+
Short: `Connect by the private network endpoint attached.`,
393+
Required: false,
394+
Default: core.DefaultValueSetter("false"),
395+
},
396+
{
397+
Name: "cluster-id",
398+
Short: `UUID of the cluster`,
399+
Required: true,
400+
Positional: true,
401+
},
402+
{
403+
Name: "cli-redis",
404+
Short: "Command line tool to use, default to redis-cli",
405+
},
406+
{
407+
Name: "cli-args",
408+
Short: "Additional arguments to pass to redis-cli",
409+
Required: false,
410+
},
411+
core.ZoneArgSpec(getRedisZones()...),
412+
},
413+
Run: func(ctx context.Context, argsI any) (any, error) {
414+
args := argsI.(*clusterConnectArgs)
415+
416+
cliRedis := "redis-cli"
417+
if args.CliRedis != nil {
418+
cliRedis = *args.CliRedis
419+
}
420+
421+
if err := checkRedisCliInstalled(cliRedis); err != nil {
422+
return nil, err
423+
}
424+
425+
client := core.ExtractClient(ctx)
426+
api := redis.NewAPI(client)
427+
cluster, err := api.GetCluster(&redis.GetClusterRequest{
428+
Zone: args.Zone,
429+
ClusterID: args.ClusterID,
430+
})
431+
if err != nil {
432+
return nil, err
433+
}
434+
435+
if len(cluster.Endpoints) == 0 {
436+
return nil, fmt.Errorf("%s", errorMessageEndpointNotFound)
437+
}
438+
439+
var endpoint *redis.Endpoint
440+
switch {
441+
case args.PrivateNetwork:
442+
endpoint, err = getPrivateEndpoint(cluster.Endpoints)
443+
if err != nil {
444+
return nil, err
445+
}
446+
default:
447+
endpoint, err = getPublicEndpoint(cluster.Endpoints)
448+
if err != nil {
449+
return nil, err
450+
}
451+
}
452+
453+
if len(endpoint.IPs) == 0 {
454+
return nil, errors.New("endpoint has no IP addresses")
455+
}
456+
457+
port := endpoint.Port
458+
459+
var certPath string
460+
if cluster.TLSEnabled {
461+
certResp, err := api.GetClusterCertificate(&redis.GetClusterCertificateRequest{
462+
Zone: args.Zone,
463+
ClusterID: args.ClusterID,
464+
})
465+
if err != nil {
466+
return nil, fmt.Errorf("failed to get certificate: %w", err)
467+
}
468+
469+
certContent, err := io.ReadAll(certResp.Content)
470+
if err != nil {
471+
return nil, fmt.Errorf("failed to read certificate content: %w", err)
472+
}
473+
474+
tmpDir := os.TempDir()
475+
certPath = filepath.Join(tmpDir, fmt.Sprintf("redis-cert-%s.crt", args.ClusterID))
476+
if err := os.WriteFile(certPath, certContent, 0o600); err != nil {
477+
return nil, fmt.Errorf("failed to write certificate: %w", err)
478+
}
479+
defer func() {
480+
if err := os.Remove(certPath); err != nil {
481+
core.ExtractLogger(ctx).Debugf("failed to remove certificate file: %v", err)
482+
}
483+
}()
484+
}
485+
486+
password, err := interactive.PromptPasswordWithConfig(&interactive.PromptPasswordConfig{
487+
Ctx: ctx,
488+
Prompt: "Password",
489+
})
490+
if err != nil {
491+
return nil, fmt.Errorf("failed to get password: %w", err)
492+
}
493+
494+
hostStr := endpoint.IPs[0].String()
495+
cmdArgs := []string{
496+
cliRedis,
497+
"-h", hostStr,
498+
"-p", strconv.FormatUint(uint64(port), 10),
499+
"-a", password,
500+
}
501+
502+
if cluster.TLSEnabled {
503+
cmdArgs = append(cmdArgs, "--tls", "--cert", certPath)
504+
}
505+
506+
if cluster.UserName != "" {
507+
cmdArgs = append(cmdArgs, "--user", cluster.UserName)
508+
}
509+
510+
// Add any additional arguments passed by the user
511+
if len(args.CliArgs) > 0 {
512+
cmdArgs = append(cmdArgs, args.CliArgs...)
513+
}
514+
515+
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) //nolint:gosec
516+
cmd.Stdin = os.Stdin
517+
cmd.Stdout = os.Stdout
518+
cmd.Stderr = os.Stderr
519+
core.ExtractLogger(ctx).Debugf("executing: %s\n", cmd.Args)
520+
521+
if err := cmd.Run(); err != nil {
522+
if exitError, ok := err.(*exec.ExitError); ok {
523+
return nil, &core.CliError{Empty: true, Code: exitError.ExitCode()}
524+
}
525+
526+
return nil, err
527+
}
528+
529+
return &core.SuccessResult{
530+
Empty: true,
531+
}, nil
532+
},
533+
Examples: []*core.Example{
534+
{
535+
Short: "Connect to a Redis cluster",
536+
Raw: `scw redis cluster connect 11111111-1111-1111-1111-111111111111`,
537+
},
538+
{
539+
Short: "Connect to a Redis cluster via private network",
540+
Raw: `scw redis cluster connect 11111111-1111-1111-1111-111111111111 private-network=true`,
541+
},
542+
},
543+
}
544+
}

0 commit comments

Comments
 (0)