|
|
|
|
| package agent
|
|
|
| import (
|
| "bytes"
|
| "context"
|
| "encoding/json"
|
| "errors"
|
| "fmt"
|
| "io"
|
| "net"
|
| "net/http"
|
| "net/http/httptest"
|
| "os"
|
| "strings"
|
| "testing"
|
| "time"
|
|
|
| "github.com/henrygd/beszel/agent/deltatracker"
|
| "github.com/henrygd/beszel/agent/utils"
|
| "github.com/henrygd/beszel/internal/entities/container"
|
| "github.com/stretchr/testify/assert"
|
| "github.com/stretchr/testify/require"
|
| )
|
|
|
| var defaultCacheTimeMs = uint16(60_000)
|
|
|
| type recordingRoundTripper struct {
|
| statusCode int
|
| body string
|
| contentType string
|
| called bool
|
| lastPath string
|
| lastQuery map[string]string
|
| }
|
|
|
| type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
| func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
| return fn(req)
|
| }
|
|
|
| func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
| rt.called = true
|
| rt.lastPath = req.URL.EscapedPath()
|
| rt.lastQuery = map[string]string{}
|
| for key, values := range req.URL.Query() {
|
| if len(values) > 0 {
|
| rt.lastQuery[key] = values[0]
|
| }
|
| }
|
| resp := &http.Response{
|
| StatusCode: rt.statusCode,
|
| Status: "200 OK",
|
| Header: make(http.Header),
|
| Body: io.NopCloser(strings.NewReader(rt.body)),
|
| Request: req,
|
| }
|
| if rt.contentType != "" {
|
| resp.Header.Set("Content-Type", rt.contentType)
|
| }
|
| return resp, nil
|
| }
|
|
|
|
|
| func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) {
|
|
|
| if dm.lastCpuContainer[cacheTimeMs] != nil {
|
| clear(dm.lastCpuContainer[cacheTimeMs])
|
| }
|
| if dm.lastCpuSystem[cacheTimeMs] != nil {
|
| clear(dm.lastCpuSystem[cacheTimeMs])
|
| }
|
| }
|
|
|
| func TestCalculateMemoryUsage(t *testing.T) {
|
| tests := []struct {
|
| name string
|
| apiStats *container.ApiStats
|
| isWindows bool
|
| expected uint64
|
| expectError bool
|
| }{
|
| {
|
| name: "Linux with valid memory stats",
|
| apiStats: &container.ApiStats{
|
| MemoryStats: container.MemoryStats{
|
| Usage: 1048576,
|
| Stats: container.MemoryStatsStats{
|
| Cache: 524288,
|
| InactiveFile: 262144,
|
| },
|
| },
|
| },
|
| isWindows: false,
|
| expected: 786432,
|
| expectError: false,
|
| },
|
| {
|
| name: "Linux with zero cache uses inactive_file",
|
| apiStats: &container.ApiStats{
|
| MemoryStats: container.MemoryStats{
|
| Usage: 1048576,
|
| Stats: container.MemoryStatsStats{
|
| Cache: 0,
|
| InactiveFile: 262144,
|
| },
|
| },
|
| },
|
| isWindows: false,
|
| expected: 786432,
|
| expectError: false,
|
| },
|
| {
|
| name: "Windows with valid memory stats",
|
| apiStats: &container.ApiStats{
|
| MemoryStats: container.MemoryStats{
|
| PrivateWorkingSet: 524288,
|
| },
|
| },
|
| isWindows: true,
|
| expected: 524288,
|
| expectError: false,
|
| },
|
| {
|
| name: "Linux with zero usage returns error",
|
| apiStats: &container.ApiStats{
|
| MemoryStats: container.MemoryStats{
|
| Usage: 0,
|
| Stats: container.MemoryStatsStats{
|
| Cache: 0,
|
| InactiveFile: 0,
|
| },
|
| },
|
| },
|
| isWindows: false,
|
| expected: 0,
|
| expectError: true,
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| result, err := calculateMemoryUsage(tt.apiStats, tt.isWindows)
|
|
|
| if tt.expectError {
|
| assert.Error(t, err)
|
| } else {
|
| assert.NoError(t, err)
|
| assert.Equal(t, tt.expected, result)
|
| }
|
| })
|
| }
|
| }
|
|
|
| func TestBuildDockerContainerEndpoint(t *testing.T) {
|
| t.Run("valid container ID builds escaped endpoint", func(t *testing.T) {
|
| endpoint, err := buildDockerContainerEndpoint("0123456789ab", "json", nil)
|
| require.NoError(t, err)
|
| assert.Equal(t, "http://localhost/containers/0123456789ab/json", endpoint)
|
| })
|
|
|
| t.Run("invalid container ID is rejected", func(t *testing.T) {
|
| _, err := buildDockerContainerEndpoint("../../version", "json", nil)
|
| require.Error(t, err)
|
| assert.Contains(t, err.Error(), "invalid container id")
|
| })
|
| }
|
|
|
| func TestContainerDetailsRequestsValidateContainerID(t *testing.T) {
|
| rt := &recordingRoundTripper{
|
| statusCode: 200,
|
| body: `{"Config":{"Env":["SECRET=1"]}}`,
|
| }
|
| dm := &dockerManager{
|
| client: &http.Client{Transport: rt},
|
| }
|
|
|
| _, err := dm.getContainerInfo(context.Background(), "../version")
|
| require.Error(t, err)
|
| assert.Contains(t, err.Error(), "invalid container id")
|
| assert.False(t, rt.called, "request should be rejected before dispatching to Docker API")
|
| }
|
|
|
| func TestContainerDetailsRequestsUseExpectedDockerPaths(t *testing.T) {
|
| t.Run("container info uses container json endpoint", func(t *testing.T) {
|
| rt := &recordingRoundTripper{
|
| statusCode: 200,
|
| body: `{"Config":{"Env":["SECRET=1"]},"Name":"demo"}`,
|
| }
|
| dm := &dockerManager{
|
| client: &http.Client{Transport: rt},
|
| }
|
|
|
| body, err := dm.getContainerInfo(context.Background(), "0123456789ab")
|
| require.NoError(t, err)
|
| assert.True(t, rt.called)
|
| assert.Equal(t, "/containers/0123456789ab/json", rt.lastPath)
|
| assert.NotContains(t, string(body), "SECRET=1", "sensitive env vars should be removed")
|
| })
|
|
|
| t.Run("container logs uses expected endpoint and query params", func(t *testing.T) {
|
| rt := &recordingRoundTripper{
|
| statusCode: 200,
|
| body: "line1\nline2\n",
|
| }
|
| dm := &dockerManager{
|
| client: &http.Client{Transport: rt},
|
| }
|
|
|
| logs, err := dm.getLogs(context.Background(), "abcdef123456")
|
| require.NoError(t, err)
|
| assert.True(t, rt.called)
|
| assert.Equal(t, "/containers/abcdef123456/logs", rt.lastPath)
|
| assert.Equal(t, "1", rt.lastQuery["stdout"])
|
| assert.Equal(t, "1", rt.lastQuery["stderr"])
|
| assert.Equal(t, "200", rt.lastQuery["tail"])
|
| assert.Equal(t, "line1\nline2\n", logs)
|
| })
|
| }
|
|
|
| func TestGetPodmanContainerHealth(t *testing.T) {
|
| called := false
|
| dm := &dockerManager{
|
| client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
| called = true
|
| assert.Equal(t, "/containers/0123456789ab/json", req.URL.EscapedPath())
|
| return &http.Response{
|
| StatusCode: http.StatusOK,
|
| Status: "200 OK",
|
| Header: make(http.Header),
|
| Body: io.NopCloser(strings.NewReader(`{"State":{"Health":{"Status":"healthy"}}}`)),
|
| Request: req,
|
| }, nil
|
| })},
|
| }
|
|
|
| health, err := dm.getPodmanContainerHealth("0123456789ab")
|
| require.NoError(t, err)
|
| assert.True(t, called)
|
| assert.Equal(t, container.DockerHealthHealthy, health)
|
| }
|
|
|
| func TestValidateCpuPercentage(t *testing.T) {
|
| tests := []struct {
|
| name string
|
| cpuPct float64
|
| containerName string
|
| expectError bool
|
| expectedError string
|
| }{
|
| {
|
| name: "valid CPU percentage",
|
| cpuPct: 50.5,
|
| containerName: "test-container",
|
| expectError: false,
|
| },
|
| {
|
| name: "zero CPU percentage",
|
| cpuPct: 0.0,
|
| containerName: "test-container",
|
| expectError: false,
|
| },
|
| {
|
| name: "CPU percentage over 100",
|
| cpuPct: 150.5,
|
| containerName: "test-container",
|
| expectError: true,
|
| expectedError: "test-container cpu pct greater than 100: 150.5",
|
| },
|
| {
|
| name: "CPU percentage exactly 100",
|
| cpuPct: 100.0,
|
| containerName: "test-container",
|
| expectError: false,
|
| },
|
| {
|
| name: "negative CPU percentage",
|
| cpuPct: -10.0,
|
| containerName: "test-container",
|
| expectError: false,
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| err := validateCpuPercentage(tt.cpuPct, tt.containerName)
|
|
|
| if tt.expectError {
|
| assert.Error(t, err)
|
| assert.Contains(t, err.Error(), tt.expectedError)
|
| } else {
|
| assert.NoError(t, err)
|
| }
|
| })
|
| }
|
| }
|
|
|
| func TestUpdateContainerStatsValues(t *testing.T) {
|
| stats := &container.Stats{
|
| Name: "test-container",
|
| Cpu: 0.0,
|
| Mem: 0.0,
|
| NetworkSent: 0.0,
|
| NetworkRecv: 0.0,
|
| PrevReadTime: time.Time{},
|
| }
|
|
|
| testTime := time.Now()
|
| updateContainerStatsValues(stats, 75.5, 1048576, 524288, 262144, testTime)
|
|
|
|
|
| assert.Equal(t, 75.5, stats.Cpu)
|
|
|
|
|
| assert.Equal(t, 1.0, stats.Mem)
|
|
|
|
|
| assert.Equal(t, [2]uint64{524288, 262144}, stats.Bandwidth)
|
|
|
|
|
| assert.Equal(t, 0.5, stats.NetworkSent)
|
| assert.Equal(t, 0.25, stats.NetworkRecv)
|
|
|
|
|
| assert.Equal(t, testTime, stats.PrevReadTime)
|
| }
|
|
|
| func TestInitializeCpuTracking(t *testing.T) {
|
| dm := &dockerManager{
|
| lastCpuContainer: make(map[uint16]map[string]uint64),
|
| lastCpuSystem: make(map[uint16]map[string]uint64),
|
| lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
| }
|
|
|
| cacheTimeMs := uint16(30000)
|
|
|
|
|
| dm.initializeCpuTracking(cacheTimeMs)
|
|
|
|
|
| assert.NotNil(t, dm.lastCpuContainer[cacheTimeMs])
|
| assert.NotNil(t, dm.lastCpuSystem[cacheTimeMs])
|
| assert.NotNil(t, dm.lastCpuReadTime[cacheTimeMs])
|
| assert.Empty(t, dm.lastCpuContainer[cacheTimeMs])
|
| assert.Empty(t, dm.lastCpuSystem[cacheTimeMs])
|
|
|
|
|
| dm.lastCpuContainer[cacheTimeMs]["test"] = 100
|
| dm.lastCpuSystem[cacheTimeMs]["test"] = 200
|
|
|
| dm.initializeCpuTracking(cacheTimeMs)
|
|
|
|
|
| assert.Equal(t, uint64(100), dm.lastCpuContainer[cacheTimeMs]["test"])
|
| assert.Equal(t, uint64(200), dm.lastCpuSystem[cacheTimeMs]["test"])
|
| }
|
|
|
| func TestGetCpuPreviousValues(t *testing.T) {
|
| dm := &dockerManager{
|
| lastCpuContainer: map[uint16]map[string]uint64{
|
| 30000: {"container1": 100, "container2": 200},
|
| },
|
| lastCpuSystem: map[uint16]map[string]uint64{
|
| 30000: {"container1": 150, "container2": 250},
|
| },
|
| }
|
|
|
|
|
| container, system := dm.getCpuPreviousValues(30000, "container1")
|
| assert.Equal(t, uint64(100), container)
|
| assert.Equal(t, uint64(150), system)
|
|
|
|
|
| container, system = dm.getCpuPreviousValues(30000, "nonexistent")
|
| assert.Equal(t, uint64(0), container)
|
| assert.Equal(t, uint64(0), system)
|
|
|
|
|
| container, system = dm.getCpuPreviousValues(60000, "container1")
|
| assert.Equal(t, uint64(0), container)
|
| assert.Equal(t, uint64(0), system)
|
| }
|
|
|
| func TestSetCpuCurrentValues(t *testing.T) {
|
| dm := &dockerManager{
|
| lastCpuContainer: make(map[uint16]map[string]uint64),
|
| lastCpuSystem: make(map[uint16]map[string]uint64),
|
| }
|
|
|
| cacheTimeMs := uint16(30000)
|
| containerId := "test-container"
|
|
|
|
|
| dm.initializeCpuTracking(cacheTimeMs)
|
|
|
|
|
| dm.setCpuCurrentValues(cacheTimeMs, containerId, 500, 750)
|
|
|
|
|
| assert.Equal(t, uint64(500), dm.lastCpuContainer[cacheTimeMs][containerId])
|
| assert.Equal(t, uint64(750), dm.lastCpuSystem[cacheTimeMs][containerId])
|
| }
|
|
|
| func TestCalculateNetworkStats(t *testing.T) {
|
|
|
| dm := &dockerManager{
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
| }
|
|
|
| cacheTimeMs := uint16(30000)
|
|
|
|
|
| sentTracker := deltatracker.NewDeltaTracker[string, uint64]()
|
| recvTracker := deltatracker.NewDeltaTracker[string, uint64]()
|
| sentTracker.Set("container1", 1000)
|
| recvTracker.Set("container1", 800)
|
| sentTracker.Cycle()
|
| recvTracker.Cycle()
|
|
|
| dm.networkSentTrackers[cacheTimeMs] = sentTracker
|
| dm.networkRecvTrackers[cacheTimeMs] = recvTracker
|
|
|
|
|
| dm.lastNetworkReadTime[cacheTimeMs] = map[string]time.Time{
|
| "container1": time.Now().Add(-time.Second),
|
| }
|
|
|
| ctr := &container.ApiInfo{
|
| IdShort: "container1",
|
| }
|
|
|
| apiStats := &container.ApiStats{
|
| Networks: map[string]container.NetworkStats{
|
| "eth0": {TxBytes: 2000, RxBytes: 1800},
|
| },
|
| }
|
|
|
|
|
| sent, recv := dm.calculateNetworkStats(ctr, apiStats, "test-container", cacheTimeMs)
|
|
|
|
|
| assert.GreaterOrEqual(t, sent, uint64(0))
|
| assert.GreaterOrEqual(t, recv, uint64(0))
|
|
|
|
|
| dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
| dm.lastNetworkReadTime[cacheTimeMs]["container1"] = time.Now().Add(-time.Second)
|
| apiStats.Networks["eth0"] = container.NetworkStats{TxBytes: 2500, RxBytes: 1800}
|
| sent, recv = dm.calculateNetworkStats(ctr, apiStats, "test-container", cacheTimeMs)
|
| assert.Greater(t, sent, uint64(0))
|
| assert.Equal(t, uint64(0), recv)
|
| }
|
|
|
|
|
|
|
|
|
|
|
| func TestNetworkStatsCacheTimeIsolation(t *testing.T) {
|
| dm := &dockerManager{
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
| }
|
|
|
| ctr := &container.ApiInfo{IdShort: "container1"}
|
| fastCache := uint16(1000)
|
| slowCache := uint16(60000)
|
|
|
|
|
| baseline := &container.ApiStats{
|
| Networks: map[string]container.NetworkStats{
|
| "eth0": {TxBytes: 100, RxBytes: 100},
|
| },
|
| }
|
| dm.calculateNetworkStats(ctr, baseline, "test", fastCache)
|
| dm.calculateNetworkStats(ctr, baseline, "test", slowCache)
|
|
|
|
|
| now := time.Now()
|
| dm.lastNetworkReadTime[fastCache] = map[string]time.Time{"container1": now}
|
| dm.lastNetworkReadTime[slowCache] = map[string]time.Time{"container1": now}
|
| dm.cycleNetworkDeltasForCacheTime(fastCache)
|
| dm.cycleNetworkDeltasForCacheTime(slowCache)
|
|
|
|
|
| totalBytes := uint64(100)
|
| for i := 0; i < 5; i++ {
|
| totalBytes += 10
|
| stats := &container.ApiStats{
|
| Networks: map[string]container.NetworkStats{
|
| "eth0": {TxBytes: totalBytes, RxBytes: totalBytes},
|
| },
|
| }
|
|
|
| dm.lastNetworkReadTime[fastCache]["container1"] = time.Now().Add(-time.Second)
|
| sent, _ := dm.calculateNetworkStats(ctr, stats, "test", fastCache)
|
|
|
| assert.LessOrEqual(t, sent, uint64(100), "fast cache rate should be reasonable")
|
| dm.cycleNetworkDeltasForCacheTime(fastCache)
|
| }
|
|
|
|
|
|
|
| dm.lastNetworkReadTime[slowCache]["container1"] = time.Now().Add(-5 * time.Second)
|
| finalStats := &container.ApiStats{
|
| Networks: map[string]container.NetworkStats{
|
| "eth0": {TxBytes: totalBytes, RxBytes: totalBytes},
|
| },
|
| }
|
| sent, _ := dm.calculateNetworkStats(ctr, finalStats, "test", slowCache)
|
|
|
|
|
| assert.LessOrEqual(t, sent, uint64(100), "slow cache rate should NOT be inflated by fast cache collections")
|
| assert.GreaterOrEqual(t, sent, uint64(1), "slow cache should still report some traffic")
|
| }
|
|
|
| func TestDockerManagerCreation(t *testing.T) {
|
|
|
| dm := &dockerManager{
|
| lastCpuContainer: make(map[uint16]map[string]uint64),
|
| lastCpuSystem: make(map[uint16]map[string]uint64),
|
| lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
| }
|
|
|
| assert.NotNil(t, dm)
|
| assert.NotNil(t, dm.lastCpuContainer)
|
| assert.NotNil(t, dm.lastCpuSystem)
|
| assert.NotNil(t, dm.networkSentTrackers)
|
| assert.NotNil(t, dm.networkRecvTrackers)
|
| assert.NotNil(t, dm.lastNetworkReadTime)
|
| }
|
|
|
| func TestCheckDockerVersion(t *testing.T) {
|
| tests := []struct {
|
| name string
|
| statusCode int
|
| body string
|
| server string
|
| expectSuccess bool
|
| expectedGood bool
|
| expectedPodman bool
|
| expectError bool
|
| expectedRequest string
|
| }{
|
| {
|
| name: "good docker version",
|
| statusCode: http.StatusOK,
|
| body: `{"Version":"25.0.1"}`,
|
| expectSuccess: true,
|
| expectedGood: true,
|
| expectedPodman: false,
|
| expectedRequest: "/version",
|
| },
|
| {
|
| name: "old docker version",
|
| statusCode: http.StatusOK,
|
| body: `{"Version":"24.0.7"}`,
|
| expectSuccess: true,
|
| expectedGood: false,
|
| expectedPodman: false,
|
| expectedRequest: "/version",
|
| },
|
| {
|
| name: "podman from server header",
|
| statusCode: http.StatusOK,
|
| body: `{"Version":"5.5.0"}`,
|
| server: "Libpod/5.5.0",
|
| expectSuccess: true,
|
| expectedGood: true,
|
| expectedPodman: true,
|
| expectedRequest: "/version",
|
| },
|
| {
|
| name: "non-200 response",
|
| statusCode: http.StatusServiceUnavailable,
|
| body: `"not ready"`,
|
| expectSuccess: false,
|
| expectedGood: false,
|
| expectedPodman: false,
|
| expectError: true,
|
| expectedRequest: "/version",
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| requestCount := 0
|
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| requestCount++
|
| assert.Equal(t, tt.expectedRequest, r.URL.EscapedPath())
|
| if tt.server != "" {
|
| w.Header().Set("Server", tt.server)
|
| }
|
| w.WriteHeader(tt.statusCode)
|
| fmt.Fprint(w, tt.body)
|
| }))
|
| defer server.Close()
|
|
|
| dm := &dockerManager{
|
| client: &http.Client{
|
| Transport: &http.Transport{
|
| DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
|
| return net.Dial(network, server.Listener.Addr().String())
|
| },
|
| },
|
| },
|
| }
|
|
|
| success, err := dm.checkDockerVersion()
|
|
|
| assert.Equal(t, tt.expectSuccess, success)
|
| assert.Equal(t, tt.expectSuccess, dm.dockerVersionChecked)
|
| assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
| assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
| assert.Equal(t, 1, requestCount)
|
| if tt.expectError {
|
| require.Error(t, err)
|
| } else {
|
| require.NoError(t, err)
|
| }
|
| })
|
| }
|
|
|
| t.Run("request error", func(t *testing.T) {
|
| requestCount := 0
|
| dm := &dockerManager{
|
| client: &http.Client{
|
| Transport: &http.Transport{
|
| DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
| requestCount++
|
| return nil, errors.New("connection refused")
|
| },
|
| },
|
| },
|
| }
|
|
|
| success, err := dm.checkDockerVersion()
|
|
|
| assert.False(t, success)
|
| require.Error(t, err)
|
| assert.False(t, dm.dockerVersionChecked)
|
| assert.False(t, dm.goodDockerVersion)
|
| assert.False(t, dm.usingPodman)
|
| assert.Equal(t, 1, requestCount)
|
| })
|
| }
|
|
|
|
|
| func newDockerManagerForVersionTest(server *httptest.Server) *dockerManager {
|
| return &dockerManager{
|
| client: &http.Client{
|
| Transport: &http.Transport{
|
| DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
|
| return net.Dial(network, server.Listener.Addr().String())
|
| },
|
| },
|
| },
|
| containerStatsMap: make(map[string]*container.Stats),
|
| lastCpuContainer: make(map[uint16]map[string]uint64),
|
| lastCpuSystem: make(map[uint16]map[string]uint64),
|
| lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
| }
|
| }
|
|
|
| func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
| tests := []struct {
|
| name string
|
| containerServer string
|
| versionServer string
|
| versionBody string
|
| expectedGood bool
|
| expectedPodman bool
|
| }{
|
| {
|
| name: "200 with good version on first try",
|
| versionBody: `{"Version":"25.0.1"}`,
|
| expectedGood: true,
|
| expectedPodman: false,
|
| },
|
| {
|
| name: "200 with old version on first try",
|
| versionBody: `{"Version":"24.0.7"}`,
|
| expectedGood: false,
|
| expectedPodman: false,
|
| },
|
| {
|
| name: "podman detected from server header",
|
| containerServer: "Libpod/5.5.0",
|
| expectedGood: true,
|
| expectedPodman: true,
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| requestCounts := map[string]int{}
|
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| requestCounts[r.URL.EscapedPath()]++
|
| switch r.URL.EscapedPath() {
|
| case "/containers/json":
|
| if tt.containerServer != "" {
|
| w.Header().Set("Server", tt.containerServer)
|
| }
|
| w.WriteHeader(http.StatusOK)
|
| fmt.Fprint(w, `[]`)
|
| case "/version":
|
| if tt.versionServer != "" {
|
| w.Header().Set("Server", tt.versionServer)
|
| }
|
| w.WriteHeader(http.StatusOK)
|
| fmt.Fprint(w, tt.versionBody)
|
| default:
|
| t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
|
| }
|
| }))
|
| defer server.Close()
|
|
|
| dm := newDockerManagerForVersionTest(server)
|
|
|
| stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
| require.NoError(t, err)
|
| assert.Empty(t, stats)
|
| assert.True(t, dm.dockerVersionChecked)
|
| assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
| assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
| assert.Equal(t, 1, requestCounts["/containers/json"])
|
| if tt.expectedPodman {
|
| assert.Equal(t, 0, requestCounts["/version"])
|
| } else {
|
| assert.Equal(t, 1, requestCounts["/version"])
|
| }
|
|
|
| stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
| require.NoError(t, err)
|
| assert.Empty(t, stats)
|
| assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
| assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
| assert.Equal(t, 2, requestCounts["/containers/json"])
|
| if tt.expectedPodman {
|
| assert.Equal(t, 0, requestCounts["/version"])
|
| } else {
|
| assert.Equal(t, 1, requestCounts["/version"])
|
| }
|
| })
|
| }
|
|
|
| }
|
|
|
| func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
|
| requestCounts := map[string]int{}
|
| versionStatuses := []int{http.StatusServiceUnavailable, http.StatusOK}
|
| versionBodies := []string{`"not ready"`, `{"Version":"25.1.0"}`}
|
|
|
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
| requestCounts[r.URL.EscapedPath()]++
|
| switch r.URL.EscapedPath() {
|
| case "/containers/json":
|
| w.WriteHeader(http.StatusOK)
|
| fmt.Fprint(w, `[]`)
|
| case "/version":
|
| idx := requestCounts["/version"] - 1
|
| if idx >= len(versionStatuses) {
|
| idx = len(versionStatuses) - 1
|
| }
|
| w.WriteHeader(versionStatuses[idx])
|
| fmt.Fprint(w, versionBodies[idx])
|
| default:
|
| t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
|
| }
|
| }))
|
| defer server.Close()
|
|
|
| dm := newDockerManagerForVersionTest(server)
|
|
|
| stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
| require.NoError(t, err)
|
| assert.Empty(t, stats)
|
| assert.False(t, dm.dockerVersionChecked)
|
| assert.False(t, dm.goodDockerVersion)
|
| assert.Equal(t, 1, requestCounts["/version"])
|
|
|
| stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
| require.NoError(t, err)
|
| assert.Empty(t, stats)
|
| assert.True(t, dm.dockerVersionChecked)
|
| assert.True(t, dm.goodDockerVersion)
|
| assert.Equal(t, 2, requestCounts["/containers/json"])
|
| assert.Equal(t, 2, requestCounts["/version"])
|
|
|
| stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
| require.NoError(t, err)
|
| assert.Empty(t, stats)
|
| assert.Equal(t, 3, requestCounts["/containers/json"])
|
| assert.Equal(t, 2, requestCounts["/version"])
|
| }
|
|
|
| func TestCycleCpuDeltas(t *testing.T) {
|
| dm := &dockerManager{
|
| lastCpuContainer: map[uint16]map[string]uint64{
|
| 30000: {"container1": 100, "container2": 200},
|
| },
|
| lastCpuSystem: map[uint16]map[string]uint64{
|
| 30000: {"container1": 150, "container2": 250},
|
| },
|
| lastCpuReadTime: map[uint16]map[string]time.Time{
|
| 30000: {"container1": time.Now()},
|
| },
|
| }
|
|
|
| cacheTimeMs := uint16(30000)
|
|
|
|
|
| assert.Equal(t, uint64(100), dm.lastCpuContainer[cacheTimeMs]["container1"])
|
| assert.Equal(t, uint64(200), dm.lastCpuContainer[cacheTimeMs]["container2"])
|
|
|
|
|
| dm.cycleCpuDeltas(cacheTimeMs)
|
|
|
|
|
| assert.Empty(t, dm.lastCpuContainer[cacheTimeMs])
|
| assert.Empty(t, dm.lastCpuSystem[cacheTimeMs])
|
|
|
| assert.NotEmpty(t, dm.lastCpuReadTime[cacheTimeMs])
|
| }
|
|
|
| func TestCycleNetworkDeltas(t *testing.T) {
|
|
|
| dm := &dockerManager{
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| }
|
|
|
| cacheTimeMs := uint16(30000)
|
|
|
|
|
| sentTracker := dm.getNetworkTracker(cacheTimeMs, true)
|
| recvTracker := dm.getNetworkTracker(cacheTimeMs, false)
|
|
|
|
|
| sentTracker.Set("test", 100)
|
| recvTracker.Set("test", 200)
|
|
|
|
|
| assert.NotPanics(t, func() {
|
| dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
| })
|
|
|
|
|
| assert.Equal(t, uint64(0), sentTracker.Delta("test"))
|
| assert.Equal(t, uint64(0), recvTracker.Delta("test"))
|
| }
|
|
|
| func TestConstants(t *testing.T) {
|
|
|
| assert.Equal(t, uint16(60000), defaultCacheTimeMs)
|
| assert.Equal(t, uint64(5e9), maxNetworkSpeedBps)
|
| assert.Equal(t, 2100, dockerTimeoutMs)
|
| }
|
|
|
| func TestDockerStatsWithMockData(t *testing.T) {
|
|
|
| dm := &dockerManager{
|
| lastCpuContainer: make(map[uint16]map[string]uint64),
|
| lastCpuSystem: make(map[uint16]map[string]uint64),
|
| lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
| containerStatsMap: make(map[string]*container.Stats),
|
| }
|
|
|
| cacheTimeMs := uint16(30000)
|
|
|
|
|
| dm.initializeCpuTracking(cacheTimeMs)
|
| assert.NotNil(t, dm.lastCpuContainer[cacheTimeMs])
|
| assert.NotNil(t, dm.lastCpuSystem[cacheTimeMs])
|
|
|
|
|
| dm.setCpuCurrentValues(cacheTimeMs, "test-container", 1000, 2000)
|
| container, system := dm.getCpuPreviousValues(cacheTimeMs, "test-container")
|
| assert.Equal(t, uint64(1000), container)
|
| assert.Equal(t, uint64(2000), system)
|
| }
|
|
|
| func TestMemoryStatsEdgeCases(t *testing.T) {
|
| tests := []struct {
|
| name string
|
| usage uint64
|
| cache uint64
|
| inactive uint64
|
| isWindows bool
|
| expected uint64
|
| hasError bool
|
| }{
|
| {"Linux normal case", 1000, 200, 0, false, 800, false},
|
| {"Linux with inactive file", 1000, 0, 300, false, 700, false},
|
| {"Windows normal case", 0, 0, 0, true, 500, false},
|
| {"Linux zero usage error", 0, 0, 0, false, 0, true},
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| apiStats := &container.ApiStats{
|
| MemoryStats: container.MemoryStats{
|
| Usage: tt.usage,
|
| Stats: container.MemoryStatsStats{
|
| Cache: tt.cache,
|
| InactiveFile: tt.inactive,
|
| },
|
| },
|
| }
|
|
|
| if tt.isWindows {
|
| apiStats.MemoryStats.PrivateWorkingSet = tt.expected
|
| }
|
|
|
| result, err := calculateMemoryUsage(apiStats, tt.isWindows)
|
|
|
| if tt.hasError {
|
| assert.Error(t, err)
|
| } else {
|
| assert.NoError(t, err)
|
| assert.Equal(t, tt.expected, result)
|
| }
|
| })
|
| }
|
| }
|
|
|
| func TestContainerStatsInitialization(t *testing.T) {
|
| stats := &container.Stats{Name: "test-container"}
|
|
|
|
|
| assert.Equal(t, "test-container", stats.Name)
|
| assert.Equal(t, 0.0, stats.Cpu)
|
| assert.Equal(t, 0.0, stats.Mem)
|
| assert.Equal(t, 0.0, stats.NetworkSent)
|
| assert.Equal(t, 0.0, stats.NetworkRecv)
|
| assert.Equal(t, time.Time{}, stats.PrevReadTime)
|
|
|
|
|
| testTime := time.Now()
|
| updateContainerStatsValues(stats, 45.67, 2097152, 1048576, 524288, testTime)
|
|
|
| assert.Equal(t, 45.67, stats.Cpu)
|
| assert.Equal(t, 2.0, stats.Mem)
|
| assert.Equal(t, [2]uint64{1048576, 524288}, stats.Bandwidth)
|
|
|
| assert.Equal(t, 1.0, stats.NetworkSent)
|
| assert.Equal(t, 0.5, stats.NetworkRecv)
|
| assert.Equal(t, testTime, stats.PrevReadTime)
|
| }
|
|
|
|
|
| func TestCalculateMemoryUsageWithRealData(t *testing.T) {
|
|
|
| data, err := os.ReadFile("test-data/container.json")
|
| require.NoError(t, err)
|
|
|
| var apiStats container.ApiStats
|
| err = json.Unmarshal(data, &apiStats)
|
| require.NoError(t, err)
|
|
|
|
|
| usedMemory, err := calculateMemoryUsage(&apiStats, false)
|
| require.NoError(t, err)
|
|
|
|
|
| expected := uint64(507400192 - 165130240)
|
| assert.Equal(t, expected, usedMemory)
|
| }
|
|
|
| func TestCpuPercentageCalculationWithRealData(t *testing.T) {
|
|
|
| data1, err := os.ReadFile("test-data/container.json")
|
| require.NoError(t, err)
|
|
|
| data2, err := os.ReadFile("test-data/container2.json")
|
| require.NoError(t, err)
|
|
|
| var apiStats1, apiStats2 container.ApiStats
|
| err = json.Unmarshal(data1, &apiStats1)
|
| require.NoError(t, err)
|
| err = json.Unmarshal(data2, &apiStats2)
|
| require.NoError(t, err)
|
|
|
|
|
|
|
|
|
| expectedPct := float64(2836525000) / float64(2075070000000) * 100.0
|
| actualPct := apiStats2.CalculateCpuPercentLinux(apiStats1.CPUStats.CPUUsage.TotalUsage, apiStats1.CPUStats.SystemUsage)
|
|
|
| assert.InDelta(t, expectedPct, actualPct, 0.01)
|
| }
|
|
|
| func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
|
|
| apiStats1 := &container.ApiStats{
|
| Networks: map[string]container.NetworkStats{
|
| "eth0": {TxBytes: 1000000, RxBytes: 500000},
|
| },
|
| }
|
|
|
| apiStats2 := &container.ApiStats{
|
| Networks: map[string]container.NetworkStats{
|
| "eth0": {TxBytes: 3000000, RxBytes: 1500000},
|
| },
|
| }
|
|
|
|
|
| dm := &dockerManager{
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
| }
|
|
|
| ctr := &container.ApiInfo{IdShort: "test-container"}
|
| cacheTimeMs := uint16(30000)
|
|
|
|
|
| sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, "test", cacheTimeMs)
|
| assert.Equal(t, uint64(0), sent1)
|
| assert.Equal(t, uint64(0), recv1)
|
|
|
|
|
| exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
|
| dm.lastNetworkReadTime[cacheTimeMs] = map[string]time.Time{
|
| "test-container": exactly1000msAgo,
|
| }
|
| dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
|
|
|
|
| deltaSent := uint64(2000000)
|
| deltaRecv := uint64(1000000)
|
| expectedElapsedMs := uint64(1000)
|
| expectedSentRate := deltaSent * 1000 / expectedElapsedMs
|
| expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs
|
|
|
|
|
| sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, "test", cacheTimeMs)
|
|
|
|
|
| assert.Equal(t, expectedSentRate, sent2)
|
| assert.Equal(t, expectedRecvRate, recv2)
|
|
|
|
|
| dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
| dm.lastNetworkReadTime[cacheTimeMs]["test-container"] = time.Now().Add(-1 * time.Millisecond)
|
| apiStats1.Networks["eth0"] = container.NetworkStats{TxBytes: 0, RxBytes: 0}
|
| apiStats2.Networks["eth0"] = container.NetworkStats{TxBytes: 10 * 1024 * 1024 * 1024, RxBytes: 0}
|
| _, _ = dm.calculateNetworkStats(ctr, apiStats1, "test", cacheTimeMs)
|
| dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
| dm.lastNetworkReadTime[cacheTimeMs]["test-container"] = time.Now().Add(-1 * time.Millisecond)
|
| sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, "test", cacheTimeMs)
|
| assert.Equal(t, uint64(0), sent3)
|
| assert.Equal(t, uint64(0), recv3)
|
| }
|
|
|
| func TestContainerStatsEndToEndWithRealData(t *testing.T) {
|
|
|
| data, err := os.ReadFile("test-data/container.json")
|
| require.NoError(t, err)
|
|
|
| var apiStats container.ApiStats
|
| err = json.Unmarshal(data, &apiStats)
|
| require.NoError(t, err)
|
|
|
|
|
| dm := &dockerManager{
|
| lastCpuContainer: make(map[uint16]map[string]uint64),
|
| lastCpuSystem: make(map[uint16]map[string]uint64),
|
| lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
| containerStatsMap: make(map[string]*container.Stats),
|
| }
|
|
|
|
|
| cacheTimeMs := uint16(30000)
|
| dm.initializeCpuTracking(cacheTimeMs)
|
|
|
|
|
| ctr := &container.ApiInfo{
|
| IdShort: "abc123",
|
| }
|
|
|
|
|
| stats := &container.Stats{Name: "jellyfin"}
|
| dm.containerStatsMap[ctr.IdShort] = stats
|
|
|
|
|
| usedMemory, memErr := calculateMemoryUsage(&apiStats, false)
|
| assert.NoError(t, memErr)
|
| assert.Greater(t, usedMemory, uint64(0))
|
|
|
|
|
| cpuPct := 85.5
|
| err = validateCpuPercentage(cpuPct, "jellyfin")
|
| assert.NoError(t, err)
|
|
|
| err = validateCpuPercentage(150.0, "jellyfin")
|
| assert.Error(t, err)
|
|
|
|
|
| testStats := &container.Stats{}
|
| testTime := time.Now()
|
| updateContainerStatsValues(testStats, cpuPct, usedMemory, 1000000, 500000, testTime)
|
|
|
| assert.Equal(t, cpuPct, testStats.Cpu)
|
| assert.Equal(t, utils.BytesToMegabytes(float64(usedMemory)), testStats.Mem)
|
| assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth)
|
|
|
| assert.Equal(t, utils.BytesToMegabytes(1000000), testStats.NetworkSent)
|
| assert.Equal(t, utils.BytesToMegabytes(500000), testStats.NetworkRecv)
|
| assert.Equal(t, testTime, testStats.PrevReadTime)
|
| }
|
|
|
| func TestGetLogsDetectsMultiplexedWithoutContentType(t *testing.T) {
|
|
|
| frame := []byte{
|
| 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
| 'H', 'e', 'l', 'l', 'o',
|
| }
|
| rt := &recordingRoundTripper{
|
| statusCode: 200,
|
| body: string(frame),
|
|
|
| }
|
| dm := &dockerManager{
|
| client: &http.Client{Transport: rt},
|
| }
|
|
|
| logs, err := dm.getLogs(context.Background(), "abcdef123456")
|
| require.NoError(t, err)
|
| assert.Equal(t, "Hello", logs)
|
| }
|
|
|
| func TestGetLogsDoesNotMisclassifyRawStreamAsMultiplexed(t *testing.T) {
|
|
|
| raw := []byte{0x01, 0x02, 0x03, 0x04, 'r', 'a', 'w'}
|
| rt := &recordingRoundTripper{
|
| statusCode: 200,
|
| body: string(raw),
|
| }
|
| dm := &dockerManager{
|
| client: &http.Client{Transport: rt},
|
| }
|
|
|
| logs, err := dm.getLogs(context.Background(), "abcdef123456")
|
| require.NoError(t, err)
|
| assert.Equal(t, raw, []byte(logs))
|
| }
|
|
|
| func TestEdgeCasesWithRealData(t *testing.T) {
|
|
|
| minimalStats := &container.ApiStats{
|
| CPUStats: container.CPUStats{
|
| CPUUsage: container.CPUUsage{TotalUsage: 1000},
|
| SystemUsage: 50000,
|
| },
|
| MemoryStats: container.MemoryStats{
|
| Usage: 1000000,
|
| Stats: container.MemoryStatsStats{
|
| Cache: 0,
|
| InactiveFile: 0,
|
| },
|
| },
|
| Networks: map[string]container.NetworkStats{
|
| "eth0": {TxBytes: 1000, RxBytes: 500},
|
| },
|
| }
|
|
|
|
|
| usedMemory, err := calculateMemoryUsage(minimalStats, false)
|
| assert.NoError(t, err)
|
| assert.Equal(t, uint64(1000000), usedMemory)
|
|
|
|
|
| cpuPct := minimalStats.CalculateCpuPercentLinux(0, 0)
|
| assert.Equal(t, 0.0, cpuPct)
|
|
|
|
|
| minimalStats.MemoryStats.PrivateWorkingSet = 800000
|
| usedMemory, err = calculateMemoryUsage(minimalStats, true)
|
| assert.NoError(t, err)
|
| assert.Equal(t, uint64(800000), usedMemory)
|
| }
|
|
|
| func TestDockerStatsWorkflow(t *testing.T) {
|
|
|
| dm := &dockerManager{
|
| lastCpuContainer: make(map[uint16]map[string]uint64),
|
| lastCpuSystem: make(map[uint16]map[string]uint64),
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
| containerStatsMap: make(map[string]*container.Stats),
|
| }
|
|
|
| cacheTimeMs := uint16(30000)
|
|
|
|
|
| dm.initializeCpuTracking(cacheTimeMs)
|
| assert.NotNil(t, dm.lastCpuContainer[cacheTimeMs])
|
|
|
|
|
| dm.setCpuCurrentValues(cacheTimeMs, "test-container", 1000, 50000)
|
| containerVal, systemVal := dm.getCpuPreviousValues(cacheTimeMs, "test-container")
|
| assert.Equal(t, uint64(1000), containerVal)
|
| assert.Equal(t, uint64(50000), systemVal)
|
|
|
|
|
| sentTracker := dm.getNetworkTracker(cacheTimeMs, true)
|
| recvTracker := dm.getNetworkTracker(cacheTimeMs, false)
|
|
|
|
|
| sentTracker.Set("test-container", 1000+2000)
|
| recvTracker.Set("test-container", 500+700)
|
|
|
| deltaSent := sentTracker.Delta("test-container")
|
| deltaRecv := recvTracker.Delta("test-container")
|
| assert.Equal(t, uint64(0), deltaSent)
|
| assert.Equal(t, uint64(0), deltaRecv)
|
|
|
|
|
| dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
|
|
|
|
| sentTracker.Set("test-container", (1000+2000)+1500)
|
| recvTracker.Set("test-container", (500+700)+800)
|
|
|
| deltaSent = sentTracker.Delta("test-container")
|
| deltaRecv = recvTracker.Delta("test-container")
|
| assert.Equal(t, uint64(1500), deltaSent)
|
| assert.Equal(t, uint64(800), deltaRecv)
|
| }
|
|
|
| func TestNetworkRateCalculationFormula(t *testing.T) {
|
|
|
| testCases := []struct {
|
| name string
|
| deltaBytes uint64
|
| elapsedMs uint64
|
| expectedRate uint64
|
| }{
|
| {"1MB over 1 second", 1000000, 1000, 1000000},
|
| {"2MB over 1 second", 2000000, 1000, 2000000},
|
| {"1MB over 2 seconds", 1000000, 2000, 500000},
|
| {"500KB over 500ms", 500000, 500, 1000000},
|
| }
|
|
|
| for _, tc := range testCases {
|
| t.Run(tc.name, func(t *testing.T) {
|
|
|
| actualRate := tc.deltaBytes * 1000 / tc.elapsedMs
|
| assert.Equal(t, tc.expectedRate, actualRate,
|
| "Rate calculation should be exact: %d bytes * 1000 / %d ms = %d",
|
| tc.deltaBytes, tc.elapsedMs, tc.expectedRate)
|
| })
|
| }
|
| }
|
|
|
| func TestGetHostInfo(t *testing.T) {
|
| data, err := os.ReadFile("test-data/system_info.json")
|
| require.NoError(t, err)
|
|
|
| var info container.HostInfo
|
| err = json.Unmarshal(data, &info)
|
| require.NoError(t, err)
|
|
|
| assert.Equal(t, "6.8.0-31-generic", info.KernelVersion)
|
| assert.Equal(t, "Ubuntu 24.04 LTS", info.OperatingSystem)
|
|
|
|
|
|
|
| assert.EqualValues(t, 4, info.NCPU)
|
| assert.EqualValues(t, 2095882240, info.MemTotal)
|
|
|
| }
|
|
|
| func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
|
|
| dm := &dockerManager{
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| }
|
|
|
| ctr := &container.ApiInfo{IdShort: "web-server"}
|
| cacheTime1 := uint16(30000)
|
| cacheTime2 := uint16(60000)
|
|
|
|
|
| sentTracker1 := dm.getNetworkTracker(cacheTime1, true)
|
| recvTracker1 := dm.getNetworkTracker(cacheTime1, false)
|
|
|
| sentTracker2 := dm.getNetworkTracker(cacheTime2, true)
|
| recvTracker2 := dm.getNetworkTracker(cacheTime2, false)
|
|
|
|
|
| assert.NotSame(t, sentTracker1, sentTracker2)
|
| assert.NotSame(t, recvTracker1, recvTracker2)
|
|
|
|
|
| sentTracker1.Set(ctr.IdShort, 1000000)
|
| recvTracker1.Set(ctr.IdShort, 500000)
|
|
|
|
|
| sentTracker2.Set(ctr.IdShort, 2000000)
|
| recvTracker2.Set(ctr.IdShort, 1000000)
|
|
|
|
|
| assert.Equal(t, uint64(0), sentTracker1.Delta(ctr.IdShort))
|
| assert.Equal(t, uint64(0), recvTracker1.Delta(ctr.IdShort))
|
| assert.Equal(t, uint64(0), sentTracker2.Delta(ctr.IdShort))
|
| assert.Equal(t, uint64(0), recvTracker2.Delta(ctr.IdShort))
|
|
|
|
|
| dm.cycleNetworkDeltasForCacheTime(cacheTime1)
|
|
|
|
|
| sentTracker1.Set(ctr.IdShort, 3000000)
|
| recvTracker1.Set(ctr.IdShort, 1500000)
|
|
|
|
|
| assert.Equal(t, uint64(2000000), sentTracker1.Delta(ctr.IdShort))
|
| assert.Equal(t, uint64(1000000), recvTracker1.Delta(ctr.IdShort))
|
| assert.Equal(t, uint64(0), sentTracker2.Delta(ctr.IdShort))
|
| assert.Equal(t, uint64(0), recvTracker2.Delta(ctr.IdShort))
|
|
|
|
|
| dm.cycleNetworkDeltasForCacheTime(cacheTime2)
|
| sentTracker2.Set(ctr.IdShort, 2500000)
|
| recvTracker2.Set(ctr.IdShort, 1200000)
|
|
|
| assert.Equal(t, uint64(500000), sentTracker2.Delta(ctr.IdShort))
|
| assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort))
|
| }
|
|
|
| func TestParseDockerStatus(t *testing.T) {
|
| tests := []struct {
|
| name string
|
| input string
|
| expectedStatus string
|
| expectedHealth container.DockerHealth
|
| }{
|
| {
|
| name: "status with About an removed",
|
| input: "Up About an hour (healthy)",
|
| expectedStatus: "Up an hour",
|
| expectedHealth: container.DockerHealthHealthy,
|
| },
|
| {
|
| name: "status without About an unchanged",
|
| input: "Up 2 hours (healthy)",
|
| expectedStatus: "Up 2 hours",
|
| expectedHealth: container.DockerHealthHealthy,
|
| },
|
| {
|
| name: "status with About and no parentheses",
|
| input: "Up About an hour",
|
| expectedStatus: "Up an hour",
|
| expectedHealth: container.DockerHealthNone,
|
| },
|
| {
|
| name: "status without parentheses",
|
| input: "Created",
|
| expectedStatus: "Created",
|
| expectedHealth: container.DockerHealthNone,
|
| },
|
| {
|
| name: "empty status",
|
| input: "",
|
| expectedStatus: "",
|
| expectedHealth: container.DockerHealthNone,
|
| },
|
| {
|
| name: "status health with health: prefix",
|
| input: "Up 5 minutes (health: starting)",
|
| expectedStatus: "Up 5 minutes",
|
| expectedHealth: container.DockerHealthStarting,
|
| },
|
| {
|
| name: "status health with health status: prefix",
|
| input: "Up 10 minutes (health status: unhealthy)",
|
| expectedStatus: "Up 10 minutes",
|
| expectedHealth: container.DockerHealthUnhealthy,
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| status, health := parseDockerStatus(tt.input)
|
| assert.Equal(t, tt.expectedStatus, status)
|
| assert.Equal(t, tt.expectedHealth, health)
|
| })
|
| }
|
| }
|
|
|
| func TestParseDockerHealthStatus(t *testing.T) {
|
| tests := []struct {
|
| input string
|
| expectedHealth container.DockerHealth
|
| expectedOk bool
|
| }{
|
| {"healthy", container.DockerHealthHealthy, true},
|
| {"unhealthy", container.DockerHealthUnhealthy, true},
|
| {"starting", container.DockerHealthStarting, true},
|
| {"none", container.DockerHealthNone, true},
|
| {" Healthy ", container.DockerHealthHealthy, true},
|
| {"unknown", container.DockerHealthNone, false},
|
| {"", container.DockerHealthNone, false},
|
| }
|
| for _, tt := range tests {
|
| t.Run(tt.input, func(t *testing.T) {
|
| health, ok := parseDockerHealthStatus(tt.input)
|
| assert.Equal(t, tt.expectedHealth, health)
|
| assert.Equal(t, tt.expectedOk, ok)
|
| })
|
| }
|
| }
|
|
|
| func TestUpdateContainerStatsUsesPodmanInspectHealthFallback(t *testing.T) {
|
| var requestedPaths []string
|
| dm := &dockerManager{
|
| client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
| requestedPaths = append(requestedPaths, req.URL.EscapedPath())
|
| switch req.URL.EscapedPath() {
|
| case "/containers/0123456789ab/stats":
|
| return &http.Response{
|
| StatusCode: http.StatusOK,
|
| Status: "200 OK",
|
| Header: make(http.Header),
|
| Body: io.NopCloser(strings.NewReader(`{
|
| "read":"2026-03-15T21:26:59Z",
|
| "cpu_stats":{"cpu_usage":{"total_usage":1000},"system_cpu_usage":2000},
|
| "memory_stats":{"usage":1048576,"stats":{"inactive_file":262144}},
|
| "networks":{"eth0":{"rx_bytes":0,"tx_bytes":0}}
|
| }`)),
|
| Request: req,
|
| }, nil
|
| case "/containers/0123456789ab/json":
|
| return &http.Response{
|
| StatusCode: http.StatusOK,
|
| Status: "200 OK",
|
| Header: make(http.Header),
|
| Body: io.NopCloser(strings.NewReader(`{"State":{"Health":{"Status":"healthy"}}}`)),
|
| Request: req,
|
| }, nil
|
| default:
|
| return nil, fmt.Errorf("unexpected path: %s", req.URL.EscapedPath())
|
| }
|
| })},
|
| containerStatsMap: make(map[string]*container.Stats),
|
| apiStats: &container.ApiStats{},
|
| usingPodman: true,
|
| lastCpuContainer: make(map[uint16]map[string]uint64),
|
| lastCpuSystem: make(map[uint16]map[string]uint64),
|
| lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
| networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
| lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
| }
|
|
|
| ctr := &container.ApiInfo{
|
| IdShort: "0123456789ab",
|
| Names: []string{"/beszel"},
|
| Status: "Up 2 minutes",
|
| Image: "beszel:latest",
|
| }
|
|
|
| err := dm.updateContainerStats(ctr, defaultCacheTimeMs)
|
| require.NoError(t, err)
|
| assert.Equal(t, []string{"/containers/0123456789ab/stats", "/containers/0123456789ab/json"}, requestedPaths)
|
| assert.Equal(t, container.DockerHealthHealthy, dm.containerStatsMap[ctr.IdShort].Health)
|
| assert.Equal(t, "Up 2 minutes", dm.containerStatsMap[ctr.IdShort].Status)
|
| }
|
|
|
| func TestConstantsAndUtilityFunctions(t *testing.T) {
|
|
|
| assert.Equal(t, uint16(60000), defaultCacheTimeMs)
|
| assert.Equal(t, uint64(5e9), maxNetworkSpeedBps)
|
| assert.Equal(t, 2100, dockerTimeoutMs)
|
| assert.Equal(t, uint32(1024*1024), uint32(maxLogFrameSize))
|
| assert.Equal(t, 5*1024*1024, maxTotalLogSize)
|
|
|
|
|
| assert.Equal(t, 1.5, utils.TwoDecimals(1.499))
|
| assert.Equal(t, 1.5, utils.TwoDecimals(1.5))
|
| assert.Equal(t, 1.5, utils.TwoDecimals(1.501))
|
|
|
| assert.Equal(t, 1.0, utils.BytesToMegabytes(1048576))
|
| assert.Equal(t, 0.5, utils.BytesToMegabytes(524288))
|
| assert.Equal(t, 0.0, utils.BytesToMegabytes(0))
|
| }
|
|
|
| func TestDecodeDockerLogStream(t *testing.T) {
|
| tests := []struct {
|
| name string
|
| input []byte
|
| expected string
|
| expectError bool
|
| multiplexed bool
|
| }{
|
| {
|
| name: "simple log entry",
|
| input: []byte{
|
|
|
| 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B,
|
| 'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd',
|
| },
|
| expected: "Hello World",
|
| expectError: false,
|
| multiplexed: true,
|
| },
|
| {
|
| name: "multiple frames",
|
| input: []byte{
|
|
|
| 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
| 'H', 'e', 'l', 'l', 'o',
|
|
|
| 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
| 'W', 'o', 'r', 'l', 'd',
|
| },
|
| expected: "HelloWorld",
|
| expectError: false,
|
| multiplexed: true,
|
| },
|
| {
|
| name: "zero length frame",
|
| input: []byte{
|
|
|
| 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
|
| 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
| 'H', 'e', 'l', 'l', 'o',
|
| },
|
| expected: "Hello",
|
| expectError: false,
|
| multiplexed: true,
|
| },
|
| {
|
| name: "empty input",
|
| input: []byte{},
|
| expected: "",
|
| expectError: false,
|
| multiplexed: true,
|
| },
|
| {
|
| name: "raw stream (not multiplexed)",
|
| input: []byte("raw log content"),
|
| expected: "raw log content",
|
| multiplexed: false,
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| reader := bytes.NewReader(tt.input)
|
| var builder strings.Builder
|
| err := decodeDockerLogStream(reader, &builder, tt.multiplexed)
|
|
|
| if tt.expectError {
|
| assert.Error(t, err)
|
| } else {
|
| assert.NoError(t, err)
|
| assert.Equal(t, tt.expected, builder.String())
|
| }
|
| })
|
| }
|
| }
|
|
|
| func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
|
| t.Run("excessively large frame should error", func(t *testing.T) {
|
|
|
| excessiveSize := uint32(maxLogFrameSize + 1)
|
| input := []byte{
|
|
|
| 0x01, 0x00, 0x00, 0x00,
|
| byte(excessiveSize >> 24), byte(excessiveSize >> 16), byte(excessiveSize >> 8), byte(excessiveSize),
|
| }
|
|
|
| reader := bytes.NewReader(input)
|
| var builder strings.Builder
|
| err := decodeDockerLogStream(reader, &builder, true)
|
|
|
| assert.Error(t, err)
|
| assert.Contains(t, err.Error(), "log frame size")
|
| assert.Contains(t, err.Error(), "exceeds maximum")
|
| })
|
|
|
| t.Run("total size limit should truncate", func(t *testing.T) {
|
|
|
|
|
| frameSize := uint32(800 * 1024)
|
| var input []byte
|
|
|
|
|
| for i := 0; i < 6; i++ {
|
| char := byte('A' + i)
|
| frameHeader := []byte{
|
| 0x01, 0x00, 0x00, 0x00,
|
| byte(frameSize >> 24), byte(frameSize >> 16), byte(frameSize >> 8), byte(frameSize),
|
| }
|
| input = append(input, frameHeader...)
|
| input = append(input, bytes.Repeat([]byte{char}, int(frameSize))...)
|
| }
|
|
|
|
|
| frame7Header := []byte{
|
| 0x01, 0x00, 0x00, 0x00,
|
| byte(frameSize >> 24), byte(frameSize >> 16), byte(frameSize >> 8), byte(frameSize),
|
| }
|
| input = append(input, frame7Header...)
|
| input = append(input, bytes.Repeat([]byte{'Z'}, int(frameSize))...)
|
|
|
| reader := bytes.NewReader(input)
|
| var builder strings.Builder
|
| err := decodeDockerLogStream(reader, &builder, true)
|
|
|
|
|
| assert.NoError(t, err)
|
|
|
| expectedSize := int(frameSize) * 6
|
| assert.Equal(t, expectedSize, builder.Len())
|
|
|
| result := builder.String()
|
| assert.Contains(t, result, "A")
|
| assert.Contains(t, result, "F")
|
| assert.NotContains(t, result, "Z")
|
| })
|
| }
|
|
|
| func TestShouldExcludeContainer(t *testing.T) {
|
| tests := []struct {
|
| name string
|
| containerName string
|
| patterns []string
|
| expected bool
|
| }{
|
| {
|
| name: "empty patterns excludes nothing",
|
| containerName: "any-container",
|
| patterns: []string{},
|
| expected: false,
|
| },
|
| {
|
| name: "exact match - excluded",
|
| containerName: "test-web",
|
| patterns: []string{"test-web", "test-api"},
|
| expected: true,
|
| },
|
| {
|
| name: "exact match - not excluded",
|
| containerName: "prod-web",
|
| patterns: []string{"test-web", "test-api"},
|
| expected: false,
|
| },
|
| {
|
| name: "wildcard prefix match - excluded",
|
| containerName: "test-web",
|
| patterns: []string{"test-*"},
|
| expected: true,
|
| },
|
| {
|
| name: "wildcard prefix match - not excluded",
|
| containerName: "prod-web",
|
| patterns: []string{"test-*"},
|
| expected: false,
|
| },
|
| {
|
| name: "wildcard suffix match - excluded",
|
| containerName: "myapp-staging",
|
| patterns: []string{"*-staging"},
|
| expected: true,
|
| },
|
| {
|
| name: "wildcard suffix match - not excluded",
|
| containerName: "myapp-prod",
|
| patterns: []string{"*-staging"},
|
| expected: false,
|
| },
|
| {
|
| name: "wildcard both sides match - excluded",
|
| containerName: "test-myapp-staging",
|
| patterns: []string{"*-myapp-*"},
|
| expected: true,
|
| },
|
| {
|
| name: "wildcard both sides match - not excluded",
|
| containerName: "prod-yourapp-live",
|
| patterns: []string{"*-myapp-*"},
|
| expected: false,
|
| },
|
| {
|
| name: "multiple patterns - matches first",
|
| containerName: "test-container",
|
| patterns: []string{"test-*", "*-staging"},
|
| expected: true,
|
| },
|
| {
|
| name: "multiple patterns - matches second",
|
| containerName: "myapp-staging",
|
| patterns: []string{"test-*", "*-staging"},
|
| expected: true,
|
| },
|
| {
|
| name: "multiple patterns - no match",
|
| containerName: "prod-web",
|
| patterns: []string{"test-*", "*-staging"},
|
| expected: false,
|
| },
|
| {
|
| name: "mixed exact and wildcard - exact match",
|
| containerName: "temp-container",
|
| patterns: []string{"temp-container", "test-*"},
|
| expected: true,
|
| },
|
| {
|
| name: "mixed exact and wildcard - wildcard match",
|
| containerName: "test-web",
|
| patterns: []string{"temp-container", "test-*"},
|
| expected: true,
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| dm := &dockerManager{
|
| excludeContainers: tt.patterns,
|
| }
|
| result := dm.shouldExcludeContainer(tt.containerName)
|
| assert.Equal(t, tt.expected, result)
|
| })
|
| }
|
| }
|
|
|
| func TestAnsiEscapePattern(t *testing.T) {
|
| tests := []struct {
|
| name string
|
| input string
|
| expected string
|
| }{
|
| {
|
| name: "no ANSI codes",
|
| input: "Hello, World!",
|
| expected: "Hello, World!",
|
| },
|
| {
|
| name: "simple color code",
|
| input: "\x1b[34mINFO\x1b[0m client mode",
|
| expected: "INFO client mode",
|
| },
|
| {
|
| name: "multiple color codes",
|
| input: "\x1b[31mERROR\x1b[0m: \x1b[33mWarning\x1b[0m message",
|
| expected: "ERROR: Warning message",
|
| },
|
| {
|
| name: "bold and color",
|
| input: "\x1b[1;32mSUCCESS\x1b[0m",
|
| expected: "SUCCESS",
|
| },
|
| {
|
| name: "cursor movement codes",
|
| input: "Line 1\x1b[KLine 2",
|
| expected: "Line 1Line 2",
|
| },
|
| {
|
| name: "256 color code",
|
| input: "\x1b[38;5;196mRed text\x1b[0m",
|
| expected: "Red text",
|
| },
|
| {
|
| name: "RGB/truecolor code",
|
| input: "\x1b[38;2;255;0;0mRed text\x1b[0m",
|
| expected: "Red text",
|
| },
|
| {
|
| name: "mixed content with newlines",
|
| input: "\x1b[34m2024-01-01 12:00:00\x1b[0m INFO Starting\n\x1b[31m2024-01-01 12:00:01\x1b[0m ERROR Failed",
|
| expected: "2024-01-01 12:00:00 INFO Starting\n2024-01-01 12:00:01 ERROR Failed",
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| result := ansiEscapePattern.ReplaceAllString(tt.input, "")
|
| assert.Equal(t, tt.expected, result)
|
| })
|
| }
|
| }
|
|
|
| func TestConvertContainerPortsToString(t *testing.T) {
|
| type port = struct {
|
| PublicPort uint16
|
| IP string
|
| }
|
| tests := []struct {
|
| name string
|
| ports []port
|
| expected string
|
| }{
|
| {
|
| name: "empty ports",
|
| ports: nil,
|
| expected: "",
|
| },
|
| {
|
| name: "single port",
|
| ports: []port{
|
| {PublicPort: 80, IP: "0.0.0.0"},
|
| },
|
| expected: "80",
|
| },
|
| {
|
| name: "single port with non-default IP",
|
| ports: []port{
|
| {PublicPort: 80, IP: "1.2.3.4"},
|
| },
|
| expected: "1.2.3.4:80",
|
| },
|
| {
|
| name: "ipv6 default ip",
|
| ports: []port{
|
| {PublicPort: 80, IP: "::"},
|
| },
|
| expected: "80",
|
| },
|
| {
|
| name: "zero PublicPort is skipped",
|
| ports: []port{
|
| {PublicPort: 0, IP: "0.0.0.0"},
|
| {PublicPort: 80, IP: "0.0.0.0"},
|
| },
|
| expected: "80",
|
| },
|
| {
|
| name: "ports sorted ascending by PublicPort",
|
| ports: []port{
|
| {PublicPort: 443, IP: "0.0.0.0"},
|
| {PublicPort: 80, IP: "0.0.0.0"},
|
| {PublicPort: 8080, IP: "0.0.0.0"},
|
| },
|
| expected: "80, 443, 8080",
|
| },
|
| {
|
| name: "duplicates are deduplicated",
|
| ports: []port{
|
| {PublicPort: 80, IP: "0.0.0.0"},
|
| {PublicPort: 80, IP: "0.0.0.0"},
|
| {PublicPort: 443, IP: "0.0.0.0"},
|
| },
|
| expected: "80, 443",
|
| },
|
| {
|
| name: "multiple ports with different IPs",
|
| ports: []port{
|
| {PublicPort: 80, IP: "0.0.0.0"},
|
| {PublicPort: 443, IP: "1.2.3.4"},
|
| },
|
| expected: "80, 1.2.3.4:443",
|
| },
|
| {
|
| name: "ports slice is nilled after call",
|
| ports: []port{
|
| {PublicPort: 8080, IP: "0.0.0.0"},
|
| },
|
| expected: "8080",
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| ctr := &container.ApiInfo{}
|
| for _, p := range tt.ports {
|
| ctr.Ports = append(ctr.Ports, struct {
|
| PublicPort uint16
|
| IP string
|
| }{PublicPort: p.PublicPort, IP: p.IP})
|
| }
|
| result := convertContainerPortsToString(ctr)
|
| assert.Equal(t, tt.expected, result)
|
|
|
| assert.Nil(t, ctr.Ports, "ctr.Ports should be nil after formatContainerPorts")
|
| })
|
| }
|
| }
|
|
|