diff --git a/cli/command/formatter/container.go b/cli/command/formatter/container.go index 60dfb91b42..760b925536 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" @@ -21,13 +23,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. @@ -39,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 { @@ -121,6 +134,7 @@ func NewContainerContext() *ContainerContext { "LocalVolumes": localVolumes, "Networks": networksHeader, "Platform": platformHeader, + "IPAddresses": ipAddressesHeader, } return &containerCtx } @@ -341,6 +355,36 @@ 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() []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, NetworkIP{ + Network: name, + IP: nw.IPAddress, + }) + } + if 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 +} + // 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 594ced3b47..6bfa384890 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" @@ -389,7 +390,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}, } @@ -475,7 +476,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"}}, }, { @@ -494,6 +506,7 @@ func TestContainerContextWriteJSON(t *testing.T) { "Command": `""`, "CreatedAt": expectedCreated, "ID": "containerID1", + "IPAddresses": []any{}, "Image": "ubuntu", "Labels": "", "LocalVolumes": "0", @@ -508,15 +521,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", @@ -528,6 +547,7 @@ func TestContainerContextWriteJSON(t *testing.T) { "Command": `""`, "CreatedAt": expectedCreated, "ID": "containerID3", + "IPAddresses": []any{}, "Image": "ubuntu", "Labels": "", "LocalVolumes": "0", @@ -575,6 +595,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..4632d0a01c 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 +``` 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) + }) + } +}