@@ -3,13 +3,20 @@ package redis
33import (
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