Pulse/internal/monitoring/docker_detection_test.go
rcourtman 926906b134 Don't overwrite populated Docker hosts with empty inventory polls
The Proxmox-side LXC Docker inventory poll occasionally produces an
ok=true report with zero CONTAINER lines but a valid PS_OK marker:
docker ps -a inside the LXC genuinely returned an empty list for
that one call, typically while watchtower is briefly recreating a
container or the dockerd is mid-restart. ApplyDockerReport then
wipes the host's container list, every connected WebSocket client
sees every row for that host flash blank, and the next successful
poll 10 s later restores it.

Before calling ApplyDockerReport, check whether the host already
has containers tracked in state. If yes and the new report has
zero containers, log at debug and skip the apply, counting the
poll as skipped rather than collected. A host that is genuinely
empty still applies on first sight (no previous containers means
no previous state to protect).

Add regression tests for both branches: a populated host receiving
an empty report keeps its container list; a freshly-seen empty
host still gets created.
2026-05-19 12:04:51 +01:00

841 lines
26 KiB
Go

package monitoring
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
)
// mockDockerChecker is a test implementation of DockerChecker
type mockDockerChecker struct {
results map[int]bool // vmid -> hasDocker
mu sync.Mutex
calls []int // records vmids that were checked
}
func (m *mockDockerChecker) CheckDockerInContainer(ctx context.Context, node string, vmid int) (bool, error) {
m.mu.Lock()
m.calls = append(m.calls, vmid)
m.mu.Unlock()
if hasDocker, ok := m.results[vmid]; ok {
return hasDocker, nil
}
return false, nil
}
func TestCheckContainersForDocker_NewContainers(t *testing.T) {
// Create a monitor with state
state := models.NewState()
monitor := &Monitor{
state: state,
}
// Create a mock checker
checker := &mockDockerChecker{
results: map[int]bool{
101: true,
102: false,
103: true, // This one is stopped, so it shouldn't be checked
},
}
monitor.SetDockerChecker(checker)
// Input containers - all new (not in state yet)
containers := []models.Container{
{ID: "ct-1", VMID: 101, Name: "container-with-docker", Node: "node1", Status: "running"},
{ID: "ct-2", VMID: 102, Name: "container-without-docker", Node: "node1", Status: "running"},
{ID: "ct-3", VMID: 103, Name: "stopped-container", Node: "node1", Status: "stopped"},
}
// Run the check
result := monitor.CheckContainersForDocker(context.Background(), containers)
// Verify results
var ct1, ct2, ct3 *models.Container
for i := range result {
switch result[i].ID {
case "ct-1":
ct1 = &result[i]
case "ct-2":
ct2 = &result[i]
case "ct-3":
ct3 = &result[i]
}
}
if ct1 == nil || !ct1.HasDocker {
t.Errorf("Container 101 should have HasDocker=true")
}
if ct1 != nil && ct1.DockerCheckedAt.IsZero() {
t.Errorf("Container 101 should have DockerCheckedAt set")
}
if ct2 == nil || ct2.HasDocker {
t.Errorf("Container 102 should have HasDocker=false")
}
if ct2 != nil && ct2.DockerCheckedAt.IsZero() {
t.Errorf("Container 102 should have DockerCheckedAt set")
}
// Stopped container should not be checked
if ct3 != nil && !ct3.DockerCheckedAt.IsZero() {
t.Errorf("Stopped container 103 should not have been checked")
}
// Verify only running containers were checked
if len(checker.calls) != 2 {
t.Errorf("Expected 2 Docker checks (running containers only), got %d", len(checker.calls))
}
}
func TestCheckContainersForDocker_PreservesExistingStatus(t *testing.T) {
// Create a monitor with state containing previously checked containers
state := models.NewState()
checkedTime := time.Now().Add(-30 * time.Minute)
state.UpdateContainers([]models.Container{
{
ID: "ct-1",
VMID: 101,
Name: "already-checked",
Node: "node1",
Status: "running",
HasDocker: true,
DockerCheckedAt: checkedTime,
Uptime: 3600, // 1 hour uptime
},
})
monitor := &Monitor{
state: state,
}
checker := &mockDockerChecker{
results: map[int]bool{101: false}, // Would return false if checked
}
monitor.SetDockerChecker(checker)
// Same container, still running with same/higher uptime
containers := []models.Container{
{
ID: "ct-1",
VMID: 101,
Name: "already-checked",
Node: "node1",
Status: "running",
Uptime: 7200, // 2 hours uptime (no restart)
},
}
result := monitor.CheckContainersForDocker(context.Background(), containers)
// Should not have been re-checked
if len(checker.calls) != 0 {
t.Errorf("Expected 0 checks (should preserve existing status), got %d", len(checker.calls))
}
// Should preserve the previous Docker status
if !result[0].HasDocker {
t.Errorf("Container should still have cached HasDocker=true")
}
if result[0].DockerCheckedAt.IsZero() {
t.Errorf("Container should have preserved DockerCheckedAt")
}
}
func TestCheckContainersForDocker_RechecksOnRestart(t *testing.T) {
// Create a monitor with state containing previously checked container
state := models.NewState()
state.UpdateContainers([]models.Container{
{
ID: "ct-1",
VMID: 101,
Name: "restarted-container",
Node: "node1",
Status: "running",
HasDocker: false,
DockerCheckedAt: time.Now().Add(-1 * time.Hour),
Uptime: 3600, // 1 hour uptime
},
})
monitor := &Monitor{
state: state,
}
checker := &mockDockerChecker{
results: map[int]bool{101: true}, // Now has Docker after restart
}
monitor.SetDockerChecker(checker)
// Same container but with reset uptime (restarted)
containers := []models.Container{
{
ID: "ct-1",
VMID: 101,
Name: "restarted-container",
Node: "node1",
Status: "running",
Uptime: 60, // Only 1 minute uptime - it restarted
},
}
result := monitor.CheckContainersForDocker(context.Background(), containers)
// Should have been re-checked because uptime reset
if len(checker.calls) != 1 {
t.Errorf("Expected 1 check (container restarted), got %d", len(checker.calls))
}
// Should have updated Docker status
if !result[0].HasDocker {
t.Errorf("Container should now have HasDocker=true after restart")
}
}
func TestCheckContainersForDocker_RechecksWhenStarted(t *testing.T) {
// Create a monitor with state containing previously stopped container
state := models.NewState()
state.UpdateContainers([]models.Container{
{
ID: "ct-1",
VMID: 101,
Name: "was-stopped",
Node: "node1",
Status: "stopped",
HasDocker: false,
DockerCheckedAt: time.Now().Add(-1 * time.Hour),
},
})
monitor := &Monitor{
state: state,
}
checker := &mockDockerChecker{
results: map[int]bool{101: true},
}
monitor.SetDockerChecker(checker)
// Container is now running
containers := []models.Container{
{
ID: "ct-1",
VMID: 101,
Name: "was-stopped",
Node: "node1",
Status: "running",
Uptime: 60,
},
}
result := monitor.CheckContainersForDocker(context.Background(), containers)
// Should have been checked because it just started
if len(checker.calls) != 1 {
t.Errorf("Expected 1 check (container started), got %d", len(checker.calls))
}
if !result[0].HasDocker {
t.Errorf("Container should have HasDocker=true after starting")
}
}
func TestCheckContainersForDocker_NoCheckerConfigured(t *testing.T) {
state := models.NewState()
monitor := &Monitor{
state: state,
}
// No checker configured
containers := []models.Container{
{ID: "ct-1", VMID: 101, Name: "test", Node: "node1", Status: "running"},
}
result := monitor.CheckContainersForDocker(context.Background(), containers)
// Should return containers unchanged
if len(result) != 1 {
t.Errorf("Expected 1 container, got %d", len(result))
}
if result[0].HasDocker {
t.Errorf("Container should not have HasDocker set without checker")
}
}
func TestAgentDockerChecker_ParsesOutput(t *testing.T) {
tests := []struct {
name string
stdout string
exitCode int
wantDocker bool
wantErr bool
}{
{
name: "docker socket exists",
stdout: "yes\n",
exitCode: 0,
wantDocker: true,
wantErr: false,
},
{
name: "docker socket does not exist",
stdout: "no\n",
exitCode: 1,
wantDocker: false,
wantErr: false,
},
{
name: "container not accessible",
stdout: "error: CT 101 is locked",
exitCode: 1,
wantDocker: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checker := NewAgentDockerChecker(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
return tt.stdout, tt.exitCode, nil
})
hasDocker, err := checker.CheckDockerInContainer(context.Background(), "node1", 101)
if tt.wantErr && err == nil {
t.Errorf("Expected error but got none")
}
if !tt.wantErr && err != nil {
t.Errorf("Unexpected error: %v", err)
}
if hasDocker != tt.wantDocker {
t.Errorf("Expected hasDocker=%v, got %v", tt.wantDocker, hasDocker)
}
})
}
}
func TestAgentDockerInventoryCollector_MinimalCommandAndReport(t *testing.T) {
var gotCommand string
collector := NewAgentDockerInventoryCollector(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
gotCommand = command
return strings.Join([]string{
proxmoxGuestDockerInventoryMarker,
"HOSTNAME\tct-web",
"UNAME\tLinux 6.8.12-9-pve x86_64",
"CPUS\t2",
"MEMTOTAL\t1073741824",
"VERSION\t\"26.1.4\"",
"CONTAINER\t\"abcdef123456\"\t\"web\"\t\"nginx:latest\"\t\"running\"\t\"Up 2 hours\"\t\"0.0.0.0:8080->80/tcp, 443/tcp\"\t\"2 hours ago\"",
"PS_OK",
"STAT\t\"abcdef123456\"\t\"web\"\t\"0.50%\"\t\"64MiB / 1GiB\"\t\"6.25%\"\t\"1.2kB / 3.4MB\"\t\"5MB / 6MB\"",
"",
}, "\n"), 0, nil
}, AgentDockerInventoryCollectorOptions{})
report, ok, err := collector.CollectDockerInventory(context.Background(), models.Container{
ID: "pve-a:node-a:101",
VMID: 101,
Name: "web-lxc",
Node: "node-a",
Status: "running",
})
if err != nil {
t.Fatalf("CollectDockerInventory returned error: %v", err)
}
if !ok {
t.Fatal("expected inventory report to be collected")
}
if !strings.Contains(gotCommand, "pct exec 101 -- sh -c") {
t.Fatalf("expected pct exec command for VMID 101, got %q", gotCommand)
}
for _, forbidden := range []string{"docker inspect", "docker exec", "docker top", "printenv", ".Labels", ".Mounts", ".Command"} {
if strings.Contains(gotCommand, forbidden) {
t.Fatalf("inventory command must not collect %s; command=%q", forbidden, gotCommand)
}
}
if report.Agent.ID != "proxmox-lxc-docker:pve-a:node-a:101" {
t.Fatalf("Agent.ID = %q", report.Agent.ID)
}
if report.Host.Hostname != "ct-web" {
t.Fatalf("Host.Hostname = %q", report.Host.Hostname)
}
if report.Host.Name != "web-lxc" {
t.Fatalf("Host.Name = %q", report.Host.Name)
}
if report.Host.DockerVersion != "26.1.4" || report.Host.TotalCPU != 2 || report.Host.TotalMemoryBytes != 1073741824 {
t.Fatalf("unexpected host info: %#v", report.Host)
}
if len(report.Containers) != 1 {
t.Fatalf("expected 1 container, got %d", len(report.Containers))
}
ct := report.Containers[0]
if ct.ID != "abcdef123456" || ct.Name != "web" || ct.Image != "nginx:latest" || ct.State != "running" {
t.Fatalf("unexpected container payload: %#v", ct)
}
if ct.UptimeSeconds != 7200 {
t.Fatalf("UptimeSeconds = %d, want 7200", ct.UptimeSeconds)
}
if ct.CPUPercent != 0.5 || ct.MemoryUsageBytes != 64*1024*1024 || ct.MemoryLimitBytes != 1024*1024*1024 || ct.MemoryPercent != 6.25 {
t.Fatalf("unexpected stats payload: %#v", ct)
}
if ct.NetworkRXBytes != 1200 || ct.NetworkTXBytes != 3400000 {
t.Fatalf("unexpected network IO: rx=%d tx=%d", ct.NetworkRXBytes, ct.NetworkTXBytes)
}
if ct.BlockIO == nil || ct.BlockIO.ReadBytes != 5000000 || ct.BlockIO.WriteBytes != 6000000 {
t.Fatalf("unexpected block IO: %#v", ct.BlockIO)
}
if len(ct.Ports) != 2 || ct.Ports[0].PrivatePort != 80 || ct.Ports[0].PublicPort != 8080 || ct.Ports[1].PrivatePort != 443 {
t.Fatalf("unexpected ports: %#v", ct.Ports)
}
}
func TestAgentDockerInventoryCollector_PartialOutputRefusesApply(t *testing.T) {
collector := NewAgentDockerInventoryCollector(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
return strings.Join([]string{
proxmoxGuestDockerInventoryMarker,
"HOSTNAME\tct-web",
"UNAME\tLinux 6.8.12-9-pve x86_64",
"CPUS\t2",
"MEMTOTAL\t1073741824",
"VERSION\t\"26.1.4\"",
"",
}, "\n"), 0, nil
}, AgentDockerInventoryCollectorOptions{})
_, ok, err := collector.CollectDockerInventory(context.Background(), models.Container{
ID: "pve-a:node-a:101",
VMID: 101,
Name: "web-lxc",
Node: "node-a",
Status: "running",
})
if err == nil {
t.Fatal("expected an error when docker ps marker is missing")
}
if ok {
t.Fatal("partial inventory output (no PS_OK) must not produce an applicable report")
}
}
func TestAgentDockerInventoryCollector_PSOKWithZeroContainersApplies(t *testing.T) {
collector := NewAgentDockerInventoryCollector(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
return strings.Join([]string{
proxmoxGuestDockerInventoryMarker,
"HOSTNAME\tct-web",
"UNAME\tLinux 6.8.12-9-pve x86_64",
"CPUS\t2",
"MEMTOTAL\t1073741824",
"VERSION\t\"26.1.4\"",
"PS_OK",
"",
}, "\n"), 0, nil
}, AgentDockerInventoryCollectorOptions{})
report, ok, err := collector.CollectDockerInventory(context.Background(), models.Container{
ID: "pve-a:node-a:101",
VMID: 101,
Name: "web-lxc",
Node: "node-a",
Status: "running",
})
if err != nil {
t.Fatalf("CollectDockerInventory returned error: %v", err)
}
if !ok {
t.Fatal("zero-container result with PS_OK must be applied (host genuinely has no containers)")
}
if len(report.Containers) != 0 {
t.Fatalf("expected zero containers, got %d", len(report.Containers))
}
}
func TestEnrichGuestDockerReportFromContainer_FillsMissingHostMetrics(t *testing.T) {
report := agentsdocker.Report{
Host: agentsdocker.HostInfo{
Hostname: "web-lxc",
TotalCPU: 4,
TotalMemoryBytes: 4 * 1024 * 1024 * 1024,
},
}
container := models.Container{
VMID: 141,
Name: "homepage-docker",
CPU: 0.42,
Uptime: 86400,
Memory: models.Memory{Total: 4 * 1024 * 1024 * 1024, Used: 1 * 1024 * 1024 * 1024, Free: 3 * 1024 * 1024 * 1024, Usage: 25},
Disk: models.Disk{Total: 8 * 1024 * 1024 * 1024, Used: 2 * 1024 * 1024 * 1024, Free: 6 * 1024 * 1024 * 1024, Usage: 25},
}
enrichGuestDockerReportFromContainer(&report, container)
if report.Host.CPUUsagePercent != 42 {
t.Fatalf("CPUUsagePercent = %v, want 42", report.Host.CPUUsagePercent)
}
if report.Host.Memory.UsedBytes != 1*1024*1024*1024 || report.Host.Memory.TotalBytes != 4*1024*1024*1024 || report.Host.Memory.Usage != 25 {
t.Fatalf("Memory enrichment failed: %#v", report.Host.Memory)
}
if len(report.Host.Disks) != 1 || report.Host.Disks[0].Mountpoint != "/" || report.Host.Disks[0].UsedBytes != 2*1024*1024*1024 {
t.Fatalf("Disks enrichment failed: %#v", report.Host.Disks)
}
if report.Host.UptimeSeconds != 86400 {
t.Fatalf("UptimeSeconds = %d, want 86400", report.Host.UptimeSeconds)
}
}
func TestEnrichGuestDockerReportFromContainer_DoesNotOverrideReportedValues(t *testing.T) {
report := agentsdocker.Report{
Host: agentsdocker.HostInfo{
CPUUsagePercent: 7,
Memory: agentsdocker.MemoryMetric{TotalBytes: 1, UsedBytes: 1, Usage: 99},
Disks: []agentsdocker.Disk{{Mountpoint: "/data", TotalBytes: 1}},
},
}
container := models.Container{
CPU: 0.5,
Memory: models.Memory{Total: 1000, Used: 500, Usage: 50},
Disk: models.Disk{Total: 1000, Used: 500, Usage: 50},
}
enrichGuestDockerReportFromContainer(&report, container)
if report.Host.CPUUsagePercent != 7 {
t.Fatalf("CPUUsagePercent overwritten: %v", report.Host.CPUUsagePercent)
}
if report.Host.Memory.UsedBytes != 1 || report.Host.Memory.Usage != 99 {
t.Fatalf("Memory overwritten: %#v", report.Host.Memory)
}
if len(report.Host.Disks) != 1 || report.Host.Disks[0].Mountpoint != "/data" {
t.Fatalf("Disks overwritten: %#v", report.Host.Disks)
}
}
func TestAgentDockerInventoryCollector_AllowlistSkipsUnlistedVMID(t *testing.T) {
called := false
collector := NewAgentDockerInventoryCollector(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
called = true
return "", 0, nil
}, AgentDockerInventoryCollectorOptions{
AllowedVMIDs: map[int]struct{}{102: {}},
})
_, ok, err := collector.CollectDockerInventory(context.Background(), models.Container{VMID: 101, Node: "node-a"})
if err != nil {
t.Fatalf("CollectDockerInventory returned error: %v", err)
}
if ok {
t.Fatal("expected unlisted VMID to be skipped")
}
if called {
t.Fatal("collector should not execute a command for an unlisted VMID")
}
}
type mockDockerInventoryCollector struct {
mu sync.Mutex
calls []int
reports map[int]agentsdocker.Report
}
func (m *mockDockerInventoryCollector) CollectDockerInventory(ctx context.Context, container models.Container) (agentsdocker.Report, bool, error) {
m.mu.Lock()
m.calls = append(m.calls, container.VMID)
m.mu.Unlock()
report, ok := m.reports[container.VMID]
return report, ok, nil
}
func TestMonitorCollectProxmoxGuestDockerInventory_AppliesDockerReport(t *testing.T) {
monitor := newTestMonitor(t)
collector := &mockDockerInventoryCollector{
reports: map[int]agentsdocker.Report{
101: {
Agent: agentsdocker.AgentInfo{ID: "proxmox-lxc-docker:pve-a:node-a:101", Type: "unified", IntervalSeconds: 30},
Host: agentsdocker.HostInfo{
Hostname: "ct-web",
Name: "web-lxc",
Runtime: "docker",
DockerVersion: "26.1.4",
RuntimeVersion: "26.1.4",
},
Containers: []agentsdocker.Container{{
ID: "docker-1",
Name: "web",
Image: "nginx:latest",
State: "running",
Status: "Up 1 minute",
}},
Timestamp: time.Now().UTC(),
},
},
}
monitor.SetDockerInventoryCollector(collector)
monitor.CollectProxmoxGuestDockerInventory(context.Background(), []models.Container{
{ID: "pve-a:node-a:101", VMID: 101, Name: "web-lxc", Node: "node-a", Status: "running", HasDocker: true},
{ID: "pve-a:node-a:102", VMID: 102, Name: "plain-lxc", Node: "node-a", Status: "running", HasDocker: false},
})
if len(collector.calls) != 1 || collector.calls[0] != 101 {
t.Fatalf("collector calls = %#v, want [101]", collector.calls)
}
hosts := monitor.state.GetDockerHosts()
if len(hosts) != 1 {
t.Fatalf("expected 1 Docker host, got %d", len(hosts))
}
if hosts[0].ID != "proxmox-lxc-docker:pve-a:node-a:101" {
t.Fatalf("Docker host ID = %q", hosts[0].ID)
}
if len(hosts[0].Containers) != 1 || hosts[0].Containers[0].Name != "web" {
t.Fatalf("unexpected Docker containers: %#v", hosts[0].Containers)
}
}
func TestMonitorCollectProxmoxGuestDockerInventory_RefusesEmptyOverwriteOfPopulatedHost(t *testing.T) {
monitor := newTestMonitor(t)
// First poll populates the docker host with one container.
collector := &mockDockerInventoryCollector{
reports: map[int]agentsdocker.Report{
101: {
Agent: agentsdocker.AgentInfo{ID: "proxmox-lxc-docker:pve-a:node-a:101", Type: "unified", IntervalSeconds: 30},
Host: agentsdocker.HostInfo{
Hostname: "ct-web",
Name: "web-lxc",
Runtime: "docker",
DockerVersion: "26.1.4",
RuntimeVersion: "26.1.4",
},
Containers: []agentsdocker.Container{{
ID: "docker-1",
Name: "web",
Image: "nginx:latest",
State: "running",
Status: "Up 1 minute",
}},
Timestamp: time.Now().UTC(),
},
},
}
monitor.SetDockerInventoryCollector(collector)
monitor.CollectProxmoxGuestDockerInventory(context.Background(), []models.Container{
{ID: "pve-a:node-a:101", VMID: 101, Name: "web-lxc", Node: "node-a", Status: "running", HasDocker: true},
})
hosts := monitor.state.GetDockerHosts()
if len(hosts) != 1 || len(hosts[0].Containers) != 1 {
t.Fatalf("setup failed: expected 1 host with 1 container, got %d hosts", len(hosts))
}
// Second poll returns the same host but with zero containers — the
// transient `docker ps` blip we want to ignore so the UI does not
// flash empty until the next successful poll.
collector.reports[101] = agentsdocker.Report{
Agent: agentsdocker.AgentInfo{ID: "proxmox-lxc-docker:pve-a:node-a:101", Type: "unified", IntervalSeconds: 30},
Host: agentsdocker.HostInfo{
Hostname: "ct-web",
Name: "web-lxc",
Runtime: "docker",
DockerVersion: "26.1.4",
RuntimeVersion: "26.1.4",
},
Containers: nil,
Timestamp: time.Now().UTC(),
}
monitor.CollectProxmoxGuestDockerInventory(context.Background(), []models.Container{
{ID: "pve-a:node-a:101", VMID: 101, Name: "web-lxc", Node: "node-a", Status: "running", HasDocker: true},
})
hosts = monitor.state.GetDockerHosts()
if len(hosts) != 1 {
t.Fatalf("docker host disappeared after empty report")
}
if len(hosts[0].Containers) != 1 || hosts[0].Containers[0].Name != "web" {
t.Fatalf("empty inventory report wiped the previously-populated container list: %#v", hosts[0].Containers)
}
}
func TestMonitorCollectProxmoxGuestDockerInventory_AppliesFirstZeroContainerReport(t *testing.T) {
monitor := newTestMonitor(t)
collector := &mockDockerInventoryCollector{
reports: map[int]agentsdocker.Report{
101: {
Agent: agentsdocker.AgentInfo{ID: "proxmox-lxc-docker:pve-a:node-a:101", Type: "unified", IntervalSeconds: 30},
Host: agentsdocker.HostInfo{
Hostname: "ct-web",
Name: "web-lxc",
Runtime: "docker",
DockerVersion: "26.1.4",
},
Containers: nil,
Timestamp: time.Now().UTC(),
},
},
}
monitor.SetDockerInventoryCollector(collector)
monitor.CollectProxmoxGuestDockerInventory(context.Background(), []models.Container{
{ID: "pve-a:node-a:101", VMID: 101, Name: "web-lxc", Node: "node-a", Status: "running", HasDocker: true},
})
hosts := monitor.state.GetDockerHosts()
if len(hosts) != 1 {
t.Fatalf("expected the host to be created from the first (empty) report, got %d hosts", len(hosts))
}
if len(hosts[0].Containers) != 0 {
t.Fatalf("expected zero containers on a freshly-seen empty host, got %d", len(hosts[0].Containers))
}
}
func TestMonitorCollectProxmoxGuestDockerInventory_SkipsLinkedGuestLocalAgent(t *testing.T) {
monitor := newTestMonitor(t)
monitor.state.UpsertHost(models.Host{
ID: "host-agent-101",
Hostname: "web-lxc",
Status: "online",
LinkedContainerID: "pve-a:node-a:101",
})
collector := &mockDockerInventoryCollector{reports: map[int]agentsdocker.Report{}}
monitor.SetDockerInventoryCollector(collector)
monitor.CollectProxmoxGuestDockerInventory(context.Background(), []models.Container{
{ID: "pve-a:node-a:101", VMID: 101, Name: "web-lxc", Node: "node-a", Status: "running", HasDocker: true},
})
if len(collector.calls) != 0 {
t.Fatalf("collector should not run for a container with a linked online host agent, got calls %#v", collector.calls)
}
}
func TestParseProxmoxGuestDockerInventoryVMIDs(t *testing.T) {
allowed, invalid := ParseProxmoxGuestDockerInventoryVMIDs("101, 102, nope, -1, 101")
if len(invalid) != 2 || invalid[0] != "nope" || invalid[1] != "-1" {
t.Fatalf("invalid entries = %#v", invalid)
}
if _, ok := allowed[101]; !ok {
t.Fatal("expected VMID 101 in allowlist")
}
if _, ok := allowed[102]; !ok {
t.Fatal("expected VMID 102 in allowlist")
}
if len(allowed) != 2 {
t.Fatalf("allowed = %#v", allowed)
}
}
func TestState_GetContainers(t *testing.T) {
state := models.NewState()
state.UpdateContainers([]models.Container{
{ID: "ct-1", VMID: 101, Name: "test1"},
{ID: "ct-2", VMID: 102, Name: "test2"},
})
containers := state.GetContainers()
if len(containers) != 2 {
t.Errorf("Expected 2 containers, got %d", len(containers))
}
// Verify it's a copy (modifying shouldn't affect original)
containers[0].Name = "modified"
originalContainers := state.GetContainers()
if originalContainers[0].Name == "modified" {
t.Error("GetContainers should return a copy, not the original slice")
}
}
func TestState_UpdateContainerDockerStatus(t *testing.T) {
state := models.NewState()
state.UpdateContainers([]models.Container{
{ID: "ct-1", VMID: 101, Name: "test1"},
})
now := time.Now()
updated := state.UpdateContainerDockerStatus("ct-1", true, now)
if !updated {
t.Error("UpdateContainerDockerStatus should return true for existing container")
}
containers := state.GetContainers()
if !containers[0].HasDocker {
t.Error("Container should have HasDocker=true")
}
if containers[0].DockerCheckedAt.IsZero() {
t.Error("Container should have DockerCheckedAt set")
}
// Test non-existent container
updated = state.UpdateContainerDockerStatus("non-existent", true, now)
if updated {
t.Error("UpdateContainerDockerStatus should return false for non-existent container")
}
}
func TestContainerNeedsDockerCheck(t *testing.T) {
monitor := &Monitor{
state: models.NewState(),
}
tests := []struct {
name string
container models.Container
previous map[string]models.Container
wantReason string
}{
{
name: "new container",
container: models.Container{ID: "ct-1", Status: "running"},
previous: map[string]models.Container{},
wantReason: "new",
},
{
name: "first check - never checked before",
container: models.Container{ID: "ct-1", Status: "running", Uptime: 3600},
previous: map[string]models.Container{
"ct-1": {ID: "ct-1", Status: "running", Uptime: 3600},
},
wantReason: "first_check",
},
{
name: "restarted - uptime lower",
container: models.Container{ID: "ct-1", Status: "running", Uptime: 60},
previous: map[string]models.Container{
"ct-1": {ID: "ct-1", Status: "running", Uptime: 3600, DockerCheckedAt: time.Now()},
},
wantReason: "restarted",
},
{
name: "started - was stopped",
container: models.Container{ID: "ct-1", Status: "running", Uptime: 60},
previous: map[string]models.Container{
"ct-1": {ID: "ct-1", Status: "stopped", DockerCheckedAt: time.Now()},
},
wantReason: "started",
},
{
name: "no check needed - same state",
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
previous: map[string]models.Container{
"ct-1": {ID: "ct-1", Status: "running", Uptime: 3600, DockerCheckedAt: time.Now()},
},
wantReason: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reason := monitor.containerNeedsDockerCheck(tt.container, tt.previous)
if reason != tt.wantReason {
t.Errorf("Expected reason %q, got %q", tt.wantReason, reason)
}
})
}
}