Pulse/internal/config/guest_metadata_test.go
rcourtman c97dffb81a fix: wait for async save in guest metadata test
The TestGuestMetadataStore_GetWithLegacyMigration_ClusteredMatchesNodeFormat
test was flaky because it triggered an async save in GetWithLegacyMigration
but didn't wait for it to complete. When the test ended, t.TempDir() tried
to clean up while the goroutine was still writing, causing 'directory not
empty' errors on CI.

Added time.Sleep(100ms) to wait for the async save, matching the pattern
used in other similar tests in the same file.
2025-12-18 22:48:15 +00:00

686 lines
18 KiB
Go

package config
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestGuestMetadataStore_Get(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Test get on empty store
result := store.Get("nonexistent")
if result != nil {
t.Error("Get on empty store should return nil")
}
// Add metadata
store.metadata["pve1:node1:100"] = &GuestMetadata{
ID: "pve1:node1:100",
CustomURL: "http://example.com",
Description: "Test VM",
Tags: []string{"tag1", "tag2"},
LastKnownName: "test-vm",
LastKnownType: "qemu",
}
// Test get existing
result = store.Get("pve1:node1:100")
if result == nil {
t.Fatal("Get should return metadata for existing entry")
}
if result.CustomURL != "http://example.com" {
t.Errorf("CustomURL = %q, want %q", result.CustomURL, "http://example.com")
}
if result.Description != "Test VM" {
t.Errorf("Description = %q, want %q", result.Description, "Test VM")
}
if len(result.Tags) != 2 {
t.Errorf("Tags count = %d, want 2", len(result.Tags))
}
if result.LastKnownName != "test-vm" {
t.Errorf("LastKnownName = %q, want %q", result.LastKnownName, "test-vm")
}
if result.LastKnownType != "qemu" {
t.Errorf("LastKnownType = %q, want %q", result.LastKnownType, "qemu")
}
}
func TestGuestMetadataStore_GetAll(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Test empty store
all := store.GetAll()
if len(all) != 0 {
t.Errorf("GetAll on empty store returned %d entries, want 0", len(all))
}
// Add metadata
store.metadata["id1"] = &GuestMetadata{ID: "id1", CustomURL: "url1"}
store.metadata["id2"] = &GuestMetadata{ID: "id2", CustomURL: "url2"}
// Test GetAll returns all entries
all = store.GetAll()
if len(all) != 2 {
t.Errorf("GetAll returned %d entries, want 2", len(all))
}
// Verify it's a copy (modification shouldn't affect store)
all["id3"] = &GuestMetadata{ID: "id3"}
if len(store.metadata) != 2 {
t.Error("GetAll should return a copy, not the original map")
}
}
func TestGuestMetadataStore_Set(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Test set nil
err := store.Set("id1", nil)
if err == nil {
t.Error("Set with nil metadata should return error")
}
// Test successful set
meta := &GuestMetadata{
CustomURL: "http://test.com",
Description: "Test desc",
Tags: []string{"tag1"},
LastKnownName: "vm1",
LastKnownType: "lxc",
}
err = store.Set("id1", meta)
if err != nil {
t.Fatalf("Set failed: %v", err)
}
// Verify ID is set
if meta.ID != "id1" {
t.Errorf("ID = %q, want %q", meta.ID, "id1")
}
// Verify stored
stored := store.Get("id1")
if stored == nil {
t.Fatal("Set did not store metadata")
}
if stored.CustomURL != "http://test.com" {
t.Errorf("Stored CustomURL = %q, want %q", stored.CustomURL, "http://test.com")
}
if stored.LastKnownName != "vm1" {
t.Errorf("Stored LastKnownName = %q, want %q", stored.LastKnownName, "vm1")
}
// Verify persisted to disk
filePath := filepath.Join(tmpDir, "guest_metadata.json")
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read persisted file: %v", err)
}
var fileData map[string]*GuestMetadata
if err := json.Unmarshal(data, &fileData); err != nil {
t.Fatalf("Failed to unmarshal persisted data: %v", err)
}
if fileData["id1"] == nil {
t.Error("Metadata not persisted")
}
}
func TestGuestMetadataStore_Set_UpdateExisting(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Set initial
err := store.Set("id1", &GuestMetadata{CustomURL: "url1", Description: "desc1"})
if err != nil {
t.Fatalf("Initial Set failed: %v", err)
}
// Update
err = store.Set("id1", &GuestMetadata{CustomURL: "url2", Description: "desc2"})
if err != nil {
t.Fatalf("Update Set failed: %v", err)
}
// Verify updated
meta := store.Get("id1")
if meta.CustomURL != "url2" {
t.Errorf("CustomURL = %q, want %q", meta.CustomURL, "url2")
}
if meta.Description != "desc2" {
t.Errorf("Description = %q, want %q", meta.Description, "desc2")
}
}
func TestGuestMetadataStore_Delete(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Add metadata
store.metadata["id1"] = &GuestMetadata{ID: "id1", CustomURL: "url1"}
// Delete
err := store.Delete("id1")
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
// Verify deleted
if store.Get("id1") != nil {
t.Error("Delete did not remove metadata")
}
// Delete nonexistent (should not error)
err = store.Delete("nonexistent")
if err != nil {
t.Errorf("Delete nonexistent should not error: %v", err)
}
}
func TestGuestMetadataStore_ReplaceAll(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Add initial data
store.metadata["old1"] = &GuestMetadata{ID: "old1"}
// Replace all
newData := map[string]*GuestMetadata{
"new1": {CustomURL: "url1", Tags: []string{"t1"}},
"new2": {CustomURL: "url2"},
}
err := store.ReplaceAll(newData)
if err != nil {
t.Fatalf("ReplaceAll failed: %v", err)
}
// Verify old data gone
if store.Get("old1") != nil {
t.Error("ReplaceAll should remove old entries")
}
// Verify new data present
if store.Get("new1") == nil || store.Get("new2") == nil {
t.Error("ReplaceAll should add new entries")
}
// Verify IDs set correctly
if store.Get("new1").ID != "new1" {
t.Errorf("ID = %q, want %q", store.Get("new1").ID, "new1")
}
// Verify nil tags converted to empty slice
if store.Get("new2").Tags == nil {
t.Error("ReplaceAll should convert nil tags to empty slice")
}
}
func TestGuestMetadataStore_ReplaceAll_NilEntry(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Replace with map containing nil entry
newData := map[string]*GuestMetadata{
"valid": {CustomURL: "url1"},
"nil": nil,
}
err := store.ReplaceAll(newData)
if err != nil {
t.Fatalf("ReplaceAll failed: %v", err)
}
// Verify valid entry present
if store.Get("valid") == nil {
t.Error("ReplaceAll should add valid entries")
}
// Verify nil entry skipped
if store.Get("nil") != nil {
t.Error("ReplaceAll should skip nil entries")
}
}
func TestGuestMetadataStore_Load(t *testing.T) {
tmpDir := t.TempDir()
// Write test file
fileData := map[string]*GuestMetadata{
"pve1:node1:100": {
ID: "pve1:node1:100",
CustomURL: "http://vm1.local",
Description: "Production VM",
Tags: []string{"prod", "web"},
LastKnownName: "web-server",
LastKnownType: "qemu",
},
"pve1:node1:101": {
ID: "pve1:node1:101",
CustomURL: "http://ct1.local",
Description: "Dev container",
},
}
data, _ := json.Marshal(fileData)
filePath := filepath.Join(tmpDir, "guest_metadata.json")
if err := os.WriteFile(filePath, data, 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Load
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
err := store.Load()
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Verify loaded
if len(store.metadata) != 2 {
t.Errorf("Metadata count = %d, want 2", len(store.metadata))
}
meta := store.Get("pve1:node1:100")
if meta == nil {
t.Fatal("Load did not load metadata")
}
if meta.CustomURL != "http://vm1.local" {
t.Errorf("CustomURL = %q, want %q", meta.CustomURL, "http://vm1.local")
}
if meta.LastKnownName != "web-server" {
t.Errorf("LastKnownName = %q, want %q", meta.LastKnownName, "web-server")
}
}
func TestGuestMetadataStore_Load_NonExistent(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Load from nonexistent file should not error
err := store.Load()
if err != nil {
t.Errorf("Load from nonexistent file should not error: %v", err)
}
}
func TestGuestMetadataStore_Load_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
// Write invalid JSON
filePath := filepath.Join(tmpDir, "guest_metadata.json")
if err := os.WriteFile(filePath, []byte("{invalid json"), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
err := store.Load()
if err == nil {
t.Error("Load with invalid JSON should return error")
}
}
func TestGuestMetadataStore_Save_CreatesDirectory(t *testing.T) {
tmpDir := t.TempDir()
subDir := filepath.Join(tmpDir, "nested", "dir")
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: subDir,
}
// Set should create directory and save
err := store.Set("id1", &GuestMetadata{CustomURL: "url1"})
if err != nil {
t.Fatalf("Set failed: %v", err)
}
// Verify file exists
filePath := filepath.Join(subDir, "guest_metadata.json")
if _, err := os.Stat(filePath); os.IsNotExist(err) {
t.Error("Save should create directory and file")
}
}
func TestGuestMetadataStore_Save_AtomicWrite(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Set some data
err := store.Set("id1", &GuestMetadata{CustomURL: "url1"})
if err != nil {
t.Fatalf("Set failed: %v", err)
}
// Verify no temp file left behind
tempFile := filepath.Join(tmpDir, "guest_metadata.json.tmp")
if _, err := os.Stat(tempFile); !os.IsNotExist(err) {
t.Error("Temp file should be removed after successful save")
}
}
func TestGuestMetadataStore_RoundTrip(t *testing.T) {
tmpDir := t.TempDir()
// Create store and add data
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
err := store.Set("pve1:node1:100", &GuestMetadata{
CustomURL: "http://vm.local",
Description: "Test VM",
Tags: []string{"prod", "web"},
LastKnownName: "webserver",
LastKnownType: "qemu",
})
if err != nil {
t.Fatalf("Set failed: %v", err)
}
// Create new store and load
store2 := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
err = store2.Load()
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Verify metadata
meta := store2.Get("pve1:node1:100")
if meta == nil {
t.Fatal("Metadata not loaded")
}
if meta.CustomURL != "http://vm.local" {
t.Errorf("CustomURL = %q, want %q", meta.CustomURL, "http://vm.local")
}
if meta.Description != "Test VM" {
t.Errorf("Description = %q, want %q", meta.Description, "Test VM")
}
if len(meta.Tags) != 2 || meta.Tags[0] != "prod" || meta.Tags[1] != "web" {
t.Errorf("Tags = %v, want [prod web]", meta.Tags)
}
if meta.LastKnownName != "webserver" {
t.Errorf("LastKnownName = %q, want %q", meta.LastKnownName, "webserver")
}
if meta.LastKnownType != "qemu" {
t.Errorf("LastKnownType = %q, want %q", meta.LastKnownType, "qemu")
}
}
func TestGuestMetadata_Fields(t *testing.T) {
meta := GuestMetadata{
ID: "pve1:node1:100",
CustomURL: "http://app.local:8080",
Description: "My virtual machine",
Tags: []string{"production", "database"},
LastKnownName: "db-server",
LastKnownType: "qemu",
}
if meta.ID != "pve1:node1:100" {
t.Errorf("ID = %q, want %q", meta.ID, "pve1:node1:100")
}
if meta.CustomURL != "http://app.local:8080" {
t.Errorf("CustomURL = %q, want %q", meta.CustomURL, "http://app.local:8080")
}
if meta.Description != "My virtual machine" {
t.Errorf("Description = %q, want %q", meta.Description, "My virtual machine")
}
if len(meta.Tags) != 2 {
t.Errorf("Tags count = %d, want 2", len(meta.Tags))
}
if meta.LastKnownName != "db-server" {
t.Errorf("LastKnownName = %q, want %q", meta.LastKnownName, "db-server")
}
if meta.LastKnownType != "qemu" {
t.Errorf("LastKnownType = %q, want %q", meta.LastKnownType, "qemu")
}
}
func TestGuestMetadataStore_ConcurrentAccess(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Pre-populate
store.metadata["id1"] = &GuestMetadata{ID: "id1", CustomURL: "url1"}
// Concurrent reads should not panic
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
store.Get("id1")
store.GetAll()
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
func TestGuestMetadataStore_GetWithLegacyMigration_ExistingNewFormat(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Add metadata with new format ID
store.metadata["pve1:node1:100"] = &GuestMetadata{
ID: "pve1:node1:100",
CustomURL: "http://example.com",
}
// Get with new format should return directly
result := store.GetWithLegacyMigration("pve1:node1:100", "pve1", "node1", 100)
if result == nil {
t.Fatal("Should return metadata for existing new format ID")
}
if result.CustomURL != "http://example.com" {
t.Errorf("CustomURL = %q, want %q", result.CustomURL, "http://example.com")
}
}
func TestGuestMetadataStore_GetWithLegacyMigration_ClusteredLegacy(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Add metadata with legacy clustered format: instance-node-VMID
store.metadata["pve1-node1-100"] = &GuestMetadata{
ID: "pve1-node1-100",
CustomURL: "http://legacy.com",
}
// Get with new format should migrate
result := store.GetWithLegacyMigration("pve1:node1:100", "pve1", "node1", 100)
if result == nil {
t.Fatal("Should migrate and return metadata")
}
if result.CustomURL != "http://legacy.com" {
t.Errorf("CustomURL = %q, want %q", result.CustomURL, "http://legacy.com")
}
// ID should be updated to new format
if result.ID != "pve1:node1:100" {
t.Errorf("ID = %q, want %q", result.ID, "pve1:node1:100")
}
// Wait for async save
time.Sleep(100 * time.Millisecond)
// Old ID should be removed
if store.metadata["pve1-node1-100"] != nil {
t.Error("Legacy ID should be removed after migration")
}
// New ID should exist
if store.metadata["pve1:node1:100"] == nil {
t.Error("New ID should exist after migration")
}
}
func TestGuestMetadataStore_GetWithLegacyMigration_StandaloneLegacy(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Add metadata with legacy standalone format: node-VMID
store.metadata["node1-100"] = &GuestMetadata{
ID: "node1-100",
CustomURL: "http://standalone.com",
}
// Get with instance == node (standalone) should migrate
result := store.GetWithLegacyMigration("node1:node1:100", "node1", "node1", 100)
if result == nil {
t.Fatal("Should migrate and return metadata")
}
if result.CustomURL != "http://standalone.com" {
t.Errorf("CustomURL = %q, want %q", result.CustomURL, "http://standalone.com")
}
// ID should be updated
if result.ID != "node1:node1:100" {
t.Errorf("ID = %q, want %q", result.ID, "node1:node1:100")
}
// Wait for async save
time.Sleep(100 * time.Millisecond)
// Old ID should be removed
if store.metadata["node1-100"] != nil {
t.Error("Legacy ID should be removed after migration")
}
}
func TestGuestMetadataStore_GetWithLegacyMigration_NotFound(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Get non-existent should return nil
result := store.GetWithLegacyMigration("pve1:node1:100", "pve1", "node1", 100)
if result != nil {
t.Error("Should return nil for non-existent metadata")
}
}
func TestGuestMetadataStore_GetWithLegacyMigration_ClusteredMatchesNodeFormat(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Add node-vmid format (legacy standalone format)
store.metadata["node1-100"] = &GuestMetadata{
ID: "node1-100",
CustomURL: "http://standalone.com",
}
// Clustered request (instance != node) CAN match node-vmid as fallback
// This handles cases where metadata was created with old format
result := store.GetWithLegacyMigration("pve1:node1:100", "pve1", "node1", 100)
if result == nil {
t.Fatal("Should migrate from node-vmid format for clustered request")
}
if result.CustomURL != "http://standalone.com" {
t.Errorf("CustomURL = %q, want %q", result.CustomURL, "http://standalone.com")
}
if result.ID != "pve1:node1:100" {
t.Errorf("ID = %q, want %q", result.ID, "pve1:node1:100")
}
// Wait for async save to complete before test cleanup
time.Sleep(100 * time.Millisecond)
}
func TestGuestMetadataStore_GetWithLegacyMigration_ConcurrentMigration(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Add legacy metadata
store.metadata["pve1-node1-100"] = &GuestMetadata{
ID: "pve1-node1-100",
CustomURL: "http://legacy.com",
}
// Multiple concurrent migrations should not panic
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
store.GetWithLegacyMigration("pve1:node1:100", "pve1", "node1", 100)
}()
}
wg.Wait()
// Wait for any async saves
time.Sleep(200 * time.Millisecond)
// New ID should exist
if store.metadata["pve1:node1:100"] == nil {
t.Error("New ID should exist after migration")
}
}