mirror of https://github.com/docker/cli.git
Merge 0e8f85c5bf into 27b316fc0d
This commit is contained in:
commit
1354d93bff
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <join . ", ">: 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue