diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index eca575b..667acb6 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -10,6 +10,8 @@ import ( "os/exec" goruntime "runtime" "strings" + "sync" + "time" "github.com/GeiserX/CashPilot-Desktop/internal/catalog" "github.com/docker/docker/api/types/container" @@ -286,51 +288,168 @@ func (p *DockerProvider) List(ctx context.Context) ([]ContainerInfo, error) { if err != nil { return nil, err } - out := make([]ContainerInfo, 0, len(containers)) - for _, c := range containers { - slug := c.Labels[LabelService] - name := "" - if len(c.Names) > 0 { - name = strings.TrimPrefix(c.Names[0], "/") - } - cpu, mem := p.stats(ctx, cli, c.ID, c.State == "running") - out = append(out, ContainerInfo{ - Slug: slug, - ContainerID: c.ID, - Name: name, - Image: c.Image, - Status: c.State, - CPUPercent: cpu, - MemoryMB: mem, - }) + return p.statsForContainers(ctx, cli, containers), nil +} + +// statsForContainers builds the ContainerInfo list, sampling every container's CPU +// and memory concurrently. stats() waits cpuSampleInterval between its two samples, +// so a serial loop would make this O(N * interval); with one goroutine per container +// the added latency stays ~one interval regardless of N. Each goroutine owns its own +// out[i] slot, so no locking is needed. It takes a statsClient (not *client.Client) +// so the concurrency and mapping can be unit-tested with a fake, no Docker daemon. +func (p *DockerProvider) statsForContainers(ctx context.Context, cli statsClient, containers []container.Summary) []ContainerInfo { + out := make([]ContainerInfo, len(containers)) + var wg sync.WaitGroup + for i := range containers { + wg.Add(1) + go func(i int) { + defer wg.Done() + c := containers[i] + cpu, mem := p.stats(ctx, cli, c.ID, c.State == "running") + out[i] = toContainerInfo(c, cpu, mem) + }(i) + } + wg.Wait() + return out +} + +// toContainerInfo maps a Docker container summary plus its sampled CPU% and memory +// into a ContainerInfo. Split out so the field mapping is unit-testable. +func toContainerInfo(c container.Summary, cpu, mem float64) ContainerInfo { + name := "" + if len(c.Names) > 0 { + name = strings.TrimPrefix(c.Names[0], "/") + } + return ContainerInfo{ + Slug: c.Labels[LabelService], + ContainerID: c.ID, + Name: name, + Image: c.Image, + Status: c.State, + CPUPercent: cpu, + MemoryMB: mem, } - return out, nil } -func (p *DockerProvider) stats(ctx context.Context, cli *client.Client, containerID string, running bool) (float64, float64) { +// cpuSampleInterval is the delay between the two container-stats samples used to +// compute a live CPU percentage. Docker's one-shot stats endpoint zeroes +// PreCPUStats, so a single sample makes cpuDelta the container's entire lifetime +// CPU time and systemDelta the entire system CPU time — a lifetime average, not +// current load. Two time-separated samples give a true "CPU% right now". +const cpuSampleInterval = 1 * time.Second + +// containerSample holds the raw counters read from one ContainerStatsOneShot call. +type containerSample struct { + cpuTotal uint64 // CPUStats.CPUUsage.TotalUsage + systemCPU uint64 // CPUStats.SystemUsage + onlineCPUs float64 // CPUStats.OnlineCPUs, falling back to len(PercpuUsage) + memoryMB float64 // docker-stats-style memory (Usage minus inactive_file) +} + +// statsClient is the small subset of *client.Client that the sampling code needs. +// Narrowing it to an interface lets stats(), sampleStats() and statsForContainers() +// be unit-tested with a fake that returns canned stats, with no Docker daemon. +type statsClient interface { + ContainerStatsOneShot(ctx context.Context, containerID string) (container.StatsResponseReader, error) +} + +// stats returns the container's current CPU percentage and memory in MB. It reads +// two stats samples cpuSampleInterval apart and derives the percentage from the +// delta between them; a single one-shot sample would report a meaningless lifetime +// average (see cpuSampleInterval). If ctx is cancelled during the wait it returns +// 0 CPU with sample A's memory, which is still a valid reading. +func (p *DockerProvider) stats(ctx context.Context, cli statsClient, containerID string, running bool) (float64, float64) { if !running { return 0, 0 } + a, ok := sampleStats(ctx, cli, containerID) + if !ok { + return 0, 0 + } + select { + case <-ctx.Done(): + return 0, a.memoryMB + case <-time.After(cpuSampleInterval): + } + b, ok := sampleStats(ctx, cli, containerID) + if !ok { + return 0, a.memoryMB + } + return combineSamples(a, b) +} + +// sampleStats reads one ContainerStatsOneShot sample and extracts its counters via +// sampleFromResponse. ok is false if the sample cannot be read or decoded. +func sampleStats(ctx context.Context, cli statsClient, containerID string) (containerSample, bool) { reader, err := cli.ContainerStatsOneShot(ctx, containerID) if err != nil { - return 0, 0 + return containerSample{}, false } defer reader.Body.Close() var stats container.StatsResponse if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil { - return 0, 0 + return containerSample{}, false } - memoryMB := float64(stats.MemoryStats.Usage) / 1024 / 1024 - cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage) - systemDelta := float64(stats.CPUStats.SystemUsage - stats.PreCPUStats.SystemUsage) + return sampleFromResponse(stats), true +} + +// sampleFromResponse extracts the counters needed to compute CPU% and memory from an +// already-decoded stats response. It is pure (no IO), so the extraction — including +// the OnlineCPUs==0 → len(PercpuUsage) fallback and the inactive_file memory +// adjustment via memoryMB — is unit-testable without a Docker daemon. +func sampleFromResponse(stats container.StatsResponse) containerSample { onlineCPUs := float64(stats.CPUStats.OnlineCPUs) if onlineCPUs == 0 { onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage)) } + return containerSample{ + cpuTotal: stats.CPUStats.CPUUsage.TotalUsage, + systemCPU: stats.CPUStats.SystemUsage, + onlineCPUs: onlineCPUs, + memoryMB: memoryMB(stats.MemoryStats), + } +} + +// combineSamples derives the current CPU percentage and memory (MB) from two samples +// taken cpuSampleInterval apart: a is the earlier sample, b the later one. CPU% comes +// from the A→B delta (see cpuPercent, which applies the guards) and memory from the +// more recent sample b. It is pure so the two-sample combination is unit-testable +// without a Docker daemon. +func combineSamples(a, b containerSample) (float64, float64) { + return cpuPercent(a.cpuTotal, b.cpuTotal, a.systemCPU, b.systemCPU, b.onlineCPUs), b.memoryMB +} + +// cpuPercent computes the Docker-style CPU percentage from two samples: +// +// (cpuDelta / systemDelta) * onlineCPUs * 100 +// +// cpuDelta and systemDelta are the differences between the current (cur) and +// previous (pre) counters. Deltas are computed in float64 so that a counter which +// appears to move backwards produces a non-positive delta and trips the guard +// instead of underflowing an unsigned subtraction. It returns 0 when either delta +// is non-positive or onlineCPUs is non-positive. +// +// NOTE: passing a single one-shot sample (pre counters = 0) reproduces the original +// bug — cpuDelta becomes lifetime CPU and systemDelta lifetime system time, so the +// result is a lifetime average that does not reflect current load. +func cpuPercent(preTotal, curTotal, preSystem, curSystem uint64, onlineCPUs float64) float64 { + cpuDelta := float64(curTotal) - float64(preTotal) + systemDelta := float64(curSystem) - float64(preSystem) if systemDelta <= 0 || cpuDelta <= 0 || onlineCPUs <= 0 { - return 0, memoryMB + return 0 + } + return (cpuDelta / systemDelta) * onlineCPUs * 100 +} + +// memoryMB converts Docker's MemoryStats to megabytes the way `docker stats` does. +// MemoryStats.Usage includes reclaimable page cache, so inactive_file is subtracted +// when the key is present and not larger than Usage; otherwise raw Usage is used. +func memoryMB(mem container.MemoryStats) float64 { + usage := mem.Usage + if inactive, ok := mem.Stats["inactive_file"]; ok && inactive <= usage { + usage -= inactive } - return (cpuDelta / systemDelta) * onlineCPUs * 100, memoryMB + return float64(usage) / 1024 / 1024 } func dockerClient() (*client.Client, error) { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 435935c..346d1a9 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1,12 +1,23 @@ package runtime import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os/exec" goruntime "runtime" "strings" + "sync" "testing" + "time" "github.com/GeiserX/CashPilot-Desktop/internal/catalog" + "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/client" ) // TestStreamPullProgressHandlesLongLine pins the bufio.Scanner buffer raise: a @@ -87,3 +98,438 @@ func TestBuildEnvSubstitutesDefaultsAndOverrides(t *testing.T) { t.Fatalf("expected substituted default, got %q", env["DEVICE_NAME"]) } } + +// TestCPUPercentTwoSampleDeltaYieldsExpectedPercent pins the arithmetic of the +// two-sample fix with a known answer: cpuDelta = 1e9, systemDelta = 10e9, +// onlineCPUs = 4 -> (1e9 / 10e9) * 4 * 100 = 40%. +func TestCPUPercentTwoSampleDeltaYieldsExpectedPercent(t *testing.T) { + got := cpuPercent(0, 1_000_000_000, 0, 10_000_000_000, 4) + if got != 40 { + t.Fatalf("cpuPercent = %v, want 40", got) + } +} + +// TestCPUPercentGuardsReturnZero verifies every guard clause returns 0 rather than +// a NaN, Inf, or huge underflowed value. +func TestCPUPercentGuardsReturnZero(t *testing.T) { + cases := []struct { + name string + preTotal, curTotal, preSystem, curSystem uint64 + onlineCPUs float64 + }{ + {"zero system delta", 0, 1_000_000_000, 5_000_000_000, 5_000_000_000, 4}, + {"zero cpu delta", 500_000_000, 500_000_000, 0, 10_000_000_000, 4}, + {"zero online cpus", 0, 1_000_000_000, 0, 10_000_000_000, 0}, + {"backwards cpu counter", 2_000_000_000, 1_000_000_000, 0, 10_000_000_000, 4}, + {"backwards system counter", 0, 1_000_000_000, 10_000_000_000, 5_000_000_000, 4}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := cpuPercent(tc.preTotal, tc.curTotal, tc.preSystem, tc.curSystem, tc.onlineCPUs) + if got != 0 { + t.Fatalf("cpuPercent = %v, want 0", got) + } + }) + } +} + +// TestCPUPercentSingleSampleBugIsWrong locks the intent of the two-sample fix. A +// single one-shot sample has pre counters = 0, so cpuPercent computes a lifetime +// average that does NOT reflect current load. Here a container pegging one core +// over the sample window (delta 1e9 / 1e9 = 100%) has only used 5e9 ns of CPU +// across 500e9 ns of lifetime system time: the buggy single-sample call reports +// 1%, while the correct two-sample delta reports 100%. +func TestCPUPercentSingleSampleBugIsWrong(t *testing.T) { + const onlineCPUs = 1 + + // OLD behaviour: one one-shot sample, pre counters zero -> lifetime average. + buggy := cpuPercent(0, 5_000_000_000, 0, 500_000_000_000, onlineCPUs) + if buggy != 1 { + t.Fatalf("single-sample lifetime average = %v, want 1", buggy) + } + + // NEW behaviour: two samples one interval apart -> true current load. + correct := cpuPercent(5_000_000_000, 6_000_000_000, 500_000_000_000, 501_000_000_000, onlineCPUs) + if correct != 100 { + t.Fatalf("two-sample current load = %v, want 100", correct) + } + + if buggy == correct { + t.Fatal("single-sample and two-sample results must differ; the bug has to be observable") + } +} + +// TestMemoryMBSubtractsInactiveFile checks that reclaimable page cache +// (inactive_file) is excluded, matching `docker stats`. +func TestMemoryMBSubtractsInactiveFile(t *testing.T) { + const mib = 1024 * 1024 + got := memoryMB(container.MemoryStats{ + Usage: 200 * mib, + Stats: map[string]uint64{"inactive_file": 50 * mib}, + }) + if got != 150 { + t.Fatalf("memoryMB = %v, want 150", got) + } +} + +// TestMemoryMBFallsBackToUsage checks the guards: no inactive_file key, or an +// inactive_file larger than Usage, both fall back to raw Usage without underflow. +func TestMemoryMBFallsBackToUsage(t *testing.T) { + const mib = 1024 * 1024 + if got := memoryMB(container.MemoryStats{Usage: 128 * mib}); got != 128 { + t.Fatalf("memoryMB without inactive_file = %v, want 128", got) + } + if got := memoryMB(container.MemoryStats{ + Usage: 64 * mib, + Stats: map[string]uint64{"inactive_file": 999 * mib}, + }); got != 64 { + t.Fatalf("memoryMB with oversized inactive_file = %v, want 64", got) + } +} + +// mib is one mebibyte, used to build memory figures in the stats-math tests. +const mib = 1024 * 1024 + +// statsJSON marshals a container.StatsResponse with the given counters, mimicking the +// JSON the daemon streams from ContainerStatsOneShot. It drives the sampling code +// without a Docker daemon. +func statsJSON(t *testing.T, total, system uint64, onlineCPUs uint32, memUsage, inactiveFile uint64) string { + t.Helper() + var sr container.StatsResponse + sr.CPUStats.CPUUsage.TotalUsage = total + sr.CPUStats.SystemUsage = system + sr.CPUStats.OnlineCPUs = onlineCPUs + sr.MemoryStats.Usage = memUsage + if inactiveFile > 0 { + sr.MemoryStats.Stats = map[string]uint64{"inactive_file": inactiveFile} + } + b, err := json.Marshal(sr) + if err != nil { + t.Fatalf("marshal stats: %v", err) + } + return string(b) +} + +// fakeStat is one canned ContainerStatsOneShot result: either a JSON body or an error. +type fakeStat struct { + body string + err error +} + +// fakeStatsClient implements statsClient, returning canned results in sequence (the +// last entry repeats once the sequence is exhausted, e.g. for the concurrent calls +// statsForContainers makes). It is safe for concurrent use so -race can vet the fan-out. +type fakeStatsClient struct { + mu sync.Mutex + seq []fakeStat + calls int +} + +func (f *fakeStatsClient) ContainerStatsOneShot(_ context.Context, _ string) (container.StatsResponseReader, error) { + f.mu.Lock() + defer f.mu.Unlock() + var s fakeStat + switch { + case f.calls < len(f.seq): + s = f.seq[f.calls] + case len(f.seq) > 0: + s = f.seq[len(f.seq)-1] + } + f.calls++ + if s.err != nil { + return container.StatsResponseReader{}, s.err + } + return container.StatsResponseReader{Body: io.NopCloser(strings.NewReader(s.body))}, nil +} + +// TestSampleFromResponseExtractsCountersAndMemory covers the pure field extraction, +// including the inactive_file memory adjustment, with no Docker daemon. +func TestSampleFromResponseExtractsCountersAndMemory(t *testing.T) { + var sr container.StatsResponse + sr.CPUStats.CPUUsage.TotalUsage = 6_000_000_000 + sr.CPUStats.SystemUsage = 500_000_000_000 + sr.CPUStats.OnlineCPUs = 8 + sr.MemoryStats.Usage = 200 * mib + sr.MemoryStats.Stats = map[string]uint64{"inactive_file": 50 * mib} + + got := sampleFromResponse(sr) + if got.cpuTotal != 6_000_000_000 || got.systemCPU != 500_000_000_000 { + t.Fatalf("cpu counters = %d/%d, want 6e9/5e11", got.cpuTotal, got.systemCPU) + } + if got.onlineCPUs != 8 { + t.Fatalf("onlineCPUs = %v, want 8", got.onlineCPUs) + } + if got.memoryMB != 150 { // 200 MiB Usage minus 50 MiB inactive_file + t.Fatalf("memoryMB = %v, want 150", got.memoryMB) + } +} + +// TestSampleFromResponseFallsBackToPercpuUsage covers the OnlineCPUs==0 fallback path. +func TestSampleFromResponseFallsBackToPercpuUsage(t *testing.T) { + var sr container.StatsResponse + sr.CPUStats.OnlineCPUs = 0 // daemon did not report it + sr.CPUStats.CPUUsage.PercpuUsage = []uint64{10, 20, 30, 40} + if got := sampleFromResponse(sr); got.onlineCPUs != 4 { + t.Fatalf("onlineCPUs fallback = %v, want 4 (len PercpuUsage)", got.onlineCPUs) + } +} + +// TestCombineSamplesKnownAnswer pins the two-sample combination: cpuDelta = 1e9, +// systemDelta = 10e9, onlineCPUs = 4 -> 40%, with memory taken from the later sample. +func TestCombineSamplesKnownAnswer(t *testing.T) { + a := containerSample{cpuTotal: 5_000_000_000, systemCPU: 500_000_000_000, onlineCPUs: 4, memoryMB: 10} + b := containerSample{cpuTotal: 6_000_000_000, systemCPU: 510_000_000_000, onlineCPUs: 4, memoryMB: 42} + cpu, mem := combineSamples(a, b) + if cpu != 40 { + t.Fatalf("combineSamples cpu = %v, want 40", cpu) + } + if mem != 42 { // memory taken from the later sample b + t.Fatalf("combineSamples mem = %v, want 42 (sample b)", mem) + } +} + +// TestCombineSamplesGuardsAndMemoryFromB covers the guard paths (zero and backwards +// deltas), and that memory always comes from the later sample b. +func TestCombineSamplesGuardsAndMemoryFromB(t *testing.T) { + a := containerSample{cpuTotal: 5_000_000_000, systemCPU: 100_000_000_000, onlineCPUs: 4, memoryMB: 1} + b := containerSample{cpuTotal: 5_000_000_000, systemCPU: 110_000_000_000, onlineCPUs: 4, memoryMB: 7} + if cpu, mem := combineSamples(a, b); cpu != 0 || mem != 7 { + t.Fatalf("zero cpu delta: cpu=%v mem=%v, want 0 and 7", cpu, mem) + } + a2 := containerSample{cpuTotal: 1_000_000_000, systemCPU: 200_000_000_000, onlineCPUs: 4, memoryMB: 3} + b2 := containerSample{cpuTotal: 2_000_000_000, systemCPU: 100_000_000_000, onlineCPUs: 4, memoryMB: 9} + if cpu, _ := combineSamples(a2, b2); cpu != 0 { + t.Fatalf("backwards system counter: cpu=%v, want 0", cpu) + } +} + +// TestSampleStatsDecodesCannedResponse covers the read+decode path via a fake client. +func TestSampleStatsDecodesCannedResponse(t *testing.T) { + f := &fakeStatsClient{seq: []fakeStat{{body: statsJSON(t, 6_000_000_000, 500_000_000_000, 8, 200*mib, 50*mib)}}} + got, ok := sampleStats(context.Background(), f, "id") + if !ok { + t.Fatal("sampleStats ok = false, want true") + } + if got.cpuTotal != 6_000_000_000 || got.onlineCPUs != 8 || got.memoryMB != 150 { + t.Fatalf("sampleStats decoded %+v, want cpuTotal 6e9 / onlineCPUs 8 / mem 150", got) + } +} + +// TestSampleStatsReaderErrorReturnsNotOK covers the ContainerStatsOneShot error path. +func TestSampleStatsReaderErrorReturnsNotOK(t *testing.T) { + f := &fakeStatsClient{seq: []fakeStat{{err: errors.New("stats unavailable")}}} + if _, ok := sampleStats(context.Background(), f, "id"); ok { + t.Fatal("sampleStats ok = true on reader error, want false") + } +} + +// TestSampleStatsDecodeErrorReturnsNotOK covers the JSON-decode error path. +func TestSampleStatsDecodeErrorReturnsNotOK(t *testing.T) { + f := &fakeStatsClient{seq: []fakeStat{{body: "this is not json"}}} + if _, ok := sampleStats(context.Background(), f, "id"); ok { + t.Fatal("sampleStats ok = true on decode error, want false") + } +} + +// TestStatsReturnsZeroWhenNotRunning covers the early return; cli is never touched. +func TestStatsReturnsZeroWhenNotRunning(t *testing.T) { + p := &DockerProvider{} + if cpu, mem := p.stats(context.Background(), nil, "id", false); cpu != 0 || mem != 0 { + t.Fatalf("stats(running=false) = %v,%v want 0,0", cpu, mem) + } +} + +// TestStatsTwoSampleComputesLiveCPU covers the full two-sample orchestration (read A, +// wait, read B, combine) via a fake client, with no Docker daemon. +func TestStatsTwoSampleComputesLiveCPU(t *testing.T) { + f := &fakeStatsClient{seq: []fakeStat{ + {body: statsJSON(t, 5_000_000_000, 500_000_000_000, 4, 100*mib, 0)}, + {body: statsJSON(t, 6_000_000_000, 510_000_000_000, 4, 128*mib, 0)}, + }} + p := &DockerProvider{} + cpu, mem := p.stats(context.Background(), f, "id", true) + if cpu != 40 { + t.Fatalf("stats cpu = %v, want 40", cpu) + } + if mem != 128 { + t.Fatalf("stats mem = %v, want 128 (sample b)", mem) + } +} + +// TestStatsFirstSampleFailureReturnsZero covers the sample-A failure branch. +func TestStatsFirstSampleFailureReturnsZero(t *testing.T) { + f := &fakeStatsClient{seq: []fakeStat{{err: errors.New("boom")}}} + p := &DockerProvider{} + if cpu, mem := p.stats(context.Background(), f, "id", true); cpu != 0 || mem != 0 { + t.Fatalf("first-sample failure = %v,%v want 0,0", cpu, mem) + } +} + +// TestStatsCtxCancelledDuringWaitReturnsSampleAMemory covers the ctx.Done() branch of +// the wait: CPU is 0 but sample A's memory is still returned. +func TestStatsCtxCancelledDuringWaitReturnsSampleAMemory(t *testing.T) { + f := &fakeStatsClient{seq: []fakeStat{{body: statsJSON(t, 5_000_000_000, 500_000_000_000, 4, 64*mib, 0)}}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already cancelled: the wait select takes the ctx.Done() branch at once + p := &DockerProvider{} + cpu, mem := p.stats(ctx, f, "id", true) + if cpu != 0 { + t.Fatalf("ctx-cancelled cpu = %v, want 0", cpu) + } + if mem != 64 { + t.Fatalf("ctx-cancelled mem = %v, want 64 (sample A memory)", mem) + } +} + +// TestStatsSecondSampleFailureReturnsSampleAMemory covers the sample-B failure branch +// after a successful sample A. +func TestStatsSecondSampleFailureReturnsSampleAMemory(t *testing.T) { + f := &fakeStatsClient{seq: []fakeStat{ + {body: statsJSON(t, 5_000_000_000, 500_000_000_000, 4, 96*mib, 0)}, + {err: errors.New("container gone")}, + }} + p := &DockerProvider{} + cpu, mem := p.stats(context.Background(), f, "id", true) + if cpu != 0 { + t.Fatalf("second-sample failure cpu = %v, want 0", cpu) + } + if mem != 96 { + t.Fatalf("second-sample failure mem = %v, want 96 (sample A memory)", mem) + } +} + +// TestStatsForContainersMapsFieldsAndSamplesConcurrently covers the concurrent fan-out +// and the ContainerInfo mapping (via toContainerInfo) with a fake client and -race. +func TestStatsForContainersMapsFieldsAndSamplesConcurrently(t *testing.T) { + // One body, repeated for every (concurrent) call: A==B, so running containers + // report CPU 0 with the sample's memory; exited containers are not sampled. + f := &fakeStatsClient{seq: []fakeStat{{body: statsJSON(t, 5_000_000_000, 500_000_000_000, 4, 64*mib, 0)}}} + containers := []container.Summary{ + {ID: "id-a", Names: []string{"/cashpilot-alpha"}, Image: "img-a", State: "running", Labels: map[string]string{LabelService: "alpha"}}, + {ID: "id-b", Names: []string{"/cashpilot-beta"}, Image: "img-b", State: "running", Labels: map[string]string{LabelService: "beta"}}, + {ID: "id-c", Names: []string{"/cashpilot-gamma"}, Image: "img-c", State: "exited", Labels: map[string]string{LabelService: "gamma"}}, + {ID: "id-d", Names: nil, Image: "img-d", State: "exited", Labels: map[string]string{LabelService: "delta"}}, + } + p := &DockerProvider{} + out := p.statsForContainers(context.Background(), f, containers) + if len(out) != 4 { + t.Fatalf("len(out) = %d, want 4", len(out)) + } + if out[0].Slug != "alpha" || out[0].ContainerID != "id-a" || out[0].Name != "cashpilot-alpha" || out[0].Image != "img-a" || out[0].Status != "running" { + t.Fatalf("out[0] mapping wrong: %+v", out[0]) + } + if out[0].MemoryMB != 64 { // running -> sampled + t.Fatalf("out[0] memory = %v, want 64", out[0].MemoryMB) + } + if out[2].Slug != "gamma" || out[2].Name != "cashpilot-gamma" || out[2].Status != "exited" { + t.Fatalf("out[2] mapping wrong: %+v", out[2]) + } + if out[2].CPUPercent != 0 || out[2].MemoryMB != 0 { // exited -> not sampled + t.Fatalf("exited container out[2] = cpu %v mem %v, want 0/0", out[2].CPUPercent, out[2].MemoryMB) + } + if out[3].Name != "" { // empty Names -> empty display name + t.Fatalf("out[3] name = %q, want empty", out[3].Name) + } +} + +// dialTestDocker returns a Docker client pointed at a live daemon, or skips the +// test. It first tries dockerClient() (the same env/default-socket path the app +// uses) and, if that cannot be pinged, falls back to the endpoint the docker CLI's +// current context resolves to — e.g. Colima, when /var/run/docker.sock is not +// wired to the running daemon. The caller owns the returned client. +func dialTestDocker(t *testing.T) *client.Client { + t.Helper() + if cli, err := dockerClient(); err == nil { + if _, perr := cli.Ping(context.Background()); perr == nil { + return cli + } + cli.Close() + } + out, err := exec.Command("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output() + if err != nil { + t.Skipf("docker not available (context inspect failed): %v", err) + } + host := strings.TrimSpace(string(out)) + if host == "" { + t.Skip("docker not available (empty context endpoint)") + } + cli, err := client.NewClientWithOpts(client.WithHost(host), client.WithAPIVersionNegotiation()) + if err != nil { + t.Skipf("docker not available (client init failed): %v", err) + } + if _, err := cli.Ping(context.Background()); err != nil { + cli.Close() + t.Skipf("docker daemon not reachable at %s: %v", host, err) + } + return cli +} + +// TestDockerStatsIntegrationReportsLiveCPU exercises the real two-sample stats() +// against a throwaway busy-loop container. It runs only when a Docker daemon is +// reachable and not in -short mode, so CI without Docker stays green. It never +// touches any pre-existing container (e.g. unrelated k3d-* containers on this +// host): it creates, samples, and force-removes only its own uniquely-named +// throwaway. +func TestDockerStatsIntegrationReportsLiveCPU(t *testing.T) { + if testing.Short() { + t.Skip("skipping docker integration test in -short mode") + } + cli := dialTestDocker(t) + defer cli.Close() + + ctx := context.Background() + + const img = "busybox:1.37.0" // pinned tag, never :latest + if err := pullImage(ctx, cli, img, nil); err != nil { + t.Skipf("could not pull %s: %v", img, err) + } + + name := fmt.Sprintf("cashpilot-cputest-%d", time.Now().UnixNano()) + created, err := cli.ContainerCreate(ctx, &container.Config{ + Image: img, + Cmd: []string{"sh", "-c", "while true; do :; done"}, + }, &container.HostConfig{}, nil, nil, name) + if err != nil { + t.Fatalf("create throwaway container %s: %v", name, err) + } + // ALWAYS clean up our own container, even on failure. Use a fresh context so + // removal still runs if ctx has been cancelled. + defer func() { + _ = cli.ContainerRemove(context.Background(), created.ID, container.RemoveOptions{Force: true}) + }() + + if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { + t.Fatalf("start throwaway container %s: %v", name, err) + } + + // Let the busy loop spin up before the two samples are taken. + time.Sleep(500 * time.Millisecond) + + // Read onlineCPUs directly to bound the assertion's ceiling. + sample, ok := sampleStats(ctx, cli, created.ID) + if !ok { + t.Fatal("could not read a container stats sample") + } + onlineCPUs := sample.onlineCPUs + if onlineCPUs <= 0 { + onlineCPUs = float64(goruntime.NumCPU()) + } + + p := &DockerProvider{} + cpu, mem := p.stats(ctx, cli, created.ID, true) + t.Logf("live docker stats: CPU%%=%.2f memory=%.2f MB onlineCPUs=%.0f container=%s", cpu, mem, onlineCPUs, name) + + if math.IsNaN(cpu) || math.IsInf(cpu, 0) { + t.Fatalf("CPU%% is not finite: %v", cpu) + } + if cpu <= 0 { + t.Fatalf("expected CPU%% > 0 for a busy-loop container, got %v", cpu) + } + ceiling := onlineCPUs*100 + 50 // slack for scheduling/measurement noise + if cpu > ceiling { + t.Fatalf("CPU%% %v exceeds ceiling %v (onlineCPUs=%.0f)", cpu, ceiling, onlineCPUs) + } + if mem <= 0 { + t.Fatalf("expected memory > 0 MB for a running container, got %v", mem) + } +}