From f838c7c84b768a129cc3c7b7c28a054b74865e6e Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 1 Sep 2026 13:27:45 +0200 Subject: [PATCH 1/9] cmd/docker-trust: modernize with slices and maps packages Signed-off-by: Sebastiaan van Stijn --- cmd/docker-trust/internal/trust/trust_push.go | 5 +-- cmd/docker-trust/trust/common.go | 5 +-- cmd/docker-trust/trust/formatter.go | 15 +++---- cmd/docker-trust/trust/inspect.go | 41 ++++++++++--------- cmd/docker-trust/trust/inspect_pretty.go | 7 +++- cmd/docker-trust/trust/sign.go | 9 ++-- 6 files changed, 42 insertions(+), 40 deletions(-) diff --git a/cmd/docker-trust/internal/trust/trust_push.go b/cmd/docker-trust/internal/trust/trust_push.go index 64f255964cf0..c65085f2c81c 100644 --- a/cmd/docker-trust/internal/trust/trust_push.go +++ b/cmd/docker-trust/internal/trust/trust_push.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" "io" - "sort" + "slices" "github.com/distribution/reference" "github.com/docker/cli/cli/streams" @@ -113,8 +113,7 @@ func PushTrustedReference(ctx context.Context, ioStreams Streams, repoInfo *Repo var rootKeyID string // always select the first root key if len(keys) > 0 { - sort.Strings(keys) - rootKeyID = keys[0] + rootKeyID = slices.Min(keys) } else { rootPublicKey, err := repo.GetCryptoService().Create(data.CanonicalRootRole, "", data.ECDSAKey) if err != nil { diff --git a/cmd/docker-trust/trust/common.go b/cmd/docker-trust/trust/common.go index f05e5772732d..f839bd26bd8c 100644 --- a/cmd/docker-trust/trust/common.go +++ b/cmd/docker-trust/trust/common.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "fmt" + "slices" "sort" "strings" @@ -110,9 +111,6 @@ func lookupTrustInfo(ctx context.Context, cli command.Cli, remote string) ([]tru } func formatAdminRole(roleWithSigs client.RoleWithSignatures) string { - adminKeyList := roleWithSigs.KeyIDs - sort.Strings(adminKeyList) - var role string switch roleWithSigs.Name { case data.CanonicalTargetsRole: @@ -122,6 +120,7 @@ func formatAdminRole(roleWithSigs client.RoleWithSignatures) string { default: return "" } + adminKeyList := slices.Sorted(slices.Values(roleWithSigs.KeyIDs)) return fmt.Sprintf("%s:\t%s\n", role, strings.Join(adminKeyList, ", ")) } diff --git a/cmd/docker-trust/trust/formatter.go b/cmd/docker-trust/trust/formatter.go index 13e5712a2764..2bde36115f25 100644 --- a/cmd/docker-trust/trust/formatter.go +++ b/cmd/docker-trust/trust/formatter.go @@ -1,7 +1,7 @@ package trust import ( - "sort" + "slices" "strings" "github.com/docker/cli/cli/command/formatter" @@ -73,8 +73,7 @@ func (c *trustTagContext) Digest() string { // Signers returns the sorted list of entities who signed this tag func (c *trustTagContext) Signers() string { - sort.Strings(c.s.Signers) - return strings.Join(c.s.Signers, ", ") + return strings.Join(slices.Sorted(slices.Values(c.s.Signers)), ", ") } // signerInfoWrite writes the context. @@ -108,15 +107,13 @@ type signerInfoContext struct { // Keys returns the sorted list of keys associated with the signer func (c *signerInfoContext) Keys() string { - sort.Strings(c.s.Keys) - truncatedKeys := []string{} + keys := slices.Sorted(slices.Values(c.s.Keys)) if c.trunc { - for _, keyID := range c.s.Keys { - truncatedKeys = append(truncatedKeys, formatter.TruncateID(keyID)) + for i, keyID := range keys { + keys[i] = formatter.TruncateID(keyID) } - return strings.Join(truncatedKeys, ", ") } - return strings.Join(c.s.Keys, ", ") + return strings.Join(keys, ", ") } // Signer returns the name of the signer diff --git a/cmd/docker-trust/trust/inspect.go b/cmd/docker-trust/trust/inspect.go index fabb1d7a131e..74240b61ad52 100644 --- a/cmd/docker-trust/trust/inspect.go +++ b/cmd/docker-trust/trust/inspect.go @@ -4,10 +4,11 @@ package trust import ( + "cmp" "context" "encoding/json" "fmt" - "sort" + "slices" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" @@ -80,36 +81,38 @@ func getRepoTrustInfo(ctx context.Context, dockerCLI command.Cli, remote string) } } - signerList, adminList := []trustSigner{}, []trustSigner{} - - signerRoleToKeyIDs := getDelegationRoleToKeyMap(delegationRoles) - - for signerName, signerKeys := range signerRoleToKeyIDs { - signerKeyList := []trustKey{} + var signerList []trustSigner + for signerName, signerKeys := range getDelegationRoleToKeyMap(delegationRoles) { + signerKeyList := make([]trustKey, 0, len(signerKeys)) for _, keyID := range signerKeys { signerKeyList = append(signerKeyList, trustKey{ID: keyID}) } signerList = append(signerList, trustSigner{signerName, signerKeyList}) } - sort.Slice(signerList, func(i, j int) bool { return signerList[i].Name > signerList[j].Name }) + // Sort by name in descending order. + slices.SortFunc(signerList, func(a, b trustSigner) int { return cmp.Compare(b.Name, a.Name) }) + var adminList []trustSigner for _, adminRole := range adminRolesWithSigs { + var name string switch adminRole.Name { case data.CanonicalRootRole: - rootKeys := []trustKey{} - for _, keyID := range adminRole.KeyIDs { - rootKeys = append(rootKeys, trustKey{ID: keyID}) - } - adminList = append(adminList, trustSigner{"Root", rootKeys}) + name = "Root" case data.CanonicalTargetsRole: - targetKeys := []trustKey{} - for _, keyID := range adminRole.KeyIDs { - targetKeys = append(targetKeys, trustKey{ID: keyID}) - } - adminList = append(adminList, trustSigner{"Repository", targetKeys}) + name = "Repository" + default: + continue } + + keys := make([]trustKey, 0, len(adminRole.KeyIDs)) + for _, keyID := range adminRole.KeyIDs { + keys = append(keys, trustKey{ID: keyID}) + } + adminList = append(adminList, trustSigner{name, keys}) } - sort.Slice(adminList, func(i, j int) bool { return adminList[i].Name > adminList[j].Name }) + + // Sort by name in descending order. + slices.SortFunc(adminList, func(a, b trustSigner) int { return cmp.Compare(b.Name, a.Name) }) return json.Marshal(trustRepo{ Name: remote, diff --git a/cmd/docker-trust/trust/inspect_pretty.go b/cmd/docker-trust/trust/inspect_pretty.go index 3eac2cfbe827..68e717be71e9 100644 --- a/cmd/docker-trust/trust/inspect_pretty.go +++ b/cmd/docker-trust/trust/inspect_pretty.go @@ -1,9 +1,11 @@ package trust import ( + "cmp" "context" "fmt" "io" + "slices" "sort" "github.com/docker/cli/cli/command" @@ -44,7 +46,10 @@ func prettyPrintTrustInfo(ctx context.Context, dockerCLI command.Cli, remote str } func printSortedAdminKeys(out io.Writer, adminRoles []client.RoleWithSignatures) { - sort.Slice(adminRoles, func(i, j int) bool { return adminRoles[i].Name > adminRoles[j].Name }) + // Sort by name in descending order. + slices.SortFunc(adminRoles, func(a, b client.RoleWithSignatures) int { + return cmp.Compare(b.Name, a.Name) + }) for _, adminRole := range adminRoles { if formattedAdminRole := formatAdminRole(adminRole); formattedAdminRole != "" { _, _ = fmt.Fprintf(out, " %s", formattedAdminRole) diff --git a/cmd/docker-trust/trust/sign.go b/cmd/docker-trust/trust/sign.go index 6b4b933f7242..f965ffb86a37 100644 --- a/cmd/docker-trust/trust/sign.go +++ b/cmd/docker-trust/trust/sign.go @@ -6,7 +6,7 @@ import ( "fmt" "io" "path" - "sort" + "slices" "strings" "github.com/distribution/reference" @@ -189,8 +189,8 @@ func getExistingSignatureInfoForReleasedTag(notaryRepo notaryclient.Repository, } func prettyPrintExistingSignatureInfo(out io.Writer, existingSigInfo trustTagRow) { - sort.Strings(existingSigInfo.Signers) - joinedSigners := strings.Join(existingSigInfo.Signers, ", ") + signers := slices.Sorted(slices.Values(existingSigInfo.Signers)) + joinedSigners := strings.Join(signers, ", ") _, _ = fmt.Fprintf(out, "Existing signatures for tag %s digest %s from:\n%s\n", existingSigInfo.SignedTag, existingSigInfo.Digest, joinedSigners) } @@ -228,8 +228,7 @@ func getOrGenerateNotaryKey(notaryRepo notaryclient.Repository, role data.RoleNa var key data.PublicKey // always select the first key by ID if len(keys) > 0 { - sort.Strings(keys) - keyID := keys[0] + keyID := slices.Min(keys) privKey, _, err := notaryRepo.GetCryptoService().GetPrivateKey(keyID) if err != nil { return nil, err From ed406be52e35c795964cd06e8881def90214b0a9 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 1 Sep 2026 13:28:34 +0200 Subject: [PATCH 2/9] cli/command: modernize with slices and maps packages Signed-off-by: Sebastiaan van Stijn --- cli/command/completion/functions_test.go | 7 ++- cli/command/container/create_test.go | 8 +-- cli/command/container/restart_test.go | 7 ++- cli/command/container/rm_test.go | 8 ++- cli/command/container/stop_test.go | 7 ++- cli/command/formatter/buildcache.go | 29 +++++----- cli/command/formatter/container.go | 32 +++++----- .../image/build/internal/git/gitutils.go | 6 +- cli/command/image/build_test.go | 4 +- cli/command/image/push_test.go | 8 ++- cli/command/service/formatter.go | 22 ++++--- cli/command/service/logs.go | 7 ++- cli/command/service/opts.go | 10 ++-- cli/command/service/update.go | 58 +++++++------------ cli/command/service/update_test.go | 6 +- cli/command/stack/loader.go | 6 +- cli/command/stack/remove.go | 16 ++--- cli/command/system/events.go | 17 +++--- cli/command/system/info.go | 6 +- cli/command/system/version.go | 13 ++--- cli/command/volume/create_test.go | 9 ++- 21 files changed, 140 insertions(+), 146 deletions(-) diff --git a/cli/command/completion/functions_test.go b/cli/command/completion/functions_test.go index bb2fa1b62ddb..87706005c970 100644 --- a/cli/command/completion/functions_test.go +++ b/cli/command/completion/functions_test.go @@ -3,9 +3,9 @@ package completion import ( "context" "errors" - "sort" "testing" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/image" "github.com/moby/moby/api/types/network" @@ -177,9 +177,10 @@ func TestCompleteEnvVarNames(t *testing.T) { values, directives := EnvVarNames()(nil, nil, "") assert.Check(t, is.Equal(directives&cobra.ShellCompDirectiveNoFileComp, cobra.ShellCompDirectiveNoFileComp), "Should not perform file completion") - sort.Strings(values) expected := []string{"ENV_A", "ENV_B"} - assert.Check(t, is.DeepEqual(values, expected)) + assert.Check(t, is.DeepEqual(values, expected, cmpopts.SortSlices(func(a, b string) bool { + return a < b + }))) } func TestCompleteFileNames(t *testing.T) { diff --git a/cli/command/container/create_test.go b/cli/command/container/create_test.go index 1c6ec9264975..9c0099904e3b 100644 --- a/cli/command/container/create_test.go +++ b/cli/command/container/create_test.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "runtime" - "sort" "strings" "testing" @@ -15,6 +14,7 @@ import ( "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/internal/test" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/system" "github.com/moby/moby/client" @@ -284,12 +284,12 @@ func TestCreateContainerWithProxyConfig(t *testing.T) { "ALL_PROXY=allProxy", "all_proxy=allProxy", } - sort.Strings(expected) fakeCLI := test.NewFakeCli(&fakeClient{ createContainerFunc: func(options client.ContainerCreateOptions) (client.ContainerCreateResult, error) { - sort.Strings(options.Config.Env) - assert.DeepEqual(t, options.Config.Env, expected) + assert.DeepEqual(t, options.Config.Env, expected, cmpopts.SortSlices(func(a, b string) bool { + return a < b + })) return client.ContainerCreateResult{}, nil }, }) diff --git a/cli/command/container/restart_test.go b/cli/command/container/restart_test.go index 569571b7175a..475444686638 100644 --- a/cli/command/container/restart_test.go +++ b/cli/command/container/restart_test.go @@ -4,11 +4,11 @@ import ( "context" "errors" "io" - "sort" "sync" "testing" "github.com/docker/cli/internal/test" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/moby/moby/client" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" @@ -87,8 +87,9 @@ func TestRestart(t *testing.T) { } else { assert.Check(t, is.Nil(err)) } - sort.Strings(restarted) - assert.Check(t, is.DeepEqual(restarted, tc.restarted)) + assert.Check(t, is.DeepEqual(restarted, tc.restarted, cmpopts.SortSlices(func(a, b string) bool { + return a < b + }))) }) } } diff --git a/cli/command/container/rm_test.go b/cli/command/container/rm_test.go index 4ab14407bb35..5e31e9abbcd5 100644 --- a/cli/command/container/rm_test.go +++ b/cli/command/container/rm_test.go @@ -4,11 +4,11 @@ import ( "context" "errors" "io" - "sort" "sync" "testing" "github.com/docker/cli/internal/test" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/moby/moby/client" "gotest.tools/v3/assert" ) @@ -53,8 +53,10 @@ func TestRemoveForce(t *testing.T) { assert.NilError(t, err) } assert.Equal(t, cli.ErrBuffer().String(), "") - sort.Strings(removed) - assert.DeepEqual(t, removed, []string{"mycontainer", "nosuchcontainer"}) + expected := []string{"mycontainer", "nosuchcontainer"} + assert.DeepEqual(t, removed, expected, cmpopts.SortSlices(func(a, b string) bool { + return a < b + })) }) } } diff --git a/cli/command/container/stop_test.go b/cli/command/container/stop_test.go index ed7fc335e267..484e7b4f39f5 100644 --- a/cli/command/container/stop_test.go +++ b/cli/command/container/stop_test.go @@ -4,11 +4,11 @@ import ( "context" "errors" "io" - "sort" "sync" "testing" "github.com/docker/cli/internal/test" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/moby/moby/client" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" @@ -88,8 +88,9 @@ func TestStop(t *testing.T) { } else { assert.Check(t, is.Nil(err)) } - sort.Strings(stopped) - assert.Check(t, is.DeepEqual(stopped, tc.stopped)) + assert.Check(t, is.DeepEqual(stopped, tc.stopped, cmpopts.SortSlices(func(a, b string) bool { + return a < b + }))) }) } } diff --git a/cli/command/formatter/buildcache.go b/cli/command/formatter/buildcache.go index 3a3c349988ef..d63cf05d63a3 100644 --- a/cli/command/formatter/buildcache.go +++ b/cli/command/formatter/buildcache.go @@ -1,7 +1,10 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + package formatter import ( - "sort" + "slices" "strconv" "strings" "time" @@ -52,20 +55,20 @@ shared: {{.Shared}} } func buildCacheSort(buildCache []build.CacheRecord) { - sort.Slice(buildCache, func(i, j int) bool { - lui, luj := buildCache[i].LastUsedAt, buildCache[j].LastUsedAt + slices.SortFunc(buildCache, func(a, b build.CacheRecord) int { switch { - case lui == nil && luj == nil: - return strings.Compare(buildCache[i].ID, buildCache[j].ID) < 0 - case lui == nil: - return true - case luj == nil: - return false - case lui.Equal(*luj): - return strings.Compare(buildCache[i].ID, buildCache[j].ID) < 0 - default: - return lui.Before(*luj) + case a.LastUsedAt == nil && b.LastUsedAt == nil: + return strings.Compare(a.ID, b.ID) + case a.LastUsedAt == nil: + return -1 + case b.LastUsedAt == nil: + return 1 + } + + if c := a.LastUsedAt.Compare(*b.LastUsedAt); c != 0 { + return c } + return strings.Compare(a.ID, b.ID) }) } diff --git a/cli/command/formatter/container.go b/cli/command/formatter/container.go index b65209c26c89..7b77a8a75375 100644 --- a/cli/command/formatter/container.go +++ b/cli/command/formatter/container.go @@ -4,9 +4,10 @@ package formatter import ( + "cmp" "fmt" "net" - "sort" + "slices" "strconv" "strings" "time" @@ -295,7 +296,7 @@ func (c *ContainerContext) Labels() string { for k, v := range c.c.Labels { joinLabels = append(joinLabels, k+"="+v) } - sort.Strings(joinLabels) + slices.Sort(joinLabels) return strings.Join(joinLabels, ",") } @@ -395,9 +396,7 @@ func DisplayablePorts(ports []container.PortSummary) string { var result []string var hostMappings []string var groupMapKeys []string - sort.Slice(ports, func(i, j int) bool { - return comparePorts(ports[i], ports[j]) - }) + slices.SortFunc(ports, comparePorts) for _, port := range ports { current := port.PrivatePort @@ -452,18 +451,13 @@ func formGroup(key string, start, last uint16) string { return group + "/" + groupType } -func comparePorts(i, j container.PortSummary) bool { - if i.PrivatePort != j.PrivatePort { - return i.PrivatePort < j.PrivatePort - } - - if i.IP != j.IP { - return i.IP.Less(j.IP) - } - - if i.PublicPort != j.PublicPort { - return i.PublicPort < j.PublicPort - } - - return i.Type < j.Type +// comparePorts compares ports by private port, IP address, public port, +// and protocol, in that order. +func comparePorts(a, b container.PortSummary) int { + return cmp.Or( + cmp.Compare(a.PrivatePort, b.PrivatePort), + a.IP.Compare(b.IP), + cmp.Compare(a.PublicPort, b.PublicPort), + cmp.Compare(a.Type, b.Type), + ) } diff --git a/cli/command/image/build/internal/git/gitutils.go b/cli/command/image/build/internal/git/gitutils.go index e9245cb098a6..82b7188324c2 100644 --- a/cli/command/image/build/internal/git/gitutils.go +++ b/cli/command/image/build/internal/git/gitutils.go @@ -1,3 +1,6 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + package git import ( @@ -7,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "github.com/moby/sys/symlink" @@ -202,7 +206,7 @@ func (repo gitRepo) checkout(root string) (string, error) { } func (repo gitRepo) gitWithinDir(dir string, args ...string) ([]byte, error) { - args = append([]string{"-c", "protocol.file.allow=never"}, args...) // Block sneaky repositories from using repos from the filesystem as submodules. + args = slices.Concat([]string{"-c", "protocol.file.allow=never"}, args) // Block sneaky repositories from using repos from the filesystem as submodules. cmd := exec.Command("git", args...) cmd.Dir = dir // Disable unsafe remote protocols. diff --git a/cli/command/image/build_test.go b/cli/command/image/build_test.go index 88d94d6cdec4..1d61d05bbe70 100644 --- a/cli/command/image/build_test.go +++ b/cli/command/image/build_test.go @@ -8,7 +8,7 @@ import ( "io" "os" "path/filepath" - "sort" + "slices" "testing" "github.com/docker/cli/cli/streams" @@ -211,6 +211,6 @@ func (f *fakeBuild) filenames(t *testing.T) []string { for _, header := range h { names = append(names, header.Name) } - sort.Strings(names) + slices.Sort(names) return names } diff --git a/cli/command/image/push_test.go b/cli/command/image/push_test.go index e0e0195e6a28..3dbca45ca9df 100644 --- a/cli/command/image/push_test.go +++ b/cli/command/image/push_test.go @@ -102,9 +102,11 @@ func TestRunPushRespectsNoColorForAuxNotes(t *testing.T) { SelectedManifest: ocispec.Descriptor{Digest: "sha256:2222222222222222222222222222222222222222222222222222222222222222"}, }) assert.NilError(t, err) - line := append([]byte(`{"aux":`), aux...) - line = append(line, '}', '\n') - return fakeStreamResult{ReadCloser: io.NopCloser(bytes.NewReader(line))}, nil + var buf bytes.Buffer + buf.WriteString(`{"aux":`) + buf.Write(aux) + buf.WriteString("}\n") + return fakeStreamResult{ReadCloser: io.NopCloser(&buf)}, nil }, }) cli.Out().SetIsTerminal(true) diff --git a/cli/command/service/formatter.go b/cli/command/service/formatter.go index 1ec5263debe8..5b77bbb1a5ee 100644 --- a/cli/command/service/formatter.go +++ b/cli/command/service/formatter.go @@ -1,8 +1,13 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + package service import ( + "cmp" "errors" "fmt" + "slices" "sort" "strconv" "strings" @@ -775,17 +780,16 @@ func (c *serviceContext) Ports() string { return "" } - pr := portRange{} - ports := []string{} - - servicePorts := c.service.Endpoint.Ports - sort.Slice(servicePorts, func(i, j int) bool { - if servicePorts[i].Protocol == servicePorts[j].Protocol { - return servicePorts[i].PublishedPort < servicePorts[j].PublishedPort - } - return servicePorts[i].Protocol < servicePorts[j].Protocol + // Sort by protocol first, then by published port. + slices.SortFunc(c.service.Endpoint.Ports, func(a, b swarm.PortConfig) int { + return cmp.Or( + cmp.Compare(a.Protocol, b.Protocol), + cmp.Compare(a.PublishedPort, b.PublishedPort), + ) }) + var pr portRange + var ports []string for _, p := range c.service.Endpoint.Ports { if p.PublishMode == swarm.PortConfigPublishModeIngress { prIsRange := pr.tEnd != pr.tStart diff --git a/cli/command/service/logs.go b/cli/command/service/logs.go index 347442332546..79908b865758 100644 --- a/cli/command/service/logs.go +++ b/cli/command/service/logs.go @@ -1,3 +1,6 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + package service import ( @@ -6,7 +9,7 @@ import ( "errors" "fmt" "io" - "sort" + "slices" "strconv" "strings" @@ -314,7 +317,7 @@ func (lw *logWriter) Write(buf []byte) (int, error) { d = append(d, k+"="+details[k]) } // then sort em - sort.Strings(d) + slices.Sort(d) // then join and append output = append(output, []byte(strings.Join(d, ","))...) output = append(output, ' ') diff --git a/cli/command/service/opts.go b/cli/command/service/opts.go index e04f392e46b2..e2758e64cd7d 100644 --- a/cli/command/service/opts.go +++ b/cli/command/service/opts.go @@ -4,12 +4,12 @@ package service import ( + "cmp" "context" "errors" "fmt" "net/netip" "slices" - "sort" "strconv" "strings" "time" @@ -737,15 +737,15 @@ func (options *serviceOptions) ToService(ctx context.Context, apiClient client.N } networks := convertNetworks(options.networks) - for i, net := range networks { - nwID, err := resolveNetworkID(ctx, apiClient, net.Target) + for i := range networks { + nwID, err := resolveNetworkID(ctx, apiClient, networks[i].Target) if err != nil { return service, err } networks[i].Target = nwID } - sort.Slice(networks, func(i, j int) bool { - return networks[i].Target < networks[j].Target + slices.SortFunc(networks, func(a, b swarm.NetworkAttachmentConfig) int { + return cmp.Compare(a.Target, b.Target) }) resources, err := options.resources.ToResourceRequirements(flags) diff --git a/cli/command/service/update.go b/cli/command/service/update.go index 7520b0889103..f3f7df43144d 100644 --- a/cli/command/service/update.go +++ b/cli/command/service/update.go @@ -4,13 +4,13 @@ package service import ( + "cmp" "context" "errors" "fmt" "maps" "net/netip" "slices" - "sort" "strings" "time" @@ -635,7 +635,7 @@ func updatePlacementConstraints(flags *pflag.FlagSet, placement *swarm.Placement } } // Sort so that result is predictable. - sort.Strings(newConstraints) + slices.Sort(newConstraints) placement.Constraints = newConstraints } @@ -720,7 +720,7 @@ func updateSysCtls(flags *pflag.FlagSet, field *map[string]string) { } func updateUlimits(flags *pflag.FlagSet, ulimits []*container.Ulimit) []*container.Ulimit { - newUlimits := make(map[string]*container.Ulimit) + newUlimits := make(map[string]*container.Ulimit, len(ulimits)) for _, ulimit := range ulimits { newUlimits[ulimit.Name] = ulimit @@ -737,17 +737,9 @@ func updateUlimits(flags *pflag.FlagSet, ulimits []*container.Ulimit) []*contain newUlimits[ulimit.Name] = ulimit } } - if len(newUlimits) == 0 { - return nil - } - limits := make([]*container.Ulimit, 0, len(newUlimits)) - for _, ulimit := range newUlimits { - limits = append(limits, ulimit) - } - sort.SliceStable(limits, func(i, j int) bool { - return limits[i].Name < limits[j].Name + return slices.SortedFunc(maps.Values(newUlimits), func(a, b *container.Ulimit) int { + return cmp.Compare(a.Name, b.Name) }) - return limits } func updateEnvironment(flags *pflag.FlagSet, field *[]string) { @@ -953,14 +945,12 @@ func updateMounts(flags *pflag.FlagSet, mounts *[]mount.Mount) error { newMounts = append(newMounts, mnt) } } - sort.Slice(newMounts, func(i, j int) bool { - a, b := newMounts[i], newMounts[j] - - if a.Source == b.Source { - return a.Target < b.Target - } - - return a.Source < b.Source + // Sort mounts by source, then by target. + slices.SortFunc(newMounts, func(a, b mount.Mount) int { + return cmp.Or( + cmp.Compare(a.Source, b.Source), + cmp.Compare(a.Target, b.Target), + ) }) *mounts = newMounts return nil @@ -980,7 +970,7 @@ func updateGroups(flags *pflag.FlagSet, groups *[]string) error { } } // Sort so that result is predictable. - sort.Strings(newGroups) + slices.Sort(newGroups) *groups = newGroups return nil @@ -1039,7 +1029,7 @@ func updateDNSConfig(flags *pflag.FlagSet, config **swarm.DNSConfig) error { } } // Sort so that result is predictable. - sort.Strings(newConfig.Search) + slices.Sort(newConfig.Search) options := (*config).Options if flags.Changed(flagDNSOptionAdd) { @@ -1054,7 +1044,7 @@ func updateDNSConfig(flags *pflag.FlagSet, config **swarm.DNSConfig) error { } } // Sort so that result is predictable. - sort.Strings(newConfig.Options) + slices.Sort(newConfig.Options) *config = newConfig return nil @@ -1107,12 +1097,8 @@ portLoop: } } - // Sort the PortConfig to avoid unnecessary updates - sort.Slice(newPorts, func(i, j int) bool { - // We convert PortConfig into `port/protocol`, e.g., `80/tcp` - // In updatePorts we already filter out with map so there is duplicate entries - return portConfigToString(&newPorts[i]) < portConfigToString(&newPorts[j]) - }) + // Sort the PortConfig to avoid unnecessary updates. + slices.SortFunc(newPorts, swarm.PortConfig.Compare) *portConfig = newPorts return nil } @@ -1348,8 +1334,8 @@ func updateNetworks(ctx context.Context, apiClient client.NetworkAPIClient, flag } } - sort.Slice(newNetworks, func(i, j int) bool { - return newNetworks[i].Target < newNetworks[j].Target + slices.SortFunc(newNetworks, func(a, b swarm.NetworkAttachmentConfig) int { + return cmp.Compare(a.Target, b.Target) }) spec.TaskTemplate.Networks = newNetworks @@ -1523,10 +1509,6 @@ func capsList(caps map[string]bool) []string { if caps[opts.AllCapabilities] { return []string{opts.AllCapabilities} } - out := make([]string, 0, len(caps)) - for c := range caps { - out = append(out, c) - } - sort.Strings(out) - return out + + return slices.Sorted(maps.Keys(caps)) } diff --git a/cli/command/service/update_test.go b/cli/command/service/update_test.go index 6158820951b1..1211360bcbc7 100644 --- a/cli/command/service/update_test.go +++ b/cli/command/service/update_test.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "net/netip" - "sort" + "slices" "strconv" "testing" "time" @@ -141,7 +141,7 @@ func TestUpdateEnvironment(t *testing.T) { updateEnvironment(flags, &envs) assert.Assert(t, is.Len(envs, 2)) // Order has been removed in updateEnvironment (map) - sort.Strings(envs) + slices.Sort(envs) assert.Check(t, is.Equal("toadd=newenv", envs[0])) assert.Check(t, is.Equal("tokeep=value", envs[1])) } @@ -282,7 +282,7 @@ func TestUpdatePorts(t *testing.T) { assert.Assert(t, is.Len(portConfigs, 2)) // Do a sort to have the order (might have changed by map) targetPorts := []int{int(portConfigs[0].TargetPort), int(portConfigs[1].TargetPort)} - sort.Ints(targetPorts) + slices.Sort(targetPorts) assert.Check(t, is.Equal(555, targetPorts[0])) assert.Check(t, is.Equal(1000, targetPorts[1])) } diff --git a/cli/command/stack/loader.go b/cli/command/stack/loader.go index 49366e4d50cc..10579a08d89d 100644 --- a/cli/command/stack/loader.go +++ b/cli/command/stack/loader.go @@ -10,7 +10,7 @@ import ( "os" "path/filepath" "runtime" - "sort" + "slices" "strings" "github.com/distribution/reference" @@ -75,9 +75,9 @@ func getDictsFrom(configFiles []composetypes.ConfigFile) []map[string]any { func propertyWarnings(properties map[string]string) string { msgs := make([]string, 0, len(properties)) for name, description := range properties { - msgs = append(msgs, fmt.Sprintf("%s: %s", name, description)) + msgs = append(msgs, name+": "+description) } - sort.Strings(msgs) + slices.Sort(msgs) return strings.Join(msgs, "\n\n") } diff --git a/cli/command/stack/remove.go b/cli/command/stack/remove.go index 90ab2df29bbb..2f6d061b35b6 100644 --- a/cli/command/stack/remove.go +++ b/cli/command/stack/remove.go @@ -1,10 +1,14 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + package stack import ( + "cmp" "context" "errors" "fmt" - "sort" + "slices" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" @@ -96,15 +100,11 @@ func runRemove(ctx context.Context, dockerCli command.Cli, opts removeOptions) e return errors.Join(errs...) } -func sortServiceByName(services []swarm.Service) func(i, j int) bool { - return func(i, j int) bool { - return services[i].Spec.Name < services[j].Spec.Name - } -} - func removeServices(ctx context.Context, dockerCLI command.Cli, services []swarm.Service) bool { + slices.SortFunc(services, func(a, b swarm.Service) int { + return cmp.Compare(a.Spec.Name, b.Spec.Name) + }) var hasError bool - sort.Slice(services, sortServiceByName(services)) for _, service := range services { _, _ = fmt.Fprintln(dockerCLI.Out(), "Removing service", service.Spec.Name) if _, err := dockerCLI.Client().ServiceRemove(ctx, service.ID, client.ServiceRemoveOptions{}); err != nil { diff --git a/cli/command/system/events.go b/cli/command/system/events.go index eb77c6c6b2f9..36264ef010d9 100644 --- a/cli/command/system/events.go +++ b/cli/command/system/events.go @@ -1,10 +1,14 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + package system import ( "context" "fmt" "io" - "sort" + "maps" + "slices" "strings" "text/template" "time" @@ -133,18 +137,13 @@ func prettyPrintEvent(out io.Writer, event events.Message) error { _, _ = fmt.Fprintf(out, "%s %s %s", event.Type, event.Action, event.Actor.ID) if len(event.Actor.Attributes) > 0 { - keys := make([]string, 0, len(event.Actor.Attributes)) - for k := range event.Actor.Attributes { - keys = append(keys, k) - } - sort.Strings(keys) + keys := slices.Sorted(maps.Keys(event.Actor.Attributes)) attrs := make([]string, 0, len(keys)) for _, k := range keys { - v := event.Actor.Attributes[k] - attrs = append(attrs, k+"="+v) + attrs = append(attrs, k+"="+event.Actor.Attributes[k]) } _, _ = fmt.Fprintf(out, " (%s)", strings.Join(attrs, ", ")) } - _, _ = fmt.Fprint(out, "\n") + _, _ = fmt.Fprintln(out) return nil } diff --git a/cli/command/system/info.go b/cli/command/system/info.go index 80a21629ec6e..fcc533c4265d 100644 --- a/cli/command/system/info.go +++ b/cli/command/system/info.go @@ -8,7 +8,7 @@ import ( "errors" "fmt" "io" - "sort" + "slices" "strings" "github.com/docker/cli/cli" @@ -468,11 +468,11 @@ func printSwarmInfo(output io.Writer, info system.Info) { } fprintln(output, " Node Address:", info.Swarm.NodeAddr) if len(info.Swarm.RemoteManagers) > 0 { - managers := []string{} + managers := make([]string, 0, len(info.Swarm.RemoteManagers)) for _, entry := range info.Swarm.RemoteManagers { managers = append(managers, entry.Addr) } - sort.Strings(managers) + slices.Sort(managers) fprintln(output, " Manager Addresses:") for _, entry := range managers { fprintf(output, " %s\n", entry) diff --git a/cli/command/system/version.go b/cli/command/system/version.go index b385ef7181d6..41b7de87c9e7 100644 --- a/cli/command/system/version.go +++ b/cli/command/system/version.go @@ -1,11 +1,15 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + package system import ( "context" "fmt" "io" + "maps" "runtime" - "sort" + "slices" "strconv" "text/template" "time" @@ -252,10 +256,5 @@ func newVersionTemplate(templateFormat string) (*template.Template, error) { } func getDetailsOrder(v system.ComponentVersion) []string { - out := make([]string, 0, len(v.Details)) - for k := range v.Details { - out = append(out, k) - } - sort.Strings(out) - return out + return slices.Sorted(maps.Keys(v.Details)) } diff --git a/cli/command/volume/create_test.go b/cli/command/volume/create_test.go index 7f8a2c387c6a..3f9e43cdc016 100644 --- a/cli/command/volume/create_test.go +++ b/cli/command/volume/create_test.go @@ -8,11 +8,11 @@ import ( "fmt" "io" "maps" - "sort" "strings" "testing" "github.com/docker/cli/internal/test" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/moby/moby/api/types/volume" "github.com/moby/moby/client" "gotest.tools/v3/assert" @@ -230,10 +230,9 @@ func TestVolumeCreateClusterOpts(t *testing.T) { cli := test.NewFakeCli(&fakeClient{ volumeCreateFunc: func(options client.VolumeCreateOptions) (client.VolumeCreateResult, error) { - sort.SliceStable(options.ClusterVolumeSpec.Secrets, func(i, j int) bool { - return options.ClusterVolumeSpec.Secrets[i].Key < options.ClusterVolumeSpec.Secrets[j].Key - }) - assert.DeepEqual(t, options, expectedOptions) + assert.Check(t, is.DeepEqual(options, expectedOptions, cmpopts.SortSlices(func(a, b volume.Secret) bool { + return a.Key < b.Key + }))) return client.VolumeCreateResult{}, nil }, }) From 33d586d81f1e4f00b1a737852680d97d2cb5b03c Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 1 Sep 2026 13:28:52 +0200 Subject: [PATCH 3/9] cli/compose: modernize with slices and maps packages Signed-off-by: Sebastiaan van Stijn --- cli/compose/convert/service.go | 22 ++++++++++--------- cli/compose/loader/interpolate.go | 23 +++++++------------- cli/compose/loader/loader.go | 36 +++++++++---------------------- cli/compose/loader/loader_test.go | 16 ++++++-------- cli/compose/loader/merge.go | 17 ++++++++++----- 5 files changed, 48 insertions(+), 66 deletions(-) diff --git a/cli/compose/convert/service.go b/cli/compose/convert/service.go index 762bf144ffdf..d30597451a7d 100644 --- a/cli/compose/convert/service.go +++ b/cli/compose/convert/service.go @@ -11,7 +11,6 @@ import ( "net/netip" "os" "slices" - "sort" "strings" "time" @@ -235,8 +234,8 @@ func convertServiceNetworks( nets = append(nets, netAttachConfig) } - sort.Slice(nets, func(i, j int) bool { - return nets[i].Target < nets[j].Target + slices.SortFunc(nets, func(a, b swarm.NetworkAttachmentConfig) int { + return cmp.Compare(a.Target, b.Target) }) return nets, nil } @@ -276,8 +275,10 @@ func convertServiceSecrets( return nil, err } // sort to ensure idempotence (don't restart services just because the entries are in different order) - sort.SliceStable(secrs, func(i, j int) bool { return secrs[i].SecretName < secrs[j].SecretName }) - return secrs, err + slices.SortStableFunc(secrs, func(a, b *swarm.SecretReference) int { + return cmp.Compare(a.SecretName, b.SecretName) + }) + return secrs, nil } // convertServiceConfigObjs takes an API client, a namespace, a ServiceConfig, @@ -352,8 +353,10 @@ func convertServiceConfigObjs( return nil, err } // sort to ensure idempotence (don't restart services just because the entries are in different order) - sort.SliceStable(confs, func(i, j int) bool { return confs[i].ConfigName < confs[j].ConfigName }) - return confs, err + slices.SortStableFunc(confs, func(a, b *swarm.ConfigReference) int { + return cmp.Compare(a.ConfigName, b.ConfigName) + }) + return confs, nil } type swarmReferenceTarget struct { @@ -609,8 +612,7 @@ func convertEndpointSpec(endpointMode string, source []composetypes.ServicePortC // convertEnvironment converts key/value mappings to a slice, and sorts // the results. func convertEnvironment(source map[string]*string) []string { - var output []string - + output := make([]string, 0, len(source)) for name, value := range source { switch value { case nil: @@ -619,7 +621,7 @@ func convertEnvironment(source map[string]*string) []string { output = append(output, name+"="+*value) } } - sort.Strings(output) + slices.Sort(output) return output } diff --git a/cli/compose/loader/interpolate.go b/cli/compose/loader/interpolate.go index 11c44113d451..ef84ea10a4bd 100644 --- a/cli/compose/loader/interpolate.go +++ b/cli/compose/loader/interpolate.go @@ -5,6 +5,7 @@ package loader import ( "fmt" + "slices" "strconv" "strings" @@ -35,20 +36,16 @@ var interpolateTypeCastMapping = map[interp.Path]interp.Cast{ servicePath("tty"): toBoolean, servicePath("volumes", interp.PathMatchList, "read_only"): toBoolean, servicePath("volumes", interp.PathMatchList, "volume", "nocopy"): toBoolean, - iPath("networks", interp.PathMatchAll, "external"): toBoolean, - iPath("networks", interp.PathMatchAll, "internal"): toBoolean, - iPath("networks", interp.PathMatchAll, "attachable"): toBoolean, - iPath("volumes", interp.PathMatchAll, "external"): toBoolean, - iPath("secrets", interp.PathMatchAll, "external"): toBoolean, - iPath("configs", interp.PathMatchAll, "external"): toBoolean, -} - -func iPath(parts ...string) interp.Path { - return interp.NewPath(parts...) + interp.NewPath("networks", interp.PathMatchAll, "external"): toBoolean, + interp.NewPath("networks", interp.PathMatchAll, "internal"): toBoolean, + interp.NewPath("networks", interp.PathMatchAll, "attachable"): toBoolean, + interp.NewPath("volumes", interp.PathMatchAll, "external"): toBoolean, + interp.NewPath("secrets", interp.PathMatchAll, "external"): toBoolean, + interp.NewPath("configs", interp.PathMatchAll, "external"): toBoolean, } func servicePath(parts ...string) interp.Path { - return iPath(append([]string{"services", interp.PathMatchAll}, parts...)...) + return interp.NewPath(slices.Concat([]string{"services", interp.PathMatchAll}, parts)...) } func toInt(value string) (any, error) { @@ -70,7 +67,3 @@ func toBoolean(value string) (any, error) { return nil, fmt.Errorf("invalid boolean: %s", value) } } - -func interpolateConfig(configDict map[string]any, opts interp.Options) (map[string]any, error) { - return interp.Interpolate(configDict, opts) -} diff --git a/cli/compose/loader/loader.go b/cli/compose/loader/loader.go index 91d85cfd0a43..0a9cb03ae0a5 100644 --- a/cli/compose/loader/loader.go +++ b/cli/compose/loader/loader.go @@ -10,7 +10,7 @@ import ( "path" "path/filepath" "reflect" - "sort" + "slices" "strconv" "strings" "time" @@ -111,7 +111,7 @@ func Load(configDetails types.ConfigDetails, opt ...func(*Options)) (*types.Conf } if !options.SkipInterpolation { - configDict, err = interpolateConfig(configDict, *options.Interpolate) + configDict, err = interp.Interpolate(configDict, *options.Interpolate) if err != nil { return nil, err } @@ -231,16 +231,7 @@ func GetUnsupportedProperties(configDicts ...map[string]any) []string { } } - return sortedKeys(unsupported) -} - -func sortedKeys(set map[string]bool) []string { - keys := make([]string, 0, len(set)) - for key := range set { - keys = append(keys, key) - } - sort.Strings(keys) - return keys + return slices.Sorted(maps.Keys(unsupported)) } // GetDeprecatedProperties returns the list of any deprecated properties that @@ -827,8 +818,7 @@ func transformListOrMapping(listOrMapping any, sep string, allowNil bool, allowS result := make([]string, 0, len(value)) for _, entry := range value { for i, allowSep := range allowSeps { - entry := fmt.Sprint(entry) - k, v, ok := strings.Cut(entry, allowSep) + k, v, ok := strings.Cut(fmt.Sprint(entry), allowSep) if ok { // Entry uses this allowed separator. Add it to the result, using // sep as a separator. @@ -885,7 +875,7 @@ var transformShellCommand TransformerFunc = func(value any) (any, error) { var transformHealthCheckTest TransformerFunc = func(data any) (any, error) { switch value := data.(type) { case string: - return append([]string{"CMD-SHELL"}, value), nil + return []string{"CMD-SHELL", value}, nil case []any: return value, nil default: @@ -925,16 +915,10 @@ func toServicePortConfigs(value string) ([]any, error) { return nil, err } // We need to sort the key of the ports to make sure it is consistent - keys := make([]string, 0, len(ports)) - for port := range ports { - keys = append(keys, string(port)) - } - sort.Strings(keys) - var portConfigs []any - for _, key := range keys { + for _, key := range slices.Sorted(maps.Keys(ports)) { // Reuse ConvertPortToPortConfig so that it is consistent - port, err := network.ParsePort(key) + port, err := network.ParsePort(string(key)) if err != nil { return nil, err } @@ -975,13 +959,13 @@ func toString(value any, allowNil bool) any { } func toStringList(value map[string]any, separator string, allowNil bool) []string { - output := []string{} + output := make([]string, 0, len(value)) for key, value := range value { if value == nil && !allowNil { continue } - output = append(output, fmt.Sprintf("%s%s%s", key, separator, value)) + output = append(output, fmt.Sprintf("%s%s%v", key, separator, value)) } - sort.Strings(output) + slices.Sort(output) return output } diff --git a/cli/compose/loader/loader_test.go b/cli/compose/loader/loader_test.go index 1b97ffaf2d2b..41f70c7cb23c 100644 --- a/cli/compose/loader/loader_test.go +++ b/cli/compose/loader/loader_test.go @@ -7,7 +7,6 @@ import ( "bytes" "os" "runtime" - "sort" "testing" "github.com/docker/cli/cli/compose/types" @@ -236,7 +235,9 @@ func TestLoad(t *testing.T) { actual, err := Load(buildConfigDetails(sampleDict, nil)) assert.NilError(t, err) assert.Check(t, is.Equal(sampleConfig.Version, actual.Version)) - assert.Check(t, is.DeepEqual(serviceSort(sampleConfig.Services), serviceSort(actual.Services))) + assert.Check(t, is.DeepEqual(sampleConfig.Services, actual.Services, cmpopts.SortSlices(func(a, b types.ServiceConfig) bool { + return a.Name < b.Name + }))) assert.Check(t, is.DeepEqual(sampleConfig.Networks, actual.Networks)) assert.Check(t, is.DeepEqual(sampleConfig.Volumes, actual.Volumes)) } @@ -310,7 +311,9 @@ services: func TestParseAndLoad(t *testing.T) { actual, err := loadYAML(sampleYAML) assert.NilError(t, err) - assert.Check(t, is.DeepEqual(serviceSort(sampleConfig.Services), serviceSort(actual.Services))) + assert.Check(t, is.DeepEqual(sampleConfig.Services, actual.Services, cmpopts.SortSlices(func(a, b types.ServiceConfig) bool { + return a.Name < b.Name + }))) assert.Check(t, is.DeepEqual(sampleConfig.Networks, actual.Networks)) assert.Check(t, is.DeepEqual(sampleConfig.Volumes, actual.Volumes)) } @@ -1191,13 +1194,6 @@ services: assert.ErrorContains(t, err, "services.tmpfs.volumes.0.tmpfs.size: must be an integer") } -func serviceSort(services []types.ServiceConfig) []types.ServiceConfig { - sort.Slice(services, func(i, j int) bool { - return services[i].Name < services[j].Name - }) - return services -} - func TestLoadAttachableNetwork(t *testing.T) { config, err := loadYAML(` version: "3.2" diff --git a/cli/compose/loader/merge.go b/cli/compose/loader/merge.go index 4b397a2bd62e..cf666ab0b2cc 100644 --- a/cli/compose/loader/merge.go +++ b/cli/compose/loader/merge.go @@ -9,7 +9,6 @@ import ( "fmt" "reflect" "slices" - "sort" "dario.cat/mergo" "github.com/docker/cli/cli/compose/types" @@ -152,7 +151,9 @@ func toServiceSecretConfigsSlice(dst reflect.Value, m map[any]any) error { for _, v := range m { s = append(s, v.(types.ServiceSecretConfig)) } - sort.Slice(s, func(i, j int) bool { return s[i].Source < s[j].Source }) + slices.SortFunc(s, func(a, b types.ServiceSecretConfig) int { + return cmp.Compare(a.Source, b.Source) + }) dst.Set(reflect.ValueOf(s)) return nil } @@ -162,7 +163,9 @@ func toSServiceConfigObjConfigsSlice(dst reflect.Value, m map[any]any) error { for _, v := range m { s = append(s, v.(types.ServiceConfigObjConfig)) } - sort.Slice(s, func(i, j int) bool { return s[i].Source < s[j].Source }) + slices.SortFunc(s, func(a, b types.ServiceConfigObjConfig) int { + return cmp.Compare(a.Source, b.Source) + }) dst.Set(reflect.ValueOf(s)) return nil } @@ -172,7 +175,9 @@ func toServicePortConfigsSlice(dst reflect.Value, m map[any]any) error { for _, v := range m { s = append(s, v.(types.ServicePortConfig)) } - sort.Slice(s, func(i, j int) bool { return s[i].Published < s[j].Published }) + slices.SortFunc(s, func(a, b types.ServicePortConfig) int { + return cmp.Compare(a.Published, b.Published) + }) dst.Set(reflect.ValueOf(s)) return nil } @@ -182,7 +187,9 @@ func toServiceVolumeConfigsSlice(dst reflect.Value, m map[any]any) error { for _, v := range m { s = append(s, v.(types.ServiceVolumeConfig)) } - sort.Slice(s, func(i, j int) bool { return s[i].Target < s[j].Target }) + slices.SortFunc(s, func(a, b types.ServiceVolumeConfig) int { + return cmp.Compare(a.Target, b.Target) + }) dst.Set(reflect.ValueOf(s)) return nil } From 6ea81b3e2ea869753158f180b2901f0b0526d047 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 1 Sep 2026 13:29:16 +0200 Subject: [PATCH 4/9] templates: modernize with slices and maps packages Signed-off-by: Sebastiaan van Stijn --- templates/templates.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/templates.go b/templates/templates.go index 24f29c179fc6..5443d70d7c77 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -8,7 +8,7 @@ import ( "encoding/json" "fmt" "reflect" - "sort" + "slices" "strings" "text/template" ) @@ -125,12 +125,12 @@ func joinElements(elems any, sep string) (string, error) { return b.String(), nil case reflect.Map: - var out []string + out := make([]string, 0, rv.Len()) for _, k := range rv.MapKeys() { out = append(out, fmt.Sprint(rv.MapIndex(k).Interface())) } // Not ideal, but trying to keep a consistent order - sort.Strings(out) + slices.Sort(out) return strings.Join(out, sep), nil default: From b1780e06a098bf085273a6a5265c55210ecbdbe9 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 1 Sep 2026 13:29:35 +0200 Subject: [PATCH 5/9] opts: modernize with slices and maps packages Signed-off-by: Sebastiaan van Stijn --- opts/capabilities.go | 9 ++++++--- opts/ulimit.go | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/opts/capabilities.go b/opts/capabilities.go index 82d071853b67..96a0e7d0f4f9 100644 --- a/opts/capabilities.go +++ b/opts/capabilities.go @@ -1,7 +1,10 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + package opts import ( - "sort" + "slices" "strings" ) @@ -82,8 +85,8 @@ func EffectiveCapAddCapDrop(add, drop []string) (capAdd, capDrop []string) { } } - sort.Strings(capAdd) - sort.Strings(capDrop) + slices.Sort(capAdd) + slices.Sort(capDrop) return capAdd, capDrop } diff --git a/opts/ulimit.go b/opts/ulimit.go index aa88bce71a24..ba4a82c9d865 100644 --- a/opts/ulimit.go +++ b/opts/ulimit.go @@ -1,8 +1,13 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.26 + package opts import ( "fmt" - "sort" + "maps" + "slices" + "strings" "github.com/docker/go-units" "github.com/moby/moby/api/types/container" @@ -41,20 +46,15 @@ func (o *UlimitOpt) String() string { for _, v := range *o.values { out = append(out, v.String()) } - sort.Strings(out) - return fmt.Sprintf("%v", out) + slices.Sort(out) + return fmt.Sprint(out) } // GetList returns a slice of pointers to Ulimits. Values are sorted by name. func (o *UlimitOpt) GetList() []*container.Ulimit { - ulimits := make([]*container.Ulimit, 0, len(*o.values)) - for _, v := range *o.values { - ulimits = append(ulimits, v) - } - sort.SliceStable(ulimits, func(i, j int) bool { - return ulimits[i].Name < ulimits[j].Name + return slices.SortedFunc(maps.Values(*o.values), func(a, b *container.Ulimit) int { + return strings.Compare(a.Name, b.Name) }) - return ulimits } // Type returns the option type From 7631c672089ea48d5f72feb01c5b182c0394ad56 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 1 Sep 2026 13:30:07 +0200 Subject: [PATCH 6/9] e2e: modernize Signed-off-by: Sebastiaan van Stijn --- e2e/stack/deploy_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/stack/deploy_test.go b/e2e/stack/deploy_test.go index daf7ccc220a3..166236936e7a 100644 --- a/e2e/stack/deploy_test.go +++ b/e2e/stack/deploy_test.go @@ -1,10 +1,10 @@ package stack import ( - "sort" "strings" "testing" + "github.com/google/go-cmp/cmp/cmpopts" "gotest.tools/v3/assert" "gotest.tools/v3/golden" "gotest.tools/v3/icmd" @@ -21,7 +21,7 @@ func TestDeployWithNamedResources(t *testing.T) { result.Assert(t, icmd.Success) stdout := strings.Split(result.Stdout(), "\n") expected := strings.Split(string(golden.Get(t, "stack-deploy-with-names.golden")), "\n") - sort.Strings(stdout) - sort.Strings(expected) - assert.DeepEqual(t, stdout, expected) + assert.DeepEqual(t, stdout, expected, cmpopts.SortSlices(func(a, b string) bool { + return a < b + })) } From 64484430f41f1d828d769f40a8f585dd82b4456b Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 1 Sep 2026 21:37:43 +0200 Subject: [PATCH 7/9] cli-plugins: modernize with slices and maps packages Signed-off-by: Sebastiaan van Stijn --- cli-plugins/manager/cobra_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli-plugins/manager/cobra_test.go b/cli-plugins/manager/cobra_test.go index 7089bdccb9cc..0de070fefddc 100644 --- a/cli-plugins/manager/cobra_test.go +++ b/cli-plugins/manager/cobra_test.go @@ -3,6 +3,7 @@ package manager import ( "os" "path/filepath" + "slices" "sync" "testing" @@ -45,7 +46,7 @@ func TestPluginStubCompletionRestoresOSArgs(t *testing.T) { t.Cleanup(func() { os.Args = savedArgs }) originalArgs := []string{"docker", "image", "ls"} - os.Args = append([]string(nil), originalArgs...) + os.Args = slices.Clone(originalArgs) _, directive := cmd.ValidArgsFunction(cmd, []string{"--all"}, "alp") assert.Equal(t, directive, cobra.ShellCompDirectiveError) From 977d9993820d4facd87953a96d3680ba13e27f9c Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 1 Sep 2026 21:39:01 +0200 Subject: [PATCH 8/9] cli/config: use filepath.IsLocal to validate config paths Use filepath.Rel and filepath.IsLocal instead of comparing path prefixes as strings when checking that a path stays within the config directory. Also avoid calling Dir multiple times when constructing and validating the path. Signed-off-by: Sebastiaan van Stijn --- cli/config/config.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/config/config.go b/cli/config/config.go index 5a637805091c..a7ea0acdd7e6 100644 --- a/cli/config/config.go +++ b/cli/config/config.go @@ -7,7 +7,6 @@ import ( "os/user" "path/filepath" "runtime" - "strings" "sync" "github.com/docker/cli/cli/config/configfile" @@ -98,9 +97,11 @@ func SetDir(dir string) { // Path returns the path to a file relative to the config dir func Path(p ...string) (string, error) { - path := filepath.Join(append([]string{Dir()}, p...)...) - if !strings.HasPrefix(path, Dir()+string(filepath.Separator)) { - return "", fmt.Errorf("path %q is outside of root config directory %q", path, Dir()) + root := Dir() + path := filepath.Join(append([]string{root}, p...)...) + + if rel, err := filepath.Rel(root, path); err != nil || !filepath.IsLocal(rel) { + return "", fmt.Errorf("path %q is outside of root config directory %q", path, root) } return path, nil } From e480e6c6fc80f7545a290c5be9bdd3aa00a3dc16 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Wed, 2 Sep 2026 01:00:33 +0200 Subject: [PATCH 9/9] volume create: simplify cluster volume options Extract construction of the cluster volume spec from runCreate, and use struct literals for the individual cluster volume options. Also simplify topology parsing by reusing ConvertKVStringsToMap, use slices.SortFunc for deterministic secret ordering, and use max to clamp negative capacity values to zero. Signed-off-by: Sebastiaan van Stijn --- cli/command/volume/create.go | 140 +++++++++++++++-------------------- 1 file changed, 58 insertions(+), 82 deletions(-) diff --git a/cli/command/volume/create.go b/cli/command/volume/create.go index 5c2098e03519..80b09ed2097b 100644 --- a/cli/command/volume/create.go +++ b/cli/command/volume/create.go @@ -4,10 +4,11 @@ package volume import ( + "cmp" "context" "errors" "fmt" - "sort" + "slices" "strings" "github.com/docker/cli/cli" @@ -117,93 +118,68 @@ func hasClusterVolumeOptionSet(flags *pflag.FlagSet) bool { } func runCreate(ctx context.Context, dockerCli command.Cli, options createOptions) error { - volOpts := client.VolumeCreateOptions{ - Driver: options.driver, - DriverOpts: options.driverOpts.GetAll(), - Name: options.name, - Labels: opts.ConvertKVStringsToMap(options.labels.GetSlice()), + res, err := dockerCli.Client().VolumeCreate(ctx, client.VolumeCreateOptions{ + Driver: options.driver, + DriverOpts: options.driverOpts.GetAll(), + Name: options.name, + Labels: opts.ConvertKVStringsToMap(options.labels.GetSlice()), + ClusterVolumeSpec: clusterVolumeSpec(options), + }) + if err != nil { + return err } - if options.cluster { - volOpts.ClusterVolumeSpec = &volume.ClusterVolumeSpec{ - Group: options.group, - AccessMode: &volume.AccessMode{ - Scope: volume.Scope(options.scope), - Sharing: volume.SharingMode(options.sharing), - }, - Availability: volume.Availability(options.availability), - } - - switch options.accessType { - case "mount": - volOpts.ClusterVolumeSpec.AccessMode.MountVolume = &volume.TypeMount{} - case "block": - volOpts.ClusterVolumeSpec.AccessMode.BlockVolume = &volume.TypeBlock{} - } - - vcr := &volume.CapacityRange{} - if r := options.requiredBytes.Value(); r >= 0 { - vcr.RequiredBytes = r - } - - if l := options.limitBytes.Value(); l >= 0 { - vcr.LimitBytes = l - } - volOpts.ClusterVolumeSpec.CapacityRange = vcr - - for key, secret := range options.secrets.GetAll() { - volOpts.ClusterVolumeSpec.Secrets = append( - volOpts.ClusterVolumeSpec.Secrets, - volume.Secret{ - Key: key, - Secret: secret, - }, - ) - } - sort.SliceStable(volOpts.ClusterVolumeSpec.Secrets, func(i, j int) bool { - return volOpts.ClusterVolumeSpec.Secrets[i].Key < volOpts.ClusterVolumeSpec.Secrets[j].Key - }) - // TODO(dperny): ignore if no topology specified - topology := &volume.TopologyRequirement{} - for _, top := range options.requisiteTopology.GetSlice() { - // each topology takes the form segment=value,segment=value - // comma-separated list of equal separated maps - segments := map[string]string{} - for segment := range strings.SplitSeq(top, ",") { - // TODO(dperny): validate topology syntax - k, v, _ := strings.Cut(segment, "=") - segments[k] = v - } - topology.Requisite = append( - topology.Requisite, - volume.Topology{Segments: segments}, - ) - } - - for _, top := range options.preferredTopology.GetSlice() { - // each topology takes the form segment=value,segment=value - // comma-separated list of equal separated maps - segments := map[string]string{} - for segment := range strings.SplitSeq(top, ",") { - // TODO(dperny): validate topology syntax - k, v, _ := strings.Cut(segment, "=") - segments[k] = v - } + _, _ = fmt.Fprintln(dockerCli.Out(), res.Volume.Name) + return nil +} - topology.Preferred = append( - topology.Preferred, - volume.Topology{Segments: segments}, - ) - } +func clusterVolumeSpec(options createOptions) *volume.ClusterVolumeSpec { + if !options.cluster { + return nil + } - volOpts.ClusterVolumeSpec.AccessibilityRequirements = topology + var secrets []volume.Secret + for key, secret := range options.secrets.GetAll() { + secrets = append(secrets, volume.Secret{Key: key, Secret: secret}) } + slices.SortFunc(secrets, func(a, b volume.Secret) int { + return cmp.Compare(a.Key, b.Key) + }) - res, err := dockerCli.Client().VolumeCreate(ctx, volOpts) - if err != nil { - return err + accessMode := &volume.AccessMode{ + Scope: volume.Scope(options.scope), + Sharing: volume.SharingMode(options.sharing), + } + switch options.accessType { + case "mount": + accessMode.MountVolume = &volume.TypeMount{} + case "block": + accessMode.BlockVolume = &volume.TypeBlock{} } - _, _ = fmt.Fprintln(dockerCli.Out(), res.Volume.Name) - return nil + return &volume.ClusterVolumeSpec{ + Group: options.group, + AccessMode: accessMode, + AccessibilityRequirements: &volume.TopologyRequirement{ + Requisite: parseTopologies(options.requisiteTopology.GetSlice()), + Preferred: parseTopologies(options.preferredTopology.GetSlice()), + }, + CapacityRange: &volume.CapacityRange{ + RequiredBytes: max(options.requiredBytes.Value(), 0), + LimitBytes: max(options.limitBytes.Value(), 0), + }, + Secrets: secrets, + Availability: volume.Availability(options.availability), + } +} + +func parseTopologies(values []string) []volume.Topology { + topologies := make([]volume.Topology, 0, len(values)) + for _, top := range values { + // TODO(dperny): validate topology syntax + topologies = append(topologies, volume.Topology{ + Segments: opts.ConvertKVStringsToMap(strings.Split(top, ",")), + }) + } + return topologies }