diff --git a/cli/aeon/cmd/connect.go b/cli/aeon/cmd/connect.go new file mode 100644 index 000000000..cdb3b80a2 --- /dev/null +++ b/cli/aeon/cmd/connect.go @@ -0,0 +1,19 @@ +package cmd + +// Ssl structure groups paths to ssl key files. +type Ssl struct { + // KeyFile path to the private SSL key file (optional). + KeyFile string + // CertFile path to the SSL certificate file (optional). + CertFile string + // CaFile path to the trusted certificate authorities (CA) file (optional). + CaFile string +} + +// ConnectCtx keeps context information for aeon connection. +type ConnectCtx struct { + // Ssl group of paths to ssl key files. + Ssl Ssl + // Transport is a connection mode. + Transport Transport +} diff --git a/cli/aeon/cmd/transport.go b/cli/aeon/cmd/transport.go new file mode 100644 index 000000000..771dc8d96 --- /dev/null +++ b/cli/aeon/cmd/transport.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + "slices" + + "golang.org/x/exp/maps" +) + +// Transport is a type, with a restriction on the list of supported connection modes. +type Transport string + +// String is used both by fmt.Print and by Cobra in help text. +func (t Transport) String() string { + return string(t) +} + +// Type is only used in Cobra help text. +func (t Transport) Type() string { + return "MODE" +} + +const ( + // TransportPlain used as a default insecure transport mode. + TransportPlain Transport = "plain" + + // TransportSsl used for encrypted connection mode. + TransportSsl Transport = "ssl" +) + +// ValidTransport is a list of supported transports with its Cobra helping information. +var ValidTransport = map[Transport]string{ + TransportPlain: "unsafe connection mode", + TransportSsl: "secure encrypted connection", +} + +// Set ensures valid value is applied. +func (t *Transport) Set(v string) error { + _, ok := ValidTransport[Transport(v)] + if !ok { + return fmt.Errorf(`must be %s`, ListValidTransports()) + } + *t = Transport(v) + return nil +} + +// ListValidTransports returns string representation with list of supported transport modes. +func ListValidTransports() string { + ks := maps.Keys(ValidTransport) + slices.Sort(ks) + return fmt.Sprintf("%v", ks) +} diff --git a/cli/aeon/cmd/transport_test.go b/cli/aeon/cmd/transport_test.go new file mode 100644 index 000000000..5f06a01c2 --- /dev/null +++ b/cli/aeon/cmd/transport_test.go @@ -0,0 +1,49 @@ +package cmd_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/tarantool/tt/cli/aeon/cmd" +) + +func TestTransport_Set(t *testing.T) { + tests := []struct { + val string + want cmd.Transport + wantErr bool + }{ + {"plain", cmd.Transport("plain"), false}, + {"ssl", cmd.Transport("ssl"), false}, + {"", cmd.Transport(""), true}, + {"mode", cmd.Transport(""), true}, + } + for _, tt := range tests { + t.Run(string(tt.val), func(t *testing.T) { + var tr cmd.Transport + if err := tr.Set(tt.val); (err != nil) != tt.wantErr { + t.Errorf("Transport.Set() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestTransport_Type(t *testing.T) { + tests := []cmd.Transport{ + "plain", + "ssl", + "", + } + for _, tt := range tests { + t.Run(string(tt), func(t *testing.T) { + if got := tt.Type(); got != "MODE" { + t.Errorf("Transport.Type() = %v, want MODE", got) + } + }) + } +} + +func TestListValidTransports(t *testing.T) { + ts := cmd.ListValidTransports() + require.Equal(t, "[plain ssl]", ts) +} diff --git a/cli/cmd/aeon.go b/cli/cmd/aeon.go new file mode 100644 index 000000000..0e41f0d4d --- /dev/null +++ b/cli/cmd/aeon.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + aeon "github.com/tarantool/tt/cli/aeon/cmd" + "github.com/tarantool/tt/cli/cmdcontext" + "github.com/tarantool/tt/cli/modules" + "github.com/tarantool/tt/cli/util" + libconnect "github.com/tarantool/tt/lib/connect" +) + +var aeonConnectCtx = aeon.ConnectCtx{ + Transport: aeon.TransportPlain, +} + +func newAeonConnectCmd() *cobra.Command { + var aeonCmd = &cobra.Command{ + Use: "connect URI", + Short: "Connect to the aeon instance", + Long: "Connect to the aeon instance.\n\n" + + libconnect.EnvCredentialsHelp + "\n\n" + + `tt aeon connect user:pass@localhost:3013`, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := aeonConnectValidateArgs(cmd, args) + util.HandleCmdErr(cmd, err) + return err + }, + Run: func(cmd *cobra.Command, args []string) { + cmdCtx.CommandName = cmd.Name() + err := modules.RunCmd(&cmdCtx, cmd.CommandPath(), &modulesInfo, + internalAeonConnect, args) + util.HandleCmdErr(cmd, err) + }, + } + aeonCmd.Flags().StringVar(&aeonConnectCtx.Ssl.KeyFile, "sslkeyfile", "", + "path to a private SSL key file") + aeonCmd.Flags().StringVar(&aeonConnectCtx.Ssl.CertFile, "sslcertfile", "", + "path to a SSL certificate file") + aeonCmd.Flags().StringVar(&aeonConnectCtx.Ssl.CaFile, "sslcafile", "", + "path to a trusted certificate authorities (CA) file") + + aeonCmd.Flags().Var(&aeonConnectCtx.Transport, "transport", + fmt.Sprintf("allowed %s", aeon.ListValidTransports())) + aeonCmd.RegisterFlagCompletionFunc("transport", aeonTransportCompletion) + + return aeonCmd +} + +func aeonTransportCompletion(cmd *cobra.Command, args []string, toComplete string) ( + []string, cobra.ShellCompDirective) { + suggest := make([]string, 0, len(aeon.ValidTransport)) + for k, v := range aeon.ValidTransport { + suggest = append(suggest, string(k)+"\t"+v) + } + return suggest, cobra.ShellCompDirectiveDefault +} + +// NewAeonCmd() create new aeon command. +func NewAeonCmd() *cobra.Command { + var aeonCmd = &cobra.Command{ + Use: "aeon", + Short: "Manage aeon application", + } + aeonCmd.AddCommand( + newAeonConnectCmd(), + ) + return aeonCmd +} + +func aeonConnectValidateArgs(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("transport") && (aeonConnectCtx.Ssl.KeyFile != "" || + aeonConnectCtx.Ssl.CertFile != "" || aeonConnectCtx.Ssl.CaFile != "") { + aeonConnectCtx.Transport = aeon.TransportSsl + } + + checkFile := func(path string) bool { + return path == "" || util.IsRegularFile(path) + } + + if aeonConnectCtx.Transport != aeon.TransportPlain { + if cmd.Flags().Changed("sslkeyfile") != cmd.Flags().Changed("sslcertfile") { + return errors.New("files Key and Cert must be specified both") + } + + if !checkFile(aeonConnectCtx.Ssl.KeyFile) { + return fmt.Errorf("not valid path to a private SSL key file=%q", + aeonConnectCtx.Ssl.KeyFile) + } + if !checkFile(aeonConnectCtx.Ssl.CertFile) { + return fmt.Errorf("not valid path to an SSL certificate file=%q", + aeonConnectCtx.Ssl.CertFile) + } + if !checkFile(aeonConnectCtx.Ssl.CaFile) { + return fmt.Errorf("not valid path to trusted certificate authorities (CA) file=%q", + aeonConnectCtx.Ssl.CaFile) + } + } + return nil +} + +func internalAeonConnect(cmdCtx *cmdcontext.CmdCtx, args []string) error { + return nil +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 1b69240cc..d09c171d7 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -194,6 +194,7 @@ After that tt will be able to manage the application using 'replicaset_example' NewKillCmd(), NewLogCmd(), NewEnableCmd(), + NewAeonCmd(), ) if err := injectCmds(rootCmd); err != nil { panic(err.Error()) diff --git a/test/integration/aeon/test_aeon.py b/test/integration/aeon/test_aeon.py new file mode 100644 index 000000000..faeb567cb --- /dev/null +++ b/test/integration/aeon/test_aeon.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +from pathlib import Path +from subprocess import PIPE, STDOUT, run + +import pytest + +AeonConnectCommand = ("aeon", "connect") + +FormatData = { + "testdata": Path(__file__).parent / "testdata", +} + + +@pytest.mark.parametrize( + "args", + [ + (), + ("--transport", "plain"), + ("--transport=plain"), + # "plain" mode ignores any ssl flags values. + ("--transport", "plain", "--sslkeyfile", "not-exits.key"), + ("--transport", "plain", "--sslcertfile", "not-exits.key"), + ("--transport", "plain", "--sslcafile", "not-exits.key"), + ("--transport", "plain", "--sslkeyfile", "{testdata}/private.key"), + ("--transport", "plain", "--sslcertfile", "{testdata}/certfile.key"), + ("--transport", "plain", "--sslcafile", "{testdata}/ca.key"), + ( + "--sslkeyfile={testdata}/private.key", + "--sslcertfile={testdata}/certfile.key", + ), + ( + # "ssl" mode require existed path to files. + "--transport=ssl", + "--sslkeyfile={testdata}/private.key", + "--sslcertfile={testdata}/certfile.key", + "--sslcafile={testdata}/ca.key", + ), + ], +) +def test_cli_arguments_success(tt_cmd, args): + args = (a.format(**FormatData) for a in args) + result = run((tt_cmd, *AeonConnectCommand, *args)) + assert result.returncode == 0 + + +@pytest.mark.parametrize( + "args, error", + [ + ( + ("--transport", "mode"), + 'Error: invalid argument "mode" for "--transport" flag', + ), + ( + ( + "--transport=ssl", + "--sslkeyfile=not-exits.key", + "--sslcertfile={testdata}/certfile.key", + "--sslcafile={testdata}/ca.key", + ), + 'not valid path to a private SSL key file="not-exits.key"', + ), + ( + ( + "--transport=ssl", + "--sslkeyfile={testdata}/private.key", + "--sslcertfile=not-exits.key", + "--sslcafile={testdata}/ca.key", + ), + 'not valid path to an SSL certificate file="not-exits.key"', + ), + ( + ( + "--transport=ssl", + "--sslkeyfile={testdata}/private.key", + "--sslcertfile={testdata}/certfile.key", + "--sslcafile=not-exits.key", + ), + 'not valid path to trusted certificate authorities (CA) file="not-exits.key"', + ), + ( + ("--sslcafile=not-exits.key",), + 'not valid path to trusted certificate authorities (CA) file="not-exits.key"', + ), + ( + ( + "--transport=ssl", + "--sslcertfile={testdata}/certfile.key", + "--sslcafile={testdata}/ca.key", + ), + "files Key and Cert must be specified both", + ), + ( + ( + "--transport=ssl", + "--sslkeyfile={testdata}/private.key", + "--sslcafile={testdata}/ca.key", + ), + "files Key and Cert must be specified both", + ), + ( + ( + "--transport=ssl", + "--sslcertfile={testdata}/certfile.key", + "--sslcafile={testdata}/ca.key", + "--sslkeyfile", + ), + "flag needs an argument: --sslkeyfile", + ), + ( + ( + "--transport=ssl", + "--sslkeyfile={testdata}/private.key", + "--sslcafile={testdata}/ca.key", + "--sslcertfile", + ), + "flag needs an argument: --sslcertfile", + ), + ( + ( + "--transport=ssl", + "--sslkeyfile={testdata}/private.key", + "--sslcertfile={testdata}/certfile.key", + "--sslcafile", + ), + "flag needs an argument: --sslcafile", + ), + ], +) +def test_cli_arguments_fail(tt_cmd, args, error): + args = (a.format(**FormatData) for a in args) + result = run( + (tt_cmd, *AeonConnectCommand, *args), + stderr=STDOUT, + stdout=PIPE, + text=True, + ) + assert result.returncode != 0 + assert error in result.stdout diff --git a/test/integration/aeon/testdata/ca.key b/test/integration/aeon/testdata/ca.key new file mode 100644 index 000000000..93d503f39 --- /dev/null +++ b/test/integration/aeon/testdata/ca.key @@ -0,0 +1 @@ +# trusted certificate authorities (CA) file diff --git a/test/integration/aeon/testdata/certfile.key b/test/integration/aeon/testdata/certfile.key new file mode 100644 index 000000000..eb26018b7 --- /dev/null +++ b/test/integration/aeon/testdata/certfile.key @@ -0,0 +1 @@ +# SSL certificate file diff --git a/test/integration/aeon/testdata/private.key b/test/integration/aeon/testdata/private.key new file mode 100644 index 000000000..522386ee1 --- /dev/null +++ b/test/integration/aeon/testdata/private.key @@ -0,0 +1 @@ +# private SSL key file