diff --git a/cli/cli.go b/cli/cli.go index c5bfeadd6..f3d041232 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,15 @@ 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 + } + return tmpl.Execute(w, container) +} + // 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 000000000..7868bf219 --- /dev/null +++ b/cli/formatter/container.go @@ -0,0 +1,70 @@ +package formatter + +import ( + "fmt" + "time" + + "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{ + "Names": "Name", + "ID": "ID", + "Status": "Status", + "RunningFor": "Created", + "Image": "Image", + "Runtime": "Runtime", + "Command": "Command", + "ImageID": "ImageID", + "Labels": "Labels", + "Mounts": "Mounts", + "State": "State", + "Ports": "Ports", + "Size": "Size", + "LocalVolumes": "LocalVolumes", + "Networks": "Networks", + "CreatedAt": "CreatedAt", +} + +// 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 ContainerContext, err error) { + id := c.ID[:6] + if flagNoTrunc { + id = c.ID + } + runningFor, err := utils.FormatTimeInterval(c.Created) + if err != nil { + return nil, err + } + createdAt := time.Unix(0, c.Created).String() + networks := c.HostConfig.NetworkMode + ports := PortBindingsToString(c.HostConfig.PortBindings) + size := SizeToString(c.SizeRw, c.SizeRootFs) + labels := LabelsToString(c.Labels) + mount := MountPointToString(c.Mounts) + localVolume := LocalVolumes(c.Mounts) + containerContext = ContainerContext{ + "Names": c.Names[0], + "ID": id, + "Status": c.Status, + "RunningFor": fmt.Sprintf("%s ago", runningFor), + "Image": c.Image, + "Runtime": c.HostConfig.Runtime, + "Command": c.Command, + "ImageID": c.ImageID, + "Labels": labels, + "Mounts": mount, + "State": c.State, + "Ports": ports, + "Size": size, + "LocalVolumes": localVolume, + "Networks": networks, + "CreatedAt": createdAt, + } + return containerContext, nil +} diff --git a/cli/formatter/container_test.go b/cli/formatter/container_test.go new file mode 100644 index 000000000..4f3801834 --- /dev/null +++ b/cli/formatter/container_test.go @@ -0,0 +1,100 @@ +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: 8392772900000, + HostConfig: &types.HostConfig{Runtime: "runc", PortBindings: types.PortMap{"80/tcp": []types.PortBinding{{HostIP: "127.0.0.1", HostPort: "80"}, {HostIP: "127.0.0.1", HostPort: "88"}}}, NetworkMode: "bridge"}, + 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", Driver: "local"}, + {Source: "/test"}, + }, + SizeRw: 10, + SizeRootFs: 100, + }, + flagNoTrunc: false, + expected: ContainerContext{ + "Names": "nameA", + "ID": "abcdel", + "Status": "StatusB", + "RunningFor": "49 years" + " ago", + "Image": "Image123", + "Runtime": "runc", + "Command": "bash", + "ImageID": "234567890", + "Labels": "a = b;c = d;", + "Mounts": "/root/code;/test;", + "State": "StateA", + "Ports": "80/tcp->127.0.0.1:80;80/tcp->127.0.0.1:88;", + "Size": "10B (virtual 100B)", + "LocalVolumes": "1", + "Networks": "bridge", + "CreatedAt": "1970-01-01 02:19:52.7729 +0000 UTC", + }, + }, + { + container: &types.Container{ + Command: "shell", + Created: 997349794700000, + 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{ + "Names": "nameB", + "ID": "1234567890", + "Status": "StatusA", + "RunningFor": "49 years" + " ago", + "Image": "Image456", + "Runtime": "runv", + "Command": "shell", + "ImageID": "abcdefg", + "Labels": "", + "Mounts": "/root/code;/test;", + "State": "StateB", + "Ports": "", + "Size": "0B", + "LocalVolumes": "0", + "Networks": "", + "CreatedAt": "1970-01-12 13:02:29.7947 +0000 UTC", + }, + }, + } + 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 000000000..96445f002 --- /dev/null +++ b/cli/formatter/formatter.go @@ -0,0 +1,101 @@ +package formatter + +import ( + "fmt" + "sort" + "strings" + + "github.com/alibaba/pouch/apis/types" + units "github.com/docker/go-units" +) + +// 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:{{.Names}}\nID:{{.ID}}\nStatus:{{.Status}}\nCreated:{{.RunningFor}}\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 +} + +// PortBindingsToString is to transform the portbindings from map to string +func PortBindingsToString(portMap types.PortMap) string { + var portBindings string + sortedkeys := make([]string, 0) + for key := range portMap { + sortedkeys = append(sortedkeys, key) + } + for _, key := range sortedkeys { + for _, ipPort := range portMap[key] { + portBindings += fmt.Sprintf("%s->%s:%s;", key, ipPort.HostIP, ipPort.HostPort) + } + } + return portBindings +} + +// SizeToString is to get the size related output +func SizeToString(sizeRw int64, sizeRootFs int64) string { + var strResult string + sRw := units.HumanSizeWithPrecision(float64(sizeRw), 3) + sRFs := units.HumanSizeWithPrecision(float64(sizeRootFs), 3) + strResult = sRw + if sizeRootFs > 0 { + strResult = fmt.Sprintf("%s (virtual %s)", sRw, sRFs) + } + return strResult +} + +// LocalVolumes is get the count of local volumes +func LocalVolumes(mount []types.MountPoint) string { + c := 0 + for _, v := range mount { + if v.Driver == "local" { + c++ + } + } + return fmt.Sprintf("%d", c) +} diff --git a/cli/formatter/formatter_test.go b/cli/formatter/formatter_test.go new file mode 100644 index 000000000..05f9fcb2d --- /dev/null +++ b/cli/formatter/formatter_test.go @@ -0,0 +1,184 @@ +package formatter + +import ( + "testing" + + "github.com/alibaba/pouch/apis/types" + "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{{.CreatedAt}}\t{{.Name}}\t{{.Image}}\t{{.State}}", + output: "{{.ID}}\t{{.Status}}\t{{.CreatedAt}}\t{{.Name}}\t{{.Image}}\t{{.State}}\n", + }, + { + input: "table {{.ID}}\\t{{.Status}}\\t{{.CreatedAt}}\\t{{.Name}}\\t{{.Image}}\\t{{.State}}", + output: "{{.ID}}\t{{.Status}}\t{{.CreatedAt}}\t{{.Name}}\t{{.Image}}\t{{.State}}\n", + }, + { + input: "table {{.ID}}\t{{.Status}}\t{{.CreatedAt}}\\t{{.Name}}\t{{.Image}}\t{{.State}}\n", + output: "{{.ID}}\t{{.Status}}\t{{.CreatedAt}}\t{{.Name}}\t{{.Image}}\t{{.State}}\n", + }, + { + input: "raw", + output: "Name:{{.Names}}\nID:{{.ID}}\nStatus:{{.Status}}\nCreated:{{.RunningFor}}\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) + } +} + +func TestMountPointToString(t *testing.T) { + type TestCase struct { + input []types.MountPoint + output string + } + testCases := []TestCase{ + { + input: []types.MountPoint{ + {Source: "/root/code"}, + {Source: "/test"}, + }, + output: "/root/code;/test;", + }, + { + input: []types.MountPoint{ + {Source: "/root/code"}, + }, + output: "/root/code;", + }, + } + for _, testCase := range testCases { + result := MountPointToString(testCase.input) + assert.Equal(t, testCase.output, result) + } +} + +func TestLocalVolumes(t *testing.T) { + type TestCase struct { + input []types.MountPoint + output string + } + testCases := []TestCase{ + { + input: []types.MountPoint{ + {Source: "/root/code", Driver: "local"}, + {Source: "/test", Driver: "local"}, + }, + output: "2", + }, + { + input: []types.MountPoint{ + {Source: "/root/code"}, + }, + output: "0", + }, + } + for _, testCase := range testCases { + result := LocalVolumes(testCase.input) + assert.Equal(t, testCase.output, result) + } +} + +func TestSizeToString(t *testing.T) { + type TestCase struct { + input []int64 + output string + } + testCases := []TestCase{ + { + input: []int64{45, 56}, + output: "45B (virtual 56B)", + }, + { + input: []int64{65526, 409626}, + output: "65.5kB (virtual 410kB)", + }, + } + for _, testCase := range testCases { + result := SizeToString(testCase.input[0], testCase.input[1]) + assert.Equal(t, testCase.output, result) + } +} + +func TestPortBindingsToString(t *testing.T) { + type TestCase struct { + input types.PortMap + output string + } + testCases := []TestCase{ + { + input: map[string][]types.PortBinding{ + "80/tcp": { + { + HostIP: "127.0.0.1", + HostPort: "80", + }, + { + HostIP: "127.0.0.1", + HostPort: "88", + }, + }, + }, + output: "80/tcp->127.0.0.1:80;80/tcp->127.0.0.1:88;", + }, + { + input: map[string][]types.PortBinding{ + "80/udp": { + { + HostIP: "192.168.1.1", + HostPort: "65535", + }, + { + HostIP: "192.168.1.3", + HostPort: "65536", + }, + }, + }, + output: "80/udp->192.168.1.1:65535;80/udp->192.168.1.3:65536;", + }, + } + for _, testCase := range testCases { + result := PortBindingsToString(testCase.input) + assert.Equal(t, testCase.output, result) + } +} diff --git a/cli/ps.go b/cli/ps.go index ec7d15fd9..96959cb35 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 {{.Names}}\t{{.ID}}\t{{.Status}}\t{{.RunningFor}}\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,29 @@ 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) + containerContext, err := formatter.NewContainerContext(c, p.flagNoTrunc) 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}) + tmplD := template.New("ps_detail") + p.cli.FormatDisplay(format, tmplD, containerContext, w) } - display.Flush() + w.Flush() return nil } @@ -145,6 +156,10 @@ 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 --format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.RunningFor}}\t{{.Ports}}\t{{.Status}}\t{{.Size}}\t{{.Labels}}\t{{.Mounts}}\t{{.LocalVolumes}}\t{{.Networks}}\t{{.Runtime}}\t{{.ImageID}}" +ID Name Image Command CreatedAt Created Ports Status Size Labels Mounts Volumes Networks Runtime ImageID +869433 test registry.hub.docker.com/library/busybox:1.28 sh 2019-05-29 05:40:46.64617376 +0000 UTC 6 seconds ago 3333/tcp->:3333; Up 6 seconds 0B a = b; /root/test; 0 bridge runc sha256:8c811b4aec35f259572d0f79207bc0678df4c736eeec50bc9fec37ed936a472a ` } diff --git a/test/cli_ps_test.go b/test/cli_ps_test.go index cd7cd3eb4..6c44358fb 100644 --- a/test/cli_ps_test.go +++ b/test/cli_ps_test.go @@ -256,6 +256,61 @@ 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{{.Names}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.RunningFor}}\t{{.Ports}}\t{{.Status}}\t{{.Size}}\t{{.Labels}}\t{{.Mounts}}\t{{.LocalVolumes}}\t{{.Networks}}\t{{.Runtime}}\t{{.ImageID}}").Assert(c, icmd.Success) + resTableHead := findTableFormatPsHead(resTable.Combined()) + c.Assert(len(resTableHead), check.Equals, 15) + c.Assert(resTableHead[0], check.Equals, "ID") + c.Assert(resTableHead[1], check.Equals, "Name") + c.Assert(resTableHead[2], check.Equals, "Image") + c.Assert(resTableHead[3], check.Equals, "Command") + c.Assert(resTableHead[4], check.Equals, "CreatedAt") + c.Assert(resTableHead[5], check.Equals, "Created") + c.Assert(resTableHead[6], check.Equals, "Ports") + c.Assert(resTableHead[7], check.Equals, "Status") + c.Assert(resTableHead[8], check.Equals, "Size") + c.Assert(resTableHead[9], check.Equals, "Labels") + c.Assert(resTableHead[10], check.Equals, "Mounts") + c.Assert(resTableHead[11], check.Equals, "LocalVolumes") + c.Assert(resTableHead[12], check.Equals, "Networks") + c.Assert(resTableHead[13], check.Equals, "Runtime") + c.Assert(resTableHead[14], check.Equals, "ImageID") + + 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