Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Setup nerdctl top cmd #223

Merged
merged 1 commit into from
Jun 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -848,7 +858,6 @@ Container management:

Stats:
- `docker stats`
- `docker top`

Image:
- `docker export` and `docker import`
Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ func newApp() *cli.App {
versionCommand,
// Inspect
inspectCommand,
// stats
topCommand,
// Management
containerCommand,
imageCommand,
Expand Down
303 changes: 303 additions & 0 deletions top.go
Original file line number Diff line number Diff line change
@@ -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)
}
42 changes: 42 additions & 0 deletions top_test.go
Original file line number Diff line number Diff line change
@@ -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()

}