Compare commits

...
24 Commits
Author SHA1 Message Date
Paweł Gronowski dfc520dcc2 Merge pull request #7390 from renovate-bot/renovate/go-1.x
chore(deps): update dependency go to v1.27.1
2026-10-14 20:09:52 +02:00
Mend Renovate a3a2bb6118 chore(deps): update dependency go to v1.27.1
Signed-off-by: Mend Renovate <bot@renovateapp.com>
2026-10-14 18:03:30 +00:00
Sebastiaan van Stijn c11295afc4 Merge pull request #7388 from renovate-bot/renovate/tonistiigi-xx-1.x
chore(deps): update tonistiigi/xx docker tag to v1.10.0
2026-10-14 18:11:18 +02:00
Sebastiaan van Stijn 010e125d8b Merge pull request #7386 from renovate-bot/renovate/docker-buildx-bin-0.x
chore(deps): update docker/buildx-bin docker tag to v0.38.0
2026-10-14 18:10:45 +02:00
Sebastiaan van Stijn 5cdf229230 Merge pull request #7387 from renovate-bot/renovate/docker-compose-bin-5.x
chore(deps): update docker/compose-bin docker tag to v5.6.0
2026-10-14 18:10:28 +02:00
Mend Renovate 66f6797582 chore(deps): update tonistiigi/xx docker tag to v1.10.0
Signed-off-by: Mend Renovate <bot@renovateapp.com>
2026-10-14 16:02:38 +00:00
Mend Renovate cb8870a537 chore(deps): update docker/compose-bin docker tag to v5.6.0
Signed-off-by: Mend Renovate <bot@renovateapp.com>
2026-10-14 16:02:33 +00:00
Mend Renovate 529f66d2f9 chore(deps): update docker/buildx-bin docker tag to v0.38.0
Signed-off-by: Mend Renovate <bot@renovateapp.com>
2026-10-14 16:02:28 +00:00
Paweł Gronowski 4d6ff7d646 Merge pull request #7380 from renovate-bot/renovate/actions-upload-artifact-7.x
chore(deps): update actions/upload-artifact action to v7.0.2
2026-10-14 16:53:33 +02:00
Paweł Gronowski b997c5b7aa Merge pull request #7381 from renovate-bot/renovate/docker-actions
chore(deps): update docker/setup-buildx-action action to v4.4.1
2026-10-14 16:49:47 +02:00
Mend Renovate 1822903c1d chore(deps): update docker/setup-buildx-action action to v4.4.1
Signed-off-by: Mend Renovate <bot@renovateapp.com>
2026-10-14 14:34:17 +00:00
Mend Renovate 37013cb262 chore(deps): update actions/upload-artifact action to v7.0.2
Signed-off-by: Mend Renovate <bot@renovateapp.com>
2026-10-14 14:34:11 +00:00
Paweł Gronowski 61ee187552 Merge pull request #7251 from vvoland/add-renovate
Add Renovate config
2026-10-14 16:32:20 +02:00
Paweł Gronowski 433b13a249 Add Renovate config
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2026-10-14 15:25:17 +02:00
Sebastiaan van Stijn 5c8bfb2844 Merge pull request #7378 from thaJeztah/pr_7371_alt
cli-plugins: Add generic hook messages (alternative)
2026-10-13 17:27:57 +02:00
Paweł Gronowski 528a2ada2e Merge pull request #7375 from thaJeztah/bump_go_winio
vendor: github.com/Microsoft/go-winio v0.6.3
2026-10-13 17:25:25 +02:00
Nicolas Beck de8f4032ad cli-plugins: Add generic hook messages
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>
2026-10-12 16:31:43 +02:00
Sebastiaan van Stijn e4078058de Merge pull request #7373 from TanvirTian/7372-fix-remote-context-download-error
fix: return remote context download errors
2026-10-10 16:03:08 +02:00
Sebastiaan van Stijn 8da8b00555 vendor: github.com/Microsoft/go-winio v0.6.3
- improve interoperability with stdlib sentinel errors and deprecate aliases

full diff: https://github.com/Microsoft/go-winio/compare/f19d9717deb0...v0.6.3

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-10 15:28:26 +02:00
Tanvir Tian 067d069f0c fix: return remote context download errors
Signed-off-by: Tanvir Tian <beachpilis@gmail.com>
2026-10-10 18:16:02 +06:00
Sebastiaan van Stijn 02fbbf3eb0 Merge pull request #7369 from nico1510/offload-plugin-guidance
cmd/docker: Add guidance for unavailable offload commands
2026-10-09 18:08:27 +02:00
Sebastiaan van Stijn 890976a13b Merge pull request #7370 from thaJeztah/bump_version
Bump version to 29.9.1
2026-10-09 15:12:08 +02:00
Sebastiaan van Stijn 23c66ccd61 Bump version to 29.9.1
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-10-09 13:45:39 +02:00
Nicolas Beck 3e686a8b59 cmd/docker: Add guidance for unavailable offload commands
Show installation guidance and a documentation link when the offload
plugin is missing or invalid. Handle this before command execution so
plugin-specific flags and --help also receive the guidance.

Signed-off-by: Nicolas Beck <nicolas.beck@docker.com>
2026-10-09 10:06:48 +00:00
26 changed files with 433 additions and 138 deletions
+59
View File
@@ -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"]
}
]
}
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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/
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
29.9.0
29.9.1
+4
View File
@@ -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
+35 -10
View File
@@ -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
})
}
+84 -1
View File
@@ -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) {
+11 -1
View File
@@ -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")
+11 -1
View File
@@ -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",
+23 -47
View File
@@ -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
+108 -35
View File
@@ -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")
}
})
}
}
+5 -2
View File
@@ -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)
+31
View File
@@ -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
+7 -3
View File
@@ -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.
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+25 -7
View File
@@ -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.
+1 -1
View File
@@ -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}
+11 -12
View File
@@ -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
}
}
+1 -1
View File
@@ -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