Skip to content

feat(cli): add coder stat command #8005

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 48 commits into from
Jun 20, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
5db9006
add stat command
johnstcn May 29, 2023
d6029b4
cpu working on mac
johnstcn Jun 7, 2023
18f4942
add stat memory
johnstcn Jun 7, 2023
251fdda
support values with no total
johnstcn Jun 7, 2023
4c081dc
move clistats to its own package
johnstcn Jun 8, 2023
2ba7392
fix container detection to work with sysbox containers
johnstcn Jun 8, 2023
0e1c96a
add cross-platform declaration for IsContainerized()
johnstcn Jun 8, 2023
0f9859e
add a sync.Once to IsContainerized()
johnstcn Jun 8, 2023
a220c7f
make uptime minutes
johnstcn Jun 8, 2023
89f7e8d
lint
johnstcn Jun 8, 2023
c51e245
extract nproc to variable
johnstcn Jun 8, 2023
3528c00
add skeleton of cgroup stuff
johnstcn Jun 8, 2023
7108c6e
initial cgroupv2 cpu implementation
johnstcn Jun 8, 2023
4ef5f24
fix disk_windows
johnstcn Jun 8, 2023
f0f7b6a
add tests for clistats
johnstcn Jun 8, 2023
6a878b9
improve testing
johnstcn Jun 9, 2023
be7ba72
remove unnecessary os-specific implementations now that we have abstr…
johnstcn Jun 12, 2023
3643407
remove uptime stat as it is trivial to implement in bash
johnstcn Jun 12, 2023
1c8943e
implement cgroupv1 cpu
johnstcn Jun 12, 2023
95b8d1f
unskip container memory tests
johnstcn Jun 12, 2023
495b5b0
flesh out tests
johnstcn Jun 13, 2023
fa0c4c6
cgroupv1 memory
johnstcn Jun 13, 2023
70ef79b
improve tests to allow testing cpu used
johnstcn Jun 13, 2023
7eeefc1
refactor cpu usage calc
johnstcn Jun 13, 2023
305675f
fix tests
johnstcn Jun 13, 2023
d1bb322
fix off-by-10 error
johnstcn Jun 13, 2023
eb2bcf6
remove --sample-interval and collect CPU stats in parallel
johnstcn Jun 13, 2023
44edcf3
fmt; gen
johnstcn Jun 13, 2023
0f3254a
make default_cols consistent to avoid ci surprises
johnstcn Jun 13, 2023
edd99f4
fix race condition
johnstcn Jun 13, 2023
49b6861
remove UPTIME from test
johnstcn Jun 13, 2023
69b1904
update golden files
johnstcn Jun 13, 2023
7eb526d
add stat subcommands
johnstcn Jun 14, 2023
665bf7f
allow modifying unit prefixes
johnstcn Jun 14, 2023
6b11a5c
update docs and examples
johnstcn Jun 14, 2023
c1467f0
fix NaN issue for HostCPU
johnstcn Jun 14, 2023
789c6de
avoid blocking on err chan
johnstcn Jun 14, 2023
482db10
add percentages
johnstcn Jun 15, 2023
0775082
remove outdated comments
johnstcn Jun 15, 2023
73debf8
handle counter reset
johnstcn Jun 15, 2023
d0c992a
add test for large difference between used and total
johnstcn Jun 15, 2023
ef7460a
auto-scale precision, limiting to 3 digits
johnstcn Jun 15, 2023
bec527f
automatically scale precision, remove --prefix arg
johnstcn Jun 15, 2023
08adba7
make gen
johnstcn Jun 15, 2023
78f76e7
improve cli tests
johnstcn Jun 15, 2023
9a82882
update go.mod
johnstcn Jun 15, 2023
19c8a80
Merge remote-tracking branch 'origin/main' into cj/coder-stat
johnstcn Jun 15, 2023
eab2530
update go.sum
johnstcn Jun 15, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
improve tests to allow testing cpu used
  • Loading branch information
johnstcn committed Jun 13, 2023
commit 70ef79bb91cc421fd5da535446d33519090ff7c5
250 changes: 132 additions & 118 deletions cli/clistat/cgroup.go
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy that you split a lot of this logic into its own package.

Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,45 @@ import (
"bufio"
"bytes"
"strconv"
"strings"
"time"

"github.com/spf13/afero"
"golang.org/x/xerrors"
"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.
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand All @@ -142,66 +124,49 @@ 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()
}

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)
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() {
Expand All @@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loose thought, but wouldn't like to block you.

"Result" stores a statistic with the approximate format. If somebody would like to use it to check a condition <100 MB, they will have to look up the format, and then possibly convert. I'm wondering if it isn't simple to just keep it as basic units (bytes, seconds, percentage, etc.) and transform it to "human readable" when rendering the output.

  • mixing responsibilities here: value store vs formatting.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair.

}, 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)
}
Loading