From 30c9b9e50a2100491d5e830101fce69bbbc6fa50 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 11:52:09 -0300 Subject: [PATCH] feat(dashboard): live CPU/mem stats stream (spec 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the reserved dashboard CPU/mem stats feature (spec 16 §CPU/mem, §gotchas [Q-DASH-STATS]). docker: - Add a read-only ContainerStats method to the Client interface + moby impl: a one-shot ContainerStats with IncludePreviousSample so CPU% is computed from the cpu/precpu delta in a single call (no streaming reader held open). Decoded to a small Stats{CPUPercent, MemUsage, MemLimit, MemPercent}. - cpuPercent applies the Docker formula (cpuDelta/systemDelta × onlineCPUs), guarded against counter resets / the first sample. Memory is cache-adjusted (inactive_file subtracted) to match `docker stats`. - Mirror ContainerStats in MockClient with a StatsCalls counter + StatsErr. dashboard: - dashRow carries HasStats/CPUPercent/MemUsage/MemLimit; the table gains "CPU% (engine)" and MEM columns (labeled engine per spec: VM-skewed on Desktop/WSL2), rendered "—" when no sample was taken; detail pane shows both. - collectDashboardData fetches a bounded, read-only sample per visible container each poll and folds it into the matching row (keyed by the row's display name: shared alias / "/"). - --no-stats now actually disables the fetch (zero ContainerStats calls); stats default ON for the cockpit. The --json snapshot includes a "stats" section when enabled and available. Stays strictly read-only: no flock, only SDK reads. Tests: table-driven CPU% computation + full projection, cache-adjusted memory, the mock feeding the collector/model, --no-stats skipping the fetch, and the CPU%/MEM columns rendering via Update/View without a TTY. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/dashboard.go | 77 ++++++++++++- internal/cli/dashboard_model.go | 55 ++++++++- internal/cli/dashboard_stats_test.go | 161 +++++++++++++++++++++++++++ internal/docker/docker.go | 6 + internal/docker/mock.go | 19 ++++ internal/docker/stats.go | 105 +++++++++++++++++ internal/docker/stats_test.go | 134 ++++++++++++++++++++++ 7 files changed, 548 insertions(+), 9 deletions(-) create mode 100644 internal/cli/dashboard_stats_test.go create mode 100644 internal/docker/stats.go create mode 100644 internal/docker/stats_test.go diff --git a/internal/cli/dashboard.go b/internal/cli/dashboard.go index df4a86f..0a7dc70 100644 --- a/internal/cli/dashboard.go +++ b/internal/cli/dashboard.go @@ -39,18 +39,22 @@ func newDashboardCmd(g *GlobalOpts) *cobra.Command { // this reconcile takes the lock only if it prunes — same as status). _, _ = mgr.Reconcile(cmd.Context()) + // Stats default ON for the cockpit; --no-stats disables the per-container + // ContainerStats fetch for low-power machines (spec 16 §gotchas [Q-DASH-STATS]). + stats := !noStats + if !dashboardInteractive(cmd, g) { - return printDashboardSnapshot(cmd, g, mgr) + return printDashboardSnapshot(cmd, g, mgr, stats) } ctx := cmd.Context() - fetch := func(c context.Context) dashboardData { return collectDashboardData(c, mgr) } + fetch := func(c context.Context) dashboardData { return collectDashboardData(c, mgr, stats) } model := newDashboardModel(ctx, fetch, dashboardPoll) _, err = tea.NewProgram(model, tea.WithContext(ctx)).Run() return err }, } - cmd.Flags().BoolVar(&noStats, "no-stats", false, "reserved: disable the CPU/mem stats stream (stats are opt-in in this build)") + cmd.Flags().BoolVar(&noStats, "no-stats", false, "disable the per-container CPU/mem stats fetch (lower overhead)") return cmd } @@ -67,7 +71,7 @@ func dashboardInteractive(cmd *cobra.Command, g *GlobalOpts) bool { // printDashboardSnapshot is the non-TTY fallback: a one-shot projection reusing // the same shared-status + per-project views as `status`, plus a redirect to the // scriptable commands. -func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Manager) error { +func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Manager, stats bool) error { ctx := cmd.Context() projects := collectProjectStatus(ctx, mgr) shared, err := mgr.Status() @@ -75,7 +79,14 @@ func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Ma return err } if g.JSON { - return writeJSON(cmd, map[string]any{"projects": projects, "shared": shared}) + out := map[string]any{"projects": projects, "shared": shared} + // Include live stats when enabled and available, keyed by the same + // service display name the TUI rows use (spec 16: keep the snapshot path + // working, include stats when available). + if st := collectDashboardStats(ctx, mgr.Docker, stats); len(st) > 0 { + out["stats"] = st + } + return writeJSON(cmd, out) } if g.Quiet { return nil @@ -91,7 +102,7 @@ func printDashboardSnapshot(cmd *cobra.Command, g *GlobalOpts, mgr *workspace.Ma // collectDashboardData is the read-only collector: it fans in shared-service rows // (ledger), per-project service rows (live containers + health), and a bounded // tail of recent log lines into one snapshot. Lock-free. -func collectDashboardData(ctx context.Context, mgr *workspace.Manager) dashboardData { +func collectDashboardData(ctx context.Context, mgr *workspace.Manager, stats bool) dashboardData { var data dashboardData shared, err := mgr.Status() @@ -121,10 +132,64 @@ func collectDashboardData(ctx context.Context, mgr *workspace.Manager) dashboard } } + attachDashboardStats(ctx, mgr.Docker, data.Rows, stats) + data.Logs = collectRecentLogs(ctx, mgr.Docker, 8) return data } +// attachDashboardStats fetches a live CPU/mem sample per visible container (a +// bounded, read-only fetch — one call each, no streaming reader) and folds it +// into the matching rows. A disabled flag or an unreadable container leaves the +// row's HasStats false, which the table renders as "—". Best-effort: never fatal. +func attachDashboardStats(ctx context.Context, client docker.Client, rows []dashRow, enabled bool) { + byKey := collectDashboardStats(ctx, client, enabled) + if len(byKey) == 0 { + return + } + for i := range rows { + if st, ok := byKey[rows[i].Name]; ok { + rows[i].HasStats = true + rows[i].CPUPercent = st.CPUPercent + rows[i].MemUsage = st.MemUsage + rows[i].MemLimit = st.MemLimit + } + } +} + +// collectDashboardStats resolves the workspace's managed containers and fetches +// one resource-usage sample each, keyed by the row's display name (the shared +// DNS alias, or "/"). Returns nil when stats are disabled — the +// --no-stats path so a low-power machine issues zero ContainerStats calls. +func collectDashboardStats(ctx context.Context, client docker.Client, enabled bool) map[string]docker.Stats { + if !enabled { + return nil + } + targets, err := resolveLogTargets(ctx, client, nil) + if err != nil { + return nil + } + out := make(map[string]docker.Stats, len(targets)) + for _, t := range targets { + st, err := client.ContainerStats(ctx, t.ID) + if err != nil { + continue // unreadable container: leave the row without stats + } + out[dashStatsKey(t)] = st + } + return out +} + +// dashStatsKey maps a log target onto the dashboard row's display name so stats +// join to the right row: a shared service by its DNS alias, a project service by +// "/". logTarget.Project is empty for shared services. +func dashStatsKey(t logTarget) string { + if t.Project != "" { + return t.Project + "/" + t.Service + } + return t.Service +} + // dashEngine renders a shared row's "engine version" detail. func dashEngine(s workspace.SharedStatus) string { if s.Major == "" || s.Major == "default" { diff --git a/internal/cli/dashboard_model.go b/internal/cli/dashboard_model.go index cfe688f..bfc0538 100644 --- a/internal/cli/dashboard_model.go +++ b/internal/cli/dashboard_model.go @@ -21,6 +21,14 @@ type dashRow struct { Projects []string // shared: the referencing projects Engine string // shared: engine + version URL string // project: https://..localhost + + // Live resource usage (spec 16 CPU/mem columns), populated per visible + // container on each poll unless --no-stats disabled the fetch. HasStats + // distinguishes "0.0%" (a real, idle sample) from "no sample taken". + HasStats bool + CPUPercent float64 // engine-computed CPU% (VM-skewed on Desktop/WSL2) + MemUsage uint64 // cache-adjusted bytes + MemLimit uint64 // bytes; 0 when unlimited/unknown } // dashLog is one recent, service-tagged log line for the bottom pane. @@ -86,8 +94,10 @@ func newDashboardModel(ctx context.Context, fetch func(context.Context) dashboar t := table.New( table.WithColumns([]table.Column{ {Title: "SERVICE", Width: 22}, - {Title: "STATE", Width: 10}, - {Title: "HEALTH", Width: 10}, + {Title: "STATE", Width: 9}, + {Title: "HEALTH", Width: 9}, + {Title: "CPU% (engine)", Width: 13}, + {Title: "MEM", Width: 11}, {Title: "REFS", Width: 18}, }), table.WithFocused(true), @@ -219,6 +229,9 @@ func (m dashboardModel) selectedDetail() string { state += " (" + r.Health + ")" } parts = append(parts, "state: "+state) + if r.HasStats { + parts = append(parts, fmt.Sprintf("cpu: %.1f%%", r.CPUPercent), "mem: "+dashMem(r)) + } return strings.Join(parts, " ") } @@ -245,11 +258,47 @@ func dashTableRows(rows []dashRow) []table.Row { case r.URL != "": refs = r.URL } - out = append(out, table.Row{r.Name, r.State, r.Health, refs}) + out = append(out, table.Row{r.Name, r.State, r.Health, dashCPU(r), dashMem(r), refs}) } return out } +// dashCPU renders a row's CPU% cell — "—" when no sample was taken (stats off or +// an unreadable container), else one decimal place. +func dashCPU(r dashRow) string { + if !r.HasStats { + return "—" + } + return fmt.Sprintf("%.1f%%", r.CPUPercent) +} + +// dashMem renders a row's memory cell as "usage/limit" (or just usage when the +// Engine reports no limit); "—" when no sample was taken. +func dashMem(r dashRow) string { + if !r.HasStats { + return "—" + } + if r.MemLimit == 0 { + return formatBytes(r.MemUsage) + } + return formatBytes(r.MemUsage) + "/" + formatBytes(r.MemLimit) +} + +// formatBytes renders a byte count in binary units with no decimals below 1 KiB +// and one decimal above, for a compact, stable dashboard cell. +func formatBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%dB", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f%cB", float64(b)/float64(div), "KMGTPE"[exp]) +} + func clampLogs(logs []dashLog) []dashLog { if len(logs) <= dashLogCap { return logs diff --git a/internal/cli/dashboard_stats_test.go b/internal/cli/dashboard_stats_test.go new file mode 100644 index 0000000..f6bf68b --- /dev/null +++ b/internal/cli/dashboard_stats_test.go @@ -0,0 +1,161 @@ +package cli + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" +) + +// statsMockClient seeds a shared + a project container plus their canned stats +// so the collector's stats fetch is exercised without a daemon. +func statsMockClient() *docker.MockClient { + return &docker.MockClient{ + Containers: []docker.Container{ + {ID: "pg1", Name: "devstack-shared-postgres-1", State: "running", Labels: map[string]string{ + generate.LabelManaged: "true", generate.LabelShared: "postgres", + }}, + {ID: "web1", Name: "devstack-shop-web-1", State: "running", Labels: map[string]string{ + generate.LabelManaged: "true", generate.LabelProject: "shop", generate.LabelService: "web", + }}, + }, + Stats: map[string]docker.Stats{ + "pg1": {CPUPercent: 3.1, MemUsage: 412 * 1024 * 1024, MemLimit: 2 * 1024 * 1024 * 1024, MemPercent: 20}, + "web1": {CPUPercent: 12.0, MemUsage: 190 * 1024 * 1024, MemLimit: 0}, + }, + } +} + +// TestCollectDashboardStats verifies the collector keys each container's sample +// by the dashboard row's display name (shared alias / "/"). +func TestCollectDashboardStats(t *testing.T) { + m := statsMockClient() + got := collectDashboardStats(context.Background(), m, true) + if len(got) != 2 { + t.Fatalf("stats map = %d entries, want 2: %+v", len(got), got) + } + if st, ok := got["shared-postgres"]; !ok || st.CPUPercent != 3.1 { + t.Errorf("shared-postgres stats = %+v (ok=%v)", st, ok) + } + if st, ok := got["shop/web"]; !ok || st.MemUsage != 190*1024*1024 { + t.Errorf("shop/web stats = %+v (ok=%v)", st, ok) + } + if m.StatsCalls != 2 { + t.Fatalf("StatsCalls = %d, want 2", m.StatsCalls) + } +} + +// TestCollectDashboardStatsDisabled asserts --no-stats (enabled=false) issues no +// ContainerStats calls at all. +func TestCollectDashboardStatsDisabled(t *testing.T) { + m := statsMockClient() + if got := collectDashboardStats(context.Background(), m, false); got != nil { + t.Fatalf("disabled stats should be nil, got %+v", got) + } + if m.StatsCalls != 0 { + t.Fatalf("StatsCalls = %d, want 0 (fetch must be skipped)", m.StatsCalls) + } +} + +// TestAttachDashboardStats folds seeded stats into the matching rows and leaves +// unmatched rows without stats. +func TestAttachDashboardStats(t *testing.T) { + m := statsMockClient() + rows := []dashRow{ + {Name: "shared-postgres", Kind: "shared"}, + {Name: "shop/web", Kind: "project"}, + {Name: "shop/api", Kind: "project"}, // no container → stays statless + } + attachDashboardStats(context.Background(), m, rows, true) + + if !rows[0].HasStats || rows[0].CPUPercent != 3.1 { + t.Errorf("shared-postgres row = %+v", rows[0]) + } + if !rows[1].HasStats || rows[1].MemUsage != 190*1024*1024 { + t.Errorf("shop/web row = %+v", rows[1]) + } + if rows[2].HasStats { + t.Errorf("shop/api should have no stats: %+v", rows[2]) + } +} + +// TestAttachDashboardStatsDisabled asserts disabled stats leave every row untouched. +func TestAttachDashboardStats_Disabled(t *testing.T) { + m := statsMockClient() + rows := []dashRow{{Name: "shared-postgres", Kind: "shared"}} + attachDashboardStats(context.Background(), m, rows, false) + if rows[0].HasStats { + t.Fatalf("row should have no stats when disabled: %+v", rows[0]) + } + if m.StatsCalls != 0 { + t.Fatalf("StatsCalls = %d, want 0", m.StatsCalls) + } +} + +// TestDashboardStatsInModel drives the Bubble Tea Update/View with stats-bearing +// rows and asserts the CPU%/MEM columns render in the table and detail pane. +func TestDashboardStatsInModel(t *testing.T) { + data := dashboardData{Rows: []dashRow{ + {Name: "shared-postgres", Kind: "shared", State: "running", Refs: 2, + HasStats: true, CPUPercent: 3.1, MemUsage: 412 * 1024 * 1024, MemLimit: 2 * 1024 * 1024 * 1024}, + }} + m := newDashboardModel(context.Background(), func(context.Context) dashboardData { return data }, 0) + updated, _ := m.Update(dashDataMsg(data)) + dm := updated.(dashboardModel) + + tr := dm.table.Rows() + if len(tr) != 1 { + t.Fatalf("table rows = %d, want 1", len(tr)) + } + // Columns: SERVICE, STATE, HEALTH, CPU%, MEM, REFS. + if got := tr[0][3]; got != "3.1%" { + t.Errorf("CPU%% cell = %q, want 3.1%%", got) + } + if got := tr[0][4]; !strings.Contains(got, "412.0MB") { + t.Errorf("MEM cell = %q, want it to contain 412.0MB", got) + } + + detail := dm.selectedDetail() + if !strings.Contains(detail, "cpu: 3.1%") || !strings.Contains(detail, "mem:") { + t.Errorf("detail missing stats: %q", detail) + } + + dm2, _ := dm.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + view := dm2.(dashboardModel).View() + if !strings.Contains(view.Content, "CPU% (engine)") { + t.Errorf("view missing CPU%% column header") + } +} + +// TestDashboardNoStatsRendersDash verifies a statless row shows the "—" placeholder. +func TestDashboardNoStatsRendersDash(t *testing.T) { + r := dashRow{Name: "shop/api", Kind: "project", State: "running", HasStats: false} + if got := dashCPU(r); got != "—" { + t.Errorf("dashCPU = %q, want —", got) + } + if got := dashMem(r); got != "—" { + t.Errorf("dashMem = %q, want —", got) + } +} + +// TestFormatBytes covers the byte humanizer used by the MEM column. +func TestFormatBytes(t *testing.T) { + tests := []struct { + in uint64 + want string + }{ + {512, "512B"}, + {1024, "1.0KB"}, + {412 * 1024 * 1024, "412.0MB"}, + {2 * 1024 * 1024 * 1024, "2.0GB"}, + } + for _, tt := range tests { + if got := formatBytes(tt.in); got != tt.want { + t.Errorf("formatBytes(%d) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 0a827ed..d3d6d50 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -62,6 +62,12 @@ type Client interface { // mode, ctx cancellation, or the container stopping while following. Non-TTY // containers are demuxed through stdcopy; TTY containers stream raw. Read-only. ContainerLogStream(ctx context.Context, id string, opts LogOptions) (<-chan LogLine, error) + // ContainerStats retrieves a single, decoded resource-usage sample for one + // container (spec 16 CPU/mem columns): CPU% computed from the cpu/precpu + // delta plus cache-adjusted memory usage/limit. It opens no streaming reader + // (the daemon includes a previous sample so one call yields a CPU delta), so + // the dashboard can fetch it per visible container on each poll. Read-only. + ContainerStats(ctx context.Context, id string) (Stats, error) // Close releases the underlying connection. Close() error } diff --git a/internal/docker/mock.go b/internal/docker/mock.go index fbc6892..3a0c936 100644 --- a/internal/docker/mock.go +++ b/internal/docker/mock.go @@ -27,6 +27,12 @@ type MockClient struct { // emits (in order) before closing the channel — the seam for logs/dashboard // tests without a daemon. Streams map[string][]LogLine + // Stats maps a container ID or name to its canned resource-usage sample the + // dashboard's stats fetch reads — the seam for CPU/mem tests without a daemon. + Stats map[string]Stats + // StatsCalls counts ContainerStats invocations so a test can assert the + // --no-stats path skips the fetch entirely. + StatsCalls int // NetworkErr / ListErr / InspectErr / LogsErr force the op to fail. NetworkErr error ListErr error @@ -34,6 +40,8 @@ type MockClient struct { LogsErr error // StreamErr forces ContainerLogStream to fail (e.g. the unreadable-driver case). StreamErr error + // StatsErr forces ContainerStats to fail. + StatsErr error } var _ Client = (*MockClient)(nil) @@ -146,6 +154,17 @@ func (m *MockClient) ContainerLogStream(ctx context.Context, id string, _ LogOpt return out, nil } +// ContainerStats returns the seeded sample for id (by ID or name) and records +// the call, so tests can both feed the model and assert the fetch was (or was +// not) issued. An unseeded id yields a zero Stats value, not an error. +func (m *MockClient) ContainerStats(_ context.Context, id string) (Stats, error) { + m.StatsCalls++ + if m.StatsErr != nil { + return Stats{}, m.StatsErr + } + return m.Stats[id], nil +} + // lastLines returns the final n lines of s, preserving a trailing newline. func lastLines(s string, n int) string { if s == "" || n <= 0 { diff --git a/internal/docker/stats.go b/internal/docker/stats.go new file mode 100644 index 0000000..29c514c --- /dev/null +++ b/internal/docker/stats.go @@ -0,0 +1,105 @@ +package docker + +import ( + "context" + "encoding/json" + "fmt" + + ctypes "github.com/moby/moby/api/types/container" + moby "github.com/moby/moby/client" +) + +// Stats is the read-only, decoded resource-usage projection for one container +// (spec 16 CPU/mem columns). CPUPercent is the *computed* engine-side percentage +// (deltas over system usage × online CPUs) — NOT the daemon's pre-baked value, +// and on the Docker Desktop / WSL2 VM it reflects the VM's CPU allocation, not +// the host's (spec 16 §gotchas: label the column "CPU% (engine)"). MemUsage is +// cache-adjusted (inactive file pages subtracted) to match `docker stats`. +type Stats struct { + CPUPercent float64 // 0..(100×onlineCPUs) + MemUsage uint64 // bytes, cache-adjusted + MemLimit uint64 // bytes; 0 when the Engine reports no limit + MemPercent float64 // MemUsage / MemLimit × 100 (0 when MemLimit==0) +} + +// ContainerStats retrieves a single resource-usage sample for one container and +// decodes it into a small Stats projection. It asks the daemon to include a +// previous sample (IncludePreviousSample) so CPU% can be computed from the +// cpu/precpu delta in one call, without holding a streaming reader open — the +// dashboard fetches this per visible container on each poll. Strictly read-only. +func (m *mobyClient) ContainerStats(ctx context.Context, id string) (Stats, error) { + res, err := m.cli.ContainerStats(ctx, id, moby.ContainerStatsOptions{ + Stream: false, + IncludePreviousSample: true, + }) + if err != nil { + return Stats{}, fmt.Errorf("stats for container %q: %w", id, err) + } + defer func() { _ = res.Body.Close() }() + + var sr ctypes.StatsResponse + if err := json.NewDecoder(res.Body).Decode(&sr); err != nil { + return Stats{}, fmt.Errorf("decode stats for container %q: %w", id, err) + } + return statsFromResponse(sr), nil +} + +// statsFromResponse projects a raw Engine StatsResponse into the Stats struct, +// applying the Docker CPU% formula and the cache-adjusted memory accounting. +func statsFromResponse(sr ctypes.StatsResponse) Stats { + onlineCPUs := sr.CPUStats.OnlineCPUs + if onlineCPUs == 0 { + onlineCPUs = uint32(len(sr.CPUStats.CPUUsage.PercpuUsage)) + } + st := Stats{ + CPUPercent: cpuPercent( + sr.CPUStats.CPUUsage.TotalUsage, + sr.PreCPUStats.CPUUsage.TotalUsage, + sr.CPUStats.SystemUsage, + sr.PreCPUStats.SystemUsage, + onlineCPUs, + ), + MemUsage: memUsageNoCache(sr.MemoryStats), + MemLimit: sr.MemoryStats.Limit, + } + if st.MemLimit > 0 { + st.MemPercent = float64(st.MemUsage) / float64(st.MemLimit) * 100 + } + return st +} + +// cpuPercent computes a container's CPU utilisation the way the Docker CLI does: +// the container's CPU-time delta as a fraction of the system CPU-time delta, +// scaled by the number of online CPUs. Returns 0 when either delta is +// non-positive (the first sample, or an idle container) so callers never divide +// by zero. Pure and table-driven testable. +func cpuPercent(cpuTotal, preCPUTotal, systemUsage, preSystemUsage uint64, onlineCPUs uint32) float64 { + // uint64 subtraction guarded against counter resets / a missing previous + // sample (which would wrap negative). + if cpuTotal < preCPUTotal || systemUsage < preSystemUsage { + return 0 + } + cpuDelta := float64(cpuTotal - preCPUTotal) + systemDelta := float64(systemUsage - preSystemUsage) + if systemDelta <= 0 || cpuDelta <= 0 { + return 0 + } + n := float64(onlineCPUs) + if n <= 0 { + n = 1 + } + return (cpuDelta / systemDelta) * n * 100 +} + +// memUsageNoCache returns memory usage with the page cache subtracted, matching +// `docker stats`: on cgroup v1 subtract total_inactive_file, on v2 subtract +// inactive_file (whichever is present and below Usage). Falls back to raw Usage. +func memUsageNoCache(mem ctypes.MemoryStats) uint64 { + if v, ok := mem.Stats["total_inactive_file"]; ok && v <= mem.Usage { + return mem.Usage - v + } + if v, ok := mem.Stats["inactive_file"]; ok && v <= mem.Usage { + return mem.Usage - v + } + return mem.Usage +} diff --git a/internal/docker/stats_test.go b/internal/docker/stats_test.go new file mode 100644 index 0000000..01482cf --- /dev/null +++ b/internal/docker/stats_test.go @@ -0,0 +1,134 @@ +package docker + +import ( + "context" + "math" + "testing" + + ctypes "github.com/moby/moby/api/types/container" +) + +// TestCPUPercent covers the Docker CPU% formula from a stats delta: the +// container CPU-time delta over the system CPU-time delta, scaled by online CPUs. +func TestCPUPercent(t *testing.T) { + tests := []struct { + name string + cpuTotal, preCPU, sysUsage, preSys uint64 + onlineCPUs uint32 + want float64 + }{ + // 1 of 1 CPU fully busy: cpuDelta == systemDelta, 1 CPU → 100%. + {"one-cpu-full", 2000, 1000, 2000, 1000, 1, 100}, + // Half of one CPU: cpuDelta is half the system delta → 50%. + {"half-cpu", 1500, 1000, 2000, 1000, 1, 50}, + // 4 CPUs, container uses one full core's worth of the aggregate → 100%. + {"one-of-four", 2000, 1000, 4000, 0, 4, 100}, + // onlineCPUs derived elsewhere as 0 defaults to 1 (never divide by zero). + {"zero-online-defaults-one", 1500, 1000, 2000, 1000, 0, 50}, + // First sample: no previous → both deltas zero → 0, not NaN. + {"first-sample", 1000, 1000, 2000, 2000, 2, 0}, + // Counter reset (cur < prev): guarded to 0, never negative/huge. + {"cpu-reset", 500, 1000, 2000, 1000, 1, 0}, + {"system-reset", 2000, 1000, 500, 1000, 1, 0}, + // System delta zero but cpu delta positive → 0 (avoid inf). + {"no-system-delta", 2000, 1000, 1000, 1000, 1, 0}, + // Idle container: no cpu delta → 0. + {"idle", 1000, 1000, 5000, 1000, 2, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cpuPercent(tt.cpuTotal, tt.preCPU, tt.sysUsage, tt.preSys, tt.onlineCPUs) + if math.Abs(got-tt.want) > 1e-9 { + t.Fatalf("cpuPercent = %v, want %v", got, tt.want) + } + }) + } +} + +// TestStatsFromResponse verifies the full projection: CPU%, cache-adjusted +// memory, limit, and the derived memory percentage. +func TestStatsFromResponse(t *testing.T) { + sr := ctypes.StatsResponse{ + CPUStats: ctypes.CPUStats{ + CPUUsage: ctypes.CPUUsage{TotalUsage: 2000}, + SystemUsage: 2000, + OnlineCPUs: 2, + }, + PreCPUStats: ctypes.CPUStats{ + CPUUsage: ctypes.CPUUsage{TotalUsage: 1000}, + SystemUsage: 1000, + }, + MemoryStats: ctypes.MemoryStats{ + Usage: 500, + Limit: 1000, + Stats: map[string]uint64{"inactive_file": 100}, + }, + } + st := statsFromResponse(sr) + // cpuDelta=1000, systemDelta=1000, 2 CPUs → 200%. + if math.Abs(st.CPUPercent-200) > 1e-9 { + t.Errorf("CPUPercent = %v, want 200", st.CPUPercent) + } + // 500 - 100 (inactive_file) = 400 bytes. + if st.MemUsage != 400 { + t.Errorf("MemUsage = %d, want 400", st.MemUsage) + } + if st.MemLimit != 1000 { + t.Errorf("MemLimit = %d, want 1000", st.MemLimit) + } + // 400 / 1000 = 40%. + if math.Abs(st.MemPercent-40) > 1e-9 { + t.Errorf("MemPercent = %v, want 40", st.MemPercent) + } +} + +// TestStatsFromResponseOnlineCPUsFromPercpu covers the fallback where the Engine +// omits online_cpus and the count comes from the per-CPU usage slice length. +func TestStatsFromResponseOnlineCPUsFromPercpu(t *testing.T) { + sr := ctypes.StatsResponse{ + CPUStats: ctypes.CPUStats{ + CPUUsage: ctypes.CPUUsage{TotalUsage: 2000, PercpuUsage: []uint64{1, 1, 1, 1}}, + SystemUsage: 4000, + }, + PreCPUStats: ctypes.CPUStats{CPUUsage: ctypes.CPUUsage{TotalUsage: 1000}}, + } + st := statsFromResponse(sr) + // cpuDelta=1000, systemDelta=4000, 4 CPUs → 100%. + if math.Abs(st.CPUPercent-100) > 1e-9 { + t.Fatalf("CPUPercent = %v, want 100", st.CPUPercent) + } +} + +// TestMemUsageNoCacheV1 covers the cgroup v1 branch (total_inactive_file). +func TestMemUsageNoCacheV1(t *testing.T) { + got := memUsageNoCache(ctypes.MemoryStats{Usage: 1000, Stats: map[string]uint64{"total_inactive_file": 250}}) + if got != 750 { + t.Fatalf("MemUsage = %d, want 750", got) + } +} + +// TestMemUsageNoCacheRaw falls back to raw Usage when no cache stat is present. +func TestMemUsageNoCacheRaw(t *testing.T) { + got := memUsageNoCache(ctypes.MemoryStats{Usage: 1000}) + if got != 1000 { + t.Fatalf("MemUsage = %d, want 1000", got) + } +} + +// TestMockContainerStats verifies the mock returns seeded samples and counts calls. +func TestMockContainerStats(t *testing.T) { + m := &MockClient{Stats: map[string]Stats{"c1": {CPUPercent: 12.5, MemUsage: 400, MemLimit: 1000}}} + st, err := m.ContainerStats(context.Background(), "c1") + if err != nil { + t.Fatal(err) + } + if st.CPUPercent != 12.5 || st.MemUsage != 400 { + t.Fatalf("unexpected stats: %+v", st) + } + if _, err := m.ContainerStats(context.Background(), "missing"); err != nil { + t.Fatalf("unseeded id should not error: %v", err) + } + if m.StatsCalls != 2 { + t.Fatalf("StatsCalls = %d, want 2", m.StatsCalls) + } +}