package monitoring import ( "context" "errors" "math/rand" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) type emptyGuestMetadataClient struct { stubPVEClient } func (emptyGuestMetadataClient) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error) { return []proxmox.VMNetworkInterface{}, nil } func (emptyGuestMetadataClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return map[string]interface{}{}, nil } func (emptyGuestMetadataClient) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { return "", nil } type emptyThenPopulatedGuestMetadataClient struct { stubPVEClient networkCalls int } func (c *emptyThenPopulatedGuestMetadataClient) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error) { c.networkCalls++ if c.networkCalls == 1 { return []proxmox.VMNetworkInterface{}, nil } return []proxmox.VMNetworkInterface{ { Name: "Ethernet0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.50", Prefix: 24}, }, }, }, nil } func (*emptyThenPopulatedGuestMetadataClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return map[string]interface{}{}, nil } func (*emptyThenPopulatedGuestMetadataClient) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { return "", nil } type identityThenNetworkGuestMetadataClient struct { stubPVEClient networkCalls int } func (c *identityThenNetworkGuestMetadataClient) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error) { c.networkCalls++ if c.networkCalls == 1 { return []proxmox.VMNetworkInterface{}, nil } return []proxmox.VMNetworkInterface{ { Name: "Ethernet0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.50", Prefix: 24}, }, }, }, nil } func (*identityThenNetworkGuestMetadataClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return map[string]interface{}{ "result": map[string]interface{}{ "name": "Microsoft Windows", "version": "11", }, }, nil } func (*identityThenNetworkGuestMetadataClient) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { return "8.2.2", nil } type ipOnlyThenNetworkGuestMetadataClient struct { stubPVEClient networkCalls int } func (c *ipOnlyThenNetworkGuestMetadataClient) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error) { c.networkCalls++ if c.networkCalls == 1 { return []proxmox.VMNetworkInterface{ { IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.60", Prefix: 24}, }, }, }, nil } return []proxmox.VMNetworkInterface{ { Name: "Ethernet0", HardwareAddr: "00:11:22:33:44:66", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.60", Prefix: 24}, }, }, }, nil } func (*ipOnlyThenNetworkGuestMetadataClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { return map[string]interface{}{}, nil } func (*ipOnlyThenNetworkGuestMetadataClient) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { return "", nil } func TestGuestMetadataCacheKey(t *testing.T) { t.Parallel() tests := []struct { name string instanceName string nodeName string vmid int want string }{ { name: "simple values", instanceName: "pve", nodeName: "node1", vmid: 100, want: "pve|node1|100", }, { name: "empty instance name", instanceName: "", nodeName: "node1", vmid: 100, want: "|node1|100", }, { name: "empty node name", instanceName: "pve", nodeName: "", vmid: 100, want: "pve||100", }, { name: "zero vmid", instanceName: "pve", nodeName: "node1", vmid: 0, want: "pve|node1|0", }, { name: "large vmid", instanceName: "cluster-01", nodeName: "pve-node-2", vmid: 999999, want: "cluster-01|pve-node-2|999999", }, { name: "all empty with zero", instanceName: "", nodeName: "", vmid: 0, want: "||0", }, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := guestMetadataCacheKey(tc.instanceName, tc.nodeName, tc.vmid) if got != tc.want { t.Fatalf("guestMetadataCacheKey(%q, %q, %d) = %q, want %q", tc.instanceName, tc.nodeName, tc.vmid, got, tc.want) } }) } } func TestCloneStringSlice(t *testing.T) { t.Parallel() tests := []struct { name string src []string want []string }{ { name: "nil slice", src: nil, want: nil, }, { name: "empty slice", src: []string{}, want: nil, }, { name: "single element", src: []string{"a"}, want: []string{"a"}, }, { name: "multiple elements", src: []string{"a", "b", "c"}, want: []string{"a", "b", "c"}, }, { name: "with empty strings", src: []string{"", "a", ""}, want: []string{"", "a", ""}, }, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := cloneStringSlice(tc.src) // Check equality if len(got) != len(tc.want) { t.Fatalf("cloneStringSlice returned slice of len %d, want %d", len(got), len(tc.want)) } for i := range got { if got[i] != tc.want[i] { t.Fatalf("cloneStringSlice()[%d] = %q, want %q", i, got[i], tc.want[i]) } } // Verify it's a copy (not same backing array) for non-empty slices if len(tc.src) > 0 && got != nil { tc.src[0] = "modified" if got[0] == "modified" { t.Fatal("cloneStringSlice did not create independent copy") } } }) } } func TestCloneGuestNetworkInterfaces(t *testing.T) { t.Parallel() tests := []struct { name string src []models.GuestNetworkInterface want []models.GuestNetworkInterface }{ { name: "nil slice", src: nil, want: nil, }, { name: "empty slice", src: []models.GuestNetworkInterface{}, want: nil, }, { name: "single interface no addresses", src: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55"}, }, want: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55"}, }, }, { name: "single interface with addresses", src: []models.GuestNetworkInterface{ { Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10", "10.0.0.5"}, RXBytes: 1024, TXBytes: 512, }, }, want: []models.GuestNetworkInterface{ { Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10", "10.0.0.5"}, RXBytes: 1024, TXBytes: 512, }, }, }, { name: "multiple interfaces", src: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, {Name: "eth1", MAC: "AA:BB:CC:DD:EE:FF", Addresses: []string{"10.0.0.5"}}, }, want: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, {Name: "eth1", MAC: "AA:BB:CC:DD:EE:FF", Addresses: []string{"10.0.0.5"}}, }, }, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := cloneGuestNetworkInterfaces(tc.src) if len(got) != len(tc.want) { t.Fatalf("cloneGuestNetworkInterfaces returned slice of len %d, want %d", len(got), len(tc.want)) } for i := range got { if got[i].Name != tc.want[i].Name { t.Errorf("interface[%d].Name = %q, want %q", i, got[i].Name, tc.want[i].Name) } if got[i].MAC != tc.want[i].MAC { t.Errorf("interface[%d].MAC = %q, want %q", i, got[i].MAC, tc.want[i].MAC) } if got[i].RXBytes != tc.want[i].RXBytes { t.Errorf("interface[%d].RXBytes = %d, want %d", i, got[i].RXBytes, tc.want[i].RXBytes) } if got[i].TXBytes != tc.want[i].TXBytes { t.Errorf("interface[%d].TXBytes = %d, want %d", i, got[i].TXBytes, tc.want[i].TXBytes) } if len(got[i].Addresses) != len(tc.want[i].Addresses) { t.Errorf("interface[%d].Addresses length = %d, want %d", i, len(got[i].Addresses), len(tc.want[i].Addresses)) } for j := range got[i].Addresses { if got[i].Addresses[j] != tc.want[i].Addresses[j] { t.Errorf("interface[%d].Addresses[%d] = %q, want %q", i, j, got[i].Addresses[j], tc.want[i].Addresses[j]) } } } // Verify addresses are deep-copied (not shared) if len(tc.src) > 0 && len(tc.src[0].Addresses) > 0 && got != nil { original := tc.src[0].Addresses[0] tc.src[0].Addresses[0] = "modified" if got[0].Addresses[0] == "modified" { t.Fatal("cloneGuestNetworkInterfaces did not deep copy addresses") } tc.src[0].Addresses[0] = original // restore for parallel safety } }) } } func TestProcessGuestNetworkInterfaces(t *testing.T) { t.Parallel() tests := []struct { name string raw []proxmox.VMNetworkInterface wantIPs []string wantIfaces []models.GuestNetworkInterface }{ { name: "nil input", raw: nil, wantIPs: []string{}, wantIfaces: []models.GuestNetworkInterface{}, }, { name: "empty input", raw: []proxmox.VMNetworkInterface{}, wantIPs: []string{}, wantIfaces: []models.GuestNetworkInterface{}, }, { name: "single interface with valid IP", raw: []proxmox.VMNetworkInterface{ { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.10", Prefix: 24}, }, }, }, wantIPs: []string{"192.168.1.10"}, wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, }, }, { name: "filter loopback 127.x.x.x", raw: []proxmox.VMNetworkInterface{ { Name: "lo", HardwareAddr: "00:00:00:00:00:00", IPAddresses: []proxmox.VMIpAddress{ {Address: "127.0.0.1", Prefix: 8}, {Address: "127.0.0.2", Prefix: 8}, }, }, }, wantIPs: []string{}, wantIfaces: []models.GuestNetworkInterface{}, }, { name: "preserve named interface when only link-local addresses are reported", raw: []proxmox.VMNetworkInterface{ { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: "fe80::1", Prefix: 64}, {Address: "FE80::abcd", Prefix: 64}, }, }, }, wantIPs: []string{}, wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: nil}, }, }, { name: "filter IPv6 loopback ::1", raw: []proxmox.VMNetworkInterface{ { Name: "lo", HardwareAddr: "00:00:00:00:00:00", IPAddresses: []proxmox.VMIpAddress{ {Address: "::1", Prefix: 128}, }, }, }, wantIPs: []string{}, wantIfaces: []models.GuestNetworkInterface{}, }, { name: "mixed valid and filtered IPs", raw: []proxmox.VMNetworkInterface{ { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.10", Prefix: 24}, {Address: "127.0.0.1", Prefix: 8}, {Address: "fe80::1", Prefix: 64}, {Address: "10.0.0.5", Prefix: 8}, }, }, }, wantIPs: []string{"10.0.0.5", "192.168.1.10"}, // sorted wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"10.0.0.5", "192.168.1.10"}}, }, }, { name: "deduplicate IPs within interface", raw: []proxmox.VMNetworkInterface{ { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.10", Prefix: 24}, {Address: "192.168.1.10", Prefix: 24}, {Address: "192.168.1.10", Prefix: 24}, }, }, }, wantIPs: []string{"192.168.1.10"}, wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, }, }, { name: "deduplicate IPs across interfaces", raw: []proxmox.VMNetworkInterface{ { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.10", Prefix: 24}, }, }, { Name: "eth1", HardwareAddr: "AA:BB:CC:DD:EE:FF", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.10", Prefix: 24}, // same IP on different interface }, }, }, wantIPs: []string{"192.168.1.10"}, // deduplicated globally wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, {Name: "eth1", MAC: "AA:BB:CC:DD:EE:FF", Addresses: []string{"192.168.1.10"}}, }, }, { name: "multiple interfaces sorted by name", raw: []proxmox.VMNetworkInterface{ { Name: "eth1", HardwareAddr: "AA:BB:CC:DD:EE:FF", IPAddresses: []proxmox.VMIpAddress{ {Address: "10.0.0.5", Prefix: 8}, }, }, { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.10", Prefix: 24}, }, }, }, wantIPs: []string{"10.0.0.5", "192.168.1.10"}, wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, {Name: "eth1", MAC: "AA:BB:CC:DD:EE:FF", Addresses: []string{"10.0.0.5"}}, }, }, { name: "interface with only traffic (no IPs) is included", raw: []proxmox.VMNetworkInterface{ { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: nil, Statistics: map[string]interface{}{"rx-bytes": float64(1024), "tx-bytes": float64(512)}, }, }, wantIPs: []string{}, wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: nil, RXBytes: 1024, TXBytes: 512}, }, }, { name: "interface with no IPs and no traffic keeps non-loopback identity", raw: []proxmox.VMNetworkInterface{ { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: nil, Statistics: nil, }, }, wantIPs: []string{}, wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: nil}, }, }, { name: "interface with name only still keeps non-loopback identity", raw: []proxmox.VMNetworkInterface{ { Name: "Ethernet 2", HardwareAddr: "", IPAddresses: nil, Statistics: nil, }, }, wantIPs: []string{}, wantIfaces: []models.GuestNetworkInterface{ {Name: "Ethernet 2", MAC: "", Addresses: nil}, }, }, { name: "loopback-only interface with no IPs and no traffic is excluded", raw: []proxmox.VMNetworkInterface{ { Name: "lo", HardwareAddr: "00:00:00:00:00:00", IPAddresses: nil, Statistics: nil, }, }, wantIPs: []string{}, wantIfaces: []models.GuestNetworkInterface{}, }, { name: "whitespace trimmed from name and MAC", raw: []proxmox.VMNetworkInterface{ { Name: " eth0 ", HardwareAddr: " 00:11:22:33:44:55 ", IPAddresses: []proxmox.VMIpAddress{ {Address: "192.168.1.10", Prefix: 24}, }, }, }, wantIPs: []string{"192.168.1.10"}, wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, }, }, { name: "whitespace trimmed from IP addresses", raw: []proxmox.VMNetworkInterface{ { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: " 192.168.1.10 ", Prefix: 24}, }, }, }, wantIPs: []string{"192.168.1.10"}, wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, }, }, { name: "empty IP addresses are skipped", raw: []proxmox.VMNetworkInterface{ { Name: "eth0", HardwareAddr: "00:11:22:33:44:55", IPAddresses: []proxmox.VMIpAddress{ {Address: "", Prefix: 0}, {Address: " ", Prefix: 0}, {Address: "192.168.1.10", Prefix: 24}, }, }, }, wantIPs: []string{"192.168.1.10"}, wantIfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, }, }, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() gotIPs, gotIfaces := processGuestNetworkInterfaces(tc.raw) // Check IPs if len(gotIPs) != len(tc.wantIPs) { t.Errorf("processGuestNetworkInterfaces() IPs length = %d, want %d", len(gotIPs), len(tc.wantIPs)) } for i := range gotIPs { if i >= len(tc.wantIPs) { break } if gotIPs[i] != tc.wantIPs[i] { t.Errorf("IPs[%d] = %q, want %q", i, gotIPs[i], tc.wantIPs[i]) } } // Check interfaces if len(gotIfaces) != len(tc.wantIfaces) { t.Errorf("processGuestNetworkInterfaces() interfaces length = %d, want %d", len(gotIfaces), len(tc.wantIfaces)) } for i := range gotIfaces { if i >= len(tc.wantIfaces) { break } if gotIfaces[i].Name != tc.wantIfaces[i].Name { t.Errorf("interface[%d].Name = %q, want %q", i, gotIfaces[i].Name, tc.wantIfaces[i].Name) } if gotIfaces[i].MAC != tc.wantIfaces[i].MAC { t.Errorf("interface[%d].MAC = %q, want %q", i, gotIfaces[i].MAC, tc.wantIfaces[i].MAC) } if gotIfaces[i].RXBytes != tc.wantIfaces[i].RXBytes { t.Errorf("interface[%d].RXBytes = %d, want %d", i, gotIfaces[i].RXBytes, tc.wantIfaces[i].RXBytes) } if gotIfaces[i].TXBytes != tc.wantIfaces[i].TXBytes { t.Errorf("interface[%d].TXBytes = %d, want %d", i, gotIfaces[i].TXBytes, tc.wantIfaces[i].TXBytes) } if len(gotIfaces[i].Addresses) != len(tc.wantIfaces[i].Addresses) { t.Errorf("interface[%d].Addresses length = %d, want %d", i, len(gotIfaces[i].Addresses), len(tc.wantIfaces[i].Addresses)) } for j := range gotIfaces[i].Addresses { if j >= len(tc.wantIfaces[i].Addresses) { break } if gotIfaces[i].Addresses[j] != tc.wantIfaces[i].Addresses[j] { t.Errorf("interface[%d].Addresses[%d] = %q, want %q", i, j, gotIfaces[i].Addresses[j], tc.wantIfaces[i].Addresses[j]) } } } }) } } func TestRetryGuestAgentCall(t *testing.T) { t.Parallel() t.Run("successful call on first attempt", func(t *testing.T) { t.Parallel() m := &Monitor{} callCount := 0 fn := func(ctx context.Context) (interface{}, error) { callCount++ return "success", nil } ctx := context.Background() result, err := m.retryGuestAgentCall(ctx, 50*time.Millisecond, 2, fn) if err != nil { t.Fatalf("expected no error, got %v", err) } if result != "success" { t.Fatalf("expected result 'success', got %v", result) } if callCount != 1 { t.Fatalf("expected 1 call, got %d", callCount) } }) t.Run("timeout error triggers retry and eventually succeeds", func(t *testing.T) { t.Parallel() m := &Monitor{} callCount := 0 fn := func(ctx context.Context) (interface{}, error) { callCount++ if callCount < 3 { return nil, errors.New("context deadline exceeded (timeout)") } return "success after retries", nil } ctx := context.Background() result, err := m.retryGuestAgentCall(ctx, 50*time.Millisecond, 3, fn) if err != nil { t.Fatalf("expected no error, got %v", err) } if result != "success after retries" { t.Fatalf("expected result 'success after retries', got %v", result) } if callCount != 3 { t.Fatalf("expected 3 calls, got %d", callCount) } }) t.Run("non-timeout error does not trigger retry", func(t *testing.T) { t.Parallel() m := &Monitor{} callCount := 0 nonTimeoutErr := errors.New("connection refused") fn := func(ctx context.Context) (interface{}, error) { callCount++ return nil, nonTimeoutErr } ctx := context.Background() result, err := m.retryGuestAgentCall(ctx, 50*time.Millisecond, 3, fn) if err == nil { t.Fatal("expected error, got nil") } if err.Error() != nonTimeoutErr.Error() { t.Fatalf("expected error %q, got %q", nonTimeoutErr.Error(), err.Error()) } if result != nil { t.Fatalf("expected nil result, got %v", result) } if callCount != 1 { t.Fatalf("expected 1 call (no retry for non-timeout), got %d", callCount) } }) t.Run("all retries exhausted returns last error", func(t *testing.T) { t.Parallel() m := &Monitor{} callCount := 0 fn := func(ctx context.Context) (interface{}, error) { callCount++ return nil, errors.New("persistent timeout error") } ctx := context.Background() result, err := m.retryGuestAgentCall(ctx, 50*time.Millisecond, 2, fn) if err == nil { t.Fatal("expected error, got nil") } if err.Error() != "persistent timeout error" { t.Fatalf("expected 'persistent timeout error', got %q", err.Error()) } if result != nil { t.Fatalf("expected nil result, got %v", result) } // maxRetries=2 means: attempt 0 (initial) + attempts 1,2 (retries) = 3 calls if callCount != 3 { t.Fatalf("expected 3 calls (1 initial + 2 retries), got %d", callCount) } }) t.Run("context cancellation during retry delay aborts early", func(t *testing.T) { t.Parallel() m := &Monitor{} callCount := 0 fn := func(ctx context.Context) (interface{}, error) { callCount++ return nil, errors.New("timeout error") } ctx, cancel := context.WithCancel(context.Background()) // Cancel context after first call completes but during retry delay go func() { time.Sleep(10 * time.Millisecond) cancel() }() result, err := m.retryGuestAgentCall(ctx, 50*time.Millisecond, 5, fn) if err == nil { t.Fatal("expected error, got nil") } if !errors.Is(err, context.Canceled) { t.Fatalf("expected context.Canceled error, got %v", err) } if result != nil { t.Fatalf("expected nil result, got %v", result) } // Should only have made 1 call before context was canceled during delay if callCount != 1 { t.Fatalf("expected 1 call before cancellation, got %d", callCount) } }) t.Run("zero retries means single attempt", func(t *testing.T) { t.Parallel() m := &Monitor{} callCount := 0 fn := func(ctx context.Context) (interface{}, error) { callCount++ return nil, errors.New("timeout error") } ctx := context.Background() result, err := m.retryGuestAgentCall(ctx, 50*time.Millisecond, 0, fn) if err == nil { t.Fatal("expected error, got nil") } if result != nil { t.Fatalf("expected nil result, got %v", result) } if callCount != 1 { t.Fatalf("expected 1 call with maxRetries=0, got %d", callCount) } }) } func TestFetchGuestAgentMetadataPreservesCachedValuesOnEmptyResponses(t *testing.T) { t.Parallel() key := guestMetadataCacheKey("pve", "node1", 100) cachedFetchedAt := time.Now().Add(-guestMetadataCacheTTL - time.Minute) monitor := &Monitor{ guestMetadataCache: map[string]guestMetadataCacheEntry{ key: { ipAddresses: []string{"192.168.1.10"}, networkInterfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, }, osName: "Ubuntu", osVersion: "24.04", agentVersion: "8.2.0", fetchedAt: cachedFetchedAt, }, }, guestMetadataLimiter: make(map[string]time.Time), } gotIPs, gotIfaces, gotOSName, gotOSVersion, gotAgentVersion := monitor.fetchGuestAgentMetadata( context.Background(), &emptyGuestMetadataClient{}, "pve", "node1", "vm100", 100, &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}}, false, ) if len(gotIPs) != 1 || gotIPs[0] != "192.168.1.10" { t.Fatalf("expected cached IPs to be preserved, got %#v", gotIPs) } if len(gotIfaces) != 1 || gotIfaces[0].Name != "eth0" { t.Fatalf("expected cached interfaces to be preserved, got %#v", gotIfaces) } if gotOSName != "Ubuntu" || gotOSVersion != "24.04" { t.Fatalf("expected cached OS info to be preserved, got %q %q", gotOSName, gotOSVersion) } if gotAgentVersion != "8.2.0" { t.Fatalf("expected cached agent version to be preserved, got %q", gotAgentVersion) } updated := monitor.guestMetadataCache[key] if len(updated.ipAddresses) != 1 || updated.ipAddresses[0] != "192.168.1.10" { t.Fatalf("expected cache IPs to remain populated, got %#v", updated.ipAddresses) } if len(updated.networkInterfaces) != 1 || updated.networkInterfaces[0].Name != "eth0" { t.Fatalf("expected cache interfaces to remain populated, got %#v", updated.networkInterfaces) } if updated.fetchedAt.Before(cachedFetchedAt) { t.Fatalf("expected cache timestamp to be refreshed, old=%v new=%v", cachedFetchedAt, updated.fetchedAt) } } func TestFetchGuestAgentMetadataPreservesFreshCacheWhenAgentTemporarilyUnavailable(t *testing.T) { t.Parallel() key := guestMetadataCacheKey("pve", "node1", 100) cachedFetchedAt := time.Now().Add(-time.Minute) newMonitor := func() *Monitor { return &Monitor{ guestMetadataCache: map[string]guestMetadataCacheEntry{ key: { ipAddresses: []string{"192.168.1.10"}, networkInterfaces: []models.GuestNetworkInterface{ {Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10"}}, }, osName: "Ubuntu", osVersion: "24.04", agentVersion: "8.2.0", fetchedAt: cachedFetchedAt, }, }, guestMetadataLimiter: make(map[string]time.Time), } } assertCachePreserved := func(t *testing.T, monitor *Monitor, gotIPs []string, gotIfaces []models.GuestNetworkInterface, gotOSName, gotOSVersion, gotAgentVersion string) { t.Helper() if len(gotIPs) != 1 || gotIPs[0] != "192.168.1.10" { t.Fatalf("expected cached IPs to be preserved, got %#v", gotIPs) } if len(gotIfaces) != 1 || gotIfaces[0].Name != "eth0" { t.Fatalf("expected cached interfaces to be preserved, got %#v", gotIfaces) } if gotOSName != "Ubuntu" || gotOSVersion != "24.04" { t.Fatalf("expected cached OS info to be preserved, got %q %q", gotOSName, gotOSVersion) } if gotAgentVersion != "8.2.0" { t.Fatalf("expected cached agent version to be preserved, got %q", gotAgentVersion) } entry, ok := monitor.guestMetadataCache[key] if !ok { t.Fatal("expected guest metadata cache entry to remain populated") } if len(entry.networkInterfaces) != 1 || entry.networkInterfaces[0].Name != "eth0" { t.Fatalf("expected cached interfaces to remain populated, got %#v", entry.networkInterfaces) } } t.Run("nil vm status keeps fresh cache", func(t *testing.T) { t.Parallel() monitor := newMonitor() gotIPs, gotIfaces, gotOSName, gotOSVersion, gotAgentVersion := monitor.fetchGuestAgentMetadata( context.Background(), &emptyGuestMetadataClient{}, "pve", "node1", "vm100", 100, nil, false, ) assertCachePreserved(t, monitor, gotIPs, gotIfaces, gotOSName, gotOSVersion, gotAgentVersion) }) t.Run("agent temporarily unavailable keeps fresh cache", func(t *testing.T) { t.Parallel() monitor := newMonitor() gotIPs, gotIfaces, gotOSName, gotOSVersion, gotAgentVersion := monitor.fetchGuestAgentMetadata( context.Background(), &emptyGuestMetadataClient{}, "pve", "node1", "vm100", 100, &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 0}}, false, ) assertCachePreserved(t, monitor, gotIPs, gotIfaces, gotOSName, gotOSVersion, gotAgentVersion) }) } func TestGuestMetadataCacheEntryTTL(t *testing.T) { t.Parallel() tests := []struct { name string entry guestMetadataCacheEntry want time.Duration }{ { name: "empty entry retries quickly", entry: guestMetadataCacheEntry{}, want: guestMetadataEmptyTTL, }, { name: "network metadata uses full ttl", entry: guestMetadataCacheEntry{ ipAddresses: []string{"192.168.1.10"}, networkInterfaces: []models.GuestNetworkInterface{ {Name: "eth0", Addresses: []string{"192.168.1.10"}}, }, }, want: guestMetadataCacheTTL, }, { name: "ip-only metadata retries quickly", entry: guestMetadataCacheEntry{ ipAddresses: []string{"192.168.1.10"}, }, want: guestMetadataEmptyTTL, }, { name: "identity-only metadata retries quickly", entry: guestMetadataCacheEntry{ osName: "Ubuntu", }, want: guestMetadataEmptyTTL, }, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := guestMetadataCacheEntryTTL(tc.entry); got != tc.want { t.Fatalf("guestMetadataCacheEntryTTL() = %s, want %s", got, tc.want) } }) } } func TestFetchGuestAgentMetadataRetriesIdentityOnlyCacheSooner(t *testing.T) { t.Parallel() client := &identityThenNetworkGuestMetadataClient{} monitor := &Monitor{ guestMetadataCache: make(map[string]guestMetadataCacheEntry), guestMetadataLimiter: make(map[string]time.Time), } status := &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}} firstIPs, firstIfaces, firstOSName, firstOSVersion, firstAgentVersion := monitor.fetchGuestAgentMetadata( context.Background(), client, "pve", "node1", "vm100", 100, status, false, ) if len(firstIPs) != 0 || len(firstIfaces) != 0 { t.Fatalf("expected first fetch to be missing network metadata, got ips=%#v ifaces=%#v", firstIPs, firstIfaces) } if firstOSName == "" || firstOSVersion == "" || firstAgentVersion == "" { t.Fatalf("expected identity metadata on first fetch, got os=%q version=%q agent=%q", firstOSName, firstOSVersion, firstAgentVersion) } key := guestMetadataCacheKey("pve", "node1", 100) entry := monitor.guestMetadataCache[key] if got := guestMetadataCacheEntryTTL(entry); got != guestMetadataEmptyTTL { t.Fatalf("guestMetadataCacheEntryTTL(identity-only) = %s, want %s", got, guestMetadataEmptyTTL) } entry.fetchedAt = time.Now().Add(-guestMetadataEmptyTTL - time.Second) monitor.guestMetadataCache[key] = entry monitor.guestMetadataLimiter[key] = time.Now().Add(-time.Second) secondIPs, secondIfaces, secondOSName, secondOSVersion, secondAgentVersion := monitor.fetchGuestAgentMetadata( context.Background(), client, "pve", "node1", "vm100", 100, status, false, ) if len(secondIPs) == 0 || len(secondIfaces) == 0 { t.Fatalf("expected second fetch to populate network metadata, got ips=%#v ifaces=%#v", secondIPs, secondIfaces) } if secondOSName != firstOSName || secondOSVersion != firstOSVersion || secondAgentVersion != firstAgentVersion { t.Fatalf("expected identity metadata to be preserved, got os=%q/%q agent=%q want os=%q/%q agent=%q", secondOSName, secondOSVersion, secondAgentVersion, firstOSName, firstOSVersion, firstAgentVersion) } } func TestFetchGuestAgentMetadataRetriesIPOnlyCacheSooner(t *testing.T) { t.Parallel() client := &ipOnlyThenNetworkGuestMetadataClient{} monitor := &Monitor{ guestMetadataCache: make(map[string]guestMetadataCacheEntry), guestMetadataLimiter: make(map[string]time.Time), } status := &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}} firstIPs, firstIfaces, _, _, _ := monitor.fetchGuestAgentMetadata( context.Background(), client, "pve", "node1", "vm100", 100, status, false, ) if len(firstIPs) != 1 || firstIPs[0] != "192.168.1.60" { t.Fatalf("expected first fetch to preserve discovered IP, got %#v", firstIPs) } if len(firstIfaces) != 0 { t.Fatalf("expected first fetch to remain interface-incomplete, got %#v", firstIfaces) } key := guestMetadataCacheKey("pve", "node1", 100) entry := monitor.guestMetadataCache[key] if got := guestMetadataCacheEntryTTL(entry); got != guestMetadataEmptyTTL { t.Fatalf("guestMetadataCacheEntryTTL(ip-only) = %s, want %s", got, guestMetadataEmptyTTL) } entry.fetchedAt = time.Now().Add(-guestMetadataEmptyTTL - time.Second) monitor.guestMetadataCache[key] = entry monitor.guestMetadataLimiter[key] = time.Now().Add(-time.Second) secondIPs, secondIfaces, _, _, _ := monitor.fetchGuestAgentMetadata( context.Background(), client, "pve", "node1", "vm100", 100, status, false, ) if len(secondIPs) != 1 || secondIPs[0] != "192.168.1.60" { t.Fatalf("expected second fetch to preserve IP, got %#v", secondIPs) } if len(secondIfaces) != 1 || secondIfaces[0].Name != "Ethernet0" { t.Fatalf("expected second fetch to populate interfaces, got %#v", secondIfaces) } if client.networkCalls != 2 { t.Fatalf("expected network metadata to be fetched twice, got %d calls", client.networkCalls) } } func TestFetchGuestAgentMetadataRetriesEmptyCacheSooner(t *testing.T) { t.Parallel() client := &emptyThenPopulatedGuestMetadataClient{} monitor := &Monitor{ guestMetadataCache: make(map[string]guestMetadataCacheEntry), guestMetadataLimiter: make(map[string]time.Time), } status := &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}} firstIPs, firstIfaces, _, _, _ := monitor.fetchGuestAgentMetadata( context.Background(), client, "pve", "node1", "vm100", 100, status, false, ) if len(firstIPs) != 0 || len(firstIfaces) != 0 { t.Fatalf("expected first fetch to be empty, got ips=%#v ifaces=%#v", firstIPs, firstIfaces) } key := guestMetadataCacheKey("pve", "node1", 100) entry := monitor.guestMetadataCache[key] entry.fetchedAt = time.Now().Add(-guestMetadataEmptyTTL - time.Second) monitor.guestMetadataCache[key] = entry monitor.guestMetadataLimiter[key] = time.Now().Add(-time.Second) secondIPs, secondIfaces, _, _, _ := monitor.fetchGuestAgentMetadata( context.Background(), client, "pve", "node1", "vm100", 100, status, false, ) if len(secondIPs) != 1 || secondIPs[0] != "192.168.1.50" { t.Fatalf("expected second fetch to refresh IPs, got %#v", secondIPs) } if len(secondIfaces) != 1 || secondIfaces[0].Name != "Ethernet0" { t.Fatalf("expected second fetch to refresh interfaces, got %#v", secondIfaces) } if client.networkCalls != 2 { t.Fatalf("expected network metadata to be fetched twice, got %d calls", client.networkCalls) } } func TestAcquireGuestMetadataSlot(t *testing.T) { t.Parallel() t.Run("nil monitor returns true", func(t *testing.T) { t.Parallel() var m *Monitor ctx := context.Background() if !m.acquireGuestMetadataSlot(ctx) { t.Fatal("nil monitor should return true (permissive default)") } }) t.Run("nil slots channel returns true", func(t *testing.T) { t.Parallel() m := &Monitor{guestMetadataSlots: nil} ctx := context.Background() if !m.acquireGuestMetadataSlot(ctx) { t.Fatal("nil slots channel should return true (permissive default)") } }) t.Run("successfully acquires slot when available", func(t *testing.T) { t.Parallel() m := &Monitor{guestMetadataSlots: make(chan struct{}, 2)} ctx := context.Background() if !m.acquireGuestMetadataSlot(ctx) { t.Fatal("should acquire slot when channel has capacity") } // Verify slot was actually acquired (channel should have 1 element) if len(m.guestMetadataSlots) != 1 { t.Fatalf("expected 1 slot acquired, got %d", len(m.guestMetadataSlots)) } }) t.Run("context cancellation returns false when slot not available", func(t *testing.T) { t.Parallel() // Create a channel with capacity 1 and fill it m := &Monitor{guestMetadataSlots: make(chan struct{}, 1)} m.guestMetadataSlots <- struct{}{} // Create already-cancelled context ctx, cancel := context.WithCancel(context.Background()) cancel() if m.acquireGuestMetadataSlot(ctx) { t.Fatal("should return false when context is cancelled and slot not available") } }) t.Run("multiple acquires fill up the channel", func(t *testing.T) { t.Parallel() capacity := 3 m := &Monitor{guestMetadataSlots: make(chan struct{}, capacity)} ctx := context.Background() // Acquire all available slots for i := 0; i < capacity; i++ { if !m.acquireGuestMetadataSlot(ctx) { t.Fatalf("should acquire slot %d of %d", i+1, capacity) } } // Verify channel is full if len(m.guestMetadataSlots) != capacity { t.Fatalf("expected %d slots acquired, got %d", capacity, len(m.guestMetadataSlots)) } // Next acquire should block; use cancelled context to test ctx2, cancel := context.WithCancel(context.Background()) cancel() if m.acquireGuestMetadataSlot(ctx2) { t.Fatal("should return false when channel is full and context cancelled") } }) } func TestTryReserveGuestMetadataFetch(t *testing.T) { t.Parallel() t.Run("nil monitor returns false", func(t *testing.T) { t.Parallel() var m *Monitor now := time.Now() if m.tryReserveGuestMetadataFetch("test-key", now) { t.Fatal("nil monitor should return false") } }) t.Run("key not in map reserves and returns true", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataHoldDuration: 15 * time.Second, } now := time.Now() if !m.tryReserveGuestMetadataFetch("new-key", now) { t.Fatal("should return true for new key") } // Verify the key was added with correct expiry next, ok := m.guestMetadataLimiter["new-key"] if !ok { t.Fatal("key should be in limiter map") } expectedNext := now.Add(15 * time.Second) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v, got %v", expectedNext, next) } }) t.Run("key in map with future time returns false", func(t *testing.T) { t.Parallel() now := time.Now() m := &Monitor{ guestMetadataLimiter: map[string]time.Time{ "existing-key": now.Add(10 * time.Second), // future }, guestMetadataHoldDuration: 15 * time.Second, } if m.tryReserveGuestMetadataFetch("existing-key", now) { t.Fatal("should return false when key has future expiry") } // Verify the expiry was not changed next := m.guestMetadataLimiter["existing-key"] if !next.Equal(now.Add(10 * time.Second)) { t.Fatal("expiry time should not have been modified") } }) t.Run("key in map with past time reserves and returns true", func(t *testing.T) { t.Parallel() now := time.Now() m := &Monitor{ guestMetadataLimiter: map[string]time.Time{ "expired-key": now.Add(-5 * time.Second), // past }, guestMetadataHoldDuration: 20 * time.Second, } if !m.tryReserveGuestMetadataFetch("expired-key", now) { t.Fatal("should return true when key has past expiry") } // Verify the key was updated with new expiry next := m.guestMetadataLimiter["expired-key"] expectedNext := now.Add(20 * time.Second) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v, got %v", expectedNext, next) } }) t.Run("default hold duration used when guestMetadataHoldDuration is zero", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataHoldDuration: 0, // zero triggers default } now := time.Now() if !m.tryReserveGuestMetadataFetch("key", now) { t.Fatal("should return true for new key") } next := m.guestMetadataLimiter["key"] expectedNext := now.Add(defaultGuestMetadataHold) // 15 seconds if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v (default hold), got %v", expectedNext, next) } }) t.Run("default hold duration used when guestMetadataHoldDuration is negative", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataHoldDuration: -5 * time.Second, // negative triggers default } now := time.Now() if !m.tryReserveGuestMetadataFetch("key", now) { t.Fatal("should return true for new key") } next := m.guestMetadataLimiter["key"] expectedNext := now.Add(defaultGuestMetadataHold) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v (default hold), got %v", expectedNext, next) } }) t.Run("key at exact current time reserves and returns true", func(t *testing.T) { t.Parallel() now := time.Now() m := &Monitor{ guestMetadataLimiter: map[string]time.Time{ "exact-key": now, // exactly now }, guestMetadataHoldDuration: 10 * time.Second, } // now.Before(now) is false, so this should reserve if !m.tryReserveGuestMetadataFetch("exact-key", now) { t.Fatal("should return true when key expires exactly at now") } }) } func TestScheduleNextGuestMetadataFetch(t *testing.T) { t.Parallel() t.Run("nil monitor is safe", func(t *testing.T) { t.Parallel() var m *Monitor now := time.Now() // Should not panic m.scheduleNextGuestMetadataFetch("test-key", now) }) t.Run("schedules with default interval when guestMetadataMinRefresh is zero", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataMinRefresh: 0, // zero triggers default } now := time.Now() m.scheduleNextGuestMetadataFetch("key", now) next, ok := m.guestMetadataLimiter["key"] if !ok { t.Fatal("key should be in limiter map") } // config.DefaultGuestMetadataMinRefresh is 2 minutes expectedNext := now.Add(2 * time.Minute) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v (default interval), got %v", expectedNext, next) } }) t.Run("schedules with default interval when guestMetadataMinRefresh is negative", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataMinRefresh: -1 * time.Minute, // negative triggers default } now := time.Now() m.scheduleNextGuestMetadataFetch("key", now) next := m.guestMetadataLimiter["key"] expectedNext := now.Add(2 * time.Minute) // DefaultGuestMetadataMinRefresh if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v (default interval), got %v", expectedNext, next) } }) t.Run("uses configured interval when positive", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataMinRefresh: 5 * time.Minute, } now := time.Now() m.scheduleNextGuestMetadataFetch("key", now) next := m.guestMetadataLimiter["key"] expectedNext := now.Add(5 * time.Minute) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v, got %v", expectedNext, next) } }) t.Run("adds jitter when rng is non-nil and jitter is positive", func(t *testing.T) { t.Parallel() // Use a seeded rng for deterministic testing rng := newDeterministicRng(42) m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataMinRefresh: 1 * time.Minute, guestMetadataRefreshJitter: 30 * time.Second, rng: rng, } now := time.Now() m.scheduleNextGuestMetadataFetch("key", now) next := m.guestMetadataLimiter["key"] minExpected := now.Add(1 * time.Minute) maxExpected := now.Add(1*time.Minute + 30*time.Second) if next.Before(minExpected) || next.After(maxExpected) { t.Fatalf("expected next time between %v and %v, got %v", minExpected, maxExpected, next) } }) t.Run("no jitter when rng is nil", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataMinRefresh: 1 * time.Minute, guestMetadataRefreshJitter: 30 * time.Second, // jitter configured but rng is nil rng: nil, } now := time.Now() m.scheduleNextGuestMetadataFetch("key", now) next := m.guestMetadataLimiter["key"] expectedNext := now.Add(1 * time.Minute) // no jitter added if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v (no jitter), got %v", expectedNext, next) } }) t.Run("no jitter when jitter duration is zero", func(t *testing.T) { t.Parallel() rng := newDeterministicRng(42) m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataMinRefresh: 1 * time.Minute, guestMetadataRefreshJitter: 0, // zero jitter rng: rng, } now := time.Now() m.scheduleNextGuestMetadataFetch("key", now) next := m.guestMetadataLimiter["key"] expectedNext := now.Add(1 * time.Minute) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v (no jitter), got %v", expectedNext, next) } }) t.Run("no jitter when jitter duration is negative", func(t *testing.T) { t.Parallel() rng := newDeterministicRng(42) m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataMinRefresh: 1 * time.Minute, guestMetadataRefreshJitter: -10 * time.Second, // negative jitter rng: rng, } now := time.Now() m.scheduleNextGuestMetadataFetch("key", now) next := m.guestMetadataLimiter["key"] expectedNext := now.Add(1 * time.Minute) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v (no jitter), got %v", expectedNext, next) } }) t.Run("overwrites existing key", func(t *testing.T) { t.Parallel() now := time.Now() m := &Monitor{ guestMetadataLimiter: map[string]time.Time{ "key": now.Add(-10 * time.Minute), // old value }, guestMetadataMinRefresh: 3 * time.Minute, } m.scheduleNextGuestMetadataFetch("key", now) next := m.guestMetadataLimiter["key"] expectedNext := now.Add(3 * time.Minute) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v, got %v", expectedNext, next) } }) } func TestDeferGuestMetadataRetry(t *testing.T) { t.Parallel() t.Run("nil monitor is safe", func(t *testing.T) { t.Parallel() var m *Monitor now := time.Now() // Should not panic m.deferGuestMetadataRetry("test-key", now) }) t.Run("uses default backoff when guestMetadataRetryBackoff is zero", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataRetryBackoff: 0, // zero triggers default } now := time.Now() m.deferGuestMetadataRetry("key", now) next, ok := m.guestMetadataLimiter["key"] if !ok { t.Fatal("key should be in limiter map") } // config.DefaultGuestMetadataRetryBackoff is 30 seconds expectedNext := now.Add(30 * time.Second) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v (default backoff), got %v", expectedNext, next) } }) t.Run("uses default backoff when guestMetadataRetryBackoff is negative", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataRetryBackoff: -10 * time.Second, // negative triggers default } now := time.Now() m.deferGuestMetadataRetry("key", now) next := m.guestMetadataLimiter["key"] expectedNext := now.Add(30 * time.Second) // DefaultGuestMetadataRetryBackoff if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v (default backoff), got %v", expectedNext, next) } }) t.Run("uses configured backoff when positive", func(t *testing.T) { t.Parallel() m := &Monitor{ guestMetadataLimiter: make(map[string]time.Time), guestMetadataRetryBackoff: 45 * time.Second, } now := time.Now() m.deferGuestMetadataRetry("key", now) next := m.guestMetadataLimiter["key"] expectedNext := now.Add(45 * time.Second) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v, got %v", expectedNext, next) } }) t.Run("overwrites existing key", func(t *testing.T) { t.Parallel() now := time.Now() m := &Monitor{ guestMetadataLimiter: map[string]time.Time{ "key": now.Add(-5 * time.Minute), // old value }, guestMetadataRetryBackoff: 1 * time.Minute, } m.deferGuestMetadataRetry("key", now) next := m.guestMetadataLimiter["key"] expectedNext := now.Add(1 * time.Minute) if next.Sub(expectedNext) > time.Millisecond || expectedNext.Sub(next) > time.Millisecond { t.Fatalf("expected next time ~%v, got %v", expectedNext, next) } }) } // newDeterministicRng creates a rand.Rand with a fixed seed for reproducible tests. func newDeterministicRng(seed int64) *rand.Rand { return rand.New(rand.NewSource(seed)) } func TestClearGuestMetadataCache(t *testing.T) { t.Parallel() t.Run("nil monitor is safe", func(t *testing.T) { t.Parallel() var m *Monitor // Should not panic m.clearGuestMetadataCache("instance", "node", 100) }) t.Run("nil cache map is safe", func(t *testing.T) { t.Parallel() m := &Monitor{guestMetadataCache: nil} // Should not panic m.clearGuestMetadataCache("instance", "node", 100) }) t.Run("successfully clears existing entry", func(t *testing.T) { t.Parallel() key := guestMetadataCacheKey("instance", "node", 100) m := &Monitor{ guestMetadataCache: map[string]guestMetadataCacheEntry{ key: { ipAddresses: []string{"192.168.1.10"}, osName: "Linux", osVersion: "5.15", agentVersion: "1.0", fetchedAt: time.Now(), }, }, } // Verify entry exists before clearing if _, ok := m.guestMetadataCache[key]; !ok { t.Fatal("entry should exist before clearing") } m.clearGuestMetadataCache("instance", "node", 100) // Verify entry was removed if _, ok := m.guestMetadataCache[key]; ok { t.Fatal("entry should not exist after clearing") } }) t.Run("non-existent key does not cause error", func(t *testing.T) { t.Parallel() existingKey := guestMetadataCacheKey("other-instance", "other-node", 200) m := &Monitor{ guestMetadataCache: map[string]guestMetadataCacheEntry{ existingKey: { ipAddresses: []string{"10.0.0.5"}, fetchedAt: time.Now(), }, }, } // Clear a key that doesn't exist - should not panic or error m.clearGuestMetadataCache("instance", "node", 100) // Verify existing entry is still there if _, ok := m.guestMetadataCache[existingKey]; !ok { t.Fatal("existing entry should not be affected") } }) t.Run("only clears specified key, other entries remain", func(t *testing.T) { t.Parallel() key1 := guestMetadataCacheKey("instance1", "node1", 100) key2 := guestMetadataCacheKey("instance2", "node2", 200) key3 := guestMetadataCacheKey("instance1", "node1", 300) m := &Monitor{ guestMetadataCache: map[string]guestMetadataCacheEntry{ key1: {ipAddresses: []string{"192.168.1.10"}, fetchedAt: time.Now()}, key2: {ipAddresses: []string{"192.168.1.20"}, fetchedAt: time.Now()}, key3: {ipAddresses: []string{"192.168.1.30"}, fetchedAt: time.Now()}, }, } // Clear only key2 m.clearGuestMetadataCache("instance2", "node2", 200) // Verify key2 was removed if _, ok := m.guestMetadataCache[key2]; ok { t.Fatal("key2 should be removed") } // Verify key1 and key3 still exist if _, ok := m.guestMetadataCache[key1]; !ok { t.Fatal("key1 should still exist") } if _, ok := m.guestMetadataCache[key3]; !ok { t.Fatal("key3 should still exist") } // Verify map size if len(m.guestMetadataCache) != 2 { t.Fatalf("expected 2 entries remaining, got %d", len(m.guestMetadataCache)) } }) } func TestReleaseGuestMetadataSlot(t *testing.T) { t.Parallel() t.Run("nil monitor is safe", func(t *testing.T) { t.Parallel() var m *Monitor // Should not panic m.releaseGuestMetadataSlot() }) t.Run("nil slots channel is safe", func(t *testing.T) { t.Parallel() m := &Monitor{guestMetadataSlots: nil} // Should not panic m.releaseGuestMetadataSlot() }) t.Run("successfully releases a slot", func(t *testing.T) { t.Parallel() m := &Monitor{guestMetadataSlots: make(chan struct{}, 2)} ctx := context.Background() // Acquire a slot first if !m.acquireGuestMetadataSlot(ctx) { t.Fatal("failed to acquire slot") } if len(m.guestMetadataSlots) != 1 { t.Fatalf("expected 1 slot acquired, got %d", len(m.guestMetadataSlots)) } // Release the slot m.releaseGuestMetadataSlot() // Verify slot was released if len(m.guestMetadataSlots) != 0 { t.Fatalf("expected 0 slots after release, got %d", len(m.guestMetadataSlots)) } }) t.Run("release on empty channel does not block", func(t *testing.T) { t.Parallel() m := &Monitor{guestMetadataSlots: make(chan struct{}, 2)} // Channel is empty - release should not block due to default case in select done := make(chan struct{}) go func() { m.releaseGuestMetadataSlot() close(done) }() select { case <-done: // Success - release completed without blocking case <-time.After(100 * time.Millisecond): t.Fatal("releaseGuestMetadataSlot blocked on empty channel") } }) }