diff --git a/api/client/cli.go b/api/client/cli.go index 4f02d0bae2..951023eb5b 100644 --- a/api/client/cli.go +++ b/api/client/cli.go @@ -130,6 +130,12 @@ func (cli *DockerCli) ImagesFormat() string { return cli.configFile.ImagesFormat } +// NetworksFormat returns the format string specified in the configuration. +// String contains columns and format specification, for example {{ID}}\t{{Name}} +func (cli *DockerCli) NetworksFormat() string { + return cli.configFile.NetworksFormat +} + func (cli *DockerCli) setRawTerminal() error { if os.Getenv("NORAW") == "" { if cli.isTerminalIn { diff --git a/api/client/formatter/custom.go b/api/client/formatter/custom.go index 55dc3f8258..2aa2e7b554 100644 --- a/api/client/formatter/custom.go +++ b/api/client/formatter/custom.go @@ -14,6 +14,7 @@ const ( labelsHeader = "LABELS" nameHeader = "NAME" driverHeader = "DRIVER" + scopeHeader = "SCOPE" ) type subContext interface { diff --git a/api/client/formatter/network.go b/api/client/formatter/network.go new file mode 100644 index 0000000000..9bd3d3b180 --- /dev/null +++ b/api/client/formatter/network.go @@ -0,0 +1,129 @@ +package formatter + +import ( + "bytes" + "fmt" + "strings" + + "github.com/docker/docker/pkg/stringid" + "github.com/docker/engine-api/types" +) + +const ( + defaultNetworkTableFormat = "table {{.ID}}\t{{.Name}}\t{{.Driver}}\t{{.Scope}}" + + networkIDHeader = "NETWORK ID" + ipv6Header = "IPV6" + internalHeader = "INTERNAL" +) + +// NetworkContext contains network specific information required by the formatter, +// encapsulate a Context struct. +type NetworkContext struct { + Context + // Networks + Networks []types.NetworkResource +} + +func (ctx NetworkContext) Write() { + switch ctx.Format { + case tableFormatKey: + if ctx.Quiet { + ctx.Format = defaultQuietFormat + } else { + ctx.Format = defaultNetworkTableFormat + } + case rawFormatKey: + if ctx.Quiet { + ctx.Format = `network_id: {{.ID}}` + } else { + ctx.Format = `network_id: {{.ID}}\nname: {{.Name}}\ndriver: {{.Driver}}\nscope: {{.Scope}}\n` + } + } + + ctx.buffer = bytes.NewBufferString("") + ctx.preformat() + + tmpl, err := ctx.parseFormat() + if err != nil { + return + } + + for _, network := range ctx.Networks { + networkCtx := &networkContext{ + trunc: ctx.Trunc, + n: network, + } + err = ctx.contextFormat(tmpl, networkCtx) + if err != nil { + return + } + } + + ctx.postformat(tmpl, &networkContext{}) +} + +type networkContext struct { + baseSubContext + trunc bool + n types.NetworkResource +} + +func (c *networkContext) ID() string { + c.addHeader(networkIDHeader) + if c.trunc { + return stringid.TruncateID(c.n.ID) + } + return c.n.ID +} + +func (c *networkContext) Name() string { + c.addHeader(nameHeader) + return c.n.Name +} + +func (c *networkContext) Driver() string { + c.addHeader(driverHeader) + return c.n.Driver +} + +func (c *networkContext) Scope() string { + c.addHeader(scopeHeader) + return c.n.Scope +} + +func (c *networkContext) IPv6() string { + c.addHeader(ipv6Header) + return fmt.Sprintf("%v", c.n.EnableIPv6) +} + +func (c *networkContext) Internal() string { + c.addHeader(internalHeader) + return fmt.Sprintf("%v", c.n.Internal) +} + +func (c *networkContext) Labels() string { + c.addHeader(labelsHeader) + if c.n.Labels == nil { + return "" + } + + var joinLabels []string + for k, v := range c.n.Labels { + joinLabels = append(joinLabels, fmt.Sprintf("%s=%s", k, v)) + } + return strings.Join(joinLabels, ",") +} + +func (c *networkContext) Label(name string) string { + n := strings.Split(name, ".") + r := strings.NewReplacer("-", " ", "_", " ") + h := r.Replace(n[len(n)-1]) + + c.addHeader(h) + + if c.n.Labels == nil { + return "" + } + return c.n.Labels[name] +} diff --git a/api/client/formatter/network_test.go b/api/client/formatter/network_test.go new file mode 100644 index 0000000000..b760bfab1d --- /dev/null +++ b/api/client/formatter/network_test.go @@ -0,0 +1,201 @@ +package formatter + +import ( + "bytes" + "strings" + "testing" + + "github.com/docker/docker/pkg/stringid" + "github.com/docker/engine-api/types" +) + +func TestNetworkContext(t *testing.T) { + networkID := stringid.GenerateRandomID() + + var ctx networkContext + cases := []struct { + networkCtx networkContext + expValue string + expHeader string + call func() string + }{ + {networkContext{ + n: types.NetworkResource{ID: networkID}, + trunc: false, + }, networkID, networkIDHeader, ctx.ID}, + {networkContext{ + n: types.NetworkResource{ID: networkID}, + trunc: true, + }, stringid.TruncateID(networkID), networkIDHeader, ctx.ID}, + {networkContext{ + n: types.NetworkResource{Name: "network_name"}, + }, "network_name", nameHeader, ctx.Name}, + {networkContext{ + n: types.NetworkResource{Driver: "driver_name"}, + }, "driver_name", driverHeader, ctx.Driver}, + {networkContext{ + n: types.NetworkResource{EnableIPv6: true}, + }, "true", ipv6Header, ctx.IPv6}, + {networkContext{ + n: types.NetworkResource{EnableIPv6: false}, + }, "false", ipv6Header, ctx.IPv6}, + {networkContext{ + n: types.NetworkResource{Internal: true}, + }, "true", internalHeader, ctx.Internal}, + {networkContext{ + n: types.NetworkResource{Internal: false}, + }, "false", internalHeader, ctx.Internal}, + {networkContext{ + n: types.NetworkResource{}, + }, "", labelsHeader, ctx.Labels}, + {networkContext{ + n: types.NetworkResource{Labels: map[string]string{"label1": "value1", "label2": "value2"}}, + }, "label1=value1,label2=value2", labelsHeader, ctx.Labels}, + } + + for _, c := range cases { + ctx = c.networkCtx + v := c.call() + if strings.Contains(v, ",") { + compareMultipleValues(t, v, c.expValue) + } else if v != c.expValue { + t.Fatalf("Expected %s, was %s\n", c.expValue, v) + } + + h := ctx.fullHeader() + if h != c.expHeader { + t.Fatalf("Expected %s, was %s\n", c.expHeader, h) + } + } +} + +func TestNetworkContextWrite(t *testing.T) { + contexts := []struct { + context NetworkContext + expected string + }{ + + // Errors + { + NetworkContext{ + Context: Context{ + Format: "{{InvalidFunction}}", + }, + }, + `Template parsing error: template: :1: function "InvalidFunction" not defined +`, + }, + { + NetworkContext{ + Context: Context{ + Format: "{{nil}}", + }, + }, + `Template parsing error: template: :1:2: executing "" at : nil is not a command +`, + }, + // Table format + { + NetworkContext{ + Context: Context{ + Format: "table", + }, + }, + `NETWORK ID NAME DRIVER SCOPE +networkID1 foobar_baz foo local +networkID2 foobar_bar bar local +`, + }, + { + NetworkContext{ + Context: Context{ + Format: "table", + Quiet: true, + }, + }, + `networkID1 +networkID2 +`, + }, + { + NetworkContext{ + Context: Context{ + Format: "table {{.Name}}", + }, + }, + `NAME +foobar_baz +foobar_bar +`, + }, + { + NetworkContext{ + Context: Context{ + Format: "table {{.Name}}", + Quiet: true, + }, + }, + `NAME +foobar_baz +foobar_bar +`, + }, + // Raw Format + { + NetworkContext{ + Context: Context{ + Format: "raw", + }, + }, `network_id: networkID1 +name: foobar_baz +driver: foo +scope: local + +network_id: networkID2 +name: foobar_bar +driver: bar +scope: local + +`, + }, + { + NetworkContext{ + Context: Context{ + Format: "raw", + Quiet: true, + }, + }, + `network_id: networkID1 +network_id: networkID2 +`, + }, + // Custom Format + { + NetworkContext{ + Context: Context{ + Format: "{{.Name}}", + }, + }, + `foobar_baz +foobar_bar +`, + }, + } + + for _, context := range contexts { + networks := []types.NetworkResource{ + {ID: "networkID1", Name: "foobar_baz", Driver: "foo", Scope: "local"}, + {ID: "networkID2", Name: "foobar_bar", Driver: "bar", Scope: "local"}, + } + out := bytes.NewBufferString("") + context.context.Output = out + context.context.Networks = networks + context.context.Write() + actual := out.String() + if actual != context.expected { + t.Fatalf("Expected \n%s, got \n%s", context.expected, actual) + } + // Clean buffer + out.Reset() + } +} diff --git a/api/client/network/list.go b/api/client/network/list.go index c149b985c9..1bb5761511 100644 --- a/api/client/network/list.go +++ b/api/client/network/list.go @@ -1,15 +1,13 @@ package network import ( - "fmt" "sort" - "text/tabwriter" "golang.org/x/net/context" "github.com/docker/docker/api/client" + "github.com/docker/docker/api/client/formatter" "github.com/docker/docker/cli" - "github.com/docker/docker/pkg/stringid" "github.com/docker/engine-api/types" "github.com/docker/engine-api/types/filters" "github.com/spf13/cobra" @@ -24,6 +22,7 @@ func (r byNetworkName) Less(i, j int) bool { return r[i].Name < r[j].Name } type listOptions struct { quiet bool noTrunc bool + format string filter []string } @@ -43,6 +42,7 @@ func newListCommand(dockerCli *client.DockerCli) *cobra.Command { flags := cmd.Flags() flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display volume names") flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Do not truncate the output") + flags.StringVar(&opts.format, "format", "", "Pretty-print networks using a Go template") flags.StringSliceVarP(&opts.filter, "filter", "f", []string{}, "Provide filter values (i.e. 'dangling=true')") return cmd @@ -69,32 +69,28 @@ func runList(dockerCli *client.DockerCli, opts listOptions) error { return err } - w := tabwriter.NewWriter(dockerCli.Out(), 20, 1, 3, ' ', 0) - if !opts.quiet { - fmt.Fprintf(w, "NETWORK ID\tNAME\tDRIVER\tSCOPE") - fmt.Fprintf(w, "\n") + f := opts.format + if len(f) == 0 { + if len(dockerCli.NetworksFormat()) > 0 && !opts.quiet { + f = dockerCli.NetworksFormat() + } else { + f = "table" + } } sort.Sort(byNetworkName(networkResources)) - for _, networkResource := range networkResources { - ID := networkResource.ID - netName := networkResource.Name - driver := networkResource.Driver - scope := networkResource.Scope - if !opts.noTrunc { - ID = stringid.TruncateID(ID) - } - if opts.quiet { - fmt.Fprintln(w, ID) - continue - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t", - ID, - netName, - driver, - scope) - fmt.Fprint(w, "\n") + + networksCtx := formatter.NetworkContext{ + Context: formatter.Context{ + Output: dockerCli.Out(), + Format: f, + Quiet: opts.quiet, + Trunc: !opts.noTrunc, + }, + Networks: networkResources, } - w.Flush() + + networksCtx.Write() + return nil } diff --git a/cliconfig/configfile/file.go b/cliconfig/configfile/file.go index 7c94e27dce..201784c7b7 100644 --- a/cliconfig/configfile/file.go +++ b/cliconfig/configfile/file.go @@ -26,6 +26,7 @@ type ConfigFile struct { HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"` PsFormat string `json:"psFormat,omitempty"` ImagesFormat string `json:"imagesFormat,omitempty"` + NetworksFormat string `json:"networksFormat,omitempty"` DetachKeys string `json:"detachKeys,omitempty"` CredentialsStore string `json:"credsStore,omitempty"` Filename string `json:"-"` // Note: for internal use only diff --git a/docs/reference/commandline/network_ls.md b/docs/reference/commandline/network_ls.md index 49614bdaf4..e8ba6d97fb 100644 --- a/docs/reference/commandline/network_ls.md +++ b/docs/reference/commandline/network_ls.md @@ -20,6 +20,7 @@ Aliases: Options: -f, --filter value Provide filter values (i.e. 'dangling=true') (default []) + --format string Pretty-print networks using a Go template --help Print usage --no-trunc Do not truncate the output -q, --quiet Only display volume names @@ -169,6 +170,38 @@ $ docker network rm `docker network ls --filter type=custom -q` A warning will be issued when trying to remove a network that has containers attached. +## Formatting + +The formatting options (`--format`) pretty-prints networks output +using a Go template. + +Valid placeholders for the Go template are listed below: + +Placeholder | Description +------------|------------------------------------------------------------------------------------------ +`.ID` | Network ID +`.Name` | Network name +`.Driver` | Network driver +`.Scope` | Network scope (local, global) +`.IPv6` | Whether IPv6 is enabled on the network or not. +`.Internal` | Whether the network is internal or not. +`.Labels` | All labels assigned to the network. +`.Label` | Value of a specific label for this network. For example `{{.Label "project.version"}}` + +When using the `--format` option, the `network ls` command will either +output the data exactly as the template declares or, when using the +`table` directive, includes column headers as well. + +The following example uses a template without headers and outputs the +`ID` and `Driver` entries separated by a colon for all networks: + +```bash +$ docker network ls --format "{{.ID}}: {{.Driver}}" +afaaab448eb2: bridge +d1584f8dc718: host +391df270dc66: null +``` + ## Related information * [network disconnect ](network_disconnect.md) diff --git a/integration-cli/docker_cli_network_unix_test.go b/integration-cli/docker_cli_network_unix_test.go index 0611b2c089..faf798782f 100644 --- a/integration-cli/docker_cli_network_unix_test.go +++ b/integration-cli/docker_cli_network_unix_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "time" @@ -279,6 +280,43 @@ func (s *DockerNetworkSuite) TestDockerNetworkLsDefault(c *check.C) { } } +func (s *DockerSuite) TestNetworkLsFormat(c *check.C) { + testRequires(c, DaemonIsLinux) + out, _ := dockerCmd(c, "network", "ls", "--format", "{{.Name}}") + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + + expected := []string{"bridge", "host", "none"} + var names []string + for _, l := range lines { + names = append(names, l) + } + c.Assert(expected, checker.DeepEquals, names, check.Commentf("Expected array with truncated names: %v, got: %v", expected, names)) +} + +func (s *DockerSuite) TestNetworkLsFormatDefaultFormat(c *check.C) { + testRequires(c, DaemonIsLinux) + + config := `{ + "networksFormat": "{{ .Name }} default" +}` + d, err := ioutil.TempDir("", "integration-cli-") + c.Assert(err, checker.IsNil) + defer os.RemoveAll(d) + + err = ioutil.WriteFile(filepath.Join(d, "config.json"), []byte(config), 0644) + c.Assert(err, checker.IsNil) + + out, _ := dockerCmd(c, "--config", d, "network", "ls") + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + + expected := []string{"bridge default", "host default", "none default"} + var names []string + for _, l := range lines { + names = append(names, l) + } + c.Assert(expected, checker.DeepEquals, names, check.Commentf("Expected array with truncated names: %v, got: %v", expected, names)) +} + func (s *DockerNetworkSuite) TestDockerNetworkCreatePredefined(c *check.C) { predefined := []string{"bridge", "host", "none", "default"} for _, net := range predefined { diff --git a/man/docker-network-ls.1.md b/man/docker-network-ls.1.md index 3eeff05993..d5399d7a59 100644 --- a/man/docker-network-ls.1.md +++ b/man/docker-network-ls.1.md @@ -7,6 +7,7 @@ docker-network-ls - list networks # SYNOPSIS **docker network ls** [**-f**|**--filter**[=*[]*]] +[**--format**=*"TEMPLATE"*] [**--no-trunc**[=*true*|*false*]] [**-q**|**--quiet**[=*true*|*false*]] [**--help**] @@ -162,6 +163,18 @@ attached. **-f**, **--filter**=*[]* filter output based on conditions provided. +**--format**="*TEMPLATE*" + Pretty-print networks using a Go template. + Valid placeholders: + .ID - Network ID + .Name - Network name + .Driver - Network driver + .Scope - Network scope (local, global) + .IPv6 - Whether IPv6 is enabled on the network or not + .Internal - Whether the network is internal or not + .Labels - All labels assigned to the network + .Label - Value of a specific label for this network. For example `{{.Label "project.version"}}` + **--no-trunc**=*true*|*false* Do not truncate the output