Skip to content

Commit ddb9864

Browse files
authored
Merge pull request #6794 from derekmisler/cli-hints-for-docker-ai-after-buildcompose-failur
Fix: run plugin hooks on command failure, not just success
2 parents 5348cf8 + 830d05d commit ddb9864

File tree

4 files changed

+348
-22
lines changed

4 files changed

+348
-22
lines changed

cli-plugins/manager/hooks.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ func RunCLICommandHooks(ctx context.Context, dockerCLI config.Provider, rootCmd,
4242

4343
// RunPluginHooks is the entrypoint for the hooks execution flow
4444
// after a plugin command was just executed by the CLI.
45-
func RunPluginHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, subCommand *cobra.Command, args []string) {
45+
func RunPluginHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, subCommand *cobra.Command, args []string, cmdErrorMessage string) {
4646
commandName := strings.Join(args, " ")
4747
flags := getNaiveFlags(args)
4848

49-
runHooks(ctx, dockerCLI.ConfigFile(), rootCmd, subCommand, commandName, flags, "")
49+
runHooks(ctx, dockerCLI.ConfigFile(), rootCmd, subCommand, commandName, flags, cmdErrorMessage)
5050
}
5151

5252
func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCommand *cobra.Command, invokedCommand string, flags map[string]string, cmdErrorMessage string) {
@@ -70,7 +70,7 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root
7070
pluginDirs := getPluginDirs(cfg)
7171
nextSteps := make([]string, 0, len(pluginsCfg))
7272
for pluginName, pluginCfg := range pluginsCfg {
73-
match, ok := pluginMatch(pluginCfg, subCmdStr)
73+
match, ok := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage)
7474
if !ok {
7575
continue
7676
}
@@ -138,13 +138,34 @@ func appendNextSteps(nextSteps []string, processed []string) ([]string, bool) {
138138
// command being executed (such as 'image ls' – the root 'docker' is omitted)
139139
// and, if the configuration includes a hook for the invoked command, returns
140140
// the configured hook string.
141-
func pluginMatch(pluginCfg map[string]string, subCmd string) (string, bool) {
142-
configuredPluginHooks, ok := pluginCfg["hooks"]
143-
if !ok || configuredPluginHooks == "" {
141+
//
142+
// Plugins can declare two types of hooks in their configuration:
143+
// - "hooks": fires on every command invocation (success or failure)
144+
// - "error-hooks": fires only when a command fails (cmdErrorMessage is non-empty)
145+
func pluginMatch(pluginCfg map[string]string, subCmd string, cmdErrorMessage string) (string, bool) {
146+
// Check "hooks" first — these always fire regardless of command outcome.
147+
if match, ok := matchHookConfig(pluginCfg["hooks"], subCmd); ok {
148+
return match, true
149+
}
150+
151+
// Check "error-hooks" — these only fire when there was an error.
152+
if cmdErrorMessage != "" {
153+
if match, ok := matchHookConfig(pluginCfg["error-hooks"], subCmd); ok {
154+
return match, true
155+
}
156+
}
157+
158+
return "", false
159+
}
160+
161+
// matchHookConfig checks if a comma-separated hook configuration string
162+
// contains a prefix match for the given subcommand.
163+
func matchHookConfig(configuredHooks string, subCmd string) (string, bool) {
164+
if configuredHooks == "" {
144165
return "", false
145166
}
146167

147-
for hookCmd := range strings.SplitSeq(configuredPluginHooks, ",") {
168+
for hookCmd := range strings.SplitSeq(configuredHooks, ",") {
148169
if hookMatch(hookCmd, subCmd) {
149170
return hookCmd, true
150171
}

cli-plugins/manager/hooks_test.go

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

33
import (
4+
"context"
45
"testing"
56

7+
"github.com/docker/cli/cli/config/configfile"
8+
"github.com/spf13/cobra"
69
"gotest.tools/v3/assert"
710
is "gotest.tools/v3/assert/cmp"
811
)
912

13+
type fakeConfigProvider struct {
14+
cfg *configfile.ConfigFile
15+
}
16+
17+
func (f *fakeConfigProvider) ConfigFile() *configfile.ConfigFile {
18+
return f.cfg
19+
}
20+
1021
func TestGetNaiveFlags(t *testing.T) {
1122
testCases := []struct {
1223
args []string
@@ -40,12 +51,15 @@ func TestGetNaiveFlags(t *testing.T) {
4051

4152
func TestPluginMatch(t *testing.T) {
4253
testCases := []struct {
43-
commandString string
44-
pluginConfig map[string]string
45-
expectedMatch string
46-
expectedOk bool
54+
doc string
55+
commandString string
56+
pluginConfig map[string]string
57+
cmdErrorMessage string
58+
expectedMatch string
59+
expectedOk bool
4760
}{
4861
{
62+
doc: "hooks prefix match",
4963
commandString: "image ls",
5064
pluginConfig: map[string]string{
5165
"hooks": "image",
@@ -54,6 +68,7 @@ func TestPluginMatch(t *testing.T) {
5468
expectedOk: true,
5569
},
5670
{
71+
doc: "hooks no match",
5772
commandString: "context ls",
5873
pluginConfig: map[string]string{
5974
"hooks": "build",
@@ -62,6 +77,7 @@ func TestPluginMatch(t *testing.T) {
6277
expectedOk: false,
6378
},
6479
{
80+
doc: "hooks exact match",
6581
commandString: "context ls",
6682
pluginConfig: map[string]string{
6783
"hooks": "context ls",
@@ -70,6 +86,7 @@ func TestPluginMatch(t *testing.T) {
7086
expectedOk: true,
7187
},
7288
{
89+
doc: "hooks first match wins",
7390
commandString: "image ls",
7491
pluginConfig: map[string]string{
7592
"hooks": "image ls,image",
@@ -78,6 +95,7 @@ func TestPluginMatch(t *testing.T) {
7895
expectedOk: true,
7996
},
8097
{
98+
doc: "hooks empty string",
8199
commandString: "image ls",
82100
pluginConfig: map[string]string{
83101
"hooks": "",
@@ -86,6 +104,7 @@ func TestPluginMatch(t *testing.T) {
86104
expectedOk: false,
87105
},
88106
{
107+
doc: "hooks partial token no match",
89108
commandString: "image inspect",
90109
pluginConfig: map[string]string{
91110
"hooks": "image i",
@@ -94,19 +113,148 @@ func TestPluginMatch(t *testing.T) {
94113
expectedOk: false,
95114
},
96115
{
116+
doc: "hooks prefix token match",
97117
commandString: "image inspect",
98118
pluginConfig: map[string]string{
99119
"hooks": "image",
100120
},
101121
expectedMatch: "image",
102122
expectedOk: true,
103123
},
124+
{
125+
doc: "error-hooks match on error",
126+
commandString: "build",
127+
pluginConfig: map[string]string{
128+
"error-hooks": "build",
129+
},
130+
cmdErrorMessage: "exit status 1",
131+
expectedMatch: "build",
132+
expectedOk: true,
133+
},
134+
{
135+
doc: "error-hooks no match on success",
136+
commandString: "build",
137+
pluginConfig: map[string]string{
138+
"error-hooks": "build",
139+
},
140+
cmdErrorMessage: "",
141+
expectedMatch: "",
142+
expectedOk: false,
143+
},
144+
{
145+
doc: "error-hooks prefix match on error",
146+
commandString: "compose up",
147+
pluginConfig: map[string]string{
148+
"error-hooks": "compose",
149+
},
150+
cmdErrorMessage: "exit status 1",
151+
expectedMatch: "compose",
152+
expectedOk: true,
153+
},
154+
{
155+
doc: "error-hooks no match for wrong command",
156+
commandString: "pull",
157+
pluginConfig: map[string]string{
158+
"error-hooks": "build",
159+
},
160+
cmdErrorMessage: "exit status 1",
161+
expectedMatch: "",
162+
expectedOk: false,
163+
},
164+
{
165+
doc: "hooks takes precedence over error-hooks",
166+
commandString: "build",
167+
pluginConfig: map[string]string{
168+
"hooks": "build",
169+
"error-hooks": "build",
170+
},
171+
cmdErrorMessage: "exit status 1",
172+
expectedMatch: "build",
173+
expectedOk: true,
174+
},
175+
{
176+
doc: "hooks fires on success even with error-hooks configured",
177+
commandString: "build",
178+
pluginConfig: map[string]string{
179+
"hooks": "build",
180+
"error-hooks": "build",
181+
},
182+
cmdErrorMessage: "",
183+
expectedMatch: "build",
184+
expectedOk: true,
185+
},
186+
{
187+
doc: "error-hooks with multiple commands",
188+
commandString: "compose up",
189+
pluginConfig: map[string]string{
190+
"error-hooks": "build,compose up,pull",
191+
},
192+
cmdErrorMessage: "exit status 1",
193+
expectedMatch: "compose up",
194+
expectedOk: true,
195+
},
196+
}
197+
198+
for _, tc := range testCases {
199+
t.Run(tc.doc, func(t *testing.T) {
200+
match, ok := pluginMatch(tc.pluginConfig, tc.commandString, tc.cmdErrorMessage)
201+
assert.Equal(t, ok, tc.expectedOk)
202+
assert.Equal(t, match, tc.expectedMatch)
203+
})
204+
}
205+
}
206+
207+
func TestMatchHookConfig(t *testing.T) {
208+
testCases := []struct {
209+
doc string
210+
configuredHooks string
211+
subCmd string
212+
expectedMatch string
213+
expectedOk bool
214+
}{
215+
{
216+
doc: "empty config",
217+
configuredHooks: "",
218+
subCmd: "build",
219+
expectedMatch: "",
220+
expectedOk: false,
221+
},
222+
{
223+
doc: "exact match",
224+
configuredHooks: "build",
225+
subCmd: "build",
226+
expectedMatch: "build",
227+
expectedOk: true,
228+
},
229+
{
230+
doc: "prefix match",
231+
configuredHooks: "image",
232+
subCmd: "image ls",
233+
expectedMatch: "image",
234+
expectedOk: true,
235+
},
236+
{
237+
doc: "comma-separated match",
238+
configuredHooks: "pull,build,push",
239+
subCmd: "build",
240+
expectedMatch: "build",
241+
expectedOk: true,
242+
},
243+
{
244+
doc: "no match",
245+
configuredHooks: "pull,push",
246+
subCmd: "build",
247+
expectedMatch: "",
248+
expectedOk: false,
249+
},
104250
}
105251

106252
for _, tc := range testCases {
107-
match, ok := pluginMatch(tc.pluginConfig, tc.commandString)
108-
assert.Equal(t, ok, tc.expectedOk)
109-
assert.Equal(t, match, tc.expectedMatch)
253+
t.Run(tc.doc, func(t *testing.T) {
254+
match, ok := matchHookConfig(tc.configuredHooks, tc.subCmd)
255+
assert.Equal(t, ok, tc.expectedOk)
256+
assert.Equal(t, match, tc.expectedMatch)
257+
})
110258
}
111259
}
112260

@@ -141,3 +289,87 @@ func TestAppendNextSteps(t *testing.T) {
141289
})
142290
}
143291
}
292+
293+
func TestRunPluginHooksPassesErrorMessage(t *testing.T) {
294+
cfg := configfile.New("")
295+
cfg.Plugins = map[string]map[string]string{
296+
"test-plugin": {"hooks": "build"},
297+
}
298+
provider := &fakeConfigProvider{cfg: cfg}
299+
root := &cobra.Command{Use: "docker"}
300+
sub := &cobra.Command{Use: "build"}
301+
root.AddCommand(sub)
302+
303+
// Should not panic with empty error message (success case)
304+
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "")
305+
306+
// Should not panic with non-empty error message (failure case)
307+
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "exit status 1")
308+
}
309+
310+
func TestRunPluginHooksErrorHooks(t *testing.T) {
311+
cfg := configfile.New("")
312+
cfg.Plugins = map[string]map[string]string{
313+
"test-plugin": {"error-hooks": "build"},
314+
}
315+
provider := &fakeConfigProvider{cfg: cfg}
316+
root := &cobra.Command{Use: "docker"}
317+
sub := &cobra.Command{Use: "build"}
318+
root.AddCommand(sub)
319+
320+
// Should not panic — error-hooks with error message
321+
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "exit status 1")
322+
323+
// Should not panic — error-hooks with no error (should be skipped)
324+
RunPluginHooks(context.Background(), provider, root, sub, []string{"build"}, "")
325+
}
326+
327+
func TestInvokeAndCollectHooksErrorHooksSkippedOnSuccess(t *testing.T) {
328+
cfg := configfile.New("")
329+
cfg.Plugins = map[string]map[string]string{
330+
"nonexistent": {"error-hooks": "build"},
331+
}
332+
root := &cobra.Command{Use: "docker"}
333+
sub := &cobra.Command{Use: "build"}
334+
root.AddCommand(sub)
335+
336+
// On success, error-hooks should not match, so the plugin
337+
// binary is never looked up and no results are returned.
338+
result := invokeAndCollectHooks(
339+
context.Background(), cfg, root, sub,
340+
"build", map[string]string{}, "",
341+
)
342+
assert.Check(t, is.Len(result, 0))
343+
}
344+
345+
func TestInvokeAndCollectHooksNoPlugins(t *testing.T) {
346+
cfg := configfile.New("")
347+
root := &cobra.Command{Use: "docker"}
348+
sub := &cobra.Command{Use: "build"}
349+
root.AddCommand(sub)
350+
351+
result := invokeAndCollectHooks(
352+
context.Background(), cfg, root, sub,
353+
"build", map[string]string{}, "some error",
354+
)
355+
assert.Check(t, is.Len(result, 0))
356+
}
357+
358+
func TestInvokeAndCollectHooksCancelledContext(t *testing.T) {
359+
cfg := configfile.New("")
360+
cfg.Plugins = map[string]map[string]string{
361+
"test-plugin": {"hooks": "build"},
362+
}
363+
root := &cobra.Command{Use: "docker"}
364+
sub := &cobra.Command{Use: "build"}
365+
root.AddCommand(sub)
366+
367+
ctx, cancel := context.WithCancel(context.Background())
368+
cancel() // cancel immediately
369+
370+
result := invokeAndCollectHooks(
371+
ctx, cfg, root, sub,
372+
"build", map[string]string{}, "exit status 1",
373+
)
374+
assert.Check(t, is.Nil(result))
375+
}

0 commit comments

Comments
 (0)