mirror of https://github.com/docker/cli.git
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:
parent
cb8723543e
commit
31fb756bb6
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue