mirror of
https://github.com/docker/cli.git
synced 2026-11-06 13:10:41 -04:00
Allow plugins to return informational messages without the next-steps heading or indentation. Invoke each hook once and render generic messages before the shared next-steps block. Centralize grouping and formatting in PrintMessages, with a callback for template evaluation and error handling. This avoids the need for an intermediate EvaluatedMessage type and simplifies hook collection to return the original responses. Skip messages containing only whitespace while preserving empty lines within non-empty messages. Keep PrintNextSteps as a deprecated wrapper to preserve existing callers and output. Update the printer tests and remove the obsolete appendMessages helper and its tests. Co-authored-by: Nicolas Beck <nicolas.beck@docker.com> Signed-off-by: Nicolas Beck <nicolas.beck@docker.com> Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
449 lines
12 KiB
Go
449 lines
12 KiB
Go
package manager
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/docker/cli/cli/config/configfile"
|
|
"github.com/spf13/cobra"
|
|
"gotest.tools/v3/assert"
|
|
is "gotest.tools/v3/assert/cmp"
|
|
)
|
|
|
|
type fakeConfigProvider struct {
|
|
cfg *configfile.ConfigFile
|
|
}
|
|
|
|
func (f *fakeConfigProvider) ConfigFile() *configfile.ConfigFile {
|
|
return f.cfg
|
|
}
|
|
|
|
func TestGetNaiveFlags(t *testing.T) {
|
|
testCases := []struct {
|
|
args []string
|
|
expectedFlags map[string]string
|
|
}{
|
|
{
|
|
args: []string{"docker"},
|
|
expectedFlags: map[string]string{},
|
|
},
|
|
{
|
|
args: []string{"docker", "build", "-q", "--file", "test.Dockerfile", "."},
|
|
expectedFlags: map[string]string{
|
|
"q": "",
|
|
"file": "",
|
|
},
|
|
},
|
|
{
|
|
args: []string{"docker", "--context", "a-context", "pull", "-q", "--progress", "auto", "alpine"},
|
|
expectedFlags: map[string]string{
|
|
"context": "",
|
|
"q": "",
|
|
"progress": "",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
assert.DeepEqual(t, getNaiveFlags(tc.args), tc.expectedFlags)
|
|
}
|
|
}
|
|
|
|
func TestPluginMatch(t *testing.T) {
|
|
testCases := []struct {
|
|
doc string
|
|
commandString string
|
|
pluginConfig map[string]string
|
|
cmdErrorMessage string
|
|
expectedMatch string
|
|
expectedOk bool
|
|
}{
|
|
{
|
|
doc: "hooks prefix match",
|
|
commandString: "image ls",
|
|
pluginConfig: map[string]string{
|
|
"hooks": "image",
|
|
},
|
|
expectedMatch: "image",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "hooks no match",
|
|
commandString: "context ls",
|
|
pluginConfig: map[string]string{
|
|
"hooks": "build",
|
|
},
|
|
expectedMatch: "",
|
|
expectedOk: false,
|
|
},
|
|
{
|
|
doc: "hooks exact match",
|
|
commandString: "context ls",
|
|
pluginConfig: map[string]string{
|
|
"hooks": "context ls",
|
|
},
|
|
expectedMatch: "context ls",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "hooks first match wins",
|
|
commandString: "image ls",
|
|
pluginConfig: map[string]string{
|
|
"hooks": "image ls,image",
|
|
},
|
|
expectedMatch: "image ls",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "hooks empty string",
|
|
commandString: "image ls",
|
|
pluginConfig: map[string]string{
|
|
"hooks": "",
|
|
},
|
|
expectedMatch: "",
|
|
expectedOk: false,
|
|
},
|
|
{
|
|
doc: "hooks partial token no match",
|
|
commandString: "image inspect",
|
|
pluginConfig: map[string]string{
|
|
"hooks": "image i",
|
|
},
|
|
expectedMatch: "",
|
|
expectedOk: false,
|
|
},
|
|
{
|
|
doc: "hooks prefix token match",
|
|
commandString: "image inspect",
|
|
pluginConfig: map[string]string{
|
|
"hooks": "image",
|
|
},
|
|
expectedMatch: "image",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "error-hooks match on error",
|
|
commandString: "build",
|
|
pluginConfig: map[string]string{
|
|
"error-hooks": "build",
|
|
},
|
|
cmdErrorMessage: "exit status 1",
|
|
expectedMatch: "build",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "error-hooks no match on success",
|
|
commandString: "build",
|
|
pluginConfig: map[string]string{
|
|
"error-hooks": "build",
|
|
},
|
|
cmdErrorMessage: "",
|
|
expectedMatch: "",
|
|
expectedOk: false,
|
|
},
|
|
{
|
|
doc: "error-hooks prefix match on error",
|
|
commandString: "compose up",
|
|
pluginConfig: map[string]string{
|
|
"error-hooks": "compose",
|
|
},
|
|
cmdErrorMessage: "exit status 1",
|
|
expectedMatch: "compose",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "error-hooks no match for wrong command",
|
|
commandString: "pull",
|
|
pluginConfig: map[string]string{
|
|
"error-hooks": "build",
|
|
},
|
|
cmdErrorMessage: "exit status 1",
|
|
expectedMatch: "",
|
|
expectedOk: false,
|
|
},
|
|
{
|
|
doc: "hooks takes precedence over error-hooks",
|
|
commandString: "build",
|
|
pluginConfig: map[string]string{
|
|
"hooks": "build",
|
|
"error-hooks": "build",
|
|
},
|
|
cmdErrorMessage: "exit status 1",
|
|
expectedMatch: "build",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "hooks fires on success even with error-hooks configured",
|
|
commandString: "build",
|
|
pluginConfig: map[string]string{
|
|
"hooks": "build",
|
|
"error-hooks": "build",
|
|
},
|
|
cmdErrorMessage: "",
|
|
expectedMatch: "build",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "error-hooks with multiple commands",
|
|
commandString: "compose up",
|
|
pluginConfig: map[string]string{
|
|
"error-hooks": "build,compose up,pull",
|
|
},
|
|
cmdErrorMessage: "exit status 1",
|
|
expectedMatch: "compose up",
|
|
expectedOk: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.doc, func(t *testing.T) {
|
|
match, ok := pluginMatch(tc.pluginConfig, tc.commandString, tc.cmdErrorMessage)
|
|
assert.Equal(t, ok, tc.expectedOk)
|
|
assert.Equal(t, match, tc.expectedMatch)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchHookConfig(t *testing.T) {
|
|
testCases := []struct {
|
|
doc string
|
|
configuredHooks string
|
|
subCmd string
|
|
expectedMatch string
|
|
expectedOk bool
|
|
}{
|
|
{
|
|
doc: "empty config",
|
|
configuredHooks: "",
|
|
subCmd: "build",
|
|
expectedMatch: "",
|
|
expectedOk: false,
|
|
},
|
|
{
|
|
doc: "exact match",
|
|
configuredHooks: "build",
|
|
subCmd: "build",
|
|
expectedMatch: "build",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "prefix match",
|
|
configuredHooks: "image",
|
|
subCmd: "image ls",
|
|
expectedMatch: "image",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "comma-separated match",
|
|
configuredHooks: "pull,build,push",
|
|
subCmd: "build",
|
|
expectedMatch: "build",
|
|
expectedOk: true,
|
|
},
|
|
{
|
|
doc: "no match",
|
|
configuredHooks: "pull,push",
|
|
subCmd: "build",
|
|
expectedMatch: "",
|
|
expectedOk: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.doc, func(t *testing.T) {
|
|
match, ok := matchHookConfig(tc.configuredHooks, tc.subCmd)
|
|
assert.Equal(t, ok, tc.expectedOk)
|
|
assert.Equal(t, match, tc.expectedMatch)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunPluginHooksPassesErrorMessage(t *testing.T) {
|
|
cfg := configfile.New("")
|
|
cfg.Plugins = map[string]map[string]string{
|
|
"test-plugin": {"hooks": "build"},
|
|
}
|
|
provider := &fakeConfigProvider{cfg: cfg}
|
|
root := &cobra.Command{Use: "docker"}
|
|
sub := &cobra.Command{Use: "build"}
|
|
root.AddCommand(sub)
|
|
|
|
// Should not panic with empty error message (success case)
|
|
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "")
|
|
|
|
// Should not panic with non-empty error message (failure case)
|
|
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "exit status 1")
|
|
}
|
|
|
|
func TestRunPluginHooksErrorHooks(t *testing.T) {
|
|
cfg := configfile.New("")
|
|
cfg.Plugins = map[string]map[string]string{
|
|
"test-plugin": {"error-hooks": "build"},
|
|
}
|
|
provider := &fakeConfigProvider{cfg: cfg}
|
|
root := &cobra.Command{Use: "docker"}
|
|
sub := &cobra.Command{Use: "build"}
|
|
root.AddCommand(sub)
|
|
|
|
// Should not panic — error-hooks with error message
|
|
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "exit status 1")
|
|
|
|
// Should not panic — error-hooks with no error (should be skipped)
|
|
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "")
|
|
}
|
|
|
|
func TestInvokeAndCollectHooksErrorHooksSkippedOnSuccess(t *testing.T) {
|
|
cfg := configfile.New("")
|
|
cfg.Plugins = map[string]map[string]string{
|
|
"nonexistent": {"error-hooks": "build"},
|
|
}
|
|
root := &cobra.Command{Use: "docker"}
|
|
sub := &cobra.Command{Use: "build"}
|
|
root.AddCommand(sub)
|
|
|
|
// On success, error-hooks should not match, so the plugin
|
|
// binary is never looked up and no results are returned.
|
|
result := invokeAndCollectHooks(
|
|
context.Background(), cfg, root,
|
|
"build", map[string]string{}, "",
|
|
)
|
|
assert.Check(t, is.Len(result, 0))
|
|
}
|
|
|
|
func TestInvokeAndCollectHooksNoPlugins(t *testing.T) {
|
|
cfg := configfile.New("")
|
|
root := &cobra.Command{Use: "docker"}
|
|
sub := &cobra.Command{Use: "build"}
|
|
root.AddCommand(sub)
|
|
|
|
result := invokeAndCollectHooks(
|
|
context.Background(), cfg, root,
|
|
"build", map[string]string{}, "some error",
|
|
)
|
|
assert.Check(t, is.Len(result, 0))
|
|
}
|
|
|
|
func TestInvokeAndCollectHooksCancelledContext(t *testing.T) {
|
|
cfg := configfile.New("")
|
|
cfg.Plugins = map[string]map[string]string{
|
|
"test-plugin": {"hooks": "build"},
|
|
}
|
|
root := &cobra.Command{Use: "docker"}
|
|
sub := &cobra.Command{Use: "build"}
|
|
root.AddCommand(sub)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
|
|
result := invokeAndCollectHooks(
|
|
ctx, cfg, root,
|
|
"build", map[string]string{}, "exit status 1",
|
|
)
|
|
assert.Check(t, is.Nil(result))
|
|
}
|
|
|
|
func TestRunHooksMessages(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("test plugins require a POSIX shell")
|
|
}
|
|
|
|
const header = "\n\x1b[1mWhat's next:\x1b[0m\n"
|
|
for _, tc := range []struct {
|
|
doc string
|
|
responses map[string]string
|
|
expected string
|
|
}{
|
|
{
|
|
doc: "generic message preserves formatting and expands templates",
|
|
responses: map[string]string{
|
|
"status": `{"Type":1,"Template":"Status for {{command}}\n\n Details"}`,
|
|
},
|
|
expected: "\nStatus for build\n\n Details\n",
|
|
},
|
|
{
|
|
doc: "omitted type defaults to next steps",
|
|
responses: map[string]string{
|
|
"suggestion": `{"Template":"Try another command"}`,
|
|
},
|
|
expected: header + " Try another command\n",
|
|
},
|
|
{
|
|
doc: "generic messages precede shared next steps header",
|
|
responses: map[string]string{
|
|
"statusone": `{"Type":1,"Template":"Session active"}`,
|
|
"statustwo": `{"Type":1,"Template":"Session active"}`,
|
|
"suggestionone": `{"Type":0,"Template":"Try another command"}`,
|
|
"suggestiontwo": `{"Type":0,"Template":"Try another command"}`,
|
|
},
|
|
expected: "\nSession active\nSession active\n" + header + " Try another command\n Try another command\n",
|
|
},
|
|
{
|
|
doc: "empty responses produce no output",
|
|
responses: map[string]string{
|
|
"status": `{"Type":1,"Template":" \n\t"}`,
|
|
"suggestion": `{"Type":0,"Template":""}`,
|
|
},
|
|
},
|
|
{
|
|
doc: "empty generic message does not affect next steps",
|
|
responses: map[string]string{
|
|
"status": `{"Type":1,"Template":""}`,
|
|
"suggestion": `{"Type":0,"Template":"Try another command"}`,
|
|
},
|
|
expected: header + " Try another command\n",
|
|
},
|
|
{
|
|
doc: "invalid responses do not affect other plugins",
|
|
responses: map[string]string{
|
|
"unknown": `{"Type":99,"Template":"Ignored"}`,
|
|
"invalid": `{"Type":1,"Template":"{{"}`,
|
|
"status": `{"Type":1,"Template":"Session active"}`,
|
|
"suggestion": `{"Type":0,"Template":"Try another command"}`,
|
|
},
|
|
expected: "\nSession active\n" + header + " Try another command\n",
|
|
},
|
|
} {
|
|
t.Run(tc.doc, func(t *testing.T) {
|
|
pluginDir := t.TempDir()
|
|
cfg := configfile.New("")
|
|
cfg.CLIPluginsExtraDirs = []string{pluginDir}
|
|
cfg.Plugins = make(map[string]map[string]string)
|
|
for name, response := range tc.responses {
|
|
plugin := fmt.Sprintf(`#!/bin/sh
|
|
case "$1" in
|
|
docker-cli-plugin-metadata)
|
|
printf '%%s\n' '{"SchemaVersion":"0.1.0","Vendor":"test"}'
|
|
;;
|
|
*)
|
|
test "$2" = docker-cli-plugin-hooks || exit 1
|
|
printf 'called\n' >> "$0.calls"
|
|
printf '%%s\n' '%s'
|
|
;;
|
|
esac
|
|
`, response)
|
|
assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-"+name), []byte(plugin), 0o755))
|
|
cfg.Plugins[name] = map[string]string{"hooks": "build"}
|
|
}
|
|
|
|
root := &cobra.Command{Use: "docker"}
|
|
sub := &cobra.Command{Use: "build"}
|
|
root.AddCommand(sub)
|
|
var output strings.Builder
|
|
sub.SetErr(&output)
|
|
|
|
runHooks(context.Background(), cfg, root, sub, "build", nil, "")
|
|
assert.Equal(t, output.String(), tc.expected)
|
|
for name := range tc.responses {
|
|
calls, err := os.ReadFile(filepath.Join(pluginDir, "docker-"+name+".calls"))
|
|
assert.NilError(t, err)
|
|
assert.Equal(t, string(calls), "called\n", "each plugin hook must run exactly once")
|
|
}
|
|
})
|
|
}
|
|
}
|