From 9bbefd6b56aebbd1c5198d1dcda6bc714800b3d8 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Sun, 24 Aug 2025 18:43:33 +0200 Subject: [PATCH 1/3] templates: make "join" work with non-string slices and map values Add a custom join function that allows for non-string slices to be joined, following the same rules as "fmt.Sprint", it will use the fmt.Stringer interface if implemented, or "error" if the type has an "Error()". For maps, it joins the map-values, for example: docker image inspect --format '{{join .Config.Labels ", "}}' ubuntu 24.04, ubuntu Signed-off-by: Sebastiaan van Stijn --- templates/templates.go | 42 +++++++++++++++++- templates/templates_test.go | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/templates/templates.go b/templates/templates.go index 4af4496d19..12a91f38bf 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -6,6 +6,9 @@ package templates import ( "bytes" "encoding/json" + "fmt" + "reflect" + "sort" "strings" "text/template" ) @@ -26,7 +29,7 @@ var basicFunctions = template.FuncMap{ return strings.TrimSpace(buf.String()) }, "split": strings.Split, - "join": strings.Join, + "join": joinElements, "title": strings.Title, //nolint:nolintlint,staticcheck // strings.Title is deprecated, but we only use it for ASCII, so replacing with golang.org/x/text is out of scope "lower": strings.ToLower, "upper": strings.ToUpper, @@ -103,3 +106,40 @@ func truncateWithLength(source string, length int) string { } return source[:length] } + +// joinElements joins a slice of items with the given separator. It uses +// [strings.Join] if it's a slice of strings, otherwise uses [fmt.Sprint] +// to join each item to the output. +func joinElements(elems any, sep string) (string, error) { + if elems == nil { + return "", nil + } + + if ss, ok := elems.([]string); ok { + return strings.Join(ss, sep), nil + } + + switch rv := reflect.ValueOf(elems); rv.Kind() { + case reflect.Array, reflect.Slice: + var b strings.Builder + for i := range rv.Len() { + if i > 0 { + b.WriteString(sep) + } + _, _ = fmt.Fprint(&b, rv.Index(i).Interface()) + } + return b.String(), nil + + case reflect.Map: + var out []string + 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) + return strings.Join(out, sep), nil + + default: + return "", fmt.Errorf("expected slice, got %T", elems) + } +} diff --git a/templates/templates_test.go b/templates/templates_test.go index e9dbaefd0e..2cca5d116b 100644 --- a/templates/templates_test.go +++ b/templates/templates_test.go @@ -3,6 +3,7 @@ package templates import ( "bytes" "testing" + "text/template" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" @@ -139,3 +140,87 @@ func TestHeaderFunctions(t *testing.T) { }) } } + +type stringerString string + +func (s stringerString) String() string { + return "stringer" + string(s) +} + +type stringerAndError string + +func (s stringerAndError) String() string { + return "stringer" + string(s) +} + +func (s stringerAndError) Error() string { + return "error" + string(s) +} + +func TestJoinElements(t *testing.T) { + tests := []struct { + doc string + data any + expOut string + expErr string + }{ + { + doc: "nil", + data: nil, + expOut: `output: ""`, + }, + { + doc: "non-slice", + data: "hello", + expOut: `output: "`, + expErr: `template: my-template:1:13: executing "my-template" at : error calling join: expected slice, got string`, + }, + { + doc: "structs", + data: []struct{ A, B string }{{"1", "2"}, {"3", "4"}}, + expOut: `output: "{1 2}, {3 4}"`, + }, + { + doc: "map with strings", + data: map[string]string{"A": "1", "B": "2", "C": "3"}, + expOut: `output: "1, 2, 3"`, + }, + { + doc: "map with stringers", + data: map[string]stringerString{"A": "1", "B": "2", "C": "3"}, + expOut: `output: "stringer1, stringer2, stringer3"`, + }, + { + doc: "map with errors", + data: []stringerAndError{"1", "2", "3"}, + expOut: `output: "error1, error2, error3"`, + }, + { + doc: "stringers", + data: []stringerString{"1", "2", "3"}, + expOut: `output: "stringer1, stringer2, stringer3"`, + }, + { + doc: "stringer with errors", + data: []stringerAndError{"1", "2", "3"}, + expOut: `output: "error1, error2, error3"`, + }, + } + + const formatStr = `output: "{{- join . ", " -}}"` + tmpl, err := New("my-template").Funcs(template.FuncMap{"join": joinElements}).Parse(formatStr) + assert.NilError(t, err) + + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + var b bytes.Buffer + err := tmpl.Execute(&b, tc.data) + if tc.expErr != "" { + assert.ErrorContains(t, err, tc.expErr) + } else { + assert.NilError(t, err) + } + assert.Equal(t, b.String(), tc.expOut) + }) + } +} From 9c9fcec6d454accdef3dddbd5fa9e3e18394590c Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 27 Dec 2019 11:22:10 +0100 Subject: [PATCH 2/3] Add .IPAddresses as formatting option on docker ps This allows showing the IP address for each network that the container is attached to, for example: docker network create foo docker run -d --name foo nginx:alpine docker network connect foo foo container container ls --format 'table {{.ID}}\\t{{join .IPAddresses ", "}}' CONTAINER ID IP ADDRESSES 17e7d1910fc0 bridge:172.17.0.2, foo:172.19.0.2 container container ls --format='{{json .IPAddresses}}' | jq . [ "bridge:172.17.0.2", "foo:172.19.0.2" ] Signed-off-by: Sebastiaan van Stijn --- cli/command/formatter/container.go | 35 +++++++++++++---- cli/command/formatter/container_test.go | 33 +++++++++++++++- docs/reference/commandline/container_ls.md | 44 ++++++++++++++-------- 3 files changed, 88 insertions(+), 24 deletions(-) diff --git a/cli/command/formatter/container.go b/cli/command/formatter/container.go index 471ecf3888..3c0a943c81 100644 --- a/cli/command/formatter/container.go +++ b/cli/command/formatter/container.go @@ -21,13 +21,14 @@ import ( const ( defaultContainerTableFormat = "table {{.ID}}\t{{.Image}}\t{{.Command}}\t{{.RunningFor}}\t{{.Status}}\t{{.Ports}}\t{{.Names}}" - namesHeader = "NAMES" - commandHeader = "COMMAND" - runningForHeader = "CREATED" - mountsHeader = "MOUNTS" - localVolumes = "LOCAL VOLUMES" - networksHeader = "NETWORKS" - platformHeader = "PLATFORM" + namesHeader = "NAMES" + commandHeader = "COMMAND" + runningForHeader = "CREATED" + mountsHeader = "MOUNTS" + localVolumes = "LOCAL VOLUMES" + networksHeader = "NETWORKS" + platformHeader = "PLATFORM" + ipAddressesHeader = "IP ADDRESSES" ) // Platform wraps a [ocispec.Platform] to implement the stringer interface. @@ -121,6 +122,7 @@ func NewContainerContext() *ContainerContext { "LocalVolumes": localVolumes, "Networks": networksHeader, "Platform": platformHeader, + "IPAddresses": ipAddressesHeader, } return &containerCtx } @@ -335,6 +337,25 @@ func (c *ContainerContext) Networks() string { return strings.Join(networks, ",") } +// IPAddresses returns the list of IP-addresses assigned to the container +// IP-addresses are prefixed with the name of the network, separated with a colon. +// For example: "bridge:192.168.1.10" +func (c *ContainerContext) IPAddresses() []string { + ipAddresses := []string{} + if c.c.NetworkSettings == nil { + return ipAddresses + } + for name, nw := range c.c.NetworkSettings.Networks { + if nw.IPAddress != "" { + ipAddresses = append(ipAddresses, name+":"+nw.IPAddress) + } + if nw.GlobalIPv6Address != "" { + ipAddresses = append(ipAddresses, name+":"+nw.GlobalIPv6Address) + } + } + return ipAddresses +} + // DisplayablePorts returns formatted string representing open ports of container // e.g. "0.0.0.0:80->9090/tcp, 9988/tcp" // it's used by command 'docker ps' diff --git a/cli/command/formatter/container_test.go b/cli/command/formatter/container_test.go index 96626d29f0..b9dedad16f 100644 --- a/cli/command/formatter/container_test.go +++ b/cli/command/formatter/container_test.go @@ -13,6 +13,7 @@ import ( "github.com/docker/cli/internal/test" "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" @@ -352,7 +353,7 @@ size: 0B } containers := []container.Summary{ - {ID: "containerID1", Names: []string{"/foobar_baz"}, Image: "ubuntu", Created: unixTime, State: container.StateRunning}, + {ID: "containerID1", Names: []string{"/foobar_baz"}, Image: "ubuntu", Created: unixTime, State: container.StateRunning, NetworkSettings: &container.NetworkSettingsSummary{}}, {ID: "containerID2", Names: []string{"/foobar_bar"}, Image: "ubuntu", Created: unixTime, State: container.StateRunning}, } @@ -538,6 +539,36 @@ func TestContainerContextWriteJSONField(t *testing.T) { } } +func TestContainerContextIPAddresses(t *testing.T) { + containers := []container.Summary{ + { + ID: "containerID1", + NetworkSettings: &container.NetworkSettingsSummary{ + Networks: map[string]*network.EndpointSettings{ + "one": {IPAddress: "192.168.1.2"}, + "two": {IPAddress: "192.168.178.2"}, + }, + }, + }, + { + ID: "containerID2", + NetworkSettings: &container.NetworkSettingsSummary{ + Networks: map[string]*network.EndpointSettings{ + "one": {IPAddress: "192.168.1.3"}, + "two": {IPAddress: "192.168.178.3"}, + }, + }, + }, + } + + out := bytes.NewBufferString("") + err := ContainerWrite(Context{Format: "{{.IPAddresses}}", Output: out}, containers) + assert.NilError(t, err) + assert.Equal(t, out.String(), `[one:192.168.1.2 two:192.168.178.2] +[one:192.168.1.3 two:192.168.178.3] +`) +} + func TestContainerBackCompat(t *testing.T) { createdAtTime := time.Now().AddDate(-1, 0, 0) // 1 year ago diff --git a/docs/reference/commandline/container_ls.md b/docs/reference/commandline/container_ls.md index a19f7a5e04..8bb74177fd 100644 --- a/docs/reference/commandline/container_ls.md +++ b/docs/reference/commandline/container_ls.md @@ -395,22 +395,24 @@ template. Valid placeholders for the Go template are listed below: -| Placeholder | Description | -|:--------------|:------------------------------------------------------------------------------------------------| -| `.ID` | Container ID | -| `.Image` | Image ID | -| `.Command` | Quoted command | -| `.CreatedAt` | Time when the container was created. | -| `.RunningFor` | Elapsed time since the container was started. | -| `.Ports` | Exposed ports. | -| `.State` | Container status (for example; "created", "running", "exited"). | -| `.Status` | Container status with details about duration and health-status. | -| `.Size` | Container disk size. | -| `.Names` | Container names. | -| `.Labels` | All labels assigned to the container. | -| `.Label` | Value of a specific label for this container. For example `'{{.Label "com.docker.swarm.cpu"}}'` | -| `.Mounts` | Names of the volumes mounted in this container. | -| `.Networks` | Names of the networks attached to this container. | +| Placeholder | Description | +|:---------------|:------------------------------------------------------------------------------------------------| +| `.ID` | Container ID | +| `.Image` | Image ID | +| `.Command` | Quoted command | +| `.CreatedAt` | Time when the container was created. | +| `.RunningFor` | Elapsed time since the container was started. | +| `.Ports` | Exposed ports. | +| `.State` | Container status (for example; "created", "running", "exited"). | +| `.Status` | Container status with details about duration and health-status. | +| `.Size` | Container disk size. | +| `.Names` | Container names. | +| `.Labels` | All labels assigned to the container. | +| `.Label` | Value of a specific label for this container. For example `'{{.Label "com.docker.swarm.cpu"}}'` | +| `.Mounts` | Names of the volumes mounted in this container. | +| `.Networks` | Names of the networks attached to this container. | +| `.IPAddresses` | List of IP-Addresses for each network that the container is attached to. | + When using the `--format` option, the `ps` command will either output the data exactly as the template declares or, when using the `table` directive, includes @@ -446,3 +448,13 @@ To list all running containers in JSON format, use the `json` directive: $ docker ps --format json {"Command":"\"/docker-entrypoint.…\"","CreatedAt":"2021-03-10 00:15:05 +0100 CET","ID":"a762a2b37a1d","Image":"nginx","Labels":"maintainer=NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e","LocalVolumes":"0","Mounts":"","Names":"boring_keldysh","Networks":"bridge","Ports":"80/tcp","RunningFor":"4 seconds ago","Size":"0B","State":"running","Status":"Up 3 seconds"} ``` + +Show the IP-addresses that containers have: + +```console +$ docker ps --format "table {{.ID}}\\t{{join .IPAddresses \", \"}}" + +CONTAINER ID IP ADDRESSES +c0cf2877da71 bridge:172.17.0.3 +17e7d1910fc0 bridge:172.17.0.2, mynetwork:172.19.0.2 +``` From 0e8f85c5bf97a4b2370684938eb0f48d575105f5 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Tue, 22 Sep 2020 16:03:21 +0200 Subject: [PATCH 3/3] WIP use structured type Still failing when joining; docker container ls --format 'table {{.ID}}\t{{join .IPAddresses ", "}}' CONTAINER ID IP ADDRESSES 245bd1d81375 bridge/172.17.0.2, foo/172.19.0.2 docker container ls --format '{{json .IPAddresses}}' [{"Network":"bridge","IP":"172.17.0.2"},{"Network":"foo","IP":"172.19.0.2"}] Signed-off-by: Sebastiaan van Stijn --- cli/command/formatter/container.go | 35 ++++++++++++++++++---- cli/command/formatter/container_test.go | 33 +++++++++++++++----- docs/reference/commandline/container_ls.md | 4 +-- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/cli/command/formatter/container.go b/cli/command/formatter/container.go index 3c0a943c81..87ae32b61f 100644 --- a/cli/command/formatter/container.go +++ b/cli/command/formatter/container.go @@ -4,8 +4,10 @@ package formatter import ( + "cmp" "fmt" "net" + "slices" "sort" "strconv" "strings" @@ -40,6 +42,16 @@ func (p Platform) String() string { return platforms.FormatAll(p.Platform) } +// NetworkIP describes an IP-address and the network it's associated with. +type NetworkIP struct { + Network string `json:"Network,omitempty"` + IP string `json:"IP"` +} + +func (p NetworkIP) String() string { + return p.Network + "/" + p.IP +} + // NewContainerFormat returns a Format for rendering using a Context func NewContainerFormat(source string, quiet bool, size bool) Format { switch source { @@ -340,19 +352,30 @@ func (c *ContainerContext) Networks() string { // IPAddresses returns the list of IP-addresses assigned to the container // IP-addresses are prefixed with the name of the network, separated with a colon. // For example: "bridge:192.168.1.10" -func (c *ContainerContext) IPAddresses() []string { - ipAddresses := []string{} - if c.c.NetworkSettings == nil { - return ipAddresses +func (c *ContainerContext) IPAddresses() []NetworkIP { + if c.c.NetworkSettings == nil || len(c.c.NetworkSettings.Networks) == 0 { + return []NetworkIP{} } + ipAddresses := make([]NetworkIP, 0, len(c.c.NetworkSettings.Networks)) for name, nw := range c.c.NetworkSettings.Networks { if nw.IPAddress != "" { - ipAddresses = append(ipAddresses, name+":"+nw.IPAddress) + ipAddresses = append(ipAddresses, NetworkIP{ + Network: name, + IP: nw.IPAddress, + }) } if nw.GlobalIPv6Address != "" { - ipAddresses = append(ipAddresses, name+":"+nw.GlobalIPv6Address) + ipAddresses = append(ipAddresses, NetworkIP{ + Network: name, + IP: nw.GlobalIPv6Address, + }) } } + + slices.SortFunc(ipAddresses, func(a, b NetworkIP) int { + return cmp.Compare(a.String(), b.String()) + }) + return ipAddresses } diff --git a/cli/command/formatter/container_test.go b/cli/command/formatter/container_test.go index b9dedad16f..b68c18a026 100644 --- a/cli/command/formatter/container_test.go +++ b/cli/command/formatter/container_test.go @@ -439,7 +439,18 @@ func TestContainerContextWriteJSON(t *testing.T) { Image: "ubuntu", Created: unix, State: container.StateRunning, - + NetworkSettings: &container.NetworkSettingsSummary{ + Networks: map[string]*network.EndpointSettings{ + "bridge": { + IPAddress: "172.17.0.1", + GlobalIPv6Address: "ff02::1", + }, + "my-net": { + IPAddress: "172.18.0.1", + GlobalIPv6Address: "ff02::2", + }, + }, + }, ImageManifestDescriptor: &ocispec.Descriptor{Platform: &ocispec.Platform{Architecture: "amd64", OS: "linux"}}, }, { @@ -458,6 +469,7 @@ func TestContainerContextWriteJSON(t *testing.T) { "Command": `""`, "CreatedAt": expectedCreated, "ID": "containerID1", + "IPAddresses": []any{}, "Image": "ubuntu", "Labels": "", "LocalVolumes": "0", @@ -472,15 +484,21 @@ func TestContainerContextWriteJSON(t *testing.T) { "Status": "", }, { - "Command": `""`, - "CreatedAt": expectedCreated, - "ID": "containerID2", + "Command": `""`, + "CreatedAt": expectedCreated, + "ID": "containerID2", + "IPAddresses": []any{ + map[string]any{"IP": "172.17.0.1", "Network": "bridge"}, + map[string]any{"IP": "ff02::1", "Network": "bridge"}, + map[string]any{"IP": "172.18.0.1", "Network": "my-net"}, + map[string]any{"IP": "ff02::2", "Network": "my-net"}, + }, "Image": "ubuntu", "Labels": "", "LocalVolumes": "0", "Mounts": "", "Names": "foobar_bar", - "Networks": "", + "Networks": "bridge,my-net", "Platform": map[string]any{"architecture": "amd64", "os": "linux"}, "Ports": "", "RunningFor": "About a minute ago", @@ -492,6 +510,7 @@ func TestContainerContextWriteJSON(t *testing.T) { "Command": `""`, "CreatedAt": expectedCreated, "ID": "containerID3", + "IPAddresses": []any{}, "Image": "ubuntu", "Labels": "", "LocalVolumes": "0", @@ -564,8 +583,8 @@ func TestContainerContextIPAddresses(t *testing.T) { out := bytes.NewBufferString("") err := ContainerWrite(Context{Format: "{{.IPAddresses}}", Output: out}, containers) assert.NilError(t, err) - assert.Equal(t, out.String(), `[one:192.168.1.2 two:192.168.178.2] -[one:192.168.1.3 two:192.168.178.3] + assert.Equal(t, out.String(), `[one/192.168.1.2 two/192.168.178.2] +[one/192.168.1.3 two/192.168.178.3] `) } diff --git a/docs/reference/commandline/container_ls.md b/docs/reference/commandline/container_ls.md index 8bb74177fd..4632d0a01c 100644 --- a/docs/reference/commandline/container_ls.md +++ b/docs/reference/commandline/container_ls.md @@ -455,6 +455,6 @@ Show the IP-addresses that containers have: $ docker ps --format "table {{.ID}}\\t{{join .IPAddresses \", \"}}" CONTAINER ID IP ADDRESSES -c0cf2877da71 bridge:172.17.0.3 -17e7d1910fc0 bridge:172.17.0.2, mynetwork:172.19.0.2 +c0cf2877da71 bridge/172.17.0.3 +17e7d1910fc0 bridge/172.17.0.2, mynetwork/172.19.0.2 ```