diff --git a/command/formatter/service.go b/command/formatter/service.go index 8242e1cb9e..9d9241b224 100644 --- a/command/formatter/service.go +++ b/command/formatter/service.go @@ -5,9 +5,11 @@ import ( "strings" "time" + distreference "github.com/docker/distribution/reference" mounttypes "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/cli/command/inspect" + "github.com/docker/docker/pkg/stringid" units "github.com/docker/go-units" ) @@ -327,3 +329,93 @@ func (ctx *serviceInspectContext) EndpointMode() string { func (ctx *serviceInspectContext) Ports() []swarm.PortConfig { 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 +} diff --git a/command/formatter/service_test.go b/command/formatter/service_test.go new file mode 100644 index 0000000000..d4474297db --- /dev/null +++ b/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 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) + } +} diff --git a/command/service/list.go b/command/service/list.go index 724126079c..ca3e741fab 100644 --- a/command/service/list.go +++ b/command/service/list.go @@ -2,27 +2,21 @@ package service import ( "fmt" - "io" - "text/tabwriter" - distreference "github.com/docker/distribution/reference" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/cli" "github.com/docker/docker/cli/command" + "github.com/docker/docker/cli/command/formatter" "github.com/docker/docker/opts" - "github.com/docker/docker/pkg/stringid" "github.com/spf13/cobra" "golang.org/x/net/context" ) -const ( - listItemFmt = "%s\t%s\t%s\t%s\t%s\n" -) - type listOptions struct { quiet bool + format string filter opts.FilterOpt } @@ -41,6 +35,7 @@ func newListCommand(dockerCli *command.DockerCli) *cobra.Command { flags := cmd.Flags() 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") return cmd @@ -49,13 +44,13 @@ func newListCommand(dockerCli *command.DockerCli) *cobra.Command { func runList(dockerCli *command.DockerCli, opts listOptions) error { ctx := context.Background() client := dockerCli.Client() - out := dockerCli.Out() services, err := client.ServiceList(ctx, types.ServiceListOptions{Filters: opts.filter.Value()}) if err != nil { return err } + info := map[string]formatter.ServiceListInfo{} if len(services) > 0 && !opts.quiet { // only non-empty services and not quiet, should we call TaskList and NodeList api taskFilter := filters.NewArgs() @@ -73,20 +68,30 @@ func runList(dockerCli *command.DockerCli, opts listOptions) error { 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) } - return nil + 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 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{}) for _, n := range nodes { 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 { if task.DesiredState != swarm.TaskStateShutdown { 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 { - mode := "" - replicas := "" + info[service.ID] = formatter.ServiceListInfo{} 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 { - 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 } diff --git a/command/stack/services.go b/command/stack/services.go index a46652df7c..78ddd399ce 100644 --- a/command/stack/services.go +++ b/command/stack/services.go @@ -9,6 +9,7 @@ import ( "github.com/docker/docker/api/types/filters" "github.com/docker/docker/cli" "github.com/docker/docker/cli/command" + "github.com/docker/docker/cli/command/formatter" "github.com/docker/docker/cli/command/service" "github.com/docker/docker/opts" "github.com/spf13/cobra" @@ -16,6 +17,7 @@ import ( type servicesOptions struct { quiet bool + format string filter opts.FilterOpt namespace string } @@ -34,6 +36,7 @@ func newServicesCommand(dockerCli *command.DockerCli) *cobra.Command { } flags := cmd.Flags() 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") return cmd @@ -57,9 +60,8 @@ func runServices(dockerCli *command.DockerCli, opts servicesOptions) error { return nil } - if opts.quiet { - service.PrintQuiet(out, services) - } else { + info := map[string]formatter.ServiceListInfo{} + if !opts.quiet { taskFilter := filters.NewArgs() for _, service := range services { taskFilter.Add("service", service.ID) @@ -69,11 +71,27 @@ func runServices(dockerCli *command.DockerCli, opts servicesOptions) error { if err != nil { return err } + nodes, err := client.NodeList(ctx, types.NodeListOptions{}) if err != nil { return err } - service.PrintNotQuiet(out, services, nodes, tasks) + + info = service.GetServicesStatus(services, nodes, tasks) } - return nil + + 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 formatter.ServiceListWrite(servicesCtx, services, info) } diff --git a/config/configfile/file.go b/config/configfile/file.go index e8fe96e847..c321b97f2e 100644 --- a/config/configfile/file.go +++ b/config/configfile/file.go @@ -35,6 +35,7 @@ type ConfigFile struct { CredentialHelpers map[string]string `json:"credHelpers,omitempty"` Filename string `json:"-"` // Note: for internal use only ServiceInspectFormat string `json:"serviceInspectFormat,omitempty"` + ServicesFormat string `json:"servicesFormat,omitempty"` } // LegacyLoadFromReader reads the non-nested configuration data given and sets up the