Repository: tetratelabs/getenvoy
Pull request #217 (2021-05-14 11:40:01)

Backfills tests in CLI
This cleans up some sentences and backfills tests to raise test coverage a bit (currently 85.8%)

Signed-off-by: Adrian Cole <adrian@tetrate.io>

codefromthecrypt: @musaprg FYI as this is more housekeeping in nature, I'm not bothering you about approval, but if you want to take a look or find something afterwards we should change, let me know.

Patch:
diff --git a/cmd/getenvoy/main.go b/cmd/getenvoy/main.go
index 32977bd..7ca030e 100644
--- a/cmd/getenvoy/main.go
+++ b/cmd/getenvoy/main.go
@@ -15,6 +15,8 @@
 package main
 
 import (
+	"fmt"
+	"io"
 	"os"
 
 	cmdutil "github.com/tetratelabs/getenvoy/pkg/cmd"
@@ -22,7 +24,27 @@ import (
 )
 
 func main() {
-	if cmdutil.Execute(cmdutil.NewRoot(&globals.GlobalOpts{})) != nil {
-		os.Exit(1)
+	os.Exit(run(os.Stdout, os.Stderr, os.Args))
+}
+
+// run handles all error logging and coding so that no other place needs to.
+func run(stdout, stderr io.Writer, args []string) int {
+	app := cmdutil.NewApp(&globals.GlobalOpts{})
+	app.SetArgs(args[1:])
+	app.SetOut(stdout)
+	app.SetErr(stderr)
+	if err := app.Execute(); err != nil {
+		if _, ok := err.(*cmdutil.ValidationError); ok {
+			logUsageError(app.Name(), err, stderr)
+		} else {
+			_, _ = fmt.Fprintln(stderr, "error:", err)
+		}
+		return 1
 	}
+	return 0
+}
+
+func logUsageError(name string, err error, stderr io.Writer) {
+	_, _ = fmt.Fprintln(stderr, err)
+	_, _ = fmt.Fprintln(stderr, "show usage with:", name, "-h")
 }
diff --git a/pkg/cmd/app.go b/pkg/cmd/app.go
new file mode 100644
index 0000000..f151e84
--- /dev/null
+++ b/pkg/cmd/app.go
@@ -0,0 +1,116 @@
+// Copyright 2019 Tetrate
+//
+// 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 cmd
+
+import (
+	"net/url"
+	"os"
+	"os/user"
+	"path/filepath"
+
+	"github.com/spf13/cobra"
+
+	"github.com/tetratelabs/getenvoy/pkg/globals"
+	"github.com/tetratelabs/getenvoy/pkg/manifest"
+	"github.com/tetratelabs/getenvoy/pkg/version"
+)
+
+// NewApp create a new root command. The globals.GlobalOpts parameter allows tests to scope overrides, which avoids
+// having to define a flag for everything needed in tests.
+func NewApp(o *globals.GlobalOpts) *cobra.Command {
+	homeDir, manifestURL := initializeGlobalOpts(o)
+
+	rootCmd := &cobra.Command{
+		Use:               "getenvoy",
+		SilenceErrors:     true, // We can't adjust the error message on Ctrl+C, so we redo error logging in Execute
+		SilenceUsage:      true, // We decided to return short usage form on error in Execute
+		DisableAutoGenTag: true, // removes autogenerate on ___ from produced docs
+		Short:             "Fetch and run Envoy",
+		Long:              `Manage Envoy lifecycle including fetching binaries and collection of process state.`,
+		Version:           version.Build.Version, // TODO: Add version support on the command
+		PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
+			if err := setHomeDir(o, homeDir); err != nil {
+				return err
+			}
+			return setManifestURL(o, manifestURL)
+		},
+	}
+
+	rootCmd.AddCommand(NewRunCmd(o))
+	rootCmd.AddCommand(NewListCmd(o))
+	rootCmd.AddCommand(NewFetchCmd(o))
+	rootCmd.AddCommand(NewDocCmd())
+
+	rootCmd.PersistentFlags().StringVar(&homeDir, "home-dir", homeDir,
+		"GetEnvoy home directory (location of downloaded artifacts, caches, etc)")
+
+	rootCmd.PersistentFlags().StringVar(&manifestURL, "manifest", manifestURL,
+		"GetEnvoy manifest URL (source of information about available Envoy builds)")
+	rootCmd.PersistentFlags().MarkHidden("manifest") // nolint
+	return rootCmd
+}
+
+func initializeGlobalOpts(o *globals.GlobalOpts) (homeDir, manifestURL string) {
+	if o.HomeDir == "" { // don't lookup homedir when overridden for tests
+		homeDir = os.Getenv("GETENVOY_HOME")
+	}
+
+	if o.ManifestURL == "" {
+		manifestURL = os.Getenv("GETENVOY_MANIFEST_URL")
+	}
+	return
+}
+
+func setManifestURL(o *globals.GlobalOpts, manifestURL string) error {
+	if o.ManifestURL == "" { // not overridden for tests
+		if manifestURL == "" {
+			manifestURL = globals.DefaultManifestURL
+		}
+		otherURL, err := url.Parse(manifestURL)
+		if err != nil || otherURL.Host == "" || otherURL.Scheme == "" {
+			return newValidationError("%q is not a valid manifest URL", manifestURL)
+		}
+		o.ManifestURL = manifestURL
+	}
+	return nil
+}
+
+func setHomeDir(o *globals.GlobalOpts, homeDir string) error {
+	if o.HomeDir == "" { // not overridden for tests
+		if homeDir == "" {
+			u, err := user.Current()
+			if err != nil || u.HomeDir == "" {
+				return newValidationError("unable to determine home directory. Set GETENVOY_HOME instead: %v", err)
+			}
+			homeDir = filepath.Join(u.HomeDir, ".getenvoy")
+		}
+		abs, err := filepath.Abs(homeDir)
+		if err != nil {
+			return newValidationError(err.Error())
+		}
+		o.HomeDir = abs
+	}
+	return nil
+}
+
+func validateReferenceArg(_ *cobra.Command, args []string) error {
+	if len(args) == 0 {
+		return newValidationError("missing reference parameter")
+	}
+	if _, e := manifest.ParseReference(args[0]); e != nil {
+		return newValidationError(e.Error())
+	}
+	return nil
+}
diff --git a/pkg/cmd/doc.go b/pkg/cmd/doc.go
index a71d6e7..fb7dc17 100644
--- a/pkg/cmd/doc.go
+++ b/pkg/cmd/doc.go
@@ -33,7 +33,7 @@ var (
 func NewDocCmd() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:    "doc",
-		Short:  "Generates markdown documentation for the cli.",
+		Short:  "Generates Markdown documentation for the CLI.",
 		Hidden: true,
 		RunE: func(cmd *cobra.Command, _ []string) error {
 			getenvoy := cmd.Parent()
diff --git a/pkg/cmd/errors.go b/pkg/cmd/errors.go
new file mode 100644
index 0000000..b41b975
--- /dev/null
+++ b/pkg/cmd/errors.go
@@ -0,0 +1,31 @@
+// Copyright 2021 Tetrate
+//
+// 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 cmd
+
+import "fmt"
+
+func newValidationError(format string, a ...interface{}) error {
+	return &ValidationError{fmt.Sprintf(format, a...)}
+}
+
+// ValidationError is a marker of a validation error vs an execution one.
+type ValidationError struct {
+	string
+}
+
+// Error implements the error interface.
+func (e *ValidationError) Error() string {
+	return e.string
+}
diff --git a/pkg/cmd/fetch.go b/pkg/cmd/fetch.go
index a7bd00f..e42f8f9 100644
--- a/pkg/cmd/fetch.go
+++ b/pkg/cmd/fetch.go
@@ -15,7 +15,6 @@
 package cmd
 
 import (
-	"errors"
 	"fmt"
 
 	"github.com/spf13/cobra"
@@ -29,21 +28,13 @@ import (
 func NewFetchCmd(o *globals.GlobalOpts) *cobra.Command {
 	return &cobra.Command{
 		Use:   "fetch <reference>",
-		Short: "Retrieve Envoy binaries from GetEnvoy.",
-		Long: `
-Retrieves the referenced Envoy binary from GetEnvoy. The reference can be a full or partial reference.
-A complete list of available builds can be retrieved using` + "`getenvoy list`" + `.`,
+		Short: "Downloads a version of Envoy. Available builds can be retrieved using `getenvoy list`.",
 		Example: fmt.Sprintf(`# Fetch using a partial manifest reference to retrieve a build suitable for your operating system.
 getenvoy fetch %[1]s
 		
 # Fetch using a full manifest reference to retrieve a specific build for Linux. 
-getenvoy fetch s%[1]s/linux-glibc`, reference.Latest),
-		Args: func(cmd *cobra.Command, args []string) error {
-			if len(args) == 0 {
-				return errors.New("missing reference parameter")
-			}
-			return nil
-		},
+getenvoy fetch %[1]s/linux-glibc`, reference.Latest),
+		Args: validateReferenceArg,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			_, err := envoy.FetchIfNeeded(o, args[0])
 			return err
diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go
index c0b5d2e..e55193d 100644
--- a/pkg/cmd/list.go
+++ b/pkg/cmd/list.go
@@ -27,9 +27,7 @@ import (
 func NewListCmd(o *globals.GlobalOpts) *cobra.Command {
 	return &cobra.Command{
 		Use:   "list",
-		Short: "List available Envoys provided by GetEnvoy.",
-		Long: `
-Retrieves a list of Envoy builds provided by GetEnvoy.`,
+		Short: "List available Envoy version references you can run",
 		RunE: func(cmd *cobra.Command, _ []string) error {
 			m, err := manifest.FetchManifest(o.ManifestURL)
 			if err != nil {
diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go
deleted file mode 100644
index e2470fe..0000000
--- a/pkg/cmd/root.go
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright 2019 Tetrate
-//
-// 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 cmd
-
-import (
-	"errors"
-	"fmt"
-	"log"
-	"net/url"
-	"os"
-	"os/user"
-	"path/filepath"
-
-	"github.com/spf13/cobra"
-
-	"github.com/tetratelabs/getenvoy/pkg/globals"
-	"github.com/tetratelabs/getenvoy/pkg/version"
-)
-
-// NewRoot create a new root command. The globals.GlobalOpts parameter allows tests to scope overrides, which avoids
-// having to define a flag for everything needed in tests.
-func NewRoot(globalOpts *globals.GlobalOpts) *cobra.Command {
-	// We set default flags values eagerly so that handleFlagOverrides can validate if the user errored by clearing it
-	// TODO: determine if it is worth the code and bother if the user or a tool they use accidentally set to empty!!
-	homeDirFlag, manifestURLFlag, err := initializeGlobalOpts(globalOpts)
-	if err != nil {
-		log.Fatalln(err) // exit the process instead of using incoherent state
-	}
-
-	rootCmd := &cobra.Command{
-		Use:               "getenvoy",
-		SilenceErrors:     true, // We can't adjust the error message on Ctrl+C, so we redo error logging in Execute
-		SilenceUsage:      true, // We decided to return short usage form on error in Execute
-		DisableAutoGenTag: true, // removes autogenerate on ___ from produced docs
-		Short:             "Fetch, deploy and debug Envoy",
-		Long: `Manage full lifecycle of Envoy including fetching binaries,
-bootstrap generation and automated collection of access logs, Envoy state and machine state.`,
-		Version: version.Build.Version, // TODO: Add version support on the command
-		PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
-			return handleFlagOverrides(globalOpts, homeDirFlag, manifestURLFlag)
-		},
-	}
-
-	rootCmd.AddCommand(NewRunCmd(globalOpts))
-	rootCmd.AddCommand(NewListCmd(globalOpts))
-	rootCmd.AddCommand(NewFetchCmd(globalOpts))
-	rootCmd.AddCommand(NewDocCmd())
-
-	rootCmd.PersistentFlags().StringVar(&homeDirFlag, "home-dir", homeDirFlag,
-		"GetEnvoy home directory (location of downloaded artifacts, caches, etc)")
-
-	rootCmd.PersistentFlags().StringVar(&manifestURLFlag, "manifest", manifestURLFlag,
-		"GetEnvoy manifest URL (source of information about available Envoy builds)")
-	rootCmd.PersistentFlags().MarkHidden("manifest") // nolint
-	return rootCmd
-}
-
-func initializeGlobalOpts(globalOpts *globals.GlobalOpts) (string, string, error) {
-	homeDirFlag := os.Getenv("GETENVOY_HOME")
-	if homeDirFlag == "" && globalOpts.HomeDir == "" { // don't lookup homedir when overridden for tests
-		u, err := user.Current()
-		if err != nil || u.HomeDir == "" {
-			return "", "", fmt.Errorf("unable to determine home directory. Set GETENVOY_HOME instead: %w", err)
-		}
-		homeDirFlag = filepath.Join(u.HomeDir, ".getenvoy")
-	}
-
-	manifestURLFlag := os.Getenv("GETENVOY_MANIFEST_URL")
-	if manifestURLFlag == "" {
-		manifestURLFlag = globals.DefaultManifestURL
-	}
-	return homeDirFlag, manifestURLFlag, nil
-}
-
-func handleFlagOverrides(o *globals.GlobalOpts, homeDirFlag, manifestURLFlag string) error {
-	if o.HomeDir == "" { // not overridden for tests
-		if homeDirFlag == "" {
-			return errors.New("GetEnvoy home directory cannot be empty")
-		}
-		abs, err := filepath.Abs(homeDirFlag)
-		if err != nil {
-			return err
-		}
-		o.HomeDir = abs
-	}
-
-	if o.ManifestURL == "" { // not overridden for tests
-		if manifestURLFlag == "" {
-			return errors.New("GetEnvoy manifest URL cannot be empty")
-		}
-		otherURL, err := url.Parse(manifestURLFlag)
-		if err != nil || otherURL.Host == "" || otherURL.Scheme == "" {
-			return fmt.Errorf("%q is not a valid manifest URL", manifestURLFlag)
-		}
-		o.ManifestURL = manifestURLFlag
-	}
-	return nil
-}
-
-// Execute prints short usage (similar to args failing to parse) on any error.
-// This requires ExecuteC, which doesn't support context.Context, or looking up the subcommand with Find.
-func Execute(cmd *cobra.Command) error {
-	actualCmd, err := cmd.ExecuteC()
-	if actualCmd != nil && err != nil { // both are always true on error
-		cmd.PrintErrln("Error:", err.Error())
-		// actualCmd ensures command path includes the subcommand (ex "extension run")
-		cmd.PrintErrf("\nRun '%v --help' for usage.\n", actualCmd.CommandPath())
-		return err
-	}
-	return nil
-}
diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go
index 21e4379..7664134 100644
--- a/pkg/cmd/run.go
+++ b/pkg/cmd/run.go
@@ -15,7 +15,6 @@
 package cmd
 
 import (
-	"errors"
 	"fmt"
 	"log"
 	"os"
@@ -33,47 +32,42 @@ import (
 
 // NewRunCmd create a command responsible for starting an Envoy process
 func NewRunCmd(o *globals.GlobalOpts) *cobra.Command {
-	templateArgs := map[string]string{}
-
 	cmd := &cobra.Command{
 		Use:   "run reference [flags] [-- <envoy-args>]",
-		Short: "Runs an instance of Envoy.",
-		Long: `
-Manages full lifecycle of Envoy including bootstrap generation and automated collection of access logs,
-Envoy state and machine state into the ` + "`~/.getenvoy/debug`" + ` directory.`,
+		Short: "Runs Envoy and collects process state on exit. Available builds can be retrieved using `getenvoy list`.",
 		Example: fmt.Sprintf(`# Run using a manifest reference.
 getenvoy run %[1]s -- --config-path ./bootstrap.yaml
 
 # List available Envoy flags.
 getenvoy run %[1]s -- --help
 `, defaultreference.Latest),
-		Args: func(cmd *cobra.Command, args []string) error {
-			if len(args) == 0 {
-				return errors.New("missing reference parameter")
-			}
-			return nil
-		},
-		RunE: func(cmd *cobra.Command, args []string) error {
-			if err := InitializeRunOpts(o, args[0]); err != nil {
+		Args: validateReferenceArg,
+		RunE: func(c *cobra.Command, args []string) error {
+			if err := initializeRunOpts(o, args[0]); err != nil {
 				return err
 			}
-			return Run(o, cmd, args[1:]) // consume the envoy path argument
+			r := envoy.NewRuntime(&o.RunOpts)
+			r.Out = c.OutOrStderr()
+			r.Err = c.ErrOrStderr()
+
+			debug.EnableAll(r)
+
+			envoyArgs := args[1:]
+			return r.Run(c.Context(), envoyArgs)
 		},
 	}
-	cmd.Flags().StringToStringVar(&templateArgs, "templateArg", map[string]string{},
-		"arguments passed to a config template for substitution")
 	return cmd
 }
 
-// InitializeRunOpts allows us to default values when not overridden for tests.
+// initializeRunOpts allows us to default values when not overridden for tests.
 // The reference parameter corresponds to the globals.GlobalOpts EnvoyPath which is fetched if needed.
 // Notably, this creates and sets a globals.GlobalOpts WorkingDirectory for Envoy, and any files that precede it.
-func InitializeRunOpts(o *globals.GlobalOpts, reference string) error {
+func initializeRunOpts(o *globals.GlobalOpts, reference string) error {
 	runOpts := &o.RunOpts
 	if o.EnvoyPath == "" { // not overridden for tests
 		envoyPath, err := envoy.FetchIfNeeded(o, reference)
 		if err != nil {
-			return err
+			return newValidationError(err.Error())
 		}
 		o.EnvoyPath = envoyPath
 	}
@@ -84,7 +78,7 @@ func InitializeRunOpts(o *globals.GlobalOpts, reference string) error {
 
 		// When the directory is implicitly generated, we should create it to avoid late errors.
 		if err := os.MkdirAll(workingDir, 0750); err != nil {
-			return fmt.Errorf("unable to create working directory %q, so we cannot run envoy: %w", workingDir, err)
+			return newValidationError("unable to create working directory %q, so we cannot run envoy", workingDir)
 		}
 		runOpts.WorkingDir = workingDir
 	}


Test patch:
diff --git a/cmd/getenvoy/main_test.go b/cmd/getenvoy/main_test.go
new file mode 100644
index 0000000..9b6eaca
--- /dev/null
+++ b/cmd/getenvoy/main_test.go
@@ -0,0 +1,97 @@
+// Copyright 2021 Tetrate
+//
+// 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 main
+
+import (
+	"bytes"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestRunErrors(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(200)
+	}))
+	defer server.Close()
+
+	tests := []struct {
+		name           string
+		args           []string
+		expectedStatus int
+		expectedStdout string
+		expectedStderr string
+	}{
+		{
+			name: "built-in --version output",
+			args: []string{"getenvoy", "--version"},
+			expectedStdout: `getenvoy version dev
+`,
+		},
+		{
+			name:           "incorrect global flag name",
+			args:           []string{"getenvoy", "--d"},
+			expectedStatus: 1,
+			expectedStderr: `error: unknown flag: --d
+`,
+		},
+		{
+			name:           "incorrect global flag value",
+			args:           []string{"getenvoy", "--manifest", ".", "help"},
+			expectedStatus: 1,
+			expectedStderr: `"." is not a valid manifest URL
+show usage with: getenvoy -h
+`,
+		},
+		{
+			name:           "unknown command",
+			args:           []string{"getenvoy", "fly"},
+			expectedStatus: 1,
+			expectedStderr: `error: unknown command "fly" for "getenvoy"
+`,
+		},
+		{
+			name:           "invalid command arg",
+			args:           []string{"getenvoy", "fetch", "/"},
+			expectedStatus: 1,
+			expectedStderr: `"/" is not a valid GetEnvoy reference. Expected format: [<flavor>:]<version>[/<platform>]
+show usage with: getenvoy -h
+`,
+		},
+		{
+			name:           "execution error",
+			args:           []string{"getenvoy", "--manifest", server.URL, "list"},
+			expectedStatus: 1,
+			expectedStderr: `error: error unmarshalling manifest: unexpected end of JSON input
+`,
+		},
+	}
+
+	for _, test := range tests {
+		test := test // pin! see https://github.com/kyoh86/scopelint for why
+
+		t.Run(test.name, func(t *testing.T) {
+			stdout := new(bytes.Buffer)
+			stderr := new(bytes.Buffer)
+
+			status := run(stdout, stderr, test.args)
+			require.Equal(t, test.expectedStatus, status)
+			require.Equal(t, test.expectedStdout, stdout.String())
+			require.Equal(t, test.expectedStderr, stderr.String())
+		})
+	}
+}
diff --git a/pkg/cmd/root_test.go b/pkg/cmd/app_test.go
similarity index 77%
rename from pkg/cmd/root_test.go
rename to pkg/cmd/app_test.go
index 7b9a61e..2231fdb 100644
--- a/pkg/cmd/root_test.go
+++ b/pkg/cmd/app_test.go
@@ -16,7 +16,6 @@ package cmd_test
 
 import (
 	"bytes"
-	"fmt"
 	"os"
 	"os/user"
 	"path/filepath"
@@ -37,19 +36,9 @@ func TestGetEnvoyValidateArgs(t *testing.T) {
 		args        []string
 		expectedErr string
 	}{
-		{
-			name:        "--home-dir empty",
-			args:        []string{"--home-dir", "", "help"},
-			expectedErr: `GetEnvoy home directory cannot be empty`,
-		},
-		{
-			name:        "--manifest empty",
-			args:        []string{"--manifest", "", "help"},
-			expectedErr: `GetEnvoy manifest URL cannot be empty`,
-		},
 		{
 			name:        "--manifest not a URL",
-			args:        []string{"--manifest", "/not/url", "help"},
+			args:        []string{"getenvoy", "--manifest", "/not/url", "help"},
 			expectedErr: `"/not/url" is not a valid manifest URL`,
 		},
 	}
@@ -58,15 +47,15 @@ func TestGetEnvoyValidateArgs(t *testing.T) {
 		test := test // pin! see https://github.com/kyoh86/scopelint for why
 
 		t.Run(test.name, func(t *testing.T) {
-			c, stdout, stderr := newRootCommand(o)
-			c.SetArgs(test.args)
-			err := rootcmd.Execute(c)
+			c, stdout, stderr := newApp(o)
+			c.SetArgs(test.args[1:])
+			err := c.Execute()
 
 			// Verify the command failed with the expected error
 			require.EqualError(t, err, test.expectedErr, `expected an error running [%v]`, c)
-			require.Empty(t, stdout.String(), `expected no stdout running [%v]`, c)
-			expectedStderr := fmt.Sprintf("Error: %s\n\nRun 'getenvoy help --help' for usage.\n", test.expectedErr)
-			require.Equal(t, expectedStderr, stderr.String(), `unexpected stderr running [%v]`, c)
+			// Main handles logging of errors, so we expect nothing in stdout or stderr
+			require.Empty(t, stdout, `expected no stdout running [%v]`, c)
+			require.Empty(t, stderr, `expected no stderr running [%v]`, c)
 		})
 	}
 }
@@ -86,12 +75,12 @@ func TestGetEnvoyHomeDir(t *testing.T) {
 	tests := []testCase{ // we don't test default as that depends on the runtime env
 		{
 			name:     "default is ~/.getenvoy",
-			args:     []string{"help"},
+			args:     []string{"getenvoy", "help"},
 			expected: filepath.Join(u.HomeDir, ".getenvoy"),
 		},
 		{
 			name: "GETENVOY_HOME env",
-			args: []string{"help"},
+			args: []string{"getenvoy", "help"},
 			setup: func() func() {
 				return requireSetenv(t, "GETENVOY_HOME", "/from/GETENVOY_HOME/env")
 			},
@@ -99,12 +88,12 @@ func TestGetEnvoyHomeDir(t *testing.T) {
 		},
 		{
 			name:     "--home-dir arg",
-			args:     []string{"--home-dir", "/from/home-dir/arg", "help"},
+			args:     []string{"getenvoy", "--home-dir", "/from/home-dir/arg", "help"},
 			expected: "/from/home-dir/arg",
 		},
 		{
 			name: "prioritizes --home-dir arg over GETENVOY_HOME env",
-			args: []string{"--home-dir", "/from/home-dir/arg", "help"},
+			args: []string{"getenvoy", "--home-dir", "/from/home-dir/arg", "help"},
 			setup: func() func() {
 				return requireSetenv(t, "GETENVOY_HOME", "/from/GETENVOY_HOME/env")
 			},
@@ -122,9 +111,9 @@ func TestGetEnvoyHomeDir(t *testing.T) {
 			}
 
 			o := &globals.GlobalOpts{}
-			c, stdout, stderr := newRootCommand(o)
-			c.SetArgs(test.args)
-			err := rootcmd.Execute(c)
+			c, stdout, stderr := newApp(o)
+			c.SetArgs(test.args[1:])
+			err := c.Execute()
 
 			require.NoError(t, err, `expected no error running [%v]`, c)
 			require.NotEmpty(t, stdout.String(), `expected stdout running [%v]`, c)
@@ -147,10 +136,12 @@ func TestGetEnvoyManifest(t *testing.T) {
 	tests := []testCase{ // we don't test default as that depends on the runtime env
 		{
 			name:     "default is https://dl.getenvoy.io/public/raw/files/manifest.json",
+			args:     []string{"getenvoy", "help"},
 			expected: "https://dl.getenvoy.io/public/raw/files/manifest.json",
 		},
 		{
 			name: "GETENVOY_MANIFEST_URL env",
+			args: []string{"getenvoy", "help"},
 			setup: func() func() {
 				return requireSetenv(t, "GETENVOY_MANIFEST_URL", "http://GETENVOY_MANIFEST_URL/env")
 			},
@@ -158,12 +149,12 @@ func TestGetEnvoyManifest(t *testing.T) {
 		},
 		{
 			name:     "--manifest arg",
-			args:     []string{"--manifest", "http://manifest/arg"},
+			args:     []string{"getenvoy", "--manifest", "http://manifest/arg", "help"},
 			expected: "http://manifest/arg",
 		},
 		{
 			name: "prioritizes --manifest arg over GETENVOY_MANIFEST_URL env",
-			args: []string{"--manifest", "http://manifest/arg"},
+			args: []string{"getenvoy", "--manifest", "http://manifest/arg", "help"},
 			setup: func() func() {
 				return requireSetenv(t, "GETENVOY_MANIFEST_URL", "http://GETENVOY_MANIFEST_URL/env")
 			},
@@ -181,9 +172,9 @@ func TestGetEnvoyManifest(t *testing.T) {
 			}
 
 			o := &globals.GlobalOpts{}
-			c, stdout, stderr := newRootCommand(o)
-			c.SetArgs(append(test.args, "help"))
-			err := rootcmd.Execute(c)
+			c, stdout, stderr := newApp(o)
+			c.SetArgs(test.args[1:])
+			err := c.Execute()
 
 			require.NoError(t, err, `expected no error running [%v]`, c)
 			require.NotEmpty(t, stdout.String(), `expected stdout running [%v]`, c)
@@ -205,11 +196,11 @@ func requireSetenv(t *testing.T, key, value string) func() {
 	}
 }
 
-// newRootCommand initializes a command with buffers for stdout and stderr.
-func newRootCommand(o *globals.GlobalOpts) (c *cobra.Command, stdout, stderr *bytes.Buffer) {
+// newApp initializes a command with buffers for stdout and stderr.
+func newApp(o *globals.GlobalOpts) (c *cobra.Command, stdout, stderr *bytes.Buffer) {
 	stdout = new(bytes.Buffer)
 	stderr = new(bytes.Buffer)
-	c = rootcmd.NewRoot(o)
+	c = rootcmd.NewApp(o)
 	c.SetOut(stdout)
 	c.SetErr(stderr)
 	return c, stdout, stderr
diff --git a/pkg/cmd/doc_test.go b/pkg/cmd/doc_test.go
new file mode 100644
index 0000000..ddbc48c
--- /dev/null
+++ b/pkg/cmd/doc_test.go
@@ -0,0 +1,49 @@
+// Copyright 2020 Tetrate
+//
+// 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 cmd_test
+
+import (
+	"io/ioutil"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	reference "github.com/tetratelabs/getenvoy/pkg"
+	"github.com/tetratelabs/getenvoy/pkg/globals"
+	"github.com/tetratelabs/getenvoy/pkg/test/morerequire"
+)
+
+func TestGetEnvoyDoc(t *testing.T) {
+	tempDir, deleteTempDir := morerequire.RequireNewTempDir(t)
+	defer deleteTempDir()
+
+	c, _, _ := newApp(&globals.GlobalOpts{})
+	c.SetArgs([]string{"doc", "-o", tempDir, "-l", "/reference/"})
+	require.NoError(t, c.Execute())
+
+	for _, file := range []string{"getenvoy.md", "getenvoy_list.md", "getenvoy_fetch.md", "getenvoy_run.md"} {
+		file := file // pin! see https://github.com/kyoh86/scopelint for why
+
+		t.Run(file, func(t *testing.T) {
+			want, err := ioutil.ReadFile(filepath.Join("testdata", file))
+			require.NoError(t, err)
+			have, err := ioutil.ReadFile(filepath.Join(tempDir, file))
+			require.NoError(t, err)
+			require.Equal(t, strings.ReplaceAll(string(want), "VERSION", reference.Latest), string(have))
+		})
+	}
+}
diff --git a/pkg/cmd/run_test.go b/pkg/cmd/run_test.go
index 95ce825..8758408 100644
--- a/pkg/cmd/run_test.go
+++ b/pkg/cmd/run_test.go
@@ -23,7 +23,6 @@ import (
 	"github.com/stretchr/testify/require"
 
 	reference "github.com/tetratelabs/getenvoy/pkg"
-	rootcmd "github.com/tetratelabs/getenvoy/pkg/cmd"
 	"github.com/tetratelabs/getenvoy/pkg/globals"
 	"github.com/tetratelabs/getenvoy/pkg/manifest"
 	manifesttest "github.com/tetratelabs/getenvoy/pkg/test/manifest"
@@ -38,12 +37,12 @@ func TestGetEnvoyRunValidateFlag(t *testing.T) {
 	}{
 		{
 			name:        "arg[0] missing",
-			args:        []string{"run"},
+			args:        []string{"getenvoy", "run"},
 			expectedErr: `missing reference parameter`,
 		},
 		{
 			name:        "arg[0] with invalid reference",
-			args:        []string{"run", "???"},
+			args:        []string{"getenvoy", "run", "???"},
 			expectedErr: `"???" is not a valid GetEnvoy reference. Expected format: [<flavor>:]<version>[/<platform>]`,
 		},
 	}
@@ -53,37 +52,63 @@ func TestGetEnvoyRunValidateFlag(t *testing.T) {
 
 		t.Run(test.name, func(t *testing.T) {
 			// Run "getenvoy run"
-			c, stdout, stderr := newRootCommand(&globals.GlobalOpts{})
-			c.SetArgs(test.args)
-			err := rootcmd.Execute(c)
+			c, stdout, stderr := newApp(&globals.GlobalOpts{})
+			c.SetArgs(test.args[1:])
+			err := c.Execute()
 
 			// Verify the command failed with the expected error
 			require.EqualError(t, err, test.expectedErr, `expected an error running [%v]`, c)
-			require.Empty(t, stdout.String(), `expected no stdout running [%v]`, c)
-			expectedStderr := fmt.Sprintf("Error: %s\n\nRun 'getenvoy run --help' for usage.\n", test.expectedErr)
-			require.Equal(t, expectedStderr, stderr.String(), `expected stderr running [%v]`, c)
+			// Main handles logging of errors, so we expect nothing in stdout or stderr
+			require.Empty(t, stdout, `expected no stdout running [%v]`, c)
+			require.Empty(t, stderr, `expected no stderr running [%v]`, c)
 		})
 	}
 }
 
 func TestGetEnvoyRun(t *testing.T) {
-	o, cleanup := setupTest(t)
-	defer cleanup()
+	tests := []struct {
+		name              string
+		args              []string
+		expectedEnvoyArgs string
+	}{
+		{
+			name: "no envoy args",
+			args: []string{"getenvoy", "run", reference.Latest},
+		},
+		{
+			name: "empty envoy args",
+			args: []string{"getenvoy", "run", reference.Latest, "--"},
+		},
+		{
+			name:              "envoy args",
+			args:              []string{"getenvoy", "run", reference.Latest, "--", "-c", "envoy.yaml"},
+			expectedEnvoyArgs: ` -c envoy.yaml`,
+		},
+	}
 
-	// Run "getenvoy run standard:1.17.1 -- -c envoy.yaml"
-	c, stdout, stderr := newRootCommand(&o.GlobalOpts)
-	c.SetArgs([]string{"run", reference.Latest, "--", "-c", "envoy.yaml"})
-	err := rootcmd.Execute(c)
+	for _, test := range tests {
+		test := test // pin! see https://github.com/kyoh86/scopelint for why
+
+		t.Run(test.name, func(t *testing.T) {
+			o, cleanup := setupTest(t)
+			defer cleanup()
 
-	// Verify the command invoked, passing the correct default commandline
-	require.NoError(t, err, `expected no error running [%v]`, c)
+			// Run "getenvoy run standard:1.17.1 -- -c envoy.yaml"
+			c, stdout, stderr := newApp(&o.GlobalOpts)
+			c.SetArgs(test.args[1:])
+			err := c.Execute()
 
-	// We expect envoy to run from the expected path, and add the --admin-address-path flag
-	expectedStdout := fmt.Sprintf(`envoy wd: %s
+			// Verify the command invoked, passing the correct default commandline
+			require.NoError(t, err, `expected no error running [%v]`, c)
+
+			// We expect envoy to run from the expected path, and add the --admin-address-path flag
+			expectedStdout := fmt.Sprintf(`envoy wd: %s
 envoy bin: %s
-envoy args: -c envoy.yaml --admin-address-path admin-address.txt`, o.WorkingDir, o.EnvoyPath)
-	require.Equal(t, expectedStdout+"\n", stdout.String(), `expected stdout running [%v]`, c)
-	require.Equal(t, "envoy stderr\n", stderr.String(), `expected stderr running [%v]`, c)
+envoy args:%s --admin-address-path admin-address.txt`, o.WorkingDir, o.EnvoyPath, test.expectedEnvoyArgs)
+			require.Equal(t, expectedStdout+"\n", stdout.String(), `expected stdout running [%v]`, c)
+			require.Equal(t, "envoy stderr\n", stderr.String(), `expected stderr running [%v]`, c)
+		})
+	}
 }
 
 func TestGetEnvoyRunFailWithUnknownVersion(t *testing.T) {
@@ -91,21 +116,20 @@ func TestGetEnvoyRunFailWithUnknownVersion(t *testing.T) {
 	defer cleanup()
 
 	o.EnvoyPath = "" // force lookup of version flag
-	c, _, stderr := newRootCommand(&o.GlobalOpts)
+	c, stdout, stderr := newApp(&o.GlobalOpts)
 
 	// Run "getenvoy run unknown"
 	version := "unknown"
 	c.SetArgs([]string{"run", version})
-	err := rootcmd.Execute(c)
+	err := c.Execute()
 
 	// Verify the command failed with the expected error.
 	r := version + "/" + o.platform
 	expectedErr := fmt.Sprintf(`unable to find matching GetEnvoy build for reference "%s"`, r)
 	require.EqualError(t, err, expectedErr, `expected an error running [%v]`, c)
-
-	// We expect the end-users will see the error in stderr
-	expectedStderr := fmt.Sprintf("Error: %s\n\nRun 'getenvoy run --help' for usage.\n", expectedErr)
-	require.Equal(t, expectedStderr, stderr.String(), `expected stderr running [%v]`, c)
+	// Main handles logging of errors, so we expect nothing in stdout or stderr
+	require.Empty(t, stdout, `expected no stdout running [%v]`, c)
+	require.Empty(t, stderr, `expected no stderr running [%v]`, c)
 }
 
 type testEnvoyExtensionConfig struct {
diff --git a/pkg/cmd/testdata/getenvoy.md b/pkg/cmd/testdata/getenvoy.md
new file mode 100644
index 0000000..124d301
--- /dev/null
+++ b/pkg/cmd/testdata/getenvoy.md
@@ -0,0 +1,27 @@
++++
+title = "getenvoy"
+type = "reference"
+parent = "root"
+command = "getenvoy"
++++
+## getenvoy
+
+Fetch and run Envoy
+
+### Synopsis
+
+Manage Envoy lifecycle including fetching binaries and collection of process state.
+
+### Options
+
+```
+  -h, --help              help for getenvoy
+      --home-dir string   GetEnvoy home directory (location of downloaded artifacts, caches, etc)
+```
+
+### SEE ALSO
+
+* [getenvoy fetch](/reference/getenvoy_fetch)	 - Downloads a version of Envoy. Available builds can be retrieved using `getenvoy list`.
+* [getenvoy list](/reference/getenvoy_list)	 - List available Envoy version references you can run
+* [getenvoy run](/reference/getenvoy_run)	 - Runs Envoy and collects process state on exit. Available builds can be retrieved using `getenvoy list`.
+
diff --git a/pkg/cmd/testdata/getenvoy_fetch.md b/pkg/cmd/testdata/getenvoy_fetch.md
new file mode 100644
index 0000000..2daa7a2
--- /dev/null
+++ b/pkg/cmd/testdata/getenvoy_fetch.md
@@ -0,0 +1,40 @@
++++
+title = "getenvoy fetch"
+type = "reference"
+parent = "getenvoy"
+command = "fetch"
++++
+## getenvoy fetch
+
+Downloads a version of Envoy. Available builds can be retrieved using `getenvoy list`.
+
+```
+getenvoy fetch <reference> [flags]
+```
+
+### Examples
+
+```
+# Fetch using a partial manifest reference to retrieve a build suitable for your operating system.
+getenvoy fetch VERSION
+		
+# Fetch using a full manifest reference to retrieve a specific build for Linux. 
+getenvoy fetch VERSION/linux-glibc
+```
+
+### Options
+
+```
+  -h, --help   help for fetch
+```
+
+### Options inherited from parent commands
+
+```
+      --home-dir string   GetEnvoy home directory (location of downloaded artifacts, caches, etc)
+```
+
+### SEE ALSO
+
+* [getenvoy](/reference/getenvoy)	 - Fetch and run Envoy
+
diff --git a/pkg/cmd/testdata/getenvoy_list.md b/pkg/cmd/testdata/getenvoy_list.md
new file mode 100644
index 0000000..32fb2d3
--- /dev/null
+++ b/pkg/cmd/testdata/getenvoy_list.md
@@ -0,0 +1,30 @@
++++
+title = "getenvoy list"
+type = "reference"
+parent = "getenvoy"
+command = "list"
++++
+## getenvoy list
+
+List available Envoy version references you can run
+
+```
+getenvoy list [flags]
+```
+
+### Options
+
+```
+  -h, --help   help for list
+```
+
+### Options inherited from parent commands
+
+```
+      --home-dir string   GetEnvoy home directory (location of downloaded artifacts, caches, etc)
+```
+
+### SEE ALSO
+
+* [getenvoy](/reference/getenvoy)	 - Fetch and run Envoy
+
diff --git a/pkg/cmd/testdata/getenvoy_run.md b/pkg/cmd/testdata/getenvoy_run.md
new file mode 100644
index 0000000..4c7b2e0
--- /dev/null
+++ b/pkg/cmd/testdata/getenvoy_run.md
@@ -0,0 +1,41 @@
++++
+title = "getenvoy run"
+type = "reference"
+parent = "getenvoy"
+command = "run"
++++
+## getenvoy run
+
+Runs Envoy and collects process state on exit. Available builds can be retrieved using `getenvoy list`.
+
+```
+getenvoy run reference [flags] [-- <envoy-args>]
+```
+
+### Examples
+
+```
+# Run using a manifest reference.
+getenvoy run VERSION -- --config-path ./bootstrap.yaml
+
+# List available Envoy flags.
+getenvoy run VERSION -- --help
+
+```
+
+### Options
+
+```
+  -h, --help   help for run
+```
+
+### Options inherited from parent commands
+
+```
+      --home-dir string   GetEnvoy home directory (location of downloaded artifacts, caches, etc)
+```
+
+### SEE ALSO
+
+* [getenvoy](/reference/getenvoy)	 - Fetch and run Envoy
+