diff --git a/CHANGELOG.md b/CHANGELOG.md index 298b1b8fa..fa6b46c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ running apps. - `tt env`: add current environment binaries location to the PATH variable. - `tt cluster`: add an ability to specify a key for `show`/`publish` via URI. - `tt cluster`: add an ability to publish/show configuration from tarantool -config storage via URI. +config storage. ## [2.0.0] - 2023-11-13 diff --git a/cli/cluster/cluster.go b/cli/cluster/cluster.go index f36de5aaa..f62c63f27 100644 --- a/cli/cluster/cluster.go +++ b/cli/cluster/cluster.go @@ -6,6 +6,9 @@ import ( "time" "gopkg.in/yaml.v2" + + "github.com/tarantool/tt/cli/connect" + "github.com/tarantool/tt/cli/connector" ) const ( @@ -153,6 +156,24 @@ type ClusterConfig struct { } `yaml:"request"` } `yaml:"http"` } `yaml:"etcd"` + Storage struct { + Prefix string `yaml:"prefix"` + Timeout float64 `yaml:"timeout"` + Endpoints []struct { + Uri string `yaml:"uri"` + Login string `yaml:"login"` + Password string `yaml:"password"` + Params struct { + Transport string `yaml:"transport"` + SslKeyFile string `yaml:"ssl_key_file"` + SslCertFile string `yaml:"ssl_cert_file"` + SslCaFile string `yaml:"ssl_ca_file"` + SslCiphers string `yaml:"ssl_ciphers"` + SslPassword string `yaml:"ssl_password"` + SslPasswordFile string `yaml:"ssl_password_file"` + } `yaml:"params"` + } `yaml:"endpoints"` + } `yaml:"storage"` } `yaml:"config"` // RawConfig is a configuration of the global scope. RawConfig *Config `yaml:"-"` @@ -305,13 +326,67 @@ func collectEtcdConfig(clusterConfig ClusterConfig) (*Config, error) { if err != nil { return nil, fmt.Errorf("unable to connect to etcd: %w", err) } + defer etcd.Close() etcdCollector := NewEtcdAllCollector(etcd, etcdConfig.Prefix, opts.Timeout) etcdRawConfig, err := etcdCollector.Collect() if err != nil { return nil, fmt.Errorf("unable to get config from etcd: %w", err) } - return etcdRawConfig, err + return etcdRawConfig, nil +} + +// collectTarantoolConfig collects a configuration from tarantool config +// storage with options from the tarantool configuration. +func collectTarantoolConfig(clusterConfig ClusterConfig) (*Config, error) { + tarantoolConfig := clusterConfig.Config.Storage + var opts []connector.ConnectOpts + for _, endpoint := range tarantoolConfig.Endpoints { + var network, address string + if !connect.IsBaseURI(endpoint.Uri) { + network = connector.TCPNetwork + address = endpoint.Uri + } else { + network, address = connect.ParseBaseURI(endpoint.Uri) + } + + if endpoint.Params.Transport == "" || endpoint.Params.Transport != "ssl" { + opts = append(opts, connector.ConnectOpts{ + Network: network, + Address: address, + Username: endpoint.Login, + Password: endpoint.Password, + }) + } else { + opts = append(opts, connector.ConnectOpts{ + Network: network, + Address: address, + Username: endpoint.Login, + Password: endpoint.Password, + Ssl: connector.SslOpts{ + KeyFile: endpoint.Params.SslKeyFile, + CertFile: endpoint.Params.SslCertFile, + CaFile: endpoint.Params.SslCaFile, + Ciphers: endpoint.Params.SslCiphers, + }, + }) + } + } + + pool, err := connector.ConnectPool(opts) + if err != nil { + return nil, fmt.Errorf("unable to connect to tarantool config storage: %w", err) + } + defer pool.Close() + + tarantoolCollector := NewTarantoolAllCollector(pool, + tarantoolConfig.Prefix, + time.Duration(tarantoolConfig.Timeout*float64(time.Second))) + tarantoolRawConfig, err := tarantoolCollector.Collect() + if err != nil { + return nil, fmt.Errorf("failed to get config from tarantool config storage: %w", err) + } + return tarantoolRawConfig, nil } // GetClusterConfig returns a cluster configuration loaded from a path to @@ -353,6 +428,14 @@ func GetClusterConfig(path string) (ClusterConfig, error) { config.Merge(etcdConfig) } + if len(clusterConfig.Config.Storage.Endpoints) > 0 { + tarantoolConfig, err := collectTarantoolConfig(clusterConfig) + if err != nil { + return ret, err + } + config.Merge(tarantoolConfig) + } + defaultEnvConfig, err := defaultEnvCollector.Collect() if err != nil { fmtErr := "failed to collect a config from default environment variables: %w" diff --git a/cli/cluster/cluster_test.go b/cli/cluster/cluster_test.go index f5232d9a8..78b65a97b 100644 --- a/cli/cluster/cluster_test.go +++ b/cli/cluster/cluster_test.go @@ -91,17 +91,47 @@ func TestMakeClusterConfig_settings(t *testing.T) { expected.RawConfig = config expected.Groups = nil expected.Config.Etcd.Endpoints = []string{"a", "b"} - expected.Config.Etcd.Username = "user" - expected.Config.Etcd.Password = "pass" - expected.Config.Etcd.Prefix = "/prefix" - expected.Config.Etcd.Ssl.KeyFile = "keyfile" - expected.Config.Etcd.Ssl.CertFile = "certfile" - expected.Config.Etcd.Ssl.CaPath = "cafile" - expected.Config.Etcd.Ssl.CaFile = "capath" + expected.Config.Etcd.Username = "etcd_user" + expected.Config.Etcd.Password = "etcd_pass" + expected.Config.Etcd.Prefix = "/etcd_prefix" + expected.Config.Etcd.Ssl.KeyFile = "etcd_keyfile" + expected.Config.Etcd.Ssl.CertFile = "etcd_certfile" + expected.Config.Etcd.Ssl.CaPath = "etcd_cafile" + expected.Config.Etcd.Ssl.CaFile = "etcd_capath" expected.Config.Etcd.Ssl.VerifyPeer = true expected.Config.Etcd.Ssl.VerifyHost = true expected.Config.Etcd.Http.Request.Timeout = 123 + expected.Config.Storage.Prefix = "/tt_prefix" + expected.Config.Storage.Timeout = 234 + expected.Config.Storage.Endpoints = []struct { + Uri string `yaml:"uri"` + Login string `yaml:"login"` + Password string `yaml:"password"` + Params struct { + Transport string `yaml:"transport"` + SslKeyFile string `yaml:"ssl_key_file"` + SslCertFile string `yaml:"ssl_cert_file"` + SslCaFile string `yaml:"ssl_ca_file"` + SslCiphers string `yaml:"ssl_ciphers"` + SslPassword string `yaml:"ssl_password"` + SslPasswordFile string `yaml:"ssl_password_file"` + } `yaml:"params"` + }{ + { + Uri: "tt_uri", + Login: "tt_login", + Password: "tt_password", + }, + } + expected.Config.Storage.Endpoints[0].Params.Transport = "tt_transport" + expected.Config.Storage.Endpoints[0].Params.SslKeyFile = "tt_key_file" + expected.Config.Storage.Endpoints[0].Params.SslCertFile = "tt_cert_file" + expected.Config.Storage.Endpoints[0].Params.SslCaFile = "tt_ca_file" + expected.Config.Storage.Endpoints[0].Params.SslCiphers = "tt_ciphers" + expected.Config.Storage.Endpoints[0].Params.SslPassword = "tt_password" + expected.Config.Storage.Endpoints[0].Params.SslPasswordFile = "tt_password_file" + config.Set([]string{"config", "etcd", "endpoints"}, []any{expected.Config.Etcd.Endpoints[0], expected.Config.Etcd.Endpoints[1]}) config.Set([]string{"config", "etcd", "username"}, @@ -125,6 +155,30 @@ func TestMakeClusterConfig_settings(t *testing.T) { config.Set([]string{"config", "etcd", "http", "request", "timeout"}, int(expected.Config.Etcd.Http.Request.Timeout)) + config.Set([]string{"config", "storage", "prefix"}, + expected.Config.Storage.Prefix) + config.Set([]string{"config", "storage", "timeout"}, + int(expected.Config.Storage.Timeout)) + + config.Set([]string{"config", "storage", "endpoints"}, + []any{ + map[any]any{ + "uri": "tt_uri", + "login": "tt_login", + "password": "tt_password", + "params": map[any]any{ + "transport": "tt_transport", + "ssl_key_file": "tt_key_file", + "ssl_cert_file": "tt_cert_file", + "ssl_ca_file": "tt_ca_file", + "ssl_ciphers": "tt_ciphers", + "ssl_password": "tt_password", + "ssl_password_file": "tt_password_file", + }, + }, + }, + ) + cconfig, err := cluster.MakeClusterConfig(config) require.NoError(t, err) assert.Equal(t, expected.Config, cconfig.Config) diff --git a/cli/cluster/cmd/uri.go b/cli/cluster/cmd/uri.go index 4fecc24de..35b9e4f7d 100644 --- a/cli/cluster/cmd/uri.go +++ b/cli/cluster/cmd/uri.go @@ -39,6 +39,9 @@ type UriOpts struct { CaPath string // CaFile is a path to a trusted certificate authorities (CA) file. CaFile string + // Ciphers is a colon-separated (:) list of SSL cipher suites the + // connection can use. + Ciphers string // SkipHostVerify controls whether a client verifies the server's // host name. This is dangerous option so by default it is false. SkipHostVerify bool @@ -72,6 +75,7 @@ func ParseUriOpts(uri *url.URL) (UriOpts, error) { CertFile: values.Get("ssl_cert_file"), CaPath: values.Get("ssl_ca_path"), CaFile: values.Get("ssl_ca_file"), + Ciphers: values.Get("ssl_ciphers"), Timeout: DefaultUriTimeout, } if password, ok := uri.User.Password(); ok { @@ -150,6 +154,7 @@ func MakeConnectOptsFromUriOpts(src UriOpts) connector.ConnectOpts { KeyFile: src.KeyFile, CertFile: src.CertFile, CaFile: src.CaFile, + Ciphers: src.Ciphers, }, } } diff --git a/cli/cluster/cmd/uri_test.go b/cli/cluster/cmd/uri_test.go index 0d225bc06..70ffd2047 100644 --- a/cli/cluster/cmd/uri_test.go +++ b/cli/cluster/cmd/uri_test.go @@ -215,6 +215,7 @@ func TestParseUriOpts(t *testing.T) { "?key=anykey&name=anyname" + "&ssl_key_file=kfile&ssl_cert_file=certfile" + "&ssl_ca_path=capath&ssl_ca_file=cafile" + + "&ssl_ciphers=foo:bar:ciphers" + "&verify_peer=true&verify_host=false&timeout=2", Opts: cmd.UriOpts{ Endpoint: "scheme://localhost:2012", @@ -228,6 +229,7 @@ func TestParseUriOpts(t *testing.T) { CertFile: "certfile", CaPath: "capath", CaFile: "cafile", + Ciphers: "foo:bar:ciphers", SkipHostVerify: true, Timeout: time.Duration(2 * time.Second), }, @@ -268,6 +270,7 @@ func TestMakeEtcdOptsFromUriOpts(t *testing.T) { Prefix: "foo", Key: "bar", Instance: "zoo", + Ciphers: "foo:bar:ciphers", }, Expected: cluster.EtcdOpts{}, }, @@ -373,6 +376,7 @@ func TestMakeConnectOptsFromUriOpts(t *testing.T) { CertFile: "cert_file", CaPath: "ca_path", CaFile: "ca_file", + Ciphers: "foo:bar:ciphers", SkipHostVerify: true, SkipPeerVerify: true, Timeout: 2 * time.Second, @@ -386,6 +390,7 @@ func TestMakeConnectOptsFromUriOpts(t *testing.T) { KeyFile: "key_file", CertFile: "cert_file", CaFile: "ca_file", + Ciphers: "foo:bar:ciphers", }, }, }, diff --git a/cli/cmd/cluster.go b/cli/cmd/cluster.go index 47716e259..90b39274a 100644 --- a/cli/cmd/cluster.go +++ b/cli/cmd/cluster.go @@ -56,6 +56,7 @@ Possible arguments: * ssl_cert_file - a path to an SSL certificate file. * ssl_ca_file - a path to a trusted certificate authorities (CA) file. * ssl_ca_path - a path to a trusted certificate authorities (CA) directory. +* ssl_ciphers - a colon-separated (:) list of SSL cipher suites the connection can use. * verify_host - set off (default true) verification of the certificate’s name against the host. * verify_peer - set off (default true) verification of the peer’s SSL certificate. diff --git a/cli/connector/integration_test.go b/cli/connector/integration_test.go index ffee8d9f1..543f67a9a 100644 --- a/cli/connector/integration_test.go +++ b/cli/connector/integration_test.go @@ -245,6 +245,82 @@ func TestConnect_textTls(t *testing.T) { require.ErrorContains(t, err, expected) } +var poolCases = []struct { + Name string + Opts []ConnectOpts +} { + { + Name: "single", + Opts: []ConnectOpts{ + ConnectOpts{ + Network: "tcp", + Address: "unreachetable", + Username: "test", + Password: "password", + }, + ConnectOpts{ + Network: "tcp", + Address: server, + Username: "test", + Password: "password", + }, + }, + }, + { + Name: "with_invalid", + Opts: []ConnectOpts{ + ConnectOpts{ + Network: "tcp", + Address: server, + Username: "test", + Password: "password", + }, + }, + }, +} + +func TestPoolConnect_success(t *testing.T) { + for _, tc := range poolCases { + t.Run(tc.Name, func(t *testing.T) { + pool, err := ConnectPool(tc.Opts) + require.NoError(t, err) + require.NotNil(t, pool) + pool.Close() + }) + } +} + +func TestPoolEval_success(t *testing.T) { + for _, tc := range poolCases { + t.Run(tc.Name, func(t *testing.T) { + pool, err := ConnectPool(tc.Opts) + require.NoError(t, err) + require.NotNil(t, pool) + defer pool.Close() + + ret, err := pool.Eval("return ...", []any{"foo"}, RequestOpts{}) + assert.NoError(t, err) + assert.Equal(t, ret, []any{"foo"}) + }) + } +} + +func TestPoolEval_error(t *testing.T) { + for _, tc := range poolCases { + t.Run(tc.Name, func(t *testing.T) { + pool, err := ConnectPool(tc.Opts) + require.NoError(t, err) + require.NotNil(t, pool) + defer pool.Close() + + for i := 0; i < 10; i++ { + _, err = pool.Eval("error('foo')", []any{"foo"}, RequestOpts{}) + assert.ErrorContains(t, err, "foo") + } + }) + } +} + func runTestMain(m *testing.M) int { inst, err := test_helpers.StartTarantool(test_helpers.StartOpts{ InitScript: "testdata/config.lua", diff --git a/cli/connector/pool.go b/cli/connector/pool.go new file mode 100644 index 000000000..4e125faf2 --- /dev/null +++ b/cli/connector/pool.go @@ -0,0 +1,76 @@ +package connector + +import ( + "errors" +) + +var ( + errFailedToConnect = errors.New("failed to connect to any instance") +) + +// Pool is a very simple connection pool. It uses a one active connection +// and switches to another on an error. +type Pool struct { + opts []ConnectOpts + current Connector + currentIndex int +} + +// ConnectPool creates a connection pool object. It makes sure that it can +// connect to at least one instance. +func ConnectPool(opts []ConnectOpts) (*Pool, error) { + // Protects from external modifications of the data in the slice. + cpy := make([]ConnectOpts, len(opts)) + copy(cpy, opts) + + for i, opt := range cpy { + conn, err := Connect(opt) + if err == nil { + return &Pool{ + opts: cpy, + current: conn, + currentIndex: i, + }, nil + } + } + return nil, errFailedToConnect +} + +// Eval executes the expression on each connectable instance until +// success. +func (pool *Pool) Eval(expr string, args []any, opts RequestOpts) ([]any, error) { + var err error + for i := 0; i < len(pool.opts); i++ { + if pool.current == nil { + conn, err := Connect(pool.opts[pool.currentIndex]) + if err != nil { + pool.currentIndex = (pool.currentIndex + 1) % len(pool.opts) + continue + } + pool.current = conn + } + + var ret []any + ret, err = pool.current.Eval(expr, args, opts) + if err == nil { + return ret, nil + } + + pool.current.Close() + pool.current = nil + pool.currentIndex = (pool.currentIndex + 1) % len(pool.opts) + } + + if err == nil { + err = errFailedToConnect + } // Else it contains a last error from pool.current.Eval(). + return nil, err +} + +// Close closes the pool. +func (pool *Pool) Close() error { + if pool.current != nil { + return pool.current.Close() + } + return nil +} diff --git a/cli/connector/pool_test.go b/cli/connector/pool_test.go new file mode 100644 index 000000000..a5487e549 --- /dev/null +++ b/cli/connector/pool_test.go @@ -0,0 +1,34 @@ +package connector_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/tarantool/tt/cli/connector" +) + +func TestConnectPool_failed_to_connect(t *testing.T) { + cases := []struct { + Name string + Opts []connector.ConnectOpts + }{ + {"nil", nil}, + {"empty", []connector.ConnectOpts{}}, + {"unreachetable", []connector.ConnectOpts{ + connector.ConnectOpts{ + Network: connector.TCPNetwork, + Address: "unreachetable", + }, + }}, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + pool, err := connector.ConnectPool(tc.Opts) + + assert.Nil(t, pool) + assert.EqualError(t, err, "failed to connect to any instance") + }) + } +}