Files
pulse/internal/monitoring/availability_link_migration.go
T
rcourtman e74fea4bbc Give Proxmox guests node-independent canonical identity
A guest's unified canonical ID hashed its node-scoped source ID
(instance:node:vmid), so a live migration to another cluster node
re-minted the resource and orphaned every operator-owned row keyed by
the old ID: explicit availability check links (fail-closed by design,
the reported symptom in #1669), alert overrides, operator state, action
audits, manual links, and recovery subjects. VMIDs are unique within a
cluster, so guests now derive their canonical ID from instance+VMID
("proxmox-guest:<instance>:<vmid>") and keep it across migrations. The
guest-metadata half of #1669 was fixed separately at the metadata-store
layer.

Existing installs converge through record-declared succession: ingest
declares the retired node-scoped IDs superseded for every node the
instance currently knows (current names plus native aliases), so rows
orphaned by pre-upgrade migrations also re-key. Successions are now
recorded durably in a canonical_id_successions table, which memoizes
the re-key (steady-state rebuilds re-declare the same eras every tick
without touching SQL) and lets change-journal reads merge retired guest
eras the way pin EraIDs do for hosts. The succession re-key also covers
manual link/exclusion rows.

Availability links resolve retired canonical IDs and old-node source
triples through a registry superseded index plus guest-triple parsing
(persistence keys only, ambiguity fails closed), and the stored
LinkedResourceID re-homes to the current canonical ID on the
alert-migration cadence. Recovery subjects converge on the same
derivation via CanonicalSubjectResourceID, the mapper's registry-miss
fallback, node-independent external guest keys, and the store's startup
backfill, which also sweeps posture rows stranded under retired subject
keys. Metrics history and frontend row identity key off the node-scoped
source ID and are deliberately unchanged.
2026-08-02 20:30:38 +01:00

135 lines
4.3 KiB
Go

package monitoring
import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
// migrateAvailabilityLinksToCanonicalIDs loads the availability target
// configuration, re-homes explicit links whose resource reference was retired
// by a canonical-ID succession or a guest live migration, and persists the
// result. Runs on the same cadence as the alert override migration; once
// every link references a live canonical ID it is a cheap no-op.
func (m *Monitor) migrateAvailabilityLinksToCanonicalIDs(resources []unifiedresources.Resource) {
if m == nil || m.configPersist == nil || len(resources) == 0 {
return
}
targets, err := m.configPersist.LoadAvailabilityTargets()
if err != nil || len(targets) == 0 {
return
}
migrated, changed := migrateAvailabilityLinkedResources(targets, resources)
if !changed {
return
}
if err := m.configPersist.SaveAvailabilityTargets(migrated); err != nil {
log.Error().Err(err).Msg("failed to persist canonical availability link migration")
return
}
log.Info().Msg("migrated availability links to current canonical resource IDs")
}
// migrateAvailabilityLinkedResources re-homes explicit availability links
// written under a retired canonical resource ID (or under a node-scoped guest
// source ID) onto the resource's current canonical ID. The explicit link is
// documented as authoritative and fail-closed, so only provider-declared
// persistence keys participate: a superseded canonical ID declared by exactly
// one live resource, or a Proxmox guest source reference whose instance+VMID
// matches exactly one live guest. Address or hostname coincidences never
// migrate a link.
//
// The returned slice shares unmodified entries with the input; the boolean
// reports whether any link changed.
func migrateAvailabilityLinkedResources(targets []config.AvailabilityTarget, resources []unifiedresources.Resource) ([]config.AvailabilityTarget, bool) {
if len(targets) == 0 || len(resources) == 0 {
return targets, false
}
hasLink := false
for _, target := range targets {
if strings.TrimSpace(target.LinkedResourceID) != "" {
hasLink = true
break
}
}
if !hasLink {
return targets, false
}
liveIDs := make(map[string]struct{}, len(resources))
successors := make(map[string]string)
ambiguous := make(map[string]struct{})
guestIDsByKey := make(map[string][]string)
for _, resource := range resources {
currentID := unifiedresources.CanonicalResourceID(resource.ID)
if currentID == "" {
continue
}
liveIDs[currentID] = struct{}{}
for _, supersededID := range resource.SupersededCanonicalIDs {
oldID := unifiedresources.CanonicalResourceID(supersededID)
if oldID == "" || oldID == currentID {
continue
}
if existing, ok := successors[oldID]; ok && existing != currentID {
delete(successors, oldID)
ambiguous[oldID] = struct{}{}
continue
}
if _, conflict := ambiguous[oldID]; conflict {
continue
}
successors[oldID] = currentID
}
if resource.Proxmox != nil && resource.Proxmox.VMID > 0 {
switch unifiedresources.CanonicalResourceType(resource.Type) {
case unifiedresources.ResourceTypeVM, unifiedresources.ResourceTypeSystemContainer:
key := unifiedresources.ProxmoxGuestIdentityKey(resource.Proxmox.Instance, resource.Proxmox.VMID)
if key != "" {
guestIDsByKey[key] = append(guestIDsByKey[key], currentID)
}
}
}
}
changed := false
out := targets
for i, target := range targets {
linkedID := strings.TrimSpace(target.LinkedResourceID)
if linkedID == "" {
continue
}
if _, live := liveIDs[linkedID]; live {
continue
}
newID := successors[linkedID]
if newID == "" {
if instance, _, vmid, ok := unifiedresources.ParseProxmoxGuestSourceID(linkedID); ok {
// Node segment deliberately ignored: the link must follow the
// guest across live migrations. Ambiguity fails closed.
if candidates := guestIDsByKey[unifiedresources.ProxmoxGuestIdentityKey(instance, vmid)]; len(candidates) == 1 {
newID = candidates[0]
}
}
}
if newID == "" || newID == linkedID {
continue
}
if !changed {
out = make([]config.AvailabilityTarget, len(targets))
copy(out, targets)
changed = true
}
out[i].LinkedResourceID = newID
}
return out, changed
}