github.com/thajeztah/cli@v0.0.0-20240223162942-dc6bfac81a8b/cli/command/container/stats.go (about)

     1  package container
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"strings"
     8  	"sync"
     9  	"time"
    10  
    11  	"github.com/docker/cli/cli"
    12  	"github.com/docker/cli/cli/command"
    13  	"github.com/docker/cli/cli/command/completion"
    14  	"github.com/docker/cli/cli/command/formatter"
    15  	flagsHelper "github.com/docker/cli/cli/flags"
    16  	"github.com/docker/docker/api/types"
    17  	"github.com/docker/docker/api/types/container"
    18  	"github.com/docker/docker/api/types/events"
    19  	"github.com/docker/docker/api/types/filters"
    20  	"github.com/pkg/errors"
    21  	"github.com/spf13/cobra"
    22  )
    23  
    24  // StatsOptions defines options for [RunStats].
    25  type StatsOptions struct {
    26  	// All allows including both running and stopped containers. The default
    27  	// is to only include running containers.
    28  	All bool
    29  
    30  	// NoStream disables streaming stats. If enabled, stats are collected once,
    31  	// and the result is printed.
    32  	NoStream bool
    33  
    34  	// NoTrunc disables truncating the output. The default is to truncate
    35  	// output such as container-IDs.
    36  	NoTrunc bool
    37  
    38  	// Format is a custom template to use for presenting the stats.
    39  	// Refer to [flagsHelper.FormatHelp] for accepted formats.
    40  	Format string
    41  
    42  	// Containers is the list of container names or IDs to include in the stats.
    43  	// If empty, all containers are included. It is mutually exclusive with the
    44  	// Filters option, and an error is produced if both are set.
    45  	Containers []string
    46  
    47  	// Filters provides optional filters to filter the list of containers and their
    48  	// associated container-events to include in the stats if no list of containers
    49  	// is set. If no filter is provided, all containers are included. Filters and
    50  	// Containers are currently mutually exclusive, and setting both options
    51  	// produces an error.
    52  	//
    53  	// These filters are used both to collect the initial list of containers and
    54  	// to refresh the list of containers based on container-events, accepted
    55  	// filters are limited to the intersection of filters accepted by "events"
    56  	// and "container list".
    57  	//
    58  	// Currently only "label" / "label=value" filters are accepted. Additional
    59  	// filter options may be added in future (within the constraints described
    60  	// above), but may require daemon-side validation as the list of accepted
    61  	// filters can differ between daemon- and API versions.
    62  	Filters *filters.Args
    63  }
    64  
    65  // NewStatsCommand creates a new [cobra.Command] for "docker stats".
    66  func NewStatsCommand(dockerCLI command.Cli) *cobra.Command {
    67  	options := StatsOptions{}
    68  
    69  	cmd := &cobra.Command{
    70  		Use:   "stats [OPTIONS] [CONTAINER...]",
    71  		Short: "Display a live stream of container(s) resource usage statistics",
    72  		Args:  cli.RequiresMinArgs(0),
    73  		RunE: func(cmd *cobra.Command, args []string) error {
    74  			options.Containers = args
    75  			return RunStats(cmd.Context(), dockerCLI, &options)
    76  		},
    77  		Annotations: map[string]string{
    78  			"aliases": "docker container stats, docker stats",
    79  		},
    80  		ValidArgsFunction: completion.ContainerNames(dockerCLI, false),
    81  	}
    82  
    83  	flags := cmd.Flags()
    84  	flags.BoolVarP(&options.All, "all", "a", false, "Show all containers (default shows just running)")
    85  	flags.BoolVar(&options.NoStream, "no-stream", false, "Disable streaming stats and only pull the first result")
    86  	flags.BoolVar(&options.NoTrunc, "no-trunc", false, "Do not truncate output")
    87  	flags.StringVar(&options.Format, "format", "", flagsHelper.FormatHelp)
    88  	return cmd
    89  }
    90  
    91  // acceptedStatsFilters is the list of filters accepted by [RunStats] (through
    92  // the [StatsOptions.Filters] option).
    93  //
    94  // TODO(thaJeztah): don't hard-code the list of accept filters, and expand
    95  // to the intersection of filters accepted by both "container list" and
    96  // "system events". Validating filters may require an initial API call
    97  // to both endpoints ("container list" and "system events").
    98  var acceptedStatsFilters = map[string]bool{
    99  	"label": true,
   100  }
   101  
   102  // RunStats displays a live stream of resource usage statistics for one or more containers.
   103  // This shows real-time information on CPU usage, memory usage, and network I/O.
   104  //
   105  //nolint:gocyclo
   106  func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) error {
   107  	apiClient := dockerCLI.Client()
   108  
   109  	// Get the daemonOSType if not set already
   110  	if daemonOSType == "" {
   111  		sv, err := apiClient.ServerVersion(ctx)
   112  		if err != nil {
   113  			return err
   114  		}
   115  		daemonOSType = sv.Os
   116  	}
   117  
   118  	// waitFirst is a WaitGroup to wait first stat data's reach for each container
   119  	waitFirst := &sync.WaitGroup{}
   120  	// closeChan is a non-buffered channel used to collect errors from goroutines.
   121  	closeChan := make(chan error)
   122  	cStats := stats{}
   123  
   124  	showAll := len(options.Containers) == 0
   125  	if showAll {
   126  		// If no names were specified, start a long-running goroutine which
   127  		// monitors container events. We make sure we're subscribed before
   128  		// retrieving the list of running containers to avoid a race where we
   129  		// would "miss" a creation.
   130  		started := make(chan struct{})
   131  
   132  		if options.Filters == nil {
   133  			f := filters.NewArgs()
   134  			options.Filters = &f
   135  		}
   136  
   137  		if err := options.Filters.Validate(acceptedStatsFilters); err != nil {
   138  			return err
   139  		}
   140  
   141  		eh := command.InitEventHandler()
   142  		if options.All {
   143  			eh.Handle(events.ActionCreate, func(e events.Message) {
   144  				s := NewStats(e.Actor.ID[:12])
   145  				if cStats.add(s) {
   146  					waitFirst.Add(1)
   147  					go collect(ctx, s, apiClient, !options.NoStream, waitFirst)
   148  				}
   149  			})
   150  		}
   151  
   152  		eh.Handle(events.ActionStart, func(e events.Message) {
   153  			s := NewStats(e.Actor.ID[:12])
   154  			if cStats.add(s) {
   155  				waitFirst.Add(1)
   156  				go collect(ctx, s, apiClient, !options.NoStream, waitFirst)
   157  			}
   158  		})
   159  
   160  		if !options.All {
   161  			eh.Handle(events.ActionDie, func(e events.Message) {
   162  				cStats.remove(e.Actor.ID[:12])
   163  			})
   164  		}
   165  
   166  		// monitorContainerEvents watches for container creation and removal (only
   167  		// used when calling `docker stats` without arguments).
   168  		monitorContainerEvents := func(started chan<- struct{}, c chan events.Message, stopped <-chan struct{}) {
   169  			// Create a copy of the custom filters so that we don't mutate
   170  			// the original set of filters. Custom filters are used both
   171  			// to list containers and to filter events, but the "type" filter
   172  			// is not valid for filtering containers.
   173  			f := options.Filters.Clone()
   174  			f.Add("type", string(events.ContainerEventType))
   175  			eventChan, errChan := apiClient.Events(ctx, types.EventsOptions{
   176  				Filters: f,
   177  			})
   178  
   179  			// Whether we successfully subscribed to eventChan or not, we can now
   180  			// unblock the main goroutine.
   181  			close(started)
   182  			defer close(c)
   183  
   184  			for {
   185  				select {
   186  				case <-stopped:
   187  					return
   188  				case event := <-eventChan:
   189  					c <- event
   190  				case err := <-errChan:
   191  					closeChan <- err
   192  					return
   193  				}
   194  			}
   195  		}
   196  
   197  		eventChan := make(chan events.Message)
   198  		go eh.Watch(eventChan)
   199  		stopped := make(chan struct{})
   200  		go monitorContainerEvents(started, eventChan, stopped)
   201  		defer close(stopped)
   202  		<-started
   203  
   204  		// Fetch the initial list of containers and collect stats for them.
   205  		// After the initial list was collected, we start listening for events
   206  		// to refresh the list of containers.
   207  		cs, err := apiClient.ContainerList(ctx, container.ListOptions{
   208  			All:     options.All,
   209  			Filters: *options.Filters,
   210  		})
   211  		if err != nil {
   212  			return err
   213  		}
   214  		for _, ctr := range cs {
   215  			s := NewStats(ctr.ID[:12])
   216  			if cStats.add(s) {
   217  				waitFirst.Add(1)
   218  				go collect(ctx, s, apiClient, !options.NoStream, waitFirst)
   219  			}
   220  		}
   221  
   222  		// make sure each container get at least one valid stat data
   223  		waitFirst.Wait()
   224  	} else {
   225  		// TODO(thaJeztah): re-implement options.Containers as a filter so that
   226  		// only a single code-path is needed, and custom filters can be combined
   227  		// with a list of container names/IDs.
   228  
   229  		if options.Filters != nil && options.Filters.Len() > 0 {
   230  			return fmt.Errorf("filtering is not supported when specifying a list of containers")
   231  		}
   232  
   233  		// Create the list of containers, and start collecting stats for all
   234  		// containers passed.
   235  		for _, ctr := range options.Containers {
   236  			s := NewStats(ctr)
   237  			if cStats.add(s) {
   238  				waitFirst.Add(1)
   239  				go collect(ctx, s, apiClient, !options.NoStream, waitFirst)
   240  			}
   241  		}
   242  
   243  		// We don't expect any asynchronous errors: closeChan can be closed.
   244  		close(closeChan)
   245  
   246  		// make sure each container get at least one valid stat data
   247  		waitFirst.Wait()
   248  
   249  		var errs []string
   250  		cStats.mu.RLock()
   251  		for _, c := range cStats.cs {
   252  			if err := c.GetError(); err != nil {
   253  				errs = append(errs, err.Error())
   254  			}
   255  		}
   256  		cStats.mu.RUnlock()
   257  		if len(errs) > 0 {
   258  			return errors.New(strings.Join(errs, "\n"))
   259  		}
   260  	}
   261  
   262  	format := options.Format
   263  	if len(format) == 0 {
   264  		if len(dockerCLI.ConfigFile().StatsFormat) > 0 {
   265  			format = dockerCLI.ConfigFile().StatsFormat
   266  		} else {
   267  			format = formatter.TableFormatKey
   268  		}
   269  	}
   270  	statsCtx := formatter.Context{
   271  		Output: dockerCLI.Out(),
   272  		Format: NewStatsFormat(format, daemonOSType),
   273  	}
   274  	cleanScreen := func() {
   275  		if !options.NoStream {
   276  			_, _ = fmt.Fprint(dockerCLI.Out(), "\033[2J")
   277  			_, _ = fmt.Fprint(dockerCLI.Out(), "\033[H")
   278  		}
   279  	}
   280  
   281  	var err error
   282  	ticker := time.NewTicker(500 * time.Millisecond)
   283  	defer ticker.Stop()
   284  	for range ticker.C {
   285  		cleanScreen()
   286  		var ccStats []StatsEntry
   287  		cStats.mu.RLock()
   288  		for _, c := range cStats.cs {
   289  			ccStats = append(ccStats, c.GetStatistics())
   290  		}
   291  		cStats.mu.RUnlock()
   292  		if err = statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil {
   293  			break
   294  		}
   295  		if len(cStats.cs) == 0 && !showAll {
   296  			break
   297  		}
   298  		if options.NoStream {
   299  			break
   300  		}
   301  		select {
   302  		case err, ok := <-closeChan:
   303  			if ok {
   304  				if err != nil {
   305  					// Suppress "unexpected EOF" errors in the CLI so that
   306  					// it shuts down cleanly when the daemon restarts.
   307  					if errors.Is(err, io.ErrUnexpectedEOF) {
   308  						return nil
   309  					}
   310  					return err
   311  				}
   312  			}
   313  		default:
   314  			// just skip
   315  		}
   316  	}
   317  	return err
   318  }