diff --git a/internal/config/guest_metadata_test.go b/internal/config/guest_metadata_test.go new file mode 100644 index 0000000..a44027e --- /dev/null +++ b/internal/config/guest_metadata_test.go @@ -0,0 +1,676 @@ +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_ClusteredSkipStandalone(t *testing.T) { + tmpDir := t.TempDir() + store := &GuestMetadataStore{ + metadata: make(map[string]*GuestMetadata), + dataPath: tmpDir, + } + + // Only add standalone format (shouldn't match for clustered request) + store.metadata["node1-100"] = &GuestMetadata{ + ID: "node1-100", + CustomURL: "http://standalone.com", + } + + // Clustered request (instance != node) should NOT match standalone format + result := store.GetWithLegacyMigration("pve1:node1:100", "pve1", "node1", 100) + if result != nil { + t.Error("Clustered request should not match standalone legacy format") + } +} + +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") + } +}