Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions cli/command/formatter/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
package formatter

import (
"cmp"
"fmt"
"net"
"slices"
"sort"
"strconv"
"strings"
Expand All @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -121,6 +134,7 @@ func NewContainerContext() *ContainerContext {
"LocalVolumes": localVolumes,
"Networks": networksHeader,
"Platform": platformHeader,
"IPAddresses": ipAddressesHeader,
}
return &containerCtx
}
Expand Down Expand Up @@ -335,6 +349,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'
Expand Down
62 changes: 56 additions & 6 deletions cli/command/formatter/container_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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},
}

Expand Down Expand Up @@ -438,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"}},
},
{
Expand All @@ -457,6 +469,7 @@ func TestContainerContextWriteJSON(t *testing.T) {
"Command": `""`,
"CreatedAt": expectedCreated,
"ID": "containerID1",
"IPAddresses": []any{},
"Image": "ubuntu",
"Labels": "",
"LocalVolumes": "0",
Expand All @@ -471,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",
Expand All @@ -491,6 +510,7 @@ func TestContainerContextWriteJSON(t *testing.T) {
"Command": `""`,
"CreatedAt": expectedCreated,
"ID": "containerID3",
"IPAddresses": []any{},
"Image": "ubuntu",
"Labels": "",
"LocalVolumes": "0",
Expand Down Expand Up @@ -538,6 +558,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

Expand Down
44 changes: 28 additions & 16 deletions docs/reference/commandline/container_ls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \[email protected]\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
```
42 changes: 41 additions & 1 deletion templates/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ package templates
import (
"bytes"
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"text/template"
)
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}
Loading
Loading