Files
teleport/lib/utils/usage_docs_test.go
T
Paul Gottschling f104ab66e4 Add a CLI reference generator (#54394)
* Add a CLI reference generator

See #3568

Add a function to update the kingpin usage template for a CLI
application and print a docs page. As a starting point, includes a
`docs` subcommand in `tsh` to call the function for that tool. By
generating CLI documentation, we can ensure that commmand, argument, and
flag usage information is consistent with the terminal help text for a
given CLI. We can also ensure that all changes to a CLI application are
reflected in the documentation.

As an alternative, we could edit `UpdateAppUsageTemplate` in `lib/utils`
to update the usage template to print an MDX page, e.g., using build
tags to print a docs template instead of the default usage template.
This approach would update all CLI tools at once and use a consistent
approach with minimal changes to each CLI tool. However, using a
separate function to take a `*kingpin.Application` and print a docs page
is more straightforward and explicit, even though it does require
editing each CLI tool to call the function.

One shortcoming of generating the CLI reference docs versus our current
manual approach is that it is non-trivial (and potentially impossible)
to use `kingpin`'s usage API to obtain the value types of the
subcommands, flags, and arguments registered against a
`*kingpin.Application`. We can use flag and argument descriptions and
default values to achieve the same purpose.

Another shortcoming is that `kingpin`'s usage API does not print usage
information for arguments, environment variable, etc. that a CLI looks
up directly from the OS, such as the `TELEPORT_CLUSTER` environment
variable for `tsh`.

* Respond to zmb3 and atburke feedback

**Separate docs generation code from production code.**

Add a separate implementation of `UpdateAppUsageTemplate` that requires
the `docs` build tag. Since all Go-based Teleport CLI tools call
`UpdateAppUsageTemplate`, the `docs` build of the function ensure that
the `help` command for Teleport CLI tools prints a docs page.

This change also adds a `cli-docs-tsh` make target to generate a docs
page for `tsh`.

**Escape pipes in `formatHelp`** to avoid breaking table cells.

* Respond to atburke feedback

- Make the main help description more visible. Assume that the main help
  description is not necessarily a complete sentence, adding the text to
  the end of the introductory sentence using a colon.

- Add a make rule for running CLI docs generator tests. Use the `-run`
  flag since the tests are in a package we already run tests for in
  another make rule.

* Respond to zmb3 feedback

- Use Fprintf instead of Buffer.WriteString.
- Use the zero value of bytes.Buffer instead of NewBuffer.
- Rename `.*ToColumns` functions to `.*ToRows` for clarity.
- Use slices.ContainsFunc in anyVisibleFlags.
- Use slices.ContainsFunc in anyEnvVarsForCmd.
- Clean up argsToRows.
- Minor cleanup in TestUpdateAppUsageTemplate.
- Clean up sortCommandsByName (no need to implement sort.Interface).
- Read the docs usage template from disk in UpdateAppUsageTemplate. For
  this to work, add the function updateAppUsageTemplate, which takes an
  io.Reader, and use separate file paths to pass the io.Reader to the
  function in the tests and in UpdateAppUsageTemplate.
- Inline formatFlagForTable and remove unnecessary branching.
- Add an ADDTAGS environment variable to the `test-go-unit` make
  target so it's possible to pass arbitrary build tags, e.g., "docs".
2025-06-27 17:33:14 +00:00

446 lines
9.9 KiB
Go

// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//go:build docs
package utils
import (
"bytes"
"os"
"strings"
"testing"
"github.com/alecthomas/kingpin/v2"
"github.com/stretchr/testify/require"
)
func TestUpdateAppUsageTemplate(t *testing.T) {
tests := []struct {
name string
makeApp func() *kingpin.Application
expectSubstring string // The @ character is replaced with a backtick
}{
{
name: "subcommand flags and global flags",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Command("hello", "Hello.")
create := app.Command("create", "Create.")
create.Flag("name", "The name of the resource").Default("myresource").String()
createRocket := create.Command("rocket", "Rocket.")
createRocket.Flag("launch", "Whether to launch the Rocket").Bool()
return app
},
expectSubstring: `---
title: myapp Reference
description: Provides a comprehensive list of commands, arguments, and flags for myapp.
---
This guide provides a comprehensive list of commands, arguments, and flags for
myapp: This is the main CLI tool.
@@@code
$ myapp [<flags>] <command> [<args> ...]
@@@
Global flags:
|Flag|Default|Description|
|---|---|---|
|@--config@|@config.yaml@|The location of the config file|
## myapp create rocket
Rocket.
Usage:
@@@code
$ myapp create rocket [<flags>]
@@@
Flags:
|Flag|Default|Description|
|---|---|---|
|@--[no-]launch@|@false@|Whether to launch the Rocket|
## myapp hello
Hello.
Usage:
@@@code
$ myapp hello
@@@
## myapp help
Show help.
Usage:
@@@code
$ myapp help [<command>...]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|command|none (optional)|Show help on command.|
`,
},
{
name: "multiple main command flags",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Flag("verbosity", "Verbosity level.").Default("3").Int()
app.Flag("dry-run", "Whether to use dry-run mode").Default("false").Bool()
return app
},
expectSubstring: `This guide provides a comprehensive list of commands, arguments, and flags for
myapp: This is the main CLI tool.
@@@code
$ myapp [<flags>] <command> [<args> ...]
@@@
Global flags:
|Flag|Default|Description|
|---|---|---|
|@--config@|@config.yaml@|The location of the config file|
|@--verbosity@|@3@|Verbosity level.|
|@--[no-]dry-run@|@false@|Whether to use dry-run mode|
`,
},
{
name: "multiple subcommand flags",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
create := app.Command("create", "Create a resource.")
create.Flag("verbosity", "Verbosity level.").Default("3").Int()
create.Flag("dry-run", "Whether to use dry-run mode").Default("false").Bool()
return app
},
expectSubstring: `## myapp create
Create a resource.
Usage:
@@@code
$ myapp create [<flags>]
@@@
Flags:
|Flag|Default|Description|
|---|---|---|
|@--verbosity@|@3@|Verbosity level.|
|@--[no-]dry-run@|@false@|Whether to use dry-run mode|
`,
},
{
name: "multiple sub-command args",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
create := app.Command("create", "Create.")
create.Arg("verbosity", "Verbosity level.").Default("3").Int()
create.Arg("dry-run", "Whether to use dry-run mode").Default("false").Bool()
return app
},
expectSubstring: `## myapp create
Create.
Usage:
@@@code
$ myapp create [<verbosity>] [<dry-run>]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|verbosity|@3@ (optional)|Verbosity level.|
|dry-run|@false@ (optional)|Whether to use dry-run mode|
`,
},
{
name: "sub-command order",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Command("create", "Create a resource.")
app.Command("validate", "Validate the config.")
app.Command("connect", "Connect to a server.")
return app
},
expectSubstring: `## myapp connect
Connect to a server.
Usage:
@@@code
$ myapp connect
@@@
## myapp create
Create a resource.
Usage:
@@@code
$ myapp create
@@@
## myapp help
Show help.
Usage:
@@@code
$ myapp help [<command>...]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|command|none (optional)|Show help on command.|
## myapp validate
Validate the config.
Usage:
@@@code
$ myapp validate
@@@
`,
},
{
name: "level-3 command order",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
mfa := app.Command("mfa", "Manage MFA resources.")
mfa.Command("add", "Add an MFA device.")
app.Command("create", "Create a resource")
return app
},
expectSubstring: `## myapp create
Create a resource
Usage:
@@@code
$ myapp create
@@@
## myapp help
Show help.
Usage:
@@@code
$ myapp help [<command>...]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|command|none (optional)|Show help on command.|
## myapp mfa add
Add an MFA device.
Usage:
@@@code
$ myapp mfa add
@@@
`,
},
{
name: "empty arg",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Command("kubectl", "Proxy kubectl commands.")
kubectl := app.Command("kubectl", "Proxy kubectl commands.").Interspersed(false)
// This hack is required in order to accept any args for tsh kubectl.
kubectl.Arg("", "").StringsVar(new([]string))
return app
},
expectSubstring: `## myapp kubectl
Proxy kubectl commands.
Usage:
@@@code
$ myapp kubectl [args...]
@@@
Arguments:
|Argument|Default|Description|
|---|---|---|
|args|none (optional)|Arbitrary arguments|
`,
},
{
name: "hidden flag",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
app.Command("kubectl", "Proxy kubectl commands.")
kubectl := app.Command("kubectl", "Proxy kubectl commands.").Interspersed(false)
kubectl.Flag("diag", "Run diagnostics").Hidden().Bool()
app.Command("log", "Print logs")
return app
},
expectSubstring: `## myapp kubectl
Proxy kubectl commands.
Usage:
@@@code
$ myapp kubectl
@@@
## myapp log
`,
},
{
name: "main command env vars",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("verbosity", "Verbosity level.").Default("3").Envar("MYAPP_VERBOSITY").Int()
return app
},
expectSubstring: `Global flags:
|Flag|Default|Description|
|---|---|---|
|@--verbosity@|@3@|Verbosity level.|
Global environment variables:
|Variable|Default|Description|
|---|---|---|
|@MYAPP_VERBOSITY@|@3@|Verbosity level.|
`,
},
{
name: "subcommand env vars",
makeApp: func() *kingpin.Application {
app := InitCLIParser("myapp", "This is the main CLI tool.")
app.Flag("config", "The location of the config file").Default("config.yaml").String()
create := app.Command("create", "Create a resource.")
create.Flag("name", "The name of the resource").Envar("CREATE_NAME").Default("myresource").String()
create.Arg("type", "The type of the resource").Envar("CREATE_TYPE").String()
return app
},
expectSubstring: `## myapp create
Create a resource.
Usage:
@@@code
$ myapp create [<flags>] [<type>]
@@@
Environment variables:
|Variable|Default|Description|
|---|---|---|
|@CREATE_TYPE@|none (optional)|The type of the resource|
|@CREATE_NAME@|@myresource@|The name of the resource|
Flags:
|Flag|Default|Description|
|---|---|---|
|@--name@|@myresource@|The name of the resource|
Arguments:
|Argument|Default|Description|
|---|---|---|
|type|none (optional)|The type of the resource|
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := tt.makeApp()
var buffer bytes.Buffer
app.UsageWriter(&buffer)
args := []string{"help"}
app.Terminate(func(int) {})
docsUsageTemplatePath := "docs-usage.md.tmpl"
f, err := os.Open(docsUsageTemplatePath)
require.NoError(t, err)
updateAppUsageTemplate(f, app)
// kingpin only adds a help command if there is at least
// one subcommand. Make sure that all test cases
// introduce a help command.
app.HelpCommand = app.Command("help", "Print help for the application.")
// HelpCommand is triggered on PreAction during Parse.
// See kingpin.Application.init for more details.
_, err = app.Parse(args)
require.NoError(t, err)
expected := strings.ReplaceAll(tt.expectSubstring, "@", "`")
require.Contains(t, buffer.String(), expected)
})
}
}