Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions cmd/scw/testdata/test-all-usage-rdb-instance-connect-usage.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
🟥🟥🟥 STDERR️️ 🟥🟥🟥️
Connect to an instance using locally installed CLI such as psql or mysql.

USAGE:
scw rdb instance connect <instance-id ...> [arg=value ...]

ARGS:
instance-id UUID of the instance
username Name of the user to connect with to the database
[database=rdb] Name of the database
[cli-db] Command line tool to use, default to psql/mysql
[region=fr-par] Region to target. If none is passed will use default region from the config (fr-par | nl-ams)

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
1 change: 1 addition & 0 deletions cmd/scw/testdata/test-all-usage-rdb-instance-usage.golden
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ USAGE:

AVAILABLE COMMANDS:
clone Clone an instance
connect Connect to an instance using locally installed CLI
create Create an instance
delete Delete an instance
get Get an instance
Expand Down
1 change: 1 addition & 0 deletions internal/namespaces/rdb/v1/custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func GetCommands() *core.Commands {

cmds.Merge(core.NewCommands(
instanceWaitCommand(),
instanceConnectCommand(),
))
cmds.MustFind("rdb", "instance", "create").Override(instanceCreateBuilder)
cmds.MustFind("rdb", "instance", "clone").Override(instanceCloneBuilder)
Expand Down
192 changes: 192 additions & 0 deletions internal/namespaces/rdb/v1/custom_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ package rdb

import (
"context"
"fmt"
"os"
"os/exec"
"path"
"reflect"
"runtime"
"strings"
"time"

"github.com/scaleway/scaleway-cli/internal/core"
"github.com/scaleway/scaleway-cli/internal/human"
"github.com/scaleway/scaleway-cli/internal/interactive"
"github.com/scaleway/scaleway-sdk-go/api/rdb/v1"
"github.com/scaleway/scaleway-sdk-go/scw"
)
Expand Down Expand Up @@ -165,3 +171,189 @@ func instanceWaitCommand() *core.Command {
},
}
}

type instanceConnectArgs struct {
Region scw.Region
InstanceID string
Username string
Database *string
CliDB *string
}

type engineFamily string

const (
Unknown = engineFamily("Unknown")
PostgreSQL = engineFamily("PostgreSQL")
MySQL = engineFamily("MySQL")
postgreSQLHint = `
psql supports password file to avoid typing your password manually.
Learn more at: https://www.postgresql.org/docs/current/libpq-pgpass.html`
mySQLHint = `
mysql supports loading your password from a file to avoid typing them manually.
Learn more at: https://dev.mysql.com/doc/refman/8.0/en/option-files.html`
)

func passwordFileExist(ctx context.Context, family engineFamily) bool {
passwordFilePath := ""
switch family {
case PostgreSQL:
switch runtime.GOOS {
case "windows":
passwordFilePath = path.Join(core.ExtractUserHomeDir(ctx), core.ExtractEnv(ctx, "APPDATA"), "postgresql", "pgpass.conf")
default:
passwordFilePath = path.Join(core.ExtractUserHomeDir(ctx), ".pgpass")
}
case MySQL:
passwordFilePath = path.Join(core.ExtractUserHomeDir(ctx), ".my.cnf")
default:
return false
}
if passwordFilePath == "" {
return false
}
_, err := os.Stat(passwordFilePath)
return err == nil
}

func passwordFileHint(family engineFamily) string {
switch family {
case PostgreSQL:
return postgreSQLHint
case MySQL:
return mySQLHint
default:
return ""
}
}

func detectEngineFamily(instance *rdb.Instance) (engineFamily, error) {
if instance == nil {
return Unknown, fmt.Errorf("instance engine is nil")
}
if strings.HasPrefix(instance.Engine, string(PostgreSQL)) {
return PostgreSQL, nil
}
if strings.HasPrefix(instance.Engine, string(MySQL)) {
return MySQL, nil
}
return Unknown, fmt.Errorf("unknown engine: %s", instance.Engine)
}

func createConnectCommandLineArgs(instance *rdb.Instance, family engineFamily, args *instanceConnectArgs) ([]string, error) {
database := "rdb"
if args.Database != nil {
database = *args.Database
}

switch family {
case PostgreSQL:
clidb := "psql"
if args.CliDB != nil {
clidb = *args.CliDB
}

// psql -h 51.159.25.206 --port 13917 -d rdb -U username
return []string{
clidb,
"--host", instance.Endpoint.IP.String(),
"--port", fmt.Sprintf("%d", instance.Endpoint.Port),
"--username", args.Username,
"--dbname", database,
}, nil
case MySQL:
clidb := "mysql"
if args.CliDB != nil {
clidb = *args.CliDB
}

// mysql -h 195.154.69.163 --port 12210 -p -u username
return []string{
clidb,
"--host", instance.Endpoint.IP.String(),
"--port", fmt.Sprintf("%d", instance.Endpoint.Port),
"--database", database,
"--user", args.Username,
}, nil
}

return nil, fmt.Errorf("unrecognize database engine: %s", instance.Engine)
}

func instanceConnectCommand() *core.Command {
return &core.Command{
Namespace: "rdb",
Resource: "instance",
Verb: "connect",
Short: "Connect to an instance using locally installed CLI",
Long: "Connect to an instance using locally installed CLI such as psql or mysql.",
ArgsType: reflect.TypeOf(instanceConnectArgs{}),
ArgSpecs: core.ArgSpecs{
{
Name: "instance-id",
Short: `UUID of the instance`,
Required: true,
Positional: true,
},
{
Name: "username",
Short: "Name of the user to connect with to the database",
Required: true,
},
{
Name: "database",
Short: "Name of the database",
Default: core.DefaultValueSetter("rdb"),
},
{
Name: "cli-db",
Short: "Command line tool to use, default to psql/mysql",
},
core.RegionArgSpec(scw.RegionFrPar, scw.RegionNlAms),
},
Run: func(ctx context.Context, argsI interface{}) (interface{}, error) {
args := argsI.(*instanceConnectArgs)

client := core.ExtractClient(ctx)
api := rdb.NewAPI(client)
instance, err := api.GetInstance(&rdb.GetInstanceRequest{
Region: args.Region,
InstanceID: args.InstanceID,
})
if err != nil {
return nil, err
}

engineFamily, err := detectEngineFamily(instance)
if err != nil {
return nil, err
}

cmdArgs, err := createConnectCommandLineArgs(instance, engineFamily, args)
if err != nil {
return nil, err
}

if !passwordFileExist(ctx, engineFamily) {
interactive.Println(passwordFileHint(engineFamily))
}

// Run command
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) //nolint:gosec
//cmd.Stdin = os.Stdin
core.ExtractLogger(ctx).Debugf("executing: %s\n", cmd.Args)
exitCode, err := core.ExecCmd(ctx, cmd)

if err != nil {
return nil, err
}
if exitCode != 0 {
return nil, &core.CliError{Empty: true, Code: exitCode}
}

return &core.SuccessResult{
Empty: true, // the program will output the success message
}, nil
},
}
}
44 changes: 41 additions & 3 deletions internal/namespaces/rdb/v1/custom_instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
func Test_CloneInstance(t *testing.T) {
t.Run("Simple", core.Test(&core.TestConfig{
Commands: GetCommands(),
BeforeFunc: createInstance(),
BeforeFunc: createInstance("PostgreSQL-12"),
Cmd: "scw rdb instance clone {{ .Instance.ID }} node-type=DB-DEV-M name=foobar --wait",
Check: core.TestCheckGolden(),
AfterFunc: deleteInstance(),
Expand All @@ -29,7 +29,7 @@ func Test_CreateInstance(t *testing.T) {
func Test_GetInstance(t *testing.T) {
t.Run("Simple", core.Test(&core.TestConfig{
Commands: GetCommands(),
BeforeFunc: createInstance(),
BeforeFunc: createInstance("PostgreSQL-12"),
Cmd: "scw rdb instance get {{ .Instance.ID }}",
Check: core.TestCheckGolden(),
AfterFunc: deleteInstance(),
Expand All @@ -39,9 +39,47 @@ func Test_GetInstance(t *testing.T) {
func Test_UpgradeInstance(t *testing.T) {
t.Run("Simple", core.Test(&core.TestConfig{
Commands: GetCommands(),
BeforeFunc: createInstance(),
BeforeFunc: createInstance("PostgreSQL-12"),
Cmd: "scw rdb instance upgrade {{ .Instance.ID }} node-type=DB-DEV-M --wait",
Check: core.TestCheckGolden(),
AfterFunc: deleteInstance(),
}))
}

func Test_Connect(t *testing.T) {
t.Run("mysql", core.Test(&core.TestConfig{
Commands: GetCommands(),
BeforeFunc: core.BeforeFuncCombine(
func(ctx *core.BeforeFuncCtx) error {
ctx.Meta["username"] = user
return nil
},
createInstance("MySQL-8"),
),
Cmd: "scw rdb instance connect {{ .Instance.ID }} username={{ .username }}",
Check: core.TestCheckCombine(
core.TestCheckGolden(),
core.TestCheckExitCode(0),
),
OverrideExec: core.OverrideExecSimple("mysql --host {{ .Instance.Endpoint.IP }} --port {{ .Instance.Endpoint.Port }} --database rdb --user {{ .username }}", 0),
AfterFunc: deleteInstance(),
}))

t.Run("psql", core.Test(&core.TestConfig{
Commands: GetCommands(),
BeforeFunc: core.BeforeFuncCombine(
func(ctx *core.BeforeFuncCtx) error {
ctx.Meta["username"] = user
return nil
},
createInstance("PostgreSQL-12"),
),
Cmd: "scw rdb instance connect {{ .Instance.ID }} username={{ .username }}",
Check: core.TestCheckCombine(
core.TestCheckGolden(),
core.TestCheckExitCode(0),
),
OverrideExec: core.OverrideExecSimple("psql --host {{ .Instance.Endpoint.IP }} --port {{ .Instance.Endpoint.Port }} --username {{ .username }} --dbname rdb", 0),
AfterFunc: deleteInstance(),
}))
}
2 changes: 1 addition & 1 deletion internal/namespaces/rdb/v1/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const (
engine = "PostgreSQL-12"
)

func createInstance() core.BeforeFunc {
func createInstance(engine string) core.BeforeFunc {
return core.ExecStoreBeforeCmd(
"Instance",
fmt.Sprintf("scw rdb instance create node-type=DB-DEV-S is-ha-cluster=false name=%s engine=%s user-name=%s password=%s --wait", name, engine, user, password),
Expand Down
Loading