From 804c2e861191ff1d2ce0d325fbabdee6376c65fa Mon Sep 17 00:00:00 2001 From: Oleg Jukovec Date: Thu, 16 Apr 2026 00:50:57 +0300 Subject: [PATCH] cli: add cluster worker subcommands Add `tt cluster worker` with publish, show, and delete subcommands for managing TDB worker configurations in etcd or tarantool config storage. The implementation includes context types, URL path parsing, and credentials resolution with proper priority handling. Extend MakeURLHelp to support custom URL path formats via url_path and path_ parameters, enabling help messages for commands with non-standard URL structures like /prefix/host-name/worker-name. The actual storage operations are stubbed out and will be implemented in follow-up commits. Includes unit and integration tests. Closes TNTP-7059 --- .cspell_project-words.txt | 1 + cli/cluster/cmd/worker.go | 125 ++++++++ cli/cluster/cmd/worker_test.go | 280 ++++++++++++++++++ cli/cmd/cluster.go | 136 +++++++++ go.mod | 2 + go.sum | 4 + lib/connect/help.go | 16 +- lib/connect/help_test.go | 86 ++++++ .../cluster/test_cluster_worker.py | 159 ++++++++++ 9 files changed, 807 insertions(+), 2 deletions(-) create mode 100644 cli/cluster/cmd/worker.go create mode 100644 cli/cluster/cmd/worker_test.go create mode 100644 test/integration/cluster/test_cluster_worker.py diff --git a/.cspell_project-words.txt b/.cspell_project-words.txt index 737a25078..704ce560a 100644 --- a/.cspell_project-words.txt +++ b/.cspell_project-words.txt @@ -57,6 +57,7 @@ noarch nodeps nogc nolint +nontarantool notarantool oldfd outputformat diff --git a/cli/cluster/cmd/worker.go b/cli/cluster/cmd/worker.go new file mode 100644 index 000000000..79be267e7 --- /dev/null +++ b/cli/cluster/cmd/worker.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strings" + + libconnect "github.com/tarantool/tt/lib/connect" +) + +// WorkerPublishCtx contains information about cluster worker publish command +// execution context. +type WorkerPublishCtx struct { + // Username defines a username for connection. + Username string + // Password defines a password for connection. + Password string + // Force defines whether the publish should be forced. + Force bool + // Src is a raw data to publish. + Src []byte +} + +// WorkerShowCtx contains information about cluster worker show command +// execution context. +type WorkerShowCtx struct { + // Username defines a username for connection. + Username string + // Password defines a password for connection. + Password string +} + +// WorkerDeleteCtx contains information about cluster worker delete command +// execution context. +type WorkerDeleteCtx struct { + // Username defines a username for connection. + Username string + // Password defines a password for connection. + Password string + // Force defines whether the delete should be forced (skip confirmation). + Force bool +} + +// ParseWorkerPath parses a URL path and extracts prefix, hostName and workerName. +// The expected format is: /prefix/host-name/worker-name +// where the last two segments are host-name and worker-name, +// and everything before them is the prefix. +// +// Example: /tdb-workers/tdb-cluster/host1/http-server-1 +// → prefix="/tdb-workers/tdb-cluster", hostName="host1", workerName="http-server-1". +func ParseWorkerPath(urlPath string) (prefix, hostName, workerName string, err error) { + path := strings.TrimPrefix(urlPath, "/") + path = strings.TrimSuffix(path, "/") + + if path == "" { + return "", "", "", errors.New("URL path must not be empty") + } + + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", "", "", fmt.Errorf( + "URL path must contain at least a host-name and a worker-name, got: %q", urlPath) + } + + workerName = parts[len(parts)-1] + hostName = parts[len(parts)-2] + prefix = "/" + strings.Join(parts[:len(parts)-2], "/") + + return prefix, hostName, workerName, nil +} + +// BuildWorkerStorageKey builds the storage key for a worker configuration. +// The format is: //instances//. +func BuildWorkerStorageKey(prefix, hostName, workerName string) string { + prefix = strings.TrimSuffix(prefix, "/") + return fmt.Sprintf("%s/instances/%s/%s", prefix, hostName, workerName) +} + +func firstNonEmpty(candidates ...string) string { + for _, s := range candidates { + if s != "" { + return s + } + } + return "" +} + +// ResolveWorkerCredentials resolves credentials with the priority: +// environment variables < command flags < URL credentials. +func ResolveWorkerCredentials( + uriOpts libconnect.UriOpts, + flagUsername, flagPassword string, +) (username, password string) { + username = firstNonEmpty( + uriOpts.Username, + flagUsername, + os.Getenv(libconnect.EtcdUsernameEnv), + os.Getenv(libconnect.TarantoolUsernameEnv), + ) + + password = firstNonEmpty( + uriOpts.Password, + flagPassword, + os.Getenv(libconnect.EtcdPasswordEnv), + os.Getenv(libconnect.TarantoolPasswordEnv), + ) + + return username, password +} + +// WorkerPublish publishes a worker configuration. Unimplemented. +func WorkerPublish(url string, ctx WorkerPublishCtx) error { + return errors.New("unimplemented") +} + +// WorkerShow shows a worker configuration. Unimplemented. +func WorkerShow(url string, ctx WorkerShowCtx) error { + return errors.New("unimplemented") +} + +// WorkerDelete deletes a worker configuration. Unimplemented. +func WorkerDelete(url string, ctx WorkerDeleteCtx) error { + return errors.New("unimplemented") +} diff --git a/cli/cluster/cmd/worker_test.go b/cli/cluster/cmd/worker_test.go new file mode 100644 index 000000000..17bd74372 --- /dev/null +++ b/cli/cluster/cmd/worker_test.go @@ -0,0 +1,280 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + + libconnect "github.com/tarantool/tt/lib/connect" +) + +func TestParseWorkerPath(t *testing.T) { + cases := []struct { + name string + urlPath string + expectedPrefix string + expectedHost string + expectedWorker string + expectedErr string + }{ + { + name: "simple path", + urlPath: "/prefix/host1/worker1", + expectedPrefix: "/prefix", + expectedHost: "host1", + expectedWorker: "worker1", + }, + { + name: "nested prefix", + urlPath: "/tdb-workers/tdb-cluster/host1/http-server-1", + expectedPrefix: "/tdb-workers/tdb-cluster", + expectedHost: "host1", + expectedWorker: "http-server-1", + }, + { + name: "deeply nested prefix", + urlPath: "/a/b/c/d/host/worker", + expectedPrefix: "/a/b/c/d", + expectedHost: "host", + expectedWorker: "worker", + }, + { + name: "minimal path", + urlPath: "/host/worker", + expectedPrefix: "/", + expectedHost: "host", + expectedWorker: "worker", + }, + { + name: "path with trailing slash", + urlPath: "/prefix/host/worker/", + expectedPrefix: "/prefix", + expectedHost: "host", + expectedWorker: "worker", + }, + { + name: "empty path", + urlPath: "", + expectedErr: "URL path must not be empty", + }, + { + name: "single segment", + urlPath: "/worker", + expectedErr: "URL path must contain at least a host-name and a worker-name", + }, + { + name: "single segment no slash", + urlPath: "worker", + expectedErr: "URL path must contain at least a host-name and a worker-name", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + prefix, host, worker, err := ParseWorkerPath(tc.urlPath) + if tc.expectedErr != "" { + require.ErrorContains(t, err, tc.expectedErr) + return + } + require.NoError(t, err) + require.Equal(t, tc.expectedPrefix, prefix) + require.Equal(t, tc.expectedHost, host) + require.Equal(t, tc.expectedWorker, worker) + }) + } +} + +func TestBuildWorkerStorageKey(t *testing.T) { + cases := []struct { + name string + prefix string + hostName string + workerName string + expectedKey string + }{ + { + name: "simple", + prefix: "/tdb-workers/tdb-cluster", + hostName: "host1", + workerName: "http-server-1", + expectedKey: "/tdb-workers/tdb-cluster/instances/host1/http-server-1", + }, + { + name: "prefix with trailing slash", + prefix: "/tdb-workers/tdb-cluster/", + hostName: "host1", + workerName: "worker1", + expectedKey: "/tdb-workers/tdb-cluster/instances/host1/worker1", + }, + { + name: "root prefix", + prefix: "/", + hostName: "host", + workerName: "worker", + expectedKey: "/instances/host/worker", + }, + { + name: "empty prefix", + prefix: "", + hostName: "host", + workerName: "worker", + expectedKey: "/instances/host/worker", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key := BuildWorkerStorageKey(tc.prefix, tc.hostName, tc.workerName) + require.Equal(t, tc.expectedKey, key) + }) + } +} + +func TestResolveWorkerCredentials(t *testing.T) { + cases := []struct { + name string + envUsername string + envPassword string + envEtcdUsername string + envEtcdPassword string + flagUsername string + flagPassword string + urlUsername string + urlPassword string + expectedUser string + expectedPass string + }{ + { + name: "no credentials", + expectedUser: "", + expectedPass: "", + }, + { + name: "env only - tarantool", + envUsername: "tarantool_user", + envPassword: "tarantool_pass", + expectedUser: "tarantool_user", + expectedPass: "tarantool_pass", + }, + { + name: "env only - etcd", + envEtcdUsername: "etcd_user", + envEtcdPassword: "etcd_pass", + expectedUser: "etcd_user", + expectedPass: "etcd_pass", + }, + { + name: "etcd env takes precedence over tarantool", + envUsername: "tarantool_user", + envPassword: "tarantool_pass", + envEtcdUsername: "etcd_user", + envEtcdPassword: "etcd_pass", + expectedUser: "etcd_user", + expectedPass: "etcd_pass", + }, + { + name: "flags override env", + envUsername: "env_user", + envPassword: "env_pass", + flagUsername: "flag_user", + flagPassword: "flag_pass", + expectedUser: "flag_user", + expectedPass: "flag_pass", + }, + { + name: "url overrides flags", + envUsername: "env_user", + envPassword: "env_pass", + flagUsername: "flag_user", + flagPassword: "flag_pass", + urlUsername: "url_user", + urlPassword: "url_pass", + expectedUser: "url_user", + expectedPass: "url_pass", + }, + { + name: "url username only", + urlUsername: "url_user", + flagPassword: "flag_pass", + expectedUser: "url_user", + expectedPass: "flag_pass", + }, + { + name: "url password only", + flagUsername: "flag_user", + urlPassword: "url_pass", + expectedUser: "flag_user", + expectedPass: "url_pass", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.envUsername != "" { + t.Setenv(libconnect.TarantoolUsernameEnv, tc.envUsername) + } + if tc.envPassword != "" { + t.Setenv(libconnect.TarantoolPasswordEnv, tc.envPassword) + } + if tc.envEtcdUsername != "" { + t.Setenv(libconnect.EtcdUsernameEnv, tc.envEtcdUsername) + } + if tc.envEtcdPassword != "" { + t.Setenv(libconnect.EtcdPasswordEnv, tc.envEtcdPassword) + } + + uriOpts := libconnect.UriOpts{ + Username: tc.urlUsername, + Password: tc.urlPassword, + } + + username, password := ResolveWorkerCredentials( + uriOpts, + tc.flagUsername, + tc.flagPassword, + ) + require.Equal(t, tc.expectedUser, username) + require.Equal(t, tc.expectedPass, password) + }) + } +} + +func TestWorkerPublish(t *testing.T) { + err := WorkerPublish("http://localhost:2379/prefix/host/worker", WorkerPublishCtx{}) + require.EqualError(t, err, "unimplemented") +} + +func TestWorkerShow(t *testing.T) { + err := WorkerShow("http://localhost:2379/prefix/host/worker", WorkerShowCtx{}) + require.EqualError(t, err, "unimplemented") +} + +func TestWorkerDelete(t *testing.T) { + err := WorkerDelete("http://localhost:2379/prefix/host/worker", WorkerDeleteCtx{}) + require.EqualError(t, err, "unimplemented") +} + +func TestParseWorkerPathAndBuildKeyIntegration(t *testing.T) { + cases := []struct { + urlPath string + expectedKey string + }{ + { + urlPath: "/tdb-workers/tdb-cluster/host1/http-server-1", + expectedKey: "/tdb-workers/tdb-cluster/instances/host1/http-server-1", + }, + { + urlPath: "/prefix/host/worker", + expectedKey: "/prefix/instances/host/worker", + }, + } + + for _, tc := range cases { + t.Run(tc.urlPath, func(t *testing.T) { + prefix, host, worker, err := ParseWorkerPath(tc.urlPath) + require.NoError(t, err) + key := BuildWorkerStorageKey(prefix, host, worker) + require.Equal(t, tc.expectedKey, key) + }) + } +} diff --git a/cli/cmd/cluster.go b/cli/cmd/cluster.go index c86766b5f..b288d0481 100644 --- a/cli/cmd/cluster.go +++ b/cli/cmd/cluster.go @@ -66,6 +66,12 @@ var switchStatusCtx = clustercmd.SwitchStatusCtx{ var rolesChangeCtx = clustercmd.RolesChangeCtx{} +var workerPublishCtx clustercmd.WorkerPublishCtx + +var workerShowCtx clustercmd.WorkerShowCtx + +var workerDeleteCtx clustercmd.WorkerDeleteCtx + var ( defaultSwitchTimeout uint64 = 30 clusterIntegrityPrivateKey string @@ -88,6 +94,18 @@ environment variables < command flags < URL credentials.`, "env_TT_CLI_auth": "Tarantool", "env_TT_CLI_ETCD_auth": "Etcd", "footer": `The priority of credentials: +environment variables < command flags < URL credentials.`, + }) + + workerUriHelp = libconnect.MakeURLHelp(map[string]any{ + "service": "etcd or tarantool config storage", + "url_path": "/prefix/host-name/worker-name", + "prefix": "a base path to the worker configuration", + "path_host-name": "a name of the host", + "path_worker-name": "a name of the worker", + "env_TT_CLI_auth": "Tarantool", + "env_TT_CLI_ETCD_auth": "Etcd", + "footer": `The priority of credentials: environment variables < command flags < URL credentials.`, }) ) @@ -260,6 +278,69 @@ func newClusterFailoverCmd() *cobra.Command { return cmd } +func newClusterWorkerCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "worker", + Short: "Manage worker configuration", + } + + publishCmd := &cobra.Command{ + Use: "publish ", + DisableFlagsInUseLine: true, + Short: "Publish a worker configuration", + Long: "Publish a worker configuration\n\n" + workerUriHelp, + Example: "tt cluster worker publish " + + "https://user:pass@localhost:2379/tdb-workers/tdb-cluster/host1/http-server-1 " + + "worker.yaml", + Run: RunModuleFunc(internalClusterWorkerPublishModule), + Args: cobra.ExactArgs(2), + } + publishCmd.Flags().StringVarP(&workerPublishCtx.Username, "username", "u", "", + "username (used as etcd/tarantool config storage credentials)") + publishCmd.Flags().StringVarP(&workerPublishCtx.Password, "password", "p", "", + "password (used as etcd/tarantool config storage credentials)") + publishCmd.Flags().BoolVar(&workerPublishCtx.Force, "force", false, + "force publish and skip checking existence") + + showCmd := &cobra.Command{ + Use: "show ", + DisableFlagsInUseLine: true, + Short: "Show a worker configuration", + Long: "Show a worker configuration\n\n" + workerUriHelp, + Example: "tt cluster worker show " + + "https://user:pass@localhost:2379/tdb-workers/tdb-cluster/host1/http-server-1", + Run: RunModuleFunc(internalClusterWorkerShowModule), + Args: cobra.ExactArgs(1), + } + showCmd.Flags().StringVarP(&workerShowCtx.Username, "username", "u", "", + "username (used as etcd/tarantool config storage credentials)") + showCmd.Flags().StringVarP(&workerShowCtx.Password, "password", "p", "", + "password (used as etcd/tarantool config storage credentials)") + + deleteCmd := &cobra.Command{ + Use: "delete ", + DisableFlagsInUseLine: true, + Short: "Delete a worker configuration", + Long: "Delete a worker configuration\n\n" + workerUriHelp, + Example: "tt cluster worker delete " + + "https://user:pass@localhost:2379/tdb-workers/tdb-cluster/host1/http-server-1", + Run: RunModuleFunc(internalClusterWorkerDeleteModule), + Args: cobra.ExactArgs(1), + } + deleteCmd.Flags().StringVarP(&workerDeleteCtx.Username, "username", "u", "", + "username (used as etcd/tarantool config storage credentials)") + deleteCmd.Flags().StringVarP(&workerDeleteCtx.Password, "password", "p", "", + "password (used as etcd/tarantool config storage credentials)") + deleteCmd.Flags().BoolVar(&workerDeleteCtx.Force, "force", false, + "force delete and skip confirmation") + + cmd.AddCommand(publishCmd) + cmd.AddCommand(showCmd) + cmd.AddCommand(deleteCmd) + + return cmd +} + func NewClusterCmd() *cobra.Command { clusterCmd := &cobra.Command{ Use: "cluster", @@ -345,6 +426,7 @@ func NewClusterCmd() *cobra.Command { clusterCmd.AddCommand(publish) clusterCmd.AddCommand(newClusterReplicasetCmd()) clusterCmd.AddCommand(newClusterFailoverCmd()) + clusterCmd.AddCommand(newClusterWorkerCmd()) return clusterCmd } @@ -511,6 +593,60 @@ func internalClusterFailoverSwitchStatusModule(cmdCtx *cmdcontext.CmdCtx, args [ return clustercmd.SwitchStatus(args[0], switchStatusCtx) } +// internalClusterWorkerPublishModule is a "cluster worker publish" command. +func internalClusterWorkerPublishModule(cmdCtx *cmdcontext.CmdCtx, args []string) error { + opts, err := libconnect.CreateUriOpts(args[0]) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", args[0], err) + } + + data, err := os.ReadFile(args[1]) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", args[1], err) + } + workerPublishCtx.Src = data + + workerPublishCtx.Username, workerPublishCtx.Password = clustercmd.ResolveWorkerCredentials( + opts, workerPublishCtx.Username, workerPublishCtx.Password) + + if err := clustercmd.WorkerPublish(args[0], workerPublishCtx); err != nil { + return fmt.Errorf("failed to publish worker configuration: %w", err) + } + return nil +} + +// internalClusterWorkerShowModule is a "cluster worker show" command. +func internalClusterWorkerShowModule(cmdCtx *cmdcontext.CmdCtx, args []string) error { + opts, err := libconnect.CreateUriOpts(args[0]) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", args[0], err) + } + + workerShowCtx.Username, workerShowCtx.Password = clustercmd.ResolveWorkerCredentials( + opts, workerShowCtx.Username, workerShowCtx.Password) + + if err := clustercmd.WorkerShow(args[0], workerShowCtx); err != nil { + return fmt.Errorf("failed to show worker configuration: %w", err) + } + return nil +} + +// internalClusterWorkerDeleteModule is a "cluster worker delete" command. +func internalClusterWorkerDeleteModule(cmdCtx *cmdcontext.CmdCtx, args []string) error { + opts, err := libconnect.CreateUriOpts(args[0]) + if err != nil { + return fmt.Errorf("invalid URL %q: %w", args[0], err) + } + + workerDeleteCtx.Username, workerDeleteCtx.Password = clustercmd.ResolveWorkerCredentials( + opts, workerDeleteCtx.Username, workerDeleteCtx.Password) + + if err := clustercmd.WorkerDelete(args[0], workerDeleteCtx); err != nil { + return fmt.Errorf("failed to delete worker configuration: %w", err) + } + return nil +} + // readSourceFile reads a configuration from a source file. func readSourceFile(path string) ([]byte, *libcluster.Config, error) { data, err := os.ReadFile(path) diff --git a/go.mod b/go.mod index 2f3f96ca4..082852d28 100644 --- a/go.mod +++ b/go.mod @@ -128,6 +128,8 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/tarantool/go-iproto v1.1.0 // indirect github.com/tarantool/go-openssl v1.2.1 // indirect + github.com/tarantool/go-option v1.0.0 // indirect + github.com/tarantool/go-storage v1.1.2 // indirect github.com/tarantool/go-tlsdialer v1.0.2 // indirect github.com/tklauser/go-sysconf v0.3.4 // indirect github.com/tklauser/numcpus v0.2.1 // indirect diff --git a/go.sum b/go.sum index 85834ee3b..19ee9bd60 100644 --- a/go.sum +++ b/go.sum @@ -315,10 +315,14 @@ github.com/tarantool/go-iproto v1.1.0/go.mod h1:LNCtdyZxojUed8SbOiYHoc3v9NvaZTB7 github.com/tarantool/go-openssl v0.0.8-0.20230307065445-720eeb389195/go.mod h1:M7H4xYSbzqpW/ZRBMyH0eyqQBsnhAMfsYk5mv0yid7A= github.com/tarantool/go-openssl v1.2.1 h1:WUVTeEPuBAXbrBjvJZ3ynzk/Sv4DL47V/ehWea9czjA= github.com/tarantool/go-openssl v1.2.1/go.mod h1:EwX1pKIGypLxkY49vKIIR4LTT+94DhKiunCqU2gEzLQ= +github.com/tarantool/go-option v1.0.0 h1:+Etw0i3TjsXvADTo5rfZNCfsXe3BfHOs+iVfIrl0Nlo= +github.com/tarantool/go-option v1.0.0/go.mod h1:lXzzeZtL+rPUtLOCDP6ny3FemFBjruG9aHKzNN2bS08= github.com/tarantool/go-prompt v0.2.6-tarantool h1:/dYMRBuM5nE3mleka/mqJWPf8SrJ151U+OqDlTzvES0= github.com/tarantool/go-prompt v0.2.6-tarantool/go.mod h1:8enZKIgoGFEQu2XPBK79TguJG2XF3SR4QU2iYI28NSo= github.com/tarantool/go-prompt v1.0.1 h1:88Yer6gCFylqGRrdWwikNFVbklRQsqKF7mycvGdDcj0= github.com/tarantool/go-prompt v1.0.1/go.mod h1:9Vuvi60Bk+3yaXqgYaXNTpLbwPPaaEOeaUgpFW1jqTU= +github.com/tarantool/go-storage v1.1.2 h1:I+fQtVnivSy6M2xhedn73s1604jx6ZDRWjqE2AOgRsc= +github.com/tarantool/go-storage v1.1.2/go.mod h1:lM/UPkuzeggynwtmIHD5OCqdz5H2RHsXX6HaOzYBCzk= github.com/tarantool/go-tarantool v1.12.3 h1:GXabowmrTSW225xFEjX4t+8PlccVDCeGB5OM1VLbBXE= github.com/tarantool/go-tarantool v1.12.3/go.mod h1:QRiXv0jnxwgxHtr9ZmifSr/eRba76gTUBgp69pDMX1U= github.com/tarantool/go-tarantool/v2 v2.4.2 h1:rkzYtFhLJLA9RDIhjzN93MJBN5PBxHW4+soq+RB90gE= diff --git a/lib/connect/help.go b/lib/connect/help.go index 1f6a7b70c..f28bd7612 100644 --- a/lib/connect/help.go +++ b/lib/connect/help.go @@ -24,7 +24,9 @@ const EnvEtcdCredentialsHelp = "The command supports the following Etcd environm // `header` - string: a header for the help message; // `footer` - string: a final message of the help; // `service` - string: name of the service to connect with URL; +// `url_path` - string: custom URL path format (e.g., "/prefix/host-name/worker-name"); // `prefix` - string: a base path used by service application; +// `path_` - string: description for a URL path component with ; // `tag` - string: description how `#fragment` part used by application; // `param_` - string: description for an extra URL param with added to help; // `env__auth` - string: service info. It will expanded to: @@ -36,10 +38,12 @@ func MakeURLHelp(data map[string]any) string { st := `{{ if .header }}{{.header}} {{end -}} The URL specifies a {{.service}} connection settings in the following format: -http(s)://[username:password@]host:port{{ if .prefix }}/prefix{{end}}[?arguments]{{ if .tag }}[#tag]{{end}} -{{- if or .prefix .tag }}{{ $NL := "" }} +http(s)://[username:password@]host:port{{ if .url_path }}{{.url_path}}{{else if .prefix }}/prefix{{end}}[?arguments]{{ if and (not .url_path) .tag }}[#tag]{{end}} +{{- if or .prefix .tag .path_parts }}{{ $NL := "" }} {{with .prefix }}* prefix - {{.}}.{{ $NL = "\n" }}{{end -}} +{{range $key, $value := .path_parts -}} +{{ $NL }}* {{$key}} - {{$value}}.{{ $NL = "\n" }}{{end -}} {{with .tag }}{{ $NL }}* tag - {{.}}.{{end -}} {{end}} @@ -93,6 +97,7 @@ The command supports the following environment variables: envAuth := map[string]template.HTML{} envVars := map[string]template.HTML{} + pathParts := map[string]template.HTML{} makeEnvVars := func(key, info string) { h := template.HTML(info) @@ -110,6 +115,12 @@ The command supports the following environment variables: s = fmt.Sprintf("%v", value) } makeEnvVars(strings.TrimPrefix(key, "env_"), s) + } else if strings.HasPrefix(key, "path_") { + s, ok := value.(string) + if !ok { + s = fmt.Sprintf("%v", value) + } + pathParts[strings.TrimPrefix(key, "path_")] = template.HTML(s) } else { s, ok := value.(string) if ok { @@ -122,6 +133,7 @@ The command supports the following environment variables: } params["env_auth"] = envAuth params["env_vars"] = envVars + params["path_parts"] = pathParts var sb strings.Builder t.Execute(&sb, params) diff --git a/lib/connect/help_test.go b/lib/connect/help_test.go index 4a168d5e7..519a5cfa6 100644 --- a/lib/connect/help_test.go +++ b/lib/connect/help_test.go @@ -229,6 +229,92 @@ The command supports the following environment variables: * SOME_LONG_NAME_VARIABLE_NAME - Here is a very long multiline info: Second line & with [tab] indent, Third line | with {spaces} indent. +`, + }, + + "url_path_only": { + args: args{data: map[string]any{ + "service": "etcd", + "url_path": "/prefix/host-name/worker-name", + }}, + want: `The URL specifies a etcd connection settings in the following format: +http(s)://[username:password@]host:port/prefix/host-name/worker-name[?arguments] + +Possible arguments: +* timeout - a request timeout in seconds (default 3.0). +* ssl_key_file - a path to a private SSL key file. +* 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 list of allowed SSL ciphers. +* 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. +`, + }, + + "path_parts": { + args: args{data: map[string]any{ + "service": "etcd", + "prefix": "a base path to configuration", + "path_host-name": "a name of the host", + "path_worker-name": "a name of the worker", + }}, + want: `The URL specifies a etcd connection settings in the following format: +http(s)://[username:password@]host:port/prefix[?arguments] + +* prefix - a base path to configuration. +* host-name - a name of the host. +* worker-name - a name of the worker. + +Possible arguments: +* timeout - a request timeout in seconds (default 3.0). +* ssl_key_file - a path to a private SSL key file. +* 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 list of allowed SSL ciphers. +* 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. +`, + }, + + "worker_scenario": { + args: args{data: map[string]any{ + "service": "etcd or tarantool config storage", + "url_path": "/prefix/host-name/worker-name", + "prefix": "a base path to the worker configuration", + "path_host-name": "a name of the host", + "path_worker-name": "a name of the worker", + "env_TT_CLI_auth": "Tarantool", + "env_TT_CLI_ETCD_auth": "Etcd", + "footer": `The priority of credentials: +environment variables < command flags < URL credentials.`, + }}, + want: `The URL specifies a etcd or tarantool config storage connection settings in the following format: +http(s)://[username:password@]host:port/prefix/host-name/worker-name[?arguments] + +* prefix - a base path to the worker configuration. +* host-name - a name of the host. +* worker-name - a name of the worker. + +Possible arguments: +* timeout - a request timeout in seconds (default 3.0). +* ssl_key_file - a path to a private SSL key file. +* 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 list of allowed SSL ciphers. +* 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. + +The command supports the following environment variables: +* TT_CLI_USERNAME - specifies a Tarantool username; +* TT_CLI_PASSWORD - specifies a Tarantool password. +* TT_CLI_ETCD_USERNAME - specifies a Etcd username; +* TT_CLI_ETCD_PASSWORD - specifies a Etcd password. + +The priority of credentials: +environment variables < command flags < URL credentials. `, }, } diff --git a/test/integration/cluster/test_cluster_worker.py b/test/integration/cluster/test_cluster_worker.py new file mode 100644 index 000000000..b3e50a894 --- /dev/null +++ b/test/integration/cluster/test_cluster_worker.py @@ -0,0 +1,159 @@ +import subprocess + + +def test_cluster_worker_help(tt_cmd, tmp_path): + help_cmd = [tt_cmd, "cluster", "worker", "--help"] + instance_process = subprocess.Popen( + help_cmd, + cwd=tmp_path, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + ) + help_output = instance_process.stdout.read() + + assert "Manage worker configuration" in help_output + assert "publish" in help_output + assert "show" in help_output + assert "delete" in help_output + + +def test_cluster_worker_publish_help(tt_cmd, tmp_path): + help_cmd = [tt_cmd, "cluster", "worker", "publish", "--help"] + instance_process = subprocess.Popen( + help_cmd, + cwd=tmp_path, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + ) + help_output = instance_process.stdout.read() + + assert "Publish a worker configuration" in help_output + assert "http(s)://[username:password@]host:port/prefix/host-name/worker-name" in help_output + assert "* prefix - a base path to the worker configuration." in help_output + assert "* host-name - a name of the host." in help_output + assert "* worker-name - a name of the worker." in help_output + assert "TT_CLI_USERNAME" in help_output + assert "TT_CLI_PASSWORD" in help_output + assert "TT_CLI_ETCD_USERNAME" in help_output + assert "TT_CLI_ETCD_PASSWORD" in help_output + assert "environment variables < command flags < URL credentials" in help_output + assert "--force" in help_output + assert "-u" in help_output or "--username" in help_output + assert "-p" in help_output or "--password" in help_output + + +def test_cluster_worker_show_help(tt_cmd, tmp_path): + help_cmd = [tt_cmd, "cluster", "worker", "show", "--help"] + instance_process = subprocess.Popen( + help_cmd, + cwd=tmp_path, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + ) + help_output = instance_process.stdout.read() + + assert "Show a worker configuration" in help_output + assert "http(s)://[username:password@]host:port/prefix/host-name/worker-name" in help_output + assert "* prefix - a base path to the worker configuration." in help_output + assert "* host-name - a name of the host." in help_output + assert "* worker-name - a name of the worker." in help_output + assert "TT_CLI_USERNAME" in help_output + assert "TT_CLI_PASSWORD" in help_output + assert "TT_CLI_ETCD_USERNAME" in help_output + assert "TT_CLI_ETCD_PASSWORD" in help_output + assert "environment variables < command flags < URL credentials" in help_output + assert "-u" in help_output or "--username" in help_output + assert "-p" in help_output or "--password" in help_output + + +def test_cluster_worker_delete_help(tt_cmd, tmp_path): + help_cmd = [tt_cmd, "cluster", "worker", "delete", "--help"] + instance_process = subprocess.Popen( + help_cmd, + cwd=tmp_path, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + ) + help_output = instance_process.stdout.read() + + assert "Delete a worker configuration" in help_output + assert "http(s)://[username:password@]host:port/prefix/host-name/worker-name" in help_output + assert "* prefix - a base path to the worker configuration." in help_output + assert "* host-name - a name of the host." in help_output + assert "* worker-name - a name of the worker." in help_output + assert "TT_CLI_USERNAME" in help_output + assert "TT_CLI_PASSWORD" in help_output + assert "TT_CLI_ETCD_USERNAME" in help_output + assert "TT_CLI_ETCD_PASSWORD" in help_output + assert "environment variables < command flags < URL credentials" in help_output + assert "--force" in help_output + assert "-u" in help_output or "--username" in help_output + assert "-p" in help_output or "--password" in help_output + + +def test_cluster_worker_publish_unimplemented(tt_cmd, tmp_path): + worker_cfg = tmp_path / "worker.yaml" + worker_cfg.write_text("type: nontarantool\n") + + publish_cmd = [ + tt_cmd, + "cluster", + "worker", + "publish", + "https://localhost:2379/prefix/host/worker", + str(worker_cfg), + ] + instance_process = subprocess.Popen( + publish_cmd, + cwd=tmp_path, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + ) + output = instance_process.stdout.read() + + assert "unimplemented" in output + + +def test_cluster_worker_show_unimplemented(tt_cmd, tmp_path): + show_cmd = [ + tt_cmd, + "cluster", + "worker", + "show", + "https://localhost:2379/prefix/host/worker", + ] + instance_process = subprocess.Popen( + show_cmd, + cwd=tmp_path, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + ) + output = instance_process.stdout.read() + + assert "unimplemented" in output + + +def test_cluster_worker_delete_unimplemented(tt_cmd, tmp_path): + delete_cmd = [ + tt_cmd, + "cluster", + "worker", + "delete", + "https://localhost:2379/prefix/host/worker", + ] + instance_process = subprocess.Popen( + delete_cmd, + cwd=tmp_path, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + ) + output = instance_process.stdout.read() + + assert "unimplemented" in output