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>
This commit is contained in:
Yong Tang 2017-01-26 13:08:07 -08:00
parent cb8723543e
commit 31fb756bb6
5 changed files with 327 additions and 69 deletions

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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) info = GetServicesStatus(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)
} }
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. // GetServicesStatus returns a map of mode and replicas
// Besides this, command `docker stack services xxx` will call this, too. func GetServicesStatus(services []swarm.Service, nodes []swarm.Node, tasks []swarm.Task) map[string]formatter.ServiceListInfo {
func PrintNotQuiet(out io.Writer, services []swarm.Service, nodes []swarm.Node, tasks []swarm.Task) { 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) info := map[string]formatter.ServiceListInfo{}
}
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")
for _, service := range services { for _, service := range services {
mode := "" info[service.ID] = formatter.ServiceListInfo{}
replicas := ""
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" info[service.ID] = formatter.ServiceListInfo{
replicas = fmt.Sprintf("%d/%d", running[service.ID], *service.Spec.Mode.Replicated.Replicas) 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" info[service.ID] = formatter.ServiceListInfo{
replicas = fmt.Sprintf("%d/%d", running[service.ID], tasksNoShutdown[service.ID]) 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()
} }
} }
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
} }

View File

@ -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 { info := map[string]formatter.ServiceListInfo{}
service.PrintQuiet(out, services) if !opts.quiet {
} else {
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)
} }
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)
} }

View File

@ -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