Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add plugin listing to "kn --help" #929

Merged
merged 10 commits into from
Jul 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
|===
| | Description | PR

| 🎁
| Add available plugins to help messages
| https://github.com/knative/client/pull/929[#929]

| 🎁
| Add "url" output format to return service url in service describe
| https://github.com/knative/client/pull/916[#916]
Expand Down
25 changes: 22 additions & 3 deletions cmd/kn/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ func main() {
}
}

// runError is used when during the execution of a command/plugin an error occurs and
// so no extra usage message should be shown.
type runError struct {
err error
}

// Error implements the error() interface
func (e *runError) Error() string {
return e.err.Error()
}

// Run the main program. Args are the args as given on the command line (excluding the program name itself)
func run(args []string) error {
// Parse config & plugin flags early to read in configuration file
Expand All @@ -67,7 +78,7 @@ func run(args []string) error {
}

// Create kn root command and all sub-commands
rootCmd, err := root.NewRootCommand()
rootCmd, err := root.NewRootCommand(pluginManager.HelpTemplateFuncs())
if err != nil {
return err
}
Expand All @@ -79,7 +90,11 @@ func run(args []string) error {
return err
}

return plugin.Execute(argsWithoutCommands(args, plugin.CommandParts()))
err := plugin.Execute(argsWithoutCommands(args, plugin.CommandParts()))
if err != nil {
return &runError{err: err}
}
return nil
} else {
// Validate args for root command
err = validateRootCommand(rootCmd)
Expand Down Expand Up @@ -190,7 +205,11 @@ func validateRootCommand(cmd *cobra.Command) error {
// printError prints out any given error
func printError(err error) {
fmt.Fprintf(os.Stderr, "Error: %s\n", cleanupErrorMessage(err.Error()))
fmt.Fprintf(os.Stderr, "Run '%s --help' for usage\n", extractCommandPathFromErrorMessage(err.Error(), os.Args[0]))
var runError *runError
if !errors.As(err, &runError) {
// Print help hint only if its not a runError occurred when executing a command
fmt.Fprintf(os.Stderr, "Run '%s --help' for usage\n", extractCommandPathFromErrorMessage(err.Error(), os.Args[0]))
}
}

// extractCommandPathFromErrorMessage tries to extract the command name from an error message
Expand Down
25 changes: 24 additions & 1 deletion cmd/kn/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ func TestUnknownCommands(t *testing.T) {
}
for _, d := range data {
args := append([]string{"kn"}, d.givenCmdArgs...)
rootCmd, err := root.NewRootCommand()
rootCmd, err := root.NewRootCommand(nil)
os.Args = args
assert.NilError(t, err)
err = validateRootCommand(rootCmd)
Expand Down Expand Up @@ -253,6 +253,29 @@ func TestRunWithError(t *testing.T) {
}
}

func TestRunWithPluginError(t *testing.T) {
data := []struct {
given string
expected string
}{
{
"exit status 1",
"Error: exit status 1",
},
}
for _, d := range data {
capture := test.CaptureOutput(t)
// displayHelp argument is false for plugin error
printError(&runError{errors.New(d.given)})
stdOut, errOut := capture.Close()

assert.Equal(t, stdOut, "")
assert.Assert(t, strings.Contains(errOut, d.expected))
// check that --help message isn't displayed
assert.Assert(t, util.ContainsNone(errOut, "Run", "--help", "usage"))
}
}

// Smoke test
func TestRun(t *testing.T) {
oldArgs := os.Args
Expand Down
2 changes: 1 addition & 1 deletion hack/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ go_test() {

echo "🧪 ${X}Test"
set +e
go test -v ./pkg/... >$test_output 2>&1
go test -v ./cmd/... ./pkg/... >$test_output 2>&1
local err=$?
if [ $err -ne 0 ]; then
echo "🔥 ${red}Failure${reset}"
Expand Down
2 changes: 1 addition & 1 deletion hack/generate-docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import (
)

func main() {
rootCmd, err := root.NewRootCommand()
rootCmd, err := root.NewRootCommand(nil)
if err != nil {
log.Panicf("can not create root command: %v", err)
}
Expand Down
85 changes: 83 additions & 2 deletions pkg/kn/plugin/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import (
"runtime"
"sort"
"strings"
"text/template"

homedir "github.com/mitchellh/go-homedir"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)

// Interface describing a plugin
Expand Down Expand Up @@ -108,6 +110,12 @@ func (manager *Manager) FindPlugin(parts []string) (Plugin, error) {

// ListPlugins lists all plugins that can be found in the plugin directory or in the path (if configured)
func (manager *Manager) ListPlugins() (PluginList, error) {
return manager.ListPluginsForCommandGroup(nil)
}

// ListPluginsForCommandGroup lists all plugins that can be found in the plugin directory or in the path (if configured),
// and which fits to a command group
func (manager *Manager) ListPluginsForCommandGroup(commandGroupParts []string) (PluginList, error) {
var plugins []Plugin

dirs, err := manager.pluginLookupDirectories()
Expand Down Expand Up @@ -135,6 +143,11 @@ func (manager *Manager) ListPlugins() (PluginList, error) {
continue
}

// Check if plugin matches a command group
if !isPartOfCommandGroup(commandGroupParts, f.Name()) {
continue
}

// Ignore all plugins that are shadowed
if _, ok := hasSeen[name]; !ok {
plugins = append(plugins, &plugin{
Expand All @@ -146,11 +159,35 @@ func (manager *Manager) ListPlugins() (PluginList, error) {
}
}
}

// Sort according to name
sort.Sort(PluginList(plugins))
return plugins, nil
}

func isPartOfCommandGroup(commandGroupParts []string, name string) bool {
if commandGroupParts == nil {
return true
}

commandParts := extractPluginCommandFromFileName(name)

// commandParts must be one more element then the parts of the command group
// it belongs to. E.g. for the command "service", "log" (2 elements) the containing
// group only has one element ("service"). This condition is here for
// shortcut and ensure that we don't run in an out-of-bound array error
// in the loop below.
if len(commandParts) != len(commandGroupParts)+1 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get some English comments for these conditions... typically not for comments but when conditions are not obvious...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure.

return false
}
for i := range commandGroupParts {
if commandParts[i] != commandGroupParts[i] {
return false
}
}
return true
}

// PluginsDir returns the configured directory holding plugins
func (manager *Manager) PluginsDir() string {
return manager.pluginsDir
Expand All @@ -176,8 +213,9 @@ func (plugin *plugin) Execute(args []string) error {
// Return a description of the plugin (if support by the plugin binary)
func (plugin *plugin) Description() (string, error) {
// TODO: Call out to the plugin to find a description.
// For now just use the plugin name
return strings.Join(plugin.commandParts, "-"), nil
// For now just use the path to the plugin
return plugin.path, nil
// return strings.Join(plugin.commandParts, "-"), nil
}

// The the command path leading to this plugin.
Expand Down Expand Up @@ -212,6 +250,49 @@ func (manager *Manager) pluginLookupDirectories() ([]string, error) {
return dirs, nil
}

// HelpTemplateFuncs returns a function map which can be used in templates for resolving
// plugin related help messages
func (manager *Manager) HelpTemplateFuncs() *template.FuncMap {
ret := template.FuncMap{
"listPlugins": manager.listPluginsHelpMessage(),
}

return &ret
}

// listPluginsHelpMessage returns a function which returns all plugins that are directly below the given
// command as a properly formatted string
func (manager *Manager) listPluginsHelpMessage() func(cmd *cobra.Command) string {
return func(cmd *cobra.Command) string {
if !cmd.HasSubCommands() {
return ""
}
list, err := manager.ListPluginsForCommandGroup(extractCommandGroup(cmd, []string{}))
if err != nil || len(list) == 0 {
// We don't show plugins if there is an error
return ""
}
var plugins []string
for _, pl := range list {
t := fmt.Sprintf(" %%-%ds %%s", cmd.NamePadding())
desc, _ := pl.Description()
command := (pl.CommandParts())[len(pl.CommandParts())-1]
help := fmt.Sprintf(t, command, desc)
plugins = append(plugins, help)
}
return strings.Join(plugins, "\n")
}
}

// extractCommandGroup constructs the command path as array of strings
func extractCommandGroup(cmd *cobra.Command, parts []string) []string {
if cmd.HasParent() {
parts = extractCommandGroup(cmd.Parent(), parts)
parts = append(parts, cmd.Name())
}
return parts
}

// uniquePathsList deduplicates a given slice of strings without
// sorting or otherwise altering its order in any way.
func uniquePathsList(paths []string) []string {
Expand Down
52 changes: 52 additions & 0 deletions pkg/kn/plugin/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ import (
"io/ioutil"
"os"
"path/filepath"
"regexp"
"runtime"
"testing"

"github.com/spf13/cobra"
"gotest.tools/assert"
)

Expand Down Expand Up @@ -110,6 +112,56 @@ func TestPluginExecute(t *testing.T) {
assert.Equal(t, out, "OK arg1 arg2\n")
}

func TestPluginListForCommandGroup(t *testing.T) {
ctx := setup(t)
defer cleanup(t, ctx)
createTestPlugin(t, "kn-service-log_2", ctx)

pluginList, err := ctx.pluginManager.ListPluginsForCommandGroup([]string{"service"})
assert.NilError(t, err)
assert.Assert(t, pluginList.Len() == 1)
assert.Equal(t, pluginList[0].Name(), "kn-service-log_2")
pluginList, err = ctx.pluginManager.ListPluginsForCommandGroup([]string{})
assert.NilError(t, err)
assert.Assert(t, pluginList.Len() == 0)
}

func TestPluginHelpMessage(t *testing.T) {
ctx := setup(t)
defer cleanup(t, ctx)
createTestPlugin(t, "kn-service-log_2", ctx)
createTestPlugin(t, "kn-admin", ctx)

funcs := *ctx.pluginManager.HelpTemplateFuncs()
f := funcs["listPlugins"]
assert.Assert(t, f != nil)
listPluginsFunc := ctx.pluginManager.listPluginsHelpMessage()

root := &cobra.Command{
Use: "kn",
}
serviceCmd := &cobra.Command{
Use: "service",
}
serviceCreateCmd := &cobra.Command{
Use: "create",
}
serviceCmd.AddCommand(serviceCreateCmd)
root.AddCommand(serviceCmd)

helpRoot := listPluginsFunc(root)
re := regexp.MustCompile("^\\s*admin\\s.*admin")
assert.Assert(t, re.MatchString(helpRoot))

helpService := listPluginsFunc(serviceCmd)
println(helpService)
re = regexp.MustCompile("^\\s*log-2\\s.*kn-service-log_2")
assert.Assert(t, re.MatchString(helpService))

helpServiceCreate := listPluginsFunc(serviceCreateCmd)
assert.Assert(t, len(helpServiceCreate) == 0)
}

func TestPluginList(t *testing.T) {
ctx := setup(t)
defer cleanup(t, ctx)
Expand Down
5 changes: 3 additions & 2 deletions pkg/kn/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"flag"
"fmt"
"strings"
"text/template"

"github.com/pkg/errors"
"github.com/spf13/cobra"
Expand All @@ -41,7 +42,7 @@ import (
)

// NewRootCommand creates the default `kn` command with a default plugin handler
func NewRootCommand() (*cobra.Command, error) {
func NewRootCommand(helpFuncs *template.FuncMap) (*cobra.Command, error) {
p := &commands.KnParams{}
p.Initialize()

Expand Down Expand Up @@ -107,7 +108,7 @@ func NewRootCommand() (*cobra.Command, error) {
groups.AddTo(rootCmd)

// Initialize default `help` cmd early to prevent unknown command errors
groups.SetRootUsage(rootCmd)
groups.SetRootUsage(rootCmd, helpFuncs)

// Add the "options" commands for showing all global options
rootCmd.AddCommand(options.NewOptionsCommand())
Expand Down
6 changes: 3 additions & 3 deletions pkg/kn/root/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import (
)

func TestNewRootCommand(t *testing.T) {
rootCmd, err := NewRootCommand()
rootCmd, err := NewRootCommand(nil)
assert.NilError(t, err)
assert.Assert(t, rootCmd != nil)

Expand All @@ -51,13 +51,13 @@ func TestNewRootCommand(t *testing.T) {
}

func TestSubCommands(t *testing.T) {
rootCmd, err := NewRootCommand()
rootCmd, err := NewRootCommand(nil)
assert.NilError(t, err)
checkLeafCommand(t, "version", rootCmd)
}

func TestCommandGroup(t *testing.T) {
rootCmd, err := NewRootCommand()
rootCmd, err := NewRootCommand(nil)
assert.NilError(t, err)
commandGroups := []string{
"service", "revision", "plugin", "source", "source apiserver",
Expand Down
9 changes: 4 additions & 5 deletions pkg/templates/command_groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package templates

import (
"text/template"

"github.com/spf13/cobra"
)

Expand All @@ -40,11 +42,8 @@ func (g CommandGroups) AddTo(cmd *cobra.Command) {
}

// SetRootUsage sets our own help and usage function messages to the root command
func (g CommandGroups) SetRootUsage(rootCmd *cobra.Command) {
engine := &templateEngine{
RootCmd: rootCmd,
CommandGroups: g,
}
func (g CommandGroups) SetRootUsage(rootCmd *cobra.Command, extraTemplateFunctions *template.FuncMap) {
engine := newTemplateEngine(rootCmd, g, extraTemplateFunctions)
setHelpFlagsToSubCommands(rootCmd)
rootCmd.SetUsageFunc(engine.usageFunc())
rootCmd.SetHelpFunc(engine.helpFunc())
Expand Down
Loading