diff --git a/cli/cli.go b/cli/cli.go index c5bfeadd62..f998086ebd 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "text/tabwriter" + "text/template" "time" "github.com/alibaba/pouch/client" @@ -159,6 +160,18 @@ func (c *Cli) Print(obj interface{}) { display.Flush() } +// FormatDisplay use to format output with options +func (c *Cli) FormatDisplay(format string, tmpl *template.Template, container interface{}, w *tabwriter.Writer) error { + tmpl, err := tmpl.Parse(format) + if err != nil { + return err + } + if err := tmpl.Execute(w, container); err != nil { + return err + } + return nil +} + // ExitError defines exit error produce by cli commands. type ExitError struct { Code int diff --git a/cli/formatter/container.go b/cli/formatter/container.go new file mode 100644 index 0000000000..60c916014d --- /dev/null +++ b/cli/formatter/container.go @@ -0,0 +1,49 @@ +package formatter + +import ( + "github.com/alibaba/pouch/apis/types" + "github.com/alibaba/pouch/pkg/utils" +) + +// ContainerHeader is the map to show container head +var ContainerHeader = map[string]string{ + "Name": "Name", + "ID": "ID", + "Status": "Status", + "Created": "Created", + "Image": "Image", + "Runtime": "Runtime", + "Command": "Command", + "ImageID": "ImageID", + "Labels": "Labels", + "Mounts": "Mounts", + "State": "State", +} + +// ContainerContext is the map to show container context detail +type ContainerContext map[string]string + +// NewContainerContext is to generate a ContainerContext to be show +func NewContainerContext(c *types.Container, flagNoTrunc bool) ContainerContext { + id := c.ID[:6] + if flagNoTrunc { + id = c.ID + } + created, _ := utils.FormatTimeInterval(c.Created) + labels := LabelsToString(c.Labels) + mount := MountPointToString(c.Mounts) + containerContext := ContainerContext{ + "Name": c.Names[0], + "ID": id, + "Status": c.Status, + "Created": created + " ago", + "Image": c.Image, + "Runtime": c.HostConfig.Runtime, + "Command": c.Command, + "ImageID": c.ImageID, + "Labels": labels, + "Mounts": mount, + "State": c.State, + } + return containerContext +} diff --git a/cli/formatter/container_test.go b/cli/formatter/container_test.go new file mode 100644 index 0000000000..e3463a77d7 --- /dev/null +++ b/cli/formatter/container_test.go @@ -0,0 +1,88 @@ +package formatter + +import ( + "testing" + + "github.com/alibaba/pouch/apis/types" + + "github.com/stretchr/testify/assert" +) + +func TestNewContainerContext(t *testing.T) { + type TestCase struct { + container *types.Container + flagNoTrunc bool + expected ContainerContext + } + + testCases := []TestCase{ + { + container: &types.Container{ + Command: "bash", + Created: 4, + HostConfig: &types.HostConfig{Runtime: "runc"}, + ID: "abcdelj8937", + Image: "Image123", + ImageID: "234567890", + Labels: map[string]string{"a": "b", "c": "d"}, + Names: []string{"nameA", "nameB"}, + State: "StateA", + Status: "StatusB", + Mounts: []types.MountPoint{ + {Source: "/root/code"}, + {Source: "/test"}, + }, + }, + flagNoTrunc: false, + expected: ContainerContext{ + "Name": "nameA", + "ID": "abcdel", + "Status": "StatusB", + "Created": "49 years" + " ago", + "Image": "Image123", + "Runtime": "runc", + "Command": "bash", + "ImageID": "234567890", + "Labels": "a = b;c = d;", + "State": "StateA", + "Mounts": "/root/code;/test;", + }, + }, + { + container: &types.Container{ + Command: "shell", + Created: 5, + HostConfig: &types.HostConfig{Runtime: "runv"}, + ID: "1234567890", + Image: "Image456", + ImageID: "abcdefg", + Labels: map[string]string{}, + Names: []string{"nameB", "nameA"}, + State: "StateB", + Status: "StatusA", + Mounts: []types.MountPoint{ + {Source: "/root/code"}, + {Source: "/test"}, + }, + }, + flagNoTrunc: true, + expected: ContainerContext{ + "Name": "nameB", + "ID": "1234567890", + "Status": "StatusA", + "Created": "49 years" + " ago", + "Image": "Image456", + "Runtime": "runv", + "Command": "shell", + "ImageID": "abcdefg", + "Labels": "", + "State": "StateB", + "Mounts": "/root/code;/test;", + }, + }, + } + for _, testCase := range testCases { + result := NewContainerContext(testCase.container, testCase.flagNoTrunc) + assert.Equal(t, testCase.expected, result) + } +} diff --git a/cli/formatter/formatter.go b/cli/formatter/formatter.go new file mode 100644 index 0000000000..58fdd476aa --- /dev/null +++ b/cli/formatter/formatter.go @@ -0,0 +1,62 @@ +package formatter + +import ( + "fmt" + "sort" + "strings" + + "github.com/alibaba/pouch/apis/types" +) + +// Format key to output +const ( + TableFormat = "table" + RawFormat = "raw" +) + +// IsTable to verify if table or raw +func IsTable(format string) bool { + return strings.HasPrefix(format, TableFormat) +} + +// PreFormat is to format the format option +func PreFormat(format string) string { + if IsTable(format) { + format = format[len(TableFormat):] + // cut the space + format = strings.Trim(format, " ") + // input by cmd of "\t" is "\\t" + replace := strings.NewReplacer(`\t`, "\t", `\n`, "\n") + format = replace.Replace(format) + + if format[len(format)-1:] != "\n" { + format += "\n" + } + } else { + format = "Name:{{.Name}}\nID:{{.ID}}\nStatus:{{.Status}}\nCreated:{{.Created}}\nImage:{{.Image}}\nRuntime:{{.Runtime}}\n\n" + } + return format +} + +// LabelsToString is to transform the labels from map to string +func LabelsToString(labels map[string]string) string { + var labelstring string + sortedkeys := make([]string, 0) + for key := range labels { + sortedkeys = append(sortedkeys, key) + } + sort.Strings(sortedkeys) + for _, key := range sortedkeys { + labelstring += fmt.Sprintf("%s = %s;", key, labels[key]) + } + return labelstring +} + +// MountPointToString is to transform the MountPoint from array to string +func MountPointToString(mount []types.MountPoint) string { + var mountstring string + for _, value := range mount { + mountstring += fmt.Sprintf("%s;", value.Source) + } + return mountstring +} diff --git a/cli/formatter/formatter_test.go b/cli/formatter/formatter_test.go new file mode 100644 index 0000000000..488c7891c2 --- /dev/null +++ b/cli/formatter/formatter_test.go @@ -0,0 +1,67 @@ +package formatter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPreFormat(t *testing.T) { + type TestCase struct { + input string + output string + } + + testCases := []TestCase{ + { + input: "table {{.ID}}\t{{.Status}}\t{{.Created}}\t{{.Name}}\t{{.Image}}\t{{.State}}", + output: "{{.ID}}\t{{.Status}}\t{{.Created}}\t{{.Name}}\t{{.Image}}\t{{.State}}\n", + }, + { + input: "table {{.ID}}\\t{{.Status}}\\t{{.Created}}\\t{{.Name}}\\t{{.Image}}\\t{{.State}}", + output: "{{.ID}}\t{{.Status}}\t{{.Created}}\t{{.Name}}\t{{.Image}}\t{{.State}}\n", + }, + { + input: "table {{.ID}}\t{{.Status}}\t{{.Created}}\\t{{.Name}}\t{{.Image}}\t{{.State}}\n", + output: "{{.ID}}\t{{.Status}}\t{{.Created}}\t{{.Name}}\t{{.Image}}\t{{.State}}\n", + }, + { + input: "raw", + output: "Name:{{.Name}}\nID:{{.ID}}\nStatus:{{.Status}}\nCreated:{{.Created}}\nImage:{{.Image}}\nRuntime:{{.Runtime}}\n\n", + }, + } + for _, testCase := range testCases { + result := PreFormat(testCase.input) + assert.Equal(t, testCase.output, result) + } +} + +func TestLabelToString(t *testing.T) { + type TestCase struct { + input map[string]string + output string + } + testCases := []TestCase{ + { + input: map[string]string{ + "a": "b", + "c": "d", + }, + output: "a = b;c = d;", + }, + { + input: map[string]string{ + "a": "b", + }, + output: "a = b;", + }, + { + input: map[string]string{}, + output: "", + }, + } + for _, testCase := range testCases { + result := LabelsToString(testCase.input) + assert.Equal(t, testCase.output, result) + } +} diff --git a/cli/ps.go b/cli/ps.go index ec7d15fd98..4856e30999 100644 --- a/cli/ps.go +++ b/cli/ps.go @@ -3,11 +3,14 @@ package main import ( "context" "fmt" + "os" "sort" + "text/tabwriter" + "text/template" "time" "github.com/alibaba/pouch/apis/types" - "github.com/alibaba/pouch/pkg/utils" + "github.com/alibaba/pouch/cli/formatter" "github.com/alibaba/pouch/pkg/utils/filters" "github.com/spf13/cobra" @@ -15,6 +18,7 @@ import ( // psDescription is used to describe ps command in detail and auto generate command doc. var psDescription = "\nList Containers with container name, ID, status, creation time, image reference and runtime." +var psDefaultFormat = "table {{.Name}}\t{{.ID}}\t{{.Status}}\t{{.Created}}\t{{.Image}}\t{{.Runtime}}\n" // containerList is used to save the container list. type containerList []*types.Container @@ -28,6 +32,7 @@ type PsCommand struct { flagQuiet bool flagNoTrunc bool flagFilter []string + flagFormat string } // Init initializes PsCommand command. @@ -52,6 +57,7 @@ func (p *PsCommand) addFlags() { flagSet.BoolVarP(&p.flagAll, "all", "a", false, "Show all containers (default shows just running)") flagSet.BoolVarP(&p.flagQuiet, "quiet", "q", false, "Only show numeric IDs") flagSet.BoolVar(&p.flagNoTrunc, "no-trunc", false, "Do not truncate output") + flagSet.StringVarP(&p.flagFormat, "format", "", "", "intelligent-print containers based on Go template") flagSet.StringSliceVarP(&p.flagFilter, "filter", "f", nil, "Filter output based on given conditions, support filter key [ id label name status ]") } @@ -88,24 +94,26 @@ func (p *PsCommand) runPs(args []string) error { } return nil } - - display := p.cli.NewTableDisplay() - display.AddRow([]string{"Name", "ID", "Status", "Created", "Image", "Runtime"}) - + // add to format the output with go template + format := p.flagFormat + tmplH := template.New("ps_head") + w := tabwriter.NewWriter(os.Stdout, 0, 0, p.cli.padding, ' ', 0) + if len(format) == 0 { + format = psDefaultFormat + } + // true is table,false is raw + tableOrRaw := formatter.IsTable(format) + format = formatter.PreFormat(format) + if tableOrRaw { + containerHeader := formatter.ContainerHeader + p.cli.FormatDisplay(format, tmplH, containerHeader, w) + } for _, c := range containers { - created, err := utils.FormatTimeInterval(c.Created) - if err != nil { - return err - } - - id := c.ID[:6] - if p.flagNoTrunc { - id = c.ID - } - - display.AddRow([]string{c.Names[0], id, c.Status, created + " ago", c.Image, c.HostConfig.Runtime}) + containerContext := formatter.NewContainerContext(c, p.flagNoTrunc) + tmplD := template.New("ps_detail") + p.cli.FormatDisplay(format, tmplD, containerContext, w) } - display.Flush() + w.Flush() return nil } @@ -145,6 +153,14 @@ Name ID Status foo3 63fd6371f3d614bb1ecad2780972d5975ca1ab534ec280c5f7d8f4c7b2e9989d created 2 minutes ago docker.io/library/redis:alpine runc foo2 692c77587b38f60bbd91d986ec3703848d72aea5030e320d4988eb02aa3f9d48 Up 2 minutes 2 minutes ago docker.io/library/redis:alpine runc foo 18592900006405ee64788bd108ef1de3d24dc3add73725891f4787d0f8e036f5 Up 2 minutes 2 minutes ago docker.io/library/redis:alpine runc + +$ pouch ps -a --format "table {{.ID}}\t{{.Status}}\t{{.Created}}\t{{.Name}}\t{{.Mounts}}" +ID Status Created Name Mounts +b774d3 Exited (0) 8 minutes 8 minutes ago b774d3 /root/test; +bbccb1 Exited (0) 9 minutes 9 minutes ago bbccb1 /root/test; +82c47c Up 3 hours 3 hours ago 82c47c +791e4d Up 3 hours 3 hours ago 791e4d + ` } diff --git a/test/cli_ps_test.go b/test/cli_ps_test.go index cd7cd3eb48..c97adc0830 100644 --- a/test/cli_ps_test.go +++ b/test/cli_ps_test.go @@ -256,6 +256,48 @@ func (suite *PouchPsSuite) TestPsNoTrunc(c *check.C) { c.Assert(kv[name].id, check.Equals, containerID) } +// TestPsWithFormat tests "pouch ps --format" work +func (suite *PouchPsSuite) TestPsWithFormat(c *check.C) { + name := "ps-tableFormat" + command.PouchRun("create", "--name", name, busyboxImage, "top").Assert(c, icmd.Success) + defer DelContainerForceMultyTime(c, name) + + command.PouchRun("start", name).Assert(c, icmd.Success) + resTable := command.PouchRun("ps", "--format", "table {{.ID}}\t{{.Name}}").Assert(c, icmd.Success) + resTableHead := findTableFormatPsHead(resTable.Combined()) + c.Assert(len(resTableHead), check.Equals, 2) + c.Assert(resTableHead[0], check.Equals, "ID") + c.Assert(resTableHead[1], check.Equals, "Name") + + resRaw := command.PouchRun("ps", "--format", "raw").Assert(c, icmd.Success) + resRawHead := findRawFormatPsHead(resRaw.Combined()) + c.Assert(len(resRawHead), check.Equals, 7) + c.Assert(resRawHead[0], check.Equals, "Name") + c.Assert(resRawHead[1], check.Equals, "ID") + c.Assert(resRawHead[2], check.Equals, "Status") + c.Assert(resRawHead[3], check.Equals, "Created") + c.Assert(resRawHead[4], check.Equals, "Image") + c.Assert(resRawHead[5], check.Equals, "Runtime") +} + +// findTableFormatPsHead find pouch ps --format "table" head +func findTableFormatPsHead(ps string) []string { + head := strings.Split(ps, "\n")[0] + items := strings.Fields(head) + return items +} + +// findRawFormatPsHead find pouch ps --format "raw" head +func findRawFormatPsHead(ps string) []string { + lines := strings.Split(ps, "\n")[:7] + var res []string + for _, line := range lines { + items := strings.Split(line, ":") + res = append(res, items[0]) + } + return res +} + // psTable represents the table of "pouch ps" result. type psTable struct { id string