mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-23 07:53:36 +00:00
Force stale negative LXC Docker detections to re-run after the Docker checker is configured so explicit Proxmox guest Docker inventory repopulates after backend restarts.
935 lines
29 KiB
Go
935 lines
29 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: proxmoxGuestDockerSocketMarker + "\tyes\n",
|
|
exitCode: 0,
|
|
wantDocker: true,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "docker socket does not exist",
|
|
stdout: proxmoxGuestDockerSocketMarker + "\tno\n",
|
|
exitCode: 0,
|
|
wantDocker: false,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "container not accessible",
|
|
stdout: "error: CT 101 is locked",
|
|
exitCode: 1,
|
|
wantDocker: false,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "host-side pct failure is not cached as no docker",
|
|
stdout: "no\npct: CT 101 is locked",
|
|
exitCode: 255,
|
|
wantDocker: false,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "missing marker is an error",
|
|
stdout: "no\n",
|
|
exitCode: 0,
|
|
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 TestAgentDockerChecker_ProbeRunsInsideContainer(t *testing.T) {
|
|
var gotCommand string
|
|
checker := NewAgentDockerChecker(func(ctx context.Context, hostname string, command string, timeout int) (string, int, error) {
|
|
gotCommand = command
|
|
return proxmoxGuestDockerSocketMarker + "\tyes\n", 0, nil
|
|
})
|
|
|
|
hasDocker, err := checker.CheckDockerInContainer(context.Background(), "node1", 101)
|
|
if err != nil {
|
|
t.Fatalf("CheckDockerInContainer returned error: %v", err)
|
|
}
|
|
if !hasDocker {
|
|
t.Fatal("expected Docker to be detected")
|
|
}
|
|
if !strings.Contains(gotCommand, "pct exec 101 -- sh -c") {
|
|
t.Fatalf("expected probe to execute inside the LXC, got %q", gotCommand)
|
|
}
|
|
if strings.Contains(gotCommand, "&& echo yes || echo no") {
|
|
t.Fatalf("host-side shell fallback must not mask pct exec failures: %q", gotCommand)
|
|
}
|
|
}
|
|
|
|
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
|
|
checkerConfiguredAt time.Time
|
|
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: "recheck expired negative docker result",
|
|
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
|
|
previous: map[string]models.Container{
|
|
"ct-1": {
|
|
ID: "ct-1",
|
|
Status: "running",
|
|
Uptime: 3600,
|
|
HasDocker: false,
|
|
DockerCheckedAt: time.Now().Add(-proxmoxGuestDockerNegativeRecheckAfter - time.Second),
|
|
},
|
|
},
|
|
wantReason: "negative_cache_expired",
|
|
},
|
|
{
|
|
name: "recheck negative docker result from previous checker generation",
|
|
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
|
|
previous: map[string]models.Container{
|
|
"ct-1": {
|
|
ID: "ct-1",
|
|
Status: "running",
|
|
Uptime: 3600,
|
|
HasDocker: false,
|
|
DockerCheckedAt: time.Now().Add(-time.Minute),
|
|
},
|
|
},
|
|
checkerConfiguredAt: time.Now(),
|
|
wantReason: "checker_reconfigured",
|
|
},
|
|
{
|
|
name: "keep fresh negative docker result",
|
|
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
|
|
previous: map[string]models.Container{
|
|
"ct-1": {
|
|
ID: "ct-1",
|
|
Status: "running",
|
|
Uptime: 3600,
|
|
HasDocker: false,
|
|
DockerCheckedAt: time.Now().Add(-proxmoxGuestDockerNegativeRecheckAfter + time.Second),
|
|
},
|
|
},
|
|
wantReason: "",
|
|
},
|
|
{
|
|
name: "keep positive docker result",
|
|
container: models.Container{ID: "ct-1", Status: "running", Uptime: 7200},
|
|
previous: map[string]models.Container{
|
|
"ct-1": {
|
|
ID: "ct-1",
|
|
Status: "running",
|
|
Uptime: 3600,
|
|
HasDocker: true,
|
|
DockerCheckedAt: time.Now().Add(-proxmoxGuestDockerNegativeRecheckAfter - time.Hour),
|
|
},
|
|
},
|
|
wantReason: "",
|
|
},
|
|
{
|
|
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, tt.checkerConfiguredAt)
|
|
if reason != tt.wantReason {
|
|
t.Errorf("Expected reason %q, got %q", tt.wantReason, reason)
|
|
}
|
|
})
|
|
}
|
|
}
|