diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000000..41f3d1eca0 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,59 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + // https://docs.renovatebot.com/modules/manager/github-actions/ + // https://docs.renovatebot.com/presets-helpers/ + "helpers:pinGitHubActionDigestsToSemver", + ":gitSignOff" + ], + "gomod": { + "managerFilePatterns": ["/(^|/)vendor\\.mod$/"] + }, + "postUpdateOptions": ["gomodTidy"], + "minimumReleaseAge": "3 days", + "internalChecksFilter": "strict", + "packageRules": [ + { + "matchManagers": ["gomod"], + "matchFileNames": ["vendor.mod"], + "semanticCommits": "disabled", + "commitMessagePrefix": "vendor:" + }, + { + "matchManagers": ["gomod"], + "matchFileNames": ["cmd/docker-trust/go.mod"], + // The trust module does not vendor dependencies; vendor/ belongs to the root module. + "postUpdateOptions": ["gomodSkipVendor"], + "semanticCommits": "disabled", + "commitMessagePrefix": "cmd/docker-trust:" + }, + { + "matchManagers": ["gomod"], + "matchFileNames": ["vendor.mod"], + "groupName": "golang.org/x packages", + "groupSlug": "golang-x", + "matchDepNames": ["/^golang\\.org\\/x\\//"] + }, + { + "matchManagers": ["github-actions"], + "minimumReleaseAge": "7 days", + "addLabels": ["area/ci", "status/2-code-review"] + }, + { + "matchManagers": ["github-actions"], + "groupName": "docker GitHub Actions", + "groupSlug": "docker-actions", + "matchDepNames": ["/^docker\\//"] + }, + { + // Keep all the google.golang.org/genproto modules at the same version. + "matchManagers": ["gomod"], + "matchFileNames": ["vendor.mod"], + "groupName": "google.golang.org/genproto packages", + "groupSlug": "google-genproto", + "matchDepNames": ["/^google\\.golang\\.org\\/genproto(\\/|$)/"], + "schedule": ["every month"] + } + ] +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f992f3eb4..f7663c7b58 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -65,7 +65,7 @@ jobs: steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 @@ -91,7 +91,7 @@ jobs: PLATFORM: ${{ matrix.platform }} - name: Upload artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ env.ARTIFACT_NAME }} path: /tmp/out/* @@ -156,7 +156,7 @@ jobs: steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ead4a4f0f2..73435198ca 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -64,7 +64,7 @@ jobs: name: Update Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.9" + go-version: "1.27.1" cache: false - name: Initialize CodeQL diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b7b46b5e71..5773ef4bc7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -66,7 +66,7 @@ jobs: docker info - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Run ${{ matrix.target }} run: | diff --git a/.github/workflows/pr-review-trigger.yml b/.github/workflows/pr-review-trigger.yml index 1ecc03b098..5c28d63071 100644 --- a/.github/workflows/pr-review-trigger.yml +++ b/.github/workflows/pr-review-trigger.yml @@ -35,7 +35,7 @@ jobs: fi - name: Upload context - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-review-context path: context/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b6a5ea564..0714b6345c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Test uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 @@ -68,7 +68,7 @@ jobs: name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.9" + go-version: "1.27.1" cache: false - name: Test diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1396c8d7b0..3f693dd853 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -101,7 +101,7 @@ jobs: name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.26.9" + go-version: "1.27.1" cache: false - name: Run gocompat check diff --git a/Dockerfile b/Dockerfile index 59f519dcbe..24eca25329 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ARG GO_VERSION=1.26.9 # XX_VERSION specifies the version of the xx utility to use. # It must be a valid tag in the docker.io/tonistiigi/xx image repository. -ARG XX_VERSION=1.9.0 +ARG XX_VERSION=1.10.0 # GOVERSIONINFO_VERSION is the version of GoVersionInfo to install. # It must be a valid tag from https://github.com/josephspurrier/goversioninfo @@ -25,12 +25,12 @@ ARG GOTESTSUM_VERSION=v1.13.0 # BUILDX_VERSION sets the version of buildx to use for the e2e tests. # It must be a tag in the docker.io/docker/buildx-bin image repository # on Docker Hub. -ARG BUILDX_VERSION=0.34.1 +ARG BUILDX_VERSION=0.38.0 # COMPOSE_VERSION is the version of compose to install in the dev container. # It must be a tag in the docker.io/docker/compose-bin image repository # on Docker Hub. -ARG COMPOSE_VERSION=v5.1.3 +ARG COMPOSE_VERSION=v5.6.0 FROM --platform=$BUILDPLATFORM tonistiigi/xx:${XX_VERSION} AS xx diff --git a/VERSION b/VERSION index 4552f1e80f..7012061eec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -29.9.0 +29.9.1 diff --git a/cli-plugins/hooks/hook_types.go b/cli-plugins/hooks/hook_types.go index 03ee729493..9cda993e18 100644 --- a/cli-plugins/hooks/hook_types.go +++ b/cli-plugins/hooks/hook_types.go @@ -30,7 +30,11 @@ package hooks type ResponseType int const ( + // NextSteps renders suggestions under a shared "What's next:" header. NextSteps ResponseType = 0 + // GenericMessage renders before NextSteps, without a header or indentation. + // CLI versions that do not recognize this type ignore the response. + GenericMessage ResponseType = 1 ) // Request is the type representing the information diff --git a/cli-plugins/hooks/printer.go b/cli-plugins/hooks/printer.go index c9b0bd9bcd..ff19762690 100644 --- a/cli-plugins/hooks/printer.go +++ b/cli-plugins/hooks/printer.go @@ -7,17 +7,42 @@ const ( indent = " " ) -// PrintNextSteps renders list of [NextSteps] messages and writes them -// to out. It is a no-op if messages is empty. -func PrintNextSteps(out io.Writer, messages []string) { - if len(messages) == 0 { - return +// PrintMessages groups messages by type and writes them to out. Generic messages +// appear first, without a header or indentation, followed by next steps under a +// shared "What's next:" header. Order within each type is preserved. +// Messages with no lines or an unknown type produce no output. +func PrintMessages(out io.Writer, messages []Response, evaluate func(Response) []string) { + grouped := make(map[ResponseType][]string) + for _, message := range messages { + lines := evaluate(message) + grouped[message.Type] = append(grouped[message.Type], lines...) } + for _, responseType := range []ResponseType{GenericMessage, NextSteps} { + lines := grouped[responseType] + if len(lines) == 0 { + continue + } - _, _ = io.WriteString(out, whatsNext) - for _, msg := range messages { - _, _ = io.WriteString(out, indent) - _, _ = io.WriteString(out, msg) - _, _ = io.WriteString(out, "\n") + header, prefix := "\n", "" + if responseType == NextSteps { + header, prefix = whatsNext, indent + } + + _, _ = io.WriteString(out, header) + for _, line := range lines { + _, _ = io.WriteString(out, prefix) + _, _ = io.WriteString(out, line) + _, _ = io.WriteString(out, "\n") + } } } + +// PrintNextSteps renders list of [NextSteps] messages and writes them +// to out. It is a no-op if messages is empty. +// +// Deprecated: use [PrintMessages] instead. +func PrintNextSteps(out io.Writer, messages []string) { + PrintMessages(out, []Response{{Type: NextSteps}}, func(Response) []string { + return messages + }) +} diff --git a/cli-plugins/hooks/printer_test.go b/cli-plugins/hooks/printer_test.go index 8b1ca9234e..5a1d33c331 100644 --- a/cli-plugins/hooks/printer_test.go +++ b/cli-plugins/hooks/printer_test.go @@ -8,7 +8,85 @@ import ( "gotest.tools/v3/assert" ) -func TestPrintHookMessages(t *testing.T) { +func TestPrintMessages(t *testing.T) { + const header = "\n\x1b[1mWhat's next:\x1b[0m\n" + + for _, tc := range []struct { + doc string + messages []hooks.Response + expected string + }{ + {doc: "no messages"}, + { + doc: "no lines", + messages: []hooks.Response{ + {Type: hooks.GenericMessage}, + {Type: hooks.NextSteps}, + }, + }, + { + doc: "generic message", + messages: []hooks.Response{ + {Type: hooks.GenericMessage, Template: "Session active"}, + }, + expected: "\nSession active\n", + }, + { + doc: "preserve message formatting", + messages: []hooks.Response{ + {Type: hooks.GenericMessage, Template: "Session active\n\n Details"}, + }, + expected: "\nSession active\n\n Details\n", + }, + { + doc: "next steps", + messages: []hooks.Response{ + {Type: hooks.NextSteps, Template: "Try another command"}, + }, + expected: header + " Try another command\n", + }, + { + doc: "group interleaved types and preserve order within each type", + messages: []hooks.Response{ + {Type: hooks.NextSteps, Template: "First suggestion"}, + {Type: hooks.GenericMessage, Template: "Session active"}, + {Type: hooks.NextSteps, Template: "Second suggestion\nThird suggestion"}, + {Type: hooks.GenericMessage, Template: "Another status"}, + }, + expected: "\nSession active\nAnother status\n" + header + " First suggestion\n Second suggestion\n Third suggestion\n", + }, + { + doc: "unknown type", + messages: []hooks.Response{ + {Type: hooks.ResponseType(99), Template: "Ignored"}, + }, + }, + { + doc: "unknown type does not affect known messages", + messages: []hooks.Response{ + {Type: hooks.ResponseType(99), Template: "Ignored"}, + {Type: hooks.GenericMessage, Template: "Session active"}, + {Type: hooks.NextSteps, Template: "Try another command"}, + }, + expected: "\nSession active\n" + header + " Try another command\n", + }, + } { + t.Run(tc.doc, func(t *testing.T) { + var w strings.Builder + hooks.PrintMessages(&w, tc.messages, func(message hooks.Response) []string { + if message.Template == "" { + return nil + } + lines, err := hooks.ParseTemplate(message.Template, nil) + assert.NilError(t, err) + return lines + }) + assert.Equal(t, w.String(), tc.expected) + }) + } +} + +func TestPrintNextSteps(t *testing.T) { const header = "\n\x1b[1mWhat's next:\x1b[0m\n" tests := []struct { @@ -34,6 +112,11 @@ func TestPrintHookMessages(t *testing.T) { " Foo\n" + " bar\n", }, + { + doc: "preserve empty and indented lines", + messages: []string{"", " Details", ""}, + expectedOutput: header + " \n Details\n \n", + }, } for _, tc := range tests { t.Run(tc.doc, func(t *testing.T) { diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go index 99abb9acf8..d0057da56b 100644 --- a/cli-plugins/hooks/template.go +++ b/cli-plugins/hooks/template.go @@ -7,6 +7,7 @@ import ( "bytes" "errors" "fmt" + "slices" "strings" "text/template" @@ -43,7 +44,16 @@ func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { if n := strings.Count(out, "\n"); n > maxMessages { return nil, fmt.Errorf("hook template contains too many messages (%d): maximum is %d", n, maxMessages) } - return strings.SplitN(out, "\n", maxMessages), nil + lines := strings.SplitN(out, "\n", maxMessages) + + // Skip messages containing only whitespace, but preserve empty lines + // in messages that have content (e.g., for paragraph spacing). + if !slices.ContainsFunc(lines, func(line string) bool { + return strings.TrimSpace(line) != "" + }) { + return nil, nil + } + return lines, nil } var ErrHookTemplateParse = errors.New("failed to parse hook template") diff --git a/cli-plugins/hooks/template_test.go b/cli-plugins/hooks/template_test.go index 28b290696f..81d6c9175c 100644 --- a/cli-plugins/hooks/template_test.go +++ b/cli-plugins/hooks/template_test.go @@ -33,7 +33,17 @@ func TestParseTemplate(t *testing.T) { { doc: "empty template", template: "", - expectedOutput: []string{""}, + expectedOutput: nil, + }, + { + doc: "only empty lines", + template: "\n\n", + expectedOutput: nil, + }, + { + doc: "whitespace-only lines", + template: " \n\t\n ", + expectedOutput: nil, }, { doc: "plain message", diff --git a/cli-plugins/manager/hooks.go b/cli-plugins/manager/hooks.go index 5a3d3ebe91..ffa70374e4 100644 --- a/cli-plugins/manager/hooks.go +++ b/cli-plugins/manager/hooks.go @@ -49,11 +49,18 @@ func RunPluginHooks(ctx context.Context, dockerCLI config.Provider, rootCmd, sub } func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCommand *cobra.Command, invokedCommand string, flags map[string]string, cmdErrorMessage string) { - nextSteps := invokeAndCollectHooks(ctx, cfg, rootCmd, subCommand, invokedCommand, flags, cmdErrorMessage) - hooks.PrintNextSteps(subCommand.ErrOrStderr(), nextSteps) + messages := invokeAndCollectHooks(ctx, cfg, rootCmd, invokedCommand, flags, cmdErrorMessage) + hooks.PrintMessages(subCommand.ErrOrStderr(), messages, func(message hooks.Response) []string { + lines, err := hooks.ParseTemplate(message.Template, subCommand) + if err != nil { + logrus.WithError(err).Debug("Failed to evaluate hook template") + return nil + } + return lines + }) } -func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []string { +func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []hooks.Response { if ctx.Err() != nil { return nil } @@ -64,17 +71,17 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root } pluginDirs := getPluginDirs(cfg) - nextSteps := make([]string, 0, len(pluginsCfg)) + var collected []hooks.Response - tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (messages []string, ok bool, err error) { + tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (hooks.Response, error) { match, matched := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage) if !matched { - return nil, false, nil + return hooks.Response{}, nil } p, err := getPlugin(pluginName, pluginDirs, rootCmd) if err != nil { - return nil, false, err + return hooks.Response{}, err } resp, err := p.RunHook(ctx, hooks.Request{ @@ -83,29 +90,23 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root CommandError: cmdErrorMessage, }) if err != nil { - return nil, false, err + return hooks.Response{}, err } var message hooks.Response if err := json.Unmarshal(resp, &message); err != nil { - return nil, false, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err) + return hooks.Response{}, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err) } - // currently the only hook type - if message.Type != hooks.NextSteps { - return nil, false, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type))) + if message.Type != hooks.NextSteps && message.Type != hooks.GenericMessage { + return hooks.Response{}, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type))) } - messages, err = hooks.ParseTemplate(message.Template, subCmd) - if err != nil { - return nil, false, err - } - - return messages, true, nil + return message, nil } for pluginName, pluginCfg := range pluginsCfg { - messages, ok, err := tryInvokeHook(pluginName, pluginCfg) + message, err := tryInvokeHook(pluginName, pluginCfg) if err != nil { // skip misbehaving plugins, but don't halt execution logrus.WithFields(logrus.Fields{ @@ -114,38 +115,13 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root }).Debug("Plugin hook invocation failed") continue } - if !ok { + if message.Template == "" { continue } - var appended bool - nextSteps, appended = appendNextSteps(nextSteps, messages) - if !appended { - logrus.WithFields(logrus.Fields{ - "plugin": pluginName, - }).Debug("Plugin responded with an empty hook message; ignoring") - } + collected = append(collected, message) } - return nextSteps -} - -// appendNextSteps appends the processed hook output to the nextSteps slice. -// If the processed hook output is empty, it is not appended. -// Empty lines are not stripped if there's at least one non-empty line. -func appendNextSteps(nextSteps []string, processed []string) ([]string, bool) { - empty := true - for _, l := range processed { - if strings.TrimSpace(l) != "" { - empty = false - break - } - } - - if empty { - return nextSteps, false - } - - return append(nextSteps, processed...), true + return collected } // pluginMatch takes a plugin configuration and a string representing the diff --git a/cli-plugins/manager/hooks_test.go b/cli-plugins/manager/hooks_test.go index e39a138311..6f2f376e23 100644 --- a/cli-plugins/manager/hooks_test.go +++ b/cli-plugins/manager/hooks_test.go @@ -2,6 +2,11 @@ package manager import ( "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" "testing" "github.com/docker/cli/cli/config/configfile" @@ -258,38 +263,6 @@ func TestMatchHookConfig(t *testing.T) { } } -func TestAppendNextSteps(t *testing.T) { - testCases := []struct { - processed []string - expectedOut []string - }{ - { - processed: []string{}, - expectedOut: []string{}, - }, - { - processed: []string{"", ""}, - expectedOut: []string{}, - }, - { - processed: []string{"Some hint", "", "Some other hint"}, - expectedOut: []string{"Some hint", "", "Some other hint"}, - }, - { - processed: []string{"Hint 1", "Hint 2"}, - expectedOut: []string{"Hint 1", "Hint 2"}, - }, - } - - for _, tc := range testCases { - t.Run("", func(t *testing.T) { - got, appended := appendNextSteps([]string{}, tc.processed) - assert.Check(t, is.DeepEqual(got, tc.expectedOut)) - assert.Check(t, is.Equal(appended, len(got) > 0)) - }) - } -} - func TestRunPluginHooksPassesErrorMessage(t *testing.T) { cfg := configfile.New("") cfg.Plugins = map[string]map[string]string{ @@ -336,7 +309,7 @@ func TestInvokeAndCollectHooksErrorHooksSkippedOnSuccess(t *testing.T) { // 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, sub, + context.Background(), cfg, root, "build", map[string]string{}, "", ) assert.Check(t, is.Len(result, 0)) @@ -349,7 +322,7 @@ func TestInvokeAndCollectHooksNoPlugins(t *testing.T) { root.AddCommand(sub) result := invokeAndCollectHooks( - context.Background(), cfg, root, sub, + context.Background(), cfg, root, "build", map[string]string{}, "some error", ) assert.Check(t, is.Len(result, 0)) @@ -368,8 +341,108 @@ func TestInvokeAndCollectHooksCancelledContext(t *testing.T) { cancel() // cancel immediately result := invokeAndCollectHooks( - ctx, cfg, root, sub, + 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") + } + }) + } +} diff --git a/cli/command/image/build.go b/cli/command/image/build.go index 26a8739854..f57a0c2aa6 100644 --- a/cli/command/image/build.go +++ b/cli/command/image/build.go @@ -260,8 +260,11 @@ func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions) contextDir = tempDir case build.ContextTypeRemote: buildCtx, relDockerfile, err = build.GetContextFromURL(progBuff, options.context, options.dockerfileName) - if err != nil && options.quiet { - _, _ = fmt.Fprintln(dockerCli.Err(), progBuff) + if err != nil { + if options.quiet { + _, _ = fmt.Fprintln(dockerCli.Err(), progBuff) + } + return err } default: return fmt.Errorf("unable to prepare context: path %q not found", options.context) diff --git a/cli/command/image/build_test.go b/cli/command/image/build_test.go index 1d61d05bbe..4cb6448297 100644 --- a/cli/command/image/build_test.go +++ b/cli/command/image/build_test.go @@ -6,6 +6,8 @@ import ( "compress/gzip" "context" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "slices" @@ -172,6 +174,35 @@ RUN echo hello world assert.DeepEqual(t, fakeBuild.filenames(t), []string{"Dockerfile"}) } +// TestRunBuildRemoteContextDownloadError verifies that remote context +// download errors are propagated. +// +// regression test for https://github.com/docker/cli/issues/7372 +func TestRunBuildRemoteContextDownloadError(t *testing.T) { + t.Setenv("DOCKER_BUILDKIT", "0") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + imageBuildCalled := false + cli := test.NewFakeCli(&fakeClient{ + imageBuildFunc: func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) { + imageBuildCalled = true + return client.ImageBuildResult{Body: io.NopCloser(bytes.NewReader(nil))}, nil + }, + }) + + options := newBuildOptions() + options.context = server.URL + + err := runBuild(context.Background(), cli, options) + + assert.ErrorContains(t, err, "unable to download remote context") + assert.Assert(t, !imageBuildCalled, "ImageBuild should not be called when downloading the remote context fails") +} + type fakeBuild struct { context *tar.Reader options client.ImageBuildOptions diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 47c720e941..92dfce8cdd 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -571,11 +571,15 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error { return nil } if !errdefs.IsNotFound(err) { - // For plugin not found we fall through to - // cmd.Execute() which deals with reporting - // "command not found" in a consistent way. return err } + if args[0] == "offload" { + // Report this before cmd.ExecuteContext(): plugin-specific flags such as + // --cpus otherwise fail parsing before the root command's RunE. + return errors.New("docker: to use 'docker offload', install the plugin.\nSee https://docs.docker.com/go/cloud-flag/") + } + // Other missing or invalid plugins fall through to cmd.ExecuteContext(), + // which reports the standard unknown-command error. } } diff --git a/dockerfiles/Dockerfile.dev b/dockerfiles/Dockerfile.dev index c1a7d04c27..5c310953f7 100644 --- a/dockerfiles/Dockerfile.dev +++ b/dockerfiles/Dockerfile.dev @@ -10,7 +10,7 @@ ARG ALPINE_VERSION=3.23 # BUILDX_VERSION sets the version of buildx to install in the dev container. # It must be a valid tag in the docker.io/docker/buildx-bin image repository # on Docker Hub. -ARG BUILDX_VERSION=0.34.1 +ARG BUILDX_VERSION=0.38.0 FROM docker/buildx-bin:${BUILDX_VERSION} AS buildx FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS golang diff --git a/vendor.mod b/vendor.mod index d6ca6bedd3..9b14b2e61e 100644 --- a/vendor.mod +++ b/vendor.mod @@ -71,7 +71,7 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 // indirect - github.com/Microsoft/go-winio v0.6.3-0.20260930231756-f19d9717deb0 // indirect; see https://github.com/microsoft/hcsshim/pull/2545 + github.com/Microsoft/go-winio v0.6.3 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/vendor.sum b/vendor.sum index 80f9952157..aa0e5e45f9 100644 --- a/vendor.sum +++ b/vendor.sum @@ -4,8 +4,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 h1:m2xcjRABtG288vnlV3U/PKh/HnPURxZp50RKNUwBxM8= github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4/go.mod h1:MTUE7ZuZO89FAtAwozGg6AWD83pcdefnEq5gF82MB8s= -github.com/Microsoft/go-winio v0.6.3-0.20260930231756-f19d9717deb0 h1:71VU8kW/LCxJkx41+YYys1EgQO+AUnPa1TrxTSyYUCU= -github.com/Microsoft/go-winio v0.6.3-0.20260930231756-f19d9717deb0/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= +github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4= +github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= diff --git a/vendor/github.com/Microsoft/go-winio/file.go b/vendor/github.com/Microsoft/go-winio/file.go index 19c5f8940e..449bb3bd1b 100644 --- a/vendor/github.com/Microsoft/go-winio/file.go +++ b/vendor/github.com/Microsoft/go-winio/file.go @@ -3,8 +3,9 @@ package winio import ( - "errors" + "context" "io" + "os" "runtime" "sync" "sync/atomic" @@ -20,16 +21,33 @@ import ( //sys setFileCompletionNotificationModes(h windows.Handle, flags uint8) (err error) = SetFileCompletionNotificationModes //sys wsaGetOverlappedResult(h windows.Handle, o *windows.Overlapped, bytes *uint32, wait bool, flags *uint32) (err error) = ws2_32.WSAGetOverlappedResult -var ( - ErrFileClosed = errors.New("file has already been closed") - ErrTimeout = &timeoutError{} -) +// ErrFileClosed is returned when an operation is performed on a closed file. +// +// Deprecated: use [os.ErrClosed] instead. +var ErrFileClosed = os.ErrClosed +// ErrTimeout is returned when an I/O operation exceeds its deadline. +// +// It matches both [context.DeadlineExceeded] and [os.ErrDeadlineExceeded] +// with [errors.Is]. +var ErrTimeout error = &timeoutError{} + +// timeoutError preserves the historical "i/o timeout" error while matching +// both context.DeadlineExceeded and os.ErrDeadlineExceeded through errors.Is. +// This mirrors the behavior of net.errTimeout: +// https://github.com/golang/go/blob/go1.27.1/src/net/net.go +// +// TODO: consider replacing ErrTimeout with os.ErrDeadlineExceeded if os.ErrDeadlineExceeded is changed to match context.DeadlineExceeded. type timeoutError struct{} func (*timeoutError) Error() string { return "i/o timeout" } func (*timeoutError) Timeout() bool { return true } func (*timeoutError) Temporary() bool { return true } +func (*timeoutError) Unwrap() error { return os.ErrDeadlineExceeded } + +func (*timeoutError) Is(err error) bool { + return err == context.DeadlineExceeded +} type timeoutChan chan struct{} @@ -143,7 +161,7 @@ func (f *win32File) prepareIO() (*ioOperation, error) { f.wgLock.RLock() if f.closing.Load() { f.wgLock.RUnlock() - return nil, ErrFileClosed + return nil, os.ErrClosed } f.wg.Add(1) f.wgLock.RUnlock() @@ -192,7 +210,7 @@ func (f *win32File) asyncIO(c *ioOperation, d *deadlineHandler, bytes uint32, er err = r.err if err == windows.ERROR_OPERATION_ABORTED { //nolint:errorlint // err is Errno if f.closing.Load() { - err = ErrFileClosed + err = os.ErrClosed } } else if err != nil && f.socket { // err is from Win32. Query the overlapped structure to get the winsock error. diff --git a/vendor/github.com/Microsoft/go-winio/hvsock.go b/vendor/github.com/Microsoft/go-winio/hvsock.go index d4960f5b0a..df2008f6f6 100644 --- a/vendor/github.com/Microsoft/go-winio/hvsock.go +++ b/vendor/github.com/Microsoft/go-winio/hvsock.go @@ -441,7 +441,7 @@ func canRedial(err error) bool { func (conn *HvsockConn) opErr(op string, err error) error { // translate from "file closed" to "socket closed" - if errors.Is(err, ErrFileClosed) { + if errors.Is(err, os.ErrClosed) { err = socket.ErrSocketClosed } return &net.OpError{Op: op, Net: "hvsock", Source: &conn.local, Addr: &conn.remote, Err: err} diff --git a/vendor/github.com/Microsoft/go-winio/pipe.go b/vendor/github.com/Microsoft/go-winio/pipe.go index 16a42584fc..850fe3e53a 100644 --- a/vendor/github.com/Microsoft/go-winio/pipe.go +++ b/vendor/github.com/Microsoft/go-winio/pipe.go @@ -102,12 +102,10 @@ func (status ntStatus) Err() error { return rtlNtStatusToDosError(status) } -var ( - // ErrPipeListenerClosed is returned for pipe operations on listeners that have been closed. - ErrPipeListenerClosed = net.ErrClosed - - errPipeWriteClosed = errors.New("pipe has been closed for write") -) +// ErrPipeListenerClosed is returned for pipe operations on listeners that have been closed. +// +// Deprecated: use [net.ErrClosed] instead. +var ErrPipeListenerClosed = net.ErrClosed type win32Pipe struct { *win32File @@ -144,9 +142,10 @@ func (f *win32Pipe) Disconnect() error { } // CloseWrite closes the write side of a message pipe in byte mode. +// It returns [io.ErrClosedPipe] if the write side is already closed. func (f *win32MessageBytePipe) CloseWrite() error { if f.writeClosed { - return errPipeWriteClosed + return io.ErrClosedPipe } err := f.win32File.Flush() if err != nil { @@ -161,10 +160,10 @@ func (f *win32MessageBytePipe) CloseWrite() error { } // Write writes bytes to a message pipe in byte mode. Zero-byte writes are ignored, since -// they are used to implement CloseWrite(). +// they are used to implement CloseWrite. It returns [io.ErrClosedPipe] if the write side is closed. func (f *win32MessageBytePipe) Write(b []byte) (int, error) { if f.writeClosed { - return 0, errPipeWriteClosed + return 0, io.ErrClosedPipe } if len(b) == 0 { return 0, nil @@ -454,7 +453,7 @@ func (l *win32PipeListener) makeConnectedServerPipe() (*win32File, error) { _ = p.Close() p = nil <-ch - err = ErrPipeListenerClosed + err = net.ErrClosed } return p, err } @@ -479,7 +478,7 @@ func (l *win32PipeListener) listenerRoutine() { } } responseCh <- acceptResponse{p, err} - closed = err == ErrPipeListenerClosed //nolint:errorlint // err is Errno + closed = err == net.ErrClosed //nolint:errorlint // err is Errno } } windows.Close(l.firstHandle) @@ -571,7 +570,7 @@ func (l *win32PipeListener) Accept() (net.Conn, error) { } return &win32Pipe{win32File: response.f, path: l.path}, nil case <-l.doneCh: - return nil, ErrPipeListenerClosed + return nil, net.ErrClosed } } diff --git a/vendor/modules.txt b/vendor/modules.txt index c9f32360ea..b1024ee973 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -5,7 +5,7 @@ dario.cat/mergo ## explicit; go 1.17 github.com/Azure/go-ansiterm github.com/Azure/go-ansiterm/winterm -# github.com/Microsoft/go-winio v0.6.3-0.20260930231756-f19d9717deb0 +# github.com/Microsoft/go-winio v0.6.3 ## explicit; go 1.26.0 github.com/Microsoft/go-winio github.com/Microsoft/go-winio/internal/fs