From 40b9b524f230f451ceb1f2b864da5d2ff0360c60 Mon Sep 17 00:00:00 2001 From: Andrew LeFevre Date: Mon, 9 May 2022 17:28:15 -0400 Subject: [PATCH] add --format flag to 'token add' and make the same flag visible for 'token ls' (#12327) --- docs/pages/setup/reference/cli.mdx | 12 +++ e | 2 +- tool/tctl/common/helpers_test.go | 45 +++++++++ tool/tctl/common/token_command.go | 97 +++++++++++++----- tool/tctl/common/token_command_test.go | 131 +++++++++++++++++++++++++ tool/tctl/main.go | 2 +- 6 files changed, 265 insertions(+), 24 deletions(-) create mode 100644 tool/tctl/common/token_command_test.go diff --git a/docs/pages/setup/reference/cli.mdx b/docs/pages/setup/reference/cli.mdx index bcfaa4e14f816..aa564be0d5d36 100644 --- a/docs/pages/setup/reference/cli.mdx +++ b/docs/pages/setup/reference/cli.mdx @@ -1068,6 +1068,7 @@ $ tctl tokens add --type=TYPE [] | `--type` | none | `proxy`, `auth`, `trusted_cluster`, `node`, `db`, `kube`, `app`, `windowsdesktop` | Type of token to add | | `--value` | none | **string** token value | Value of token to add | | `--ttl` | 1h | relative duration like 5s, 2m, or 3h | Set expiration time for token | +| `--format` | none | `text`, `json`, `yaml` | Output format | #### Global flags @@ -1112,6 +1113,17 @@ List node and user invitation tokens: $ tctl tokens ls [] ``` +#### Flags + +| Name | Default Value(s) | Allowed Value(s) | Description | +| - | - | - | - | +| `--format` | none | `text`, `json`, `yaml` | Output format | + +#### Global flags + +These flags are available for all commands `--debug, --config` . Run +`tctl help ` or see the [Global Flags section](#tctl-global-flags). + #### Example ```code diff --git a/e b/e index 7638f9cc51431..cf63aa7dfb15d 160000 --- a/e +++ b/e @@ -1 +1 @@ -Subproject commit 7638f9cc514312f5f8878e8f54bd54c30ba84bb5 +Subproject commit cf63aa7dfb15dfd5f69ff8311cf2493ae85fb907 diff --git a/tool/tctl/common/helpers_test.go b/tool/tctl/common/helpers_test.go index 46f19e699fe20..5386cd382e359 100644 --- a/tool/tctl/common/helpers_test.go +++ b/tool/tctl/common/helpers_test.go @@ -96,11 +96,56 @@ func runResourceCommand(t *testing.T, fc *config.FileConfig, args []string, opts return &stdoutBuff, nil } +func runTokensCommand(t *testing.T, fc *config.FileConfig, args []string, opts ...optionsFunc) (*bytes.Buffer, error) { + var options options + for _, v := range opts { + v(&options) + } + + var stdoutBuff bytes.Buffer + command := &TokensCommand{ + stdout: &stdoutBuff, + } + cfg := service.MakeDefaultConfig() + + app := utils.InitCLIParser("tctl", GlobalHelpString) + command.Initialize(app, cfg) + + args = append([]string{"tokens"}, args...) + selectedCmd, err := app.Parse(args) + require.NoError(t, err) + + var ccf GlobalCLIFlags + ccf.ConfigString = mustGetBase64EncFileConfig(t, fc) + ccf.Insecure = options.Insecure + + clientConfig, err := applyConfig(&ccf, cfg) + require.NoError(t, err) + + if options.CertPool != nil { + clientConfig.TLS.RootCAs = options.CertPool + } + + client, err := authclient.Connect(context.Background(), clientConfig) + require.NoError(t, err) + + _, err = command.TryRun(selectedCmd, client) + if err != nil { + return nil, err + } + return &stdoutBuff, nil +} + func mustDecodeJSON(t *testing.T, r io.Reader, i interface{}) { err := json.NewDecoder(r).Decode(i) require.NoError(t, err) } +func mustDecodeYAML(t *testing.T, r io.Reader, i interface{}) { + err := yaml.NewDecoder(r).Decode(i) + require.NoError(t, err) +} + func mustGetFreeLocalListenerAddr(t *testing.T) string { l, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) diff --git a/tool/tctl/common/token_command.go b/tool/tctl/common/token_command.go index 752f047a7e568..4a0e914472306 100644 --- a/tool/tctl/common/token_command.go +++ b/tool/tctl/common/token_command.go @@ -20,11 +20,13 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "sort" "strings" "time" + "github.com/ghodss/yaml" "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/asciitable" @@ -39,8 +41,8 @@ import ( "github.com/gravitational/trace" ) -// TokenCommand implements `tctl token` group of commands -type TokenCommand struct { +// TokensCommand implements `tctl tokens` group of commands +type TokensCommand struct { config *service.Config // format is the output format, e.g. text or json @@ -81,14 +83,19 @@ type TokenCommand struct { // tokenList is used to view all tokens that Teleport knows about. tokenList *kingpin.CmdClause + + // stdout allows to switch the standard output source. Used in tests. + stdout io.Writer } // Initialize allows TokenCommand to plug itself into the CLI parser -func (c *TokenCommand) Initialize(app *kingpin.Application, config *service.Config) { +func (c *TokensCommand) Initialize(app *kingpin.Application, config *service.Config) { c.config = config tokens := app.Command("tokens", "List or revoke invitation tokens") + formats := []string{teleport.Text, teleport.JSON, teleport.YAML} + // tctl tokens add ..." c.tokenAdd = tokens.Command("add", "Create a invitation token") c.tokenAdd.Flag("type", "Type(s) of token to add, e.g. --type=node,app,db").Required().StringVar(&c.tokenType) @@ -103,6 +110,7 @@ func (c *TokenCommand) Initialize(app *kingpin.Application, config *service.Conf c.tokenAdd.Flag("db-name", "Name of the database to add").StringVar(&c.dbName) c.tokenAdd.Flag("db-protocol", fmt.Sprintf("Database protocol to use. Supported are: %v", defaults.DatabaseProtocols)).StringVar(&c.dbProtocol) c.tokenAdd.Flag("db-uri", "Address the database is reachable at").StringVar(&c.dbURI) + c.tokenAdd.Flag("format", "Output format, 'text', 'json', or 'yaml'").EnumVar(&c.format, formats...) // "tctl tokens rm ..." c.tokenDel = tokens.Command("rm", "Delete/revoke an invitation token").Alias("del") @@ -110,11 +118,15 @@ func (c *TokenCommand) Initialize(app *kingpin.Application, config *service.Conf // "tctl tokens ls" c.tokenList = tokens.Command("ls", "List node and user invitation tokens") - c.tokenList.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).StringVar(&c.format) + c.tokenList.Flag("format", "Output format, 'text', 'json' or 'yaml'").EnumVar(&c.format, formats...) + + if c.stdout == nil { + c.stdout = os.Stdout + } } // TryRun takes the CLI command as an argument (like "nodes ls") and executes it. -func (c *TokenCommand) TryRun(cmd string, client auth.ClientI) (match bool, err error) { +func (c *TokensCommand) TryRun(cmd string, client auth.ClientI) (match bool, err error) { switch cmd { case c.tokenAdd.FullCommand(): err = c.Add(client) @@ -129,7 +141,7 @@ func (c *TokenCommand) TryRun(cmd string, client auth.ClientI) (match bool, err } // Add is called to execute "tokens add ..." command. -func (c *TokenCommand) Add(client auth.ClientI) error { +func (c *TokensCommand) Add(client auth.ClientI) error { // Parse string to see if it's a type of role that Teleport supports. roles, err := types.ParseTeleportRoles(c.tokenType) if err != nil { @@ -155,6 +167,36 @@ func (c *TokenCommand) Add(client auth.ClientI) error { return trace.Wrap(err) } + // Print token information formatted with JSON, YAML, or just print the raw token. + switch c.format { + case teleport.JSON, teleport.YAML: + expires := time.Now().Add(c.ttl) + tokenInfo := map[string]interface{}{ + "token": token, + "roles": roles, + "expires": expires, + } + + var ( + data []byte + err error + ) + if c.format == teleport.JSON { + data, err = json.MarshalIndent(tokenInfo, "", " ") + } else { + data, err = yaml.Marshal(tokenInfo) + } + if err != nil { + return trace.Wrap(err) + } + fmt.Fprint(c.stdout, string(data)) + + return nil + case teleport.Text: + fmt.Fprintln(c.stdout, token) + return nil + } + // Calculate the CA pins for this cluster. The CA pins are used by the // client to verify the identity of the Auth Server. localCAResponse, err := client.GetClusterCACert() @@ -187,7 +229,7 @@ func (c *TokenCommand) Add(client auth.ClientI) error { } appPublicAddr := fmt.Sprintf("%v.%v", c.appName, proxies[0].GetPublicAddr()) - return appMessageTemplate.Execute(os.Stdout, + return appMessageTemplate.Execute(c.stdout, map[string]interface{}{ "token": token, "minutes": c.ttl.Minutes(), @@ -205,7 +247,7 @@ func (c *TokenCommand) Add(client auth.ClientI) error { if len(proxies) == 0 { return trace.NotFound("cluster has no proxies") } - return dbMessageTemplate.Execute(os.Stdout, + return dbMessageTemplate.Execute(c.stdout, map[string]interface{}{ "token": token, "minutes": c.ttl.Minutes(), @@ -216,7 +258,7 @@ func (c *TokenCommand) Add(client auth.ClientI) error { "db_uri": c.dbURI, }) case roles.Include(types.RoleTrustedCluster): - fmt.Printf(trustedClusterMessage, + fmt.Fprintf(c.stdout, trustedClusterMessage, token, int(c.ttl.Minutes())) default: @@ -238,7 +280,7 @@ func (c *TokenCommand) Add(client auth.ClientI) error { } } - return nodeMessageTemplate.Execute(os.Stdout, map[string]interface{}{ + return nodeMessageTemplate.Execute(c.stdout, map[string]interface{}{ "token": token, "roles": strings.ToLower(roles.String()), "minutes": int(c.ttl.Minutes()), @@ -251,7 +293,7 @@ func (c *TokenCommand) Add(client auth.ClientI) error { } // Del is called to execute "tokens del ..." command. -func (c *TokenCommand) Del(client auth.ClientI) error { +func (c *TokensCommand) Del(client auth.ClientI) error { ctx := context.TODO() if c.value == "" { return trace.Errorf("Need an argument: token") @@ -259,26 +301,43 @@ func (c *TokenCommand) Del(client auth.ClientI) error { if err := client.DeleteToken(ctx, c.value); err != nil { return trace.Wrap(err) } - fmt.Printf("Token %s has been deleted\n", c.value) + fmt.Fprintf(c.stdout, "Token %s has been deleted\n", c.value) return nil } // List is called to execute "tokens ls" command. -func (c *TokenCommand) List(client auth.ClientI) error { +func (c *TokensCommand) List(client auth.ClientI) error { ctx := context.TODO() tokens, err := client.GetTokens(ctx) if err != nil { return trace.Wrap(err) } if len(tokens) == 0 { - fmt.Println("No active tokens found.") + fmt.Fprintln(c.stdout, "No active tokens found.") return nil } // Sort by expire time. sort.Slice(tokens, func(i, j int) bool { return tokens[i].Expiry().Unix() < tokens[j].Expiry().Unix() }) - if c.format == teleport.Text { + switch c.format { + case teleport.JSON: + data, err := json.MarshalIndent(tokens, "", " ") + if err != nil { + return trace.Wrap(err, "failed to marshal tokens") + } + fmt.Fprint(c.stdout, string(data)) + case teleport.YAML: + data, err := yaml.Marshal(tokens) + if err != nil { + return trace.Wrap(err, "failed to marshal tokens") + } + fmt.Fprint(c.stdout, string(data)) + case teleport.Text: + for _, token := range tokens { + fmt.Fprintln(c.stdout, token.GetName()) + } + default: tokensView := func() string { table := asciitable.MakeTable([]string{"Token", "Type", "Labels", "Expiry Time (UTC)"}) now := time.Now() @@ -293,13 +352,7 @@ func (c *TokenCommand) List(client auth.ClientI) error { } return table.AsBuffer().String() } - fmt.Print(tokensView()) - } else { - data, err := json.MarshalIndent(tokens, "", " ") - if err != nil { - return trace.Wrap(err, "failed to marshal tokens") - } - fmt.Print(string(data)) + fmt.Fprint(c.stdout, tokensView()) } return nil } diff --git a/tool/tctl/common/token_command_test.go b/tool/tctl/common/token_command_test.go new file mode 100644 index 0000000000000..755f6e534c08b --- /dev/null +++ b/tool/tctl/common/token_command_test.go @@ -0,0 +1,131 @@ +/* +Copyright 2022 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "strings" + "testing" + "time" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/config" + "github.com/stretchr/testify/require" +) + +type addedToken struct { + Token string + Roles []string + Expires time.Time +} + +type listedToken struct { + Kind string + Version string + Metadata struct { + Name string + Expires time.Time + ID uint + } + Spec struct { + Roles []string + JoinMethod string + } +} + +func TestTokens(t *testing.T) { + fileConfig := &config.FileConfig{ + Global: config.Global{ + DataDir: t.TempDir(), + }, + Apps: config.Apps{ + Service: config.Service{ + EnabledFlag: "true", + }, + }, + Proxy: config.Proxy{ + Service: config.Service{ + EnabledFlag: "true", + }, + WebAddr: mustGetFreeLocalListenerAddr(t), + TunAddr: mustGetFreeLocalListenerAddr(t), + }, + Auth: config.Auth{ + Service: config.Service{ + EnabledFlag: "true", + ListenAddress: mustGetFreeLocalListenerAddr(t), + }, + }, + } + + makeAndRunTestAuthServer(t, withFileConfig(fileConfig)) + + // Test all output formats of "tokens add". + t.Run("add", func(t *testing.T) { + buf, err := runTokensCommand(t, fileConfig, []string{"add", "--type=node"}) + require.NoError(t, err) + require.True(t, strings.HasPrefix(buf.String(), "The invite token:")) + + buf, err = runTokensCommand(t, fileConfig, []string{"add", "--type=node,app", "--format", teleport.Text}) + require.NoError(t, err) + require.Equal(t, strings.Count(buf.String(), "\n"), 1) + + var out addedToken + + buf, err = runTokensCommand(t, fileConfig, []string{"add", "--type=node,app", "--format", teleport.JSON}) + require.NoError(t, err) + mustDecodeJSON(t, buf, &out) + + require.Len(t, out.Roles, 2) + require.Equal(t, types.KindNode, strings.ToLower(out.Roles[0])) + require.Equal(t, types.KindApp, strings.ToLower(out.Roles[1])) + + buf, err = runTokensCommand(t, fileConfig, []string{"add", "--type=node,app", "--format", teleport.YAML}) + require.NoError(t, err) + mustDecodeYAML(t, buf, &out) + + require.Len(t, out.Roles, 2) + require.Equal(t, types.KindNode, strings.ToLower(out.Roles[0])) + require.Equal(t, types.KindApp, strings.ToLower(out.Roles[1])) + }) + + // Test all output formats of "tokens ls". + t.Run("ls", func(t *testing.T) { + buf, err := runTokensCommand(t, fileConfig, []string{"ls"}) + require.NoError(t, err) + require.True(t, strings.HasPrefix(buf.String(), "Token ")) + require.Equal(t, strings.Count(buf.String(), "\n"), 6) // account for header lines + + buf, err = runTokensCommand(t, fileConfig, []string{"ls", "--format", teleport.Text}) + require.NoError(t, err) + require.Equal(t, strings.Count(buf.String(), "\n"), 4) + + var jsonOut []listedToken + buf, err = runTokensCommand(t, fileConfig, []string{"ls", "--format", teleport.JSON}) + require.NoError(t, err) + mustDecodeJSON(t, buf, &jsonOut) + require.Len(t, jsonOut, 4) + + var yamlOut []listedToken + buf, err = runTokensCommand(t, fileConfig, []string{"ls", "--format", teleport.YAML}) + require.NoError(t, err) + mustDecodeYAML(t, buf, &yamlOut) + require.Len(t, yamlOut, 4) + + require.Equal(t, jsonOut, yamlOut) + }) +} diff --git a/tool/tctl/main.go b/tool/tctl/main.go index ebe57353b9d94..1d5451e75851b 100644 --- a/tool/tctl/main.go +++ b/tool/tctl/main.go @@ -25,7 +25,7 @@ func main() { commands := []common.CLICommand{ &common.UserCommand{}, &common.NodeCommand{}, - &common.TokenCommand{}, + &common.TokensCommand{}, &common.AuthCommand{}, &common.ResourceCommand{}, &common.StatusCommand{},