Resolves #641 ## Problem When a VM migrates between Proxmox nodes, Pulse was treating it as a new resource and discarding custom alert threshold overrides. This occurred because guest IDs included the node name (e.g., `instance-node-VMID`), causing the ID to change when the VM moved to a different node. Users reported that after migrating a VM, previously disabled alerts (e.g., memory threshold set to 0) would resume firing. ## Root Cause Guest IDs were constructed as: - Standalone: `node-VMID` - Cluster: `instance-node-VMID` When a VM migrated from node1 to node2, the ID changed from `instance-node1-100` to `instance-node2-100`, causing: - Alert threshold overrides to be orphaned (keyed by old ID) - Guest metadata (custom URLs, descriptions) to be orphaned - Active alerts to reference the wrong resource ID ## Solution Changed guest ID format to be stable across node migrations: - New format: `instance-VMID` (for both standalone and cluster) - Retains uniqueness across instances while being node-independent - Allows VMs to migrate freely without losing configuration ## Implementation ### Backend Changes 1. **Guest ID Construction** (`monitor_polling.go`): - Simplified to always use `instance-VMID` format - Removed node from the ID construction logic 2. **Alert Override Migration** (`alerts.go`): - Added lazy migration in `getGuestThresholds()` - Detects legacy ID formats and migrates to new format - Preserves user configurations automatically 3. **Guest Metadata Migration** (`guest_metadata.go`): - Added `GetWithLegacyMigration()` helper method - Called during VM/container polling to migrate metadata - Preserves custom URLs and descriptions 4. **Active Alerts Migration** (`alerts.go`): - Added migration logic in `LoadActiveAlerts()` - Translates legacy alert resource IDs to new format - Preserves alert acknowledgments across restarts ### Frontend Changes 5. **ID Construction Updates**: - `ThresholdsTable.tsx`: Updated fallback from `instance-node-vmid` to `instance-vmid` - `Dashboard.tsx`: Simplified guest ID construction - `GuestRow.tsx`: Updated `buildGuestId()` helper ## Migration Strategy - **Lazy Migration**: Configs are migrated as guests are discovered - **Backwards Compatible**: Old IDs are detected and automatically converted - **Zero Downtime**: No manual intervention required - **Persisted**: Migrated configs are saved on next config write cycle ## Testing Recommendations After deployment: 1. Verify existing alert overrides still apply 2. Test VM migration - confirm thresholds persist 3. Check guest metadata (custom URLs) survive migration 4. Verify active alerts maintain acknowledgment state ## Related - Addresses similar issues with guest metadata and active alert tracking - Lays groundwork for any future guest-specific configuration features - Aligns with project philosophy: correctness and UX over implementation complexity
256 lines
6.2 KiB
Go
256 lines
6.2 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// GuestMetadata holds additional metadata for a guest (VM/container)
|
|
type GuestMetadata struct {
|
|
ID string `json:"id"` // Guest ID (e.g., "node:vmid" format)
|
|
CustomURL string `json:"customUrl"` // Custom URL for the guest
|
|
Description string `json:"description"` // Optional description
|
|
Tags []string `json:"tags"` // Optional tags for categorization
|
|
}
|
|
|
|
// GuestMetadataStore manages guest metadata
|
|
type GuestMetadataStore struct {
|
|
mu sync.RWMutex
|
|
metadata map[string]*GuestMetadata // keyed by guest ID
|
|
dataPath string
|
|
}
|
|
|
|
// NewGuestMetadataStore creates a new metadata store
|
|
func NewGuestMetadataStore(dataPath string) *GuestMetadataStore {
|
|
store := &GuestMetadataStore{
|
|
metadata: make(map[string]*GuestMetadata),
|
|
dataPath: dataPath,
|
|
}
|
|
|
|
// Load existing metadata
|
|
if err := store.Load(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to load guest metadata")
|
|
}
|
|
|
|
return store
|
|
}
|
|
|
|
// Get retrieves metadata for a guest
|
|
func (s *GuestMetadataStore) Get(guestID string) *GuestMetadata {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if meta, exists := s.metadata[guestID]; exists {
|
|
return meta
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetWithLegacyMigration retrieves metadata for a guest, attempting legacy ID formats if needed
|
|
// and migrating them to the new stable format. This should be called when full guest context
|
|
// (node, instance, vmid) is available.
|
|
func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node string, vmid int) *GuestMetadata {
|
|
s.mu.RLock()
|
|
meta, exists := s.metadata[guestID]
|
|
s.mu.RUnlock()
|
|
|
|
if exists {
|
|
return meta
|
|
}
|
|
|
|
// Try legacy formats and migrate if found
|
|
var legacyID string
|
|
var legacyMeta *GuestMetadata
|
|
|
|
// Try legacy format: instance-node-VMID
|
|
if instance != node {
|
|
legacyID = fmt.Sprintf("%s-%s-%d", instance, node, vmid)
|
|
s.mu.RLock()
|
|
legacyMeta = s.metadata[legacyID]
|
|
s.mu.RUnlock()
|
|
|
|
if legacyMeta != nil {
|
|
log.Info().
|
|
Str("legacyID", legacyID).
|
|
Str("newID", guestID).
|
|
Msg("Migrating guest metadata from legacy ID format")
|
|
|
|
s.mu.Lock()
|
|
// Move to new ID
|
|
s.metadata[guestID] = legacyMeta
|
|
legacyMeta.ID = guestID
|
|
delete(s.metadata, legacyID)
|
|
// Save asynchronously
|
|
go func() {
|
|
if err := s.save(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save guest metadata after migration")
|
|
}
|
|
}()
|
|
s.mu.Unlock()
|
|
|
|
return legacyMeta
|
|
}
|
|
}
|
|
|
|
// Try standalone format: node-VMID
|
|
if instance == node {
|
|
legacyID = fmt.Sprintf("%s-%d", node, vmid)
|
|
s.mu.RLock()
|
|
legacyMeta = s.metadata[legacyID]
|
|
s.mu.RUnlock()
|
|
|
|
if legacyMeta != nil {
|
|
log.Info().
|
|
Str("legacyID", legacyID).
|
|
Str("newID", guestID).
|
|
Msg("Migrating guest metadata from legacy standalone ID format")
|
|
|
|
s.mu.Lock()
|
|
// Move to new ID
|
|
s.metadata[guestID] = legacyMeta
|
|
legacyMeta.ID = guestID
|
|
delete(s.metadata, legacyID)
|
|
// Save asynchronously
|
|
go func() {
|
|
if err := s.save(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save guest metadata after migration")
|
|
}
|
|
}()
|
|
s.mu.Unlock()
|
|
|
|
return legacyMeta
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetAll retrieves all guest metadata
|
|
func (s *GuestMetadataStore) GetAll() map[string]*GuestMetadata {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Return a copy to prevent external modifications
|
|
result := make(map[string]*GuestMetadata)
|
|
for k, v := range s.metadata {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Set updates or creates metadata for a guest
|
|
func (s *GuestMetadataStore) Set(guestID string, meta *GuestMetadata) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if meta == nil {
|
|
return fmt.Errorf("metadata cannot be nil")
|
|
}
|
|
|
|
meta.ID = guestID
|
|
s.metadata[guestID] = meta
|
|
|
|
// Save to disk
|
|
return s.save()
|
|
}
|
|
|
|
// Delete removes metadata for a guest
|
|
func (s *GuestMetadataStore) Delete(guestID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
delete(s.metadata, guestID)
|
|
|
|
// Save to disk
|
|
return s.save()
|
|
}
|
|
|
|
// ReplaceAll replaces all metadata entries and persists them to disk.
|
|
func (s *GuestMetadataStore) ReplaceAll(metadata map[string]*GuestMetadata) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.metadata = make(map[string]*GuestMetadata)
|
|
|
|
if metadata != nil {
|
|
for guestID, meta := range metadata {
|
|
if meta == nil {
|
|
continue
|
|
}
|
|
|
|
clone := *meta
|
|
clone.ID = guestID
|
|
// Ensure slice copy is not nil to allow JSON marshalling of empty tags
|
|
if clone.Tags == nil {
|
|
clone.Tags = []string{}
|
|
}
|
|
s.metadata[guestID] = &clone
|
|
}
|
|
}
|
|
|
|
return s.save()
|
|
}
|
|
|
|
// Load reads metadata from disk
|
|
func (s *GuestMetadataStore) Load() error {
|
|
filePath := filepath.Join(s.dataPath, "guest_metadata.json")
|
|
|
|
log.Debug().Str("path", filePath).Msg("Loading guest metadata from disk")
|
|
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
// File doesn't exist yet, not an error
|
|
log.Debug().Str("path", filePath).Msg("Guest metadata file does not exist yet")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to read metadata file: %w", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := json.Unmarshal(data, &s.metadata); err != nil {
|
|
return fmt.Errorf("failed to unmarshal metadata: %w", err)
|
|
}
|
|
|
|
log.Info().Int("count", len(s.metadata)).Msg("Loaded guest metadata")
|
|
return nil
|
|
}
|
|
|
|
// save writes metadata to disk (must be called with lock held)
|
|
func (s *GuestMetadataStore) save() error {
|
|
filePath := filepath.Join(s.dataPath, "guest_metadata.json")
|
|
|
|
log.Debug().Str("path", filePath).Msg("Saving guest metadata to disk")
|
|
|
|
data, err := json.Marshal(s.metadata)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
// Ensure directory exists
|
|
if err := os.MkdirAll(s.dataPath, 0755); err != nil {
|
|
return fmt.Errorf("failed to create data directory: %w", err)
|
|
}
|
|
|
|
// Write to temp file first for atomic operation
|
|
tempFile := filePath + ".tmp"
|
|
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write metadata file: %w", err)
|
|
}
|
|
|
|
// Rename temp file to actual file (atomic on most systems)
|
|
if err := os.Rename(tempFile, filePath); err != nil {
|
|
return fmt.Errorf("failed to rename metadata file: %w", err)
|
|
}
|
|
|
|
log.Debug().Str("path", filePath).Int("entries", len(s.metadata)).Msg("Guest metadata saved successfully")
|
|
|
|
return nil
|
|
}
|