瀏覽代碼

Add `--format` to `docker service ls`
This fix tries to improve the display of `docker service ls`
and adds `--format` flag to `docker service ls`.

In addition to `--format` flag, several other improvement:
1. Updates `docker stacks service`.
2. Adds `servicesFormat` to config file.

Related docs has been updated.

Signed-off-by: Yong Tang <yong.tang.github@outlook.com>

Yong Tang 8 年之前
父節點
當前提交
000f0403d9

+ 92 - 0
cli/command/formatter/service.go

@@ -5,9 +5,11 @@ import (
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	distreference "github.com/docker/distribution/reference"
 	mounttypes "github.com/docker/docker/api/types/mount"
 	mounttypes "github.com/docker/docker/api/types/mount"
 	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/cli/command/inspect"
 	"github.com/docker/docker/cli/command/inspect"
+	"github.com/docker/docker/pkg/stringid"
 	units "github.com/docker/go-units"
 	units "github.com/docker/go-units"
 )
 )
 
 
@@ -327,3 +329,93 @@ func (ctx *serviceInspectContext) EndpointMode() string {
 func (ctx *serviceInspectContext) Ports() []swarm.PortConfig {
 func (ctx *serviceInspectContext) Ports() []swarm.PortConfig {
 	return ctx.Service.Endpoint.Ports
 	return ctx.Service.Endpoint.Ports
 }
 }
+
+const (
+	defaultServiceTableFormat = "table {{.ID}}\t{{.Name}}\t{{.Mode}}\t{{.Replicas}}\t{{.Image}}"
+
+	serviceIDHeader = "ID"
+	modeHeader      = "MODE"
+	replicasHeader  = "REPLICAS"
+)
+
+// NewServiceListFormat returns a Format for rendering using a service Context
+func NewServiceListFormat(source string, quiet bool) Format {
+	switch source {
+	case TableFormatKey:
+		if quiet {
+			return defaultQuietFormat
+		}
+		return defaultServiceTableFormat
+	case RawFormatKey:
+		if quiet {
+			return `id: {{.ID}}`
+		}
+		return `id: {{.ID}}\nname: {{.Name}}\nmode: {{.Mode}}\nreplicas: {{.Replicas}}\nimage: {{.Image}}\n`
+	}
+	return Format(source)
+}
+
+// ServiceListInfo stores the information about mode and replicas to be used by template
+type ServiceListInfo struct {
+	Mode     string
+	Replicas string
+}
+
+// ServiceListWrite writes the context
+func ServiceListWrite(ctx Context, services []swarm.Service, info map[string]ServiceListInfo) error {
+	render := func(format func(subContext subContext) error) error {
+		for _, service := range services {
+			serviceCtx := &serviceContext{service: service, mode: info[service.ID].Mode, replicas: info[service.ID].Replicas}
+			if err := format(serviceCtx); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+	return ctx.Write(&serviceContext{}, render)
+}
+
+type serviceContext struct {
+	HeaderContext
+	service  swarm.Service
+	mode     string
+	replicas string
+}
+
+func (c *serviceContext) MarshalJSON() ([]byte, error) {
+	return marshalJSON(c)
+}
+
+func (c *serviceContext) ID() string {
+	c.AddHeader(serviceIDHeader)
+	return stringid.TruncateID(c.service.ID)
+}
+
+func (c *serviceContext) Name() string {
+	c.AddHeader(nameHeader)
+	return c.service.Spec.Name
+}
+
+func (c *serviceContext) Mode() string {
+	c.AddHeader(modeHeader)
+	return c.mode
+}
+
+func (c *serviceContext) Replicas() string {
+	c.AddHeader(replicasHeader)
+	return c.replicas
+}
+
+func (c *serviceContext) Image() string {
+	c.AddHeader(imageHeader)
+	image := c.service.Spec.TaskTemplate.ContainerSpec.Image
+	if ref, err := distreference.ParseNamed(image); err == nil {
+		// update image string for display
+		namedTagged, ok := ref.(distreference.NamedTagged)
+		if ok {
+			image = namedTagged.Name() + ":" + namedTagged.Tag()
+		}
+	}
+
+	return image
+}

+ 177 - 0
cli/command/formatter/service_test.go

@@ -0,0 +1,177 @@
+package formatter
+
+import (
+	"bytes"
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestServiceContextWrite(t *testing.T) {
+	cases := []struct {
+		context  Context
+		expected string
+	}{
+		// Errors
+		{
+			Context{Format: "{{InvalidFunction}}"},
+			`Template parsing error: template: :1: function "InvalidFunction" not defined
+`,
+		},
+		{
+			Context{Format: "{{nil}}"},
+			`Template parsing error: template: :1:2: executing "" at <nil>: nil is not a command
+`,
+		},
+		// Table format
+		{
+			Context{Format: NewServiceListFormat("table", false)},
+			`ID                  NAME                MODE                REPLICAS            IMAGE
+id_baz              baz                 global              2/4                 
+id_bar              bar                 replicated          2/4                 
+`,
+		},
+		{
+			Context{Format: NewServiceListFormat("table", true)},
+			`id_baz
+id_bar
+`,
+		},
+		{
+			Context{Format: NewServiceListFormat("table {{.Name}}", false)},
+			`NAME
+baz
+bar
+`,
+		},
+		{
+			Context{Format: NewServiceListFormat("table {{.Name}}", true)},
+			`NAME
+baz
+bar
+`,
+		},
+		// Raw Format
+		{
+			Context{Format: NewServiceListFormat("raw", false)},
+			`id: id_baz
+name: baz
+mode: global
+replicas: 2/4
+image: 
+
+id: id_bar
+name: bar
+mode: replicated
+replicas: 2/4
+image: 
+
+`,
+		},
+		{
+			Context{Format: NewServiceListFormat("raw", true)},
+			`id: id_baz
+id: id_bar
+`,
+		},
+		// Custom Format
+		{
+			Context{Format: NewServiceListFormat("{{.Name}}", false)},
+			`baz
+bar
+`,
+		},
+	}
+
+	for _, testcase := range cases {
+		services := []swarm.Service{
+			{ID: "id_baz", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "baz"}}},
+			{ID: "id_bar", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "bar"}}},
+		}
+		info := map[string]ServiceListInfo{
+			"id_baz": {
+				Mode:     "global",
+				Replicas: "2/4",
+			},
+			"id_bar": {
+				Mode:     "replicated",
+				Replicas: "2/4",
+			},
+		}
+		out := bytes.NewBufferString("")
+		testcase.context.Output = out
+		err := ServiceListWrite(testcase.context, services, info)
+		if err != nil {
+			assert.Error(t, err, testcase.expected)
+		} else {
+			assert.Equal(t, out.String(), testcase.expected)
+		}
+	}
+}
+
+func TestServiceContextWriteJSON(t *testing.T) {
+	services := []swarm.Service{
+		{ID: "id_baz", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "baz"}}},
+		{ID: "id_bar", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "bar"}}},
+	}
+	info := map[string]ServiceListInfo{
+		"id_baz": {
+			Mode:     "global",
+			Replicas: "2/4",
+		},
+		"id_bar": {
+			Mode:     "replicated",
+			Replicas: "2/4",
+		},
+	}
+	expectedJSONs := []map[string]interface{}{
+		{"ID": "id_baz", "Name": "baz", "Mode": "global", "Replicas": "2/4", "Image": ""},
+		{"ID": "id_bar", "Name": "bar", "Mode": "replicated", "Replicas": "2/4", "Image": ""},
+	}
+
+	out := bytes.NewBufferString("")
+	err := ServiceListWrite(Context{Format: "{{json .}}", Output: out}, services, info)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
+		t.Logf("Output: line %d: %s", i, line)
+		var m map[string]interface{}
+		if err := json.Unmarshal([]byte(line), &m); err != nil {
+			t.Fatal(err)
+		}
+		assert.DeepEqual(t, m, expectedJSONs[i])
+	}
+}
+func TestServiceContextWriteJSONField(t *testing.T) {
+	services := []swarm.Service{
+		{ID: "id_baz", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "baz"}}},
+		{ID: "id_bar", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "bar"}}},
+	}
+	info := map[string]ServiceListInfo{
+		"id_baz": {
+			Mode:     "global",
+			Replicas: "2/4",
+		},
+		"id_bar": {
+			Mode:     "replicated",
+			Replicas: "2/4",
+		},
+	}
+	out := bytes.NewBufferString("")
+	err := ServiceListWrite(Context{Format: "{{json .Name}}", Output: out}, services, info)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
+		t.Logf("Output: line %d: %s", i, line)
+		var s string
+		if err := json.Unmarshal([]byte(line), &s); err != nil {
+			t.Fatal(err)
+		}
+		assert.Equal(t, s, services[i].Spec.Name)
+	}
+}

+ 34 - 64
cli/command/service/list.go

@@ -2,27 +2,21 @@ package service
 
 
 import (
 import (
 	"fmt"
 	"fmt"
-	"io"
-	"text/tabwriter"
 
 
-	distreference "github.com/docker/distribution/reference"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/filters"
 	"github.com/docker/docker/api/types/filters"
 	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/cli"
 	"github.com/docker/docker/cli"
 	"github.com/docker/docker/cli/command"
 	"github.com/docker/docker/cli/command"
+	"github.com/docker/docker/cli/command/formatter"
 	"github.com/docker/docker/opts"
 	"github.com/docker/docker/opts"
-	"github.com/docker/docker/pkg/stringid"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 	"golang.org/x/net/context"
 	"golang.org/x/net/context"
 )
 )
 
 
-const (
-	listItemFmt = "%s\t%s\t%s\t%s\t%s\n"
-)
-
 type listOptions struct {
 type listOptions struct {
 	quiet  bool
 	quiet  bool
+	format string
 	filter opts.FilterOpt
 	filter opts.FilterOpt
 }
 }
 
 
@@ -41,6 +35,7 @@ func newListCommand(dockerCli *command.DockerCli) *cobra.Command {
 
 
 	flags := cmd.Flags()
 	flags := cmd.Flags()
 	flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display IDs")
 	flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display IDs")
+	flags.StringVar(&opts.format, "format", "", "Pretty-print services using a Go template")
 	flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided")
 	flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided")
 
 
 	return cmd
 	return cmd
@@ -49,13 +44,13 @@ func newListCommand(dockerCli *command.DockerCli) *cobra.Command {
 func runList(dockerCli *command.DockerCli, opts listOptions) error {
 func runList(dockerCli *command.DockerCli, opts listOptions) error {
 	ctx := context.Background()
 	ctx := context.Background()
 	client := dockerCli.Client()
 	client := dockerCli.Client()
-	out := dockerCli.Out()
 
 
 	services, err := client.ServiceList(ctx, types.ServiceListOptions{Filters: opts.filter.Value()})
 	services, err := client.ServiceList(ctx, types.ServiceListOptions{Filters: opts.filter.Value()})
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
+	info := map[string]formatter.ServiceListInfo{}
 	if len(services) > 0 && !opts.quiet {
 	if len(services) > 0 && !opts.quiet {
 		// only non-empty services and not quiet, should we call TaskList and NodeList api
 		// only non-empty services and not quiet, should we call TaskList and NodeList api
 		taskFilter := filters.NewArgs()
 		taskFilter := filters.NewArgs()
@@ -73,20 +68,30 @@ func runList(dockerCli *command.DockerCli, opts listOptions) error {
 			return err
 			return err
 		}
 		}
 
 
-		PrintNotQuiet(out, services, nodes, tasks)
-	} else if !opts.quiet {
-		// no services and not quiet, print only one line with columns ID, NAME, MODE, REPLICAS...
-		PrintNotQuiet(out, services, []swarm.Node{}, []swarm.Task{})
-	} else {
-		PrintQuiet(out, services)
+		info = GetServicesStatus(services, nodes, tasks)
+	}
+
+	format := opts.format
+	if len(format) == 0 {
+		if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !opts.quiet {
+			format = dockerCli.ConfigFile().ServicesFormat
+		} else {
+			format = formatter.TableFormatKey
+		}
 	}
 	}
 
 
-	return nil
+	servicesCtx := formatter.Context{
+		Output: dockerCli.Out(),
+		Format: formatter.NewServiceListFormat(format, opts.quiet),
+	}
+	return formatter.ServiceListWrite(servicesCtx, services, info)
 }
 }
 
 
-// PrintNotQuiet shows service list in a non-quiet way.
-// Besides this, command `docker stack services xxx` will call this, too.
-func PrintNotQuiet(out io.Writer, services []swarm.Service, nodes []swarm.Node, tasks []swarm.Task) {
+// GetServicesStatus returns a map of mode and replicas
+func GetServicesStatus(services []swarm.Service, nodes []swarm.Node, tasks []swarm.Task) map[string]formatter.ServiceListInfo {
+	running := map[string]int{}
+	tasksNoShutdown := map[string]int{}
+
 	activeNodes := make(map[string]struct{})
 	activeNodes := make(map[string]struct{})
 	for _, n := range nodes {
 	for _, n := range nodes {
 		if n.Status.State != swarm.NodeStateDown {
 		if n.Status.State != swarm.NodeStateDown {
@@ -94,9 +99,6 @@ func PrintNotQuiet(out io.Writer, services []swarm.Service, nodes []swarm.Node,
 		}
 		}
 	}
 	}
 
 
-	running := map[string]int{}
-	tasksNoShutdown := map[string]int{}
-
 	for _, task := range tasks {
 	for _, task := range tasks {
 		if task.DesiredState != swarm.TaskStateShutdown {
 		if task.DesiredState != swarm.TaskStateShutdown {
 			tasksNoShutdown[task.ServiceID]++
 			tasksNoShutdown[task.ServiceID]++
@@ -107,52 +109,20 @@ func PrintNotQuiet(out io.Writer, services []swarm.Service, nodes []swarm.Node,
 		}
 		}
 	}
 	}
 
 
-	printTable(out, services, running, tasksNoShutdown)
-}
-
-func printTable(out io.Writer, services []swarm.Service, running, tasksNoShutdown map[string]int) {
-	writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
-
-	// Ignore flushing errors
-	defer writer.Flush()
-
-	fmt.Fprintf(writer, listItemFmt, "ID", "NAME", "MODE", "REPLICAS", "IMAGE")
-
+	info := map[string]formatter.ServiceListInfo{}
 	for _, service := range services {
 	for _, service := range services {
-		mode := ""
-		replicas := ""
+		info[service.ID] = formatter.ServiceListInfo{}
 		if service.Spec.Mode.Replicated != nil && service.Spec.Mode.Replicated.Replicas != nil {
 		if service.Spec.Mode.Replicated != nil && service.Spec.Mode.Replicated.Replicas != nil {
-			mode = "replicated"
-			replicas = fmt.Sprintf("%d/%d", running[service.ID], *service.Spec.Mode.Replicated.Replicas)
+			info[service.ID] = formatter.ServiceListInfo{
+				Mode:     "replicated",
+				Replicas: fmt.Sprintf("%d/%d", running[service.ID], *service.Spec.Mode.Replicated.Replicas),
+			}
 		} else if service.Spec.Mode.Global != nil {
 		} else if service.Spec.Mode.Global != nil {
-			mode = "global"
-			replicas = fmt.Sprintf("%d/%d", running[service.ID], tasksNoShutdown[service.ID])
-		}
-		image := service.Spec.TaskTemplate.ContainerSpec.Image
-		ref, err := distreference.ParseNamed(image)
-		if err == nil {
-			// update image string for display
-			namedTagged, ok := ref.(distreference.NamedTagged)
-			if ok {
-				image = namedTagged.Name() + ":" + namedTagged.Tag()
+			info[service.ID] = formatter.ServiceListInfo{
+				Mode:     "global",
+				Replicas: fmt.Sprintf("%d/%d", running[service.ID], tasksNoShutdown[service.ID]),
 			}
 			}
 		}
 		}
-
-		fmt.Fprintf(
-			writer,
-			listItemFmt,
-			stringid.TruncateID(service.ID),
-			service.Spec.Name,
-			mode,
-			replicas,
-			image)
-	}
-}
-
-// PrintQuiet shows service list in a quiet way.
-// Besides this, command `docker stack services xxx` will call this, too.
-func PrintQuiet(out io.Writer, services []swarm.Service) {
-	for _, service := range services {
-		fmt.Fprintln(out, service.ID)
 	}
 	}
+	return info
 }
 }

+ 23 - 5
cli/command/stack/services.go

@@ -9,6 +9,7 @@ import (
 	"github.com/docker/docker/api/types/filters"
 	"github.com/docker/docker/api/types/filters"
 	"github.com/docker/docker/cli"
 	"github.com/docker/docker/cli"
 	"github.com/docker/docker/cli/command"
 	"github.com/docker/docker/cli/command"
+	"github.com/docker/docker/cli/command/formatter"
 	"github.com/docker/docker/cli/command/service"
 	"github.com/docker/docker/cli/command/service"
 	"github.com/docker/docker/opts"
 	"github.com/docker/docker/opts"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
@@ -16,6 +17,7 @@ import (
 
 
 type servicesOptions struct {
 type servicesOptions struct {
 	quiet     bool
 	quiet     bool
+	format    string
 	filter    opts.FilterOpt
 	filter    opts.FilterOpt
 	namespace string
 	namespace string
 }
 }
@@ -34,6 +36,7 @@ func newServicesCommand(dockerCli *command.DockerCli) *cobra.Command {
 	}
 	}
 	flags := cmd.Flags()
 	flags := cmd.Flags()
 	flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display IDs")
 	flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display IDs")
+	flags.StringVar(&opts.format, "format", "", "Pretty-print services using a Go template")
 	flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided")
 	flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided")
 
 
 	return cmd
 	return cmd
@@ -57,9 +60,8 @@ func runServices(dockerCli *command.DockerCli, opts servicesOptions) error {
 		return nil
 		return nil
 	}
 	}
 
 
-	if opts.quiet {
-		service.PrintQuiet(out, services)
-	} else {
+	info := map[string]formatter.ServiceListInfo{}
+	if !opts.quiet {
 		taskFilter := filters.NewArgs()
 		taskFilter := filters.NewArgs()
 		for _, service := range services {
 		for _, service := range services {
 			taskFilter.Add("service", service.ID)
 			taskFilter.Add("service", service.ID)
@@ -69,11 +71,27 @@ func runServices(dockerCli *command.DockerCli, opts servicesOptions) error {
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
+
 		nodes, err := client.NodeList(ctx, types.NodeListOptions{})
 		nodes, err := client.NodeList(ctx, types.NodeListOptions{})
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
-		service.PrintNotQuiet(out, services, nodes, tasks)
+
+		info = service.GetServicesStatus(services, nodes, tasks)
+	}
+
+	format := opts.format
+	if len(format) == 0 {
+		if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !opts.quiet {
+			format = dockerCli.ConfigFile().ServicesFormat
+		} else {
+			format = formatter.TableFormatKey
+		}
+	}
+
+	servicesCtx := formatter.Context{
+		Output: dockerCli.Out(),
+		Format: formatter.NewServiceListFormat(format, opts.quiet),
 	}
 	}
-	return nil
+	return formatter.ServiceListWrite(servicesCtx, services, info)
 }
 }

+ 1 - 0
cli/config/configfile/file.go

@@ -35,6 +35,7 @@ type ConfigFile struct {
 	CredentialHelpers    map[string]string           `json:"credHelpers,omitempty"`
 	CredentialHelpers    map[string]string           `json:"credHelpers,omitempty"`
 	Filename             string                      `json:"-"` // Note: for internal use only
 	Filename             string                      `json:"-"` // Note: for internal use only
 	ServiceInspectFormat string                      `json:"serviceInspectFormat,omitempty"`
 	ServiceInspectFormat string                      `json:"serviceInspectFormat,omitempty"`
+	ServicesFormat       string                      `json:"servicesFormat,omitempty"`
 }
 }
 
 
 // LegacyLoadFromReader reads the non-nested configuration data given and sets up the
 // LegacyLoadFromReader reads the non-nested configuration data given and sets up the

+ 8 - 0
docs/reference/commandline/cli.md

@@ -137,6 +137,13 @@ Docker's client uses this property. If this property is not set, the client
 falls back to the default table format. For a list of supported formatting
 falls back to the default table format. For a list of supported formatting
 directives, see the [**Formatting** section in the `docker plugin ls` documentation](plugin_ls.md)
 directives, see the [**Formatting** section in the `docker plugin ls` documentation](plugin_ls.md)
 
 
+The property `servicesFormat` specifies the default format for `docker
+service ls` output. When the `--format` flag is not provided with the
+`docker service ls` command, Docker's client uses this property. If this
+property is not set, the client falls back to the default json format. For a
+list of supported formatting directives, see the
+[**Formatting** section in the `docker service ls` documentation](service_ls.md)
+
 The property `serviceInspectFormat` specifies the default format for `docker
 The property `serviceInspectFormat` specifies the default format for `docker
 service inspect` output. When the `--format` flag is not provided with the
 service inspect` output. When the `--format` flag is not provided with the
 `docker service inspect` command, Docker's client uses this property. If this
 `docker service inspect` command, Docker's client uses this property. If this
@@ -194,6 +201,7 @@ Following is a sample `config.json` file:
       "imagesFormat": "table {{.ID}}\\t{{.Repository}}\\t{{.Tag}}\\t{{.CreatedAt}}",
       "imagesFormat": "table {{.ID}}\\t{{.Repository}}\\t{{.Tag}}\\t{{.CreatedAt}}",
       "pluginsFormat": "table {{.ID}}\t{{.Name}}\t{{.Enabled}}",
       "pluginsFormat": "table {{.ID}}\t{{.Name}}\t{{.Enabled}}",
       "statsFormat": "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}",
       "statsFormat": "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}",
+      "servicesFormat": "table {{.ID}}\t{{.Name}}\t{{.Mode}}",
       "serviceInspectFormat": "pretty",
       "serviceInspectFormat": "pretty",
       "detachKeys": "ctrl-e,e",
       "detachKeys": "ctrl-e,e",
       "credsStore": "secretservice",
       "credsStore": "secretservice",

+ 32 - 3
docs/reference/commandline/service_ls.md

@@ -24,9 +24,10 @@ Aliases:
   ls, list
   ls, list
 
 
 Options:
 Options:
-  -f, --filter value   Filter output based on conditions provided
-      --help           Print usage
-  -q, --quiet          Only display IDs
+  -f, --filter filter   Filter output based on conditions provided
+      --format string   Pretty-print services using a Go template
+      --help            Print usage
+  -q, --quiet           Only display IDs
 ```
 ```
 
 
 This command when run targeting a manager, lists services are running in the
 This command when run targeting a manager, lists services are running in the
@@ -103,6 +104,34 @@ ID            NAME   MODE        REPLICAS  IMAGE
 0bcjwfh8ychr  redis  replicated  1/1       redis:3.0.6
 0bcjwfh8ychr  redis  replicated  1/1       redis:3.0.6
 ```
 ```
 
 
+## Formatting
+
+The formatting options (`--format`) pretty-prints services output
+using a Go template.
+
+Valid placeholders for the Go template are listed below:
+
+Placeholder | Description
+------------|------------------------------------------------------------------------------------------
+`.ID`       | Service ID
+`.Name`     | Service name
+`.Mode`     | Service mode (replicated, global)
+`.Replicas` | Service replicas
+`.Image`    | Service image
+
+When using the `--format` option, the `service 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`, `Mode`, and `Replicas` entries separated by a colon for all services:
+
+```bash
+$ docker service ls --format "{{.ID}}: {{.Mode}} {{.Replicas}}"
+0zmvwuiu3vue: replicated 10/10
+fm6uf97exkul: global 5/5
+```
+
 ## Related information
 ## Related information
 
 
 * [service create](service_create.md)
 * [service create](service_create.md)

+ 33 - 3
docs/reference/commandline/stack_services.md

@@ -22,9 +22,10 @@ Usage:	docker stack services [OPTIONS] STACK
 List the services in the stack
 List the services in the stack
 
 
 Options:
 Options:
-  -f, --filter value   Filter output based on conditions provided
-      --help           Print usage
-  -q, --quiet          Only display IDs
+  -f, --filter filter   Filter output based on conditions provided
+      --format string   Pretty-print services using a Go template
+      --help            Print usage
+  -q, --quiet           Only display IDs
 ```
 ```
 
 
 Lists the services that are running as part of the specified stack. This
 Lists the services that are running as part of the specified stack. This
@@ -62,6 +63,35 @@ The currently supported filters are:
 * name (`--filter name=myapp_web`)
 * name (`--filter name=myapp_web`)
 * label (`--filter label=key=value`)
 * label (`--filter label=key=value`)
 
 
+## Formatting
+
+The formatting options (`--format`) pretty-prints services output
+using a Go template.
+
+Valid placeholders for the Go template are listed below:
+
+Placeholder | Description
+------------|------------------------------------------------------------------------------------------
+`.ID`       | Service ID
+`.Name`     | Service name
+`.Mode`     | Service mode (replicated, global)
+`.Replicas` | Service replicas
+`.Image`    | Service image
+
+When using the `--format` option, the `stack services` 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`, `Mode`, and `Replicas` entries separated by a colon for all services:
+
+```bash
+$ docker stack services --format "{{.ID}}: {{.Mode}} {{.Replicas}}"
+0zmvwuiu3vue: replicated 10/10
+fm6uf97exkul: global 5/5
+```
+
+
 ## Related information
 ## Related information
 
 
 * [stack deploy](stack_deploy.md)
 * [stack deploy](stack_deploy.md)