From 5db9006e37cd37d10c008b4173157845a5e927a9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 29 May 2023 14:08:06 +0100 Subject: [PATCH 01/47] add stat command --- cli/root.go | 1 + cli/stat.go | 146 ++++++++++++++++++++++++++++++++++++++ cli/stat_internal_test.go | 25 +++++++ cli/stat_test.go | 40 +++++++++++ 4 files changed, 212 insertions(+) create mode 100644 cli/stat.go create mode 100644 cli/stat_internal_test.go create mode 100644 cli/stat_test.go diff --git a/cli/root.go b/cli/root.go index cec87f09d114e..71a9301236bcd 100644 --- a/cli/root.go +++ b/cli/root.go @@ -106,6 +106,7 @@ func (r *RootCmd) Core() []*clibase.Cmd { r.stop(), r.update(), r.restart(), + r.stat(), // Hidden r.gitssh(), diff --git a/cli/stat.go b/cli/stat.go new file mode 100644 index 0000000000000..05148d79f8a09 --- /dev/null +++ b/cli/stat.go @@ -0,0 +1,146 @@ +package cli + +import ( + "fmt" + "math" + "runtime" + "strconv" + "strings" + "time" + + "github.com/elastic/go-sysinfo" + + "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/cli/cliui" +) + +func (*RootCmd) stat() *clibase.Cmd { + var ( + sampleInterval time.Duration + formatter = cliui.NewOutputFormatter( + cliui.TextFormat(), + cliui.JSONFormat(), + ) + ) + + cmd := &clibase.Cmd{ + Use: "stat", + Short: "Show workspace resource usage.", + Options: clibase.OptionSet{ + { + Description: "Configure the sample interval.", + Flag: "sample-interval", + Value: clibase.DurationOf(&sampleInterval), + Default: "100ms", + }, + }, + Handler: func(inv *clibase.Invocation) error { + stats, err := newStats(sampleInterval) + if err != nil { + return err + } + out, err := formatter.Format(inv.Context(), stats) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + formatter.AttachOptions(&cmd.Options) + return cmd +} + +type stats struct { + HostCPU stat `json:"cpu_host"` + HostMemory stat `json:"mem_host"` + Disk stat `json:"disk"` + InContainer bool `json:"in_container,omitempty"` + ContainerCPU stat `json:"cpu_container,omitempty"` + ContainerMemory stat `json:"mem_container,omitempty"` +} + +func (s *stats) String() string { + var sb strings.Builder + sb.WriteString(s.HostCPU.String()) + sb.WriteString("\n") + sb.WriteString(s.HostMemory.String()) + sb.WriteString("\n") + sb.WriteString(s.Disk.String()) + sb.WriteString("\n") + if s.InContainer { + sb.WriteString(s.ContainerCPU.String()) + sb.WriteString("\n") + sb.WriteString(s.ContainerMemory.String()) + sb.WriteString("\n") + } + return sb.String() +} + +func newStats(dur time.Duration) (stats, error) { + var s stats + nproc := float64(runtime.NumCPU()) + // start := time.Now() + // ticksPerDur := dur / tickInterval + h1, err := sysinfo.Host() + if err != nil { + return s, err + } + <-time.After(dur) + h2, err := sysinfo.Host() + if err != nil { + return s, err + } + // elapsed := time.Since(start) + // numTicks := elapsed / tickInterval + cts1, err := h1.CPUTime() + if err != nil { + return s, err + } + cts2, err := h2.CPUTime() + if err != nil { + return s, err + } + // Assuming the total measured should add up to $(nproc) "cores", + // we determine a scaling factor such that scaleFactor * total = nproc. + // We then calculate used as the total time spent idle, and multiply + // that by scaleFactor to give a rough approximation of how busy the + // CPU(s) were. + s.HostCPU.Total = nproc + total := (cts2.Total() - cts1.Total()) + idle := (cts2.Idle - cts1.Idle) + used := total - idle + scaleFactor := nproc / total.Seconds() + s.HostCPU.Used = used.Seconds() * scaleFactor + s.HostCPU.Unit = "cores" + + return s, nil +} + +type stat struct { + Used float64 `json:"used"` + Total float64 `json:"total"` + Unit string `json:"unit"` +} + +func (s *stat) String() string { + var sb strings.Builder + _, _ = sb.WriteString(strconv.FormatFloat(s.Used, 'f', 1, 64)) + _, _ = sb.WriteString("/") + _, _ = sb.WriteString(strconv.FormatFloat(s.Total, 'f', 1, 64)) + _, _ = sb.WriteString(" ") + if s.Unit != "" { + _, _ = sb.WriteString(s.Unit) + _, _ = sb.WriteString(" ") + } + _, _ = sb.WriteString("(") + var pct float64 + if s.Total == 0 { + pct = math.NaN() + } else { + pct = s.Used / s.Total * 100 + } + _, _ = sb.WriteString(strconv.FormatFloat(pct, 'f', 1, 64)) + _, _ = sb.WriteString("%)") + return sb.String() +} diff --git a/cli/stat_internal_test.go b/cli/stat_internal_test.go new file mode 100644 index 0000000000000..5e0261cd9f141 --- /dev/null +++ b/cli/stat_internal_test.go @@ -0,0 +1,25 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStatString(t *testing.T) { + for _, tt := range []struct { + Expected string + Stat stat + }{ + { + Expected: "1.2/3.4 quatloos (50%)", + Stat: stat{Used: 1.2, Total: 3.4, Unit: "quatloos"}, + }, + { + Expected: "0/0 HP (NaN%)", + Stat: stat{Used: 0, Total: 0, Unit: "HP"}, + }, + } { + assert.Equal(t, tt.Expected, tt.Stat.String()) + } +} diff --git a/cli/stat_test.go b/cli/stat_test.go new file mode 100644 index 0000000000000..983af41bc1771 --- /dev/null +++ b/cli/stat_test.go @@ -0,0 +1,40 @@ +package cli_test + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/testutil" +) + +// This just tests that the stat command is recognized and does not output +// an empty string. Actually testing the output of the stat command is +// fraught with all sorts of fun. +func TestStatCmd(t *testing.T) { + t.Run("JSON", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "--output=json") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.NotEmpty(t, buf.String()) + }) + t.Run("Text", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "--output=text") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.NotEmpty(t, buf.String()) + }) +} From d6029b415cdfbf065d383a78beed970ec30d9df0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 7 Jun 2023 17:46:17 +0100 Subject: [PATCH 02/47] cpu working on mac --- cli/stat.go | 118 ++++++++++++++++---------------------- cli/stat_internal_test.go | 6 +- cli/stat_test.go | 37 ++++++++++-- 3 files changed, 82 insertions(+), 79 deletions(-) diff --git a/cli/stat.go b/cli/stat.go index 05148d79f8a09..beef78bb7e8c2 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -2,23 +2,28 @@ package cli import ( "fmt" - "math" "runtime" "strconv" "strings" "time" "github.com/elastic/go-sysinfo" + sysinfotypes "github.com/elastic/go-sysinfo/types" "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" ) func (*RootCmd) stat() *clibase.Cmd { + defaultCols := []string{"host_cpu", "host_memory", "disk"} + if isContainerized() { + // If running in a container, we assume that users want to see these first. Prepend. + defaultCols = append([]string{"container_cpu", "container_memory"}, defaultCols...) + } var ( sampleInterval time.Duration formatter = cliui.NewOutputFormatter( - cliui.TextFormat(), + cliui.TableFormat([]statsRow{}, defaultCols), cliui.JSONFormat(), ) ) @@ -35,11 +40,17 @@ func (*RootCmd) stat() *clibase.Cmd { }, }, Handler: func(inv *clibase.Invocation) error { - stats, err := newStats(sampleInterval) + hi, err := sysinfo.Host() if err != nil { return err } - out, err := formatter.Format(inv.Context(), stats) + sr := statsRow{} + if cs, err := statCPU(hi, sampleInterval); err != nil { + return err + } else { + sr.HostCPU = cs + } + out, err := formatter.Format(inv.Context(), []statsRow{sr}) if err != nil { return err } @@ -51,79 +62,47 @@ func (*RootCmd) stat() *clibase.Cmd { return cmd } -type stats struct { - HostCPU stat `json:"cpu_host"` - HostMemory stat `json:"mem_host"` - Disk stat `json:"disk"` - InContainer bool `json:"in_container,omitempty"` - ContainerCPU stat `json:"cpu_container,omitempty"` - ContainerMemory stat `json:"mem_container,omitempty"` -} - -func (s *stats) String() string { - var sb strings.Builder - sb.WriteString(s.HostCPU.String()) - sb.WriteString("\n") - sb.WriteString(s.HostMemory.String()) - sb.WriteString("\n") - sb.WriteString(s.Disk.String()) - sb.WriteString("\n") - if s.InContainer { - sb.WriteString(s.ContainerCPU.String()) - sb.WriteString("\n") - sb.WriteString(s.ContainerMemory.String()) - sb.WriteString("\n") - } - return sb.String() -} - -func newStats(dur time.Duration) (stats, error) { - var s stats +func statCPU(hi sysinfotypes.Host, interval time.Duration) (*stat, error) { nproc := float64(runtime.NumCPU()) - // start := time.Now() - // ticksPerDur := dur / tickInterval - h1, err := sysinfo.Host() - if err != nil { - return s, err - } - <-time.After(dur) - h2, err := sysinfo.Host() - if err != nil { - return s, err + s := &stat{ + Unit: "cores", } - // elapsed := time.Since(start) - // numTicks := elapsed / tickInterval - cts1, err := h1.CPUTime() + c1, err := hi.CPUTime() if err != nil { - return s, err + return nil, err } - cts2, err := h2.CPUTime() + <-time.After(interval) + c2, err := hi.CPUTime() if err != nil { - return s, err + return nil, err } - // Assuming the total measured should add up to $(nproc) "cores", - // we determine a scaling factor such that scaleFactor * total = nproc. - // We then calculate used as the total time spent idle, and multiply - // that by scaleFactor to give a rough approximation of how busy the - // CPU(s) were. - s.HostCPU.Total = nproc - total := (cts2.Total() - cts1.Total()) - idle := (cts2.Idle - cts1.Idle) + s.Total = nproc + total := c2.Total() - c1.Total() + idle := c2.Idle - c1.Idle used := total - idle scaleFactor := nproc / total.Seconds() - s.HostCPU.Used = used.Seconds() * scaleFactor - s.HostCPU.Unit = "cores" - + s.Used = used.Seconds() * scaleFactor return s, nil } +type statsRow struct { + HostCPU *stat `json:"host_cpu" table:"host_cpu,default_sort"` + HostMemory *stat `json:"host_memory" table:"host_memory"` + Disk *stat `json:"disk" table:"disk"` + ContainerCPU *stat `json:"container_cpu" table:"container_cpu"` + ContainerMemory *stat `json:"container_memory" table:"container_memory"` +} + type stat struct { - Used float64 `json:"used"` Total float64 `json:"total"` Unit string `json:"unit"` + Used float64 `json:"used"` } func (s *stat) String() string { + if s == nil { + return "-" + } var sb strings.Builder _, _ = sb.WriteString(strconv.FormatFloat(s.Used, 'f', 1, 64)) _, _ = sb.WriteString("/") @@ -131,16 +110,15 @@ func (s *stat) String() string { _, _ = sb.WriteString(" ") if s.Unit != "" { _, _ = sb.WriteString(s.Unit) - _, _ = sb.WriteString(" ") - } - _, _ = sb.WriteString("(") - var pct float64 - if s.Total == 0 { - pct = math.NaN() - } else { - pct = s.Used / s.Total * 100 } - _, _ = sb.WriteString(strconv.FormatFloat(pct, 'f', 1, 64)) - _, _ = sb.WriteString("%)") return sb.String() } + +func isContainerized() bool { + hi, err := sysinfo.Host() + if err != nil { + // If we can't get the host info, we have other issues. + panic(err) + } + return hi.Info().Containerized != nil && *hi.Info().Containerized +} diff --git a/cli/stat_internal_test.go b/cli/stat_internal_test.go index 5e0261cd9f141..861ba244d7804 100644 --- a/cli/stat_internal_test.go +++ b/cli/stat_internal_test.go @@ -12,11 +12,11 @@ func TestStatString(t *testing.T) { Stat stat }{ { - Expected: "1.2/3.4 quatloos (50%)", - Stat: stat{Used: 1.2, Total: 3.4, Unit: "quatloos"}, + Expected: "1.2/5.7 quatloos", + Stat: stat{Used: 1.234, Total: 5.678, Unit: "quatloos"}, }, { - Expected: "0/0 HP (NaN%)", + Expected: "0.0/0.0 HP", Stat: stat{Used: 0, Total: 0, Unit: "HP"}, }, } { diff --git a/cli/stat_test.go b/cli/stat_test.go index 983af41bc1771..87247b0756d0d 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -3,6 +3,8 @@ package cli_test import ( "bytes" "context" + "encoding/json" + "strings" "testing" "github.com/stretchr/testify/require" @@ -11,8 +13,8 @@ import ( "github.com/coder/coder/testutil" ) -// This just tests that the stat command is recognized and does not output -// an empty string. Actually testing the output of the stat command is +// This just tests that the statRow command is recognized and does not output +// an empty string. Actually testing the output of the stats command is // fraught with all sorts of fun. func TestStatCmd(t *testing.T) { t.Run("JSON", func(t *testing.T) { @@ -24,17 +26,40 @@ func TestStatCmd(t *testing.T) { inv.Stdout = buf err := inv.WithContext(ctx).Run() require.NoError(t, err) - require.NotEmpty(t, buf.String()) + s := buf.String() + require.NotEmpty(t, s) + // Must be valid JSON + tmp := make([]struct{}, 0) + require.NoError(t, json.NewDecoder(strings.NewReader(s)).Decode(&tmp)) }) - t.Run("Text", func(t *testing.T) { + t.Run("Table", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - inv, _ := clitest.New(t, "stat", "--output=text") + inv, _ := clitest.New(t, "stat", "--output=table") buf := new(bytes.Buffer) inv.Stdout = buf err := inv.WithContext(ctx).Run() require.NoError(t, err) - require.NotEmpty(t, buf.String()) + s := buf.String() + require.NotEmpty(t, s) + require.Contains(t, s, "HOST CPU") + require.Contains(t, s, "HOST MEMORY") + require.Contains(t, s, "DISK") + }) + t.Run("Default", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "--output=table") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + s := buf.String() + require.NotEmpty(t, s) + require.Contains(t, s, "HOST CPU") + require.Contains(t, s, "HOST MEMORY") + require.Contains(t, s, "DISK") }) } From 18f4942b1f3418dd3d15e758b22e4e7828ae3220 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 7 Jun 2023 17:54:06 +0100 Subject: [PATCH 03/47] add stat memory --- cli/stat.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cli/stat.go b/cli/stat.go index beef78bb7e8c2..22ee4dcf19200 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -50,6 +50,11 @@ func (*RootCmd) stat() *clibase.Cmd { } else { sr.HostCPU = cs } + if ms, err := statMem(hi); err != nil { + return err + } else { + sr.HostMemory = ms + } out, err := formatter.Format(inv.Context(), []statsRow{sr}) if err != nil { return err @@ -85,6 +90,19 @@ func statCPU(hi sysinfotypes.Host, interval time.Duration) (*stat, error) { return s, nil } +func statMem(hi sysinfotypes.Host) (*stat, error) { + s := &stat{ + Unit: "GB", + } + hm, err := hi.Memory() + if err != nil { + return nil, err + } + s.Total = float64(hm.Total) / 1024 / 1024 / 1024 + s.Used = float64(hm.Used) / 1024 / 1024 / 1024 + return s, nil +} + type statsRow struct { HostCPU *stat `json:"host_cpu" table:"host_cpu,default_sort"` HostMemory *stat `json:"host_memory" table:"host_memory"` From 251fddabba0c8ebcf12d70715744a6015820c7f4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 7 Jun 2023 18:09:41 +0100 Subject: [PATCH 04/47] support values with no total --- cli/stat.go | 37 +++++++++++++++++++++++++++---------- cli/stat_internal_test.go | 10 ++++++++-- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/cli/stat.go b/cli/stat.go index 22ee4dcf19200..8a297716f70a6 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -9,6 +9,7 @@ import ( "github.com/elastic/go-sysinfo" sysinfotypes "github.com/elastic/go-sysinfo/types" + "tailscale.com/types/ptr" "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/cliui" @@ -40,21 +41,26 @@ func (*RootCmd) stat() *clibase.Cmd { }, }, Handler: func(inv *clibase.Invocation) error { - hi, err := sysinfo.Host() + host, err := sysinfo.Host() if err != nil { return err } sr := statsRow{} - if cs, err := statCPU(hi, sampleInterval); err != nil { + if cs, err := statCPU(host, sampleInterval); err != nil { return err } else { sr.HostCPU = cs } - if ms, err := statMem(hi); err != nil { + if ms, err := statMem(host); err != nil { return err } else { sr.HostMemory = ms } + if ds, err := statDisk(host); err != nil { + return err + } else { + sr.Disk = ds + } out, err := formatter.Format(inv.Context(), []statsRow{sr}) if err != nil { return err @@ -81,7 +87,7 @@ func statCPU(hi sysinfotypes.Host, interval time.Duration) (*stat, error) { if err != nil { return nil, err } - s.Total = nproc + s.Total = ptr.To(nproc) total := c2.Total() - c1.Total() idle := c2.Idle - c1.Idle used := total - idle @@ -98,23 +104,32 @@ func statMem(hi sysinfotypes.Host) (*stat, error) { if err != nil { return nil, err } - s.Total = float64(hm.Total) / 1024 / 1024 / 1024 + s.Total = ptr.To(float64(hm.Total) / 1024 / 1024 / 1024) s.Used = float64(hm.Used) / 1024 / 1024 / 1024 return s, nil } +func statDisk(hi sysinfotypes.Host) (*stat, error) { + s := &stat{ + Unit: "GB", + } + return s, nil +} + type statsRow struct { HostCPU *stat `json:"host_cpu" table:"host_cpu,default_sort"` HostMemory *stat `json:"host_memory" table:"host_memory"` Disk *stat `json:"disk" table:"disk"` + LoadNorm *stat `json:"load_norm" table:"load_norm"` ContainerCPU *stat `json:"container_cpu" table:"container_cpu"` ContainerMemory *stat `json:"container_memory" table:"container_memory"` + Uptime *stat `json:"uptime" table:"uptime"` } type stat struct { - Total float64 `json:"total"` - Unit string `json:"unit"` - Used float64 `json:"used"` + Total *float64 `json:"total"` + Unit string `json:"unit"` + Used float64 `json:"used"` } func (s *stat) String() string { @@ -123,8 +138,10 @@ func (s *stat) String() string { } var sb strings.Builder _, _ = sb.WriteString(strconv.FormatFloat(s.Used, 'f', 1, 64)) - _, _ = sb.WriteString("/") - _, _ = sb.WriteString(strconv.FormatFloat(s.Total, 'f', 1, 64)) + if s.Total != (*float64)(nil) { + _, _ = sb.WriteString("/") + _, _ = sb.WriteString(strconv.FormatFloat(*s.Total, 'f', 1, 64)) + } _, _ = sb.WriteString(" ") if s.Unit != "" { _, _ = sb.WriteString(s.Unit) diff --git a/cli/stat_internal_test.go b/cli/stat_internal_test.go index 861ba244d7804..86f6adfa38b21 100644 --- a/cli/stat_internal_test.go +++ b/cli/stat_internal_test.go @@ -3,6 +3,8 @@ package cli import ( "testing" + "tailscale.com/types/ptr" + "github.com/stretchr/testify/assert" ) @@ -13,11 +15,15 @@ func TestStatString(t *testing.T) { }{ { Expected: "1.2/5.7 quatloos", - Stat: stat{Used: 1.234, Total: 5.678, Unit: "quatloos"}, + Stat: stat{Used: 1.234, Total: ptr.To(5.678), Unit: "quatloos"}, }, { Expected: "0.0/0.0 HP", - Stat: stat{Used: 0, Total: 0, Unit: "HP"}, + Stat: stat{Used: 0, Total: ptr.To(0.0), Unit: "HP"}, + }, + { + Expected: "123.0 seconds", + Stat: stat{Used: 123.0, Total: nil, Unit: "seconds"}, }, } { assert.Equal(t, tt.Expected, tt.Stat.String()) From 4c081dc147c1308f96dcb8dab396561170b50344 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 11:27:55 +0100 Subject: [PATCH 05/47] move clistats to its own package --- cli/clistat/disk.go | 26 +++++ cli/clistat/disk_windows.go | 35 +++++++ cli/clistat/stat.go | 162 ++++++++++++++++++++++++++++++ cli/clistat/stat_internal_test.go | 35 +++++++ cli/stat.go | 127 ++++++----------------- cli/stat_internal_test.go | 31 ------ cli/stat_test.go | 10 +- 7 files changed, 294 insertions(+), 132 deletions(-) create mode 100644 cli/clistat/disk.go create mode 100644 cli/clistat/disk_windows.go create mode 100644 cli/clistat/stat.go create mode 100644 cli/clistat/stat_internal_test.go delete mode 100644 cli/stat_internal_test.go diff --git a/cli/clistat/disk.go b/cli/clistat/disk.go new file mode 100644 index 0000000000000..d82fb998745b2 --- /dev/null +++ b/cli/clistat/disk.go @@ -0,0 +1,26 @@ +//go:build !windows + +package clistat + +import ( + "syscall" + + "tailscale.com/types/ptr" +) + +// Disk returns the disk usage of the given path. +// If path is empty, it returns the usage of the root directory. +func (s *Statter) Disk(path string) (*Result, error) { + if path == "" { + path = "/" + } + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return nil, err + } + var r Result + r.Total = ptr.To(float64(stat.Blocks*uint64(stat.Bsize)) / 1024 / 1024 / 1024) + r.Used = float64(stat.Blocks-stat.Bfree) * float64(stat.Bsize) / 1024 / 1024 / 1024 + r.Unit = "GB" + return &r, nil +} diff --git a/cli/clistat/disk_windows.go b/cli/clistat/disk_windows.go new file mode 100644 index 0000000000000..91706d6f28584 --- /dev/null +++ b/cli/clistat/disk_windows.go @@ -0,0 +1,35 @@ +package clistat + +import ( + "golang.org/x/sys/windows" + "tailscale.com/types/ptr" +) + +// Disk returns the disk usage of the given path. +// If path is empty, it defaults to C:\ +func (s *Statter) Disk(path string) (*Result, error) { + if path == "" { + path = `C:\` + } + + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + + var freeBytes, totalBytes, availBytes uint64 + if err := windows.GetDiskFreeSpaceEx( + pathPtr, + &freeBytes, + &totalBytes, + &availBytes, + ); err != nil { + return nil, err + } + + var r Result + r.Total = ptr.To(float64(totalBytes) / 1024 / 1024 / 1024) + r.Used = ptr.To(float64(totalBytes-freeBytes) / 1024 / 1024 / 1024) + r.Unit = "GB" + return &r, nil +} diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go new file mode 100644 index 0000000000000..d6e96b0b0326b --- /dev/null +++ b/cli/clistat/stat.go @@ -0,0 +1,162 @@ +package clistat + +import ( + "github.com/elastic/go-sysinfo" + "golang.org/x/xerrors" + "runtime" + "strconv" + "strings" + "tailscale.com/types/ptr" + "time" + + sysinfotypes "github.com/elastic/go-sysinfo/types" +) + +// Result is a generic result type for a statistic. +// Total is the total amount of the resource available. +// It is nil if the resource is not a finite quantity. +// Unit is the unit of the resource. +// Used is the amount of the resource used. +type Result struct { + Total *float64 `json:"total"` + Unit string `json:"unit"` + Used float64 `json:"used"` +} + +// String returns a human-readable representation of the result. +func (r *Result) String() string { + if r == nil { + return "-" + } + var sb strings.Builder + _, _ = sb.WriteString(strconv.FormatFloat(r.Used, 'f', 1, 64)) + if r.Total != (*float64)(nil) { + _, _ = sb.WriteString("/") + _, _ = sb.WriteString(strconv.FormatFloat(*r.Total, 'f', 1, 64)) + } + if r.Unit != "" { + _, _ = sb.WriteString(" ") + _, _ = sb.WriteString(r.Unit) + } + return sb.String() +} + +// Statter is a system statistics collector. +// It is a thin wrapper around the elastic/go-sysinfo library. +type Statter struct { + hi sysinfotypes.Host + sampleInterval time.Duration +} + +type Option func(*Statter) + +// WithSampleInterval sets the sample interval for the statter. +func WithSampleInterval(d time.Duration) Option { + return func(s *Statter) { + s.sampleInterval = d + } +} + +func New(opts ...Option) (*Statter, error) { + hi, err := sysinfo.Host() + if err != nil { + return nil, xerrors.Errorf("get host info: %w", err) + } + s := &Statter{ + hi: hi, + sampleInterval: 100 * time.Millisecond, + } + for _, opt := range opts { + opt(s) + } + return s, nil +} + +// HostCPU returns the CPU usage of the host. This is calculated by +// taking two samples of CPU usage and calculating the difference. +// Total will always be equal to the number of cores. +// Used will be an estimate of the number of cores used during the sample interval. +// This is calculated by taking the difference between the total and idle HostCPU time +// and scaling it by the number of cores. +// Units are in "cores". +func (s *Statter) HostCPU() (*Result, error) { + nproc := float64(runtime.NumCPU()) + r := &Result{ + Unit: "cores", + } + c1, err := s.hi.CPUTime() + if err != nil { + return nil, xerrors.Errorf("get first cpu sample: %w", err) + } + <-time.After(s.sampleInterval) + c2, err := s.hi.CPUTime() + if err != nil { + return nil, xerrors.Errorf("get second cpu sample: %w", err) + } + r.Total = ptr.To(nproc) + total := c2.Total() - c1.Total() + idle := c2.Idle - c1.Idle + used := total - idle + scaleFactor := nproc / total.Seconds() + r.Used = used.Seconds() * scaleFactor + return r, nil +} + +// HostMemory returns the memory usage of the host, in gigabytes. +func (s *Statter) HostMemory() (*Result, error) { + r := &Result{ + Unit: "GB", + } + hm, err := s.hi.Memory() + if err != nil { + return nil, xerrors.Errorf("get memory info: %w", err) + } + r.Total = ptr.To(float64(hm.Total) / 1024 / 1024 / 1024) + r.Used = float64(hm.Used) / 1024 / 1024 / 1024 + return r, nil +} + +// Uptime returns the uptime of the host, in seconds. +// If the host is containerized, this will return the uptime of the container +// by checking /proc/1/stat. +func (s *Statter) Uptime() (*Result, error) { + r := &Result{ + Unit: "seconds", + Total: nil, // Is time a finite quantity? For this purpose, no. + } + + if ok := IsContainerized(); ok != nil && *ok { + procStat, err := sysinfo.Process(1) + if err != nil { + return nil, xerrors.Errorf("get pid 1 info: %w", err) + } + procInfo, err := procStat.Info() + if err != nil { + return nil, xerrors.Errorf("get pid 1 stat: %w", err) + } + r.Used = time.Since(procInfo.StartTime).Seconds() + return r, nil + } + r.Used = s.hi.Info().Uptime().Seconds() + return r, nil +} + +// ContainerCPU returns the CPU usage of the container. +func (s *Statter) ContainerCPU() (*Result, error) { + return nil, xerrors.Errorf("not implemented") +} + +// ContainerMemory returns the memory usage of the container. +func (s *Statter) ContainerMemory() (*Result, error) { + return nil, xerrors.Errorf("not implemented") +} + +// IsContainerized returns whether the host is containerized. +// This wraps the elastic/go-sysinfo library. +func IsContainerized() *bool { + hi, err := sysinfo.Host() + if err != nil { + return nil + } + return hi.Info().Containerized +} diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go new file mode 100644 index 0000000000000..566dd3aa7f2e5 --- /dev/null +++ b/cli/clistat/stat_internal_test.go @@ -0,0 +1,35 @@ +package clistat + +import ( + "testing" + + "tailscale.com/types/ptr" + + "github.com/stretchr/testify/assert" +) + +func TestResultString(t *testing.T) { + for _, tt := range []struct { + Expected string + Result Result + }{ + { + Expected: "1.2/5.7 quatloos", + Result: Result{Used: 1.234, Total: ptr.To(5.678), Unit: "quatloos"}, + }, + { + Expected: "0.0/0.0 HP", + Result: Result{Used: 0.0, Total: ptr.To(0.0), Unit: "HP"}, + }, + { + Expected: "123.0 seconds", + Result: Result{Used: 123.01, Total: nil, Unit: "seconds"}, + }, + { + Expected: "12.3", + Result: Result{Used: 12.34, Total: nil, Unit: ""}, + }, + } { + assert.Equal(t, tt.Expected, tt.Result.String()) + } +} diff --git a/cli/stat.go b/cli/stat.go index 8a297716f70a6..289a77f6503c7 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -2,22 +2,17 @@ package cli import ( "fmt" - "runtime" - "strconv" - "strings" + "os" "time" - "github.com/elastic/go-sysinfo" - sysinfotypes "github.com/elastic/go-sysinfo/types" - "tailscale.com/types/ptr" - "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/cli/clistat" "github.com/coder/coder/cli/cliui" ) func (*RootCmd) stat() *clibase.Cmd { - defaultCols := []string{"host_cpu", "host_memory", "disk"} - if isContainerized() { + defaultCols := []string{"host_cpu", "host_memory", "home_disk", "uptime"} + if ok := clistat.IsContainerized(); ok != nil && *ok { // If running in a container, we assume that users want to see these first. Prepend. defaultCols = append([]string{"container_cpu", "container_memory"}, defaultCols...) } @@ -41,26 +36,40 @@ func (*RootCmd) stat() *clibase.Cmd { }, }, Handler: func(inv *clibase.Invocation) error { - host, err := sysinfo.Host() - if err != nil { + var s *clistat.Statter + if st, err := clistat.New(clistat.WithSampleInterval(sampleInterval)); err != nil { return err + } else { + s = st } - sr := statsRow{} - if cs, err := statCPU(host, sampleInterval); err != nil { + + var sr statsRow + if cs, err := s.HostCPU(); err != nil { return err } else { sr.HostCPU = cs } - if ms, err := statMem(host); err != nil { + + if ms, err := s.HostMemory(); err != nil { return err } else { sr.HostMemory = ms } - if ds, err := statDisk(host); err != nil { + + if home, err := os.UserHomeDir(); err != nil { + return err + } else if ds, err := s.Disk(home); err != nil { return err } else { sr.Disk = ds } + + if us, err := s.Uptime(); err != nil { + return err + } else { + sr.Uptime = us + } + out, err := formatter.Format(inv.Context(), []statsRow{sr}) if err != nil { return err @@ -73,87 +82,11 @@ func (*RootCmd) stat() *clibase.Cmd { return cmd } -func statCPU(hi sysinfotypes.Host, interval time.Duration) (*stat, error) { - nproc := float64(runtime.NumCPU()) - s := &stat{ - Unit: "cores", - } - c1, err := hi.CPUTime() - if err != nil { - return nil, err - } - <-time.After(interval) - c2, err := hi.CPUTime() - if err != nil { - return nil, err - } - s.Total = ptr.To(nproc) - total := c2.Total() - c1.Total() - idle := c2.Idle - c1.Idle - used := total - idle - scaleFactor := nproc / total.Seconds() - s.Used = used.Seconds() * scaleFactor - return s, nil -} - -func statMem(hi sysinfotypes.Host) (*stat, error) { - s := &stat{ - Unit: "GB", - } - hm, err := hi.Memory() - if err != nil { - return nil, err - } - s.Total = ptr.To(float64(hm.Total) / 1024 / 1024 / 1024) - s.Used = float64(hm.Used) / 1024 / 1024 / 1024 - return s, nil -} - -func statDisk(hi sysinfotypes.Host) (*stat, error) { - s := &stat{ - Unit: "GB", - } - return s, nil -} - type statsRow struct { - HostCPU *stat `json:"host_cpu" table:"host_cpu,default_sort"` - HostMemory *stat `json:"host_memory" table:"host_memory"` - Disk *stat `json:"disk" table:"disk"` - LoadNorm *stat `json:"load_norm" table:"load_norm"` - ContainerCPU *stat `json:"container_cpu" table:"container_cpu"` - ContainerMemory *stat `json:"container_memory" table:"container_memory"` - Uptime *stat `json:"uptime" table:"uptime"` -} - -type stat struct { - Total *float64 `json:"total"` - Unit string `json:"unit"` - Used float64 `json:"used"` -} - -func (s *stat) String() string { - if s == nil { - return "-" - } - var sb strings.Builder - _, _ = sb.WriteString(strconv.FormatFloat(s.Used, 'f', 1, 64)) - if s.Total != (*float64)(nil) { - _, _ = sb.WriteString("/") - _, _ = sb.WriteString(strconv.FormatFloat(*s.Total, 'f', 1, 64)) - } - _, _ = sb.WriteString(" ") - if s.Unit != "" { - _, _ = sb.WriteString(s.Unit) - } - return sb.String() -} - -func isContainerized() bool { - hi, err := sysinfo.Host() - if err != nil { - // If we can't get the host info, we have other issues. - panic(err) - } - return hi.Info().Containerized != nil && *hi.Info().Containerized + HostCPU *clistat.Result `json:"host_cpu" table:"host_cpu,default_sort"` + HostMemory *clistat.Result `json:"host_memory" table:"host_memory"` + Disk *clistat.Result `json:"home_disk" table:"home_disk"` + ContainerCPU *clistat.Result `json:"container_cpu" table:"container_cpu"` + ContainerMemory *clistat.Result `json:"container_memory" table:"container_memory"` + Uptime *clistat.Result `json:"uptime" table:"uptime"` } diff --git a/cli/stat_internal_test.go b/cli/stat_internal_test.go deleted file mode 100644 index 86f6adfa38b21..0000000000000 --- a/cli/stat_internal_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package cli - -import ( - "testing" - - "tailscale.com/types/ptr" - - "github.com/stretchr/testify/assert" -) - -func TestStatString(t *testing.T) { - for _, tt := range []struct { - Expected string - Stat stat - }{ - { - Expected: "1.2/5.7 quatloos", - Stat: stat{Used: 1.234, Total: ptr.To(5.678), Unit: "quatloos"}, - }, - { - Expected: "0.0/0.0 HP", - Stat: stat{Used: 0, Total: ptr.To(0.0), Unit: "HP"}, - }, - { - Expected: "123.0 seconds", - Stat: stat{Used: 123.0, Total: nil, Unit: "seconds"}, - }, - } { - assert.Equal(t, tt.Expected, tt.Stat.String()) - } -} diff --git a/cli/stat_test.go b/cli/stat_test.go index 87247b0756d0d..2b854bcd19273 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -13,7 +13,7 @@ import ( "github.com/coder/coder/testutil" ) -// This just tests that the statRow command is recognized and does not output +// This just tests that the stat command is recognized and does not output // an empty string. Actually testing the output of the stats command is // fraught with all sorts of fun. func TestStatCmd(t *testing.T) { @@ -45,13 +45,14 @@ func TestStatCmd(t *testing.T) { require.NotEmpty(t, s) require.Contains(t, s, "HOST CPU") require.Contains(t, s, "HOST MEMORY") - require.Contains(t, s, "DISK") + require.Contains(t, s, "HOME DISK") + require.Contains(t, s, "UPTIME") }) t.Run("Default", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - inv, _ := clitest.New(t, "stat", "--output=table") + inv, _ := clitest.New(t, "stat") buf := new(bytes.Buffer) inv.Stdout = buf err := inv.WithContext(ctx).Run() @@ -60,6 +61,7 @@ func TestStatCmd(t *testing.T) { require.NotEmpty(t, s) require.Contains(t, s, "HOST CPU") require.Contains(t, s, "HOST MEMORY") - require.Contains(t, s, "DISK") + require.Contains(t, s, "HOME DISK") + require.Contains(t, s, "UPTIME") }) } From 2ba739208e1d942da2b0a56b643b69cdf99b603d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 14:32:59 +0100 Subject: [PATCH 06/47] fix container detection to work with sysbox containers --- cli/clistat/stat.go | 56 +++++++++++++++++++++++++++++++++++++-------- cli/stat.go | 15 +++++++++++- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index d6e96b0b0326b..34a92d4a38b49 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -1,17 +1,23 @@ package clistat import ( - "github.com/elastic/go-sysinfo" - "golang.org/x/xerrors" + "bufio" + "bytes" + "os" "runtime" "strconv" "strings" - "tailscale.com/types/ptr" "time" + "github.com/elastic/go-sysinfo" + "golang.org/x/xerrors" + "tailscale.com/types/ptr" + sysinfotypes "github.com/elastic/go-sysinfo/types" ) +const procOneCgroup = "/proc/1/cgroup" + // Result is a generic result type for a statistic. // Total is the total amount of the resource available. // It is nil if the resource is not a finite quantity. @@ -125,7 +131,7 @@ func (s *Statter) Uptime() (*Result, error) { Total: nil, // Is time a finite quantity? For this purpose, no. } - if ok := IsContainerized(); ok != nil && *ok { + if ok, err := IsContainerized(); err == nil && ok { procStat, err := sysinfo.Process(1) if err != nil { return nil, xerrors.Errorf("get pid 1 info: %w", err) @@ -152,11 +158,43 @@ func (s *Statter) ContainerMemory() (*Result, error) { } // IsContainerized returns whether the host is containerized. -// This wraps the elastic/go-sysinfo library. -func IsContainerized() *bool { - hi, err := sysinfo.Host() +// This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 +// with modifications to support Sysbox containers. +func IsContainerized() (bool, error) { + data, err := os.ReadFile(procOneCgroup) + if err != nil { + if os.IsNotExist(err) { // how? + return false, nil + } + return false, xerrors.Errorf("read process cgroups: %w", err) + } + + s := bufio.NewScanner(bytes.NewReader(data)) + for s.Scan() { + line := s.Bytes() + if bytes.Contains(line, []byte("docker")) || + bytes.Contains(line, []byte(".slice")) || + bytes.Contains(line, []byte("lxc")) || + bytes.Contains(line, []byte("kubepods")) { + return true, nil + } + } + + // Last-ditch effort to detect Sysbox containers. + // Check if we have anything mounted as type sysboxfs in /proc/mounts + data, err = os.ReadFile("/proc/mounts") if err != nil { - return nil + return false, xerrors.Errorf("read /proc/mounts: %w", err) } - return hi.Info().Containerized + + s = bufio.NewScanner(bytes.NewReader(data)) + for s.Scan() { + line := s.Bytes() + if bytes.HasPrefix(line, []byte("sysboxfs")) { + return true, nil + } + } + + // If we get here, we are _probably_ not running in a container. + return false, nil } diff --git a/cli/stat.go b/cli/stat.go index 289a77f6503c7..d408582f1db8f 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -12,7 +12,7 @@ import ( func (*RootCmd) stat() *clibase.Cmd { defaultCols := []string{"host_cpu", "host_memory", "home_disk", "uptime"} - if ok := clistat.IsContainerized(); ok != nil && *ok { + if ok, err := clistat.IsContainerized(); err == nil && ok { // If running in a container, we assume that users want to see these first. Prepend. defaultCols = append([]string{"container_cpu", "container_memory"}, defaultCols...) } @@ -70,6 +70,19 @@ func (*RootCmd) stat() *clibase.Cmd { sr.Uptime = us } + if ok, err := clistat.IsContainerized(); err == nil && ok { + if cs, err := s.ContainerCPU(); err != nil { + return err + } else { + sr.ContainerCPU = cs + } + if ms, err := s.ContainerMemory(); err != nil { + return err + } else { + sr.ContainerMemory = ms + } + } + out, err := formatter.Format(inv.Context(), []statsRow{sr}) if err != nil { return err From 0e1c96af882b566b4ff7f1abda714abd77ad9e59 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 14:36:22 +0100 Subject: [PATCH 07/47] add cross-platform declaration for IsContainerized() --- cli/clistat/container.go | 11 +++++++ cli/clistat/container_linux.go | 52 ++++++++++++++++++++++++++++++++++ cli/clistat/stat.go | 45 ----------------------------- 3 files changed, 63 insertions(+), 45 deletions(-) create mode 100644 cli/clistat/container.go create mode 100644 cli/clistat/container_linux.go diff --git a/cli/clistat/container.go b/cli/clistat/container.go new file mode 100644 index 0000000000000..5a879de3681a2 --- /dev/null +++ b/cli/clistat/container.go @@ -0,0 +1,11 @@ +//go:build !linux + +package clistat + +// IsContainerized returns whether the host is containerized. +// This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 +// with modifications to support Sysbox containers. +// On non-Linux platforms, it always returns false. +func IsContainerized() (bool, error) { + return false, nil +} diff --git a/cli/clistat/container_linux.go b/cli/clistat/container_linux.go new file mode 100644 index 0000000000000..0c9361db3b6eb --- /dev/null +++ b/cli/clistat/container_linux.go @@ -0,0 +1,52 @@ +package clistat + +import ( + "bufio" + "bytes" + "os" + + "golang.org/x/xerrors" +) + +// IsContainerized returns whether the host is containerized. +// This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 +// with modifications to support Sysbox containers. +// On non-Linux platforms, it always returns false. +func IsContainerized() (bool, error) { + data, err := os.ReadFile(procOneCgroup) + if err != nil { + if os.IsNotExist(err) { // how? + return false, nil + } + return false, xerrors.Errorf("read process cgroups: %w", err) + } + + s := bufio.NewScanner(bytes.NewReader(data)) + for s.Scan() { + line := s.Bytes() + if bytes.Contains(line, []byte("docker")) || + bytes.Contains(line, []byte(".slice")) || + bytes.Contains(line, []byte("lxc")) || + bytes.Contains(line, []byte("kubepods")) { + return true, nil + } + } + + // Last-ditch effort to detect Sysbox containers. + // Check if we have anything mounted as type sysboxfs in /proc/mounts + data, err = os.ReadFile("/proc/mounts") + if err != nil { + return false, xerrors.Errorf("read /proc/mounts: %w", err) + } + + s = bufio.NewScanner(bytes.NewReader(data)) + for s.Scan() { + line := s.Bytes() + if bytes.HasPrefix(line, []byte("sysboxfs")) { + return true, nil + } + } + + // If we get here, we are _probably_ not running in a container. + return false, nil +} diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 34a92d4a38b49..5f6e39d0098fd 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -1,9 +1,6 @@ package clistat import ( - "bufio" - "bytes" - "os" "runtime" "strconv" "strings" @@ -156,45 +153,3 @@ func (s *Statter) ContainerCPU() (*Result, error) { func (s *Statter) ContainerMemory() (*Result, error) { return nil, xerrors.Errorf("not implemented") } - -// IsContainerized returns whether the host is containerized. -// This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 -// with modifications to support Sysbox containers. -func IsContainerized() (bool, error) { - data, err := os.ReadFile(procOneCgroup) - if err != nil { - if os.IsNotExist(err) { // how? - return false, nil - } - return false, xerrors.Errorf("read process cgroups: %w", err) - } - - s := bufio.NewScanner(bytes.NewReader(data)) - for s.Scan() { - line := s.Bytes() - if bytes.Contains(line, []byte("docker")) || - bytes.Contains(line, []byte(".slice")) || - bytes.Contains(line, []byte("lxc")) || - bytes.Contains(line, []byte("kubepods")) { - return true, nil - } - } - - // Last-ditch effort to detect Sysbox containers. - // Check if we have anything mounted as type sysboxfs in /proc/mounts - data, err = os.ReadFile("/proc/mounts") - if err != nil { - return false, xerrors.Errorf("read /proc/mounts: %w", err) - } - - s = bufio.NewScanner(bytes.NewReader(data)) - for s.Scan() { - line := s.Bytes() - if bytes.HasPrefix(line, []byte("sysboxfs")) { - return true, nil - } - } - - // If we get here, we are _probably_ not running in a container. - return false, nil -} From 0f9859ef06feb8177df4ce7fc77c4bc08ef05396 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 15:07:12 +0100 Subject: [PATCH 08/47] add a sync.Once to IsContainerized() --- cli/clistat/container_linux.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/cli/clistat/container_linux.go b/cli/clistat/container_linux.go index 0c9361db3b6eb..4fd8a30fdec5b 100644 --- a/cli/clistat/container_linux.go +++ b/cli/clistat/container_linux.go @@ -4,15 +4,31 @@ import ( "bufio" "bytes" "os" + "sync" + "go.uber.org/atomic" "golang.org/x/xerrors" ) +var isContainerizedCacheOK atomic.Bool +var isContainerizedCacheErr atomic.Error +var isContainerizedCacheOnce sync.Once + // IsContainerized returns whether the host is containerized. // This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 // with modifications to support Sysbox containers. // On non-Linux platforms, it always returns false. -func IsContainerized() (bool, error) { +// The result is only computed once and stored for subsequent calls. +func IsContainerized() (ok bool, err error) { + isContainerizedCacheOnce.Do(func() { + ok, err = isContainerizedOnce() + isContainerizedCacheOK.Store(ok) + isContainerizedCacheErr.Store(err) + }) + return isContainerizedCacheOK.Load(), isContainerizedCacheErr.Load() +} + +func isContainerizedOnce() (bool, error) { data, err := os.ReadFile(procOneCgroup) if err != nil { if os.IsNotExist(err) { // how? From a220c7fe8e546366651865ec3ec8784247f81b5b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 15:49:28 +0100 Subject: [PATCH 09/47] make uptime minutes --- cli/clistat/stat.go | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 5f6e39d0098fd..7d1495bae0960 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -124,7 +124,7 @@ func (s *Statter) HostMemory() (*Result, error) { // by checking /proc/1/stat. func (s *Statter) Uptime() (*Result, error) { r := &Result{ - Unit: "seconds", + Unit: "minutes", Total: nil, // Is time a finite quantity? For this purpose, no. } @@ -137,19 +137,9 @@ func (s *Statter) Uptime() (*Result, error) { if err != nil { return nil, xerrors.Errorf("get pid 1 stat: %w", err) } - r.Used = time.Since(procInfo.StartTime).Seconds() + r.Used = time.Since(procInfo.StartTime).Minutes() return r, nil } - r.Used = s.hi.Info().Uptime().Seconds() + r.Used = s.hi.Info().Uptime().Minutes() return r, nil } - -// ContainerCPU returns the CPU usage of the container. -func (s *Statter) ContainerCPU() (*Result, error) { - return nil, xerrors.Errorf("not implemented") -} - -// ContainerMemory returns the memory usage of the container. -func (s *Statter) ContainerMemory() (*Result, error) { - return nil, xerrors.Errorf("not implemented") -} From 89f7e8d0602ad25be7fab11666632f9fc3bcc35d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 15:49:47 +0100 Subject: [PATCH 10/47] lint --- cli/clistat/container_linux.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cli/clistat/container_linux.go b/cli/clistat/container_linux.go index 4fd8a30fdec5b..7b42cc824b9bb 100644 --- a/cli/clistat/container_linux.go +++ b/cli/clistat/container_linux.go @@ -10,9 +10,11 @@ import ( "golang.org/x/xerrors" ) -var isContainerizedCacheOK atomic.Bool -var isContainerizedCacheErr atomic.Error -var isContainerizedCacheOnce sync.Once +var ( + isContainerizedCacheOK atomic.Bool + isContainerizedCacheErr atomic.Error + isContainerizedCacheOnce sync.Once +) // IsContainerized returns whether the host is containerized. // This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 From c51e2452a61f0ea123e4b97875a656b32b527fb6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 16:11:04 +0100 Subject: [PATCH 11/47] extract nproc to variable --- cli/clistat/stat.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 7d1495bae0960..3938a09417d2d 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -15,6 +15,8 @@ import ( const procOneCgroup = "/proc/1/cgroup" +var nproc = float64(runtime.NumCPU()) + // Result is a generic result type for a statistic. // Total is the total amount of the resource available. // It is nil if the resource is not a finite quantity. @@ -83,9 +85,9 @@ func New(opts ...Option) (*Statter, error) { // and scaling it by the number of cores. // Units are in "cores". func (s *Statter) HostCPU() (*Result, error) { - nproc := float64(runtime.NumCPU()) r := &Result{ - Unit: "cores", + Unit: "cores", + Total: ptr.To(nproc), } c1, err := s.hi.CPUTime() if err != nil { @@ -96,7 +98,6 @@ func (s *Statter) HostCPU() (*Result, error) { if err != nil { return nil, xerrors.Errorf("get second cpu sample: %w", err) } - r.Total = ptr.To(nproc) total := c2.Total() - c1.Total() idle := c2.Idle - c1.Idle used := total - idle From 3528c006fdf093efd1b1ef12bb92ae573929e553 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 16:11:52 +0100 Subject: [PATCH 12/47] add skeleton of cgroup stuff --- cli/clistat/cgroup.go | 15 +++++++ cli/clistat/cgroup_linux.go | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 cli/clistat/cgroup.go create mode 100644 cli/clistat/cgroup_linux.go diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go new file mode 100644 index 0000000000000..02298ba818929 --- /dev/null +++ b/cli/clistat/cgroup.go @@ -0,0 +1,15 @@ +//go:build !linux + +package clistat + +// ContainerCPU returns the CPU usage of the container cgroup. +// On non-Linux platforms, this always returns nil. +func (s *Statter) ContainerCPU() (*Result, error) { + return nil, nil +} + +// ContainerMemory returns the memory usage of the container cgroup. +// On non-Linux platforms, this always returns nil. +func (s *Statter) ContainerMemory() (*Result, error) { + return nil, nil +} diff --git a/cli/clistat/cgroup_linux.go b/cli/clistat/cgroup_linux.go new file mode 100644 index 0000000000000..041e87272bdc9 --- /dev/null +++ b/cli/clistat/cgroup_linux.go @@ -0,0 +1,83 @@ +package clistat + +import ( + "time" + + "tailscale.com/types/ptr" + + "golang.org/x/xerrors" +) + +const () + +// CGroupCPU returns the CPU usage of the container cgroup. +// On non-Linux platforms, this always returns nil. +func (s *Statter) ContainerCPU() (*Result, error) { + // Firstly, check if we are containerized. + if ok, err := IsContainerized(); err != nil || !ok { + return nil, nil + } + + used1, total1, err := cgroupCPU() + if err != nil { + return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) + } + <-time.After(s.sampleInterval) + used2, total2, err := cgroupCPU() + if err != nil { + return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) + } + + return &Result{ + Unit: "cores", + Used: used2 - used1, + Total: ptr.To(total2 - total1), + }, nil +} + +func cgroupCPU() (used, total float64, err error) { + if isCGroupV2() { + return cGroupv2CPU() + } + + // Fall back to CGroupv1 + return cGroupv1CPU() +} + +func isCGroupV2() bool { + // TODO implement + return false +} + +func cGroupv2CPU() (float64, float64, error) { + // TODO: implement + return 0, 0, nil +} + +func cGroupv1CPU() (float64, float64, error) { + // TODO: implement + return 0, 0, nil +} + +func (s *Statter) ContainerMemory() (*Result, error) { + if ok, err := IsContainerized(); err != nil || !ok { + return nil, nil + } + + if isCGroupV2() { + return cGroupv2Memory() + } + + // Fall back to CGroupv1 + return cGroupv1Memory() +} + +func cGroupv2Memory() (*Result, error) { + // TODO implement + return nil, nil +} + +func cGroupv1Memory() (*Result, error) { + // TODO implement + return nil, nil +} From 7108c6e19992c59d6d46af570e646349f78aa42b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 17:06:05 +0100 Subject: [PATCH 13/47] initial cgroupv2 cpu implementation --- cli/clistat/cgroup_linux.go | 113 ++++++++++++++++++++++++++++++------ cli/clistat/stat.go | 6 +- 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/cli/clistat/cgroup_linux.go b/cli/clistat/cgroup_linux.go index 041e87272bdc9..0f337145c509c 100644 --- a/cli/clistat/cgroup_linux.go +++ b/cli/clistat/cgroup_linux.go @@ -1,11 +1,14 @@ package clistat import ( + "bufio" + "bytes" + "os" + "strconv" "time" - "tailscale.com/types/ptr" - "golang.org/x/xerrors" + "tailscale.com/types/ptr" ) const () @@ -15,46 +18,120 @@ const () func (s *Statter) ContainerCPU() (*Result, error) { // Firstly, check if we are containerized. if ok, err := IsContainerized(); err != nil || !ok { - return nil, nil + return nil, nil //nolint: nilnil } - used1, total1, err := cgroupCPU() + used1, total, err := cgroupCPU() if err != nil { return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) } <-time.After(s.sampleInterval) - used2, total2, err := cgroupCPU() + + // total is unlikely to change. Use the first value. + used2, _, err := cgroupCPU() if err != nil { return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) } - return &Result{ + r := &Result{ Unit: "cores", - Used: used2 - used1, - Total: ptr.To(total2 - total1), - }, nil + Used: (used2 - used1).Seconds(), + Total: ptr.To(total.Seconds()), // close enough to the truth + } + return r, nil } -func cgroupCPU() (used, total float64, err error) { +func cgroupCPU() (used, total time.Duration, err error) { if isCGroupV2() { - return cGroupv2CPU() + return cGroupV2CPU() } // Fall back to CGroupv1 - return cGroupv1CPU() + return cGroupV1CPU() } func isCGroupV2() bool { - // TODO implement - return false + // Check for the presence of /sys/fs/cgroup/cpu.max + _, err := os.Stat("/sys/fs/cgroup/cpu.max") + return err == nil } -func cGroupv2CPU() (float64, float64, error) { - // TODO: implement - return 0, 0, nil +func cGroupV2CPU() (used, total time.Duration, err error) { + total, err = cGroupv2CPUTotal() + if err != nil { + return 0, 0, xerrors.Errorf("get cgroup v2 CPU cores: %w", err) + } + + used, err = cGroupv2CPUUsed() + if err != nil { + return 0, 0, xerrors.Errorf("get cgroup v2 CPU used: %w", err) + } + + return used, total, nil +} + +func cGroupv2CPUUsed() (used time.Duration, err error) { + var data []byte + data, err = os.ReadFile("/sys/fs/cgroup/cpu.stat") + if err != nil { + return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.stat: %w", err) + } + + s := bufio.NewScanner(bytes.NewReader(data)) + for s.Scan() { + line := s.Bytes() + if !bytes.HasPrefix(line, []byte("usage_usec ")) { + continue + } + + parts := bytes.Split(line, []byte(" ")) + if len(parts) != 2 { + return 0, xerrors.Errorf("unexpected line in /sys/fs/cgroup/cpu.stat: %s", line) + } + + iused, err := strconv.Atoi(string(parts[1])) + if err != nil { + return 0, xerrors.Errorf("parse /sys/fs/cgroup/cpu.stat: %w", err) + } + + return time.Duration(iused) * time.Microsecond, nil + } + + return 0, xerrors.Errorf("did not find expected usage_usec in /sys/fs/cgroup/cpu.stat") +} + +func cGroupv2CPUTotal() (total time.Duration, err error) { + var data []byte + var quotaUs int + data, err = os.ReadFile("/sys/fs/cgroup/cpu.max") + if err != nil { + return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.max: %w", err) + } + + lines := bytes.Split(data, []byte("\n")) + if len(lines) < 1 { + return 0, xerrors.Errorf("unexpected empty /sys/fs/cgroup/cpu.max") + } + + line := lines[0] + parts := bytes.Split(line, []byte(" ")) + if len(parts) != 2 { + return 0, xerrors.Errorf("unexpected line in /sys/fs/cgroup/cpu.max: %s", line) + } + + if bytes.Equal(parts[0], []byte("max")) { + quotaUs = nproc * int(time.Second.Microseconds()) + } else { + quotaUs, err = strconv.Atoi(string(parts[0])) + if err != nil { + return 0, xerrors.Errorf("parse /sys/fs/cgroup/cpu.max: %w", err) + } + } + + return time.Duration(quotaUs) * time.Microsecond, nil } -func cGroupv1CPU() (float64, float64, error) { +func cGroupV1CPU() (time.Duration, time.Duration, error) { // TODO: implement return 0, 0, nil } diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 3938a09417d2d..e4e14480fcfee 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -15,7 +15,7 @@ import ( const procOneCgroup = "/proc/1/cgroup" -var nproc = float64(runtime.NumCPU()) +var nproc = runtime.NumCPU() // Result is a generic result type for a statistic. // Total is the total amount of the resource available. @@ -87,7 +87,7 @@ func New(opts ...Option) (*Statter, error) { func (s *Statter) HostCPU() (*Result, error) { r := &Result{ Unit: "cores", - Total: ptr.To(nproc), + Total: ptr.To(float64(nproc)), } c1, err := s.hi.CPUTime() if err != nil { @@ -101,7 +101,7 @@ func (s *Statter) HostCPU() (*Result, error) { total := c2.Total() - c1.Total() idle := c2.Idle - c1.Idle used := total - idle - scaleFactor := nproc / total.Seconds() + scaleFactor := float64(nproc) / total.Seconds() r.Used = used.Seconds() * scaleFactor return r, nil } From 4ef5f249bbb29c4ddad6483150ab0b7883d49738 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 22:11:16 +0100 Subject: [PATCH 14/47] fix disk_windows --- cli/clistat/disk_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/clistat/disk_windows.go b/cli/clistat/disk_windows.go index 91706d6f28584..a904dfecebe20 100644 --- a/cli/clistat/disk_windows.go +++ b/cli/clistat/disk_windows.go @@ -29,7 +29,7 @@ func (s *Statter) Disk(path string) (*Result, error) { var r Result r.Total = ptr.To(float64(totalBytes) / 1024 / 1024 / 1024) - r.Used = ptr.To(float64(totalBytes-freeBytes) / 1024 / 1024 / 1024) + r.Used = float64(totalBytes-freeBytes) / 1024 / 1024 / 1024 r.Unit = "GB" return &r, nil } From f0f7b6ab7a9ffd6f9de62a004840f1a1199378ff Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 8 Jun 2023 22:19:23 +0100 Subject: [PATCH 15/47] add tests for clistats --- cli/clistat/stat_internal_test.go | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index 566dd3aa7f2e5..ee248df1755ab 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -6,9 +6,11 @@ import ( "tailscale.com/types/ptr" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestResultString(t *testing.T) { + t.Parallel() for _, tt := range []struct { Expected string Result Result @@ -33,3 +35,73 @@ func TestResultString(t *testing.T) { assert.Equal(t, tt.Expected, tt.Result.String()) } } + +// TestStatter does not test the actual values returned by the Statter. +func TestStatter(t *testing.T) { + t.Parallel() + + s, err := New() + require.NoError(t, err) + + t.Run("HostCPU", func(t *testing.T) { + t.Parallel() + cpu, err := s.HostCPU() + require.NoError(t, err) + assert.NotZero(t, cpu.Used) + assert.NotZero(t, cpu.Total) + assert.NotZero(t, cpu.Unit) + }) + + t.Run("HostMemory", func(t *testing.T) { + t.Parallel() + mem, err := s.HostMemory() + require.NoError(t, err) + assert.NotZero(t, mem.Used) + assert.NotZero(t, mem.Total) + assert.NotZero(t, mem.Unit) + }) + + t.Run("HostDisk", func(t *testing.T) { + t.Parallel() + disk, err := s.Disk("") + require.NoError(t, err) + assert.NotZero(t, disk.Used) + assert.NotZero(t, disk.Total) + assert.NotZero(t, disk.Unit) + }) + + t.Run("Uptime", func(t *testing.T) { + t.Parallel() + uptime, err := s.Uptime() + require.NoError(t, err) + assert.NotZero(t, uptime.Used) + assert.Zero(t, uptime.Total) + assert.NotZero(t, uptime.Unit) + }) + + t.Run("ContainerCPU", func(t *testing.T) { + t.Parallel() + if ok, err := IsContainerized(); err != nil || !ok { + t.Skip("not running in container") + } + cpu, err := s.ContainerCPU() + require.NoError(t, err) + assert.NotNil(t, cpu) + assert.NotZero(t, cpu.Used) + assert.NotZero(t, cpu.Total) + assert.NotZero(t, cpu.Unit) + }) + + t.Run("ContainerMemory", func(t *testing.T) { + t.Parallel() + if ok, err := IsContainerized(); err != nil || !ok { + t.Skip("not running in container") + } + mem, err := s.ContainerMemory() + require.NoError(t, err) + assert.NotNil(t, mem) + assert.NotZero(t, mem.Used) + assert.NotZero(t, mem.Total) + assert.NotZero(t, mem.Unit) + }) +} From 6a878b97eea707b8db79ba237f985229a3567b28 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 9 Jun 2023 17:06:59 +0100 Subject: [PATCH 16/47] improve testing --- cli/clistat/cgroup_linux.go | 63 ++++---- cli/clistat/container.go | 4 +- cli/clistat/container_linux.go | 41 +++-- cli/clistat/disk.go | 2 +- cli/clistat/disk_windows.go | 2 +- cli/clistat/stat.go | 22 ++- cli/clistat/stat_internal_test.go | 259 ++++++++++++++++++++++++------ cli/stat.go | 57 ++++--- cli/stat_test.go | 4 +- 9 files changed, 319 insertions(+), 135 deletions(-) diff --git a/cli/clistat/cgroup_linux.go b/cli/clistat/cgroup_linux.go index 0f337145c509c..be5fb2212ed0d 100644 --- a/cli/clistat/cgroup_linux.go +++ b/cli/clistat/cgroup_linux.go @@ -3,32 +3,35 @@ package clistat import ( "bufio" "bytes" - "os" "strconv" "time" + "github.com/spf13/afero" "golang.org/x/xerrors" "tailscale.com/types/ptr" ) -const () +const ( + cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max" + cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat" +) // CGroupCPU returns the CPU usage of the container cgroup. // On non-Linux platforms, this always returns nil. func (s *Statter) ContainerCPU() (*Result, error) { // Firstly, check if we are containerized. - if ok, err := IsContainerized(); err != nil || !ok { + if ok, err := IsContainerized(s.fs); err != nil || !ok { return nil, nil //nolint: nilnil } - used1, total, err := cgroupCPU() + used1, total, err := s.cgroupCPU() if err != nil { return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) } <-time.After(s.sampleInterval) // total is unlikely to change. Use the first value. - used2, _, err := cgroupCPU() + used2, _, err := s.cgroupCPU() if err != nil { return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) } @@ -36,33 +39,33 @@ func (s *Statter) ContainerCPU() (*Result, error) { r := &Result{ Unit: "cores", Used: (used2 - used1).Seconds(), - Total: ptr.To(total.Seconds()), // close enough to the truth + Total: ptr.To(total.Seconds() / s.sampleInterval.Seconds()), // close enough to the truth } return r, nil } -func cgroupCPU() (used, total time.Duration, err error) { - if isCGroupV2() { - return cGroupV2CPU() +func (s *Statter) cgroupCPU() (used, total time.Duration, err error) { + if s.isCGroupV2() { + return s.cGroupV2CPU() } // Fall back to CGroupv1 - return cGroupV1CPU() + return s.cGroupV1CPU() } -func isCGroupV2() bool { +func (s *Statter) isCGroupV2() bool { // Check for the presence of /sys/fs/cgroup/cpu.max - _, err := os.Stat("/sys/fs/cgroup/cpu.max") + _, err := s.fs.Stat("/sys/fs/cgroup/cpu.max") return err == nil } -func cGroupV2CPU() (used, total time.Duration, err error) { - total, err = cGroupv2CPUTotal() +func (s *Statter) cGroupV2CPU() (used, total time.Duration, err error) { + total, err = s.cGroupv2CPUTotal() if err != nil { return 0, 0, xerrors.Errorf("get cgroup v2 CPU cores: %w", err) } - used, err = cGroupv2CPUUsed() + used, err = s.cGroupv2CPUUsed() if err != nil { return 0, 0, xerrors.Errorf("get cgroup v2 CPU used: %w", err) } @@ -70,16 +73,16 @@ func cGroupV2CPU() (used, total time.Duration, err error) { return used, total, nil } -func cGroupv2CPUUsed() (used time.Duration, err error) { +func (s *Statter) cGroupv2CPUUsed() (used time.Duration, err error) { var data []byte - data, err = os.ReadFile("/sys/fs/cgroup/cpu.stat") + data, err = afero.ReadFile(s.fs, cgroupV2CPUStat) if err != nil { return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.stat: %w", err) } - s := bufio.NewScanner(bytes.NewReader(data)) - for s.Scan() { - line := s.Bytes() + bs := bufio.NewScanner(bytes.NewReader(data)) + for bs.Scan() { + line := bs.Bytes() if !bytes.HasPrefix(line, []byte("usage_usec ")) { continue } @@ -100,10 +103,10 @@ func cGroupv2CPUUsed() (used time.Duration, err error) { return 0, xerrors.Errorf("did not find expected usage_usec in /sys/fs/cgroup/cpu.stat") } -func cGroupv2CPUTotal() (total time.Duration, err error) { +func (s *Statter) cGroupv2CPUTotal() (total time.Duration, err error) { var data []byte var quotaUs int - data, err = os.ReadFile("/sys/fs/cgroup/cpu.max") + data, err = afero.ReadFile(s.fs, "/sys/fs/cgroup/cpu.max") if err != nil { return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.max: %w", err) } @@ -120,7 +123,7 @@ func cGroupv2CPUTotal() (total time.Duration, err error) { } if bytes.Equal(parts[0], []byte("max")) { - quotaUs = nproc * int(time.Second.Microseconds()) + quotaUs = s.nproc * int(time.Second.Microseconds()) } else { quotaUs, err = strconv.Atoi(string(parts[0])) if err != nil { @@ -131,30 +134,30 @@ func cGroupv2CPUTotal() (total time.Duration, err error) { return time.Duration(quotaUs) * time.Microsecond, nil } -func cGroupV1CPU() (time.Duration, time.Duration, error) { +func (*Statter) cGroupV1CPU() (time.Duration, time.Duration, error) { // TODO: implement return 0, 0, nil } func (s *Statter) ContainerMemory() (*Result, error) { - if ok, err := IsContainerized(); err != nil || !ok { + if ok, err := IsContainerized(s.fs); err != nil || !ok { return nil, nil } - if isCGroupV2() { - return cGroupv2Memory() + if s.isCGroupV2() { + return s.cGroupv2Memory() } // Fall back to CGroupv1 - return cGroupv1Memory() + return s.cGroupv1Memory() } -func cGroupv2Memory() (*Result, error) { +func (*Statter) cGroupv2Memory() (*Result, error) { // TODO implement return nil, nil } -func cGroupv1Memory() (*Result, error) { +func (*Statter) cGroupv1Memory() (*Result, error) { // TODO implement return nil, nil } diff --git a/cli/clistat/container.go b/cli/clistat/container.go index 5a879de3681a2..04ab77ca18fdd 100644 --- a/cli/clistat/container.go +++ b/cli/clistat/container.go @@ -2,10 +2,12 @@ package clistat +import "github.com/spf13/afero" + // IsContainerized returns whether the host is containerized. // This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 // with modifications to support Sysbox containers. // On non-Linux platforms, it always returns false. -func IsContainerized() (bool, error) { +func IsContainerized(_ afero.Fs) (bool, error) { return false, nil } diff --git a/cli/clistat/container_linux.go b/cli/clistat/container_linux.go index 7b42cc824b9bb..111448580173c 100644 --- a/cli/clistat/container_linux.go +++ b/cli/clistat/container_linux.go @@ -6,6 +6,7 @@ import ( "os" "sync" + "github.com/spf13/afero" "go.uber.org/atomic" "golang.org/x/xerrors" ) @@ -16,32 +17,37 @@ var ( isContainerizedCacheOnce sync.Once ) +const ( + procOneCgroup = "/proc/1/cgroup" + procMounts = "/proc/mounts" +) + // IsContainerized returns whether the host is containerized. // This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 // with modifications to support Sysbox containers. // On non-Linux platforms, it always returns false. // The result is only computed once and stored for subsequent calls. -func IsContainerized() (ok bool, err error) { +func IsContainerized(fs afero.Fs) (ok bool, err error) { isContainerizedCacheOnce.Do(func() { - ok, err = isContainerizedOnce() + ok, err = isContainerizedOnce(fs) isContainerizedCacheOK.Store(ok) isContainerizedCacheErr.Store(err) }) return isContainerizedCacheOK.Load(), isContainerizedCacheErr.Load() } -func isContainerizedOnce() (bool, error) { - data, err := os.ReadFile(procOneCgroup) +func isContainerizedOnce(fs afero.Fs) (bool, error) { + cgData, err := afero.ReadFile(fs, procOneCgroup) if err != nil { - if os.IsNotExist(err) { // how? - return false, nil + if os.IsNotExist(err) { + return false, nil // how? } - return false, xerrors.Errorf("read process cgroups: %w", err) + return false, xerrors.Errorf("read file %s: %w", procOneCgroup, err) } - s := bufio.NewScanner(bytes.NewReader(data)) - for s.Scan() { - line := s.Bytes() + scn := bufio.NewScanner(bytes.NewReader(cgData)) + for scn.Scan() { + line := scn.Bytes() if bytes.Contains(line, []byte("docker")) || bytes.Contains(line, []byte(".slice")) || bytes.Contains(line, []byte("lxc")) || @@ -52,15 +58,18 @@ func isContainerizedOnce() (bool, error) { // Last-ditch effort to detect Sysbox containers. // Check if we have anything mounted as type sysboxfs in /proc/mounts - data, err = os.ReadFile("/proc/mounts") + mountsData, err := afero.ReadFile(fs, procMounts) if err != nil { - return false, xerrors.Errorf("read /proc/mounts: %w", err) + if os.IsNotExist(err) { + return false, nil // how?? + } + return false, xerrors.Errorf("read file %s: %w", procMounts, err) } - s = bufio.NewScanner(bytes.NewReader(data)) - for s.Scan() { - line := s.Bytes() - if bytes.HasPrefix(line, []byte("sysboxfs")) { + scn = bufio.NewScanner(bytes.NewReader(mountsData)) + for scn.Scan() { + line := scn.Bytes() + if bytes.Contains(line, []byte("sysboxfs")) { return true, nil } } diff --git a/cli/clistat/disk.go b/cli/clistat/disk.go index d82fb998745b2..0628b9515af6c 100644 --- a/cli/clistat/disk.go +++ b/cli/clistat/disk.go @@ -10,7 +10,7 @@ import ( // Disk returns the disk usage of the given path. // If path is empty, it returns the usage of the root directory. -func (s *Statter) Disk(path string) (*Result, error) { +func (*Statter) Disk(path string) (*Result, error) { if path == "" { path = "/" } diff --git a/cli/clistat/disk_windows.go b/cli/clistat/disk_windows.go index a904dfecebe20..584a6e78ee3e4 100644 --- a/cli/clistat/disk_windows.go +++ b/cli/clistat/disk_windows.go @@ -7,7 +7,7 @@ import ( // Disk returns the disk usage of the given path. // If path is empty, it defaults to C:\ -func (s *Statter) Disk(path string) (*Result, error) { +func (*Statter) Disk(path string) (*Result, error) { if path == "" { path = `C:\` } diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index e4e14480fcfee..36f126ff609af 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -7,16 +7,13 @@ import ( "time" "github.com/elastic/go-sysinfo" + "github.com/spf13/afero" "golang.org/x/xerrors" "tailscale.com/types/ptr" sysinfotypes "github.com/elastic/go-sysinfo/types" ) -const procOneCgroup = "/proc/1/cgroup" - -var nproc = runtime.NumCPU() - // Result is a generic result type for a statistic. // Total is the total amount of the resource available. // It is nil if the resource is not a finite quantity. @@ -50,7 +47,9 @@ func (r *Result) String() string { // It is a thin wrapper around the elastic/go-sysinfo library. type Statter struct { hi sysinfotypes.Host + fs afero.Fs sampleInterval time.Duration + nproc int } type Option func(*Statter) @@ -62,6 +61,13 @@ func WithSampleInterval(d time.Duration) Option { } } +// WithFS sets the fs for the statter. +func WithFS(fs afero.Fs) Option { + return func(s *Statter) { + s.fs = fs + } +} + func New(opts ...Option) (*Statter, error) { hi, err := sysinfo.Host() if err != nil { @@ -69,7 +75,9 @@ func New(opts ...Option) (*Statter, error) { } s := &Statter{ hi: hi, + fs: afero.NewReadOnlyFs(afero.NewOsFs()), sampleInterval: 100 * time.Millisecond, + nproc: runtime.NumCPU(), } for _, opt := range opts { opt(s) @@ -87,7 +95,7 @@ func New(opts ...Option) (*Statter, error) { func (s *Statter) HostCPU() (*Result, error) { r := &Result{ Unit: "cores", - Total: ptr.To(float64(nproc)), + Total: ptr.To(float64(s.nproc)), } c1, err := s.hi.CPUTime() if err != nil { @@ -101,7 +109,7 @@ func (s *Statter) HostCPU() (*Result, error) { total := c2.Total() - c1.Total() idle := c2.Idle - c1.Idle used := total - idle - scaleFactor := float64(nproc) / total.Seconds() + scaleFactor := float64(s.nproc) / total.Seconds() r.Used = used.Seconds() * scaleFactor return r, nil } @@ -129,7 +137,7 @@ func (s *Statter) Uptime() (*Result, error) { Total: nil, // Is time a finite quantity? For this purpose, no. } - if ok, err := IsContainerized(); err == nil && ok { + if ok, err := IsContainerized(s.fs); err == nil && ok { procStat, err := sysinfo.Process(1) if err != nil { return nil, xerrors.Errorf("get pid 1 info: %w", err) diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index ee248df1755ab..eaca7b2b31ecb 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -5,6 +5,7 @@ import ( "tailscale.com/types/ptr" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,72 +37,224 @@ func TestResultString(t *testing.T) { } } -// TestStatter does not test the actual values returned by the Statter. func TestStatter(t *testing.T) { t.Parallel() - s, err := New() - require.NoError(t, err) - - t.Run("HostCPU", func(t *testing.T) { + // We cannot make many assertions about the data we get back + // for host-specific measurements because these tests could + // and should run successfully on any OS. + // The best we can do is assert that it is non-zero. + t.Run("HostOnly", func(t *testing.T) { t.Parallel() - cpu, err := s.HostCPU() + fs := initFS(t, fsHostOnly) + s, err := New(WithFS(fs)) require.NoError(t, err) - assert.NotZero(t, cpu.Used) - assert.NotZero(t, cpu.Total) - assert.NotZero(t, cpu.Unit) - }) + t.Run("HostCPU", func(t *testing.T) { + t.Parallel() + cpu, err := s.HostCPU() + require.NoError(t, err) + assert.NotZero(t, cpu.Used) + assert.NotZero(t, cpu.Total) + assert.Equal(t, "cores", cpu.Unit) + }) - t.Run("HostMemory", func(t *testing.T) { - t.Parallel() - mem, err := s.HostMemory() - require.NoError(t, err) - assert.NotZero(t, mem.Used) - assert.NotZero(t, mem.Total) - assert.NotZero(t, mem.Unit) - }) + t.Run("HostMemory", func(t *testing.T) { + t.Parallel() + mem, err := s.HostMemory() + require.NoError(t, err) + assert.NotZero(t, mem.Used) + assert.NotZero(t, mem.Total) + assert.Equal(t, "GB", mem.Unit) + }) - t.Run("HostDisk", func(t *testing.T) { - t.Parallel() - disk, err := s.Disk("") - require.NoError(t, err) - assert.NotZero(t, disk.Used) - assert.NotZero(t, disk.Total) - assert.NotZero(t, disk.Unit) - }) + t.Run("HostDisk", func(t *testing.T) { + t.Parallel() + disk, err := s.Disk("") // default to home dir + require.NoError(t, err) + assert.NotZero(t, disk.Used) + assert.NotZero(t, disk.Total) + assert.NotZero(t, disk.Unit) + }) - t.Run("Uptime", func(t *testing.T) { - t.Parallel() - uptime, err := s.Uptime() - require.NoError(t, err) - assert.NotZero(t, uptime.Used) - assert.Zero(t, uptime.Total) - assert.NotZero(t, uptime.Unit) + t.Run("Uptime", func(t *testing.T) { + t.Parallel() + uptime, err := s.Uptime() + require.NoError(t, err) + assert.NotZero(t, uptime.Used) + assert.Zero(t, uptime.Total) + assert.Equal(t, "minutes", uptime.Unit) + }) }) - t.Run("ContainerCPU", func(t *testing.T) { + t.Run("CGroupV1", func(t *testing.T) { t.Parallel() - if ok, err := IsContainerized(); err != nil || !ok { - t.Skip("not running in container") - } - cpu, err := s.ContainerCPU() - require.NoError(t, err) - assert.NotNil(t, cpu) - assert.NotZero(t, cpu.Used) - assert.NotZero(t, cpu.Total) - assert.NotZero(t, cpu.Unit) + t.Skip("not implemented") + + t.Run("Limit", func(t *testing.T) { + t.Parallel() + }) + + t.Run("NoLimit", func(t *testing.T) { + t.Parallel() + }) }) - t.Run("ContainerMemory", func(t *testing.T) { + t.Run("CGroupV2", func(t *testing.T) { t.Parallel() - if ok, err := IsContainerized(); err != nil || !ok { - t.Skip("not running in container") - } - mem, err := s.ContainerMemory() - require.NoError(t, err) - assert.NotNil(t, mem) - assert.NotZero(t, mem.Used) - assert.NotZero(t, mem.Total) - assert.NotZero(t, mem.Unit) + t.Run("Limit", func(t *testing.T) { + fs := initFS(t, fsContainerCgroupV2) + s, err := New(WithFS(fs)) + require.NoError(t, err) + // We can make assertions about the below because these all read + // data from known file paths, which we can control. + t.Run("ContainerCPU", func(t *testing.T) { + t.Parallel() + cpu, err := s.ContainerCPU() + require.NoError(t, err) + assert.NotNil(t, cpu) + // This value does not change in between tests so it is zero. + assert.Zero(t, cpu.Used) + // Eve + require.NotNil(t, cpu.Total) + assert.Equal(t, 2.5, *cpu.Total) + assert.Equal(t, "cores", cpu.Unit) + }) + + t.Run("ContainerMemory", func(t *testing.T) { + t.Parallel() + t.Skip("not implemented") + mem, err := s.ContainerMemory() + require.NoError(t, err) + assert.NotNil(t, mem) + assert.NotZero(t, mem.Used) + assert.NotZero(t, mem.Total) + assert.Equal(t, "GB", mem.Unit) + }) + }) + + t.Run("NoLimit", func(t *testing.T) { + fs := initFS(t, fsContainerCgroupV2) + s, err := New(WithFS(fs), func(s *Statter) { + s.nproc = 2 + }) + require.NoError(t, err) + // We can make assertions about the below because these all read + // data from known file paths, which we can control. + t.Run("ContainerCPU", func(t *testing.T) { + t.Parallel() + cpu, err := s.ContainerCPU() + require.NoError(t, err) + assert.NotNil(t, cpu) + // This value does not change in between tests so it is zero. + assert.Zero(t, cpu.Used) + // Eve + require.NotNil(t, cpu.Total) + assert.Equal(t, 2.5, *cpu.Total) + assert.Equal(t, "cores", cpu.Unit) + }) + + t.Run("ContainerMemory", func(t *testing.T) { + t.Parallel() + t.Skip("not implemented") + mem, err := s.ContainerMemory() + require.NoError(t, err) + assert.NotNil(t, mem) + assert.NotZero(t, mem.Used) + assert.NotZero(t, mem.Total) + assert.Equal(t, "GB", mem.Unit) + }) + }) }) } + +func TestIsContainerized(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + Name string + FS map[string]string + Expected bool + Error string + }{ + { + Name: "Empty", + FS: map[string]string{}, + Expected: false, + Error: "", + }, + { + Name: "BareMetal", + FS: fsHostOnly, + Expected: false, + Error: "", + }, + { + Name: "Docker", + FS: fsContainerCgroupV1, + Expected: true, + Error: "", + }, + { + Name: "Sysbox", + FS: fsContainerSysbox, + Expected: true, + Error: "", + }, + } { + tt := tt + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + fs := initFS(t, tt.FS) + actual, err := isContainerizedOnce(fs) + if tt.Error == "" { + assert.NoError(t, err) + assert.Equal(t, tt.Expected, actual) + } else { + assert.ErrorContains(t, err, tt.Error) + assert.False(t, actual) + } + }) + } +} + +func initFS(t testing.TB, m map[string]string) afero.Fs { + t.Helper() + fs := afero.NewMemMapFs() + for k, v := range m { + require.NoError(t, afero.WriteFile(fs, k, []byte(v+"\n"), 0o600)) + } + return fs +} + +var ( + fsHostOnly = map[string]string{ + procOneCgroup: "0::/", + procMounts: "/dev/sda1 / ext4 rw,relatime 0 0", + } + fsContainerCgroupV2 = map[string]string{ + procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", + procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 +proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, + cgroupV2CPUMax: "250000 100000", + cgroupV2CPUStat: "usage_usec 1000000", + } + fsContainerCgroupV1 = map[string]string{ + procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", + procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 +proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, + } + fsContainerCgroupV2NoLimit = map[string]string{ + procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", + procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 +proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, + cgroupV2CPUMax: "max 100000", + cgroupV2CPUStat: "usage_usec 1000000", + } + fsContainerSysbox = map[string]string{ + procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", + procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 +sysboxfs /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, + cgroupV2CPUMax: "250000 100000", + cgroupV2CPUStat: "usage_usec 1000000", + } +) diff --git a/cli/stat.go b/cli/stat.go index d408582f1db8f..68f126d7648c8 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -5,14 +5,17 @@ import ( "os" "time" + "github.com/spf13/afero" + "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/clistat" "github.com/coder/coder/cli/cliui" ) func (*RootCmd) stat() *clibase.Cmd { + fs := afero.NewReadOnlyFs(afero.NewOsFs()) defaultCols := []string{"host_cpu", "host_memory", "home_disk", "uptime"} - if ok, err := clistat.IsContainerized(); err == nil && ok { + if ok, err := clistat.IsContainerized(fs); err == nil && ok { // If running in a container, we assume that users want to see these first. Prepend. defaultCols = append([]string{"container_cpu", "container_memory"}, defaultCols...) } @@ -36,51 +39,55 @@ func (*RootCmd) stat() *clibase.Cmd { }, }, Handler: func(inv *clibase.Invocation) error { - var s *clistat.Statter - if st, err := clistat.New(clistat.WithSampleInterval(sampleInterval)); err != nil { + st, err := clistat.New(clistat.WithSampleInterval(sampleInterval), clistat.WithFS(fs)) + if err != nil { return err - } else { - s = st } + // Host-level stats var sr statsRow - if cs, err := s.HostCPU(); err != nil { + cs, err := st.HostCPU() + if err != nil { return err - } else { - sr.HostCPU = cs } + sr.HostCPU = cs - if ms, err := s.HostMemory(); err != nil { + ms, err := st.HostMemory() + if err != nil { return err - } else { - sr.HostMemory = ms } + sr.HostMemory = ms - if home, err := os.UserHomeDir(); err != nil { + home, err := os.UserHomeDir() + if err != nil { return err - } else if ds, err := s.Disk(home); err != nil { + } + ds, err := st.Disk(home) + if err != nil { return err - } else { - sr.Disk = ds } + sr.Disk = ds - if us, err := s.Uptime(); err != nil { + // Uptime is calculated either based on the host or the container, depending. + us, err := st.Uptime() + if err != nil { return err - } else { - sr.Uptime = us } + sr.Uptime = us - if ok, err := clistat.IsContainerized(); err == nil && ok { - if cs, err := s.ContainerCPU(); err != nil { + // Container-only stats. + if ok, err := clistat.IsContainerized(fs); err == nil && ok { + cs, err := st.ContainerCPU() + if err != nil { return err - } else { - sr.ContainerCPU = cs } - if ms, err := s.ContainerMemory(); err != nil { + sr.ContainerCPU = cs + + ms, err := st.ContainerMemory() + if err != nil { return err - } else { - sr.ContainerMemory = ms } + sr.ContainerMemory = ms } out, err := formatter.Format(inv.Context(), []statsRow{sr}) diff --git a/cli/stat_test.go b/cli/stat_test.go index 2b854bcd19273..92415ffb03d7e 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -15,8 +15,10 @@ import ( // This just tests that the stat command is recognized and does not output // an empty string. Actually testing the output of the stats command is -// fraught with all sorts of fun. +// fraught with all sorts of fun. Some more detailed testing of the stats +// output is performed in the tests in the clistat package. func TestStatCmd(t *testing.T) { + t.Parallel() t.Run("JSON", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) From be7ba724ef122a3009d95bf1aef35c3f3dbb36e7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 12 Jun 2023 14:48:10 +0100 Subject: [PATCH 17/47] remove unnecessary os-specific implementations now that we have abstracted out the filesystem --- cli/clistat/cgroup.go | 160 ++++++++++++++++++++++++++++- cli/clistat/cgroup_linux.go | 163 ------------------------------ cli/clistat/container.go | 52 +++++++++- cli/clistat/container_linux.go | 79 --------------- cli/clistat/stat_internal_test.go | 2 +- 5 files changed, 206 insertions(+), 250 deletions(-) delete mode 100644 cli/clistat/cgroup_linux.go delete mode 100644 cli/clistat/container_linux.go diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go index 02298ba818929..1c1c181e1258d 100644 --- a/cli/clistat/cgroup.go +++ b/cli/clistat/cgroup.go @@ -1,15 +1,165 @@ -//go:build !linux - package clistat +import ( + "bufio" + "bytes" + "strconv" + "time" + + "github.com/spf13/afero" + "golang.org/x/xerrors" + "tailscale.com/types/ptr" +) + +const ( + cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max" + cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat" +) + // ContainerCPU returns the CPU usage of the container cgroup. -// On non-Linux platforms, this always returns nil. +// If the system is not containerized, this always returns nil. func (s *Statter) ContainerCPU() (*Result, error) { - return nil, nil + // Firstly, check if we are containerized. + if ok, err := IsContainerized(s.fs); err != nil || !ok { + return nil, nil //nolint: nilnil + } + + used1, total, err := s.cgroupCPU() + if err != nil { + return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) + } + <-time.After(s.sampleInterval) + + // total is unlikely to change. Use the first value. + used2, _, err := s.cgroupCPU() + if err != nil { + return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) + } + + r := &Result{ + Unit: "cores", + Used: (used2 - used1).Seconds(), + Total: ptr.To(total.Seconds() / s.sampleInterval.Seconds()), // close enough to the truth + } + return r, nil +} + +func (s *Statter) cgroupCPU() (used, total time.Duration, err error) { + if s.isCGroupV2() { + return s.cGroupV2CPU() + } + + // Fall back to CGroupv1 + return s.cGroupV1CPU() +} + +func (s *Statter) isCGroupV2() bool { + // Check for the presence of /sys/fs/cgroup/cpu.max + _, err := s.fs.Stat("/sys/fs/cgroup/cpu.max") + return err == nil +} + +func (s *Statter) cGroupV2CPU() (used, total time.Duration, err error) { + total, err = s.cGroupv2CPUTotal() + if err != nil { + return 0, 0, xerrors.Errorf("get cgroup v2 CPU cores: %w", err) + } + + used, err = s.cGroupv2CPUUsed() + if err != nil { + return 0, 0, xerrors.Errorf("get cgroup v2 CPU used: %w", err) + } + + return used, total, nil +} + +func (s *Statter) cGroupv2CPUUsed() (used time.Duration, err error) { + var data []byte + data, err = afero.ReadFile(s.fs, cgroupV2CPUStat) + if err != nil { + return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.stat: %w", err) + } + + bs := bufio.NewScanner(bytes.NewReader(data)) + for bs.Scan() { + line := bs.Bytes() + if !bytes.HasPrefix(line, []byte("usage_usec ")) { + continue + } + + parts := bytes.Split(line, []byte(" ")) + if len(parts) != 2 { + return 0, xerrors.Errorf("unexpected line in /sys/fs/cgroup/cpu.stat: %s", line) + } + + iused, err := strconv.Atoi(string(parts[1])) + if err != nil { + return 0, xerrors.Errorf("parse /sys/fs/cgroup/cpu.stat: %w", err) + } + + return time.Duration(iused) * time.Microsecond, nil + } + + return 0, xerrors.Errorf("did not find expected usage_usec in /sys/fs/cgroup/cpu.stat") +} + +func (s *Statter) cGroupv2CPUTotal() (total time.Duration, err error) { + var data []byte + var quotaUs int + data, err = afero.ReadFile(s.fs, "/sys/fs/cgroup/cpu.max") + if err != nil { + return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.max: %w", err) + } + + lines := bytes.Split(data, []byte("\n")) + if len(lines) < 1 { + return 0, xerrors.Errorf("unexpected empty /sys/fs/cgroup/cpu.max") + } + + line := lines[0] + parts := bytes.Split(line, []byte(" ")) + if len(parts) != 2 { + return 0, xerrors.Errorf("unexpected line in /sys/fs/cgroup/cpu.max: %s", line) + } + + if bytes.Equal(parts[0], []byte("max")) { + quotaUs = s.nproc * int(time.Second.Microseconds()) + } else { + quotaUs, err = strconv.Atoi(string(parts[0])) + if err != nil { + return 0, xerrors.Errorf("parse /sys/fs/cgroup/cpu.max: %w", err) + } + } + + return time.Duration(quotaUs) * time.Microsecond, nil +} + +func (*Statter) cGroupV1CPU() (time.Duration, time.Duration, error) { + // TODO: implement + return 0, 0, nil } // ContainerMemory returns the memory usage of the container cgroup. -// On non-Linux platforms, this always returns nil. +// If the system is not containerized, this always returns nil. func (s *Statter) ContainerMemory() (*Result, error) { + if ok, err := IsContainerized(s.fs); err != nil || !ok { + return nil, nil + } + + if s.isCGroupV2() { + return s.cGroupv2Memory() + } + + // Fall back to CGroupv1 + return s.cGroupv1Memory() +} + +func (*Statter) cGroupv2Memory() (*Result, error) { + // TODO implement + return nil, nil +} + +func (*Statter) cGroupv1Memory() (*Result, error) { + // TODO implement return nil, nil } diff --git a/cli/clistat/cgroup_linux.go b/cli/clistat/cgroup_linux.go deleted file mode 100644 index be5fb2212ed0d..0000000000000 --- a/cli/clistat/cgroup_linux.go +++ /dev/null @@ -1,163 +0,0 @@ -package clistat - -import ( - "bufio" - "bytes" - "strconv" - "time" - - "github.com/spf13/afero" - "golang.org/x/xerrors" - "tailscale.com/types/ptr" -) - -const ( - cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max" - cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat" -) - -// CGroupCPU returns the CPU usage of the container cgroup. -// On non-Linux platforms, this always returns nil. -func (s *Statter) ContainerCPU() (*Result, error) { - // Firstly, check if we are containerized. - if ok, err := IsContainerized(s.fs); err != nil || !ok { - return nil, nil //nolint: nilnil - } - - used1, total, err := s.cgroupCPU() - if err != nil { - return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) - } - <-time.After(s.sampleInterval) - - // total is unlikely to change. Use the first value. - used2, _, err := s.cgroupCPU() - if err != nil { - return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) - } - - r := &Result{ - Unit: "cores", - Used: (used2 - used1).Seconds(), - Total: ptr.To(total.Seconds() / s.sampleInterval.Seconds()), // close enough to the truth - } - return r, nil -} - -func (s *Statter) cgroupCPU() (used, total time.Duration, err error) { - if s.isCGroupV2() { - return s.cGroupV2CPU() - } - - // Fall back to CGroupv1 - return s.cGroupV1CPU() -} - -func (s *Statter) isCGroupV2() bool { - // Check for the presence of /sys/fs/cgroup/cpu.max - _, err := s.fs.Stat("/sys/fs/cgroup/cpu.max") - return err == nil -} - -func (s *Statter) cGroupV2CPU() (used, total time.Duration, err error) { - total, err = s.cGroupv2CPUTotal() - if err != nil { - return 0, 0, xerrors.Errorf("get cgroup v2 CPU cores: %w", err) - } - - used, err = s.cGroupv2CPUUsed() - if err != nil { - return 0, 0, xerrors.Errorf("get cgroup v2 CPU used: %w", err) - } - - return used, total, nil -} - -func (s *Statter) cGroupv2CPUUsed() (used time.Duration, err error) { - var data []byte - data, err = afero.ReadFile(s.fs, cgroupV2CPUStat) - if err != nil { - return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.stat: %w", err) - } - - bs := bufio.NewScanner(bytes.NewReader(data)) - for bs.Scan() { - line := bs.Bytes() - if !bytes.HasPrefix(line, []byte("usage_usec ")) { - continue - } - - parts := bytes.Split(line, []byte(" ")) - if len(parts) != 2 { - return 0, xerrors.Errorf("unexpected line in /sys/fs/cgroup/cpu.stat: %s", line) - } - - iused, err := strconv.Atoi(string(parts[1])) - if err != nil { - return 0, xerrors.Errorf("parse /sys/fs/cgroup/cpu.stat: %w", err) - } - - return time.Duration(iused) * time.Microsecond, nil - } - - return 0, xerrors.Errorf("did not find expected usage_usec in /sys/fs/cgroup/cpu.stat") -} - -func (s *Statter) cGroupv2CPUTotal() (total time.Duration, err error) { - var data []byte - var quotaUs int - data, err = afero.ReadFile(s.fs, "/sys/fs/cgroup/cpu.max") - if err != nil { - return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.max: %w", err) - } - - lines := bytes.Split(data, []byte("\n")) - if len(lines) < 1 { - return 0, xerrors.Errorf("unexpected empty /sys/fs/cgroup/cpu.max") - } - - line := lines[0] - parts := bytes.Split(line, []byte(" ")) - if len(parts) != 2 { - return 0, xerrors.Errorf("unexpected line in /sys/fs/cgroup/cpu.max: %s", line) - } - - if bytes.Equal(parts[0], []byte("max")) { - quotaUs = s.nproc * int(time.Second.Microseconds()) - } else { - quotaUs, err = strconv.Atoi(string(parts[0])) - if err != nil { - return 0, xerrors.Errorf("parse /sys/fs/cgroup/cpu.max: %w", err) - } - } - - return time.Duration(quotaUs) * time.Microsecond, nil -} - -func (*Statter) cGroupV1CPU() (time.Duration, time.Duration, error) { - // TODO: implement - return 0, 0, nil -} - -func (s *Statter) ContainerMemory() (*Result, error) { - if ok, err := IsContainerized(s.fs); err != nil || !ok { - return nil, nil - } - - if s.isCGroupV2() { - return s.cGroupv2Memory() - } - - // Fall back to CGroupv1 - return s.cGroupv1Memory() -} - -func (*Statter) cGroupv2Memory() (*Result, error) { - // TODO implement - return nil, nil -} - -func (*Statter) cGroupv1Memory() (*Result, error) { - // TODO implement - return nil, nil -} diff --git a/cli/clistat/container.go b/cli/clistat/container.go index 04ab77ca18fdd..ec17f912e2115 100644 --- a/cli/clistat/container.go +++ b/cli/clistat/container.go @@ -2,12 +2,60 @@ package clistat -import "github.com/spf13/afero" +import ( + "bufio" + "bytes" + "os" + + "github.com/spf13/afero" + "golang.org/x/xerrors" +) + +const procMounts = "/proc/mounts" +const procOneCgroup = "/proc/1/cgroup" // IsContainerized returns whether the host is containerized. // This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 // with modifications to support Sysbox containers. // On non-Linux platforms, it always returns false. -func IsContainerized(_ afero.Fs) (bool, error) { +func IsContainerized(fs afero.Fs) (ok bool, err error) { + cgData, err := afero.ReadFile(fs, procOneCgroup) + if err != nil { + if os.IsNotExist(err) { + return false, nil // how? + } + return false, xerrors.Errorf("read file %s: %w", procOneCgroup, err) + } + + scn := bufio.NewScanner(bytes.NewReader(cgData)) + for scn.Scan() { + line := scn.Bytes() + if bytes.Contains(line, []byte("docker")) || + bytes.Contains(line, []byte(".slice")) || + bytes.Contains(line, []byte("lxc")) || + bytes.Contains(line, []byte("kubepods")) { + return true, nil + } + } + + // Last-ditch effort to detect Sysbox containers. + // Check if we have anything mounted as type sysboxfs in /proc/mounts + mountsData, err := afero.ReadFile(fs, procMounts) + if err != nil { + if os.IsNotExist(err) { + return false, nil // how?? + } + return false, xerrors.Errorf("read file %s: %w", procMounts, err) + } + + scn = bufio.NewScanner(bytes.NewReader(mountsData)) + for scn.Scan() { + line := scn.Bytes() + if bytes.Contains(line, []byte("sysboxfs")) { + return true, nil + } + } + + // If we get here, we are _probably_ not running in a container. return false, nil } diff --git a/cli/clistat/container_linux.go b/cli/clistat/container_linux.go deleted file mode 100644 index 111448580173c..0000000000000 --- a/cli/clistat/container_linux.go +++ /dev/null @@ -1,79 +0,0 @@ -package clistat - -import ( - "bufio" - "bytes" - "os" - "sync" - - "github.com/spf13/afero" - "go.uber.org/atomic" - "golang.org/x/xerrors" -) - -var ( - isContainerizedCacheOK atomic.Bool - isContainerizedCacheErr atomic.Error - isContainerizedCacheOnce sync.Once -) - -const ( - procOneCgroup = "/proc/1/cgroup" - procMounts = "/proc/mounts" -) - -// IsContainerized returns whether the host is containerized. -// This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 -// with modifications to support Sysbox containers. -// On non-Linux platforms, it always returns false. -// The result is only computed once and stored for subsequent calls. -func IsContainerized(fs afero.Fs) (ok bool, err error) { - isContainerizedCacheOnce.Do(func() { - ok, err = isContainerizedOnce(fs) - isContainerizedCacheOK.Store(ok) - isContainerizedCacheErr.Store(err) - }) - return isContainerizedCacheOK.Load(), isContainerizedCacheErr.Load() -} - -func isContainerizedOnce(fs afero.Fs) (bool, error) { - cgData, err := afero.ReadFile(fs, procOneCgroup) - if err != nil { - if os.IsNotExist(err) { - return false, nil // how? - } - return false, xerrors.Errorf("read file %s: %w", procOneCgroup, err) - } - - scn := bufio.NewScanner(bytes.NewReader(cgData)) - for scn.Scan() { - line := scn.Bytes() - if bytes.Contains(line, []byte("docker")) || - bytes.Contains(line, []byte(".slice")) || - bytes.Contains(line, []byte("lxc")) || - bytes.Contains(line, []byte("kubepods")) { - return true, nil - } - } - - // Last-ditch effort to detect Sysbox containers. - // Check if we have anything mounted as type sysboxfs in /proc/mounts - mountsData, err := afero.ReadFile(fs, procMounts) - if err != nil { - if os.IsNotExist(err) { - return false, nil // how?? - } - return false, xerrors.Errorf("read file %s: %w", procMounts, err) - } - - scn = bufio.NewScanner(bytes.NewReader(mountsData)) - for scn.Scan() { - line := scn.Bytes() - if bytes.Contains(line, []byte("sysboxfs")) { - return true, nil - } - } - - // If we get here, we are _probably_ not running in a container. - return false, nil -} diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index eaca7b2b31ecb..a7ee413c6a90e 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -205,7 +205,7 @@ func TestIsContainerized(t *testing.T) { t.Run(tt.Name, func(t *testing.T) { t.Parallel() fs := initFS(t, tt.FS) - actual, err := isContainerizedOnce(fs) + actual, err := IsContainerized(fs) if tt.Error == "" { assert.NoError(t, err) assert.Equal(t, tt.Expected, actual) From 364340729b4c3615e6fb15d4cb6d33f17ff2d9ea Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 12 Jun 2023 14:50:19 +0100 Subject: [PATCH 18/47] remove uptime stat as it is trivial to implement in bash --- cli/clistat/container.go | 2 -- cli/clistat/stat.go | 25 ------------------------- cli/clistat/stat_internal_test.go | 9 --------- cli/stat.go | 10 +--------- 4 files changed, 1 insertion(+), 45 deletions(-) diff --git a/cli/clistat/container.go b/cli/clistat/container.go index ec17f912e2115..09e2eb2bf42d4 100644 --- a/cli/clistat/container.go +++ b/cli/clistat/container.go @@ -1,5 +1,3 @@ -//go:build !linux - package clistat import ( diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 36f126ff609af..72456033b0a08 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -127,28 +127,3 @@ func (s *Statter) HostMemory() (*Result, error) { r.Used = float64(hm.Used) / 1024 / 1024 / 1024 return r, nil } - -// Uptime returns the uptime of the host, in seconds. -// If the host is containerized, this will return the uptime of the container -// by checking /proc/1/stat. -func (s *Statter) Uptime() (*Result, error) { - r := &Result{ - Unit: "minutes", - Total: nil, // Is time a finite quantity? For this purpose, no. - } - - if ok, err := IsContainerized(s.fs); err == nil && ok { - procStat, err := sysinfo.Process(1) - if err != nil { - return nil, xerrors.Errorf("get pid 1 info: %w", err) - } - procInfo, err := procStat.Info() - if err != nil { - return nil, xerrors.Errorf("get pid 1 stat: %w", err) - } - r.Used = time.Since(procInfo.StartTime).Minutes() - return r, nil - } - r.Used = s.hi.Info().Uptime().Minutes() - return r, nil -} diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index a7ee413c6a90e..435fc52bd9dbf 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -75,15 +75,6 @@ func TestStatter(t *testing.T) { assert.NotZero(t, disk.Total) assert.NotZero(t, disk.Unit) }) - - t.Run("Uptime", func(t *testing.T) { - t.Parallel() - uptime, err := s.Uptime() - require.NoError(t, err) - assert.NotZero(t, uptime.Used) - assert.Zero(t, uptime.Total) - assert.Equal(t, "minutes", uptime.Unit) - }) }) t.Run("CGroupV1", func(t *testing.T) { diff --git a/cli/stat.go b/cli/stat.go index 68f126d7648c8..21f8b0a291f3b 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -14,7 +14,7 @@ import ( func (*RootCmd) stat() *clibase.Cmd { fs := afero.NewReadOnlyFs(afero.NewOsFs()) - defaultCols := []string{"host_cpu", "host_memory", "home_disk", "uptime"} + defaultCols := []string{"host_cpu", "host_memory", "home_disk"} if ok, err := clistat.IsContainerized(fs); err == nil && ok { // If running in a container, we assume that users want to see these first. Prepend. defaultCols = append([]string{"container_cpu", "container_memory"}, defaultCols...) @@ -68,13 +68,6 @@ func (*RootCmd) stat() *clibase.Cmd { } sr.Disk = ds - // Uptime is calculated either based on the host or the container, depending. - us, err := st.Uptime() - if err != nil { - return err - } - sr.Uptime = us - // Container-only stats. if ok, err := clistat.IsContainerized(fs); err == nil && ok { cs, err := st.ContainerCPU() @@ -108,5 +101,4 @@ type statsRow struct { Disk *clistat.Result `json:"home_disk" table:"home_disk"` ContainerCPU *clistat.Result `json:"container_cpu" table:"container_cpu"` ContainerMemory *clistat.Result `json:"container_memory" table:"container_memory"` - Uptime *clistat.Result `json:"uptime" table:"uptime"` } From 1c8943ef74c4894396e452dc75e04a57f040a77b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 12 Jun 2023 17:49:44 +0100 Subject: [PATCH 19/47] implement cgroupv1 cpu --- cli/clistat/cgroup.go | 96 ++++++++++++++++++++++++++++++++----------- 1 file changed, 73 insertions(+), 23 deletions(-) diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go index 1c1c181e1258d..9578b6cfd54c2 100644 --- a/cli/clistat/cgroup.go +++ b/cli/clistat/cgroup.go @@ -12,8 +12,10 @@ import ( ) const ( - cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max" - cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat" + cgroupV1CPUAcctUsage = "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage" + cgroupV1CFSQuotaUs = "/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us" + cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max" + cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat" ) // ContainerCPU returns the CPU usage of the container cgroup. @@ -38,8 +40,8 @@ func (s *Statter) ContainerCPU() (*Result, error) { r := &Result{ Unit: "cores", - Used: (used2 - used1).Seconds(), - Total: ptr.To(total.Seconds() / s.sampleInterval.Seconds()), // close enough to the truth + Used: (used2 - used1).Seconds() * s.sampleInterval.Seconds(), + Total: ptr.To(total.Seconds()), // close enough to the truth } return r, nil } @@ -62,12 +64,12 @@ func (s *Statter) isCGroupV2() bool { func (s *Statter) cGroupV2CPU() (used, total time.Duration, err error) { total, err = s.cGroupv2CPUTotal() if err != nil { - return 0, 0, xerrors.Errorf("get cgroup v2 CPU cores: %w", err) + return 0, 0, xerrors.Errorf("get cgroup v2 cpu total: %w", err) } used, err = s.cGroupv2CPUUsed() if err != nil { - return 0, 0, xerrors.Errorf("get cgroup v2 CPU used: %w", err) + return 0, 0, xerrors.Errorf("get cgroup v2 cpu used: %w", err) } return used, total, nil @@ -77,7 +79,7 @@ func (s *Statter) cGroupv2CPUUsed() (used time.Duration, err error) { var data []byte data, err = afero.ReadFile(s.fs, cgroupV2CPUStat) if err != nil { - return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.stat: %w", err) + return 0, xerrors.Errorf("read %s: %w", cgroupV2CPUStat, err) } bs := bufio.NewScanner(bytes.NewReader(data)) @@ -89,54 +91,102 @@ func (s *Statter) cGroupv2CPUUsed() (used time.Duration, err error) { parts := bytes.Split(line, []byte(" ")) if len(parts) != 2 { - return 0, xerrors.Errorf("unexpected line in /sys/fs/cgroup/cpu.stat: %s", line) + return 0, xerrors.Errorf("unexpected line in %s: %s", cgroupV2CPUStat, line) } iused, err := strconv.Atoi(string(parts[1])) if err != nil { - return 0, xerrors.Errorf("parse /sys/fs/cgroup/cpu.stat: %w", err) + return 0, xerrors.Errorf("parse %s: %w", err, cgroupV2CPUStat) } return time.Duration(iused) * time.Microsecond, nil } - return 0, xerrors.Errorf("did not find expected usage_usec in /sys/fs/cgroup/cpu.stat") + return 0, xerrors.Errorf("did not find expected usage_usec in %s", cgroupV2CPUStat) } func (s *Statter) cGroupv2CPUTotal() (total time.Duration, err error) { var data []byte - var quotaUs int - data, err = afero.ReadFile(s.fs, "/sys/fs/cgroup/cpu.max") + var quotaUs int64 + data, err = afero.ReadFile(s.fs, cgroupV2CPUMax) if err != nil { - return 0, xerrors.Errorf("read /sys/fs/cgroup/cpu.max: %w", err) + return 0, xerrors.Errorf("read %s: %w", cgroupV2CPUMax, err) } lines := bytes.Split(data, []byte("\n")) if len(lines) < 1 { - return 0, xerrors.Errorf("unexpected empty /sys/fs/cgroup/cpu.max") + return 0, xerrors.Errorf("unexpected empty %s", cgroupV2CPUMax) } - line := lines[0] - parts := bytes.Split(line, []byte(" ")) + parts := bytes.Split(lines[0], []byte(" ")) if len(parts) != 2 { - return 0, xerrors.Errorf("unexpected line in /sys/fs/cgroup/cpu.max: %s", line) + return 0, xerrors.Errorf("unexpected line in %s: %s", cgroupV2CPUMax, lines[0]) } if bytes.Equal(parts[0], []byte("max")) { - quotaUs = s.nproc * int(time.Second.Microseconds()) + quotaUs = int64(s.nproc) * time.Second.Microseconds() } else { - quotaUs, err = strconv.Atoi(string(parts[0])) + quotaUs, err = strconv.ParseInt(string(parts[0]), 10, 64) if err != nil { - return 0, xerrors.Errorf("parse /sys/fs/cgroup/cpu.max: %w", err) + return 0, xerrors.Errorf("parse %s: %w", cgroupV2CPUMax, err) } } return time.Duration(quotaUs) * time.Microsecond, nil } -func (*Statter) cGroupV1CPU() (time.Duration, time.Duration, error) { - // TODO: implement - return 0, 0, nil +func (s *Statter) cGroupV1CPU() (used, total time.Duration, err error) { + total, err = s.cGroupV1CPUTotal() + if err != nil { + return 0, 0, xerrors.Errorf("get cgroup v1 CPU total: %w", err) + } + + used, err = s.cgroupV1CPUUsed() + if err != nil { + return 0, 0, xerrors.Errorf("get cgruop v1 cpu used: %w", err) + } + + return used, total, nil +} + +func (s *Statter) cGroupV1CPUTotal() (time.Duration, error) { + var data []byte + var err error + var quotaUs int64 + + data, err = afero.ReadFile(s.fs, cgroupV1CFSQuotaUs) + if err != nil { + return 0, xerrors.Errorf("read %s: %w", cgroupV1CFSQuotaUs, err) + } + + quotaUs, err = strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) + if err != nil { + return 0, xerrors.Errorf("parse %s: %w", cgroupV1CFSQuotaUs, err) + } + + if quotaUs < 0 { + quotaUs = int64(s.nproc) * time.Second.Microseconds() + } + + return time.Duration(quotaUs) * time.Microsecond, nil +} + +func (s *Statter) cgroupV1CPUUsed() (time.Duration, error) { + var data []byte + var err error + var usageUs int64 + + data, err = afero.ReadFile(s.fs, cgroupV1CPUAcctUsage) + if err != nil { + return 0, xerrors.Errorf("read %s: %w", cgroupV1CPUAcctUsage, err) + } + + usageUs, err = strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) + if err != nil { + return 0, xerrors.Errorf("parse %s: %w", cgroupV1CPUAcctUsage, err) + } + + return time.Duration(usageUs) * time.Microsecond, nil } // ContainerMemory returns the memory usage of the container cgroup. From 95b8d1f1abfe021b0e68e75142da320617e9baaf Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 12 Jun 2023 20:33:04 +0100 Subject: [PATCH 20/47] unskip container memory tests --- cli/clistat/stat_internal_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index 435fc52bd9dbf..792d2c66f0f8b 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -113,7 +113,6 @@ func TestStatter(t *testing.T) { t.Run("ContainerMemory", func(t *testing.T) { t.Parallel() - t.Skip("not implemented") mem, err := s.ContainerMemory() require.NoError(t, err) assert.NotNil(t, mem) @@ -146,7 +145,6 @@ func TestStatter(t *testing.T) { t.Run("ContainerMemory", func(t *testing.T) { t.Parallel() - t.Skip("not implemented") mem, err := s.ContainerMemory() require.NoError(t, err) assert.NotNil(t, mem) From 495b5b03f16384b95a8f8e6c8148cb7a3ad23314 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 12:27:48 +0100 Subject: [PATCH 21/47] flesh out tests --- cli/clistat/stat_internal_test.go | 77 ++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index 792d2c66f0f8b..4956b7ebe5549 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -79,20 +79,70 @@ func TestStatter(t *testing.T) { t.Run("CGroupV1", func(t *testing.T) { t.Parallel() - t.Skip("not implemented") t.Run("Limit", func(t *testing.T) { t.Parallel() + fs := initFS(t, fsContainerCgroupV1) + s, err := New(WithFS(fs)) + require.NoError(t, err) + + t.Run("ContainerCPU", func(t *testing.T) { + t.Parallel() + cpu, err := s.ContainerCPU() + require.NoError(t, err) + require.NotNil(t, cpu) + // This value does not change in between tests so it is zero. + assert.Zero(t, cpu.Used) + require.NotNil(t, cpu.Total) + assert.Equal(t, 2.5, *cpu.Total) + assert.Equal(t, "cores", cpu.Unit) + }) + + t.Run("ContainerMemory", func(t *testing.T) { + t.Parallel() + mem, err := s.ContainerMemory() + require.NoError(t, err) + require.NotNil(t, mem) + assert.NotZero(t, mem.Used) + assert.NotZero(t, mem.Total) + assert.Equal(t, "GB", mem.Unit) + }) }) t.Run("NoLimit", func(t *testing.T) { t.Parallel() + fs := initFS(t, fsContainerCgroupV1NoLimit) + s, err := New(WithFS(fs)) + require.NoError(t, err) + + t.Run("ContainerCPU", func(t *testing.T) { + t.Parallel() + cpu, err := s.ContainerCPU() + require.NoError(t, err) + require.NotNil(t, cpu) + // This value does not change in between tests so it is zero. + assert.Zero(t, cpu.Used) + require.NotNil(t, cpu.Total) + assert.Equal(t, 2.5, *cpu.Total) + assert.Equal(t, "cores", cpu.Unit) + }) + + t.Run("ContainerMemory", func(t *testing.T) { + t.Parallel() + mem, err := s.ContainerMemory() + require.NoError(t, err) + require.NotNil(t, mem) + assert.NotZero(t, mem.Used) + assert.NotZero(t, mem.Total) + assert.Equal(t, "GB", mem.Unit) + }) }) }) t.Run("CGroupV2", func(t *testing.T) { t.Parallel() t.Run("Limit", func(t *testing.T) { + t.Parallel() fs := initFS(t, fsContainerCgroupV2) s, err := New(WithFS(fs)) require.NoError(t, err) @@ -102,10 +152,9 @@ func TestStatter(t *testing.T) { t.Parallel() cpu, err := s.ContainerCPU() require.NoError(t, err) - assert.NotNil(t, cpu) + require.NotNil(t, cpu) // This value does not change in between tests so it is zero. assert.Zero(t, cpu.Used) - // Eve require.NotNil(t, cpu.Total) assert.Equal(t, 2.5, *cpu.Total) assert.Equal(t, "cores", cpu.Unit) @@ -115,7 +164,7 @@ func TestStatter(t *testing.T) { t.Parallel() mem, err := s.ContainerMemory() require.NoError(t, err) - assert.NotNil(t, mem) + require.NotNil(t, mem) assert.NotZero(t, mem.Used) assert.NotZero(t, mem.Total) assert.Equal(t, "GB", mem.Unit) @@ -123,6 +172,7 @@ func TestStatter(t *testing.T) { }) t.Run("NoLimit", func(t *testing.T) { + t.Parallel() fs := initFS(t, fsContainerCgroupV2) s, err := New(WithFS(fs), func(s *Statter) { s.nproc = 2 @@ -134,10 +184,9 @@ func TestStatter(t *testing.T) { t.Parallel() cpu, err := s.ContainerCPU() require.NoError(t, err) - assert.NotNil(t, cpu) + require.NotNil(t, cpu) // This value does not change in between tests so it is zero. assert.Zero(t, cpu.Used) - // Eve require.NotNil(t, cpu.Total) assert.Equal(t, 2.5, *cpu.Total) assert.Equal(t, "cores", cpu.Unit) @@ -147,7 +196,7 @@ func TestStatter(t *testing.T) { t.Parallel() mem, err := s.ContainerMemory() require.NoError(t, err) - assert.NotNil(t, mem) + require.NotNil(t, mem) assert.NotZero(t, mem.Used) assert.NotZero(t, mem.Total) assert.Equal(t, "GB", mem.Unit) @@ -231,6 +280,20 @@ proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, + cgroupV1CPUAcctUsage: "162237573", + cgroupV1CFSQuotaUs: "250000", + cgroupV1MemoryMaxUsageBytes: "7782400", + cgroupV1MemoryUsageBytes: "total_rss 143360", + } + fsContainerCgroupV1NoLimit = map[string]string{ + procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", + procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 +proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, + cgroupV1CPUAcctUsage: "162237573", + cgroupV1CFSQuotaUs: "-1", + cgroupV1MemoryMaxUsageBytes: "7782400", + cgroupV1MemoryUsageBytes: "total_rss 143360", + cgroupV1MemoryStat: "total_inactive_file 3891200", } fsContainerCgroupV2NoLimit = map[string]string{ procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", From fa0c4c6fbf0f373fad282dce0102737918adb3a9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 12:28:43 +0100 Subject: [PATCH 22/47] cgroupv1 memory --- cli/clistat/cgroup.go | 80 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go index 9578b6cfd54c2..41577a39fa4d6 100644 --- a/cli/clistat/cgroup.go +++ b/cli/clistat/cgroup.go @@ -12,10 +12,14 @@ import ( ) const ( - cgroupV1CPUAcctUsage = "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage" - cgroupV1CFSQuotaUs = "/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us" - cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max" - cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat" + cgroupV1CPUAcctUsage = "/sys/fs/cgroup/cpu/cpuacct.usage" + cgroupV1CPUAcctUsageAlt = "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage" + cgroupV1CFSQuotaUs = "/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us" + cgroupV1MemoryMaxUsageBytes = "/sys/fs/cgroup/memory/memory.max_usage_in_bytes" + cgroupV1MemoryUsageBytes = "/sys/fs/cgroup/memory/memory.usage_in_bytes" + cgroupV1MemoryStat = "/sys/fs/cgroup/memory/memory.stat" + cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max" + cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat" ) // ContainerCPU returns the CPU usage of the container cgroup. @@ -57,7 +61,7 @@ func (s *Statter) cgroupCPU() (used, total time.Duration, err error) { func (s *Statter) isCGroupV2() bool { // Check for the presence of /sys/fs/cgroup/cpu.max - _, err := s.fs.Stat("/sys/fs/cgroup/cpu.max") + _, err := s.fs.Stat(cgroupV2CPUMax) return err == nil } @@ -178,7 +182,11 @@ func (s *Statter) cgroupV1CPUUsed() (time.Duration, error) { data, err = afero.ReadFile(s.fs, cgroupV1CPUAcctUsage) if err != nil { - return 0, xerrors.Errorf("read %s: %w", cgroupV1CPUAcctUsage, err) + // try alternate path + data, err = afero.ReadFile(s.fs, cgroupV1CPUAcctUsageAlt) + if err != nil { + return 0, xerrors.Errorf("read %s or %s: %w", cgroupV1CPUAcctUsage, cgroupV1CPUAcctUsageAlt, err) + } } usageUs, err = strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) @@ -209,7 +217,61 @@ func (*Statter) cGroupv2Memory() (*Result, error) { return nil, nil } -func (*Statter) cGroupv1Memory() (*Result, error) { - // TODO implement - return nil, nil +func (s *Statter) cGroupv1Memory() (*Result, error) { + var data []byte + var err error + var usageBytes int64 + var maxUsageBytes int64 + var totalInactiveFileBytes int64 + + // Read max memory usage + data, err = afero.ReadFile(s.fs, cgroupV1MemoryMaxUsageBytes) + if err != nil { + return nil, xerrors.Errorf("read %s: %w", cgroupV1MemoryMaxUsageBytes, err) + } + + maxUsageBytes, err = strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) + if err != nil { + return nil, xerrors.Errorf("parse %s: %w", cgroupV1MemoryMaxUsageBytes, err) + } + + // Read current memory usage + data, err = afero.ReadFile(s.fs, cgroupV1MemoryUsageBytes) + if err != nil { + return nil, xerrors.Errorf("read %s: %w", cgroupV1MemoryUsageBytes, err) + } + + usageBytes, err = strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) + if err != nil { + return nil, xerrors.Errorf("parse %s: %w", cgroupV1MemoryUsageBytes, err) + } + + // Get total_inactive_file from memory.stat + data, err = afero.ReadFile(s.fs, cgroupV1MemoryStat) + if err != nil { + return nil, xerrors.Errorf("read %s: %w", cgroupV1MemoryStat, err) + } + scn := bufio.NewScanner(bytes.NewReader(data)) + for scn.Scan() { + line := scn.Bytes() + if !bytes.HasPrefix(line, []byte("total_inactive_file")) { + continue + } + + parts := bytes.Split(line, []byte(" ")) + if len(parts) != 2 { + return nil, xerrors.Errorf("unexpected value in %s: %s", cgroupV1MemoryUsageBytes, string(line)) + } + totalInactiveFileBytes, err = strconv.ParseInt(string(bytes.TrimSpace(parts[1])), 10, 64) + if err != nil { + return nil, xerrors.Errorf("parse %s: %w", cgroupV1MemoryUsageBytes, err) + } + } + + // Total memory used is usage - total_inactive_file + return &Result{ + Total: ptr.To(float64(maxUsageBytes) / 1024 / 1024 / 1024), + Used: float64(usageBytes-totalInactiveFileBytes) / 1024 / 1024 / 1024, + Unit: "GB", + }, nil } From 70ef79bb91cc421fd5da535446d33519090ff7c5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 16:25:56 +0100 Subject: [PATCH 23/47] improve tests to allow testing cpu used --- cli/clistat/cgroup.go | 250 ++++++++++++++++-------------- cli/clistat/stat.go | 6 +- cli/clistat/stat_internal_test.go | 186 ++++++++++++---------- 3 files changed, 238 insertions(+), 204 deletions(-) diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go index 41577a39fa4d6..b75045578253b 100644 --- a/cli/clistat/cgroup.go +++ b/cli/clistat/cgroup.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "strconv" + "strings" "time" "github.com/spf13/afero" @@ -11,15 +12,37 @@ import ( "tailscale.com/types/ptr" ) +// Paths for CGroupV1. +// Ref: https://www.kernel.org/doc/Documentation/cgroup-v1/cpuacct.txt const ( - cgroupV1CPUAcctUsage = "/sys/fs/cgroup/cpu/cpuacct.usage" - cgroupV1CPUAcctUsageAlt = "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage" - cgroupV1CFSQuotaUs = "/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us" + // CPU usage of all tasks in cgroup in nanoseconds. + cgroupV1CPUAcctUsage = "/sys/fs/cgroup/cpu/cpuacct.usage" + // Alternate path + cgroupV1CPUAcctUsageAlt = "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage" + // CFS quota and period for cgroup in MICROseconds + cgroupV1CFSQuotaUs = "/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us" + cgroupV1CFSPeriodUs = "/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us" + // Maximum memory usable by cgroup in bytes cgroupV1MemoryMaxUsageBytes = "/sys/fs/cgroup/memory/memory.max_usage_in_bytes" - cgroupV1MemoryUsageBytes = "/sys/fs/cgroup/memory/memory.usage_in_bytes" - cgroupV1MemoryStat = "/sys/fs/cgroup/memory/memory.stat" - cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max" - cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat" + // Current memory usage of cgroup in bytes + cgroupV1MemoryUsageBytes = "/sys/fs/cgroup/memory/memory.usage_in_bytes" + // Other memory stats - we are interested in total_inactive_file + cgroupV1MemoryStat = "/sys/fs/cgroup/memory/memory.stat" +) + +// Paths for CGroupV2. +// Ref: https://docs.kernel.org/admin-guide/cgroup-v2.html +const ( + // Contains quota and period in microseconds separated by a space. + cgroupV2CPUMax = "/sys/fs/cgroup/cpu.max" + // Contains current CPU usage under usage_usec + cgroupV2CPUStat = "/sys/fs/cgroup/cpu.stat" + // Contains current cgroup memory usage in bytes. + cgroupV2MemoryUsageBytes = "/sys/fs/cgroup/memory.current" + // Contains max cgroup memory usage in bytes. + cgroupV2MemoryMaxBytes = "/sys/fs/cgroup/memory.max" + // Other memory stats - we are interested in total_inactive_file + cgroupV2MemoryStat = "/sys/fs/cgroup/memory.stat" ) // ContainerCPU returns the CPU usage of the container cgroup. @@ -34,7 +57,7 @@ func (s *Statter) ContainerCPU() (*Result, error) { if err != nil { return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) } - <-time.After(s.sampleInterval) + s.wait(s.sampleInterval) // total is unlikely to change. Use the first value. used2, _, err := s.cgroupCPU() @@ -44,7 +67,7 @@ func (s *Statter) ContainerCPU() (*Result, error) { r := &Result{ Unit: "cores", - Used: (used2 - used1).Seconds() * s.sampleInterval.Seconds(), + Used: (used2 - used1).Seconds(), Total: ptr.To(total.Seconds()), // close enough to the truth } return r, nil @@ -68,72 +91,31 @@ func (s *Statter) isCGroupV2() bool { func (s *Statter) cGroupV2CPU() (used, total time.Duration, err error) { total, err = s.cGroupv2CPUTotal() if err != nil { - return 0, 0, xerrors.Errorf("get cgroup v2 cpu total: %w", err) + return 0, 0, xerrors.Errorf("get cpu total: %w", err) } used, err = s.cGroupv2CPUUsed() if err != nil { - return 0, 0, xerrors.Errorf("get cgroup v2 cpu used: %w", err) + return 0, 0, xerrors.Errorf("get cpu used: %w", err) } return used, total, nil } func (s *Statter) cGroupv2CPUUsed() (used time.Duration, err error) { - var data []byte - data, err = afero.ReadFile(s.fs, cgroupV2CPUStat) + iused, err := readInt64Prefix(s.fs, cgroupV2CPUStat, "usage_usec") if err != nil { - return 0, xerrors.Errorf("read %s: %w", cgroupV2CPUStat, err) - } - - bs := bufio.NewScanner(bytes.NewReader(data)) - for bs.Scan() { - line := bs.Bytes() - if !bytes.HasPrefix(line, []byte("usage_usec ")) { - continue - } - - parts := bytes.Split(line, []byte(" ")) - if len(parts) != 2 { - return 0, xerrors.Errorf("unexpected line in %s: %s", cgroupV2CPUStat, line) - } - - iused, err := strconv.Atoi(string(parts[1])) - if err != nil { - return 0, xerrors.Errorf("parse %s: %w", err, cgroupV2CPUStat) - } - - return time.Duration(iused) * time.Microsecond, nil + return 0, xerrors.Errorf("get cgroupv2 cpu used: %w", err) } - - return 0, xerrors.Errorf("did not find expected usage_usec in %s", cgroupV2CPUStat) + return time.Duration(iused) * time.Microsecond, nil } func (s *Statter) cGroupv2CPUTotal() (total time.Duration, err error) { - var data []byte var quotaUs int64 - data, err = afero.ReadFile(s.fs, cgroupV2CPUMax) + quotaUs, err = readInt64SepIdx(s.fs, cgroupV2CPUMax, " ", 0) if err != nil { - return 0, xerrors.Errorf("read %s: %w", cgroupV2CPUMax, err) - } - - lines := bytes.Split(data, []byte("\n")) - if len(lines) < 1 { - return 0, xerrors.Errorf("unexpected empty %s", cgroupV2CPUMax) - } - - parts := bytes.Split(lines[0], []byte(" ")) - if len(parts) != 2 { - return 0, xerrors.Errorf("unexpected line in %s: %s", cgroupV2CPUMax, lines[0]) - } - - if bytes.Equal(parts[0], []byte("max")) { + // Fall back to number of cores quotaUs = int64(s.nproc) * time.Second.Microseconds() - } else { - quotaUs, err = strconv.ParseInt(string(parts[0]), 10, 64) - if err != nil { - return 0, xerrors.Errorf("parse %s: %w", cgroupV2CPUMax, err) - } } return time.Duration(quotaUs) * time.Microsecond, nil @@ -142,33 +124,25 @@ func (s *Statter) cGroupv2CPUTotal() (total time.Duration, err error) { func (s *Statter) cGroupV1CPU() (used, total time.Duration, err error) { total, err = s.cGroupV1CPUTotal() if err != nil { - return 0, 0, xerrors.Errorf("get cgroup v1 CPU total: %w", err) + return 0, 0, xerrors.Errorf("get cpu total: %w", err) } used, err = s.cgroupV1CPUUsed() if err != nil { - return 0, 0, xerrors.Errorf("get cgruop v1 cpu used: %w", err) + return 0, 0, xerrors.Errorf("get cpu used: %w", err) } return used, total, nil } func (s *Statter) cGroupV1CPUTotal() (time.Duration, error) { - var data []byte - var err error - var quotaUs int64 - - data, err = afero.ReadFile(s.fs, cgroupV1CFSQuotaUs) + quotaUs, err := readInt64(s.fs, cgroupV1CFSQuotaUs) if err != nil { - return 0, xerrors.Errorf("read %s: %w", cgroupV1CFSQuotaUs, err) - } - - quotaUs, err = strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) - if err != nil { - return 0, xerrors.Errorf("parse %s: %w", cgroupV1CFSQuotaUs, err) + return 0, xerrors.Errorf("read cpu quota: %w", err) } if quotaUs < 0 { + // Fall back to the number of cores quotaUs = int64(s.nproc) * time.Second.Microseconds() } @@ -176,32 +150,23 @@ func (s *Statter) cGroupV1CPUTotal() (time.Duration, error) { } func (s *Statter) cgroupV1CPUUsed() (time.Duration, error) { - var data []byte - var err error - var usageUs int64 - - data, err = afero.ReadFile(s.fs, cgroupV1CPUAcctUsage) + usageNs, err := readInt64(s.fs, cgroupV1CPUAcctUsage) if err != nil { // try alternate path - data, err = afero.ReadFile(s.fs, cgroupV1CPUAcctUsageAlt) + usageNs, err = readInt64(s.fs, cgroupV1CPUAcctUsageAlt) if err != nil { - return 0, xerrors.Errorf("read %s or %s: %w", cgroupV1CPUAcctUsage, cgroupV1CPUAcctUsageAlt, err) + return 0, xerrors.Errorf("read cpu used: %w", err) } } - usageUs, err = strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) - if err != nil { - return 0, xerrors.Errorf("parse %s: %w", cgroupV1CPUAcctUsage, err) - } - - return time.Duration(usageUs) * time.Microsecond, nil + return time.Duration(usageNs), nil } // ContainerMemory returns the memory usage of the container cgroup. // If the system is not containerized, this always returns nil. func (s *Statter) ContainerMemory() (*Result, error) { if ok, err := IsContainerized(s.fs); err != nil || !ok { - return nil, nil + return nil, nil //nolint:nilnil } if s.isCGroupV2() { @@ -212,66 +177,115 @@ func (s *Statter) ContainerMemory() (*Result, error) { return s.cGroupv1Memory() } -func (*Statter) cGroupv2Memory() (*Result, error) { - // TODO implement - return nil, nil +func (s *Statter) cGroupv2Memory() (*Result, error) { + maxUsageBytes, err := readInt64(s.fs, cgroupV2MemoryMaxBytes) + if err != nil { + return nil, xerrors.Errorf("read memory total: %w", err) + } + + currUsageBytes, err := readInt64(s.fs, cgroupV2MemoryUsageBytes) + if err != nil { + return nil, xerrors.Errorf("read memory usage: %w", err) + } + + inactiveFileBytes, err := readInt64Prefix(s.fs, cgroupV2MemoryStat, "inactive_file") + if err != nil { + return nil, xerrors.Errorf("read memory stats: %w", err) + } + + return &Result{ + Total: ptr.To(float64(maxUsageBytes) / 1024 / 1024 / 1024), + Used: float64(currUsageBytes-inactiveFileBytes) / 1024 / 1024 / 1024, + Unit: "GB", + }, nil } func (s *Statter) cGroupv1Memory() (*Result, error) { - var data []byte - var err error - var usageBytes int64 - var maxUsageBytes int64 - var totalInactiveFileBytes int64 - - // Read max memory usage - data, err = afero.ReadFile(s.fs, cgroupV1MemoryMaxUsageBytes) + maxUsageBytes, err := readInt64(s.fs, cgroupV1MemoryMaxUsageBytes) + if err != nil { + return nil, xerrors.Errorf("read memory total: %w", err) + } + + // need a space after total_rss so we don't hit something else + usageBytes, err := readInt64(s.fs, cgroupV1MemoryUsageBytes) + if err != nil { + return nil, xerrors.Errorf("read memory usage: %w", err) + } + + totalInactiveFileBytes, err := readInt64Prefix(s.fs, cgroupV1MemoryStat, "total_inactive_file") + if err != nil { + return nil, xerrors.Errorf("read memory stats: %w", err) + } + + // Total memory used is usage - total_inactive_file + return &Result{ + Total: ptr.To(float64(maxUsageBytes) / 1024 / 1024 / 1024), + Used: float64(usageBytes-totalInactiveFileBytes) / 1024 / 1024 / 1024, + Unit: "GB", + }, nil +} + +// read an int64 value from path +func readInt64(fs afero.Fs, path string) (int64, error) { + data, err := afero.ReadFile(fs, path) if err != nil { - return nil, xerrors.Errorf("read %s: %w", cgroupV1MemoryMaxUsageBytes, err) + return 0, xerrors.Errorf("read %s: %w", path, err) } - maxUsageBytes, err = strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) + val, err := strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) if err != nil { - return nil, xerrors.Errorf("parse %s: %w", cgroupV1MemoryMaxUsageBytes, err) + return 0, xerrors.Errorf("parse %s: %w", path, err) } - // Read current memory usage - data, err = afero.ReadFile(s.fs, cgroupV1MemoryUsageBytes) + return val, nil +} + +// read an int64 value from path at field idx separated by sep +func readInt64SepIdx(fs afero.Fs, path, sep string, idx int) (int64, error) { + data, err := afero.ReadFile(fs, path) if err != nil { - return nil, xerrors.Errorf("read %s: %w", cgroupV1MemoryUsageBytes, err) + return 0, xerrors.Errorf("read %s: %w", path, err) + } + + parts := strings.Split(string(data), sep) + if len(parts) < idx { + return 0, xerrors.Errorf("expected line %q to have at least %d parts", string(data), idx+1) } - usageBytes, err = strconv.ParseInt(string(bytes.TrimSpace(data)), 10, 64) + val, err := strconv.ParseInt(parts[idx], 10, 64) if err != nil { - return nil, xerrors.Errorf("parse %s: %w", cgroupV1MemoryUsageBytes, err) + return 0, xerrors.Errorf("parse %s: %w", path, err) } - // Get total_inactive_file from memory.stat - data, err = afero.ReadFile(s.fs, cgroupV1MemoryStat) + return val, nil +} + +// read the first int64 value from path prefixed with prefix +func readInt64Prefix(fs afero.Fs, path, prefix string) (int64, error) { + data, err := afero.ReadFile(fs, path) if err != nil { - return nil, xerrors.Errorf("read %s: %w", cgroupV1MemoryStat, err) + return 0, xerrors.Errorf("read %s: %w", path, err) } + scn := bufio.NewScanner(bytes.NewReader(data)) for scn.Scan() { - line := scn.Bytes() - if !bytes.HasPrefix(line, []byte("total_inactive_file")) { + line := scn.Text() + if !strings.HasPrefix(line, prefix) { continue } - parts := bytes.Split(line, []byte(" ")) + parts := strings.Fields(line) if len(parts) != 2 { - return nil, xerrors.Errorf("unexpected value in %s: %s", cgroupV1MemoryUsageBytes, string(line)) + return 0, xerrors.Errorf("parse %s: expected two fields but got %s", path, line) } - totalInactiveFileBytes, err = strconv.ParseInt(string(bytes.TrimSpace(parts[1])), 10, 64) + + val, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) if err != nil { - return nil, xerrors.Errorf("parse %s: %w", cgroupV1MemoryUsageBytes, err) + return 0, xerrors.Errorf("parse %s: %w", path, err) } + + return val, nil } - // Total memory used is usage - total_inactive_file - return &Result{ - Total: ptr.To(float64(maxUsageBytes) / 1024 / 1024 / 1024), - Used: float64(usageBytes-totalInactiveFileBytes) / 1024 / 1024 / 1024, - Unit: "GB", - }, nil + return 0, xerrors.Errorf("parse %s: did not find line with prefix %s", path, prefix) } diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 72456033b0a08..71ff3247b0c68 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -50,6 +50,7 @@ type Statter struct { fs afero.Fs sampleInterval time.Duration nproc int + wait func(time.Duration) } type Option func(*Statter) @@ -78,6 +79,9 @@ func New(opts ...Option) (*Statter, error) { fs: afero.NewReadOnlyFs(afero.NewOsFs()), sampleInterval: 100 * time.Millisecond, nproc: runtime.NumCPU(), + wait: func(d time.Duration) { + <-time.After(d) + }, } for _, opt := range opts { opt(s) @@ -101,7 +105,7 @@ func (s *Statter) HostCPU() (*Result, error) { if err != nil { return nil, xerrors.Errorf("get first cpu sample: %w", err) } - <-time.After(s.sampleInterval) + s.wait(s.sampleInterval) c2, err := s.hi.CPUTime() if err != nil { return nil, xerrors.Errorf("get second cpu sample: %w", err) diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index 4956b7ebe5549..138a7c11e54a3 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -2,6 +2,7 @@ package clistat import ( "testing" + "time" "tailscale.com/types/ptr" @@ -77,13 +78,35 @@ func TestStatter(t *testing.T) { }) }) + // Sometimes we do need to "fake" some stuff + // that happens while we wait. + withWait := func(waitF func(time.Duration)) Option { + return func(s *Statter) { + s.wait = waitF + } + } + + // We don't want to use the actual host CPU here. + withNproc := func(n int) Option { + return func(s *Statter) { + s.nproc = n + } + } + + // For container-specific measurements, everything we need + // can be read from the filesystem. We control the FS, so + // we control the data. t.Run("CGroupV1", func(t *testing.T) { t.Parallel() t.Run("Limit", func(t *testing.T) { t.Parallel() fs := initFS(t, fsContainerCgroupV1) - s, err := New(WithFS(fs)) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") + } + s, err := New(WithFS(fs), withWait(fakeWait)) require.NoError(t, err) t.Run("ContainerCPU", func(t *testing.T) { @@ -91,8 +114,7 @@ func TestStatter(t *testing.T) { cpu, err := s.ContainerCPU() require.NoError(t, err) require.NotNil(t, cpu) - // This value does not change in between tests so it is zero. - assert.Zero(t, cpu.Used) + assert.Equal(t, 1.0, cpu.Used) require.NotNil(t, cpu.Total) assert.Equal(t, 2.5, *cpu.Total) assert.Equal(t, "cores", cpu.Unit) @@ -103,39 +125,28 @@ func TestStatter(t *testing.T) { mem, err := s.ContainerMemory() require.NoError(t, err) require.NotNil(t, mem) - assert.NotZero(t, mem.Used) - assert.NotZero(t, mem.Total) + assert.Equal(t, 0.25, mem.Used) + assert.Equal(t, 1.0, *mem.Total) assert.Equal(t, "GB", mem.Unit) }) }) - t.Run("NoLimit", func(t *testing.T) { + t.Run("NoCPULimit", func(t *testing.T) { t.Parallel() fs := initFS(t, fsContainerCgroupV1NoLimit) - s, err := New(WithFS(fs)) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") + } + s, err := New(WithFS(fs), withNproc(2), withWait(fakeWait)) require.NoError(t, err) - - t.Run("ContainerCPU", func(t *testing.T) { - t.Parallel() - cpu, err := s.ContainerCPU() - require.NoError(t, err) - require.NotNil(t, cpu) - // This value does not change in between tests so it is zero. - assert.Zero(t, cpu.Used) - require.NotNil(t, cpu.Total) - assert.Equal(t, 2.5, *cpu.Total) - assert.Equal(t, "cores", cpu.Unit) - }) - - t.Run("ContainerMemory", func(t *testing.T) { - t.Parallel() - mem, err := s.ContainerMemory() - require.NoError(t, err) - require.NotNil(t, mem) - assert.NotZero(t, mem.Used) - assert.NotZero(t, mem.Total) - assert.Equal(t, "GB", mem.Unit) - }) + cpu, err := s.ContainerCPU() + require.NoError(t, err) + require.NotNil(t, cpu) + assert.Equal(t, 1.0, cpu.Used) + require.NotNil(t, cpu.Total) + assert.Equal(t, 2.0, *cpu.Total) + assert.Equal(t, "cores", cpu.Unit) }) }) @@ -144,16 +155,17 @@ func TestStatter(t *testing.T) { t.Run("Limit", func(t *testing.T) { t.Parallel() fs := initFS(t, fsContainerCgroupV2) - s, err := New(WithFS(fs)) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV1CPUAcctUsage, "10000000000") + } + s, err := New(WithFS(fs), withWait(fakeWait)) require.NoError(t, err) - // We can make assertions about the below because these all read - // data from known file paths, which we can control. t.Run("ContainerCPU", func(t *testing.T) { t.Parallel() cpu, err := s.ContainerCPU() require.NoError(t, err) require.NotNil(t, cpu) - // This value does not change in between tests so it is zero. assert.Zero(t, cpu.Used) require.NotNil(t, cpu.Total) assert.Equal(t, 2.5, *cpu.Total) @@ -165,42 +177,30 @@ func TestStatter(t *testing.T) { mem, err := s.ContainerMemory() require.NoError(t, err) require.NotNil(t, mem) - assert.NotZero(t, mem.Used) - assert.NotZero(t, mem.Total) + assert.Equal(t, 0.25, mem.Used) + assert.NotNil(t, mem.Total) + assert.Equal(t, 1.0, *mem.Total) assert.Equal(t, "GB", mem.Unit) }) }) - t.Run("NoLimit", func(t *testing.T) { + t.Run("NoCPULimit", func(t *testing.T) { t.Parallel() - fs := initFS(t, fsContainerCgroupV2) - s, err := New(WithFS(fs), func(s *Statter) { - s.nproc = 2 - }) + fs := initFS(t, fsContainerCgroupV2NoLimit) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV1CPUAcctUsage, "100000") + } + s, err := New(WithFS(fs), withNproc(2), withWait(fakeWait)) require.NoError(t, err) - // We can make assertions about the below because these all read - // data from known file paths, which we can control. - t.Run("ContainerCPU", func(t *testing.T) { - t.Parallel() - cpu, err := s.ContainerCPU() - require.NoError(t, err) - require.NotNil(t, cpu) - // This value does not change in between tests so it is zero. - assert.Zero(t, cpu.Used) - require.NotNil(t, cpu.Total) - assert.Equal(t, 2.5, *cpu.Total) - assert.Equal(t, "cores", cpu.Unit) - }) - - t.Run("ContainerMemory", func(t *testing.T) { - t.Parallel() - mem, err := s.ContainerMemory() - require.NoError(t, err) - require.NotNil(t, mem) - assert.NotZero(t, mem.Used) - assert.NotZero(t, mem.Total) - assert.Equal(t, "GB", mem.Unit) - }) + cpu, err := s.ContainerCPU() + require.NoError(t, err) + require.NotNil(t, cpu) + // This value does not change in between tests so it is zero. + assert.Zero(t, cpu.Used) + require.NotNil(t, cpu.Total) + assert.Equal(t, 2.0, *cpu.Total) + assert.Equal(t, "cores", cpu.Unit) }) }) } @@ -255,58 +255,74 @@ func TestIsContainerized(t *testing.T) { } } +// helper function for initializing a fs func initFS(t testing.TB, m map[string]string) afero.Fs { t.Helper() fs := afero.NewMemMapFs() for k, v := range m { - require.NoError(t, afero.WriteFile(fs, k, []byte(v+"\n"), 0o600)) + mungeFS(t, fs, k, v) } return fs } +// helper function for writing v to fs under path k +func mungeFS(t testing.TB, fs afero.Fs, k, v string) { + t.Helper() + require.NoError(t, afero.WriteFile(fs, k, []byte(v+"\n"), 0o600)) +} + var ( fsHostOnly = map[string]string{ procOneCgroup: "0::/", procMounts: "/dev/sda1 / ext4 rw,relatime 0 0", } - fsContainerCgroupV2 = map[string]string{ + fsContainerSysbox = map[string]string{ procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 -proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, +sysboxfs /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, cgroupV2CPUMax: "250000 100000", - cgroupV2CPUStat: "usage_usec 1000000", + cgroupV2CPUStat: "usage_usec 0", } - fsContainerCgroupV1 = map[string]string{ + fsContainerCgroupV2 = map[string]string{ procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, - cgroupV1CPUAcctUsage: "162237573", - cgroupV1CFSQuotaUs: "250000", - cgroupV1MemoryMaxUsageBytes: "7782400", - cgroupV1MemoryUsageBytes: "total_rss 143360", + cgroupV2CPUMax: "250000 100000", + cgroupV2CPUStat: "usage_usec 0", + cgroupV2MemoryMaxBytes: "1073741824", + cgroupV2MemoryUsageBytes: "536870912", + cgroupV2MemoryStat: "inactive_file 268435456", } - fsContainerCgroupV1NoLimit = map[string]string{ + fsContainerCgroupV2NoLimit = map[string]string{ procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, - cgroupV1CPUAcctUsage: "162237573", - cgroupV1CFSQuotaUs: "-1", - cgroupV1MemoryMaxUsageBytes: "7782400", - cgroupV1MemoryUsageBytes: "total_rss 143360", - cgroupV1MemoryStat: "total_inactive_file 3891200", + cgroupV2CPUMax: "max 100000", + cgroupV2CPUStat: "usage_usec 0", + cgroupV2MemoryMaxBytes: "1073741824", + cgroupV2MemoryUsageBytes: "536870912", + cgroupV2MemoryStat: "inactive_file 268435456", } - fsContainerCgroupV2NoLimit = map[string]string{ + fsContainerCgroupV1 = map[string]string{ procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, - cgroupV2CPUMax: "max 100000", - cgroupV2CPUStat: "usage_usec 1000000", + cgroupV1CPUAcctUsage: "0", + cgroupV1CFSQuotaUs: "250000", + cgroupV1CFSPeriodUs: "100000", + cgroupV1MemoryMaxUsageBytes: "1073741824", + cgroupV1MemoryUsageBytes: "536870912", + cgroupV1MemoryStat: "total_inactive_file 268435456", } - fsContainerSysbox = map[string]string{ + fsContainerCgroupV1NoLimit = map[string]string{ procOneCgroup: "0::/docker/aa86ac98959eeedeae0ecb6e0c9ddd8ae8b97a9d0fdccccf7ea7a474f4e0bb1f", procMounts: `overlay / overlay rw,relatime,lowerdir=/some/path:/some/path,upperdir=/some/path:/some/path,workdir=/some/path:/some/path 0 0 -sysboxfs /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, - cgroupV2CPUMax: "250000 100000", - cgroupV2CPUStat: "usage_usec 1000000", +proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0`, + cgroupV1CPUAcctUsage: "0", + cgroupV1CFSQuotaUs: "-1", + cgroupV1CFSPeriodUs: "100000", + cgroupV1MemoryMaxUsageBytes: "1073741824", + cgroupV1MemoryUsageBytes: "536870912", + cgroupV1MemoryStat: "total_inactive_file 268435456", } ) From 7eeefc195493ce566157a3c7cbbfa007247adf08 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 17:07:36 +0100 Subject: [PATCH 24/47] refactor cpu usage calc --- cli/clistat/cgroup.go | 90 +++++++++++++++---------------- cli/clistat/stat_internal_test.go | 47 ++++++++++------ 2 files changed, 72 insertions(+), 65 deletions(-) diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go index b75045578253b..0ee37893a2922 100644 --- a/cli/clistat/cgroup.go +++ b/cli/clistat/cgroup.go @@ -53,33 +53,45 @@ func (s *Statter) ContainerCPU() (*Result, error) { return nil, nil //nolint: nilnil } - used1, total, err := s.cgroupCPU() + total, err := s.cGroupCPUTotal() + if err != nil { + return nil, xerrors.Errorf("get total cpu: %w", err) + } + + used1, err := s.cGroupCPUUsed() if err != nil { return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) } s.wait(s.sampleInterval) - // total is unlikely to change. Use the first value. - used2, _, err := s.cgroupCPU() + used2, err := s.cGroupCPUUsed() if err != nil { return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) } r := &Result{ Unit: "cores", - Used: (used2 - used1).Seconds(), - Total: ptr.To(total.Seconds()), // close enough to the truth + Used: (used2 - used1), + Total: ptr.To(total), } return r, nil } -func (s *Statter) cgroupCPU() (used, total time.Duration, err error) { +func (s *Statter) cGroupCPUTotal() (used float64, err error) { if s.isCGroupV2() { - return s.cGroupV2CPU() + return s.cGroupV2CPUTotal() } // Fall back to CGroupv1 - return s.cGroupV1CPU() + return s.cGroupV1CPUTotal() +} + +func (s *Statter) cGroupCPUUsed() (used float64, err error) { + if s.isCGroupV2() { + return s.cGroupV2CPUUsed() + } + + return s.cGroupV1CPUUsed() } func (s *Statter) isCGroupV2() bool { @@ -88,54 +100,36 @@ func (s *Statter) isCGroupV2() bool { return err == nil } -func (s *Statter) cGroupV2CPU() (used, total time.Duration, err error) { - total, err = s.cGroupv2CPUTotal() - if err != nil { - return 0, 0, xerrors.Errorf("get cpu total: %w", err) - } - - used, err = s.cGroupv2CPUUsed() +func (s *Statter) cGroupV2CPUUsed() (used float64, err error) { + usageUs, err := readInt64Prefix(s.fs, cgroupV2CPUStat, "usage_usec") if err != nil { - return 0, 0, xerrors.Errorf("get cpu used: %w", err) + return 0, xerrors.Errorf("get cgroupv2 cpu used: %w", err) } - - return used, total, nil + return (time.Duration(usageUs) * time.Microsecond).Seconds(), nil } -func (s *Statter) cGroupv2CPUUsed() (used time.Duration, err error) { - iused, err := readInt64Prefix(s.fs, cgroupV2CPUStat, "usage_usec") +func (s *Statter) cGroupV2CPUTotal() (total float64, err error) { + var quotaUs, periodUs int64 + periodUs, err = readInt64SepIdx(s.fs, cgroupV2CPUMax, " ", 1) if err != nil { - return 0, xerrors.Errorf("get cgroupv2 cpu used: %w", err) + return 0, xerrors.Errorf("get cpu period: %w", err) } - return time.Duration(iused) * time.Microsecond, nil -} -func (s *Statter) cGroupv2CPUTotal() (total time.Duration, err error) { - var quotaUs int64 quotaUs, err = readInt64SepIdx(s.fs, cgroupV2CPUMax, " ", 0) if err != nil { // Fall back to number of cores - quotaUs = int64(s.nproc) * time.Second.Microseconds() + quotaUs = int64(s.nproc) * periodUs } - return time.Duration(quotaUs) * time.Microsecond, nil + return float64(quotaUs) / float64(periodUs), nil } -func (s *Statter) cGroupV1CPU() (used, total time.Duration, err error) { - total, err = s.cGroupV1CPUTotal() +func (s *Statter) cGroupV1CPUTotal() (float64, error) { + periodUs, err := readInt64(s.fs, cgroupV1CFSPeriodUs) if err != nil { - return 0, 0, xerrors.Errorf("get cpu total: %w", err) + return 0, xerrors.Errorf("read cpu period: %w", err) } - used, err = s.cgroupV1CPUUsed() - if err != nil { - return 0, 0, xerrors.Errorf("get cpu used: %w", err) - } - - return used, total, nil -} - -func (s *Statter) cGroupV1CPUTotal() (time.Duration, error) { quotaUs, err := readInt64(s.fs, cgroupV1CFSQuotaUs) if err != nil { return 0, xerrors.Errorf("read cpu quota: %w", err) @@ -143,13 +137,13 @@ func (s *Statter) cGroupV1CPUTotal() (time.Duration, error) { if quotaUs < 0 { // Fall back to the number of cores - quotaUs = int64(s.nproc) * time.Second.Microseconds() + quotaUs = int64(s.nproc) * periodUs } - return time.Duration(quotaUs) * time.Microsecond, nil + return float64(quotaUs) / float64(periodUs), nil } -func (s *Statter) cgroupV1CPUUsed() (time.Duration, error) { +func (s *Statter) cGroupV1CPUUsed() (float64, error) { usageNs, err := readInt64(s.fs, cgroupV1CPUAcctUsage) if err != nil { // try alternate path @@ -159,7 +153,7 @@ func (s *Statter) cgroupV1CPUUsed() (time.Duration, error) { } } - return time.Duration(usageNs), nil + return time.Duration(usageNs).Seconds(), nil } // ContainerMemory returns the memory usage of the container cgroup. @@ -170,14 +164,14 @@ func (s *Statter) ContainerMemory() (*Result, error) { } if s.isCGroupV2() { - return s.cGroupv2Memory() + return s.cGroupV2Memory() } // Fall back to CGroupv1 - return s.cGroupv1Memory() + return s.cGroupV1Memory() } -func (s *Statter) cGroupv2Memory() (*Result, error) { +func (s *Statter) cGroupV2Memory() (*Result, error) { maxUsageBytes, err := readInt64(s.fs, cgroupV2MemoryMaxBytes) if err != nil { return nil, xerrors.Errorf("read memory total: %w", err) @@ -200,7 +194,7 @@ func (s *Statter) cGroupv2Memory() (*Result, error) { }, nil } -func (s *Statter) cGroupv1Memory() (*Result, error) { +func (s *Statter) cGroupV1Memory() (*Result, error) { maxUsageBytes, err := readInt64(s.fs, cgroupV1MemoryMaxUsageBytes) if err != nil { return nil, xerrors.Errorf("read memory total: %w", err) @@ -252,7 +246,7 @@ func readInt64SepIdx(fs afero.Fs, path, sep string, idx int) (int64, error) { return 0, xerrors.Errorf("expected line %q to have at least %d parts", string(data), idx+1) } - val, err := strconv.ParseInt(parts[idx], 10, 64) + val, err := strconv.ParseInt(strings.TrimSpace(parts[idx]), 10, 64) if err != nil { return 0, xerrors.Errorf("parse %s: %w", path, err) } diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index 138a7c11e54a3..81723fef41202 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -101,16 +101,16 @@ func TestStatter(t *testing.T) { t.Run("Limit", func(t *testing.T) { t.Parallel() - fs := initFS(t, fsContainerCgroupV1) - fakeWait := func(time.Duration) { - // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") - } - s, err := New(WithFS(fs), withWait(fakeWait)) - require.NoError(t, err) t.Run("ContainerCPU", func(t *testing.T) { t.Parallel() + fs := initFS(t, fsContainerCgroupV1) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") + } + s, err := New(WithFS(fs), withWait(fakeWait)) + require.NoError(t, err) cpu, err := s.ContainerCPU() require.NoError(t, err) require.NotNil(t, cpu) @@ -122,6 +122,13 @@ func TestStatter(t *testing.T) { t.Run("ContainerMemory", func(t *testing.T) { t.Parallel() + fs := initFS(t, fsContainerCgroupV1) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") + } + s, err := New(WithFS(fs), withWait(fakeWait)) + require.NoError(t, err) mem, err := s.ContainerMemory() require.NoError(t, err) require.NotNil(t, mem) @@ -154,19 +161,19 @@ func TestStatter(t *testing.T) { t.Parallel() t.Run("Limit", func(t *testing.T) { t.Parallel() - fs := initFS(t, fsContainerCgroupV2) - fakeWait := func(time.Duration) { - // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV1CPUAcctUsage, "10000000000") - } - s, err := New(WithFS(fs), withWait(fakeWait)) - require.NoError(t, err) t.Run("ContainerCPU", func(t *testing.T) { t.Parallel() + fs := initFS(t, fsContainerCgroupV2) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV1CPUAcctUsage, "10000000000") + } + s, err := New(WithFS(fs), withWait(fakeWait)) + require.NoError(t, err) cpu, err := s.ContainerCPU() require.NoError(t, err) require.NotNil(t, cpu) - assert.Zero(t, cpu.Used) + assert.Equal(t, 1.0, cpu.Used) require.NotNil(t, cpu.Total) assert.Equal(t, 2.5, *cpu.Total) assert.Equal(t, "cores", cpu.Unit) @@ -174,6 +181,13 @@ func TestStatter(t *testing.T) { t.Run("ContainerMemory", func(t *testing.T) { t.Parallel() + fs := initFS(t, fsContainerCgroupV2) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV1CPUAcctUsage, "10000000000") + } + s, err := New(WithFS(fs), withWait(fakeWait)) + require.NoError(t, err) mem, err := s.ContainerMemory() require.NoError(t, err) require.NotNil(t, mem) @@ -196,8 +210,7 @@ func TestStatter(t *testing.T) { cpu, err := s.ContainerCPU() require.NoError(t, err) require.NotNil(t, cpu) - // This value does not change in between tests so it is zero. - assert.Zero(t, cpu.Used) + assert.Equal(t, 1.0, cpu.Used) require.NotNil(t, cpu.Total) assert.Equal(t, 2.0, *cpu.Total) assert.Equal(t, "cores", cpu.Unit) From 305675f72340c6173fde1d0f23df9541e2f88218 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 17:17:17 +0100 Subject: [PATCH 25/47] fix tests --- cli/clistat/stat_internal_test.go | 142 ++++++++++++++---------------- 1 file changed, 66 insertions(+), 76 deletions(-) diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index 81723fef41202..e319fa6869732 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -86,6 +86,11 @@ func TestStatter(t *testing.T) { } } + // Other times we just want things to run fast. + withNoWait := func(s *Statter) { + s.wait = func(time.Duration) {} + } + // We don't want to use the actual host CPU here. withNproc := func(n int) Option { return func(s *Statter) { @@ -98,47 +103,25 @@ func TestStatter(t *testing.T) { // we control the data. t.Run("CGroupV1", func(t *testing.T) { t.Parallel() - - t.Run("Limit", func(t *testing.T) { + t.Run("ContainerCPU/Limit", func(t *testing.T) { t.Parallel() - - t.Run("ContainerCPU", func(t *testing.T) { - t.Parallel() - fs := initFS(t, fsContainerCgroupV1) - fakeWait := func(time.Duration) { - // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") - } - s, err := New(WithFS(fs), withWait(fakeWait)) - require.NoError(t, err) - cpu, err := s.ContainerCPU() - require.NoError(t, err) - require.NotNil(t, cpu) - assert.Equal(t, 1.0, cpu.Used) - require.NotNil(t, cpu.Total) - assert.Equal(t, 2.5, *cpu.Total) - assert.Equal(t, "cores", cpu.Unit) - }) - - t.Run("ContainerMemory", func(t *testing.T) { - t.Parallel() - fs := initFS(t, fsContainerCgroupV1) - fakeWait := func(time.Duration) { - // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") - } - s, err := New(WithFS(fs), withWait(fakeWait)) - require.NoError(t, err) - mem, err := s.ContainerMemory() - require.NoError(t, err) - require.NotNil(t, mem) - assert.Equal(t, 0.25, mem.Used) - assert.Equal(t, 1.0, *mem.Total) - assert.Equal(t, "GB", mem.Unit) - }) + fs := initFS(t, fsContainerCgroupV1) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") + } + s, err := New(WithFS(fs), withWait(fakeWait)) + require.NoError(t, err) + cpu, err := s.ContainerCPU() + require.NoError(t, err) + require.NotNil(t, cpu) + assert.Equal(t, 1.0, cpu.Used) + require.NotNil(t, cpu.Total) + assert.Equal(t, 2.5, *cpu.Total) + assert.Equal(t, "cores", cpu.Unit) }) - t.Run("NoCPULimit", func(t *testing.T) { + t.Run("ContainerCPU/NoLimit", func(t *testing.T) { t.Parallel() fs := initFS(t, fsContainerCgroupV1NoLimit) fakeWait := func(time.Duration) { @@ -155,55 +138,48 @@ func TestStatter(t *testing.T) { assert.Equal(t, 2.0, *cpu.Total) assert.Equal(t, "cores", cpu.Unit) }) + + t.Run("ContainerMemory", func(t *testing.T) { + t.Parallel() + fs := initFS(t, fsContainerCgroupV1) + s, err := New(WithFS(fs), withNoWait) + require.NoError(t, err) + mem, err := s.ContainerMemory() + require.NoError(t, err) + require.NotNil(t, mem) + assert.Equal(t, 0.25, mem.Used) + assert.Equal(t, 1.0, *mem.Total) + assert.Equal(t, "GB", mem.Unit) + }) }) t.Run("CGroupV2", func(t *testing.T) { t.Parallel() - t.Run("Limit", func(t *testing.T) { - t.Parallel() - t.Run("ContainerCPU", func(t *testing.T) { - t.Parallel() - fs := initFS(t, fsContainerCgroupV2) - fakeWait := func(time.Duration) { - // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV1CPUAcctUsage, "10000000000") - } - s, err := New(WithFS(fs), withWait(fakeWait)) - require.NoError(t, err) - cpu, err := s.ContainerCPU() - require.NoError(t, err) - require.NotNil(t, cpu) - assert.Equal(t, 1.0, cpu.Used) - require.NotNil(t, cpu.Total) - assert.Equal(t, 2.5, *cpu.Total) - assert.Equal(t, "cores", cpu.Unit) - }) - t.Run("ContainerMemory", func(t *testing.T) { - t.Parallel() - fs := initFS(t, fsContainerCgroupV2) - fakeWait := func(time.Duration) { - // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV1CPUAcctUsage, "10000000000") - } - s, err := New(WithFS(fs), withWait(fakeWait)) - require.NoError(t, err) - mem, err := s.ContainerMemory() - require.NoError(t, err) - require.NotNil(t, mem) - assert.Equal(t, 0.25, mem.Used) - assert.NotNil(t, mem.Total) - assert.Equal(t, 1.0, *mem.Total) - assert.Equal(t, "GB", mem.Unit) - }) + t.Run("ContainerCPU/Limit", func(t *testing.T) { + t.Parallel() + fs := initFS(t, fsContainerCgroupV2) + fakeWait := func(time.Duration) { + // Fake 1 second in ns of usage + mungeFS(t, fs, cgroupV2CPUStat, "usage_usec 1000000") + } + s, err := New(WithFS(fs), withWait(fakeWait)) + require.NoError(t, err) + cpu, err := s.ContainerCPU() + require.NoError(t, err) + require.NotNil(t, cpu) + assert.Equal(t, 1.0, cpu.Used) + require.NotNil(t, cpu.Total) + assert.Equal(t, 2.5, *cpu.Total) + assert.Equal(t, "cores", cpu.Unit) }) - t.Run("NoCPULimit", func(t *testing.T) { + t.Run("ContainerCPU/NoLimit", func(t *testing.T) { t.Parallel() fs := initFS(t, fsContainerCgroupV2NoLimit) fakeWait := func(time.Duration) { // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV1CPUAcctUsage, "100000") + mungeFS(t, fs, cgroupV2CPUStat, "usage_usec 1000000") } s, err := New(WithFS(fs), withNproc(2), withWait(fakeWait)) require.NoError(t, err) @@ -215,6 +191,20 @@ func TestStatter(t *testing.T) { assert.Equal(t, 2.0, *cpu.Total) assert.Equal(t, "cores", cpu.Unit) }) + + t.Run("ContainerMemory", func(t *testing.T) { + t.Parallel() + fs := initFS(t, fsContainerCgroupV2) + s, err := New(WithFS(fs), withNoWait) + require.NoError(t, err) + mem, err := s.ContainerMemory() + require.NoError(t, err) + require.NotNil(t, mem) + assert.Equal(t, 0.25, mem.Used) + assert.NotNil(t, mem.Total) + assert.Equal(t, 1.0, *mem.Total) + assert.Equal(t, "GB", mem.Unit) + }) }) } From d1bb3224c23e8752c3d71fadd51274d99e6cdbf9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 17:32:53 +0100 Subject: [PATCH 26/47] fix off-by-10 error --- cli/clistat/cgroup.go | 17 ++++++++++++++--- cli/clistat/stat_internal_test.go | 10 ++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go index 0ee37893a2922..9294b9f854a85 100644 --- a/cli/clistat/cgroup.go +++ b/cli/clistat/cgroup.go @@ -5,7 +5,6 @@ import ( "bytes" "strconv" "strings" - "time" "github.com/spf13/afero" "golang.org/x/xerrors" @@ -105,7 +104,12 @@ func (s *Statter) cGroupV2CPUUsed() (used float64, err error) { if err != nil { return 0, xerrors.Errorf("get cgroupv2 cpu used: %w", err) } - return (time.Duration(usageUs) * time.Microsecond).Seconds(), nil + periodUs, err := readInt64SepIdx(s.fs, cgroupV2CPUMax, " ", 1) + if err != nil { + return 0, xerrors.Errorf("get cpu period: %w", err) + } + + return float64(usageUs) / float64(periodUs), nil } func (s *Statter) cGroupV2CPUTotal() (total float64, err error) { @@ -153,7 +157,14 @@ func (s *Statter) cGroupV1CPUUsed() (float64, error) { } } - return time.Duration(usageNs).Seconds(), nil + // usage is in ns, convert to us + usageNs /= 1000 + periodUs, err := readInt64(s.fs, cgroupV1CFSPeriodUs) + if err != nil { + return 0, xerrors.Errorf("get cpu period: %w", err) + } + + return float64(usageNs) / float64(periodUs), nil } // ContainerMemory returns the memory usage of the container cgroup. diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index e319fa6869732..c8dc211ea7121 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -108,7 +108,7 @@ func TestStatter(t *testing.T) { fs := initFS(t, fsContainerCgroupV1) fakeWait := func(time.Duration) { // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") + mungeFS(t, fs, cgroupV1CPUAcctUsage, "100000000") } s, err := New(WithFS(fs), withWait(fakeWait)) require.NoError(t, err) @@ -126,7 +126,7 @@ func TestStatter(t *testing.T) { fs := initFS(t, fsContainerCgroupV1NoLimit) fakeWait := func(time.Duration) { // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV1CPUAcctUsage, "1000000000") + mungeFS(t, fs, cgroupV1CPUAcctUsage, "100000000") } s, err := New(WithFS(fs), withNproc(2), withWait(fakeWait)) require.NoError(t, err) @@ -160,8 +160,7 @@ func TestStatter(t *testing.T) { t.Parallel() fs := initFS(t, fsContainerCgroupV2) fakeWait := func(time.Duration) { - // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV2CPUStat, "usage_usec 1000000") + mungeFS(t, fs, cgroupV2CPUStat, "usage_usec 100000") } s, err := New(WithFS(fs), withWait(fakeWait)) require.NoError(t, err) @@ -178,8 +177,7 @@ func TestStatter(t *testing.T) { t.Parallel() fs := initFS(t, fsContainerCgroupV2NoLimit) fakeWait := func(time.Duration) { - // Fake 1 second in ns of usage - mungeFS(t, fs, cgroupV2CPUStat, "usage_usec 1000000") + mungeFS(t, fs, cgroupV2CPUStat, "usage_usec 100000") } s, err := New(WithFS(fs), withNproc(2), withWait(fakeWait)) require.NoError(t, err) From eb2bcf6110293c2c43f76858297f305a479f00e0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 17:33:16 +0100 Subject: [PATCH 27/47] remove --sample-interval and collect CPU stats in parallel --- cli/stat.go | 54 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/cli/stat.go b/cli/stat.go index 21f8b0a291f3b..7a5e08fecd16f 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -3,7 +3,6 @@ package cli import ( "fmt" "os" - "time" "github.com/spf13/afero" @@ -20,38 +19,57 @@ func (*RootCmd) stat() *clibase.Cmd { defaultCols = append([]string{"container_cpu", "container_memory"}, defaultCols...) } var ( - sampleInterval time.Duration - formatter = cliui.NewOutputFormatter( + formatter = cliui.NewOutputFormatter( cliui.TableFormat([]statsRow{}, defaultCols), cliui.JSONFormat(), ) ) cmd := &clibase.Cmd{ - Use: "stat", - Short: "Show workspace resource usage.", - Options: clibase.OptionSet{ - { - Description: "Configure the sample interval.", - Flag: "sample-interval", - Value: clibase.DurationOf(&sampleInterval), - Default: "100ms", - }, - }, + Use: "stat", + Short: "Show workspace resource usage.", + Options: clibase.OptionSet{}, Handler: func(inv *clibase.Invocation) error { - st, err := clistat.New(clistat.WithSampleInterval(sampleInterval), clistat.WithFS(fs)) + st, err := clistat.New(clistat.WithFS(fs)) if err != nil { return err } - // Host-level stats var sr statsRow - cs, err := st.HostCPU() - if err != nil { + + // Get CPU measurements first. + errCh := make(chan error, 2) + go func() { + cs, err := st.HostCPU() + if err != nil { + errCh <- err + return + } + sr.HostCPU = cs + errCh <- nil + }() + go func() { + if ok, _ := clistat.IsContainerized(fs); !ok { + errCh <- nil + } + cs, err := st.ContainerCPU() + if err != nil { + errCh <- err + return + } + sr.ContainerCPU = cs + errCh <- nil + }() + + if err1 := <-errCh; err1 != nil { return err } - sr.HostCPU = cs + if err2 := <-errCh; err2 != nil { + return err + } + close(errCh) + // Host-level stats ms, err := st.HostMemory() if err != nil { return err From 44edcf394364bde203c1b7c879a8dba64135dcb7 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 18:02:50 +0100 Subject: [PATCH 28/47] fmt; gen --- cli/clistat/container.go | 6 ++++-- cli/stat.go | 8 +++----- docs/cli.md | 1 + docs/cli/stat.md | 31 +++++++++++++++++++++++++++++++ docs/manifest.json | 5 +++++ 5 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 docs/cli/stat.md diff --git a/cli/clistat/container.go b/cli/clistat/container.go index 09e2eb2bf42d4..27b9c715ed741 100644 --- a/cli/clistat/container.go +++ b/cli/clistat/container.go @@ -9,8 +9,10 @@ import ( "golang.org/x/xerrors" ) -const procMounts = "/proc/mounts" -const procOneCgroup = "/proc/1/cgroup" +const ( + procMounts = "/proc/mounts" + procOneCgroup = "/proc/1/cgroup" +) // IsContainerized returns whether the host is containerized. // This is adapted from https://github.com/elastic/go-sysinfo/tree/main/providers/linux/container.go#L31 diff --git a/cli/stat.go b/cli/stat.go index 7a5e08fecd16f..12f315329347c 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -18,11 +18,9 @@ func (*RootCmd) stat() *clibase.Cmd { // If running in a container, we assume that users want to see these first. Prepend. defaultCols = append([]string{"container_cpu", "container_memory"}, defaultCols...) } - var ( - formatter = cliui.NewOutputFormatter( - cliui.TableFormat([]statsRow{}, defaultCols), - cliui.JSONFormat(), - ) + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]statsRow{}, defaultCols), + cliui.JSONFormat(), ) cmd := &clibase.Cmd{ diff --git a/docs/cli.md b/docs/cli.md index c92caa6feb037..0ce3ff240ac5d 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.md) | Run upload and download tests from your machine to a workspace | | [ssh](./cli/ssh.md) | Start a shell into a workspace | | [start](./cli/start.md) | Start a workspace | +| [stat](./cli/stat.md) | Show workspace resource usage. | | [state](./cli/state.md) | Manually manage Terraform state to fix broken workspaces | | [stop](./cli/stop.md) | Stop a workspace | | [templates](./cli/templates.md) | Manage templates | diff --git a/docs/cli/stat.md b/docs/cli/stat.md new file mode 100644 index 0000000000000..d5de89957de51 --- /dev/null +++ b/docs/cli/stat.md @@ -0,0 +1,31 @@ + + +# stat + +Show workspace resource usage. + +## Usage + +```console +coder stat [flags] +``` + +## Options + +### -c, --column + +| | | +| ------- | -------------------------------------------------------------------------- | +| Type | string-array | +| Default | container_cpu,container_memory,host_cpu,host_memory,home_disk | + +Columns to display in table output. Available columns: host cpu, host memory, home disk, container cpu, container memory. + +### -o, --output + +| | | +| ------- | ------------------- | +| Type | string | +| Default | table | + +Output format. Available formats: table, json. diff --git a/docs/manifest.json b/docs/manifest.json index 47ddd9d7f9f48..a5a19a419ab44 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -703,6 +703,11 @@ "description": "Start a workspace", "path": "cli/start.md" }, + { + "title": "stat", + "description": "Show workspace resource usage.", + "path": "cli/stat.md" + }, { "title": "state", "description": "Manually manage Terraform state to fix broken workspaces", From 0f3254a64640f1ace3dabf9ea2e315305de2eb1c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 18:23:38 +0100 Subject: [PATCH 29/47] make default_cols consistent to avoid ci surprises --- cli/stat.go | 6 +----- docs/cli/stat.md | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/cli/stat.go b/cli/stat.go index 12f315329347c..2b33888c93687 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -13,11 +13,7 @@ import ( func (*RootCmd) stat() *clibase.Cmd { fs := afero.NewReadOnlyFs(afero.NewOsFs()) - defaultCols := []string{"host_cpu", "host_memory", "home_disk"} - if ok, err := clistat.IsContainerized(fs); err == nil && ok { - // If running in a container, we assume that users want to see these first. Prepend. - defaultCols = append([]string{"container_cpu", "container_memory"}, defaultCols...) - } + defaultCols := []string{"host_cpu", "host_memory", "home_disk", "container_cpu", "container_memory"} formatter := cliui.NewOutputFormatter( cliui.TableFormat([]statsRow{}, defaultCols), cliui.JSONFormat(), diff --git a/docs/cli/stat.md b/docs/cli/stat.md index d5de89957de51..86d92f44aac44 100644 --- a/docs/cli/stat.md +++ b/docs/cli/stat.md @@ -17,7 +17,7 @@ coder stat [flags] | | | | ------- | -------------------------------------------------------------------------- | | Type | string-array | -| Default | container_cpu,container_memory,host_cpu,host_memory,home_disk | +| Default | host_cpu,host_memory,home_disk,container_cpu,container_memory | Columns to display in table output. Available columns: host cpu, host memory, home disk, container cpu, container memory. From edd99f40fd9f6fdc68f70c91203a363a99e2d27d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 20:18:41 +0100 Subject: [PATCH 30/47] fix race condition --- cli/stat.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cli/stat.go b/cli/stat.go index 2b33888c93687..f90fc297c545b 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -32,36 +32,37 @@ func (*RootCmd) stat() *clibase.Cmd { var sr statsRow // Get CPU measurements first. - errCh := make(chan error, 2) + hostErr := make(chan error) + containerErr := make(chan error) go func() { + defer close(hostErr) cs, err := st.HostCPU() if err != nil { - errCh <- err + hostErr <- err return } sr.HostCPU = cs - errCh <- nil }() go func() { + defer close(containerErr) if ok, _ := clistat.IsContainerized(fs); !ok { - errCh <- nil + // don't error if we're not in a container + return } cs, err := st.ContainerCPU() if err != nil { - errCh <- err + containerErr <- err return } sr.ContainerCPU = cs - errCh <- nil }() - if err1 := <-errCh; err1 != nil { + if err := <-hostErr; err != nil { return err } - if err2 := <-errCh; err2 != nil { + if err := <-containerErr; err != nil { return err } - close(errCh) // Host-level stats ms, err := st.HostMemory() From 49b68616639860c808e09cfa003b72d60ac5d06a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 20:19:00 +0100 Subject: [PATCH 31/47] remove UPTIME from test --- cli/stat_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/stat_test.go b/cli/stat_test.go index 92415ffb03d7e..c38e778a321b6 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -48,7 +48,6 @@ func TestStatCmd(t *testing.T) { require.Contains(t, s, "HOST CPU") require.Contains(t, s, "HOST MEMORY") require.Contains(t, s, "HOME DISK") - require.Contains(t, s, "UPTIME") }) t.Run("Default", func(t *testing.T) { t.Parallel() @@ -64,6 +63,5 @@ func TestStatCmd(t *testing.T) { require.Contains(t, s, "HOST CPU") require.Contains(t, s, "HOST MEMORY") require.Contains(t, s, "HOME DISK") - require.Contains(t, s, "UPTIME") }) } From 69b190442e61b15316cea50c42c2d6fbd4829143 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 13 Jun 2023 20:25:11 +0100 Subject: [PATCH 32/47] update golden files --- cli/testdata/coder_--help.golden | 1 + cli/testdata/coder_stat_--help.golden | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 cli/testdata/coder_stat_--help.golden diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 7b2fcd494a5db..350282e9ac9bc 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 Show workspace resource usage. 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..7469fad90e875 --- /dev/null +++ b/cli/testdata/coder_stat_--help.golden @@ -0,0 +1,14 @@ +Usage: coder stat [flags] + +Show workspace resource usage. + +Options + -c, --column string-array (default: host_cpu,host_memory,home_disk,container_cpu,container_memory) + Columns to display in table output. Available columns: host cpu, host + memory, home disk, container cpu, container memory. + + -o, --output string (default: table) + Output format. Available formats: table, json. + +--- +Run `coder --help` for a list of global options. From 7eb526d9d74a799d1b8f8cbd30d3555b7928041b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 14 Jun 2023 11:48:50 +0100 Subject: [PATCH 33/47] add stat subcommands --- cli/stat.go | 138 +++++++++++++++++++-- cli/stat_test.go | 99 ++++++++++++++- cli/testdata/coder_--help.golden | 2 +- cli/testdata/coder_stat_--help.golden | 7 +- cli/testdata/coder_stat_cpu_--help.golden | 13 ++ cli/testdata/coder_stat_disk_--help.golden | 13 ++ cli/testdata/coder_stat_mem_--help.golden | 13 ++ docs/cli.md | 2 +- docs/cli/stat.md | 10 +- docs/cli/stat_cpu.md | 30 +++++ docs/cli/stat_disk.md | 31 +++++ docs/cli/stat_mem.md | 30 +++++ docs/manifest.json | 17 ++- 13 files changed, 387 insertions(+), 18 deletions(-) create mode 100644 cli/testdata/coder_stat_cpu_--help.golden create mode 100644 cli/testdata/coder_stat_disk_--help.golden create mode 100644 cli/testdata/coder_stat_mem_--help.golden create mode 100644 docs/cli/stat_cpu.md create mode 100644 docs/cli/stat_disk.md create mode 100644 docs/cli/stat_mem.md diff --git a/cli/stat.go b/cli/stat.go index f90fc297c545b..e9ae80c5976a8 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -5,30 +5,40 @@ import ( "os" "github.com/spf13/afero" + "golang.org/x/xerrors" "github.com/coder/coder/cli/clibase" "github.com/coder/coder/cli/clistat" "github.com/coder/coder/cli/cliui" ) -func (*RootCmd) stat() *clibase.Cmd { +func (r *RootCmd) stat() *clibase.Cmd { fs := afero.NewReadOnlyFs(afero.NewOsFs()) - defaultCols := []string{"host_cpu", "host_memory", "home_disk", "container_cpu", "container_memory"} + defaultCols := []string{ + "host_cpu", + "host_memory", + "home_disk", + "container_cpu", + "container_memory", + } formatter := cliui.NewOutputFormatter( cliui.TableFormat([]statsRow{}, defaultCols), cliui.JSONFormat(), ) + st, err := clistat.New(clistat.WithFS(fs)) + if err != nil { + panic(xerrors.Errorf("initialize workspace stats collector: %w", err)) + } cmd := &clibase.Cmd{ - Use: "stat", - Short: "Show workspace resource usage.", - Options: clibase.OptionSet{}, + Use: "stat", + Short: "Show resource usage for the current workspace.", + Children: []*clibase.Cmd{ + r.statCPU(st, fs), + r.statMem(st, fs), + r.statDisk(st, fs), + }, Handler: func(inv *clibase.Invocation) error { - st, err := clistat.New(clistat.WithFS(fs)) - if err != nil { - return err - } - var sr statsRow // Get CPU measurements first. @@ -108,6 +118,114 @@ func (*RootCmd) stat() *clibase.Cmd { return cmd } +func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { + var hostArg bool + formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) + + cmd := &clibase.Cmd{ + Use: "cpu", + Short: "Show CPU usage, in cores.", + Options: clibase.OptionSet{ + { + Flag: "host", + Value: clibase.BoolOf(&hostArg), + Description: "Force host CPU measurement.", + }, + }, + Handler: func(inv *clibase.Invocation) error { + var cs *clistat.Result + var err error + if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { + cs, err = s.ContainerCPU() + } else { + cs, err = s.HostCPU() + } + if err != nil { + return err + } + out, err := formatter.Format(inv.Context(), cs) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + formatter.AttachOptions(&cmd.Options) + + return cmd +} + +func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { + var hostArg bool + formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) + cmd := &clibase.Cmd{ + Use: "mem", + Short: "Show memory usage, in gigabytes.", + Options: clibase.OptionSet{ + { + Flag: "host", + Value: clibase.BoolOf(&hostArg), + Description: "Force host memory measurement.", + }, + }, + Handler: func(inv *clibase.Invocation) error { + var ms *clistat.Result + var err error + if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { + ms, err = s.ContainerMemory() + } else { + ms, err = s.HostMemory() + } + if err != nil { + return err + } + out, err := formatter.Format(inv.Context(), ms) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + +func (*RootCmd) statDisk(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { + var pathArg string + formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) + cmd := &clibase.Cmd{ + Use: "disk", + Short: "Show disk usage, in gigabytes.", + Options: clibase.OptionSet{ + { + Flag: "path", + Value: clibase.StringOf(&pathArg), + Description: "Path for which to check disk usage.", + Default: "/", + }, + }, + Handler: func(inv *clibase.Invocation) error { + ds, err := s.Disk(pathArg) + if err != nil { + return err + } + + out, err := formatter.Format(inv.Context(), ds) + if err != nil { + return err + } + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + type statsRow struct { HostCPU *clistat.Result `json:"host_cpu" table:"host_cpu,default_sort"` HostMemory *clistat.Result `json:"host_memory" table:"host_memory"` diff --git a/cli/stat_test.go b/cli/stat_test.go index c38e778a321b6..f8650d10dbcfe 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -23,7 +23,7 @@ func TestStatCmd(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - inv, _ := clitest.New(t, "stat", "--output=json") + inv, _ := clitest.New(t, "stat", "all", "--output=json") buf := new(bytes.Buffer) inv.Stdout = buf err := inv.WithContext(ctx).Run() @@ -38,7 +38,7 @@ func TestStatCmd(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - inv, _ := clitest.New(t, "stat", "--output=table") + inv, _ := clitest.New(t, "stat", "all", "--output=table") buf := new(bytes.Buffer) inv.Stdout = buf err := inv.WithContext(ctx).Run() @@ -53,7 +53,7 @@ func TestStatCmd(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - inv, _ := clitest.New(t, "stat") + inv, _ := clitest.New(t, "stat", "all") buf := new(bytes.Buffer) inv.Stdout = buf err := inv.WithContext(ctx).Run() @@ -65,3 +65,96 @@ func TestStatCmd(t *testing.T) { require.Contains(t, s, "HOME DISK") }) } + +func TestStatCPUCmd(t *testing.T) { + t.Parallel() + + t.Run("Text", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "cpu", "--output=text") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + s := buf.String() + require.NotEmpty(t, s) + }) + + t.Run("JSON", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "cpu", "--output=json") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + s := buf.String() + tmp := struct{}{} + require.NoError(t, json.NewDecoder(strings.NewReader(s)).Decode(&tmp)) + }) +} + +func TestStatMemCmd(t *testing.T) { + t.Parallel() + + t.Run("Text", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "mem", "--output=text") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + s := buf.String() + require.NotEmpty(t, s) + }) + + t.Run("JSON", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "mem", "--output=json") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + s := buf.String() + tmp := struct{}{} + require.NoError(t, json.NewDecoder(strings.NewReader(s)).Decode(&tmp)) + }) +} + +func TestStatDiskCmd(t *testing.T) { + t.Parallel() + + t.Run("Text", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "disk", "--output=text") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + s := buf.String() + require.NotEmpty(t, s) + }) + + t.Run("JSON", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, "stat", "disk", "--output=json") + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + s := buf.String() + tmp := struct{}{} + require.NoError(t, json.NewDecoder(strings.NewReader(s)).Decode(&tmp)) + }) +} diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 350282e9ac9bc..381bc30ece5b9 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -34,7 +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 Show workspace resource usage. + stat Show resource usage for the current workspace. 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 index 7469fad90e875..e2eddcb30fcf2 100644 --- a/cli/testdata/coder_stat_--help.golden +++ b/cli/testdata/coder_stat_--help.golden @@ -1,6 +1,11 @@ Usage: coder stat [flags] -Show workspace resource usage. +Show resource usage for the current workspace. + +Subcommands + cpu Show CPU usage, in cores. + disk Show disk usage, in gigabytes. + mem Show memory usage, in gigabytes. Options -c, --column string-array (default: host_cpu,host_memory,home_disk,container_cpu,container_memory) diff --git a/cli/testdata/coder_stat_cpu_--help.golden b/cli/testdata/coder_stat_cpu_--help.golden new file mode 100644 index 0000000000000..f9cc1124b2637 --- /dev/null +++ b/cli/testdata/coder_stat_cpu_--help.golden @@ -0,0 +1,13 @@ +Usage: coder stat cpu [flags] + +Show CPU usage, in cores. + +Options + --host bool + Force host CPU measurement. + + -o, --output string (default: text) + Output format. Available formats: text, json. + +--- +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_stat_disk_--help.golden b/cli/testdata/coder_stat_disk_--help.golden new file mode 100644 index 0000000000000..cb33481f726b0 --- /dev/null +++ b/cli/testdata/coder_stat_disk_--help.golden @@ -0,0 +1,13 @@ +Usage: coder stat disk [flags] + +Show disk usage, in gigabytes. + +Options + -o, --output string (default: text) + Output format. Available formats: text, json. + + --path string (default: /) + Path for which to check disk usage. + +--- +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_stat_mem_--help.golden b/cli/testdata/coder_stat_mem_--help.golden new file mode 100644 index 0000000000000..0905c38a9639d --- /dev/null +++ b/cli/testdata/coder_stat_mem_--help.golden @@ -0,0 +1,13 @@ +Usage: coder stat mem [flags] + +Show memory usage, in gigabytes. + +Options + --host bool + Force host memory measurement. + + -o, --output string (default: text) + Output format. Available formats: text, json. + +--- +Run `coder --help` for a list of global options. diff --git a/docs/cli.md b/docs/cli.md index 0ce3ff240ac5d..9ff6f4596a8e0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -49,7 +49,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [speedtest](./cli/speedtest.md) | Run upload and download tests from your machine to a workspace | | [ssh](./cli/ssh.md) | Start a shell into a workspace | | [start](./cli/start.md) | Start a workspace | -| [stat](./cli/stat.md) | Show workspace resource usage. | +| [stat](./cli/stat.md) | Show resource usage for the current workspace. | | [state](./cli/state.md) | Manually manage Terraform state to fix broken workspaces | | [stop](./cli/stop.md) | Stop a workspace | | [templates](./cli/templates.md) | Manage templates | diff --git a/docs/cli/stat.md b/docs/cli/stat.md index 86d92f44aac44..ef66830f9348b 100644 --- a/docs/cli/stat.md +++ b/docs/cli/stat.md @@ -2,7 +2,7 @@ # stat -Show workspace resource usage. +Show resource usage for the current workspace. ## Usage @@ -10,6 +10,14 @@ Show workspace resource usage. coder stat [flags] ``` +## Subcommands + +| Name | Purpose | +| ----------------------------------- | -------------------------------- | +| [cpu](./stat_cpu.md) | Show CPU usage, in cores. | +| [disk](./stat_disk.md) | Show disk usage, in gigabytes. | +| [mem](./stat_mem.md) | Show memory usage, in gigabytes. | + ## Options ### -c, --column diff --git a/docs/cli/stat_cpu.md b/docs/cli/stat_cpu.md new file mode 100644 index 0000000000000..f86397155d5cc --- /dev/null +++ b/docs/cli/stat_cpu.md @@ -0,0 +1,30 @@ + + +# stat cpu + +Show CPU usage, in cores. + +## Usage + +```console +coder stat cpu [flags] +``` + +## Options + +### --host + +| | | +| ---- | ----------------- | +| Type | bool | + +Force host CPU measurement. + +### -o, --output + +| | | +| ------- | ------------------- | +| Type | string | +| Default | text | + +Output format. Available formats: text, json. diff --git a/docs/cli/stat_disk.md b/docs/cli/stat_disk.md new file mode 100644 index 0000000000000..6b6ddc34882c8 --- /dev/null +++ b/docs/cli/stat_disk.md @@ -0,0 +1,31 @@ + + +# stat disk + +Show disk usage, in gigabytes. + +## Usage + +```console +coder stat disk [flags] +``` + +## Options + +### -o, --output + +| | | +| ------- | ------------------- | +| Type | string | +| Default | text | + +Output format. Available formats: text, json. + +### --path + +| | | +| ------- | ------------------- | +| Type | string | +| Default | / | + +Path for which to check disk usage. diff --git a/docs/cli/stat_mem.md b/docs/cli/stat_mem.md new file mode 100644 index 0000000000000..387e7d9ad18cb --- /dev/null +++ b/docs/cli/stat_mem.md @@ -0,0 +1,30 @@ + + +# stat mem + +Show memory usage, in gigabytes. + +## Usage + +```console +coder stat mem [flags] +``` + +## Options + +### --host + +| | | +| ---- | ----------------- | +| Type | bool | + +Force host memory measurement. + +### -o, --output + +| | | +| ------- | ------------------- | +| Type | string | +| Default | text | + +Output format. Available formats: text, json. diff --git a/docs/manifest.json b/docs/manifest.json index a5a19a419ab44..f01144605eb50 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -705,9 +705,24 @@ }, { "title": "stat", - "description": "Show workspace resource usage.", + "description": "Show resource usage for the current workspace.", "path": "cli/stat.md" }, + { + "title": "stat cpu", + "description": "Show CPU usage, in cores.", + "path": "cli/stat_cpu.md" + }, + { + "title": "stat disk", + "description": "Show disk usage, in gigabytes.", + "path": "cli/stat_disk.md" + }, + { + "title": "stat mem", + "description": "Show memory usage, in gigabytes.", + "path": "cli/stat_mem.md" + }, { "title": "state", "description": "Manually manage Terraform state to fix broken workspaces", From 665bf7fea59b2d1de10a737d10e6be19fa57165e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 14 Jun 2023 14:19:55 +0100 Subject: [PATCH 34/47] allow modifying unit prefixes --- cli/clistat/cgroup.go | 43 ++++++---- cli/clistat/disk.go | 9 +- cli/clistat/disk_windows.go | 9 +- cli/clistat/stat.go | 96 ++++++++++++++++++++-- cli/clistat/stat_internal_test.go | 43 ++++++---- cli/stat.go | 48 +++++++---- cli/testdata/coder_stat_cpu_--help.golden | 3 + cli/testdata/coder_stat_disk_--help.golden | 3 + cli/testdata/coder_stat_mem_--help.golden | 3 + docs/cli/stat_cpu.md | 8 ++ docs/cli/stat_disk.md | 9 ++ docs/cli/stat_mem.md | 9 ++ 12 files changed, 222 insertions(+), 61 deletions(-) diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go index 9294b9f854a85..ff84434547042 100644 --- a/cli/clistat/cgroup.go +++ b/cli/clistat/cgroup.go @@ -45,8 +45,13 @@ const ( ) // ContainerCPU returns the CPU usage of the container cgroup. +// This is calculated as difference of two samples of the +// CPU usage of the container cgroup. +// The total is read from the relevant path in /sys/fs/cgroup. +// If there is no limit set, the total is assumed to be the +// number of host cores multiplied by the CFS period. // If the system is not containerized, this always returns nil. -func (s *Statter) ContainerCPU() (*Result, error) { +func (s *Statter) ContainerCPU(m Prefix) (*Result, error) { // Firstly, check if we are containerized. if ok, err := IsContainerized(s.fs); err != nil || !ok { return nil, nil //nolint: nilnil @@ -61,6 +66,11 @@ func (s *Statter) ContainerCPU() (*Result, error) { if err != nil { return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) } + + // The measurements in /sys/fs/cgroup are counters. + // We need to wait for a bit to get a difference. + // Note that someone could reset the counter in the meantime. + // We can't do anything about that. s.wait(s.sampleInterval) used2, err := s.cGroupCPUUsed() @@ -69,9 +79,10 @@ func (s *Statter) ContainerCPU() (*Result, error) { } r := &Result{ - Unit: "cores", - Used: (used2 - used1), - Total: ptr.To(total), + Unit: "cores", + Prefix: m, + Used: (used2 - used1), + Total: ptr.To(total), } return r, nil } @@ -169,20 +180,20 @@ func (s *Statter) cGroupV1CPUUsed() (float64, error) { // ContainerMemory returns the memory usage of the container cgroup. // If the system is not containerized, this always returns nil. -func (s *Statter) ContainerMemory() (*Result, error) { +func (s *Statter) ContainerMemory(m Prefix) (*Result, error) { if ok, err := IsContainerized(s.fs); err != nil || !ok { return nil, nil //nolint:nilnil } if s.isCGroupV2() { - return s.cGroupV2Memory() + return s.cGroupV2Memory(m) } // Fall back to CGroupv1 - return s.cGroupV1Memory() + return s.cGroupV1Memory(m) } -func (s *Statter) cGroupV2Memory() (*Result, error) { +func (s *Statter) cGroupV2Memory(m Prefix) (*Result, error) { maxUsageBytes, err := readInt64(s.fs, cgroupV2MemoryMaxBytes) if err != nil { return nil, xerrors.Errorf("read memory total: %w", err) @@ -199,13 +210,14 @@ func (s *Statter) cGroupV2Memory() (*Result, error) { } return &Result{ - Total: ptr.To(float64(maxUsageBytes) / 1024 / 1024 / 1024), - Used: float64(currUsageBytes-inactiveFileBytes) / 1024 / 1024 / 1024, - Unit: "GB", + Total: ptr.To(float64(maxUsageBytes)), + Used: float64(currUsageBytes - inactiveFileBytes), + Unit: "B", + Prefix: m, }, nil } -func (s *Statter) cGroupV1Memory() (*Result, error) { +func (s *Statter) cGroupV1Memory(m Prefix) (*Result, error) { maxUsageBytes, err := readInt64(s.fs, cgroupV1MemoryMaxUsageBytes) if err != nil { return nil, xerrors.Errorf("read memory total: %w", err) @@ -224,9 +236,10 @@ func (s *Statter) cGroupV1Memory() (*Result, error) { // Total memory used is usage - total_inactive_file return &Result{ - Total: ptr.To(float64(maxUsageBytes) / 1024 / 1024 / 1024), - Used: float64(usageBytes-totalInactiveFileBytes) / 1024 / 1024 / 1024, - Unit: "GB", + Total: ptr.To(float64(maxUsageBytes)), + Used: float64(usageBytes - totalInactiveFileBytes), + Unit: "B", + Prefix: m, }, nil } diff --git a/cli/clistat/disk.go b/cli/clistat/disk.go index 0628b9515af6c..ab17976ed2315 100644 --- a/cli/clistat/disk.go +++ b/cli/clistat/disk.go @@ -10,7 +10,7 @@ import ( // Disk returns the disk usage of the given path. // If path is empty, it returns the usage of the root directory. -func (*Statter) Disk(path string) (*Result, error) { +func (*Statter) Disk(path string, m Prefix) (*Result, error) { if path == "" { path = "/" } @@ -19,8 +19,9 @@ func (*Statter) Disk(path string) (*Result, error) { return nil, err } var r Result - r.Total = ptr.To(float64(stat.Blocks*uint64(stat.Bsize)) / 1024 / 1024 / 1024) - r.Used = float64(stat.Blocks-stat.Bfree) * float64(stat.Bsize) / 1024 / 1024 / 1024 - r.Unit = "GB" + r.Total = ptr.To(float64(stat.Blocks * uint64(stat.Bsize))) + r.Used = float64(stat.Blocks-stat.Bfree) * float64(stat.Bsize) + r.Unit = "B" + r.Prefix = m return &r, nil } diff --git a/cli/clistat/disk_windows.go b/cli/clistat/disk_windows.go index 584a6e78ee3e4..df80214b962ac 100644 --- a/cli/clistat/disk_windows.go +++ b/cli/clistat/disk_windows.go @@ -7,7 +7,7 @@ import ( // Disk returns the disk usage of the given path. // If path is empty, it defaults to C:\ -func (*Statter) Disk(path string) (*Result, error) { +func (*Statter) Disk(path string, m Prefix) (*Result, error) { if path == "" { path = `C:\` } @@ -28,8 +28,9 @@ func (*Statter) Disk(path string) (*Result, error) { } var r Result - r.Total = ptr.To(float64(totalBytes) / 1024 / 1024 / 1024) - r.Used = float64(totalBytes-freeBytes) / 1024 / 1024 / 1024 - r.Unit = "GB" + r.Total = ptr.To(float64(totalBytes)) + r.Used = float64(totalBytes - freeBytes) + r.Unit = "B" + r.Prefix = m return &r, nil } diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 71ff3247b0c68..eb7ee11aec31e 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -14,6 +14,77 @@ import ( sysinfotypes "github.com/elastic/go-sysinfo/types" ) +// Prefix is an SI prefix for a unit. +type Prefix string + +// Float64 returns the prefix as a float64. +func (m *Prefix) Float64() (float64, error) { + switch *m { + case PrefixDeciShort, PrefixDeci: + return 0.1, nil + case PrefixCentiShort, PrefixCenti: + return 0.01, nil + case PrefixMilliShort, PrefixMilli: + return 0.001, nil + case PrefixMicroShort, PrefixMicro: + return 0.000_001, nil + case PrefixNanoShort, PrefixNano: + return 0.000_000_001, nil + case PrefixKiloShort, PrefixKilo: + return 1_000.0, nil + case PrefixMegaShort, PrefixMega: + return 1_000_000.0, nil + case PrefixGigaShort, PrefixGiga: + return 1_000_000_000.0, nil + case PrefixTeraShort, PrefixTera: + return 1_000_000_000_000.0, nil + case PrefixKibiShort, PrefixKibi: + return 1024.0, nil + case PrefixMebiShort, PrefixMebi: + return 1_048_576.0, nil + case PrefixGibiShort, PrefixGibi: + return 1_073_741_824.0, nil + case PrefixTebiShort, PrefixTebi: + return 1_099_511_627_776.0, nil + default: + return 0, xerrors.Errorf("unknown prefix: %s", *m) + } +} + +const ( + PrefixDeci Prefix = "deci" + PrefixCenti Prefix = "centi" + PrefixMilli Prefix = "milli" + PrefixMicro Prefix = "micro" + PrefixNano Prefix = "nano" + + PrefixDeciShort Prefix = "d" + PrefixCentiShort Prefix = "c" + PrefixMilliShort Prefix = "m" + PrefixMicroShort Prefix = "u" + PrefixNanoShort Prefix = "n" + + PrefixKilo Prefix = "kilo" + PrefixMega Prefix = "mega" + PrefixGiga Prefix = "giga" + PrefixTera Prefix = "tera" + + PrefixKiloShort Prefix = "K" + PrefixMegaShort Prefix = "M" + PrefixGigaShort Prefix = "G" + PrefixTeraShort Prefix = "T" + + PrefixKibi = "kibi" + PrefixMebi = "mebi" + PrefixGibi = "gibi" + PrefixTebi = "tebi" + + PrefixKibiShort Prefix = "Ki" + PrefixMebiShort Prefix = "Mi" + PrefixGibiShort Prefix = "Gi" + PrefixTebiShort Prefix = "Ti" +) + // Result is a generic result type for a statistic. // Total is the total amount of the resource available. // It is nil if the resource is not a finite quantity. @@ -23,6 +94,8 @@ type Result struct { Total *float64 `json:"total"` Unit string `json:"unit"` Used float64 `json:"used"` + // Prefix controls the string representation of the result. + Prefix Prefix `json:"-"` } // String returns a human-readable representation of the result. @@ -31,13 +104,20 @@ func (r *Result) String() string { return "-" } var sb strings.Builder - _, _ = sb.WriteString(strconv.FormatFloat(r.Used, 'f', 1, 64)) + scale, err := r.Prefix.Float64() + prefix := string(r.Prefix) + if err != nil { + prefix = "" + scale = 1.0 + } + _, _ = sb.WriteString(strconv.FormatFloat(r.Used/scale, 'f', 1, 64)) if r.Total != (*float64)(nil) { _, _ = sb.WriteString("/") - _, _ = sb.WriteString(strconv.FormatFloat(*r.Total, 'f', 1, 64)) + _, _ = sb.WriteString(strconv.FormatFloat(*r.Total/scale, 'f', 1, 64)) } if r.Unit != "" { _, _ = sb.WriteString(" ") + _, _ = sb.WriteString(prefix) _, _ = sb.WriteString(r.Unit) } return sb.String() @@ -96,7 +176,7 @@ func New(opts ...Option) (*Statter, error) { // This is calculated by taking the difference between the total and idle HostCPU time // and scaling it by the number of cores. // Units are in "cores". -func (s *Statter) HostCPU() (*Result, error) { +func (s *Statter) HostCPU(m Prefix) (*Result, error) { r := &Result{ Unit: "cores", Total: ptr.To(float64(s.nproc)), @@ -115,19 +195,21 @@ func (s *Statter) HostCPU() (*Result, error) { used := total - idle scaleFactor := float64(s.nproc) / total.Seconds() r.Used = used.Seconds() * scaleFactor + r.Prefix = m return r, nil } // HostMemory returns the memory usage of the host, in gigabytes. -func (s *Statter) HostMemory() (*Result, error) { +func (s *Statter) HostMemory(m Prefix) (*Result, error) { r := &Result{ - Unit: "GB", + Unit: "B", + Prefix: m, } hm, err := s.hi.Memory() if err != nil { return nil, xerrors.Errorf("get memory info: %w", err) } - r.Total = ptr.To(float64(hm.Total) / 1024 / 1024 / 1024) - r.Used = float64(hm.Used) / 1024 / 1024 / 1024 + r.Total = ptr.To(float64(hm.Total)) + r.Used = float64(hm.Used) return r, nil } diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index c8dc211ea7121..1c2e159f6e9ad 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -33,6 +33,14 @@ func TestResultString(t *testing.T) { Expected: "12.3", Result: Result{Used: 12.34, Total: nil, Unit: ""}, }, + { + Expected: "1.5 KiB", + Result: Result{Used: 1536, Total: nil, Unit: "B", Prefix: PrefixKibiShort}, + }, + { + Expected: "1.2 things", + Result: Result{Used: 1.234, Total: nil, Unit: "things", Prefix: "invalid"}, + }, } { assert.Equal(t, tt.Expected, tt.Result.String()) } @@ -52,7 +60,7 @@ func TestStatter(t *testing.T) { require.NoError(t, err) t.Run("HostCPU", func(t *testing.T) { t.Parallel() - cpu, err := s.HostCPU() + cpu, err := s.HostCPU("") require.NoError(t, err) assert.NotZero(t, cpu.Used) assert.NotZero(t, cpu.Total) @@ -61,20 +69,20 @@ func TestStatter(t *testing.T) { t.Run("HostMemory", func(t *testing.T) { t.Parallel() - mem, err := s.HostMemory() + mem, err := s.HostMemory("") require.NoError(t, err) assert.NotZero(t, mem.Used) assert.NotZero(t, mem.Total) - assert.Equal(t, "GB", mem.Unit) + assert.Equal(t, "B", mem.Unit) }) t.Run("HostDisk", func(t *testing.T) { t.Parallel() - disk, err := s.Disk("") // default to home dir + disk, err := s.Disk("", "") // default to home dir require.NoError(t, err) assert.NotZero(t, disk.Used) assert.NotZero(t, disk.Total) - assert.NotZero(t, disk.Unit) + assert.Equal(t, "B", disk.Unit) }) }) @@ -112,7 +120,7 @@ func TestStatter(t *testing.T) { } s, err := New(WithFS(fs), withWait(fakeWait)) require.NoError(t, err) - cpu, err := s.ContainerCPU() + cpu, err := s.ContainerCPU("") require.NoError(t, err) require.NotNil(t, cpu) assert.Equal(t, 1.0, cpu.Used) @@ -130,7 +138,7 @@ func TestStatter(t *testing.T) { } s, err := New(WithFS(fs), withNproc(2), withWait(fakeWait)) require.NoError(t, err) - cpu, err := s.ContainerCPU() + cpu, err := s.ContainerCPU("") require.NoError(t, err) require.NotNil(t, cpu) assert.Equal(t, 1.0, cpu.Used) @@ -144,12 +152,13 @@ func TestStatter(t *testing.T) { fs := initFS(t, fsContainerCgroupV1) s, err := New(WithFS(fs), withNoWait) require.NoError(t, err) - mem, err := s.ContainerMemory() + mem, err := s.ContainerMemory("") require.NoError(t, err) require.NotNil(t, mem) - assert.Equal(t, 0.25, mem.Used) - assert.Equal(t, 1.0, *mem.Total) - assert.Equal(t, "GB", mem.Unit) + assert.Equal(t, 268435456.0, mem.Used) + assert.NotNil(t, mem.Total) + assert.Equal(t, 1073741824.0, *mem.Total) + assert.Equal(t, "B", mem.Unit) }) }) @@ -164,7 +173,7 @@ func TestStatter(t *testing.T) { } s, err := New(WithFS(fs), withWait(fakeWait)) require.NoError(t, err) - cpu, err := s.ContainerCPU() + cpu, err := s.ContainerCPU("") require.NoError(t, err) require.NotNil(t, cpu) assert.Equal(t, 1.0, cpu.Used) @@ -181,7 +190,7 @@ func TestStatter(t *testing.T) { } s, err := New(WithFS(fs), withNproc(2), withWait(fakeWait)) require.NoError(t, err) - cpu, err := s.ContainerCPU() + cpu, err := s.ContainerCPU("") require.NoError(t, err) require.NotNil(t, cpu) assert.Equal(t, 1.0, cpu.Used) @@ -195,13 +204,13 @@ func TestStatter(t *testing.T) { fs := initFS(t, fsContainerCgroupV2) s, err := New(WithFS(fs), withNoWait) require.NoError(t, err) - mem, err := s.ContainerMemory() + mem, err := s.ContainerMemory("") require.NoError(t, err) require.NotNil(t, mem) - assert.Equal(t, 0.25, mem.Used) + assert.Equal(t, 268435456.0, mem.Used) assert.NotNil(t, mem.Total) - assert.Equal(t, 1.0, *mem.Total) - assert.Equal(t, "GB", mem.Unit) + assert.Equal(t, 1073741824.0, *mem.Total) + assert.Equal(t, "B", mem.Unit) }) }) } diff --git a/cli/stat.go b/cli/stat.go index e9ae80c5976a8..d26370dfd99ab 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -36,7 +36,7 @@ func (r *RootCmd) stat() *clibase.Cmd { Children: []*clibase.Cmd{ r.statCPU(st, fs), r.statMem(st, fs), - r.statDisk(st, fs), + r.statDisk(st), }, Handler: func(inv *clibase.Invocation) error { var sr statsRow @@ -46,7 +46,7 @@ func (r *RootCmd) stat() *clibase.Cmd { containerErr := make(chan error) go func() { defer close(hostErr) - cs, err := st.HostCPU() + cs, err := st.HostCPU("") if err != nil { hostErr <- err return @@ -59,7 +59,7 @@ func (r *RootCmd) stat() *clibase.Cmd { // don't error if we're not in a container return } - cs, err := st.ContainerCPU() + cs, err := st.ContainerCPU(clistat.PrefixGibiShort) if err != nil { containerErr <- err return @@ -75,7 +75,7 @@ func (r *RootCmd) stat() *clibase.Cmd { } // Host-level stats - ms, err := st.HostMemory() + ms, err := st.HostMemory(clistat.PrefixGibiShort) if err != nil { return err } @@ -85,7 +85,7 @@ func (r *RootCmd) stat() *clibase.Cmd { if err != nil { return err } - ds, err := st.Disk(home) + ds, err := st.Disk(home, clistat.PrefixGibiShort) if err != nil { return err } @@ -93,13 +93,13 @@ func (r *RootCmd) stat() *clibase.Cmd { // Container-only stats. if ok, err := clistat.IsContainerized(fs); err == nil && ok { - cs, err := st.ContainerCPU() + cs, err := st.ContainerCPU("") if err != nil { return err } sr.ContainerCPU = cs - ms, err := st.ContainerMemory() + ms, err := st.ContainerMemory(clistat.PrefixGibiShort) if err != nil { return err } @@ -120,8 +120,8 @@ func (r *RootCmd) stat() *clibase.Cmd { func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { var hostArg bool + var prefixArg string formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) - cmd := &clibase.Cmd{ Use: "cpu", Short: "Show CPU usage, in cores.", @@ -131,14 +131,20 @@ func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { Value: clibase.BoolOf(&hostArg), Description: "Force host CPU measurement.", }, + { + Flag: "prefix", + Value: clibase.StringOf(&prefixArg), + Description: "Unit prefix.", + Default: "", + }, }, Handler: func(inv *clibase.Invocation) error { var cs *clistat.Result var err error if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { - cs, err = s.ContainerCPU() + cs, err = s.ContainerCPU(clistat.Prefix(prefixArg)) } else { - cs, err = s.HostCPU() + cs, err = s.HostCPU(clistat.Prefix(prefixArg)) } if err != nil { return err @@ -158,6 +164,7 @@ func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { var hostArg bool + var prefixArg string formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) cmd := &clibase.Cmd{ Use: "mem", @@ -168,14 +175,20 @@ func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { Value: clibase.BoolOf(&hostArg), Description: "Force host memory measurement.", }, + { + Flag: "prefix", + Value: clibase.StringOf(&prefixArg), + Description: "Unit prefix.", + Default: string(clistat.PrefixGibiShort), + }, }, Handler: func(inv *clibase.Invocation) error { var ms *clistat.Result var err error if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { - ms, err = s.ContainerMemory() + ms, err = s.ContainerMemory(clistat.Prefix(prefixArg)) } else { - ms, err = s.HostMemory() + ms, err = s.HostMemory(clistat.Prefix(prefixArg)) } if err != nil { return err @@ -193,8 +206,9 @@ func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { return cmd } -func (*RootCmd) statDisk(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { +func (*RootCmd) statDisk(s *clistat.Statter) *clibase.Cmd { var pathArg string + var prefixArg string formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) cmd := &clibase.Cmd{ Use: "disk", @@ -206,9 +220,15 @@ func (*RootCmd) statDisk(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { Description: "Path for which to check disk usage.", Default: "/", }, + { + Flag: "prefix", + Value: clibase.StringOf(&prefixArg), + Description: "Unit prefix.", + Default: string(clistat.PrefixGibiShort), + }, }, Handler: func(inv *clibase.Invocation) error { - ds, err := s.Disk(pathArg) + ds, err := s.Disk(pathArg, clistat.Prefix(prefixArg)) if err != nil { return err } diff --git a/cli/testdata/coder_stat_cpu_--help.golden b/cli/testdata/coder_stat_cpu_--help.golden index f9cc1124b2637..dba620751ba6d 100644 --- a/cli/testdata/coder_stat_cpu_--help.golden +++ b/cli/testdata/coder_stat_cpu_--help.golden @@ -9,5 +9,8 @@ Show CPU usage, in cores. -o, --output string (default: text) Output format. Available formats: text, json. + --prefix string + Unit prefix. + --- Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_stat_disk_--help.golden b/cli/testdata/coder_stat_disk_--help.golden index cb33481f726b0..0dd7e3968d729 100644 --- a/cli/testdata/coder_stat_disk_--help.golden +++ b/cli/testdata/coder_stat_disk_--help.golden @@ -9,5 +9,8 @@ Show disk usage, in gigabytes. --path string (default: /) Path for which to check disk usage. + --prefix string (default: Gi) + Unit prefix. + --- Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_stat_mem_--help.golden b/cli/testdata/coder_stat_mem_--help.golden index 0905c38a9639d..b6d84afe8ee09 100644 --- a/cli/testdata/coder_stat_mem_--help.golden +++ b/cli/testdata/coder_stat_mem_--help.golden @@ -9,5 +9,8 @@ Show memory usage, in gigabytes. -o, --output string (default: text) Output format. Available formats: text, json. + --prefix string (default: Gi) + Unit prefix. + --- Run `coder --help` for a list of global options. diff --git a/docs/cli/stat_cpu.md b/docs/cli/stat_cpu.md index f86397155d5cc..7edc442a1cb33 100644 --- a/docs/cli/stat_cpu.md +++ b/docs/cli/stat_cpu.md @@ -28,3 +28,11 @@ Force host CPU measurement. | Default | text | Output format. Available formats: text, json. + +### --prefix + +| | | +| ---- | ------------------- | +| Type | string | + +Unit prefix. diff --git a/docs/cli/stat_disk.md b/docs/cli/stat_disk.md index 6b6ddc34882c8..235a363e9324a 100644 --- a/docs/cli/stat_disk.md +++ b/docs/cli/stat_disk.md @@ -29,3 +29,12 @@ Output format. Available formats: text, json. | Default | / | Path for which to check disk usage. + +### --prefix + +| | | +| ------- | ------------------- | +| Type | string | +| Default | Gi | + +Unit prefix. diff --git a/docs/cli/stat_mem.md b/docs/cli/stat_mem.md index 387e7d9ad18cb..a9274ce8afdf7 100644 --- a/docs/cli/stat_mem.md +++ b/docs/cli/stat_mem.md @@ -28,3 +28,12 @@ Force host memory measurement. | Default | text | Output format. Available formats: text, json. + +### --prefix + +| | | +| ------- | ------------------- | +| Type | string | +| Default | Gi | + +Unit prefix. From 6b11a5c2aea25bad1793909978ee5b78dacf9f60 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 14 Jun 2023 15:41:26 +0100 Subject: [PATCH 35/47] update docs and examples --- docs/templates/agent-metadata.md | 42 ++++++++++--- dogfood/main.tf | 87 +++++++++------------------ examples/templates/docker/main.tf | 66 ++++++++++++++++++++ examples/templates/kubernetes/main.tf | 56 +++++++++++++++++ 4 files changed, 181 insertions(+), 70 deletions(-) diff --git a/docs/templates/agent-metadata.md b/docs/templates/agent-metadata.md index a7504b2e5ecb8..2cd21e898a8f4 100644 --- a/docs/templates/agent-metadata.md +++ b/docs/templates/agent-metadata.md @@ -16,6 +16,10 @@ See the [Terraform reference](https://registry.terraform.io/providers/coder/code All of these examples use [heredoc strings](https://developer.hashicorp.com/terraform/language/expressions/strings#heredoc-strings) for the script declaration. With heredoc strings, you can script without messy escape codes, just as if you were working in your terminal. +Some of the below examples use the [`coder stat`](../cli/stat.md) command. +This is useful for determining CPU/memory usage inside a container, which +can be tricky otherwise. + Here's a standard set of metadata snippets for Linux agents: ```hcl @@ -25,26 +29,36 @@ resource "coder_agent" "main" { metadata { display_name = "CPU Usage" key = "cpu" - # calculates CPU usage by summing the "us", "sy" and "id" columns of - # vmstat. - script = < /tmp/cusage - echo "Unknown" - exit 0 - fi - - # interval in microseconds should be metadata.interval * 1000000 - interval=10000000 - ncores=$(nproc) - echo "$cusage $cusage_p $interval $ncores" | awk '{ printf "%2.0f%%\n", (($1 - $2)/$3/$4)*100 }' - - EOT } metadata { display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" interval = 10 timeout = 1 - key = "1_ram_usage" - script = <&1 | awk ' $0 ~ "Word of the Day: [A-z]+" { print $5; exit }' EOT diff --git a/examples/templates/docker/main.tf b/examples/templates/docker/main.tf index ed7b51d2d8519..d30aa8c1f8afa 100644 --- a/examples/templates/docker/main.tf +++ b/examples/templates/docker/main.tf @@ -46,6 +46,72 @@ resource "coder_agent" "main" { GIT_AUTHOR_EMAIL = "${data.coder_workspace.me.owner_email}" GIT_COMMITTER_EMAIL = "${data.coder_workspace.me.owner_email}" } + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = </tmp/code-server.log 2>&1 & EOT + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = < Date: Wed, 14 Jun 2023 16:00:36 +0100 Subject: [PATCH 36/47] fix NaN issue for HostCPU --- cli/clistat/stat.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index eb7ee11aec31e..135b8e75feecb 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -178,8 +178,9 @@ func New(opts ...Option) (*Statter, error) { // Units are in "cores". func (s *Statter) HostCPU(m Prefix) (*Result, error) { r := &Result{ - Unit: "cores", - Total: ptr.To(float64(s.nproc)), + Unit: "cores", + Total: ptr.To(float64(s.nproc)), + Prefix: m, } c1, err := s.hi.CPUTime() if err != nil { @@ -191,11 +192,13 @@ func (s *Statter) HostCPU(m Prefix) (*Result, error) { return nil, xerrors.Errorf("get second cpu sample: %w", err) } total := c2.Total() - c1.Total() + if total == 0 { + return r, nil // no change + } idle := c2.Idle - c1.Idle used := total - idle scaleFactor := float64(s.nproc) / total.Seconds() r.Used = used.Seconds() * scaleFactor - r.Prefix = m return r, nil } From 789c6de388c0cf3c694b8ac097b51628daeaabe8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 14 Jun 2023 17:51:23 +0100 Subject: [PATCH 37/47] avoid blocking on err chan Co-authored-by: Mathias Fredriksson --- cli/stat.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/stat.go b/cli/stat.go index d26370dfd99ab..f4711c4662bbf 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -42,8 +42,8 @@ func (r *RootCmd) stat() *clibase.Cmd { var sr statsRow // Get CPU measurements first. - hostErr := make(chan error) - containerErr := make(chan error) + hostErr := make(chan error, 1) + containerErr := make(chan error, 1) go func() { defer close(hostErr) cs, err := st.HostCPU("") From 482db10fd2672cfc9e85019bb66f7b0fcf6bfac8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 13:49:31 +0100 Subject: [PATCH 38/47] add percentages --- cli/clistat/stat.go | 5 +++++ cli/clistat/stat_internal_test.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 135b8e75feecb..185c3d4d88ab2 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -120,6 +120,11 @@ func (r *Result) String() string { _, _ = sb.WriteString(prefix) _, _ = sb.WriteString(r.Unit) } + if r.Total != (*float64)(nil) && *r.Total != 0.0 { + _, _ = sb.WriteString(" (") + _, _ = sb.WriteString(strconv.FormatFloat(100.0*r.Used/(*r.Total), 'f', 0, 64)) + _, _ = sb.WriteString("%)") + } return sb.String() } diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index 1c2e159f6e9ad..85bf6d7123822 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -18,7 +18,7 @@ func TestResultString(t *testing.T) { Result Result }{ { - Expected: "1.2/5.7 quatloos", + Expected: "1.2/5.7 quatloos (22%)", Result: Result{Used: 1.234, Total: ptr.To(5.678), Unit: "quatloos"}, }, { From 0775082db08639d7811f0cb6aeb370fcc9198b96 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 13:50:09 +0100 Subject: [PATCH 39/47] remove outdated comments --- cli/clistat/container.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/clistat/container.go b/cli/clistat/container.go index 27b9c715ed741..079bffe5e3c43 100644 --- a/cli/clistat/container.go +++ b/cli/clistat/container.go @@ -22,7 +22,7 @@ func IsContainerized(fs afero.Fs) (ok bool, err error) { cgData, err := afero.ReadFile(fs, procOneCgroup) if err != nil { if os.IsNotExist(err) { - return false, nil // how? + return false, nil } return false, xerrors.Errorf("read file %s: %w", procOneCgroup, err) } @@ -43,7 +43,7 @@ func IsContainerized(fs afero.Fs) (ok bool, err error) { mountsData, err := afero.ReadFile(fs, procMounts) if err != nil { if os.IsNotExist(err) { - return false, nil // how?? + return false, nil } return false, xerrors.Errorf("read file %s: %w", procMounts, err) } From 73debf8579db79047c75f13e4cb7e32db453e617 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 13:52:19 +0100 Subject: [PATCH 40/47] handle counter reset --- cli/clistat/cgroup.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go index ff84434547042..18e665848da9b 100644 --- a/cli/clistat/cgroup.go +++ b/cli/clistat/cgroup.go @@ -78,10 +78,15 @@ func (s *Statter) ContainerCPU(m Prefix) (*Result, error) { return nil, xerrors.Errorf("get cgroup CPU usage: %w", err) } + if used2 < used1 { + // Someone reset the counter. Best we can do is count from zero. + used1 = 0 + } + r := &Result{ Unit: "cores", Prefix: m, - Used: (used2 - used1), + Used: used2 - used1, Total: ptr.To(total), } return r, nil From d0c992aeea64b1be62e8a5a3b3e37ac8d07d8687 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 13:55:44 +0100 Subject: [PATCH 41/47] add test for large difference between used and total --- cli/clistat/stat_internal_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index 85bf6d7123822..44b7a7faf9e61 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -41,6 +41,10 @@ func TestResultString(t *testing.T) { Expected: "1.2 things", Result: Result{Used: 1.234, Total: nil, Unit: "things", Prefix: "invalid"}, }, + { + Expected: "0.0/100.0 TiB (0%)", + Result: Result{Used: 1, Total: ptr.To(1024 * 1024 * 1024 * 1024 * 100.0), Unit: "B", Prefix: "Ti"}, + }, } { assert.Equal(t, tt.Expected, tt.Result.String()) } From ef7460a7e5cb3021ba75272f58db31fa467231f8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 14:21:36 +0100 Subject: [PATCH 42/47] auto-scale precision, limiting to 3 digits --- cli/clistat/cgroup.go | 33 ++++----- cli/clistat/disk.go | 3 +- cli/clistat/disk_windows.go | 3 +- cli/clistat/stat.go | 115 +++++++----------------------- cli/clistat/stat_internal_test.go | 30 ++++---- 5 files changed, 57 insertions(+), 127 deletions(-) diff --git a/cli/clistat/cgroup.go b/cli/clistat/cgroup.go index 18e665848da9b..e22f0c4309b7a 100644 --- a/cli/clistat/cgroup.go +++ b/cli/clistat/cgroup.go @@ -51,7 +51,7 @@ const ( // If there is no limit set, the total is assumed to be the // number of host cores multiplied by the CFS period. // If the system is not containerized, this always returns nil. -func (s *Statter) ContainerCPU(m Prefix) (*Result, error) { +func (s *Statter) ContainerCPU() (*Result, error) { // Firstly, check if we are containerized. if ok, err := IsContainerized(s.fs); err != nil || !ok { return nil, nil //nolint: nilnil @@ -84,10 +84,9 @@ func (s *Statter) ContainerCPU(m Prefix) (*Result, error) { } r := &Result{ - Unit: "cores", - Prefix: m, - Used: used2 - used1, - Total: ptr.To(total), + Unit: "cores", + Used: used2 - used1, + Total: ptr.To(total), } return r, nil } @@ -185,20 +184,20 @@ func (s *Statter) cGroupV1CPUUsed() (float64, error) { // ContainerMemory returns the memory usage of the container cgroup. // If the system is not containerized, this always returns nil. -func (s *Statter) ContainerMemory(m Prefix) (*Result, error) { +func (s *Statter) ContainerMemory() (*Result, error) { if ok, err := IsContainerized(s.fs); err != nil || !ok { return nil, nil //nolint:nilnil } if s.isCGroupV2() { - return s.cGroupV2Memory(m) + return s.cGroupV2Memory() } // Fall back to CGroupv1 - return s.cGroupV1Memory(m) + return s.cGroupV1Memory() } -func (s *Statter) cGroupV2Memory(m Prefix) (*Result, error) { +func (s *Statter) cGroupV2Memory() (*Result, error) { maxUsageBytes, err := readInt64(s.fs, cgroupV2MemoryMaxBytes) if err != nil { return nil, xerrors.Errorf("read memory total: %w", err) @@ -215,14 +214,13 @@ func (s *Statter) cGroupV2Memory(m Prefix) (*Result, error) { } return &Result{ - Total: ptr.To(float64(maxUsageBytes)), - Used: float64(currUsageBytes - inactiveFileBytes), - Unit: "B", - Prefix: m, + Total: ptr.To(float64(maxUsageBytes)), + Used: float64(currUsageBytes - inactiveFileBytes), + Unit: "B", }, nil } -func (s *Statter) cGroupV1Memory(m Prefix) (*Result, error) { +func (s *Statter) cGroupV1Memory() (*Result, error) { maxUsageBytes, err := readInt64(s.fs, cgroupV1MemoryMaxUsageBytes) if err != nil { return nil, xerrors.Errorf("read memory total: %w", err) @@ -241,10 +239,9 @@ func (s *Statter) cGroupV1Memory(m Prefix) (*Result, error) { // Total memory used is usage - total_inactive_file return &Result{ - Total: ptr.To(float64(maxUsageBytes)), - Used: float64(usageBytes - totalInactiveFileBytes), - Unit: "B", - Prefix: m, + Total: ptr.To(float64(maxUsageBytes)), + Used: float64(usageBytes - totalInactiveFileBytes), + Unit: "B", }, nil } diff --git a/cli/clistat/disk.go b/cli/clistat/disk.go index ab17976ed2315..54731dfd9737f 100644 --- a/cli/clistat/disk.go +++ b/cli/clistat/disk.go @@ -10,7 +10,7 @@ import ( // Disk returns the disk usage of the given path. // If path is empty, it returns the usage of the root directory. -func (*Statter) Disk(path string, m Prefix) (*Result, error) { +func (*Statter) Disk(path string) (*Result, error) { if path == "" { path = "/" } @@ -22,6 +22,5 @@ func (*Statter) Disk(path string, m Prefix) (*Result, error) { r.Total = ptr.To(float64(stat.Blocks * uint64(stat.Bsize))) r.Used = float64(stat.Blocks-stat.Bfree) * float64(stat.Bsize) r.Unit = "B" - r.Prefix = m return &r, nil } diff --git a/cli/clistat/disk_windows.go b/cli/clistat/disk_windows.go index df80214b962ac..d11995e2c2980 100644 --- a/cli/clistat/disk_windows.go +++ b/cli/clistat/disk_windows.go @@ -7,7 +7,7 @@ import ( // Disk returns the disk usage of the given path. // If path is empty, it defaults to C:\ -func (*Statter) Disk(path string, m Prefix) (*Result, error) { +func (*Statter) Disk(path string) (*Result, error) { if path == "" { path = `C:\` } @@ -31,6 +31,5 @@ func (*Statter) Disk(path string, m Prefix) (*Result, error) { r.Total = ptr.To(float64(totalBytes)) r.Used = float64(totalBytes - freeBytes) r.Unit = "B" - r.Prefix = m return &r, nil } diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 185c3d4d88ab2..04e4286001e01 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/dustin/go-humanize" "github.com/elastic/go-sysinfo" "github.com/spf13/afero" "golang.org/x/xerrors" @@ -14,77 +15,6 @@ import ( sysinfotypes "github.com/elastic/go-sysinfo/types" ) -// Prefix is an SI prefix for a unit. -type Prefix string - -// Float64 returns the prefix as a float64. -func (m *Prefix) Float64() (float64, error) { - switch *m { - case PrefixDeciShort, PrefixDeci: - return 0.1, nil - case PrefixCentiShort, PrefixCenti: - return 0.01, nil - case PrefixMilliShort, PrefixMilli: - return 0.001, nil - case PrefixMicroShort, PrefixMicro: - return 0.000_001, nil - case PrefixNanoShort, PrefixNano: - return 0.000_000_001, nil - case PrefixKiloShort, PrefixKilo: - return 1_000.0, nil - case PrefixMegaShort, PrefixMega: - return 1_000_000.0, nil - case PrefixGigaShort, PrefixGiga: - return 1_000_000_000.0, nil - case PrefixTeraShort, PrefixTera: - return 1_000_000_000_000.0, nil - case PrefixKibiShort, PrefixKibi: - return 1024.0, nil - case PrefixMebiShort, PrefixMebi: - return 1_048_576.0, nil - case PrefixGibiShort, PrefixGibi: - return 1_073_741_824.0, nil - case PrefixTebiShort, PrefixTebi: - return 1_099_511_627_776.0, nil - default: - return 0, xerrors.Errorf("unknown prefix: %s", *m) - } -} - -const ( - PrefixDeci Prefix = "deci" - PrefixCenti Prefix = "centi" - PrefixMilli Prefix = "milli" - PrefixMicro Prefix = "micro" - PrefixNano Prefix = "nano" - - PrefixDeciShort Prefix = "d" - PrefixCentiShort Prefix = "c" - PrefixMilliShort Prefix = "m" - PrefixMicroShort Prefix = "u" - PrefixNanoShort Prefix = "n" - - PrefixKilo Prefix = "kilo" - PrefixMega Prefix = "mega" - PrefixGiga Prefix = "giga" - PrefixTera Prefix = "tera" - - PrefixKiloShort Prefix = "K" - PrefixMegaShort Prefix = "M" - PrefixGigaShort Prefix = "G" - PrefixTeraShort Prefix = "T" - - PrefixKibi = "kibi" - PrefixMebi = "mebi" - PrefixGibi = "gibi" - PrefixTebi = "tebi" - - PrefixKibiShort Prefix = "Ki" - PrefixMebiShort Prefix = "Mi" - PrefixGibiShort Prefix = "Gi" - PrefixTebiShort Prefix = "Ti" -) - // Result is a generic result type for a statistic. // Total is the total amount of the resource available. // It is nil if the resource is not a finite quantity. @@ -94,8 +24,6 @@ type Result struct { Total *float64 `json:"total"` Unit string `json:"unit"` Used float64 `json:"used"` - // Prefix controls the string representation of the result. - Prefix Prefix `json:"-"` } // String returns a human-readable representation of the result. @@ -103,28 +31,37 @@ func (r *Result) String() string { if r == nil { return "-" } + var sb strings.Builder - scale, err := r.Prefix.Float64() - prefix := string(r.Prefix) - if err != nil { - prefix = "" - scale = 1.0 + usedScaled, scale := humanize.ComputeSI(r.Used) + usedPrec := 1 + if usedScaled >= 100.0 { + usedPrec = 0 } - _, _ = sb.WriteString(strconv.FormatFloat(r.Used/scale, 'f', 1, 64)) + _, _ = sb.WriteString(strconv.FormatFloat(usedScaled, 'f', usedPrec, 64)) if r.Total != (*float64)(nil) { + // TODO(cian): handle case where scale of total is different to used + totalScaled, _ := humanize.ComputeSI(*r.Total) + totalPrec := 1 + if totalScaled >= 100.0 { + totalPrec = 0 + } _, _ = sb.WriteString("/") - _, _ = sb.WriteString(strconv.FormatFloat(*r.Total/scale, 'f', 1, 64)) + _, _ = sb.WriteString(strconv.FormatFloat(totalScaled, 'f', totalPrec, 64)) } + if r.Unit != "" { _, _ = sb.WriteString(" ") - _, _ = sb.WriteString(prefix) + _, _ = sb.WriteString(scale) _, _ = sb.WriteString(r.Unit) } - if r.Total != (*float64)(nil) && *r.Total != 0.0 { + + if r.Total != nil && *r.Total != 0.0 { _, _ = sb.WriteString(" (") - _, _ = sb.WriteString(strconv.FormatFloat(100.0*r.Used/(*r.Total), 'f', 0, 64)) + _, _ = sb.WriteString(strconv.FormatFloat(r.Used/(*r.Total)*100, 'f', 0, 64)) _, _ = sb.WriteString("%)") } + return sb.String() } @@ -181,11 +118,10 @@ func New(opts ...Option) (*Statter, error) { // This is calculated by taking the difference between the total and idle HostCPU time // and scaling it by the number of cores. // Units are in "cores". -func (s *Statter) HostCPU(m Prefix) (*Result, error) { +func (s *Statter) HostCPU() (*Result, error) { r := &Result{ - Unit: "cores", - Total: ptr.To(float64(s.nproc)), - Prefix: m, + Unit: "cores", + Total: ptr.To(float64(s.nproc)), } c1, err := s.hi.CPUTime() if err != nil { @@ -208,10 +144,9 @@ func (s *Statter) HostCPU(m Prefix) (*Result, error) { } // HostMemory returns the memory usage of the host, in gigabytes. -func (s *Statter) HostMemory(m Prefix) (*Result, error) { +func (s *Statter) HostMemory() (*Result, error) { r := &Result{ - Unit: "B", - Prefix: m, + Unit: "B", } hm, err := s.hi.Memory() if err != nil { diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index 44b7a7faf9e61..a42b92bba88c1 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -26,7 +26,7 @@ func TestResultString(t *testing.T) { Result: Result{Used: 0.0, Total: ptr.To(0.0), Unit: "HP"}, }, { - Expected: "123.0 seconds", + Expected: "123 seconds", Result: Result{Used: 123.01, Total: nil, Unit: "seconds"}, }, { @@ -34,16 +34,16 @@ func TestResultString(t *testing.T) { Result: Result{Used: 12.34, Total: nil, Unit: ""}, }, { - Expected: "1.5 KiB", - Result: Result{Used: 1536, Total: nil, Unit: "B", Prefix: PrefixKibiShort}, + Expected: "1.5 kB", + Result: Result{Used: 1536, Total: nil, Unit: "B"}, }, { Expected: "1.2 things", - Result: Result{Used: 1.234, Total: nil, Unit: "things", Prefix: "invalid"}, + Result: Result{Used: 1.234, Total: nil, Unit: "things"}, }, { - Expected: "0.0/100.0 TiB (0%)", - Result: Result{Used: 1, Total: ptr.To(1024 * 1024 * 1024 * 1024 * 100.0), Unit: "B", Prefix: "Ti"}, + Expected: "0.0/100 TiB (0%)", + Result: Result{Used: 1, Total: ptr.To(1000 * 1000 * 1000 * 1000 * 100.0), Unit: "B"}, }, } { assert.Equal(t, tt.Expected, tt.Result.String()) @@ -64,7 +64,7 @@ func TestStatter(t *testing.T) { require.NoError(t, err) t.Run("HostCPU", func(t *testing.T) { t.Parallel() - cpu, err := s.HostCPU("") + cpu, err := s.HostCPU() require.NoError(t, err) assert.NotZero(t, cpu.Used) assert.NotZero(t, cpu.Total) @@ -73,7 +73,7 @@ func TestStatter(t *testing.T) { t.Run("HostMemory", func(t *testing.T) { t.Parallel() - mem, err := s.HostMemory("") + mem, err := s.HostMemory() require.NoError(t, err) assert.NotZero(t, mem.Used) assert.NotZero(t, mem.Total) @@ -82,7 +82,7 @@ func TestStatter(t *testing.T) { t.Run("HostDisk", func(t *testing.T) { t.Parallel() - disk, err := s.Disk("", "") // default to home dir + disk, err := s.Disk("") // default to home dir require.NoError(t, err) assert.NotZero(t, disk.Used) assert.NotZero(t, disk.Total) @@ -124,7 +124,7 @@ func TestStatter(t *testing.T) { } s, err := New(WithFS(fs), withWait(fakeWait)) require.NoError(t, err) - cpu, err := s.ContainerCPU("") + cpu, err := s.ContainerCPU() require.NoError(t, err) require.NotNil(t, cpu) assert.Equal(t, 1.0, cpu.Used) @@ -142,7 +142,7 @@ func TestStatter(t *testing.T) { } s, err := New(WithFS(fs), withNproc(2), withWait(fakeWait)) require.NoError(t, err) - cpu, err := s.ContainerCPU("") + cpu, err := s.ContainerCPU() require.NoError(t, err) require.NotNil(t, cpu) assert.Equal(t, 1.0, cpu.Used) @@ -156,7 +156,7 @@ func TestStatter(t *testing.T) { fs := initFS(t, fsContainerCgroupV1) s, err := New(WithFS(fs), withNoWait) require.NoError(t, err) - mem, err := s.ContainerMemory("") + mem, err := s.ContainerMemory() require.NoError(t, err) require.NotNil(t, mem) assert.Equal(t, 268435456.0, mem.Used) @@ -177,7 +177,7 @@ func TestStatter(t *testing.T) { } s, err := New(WithFS(fs), withWait(fakeWait)) require.NoError(t, err) - cpu, err := s.ContainerCPU("") + cpu, err := s.ContainerCPU() require.NoError(t, err) require.NotNil(t, cpu) assert.Equal(t, 1.0, cpu.Used) @@ -194,7 +194,7 @@ func TestStatter(t *testing.T) { } s, err := New(WithFS(fs), withNproc(2), withWait(fakeWait)) require.NoError(t, err) - cpu, err := s.ContainerCPU("") + cpu, err := s.ContainerCPU() require.NoError(t, err) require.NotNil(t, cpu) assert.Equal(t, 1.0, cpu.Used) @@ -208,7 +208,7 @@ func TestStatter(t *testing.T) { fs := initFS(t, fsContainerCgroupV2) s, err := New(WithFS(fs), withNoWait) require.NoError(t, err) - mem, err := s.ContainerMemory("") + mem, err := s.ContainerMemory() require.NoError(t, err) require.NotNil(t, mem) assert.Equal(t, 268435456.0, mem.Used) From bec527ff419f8c4a3a2cfc9df6ca4d6af6db095c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 15:56:57 +0100 Subject: [PATCH 43/47] automatically scale precision, remove --prefix arg --- cli/clistat/stat.go | 71 ++++++++++++++++++++++--------- cli/clistat/stat_internal_test.go | 17 +++++--- cli/stat.go | 36 +++++----------- 3 files changed, 73 insertions(+), 51 deletions(-) diff --git a/cli/clistat/stat.go b/cli/clistat/stat.go index 04e4286001e01..1c19e33ef5f2d 100644 --- a/cli/clistat/stat.go +++ b/cli/clistat/stat.go @@ -1,6 +1,8 @@ package clistat import ( + "fmt" + "math" "runtime" "strconv" "strings" @@ -32,37 +34,68 @@ func (r *Result) String() string { return "-" } - var sb strings.Builder - usedScaled, scale := humanize.ComputeSI(r.Used) - usedPrec := 1 - if usedScaled >= 100.0 { - usedPrec = 0 - } - _, _ = sb.WriteString(strconv.FormatFloat(usedScaled, 'f', usedPrec, 64)) + var usedDisplay, totalDisplay string + var usedScaled, totalScaled float64 + var usedPrefix, totalPrefix string + usedScaled, usedPrefix = humanize.ComputeSI(r.Used) + usedDisplay = humanizeFloat(usedScaled) if r.Total != (*float64)(nil) { - // TODO(cian): handle case where scale of total is different to used - totalScaled, _ := humanize.ComputeSI(*r.Total) - totalPrec := 1 - if totalScaled >= 100.0 { - totalPrec = 0 - } - _, _ = sb.WriteString("/") - _, _ = sb.WriteString(strconv.FormatFloat(totalScaled, 'f', totalPrec, 64)) + totalScaled, totalPrefix = humanize.ComputeSI(*r.Total) + totalDisplay = humanizeFloat(totalScaled) + } + + var sb strings.Builder + _, _ = sb.WriteString(usedDisplay) + + // If the unit prefixes of the used and total values are different, + // display the used value's prefix to avoid confusion. + if usedPrefix != totalPrefix || totalDisplay == "" { + _, _ = sb.WriteString(" ") + _, _ = sb.WriteString(usedPrefix) + _, _ = sb.WriteString(r.Unit) } - if r.Unit != "" { + if totalDisplay != "" { + _, _ = sb.WriteString("/") + _, _ = sb.WriteString(totalDisplay) _, _ = sb.WriteString(" ") - _, _ = sb.WriteString(scale) + _, _ = sb.WriteString(totalPrefix) _, _ = sb.WriteString(r.Unit) } if r.Total != nil && *r.Total != 0.0 { _, _ = sb.WriteString(" (") - _, _ = sb.WriteString(strconv.FormatFloat(r.Used/(*r.Total)*100, 'f', 0, 64)) + _, _ = sb.WriteString(fmt.Sprintf("%.0f", r.Used/(*r.Total)*100.0)) _, _ = sb.WriteString("%)") } - return sb.String() + return strings.TrimSpace(sb.String()) +} + +func humanizeFloat(f float64) string { + // humanize.FtoaWithDigits does not round correctly. + prec := precision(f) + rat := math.Pow(10, float64(prec)) + rounded := math.Round(f*rat) / rat + return strconv.FormatFloat(rounded, 'f', -1, 64) +} + +// limit precision to 3 digits at most to preserve space +func precision(f float64) int { + fabs := math.Abs(f) + if fabs == 0.0 { + return 0 + } + if fabs < 1.0 { + return 3 + } + if fabs < 10.0 { + return 2 + } + if fabs < 100.0 { + return 1 + } + return 0 } // Statter is a system statistics collector. diff --git a/cli/clistat/stat_internal_test.go b/cli/clistat/stat_internal_test.go index a42b92bba88c1..0cfdc26f7afb4 100644 --- a/cli/clistat/stat_internal_test.go +++ b/cli/clistat/stat_internal_test.go @@ -4,11 +4,10 @@ import ( "testing" "time" - "tailscale.com/types/ptr" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "tailscale.com/types/ptr" ) func TestResultString(t *testing.T) { @@ -18,11 +17,11 @@ func TestResultString(t *testing.T) { Result Result }{ { - Expected: "1.2/5.7 quatloos (22%)", + Expected: "1.23/5.68 quatloos (22%)", Result: Result{Used: 1.234, Total: ptr.To(5.678), Unit: "quatloos"}, }, { - Expected: "0.0/0.0 HP", + Expected: "0/0 HP", Result: Result{Used: 0.0, Total: ptr.To(0.0), Unit: "HP"}, }, { @@ -34,17 +33,21 @@ func TestResultString(t *testing.T) { Result: Result{Used: 12.34, Total: nil, Unit: ""}, }, { - Expected: "1.5 kB", + Expected: "1.54 kB", Result: Result{Used: 1536, Total: nil, Unit: "B"}, }, { - Expected: "1.2 things", + Expected: "1.23 things", Result: Result{Used: 1.234, Total: nil, Unit: "things"}, }, { - Expected: "0.0/100 TiB (0%)", + Expected: "1 B/100 TB (0%)", Result: Result{Used: 1, Total: ptr.To(1000 * 1000 * 1000 * 1000 * 100.0), Unit: "B"}, }, + { + Expected: "500 mcores/8 cores (6%)", + Result: Result{Used: 0.5, Total: ptr.To(8.0), Unit: "cores"}, + }, } { assert.Equal(t, tt.Expected, tt.Result.String()) } diff --git a/cli/stat.go b/cli/stat.go index f4711c4662bbf..67232caee53c7 100644 --- a/cli/stat.go +++ b/cli/stat.go @@ -46,7 +46,7 @@ func (r *RootCmd) stat() *clibase.Cmd { containerErr := make(chan error, 1) go func() { defer close(hostErr) - cs, err := st.HostCPU("") + cs, err := st.HostCPU() if err != nil { hostErr <- err return @@ -59,7 +59,7 @@ func (r *RootCmd) stat() *clibase.Cmd { // don't error if we're not in a container return } - cs, err := st.ContainerCPU(clistat.PrefixGibiShort) + cs, err := st.ContainerCPU() if err != nil { containerErr <- err return @@ -75,7 +75,7 @@ func (r *RootCmd) stat() *clibase.Cmd { } // Host-level stats - ms, err := st.HostMemory(clistat.PrefixGibiShort) + ms, err := st.HostMemory() if err != nil { return err } @@ -85,7 +85,7 @@ func (r *RootCmd) stat() *clibase.Cmd { if err != nil { return err } - ds, err := st.Disk(home, clistat.PrefixGibiShort) + ds, err := st.Disk(home) if err != nil { return err } @@ -93,13 +93,13 @@ func (r *RootCmd) stat() *clibase.Cmd { // Container-only stats. if ok, err := clistat.IsContainerized(fs); err == nil && ok { - cs, err := st.ContainerCPU("") + cs, err := st.ContainerCPU() if err != nil { return err } sr.ContainerCPU = cs - ms, err := st.ContainerMemory(clistat.PrefixGibiShort) + ms, err := st.ContainerMemory() if err != nil { return err } @@ -142,9 +142,9 @@ func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { var cs *clistat.Result var err error if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { - cs, err = s.ContainerCPU(clistat.Prefix(prefixArg)) + cs, err = s.ContainerCPU() } else { - cs, err = s.HostCPU(clistat.Prefix(prefixArg)) + cs, err = s.HostCPU() } if err != nil { return err @@ -164,7 +164,6 @@ func (*RootCmd) statCPU(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { var hostArg bool - var prefixArg string formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) cmd := &clibase.Cmd{ Use: "mem", @@ -175,20 +174,14 @@ func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { Value: clibase.BoolOf(&hostArg), Description: "Force host memory measurement.", }, - { - Flag: "prefix", - Value: clibase.StringOf(&prefixArg), - Description: "Unit prefix.", - Default: string(clistat.PrefixGibiShort), - }, }, Handler: func(inv *clibase.Invocation) error { var ms *clistat.Result var err error if ok, _ := clistat.IsContainerized(fs); ok && !hostArg { - ms, err = s.ContainerMemory(clistat.Prefix(prefixArg)) + ms, err = s.ContainerMemory() } else { - ms, err = s.HostMemory(clistat.Prefix(prefixArg)) + ms, err = s.HostMemory() } if err != nil { return err @@ -208,7 +201,6 @@ func (*RootCmd) statMem(s *clistat.Statter, fs afero.Fs) *clibase.Cmd { func (*RootCmd) statDisk(s *clistat.Statter) *clibase.Cmd { var pathArg string - var prefixArg string formatter := cliui.NewOutputFormatter(cliui.TextFormat(), cliui.JSONFormat()) cmd := &clibase.Cmd{ Use: "disk", @@ -220,15 +212,9 @@ func (*RootCmd) statDisk(s *clistat.Statter) *clibase.Cmd { Description: "Path for which to check disk usage.", Default: "/", }, - { - Flag: "prefix", - Value: clibase.StringOf(&prefixArg), - Description: "Unit prefix.", - Default: string(clistat.PrefixGibiShort), - }, }, Handler: func(inv *clibase.Invocation) error { - ds, err := s.Disk(pathArg, clistat.Prefix(prefixArg)) + ds, err := s.Disk(pathArg) if err != nil { return err } From 08adba7b4be98a0bbcae4f706c6f8616ea2cd6ad Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 16:02:24 +0100 Subject: [PATCH 44/47] make gen --- cli/testdata/coder_stat_disk_--help.golden | 3 --- cli/testdata/coder_stat_mem_--help.golden | 3 --- docs/cli/stat_disk.md | 9 --------- docs/cli/stat_mem.md | 9 --------- 4 files changed, 24 deletions(-) diff --git a/cli/testdata/coder_stat_disk_--help.golden b/cli/testdata/coder_stat_disk_--help.golden index 0dd7e3968d729..cb33481f726b0 100644 --- a/cli/testdata/coder_stat_disk_--help.golden +++ b/cli/testdata/coder_stat_disk_--help.golden @@ -9,8 +9,5 @@ Show disk usage, in gigabytes. --path string (default: /) Path for which to check disk usage. - --prefix string (default: Gi) - Unit prefix. - --- Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_stat_mem_--help.golden b/cli/testdata/coder_stat_mem_--help.golden index b6d84afe8ee09..0905c38a9639d 100644 --- a/cli/testdata/coder_stat_mem_--help.golden +++ b/cli/testdata/coder_stat_mem_--help.golden @@ -9,8 +9,5 @@ Show memory usage, in gigabytes. -o, --output string (default: text) Output format. Available formats: text, json. - --prefix string (default: Gi) - Unit prefix. - --- Run `coder --help` for a list of global options. diff --git a/docs/cli/stat_disk.md b/docs/cli/stat_disk.md index 235a363e9324a..6b6ddc34882c8 100644 --- a/docs/cli/stat_disk.md +++ b/docs/cli/stat_disk.md @@ -29,12 +29,3 @@ Output format. Available formats: text, json. | Default | / | Path for which to check disk usage. - -### --prefix - -| | | -| ------- | ------------------- | -| Type | string | -| Default | Gi | - -Unit prefix. diff --git a/docs/cli/stat_mem.md b/docs/cli/stat_mem.md index a9274ce8afdf7..387e7d9ad18cb 100644 --- a/docs/cli/stat_mem.md +++ b/docs/cli/stat_mem.md @@ -28,12 +28,3 @@ Force host memory measurement. | Default | text | Output format. Available formats: text, json. - -### --prefix - -| | | -| ------- | ------------------- | -| Type | string | -| Default | Gi | - -Unit prefix. From 78f76e78f33c81b4fd4bad5f9bf62778eeba4c9e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 16:14:46 +0100 Subject: [PATCH 45/47] improve cli tests --- cli/stat_test.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/cli/stat_test.go b/cli/stat_test.go index f8650d10dbcfe..39934133b107c 100644 --- a/cli/stat_test.go +++ b/cli/stat_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/coder/coder/cli/clistat" "github.com/coder/coder/cli/clitest" "github.com/coder/coder/testutil" ) @@ -31,7 +32,7 @@ func TestStatCmd(t *testing.T) { s := buf.String() require.NotEmpty(t, s) // Must be valid JSON - tmp := make([]struct{}, 0) + tmp := make([]clistat.Result, 0) require.NoError(t, json.NewDecoder(strings.NewReader(s)).Decode(&tmp)) }) t.Run("Table", func(t *testing.T) { @@ -92,8 +93,12 @@ func TestStatCPUCmd(t *testing.T) { err := inv.WithContext(ctx).Run() require.NoError(t, err) s := buf.String() - tmp := struct{}{} + tmp := clistat.Result{} require.NoError(t, json.NewDecoder(strings.NewReader(s)).Decode(&tmp)) + require.NotZero(t, tmp.Used) + require.NotNil(t, tmp.Total) + require.NotZero(t, *tmp.Total) + require.Equal(t, "cores", tmp.Unit) }) } @@ -123,8 +128,12 @@ func TestStatMemCmd(t *testing.T) { err := inv.WithContext(ctx).Run() require.NoError(t, err) s := buf.String() - tmp := struct{}{} + tmp := clistat.Result{} require.NoError(t, json.NewDecoder(strings.NewReader(s)).Decode(&tmp)) + require.NotZero(t, tmp.Used) + require.NotNil(t, tmp.Total) + require.NotZero(t, *tmp.Total) + require.Equal(t, "B", tmp.Unit) }) } @@ -154,7 +163,11 @@ func TestStatDiskCmd(t *testing.T) { err := inv.WithContext(ctx).Run() require.NoError(t, err) s := buf.String() - tmp := struct{}{} + tmp := clistat.Result{} require.NoError(t, json.NewDecoder(strings.NewReader(s)).Decode(&tmp)) + require.NotZero(t, tmp.Used) + require.NotNil(t, tmp.Total) + require.NotZero(t, *tmp.Total) + require.Equal(t, "B", tmp.Unit) }) } From 9a828823328e40ac0a8f522f63fb08586309c71f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 16:23:05 +0100 Subject: [PATCH 46/47] update go.mod --- go.mod | 8 +++++--- go.sum | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6e9ecfe34ad22..6123b73620e32 100644 --- a/go.mod +++ b/go.mod @@ -215,7 +215,7 @@ require ( github.com/docker/docker v23.0.3+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dustin/go-humanize v1.0.1 github.com/elastic/go-windows v1.0.0 // indirect github.com/fxamacker/cbor/v2 v2.4.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect @@ -352,12 +352,14 @@ require ( inet.af/peercred v0.0.0-20210906144145-0893ea02156a // indirect ) -require github.com/gobwas/httphead v0.1.0 +require ( + github.com/dave/dst v0.27.2 + github.com/gobwas/httphead v0.1.0 +) require ( github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/cloudflare/circl v1.3.3 // indirect - github.com/dave/dst v0.27.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect diff --git a/go.sum b/go.sum index b7e68714a6fd1..e2b2c59ded488 100644 --- a/go.sum +++ b/go.sum @@ -230,6 +230,7 @@ github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxG github.com/daixiang0/gci v0.2.9/go.mod h1:+4dZ7TISfSmqfAGv59ePaHfNzgGtIkHAhhdKggP1JAc= github.com/dave/dst v0.27.2 h1:4Y5VFTkhGLC1oddtNwuxxe36pnyLxMFXT51FOzH8Ekc= github.com/dave/dst v0.27.2/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= +github.com/dave/jennifer v1.5.0 h1:HmgPN93bVDpkQyYbqhCHj5QlgvUkvEOzMyEvKLgCRrg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= From eab2530ae11a0772ae8eb76f4eeb0495e79a3948 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 15 Jun 2023 16:26:45 +0100 Subject: [PATCH 47/47] update go.sum --- go.sum | 1 + 1 file changed, 1 insertion(+) diff --git a/go.sum b/go.sum index dd6f0facbfb65..182d83ed27542 100644 --- a/go.sum +++ b/go.sum @@ -236,6 +236,7 @@ github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elastic/go-sysinfo v1.11.0 h1:QW+6BF1oxBoAprH3w2yephF7xLkrrSXj7gl2xC2BM4w= github.com/elastic/go-sysinfo v1.11.0/go.mod h1:6KQb31j0QeWBDF88jIdWSxE8cwoOB9tO4Y4osN7Q70E= github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY=