chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
type ContainerStats struct {
|
||||
Running int `json:"running"`
|
||||
Stopped int `json:"stopped"`
|
||||
Healthy int `json:"healthy"`
|
||||
Unhealthy int `json:"unhealthy"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type DockerClient interface {
|
||||
ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)
|
||||
}
|
||||
|
||||
func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool, containers []container.Summary) (ContainerStats, error) {
|
||||
if isSwarm {
|
||||
return CalculateContainerStatsForSwarm(containers), nil
|
||||
}
|
||||
|
||||
var running, stopped, healthy, unhealthy int
|
||||
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 5)
|
||||
|
||||
var aggErr error
|
||||
var aggMu sync.Mutex
|
||||
|
||||
var processedCount int
|
||||
for i := range containers {
|
||||
id := containers[i].ID
|
||||
|
||||
semaphore <- struct{}{}
|
||||
wg.Go(func() {
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
containerInspection, err := cli.ContainerInspect(ctx, id)
|
||||
stat := ContainerStats{}
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
// An edge case is reported that Docker can list containers with no names,
|
||||
// but when inspecting a container with specific ID and it is not found.
|
||||
// In this case, we can safely ignore the error.
|
||||
// ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer
|
||||
return
|
||||
}
|
||||
|
||||
aggMu.Lock()
|
||||
aggErr = errors.Join(aggErr, err)
|
||||
processedCount++
|
||||
aggMu.Unlock()
|
||||
return
|
||||
}
|
||||
stat = getContainerStatus(containerInspection.State)
|
||||
|
||||
mu.Lock()
|
||||
running += stat.Running
|
||||
stopped += stat.Stopped
|
||||
healthy += stat.Healthy
|
||||
unhealthy += stat.Unhealthy
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return ContainerStats{
|
||||
Running: running,
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: processedCount,
|
||||
}, aggErr
|
||||
}
|
||||
|
||||
func getContainerStatus(state *container.State) ContainerStats {
|
||||
stat := ContainerStats{}
|
||||
if state == nil {
|
||||
return stat
|
||||
}
|
||||
|
||||
switch state.Status {
|
||||
case container.StateRunning:
|
||||
stat.Running++
|
||||
case container.StateExited, container.StateDead:
|
||||
stat.Stopped++
|
||||
}
|
||||
|
||||
if state.Health != nil {
|
||||
switch state.Health.Status {
|
||||
case container.Healthy:
|
||||
stat.Healthy++
|
||||
case container.Unhealthy:
|
||||
stat.Unhealthy++
|
||||
}
|
||||
}
|
||||
|
||||
return stat
|
||||
}
|
||||
|
||||
// This is a temporary workaround to calculate container stats for Swarm
|
||||
// TODO: Remove this once we have a proper way to calculate container stats for Swarm
|
||||
func CalculateContainerStatsForSwarm(containers []container.Summary) ContainerStats {
|
||||
var running, stopped, healthy, unhealthy int
|
||||
for _, container := range containers {
|
||||
switch container.State {
|
||||
case "running":
|
||||
running++
|
||||
case "exited", "stopped":
|
||||
stopped++
|
||||
}
|
||||
|
||||
if strings.Contains(container.Status, "(healthy)") {
|
||||
healthy++
|
||||
} else if strings.Contains(container.Status, "(unhealthy)") {
|
||||
unhealthy++
|
||||
}
|
||||
}
|
||||
|
||||
return ContainerStats{
|
||||
Running: running,
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: len(containers),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockDockerClient implements the DockerClient interface for testing
|
||||
type MockDockerClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockDockerClient) ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) {
|
||||
args := m.Called(ctx, containerID)
|
||||
return args.Get(0).(container.InspectResponse), args.Error(1)
|
||||
}
|
||||
|
||||
func TestCalculateContainerStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockClient := new(MockDockerClient)
|
||||
|
||||
// Test containers - using enough containers to test concurrent processing
|
||||
containers := []container.Summary{
|
||||
{ID: "container1"},
|
||||
{ID: "container2"},
|
||||
{ID: "container3"},
|
||||
{ID: "container4"},
|
||||
{ID: "container5"},
|
||||
{ID: "container6"},
|
||||
{ID: "container7"},
|
||||
{ID: "container8"},
|
||||
{ID: "container9"},
|
||||
{ID: "container10"},
|
||||
{ID: "container11"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with different container states to test various scenarios
|
||||
containerStates := []struct {
|
||||
id string
|
||||
status string
|
||||
health *container.Health
|
||||
expected ContainerStats
|
||||
}{
|
||||
{"container1", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}},
|
||||
{"container2", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}},
|
||||
{"container3", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}},
|
||||
{"container4", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
{"container5", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
{"container6", container.StateRunning, &container.Health{Status: container.Healthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 1, Unhealthy: 0}},
|
||||
{"container7", container.StateRunning, &container.Health{Status: container.Unhealthy}, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 1}},
|
||||
{"container8", container.StateExited, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
{"container9", container.StateRunning, nil, ContainerStats{Running: 1, Stopped: 0, Healthy: 0, Unhealthy: 0}},
|
||||
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
}
|
||||
|
||||
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
|
||||
for _, state := range containerStates {
|
||||
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
|
||||
ContainerJSONBase: &container.ContainerJSONBase{
|
||||
State: &container.State{
|
||||
Status: state.status,
|
||||
Health: state.health,
|
||||
},
|
||||
},
|
||||
}, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call
|
||||
}
|
||||
|
||||
// Setup mock expectation for a container that returns NotFound error
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond)
|
||||
|
||||
// Call the function and measure time
|
||||
startTime := time.Now()
|
||||
stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers)
|
||||
require.NoError(t, err, "failed to calculate container stats")
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Assert results
|
||||
assert.Equal(t, 6, stats.Running)
|
||||
assert.Equal(t, 4, stats.Stopped)
|
||||
assert.Equal(t, 2, stats.Healthy)
|
||||
assert.Equal(t, 2, stats.Unhealthy)
|
||||
assert.Equal(t, 10, stats.Total)
|
||||
|
||||
// Verify concurrent processing by checking that all mock calls were made
|
||||
mockClient.AssertExpectations(t)
|
||||
|
||||
// Test concurrency: With 5 workers and 10 containers taking 50ms each:
|
||||
// Sequential would take: 10 * 50ms = 500ms
|
||||
sequentialTime := 10 * 50 * time.Millisecond
|
||||
|
||||
// Verify that concurrent processing is actually faster than sequential
|
||||
// Allow some overhead for goroutine scheduling
|
||||
assert.Less(t, duration, sequentialTime, "Concurrent processing should be faster than sequential")
|
||||
// Concurrent should take: ~100-150ms (depending on scheduling)
|
||||
assert.Less(t, duration, 150*time.Millisecond, "Concurrent processing should be significantly faster")
|
||||
assert.Greater(t, duration, 100*time.Millisecond, "Concurrent processing should be longer than 100ms")
|
||||
}
|
||||
|
||||
func TestCalculateContainerStatsAllErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockClient := new(MockDockerClient)
|
||||
|
||||
// Test containers
|
||||
containers := []container.Summary{
|
||||
{ID: "container1"},
|
||||
{ID: "container2"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with all calls returning errors
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container1").Return(container.InspectResponse{}, errors.New("network error"))
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container2").Return(container.InspectResponse{}, errors.New("permission denied"))
|
||||
|
||||
// Call the function
|
||||
stats, err := CalculateContainerStats(t.Context(), mockClient, false, containers)
|
||||
|
||||
// Assert that an error was returned
|
||||
require.Error(t, err, "should return error when all containers fail to inspect")
|
||||
assert.Contains(t, err.Error(), "network error", "error should contain one of the original error messages")
|
||||
assert.Contains(t, err.Error(), "permission denied", "error should contain the other original error message")
|
||||
|
||||
// Assert that stats are zero since no containers were successfully processed
|
||||
expectedStats := ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
Total: 2, // total containers processed
|
||||
}
|
||||
assert.Equal(t, expectedStats, stats)
|
||||
|
||||
// Verify all mock calls were made
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetContainerStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
state *container.State
|
||||
expected ContainerStats
|
||||
}{
|
||||
{
|
||||
name: "running healthy container",
|
||||
state: &container.State{
|
||||
Status: container.StateRunning,
|
||||
Health: &container.Health{
|
||||
Status: container.Healthy,
|
||||
},
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 1,
|
||||
Stopped: 0,
|
||||
Healthy: 1,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "running unhealthy container",
|
||||
state: &container.State{
|
||||
Status: container.StateRunning,
|
||||
Health: &container.Health{
|
||||
Status: container.Unhealthy,
|
||||
},
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 1,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "running container without health check",
|
||||
state: &container.State{
|
||||
Status: container.StateRunning,
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 1,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exited container",
|
||||
state: &container.State{
|
||||
Status: container.StateExited,
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 1,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dead container",
|
||||
state: &container.State{
|
||||
Status: container.StateDead,
|
||||
},
|
||||
expected: ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 1,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil state",
|
||||
state: nil,
|
||||
expected: ContainerStats{
|
||||
Running: 0,
|
||||
Stopped: 0,
|
||||
Healthy: 0,
|
||||
Unhealthy: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
stat := getContainerStatus(testCase.state)
|
||||
assert.Equal(t, testCase.expected, stat)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateContainerStatsForSwarm(t *testing.T) {
|
||||
t.Parallel()
|
||||
containers := []container.Summary{
|
||||
{State: "running"},
|
||||
{State: "running", Status: "Up 5 minutes (healthy)"},
|
||||
{State: "exited"},
|
||||
{State: "stopped"},
|
||||
{State: "running", Status: "Up 10 minutes"},
|
||||
{State: "running", Status: "Up about an hour (unhealthy)"},
|
||||
}
|
||||
|
||||
stats := CalculateContainerStatsForSwarm(containers)
|
||||
|
||||
assert.Equal(t, 4, stats.Running)
|
||||
assert.Equal(t, 2, stats.Stopped)
|
||||
assert.Equal(t, 1, stats.Healthy)
|
||||
assert.Equal(t, 1, stats.Unhealthy)
|
||||
assert.Equal(t, 6, stats.Total)
|
||||
}
|
||||
Reference in New Issue
Block a user