Skip to content

Commit 384c8b6

Browse files
committed
feat(redis): add cluster connect command
1 parent 10a3065 commit 384c8b6

File tree

2 files changed

+212
-1
lines changed

2 files changed

+212
-1
lines changed

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

0 commit comments

Comments
 (0)