mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
bd6f77e093
Tighten v5-to-v6 upgrade safety, release installability, provider MSP mode handling, AI cost accounting, metrics flushing, and frontend guardrails for the v6.0.0 GA candidate.
1306 lines
37 KiB
Go
1306 lines
37 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
|
|
)
|
|
|
|
func newTestMonitor(t *testing.T) *Monitor {
|
|
t.Helper()
|
|
|
|
m := &Monitor{
|
|
state: models.NewState(),
|
|
alertManager: alerts.NewManager(),
|
|
removedDockerHosts: make(map[string]time.Time),
|
|
rateTracker: NewRateTracker(),
|
|
metricsHistory: NewMetricsHistory(1000, 24*time.Hour),
|
|
dockerTokenBindings: make(map[string]string),
|
|
dockerMetadataStore: config.NewDockerMetadataStore(t.TempDir(), nil),
|
|
}
|
|
t.Cleanup(func() { m.alertManager.Stop() })
|
|
return m
|
|
}
|
|
|
|
func TestApplyDockerReportGeneratesUniqueIDsForCollidingHosts(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
baseTimestamp := time.Now().UTC()
|
|
baseReport := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
Version: "1.0.0",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "docker-host",
|
|
Name: "Docker Host",
|
|
MachineID: "machine-duplicate",
|
|
DockerVersion: "26.0.0",
|
|
TotalCPU: 4,
|
|
TotalMemoryBytes: 8 << 30,
|
|
UptimeSeconds: 120,
|
|
},
|
|
Containers: []agentsdocker.Container{
|
|
{ID: "container-1", Name: "nginx"},
|
|
},
|
|
Timestamp: baseTimestamp,
|
|
}
|
|
|
|
token1 := &config.APITokenRecord{ID: "token-host-1", Name: "Host 1"}
|
|
host1, err := monitor.ApplyDockerReport(baseReport, token1)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport host1: %v", err)
|
|
}
|
|
if host1.ID == "" {
|
|
t.Fatalf("expected host1 to have an identifier")
|
|
}
|
|
|
|
hosts := monitor.state.GetDockerHosts()
|
|
if len(hosts) != 1 {
|
|
t.Fatalf("expected 1 host after first report, got %d", len(hosts))
|
|
}
|
|
|
|
secondReport := baseReport
|
|
secondReport.Host.Name = "Docker Host Clone"
|
|
secondReport.Timestamp = baseTimestamp.Add(45 * time.Second)
|
|
|
|
token2 := &config.APITokenRecord{ID: "token-host-2", Name: "Host 2"}
|
|
host2, err := monitor.ApplyDockerReport(secondReport, token2)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport host2: %v", err)
|
|
}
|
|
if host2.ID == "" {
|
|
t.Fatalf("expected host2 to have an identifier")
|
|
}
|
|
if host2.ID == host1.ID {
|
|
t.Fatalf("expected unique identifiers, but both hosts share %q", host2.ID)
|
|
}
|
|
|
|
hosts = monitor.state.GetDockerHosts()
|
|
if len(hosts) != 2 {
|
|
t.Fatalf("expected 2 hosts after second report, got %d", len(hosts))
|
|
}
|
|
|
|
secondReport.Timestamp = secondReport.Timestamp.Add(45 * time.Second)
|
|
secondReport.Containers = append(secondReport.Containers, agentsdocker.Container{
|
|
ID: "container-2",
|
|
Name: "redis",
|
|
})
|
|
|
|
updatedHost2, err := monitor.ApplyDockerReport(secondReport, token2)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport host2 update: %v", err)
|
|
}
|
|
if updatedHost2.ID != host2.ID {
|
|
t.Fatalf("expected host2 to retain identifier %q, got %q", host2.ID, updatedHost2.ID)
|
|
}
|
|
|
|
hosts = monitor.state.GetDockerHosts()
|
|
var found models.DockerHost
|
|
for _, h := range hosts {
|
|
if h.ID == host2.ID {
|
|
found = h
|
|
break
|
|
}
|
|
}
|
|
if found.ID == "" {
|
|
t.Fatalf("failed to locate host2 in state after update")
|
|
}
|
|
if len(found.Containers) != 2 {
|
|
t.Fatalf("expected host2 to have 2 containers after update, got %d", len(found.Containers))
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReportUsesTokenToDisambiguateAgentIDCollisions(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
baseReport := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "duplicate-agent",
|
|
Version: "1.0.0",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "docker-one",
|
|
Name: "Docker One",
|
|
MachineID: "machine-A",
|
|
DockerVersion: "26.0.0",
|
|
TotalCPU: 4,
|
|
TotalMemoryBytes: 16 << 30,
|
|
UptimeSeconds: 120,
|
|
},
|
|
Containers: []agentsdocker.Container{
|
|
{ID: "container-a", Name: "api"},
|
|
},
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
|
|
tokenOne := &config.APITokenRecord{ID: "token-one", Name: "Token One"}
|
|
hostOne, err := monitor.ApplyDockerReport(baseReport, tokenOne)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport hostOne: %v", err)
|
|
}
|
|
if hostOne.ID == "" {
|
|
t.Fatal("expected hostOne to receive an identifier")
|
|
}
|
|
|
|
secondReport := baseReport
|
|
secondReport.Host.Hostname = "docker-two"
|
|
secondReport.Host.Name = "Docker Two"
|
|
secondReport.Host.MachineID = "machine-B"
|
|
secondReport.Containers = []agentsdocker.Container{
|
|
{ID: "container-b", Name: "db"},
|
|
}
|
|
secondReport.Timestamp = baseReport.Timestamp.Add(30 * time.Second)
|
|
|
|
tokenTwo := &config.APITokenRecord{ID: "token-two", Name: "Token Two"}
|
|
hostTwo, err := monitor.ApplyDockerReport(secondReport, tokenTwo)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport hostTwo: %v", err)
|
|
}
|
|
|
|
if hostTwo.ID == "" {
|
|
t.Fatal("expected hostTwo to receive an identifier")
|
|
}
|
|
if hostOne.ID == hostTwo.ID {
|
|
t.Fatalf("expected different identifiers for hosts sharing an agent ID, got %q", hostOne.ID)
|
|
}
|
|
|
|
hosts := monitor.state.GetDockerHosts()
|
|
if len(hosts) != 2 {
|
|
t.Fatalf("expected 2 hosts after two reports, got %d", len(hosts))
|
|
}
|
|
|
|
updatedReport := baseReport
|
|
updatedReport.Timestamp = baseReport.Timestamp.Add(60 * time.Second)
|
|
updatedReport.Containers = append(updatedReport.Containers, agentsdocker.Container{
|
|
ID: "container-c",
|
|
Name: "cache",
|
|
})
|
|
|
|
updatedHostOne, err := monitor.ApplyDockerReport(updatedReport, tokenOne)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport hostOne update: %v", err)
|
|
}
|
|
if updatedHostOne.ID != hostOne.ID {
|
|
t.Fatalf("expected hostOne to retain identifier %q, got %q", hostOne.ID, updatedHostOne.ID)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReportSkipsMetricsHistoryInMockMode(t *testing.T) {
|
|
previous := mock.IsMockEnabled()
|
|
mock.SetEnabled(true)
|
|
t.Cleanup(func() { mock.SetEnabled(previous) })
|
|
|
|
monitor := newTestMonitor(t)
|
|
report := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "docker-agent-1",
|
|
Version: "1.0.0",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "docker-demo",
|
|
Name: "Docker Demo",
|
|
MachineID: "docker-machine",
|
|
DockerVersion: "26.0.0",
|
|
TotalCPU: 4,
|
|
TotalMemoryBytes: 8 << 30,
|
|
UptimeSeconds: 120,
|
|
CPUUsagePercent: 37,
|
|
Memory: agentsdocker.MemoryMetric{
|
|
TotalBytes: 8 << 30,
|
|
UsedBytes: 4 << 30,
|
|
FreeBytes: 4 << 30,
|
|
Usage: 50,
|
|
},
|
|
Disks: []agentsdocker.Disk{
|
|
{
|
|
Device: "/dev/vda1",
|
|
Mountpoint: "/",
|
|
TotalBytes: 1000,
|
|
UsedBytes: 500,
|
|
FreeBytes: 500,
|
|
Usage: 50,
|
|
},
|
|
},
|
|
},
|
|
Containers: []agentsdocker.Container{
|
|
{
|
|
ID: "docker-cont-1",
|
|
Name: "api",
|
|
CPUPercent: 48,
|
|
MemoryPercent: 62,
|
|
RootFilesystemBytes: 1000,
|
|
WritableLayerBytes: 240,
|
|
NetworkRXBytes: 120,
|
|
NetworkTXBytes: 80,
|
|
BlockIO: &agentsdocker.ContainerBlockIO{ReadBytes: 220, WriteBytes: 140},
|
|
},
|
|
},
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
|
|
host, err := monitor.ApplyDockerReport(report, nil)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport: %v", err)
|
|
}
|
|
|
|
if got := monitor.metricsHistory.GetGuestMetrics("dockerHost:"+host.ID, "cpu", time.Hour); len(got) != 0 {
|
|
t.Fatalf("expected mock mode to skip docker host metrics history, got %+v", got)
|
|
}
|
|
if got := monitor.metricsHistory.GetGuestMetrics("docker:docker-cont-1", "cpu", time.Hour); len(got) != 0 {
|
|
t.Fatalf("expected mock mode to skip docker container metrics history, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReportIncludesContainerDiskDetails(t *testing.T) {
|
|
timestamp := time.Now().UTC()
|
|
report := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "agent-1",
|
|
Version: "1.2.3",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "disk-host",
|
|
},
|
|
Containers: []agentsdocker.Container{
|
|
{
|
|
ID: "ctr-1",
|
|
Name: "app",
|
|
WritableLayerBytes: 512 * 1024 * 1024,
|
|
RootFilesystemBytes: 2 * 1024 * 1024 * 1024,
|
|
BlockIO: &agentsdocker.ContainerBlockIO{
|
|
ReadBytes: 123456,
|
|
WriteBytes: 654321,
|
|
},
|
|
Mounts: []agentsdocker.ContainerMount{
|
|
{
|
|
Type: "bind",
|
|
Source: "/srv/app/config",
|
|
Destination: "/config",
|
|
Mode: "rw",
|
|
RW: true,
|
|
Propagation: "rprivate",
|
|
Name: "",
|
|
Driver: "",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Timestamp: timestamp,
|
|
}
|
|
|
|
monitor := newTestMonitor(t)
|
|
host, err := monitor.ApplyDockerReport(report, nil)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport returned error: %v", err)
|
|
}
|
|
|
|
if len(host.Containers) != 1 {
|
|
t.Fatalf("expected 1 container, got %d", len(host.Containers))
|
|
}
|
|
|
|
container := host.Containers[0]
|
|
if container.WritableLayerBytes != 512*1024*1024 {
|
|
t.Fatalf("expected writable layer bytes to match, got %d", container.WritableLayerBytes)
|
|
}
|
|
if container.RootFilesystemBytes != 2*1024*1024*1024 {
|
|
t.Fatalf("expected root filesystem bytes to match, got %d", container.RootFilesystemBytes)
|
|
}
|
|
|
|
if container.BlockIO == nil {
|
|
t.Fatalf("expected block IO stats to be populated")
|
|
}
|
|
if container.BlockIO.ReadBytes != 123456 || container.BlockIO.WriteBytes != 654321 {
|
|
t.Fatalf("unexpected block IO values: %+v", container.BlockIO)
|
|
}
|
|
if container.BlockIO.ReadRateBytesPerSecond != nil || container.BlockIO.WriteRateBytesPerSecond != nil {
|
|
t.Fatalf("expected block IO rates to be unset on first sample: %+v", container.BlockIO)
|
|
}
|
|
|
|
if len(container.Mounts) != 1 {
|
|
t.Fatalf("expected mounts to be preserved, got %d", len(container.Mounts))
|
|
}
|
|
mount := container.Mounts[0]
|
|
if mount.Source != "/srv/app/config" || mount.Destination != "/config" || !mount.RW {
|
|
t.Fatalf("unexpected mount payload: %+v", mount)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReportMigratesMetadataWhenContainerRuntimeIDChanges(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
baseTimestamp := time.Now().UTC()
|
|
firstReport := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "agent-1",
|
|
Version: "1.0.0",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "docker-host-1",
|
|
MachineID: "machine-1",
|
|
},
|
|
Containers: []agentsdocker.Container{
|
|
{ID: "container-old", Name: "app"},
|
|
},
|
|
Timestamp: baseTimestamp,
|
|
}
|
|
|
|
host, err := monitor.ApplyDockerReport(firstReport, nil)
|
|
if err != nil {
|
|
t.Fatalf("first ApplyDockerReport failed: %v", err)
|
|
}
|
|
if host.ID == "" {
|
|
t.Fatal("expected docker host ID")
|
|
}
|
|
if err := monitor.dockerMetadataStore.Set(host.ID+":container:container-old", &config.DockerMetadata{
|
|
CustomURL: "https://app.internal",
|
|
}); err != nil {
|
|
t.Fatalf("seed docker metadata: %v", err)
|
|
}
|
|
|
|
secondReport := firstReport
|
|
secondReport.Timestamp = baseTimestamp.Add(30 * time.Second)
|
|
secondReport.Containers = []agentsdocker.Container{
|
|
{ID: "container-new", Name: "app"},
|
|
}
|
|
|
|
host, err = monitor.ApplyDockerReport(secondReport, nil)
|
|
if err != nil {
|
|
t.Fatalf("second ApplyDockerReport failed: %v", err)
|
|
}
|
|
|
|
meta := monitor.dockerMetadataStore.Get(host.ID + ":container:container-new")
|
|
if meta == nil {
|
|
t.Fatalf("expected migrated metadata for recreated container")
|
|
}
|
|
if meta.CustomURL != "https://app.internal" {
|
|
t.Fatalf("expected migrated custom URL, got %#v", meta)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReportSkipsMetadataMigrationForAmbiguousContainerNames(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
baseTimestamp := time.Now().UTC()
|
|
firstReport := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "agent-ambiguous",
|
|
Version: "1.0.0",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "docker-host-ambiguous",
|
|
MachineID: "machine-ambiguous",
|
|
},
|
|
Containers: []agentsdocker.Container{
|
|
{ID: "container-old-a", Name: "/app"},
|
|
{ID: "container-old-b", Name: "app"},
|
|
},
|
|
Timestamp: baseTimestamp,
|
|
}
|
|
|
|
host, err := monitor.ApplyDockerReport(firstReport, nil)
|
|
if err != nil {
|
|
t.Fatalf("first ApplyDockerReport failed: %v", err)
|
|
}
|
|
if err := monitor.dockerMetadataStore.Set(host.ID+":container:container-old-a", &config.DockerMetadata{
|
|
CustomURL: "https://ambiguous.internal",
|
|
}); err != nil {
|
|
t.Fatalf("seed docker metadata: %v", err)
|
|
}
|
|
|
|
secondReport := firstReport
|
|
secondReport.Timestamp = baseTimestamp.Add(30 * time.Second)
|
|
secondReport.Containers = []agentsdocker.Container{
|
|
{ID: "container-new", Name: "app"},
|
|
}
|
|
|
|
host, err = monitor.ApplyDockerReport(secondReport, nil)
|
|
if err != nil {
|
|
t.Fatalf("second ApplyDockerReport failed: %v", err)
|
|
}
|
|
|
|
meta := monitor.dockerMetadataStore.Get(host.ID + ":container:container-new")
|
|
if meta != nil {
|
|
t.Fatalf("expected ambiguous metadata migration to be skipped, got %#v", meta)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerMetadataToUnifiedResourcesAddsContainerCustomURL(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
if err := monitor.dockerMetadataStore.Set("docker-host-1:container:container-new", &config.DockerMetadata{
|
|
CustomURL: " https://app.internal ",
|
|
}); err != nil {
|
|
t.Fatalf("seed docker metadata: %v", err)
|
|
}
|
|
|
|
resources := []unifiedresources.Resource{
|
|
{
|
|
ID: "resource:app-container:app",
|
|
Type: unifiedresources.ResourceTypeAppContainer,
|
|
Docker: &unifiedresources.DockerData{
|
|
HostSourceID: " docker-host-1 ",
|
|
ContainerID: " container-new ",
|
|
},
|
|
},
|
|
}
|
|
|
|
got := monitor.applyDockerMetadataToUnifiedResources(resources)
|
|
if got[0].CustomURL != "https://app.internal" {
|
|
t.Fatalf("CustomURL = %q, want migrated Docker metadata URL", got[0].CustomURL)
|
|
}
|
|
if resources[0].CustomURL != "" {
|
|
t.Fatalf("applyDockerMetadataToUnifiedResources mutated input CustomURL to %q", resources[0].CustomURL)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerMetadataToUnifiedResourcesKeepsResourceCustomURL(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
if err := monitor.dockerMetadataStore.Set("docker-host-1:container:container-new", &config.DockerMetadata{
|
|
CustomURL: "https://metadata.internal",
|
|
}); err != nil {
|
|
t.Fatalf("seed docker metadata: %v", err)
|
|
}
|
|
|
|
resources := []unifiedresources.Resource{
|
|
{
|
|
ID: "resource:app-container:app",
|
|
Type: unifiedresources.ResourceTypeAppContainer,
|
|
CustomURL: "https://resource.internal",
|
|
Docker: &unifiedresources.DockerData{
|
|
HostSourceID: "docker-host-1",
|
|
ContainerID: "container-new",
|
|
},
|
|
},
|
|
}
|
|
|
|
got := monitor.applyDockerMetadataToUnifiedResources(resources)
|
|
if got[0].CustomURL != "https://resource.internal" {
|
|
t.Fatalf("CustomURL = %q, want existing resource URL to win", got[0].CustomURL)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReportComputesContainerNetworkAndDiskRates(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
baseTime := time.Now().UTC()
|
|
|
|
baseReport := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "agent-rates",
|
|
Version: "1.2.3",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "rates-host",
|
|
},
|
|
Containers: []agentsdocker.Container{
|
|
{
|
|
ID: "ctr-rates",
|
|
Name: "api",
|
|
NetworkRXBytes: 10_000,
|
|
NetworkTXBytes: 20_000,
|
|
BlockIO: &agentsdocker.ContainerBlockIO{
|
|
ReadBytes: 5_000,
|
|
WriteBytes: 7_000,
|
|
},
|
|
},
|
|
},
|
|
Timestamp: baseTime,
|
|
}
|
|
|
|
first, err := monitor.ApplyDockerReport(baseReport, nil)
|
|
if err != nil {
|
|
t.Fatalf("first ApplyDockerReport returned error: %v", err)
|
|
}
|
|
if len(first.Containers) != 1 {
|
|
t.Fatalf("expected 1 container on first report, got %d", len(first.Containers))
|
|
}
|
|
if first.Containers[0].NetInRate != 0 || first.Containers[0].NetOutRate != 0 {
|
|
t.Fatalf("expected network rates unset on first sample, got in=%f out=%f", first.Containers[0].NetInRate, first.Containers[0].NetOutRate)
|
|
}
|
|
if first.Containers[0].BlockIO == nil {
|
|
t.Fatal("expected block IO on first sample")
|
|
}
|
|
if first.Containers[0].BlockIO.ReadRateBytesPerSecond != nil || first.Containers[0].BlockIO.WriteRateBytesPerSecond != nil {
|
|
t.Fatalf("expected disk rates unset on first sample, got %+v", first.Containers[0].BlockIO)
|
|
}
|
|
|
|
secondReport := baseReport
|
|
secondReport.Timestamp = baseTime.Add(30 * time.Second)
|
|
secondReport.Containers = []agentsdocker.Container{
|
|
{
|
|
ID: "ctr-rates",
|
|
Name: "api",
|
|
NetworkRXBytes: 16_000,
|
|
NetworkTXBytes: 29_000,
|
|
BlockIO: &agentsdocker.ContainerBlockIO{
|
|
ReadBytes: 8_000,
|
|
WriteBytes: 10_000,
|
|
},
|
|
},
|
|
}
|
|
|
|
second, err := monitor.ApplyDockerReport(secondReport, nil)
|
|
if err != nil {
|
|
t.Fatalf("second ApplyDockerReport returned error: %v", err)
|
|
}
|
|
if len(second.Containers) != 1 {
|
|
t.Fatalf("expected 1 container on second report, got %d", len(second.Containers))
|
|
}
|
|
container := second.Containers[0]
|
|
if container.NetInRate <= 0 {
|
|
t.Fatalf("expected positive net in rate, got %f", container.NetInRate)
|
|
}
|
|
if container.NetOutRate <= 0 {
|
|
t.Fatalf("expected positive net out rate, got %f", container.NetOutRate)
|
|
}
|
|
if container.BlockIO == nil {
|
|
t.Fatal("expected block IO on second sample")
|
|
}
|
|
if container.BlockIO.ReadRateBytesPerSecond == nil || *container.BlockIO.ReadRateBytesPerSecond <= 0 {
|
|
t.Fatalf("expected positive disk read rate, got %+v", container.BlockIO.ReadRateBytesPerSecond)
|
|
}
|
|
if container.BlockIO.WriteRateBytesPerSecond == nil || *container.BlockIO.WriteRateBytesPerSecond <= 0 {
|
|
t.Fatalf("expected positive disk write rate, got %+v", container.BlockIO.WriteRateBytesPerSecond)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReportPodmanRuntimeMetadata(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
report := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "agent-podman",
|
|
Version: "2.0.0",
|
|
IntervalSeconds: 60,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "podman-host",
|
|
Runtime: "podman",
|
|
RuntimeVersion: "4.9.3",
|
|
DockerVersion: "",
|
|
},
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
|
|
host, err := monitor.ApplyDockerReport(report, nil)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport returned error: %v", err)
|
|
}
|
|
|
|
if host.Runtime != "podman" {
|
|
t.Fatalf("expected runtime podman, got %q", host.Runtime)
|
|
}
|
|
if host.RuntimeVersion != "4.9.3" {
|
|
t.Fatalf("expected runtime version 4.9.3, got %q", host.RuntimeVersion)
|
|
}
|
|
if host.DockerVersion != "4.9.3" {
|
|
t.Fatalf("expected docker version fallback to runtime version, got %q", host.DockerVersion)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReportDerivesDockerSecurityPosture(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
report := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "agent-secure",
|
|
Version: "2.0.0",
|
|
IntervalSeconds: 60,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "secure-docker-host",
|
|
Runtime: "docker",
|
|
Security: &agentsdocker.HostSecurityInfo{
|
|
AuthorizationPlugins: []string{"opa", " audit "},
|
|
},
|
|
},
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
|
|
host, err := monitor.ApplyDockerReport(report, nil)
|
|
if err != nil {
|
|
t.Fatalf("ApplyDockerReport returned error: %v", err)
|
|
}
|
|
if host.Security == nil {
|
|
t.Fatalf("expected host security posture to be populated")
|
|
}
|
|
if !host.Security.MutatingCommandsBlocked {
|
|
t.Fatalf("expected mutating commands to be blocked")
|
|
}
|
|
if got := host.Security.AuthorizationPlugins; len(got) != 2 || got[0] != "opa" || got[1] != "audit" {
|
|
t.Fatalf("expected normalized authorization plugins, got %#v", got)
|
|
}
|
|
if !strings.Contains(host.Security.MutatingCommandsBlockedReason, "GO-2026-4887") {
|
|
t.Fatalf("expected advisory reason, got %q", host.Security.MutatingCommandsBlockedReason)
|
|
}
|
|
}
|
|
|
|
func TestConvertDockerServices(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("nil input returns nil", func(t *testing.T) {
|
|
t.Parallel()
|
|
result := convertDockerServices(nil)
|
|
if result != nil {
|
|
t.Fatalf("expected nil, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("empty slice returns nil", func(t *testing.T) {
|
|
t.Parallel()
|
|
result := convertDockerServices([]agentsdocker.Service{})
|
|
if result != nil {
|
|
t.Fatalf("expected nil, got %v", result)
|
|
}
|
|
})
|
|
|
|
t.Run("basic fields are copied", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-123",
|
|
Name: "web",
|
|
Stack: "mystack",
|
|
Image: "nginx:latest",
|
|
Mode: "replicated",
|
|
DesiredTasks: 3,
|
|
RunningTasks: 2,
|
|
CompletedTasks: 1,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 service, got %d", len(result))
|
|
}
|
|
|
|
svc := result[0]
|
|
if svc.ID != "svc-123" {
|
|
t.Errorf("ID = %q, want svc-123", svc.ID)
|
|
}
|
|
if svc.Name != "web" {
|
|
t.Errorf("Name = %q, want web", svc.Name)
|
|
}
|
|
if svc.Stack != "mystack" {
|
|
t.Errorf("Stack = %q, want mystack", svc.Stack)
|
|
}
|
|
if svc.Image != "nginx:latest" {
|
|
t.Errorf("Image = %q, want nginx:latest", svc.Image)
|
|
}
|
|
if svc.Mode != "replicated" {
|
|
t.Errorf("Mode = %q, want replicated", svc.Mode)
|
|
}
|
|
if svc.DesiredTasks != 3 {
|
|
t.Errorf("DesiredTasks = %d, want 3", svc.DesiredTasks)
|
|
}
|
|
if svc.RunningTasks != 2 {
|
|
t.Errorf("RunningTasks = %d, want 2", svc.RunningTasks)
|
|
}
|
|
if svc.CompletedTasks != 1 {
|
|
t.Errorf("CompletedTasks = %d, want 1", svc.CompletedTasks)
|
|
}
|
|
})
|
|
|
|
t.Run("labels are cloned when present", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
Labels: map[string]string{
|
|
"env": "prod",
|
|
"version": "1.0",
|
|
},
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].Labels == nil {
|
|
t.Fatal("expected labels to be present")
|
|
}
|
|
if result[0].Labels["env"] != "prod" {
|
|
t.Errorf("Labels[env] = %q, want prod", result[0].Labels["env"])
|
|
}
|
|
if result[0].Labels["version"] != "1.0" {
|
|
t.Errorf("Labels[version] = %q, want 1.0", result[0].Labels["version"])
|
|
}
|
|
|
|
// Verify it's a clone, not the same map
|
|
input[0].Labels["env"] = "modified"
|
|
if result[0].Labels["env"] == "modified" {
|
|
t.Error("labels should be cloned, not shared")
|
|
}
|
|
})
|
|
|
|
t.Run("empty labels are not copied", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
Labels: map[string]string{},
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].Labels != nil {
|
|
t.Errorf("expected nil labels for empty map, got %v", result[0].Labels)
|
|
}
|
|
})
|
|
|
|
t.Run("nil labels stay nil", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
Labels: nil,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].Labels != nil {
|
|
t.Errorf("expected nil labels, got %v", result[0].Labels)
|
|
}
|
|
})
|
|
|
|
t.Run("endpoint ports are converted when present", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
EndpointPorts: []agentsdocker.ServicePort{
|
|
{
|
|
Name: "http",
|
|
Protocol: "tcp",
|
|
TargetPort: 80,
|
|
PublishedPort: 8080,
|
|
PublishMode: "ingress",
|
|
},
|
|
{
|
|
Name: "https",
|
|
Protocol: "tcp",
|
|
TargetPort: 443,
|
|
PublishedPort: 8443,
|
|
PublishMode: "host",
|
|
},
|
|
},
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if len(result[0].EndpointPorts) != 2 {
|
|
t.Fatalf("expected 2 ports, got %d", len(result[0].EndpointPorts))
|
|
}
|
|
|
|
port1 := result[0].EndpointPorts[0]
|
|
if port1.Name != "http" {
|
|
t.Errorf("port[0].Name = %q, want http", port1.Name)
|
|
}
|
|
if port1.Protocol != "tcp" {
|
|
t.Errorf("port[0].Protocol = %q, want tcp", port1.Protocol)
|
|
}
|
|
if port1.TargetPort != 80 {
|
|
t.Errorf("port[0].TargetPort = %d, want 80", port1.TargetPort)
|
|
}
|
|
if port1.PublishedPort != 8080 {
|
|
t.Errorf("port[0].PublishedPort = %d, want 8080", port1.PublishedPort)
|
|
}
|
|
if port1.PublishMode != "ingress" {
|
|
t.Errorf("port[0].PublishMode = %q, want ingress", port1.PublishMode)
|
|
}
|
|
|
|
port2 := result[0].EndpointPorts[1]
|
|
if port2.PublishMode != "host" {
|
|
t.Errorf("port[1].PublishMode = %q, want host", port2.PublishMode)
|
|
}
|
|
})
|
|
|
|
t.Run("empty endpoint ports are not copied", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
EndpointPorts: []agentsdocker.ServicePort{},
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].EndpointPorts != nil {
|
|
t.Errorf("expected nil endpoint ports for empty slice, got %v", result[0].EndpointPorts)
|
|
}
|
|
})
|
|
|
|
t.Run("nil endpoint ports stay nil", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
EndpointPorts: nil,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].EndpointPorts != nil {
|
|
t.Errorf("expected nil endpoint ports, got %v", result[0].EndpointPorts)
|
|
}
|
|
})
|
|
|
|
t.Run("update status is converted when present", func(t *testing.T) {
|
|
t.Parallel()
|
|
completedAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
UpdateStatus: &agentsdocker.ServiceUpdate{
|
|
State: "completed",
|
|
Message: "update succeeded",
|
|
CompletedAt: &completedAt,
|
|
},
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].UpdateStatus == nil {
|
|
t.Fatal("expected update status to be present")
|
|
}
|
|
if result[0].UpdateStatus.State != "completed" {
|
|
t.Errorf("UpdateStatus.State = %q, want completed", result[0].UpdateStatus.State)
|
|
}
|
|
if result[0].UpdateStatus.Message != "update succeeded" {
|
|
t.Errorf("UpdateStatus.Message = %q, want update succeeded", result[0].UpdateStatus.Message)
|
|
}
|
|
if result[0].UpdateStatus.CompletedAt == nil {
|
|
t.Fatal("expected CompletedAt to be set")
|
|
}
|
|
if !result[0].UpdateStatus.CompletedAt.Equal(completedAt) {
|
|
t.Errorf("UpdateStatus.CompletedAt = %v, want %v", result[0].UpdateStatus.CompletedAt, completedAt)
|
|
}
|
|
})
|
|
|
|
t.Run("update status with nil CompletedAt", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
UpdateStatus: &agentsdocker.ServiceUpdate{
|
|
State: "updating",
|
|
Message: "in progress",
|
|
CompletedAt: nil,
|
|
},
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].UpdateStatus == nil {
|
|
t.Fatal("expected update status to be present")
|
|
}
|
|
if result[0].UpdateStatus.CompletedAt != nil {
|
|
t.Errorf("expected nil CompletedAt, got %v", result[0].UpdateStatus.CompletedAt)
|
|
}
|
|
})
|
|
|
|
t.Run("update status with zero CompletedAt", func(t *testing.T) {
|
|
t.Parallel()
|
|
zeroTime := time.Time{}
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
UpdateStatus: &agentsdocker.ServiceUpdate{
|
|
State: "updating",
|
|
CompletedAt: &zeroTime,
|
|
},
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].UpdateStatus.CompletedAt != nil {
|
|
t.Errorf("expected nil CompletedAt for zero time, got %v", result[0].UpdateStatus.CompletedAt)
|
|
}
|
|
})
|
|
|
|
t.Run("nil update status stays nil", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
UpdateStatus: nil,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].UpdateStatus != nil {
|
|
t.Errorf("expected nil update status, got %v", result[0].UpdateStatus)
|
|
}
|
|
})
|
|
|
|
t.Run("CreatedAt is copied when valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
created := time.Date(2025, 1, 10, 8, 0, 0, 0, time.UTC)
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
CreatedAt: &created,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].CreatedAt == nil {
|
|
t.Fatal("expected CreatedAt to be set")
|
|
}
|
|
if !result[0].CreatedAt.Equal(created) {
|
|
t.Errorf("CreatedAt = %v, want %v", result[0].CreatedAt, created)
|
|
}
|
|
})
|
|
|
|
t.Run("nil CreatedAt stays nil", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
CreatedAt: nil,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].CreatedAt != nil {
|
|
t.Errorf("expected nil CreatedAt, got %v", result[0].CreatedAt)
|
|
}
|
|
})
|
|
|
|
t.Run("zero CreatedAt is not copied", func(t *testing.T) {
|
|
t.Parallel()
|
|
zeroTime := time.Time{}
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
CreatedAt: &zeroTime,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].CreatedAt != nil {
|
|
t.Errorf("expected nil CreatedAt for zero time, got %v", result[0].CreatedAt)
|
|
}
|
|
})
|
|
|
|
t.Run("UpdatedAt is copied when valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
updated := time.Date(2025, 1, 12, 14, 30, 0, 0, time.UTC)
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
UpdatedAt: &updated,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].UpdatedAt == nil {
|
|
t.Fatal("expected UpdatedAt to be set")
|
|
}
|
|
if !result[0].UpdatedAt.Equal(updated) {
|
|
t.Errorf("UpdatedAt = %v, want %v", result[0].UpdatedAt, updated)
|
|
}
|
|
})
|
|
|
|
t.Run("nil UpdatedAt stays nil", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
UpdatedAt: nil,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].UpdatedAt != nil {
|
|
t.Errorf("expected nil UpdatedAt, got %v", result[0].UpdatedAt)
|
|
}
|
|
})
|
|
|
|
t.Run("zero UpdatedAt is not copied", func(t *testing.T) {
|
|
t.Parallel()
|
|
zeroTime := time.Time{}
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-1",
|
|
Name: "web",
|
|
UpdatedAt: &zeroTime,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if result[0].UpdatedAt != nil {
|
|
t.Errorf("expected nil UpdatedAt for zero time, got %v", result[0].UpdatedAt)
|
|
}
|
|
})
|
|
|
|
t.Run("multiple services are converted", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []agentsdocker.Service{
|
|
{ID: "svc-1", Name: "web"},
|
|
{ID: "svc-2", Name: "api"},
|
|
{ID: "svc-3", Name: "worker"},
|
|
}
|
|
|
|
result := convertDockerServices(input)
|
|
if len(result) != 3 {
|
|
t.Fatalf("expected 3 services, got %d", len(result))
|
|
}
|
|
if result[0].ID != "svc-1" {
|
|
t.Errorf("result[0].ID = %q, want svc-1", result[0].ID)
|
|
}
|
|
if result[1].ID != "svc-2" {
|
|
t.Errorf("result[1].ID = %q, want svc-2", result[1].ID)
|
|
}
|
|
if result[2].ID != "svc-3" {
|
|
t.Errorf("result[2].ID = %q, want svc-3", result[2].ID)
|
|
}
|
|
})
|
|
|
|
t.Run("full service with all fields", func(t *testing.T) {
|
|
t.Parallel()
|
|
created := time.Date(2025, 1, 10, 8, 0, 0, 0, time.UTC)
|
|
updated := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
|
completedAt := time.Date(2025, 1, 15, 11, 30, 0, 0, time.UTC)
|
|
|
|
input := []agentsdocker.Service{{
|
|
ID: "svc-full",
|
|
Name: "fullservice",
|
|
Stack: "production",
|
|
Image: "myapp:v2.0",
|
|
Mode: "global",
|
|
DesiredTasks: 5,
|
|
RunningTasks: 5,
|
|
CompletedTasks: 0,
|
|
Labels: map[string]string{
|
|
"com.docker.stack.namespace": "production",
|
|
},
|
|
EndpointPorts: []agentsdocker.ServicePort{
|
|
{Name: "web", Protocol: "tcp", TargetPort: 8080, PublishedPort: 80, PublishMode: "ingress"},
|
|
},
|
|
UpdateStatus: &agentsdocker.ServiceUpdate{
|
|
State: "completed",
|
|
Message: "rollout complete",
|
|
CompletedAt: &completedAt,
|
|
},
|
|
CreatedAt: &created,
|
|
UpdatedAt: &updated,
|
|
}}
|
|
|
|
result := convertDockerServices(input)
|
|
if len(result) != 1 {
|
|
t.Fatalf("expected 1 service, got %d", len(result))
|
|
}
|
|
|
|
svc := result[0]
|
|
if svc.ID != "svc-full" {
|
|
t.Errorf("ID mismatch")
|
|
}
|
|
if svc.Mode != "global" {
|
|
t.Errorf("Mode = %q, want global", svc.Mode)
|
|
}
|
|
if svc.Labels["com.docker.stack.namespace"] != "production" {
|
|
t.Errorf("Labels mismatch")
|
|
}
|
|
if len(svc.EndpointPorts) != 1 || svc.EndpointPorts[0].PublishedPort != 80 {
|
|
t.Errorf("EndpointPorts mismatch")
|
|
}
|
|
if svc.UpdateStatus == nil || svc.UpdateStatus.State != "completed" {
|
|
t.Errorf("UpdateStatus mismatch")
|
|
}
|
|
if svc.CreatedAt == nil || !svc.CreatedAt.Equal(created) {
|
|
t.Errorf("CreatedAt mismatch")
|
|
}
|
|
if svc.UpdatedAt == nil || !svc.UpdatedAt.Equal(updated) {
|
|
t.Errorf("UpdatedAt mismatch")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestApplyDockerReport_MissingIdentifier(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
// Report with no agent ID and no hostname - should fail
|
|
report := agentsdocker.Report{
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "", // Empty hostname
|
|
},
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "", // Empty agent ID
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
_, err := monitor.ApplyDockerReport(report, nil)
|
|
if err == nil {
|
|
t.Error("expected error for missing identifier")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "missing") {
|
|
t.Errorf("expected 'missing' in error message, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReport_RemovedHostRejection(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
// Mark host as removed
|
|
hostID := "removed-docker-host"
|
|
removedAt := time.Now().Add(-1 * time.Hour)
|
|
monitor.mu.Lock()
|
|
monitor.removedDockerHosts[hostID] = removedAt
|
|
monitor.mu.Unlock()
|
|
|
|
report := agentsdocker.Report{
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: hostID,
|
|
},
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: hostID,
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
_, err := monitor.ApplyDockerReport(report, nil)
|
|
if err == nil {
|
|
t.Error("expected error for removed host")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "monitoring stopped") {
|
|
t.Errorf("expected 'monitoring stopped' in error message, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReport_TokenBoundToDifferentAgent(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
tokenID := "shared-token"
|
|
firstAgentID := "agent-first"
|
|
secondAgentID := "agent-second"
|
|
|
|
// Pre-bind token to first agent
|
|
monitor.mu.Lock()
|
|
monitor.dockerTokenBindings[tokenID] = firstAgentID
|
|
monitor.mu.Unlock()
|
|
|
|
// Report from second agent using same token
|
|
report := agentsdocker.Report{
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "second-host",
|
|
},
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: secondAgentID,
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
token := &config.APITokenRecord{ID: tokenID, Name: "TestToken"}
|
|
|
|
_, err := monitor.ApplyDockerReport(report, token)
|
|
if err == nil {
|
|
t.Error("expected error for token bound to different agent")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "already in use by agent") {
|
|
t.Errorf("expected 'already in use by agent' in error message, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReport_MissingHostname(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
// Report with agent ID but no hostname
|
|
report := agentsdocker.Report{
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "", // Missing hostname
|
|
},
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "agent-with-id",
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
_, err := monitor.ApplyDockerReport(report, nil)
|
|
if err == nil {
|
|
t.Error("expected error for missing hostname")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "missing hostname") {
|
|
t.Errorf("expected 'missing hostname' in error message, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReport_ReconnectWithoutMachineID(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
// Report with NO AgentID and NO MachineID, but valid Hostname.
|
|
// This simulates a containerized agent without persistent ID or machine-id mount.
|
|
report := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "", // Empty AgentID
|
|
Version: "1.0.0",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "reconnect-host",
|
|
MachineID: "", // Empty MachineID
|
|
},
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
|
|
token := &config.APITokenRecord{ID: "token-reconnect", Name: "Test Token"}
|
|
|
|
// First report - should succeed
|
|
host1, err := monitor.ApplyDockerReport(report, token)
|
|
if err != nil {
|
|
t.Fatalf("First ApplyDockerReport failed: %v", err)
|
|
}
|
|
if host1.ID == "" {
|
|
t.Fatal("First host has empty ID")
|
|
}
|
|
|
|
// Second report - identical to first
|
|
// This must succeed by matching the existing host via Hostname + Token
|
|
host2, err := monitor.ApplyDockerReport(report, token)
|
|
if err != nil {
|
|
t.Fatalf("Second ApplyDockerReport failed: %v", err)
|
|
}
|
|
|
|
// Verify that we matched the existing host instead of creating a new one
|
|
if host1.ID != host2.ID {
|
|
t.Errorf("Host IDs mismatch: %q vs %q - expected them to remain the same", host1.ID, host2.ID)
|
|
}
|
|
}
|
|
|
|
func TestApplyDockerReport_SameHostnameDifferentTokens(t *testing.T) {
|
|
monitor := newTestMonitor(t)
|
|
|
|
// Two agents with the same hostname but different tokens should be treated as DIFFERENT hosts.
|
|
// This tests that our Hostname+Token matching doesn't incorrectly merge unrelated agents.
|
|
reportA := agentsdocker.Report{
|
|
Agent: agentsdocker.AgentInfo{
|
|
ID: "", // Empty AgentID
|
|
Version: "1.0.0",
|
|
IntervalSeconds: 30,
|
|
},
|
|
Host: agentsdocker.HostInfo{
|
|
Hostname: "shared-hostname",
|
|
MachineID: "", // Empty MachineID
|
|
},
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
|
|
tokenA := &config.APITokenRecord{ID: "token-A", Name: "Token A"}
|
|
tokenB := &config.APITokenRecord{ID: "token-B", Name: "Token B"}
|
|
|
|
// Agent A reports first
|
|
hostA, err := monitor.ApplyDockerReport(reportA, tokenA)
|
|
if err != nil {
|
|
t.Fatalf("Agent A report failed: %v", err)
|
|
}
|
|
|
|
// Agent B reports with same hostname but different token
|
|
hostB, err := monitor.ApplyDockerReport(reportA, tokenB)
|
|
if err != nil {
|
|
t.Fatalf("Agent B report failed: %v", err)
|
|
}
|
|
|
|
// They should have DIFFERENT IDs since they use different tokens
|
|
if hostA.ID == hostB.ID {
|
|
t.Errorf("Hosts should have different IDs but both have %q", hostA.ID)
|
|
}
|
|
|
|
// Verify both hosts exist in state
|
|
hosts := monitor.state.GetDockerHosts()
|
|
if len(hosts) != 2 {
|
|
t.Errorf("Expected 2 hosts in state, got %d", len(hosts))
|
|
}
|
|
}
|