Skip to content

Commit bcfb717

Browse files
authored
Merge pull request #6859 from thaJeztah/plugin_metadata
cli-plugins: separate hook types from manager and refactor
2 parents 743c385 + 4bf4d56 commit bcfb717

File tree

9 files changed

+416
-165
lines changed

9 files changed

+416
-165
lines changed

cli-plugins/hooks/hook_types.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
2+
//go:build go1.24
3+
4+
// Package hooks defines the contract between the Docker CLI and CLI plugin hook
5+
// implementations.
6+
//
7+
// # Audience
8+
//
9+
// This package is intended to be imported by CLI plugin implementations that
10+
// implement a "hooks" subcommand, and by the Docker CLI when invoking those
11+
// hooks.
12+
//
13+
// # Contract and wire format
14+
//
15+
// Hook inputs (see [Request]) are serialized as JSON and passed to the plugin hook
16+
// subcommand (currently as a command-line argument). Hook outputs are emitted by
17+
// the plugin as JSON (see [Response]).
18+
//
19+
// # Stability
20+
//
21+
// The types that represent the hook contract ([Request], [Response] and related
22+
// constants) are considered part of Docker CLI's public Go API.
23+
// Fields and values may be extended in a backwards-compatible way (for example,
24+
// adding new fields), but existing fields and their meaning should remain stable.
25+
// Plugins should ignore unknown fields and unknown hook types to remain
26+
// forwards-compatible.
27+
package hooks
28+
29+
// ResponseType is the type of response from the plugin.
30+
type ResponseType int
31+
32+
const (
33+
NextSteps ResponseType = 0
34+
)
35+
36+
// Request is the type representing the information
37+
// that plugins declaring support for hooks get passed when
38+
// being invoked following a CLI command execution.
39+
type Request struct {
40+
// RootCmd is a string representing the matching hook configuration
41+
// which is currently being invoked. If a hook for "docker context"
42+
// is configured and the user executes "docker context ls", the plugin
43+
// is invoked with "context".
44+
RootCmd string `json:"RootCmd,omitzero"`
45+
46+
// Flags contains flags that were set on the command for which the
47+
// hook was invoked. It uses flag names as key, with leading hyphens
48+
// removed ("--flag" and "-flag" are included as "flag" and "f").
49+
//
50+
// Flag values are not included and are set to an empty string,
51+
// except for boolean flags known to the CLI itself, for which
52+
// the value is either "true", or "false".
53+
//
54+
// Plugins can use this information to adjust their [Response]
55+
// based on whether the command triggering the hook was invoked
56+
// with.
57+
Flags map[string]string `json:"Flags,omitzero"`
58+
59+
// CommandError is a string containing the error output (if any)
60+
// of the command for which the hook was invoked.
61+
CommandError string `json:"CommandError,omitzero"`
62+
}
63+
64+
// Response represents a plugin hook response. Plugins
65+
// declaring support for CLI hooks need to print a JSON
66+
// representation of this type when their hook subcommand
67+
// is invoked.
68+
type Response struct {
69+
Type ResponseType `json:"Type"`
70+
Template string `json:"Template,omitzero"`
71+
}
72+
73+
// HookType is the type of response from the plugin.
74+
//
75+
// Deprecated: use [ResponseType] instead.
76+
//
77+
//go:fix inline
78+
type HookType = ResponseType
79+
80+
// HookMessage represents a plugin hook response.
81+
//
82+
// Deprecated: use [Response] instead.
83+
//
84+
//go:fix inline
85+
type HookMessage = Response

cli-plugins/hooks/hook_utils.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package hooks
2+
3+
import (
4+
"fmt"
5+
)
6+
7+
const (
8+
hookTemplateCommandName = `{{command}}`
9+
hookTemplateFlagValue = `{{flagValue %q}}`
10+
hookTemplateArg = `{{argValue %d}}`
11+
)
12+
13+
// TemplateReplaceSubcommandName returns a hook template string
14+
// that will be replaced by the CLI subcommand being executed
15+
//
16+
// Example:
17+
//
18+
// Response{
19+
// Type: NextSteps,
20+
// Template: "you ran the subcommand: " + TemplateReplaceSubcommandName(),
21+
// }
22+
//
23+
// When being executed after the command:
24+
//
25+
// docker run --name "my-container" alpine
26+
//
27+
// It results in the message:
28+
//
29+
// you ran the subcommand: run
30+
func TemplateReplaceSubcommandName() string {
31+
return hookTemplateCommandName
32+
}
33+
34+
// TemplateReplaceFlagValue returns a hook template string that will be
35+
// replaced with the flags value when printed by the CLI.
36+
//
37+
// Example:
38+
//
39+
// Response{
40+
// Type: NextSteps,
41+
// Template: "you ran a container named: " + TemplateReplaceFlagValue("name"),
42+
// }
43+
//
44+
// when executed after the command:
45+
//
46+
// docker run --name "my-container" alpine
47+
//
48+
// it results in the message:
49+
//
50+
// you ran a container named: my-container
51+
func TemplateReplaceFlagValue(flag string) string {
52+
return fmt.Sprintf(hookTemplateFlagValue, flag)
53+
}
54+
55+
// TemplateReplaceArg takes an index i and returns a hook
56+
// template string that the CLI will replace the template with
57+
// the ith argument after processing the passed flags.
58+
//
59+
// Example:
60+
//
61+
// Response{
62+
// Type: NextSteps,
63+
// Template: "run this image with `docker run " + TemplateReplaceArg(0) + "`",
64+
// }
65+
//
66+
// when being executed after the command:
67+
//
68+
// docker pull alpine
69+
//
70+
// It results in the message:
71+
//
72+
// Run this image with `docker run alpine`
73+
func TemplateReplaceArg(i int) string {
74+
return fmt.Sprintf(hookTemplateArg, i)
75+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package hooks_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/docker/cli/cli-plugins/hooks"
7+
)
8+
9+
func TestTemplateHelpers(t *testing.T) {
10+
tests := []struct {
11+
doc string
12+
got func() string
13+
want string
14+
}{
15+
{
16+
doc: "subcommand name",
17+
got: hooks.TemplateReplaceSubcommandName,
18+
want: `{{command}}`,
19+
},
20+
{
21+
doc: "flag value",
22+
got: func() string {
23+
return hooks.TemplateReplaceFlagValue("name")
24+
},
25+
want: `{{flagValue "name"}}`,
26+
},
27+
{
28+
doc: "arg",
29+
got: func() string {
30+
return hooks.TemplateReplaceArg(0)
31+
},
32+
want: `{{argValue 0}}`,
33+
},
34+
{
35+
doc: "arg",
36+
got: func() string {
37+
return hooks.TemplateReplaceArg(3)
38+
},
39+
want: `{{argValue 3}}`,
40+
},
41+
}
42+
43+
for _, tc := range tests {
44+
t.Run(tc.doc, func(t *testing.T) {
45+
if got := tc.got(); got != tc.want {
46+
t.Fatalf("expected %q, got %q", tc.want, got)
47+
}
48+
})
49+
}
50+
}

cli-plugins/hooks/printer.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
package hooks
22

3-
import (
4-
"fmt"
5-
"io"
3+
import "io"
64

7-
"github.com/morikuni/aec"
5+
const (
6+
whatsNext = "\n\033[1mWhat's next:\033[0m\n"
7+
indent = " "
88
)
99

10+
// PrintNextSteps renders list of [NextSteps] messages and writes them
11+
// to out. It is a no-op if messages is empty.
1012
func PrintNextSteps(out io.Writer, messages []string) {
1113
if len(messages) == 0 {
1214
return
1315
}
14-
_, _ = fmt.Fprintln(out, aec.Bold.Apply("\nWhat's next:"))
15-
for _, n := range messages {
16-
_, _ = fmt.Fprintln(out, " ", n)
16+
17+
_, _ = io.WriteString(out, whatsNext)
18+
for _, msg := range messages {
19+
_, _ = io.WriteString(out, indent)
20+
_, _ = io.WriteString(out, msg)
21+
_, _ = io.WriteString(out, "\n")
1722
}
1823
}

cli-plugins/hooks/printer_test.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,45 @@
1-
package hooks
1+
package hooks_test
22

33
import (
4-
"bytes"
4+
"strings"
55
"testing"
66

7-
"github.com/morikuni/aec"
7+
"github.com/docker/cli/cli-plugins/hooks"
88
"gotest.tools/v3/assert"
99
)
1010

1111
func TestPrintHookMessages(t *testing.T) {
12-
testCases := []struct {
12+
const header = "\n\x1b[1mWhat's next:\x1b[0m\n"
13+
14+
tests := []struct {
15+
doc string
1316
messages []string
1417
expectedOutput string
1518
}{
1619
{
17-
messages: []string{},
20+
doc: "no messages",
21+
messages: nil,
1822
expectedOutput: "",
1923
},
2024
{
25+
doc: "single message",
2126
messages: []string{"Bork!"},
22-
expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" +
27+
expectedOutput: header +
2328
" Bork!\n",
2429
},
2530
{
31+
doc: "multiple messages",
2632
messages: []string{"Foo", "bar"},
27-
expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" +
33+
expectedOutput: header +
2834
" Foo\n" +
2935
" bar\n",
3036
},
3137
}
32-
33-
for _, tc := range testCases {
34-
w := bytes.Buffer{}
35-
PrintNextSteps(&w, tc.messages)
36-
assert.Equal(t, w.String(), tc.expectedOutput)
38+
for _, tc := range tests {
39+
t.Run(tc.doc, func(t *testing.T) {
40+
var w strings.Builder
41+
hooks.PrintNextSteps(&w, tc.messages)
42+
assert.Equal(t, w.String(), tc.expectedOutput)
43+
})
3744
}
3845
}

0 commit comments

Comments
 (0)