mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
67a8c4188b
Back-port v5 fix 48bdfdc30 to v6. In node scope, collectSwarmDataFromManager
now derives each service's Desired/Running/Completed task counts from the
node-local task list and drops services with no tasks on this node, instead
of reusing the cluster-wide ServiceStatus and a 'keep all when none match'
fallback. Without this, a node-scoped Swarm view showed wrong counts and
services that aren't running on that node. Adds two regression tests.
1030 lines
33 KiB
Go
1030 lines
33 KiB
Go
package dockeragent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
networktypes "github.com/moby/moby/api/types/network"
|
|
swarmtypes "github.com/moby/moby/api/types/swarm"
|
|
systemtypes "github.com/moby/moby/api/types/system"
|
|
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
|
|
)
|
|
|
|
func TestResolvedSwarmScope(t *testing.T) {
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
ControlAvailable: true,
|
|
},
|
|
}
|
|
|
|
agent := &Agent{cfg: Config{SwarmScope: swarmScopeAuto}}
|
|
if got := agent.resolvedSwarmScope(info); got != swarmScopeCluster {
|
|
t.Fatalf("expected cluster scope, got %q", got)
|
|
}
|
|
|
|
agent.cfg.SwarmScope = swarmScopeNode
|
|
if got := agent.resolvedSwarmScope(info); got != swarmScopeNode {
|
|
t.Fatalf("expected node scope, got %q", got)
|
|
}
|
|
|
|
info.Swarm.ControlAvailable = false
|
|
agent.cfg.SwarmScope = swarmScopeAuto
|
|
if got := agent.resolvedSwarmScope(info); got != swarmScopeNode {
|
|
t.Fatalf("expected node scope, got %q", got)
|
|
}
|
|
|
|
agent.cfg.SwarmScope = "unknown"
|
|
if got := agent.resolvedSwarmScope(info); got != swarmScopeNode {
|
|
t.Fatalf("expected fallback node scope, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMapSwarmService(t *testing.T) {
|
|
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
updateAt := now.Add(2 * time.Minute)
|
|
|
|
svc := &swarmtypes.Service{
|
|
ID: "svc1",
|
|
Spec: swarmtypes.ServiceSpec{
|
|
Annotations: swarmtypes.Annotations{
|
|
Name: "web",
|
|
Labels: map[string]string{
|
|
"com.docker.stack.namespace": "stack",
|
|
},
|
|
},
|
|
Mode: swarmtypes.ServiceMode{Replicated: &swarmtypes.ReplicatedService{}},
|
|
TaskTemplate: swarmtypes.TaskSpec{
|
|
ContainerSpec: &swarmtypes.ContainerSpec{
|
|
Image: "nginx:latest",
|
|
},
|
|
},
|
|
},
|
|
ServiceStatus: &swarmtypes.ServiceStatus{
|
|
DesiredTasks: 3,
|
|
RunningTasks: 2,
|
|
CompletedTasks: 1,
|
|
},
|
|
UpdateStatus: &swarmtypes.UpdateStatus{
|
|
State: swarmtypes.UpdateStateCompleted,
|
|
Message: "done",
|
|
CompletedAt: &updateAt,
|
|
},
|
|
Endpoint: swarmtypes.Endpoint{
|
|
Ports: []swarmtypes.PortConfig{
|
|
{Name: "http", Protocol: networktypes.TCP, TargetPort: 80, PublishedPort: 8080, PublishMode: swarmtypes.PortConfigPublishModeIngress},
|
|
},
|
|
},
|
|
Meta: swarmtypes.Meta{
|
|
CreatedAt: now,
|
|
UpdatedAt: updateAt,
|
|
},
|
|
}
|
|
|
|
got := mapSwarmService(svc)
|
|
if got.ID != "svc1" || got.Name != "web" || got.Mode == "" {
|
|
t.Fatalf("unexpected service mapping: %+v", got)
|
|
}
|
|
if got.Stack != "stack" {
|
|
t.Fatalf("expected stack label to be mapped, got %q", got.Stack)
|
|
}
|
|
if got.Image != "nginx:latest" {
|
|
t.Fatalf("expected image to be mapped, got %q", got.Image)
|
|
}
|
|
if got.DesiredTasks != 3 || got.RunningTasks != 2 || got.CompletedTasks != 1 {
|
|
t.Fatalf("unexpected task counts: %+v", got)
|
|
}
|
|
if got.UpdateStatus == nil || got.UpdateStatus.State != string(swarmtypes.UpdateStateCompleted) {
|
|
t.Fatalf("expected update status to be mapped, got %+v", got.UpdateStatus)
|
|
}
|
|
if got.EndpointPorts == nil || len(got.EndpointPorts) != 1 {
|
|
t.Fatalf("expected endpoint ports to be mapped")
|
|
}
|
|
if got.CreatedAt == nil || got.UpdatedAt == nil {
|
|
t.Fatalf("expected timestamps to be mapped")
|
|
}
|
|
}
|
|
|
|
func TestMapSwarmTask(t *testing.T) {
|
|
now := time.Date(2024, 2, 3, 4, 5, 6, 0, time.UTC)
|
|
containerStart := now.Add(-2 * time.Minute)
|
|
containerFinish := now.Add(-time.Minute)
|
|
|
|
containers := map[string]agentsdocker.Container{
|
|
"container-full": {
|
|
ID: "container-full",
|
|
Name: "web.1",
|
|
StartedAt: &containerStart,
|
|
FinishedAt: func() *time.Time {
|
|
val := containerFinish
|
|
return &val
|
|
}(),
|
|
},
|
|
}
|
|
|
|
t.Run("running task", func(t *testing.T) {
|
|
task := &swarmtypes.Task{
|
|
ID: "task1",
|
|
ServiceID: "svc1",
|
|
Slot: 1,
|
|
NodeID: "node1",
|
|
Status: swarmtypes.TaskStatus{
|
|
State: swarmtypes.TaskStateRunning,
|
|
Timestamp: now,
|
|
ContainerStatus: &swarmtypes.ContainerStatus{
|
|
ContainerID: "container-full",
|
|
},
|
|
},
|
|
Meta: swarmtypes.Meta{
|
|
CreatedAt: now.Add(-time.Minute),
|
|
},
|
|
}
|
|
svc := &swarmtypes.Service{Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "web"}}}
|
|
|
|
got := mapSwarmTask(task, svc, containers)
|
|
if got.ServiceName != "web" {
|
|
t.Fatalf("expected service name, got %q", got.ServiceName)
|
|
}
|
|
if got.ContainerName != "web.1" {
|
|
t.Fatalf("expected container name, got %q", got.ContainerName)
|
|
}
|
|
if got.StartedAt == nil || !got.StartedAt.Equal(now) {
|
|
t.Fatalf("expected started at timestamp from task")
|
|
}
|
|
})
|
|
|
|
t.Run("completed task uses container timestamps", func(t *testing.T) {
|
|
task := &swarmtypes.Task{
|
|
ID: "task2",
|
|
ServiceID: "svc2",
|
|
Status: swarmtypes.TaskStatus{
|
|
State: swarmtypes.TaskStateComplete,
|
|
ContainerStatus: &swarmtypes.ContainerStatus{
|
|
ContainerID: "container-full",
|
|
},
|
|
},
|
|
Meta: swarmtypes.Meta{
|
|
CreatedAt: now.Add(-time.Hour),
|
|
},
|
|
}
|
|
|
|
got := mapSwarmTask(task, nil, containers)
|
|
if got.CompletedAt == nil || !got.CompletedAt.Equal(containerFinish) {
|
|
t.Fatalf("expected completed at from container timestamp")
|
|
}
|
|
if got.StartedAt == nil || !got.StartedAt.Equal(containerStart) {
|
|
t.Fatalf("expected started at from container timestamp")
|
|
}
|
|
})
|
|
|
|
t.Run("updated at and completed timestamp", func(t *testing.T) {
|
|
updated := now.Add(time.Minute)
|
|
task := &swarmtypes.Task{
|
|
ID: "task3",
|
|
ServiceID: "svc3",
|
|
Status: swarmtypes.TaskStatus{
|
|
State: swarmtypes.TaskStateFailed,
|
|
Timestamp: now,
|
|
},
|
|
Meta: swarmtypes.Meta{
|
|
CreatedAt: now.Add(-time.Hour),
|
|
UpdatedAt: updated,
|
|
},
|
|
}
|
|
svc := &swarmtypes.Service{Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "api"}}}
|
|
|
|
got := mapSwarmTask(task, svc, nil)
|
|
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(updated) {
|
|
t.Fatalf("expected updated at to be set")
|
|
}
|
|
if got.CompletedAt == nil || !got.CompletedAt.Equal(now) {
|
|
t.Fatalf("expected completed at from task timestamp")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCollectSwarmDataFromManager(t *testing.T) {
|
|
agent := &Agent{
|
|
docker: &fakeDockerClient{
|
|
serviceListFn: func(_ context.Context, _ dockerServiceListOptions) ([]swarmtypes.Service, error) {
|
|
return []swarmtypes.Service{
|
|
{ID: "svc1", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "alpha"}}},
|
|
{ID: "svc2", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "beta"}}},
|
|
}, nil
|
|
},
|
|
taskListFn: func(_ context.Context, opts dockerTaskListOptions) ([]swarmtypes.Task, error) {
|
|
if got := opts.Filters.Get("node"); len(got) != 1 || got[0] != "node1" {
|
|
t.Fatalf("expected node filter to include node1, got %v", got)
|
|
}
|
|
if got := opts.Filters.Get("desired-state"); len(got) != 1 || got[0] != string(swarmtypes.TaskStateRunning) {
|
|
t.Fatalf("expected desired-state filter to include running, got %v", got)
|
|
}
|
|
return []swarmtypes.Task{
|
|
{ID: "task1", ServiceID: "svc1", DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}},
|
|
{ID: "task-old", ServiceID: "svc2", DesiredState: swarmtypes.TaskStateShutdown, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateComplete}},
|
|
}, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
NodeID: "node1",
|
|
},
|
|
}
|
|
|
|
services, tasks, err := agent.collectSwarmDataFromManager(context.Background(), info, swarmScopeNode, nil, true, true)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("expected 1 task, got %d", len(tasks))
|
|
}
|
|
if tasks[0].ID != "task1" {
|
|
t.Fatalf("expected running task only, got %#v", tasks)
|
|
}
|
|
if len(services) != 1 {
|
|
t.Fatalf("expected filtered services, got %d", len(services))
|
|
}
|
|
|
|
t.Run("task list error", func(t *testing.T) {
|
|
agent := &Agent{
|
|
docker: &fakeDockerClient{
|
|
serviceListFn: func(_ context.Context, _ dockerServiceListOptions) ([]swarmtypes.Service, error) {
|
|
return []swarmtypes.Service{
|
|
{ID: "svc1", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "alpha"}}},
|
|
}, nil
|
|
},
|
|
taskListFn: func(_ context.Context, _ dockerTaskListOptions) ([]swarmtypes.Task, error) {
|
|
return nil, errors.New("task failed")
|
|
},
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
NodeID: "node1",
|
|
},
|
|
}
|
|
|
|
services, tasks, err := agent.collectSwarmDataFromManager(context.Background(), info, swarmScopeNode, nil, true, true)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if len(services) != 1 || tasks != nil {
|
|
t.Fatalf("expected services only on task error")
|
|
}
|
|
})
|
|
|
|
t.Run("node scope uses local task counts for services", func(t *testing.T) {
|
|
agent := &Agent{
|
|
docker: &fakeDockerClient{
|
|
serviceListFn: func(_ context.Context, _ dockerServiceListOptions) ([]swarmtypes.Service, error) {
|
|
return []swarmtypes.Service{
|
|
{
|
|
ID: "svc1",
|
|
Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "alpha"}},
|
|
ServiceStatus: &swarmtypes.ServiceStatus{
|
|
DesiredTasks: 4,
|
|
RunningTasks: 3,
|
|
CompletedTasks: 1,
|
|
},
|
|
},
|
|
{
|
|
ID: "svc2",
|
|
Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "beta"}},
|
|
ServiceStatus: &swarmtypes.ServiceStatus{
|
|
DesiredTasks: 2,
|
|
RunningTasks: 2,
|
|
},
|
|
},
|
|
}, nil
|
|
},
|
|
taskListFn: func(_ context.Context, _ dockerTaskListOptions) ([]swarmtypes.Task, error) {
|
|
return []swarmtypes.Task{
|
|
{
|
|
ID: "task1",
|
|
ServiceID: "svc1",
|
|
DesiredState: swarmtypes.TaskStateRunning,
|
|
Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning},
|
|
},
|
|
{
|
|
ID: "task2",
|
|
ServiceID: "svc1",
|
|
DesiredState: swarmtypes.TaskStateRunning,
|
|
Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStatePreparing},
|
|
},
|
|
}, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{Swarm: swarmtypes.Info{NodeID: "node1"}}
|
|
|
|
services, tasks, err := agent.collectSwarmDataFromManager(context.Background(), info, swarmScopeNode, nil, true, true)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(tasks) != 2 {
|
|
t.Fatalf("expected 2 local tasks, got %d", len(tasks))
|
|
}
|
|
if len(services) != 1 {
|
|
t.Fatalf("expected only local services, got %d", len(services))
|
|
}
|
|
if services[0].ID != "svc1" {
|
|
t.Fatalf("expected local service svc1, got %+v", services[0])
|
|
}
|
|
if services[0].DesiredTasks != 2 || services[0].RunningTasks != 1 || services[0].CompletedTasks != 0 {
|
|
t.Fatalf("expected node-local task counts, got %+v", services[0])
|
|
}
|
|
})
|
|
|
|
t.Run("node scope drops services when no local runtime tasks remain", func(t *testing.T) {
|
|
agent := &Agent{
|
|
docker: &fakeDockerClient{
|
|
serviceListFn: func(_ context.Context, _ dockerServiceListOptions) ([]swarmtypes.Service, error) {
|
|
return []swarmtypes.Service{
|
|
{ID: "svc1", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "alpha"}}},
|
|
{ID: "svc2", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "beta"}}},
|
|
}, nil
|
|
},
|
|
taskListFn: func(_ context.Context, _ dockerTaskListOptions) ([]swarmtypes.Task, error) {
|
|
return []swarmtypes.Task{}, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{Swarm: swarmtypes.Info{NodeID: "node1"}}
|
|
|
|
services, tasks, err := agent.collectSwarmDataFromManager(context.Background(), info, swarmScopeNode, nil, true, true)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(tasks) != 0 {
|
|
t.Fatalf("expected no local tasks, got %d", len(tasks))
|
|
}
|
|
if len(services) != 0 {
|
|
t.Fatalf("expected no node-scoped services without local tasks, got %d", len(services))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMapSwarmNode(t *testing.T) {
|
|
createdAt := time.Date(2024, 3, 4, 5, 6, 7, 0, time.UTC)
|
|
updatedAt := createdAt.Add(5 * time.Minute)
|
|
node := &swarmtypes.Node{
|
|
ID: "node-1",
|
|
Spec: swarmtypes.NodeSpec{
|
|
Annotations: swarmtypes.Annotations{
|
|
Labels: map[string]string{"zone": "rack-a"},
|
|
},
|
|
Role: swarmtypes.NodeRoleManager,
|
|
Availability: swarmtypes.NodeAvailabilityActive,
|
|
},
|
|
Description: swarmtypes.NodeDescription{
|
|
Hostname: "manager-1",
|
|
Platform: swarmtypes.Platform{
|
|
OS: "linux",
|
|
Architecture: "amd64",
|
|
},
|
|
Resources: swarmtypes.Resources{
|
|
NanoCPUs: 4_000_000_000,
|
|
MemoryBytes: 16 * 1024 * 1024 * 1024,
|
|
},
|
|
Engine: swarmtypes.EngineDescription{
|
|
EngineVersion: "27.5.1",
|
|
Labels: map[string]string{"engine": "primary"},
|
|
},
|
|
},
|
|
Status: swarmtypes.NodeStatus{
|
|
State: swarmtypes.NodeStateReady,
|
|
Message: "ready",
|
|
Addr: "192.0.2.10",
|
|
},
|
|
ManagerStatus: &swarmtypes.ManagerStatus{
|
|
Leader: true,
|
|
Reachability: swarmtypes.ReachabilityReachable,
|
|
Addr: "192.0.2.10:2377",
|
|
},
|
|
Meta: swarmtypes.Meta{
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
},
|
|
}
|
|
|
|
got := mapSwarmNode(node)
|
|
if got.ID != "node-1" || got.Hostname != "manager-1" || got.Role != string(swarmtypes.NodeRoleManager) {
|
|
t.Fatalf("unexpected node identity: %+v", got)
|
|
}
|
|
if got.Availability != string(swarmtypes.NodeAvailabilityActive) || got.State != string(swarmtypes.NodeStateReady) {
|
|
t.Fatalf("unexpected node state: %+v", got)
|
|
}
|
|
if got.ManagerReachability != string(swarmtypes.ReachabilityReachable) || !got.Leader {
|
|
t.Fatalf("expected manager reachability and leader flag, got %+v", got)
|
|
}
|
|
if got.EngineVersion != "27.5.1" || got.NanoCPUs != 4_000_000_000 || got.MemoryBytes == 0 {
|
|
t.Fatalf("expected engine resources, got %+v", got)
|
|
}
|
|
if got.Labels["zone"] != "rack-a" || got.EngineLabels["engine"] != "primary" {
|
|
t.Fatalf("expected node labels to be mapped, got labels=%+v engine=%+v", got.Labels, got.EngineLabels)
|
|
}
|
|
if got.CreatedAt.IsZero() || got.UpdatedAt == nil || got.UpdatedAt.IsZero() {
|
|
t.Fatalf("expected node timestamps, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestMapSwarmSecretAndConfigOmitPayloadData(t *testing.T) {
|
|
createdAt := time.Date(2024, 3, 4, 5, 6, 7, 0, time.UTC)
|
|
updatedAt := createdAt.Add(5 * time.Minute)
|
|
|
|
secret := mapSwarmSecret(&swarmtypes.Secret{
|
|
ID: "secret-1",
|
|
Meta: swarmtypes.Meta{
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
},
|
|
Spec: swarmtypes.SecretSpec{
|
|
Annotations: swarmtypes.Annotations{
|
|
Name: "db-password",
|
|
Labels: map[string]string{"stack": "backend"},
|
|
},
|
|
Data: []byte("must-not-be-reported"),
|
|
Driver: &swarmtypes.Driver{Name: "vault", Options: map[string]string{"path": "secret/db"}},
|
|
Templating: &swarmtypes.Driver{Name: "golang"},
|
|
},
|
|
})
|
|
if secret.ID != "secret-1" || secret.Name != "db-password" || secret.DriverName != "vault" || secret.TemplatingDriver != "golang" {
|
|
t.Fatalf("unexpected mapped secret metadata: %+v", secret)
|
|
}
|
|
if secret.Labels["stack"] != "backend" || secret.CreatedAt.IsZero() || secret.UpdatedAt == nil {
|
|
t.Fatalf("expected secret labels and timestamps, got %+v", secret)
|
|
}
|
|
|
|
config := mapSwarmConfig(&swarmtypes.Config{
|
|
ID: "config-1",
|
|
Meta: swarmtypes.Meta{
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
},
|
|
Spec: swarmtypes.ConfigSpec{
|
|
Annotations: swarmtypes.Annotations{
|
|
Name: "nginx-conf",
|
|
Labels: map[string]string{"stack": "frontend"},
|
|
},
|
|
Data: []byte("must-not-be-reported"),
|
|
Templating: &swarmtypes.Driver{Name: "golang"},
|
|
},
|
|
})
|
|
if config.ID != "config-1" || config.Name != "nginx-conf" || config.TemplatingDriver != "golang" {
|
|
t.Fatalf("unexpected mapped config metadata: %+v", config)
|
|
}
|
|
if config.Labels["stack"] != "frontend" || config.CreatedAt.IsZero() || config.UpdatedAt == nil {
|
|
t.Fatalf("expected config labels and timestamps, got %+v", config)
|
|
}
|
|
}
|
|
|
|
func TestCollectSwarmData(t *testing.T) {
|
|
t.Run("unsupported swarm returns nils", func(t *testing.T) {
|
|
agent := &Agent{supportsSwarm: false}
|
|
services, tasks, nodes, secrets, configs, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil)
|
|
if services != nil || tasks != nil || nodes != nil || secrets != nil || configs != nil || info != nil {
|
|
t.Fatal("expected nil outputs when swarm unsupported")
|
|
}
|
|
})
|
|
|
|
t.Run("empty swarm info returns nil", func(t *testing.T) {
|
|
agent := &Agent{supportsSwarm: true}
|
|
services, tasks, nodes, secrets, configs, info := agent.collectSwarmData(context.Background(), systemtypes.Info{}, nil)
|
|
if services != nil || tasks != nil || nodes != nil || secrets != nil || configs != nil || info != nil {
|
|
t.Fatal("expected nil outputs for empty swarm info")
|
|
}
|
|
})
|
|
|
|
t.Run("standalone inactive swarm returns nil", func(t *testing.T) {
|
|
agent := &Agent{supportsSwarm: true, cfg: Config{SwarmScope: swarmScopeNode}}
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
LocalNodeState: swarmtypes.LocalNodeStateInactive,
|
|
},
|
|
}
|
|
|
|
services, tasks, nodes, secrets, configs, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
|
|
if services != nil || tasks != nil || nodes != nil || secrets != nil || configs != nil || swarmInfo != nil {
|
|
t.Fatal("expected standalone inactive swarm state to be ignored")
|
|
}
|
|
})
|
|
|
|
t.Run("pending swarm returns info only", func(t *testing.T) {
|
|
agent := &Agent{supportsSwarm: true, cfg: Config{SwarmScope: swarmScopeNode}}
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
NodeID: "node1",
|
|
LocalNodeState: swarmtypes.LocalNodeStatePending,
|
|
},
|
|
}
|
|
|
|
services, tasks, nodes, secrets, configs, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
|
|
if services != nil || tasks != nil || nodes != nil || secrets != nil || configs != nil {
|
|
t.Fatal("expected nil services/tasks for pending swarm")
|
|
}
|
|
if swarmInfo == nil || swarmInfo.NodeID != "node1" {
|
|
t.Fatal("expected swarm info to be returned")
|
|
}
|
|
})
|
|
|
|
t.Run("manager error falls back to containers", func(t *testing.T) {
|
|
agent := &Agent{
|
|
supportsSwarm: true,
|
|
cfg: Config{
|
|
IncludeServices: true,
|
|
IncludeTasks: true,
|
|
SwarmScope: swarmScopeAuto,
|
|
},
|
|
docker: &fakeDockerClient{
|
|
serviceListFn: func(context.Context, dockerServiceListOptions) ([]swarmtypes.Service, error) {
|
|
return nil, errors.New("boom")
|
|
},
|
|
},
|
|
}
|
|
|
|
containers := []agentsdocker.Container{
|
|
{
|
|
ID: "container1",
|
|
Name: "web.1",
|
|
Image: "nginx:latest",
|
|
State: "running",
|
|
Labels: map[string]string{
|
|
"com.docker.swarm.service.id": "svc1",
|
|
"com.docker.swarm.service.name": "web",
|
|
"com.docker.swarm.task.id": "task1",
|
|
"com.docker.swarm.task.slot": "1",
|
|
"com.docker.swarm.task.message": "ok",
|
|
"com.docker.swarm.task.error": "",
|
|
"com.docker.stack.namespace": "stack",
|
|
"com.docker.swarm.node.id": "node1",
|
|
"com.docker.swarm.node.name": "node1",
|
|
"com.docker.swarm.task.desired-state": "running",
|
|
},
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
NodeID: "node1",
|
|
ControlAvailable: true,
|
|
LocalNodeState: swarmtypes.LocalNodeStateActive,
|
|
},
|
|
}
|
|
|
|
services, tasks, nodes, secrets, configs, swarmInfo := agent.collectSwarmData(context.Background(), info, containers)
|
|
if len(tasks) != 1 || len(services) != 1 {
|
|
t.Fatalf("expected derived tasks/services, got %d/%d", len(tasks), len(services))
|
|
}
|
|
if len(secrets) != 0 || len(configs) != 0 {
|
|
t.Fatalf("expected no secrets/configs after manager collection error, got %+v/%+v", secrets, configs)
|
|
}
|
|
if len(nodes) != 1 || nodes[0].ID != "node1" {
|
|
t.Fatalf("expected derived local node, got %+v", nodes)
|
|
}
|
|
if swarmInfo == nil || swarmInfo.Scope != swarmScopeNode {
|
|
t.Fatalf("expected effective scope node, got %+v", swarmInfo)
|
|
}
|
|
})
|
|
|
|
t.Run("manager success uses manager data", func(t *testing.T) {
|
|
agent := &Agent{
|
|
supportsSwarm: true,
|
|
cfg: Config{
|
|
IncludeServices: true,
|
|
IncludeTasks: true,
|
|
SwarmScope: swarmScopeCluster,
|
|
},
|
|
docker: &fakeDockerClient{
|
|
nodeListFn: func(context.Context, dockerNodeListOptions) ([]swarmtypes.Node, error) {
|
|
return []swarmtypes.Node{{
|
|
ID: "node-manager",
|
|
Spec: swarmtypes.NodeSpec{
|
|
Role: swarmtypes.NodeRoleManager,
|
|
Availability: swarmtypes.NodeAvailabilityActive,
|
|
},
|
|
Description: swarmtypes.NodeDescription{
|
|
Hostname: "manager-1",
|
|
Engine: swarmtypes.EngineDescription{
|
|
EngineVersion: "27.5.1",
|
|
},
|
|
},
|
|
Status: swarmtypes.NodeStatus{State: swarmtypes.NodeStateReady},
|
|
ManagerStatus: &swarmtypes.ManagerStatus{
|
|
Leader: true,
|
|
Reachability: swarmtypes.ReachabilityReachable,
|
|
},
|
|
}}, nil
|
|
},
|
|
serviceListFn: func(context.Context, dockerServiceListOptions) ([]swarmtypes.Service, error) {
|
|
return []swarmtypes.Service{
|
|
{ID: "svc1", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "zeta"}}},
|
|
{ID: "svc2", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "alpha"}}},
|
|
}, nil
|
|
},
|
|
taskListFn: func(context.Context, dockerTaskListOptions) ([]swarmtypes.Task, error) {
|
|
return []swarmtypes.Task{
|
|
{ID: "task2", ServiceID: "svc2", Slot: 2, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}},
|
|
{ID: "task1", ServiceID: "svc2", Slot: 1, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}},
|
|
}, nil
|
|
},
|
|
secretListFn: func(context.Context, dockerSecretListOptions) ([]swarmtypes.Secret, error) {
|
|
return []swarmtypes.Secret{
|
|
{ID: "secret-z", Spec: swarmtypes.SecretSpec{Annotations: swarmtypes.Annotations{Name: "zeta-secret"}}},
|
|
{ID: "secret-a", Spec: swarmtypes.SecretSpec{Annotations: swarmtypes.Annotations{Name: "alpha-secret"}}},
|
|
}, nil
|
|
},
|
|
configListFn: func(context.Context, dockerConfigListOptions) ([]swarmtypes.Config, error) {
|
|
return []swarmtypes.Config{
|
|
{ID: "config-z", Spec: swarmtypes.ConfigSpec{Annotations: swarmtypes.Annotations{Name: "zeta-config"}}},
|
|
{ID: "config-a", Spec: swarmtypes.ConfigSpec{Annotations: swarmtypes.Annotations{Name: "alpha-config"}}},
|
|
}, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
NodeID: "node1",
|
|
ControlAvailable: true,
|
|
LocalNodeState: swarmtypes.LocalNodeStateActive,
|
|
},
|
|
}
|
|
|
|
services, tasks, nodes, secrets, configs, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
|
|
if len(tasks) != 2 || len(services) != 2 {
|
|
t.Fatalf("expected manager tasks/services, got %d/%d", len(tasks), len(services))
|
|
}
|
|
if len(secrets) != 2 || secrets[0].Name != "alpha-secret" {
|
|
t.Fatalf("expected sorted manager secret inventory, got %+v", secrets)
|
|
}
|
|
if len(configs) != 2 || configs[0].Name != "alpha-config" {
|
|
t.Fatalf("expected sorted manager config inventory, got %+v", configs)
|
|
}
|
|
if len(nodes) != 1 || nodes[0].ID != "node-manager" || nodes[0].Hostname != "manager-1" {
|
|
t.Fatalf("expected manager node inventory, got %+v", nodes)
|
|
}
|
|
if swarmInfo == nil || swarmInfo.Scope != swarmScopeCluster {
|
|
t.Fatalf("unexpected swarm info: %+v", swarmInfo)
|
|
}
|
|
})
|
|
|
|
t.Run("include flags prune outputs", func(t *testing.T) {
|
|
agent := &Agent{
|
|
supportsSwarm: true,
|
|
cfg: Config{
|
|
IncludeServices: false,
|
|
IncludeTasks: true,
|
|
SwarmScope: swarmScopeAuto,
|
|
},
|
|
}
|
|
|
|
containers := []agentsdocker.Container{
|
|
{
|
|
ID: "container1",
|
|
Name: "web.1",
|
|
Image: "nginx:latest",
|
|
State: "running",
|
|
Labels: map[string]string{
|
|
"com.docker.swarm.service.id": "svc1",
|
|
"com.docker.swarm.service.name": "web",
|
|
"com.docker.swarm.task.id": "task1",
|
|
},
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
NodeID: "node1",
|
|
ControlAvailable: false,
|
|
LocalNodeState: swarmtypes.LocalNodeStateActive,
|
|
},
|
|
}
|
|
|
|
services, tasks, nodes, _, _, swarmInfo := agent.collectSwarmData(context.Background(), info, containers)
|
|
if services != nil {
|
|
t.Fatal("expected services to be nil when disabled")
|
|
}
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("expected tasks to be returned")
|
|
}
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("expected local swarm node to be returned")
|
|
}
|
|
if swarmInfo == nil {
|
|
t.Fatalf("expected swarm info")
|
|
}
|
|
})
|
|
|
|
t.Run("cluster info populated", func(t *testing.T) {
|
|
agent := &Agent{
|
|
supportsSwarm: true,
|
|
cfg: Config{
|
|
IncludeServices: false,
|
|
IncludeTasks: false,
|
|
SwarmScope: swarmScopeAuto,
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
NodeID: "node1",
|
|
ControlAvailable: true,
|
|
LocalNodeState: swarmtypes.LocalNodeStateActive,
|
|
Cluster: &swarmtypes.ClusterInfo{
|
|
ID: "cluster1",
|
|
Spec: swarmtypes.Spec{
|
|
Annotations: swarmtypes.Annotations{Name: "prod"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
services, tasks, nodes, _, _, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
|
|
if services != nil || tasks != nil {
|
|
t.Fatal("expected nil services/tasks when disabled")
|
|
}
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("expected local swarm node to be returned")
|
|
}
|
|
if swarmInfo == nil || swarmInfo.ClusterID != "cluster1" || swarmInfo.ClusterName != "prod" {
|
|
t.Fatalf("expected cluster info to be populated")
|
|
}
|
|
})
|
|
|
|
t.Run("sorts tasks and services", func(t *testing.T) {
|
|
agent := &Agent{
|
|
supportsSwarm: true,
|
|
cfg: Config{
|
|
IncludeServices: true,
|
|
IncludeTasks: true,
|
|
SwarmScope: swarmScopeCluster,
|
|
},
|
|
docker: &fakeDockerClient{
|
|
serviceListFn: func(context.Context, dockerServiceListOptions) ([]swarmtypes.Service, error) {
|
|
return []swarmtypes.Service{
|
|
{ID: "b", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "web"}}},
|
|
{ID: "a", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "web"}}},
|
|
{ID: "c", Spec: swarmtypes.ServiceSpec{Annotations: swarmtypes.Annotations{Name: "api"}}},
|
|
}, nil
|
|
},
|
|
taskListFn: func(context.Context, dockerTaskListOptions) ([]swarmtypes.Task, error) {
|
|
return []swarmtypes.Task{
|
|
{ID: "b", ServiceID: "a", Slot: 1, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}},
|
|
{ID: "a", ServiceID: "a", Slot: 1, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}},
|
|
{ID: "c", ServiceID: "a", Slot: 2, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}},
|
|
{ID: "d", ServiceID: "c", Slot: 1, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}},
|
|
}, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
NodeID: "node1",
|
|
ControlAvailable: true,
|
|
LocalNodeState: swarmtypes.LocalNodeStateActive,
|
|
},
|
|
}
|
|
|
|
services, tasks, nodes, _, _, swarmInfo := agent.collectSwarmData(context.Background(), info, nil)
|
|
if swarmInfo == nil {
|
|
t.Fatalf("expected swarm info")
|
|
}
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("expected local swarm node to be returned")
|
|
}
|
|
if len(tasks) != 4 || len(services) != 3 {
|
|
t.Fatalf("expected tasks and services")
|
|
}
|
|
if tasks[0].ServiceName == "" || services[0].Name == "" {
|
|
t.Fatalf("expected sorted outputs to be populated")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIsRuntimeSwarmTask(t *testing.T) {
|
|
t.Run("accepts desired running task", func(t *testing.T) {
|
|
task := &swarmtypes.Task{
|
|
DesiredState: swarmtypes.TaskStateRunning,
|
|
Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning},
|
|
}
|
|
if !isRuntimeSwarmTask(task) {
|
|
t.Fatal("expected running task to be retained")
|
|
}
|
|
})
|
|
|
|
t.Run("accepts empty desired state active task as fallback", func(t *testing.T) {
|
|
task := &swarmtypes.Task{
|
|
Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStatePreparing},
|
|
}
|
|
if !isRuntimeSwarmTask(task) {
|
|
t.Fatal("expected active task with empty desired state to be retained")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects shutdown historical task", func(t *testing.T) {
|
|
task := &swarmtypes.Task{
|
|
DesiredState: swarmtypes.TaskStateShutdown,
|
|
Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateComplete},
|
|
}
|
|
if isRuntimeSwarmTask(task) {
|
|
t.Fatal("expected historical task to be excluded")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDeriveSwarmTasksFromContainers(t *testing.T) {
|
|
started := time.Date(2024, 1, 1, 1, 1, 1, 0, time.UTC)
|
|
finished := started.Add(time.Minute)
|
|
containers := []agentsdocker.Container{
|
|
{ID: "no-labels"},
|
|
{
|
|
ID: "no-service",
|
|
Labels: map[string]string{"com.docker.swarm.task.id": "task1"},
|
|
},
|
|
{
|
|
ID: "container1",
|
|
Name: "web.1",
|
|
State: "running",
|
|
CreatedAt: started,
|
|
Labels: map[string]string{
|
|
"com.docker.swarm.service.id": "svc1",
|
|
"com.docker.swarm.service.name": "web",
|
|
"com.docker.swarm.task.slot": "notint",
|
|
"com.docker.swarm.task.message": "ok",
|
|
"com.docker.swarm.task.error": "err",
|
|
"com.docker.swarm.task.desired-state": "running",
|
|
"com.docker.swarm.task.id": "",
|
|
"com.docker.swarm.node.name": "nodeA",
|
|
"com.docker.swarm.node.id": "",
|
|
},
|
|
StartedAt: &started,
|
|
FinishedAt: &finished,
|
|
},
|
|
{
|
|
ID: "container2",
|
|
Name: "web.2",
|
|
State: "running",
|
|
Labels: map[string]string{
|
|
"com.docker.swarm.service.id": "svc1",
|
|
"com.docker.swarm.service.name": "web",
|
|
"com.docker.swarm.task.id": "task2",
|
|
"com.docker.swarm.task.slot": "2",
|
|
},
|
|
},
|
|
}
|
|
|
|
info := systemtypes.Info{
|
|
Swarm: swarmtypes.Info{
|
|
NodeID: "node1",
|
|
},
|
|
}
|
|
|
|
tasks := deriveSwarmTasksFromContainers(containers, info)
|
|
if len(tasks) != 2 {
|
|
t.Fatalf("expected 2 tasks, got %d", len(tasks))
|
|
}
|
|
|
|
if tasks[0].NodeID == "" {
|
|
t.Fatalf("expected node id fallback to be set")
|
|
}
|
|
if tasks[0].Slot != 0 {
|
|
t.Fatalf("expected slot to remain 0 for invalid slot")
|
|
}
|
|
if tasks[0].StartedAt == nil || tasks[0].CompletedAt == nil {
|
|
t.Fatalf("expected timestamps to be set from container")
|
|
}
|
|
}
|
|
|
|
func TestDeriveSwarmTasksFromContainersEmpty(t *testing.T) {
|
|
if tasks := deriveSwarmTasksFromContainers(nil, systemtypes.Info{}); tasks != nil {
|
|
t.Fatalf("expected nil tasks for empty input")
|
|
}
|
|
}
|
|
|
|
func TestDeriveSwarmServicesFromData(t *testing.T) {
|
|
tasks := []agentsdocker.Task{
|
|
{
|
|
ID: "task1",
|
|
ServiceID: "svc1",
|
|
ServiceName: "web",
|
|
CurrentState: "running",
|
|
},
|
|
{
|
|
ID: "task2",
|
|
ServiceID: "svc1",
|
|
ServiceName: "web",
|
|
CurrentState: "complete",
|
|
},
|
|
{
|
|
ID: "task3",
|
|
ServiceID: "",
|
|
ServiceName: "",
|
|
CurrentState: "running",
|
|
},
|
|
}
|
|
|
|
containers := []agentsdocker.Container{
|
|
{
|
|
ID: "container1",
|
|
Image: "nginx:latest",
|
|
Labels: map[string]string{
|
|
"com.docker.swarm.service.id": "svc1",
|
|
"com.docker.stack.namespace": "stack",
|
|
"com.docker.swarm.service.name": "web",
|
|
},
|
|
},
|
|
}
|
|
|
|
services := deriveSwarmServicesFromData(tasks, containers)
|
|
if len(services) != 1 {
|
|
t.Fatalf("expected 1 service, got %d", len(services))
|
|
}
|
|
if services[0].Image != "nginx:latest" {
|
|
t.Fatalf("expected image to be populated")
|
|
}
|
|
if services[0].Stack != "stack" {
|
|
t.Fatalf("expected stack to be populated")
|
|
}
|
|
|
|
if got := deriveSwarmServicesFromData(nil, containers); got != nil {
|
|
t.Fatal("expected nil services for empty tasks")
|
|
}
|
|
}
|
|
|
|
func TestDeriveSwarmServicesFromDataNameFallback(t *testing.T) {
|
|
tasks := []agentsdocker.Task{
|
|
{
|
|
ID: "task1",
|
|
ServiceID: "",
|
|
ServiceName: "api",
|
|
CurrentState: "running",
|
|
},
|
|
}
|
|
|
|
containers := []agentsdocker.Container{
|
|
{
|
|
ID: "container1",
|
|
Image: "api:latest",
|
|
Labels: map[string]string{
|
|
"com.docker.swarm.service.name": "api",
|
|
},
|
|
},
|
|
}
|
|
|
|
services := deriveSwarmServicesFromData(tasks, containers)
|
|
if len(services) != 1 {
|
|
t.Fatalf("expected 1 service, got %d", len(services))
|
|
}
|
|
if services[0].ID != "api" {
|
|
t.Fatalf("expected service id to fall back to name")
|
|
}
|
|
if services[0].Labels != nil {
|
|
t.Fatalf("expected nil labels when none set")
|
|
}
|
|
}
|
|
|
|
func TestDeriveSwarmServicesFromDataEmptyAggregates(t *testing.T) {
|
|
tasks := []agentsdocker.Task{
|
|
{ID: "task1", ServiceID: "", ServiceName: ""},
|
|
}
|
|
containers := []agentsdocker.Container{
|
|
{ID: "container1"},
|
|
{
|
|
ID: "container2",
|
|
Labels: map[string]string{"unrelated": "true"},
|
|
},
|
|
{
|
|
ID: "container3",
|
|
Labels: map[string]string{"com.docker.swarm.service.id": "other"},
|
|
},
|
|
}
|
|
|
|
if services := deriveSwarmServicesFromData(tasks, containers); services != nil {
|
|
t.Fatalf("expected nil services for empty aggregates")
|
|
}
|
|
}
|
|
|
|
func TestDeriveSwarmServicesFromDataContainerSkips(t *testing.T) {
|
|
tasks := []agentsdocker.Task{
|
|
{ID: "task1", ServiceID: "svc1", ServiceName: "web", CurrentState: "running"},
|
|
}
|
|
|
|
containers := []agentsdocker.Container{
|
|
{ID: "container1"},
|
|
{ID: "container2", Labels: map[string]string{"foo": "bar"}},
|
|
{ID: "container3", Labels: map[string]string{"com.docker.swarm.service.id": "other"}},
|
|
}
|
|
|
|
services := deriveSwarmServicesFromData(tasks, containers)
|
|
if len(services) != 1 {
|
|
t.Fatalf("expected service aggregate to remain")
|
|
}
|
|
}
|