diff --git a/cli/root.go b/cli/root.go index 59096f4900bc7..81f44ce9a84fd 100644 --- a/cli/root.go +++ b/cli/root.go @@ -106,6 +106,7 @@ func (r *RootCmd) Core() []*clibase.Cmd { r.scaletest(), r.gitssh(), r.vscodeSSH(), + r.stat(), } } @@ -679,6 +680,15 @@ func (r *RootCmd) checkVersions(i *clibase.Invocation, client *codersdk.Client) return nil } +// verboseStderr returns the stderr writer if verbose is set, otherwise +// it returns a discard writer. +func (r *RootCmd) verboseStderr(inv *clibase.Invocation) io.Writer { + if r.verbose { + return inv.Stderr + } + return io.Discard +} + func (r *RootCmd) checkWarnings(i *clibase.Invocation, client *codersdk.Client) error { if r.noFeatureWarning { return nil diff --git a/cli/stat.go b/cli/stat.go new file mode 100644 index 0000000000000..3b227b8c767f8 --- /dev/null +++ b/cli/stat.go @@ -0,0 +1,170 @@ +package cli + +import ( + "bufio" + "bytes" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/shirou/gopsutil/cpu" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/cli/cliui" +) + +type statCmd struct { + *RootCmd + watch time.Duration +} + +func (r *RootCmd) stat() *clibase.Cmd { + c := &clibase.Cmd{ + Use: "stat [flags...]", + Short: "Display local system resource usage statistics", + Long: "stat calls can be used as the script for agent metadata blocks.", + } + sc := statCmd{RootCmd: r} + c.Options.Add( + clibase.Option{ + Flag: "watch", + FlagShorthand: "w", + Description: "Continuously display the statistic on the given interval.", + Value: clibase.DurationOf(&sc.watch), + }, + ) + c.AddSubcommands( + sc.cpu(), + ) + sc.setWatchLoops(c) + return c +} + +func (sc *statCmd) setWatchLoops(c *clibase.Cmd) { + for _, cmd := range c.Children { + innerHandler := cmd.Handler + cmd.Handler = func(inv *clibase.Invocation) error { + if sc.watch == 0 { + return innerHandler(inv) + } + + ticker := time.NewTicker(sc.watch) + defer ticker.Stop() + + for range ticker.C { + if err := innerHandler(inv); err != nil { + _, _ = fmt.Fprintf(inv.Stderr, "error: %v", err) + } + } + panic("unreachable") + } + } +} + +func cpuUsageFromCgroup(interval time.Duration) (float64, error) { + cgroup, err := os.OpenFile("/proc/self/cgroup", os.O_RDONLY, 0) + if err != nil { + return 0, err + } + defer cgroup.Close() + sc := bufio.NewScanner(cgroup) + + var groupDir string + for sc.Scan() { + fields := strings.Split(sc.Text(), ":") + if len(fields) != 3 { + continue + } + if fields[1] != "cpu,cpuacct" { + continue + } + groupDir = fields[2] + break + } + + if groupDir == "" { + return 0, xerrors.New("no cpu cgroup found") + } + + cpuAcct := func() (int64, error) { + path := fmt.Sprintf("/sys/fs/cgroup/cpu,cpuacct/%s/cpuacct.usage", groupDir) + + byt, err := os.ReadFile( + path, + ) + if err != nil { + return 0, err + } + + return strconv.ParseInt(string(bytes.TrimSpace(byt)), 10, 64) + } + + stat1, err := cpuAcct() + if err != nil { + return 0, err + } + + time.Sleep(interval) + + stat2, err := cpuAcct() + if err != nil { + return 0, err + } + + var ( + cpuTime = time.Duration(stat2 - stat1) + realTime = interval + ) + + ncpu, err := cpu.Counts(true) + if err != nil { + return 0, err + } + + return (cpuTime.Seconds() / realTime.Seconds()) * 100 / float64(ncpu), nil +} + +//nolint:revive +func (sc *statCmd) cpu() *clibase.Cmd { + var interval time.Duration + c := &clibase.Cmd{ + Use: "cpu-usage", + Aliases: []string{"cu"}, + Short: "Display the system's cpu usage", + Long: "If inside a cgroup (e.g. docker container), the cpu usage is ", + Handler: func(inv *clibase.Invocation) error { + if sc.watch != 0 { + interval = sc.watch + } + + r, err := cpuUsageFromCgroup(interval) + if err != nil { + cliui.Infof(sc.verboseStderr(inv), "cgroup error: %+v", err) + + // Use standard methods if cgroup method fails. + rs, err := cpu.Percent(interval, false) + if err != nil { + return err + } + r = rs[0] + } + + _, _ = fmt.Fprintf(inv.Stdout, "%02.0f\n", r) + + return nil + }, + Options: []clibase.Option{ + { + Flag: "interval", + FlagShorthand: "i", + Description: `The sample collection interval. If --watch is set, it overrides this value.`, + Default: "0s", + Value: clibase.DurationOf(&interval), + }, + }, + } + return c +} diff --git a/cli/stat_test.go b/cli/stat_test.go new file mode 100644 index 0000000000000..f546a3cb5b66a --- /dev/null +++ b/cli/stat_test.go @@ -0,0 +1,24 @@ +package cli_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" +) + +func TestStat(t *testing.T) { + t.Parallel() + + t.Run("cpu", func(t *testing.T) { + t.Parallel() + inv, _ := clitest.New(t, "stat", "cpu") + var out bytes.Buffer + inv.Stdout = &out + clitest.Run(t, inv) + + require.Regexp(t, `^[\d]{2}\n$`, out.String()) + }) +} diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index db7208fe19fae..a0110a7a4919a 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -34,6 +34,7 @@ Coder v0.0.0-devel — A tool for provisioning self-hosted development environme workspace ssh Start a shell into a workspace start Start a workspace + stat Display local system resource usage statistics state Manually manage Terraform state to fix broken workspaces stop Stop a workspace templates Manage templates diff --git a/cli/testdata/coder_stat_--help.golden b/cli/testdata/coder_stat_--help.golden new file mode 100644 index 0000000000000..886602a8fb399 --- /dev/null +++ b/cli/testdata/coder_stat_--help.golden @@ -0,0 +1,15 @@ +Usage: coder stat [flags] [flags...] + +Display local system resource usage statistics + +stat calls can be used as the script for agent metadata blocks. + +Subcommands + cpu Display the system's cpu usage + +Options + -w, --watch duration + Continuously display the statistic on the given interval. + +--- +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_stat_cpu_--help.golden b/cli/testdata/coder_stat_cpu_--help.golden new file mode 100644 index 0000000000000..6d2f023b75a8d --- /dev/null +++ b/cli/testdata/coder_stat_cpu_--help.golden @@ -0,0 +1,13 @@ +Usage: coder stat cpu [flags] + +Display the system's cpu usage + +If inside a cgroup (e.g. docker container), the cpu usage is + +Options + -i, --interval duration (default: 0s) + The sample collection interval. If --watch is set, it overrides this + value. + +--- +Run `coder --help` for a list of global options. diff --git a/docs/cli.md b/docs/cli.md index be16ae30cc53d..c342461e251f1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -49,6 +49,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [speedtest](./cli/speedtest) | Run upload and download tests from your machine to a workspace | | [ssh](./cli/ssh) | Start a shell into a workspace | | [start](./cli/start) | Start a workspace | +| [stat](./cli/stat) | Display local system resource usage statistics | | [state](./cli/state) | Manually manage Terraform state to fix broken workspaces | | [stop](./cli/stop) | Stop a workspace | | [templates](./cli/templates) | Manage templates | diff --git a/docs/cli/stat.md b/docs/cli/stat.md new file mode 100644 index 0000000000000..517184aa5f356 --- /dev/null +++ b/docs/cli/stat.md @@ -0,0 +1,33 @@ + + +# stat + +Display local system resource usage statistics + +## Usage + +```console +coder stat [flags] [flags...] +``` + +## Description + +```console +stat calls can be used as the script for agent metadata blocks. +``` + +## Subcommands + +| Name | Purpose | +| ------------------------------ | ------------------------------ | +| [cpu](./stat_cpu) | Display the system's cpu usage | + +## Options + +### -w, --watch + +| | | +| ---- | --------------------- | +| Type | duration | + +Continuously display the statistic on the given interval. diff --git a/docs/cli/stat_cpu.md b/docs/cli/stat_cpu.md new file mode 100644 index 0000000000000..7e3cfa4db6733 --- /dev/null +++ b/docs/cli/stat_cpu.md @@ -0,0 +1,28 @@ + + +# stat cpu + +Display the system's cpu usage + +## Usage + +```console +coder stat cpu [flags] +``` + +## Description + +```console +If inside a cgroup (e.g. docker container), the cpu usage is +``` + +## Options + +### -i, --interval + +| | | +| ------- | --------------------- | +| Type | duration | +| Default | 0s | + +The sample collection interval. If --watch is set, it overrides this value. diff --git a/docs/manifest.json b/docs/manifest.json index c180249d002e4..8b9ceba94bb2e 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -677,6 +677,16 @@ "description": "Start a workspace", "path": "cli/start.md" }, + { + "title": "stat", + "description": "Display local system resource usage statistics", + "path": "cli/stat.md" + }, + { + "title": "stat cpu", + "description": "Display the system's cpu usage", + "path": "cli/stat_cpu.md" + }, { "title": "state", "description": "Manually manage Terraform state to fix broken workspaces", diff --git a/go.mod b/go.mod index 8484188b3c270..0ce4cfa488d91 100644 --- a/go.mod +++ b/go.mod @@ -127,6 +127,7 @@ require ( github.com/prometheus/client_golang v1.14.0 github.com/quasilyte/go-ruleguard/dsl v0.3.21 github.com/robfig/cron/v3 v3.0.1 + github.com/shirou/gopsutil v3.21.11+incompatible github.com/spf13/afero v1.9.3 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.1 @@ -187,6 +188,9 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect + github.com/tklauser/go-sysconf v0.3.9 // indirect + github.com/tklauser/numcpus v0.3.0 // indirect + github.com/yusufpapurcu/wmi v1.2.2 // indirect golang.org/x/text v0.8.0 // indirect golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde // indirect ) diff --git a/go.sum b/go.sum index 45a0e9f010fc7..7fdf934a79bb6 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= bazil.org/fuse v0.0.0-20200407214033-5883e5a4b512/go.mod h1:FbcW6z/2VytnFDhZfumh8Ss8zxHE6qpMP5sHTRe0EaM= bitbucket.org/creachadair/shell v0.0.6/go.mod h1:8Qqi/cYk7vPnsOePHroKXDJYmb5x7ENhtiFtfZq8K+M= -cdr.dev/slog v1.4.2-0.20230228204227-60d22dceaf04 h1:d5MQ+iI2zk7t0HrHwBP9p7k2XfRsXnRclSe8Kpp3xOo= -cdr.dev/slog v1.4.2-0.20230228204227-60d22dceaf04/go.mod h1:YPVZsUbRMaLaPgme0RzlPWlC7fI7YmDj/j/kZLuvICs= cdr.dev/slog v1.4.2 h1:fIfiqASYQFJBZiASwL825atyzeA96NsqSxx2aL61P8I= cdr.dev/slog v1.4.2/go.mod h1:0EkH+GkFNxizNR+GAXUEdUHanxUH5t9zqPILmPM/Vn8= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= @@ -1717,6 +1715,8 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil/v3 v3.21.10/go.mod h1:t75NhzCZ/dYyPQjyQmrAYP6c8+LCdFANeBMdLPCNnew= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -1842,7 +1842,9 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1: github.com/tetafro/godot v1.4.11/go.mod h1:LR3CJpxDVGlYOWn3ZZg1PgNZdTUvzsZWu8xaEohUpn8= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/timakin/bodyclose v0.0.0-20200424151742-cb6215831a94/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk= +github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo= github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs= +github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ= github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -1944,6 +1946,8 @@ github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M= github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= +github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= +github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=