From 6faaf2455d75751c05df6fb8d7d6e14bda88b844 Mon Sep 17 00:00:00 2001 From: Sambhav Jain <67923444+sambhav-jain-16@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:45:20 +0100 Subject: [PATCH] feat: add ability to get individual controller stats This adds methods to retrieve statistics for individual cgroup controllers (CPU, memory, pids, IO, hugetlb, rdma, misc) instead of requiring all stats to be fetched at once. This enables tools like cadvisor to collect specific metrics with different housekeeping intervals, reducing computational overhead. Fixes: https://github.com/opencontainers/cgroups/issues/44 Signed-off-by: Sambhav Jain --- cgroups.go | 21 +++ fs/fs.go | 132 ++++++++++++++ fs/fs_test.go | 242 +++++++++++++++++++++++++ fs2/fs2.go | 104 +++++++++++ fs2/fs2_test.go | 459 ++++++++++++++++++++++++++++++++++++++++++++++++ systemd/v1.go | 126 +++++++++++++ systemd/v2.go | 49 ++++++ utils.go | 3 + 8 files changed, 1136 insertions(+) create mode 100644 fs2/fs2_test.go diff --git a/cgroups.go b/cgroups.go index 5a97bd3..4d4b29b 100644 --- a/cgroups.go +++ b/cgroups.go @@ -44,6 +44,27 @@ type Manager interface { // GetStats returns cgroups statistics. GetStats() (*Stats, error) + // AddCpuStats adds cpu statistics to the provided stats object. + AddCpuStats(stats *Stats) error + + // AddMemoryStats adds memory statistics to the provided stats object. + AddMemoryStats(stats *Stats) error + + // AddPidsStats adds pids statistics to the provided stats object. + AddPidsStats(stats *Stats) error + + // AddIoStats adds io statistics to the provided stats object. + AddIoStats(stats *Stats) error + + // AddHugetlbStats adds hugetlb statistics to the provided stats object. + AddHugetlbStats(stats *Stats) error + + // AddRdmaStats adds rdma statistics to the provided stats object. + AddRdmaStats(stats *Stats) error + + // AddMiscStats adds misc statistics to the provided stats object. + AddMiscStats(stats *Stats) error + // Freeze sets the freezer cgroup to the specified state. Freeze(state FreezerState) error diff --git a/fs/fs.go b/fs/fs.go index 6259311..31db539 100644 --- a/fs/fs.go +++ b/fs/fs.go @@ -196,6 +196,138 @@ func (m *Manager) GetStats() (*cgroups.Stats, error) { return stats, nil } +func (m *Manager) AddCpuStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + cpuGroup := &CpuGroup{} + if path := m.paths["cpu"]; path != "" { + if err := cpuGroup.GetStats(path, stats); err != nil { + return err + } + } + + cpuacctGroup := &CpuacctGroup{} + if path := m.paths["cpuacct"]; path != "" { + if err := cpuacctGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *Manager) AddMemoryStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + if stats.MemoryStats.Stats == nil { + return errors.New("stats.MemoryStats.Stats must not be nil") + } + + memoryGroup := &MemoryGroup{} + if path := m.paths["memory"]; path != "" { + if err := memoryGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *Manager) AddPidsStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + pidsGroup := &PidsGroup{} + if path := m.paths["pids"]; path != "" { + if err := pidsGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *Manager) AddIoStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + blkioGroup := &BlkioGroup{} + if path := m.paths["blkio"]; path != "" { + if err := blkioGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *Manager) AddHugetlbStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + if stats.HugetlbStats == nil { + return errors.New("stats.HugetlbStats must not be nil") + } + + hugetlbGroup := &HugetlbGroup{} + if path := m.paths["hugetlb"]; path != "" { + if err := hugetlbGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *Manager) AddRdmaStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + rdmaGroup := &RdmaGroup{} + if path := m.paths["rdma"]; path != "" { + if err := rdmaGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *Manager) AddMiscStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + return nil +} + func (m *Manager) Set(r *cgroups.Resources) error { if r == nil { return nil diff --git a/fs/fs_test.go b/fs/fs_test.go index f9a0935..3fe7f2c 100644 --- a/fs/fs_test.go +++ b/fs/fs_test.go @@ -1,6 +1,7 @@ package fs import ( + "strings" "testing" "github.com/opencontainers/cgroups" @@ -47,3 +48,244 @@ func BenchmarkGetStats(b *testing.B) { b.Fatalf("stats: %+v", st) } } + +func TestAddCpuStats(t *testing.T) { + cpuPath := tempDir(t, "cpu") + cpuacctPath := tempDir(t, "cpuacct") + + writeFileContents(t, cpuPath, map[string]string{ + "cpu.stat": "nr_periods 2000\nnr_throttled 200\nthrottled_time 18446744073709551615\n", + }) + writeFileContents(t, cpuacctPath, map[string]string{ + "cpuacct.usage": cpuAcctUsageContents, + "cpuacct.usage_percpu": cpuAcctUsagePerCPUContents, + "cpuacct.stat": cpuAcctStatContents, + }) + + m := &Manager{ + cgroups: &cgroups.Cgroup{Resources: &cgroups.Resources{}}, + paths: map[string]string{"cpu": cpuPath, "cpuacct": cpuacctPath}, + } + + stats := cgroups.NewStats() + if err := m.AddCpuStats(stats); err != nil { + t.Fatal(err) + } + + // Verify throttling data from cpu.stat + expectedThrottling := cgroups.ThrottlingData{ + Periods: 2000, + ThrottledPeriods: 200, + ThrottledTime: 18446744073709551615, + } + expectThrottlingDataEquals(t, expectedThrottling, stats.CpuStats.ThrottlingData) + + // Verify total usage from cpuacct.usage + if stats.CpuStats.CpuUsage.TotalUsage != 12262454190222160 { + t.Errorf("expected TotalUsage 12262454190222160, got %d", stats.CpuStats.CpuUsage.TotalUsage) + } +} + +func TestAddPidsStats(t *testing.T) { + path := tempDir(t, "pids") + writeFileContents(t, path, map[string]string{ + "pids.current": "1337", + "pids.max": "1024", + }) + + m := &Manager{ + cgroups: &cgroups.Cgroup{Resources: &cgroups.Resources{}}, + paths: map[string]string{"pids": path}, + } + + stats := cgroups.NewStats() + if err := m.AddPidsStats(stats); err != nil { + t.Fatal(err) + } + + if stats.PidsStats.Current != 1337 { + t.Errorf("expected Current 1337, got %d", stats.PidsStats.Current) + } + if stats.PidsStats.Limit != 1024 { + t.Errorf("expected Limit 1024, got %d", stats.PidsStats.Limit) + } +} + +func TestAddMemoryStats(t *testing.T) { + path := tempDir(t, "memory") + writeFileContents(t, path, map[string]string{ + "memory.stat": memoryStatContents, + "memory.usage_in_bytes": "2048", + "memory.max_usage_in_bytes": "4096", + "memory.failcnt": "100", + "memory.limit_in_bytes": "8192", + "memory.use_hierarchy": "1", + }) + + m := &Manager{ + cgroups: &cgroups.Cgroup{Resources: &cgroups.Resources{}}, + paths: map[string]string{"memory": path}, + } + + stats := cgroups.NewStats() + if err := m.AddMemoryStats(stats); err != nil { + t.Fatal(err) + } + + expected := cgroups.MemoryData{Usage: 2048, MaxUsage: 4096, Failcnt: 100, Limit: 8192} + expectMemoryDataEquals(t, expected, stats.MemoryStats.Usage) +} + +func TestAddIoStats(t *testing.T) { + path := tempDir(t, "blkio") + // Use blkioBFQStatsTestFiles from blkio_test.go for proper file format + writeFileContents(t, path, blkioBFQStatsTestFiles) + + m := &Manager{ + cgroups: &cgroups.Cgroup{Resources: &cgroups.Resources{}}, + paths: map[string]string{"blkio": path}, + } + + stats := cgroups.NewStats() + if err := m.AddIoStats(stats); err != nil { + t.Fatal(err) + } + + // Verify we have entries + if len(stats.BlkioStats.IoServiceBytesRecursive) == 0 { + t.Error("expected IoServiceBytesRecursive to have entries") + } + if len(stats.BlkioStats.IoServicedRecursive) == 0 { + t.Error("expected IoServicedRecursive to have entries") + } +} + +func TestAddStatsIterative(t *testing.T) { + // Set up both cpu and pids directories + cpuPath := tempDir(t, "cpu") + pidsPath := tempDir(t, "pids") + + writeFileContents(t, cpuPath, map[string]string{ + "cpu.stat": "nr_periods 100\nnr_throttled 10\nthrottled_time 5000\n", + }) + writeFileContents(t, pidsPath, map[string]string{ + "pids.current": "42", + "pids.max": "1000", + }) + + m := &Manager{ + cgroups: &cgroups.Cgroup{Resources: &cgroups.Resources{}}, + paths: map[string]string{"cpu": cpuPath, "pids": pidsPath}, + } + + stats := cgroups.NewStats() + + // Call both methods on same stats object + if err := m.AddCpuStats(stats); err != nil { + t.Fatal(err) + } + if err := m.AddPidsStats(stats); err != nil { + t.Fatal(err) + } + + // Verify both are populated + if stats.CpuStats.ThrottlingData.Periods != 100 { + t.Errorf("expected Periods 100, got %d", stats.CpuStats.ThrottlingData.Periods) + } + if stats.PidsStats.Current != 42 { + t.Errorf("expected Current 42, got %d", stats.PidsStats.Current) + } + if stats.PidsStats.Limit != 1000 { + t.Errorf("expected Limit 1000, got %d", stats.PidsStats.Limit) + } +} + +// TestAddStatsWithEmptyPaths tests that Add*Stats methods work correctly +// when the corresponding controller paths are empty (controller not available). +func TestAddStatsWithEmptyPaths(t *testing.T) { + m := &Manager{ + cgroups: &cgroups.Cgroup{Resources: &cgroups.Resources{}}, + paths: make(map[string]string), + } + + stats := cgroups.NewStats() + + // All Add*Stats methods should succeed with empty paths (no-op) + if err := m.AddCpuStats(stats); err != nil { + t.Errorf("AddCpuStats failed with empty paths: %v", err) + } + if err := m.AddMemoryStats(stats); err != nil { + t.Errorf("AddMemoryStats failed with empty paths: %v", err) + } + if err := m.AddPidsStats(stats); err != nil { + t.Errorf("AddPidsStats failed with empty paths: %v", err) + } + if err := m.AddIoStats(stats); err != nil { + t.Errorf("AddIoStats failed with empty paths: %v", err) + } + if err := m.AddHugetlbStats(stats); err != nil { + t.Errorf("AddHugetlbStats failed with empty paths: %v", err) + } + if err := m.AddRdmaStats(stats); err != nil { + t.Errorf("AddRdmaStats failed with empty paths: %v", err) + } + if err := m.AddMiscStats(stats); err != nil { + t.Errorf("AddMiscStats failed with empty paths: %v", err) + } +} + +// TestAddStatsValidation tests that Add*Stats methods properly validate +// nil parameters and nil maps. +func TestAddStatsValidation(t *testing.T) { + m := &Manager{ + cgroups: &cgroups.Cgroup{Resources: &cgroups.Resources{}}, + paths: make(map[string]string), + } + + // Create manager with paths for map validation tests + tempDir := t.TempDir() + mWithPaths := &Manager{ + cgroups: &cgroups.Cgroup{Resources: &cgroups.Resources{}}, + paths: map[string]string{ + "memory": tempDir, + "hugetlb": tempDir, + }, + } + + // Create stats with nil maps for map validation tests + statsWithNilMaps := &cgroups.Stats{} + + tests := []struct { + name string + manager *Manager + stats *cgroups.Stats + fn func(*Manager, *cgroups.Stats) error + expectedErr string + }{ + // Nil stats parameter tests + {"AddCpuStats with nil stats", m, nil, (*Manager).AddCpuStats, cgroups.ErrStatsNil}, + {"AddMemoryStats with nil stats", m, nil, (*Manager).AddMemoryStats, cgroups.ErrStatsNil}, + {"AddPidsStats with nil stats", m, nil, (*Manager).AddPidsStats, cgroups.ErrStatsNil}, + {"AddIoStats with nil stats", m, nil, (*Manager).AddIoStats, cgroups.ErrStatsNil}, + {"AddHugetlbStats with nil stats", m, nil, (*Manager).AddHugetlbStats, cgroups.ErrStatsNil}, + {"AddRdmaStats with nil stats", m, nil, (*Manager).AddRdmaStats, cgroups.ErrStatsNil}, + {"AddMiscStats with nil stats", m, nil, (*Manager).AddMiscStats, cgroups.ErrStatsNil}, + + // Nil map tests + {"AddMemoryStats with nil Stats map", mWithPaths, statsWithNilMaps, (*Manager).AddMemoryStats, "stats.MemoryStats.Stats must not be nil"}, + {"AddHugetlbStats with nil HugetlbStats map", mWithPaths, statsWithNilMaps, (*Manager).AddHugetlbStats, "stats.HugetlbStats must not be nil"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn(tt.manager, tt.stats) + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.expectedErr) + return + } + if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("expected error containing %q, got %q", tt.expectedErr, err.Error()) + } + }) + } +} diff --git a/fs2/fs2.go b/fs2/fs2.go index 356d087..5f57d2f 100644 --- a/fs2/fs2.go +++ b/fs2/fs2.go @@ -155,6 +155,110 @@ func (m *Manager) GetStats() (*cgroups.Stats, error) { return st, nil } +func (m *Manager) AddCpuStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + if err := statCpu(m.dirPath, stats); err != nil && !os.IsNotExist(err) { + return err + } + + var err error + if stats.CpuStats.PSI, err = statPSI(m.dirPath, "cpu.pressure"); err != nil { + return err + } + + return nil +} + +func (m *Manager) AddMemoryStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + if stats.MemoryStats.Stats == nil { + return errors.New("stats.MemoryStats.Stats must not be nil") + } + + if err := statMemory(m.dirPath, stats); err != nil && !os.IsNotExist(err) { + return err + } + + var err error + if stats.MemoryStats.PSI, err = statPSI(m.dirPath, "memory.pressure"); err != nil { + return err + } + + return nil +} + +func (m *Manager) AddPidsStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + return statPids(m.dirPath, stats) +} + +func (m *Manager) AddIoStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + if err := statIo(m.dirPath, stats); err != nil && !os.IsNotExist(err) { + return err + } + + var err error + if stats.BlkioStats.PSI, err = statPSI(m.dirPath, "io.pressure"); err != nil { + return err + } + + return nil +} + +func (m *Manager) AddHugetlbStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + if stats.HugetlbStats == nil { + return errors.New("stats.HugetlbStats must not be nil") + } + + err := statHugeTlb(m.dirPath, stats) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (m *Manager) AddRdmaStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + err := fscommon.RdmaGetStats(m.dirPath, stats) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (m *Manager) AddMiscStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + if stats.MiscStats == nil { + return errors.New("stats.MiscStats must not be nil") + } + + err := statMisc(m.dirPath, stats) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + func (m *Manager) Freeze(state cgroups.FreezerState) error { if m.config.Resources == nil { return errors.New("cannot toggle freezer: cgroups not configured for container") diff --git a/fs2/fs2_test.go b/fs2/fs2_test.go new file mode 100644 index 0000000..166a751 --- /dev/null +++ b/fs2/fs2_test.go @@ -0,0 +1,459 @@ +package fs2 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/opencontainers/cgroups" +) + +const ( + exampleCpuStatData = `usage_usec 1000000 +user_usec 600000 +system_usec 400000 +nr_periods 100 +nr_throttled 10 +throttled_usec 50000 +nr_bursts 5 +burst_usec 10000` + + exampleCpuStatDataShort = `usage_usec 1000000 +user_usec 600000 +system_usec 400000` + + exampleMemoryCurrent = "4194304" + exampleMemoryMax = "max" + + examplePSIData = `some avg10=1.00 avg60=2.00 avg300=3.00 total=100000 +full avg10=0.50 avg60=1.00 avg300=1.50 total=50000` + + exampleRdmaCurrent = `mlx5_0 hca_handle=10 hca_object=20` +) + +func TestAddCpuStats(t *testing.T) { + // We're using a fake cgroupfs. + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "cpu.stat"), []byte(exampleCpuStatData), 0o644); err != nil { + t.Fatal(err) + } + + // Create manager + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + // Create stats and call AddCpuStats + stats := cgroups.NewStats() + if err := m.AddCpuStats(stats); err != nil { + t.Fatal(err) + } + + // Verify CPU stats populated correctly (values are converted from usec to nsec) + if stats.CpuStats.CpuUsage.TotalUsage != 1000000000 { + t.Errorf("expected TotalUsage 1000000000, got %d", stats.CpuStats.CpuUsage.TotalUsage) + } + if stats.CpuStats.CpuUsage.UsageInUsermode != 600000000 { + t.Errorf("expected UsageInUsermode 600000000, got %d", stats.CpuStats.CpuUsage.UsageInUsermode) + } + if stats.CpuStats.CpuUsage.UsageInKernelmode != 400000000 { + t.Errorf("expected UsageInKernelmode 400000000, got %d", stats.CpuStats.CpuUsage.UsageInKernelmode) + } + if stats.CpuStats.ThrottlingData.Periods != 100 { + t.Errorf("expected Periods 100, got %d", stats.CpuStats.ThrottlingData.Periods) + } + if stats.CpuStats.ThrottlingData.ThrottledPeriods != 10 { + t.Errorf("expected ThrottledPeriods 10, got %d", stats.CpuStats.ThrottlingData.ThrottledPeriods) + } +} + +func TestAddMemoryStats(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + // Use exampleMemoryStatData from memory_test.go (file = 6502666240) + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "memory.stat"), []byte(exampleMemoryStatData), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "memory.current"), []byte(exampleMemoryCurrent), 0o644); err != nil { + t.Fatal(err) + } + + // memory.max is required by getMemoryDataV2 + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "memory.max"), []byte(exampleMemoryMax), 0o644); err != nil { + t.Fatal(err) + } + + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + stats := cgroups.NewStats() + if err := m.AddMemoryStats(stats); err != nil { + t.Fatal(err) + } + + // Verify memory stats + if stats.MemoryStats.Usage.Usage != 4194304 { + t.Errorf("expected Usage 4194304, got %d", stats.MemoryStats.Usage.Usage) + } + // Cache comes from "file" field in memory.stat (6502666240 from exampleMemoryStatData) + if stats.MemoryStats.Cache != 6502666240 { + t.Errorf("expected Cache 6502666240, got %d", stats.MemoryStats.Cache) + } +} + +func TestAddPidsStats(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "pids.current"), []byte("42\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "pids.max"), []byte("1000\n"), 0o644); err != nil { + t.Fatal(err) + } + + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + stats := cgroups.NewStats() + if err := m.AddPidsStats(stats); err != nil { + t.Fatal(err) + } + + if stats.PidsStats.Current != 42 { + t.Errorf("expected Current 42, got %d", stats.PidsStats.Current) + } + if stats.PidsStats.Limit != 1000 { + t.Errorf("expected Limit 1000, got %d", stats.PidsStats.Limit) + } +} + +func TestAddIoStats(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + // Use exampleIoStatData from io_test.go + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "io.stat"), []byte(exampleIoStatData), 0o644); err != nil { + t.Fatal(err) + } + + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + stats := cgroups.NewStats() + if err := m.AddIoStats(stats); err != nil { + t.Fatal(err) + } + + // Verify IO stats - check that we have entries + if len(stats.BlkioStats.IoServiceBytesRecursive) == 0 { + t.Error("expected IoServiceBytesRecursive to have entries") + } + if len(stats.BlkioStats.IoServicedRecursive) == 0 { + t.Error("expected IoServicedRecursive to have entries") + } +} + +func TestAddMiscStats(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + // Use exampleMiscCurrentData and exampleMiscEventsData from misc_test.go + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "misc.current"), []byte(exampleMiscCurrentData), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "misc.events"), []byte(exampleMiscEventsData), 0o644); err != nil { + t.Fatal(err) + } + + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + stats := cgroups.NewStats() + if err := m.AddMiscStats(stats); err != nil { + t.Fatal(err) + } + + // Verify misc stats - exampleMiscCurrentData has res_a, res_b, res_c + if _, ok := stats.MiscStats["res_a"]; !ok { + t.Error("expected MiscStats to have 'res_a' entry") + } + if _, ok := stats.MiscStats["res_b"]; !ok { + t.Error("expected MiscStats to have 'res_b' entry") + } + if _, ok := stats.MiscStats["res_c"]; !ok { + t.Error("expected MiscStats to have 'res_c' entry") + } +} + +func TestAddStatsIterative(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "cpu.stat"), []byte(exampleCpuStatDataShort), 0o644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "pids.current"), []byte("42\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "pids.max"), []byte("1000\n"), 0o644); err != nil { + t.Fatal(err) + } + + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + // Test iterative population - call multiple Add*Stats on the same Stats object + stats := cgroups.NewStats() + + if err := m.AddCpuStats(stats); err != nil { + t.Fatal(err) + } + if err := m.AddPidsStats(stats); err != nil { + t.Fatal(err) + } + + // Verify both stats are populated in the same object + if stats.CpuStats.CpuUsage.TotalUsage != 1000000000 { + t.Errorf("expected TotalUsage 1000000000, got %d", stats.CpuStats.CpuUsage.TotalUsage) + } + if stats.PidsStats.Current != 42 { + t.Errorf("expected Current 42, got %d", stats.PidsStats.Current) + } + if stats.PidsStats.Limit != 1000 { + t.Errorf("expected Limit 1000, got %d", stats.PidsStats.Limit) + } +} + +func TestAddCpuStatsWithPSI(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "cpu.stat"), []byte(exampleCpuStatData), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "cpu.pressure"), []byte(examplePSIData), 0o644); err != nil { + t.Fatal(err) + } + + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + stats := cgroups.NewStats() + if err := m.AddCpuStats(stats); err != nil { + t.Fatal(err) + } + + // Verify PSI data is populated + if stats.CpuStats.PSI == nil { + t.Fatal("expected PSI to be populated") + } + if stats.CpuStats.PSI.Some.Avg10 != 1.00 { + t.Errorf("expected PSI.Some.Avg10 1.00, got %f", stats.CpuStats.PSI.Some.Avg10) + } + if stats.CpuStats.PSI.Full.Total != 50000 { + t.Errorf("expected PSI.Full.Total 50000, got %d", stats.CpuStats.PSI.Full.Total) + } +} + +func TestAddMemoryStatsWithPSI(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "memory.stat"), []byte(exampleMemoryStatData), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "memory.current"), []byte(exampleMemoryCurrent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "memory.max"), []byte(exampleMemoryMax), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "memory.pressure"), []byte(examplePSIData), 0o644); err != nil { + t.Fatal(err) + } + + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + stats := cgroups.NewStats() + if err := m.AddMemoryStats(stats); err != nil { + t.Fatal(err) + } + + // Verify PSI data is populated + if stats.MemoryStats.PSI == nil { + t.Fatal("expected PSI to be populated") + } + if stats.MemoryStats.PSI.Some.Avg60 != 2.00 { + t.Errorf("expected PSI.Some.Avg60 2.00, got %f", stats.MemoryStats.PSI.Some.Avg60) + } +} + +func TestAddIoStatsWithPSI(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "io.stat"), []byte(exampleIoStatData), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "io.pressure"), []byte(examplePSIData), 0o644); err != nil { + t.Fatal(err) + } + + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + stats := cgroups.NewStats() + if err := m.AddIoStats(stats); err != nil { + t.Fatal(err) + } + + // Verify PSI data is populated + if stats.BlkioStats.PSI == nil { + t.Fatal("expected PSI to be populated") + } + if stats.BlkioStats.PSI.Full.Avg300 != 1.50 { + t.Errorf("expected PSI.Full.Avg300 1.50, got %f", stats.BlkioStats.PSI.Full.Avg300) + } +} + +func TestAddRdmaStats(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "rdma.current"), []byte(exampleRdmaCurrent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fakeCgroupDir, "rdma.max"), []byte("mlx5_0 hca_handle=max hca_object=max"), 0o644); err != nil { + t.Fatal(err) + } + + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + stats := cgroups.NewStats() + if err := m.AddRdmaStats(stats); err != nil { + t.Fatal(err) + } + + // Verify RDMA stats are populated + if len(stats.RdmaStats.RdmaCurrent) == 0 { + t.Error("expected RdmaStats.RdmaCurrent to have entries") + } +} + +func TestAddHugetlbStats(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + + // HugePageSizes() returns available page sizes from the system + // We can only test if files don't exist (should not error) + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + stats := cgroups.NewStats() + // Should not error even when files don't exist + if err := m.AddHugetlbStats(stats); err != nil { + t.Fatal(err) + } +} + +// TestAddStatsValidation tests that Add*Stats methods properly validate +// nil parameters and nil maps. +func TestAddStatsValidation(t *testing.T) { + cgroups.TestMode = true + + fakeCgroupDir := t.TempDir() + config := &cgroups.Cgroup{} + m, err := NewManager(config, fakeCgroupDir) + if err != nil { + t.Fatal(err) + } + + // Create stats with nil maps for map validation tests + statsWithNilMaps := &cgroups.Stats{} + + tests := []struct { + name string + stats *cgroups.Stats + fn func(*cgroups.Stats) error + expectedErr string + }{ + // Nil stats parameter tests + {"AddCpuStats with nil stats", nil, m.AddCpuStats, cgroups.ErrStatsNil}, + {"AddMemoryStats with nil stats", nil, m.AddMemoryStats, cgroups.ErrStatsNil}, + {"AddPidsStats with nil stats", nil, m.AddPidsStats, cgroups.ErrStatsNil}, + {"AddIoStats with nil stats", nil, m.AddIoStats, cgroups.ErrStatsNil}, + {"AddHugetlbStats with nil stats", nil, m.AddHugetlbStats, cgroups.ErrStatsNil}, + {"AddRdmaStats with nil stats", nil, m.AddRdmaStats, cgroups.ErrStatsNil}, + {"AddMiscStats with nil stats", nil, m.AddMiscStats, cgroups.ErrStatsNil}, + + // Nil map tests + {"AddMemoryStats with nil Stats map", statsWithNilMaps, m.AddMemoryStats, "stats.MemoryStats.Stats must not be nil"}, + {"AddHugetlbStats with nil HugetlbStats map", statsWithNilMaps, m.AddHugetlbStats, "stats.HugetlbStats must not be nil"}, + {"AddMiscStats with nil MiscStats map", statsWithNilMaps, m.AddMiscStats, "stats.MiscStats must not be nil"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn(tt.stats) + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.expectedErr) + return + } + if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("expected error containing %q, got %q", tt.expectedErr, err.Error()) + } + }) + } +} diff --git a/systemd/v1.go b/systemd/v1.go index 96e69bb..f8ab436 100644 --- a/systemd/v1.go +++ b/systemd/v1.go @@ -355,6 +355,132 @@ func (m *LegacyManager) GetStats() (*cgroups.Stats, error) { return stats, nil } +func (m *LegacyManager) AddCpuStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + cpuGroup := &fs.CpuGroup{} + if path := m.paths["cpu"]; path != "" { + if err := cpuGroup.GetStats(path, stats); err != nil { + return err + } + } + + cpuacctGroup := &fs.CpuacctGroup{} + if path := m.paths["cpuacct"]; path != "" { + if err := cpuacctGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *LegacyManager) AddMemoryStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + memoryGroup := &fs.MemoryGroup{} + if path := m.paths["memory"]; path != "" { + if err := memoryGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *LegacyManager) AddPidsStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + pidsGroup := &fs.PidsGroup{} + if path := m.paths["pids"]; path != "" { + if err := pidsGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *LegacyManager) AddIoStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + blkioGroup := &fs.BlkioGroup{} + if path := m.paths["blkio"]; path != "" { + if err := blkioGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *LegacyManager) AddHugetlbStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + hugetlbGroup := &fs.HugetlbGroup{} + if path := m.paths["hugetlb"]; path != "" { + if err := hugetlbGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *LegacyManager) AddRdmaStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + rdmaGroup := &fs.RdmaGroup{} + if path := m.paths["rdma"]; path != "" { + if err := rdmaGroup.GetStats(path, stats); err != nil { + return err + } + } + + return nil +} + +func (m *LegacyManager) AddMiscStats(stats *cgroups.Stats) error { + m.mu.Lock() + defer m.mu.Unlock() + + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + + return nil +} + func (m *LegacyManager) Set(r *cgroups.Resources) error { if r == nil { return nil diff --git a/systemd/v2.go b/systemd/v2.go index f76c93e..3b8fa18 100644 --- a/systemd/v2.go +++ b/systemd/v2.go @@ -497,6 +497,55 @@ func (m *UnifiedManager) GetStats() (*cgroups.Stats, error) { return m.fsMgr.GetStats() } +func (m *UnifiedManager) AddCpuStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + return m.fsMgr.AddCpuStats(stats) +} + +func (m *UnifiedManager) AddMemoryStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + return m.fsMgr.AddMemoryStats(stats) +} + +func (m *UnifiedManager) AddPidsStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + return m.fsMgr.AddPidsStats(stats) +} + +func (m *UnifiedManager) AddIoStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + return m.fsMgr.AddIoStats(stats) +} + +func (m *UnifiedManager) AddHugetlbStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + return m.fsMgr.AddHugetlbStats(stats) +} + +func (m *UnifiedManager) AddRdmaStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + return m.fsMgr.AddRdmaStats(stats) +} + +func (m *UnifiedManager) AddMiscStats(stats *cgroups.Stats) error { + if stats == nil { + return errors.New(cgroups.ErrStatsNil) + } + return m.fsMgr.AddMiscStats(stats) +} + func (m *UnifiedManager) Set(r *cgroups.Resources) error { if r == nil { return nil diff --git a/utils.go b/utils.go index 95b3310..f0104e4 100644 --- a/utils.go +++ b/utils.go @@ -22,6 +22,9 @@ const ( CgroupProcesses = "cgroup.procs" unifiedMountpoint = "/sys/fs/cgroup" hybridMountpoint = "/sys/fs/cgroup/unified" + + // ErrStatsNil is returned when stats parameter is nil + ErrStatsNil = "stats must not be nil" ) var (