From ff8686b75b9d8074f5c87a1af7716ead9e20a8a5 Mon Sep 17 00:00:00 2001 From: fahed dorgaa Date: Fri, 21 May 2021 00:04:16 +0200 Subject: [PATCH] setup docker top cmd Signed-off-by: fahed dorgaa --- README.md | 11 +- main.go | 2 + top.go | 303 ++++++++++++++++++++++++++++++++++++++++++++++++++++ top_test.go | 42 ++++++++ 4 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 top.go create mode 100644 top_test.go diff --git a/README.md b/README.md index f1f141a7047..fb9388828be 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,8 @@ It does not necessarily mean that the corresponding features are missing in cont - [:whale: nerdctl events](#whale-nerdctl-events) - [:whale: nerdctl info](#whale-nerdctl-info) - [:whale: nerdctl version](#whale-nerdctl-version) + - [Stats management](#stats-management) + - [:whale: nerdctl top](#whale-nerdctl-top) - [Shell completion](#shell-completion) - [:nerd_face: nerdctl completion bash](#nerd_face-nerdctl-completion-bash) - [Compose](#compose) @@ -752,6 +754,14 @@ Usage: `nerdctl version [OPTIONS]` Unimplemented `docker version` flags: `--format` +## Stats management +### :whale: nerdctl top +Display the running processes of a container. + + +Usage: `nerdctl top CONTAINER [ps OPTIONS]` + + ## Shell completion ### :nerd_face: nerdctl completion bash @@ -848,7 +858,6 @@ Container management: Stats: - `docker stats` -- `docker top` Image: - `docker export` and `docker import` diff --git a/main.go b/main.go index 0e7b2ad58ca..acf098c2889 100644 --- a/main.go +++ b/main.go @@ -173,6 +173,8 @@ func newApp() *cli.App { versionCommand, // Inspect inspectCommand, + // stats + topCommand, // Management containerCommand, imageCommand, diff --git a/top.go b/top.go new file mode 100644 index 00000000000..76b4819ea40 --- /dev/null +++ b/top.go @@ -0,0 +1,303 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package main + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "text/tabwriter" + + "github.com/containerd/containerd" + "github.com/containerd/nerdctl/pkg/idutil/containerwalker" + "github.com/containerd/nerdctl/pkg/infoutil" + "github.com/containerd/nerdctl/pkg/rootlessutil" + "github.com/pkg/errors" + "github.com/urfave/cli/v2" +) + +// ContainerTopOKBody OK response to ContainerTop operation +type ContainerTopOKBody struct { + + // Each process running in the container, where each is process + // is an array of values corresponding to the titles. + // + // Required: true + Processes [][]string `json:"Processes"` + + // The ps column titles + // Required: true + Titles []string `json:"Titles"` +} + +var topCommand = &cli.Command{ + Name: "top", + Usage: "Display the running processes of a container", + ArgsUsage: "CONTAINER [ps OPTIONS]", + Action: topAction, + BashComplete: topBashComplete, +} + +func topAction(clicontext *cli.Context) error { + + if clicontext.NArg() < 1 { + return errors.Errorf("requires at least 1 argument") + } + + // NOTE: rootless container does not rely on cgroupv1. + // more details about possible ways to resolve this concern: #223 + if rootlessutil.IsRootless() && infoutil.CgroupsVersion() == "1" { + return fmt.Errorf("top is not supported for rootless container and cgroupv1") + } + + client, ctx, cancel, err := newClient(clicontext) + if err != nil { + return err + } + defer cancel() + + walker := &containerwalker.ContainerWalker{ + Client: client, + OnFound: func(ctx context.Context, found containerwalker.Found) error { + if err := containerTop(ctx, clicontext, client, found.Container.ID(), strings.Join(clicontext.Args().Tail(), " ")); err != nil { + return err + } + return nil + }, + } + + n, err := walker.Walk(ctx, clicontext.Args().First()) + if err != nil { + return err + } else if n == 0 { + return errors.Errorf("no such container %s", clicontext.Args().First()) + } + return nil +} + +//function from moby/moby/daemon/top_unix.go +func appendProcess2ProcList(procList *ContainerTopOKBody, fields []string) { + // Make sure number of fields equals number of header titles + // merging "overhanging" fields + process := fields[:len(procList.Titles)-1] + process = append(process, strings.Join(fields[len(procList.Titles)-1:], " ")) + procList.Processes = append(procList.Processes, process) +} + +//function from moby/moby/daemon/top_unix.go +// psPidsArg converts a slice of PIDs to a string consisting +// of comma-separated list of PIDs prepended by "-q". +// For example, psPidsArg([]uint32{1,2,3}) returns "-q1,2,3". +func psPidsArg(pids []uint32) string { + b := []byte{'-', 'q'} + for i, p := range pids { + b = strconv.AppendUint(b, uint64(p), 10) + if i < len(pids)-1 { + b = append(b, ',') + } + } + return string(b) +} + +//function from moby/moby/daemon/top_unix.go +func validatePSArgs(psArgs string) error { + // NOTE: \\s does not detect unicode whitespaces. + // So we use fieldsASCII instead of strings.Fields in parsePSOutput. + // See https://github.com/docker/docker/pull/24358 + // nolint: gosimple + re := regexp.MustCompile("\\s+([^\\s]*)=\\s*(PID[^\\s]*)") + for _, group := range re.FindAllStringSubmatch(psArgs, -1) { + if len(group) >= 3 { + k := group[1] + v := group[2] + if k != "pid" { + return fmt.Errorf("specifying \"%s=%s\" is not allowed", k, v) + } + } + } + return nil +} + +//function from moby/moby/daemon/top_unix.go +// fieldsASCII is similar to strings.Fields but only allows ASCII whitespaces +func fieldsASCII(s string) []string { + fn := func(r rune) bool { + switch r { + case '\t', '\n', '\f', '\r', ' ': + return true + } + return false + } + return strings.FieldsFunc(s, fn) +} + +//function from moby/moby/daemon/top_unix.go +func hasPid(procs []uint32, pid int) bool { + for _, p := range procs { + if int(p) == pid { + return true + } + } + return false +} + +//function from moby/moby/daemon/top_unix.go +func parsePSOutput(output []byte, procs []uint32) (*ContainerTopOKBody, error) { + procList := &ContainerTopOKBody{} + + lines := strings.Split(string(output), "\n") + procList.Titles = fieldsASCII(lines[0]) + + pidIndex := -1 + for i, name := range procList.Titles { + if name == "PID" { + pidIndex = i + break + } + } + if pidIndex == -1 { + return nil, fmt.Errorf("Couldn't find PID field in ps output") + } + + // loop through the output and extract the PID from each line + // fixing #30580, be able to display thread line also when "m" option used + // in "docker top" client command + preContainedPidFlag := false + for _, line := range lines[1:] { + if len(line) == 0 { + continue + } + fields := fieldsASCII(line) + + var ( + p int + err error + ) + + if fields[pidIndex] == "-" { + if preContainedPidFlag { + appendProcess2ProcList(procList, fields) + } + continue + } + p, err = strconv.Atoi(fields[pidIndex]) + if err != nil { + return nil, fmt.Errorf("Unexpected pid '%s': %s", fields[pidIndex], err) + } + + if hasPid(procs, p) { + preContainedPidFlag = true + appendProcess2ProcList(procList, fields) + continue + } + preContainedPidFlag = false + } + return procList, nil +} + +// function inspired from moby/moby/daemon/top_unix.go +// ContainerTop lists the processes running inside of the given +// container by calling ps with the given args, or with the flags +// "-ef" if no args are given. An error is returned if the container +// is not found, or is not running, or if there are any problems +// running ps, or parsing the output. +func containerTop(ctx context.Context, clicontext *cli.Context, client *containerd.Client, id string, psArgs string) error { + if psArgs == "" { + psArgs = "-ef" + } + + if err := validatePSArgs(psArgs); err != nil { + return err + } + + container, err := client.LoadContainer(ctx, id) + if err != nil { + return err + } + + task, err := container.Task(ctx, nil) + if err != nil { + return err + } + + status, err := task.Status(ctx) + if err != nil { + return err + } + + if status.Status != containerd.Running { + return nil + } + + //TO DO handle restarting case: wait for container to restart and then launch top command + + procs, err := task.Pids(ctx) + if err != nil { + return err + } + + psList := make([]uint32, 0, len(procs)) + for _, ps := range procs { + psList = append(psList, ps.Pid) + } + + args := strings.Split(psArgs, " ") + pids := psPidsArg(psList) + output, err := exec.Command("ps", append(args, pids)...).Output() + if err != nil { + // some ps options (such as f) can't be used together with q, + // so retry without it + output, err = exec.Command("ps", args...).Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + // first line of stderr shows why ps failed + line := bytes.SplitN(ee.Stderr, []byte{'\n'}, 2) + if len(line) > 0 && len(line[0]) > 0 { + return errors.New(string(line[0])) + } + } + return nil + } + } + procList, err := parsePSOutput(output, psList) + if err != nil { + return err + } + + w := tabwriter.NewWriter(clicontext.App.Writer, 20, 1, 3, ' ', 0) + fmt.Fprintln(w, strings.Join(procList.Titles, "\t")) + + for _, proc := range procList.Processes { + fmt.Fprintln(w, strings.Join(proc, "\t")) + } + + return w.Flush() +} + +func topBashComplete(clicontext *cli.Context) { + coco := parseCompletionContext(clicontext) + if coco.boring || coco.flagTakesValue { + defaultBashComplete(clicontext) + return + } + // show container names (TODO: only running containers) + bashCompleteContainerNames(clicontext, nil) +} diff --git a/top_test.go b/top_test.go new file mode 100644 index 00000000000..d14b18554ee --- /dev/null +++ b/top_test.go @@ -0,0 +1,42 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package main + +import ( + "testing" + + "github.com/containerd/nerdctl/pkg/infoutil" + "github.com/containerd/nerdctl/pkg/rootlessutil" + "github.com/containerd/nerdctl/pkg/testutil" +) + +func TestTop(t *testing.T) { + //more details https://github.com/containerd/nerdctl/pull/223#issuecomment-851395178 + if rootlessutil.IsRootless() && infoutil.CgroupsVersion() == "1" { + t.Skip("test skipped for rootless container with cgroup v1") + } + const ( + testContainerName = "nerdctl-test-top" + ) + + base := testutil.NewBase(t) + defer base.Cmd("rm", "-f", testContainerName).Run() + + base.Cmd("run", "-d", "--name", testContainerName, testutil.AlpineImage, "sleep", "5").AssertOK() + base.Cmd("top", testContainerName, "-o", "pid,user,cmd").AssertOK() + +}