-
Notifications
You must be signed in to change notification settings - Fork 2.1k
cli-plugins: separate hook types from manager and refactor #6859
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
base: master
Are you sure you want to change the base?
Changes from all commits
5343bdc
0501cf8
dd91ed3
6018092
607ebfc
0431e4d
e05f98f
7d57887
e59cd10
8269a67
a555e86
0182f9f
6e609c3
72e03bb
69ab970
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: | ||
| //go:build go1.24 | ||
|
|
||
| // Package hooks defines the contract between the Docker CLI and CLI plugin hook | ||
| // implementations. | ||
| // | ||
| // # Audience | ||
| // | ||
| // This package is intended to be imported by CLI plugin implementations that | ||
| // implement a "hooks" subcommand, and by the Docker CLI when invoking those | ||
| // hooks. | ||
| // | ||
| // # Contract and wire format | ||
| // | ||
| // Hook inputs (see [Request]) are serialized as JSON and passed to the plugin hook | ||
| // subcommand (currently as a command-line argument). Hook outputs are emitted by | ||
| // the plugin as JSON (see [Response]). | ||
| // | ||
| // # Stability | ||
| // | ||
| // The types that represent the hook contract ([Request], [Response] and related | ||
| // constants) are considered part of Docker CLI's public Go API. | ||
| // Fields and values may be extended in a backwards-compatible way (for example, | ||
| // adding new fields), but existing fields and their meaning should remain stable. | ||
| // Plugins should ignore unknown fields and unknown hook types to remain | ||
| // forwards-compatible. | ||
| package hooks | ||
|
|
||
| // ResponseType is the type of response from the plugin. | ||
| type ResponseType int | ||
|
|
||
| const ( | ||
| NextSteps ResponseType = 0 | ||
| ) | ||
|
|
||
| // Request is the type representing the information | ||
| // that plugins declaring support for hooks get passed when | ||
| // being invoked following a CLI command execution. | ||
| type Request struct { | ||
| // RootCmd is a string representing the matching hook configuration | ||
| // which is currently being invoked. If a hook for "docker context" | ||
| // is configured and the user executes "docker context ls", the plugin | ||
| // is invoked with "context". | ||
| RootCmd string `json:"rootCmd,omitzero"` | ||
|
|
||
| // Flags contains flags that were set on the command for which the | ||
| // hook was invoked. It uses flag names as key, with leading hyphens | ||
| // removed ("--flag" and "-flag" are included as "flag" and "f"). | ||
| // | ||
| // Flag values are not included and are set to an empty string, | ||
| // except for boolean flags known to the CLI itself, for which | ||
| // the value is either "true", or "false". | ||
| // | ||
| // Plugins can use this information to adjust their [Response] | ||
| // based on whether the command triggering the hook was invoked | ||
| // with. | ||
| Flags map[string]string `json:"flags,omitzero"` | ||
|
|
||
| // CommandError is a string containing the error output (if any) | ||
| // of the command for which the hook was invoked. | ||
| CommandError string `json:"commandError,omitzero"` | ||
|
Comment on lines
+40
to
+61
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously these were using the default JSON casing so ( Can we break the format here?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no, shouldn't ... really; when parsing, Golang is still case-insensitive, but if there's multiple fields only differing in casing, it will prefer the one added here. |
||
| } | ||
|
|
||
| // Response represents a plugin hook response. Plugins | ||
| // declaring support for CLI hooks need to print a JSON | ||
| // representation of this type when their hook subcommand | ||
| // is invoked. | ||
| type Response struct { | ||
| Type ResponseType `json:"type,omitzero"` | ||
| Template string `json:"template,omitzero"` | ||
| } | ||
|
|
||
| // HookType is the type of response from the plugin. | ||
| // | ||
| // Deprecated: use [ResponseType] instead. | ||
| // | ||
| //go:fix inline | ||
| type HookType = ResponseType | ||
|
|
||
| // HookMessage represents a plugin hook response. | ||
| // | ||
| // Deprecated: use [Response] instead. | ||
| // | ||
| //go:fix inline | ||
| type HookMessage = Response | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package hooks | ||
|
|
||
| import ( | ||
| "fmt" | ||
| ) | ||
|
|
||
| const ( | ||
| hookTemplateCommandName = `{{command}}` | ||
| hookTemplateFlagValue = `{{flagValue %q}}` | ||
| hookTemplateArg = `{{argValue %d}}` | ||
| ) | ||
|
|
||
| // TemplateReplaceSubcommandName returns a hook template string | ||
| // that will be replaced by the CLI subcommand being executed | ||
| // | ||
| // Example: | ||
| // | ||
| // Response{ | ||
| // Type: NextSteps, | ||
| // Template: "you ran the subcommand: " + TemplateReplaceSubcommandName(), | ||
| // } | ||
| // | ||
| // When being executed after the command: | ||
| // | ||
| // docker run --name "my-container" alpine | ||
| // | ||
| // It results in the message: | ||
| // | ||
| // you ran the subcommand: run | ||
| func TemplateReplaceSubcommandName() string { | ||
| return hookTemplateCommandName | ||
| } | ||
|
|
||
| // TemplateReplaceFlagValue returns a hook template string that will be | ||
| // replaced with the flags value when printed by the CLI. | ||
| // | ||
| // Example: | ||
| // | ||
| // Response{ | ||
| // Type: NextSteps, | ||
| // Template: "you ran a container named: " + TemplateReplaceFlagValue("name"), | ||
| // } | ||
| // | ||
| // when executed after the command: | ||
| // | ||
| // docker run --name "my-container" alpine | ||
| // | ||
| // it results in the message: | ||
| // | ||
| // you ran a container named: my-container | ||
| func TemplateReplaceFlagValue(flag string) string { | ||
| return fmt.Sprintf(hookTemplateFlagValue, flag) | ||
| } | ||
|
|
||
| // TemplateReplaceArg takes an index i and returns a hook | ||
| // template string that the CLI will replace the template with | ||
| // the ith argument after processing the passed flags. | ||
| // | ||
| // Example: | ||
| // | ||
| // Response{ | ||
| // Type: NextSteps, | ||
| // Template: "run this image with `docker run " + TemplateReplaceArg(0) + "`", | ||
| // } | ||
| // | ||
| // when being executed after the command: | ||
| // | ||
| // docker pull alpine | ||
| // | ||
| // It results in the message: | ||
| // | ||
| // Run this image with `docker run alpine` | ||
| func TemplateReplaceArg(i int) string { | ||
| return fmt.Sprintf(hookTemplateArg, i) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package hooks_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/docker/cli/cli-plugins/hooks" | ||
| ) | ||
|
|
||
| func TestTemplateHelpers(t *testing.T) { | ||
| tests := []struct { | ||
| doc string | ||
| got func() string | ||
| want string | ||
| }{ | ||
| { | ||
| doc: "subcommand name", | ||
| got: hooks.TemplateReplaceSubcommandName, | ||
| want: `{{command}}`, | ||
| }, | ||
| { | ||
| doc: "flag value", | ||
| got: func() string { | ||
| return hooks.TemplateReplaceFlagValue("name") | ||
| }, | ||
| want: `{{flagValue "name"}}`, | ||
| }, | ||
| { | ||
| doc: "arg", | ||
| got: func() string { | ||
| return hooks.TemplateReplaceArg(0) | ||
| }, | ||
| want: `{{argValue 0}}`, | ||
| }, | ||
| { | ||
| doc: "arg", | ||
| got: func() string { | ||
| return hooks.TemplateReplaceArg(3) | ||
| }, | ||
| want: `{{argValue 3}}`, | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range tests { | ||
| t.Run(tc.doc, func(t *testing.T) { | ||
| if got := tc.got(); got != tc.want { | ||
| t.Fatalf("expected %q, got %q", tc.want, got) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,20 @@ | ||
| package hooks | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
| import "io" | ||
|
|
||
| "github.com/morikuni/aec" | ||
| ) | ||
| const whatsNext = "\033[1mWhat's next:\033[0m" | ||
|
|
||
| func PrintNextSteps(out io.Writer, messages []string) { | ||
| if len(messages) == 0 { | ||
| return | ||
| } | ||
| _, _ = fmt.Fprintln(out, aec.Bold.Apply("\nWhat's next:")) | ||
| for _, n := range messages { | ||
| _, _ = fmt.Fprintln(out, " ", n) | ||
|
|
||
| _, _ = io.WriteString(out, "\n") | ||
| _, _ = io.WriteString(out, whatsNext) | ||
| _, _ = io.WriteString(out, "\n") | ||
| for _, msg := range messages { | ||
| _, _ = io.WriteString(out, " ") | ||
| _, _ = io.WriteString(out, msg) | ||
| _, _ = io.WriteString(out, "\n") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,38 +1,45 @@ | ||
| package hooks | ||
| package hooks_test | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/morikuni/aec" | ||
| "github.com/docker/cli/cli-plugins/hooks" | ||
| "gotest.tools/v3/assert" | ||
| ) | ||
|
|
||
| func TestPrintHookMessages(t *testing.T) { | ||
| testCases := []struct { | ||
| const header = "\x1b[1m\nWhat's next:\x1b[0m\n" | ||
|
|
||
| tests := []struct { | ||
| doc string | ||
| messages []string | ||
| expectedOutput string | ||
| }{ | ||
| { | ||
| messages: []string{}, | ||
| doc: "no messages", | ||
| messages: nil, | ||
| expectedOutput: "", | ||
| }, | ||
| { | ||
| doc: "single message", | ||
| messages: []string{"Bork!"}, | ||
| expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" + | ||
| expectedOutput: header + | ||
| " Bork!\n", | ||
| }, | ||
| { | ||
| doc: "multiple messages", | ||
| messages: []string{"Foo", "bar"}, | ||
| expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" + | ||
| expectedOutput: header + | ||
| " Foo\n" + | ||
| " bar\n", | ||
| }, | ||
| } | ||
|
|
||
| for _, tc := range testCases { | ||
| w := bytes.Buffer{} | ||
| PrintNextSteps(&w, tc.messages) | ||
| assert.Equal(t, w.String(), tc.expectedOutput) | ||
| for _, tc := range tests { | ||
| t.Run(tc.doc, func(t *testing.T) { | ||
| var w strings.Builder | ||
| hooks.PrintNextSteps(&w, tc.messages) | ||
| assert.Equal(t, w.String(), tc.expectedOutput) | ||
| }) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.