mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 03:33:53 +00:00
55048fb181
Adversarial review of 84dba861b found three ways the new evidence paths
could still attribute a snapshot to the wrong cluster.
The submission-source learner was asymmetric. Clusters only became known
to it through snapshots that were already attributable, so a cluster with
no uniquely-attributable snapshot was invisible - and a source token both
clusters share then mapped to exactly one visible cluster and looked
decisive. The visible cluster got the other's backups while the other
guest stayed at zero. Callers now declare every connection owning a
candidate guest for a PBS instance, and the learner refuses to resolve
anything for that instance until each of them has had a snapshot
attributed to it. Observation is not scoped per PBS instance, so a
cluster seen submitting to its own PBS server still counts as visible -
the reported two-server topology keeps working.
PVE storage confirmations were treated as authorship. A pbs-type storage
listing proves the connection can SEE a snapshot, which a shared token, a
synced datastore, or an offsite copy all arrange without the connection
having made it, and a single confirmer previously outscored everything
else. Confirmations now carry the storage they came from, and only a
storage view that never lists a snapshot some other connection also lists
can attribute a colliding VMID. An overlapping view has demonstrated it
sees other clusters' snapshots, so nothing it lists attributes anything.
Where an exclusive view and the learned source mapping both speak they
must agree, otherwise the snapshot drops as it did before #1639. The
disjoint case - each cluster mounting only its own datastore - is
unchanged.
Confirmations were evicted by partial poll failures. A storage whose
content query failed contributed nothing, and the partial set overwrote
the previous one, flipping attribution between cycles. They now go
through the same per-storage preservation as storage backups.
Contract text calling the PVE listing "the only deterministic
attribution" is reworded to match the weakened semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2418 lines
72 KiB
Go
2418 lines
72 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring/errors"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
|
|
proxmoxrecoverymapper "github.com/rcourtman/pulse-go-rewrite/internal/recovery/mapper/proxmox"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
pbsBackupSnapshotFetchWorkers = 5
|
|
// pbsBackupSnapshotsPerGroupLimit bounds how many real snapshots are
|
|
// retained per backup group (newest first). It must comfortably exceed
|
|
// common PBS keep policies: groups are always represented by real fetched
|
|
// snapshots, never synthesized group metadata, because a synthesized entry
|
|
// has no verification, size, file, or per-snapshot time data (issue #1541).
|
|
pbsBackupSnapshotsPerGroupLimit = 100
|
|
// pbsBackupLiveStateLimit bounds the per-instance PBS backup list as a
|
|
// whole. Groups are processed newest-first so the newest restore points
|
|
// win when the limit is hit (issue #1524).
|
|
pbsBackupLiveStateLimit = 5000
|
|
)
|
|
|
|
func pveBackupTemplateSubjectKey(instance, guestType, node string, vmid int) string {
|
|
return alerts.BuildBackupPVETemplateSubjectKey(instance, guestType, node, vmid)
|
|
}
|
|
|
|
func (m *Monitor) updatePVEBackupTemplateSubjectsForType(instanceName, guestType string, subjects map[string]struct{}) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
instanceName = strings.TrimSpace(instanceName)
|
|
guestType = strings.TrimSpace(guestType)
|
|
if instanceName == "" || guestType == "" {
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.pveBackupInventoryReady == nil {
|
|
m.pveBackupInventoryReady = make(map[string]map[string]bool)
|
|
}
|
|
if m.pveBackupInventoryReady[instanceName] == nil {
|
|
m.pveBackupInventoryReady[instanceName] = make(map[string]bool)
|
|
}
|
|
m.pveBackupInventoryReady[instanceName][guestType] = true
|
|
|
|
if m.pveBackupTemplateSubjects == nil {
|
|
m.pveBackupTemplateSubjects = make(map[string]map[string]struct{})
|
|
}
|
|
existing := m.pveBackupTemplateSubjects[instanceName]
|
|
if existing == nil {
|
|
existing = make(map[string]struct{})
|
|
}
|
|
prefix := instanceName + "\x00" + guestType + "\x00"
|
|
for key := range existing {
|
|
if strings.HasPrefix(key, prefix) {
|
|
delete(existing, key)
|
|
}
|
|
}
|
|
for key := range subjects {
|
|
if key != "" {
|
|
existing[key] = struct{}{}
|
|
}
|
|
}
|
|
m.pveBackupTemplateSubjects[instanceName] = existing
|
|
}
|
|
|
|
func (m *Monitor) updatePVEBackupTemplateSubjectsFromClusterResources(instanceName string, resources []proxmox.ClusterResource) {
|
|
qemuTemplates := make(map[string]struct{})
|
|
lxcTemplates := make(map[string]struct{})
|
|
for _, res := range resources {
|
|
if res.Template != 1 {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(res.Type) {
|
|
case "qemu":
|
|
if key := pveBackupTemplateSubjectKey(instanceName, "qemu", res.Node, res.VMID); key != "" {
|
|
qemuTemplates[key] = struct{}{}
|
|
}
|
|
case "lxc":
|
|
if key := pveBackupTemplateSubjectKey(instanceName, "lxc", res.Node, res.VMID); key != "" {
|
|
lxcTemplates[key] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
m.updatePVEBackupTemplateSubjectsForType(instanceName, "qemu", qemuTemplates)
|
|
m.updatePVEBackupTemplateSubjectsForType(instanceName, "lxc", lxcTemplates)
|
|
}
|
|
|
|
func quotePVEACLTokenID(tokenID string) string {
|
|
return "'" + strings.ReplaceAll(tokenID, "'", `'"'"'`) + "'"
|
|
}
|
|
|
|
func pveBackupPermissionWarning(instanceCfg *config.PVEInstance) string {
|
|
warning := "Missing PVEDatastoreAdmin permission on /storage. Run: pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin"
|
|
if instanceCfg == nil {
|
|
return warning + "; if using a privilege-separated API token, also grant PVEDatastoreAdmin on /storage to that token."
|
|
}
|
|
tokenID := strings.TrimSpace(instanceCfg.TokenName)
|
|
if tokenID == "" || !strings.Contains(tokenID, "!") {
|
|
return warning + "; if using a privilege-separated API token, also grant PVEDatastoreAdmin on /storage to that token."
|
|
}
|
|
return warning + " && pveum aclmod /storage -token " + quotePVEACLTokenID(tokenID) + " -role PVEDatastoreAdmin"
|
|
}
|
|
|
|
func (m *Monitor) backupInventoryScopeForAlerts() *alerts.BackupInventoryScope {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
scope := &alerts.BackupInventoryScope{
|
|
PVEOrphanInventoryReady: make(map[string]map[string]bool),
|
|
PVETemplateSubjects: make(map[string]struct{}),
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
for instance, readyByType := range m.pveBackupInventoryReady {
|
|
if len(readyByType) == 0 {
|
|
continue
|
|
}
|
|
scope.PVEOrphanInventoryReady[instance] = make(map[string]bool, len(readyByType))
|
|
for guestType, ready := range readyByType {
|
|
scope.PVEOrphanInventoryReady[instance][guestType] = ready
|
|
}
|
|
}
|
|
for _, subjects := range m.pveBackupTemplateSubjects {
|
|
for key := range subjects {
|
|
scope.PVETemplateSubjects[key] = struct{}{}
|
|
}
|
|
}
|
|
return scope
|
|
}
|
|
|
|
func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName string, client PVEClientInterface, nodes []proxmox.Node, nodeEffectiveStatus map[string]string) {
|
|
|
|
var allBackups []models.StorageBackup
|
|
var pbsConfirmations []models.PBSGuestConfirmation
|
|
hasPBSDirectConnection := m.config != nil && len(m.config.PBSInstances) > 0
|
|
seenVolids := make(map[string]bool) // Track seen volume IDs to avoid duplicates
|
|
hadSuccessfulNode := false // Track if at least one node responded successfully
|
|
storagesWithBackup := 0 // Number of storages that should contain backups
|
|
contentSuccess := 0 // Number of successful storage content fetches
|
|
contentFailures := 0 // Number of failed storage content fetches
|
|
storageQueryErrors := 0 // Number of nodes where storage list could not be queried
|
|
hadPermissionError := false // Track if any permission errors occurred this cycle
|
|
storagePreserveNeeded := map[string]struct{}{}
|
|
storageSuccess := map[string]struct{}{}
|
|
readState := m.backupReadStateForInstance(instanceName)
|
|
|
|
// Build guest lookup map to find actual node for each VMID
|
|
snapshot := m.state.GetSnapshot()
|
|
guestNodeMap := make(map[int]string) // VMID -> actual node name
|
|
populateGuestNodeMapFromReadState(readState, instanceName, guestNodeMap)
|
|
|
|
// For each node, get storage and check content
|
|
for _, node := range nodes {
|
|
if nodeEffectiveStatus[node.Node] != "online" {
|
|
for _, storageName := range storageNamesForNode(readState, instanceName, node.Node) {
|
|
storagePreserveNeeded[storageName] = struct{}{}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Get storage for this node - retry once on timeout
|
|
var storages []proxmox.Storage
|
|
var err error
|
|
|
|
for attempt := 1; attempt <= 2; attempt++ {
|
|
storages, err = client.GetStorage(ctx, node.Node)
|
|
if err == nil {
|
|
break // Success
|
|
}
|
|
|
|
// Check if it's a timeout error
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "timeout") || strings.Contains(errStr, "deadline exceeded") {
|
|
if attempt == 1 {
|
|
log.Warn().
|
|
Str("node", node.Node).
|
|
Str("instance", instanceName).
|
|
Msg("Storage query timed out, retrying with extended timeout...")
|
|
// Give it a bit more time on retry
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
}
|
|
// Non-timeout error or second attempt failed
|
|
break
|
|
}
|
|
|
|
if err != nil {
|
|
monErr := errors.NewMonitorError(errors.ErrorTypeAPI, "get_storage_for_backups", instanceName, err).WithNode(node.Node)
|
|
log.Warn().Err(monErr).Str("node", node.Node).Msg("failed to get storage for backups - skipping node")
|
|
for _, storageName := range storageNamesForNode(readState, instanceName, node.Node) {
|
|
storagePreserveNeeded[storageName] = struct{}{}
|
|
}
|
|
storageQueryErrors++
|
|
continue
|
|
}
|
|
|
|
hadSuccessfulNode = true
|
|
|
|
// For each storage that can contain backups or templates
|
|
for _, storage := range storages {
|
|
// Check if storage supports backup content
|
|
if !strings.Contains(storage.Content, "backup") {
|
|
continue
|
|
}
|
|
if !storageContentQueryable(storage) {
|
|
continue
|
|
}
|
|
|
|
storagesWithBackup++
|
|
|
|
// Get storage content
|
|
contents, err := client.GetStorageContent(ctx, node.Node, storage.Storage)
|
|
if err != nil {
|
|
monErr := errors.NewMonitorError(errors.ErrorTypeAPI, "get_storage_content", instanceName, err).WithNode(node.Node)
|
|
errStr := strings.ToLower(err.Error())
|
|
|
|
// Check if this is a permission error
|
|
if strings.Contains(errStr, "403") || strings.Contains(errStr, "401") ||
|
|
strings.Contains(errStr, "permission") || strings.Contains(errStr, "forbidden") {
|
|
hadPermissionError = true
|
|
warning := pveBackupPermissionWarning(m.getInstanceConfig(instanceName))
|
|
m.mu.Lock()
|
|
m.backupPermissionWarnings[instanceName] = warning
|
|
m.mu.Unlock()
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Str("node", node.Node).
|
|
Str("storage", storage.Storage).
|
|
Msg("Backup permission denied - PVEDatastoreAdmin role may be missing on /storage")
|
|
} else {
|
|
log.Debug().Err(monErr).
|
|
Str("node", node.Node).
|
|
Str("storage", storage.Storage).
|
|
Msg("Failed to get storage content")
|
|
}
|
|
if _, ok := storageSuccess[storage.Storage]; !ok {
|
|
storagePreserveNeeded[storage.Storage] = struct{}{}
|
|
}
|
|
contentFailures++
|
|
continue
|
|
}
|
|
|
|
contentSuccess++
|
|
storageSuccess[storage.Storage] = struct{}{}
|
|
delete(storagePreserveNeeded, storage.Storage)
|
|
|
|
// Convert to models
|
|
for _, content := range contents {
|
|
// Skip if we've already seen this item (shared storage duplicate)
|
|
if seenVolids[content.Volid] {
|
|
continue
|
|
}
|
|
seenVolids[content.Volid] = true
|
|
|
|
// Skip templates and ISOs - they're not backups
|
|
if content.Content == "vztmpl" || content.Content == "iso" {
|
|
continue
|
|
}
|
|
|
|
// Determine type from content type and VMID
|
|
backupType := "unknown"
|
|
if content.VMID == 0 {
|
|
backupType = "host"
|
|
} else if strings.Contains(content.Volid, "/vm/") || strings.Contains(content.Volid, "qemu") {
|
|
backupType = "qemu"
|
|
} else if strings.Contains(content.Volid, "/ct/") || strings.Contains(content.Volid, "lxc") {
|
|
backupType = "lxc"
|
|
} else if strings.Contains(content.Format, "pbs-ct") {
|
|
// PBS format check as fallback
|
|
backupType = "lxc"
|
|
} else if strings.Contains(content.Format, "pbs-vm") {
|
|
// PBS format check as fallback
|
|
backupType = "qemu"
|
|
}
|
|
|
|
// Determine the correct node: for guest backups (VMID > 0), use the actual guest's node
|
|
// For host backups (VMID == 0), use the node where the backup was found
|
|
backupNode := node.Node
|
|
if content.VMID > 0 {
|
|
if actualNode, found := guestNodeMap[content.VMID]; found {
|
|
backupNode = actualNode
|
|
}
|
|
// If not found in map, fall back to queried node (shouldn't happen normally)
|
|
}
|
|
// Classify by storage type only. A dir/NFS storage is free to be
|
|
// NAMED "pbs-…" (e.g. "pbs-backup"), and treating the name prefix
|
|
// as PBS silently dropped its vzdump backups whenever a direct PBS
|
|
// connection was also configured (#1592).
|
|
isPBSStorage := storage.Type == "pbs"
|
|
if isPBSStorage && hasPBSDirectConnection {
|
|
// The direct PBS connection is authoritative for the
|
|
// backup list, but which storage view listed a snapshot
|
|
// is evidence only this connection has: it helps
|
|
// attribute a snapshot whose VMID exists on several
|
|
// clusters (#1639). Keep that association, tagged with
|
|
// the storage it came from, even though the raw entry is
|
|
// dropped - a listing only proves this connection can see
|
|
// the snapshot, so consumers need the storage to tell an
|
|
// exclusive view from a shared or synced datastore.
|
|
if confirmationType := pbsGuestConfirmationType(backupType); confirmationType != "" && content.VMID > 0 && content.CTime > 0 {
|
|
pbsConfirmations = append(pbsConfirmations, models.PBSGuestConfirmation{
|
|
Storage: storage.Storage,
|
|
BackupType: confirmationType,
|
|
VMID: content.VMID,
|
|
Time: content.CTime,
|
|
})
|
|
}
|
|
log.Debug().
|
|
Str("instance", instanceName).
|
|
Str("node", node.Node).
|
|
Str("storage", storage.Storage).
|
|
Str("volid", content.Volid).
|
|
Msg("Skipping PBS backup from PVE storage - PBS direct connection is authoritative")
|
|
continue
|
|
}
|
|
|
|
// Check verification status for PBS backups
|
|
verified := false
|
|
verificationInfo := ""
|
|
if isPBSStorage {
|
|
// Check if verified flag is set
|
|
if content.Verified > 0 {
|
|
verified = true
|
|
}
|
|
// Also check verification map if available
|
|
if content.Verification != nil {
|
|
if state, ok := content.Verification["state"].(string); ok {
|
|
verified = (state == "ok")
|
|
verificationInfo = state
|
|
}
|
|
}
|
|
}
|
|
|
|
backup := models.StorageBackup{
|
|
ID: fmt.Sprintf("%s-%s", instanceName, content.Volid),
|
|
Storage: storage.Storage,
|
|
Node: backupNode,
|
|
Instance: instanceName,
|
|
Type: backupType,
|
|
VMID: content.VMID,
|
|
Time: time.Unix(content.CTime, 0),
|
|
CTime: content.CTime,
|
|
Size: int64(content.Size),
|
|
Format: content.Format,
|
|
Notes: content.Notes,
|
|
Protected: content.Protected > 0,
|
|
Volid: content.Volid,
|
|
IsPBS: isPBSStorage,
|
|
Verified: verified,
|
|
Verification: verificationInfo,
|
|
}
|
|
|
|
allBackups = append(allBackups, backup)
|
|
}
|
|
}
|
|
}
|
|
|
|
allBackups, preservedStorages := preserveFailedStorageBackups(instanceName, snapshot, storagePreserveNeeded, allBackups)
|
|
if len(preservedStorages) > 0 {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Strs("storages", preservedStorages).
|
|
Msg("Preserving previous storage backup data due to partial failures")
|
|
}
|
|
|
|
pbsConfirmations, preservedConfirmationStorages := preserveFailedPBSGuestConfirmations(
|
|
m.state.PBSGuestConfirmationsForInstance(instanceName), storagePreserveNeeded, pbsConfirmations)
|
|
if len(preservedConfirmationStorages) > 0 {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Strs("storages", preservedConfirmationStorages).
|
|
Msg("Preserving previous PBS guest confirmation evidence due to partial failures")
|
|
}
|
|
|
|
// Decide whether to keep existing backups when every query failed
|
|
if shouldPreserveBackups(len(nodes), hadSuccessfulNode, storagesWithBackup, contentSuccess) {
|
|
if len(nodes) > 0 && !hadSuccessfulNode {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Int("nodes", len(nodes)).
|
|
Int("errors", storageQueryErrors).
|
|
Msg("Failed to query storage on all nodes; keeping previous backup list")
|
|
} else if storagesWithBackup > 0 && contentSuccess == 0 {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Int("storages", storagesWithBackup).
|
|
Int("failures", contentFailures).
|
|
Msg("All storage content queries failed; keeping previous backup list")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Update state with storage backups for this instance
|
|
m.state.UpdateStorageBackupsForInstance(instanceName, allBackups)
|
|
m.state.UpdatePBSGuestConfirmationsForInstance(instanceName, pbsConfirmations)
|
|
|
|
// Best-effort ingestion into recovery store (for rollups / unified backups UX).
|
|
guestInfo := buildProxmoxGuestInfoIndex(readState)
|
|
m.ingestAndReconcileRecoveryPointsAsync(
|
|
proxmoxrecoverymapper.FromPVEStorageBackups(allBackups, guestInfo),
|
|
recoveryReconcileScope{
|
|
provider: string(recovery.ProviderProxmoxPVE),
|
|
idPrefix: "pve-backup:",
|
|
instance: instanceName,
|
|
},
|
|
)
|
|
|
|
// Sync backup times to VMs/Containers and republish them to canonical resources.
|
|
m.syncGuestBackupTimesAndResourceStore()
|
|
|
|
if m.alertManager != nil {
|
|
guestsByKey, guestsByVMID := buildGuestLookupsFromReadState(m.GetUnifiedReadStateOrSnapshot(), m.guestMetadataStore)
|
|
rollups, err := m.listBackupRollupsForAlerts(ctx)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to list recovery rollups for backup alerts")
|
|
} else {
|
|
m.alertManager.CheckBackupsWithInventory(rollups, guestsByKey, guestsByVMID, m.backupInventoryScopeForAlerts())
|
|
}
|
|
}
|
|
|
|
// Clear permission warning if no permission errors occurred this cycle
|
|
if !hadPermissionError {
|
|
m.mu.Lock()
|
|
delete(m.backupPermissionWarnings, instanceName)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
log.Debug().
|
|
Str("instance", instanceName).
|
|
Int("count", len(allBackups)).
|
|
Msg("Storage backups polled")
|
|
|
|
// Immediately broadcast the updated state so frontend sees new backups
|
|
m.broadcastStateUpdate()
|
|
}
|
|
|
|
// pbsGuestConfirmationType maps a PVE storage-content backup type to PBS
|
|
// snapshot nomenclature. Host and unclassifiable entries return "" and are
|
|
// not usable as guest attribution evidence.
|
|
func pbsGuestConfirmationType(backupType string) string {
|
|
switch backupType {
|
|
case "qemu":
|
|
return "vm"
|
|
case "lxc":
|
|
return "ct"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func shouldPreserveBackups(nodeCount int, hadSuccessfulNode bool, storagesWithBackup, contentSuccess int) bool {
|
|
if nodeCount > 0 && !hadSuccessfulNode {
|
|
return true
|
|
}
|
|
if storagesWithBackup > 0 && contentSuccess == 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *Monitor) syncGuestBackupTimesAndResourceStore() {
|
|
if m == nil || m.state == nil {
|
|
return
|
|
}
|
|
|
|
m.state.SyncGuestBackupTimes()
|
|
m.updateResourceStore(m.state.GetSnapshot())
|
|
}
|
|
|
|
func (m *Monitor) backupReadStateForInstance(instanceName string) unifiedresources.ReadState {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
readState := m.GetUnifiedReadStateOrSnapshot()
|
|
if backupReadStateHasGuestForInstance(readState, instanceName) || m.state == nil {
|
|
return readState
|
|
}
|
|
|
|
snapshot := m.state.GetSnapshot()
|
|
if !backupSnapshotHasGuestForInstance(snapshot, instanceName) {
|
|
return readState
|
|
}
|
|
|
|
m.updateResourceStore(snapshot)
|
|
readState = m.GetUnifiedReadStateOrSnapshot()
|
|
if backupReadStateHasGuestForInstance(readState, instanceName) {
|
|
return readState
|
|
}
|
|
|
|
return monitorUnifiedStateViewFromSnapshot(snapshot).readState
|
|
}
|
|
|
|
func backupReadStateHasGuestForInstance(readState unifiedresources.ReadState, instanceName string) bool {
|
|
if readState == nil {
|
|
return false
|
|
}
|
|
for _, vm := range readState.VMs() {
|
|
if vm != nil && vm.Instance() == instanceName {
|
|
return true
|
|
}
|
|
}
|
|
for _, ct := range readState.Containers() {
|
|
if ct != nil && ct.Instance() == instanceName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func backupSnapshotHasGuestForInstance(snapshot models.StateSnapshot, instanceName string) bool {
|
|
for _, vm := range snapshot.VMs {
|
|
if vm.Instance == instanceName {
|
|
return true
|
|
}
|
|
}
|
|
for _, ct := range snapshot.Containers {
|
|
if ct.Instance == instanceName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func storageNamesForNode(readState unifiedresources.ReadState, instanceName, nodeName string) []string {
|
|
if readState == nil || nodeName == "" {
|
|
return nil
|
|
}
|
|
|
|
var storages []string
|
|
for _, storage := range readState.StoragePools() {
|
|
if storage == nil || storage.Instance() != instanceName {
|
|
continue
|
|
}
|
|
if storage.Name() == "" {
|
|
continue
|
|
}
|
|
if !strings.Contains(storage.Content(), "backup") {
|
|
continue
|
|
}
|
|
if storage.Node() == nodeName {
|
|
storages = append(storages, storage.Name())
|
|
continue
|
|
}
|
|
for _, node := range storage.AccessibleNodes() {
|
|
if node == nodeName {
|
|
storages = append(storages, storage.Name())
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return storages
|
|
}
|
|
|
|
// preserveFailedPBSGuestConfirmations carries forward the PBS guest
|
|
// confirmation evidence of storages whose content query failed this cycle,
|
|
// mirroring preserveFailedStorageBackups. Publishing the partial (possibly
|
|
// empty) set instead would evict attribution evidence on a transient poll
|
|
// failure and flip collision-VMID guests between cycles (#1639).
|
|
func preserveFailedPBSGuestConfirmations(previous []models.PBSGuestConfirmation, storagesToPreserve map[string]struct{}, current []models.PBSGuestConfirmation) ([]models.PBSGuestConfirmation, []string) {
|
|
if len(storagesToPreserve) == 0 || len(previous) == 0 {
|
|
return current, nil
|
|
}
|
|
|
|
existing := make(map[models.PBSGuestConfirmation]struct{}, len(current))
|
|
for _, confirmation := range current {
|
|
existing[confirmation] = struct{}{}
|
|
}
|
|
|
|
preserved := make(map[string]struct{})
|
|
for _, confirmation := range previous {
|
|
if _, ok := storagesToPreserve[confirmation.Storage]; !ok {
|
|
continue
|
|
}
|
|
if _, duplicate := existing[confirmation]; duplicate {
|
|
continue
|
|
}
|
|
current = append(current, confirmation)
|
|
existing[confirmation] = struct{}{}
|
|
preserved[confirmation.Storage] = struct{}{}
|
|
}
|
|
|
|
if len(preserved) == 0 {
|
|
return current, nil
|
|
}
|
|
|
|
storages := make([]string, 0, len(preserved))
|
|
for storage := range preserved {
|
|
storages = append(storages, storage)
|
|
}
|
|
sort.Strings(storages)
|
|
return current, storages
|
|
}
|
|
|
|
func preserveFailedStorageBackups(instanceName string, snapshot models.StateSnapshot, storagesToPreserve map[string]struct{}, current []models.StorageBackup) ([]models.StorageBackup, []string) {
|
|
if len(storagesToPreserve) == 0 {
|
|
return current, nil
|
|
}
|
|
|
|
existing := make(map[string]struct{}, len(current))
|
|
for _, backup := range current {
|
|
existing[backup.ID] = struct{}{}
|
|
}
|
|
|
|
preserved := make(map[string]struct{})
|
|
for _, backup := range snapshot.PVEBackups.StorageBackups {
|
|
if backup.Instance != instanceName {
|
|
continue
|
|
}
|
|
if _, ok := storagesToPreserve[backup.Storage]; !ok {
|
|
continue
|
|
}
|
|
if _, duplicate := existing[backup.ID]; duplicate {
|
|
continue
|
|
}
|
|
current = append(current, backup)
|
|
existing[backup.ID] = struct{}{}
|
|
preserved[backup.Storage] = struct{}{}
|
|
}
|
|
|
|
if len(preserved) == 0 {
|
|
return current, nil
|
|
}
|
|
|
|
storages := make([]string, 0, len(preserved))
|
|
for storage := range preserved {
|
|
storages = append(storages, storage)
|
|
}
|
|
sort.Strings(storages)
|
|
return current, storages
|
|
}
|
|
|
|
func buildGuestLookupsFromReadState(readState unifiedresources.ReadState, metadataStore *config.GuestMetadataStore) (map[string]alerts.GuestLookup, map[string][]alerts.GuestLookup) {
|
|
byKey := make(map[string]alerts.GuestLookup)
|
|
byVMID := make(map[string][]alerts.GuestLookup)
|
|
|
|
if readState == nil {
|
|
if metadataStore != nil {
|
|
enrichWithPersistedMetadata(metadataStore, byVMID)
|
|
}
|
|
return byKey, byVMID
|
|
}
|
|
|
|
for _, vm := range readState.VMs() {
|
|
if vm == nil {
|
|
continue
|
|
}
|
|
info := alerts.GuestLookup{
|
|
ResourceID: makeGuestID(vm.Instance(), vm.Node(), vm.VMID()),
|
|
Name: vm.Name(),
|
|
Instance: vm.Instance(),
|
|
Node: vm.Node(),
|
|
Type: "qemu",
|
|
VMID: vm.VMID(),
|
|
Tags: vm.Tags(),
|
|
}
|
|
key := alerts.BuildGuestKey(vm.Instance(), vm.Node(), vm.VMID())
|
|
byKey[key] = info
|
|
|
|
vmidKey := strconv.Itoa(vm.VMID())
|
|
byVMID[vmidKey] = append(byVMID[vmidKey], info)
|
|
|
|
// Persist last-known name and type for this guest
|
|
if metadataStore != nil && vm.Name() != "" {
|
|
persistGuestIdentity(metadataStore, key, vm.Name(), info.Type)
|
|
}
|
|
}
|
|
|
|
for _, ct := range readState.Containers() {
|
|
if ct == nil {
|
|
continue
|
|
}
|
|
guestType := firstNonEmptyString(ct.ContainerType(), "lxc")
|
|
info := alerts.GuestLookup{
|
|
ResourceID: makeGuestID(ct.Instance(), ct.Node(), ct.VMID()),
|
|
Name: ct.Name(),
|
|
Instance: ct.Instance(),
|
|
Node: ct.Node(),
|
|
Type: guestType,
|
|
VMID: ct.VMID(),
|
|
Tags: ct.Tags(),
|
|
}
|
|
key := alerts.BuildGuestKey(ct.Instance(), ct.Node(), ct.VMID())
|
|
if _, exists := byKey[key]; !exists {
|
|
byKey[key] = info
|
|
}
|
|
|
|
vmidKey := strconv.Itoa(ct.VMID())
|
|
byVMID[vmidKey] = append(byVMID[vmidKey], info)
|
|
|
|
// Persist last-known name and type for this guest
|
|
if metadataStore != nil && ct.Name() != "" {
|
|
persistGuestIdentity(metadataStore, key, ct.Name(), guestType)
|
|
}
|
|
}
|
|
|
|
// Augment byVMID with persisted metadata for deleted guests
|
|
if metadataStore != nil {
|
|
enrichWithPersistedMetadata(metadataStore, byVMID)
|
|
}
|
|
|
|
return byKey, byVMID
|
|
}
|
|
|
|
func populateGuestNodeMapFromReadState(readState unifiedresources.ReadState, instanceName string, guestNodeMap map[int]string) {
|
|
if readState == nil {
|
|
return
|
|
}
|
|
for _, vm := range readState.VMs() {
|
|
if vm == nil || vm.Instance() != instanceName {
|
|
continue
|
|
}
|
|
guestNodeMap[vm.VMID()] = vm.Node()
|
|
}
|
|
for _, ct := range readState.Containers() {
|
|
if ct == nil || ct.Instance() != instanceName {
|
|
continue
|
|
}
|
|
guestNodeMap[ct.VMID()] = ct.Node()
|
|
}
|
|
}
|
|
|
|
// enrichWithPersistedMetadata adds entries from the metadata store for guests
|
|
// that no longer exist in the live inventory but have persisted identity data
|
|
func enrichWithPersistedMetadata(metadataStore *config.GuestMetadataStore, byVMID map[string][]alerts.GuestLookup) {
|
|
allMetadata := metadataStore.GetAll()
|
|
for guestKey, meta := range allMetadata {
|
|
if meta.LastKnownName == "" {
|
|
continue // No name persisted, skip
|
|
}
|
|
|
|
// Parse the guest key (format: instance:node:vmid)
|
|
// We need to extract instance, node, and vmid
|
|
var instance, node string
|
|
parts := strings.Split(guestKey, ":")
|
|
if len(parts) != 3 {
|
|
continue
|
|
}
|
|
instance, node = parts[0], parts[1]
|
|
vmid, err := strconv.Atoi(parts[2])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
vmidKey := strconv.Itoa(vmid)
|
|
|
|
// Check if we already have a live entry for this exact guest
|
|
hasLiveEntry := false
|
|
for _, existing := range byVMID[vmidKey] {
|
|
if existing.Instance == instance && existing.Node == node && existing.VMID == vmid {
|
|
hasLiveEntry = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Only add persisted metadata if no live entry exists
|
|
if !hasLiveEntry {
|
|
byVMID[vmidKey] = append(byVMID[vmidKey], alerts.GuestLookup{
|
|
Name: meta.LastKnownName,
|
|
Instance: instance,
|
|
Node: node,
|
|
Type: meta.LastKnownType,
|
|
VMID: vmid,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// persistGuestIdentity updates the metadata store with the last-known name and type for a guest
|
|
func persistGuestIdentity(metadataStore *config.GuestMetadataStore, guestKey, name, guestType string) {
|
|
existing := metadataStore.Get(guestKey)
|
|
if existing == nil {
|
|
existing = &config.GuestMetadata{
|
|
ID: guestKey,
|
|
Tags: []string{},
|
|
}
|
|
}
|
|
|
|
guestType = strings.TrimSpace(guestType)
|
|
if guestType == "" {
|
|
return
|
|
}
|
|
|
|
// Never "downgrade" OCI containers back to LXC. OCI classification can be transiently
|
|
// unavailable if Proxmox config reads fail due to permissions or transient API errors.
|
|
if existing.LastKnownType == "oci" && guestType != "oci" {
|
|
guestType = existing.LastKnownType
|
|
}
|
|
|
|
// Only update if the name or type has changed
|
|
if existing.LastKnownName != name || existing.LastKnownType != guestType {
|
|
existing.LastKnownName = name
|
|
existing.LastKnownType = guestType
|
|
// Save asynchronously to avoid blocking the monitor
|
|
go func() {
|
|
if err := metadataStore.Set(guestKey, existing); err != nil {
|
|
log.Error().Err(err).Str("guestKey", guestKey).Msg("failed to persist guest identity")
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (m *Monitor) calculateBackupOperationTimeout(instanceName string) time.Duration {
|
|
const (
|
|
minTimeout = 2 * time.Minute
|
|
maxTimeout = 5 * time.Minute
|
|
timeoutPerGuest = 2 * time.Second
|
|
)
|
|
|
|
timeout := minTimeout
|
|
readState := m.backupReadStateForInstance(instanceName)
|
|
|
|
guestCount := 0
|
|
for _, vm := range readState.VMs() {
|
|
if vm != nil && vm.Instance() == instanceName && !vm.Template() {
|
|
guestCount++
|
|
}
|
|
}
|
|
for _, ct := range readState.Containers() {
|
|
if ct != nil && ct.Instance() == instanceName && !ct.Template() {
|
|
guestCount++
|
|
}
|
|
}
|
|
|
|
if guestCount > 0 {
|
|
dynamic := time.Duration(guestCount) * timeoutPerGuest
|
|
if dynamic > timeout {
|
|
timeout = dynamic
|
|
}
|
|
}
|
|
|
|
if timeout > maxTimeout {
|
|
return maxTimeout
|
|
}
|
|
|
|
return timeout
|
|
}
|
|
|
|
// pollPVEBackupsAndSnapshots runs the two backup-inventory scans under a shared
|
|
// bounded budget, then polls guest snapshots on the parent context so they get
|
|
// their own independent budget. A slow storage/backup scan can no longer starve
|
|
// snapshot discovery by exhausting the shared timeout before snapshots run.
|
|
func (m *Monitor) pollPVEBackupsAndSnapshots(parentCtx context.Context, instanceName string, client PVEClientInterface, nodes []proxmox.Node, nodeEffectiveStatus map[string]string, timeout time.Duration) {
|
|
if parentCtx == nil {
|
|
parentCtx = context.Background()
|
|
}
|
|
|
|
backupCtx, cancel := context.WithTimeout(parentCtx, timeout)
|
|
|
|
// Poll backup tasks
|
|
m.pollBackupTasks(backupCtx, instanceName, client)
|
|
|
|
// Poll storage backups - pass nodes to avoid duplicate API calls
|
|
m.pollStorageBackupsWithNodes(backupCtx, instanceName, client, nodes, nodeEffectiveStatus)
|
|
|
|
backupErr := backupCtx.Err()
|
|
cancel()
|
|
|
|
if backupErr != nil && parentCtx.Err() == nil {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Err(backupErr).
|
|
Msg("Backup storage polling budget was exhausted before guest snapshot polling; continuing snapshots with their own bounded poll budget")
|
|
}
|
|
|
|
// Snapshots are independent backup inventory. Passing parentCtx (no deadline)
|
|
// lets pollGuestSnapshots establish its own bounded budget instead of
|
|
// inheriting an already-exhausted backup deadline and skipping entirely.
|
|
m.pollGuestSnapshots(parentCtx, instanceName, client)
|
|
}
|
|
|
|
// pollGuestSnapshots polls snapshots for all VMs and containers
|
|
func (m *Monitor) pollGuestSnapshots(ctx context.Context, instanceName string, client PVEClientInterface) {
|
|
log.Debug().Str("instance", instanceName).Msg("polling guest snapshots")
|
|
|
|
readState := m.backupReadStateForInstance(instanceName)
|
|
var vms []models.VM
|
|
for _, vm := range readState.VMs() {
|
|
if vm == nil || vm.Instance() != instanceName {
|
|
continue
|
|
}
|
|
vms = append(vms, vmFromReadStateView(vm))
|
|
}
|
|
var containers []models.Container
|
|
for _, ct := range readState.Containers() {
|
|
if ct == nil || ct.Instance() != instanceName {
|
|
continue
|
|
}
|
|
containers = append(containers, containerFromReadStateView(ct))
|
|
}
|
|
|
|
previousSnapshots := make([]models.GuestSnapshot, 0)
|
|
if m.state != nil {
|
|
snapshot := m.state.GetSnapshot()
|
|
for _, snap := range snapshot.PVEBackups.GuestSnapshots {
|
|
if snap.Instance == instanceName {
|
|
previousSnapshots = append(previousSnapshots, snap)
|
|
}
|
|
}
|
|
}
|
|
|
|
guestKey := func(instance, node string, vmid int) string {
|
|
if instance == node {
|
|
return fmt.Sprintf("%s-%d", node, vmid)
|
|
}
|
|
return fmt.Sprintf("%s-%s-%d", instance, node, vmid)
|
|
}
|
|
|
|
guestLookups := make(map[string]alerts.GuestLookup, len(vms)+len(containers))
|
|
for _, vm := range vms {
|
|
key := alerts.BuildGuestKey(vm.Instance, vm.Node, vm.VMID)
|
|
guestLookups[key] = alerts.GuestLookup{
|
|
ResourceID: key,
|
|
Name: vm.Name,
|
|
Instance: vm.Instance,
|
|
Node: vm.Node,
|
|
Type: "qemu",
|
|
VMID: vm.VMID,
|
|
Tags: append([]string(nil), vm.Tags...),
|
|
}
|
|
}
|
|
for _, ct := range containers {
|
|
key := alerts.BuildGuestKey(ct.Instance, ct.Node, ct.VMID)
|
|
guestLookups[key] = alerts.GuestLookup{
|
|
ResourceID: key,
|
|
Name: ct.Name,
|
|
Instance: ct.Instance,
|
|
Node: ct.Node,
|
|
Type: firstNonEmptyString(ct.Type, "lxc"),
|
|
VMID: ct.VMID,
|
|
Tags: append([]string(nil), ct.Tags...),
|
|
}
|
|
}
|
|
|
|
activeGuests := 0
|
|
for _, vm := range vms {
|
|
if !vm.Template {
|
|
activeGuests++
|
|
}
|
|
}
|
|
for _, ct := range containers {
|
|
if !ct.Template {
|
|
activeGuests++
|
|
}
|
|
}
|
|
|
|
const (
|
|
minSnapshotTimeout = 60 * time.Second
|
|
maxSnapshotTimeout = 4 * time.Minute
|
|
snapshotTimeoutPerGuest = 2 * time.Second
|
|
maxConcurrentGuestSnapshotPolls = 8
|
|
)
|
|
|
|
timeout := minSnapshotTimeout
|
|
if activeGuests > 0 {
|
|
dynamic := time.Duration(activeGuests) * snapshotTimeoutPerGuest
|
|
if dynamic > timeout {
|
|
timeout = dynamic
|
|
}
|
|
}
|
|
if timeout > maxSnapshotTimeout {
|
|
timeout = maxSnapshotTimeout
|
|
}
|
|
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
remaining := time.Until(deadline)
|
|
if remaining <= 0 {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Msg("Skipping guest snapshot polling; backup context deadline exceeded")
|
|
return
|
|
}
|
|
if timeout > remaining {
|
|
timeout = remaining
|
|
}
|
|
}
|
|
|
|
snapshotCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
log.Debug().
|
|
Str("instance", instanceName).
|
|
Int("guestCount", activeGuests).
|
|
Dur("timeout", timeout).
|
|
Msg("Guest snapshot polling budget established")
|
|
|
|
var allSnapshots []models.GuestSnapshot
|
|
deadlineExceeded := false
|
|
polledGuestKeys := make(map[string]struct{})
|
|
|
|
type guestSnapshotPollTarget struct {
|
|
key string
|
|
node string
|
|
guestType string
|
|
vmid int
|
|
vmState bool
|
|
}
|
|
|
|
targets := make([]guestSnapshotPollTarget, 0, activeGuests)
|
|
|
|
for _, vm := range vms {
|
|
if vm.Template {
|
|
continue
|
|
}
|
|
targets = append(targets, guestSnapshotPollTarget{
|
|
key: guestKey(instanceName, vm.Node, vm.VMID),
|
|
node: vm.Node,
|
|
guestType: "qemu",
|
|
vmid: vm.VMID,
|
|
vmState: true,
|
|
})
|
|
}
|
|
|
|
for _, ct := range containers {
|
|
if ct.Template {
|
|
continue
|
|
}
|
|
targets = append(targets, guestSnapshotPollTarget{
|
|
key: guestKey(instanceName, ct.Node, ct.VMID),
|
|
node: ct.Node,
|
|
guestType: "lxc",
|
|
vmid: ct.VMID,
|
|
vmState: false,
|
|
})
|
|
}
|
|
|
|
type guestSnapshotPollResult struct {
|
|
target guestSnapshotPollTarget
|
|
snapshots []models.GuestSnapshot
|
|
err error
|
|
polled bool
|
|
}
|
|
|
|
buildSnapshot := func(target guestSnapshotPollTarget, snap proxmox.Snapshot) models.GuestSnapshot {
|
|
return models.GuestSnapshot{
|
|
ID: fmt.Sprintf("%s-%s-%d-%s", instanceName, target.node, target.vmid, snap.Name),
|
|
Name: snap.Name,
|
|
Node: target.node,
|
|
Instance: instanceName,
|
|
Type: target.guestType,
|
|
VMID: target.vmid,
|
|
Time: time.Unix(snap.SnapTime, 0),
|
|
Description: snap.Description,
|
|
Parent: snap.Parent,
|
|
VMState: target.vmState,
|
|
}
|
|
}
|
|
|
|
fetchSnapshots := func(target guestSnapshotPollTarget) ([]proxmox.Snapshot, error) {
|
|
if target.guestType == "lxc" {
|
|
return client.GetContainerSnapshots(snapshotCtx, target.node, target.vmid)
|
|
}
|
|
return client.GetVMSnapshots(snapshotCtx, target.node, target.vmid)
|
|
}
|
|
|
|
results := make(chan guestSnapshotPollResult, len(targets))
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, maxConcurrentGuestSnapshotPolls)
|
|
|
|
for _, target := range targets {
|
|
target := target
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
select {
|
|
case sem <- struct{}{}:
|
|
case <-snapshotCtx.Done():
|
|
results <- guestSnapshotPollResult{target: target, err: snapshotCtx.Err()}
|
|
return
|
|
}
|
|
defer func() { <-sem }()
|
|
|
|
snapshots, err := fetchSnapshots(target)
|
|
result := guestSnapshotPollResult{target: target, err: err}
|
|
if err == nil {
|
|
result.polled = true
|
|
result.snapshots = make([]models.GuestSnapshot, 0, len(snapshots))
|
|
for _, snap := range snapshots {
|
|
result.snapshots = append(result.snapshots, buildSnapshot(target, snap))
|
|
}
|
|
} else if target.guestType == "lxc" {
|
|
errStr := err.Error()
|
|
if strings.Contains(errStr, "596") || strings.Contains(errStr, "not available") {
|
|
result.polled = true
|
|
}
|
|
}
|
|
|
|
results <- result
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
for result := range results {
|
|
if result.polled {
|
|
polledGuestKeys[result.target.key] = struct{}{}
|
|
allSnapshots = append(allSnapshots, result.snapshots...)
|
|
continue
|
|
}
|
|
|
|
if result.err == nil {
|
|
continue
|
|
}
|
|
|
|
if snapshotCtx.Err() != nil {
|
|
deadlineExceeded = true
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Str("node", result.target.node).
|
|
Str("type", result.target.guestType).
|
|
Int("vmid", result.target.vmid).
|
|
Err(snapshotCtx.Err()).
|
|
Msg("Guest snapshot polling context expired before all guests completed")
|
|
continue
|
|
}
|
|
|
|
if result.target.guestType == "lxc" {
|
|
monErr := errors.NewMonitorError(errors.ErrorTypeAPI, "get_container_snapshots", instanceName, result.err).WithNode(result.target.node)
|
|
log.Debug().
|
|
Err(monErr).
|
|
Str("node", result.target.node).
|
|
Int("vmid", result.target.vmid).
|
|
Msg("Failed to get container snapshots")
|
|
} else {
|
|
monErr := errors.NewMonitorError(errors.ErrorTypeAPI, "get_vm_snapshots", instanceName, result.err).WithNode(result.target.node)
|
|
log.Debug().
|
|
Err(monErr).
|
|
Str("node", result.target.node).
|
|
Int("vmid", result.target.vmid).
|
|
Msg("Failed to get VM snapshots")
|
|
}
|
|
}
|
|
|
|
carriedForward := 0
|
|
for _, prev := range previousSnapshots {
|
|
if _, polled := polledGuestKeys[guestKey(instanceName, prev.Node, prev.VMID)]; polled {
|
|
continue
|
|
}
|
|
allSnapshots = append(allSnapshots, prev)
|
|
carriedForward++
|
|
}
|
|
|
|
if deadlineExceeded {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Int("freshlyPolled", len(polledGuestKeys)).
|
|
Int("carriedForward", carriedForward).
|
|
Msg("Guest snapshot polling timed out before completion; merged fresh results with previously-known snapshots for unpolled guests")
|
|
} else if carriedForward > 0 {
|
|
log.Debug().
|
|
Str("instance", instanceName).
|
|
Int("carriedForward", carriedForward).
|
|
Msg("Guest snapshot polling completed; carried forward previous snapshots for guests with per-call errors")
|
|
}
|
|
|
|
if len(allSnapshots) > 0 && !deadlineExceeded {
|
|
sizeMap := m.collectSnapshotSizes(snapshotCtx, instanceName, client, allSnapshots)
|
|
if len(sizeMap) > 0 {
|
|
for i := range allSnapshots {
|
|
if size, ok := sizeMap[allSnapshots[i].ID]; ok && size > 0 {
|
|
allSnapshots[i].SizeBytes = size
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update state with guest snapshots for this instance
|
|
m.state.UpdateGuestSnapshotsForInstance(instanceName, allSnapshots)
|
|
|
|
// Best-effort ingestion into recovery store (for rollups / unified backups UX).
|
|
guestInfo := buildProxmoxGuestInfoIndex(readState)
|
|
m.ingestAndReconcileRecoveryPointsAsync(
|
|
proxmoxrecoverymapper.FromPVEGuestSnapshots(allSnapshots, guestInfo),
|
|
recoveryReconcileScope{
|
|
provider: string(recovery.ProviderProxmoxPVE),
|
|
idPrefix: "pve-snapshot:",
|
|
instance: instanceName,
|
|
},
|
|
)
|
|
|
|
if m.alertManager != nil {
|
|
m.alertManager.CheckSnapshotsForInstance(instanceName, allSnapshots, guestLookups)
|
|
}
|
|
|
|
log.Debug().
|
|
Str("instance", instanceName).
|
|
Int("count", len(allSnapshots)).
|
|
Msg("Guest snapshots polled")
|
|
|
|
// Immediately broadcast the updated state so frontend sees new snapshots
|
|
m.broadcastStateUpdate()
|
|
}
|
|
|
|
func (m *Monitor) collectSnapshotSizes(ctx context.Context, instanceName string, client PVEClientInterface, snapshots []models.GuestSnapshot) map[string]int64 {
|
|
sizes := make(map[string]int64, len(snapshots))
|
|
if len(snapshots) == 0 {
|
|
return sizes
|
|
}
|
|
|
|
validSnapshots := make(map[string]struct{}, len(snapshots))
|
|
nodes := make(map[string]struct{})
|
|
|
|
for _, snap := range snapshots {
|
|
validSnapshots[snap.ID] = struct{}{}
|
|
if snap.Node != "" {
|
|
nodes[snap.Node] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if len(nodes) == 0 {
|
|
return sizes
|
|
}
|
|
|
|
seenVolids := make(map[string]struct{})
|
|
|
|
for nodeName := range nodes {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
|
|
storages, err := client.GetStorage(ctx, nodeName)
|
|
if err != nil {
|
|
log.Debug().
|
|
Err(err).
|
|
Str("node", nodeName).
|
|
Str("instance", instanceName).
|
|
Msg("Failed to get storage list for snapshot sizing")
|
|
continue
|
|
}
|
|
|
|
for _, storage := range storages {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
|
|
contentTypes := strings.ToLower(storage.Content)
|
|
if !strings.Contains(contentTypes, "images") && !strings.Contains(contentTypes, "rootdir") {
|
|
continue
|
|
}
|
|
if !storageContentQueryable(storage) {
|
|
continue
|
|
}
|
|
|
|
contents, err := client.GetStorageContent(ctx, nodeName, storage.Storage)
|
|
if err != nil {
|
|
log.Debug().
|
|
Err(err).
|
|
Str("node", nodeName).
|
|
Str("storage", storage.Storage).
|
|
Str("instance", instanceName).
|
|
Msg("Failed to get storage content for snapshot sizing")
|
|
continue
|
|
}
|
|
|
|
for _, item := range contents {
|
|
if item.VMID <= 0 {
|
|
continue
|
|
}
|
|
|
|
if _, seen := seenVolids[item.Volid]; seen {
|
|
continue
|
|
}
|
|
|
|
snapName := extractSnapshotName(item.Volid)
|
|
if snapName == "" {
|
|
continue
|
|
}
|
|
|
|
key := fmt.Sprintf("%s-%s-%d-%s", instanceName, nodeName, item.VMID, snapName)
|
|
if _, ok := validSnapshots[key]; !ok {
|
|
continue
|
|
}
|
|
|
|
seenVolids[item.Volid] = struct{}{}
|
|
|
|
size := int64(item.Size)
|
|
if size < 0 {
|
|
size = 0
|
|
}
|
|
|
|
sizes[key] += size
|
|
}
|
|
}
|
|
}
|
|
|
|
return sizes
|
|
}
|
|
|
|
func (m *Monitor) recordAuthFailure(instanceName string, nodeType string) {
|
|
nodeID := instanceName
|
|
if nodeType != "" {
|
|
nodeID = nodeType + "-" + instanceName
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.authFailures[nodeID]++
|
|
failures := m.authFailures[nodeID]
|
|
m.lastAuthAttempt[nodeID] = time.Now()
|
|
m.mu.Unlock()
|
|
|
|
log.Warn().
|
|
Str("node", nodeID).
|
|
Int("failures", failures).
|
|
Msg("Authentication failure recorded")
|
|
|
|
const maxAuthFailures = 5
|
|
if failures >= maxAuthFailures {
|
|
// Clear tracking first, then perform removal outside the monitor lock.
|
|
// Removal updates state/health and may need to acquire monitor locks internally.
|
|
m.mu.Lock()
|
|
delete(m.authFailures, nodeID)
|
|
delete(m.lastAuthAttempt, nodeID)
|
|
m.mu.Unlock()
|
|
|
|
log.Error().
|
|
Str("node", nodeID).
|
|
Int("failures", failures).
|
|
Msg("Maximum authentication failures reached, removing node from state")
|
|
|
|
// Remove from state based on type
|
|
if nodeType == "pve" {
|
|
m.removeFailedPVENode(instanceName)
|
|
} else if nodeType == "pbs" {
|
|
m.removeFailedPBSNode(instanceName)
|
|
} else if nodeType == "pmg" {
|
|
m.removeFailedPMGInstance(instanceName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// resetAuthFailures resets the failure count for a node after successful auth
|
|
func (m *Monitor) resetAuthFailures(instanceName string, nodeType string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
nodeID := instanceName
|
|
if nodeType != "" {
|
|
nodeID = nodeType + "-" + instanceName
|
|
}
|
|
|
|
if count, exists := m.authFailures[nodeID]; exists && count > 0 {
|
|
log.Info().
|
|
Str("node", nodeID).
|
|
Int("previousFailures", count).
|
|
Msg("Authentication succeeded, resetting failure count")
|
|
|
|
delete(m.authFailures, nodeID)
|
|
delete(m.lastAuthAttempt, nodeID)
|
|
}
|
|
}
|
|
|
|
// removeFailedPVENode updates a PVE node to show failed authentication status
|
|
func (m *Monitor) removeFailedPVENode(instanceName string) {
|
|
// Get instance config to get host URL
|
|
var hostURL string
|
|
m.mu.RLock()
|
|
if m.config != nil {
|
|
for _, cfg := range m.config.PVEInstances {
|
|
if cfg.Name == instanceName {
|
|
hostURL = cfg.Host
|
|
break
|
|
}
|
|
}
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
// Create a failed node entry to show in UI with error status
|
|
failedNode := models.Node{
|
|
ID: instanceName + "-failed",
|
|
Name: instanceName,
|
|
DisplayName: instanceName,
|
|
Instance: instanceName,
|
|
Host: hostURL, // Include host URL even for failed nodes
|
|
Status: "offline",
|
|
Type: "node",
|
|
ConnectionHealth: "error",
|
|
LastSeen: time.Now(),
|
|
// Set other fields to zero values to indicate no data
|
|
CPU: 0,
|
|
Memory: models.Memory{},
|
|
Disk: models.Disk{},
|
|
}
|
|
|
|
// Update with just the failed node
|
|
m.state.UpdateNodesForInstance(instanceName, []models.Node{failedNode})
|
|
|
|
// Remove all other resources associated with this instance
|
|
m.state.UpdateVMsForInstance(instanceName, []models.VM{})
|
|
m.state.UpdateContainersForInstance(instanceName, []models.Container{})
|
|
m.state.UpdateStorageForInstance(instanceName, []models.Storage{})
|
|
m.state.UpdateCephClustersForInstance(instanceName, []models.CephCluster{})
|
|
m.state.UpdateBackupTasksForInstance(instanceName, []models.BackupTask{})
|
|
m.state.UpdateStorageBackupsForInstance(instanceName, []models.StorageBackup{})
|
|
m.state.UpdatePBSGuestConfirmationsForInstance(instanceName, nil)
|
|
m.state.UpdateGuestSnapshotsForInstance(instanceName, []models.GuestSnapshot{})
|
|
|
|
// Set connection health to false
|
|
m.setProviderConnectionHealth(InstanceTypePVE, instanceName, false)
|
|
}
|
|
|
|
// removeFailedPBSNode removes a PBS node and all its resources from state
|
|
func (m *Monitor) removeFailedPBSNode(instanceName string) {
|
|
// Remove PBS instance by passing empty array
|
|
currentInstances := m.state.PBSInstances
|
|
var updatedInstances []models.PBSInstance
|
|
for _, inst := range currentInstances {
|
|
if inst.Name != instanceName {
|
|
updatedInstances = append(updatedInstances, inst)
|
|
}
|
|
}
|
|
m.state.UpdatePBSInstances(updatedInstances)
|
|
|
|
// Remove PBS backups
|
|
m.state.UpdatePBSBackups(instanceName, []models.PBSBackup{})
|
|
|
|
// Set connection health to false
|
|
m.setProviderConnectionHealth(InstanceTypePBS, instanceName, false)
|
|
}
|
|
|
|
// removeFailedPMGInstance removes PMG data from state when authentication fails repeatedly
|
|
func (m *Monitor) removeFailedPMGInstance(instanceName string) {
|
|
currentInstances := m.state.PMGInstances
|
|
updated := make([]models.PMGInstance, 0, len(currentInstances))
|
|
for _, inst := range currentInstances {
|
|
if inst.Name != instanceName {
|
|
updated = append(updated, inst)
|
|
}
|
|
}
|
|
|
|
m.state.UpdatePMGInstances(updated)
|
|
m.state.UpdatePMGBackups(instanceName, nil)
|
|
m.setProviderConnectionHealth(InstanceTypePMG, instanceName, false)
|
|
}
|
|
|
|
// pbsBackupCacheTTL controls how long cached PBS backup snapshots are reused
|
|
// before forcing a re-fetch. This ensures verification status changes (which
|
|
// don't alter backup count or timestamp) are picked up periodically.
|
|
const pbsBackupCacheTTL = 10 * time.Minute
|
|
|
|
type pbsBackupGroupKey struct {
|
|
datastore string
|
|
namespace string
|
|
backupType string
|
|
backupID string
|
|
}
|
|
|
|
type cachedPBSGroup struct {
|
|
snapshots []models.PBSBackup
|
|
latest time.Time
|
|
}
|
|
|
|
type pbsBackupFetchRequest struct {
|
|
datastore string
|
|
namespace string
|
|
group pbs.BackupGroup
|
|
cached cachedPBSGroup
|
|
}
|
|
|
|
// pollPBSBackups fetches all backups from PBS datastores
|
|
func (m *Monitor) pollPBSBackups(ctx context.Context, instanceName string, client *pbs.Client, datastores []models.PBSDatastore) {
|
|
log.Debug().Str("instance", instanceName).Msg("polling PBS backups")
|
|
|
|
// Cache existing PBS backups so we can avoid redundant API calls when no changes occurred.
|
|
existingGroups := m.buildPBSBackupCache(instanceName)
|
|
|
|
var allBackups []models.PBSBackup
|
|
retainedGroups := make(map[pbsBackupGroupKey]struct{}, len(existingGroups))
|
|
datastoreCount := len(datastores) // Number of datastores to query
|
|
datastoreFetches := 0 // Number of successful datastore fetches
|
|
datastoreErrors := 0 // Number of failed datastore fetches
|
|
datastoreTerminalFailures := 0 // Number of datastores that failed only with terminal errors
|
|
|
|
// Process each datastore
|
|
for _, ds := range datastores {
|
|
if ctx.Err() != nil {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Msg("PBS backup polling cancelled before completion")
|
|
return
|
|
}
|
|
|
|
namespacePaths := namespacePathsForDatastore(ds)
|
|
|
|
log.Info().
|
|
Str("instance", instanceName).
|
|
Str("datastore", ds.Name).
|
|
Int("namespaces", len(namespacePaths)).
|
|
Strs("namespace_paths", namespacePaths).
|
|
Msg("Processing datastore namespaces")
|
|
|
|
datastoreHadSuccess := false
|
|
datastoreNamespaceErrors := 0
|
|
datastoreTerminalNamespaceErrors := 0
|
|
groupsReused := 0
|
|
groupsRequested := 0
|
|
|
|
for _, namespace := range namespacePaths {
|
|
if ctx.Err() != nil {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Msg("PBS backup polling cancelled mid-datastore")
|
|
return
|
|
}
|
|
|
|
groups, err := client.ListBackupGroups(ctx, ds.Name, namespace)
|
|
if err != nil {
|
|
datastoreNamespaceErrors++
|
|
if !shouldReuseCachedPBSBackups(err) {
|
|
datastoreTerminalNamespaceErrors++
|
|
}
|
|
log.Error().
|
|
Err(err).
|
|
Str("instance", instanceName).
|
|
Str("datastore", ds.Name).
|
|
Str("namespace", namespace).
|
|
Msg("Failed to list PBS backup groups")
|
|
continue
|
|
}
|
|
|
|
datastoreHadSuccess = true
|
|
sortPBSBackupGroupsByLatest(groups)
|
|
requests := make([]pbsBackupFetchRequest, 0, len(groups))
|
|
projectedBackups := len(allBackups)
|
|
|
|
for _, group := range groups {
|
|
if projectedBackups >= pbsBackupLiveStateLimit {
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Str("datastore", ds.Name).
|
|
Str("namespace", namespace).
|
|
Int("limit", pbsBackupLiveStateLimit).
|
|
Msg("PBS backup live-state limit reached; skipping remaining groups")
|
|
break
|
|
}
|
|
|
|
key := pbsBackupGroupKey{
|
|
datastore: ds.Name,
|
|
namespace: namespace,
|
|
backupType: group.BackupType,
|
|
backupID: group.BackupID,
|
|
}
|
|
cached := existingGroups[key]
|
|
|
|
// Group deleted (no backups left) - ensure cached data is dropped.
|
|
if group.BackupCount == 0 {
|
|
continue
|
|
}
|
|
retainedGroups[key] = struct{}{}
|
|
|
|
lastBackupTime := time.Unix(group.LastBackup, 0)
|
|
hasCachedData := len(cached.snapshots) > 0
|
|
cacheCountMatches := len(cached.snapshots) == retainedPBSSnapshotCount(group.BackupCount)
|
|
|
|
// Check if the cached data is still within its TTL.
|
|
cacheAge := time.Since(m.pbsBackupCacheTimeFor(instanceName, key))
|
|
cacheStillFresh := cacheAge < pbsBackupCacheTTL
|
|
|
|
// Only re-fetch when the backup count changes, the most recent backup
|
|
// is newer, or the cache TTL has expired (to pick up verification changes).
|
|
if hasCachedData &&
|
|
cacheStillFresh &&
|
|
cacheCountMatches &&
|
|
!lastBackupTime.After(cached.latest) {
|
|
|
|
allBackups = appendPBSBackupsWithinLimit(allBackups, cached.snapshots)
|
|
groupsReused++
|
|
projectedBackups = len(allBackups)
|
|
continue
|
|
}
|
|
|
|
requests = append(requests, pbsBackupFetchRequest{
|
|
datastore: ds.Name,
|
|
namespace: namespace,
|
|
group: group,
|
|
cached: cached,
|
|
})
|
|
projectedBackups += retainedPBSSnapshotCount(group.BackupCount)
|
|
}
|
|
|
|
if len(requests) == 0 {
|
|
continue
|
|
}
|
|
|
|
groupsRequested += len(requests)
|
|
fetched := m.fetchPBSBackupSnapshots(ctx, client, instanceName, requests)
|
|
if len(fetched) > 0 {
|
|
allBackups = appendPBSBackupsWithinLimit(allBackups, fetched)
|
|
}
|
|
|
|
// Record fetch time for each requested group so the TTL tracks freshness.
|
|
// We record for all requested groups — on fetch failure, fetchPBSBackupSnapshots
|
|
// falls back to cached data, so the timestamp prevents hammering a failing
|
|
// endpoint. The TTL ensures we retry within a bounded window.
|
|
fetchedAt := time.Now()
|
|
for _, req := range requests {
|
|
reqKey := pbsBackupGroupKey{
|
|
datastore: req.datastore,
|
|
namespace: req.namespace,
|
|
backupType: req.group.BackupType,
|
|
backupID: req.group.BackupID,
|
|
}
|
|
m.setPBSBackupCacheTime(instanceName, reqKey, fetchedAt)
|
|
}
|
|
}
|
|
|
|
if datastoreHadSuccess {
|
|
datastoreFetches++
|
|
log.Info().
|
|
Str("instance", instanceName).
|
|
Str("datastore", ds.Name).
|
|
Int("namespaces", len(namespacePaths)).
|
|
Int("groups_reused", groupsReused).
|
|
Int("groups_refreshed", groupsRequested).
|
|
Msg("PBS datastore processed")
|
|
} else {
|
|
allNamespaceErrorsTerminal := datastoreNamespaceErrors > 0 &&
|
|
datastoreTerminalNamespaceErrors == datastoreNamespaceErrors
|
|
if allNamespaceErrorsTerminal {
|
|
datastoreTerminalFailures++
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Str("datastore", ds.Name).
|
|
Int("namespace_errors", datastoreNamespaceErrors).
|
|
Msg("No namespaces succeeded for PBS datastore due to terminal errors; clearing cached backups")
|
|
} else {
|
|
// Preserve cached data for this datastore when failures are transient.
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Str("datastore", ds.Name).
|
|
Msg("No namespaces succeeded for PBS datastore; using cached backups")
|
|
for key, entry := range existingGroups {
|
|
if key.datastore != ds.Name || len(entry.snapshots) == 0 {
|
|
continue
|
|
}
|
|
allBackups = appendPBSBackupsWithinLimit(allBackups, entry.snapshots)
|
|
retainedGroups[key] = struct{}{}
|
|
}
|
|
}
|
|
datastoreErrors++
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("instance", instanceName).
|
|
Int("count", len(allBackups)).
|
|
Msg("PBS backups fetched")
|
|
|
|
protectionObservedAt := time.Now().UTC()
|
|
protectionObservation, protectionObservationErr :=
|
|
buildPBSProtectionProviderObservation(
|
|
instanceName,
|
|
datastoreCount,
|
|
datastoreFetches,
|
|
datastoreErrors,
|
|
datastoreTerminalFailures,
|
|
protectionObservedAt,
|
|
)
|
|
if protectionObservationErr != nil {
|
|
log.Warn().
|
|
Err(protectionObservationErr).
|
|
Str("instance", instanceName).
|
|
Msg("Failed to build PBS protection provider observation")
|
|
}
|
|
|
|
// Decide whether to keep existing backups when all queries failed
|
|
if shouldPreservePBSBackupsWithTerminal(datastoreCount, datastoreFetches, datastoreTerminalFailures) {
|
|
if protectionObservationErr == nil {
|
|
m.ingestProtectionProviderObservationsAsync(
|
|
[]recovery.ProtectionProviderObservation{protectionObservation},
|
|
)
|
|
}
|
|
log.Warn().
|
|
Str("instance", instanceName).
|
|
Int("datastores", datastoreCount).
|
|
Int("errors", datastoreErrors).
|
|
Int("terminal_failures", datastoreTerminalFailures).
|
|
Msg("All PBS datastore queries failed; keeping previous backup list")
|
|
return
|
|
}
|
|
|
|
m.prunePBSBackupCacheTimes(instanceName, retainedGroups)
|
|
|
|
// Update state
|
|
m.state.UpdatePBSBackups(instanceName, allBackups)
|
|
|
|
// Best-effort ingestion into recovery store (for rollups / unified backups UX).
|
|
candidates := buildPBSGuestCandidates(m.GetUnifiedReadStateOrSnapshot())
|
|
points, evidenceErr := proxmoxrecoverymapper.FromPBSBackupsWithEvidence(
|
|
allBackups,
|
|
candidates,
|
|
protectionObservedAt,
|
|
)
|
|
if evidenceErr != nil {
|
|
log.Warn().
|
|
Err(evidenceErr).
|
|
Str("instance", instanceName).
|
|
Msg("Failed to attach PBS recovery evidence; preserving existing points")
|
|
if protectionObservationErr == nil {
|
|
m.ingestProtectionProviderObservationsAsync(
|
|
[]recovery.ProtectionProviderObservation{protectionObservation},
|
|
)
|
|
}
|
|
return
|
|
}
|
|
observations := []recovery.ProtectionProviderObservation{}
|
|
if protectionObservationErr == nil {
|
|
observations = append(observations, protectionObservation)
|
|
}
|
|
m.ingestAndReconcileRecoveryPointsWithObservationsAsync(
|
|
points,
|
|
observations,
|
|
recoveryReconcileScope{
|
|
provider: string(recovery.ProviderProxmoxPBS),
|
|
idPrefix: "pbs-backup:",
|
|
instance: instanceName,
|
|
},
|
|
)
|
|
|
|
// Sync backup times to VMs/Containers and republish them to canonical resources.
|
|
m.syncGuestBackupTimesAndResourceStore()
|
|
|
|
if m.alertManager != nil {
|
|
guestsByKey, guestsByVMID := buildGuestLookupsFromReadState(m.GetUnifiedReadStateOrSnapshot(), m.guestMetadataStore)
|
|
rollups, err := m.listBackupRollupsForAlerts(context.Background())
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to list recovery rollups for backup alerts")
|
|
} else {
|
|
m.alertManager.CheckBackupsWithInventory(rollups, guestsByKey, guestsByVMID, m.backupInventoryScopeForAlerts())
|
|
}
|
|
}
|
|
|
|
// Immediately broadcast the updated state so frontend sees new backups
|
|
m.broadcastStateUpdate()
|
|
}
|
|
|
|
func (m *Monitor) buildPBSBackupCache(instanceName string) map[pbsBackupGroupKey]cachedPBSGroup {
|
|
snapshot := m.state.GetSnapshot()
|
|
cache := make(map[pbsBackupGroupKey]cachedPBSGroup)
|
|
for _, backup := range snapshot.PBSBackups {
|
|
if backup.Instance != instanceName {
|
|
continue
|
|
}
|
|
key := pbsBackupGroupKey{
|
|
datastore: backup.Datastore,
|
|
namespace: normalizePBSNamespacePath(backup.Namespace),
|
|
backupType: backup.BackupType,
|
|
backupID: backup.VMID,
|
|
}
|
|
entry := cache[key]
|
|
entry.snapshots = append(entry.snapshots, backup)
|
|
if backup.BackupTime.After(entry.latest) {
|
|
entry.latest = backup.BackupTime
|
|
}
|
|
cache[key] = entry
|
|
}
|
|
for key, entry := range cache {
|
|
sortPBSBackupsByLatest(entry.snapshots)
|
|
if len(entry.snapshots) > pbsBackupSnapshotsPerGroupLimit {
|
|
entry.snapshots = entry.snapshots[:pbsBackupSnapshotsPerGroupLimit]
|
|
}
|
|
cache[key] = entry
|
|
}
|
|
return cache
|
|
}
|
|
|
|
// pbsBackupCacheTimeFor returns the last fetch time for a PBS backup group.
|
|
func (m *Monitor) pbsBackupCacheTimeFor(instanceName string, key pbsBackupGroupKey) time.Time {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if perGroup, ok := m.pbsBackupCacheTime[instanceName]; ok {
|
|
return perGroup[key]
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// setPBSBackupCacheTime records when a PBS backup group was last fetched.
|
|
func (m *Monitor) setPBSBackupCacheTime(instanceName string, key pbsBackupGroupKey, t time.Time) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.pbsBackupCacheTime == nil {
|
|
m.pbsBackupCacheTime = make(map[string]map[pbsBackupGroupKey]time.Time)
|
|
}
|
|
if m.pbsBackupCacheTime[instanceName] == nil {
|
|
m.pbsBackupCacheTime[instanceName] = make(map[pbsBackupGroupKey]time.Time)
|
|
}
|
|
m.pbsBackupCacheTime[instanceName][key] = t
|
|
}
|
|
|
|
func (m *Monitor) prunePBSBackupCacheTimes(instanceName string, retained map[pbsBackupGroupKey]struct{}) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.pbsBackupCacheTime == nil {
|
|
return
|
|
}
|
|
perGroup := m.pbsBackupCacheTime[instanceName]
|
|
if len(perGroup) == 0 {
|
|
return
|
|
}
|
|
|
|
for key := range perGroup {
|
|
if _, ok := retained[key]; !ok {
|
|
delete(perGroup, key)
|
|
}
|
|
}
|
|
if len(perGroup) == 0 {
|
|
delete(m.pbsBackupCacheTime, instanceName)
|
|
}
|
|
}
|
|
|
|
func normalizePBSNamespacePath(ns string) string {
|
|
if ns == "/" {
|
|
return ""
|
|
}
|
|
return ns
|
|
}
|
|
|
|
func namespacePathsForDatastore(ds models.PBSDatastore) []string {
|
|
if len(ds.Namespaces) == 0 {
|
|
return []string{""}
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(ds.Namespaces))
|
|
var paths []string
|
|
for _, ns := range ds.Namespaces {
|
|
path := normalizePBSNamespacePath(ns.Path)
|
|
if _, ok := seen[path]; ok {
|
|
continue
|
|
}
|
|
seen[path] = struct{}{}
|
|
paths = append(paths, path)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func (m *Monitor) fetchPBSBackupSnapshots(ctx context.Context, client *pbs.Client, instanceName string, requests []pbsBackupFetchRequest) []models.PBSBackup {
|
|
if len(requests) == 0 {
|
|
return nil
|
|
}
|
|
|
|
workerCount := pbsBackupSnapshotFetchWorkers
|
|
if len(requests) < workerCount {
|
|
workerCount = len(requests)
|
|
}
|
|
|
|
jobs := make(chan pbsBackupFetchRequest)
|
|
results := make(chan []models.PBSBackup, workerCount)
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < workerCount; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
for req := range jobs {
|
|
log.Debug().
|
|
Str("instance", instanceName).
|
|
Str("datastore", req.datastore).
|
|
Str("namespace", req.namespace).
|
|
Str("type", req.group.BackupType).
|
|
Str("id", req.group.BackupID).
|
|
Msg("Refreshing PBS backup group")
|
|
|
|
snapshots, err := client.ListBackupSnapshots(ctx, req.datastore, req.namespace, req.group.BackupType, req.group.BackupID)
|
|
if err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("instance", instanceName).
|
|
Str("datastore", req.datastore).
|
|
Str("namespace", req.namespace).
|
|
Str("type", req.group.BackupType).
|
|
Str("id", req.group.BackupID).
|
|
Msg("Failed to list PBS backup snapshots")
|
|
|
|
if len(req.cached.snapshots) > 0 {
|
|
select {
|
|
case results <- req.cached.snapshots:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
backups := convertPBSSnapshots(instanceName, req.datastore, req.namespace, snapshots)
|
|
select {
|
|
case results <- backups:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
defer close(jobs)
|
|
for _, req := range requests {
|
|
select {
|
|
case jobs <- req:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
var combined []models.PBSBackup
|
|
for backups := range results {
|
|
if len(backups) == 0 {
|
|
continue
|
|
}
|
|
combined = appendPBSBackupsWithinLimit(combined, backups)
|
|
}
|
|
|
|
return combined
|
|
}
|
|
|
|
func convertPBSSnapshots(instanceName, datastore, namespace string, snapshots []pbs.BackupSnapshot) []models.PBSBackup {
|
|
sort.SliceStable(snapshots, func(i, j int) bool {
|
|
return snapshots[i].BackupTime > snapshots[j].BackupTime
|
|
})
|
|
if len(snapshots) > pbsBackupSnapshotsPerGroupLimit {
|
|
snapshots = snapshots[:pbsBackupSnapshotsPerGroupLimit]
|
|
}
|
|
|
|
backups := make([]models.PBSBackup, 0, len(snapshots))
|
|
for _, snapshot := range snapshots {
|
|
backupTime := time.Unix(snapshot.BackupTime, 0)
|
|
backupID := fmt.Sprintf("pbs-%s-%s-%s-%s-%s-%d",
|
|
instanceName, datastore, namespace,
|
|
snapshot.BackupType, snapshot.BackupID,
|
|
snapshot.BackupTime)
|
|
|
|
var fileNames []string
|
|
for _, file := range snapshot.Files {
|
|
switch f := file.(type) {
|
|
case string:
|
|
fileNames = append(fileNames, f)
|
|
case map[string]interface{}:
|
|
if filename, ok := f["filename"].(string); ok {
|
|
fileNames = append(fileNames, filename)
|
|
}
|
|
}
|
|
}
|
|
|
|
verified := false
|
|
if snapshot.Verification != nil {
|
|
switch v := snapshot.Verification.(type) {
|
|
case string:
|
|
verified = v == "ok"
|
|
case map[string]interface{}:
|
|
if state, ok := v["state"].(string); ok {
|
|
verified = state == "ok"
|
|
}
|
|
}
|
|
|
|
log.Debug().
|
|
Str("vmid", snapshot.BackupID).
|
|
Int64("time", snapshot.BackupTime).
|
|
Interface("verification", snapshot.Verification).
|
|
Bool("verified", verified).
|
|
Msg("PBS backup verification status")
|
|
}
|
|
|
|
backups = append(backups, models.PBSBackup{
|
|
ID: backupID,
|
|
Instance: instanceName,
|
|
Datastore: datastore,
|
|
Namespace: namespace,
|
|
BackupType: snapshot.BackupType,
|
|
VMID: snapshot.BackupID,
|
|
BackupTime: backupTime,
|
|
Size: snapshot.Size,
|
|
Protected: snapshot.Protected,
|
|
Verified: verified,
|
|
VerificationRaw: snapshot.Verification,
|
|
Comment: snapshot.Comment,
|
|
Files: fileNames,
|
|
Owner: snapshot.Owner,
|
|
})
|
|
}
|
|
|
|
return backups
|
|
}
|
|
|
|
func sortPBSBackupGroupsByLatest(groups []pbs.BackupGroup) {
|
|
sort.SliceStable(groups, func(i, j int) bool {
|
|
if groups[i].LastBackup == groups[j].LastBackup {
|
|
if groups[i].BackupType == groups[j].BackupType {
|
|
return groups[i].BackupID < groups[j].BackupID
|
|
}
|
|
return groups[i].BackupType < groups[j].BackupType
|
|
}
|
|
return groups[i].LastBackup > groups[j].LastBackup
|
|
})
|
|
}
|
|
|
|
func sortPBSBackupsByLatest(backups []models.PBSBackup) {
|
|
sort.SliceStable(backups, func(i, j int) bool {
|
|
if backups[i].BackupTime.Equal(backups[j].BackupTime) {
|
|
return backups[i].ID < backups[j].ID
|
|
}
|
|
return backups[i].BackupTime.After(backups[j].BackupTime)
|
|
})
|
|
}
|
|
|
|
// retainedPBSSnapshotCount is how many snapshots poll retention keeps for a
|
|
// group with the given backup count: the full group, capped at the per-group
|
|
// limit. It doubles as the expected cache size when deciding whether cached
|
|
// snapshots for a group are still complete.
|
|
func retainedPBSSnapshotCount(backupCount int) int {
|
|
if backupCount <= 0 {
|
|
return 0
|
|
}
|
|
if backupCount > pbsBackupSnapshotsPerGroupLimit {
|
|
return pbsBackupSnapshotsPerGroupLimit
|
|
}
|
|
return backupCount
|
|
}
|
|
|
|
func appendPBSBackupsWithinLimit(backups []models.PBSBackup, additions []models.PBSBackup) []models.PBSBackup {
|
|
if len(backups) >= pbsBackupLiveStateLimit || len(additions) == 0 {
|
|
return backups
|
|
}
|
|
remaining := pbsBackupLiveStateLimit - len(backups)
|
|
if len(additions) > remaining {
|
|
additions = additions[:remaining]
|
|
}
|
|
return append(backups, additions...)
|
|
}
|
|
|
|
// pollBackupTasks polls backup tasks from a PVE instance
|
|
func (m *Monitor) pollBackupTasks(ctx context.Context, instanceName string, client PVEClientInterface) {
|
|
log.Debug().Str("instance", instanceName).Msg("polling backup tasks")
|
|
|
|
tasks, err := client.GetBackupTasks(ctx)
|
|
if err != nil {
|
|
monErr := errors.WrapAPIError("get_backup_tasks", instanceName, err, 0)
|
|
log.Error().Err(monErr).Str("instance", instanceName).Msg("failed to get backup tasks")
|
|
return
|
|
}
|
|
|
|
var backupTasks []models.BackupTask
|
|
var jobTasks []models.BackupTask
|
|
jobUPIDs := make(map[string]string)
|
|
for _, task := range tasks {
|
|
// Extract VMID from task ID (format: "UPID:node:pid:starttime:type:vmid:user@realm:")
|
|
vmid := 0
|
|
if task.ID != "" {
|
|
if vmidInt, err := strconv.Atoi(task.ID); err == nil {
|
|
vmid = vmidInt
|
|
}
|
|
}
|
|
|
|
taskID := fmt.Sprintf("%s-%s", instanceName, task.UPID)
|
|
|
|
backupTask := models.BackupTask{
|
|
ID: taskID,
|
|
Node: task.Node,
|
|
Instance: instanceName,
|
|
Type: task.Type,
|
|
VMID: vmid,
|
|
Status: task.Status,
|
|
StartTime: time.Unix(task.StartTime, 0),
|
|
ObservedAt: time.Now().UTC(),
|
|
}
|
|
|
|
if task.EndTime > 0 {
|
|
backupTask.EndTime = time.Unix(task.EndTime, 0)
|
|
}
|
|
|
|
backupTasks = append(backupTasks, backupTask)
|
|
|
|
// Scheduled multi-guest backup jobs run under a single UPID with an
|
|
// empty VMID slot, so the task alone says nothing about which guests
|
|
// it covered (#1452 area, regressed with the guest-centric redesign).
|
|
if vmid == 0 && task.Type == "vzdump" && task.UPID != "" {
|
|
jobUPIDs[backupTask.ID] = task.UPID
|
|
jobTasks = append(jobTasks, backupTask)
|
|
}
|
|
}
|
|
|
|
backupTasks = append(backupTasks, m.synthesizeVzdumpJobGuestTasks(ctx, instanceName, client, jobTasks, jobUPIDs)...)
|
|
|
|
// Update state with new backup tasks for this instance
|
|
m.state.UpdateBackupTasksForInstance(instanceName, backupTasks)
|
|
|
|
// Best-effort ingestion into recovery store (for rollups / unified backups UX).
|
|
guestInfo := buildProxmoxGuestInfoIndex(m.backupReadStateForInstance(instanceName))
|
|
m.ingestRecoveryPointsAsync(proxmoxrecoverymapper.FromPVEBackupTasks(backupTasks, guestInfo))
|
|
}
|
|
|
|
// vzdumpJobTaskCacheEntry caches the per-guest tasks synthesized from one
|
|
// vzdump job run's log so finished jobs are fetched at most once.
|
|
type vzdumpJobTaskCacheEntry struct {
|
|
tasks []models.BackupTask
|
|
final bool
|
|
}
|
|
|
|
// maxVzdumpJobLogFetchesPerPoll bounds task-log requests per poll cycle so a
|
|
// large backlog of historical job runs (e.g. right after startup) trickles in
|
|
// over a few cycles instead of stalling the shared backup-poll budget.
|
|
const maxVzdumpJobLogFetchesPerPoll = 25
|
|
|
|
// synthesizeVzdumpJobGuestTasks expands multi-guest vzdump job runs into one
|
|
// synthetic per-guest task each by parsing the job's task log. Results are
|
|
// cached by instance|UPID; logs of finished jobs are immutable, so those are
|
|
// fetched only once. Running jobs are re-parsed every cycle to pick up guests
|
|
// as the job progresses.
|
|
func (m *Monitor) synthesizeVzdumpJobGuestTasks(ctx context.Context, instanceName string, client PVEClientInterface, jobTasks []models.BackupTask, jobUPIDs map[string]string) []models.BackupTask {
|
|
if len(jobTasks) == 0 {
|
|
m.pruneVzdumpJobTaskCache(instanceName, nil)
|
|
return nil
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
seen := make(map[string]struct{}, len(jobTasks))
|
|
var synthesized []models.BackupTask
|
|
fetches := 0
|
|
|
|
for _, jobTask := range jobTasks {
|
|
upid := jobUPIDs[jobTask.ID]
|
|
key := instanceName + "|" + upid
|
|
seen[key] = struct{}{}
|
|
|
|
m.mu.RLock()
|
|
entry, cached := m.vzdumpJobTaskCache[key]
|
|
m.mu.RUnlock()
|
|
|
|
if cached && entry.final {
|
|
synthesized = append(synthesized, refreshObservedAt(entry.tasks, now)...)
|
|
continue
|
|
}
|
|
|
|
if fetches >= maxVzdumpJobLogFetchesPerPoll || ctx.Err() != nil {
|
|
// Serve a stale non-final entry if we have one; the next cycle
|
|
// will catch up.
|
|
if cached {
|
|
synthesized = append(synthesized, refreshObservedAt(entry.tasks, now)...)
|
|
}
|
|
continue
|
|
}
|
|
fetches++
|
|
|
|
logLines, err := client.GetTaskLog(ctx, jobTask.Node, upid)
|
|
if err != nil {
|
|
log.Debug().Err(err).
|
|
Str("instance", instanceName).
|
|
Str("upid", upid).
|
|
Msg("failed to fetch vzdump job task log; per-guest status unavailable this cycle")
|
|
if cached {
|
|
synthesized = append(synthesized, refreshObservedAt(entry.tasks, now)...)
|
|
}
|
|
continue
|
|
}
|
|
|
|
guestTasks := parseVzdumpJobLog(jobTask, upid, logLines)
|
|
m.mu.Lock()
|
|
if m.vzdumpJobTaskCache == nil {
|
|
m.vzdumpJobTaskCache = make(map[string]vzdumpJobTaskCacheEntry)
|
|
}
|
|
m.vzdumpJobTaskCache[key] = vzdumpJobTaskCacheEntry{
|
|
tasks: guestTasks,
|
|
final: !jobTask.EndTime.IsZero(),
|
|
}
|
|
m.mu.Unlock()
|
|
synthesized = append(synthesized, refreshObservedAt(guestTasks, now)...)
|
|
}
|
|
|
|
m.pruneVzdumpJobTaskCache(instanceName, seen)
|
|
return synthesized
|
|
}
|
|
|
|
// pruneVzdumpJobTaskCache drops cached job entries for this instance whose
|
|
// UPIDs are no longer present in the task list (fell out of the window).
|
|
func (m *Monitor) pruneVzdumpJobTaskCache(instanceName string, seen map[string]struct{}) {
|
|
prefix := instanceName + "|"
|
|
m.mu.Lock()
|
|
for key := range m.vzdumpJobTaskCache {
|
|
if !strings.HasPrefix(key, prefix) {
|
|
continue
|
|
}
|
|
if _, ok := seen[key]; !ok {
|
|
delete(m.vzdumpJobTaskCache, key)
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func refreshObservedAt(tasks []models.BackupTask, now time.Time) []models.BackupTask {
|
|
if len(tasks) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]models.BackupTask, len(tasks))
|
|
copy(out, tasks)
|
|
for i := range out {
|
|
out[i].ObservedAt = now
|
|
}
|
|
return out
|
|
}
|
|
|
|
// vzdump job logs mark each guest's section with these lines.
|
|
var (
|
|
vzdumpLogGuestStartRe = regexp.MustCompile(`^INFO: Starting Backup of VM (\d+)`)
|
|
vzdumpLogGuestFinishRe = regexp.MustCompile(`^INFO: Finished Backup of VM (\d+) \((\d+):(\d{2}):(\d{2})\)$`)
|
|
vzdumpLogGuestFailRe = regexp.MustCompile(`^ERROR: Backup of VM (\d+) failed -\s*(.*)$`)
|
|
)
|
|
|
|
// parseVzdumpJobLog turns a multi-guest vzdump job run into synthetic
|
|
// per-guest BackupTask entries. Log lines carry no timestamps, so per-guest
|
|
// times are reconstructed from the job start plus the durations vzdump prints
|
|
// on each "Finished Backup" line. Task IDs embed the parent UPID, keeping them
|
|
// stable across polls and distinct from individually-run guest backups.
|
|
func parseVzdumpJobLog(jobTask models.BackupTask, upid string, logLines []proxmox.TaskLogLine) []models.BackupTask {
|
|
type guestSection struct {
|
|
vmid int
|
|
start time.Time
|
|
end time.Time
|
|
status string
|
|
errMsg string
|
|
}
|
|
|
|
var order []int
|
|
sections := make(map[int]*guestSection)
|
|
cursor := jobTask.StartTime
|
|
|
|
ensureSection := func(vmid int) *guestSection {
|
|
if sec, ok := sections[vmid]; ok {
|
|
return sec
|
|
}
|
|
sec := &guestSection{vmid: vmid, start: cursor, status: "running"}
|
|
sections[vmid] = sec
|
|
order = append(order, vmid)
|
|
return sec
|
|
}
|
|
|
|
for _, line := range logLines {
|
|
text := strings.TrimSpace(line.Text)
|
|
if match := vzdumpLogGuestStartRe.FindStringSubmatch(text); match != nil {
|
|
vmid, err := strconv.Atoi(match[1])
|
|
if err != nil || vmid <= 0 {
|
|
continue
|
|
}
|
|
sec := ensureSection(vmid)
|
|
sec.start = cursor
|
|
sec.status = "running"
|
|
} else if match := vzdumpLogGuestFinishRe.FindStringSubmatch(text); match != nil {
|
|
vmid, err := strconv.Atoi(match[1])
|
|
if err != nil || vmid <= 0 {
|
|
continue
|
|
}
|
|
sec := ensureSection(vmid)
|
|
if sec.status == "running" {
|
|
sec.status = "OK"
|
|
}
|
|
hours, _ := strconv.Atoi(match[2])
|
|
minutes, _ := strconv.Atoi(match[3])
|
|
seconds, _ := strconv.Atoi(match[4])
|
|
duration := time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second
|
|
sec.end = sec.start.Add(duration)
|
|
cursor = sec.end
|
|
} else if match := vzdumpLogGuestFailRe.FindStringSubmatch(text); match != nil {
|
|
vmid, err := strconv.Atoi(match[1])
|
|
if err != nil || vmid <= 0 {
|
|
continue
|
|
}
|
|
sec := ensureSection(vmid)
|
|
sec.status = "error"
|
|
sec.errMsg = strings.TrimSpace(match[2])
|
|
if sec.errMsg == "" {
|
|
sec.errMsg = "backup failed"
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(order) == 0 {
|
|
return nil
|
|
}
|
|
|
|
jobFinished := !jobTask.EndTime.IsZero()
|
|
tasks := make([]models.BackupTask, 0, len(order))
|
|
for _, vmid := range order {
|
|
sec := sections[vmid]
|
|
if jobFinished {
|
|
if sec.status == "running" {
|
|
// The job ended without a per-guest verdict; treat as failed
|
|
// so the guest doesn't look covered by a backup that never
|
|
// completed.
|
|
sec.status = "error"
|
|
sec.errMsg = "backup job ended before guest backup completed"
|
|
}
|
|
if sec.end.IsZero() {
|
|
sec.end = jobTask.EndTime
|
|
}
|
|
}
|
|
|
|
tasks = append(tasks, models.BackupTask{
|
|
ID: fmt.Sprintf("%s-%s-vm%d", jobTask.Instance, upid, vmid),
|
|
Node: jobTask.Node,
|
|
Instance: jobTask.Instance,
|
|
Type: jobTask.Type,
|
|
VMID: vmid,
|
|
Status: sec.status,
|
|
Error: sec.errMsg,
|
|
StartTime: sec.start,
|
|
EndTime: sec.end,
|
|
})
|
|
}
|
|
|
|
return tasks
|
|
}
|
|
|
|
func (m *Monitor) pollPVEBackupsAsync(
|
|
ctx context.Context,
|
|
instanceName string,
|
|
instanceCfg *config.PVEInstance,
|
|
client PVEClientInterface,
|
|
nodes []proxmox.Node,
|
|
nodeEffectiveStatus map[string]string,
|
|
) error {
|
|
// Poll backups if enabled - respect configured interval or cycle gating
|
|
if !instanceCfg.MonitorBackups {
|
|
return nil
|
|
}
|
|
|
|
if !m.backupPollingEnabledSetting() {
|
|
log.Debug().
|
|
Str("instance", instanceName).
|
|
Msg("Skipping backup polling - globally disabled")
|
|
return nil
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
m.mu.RLock()
|
|
lastPoll := m.lastPVEBackupPoll[instanceName]
|
|
m.mu.RUnlock()
|
|
|
|
shouldPoll, reason, newLast := m.shouldRunBackupPoll(lastPoll, now)
|
|
if !shouldPoll {
|
|
if reason != "" {
|
|
log.Debug().
|
|
Str("instance", instanceName).
|
|
Str("reason", reason).
|
|
Msg("Skipping PVE backup polling this cycle")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
// Set initial timestamp before starting goroutine (prevents concurrent starts)
|
|
m.mu.Lock()
|
|
m.lastPVEBackupPoll[instanceName] = newLast
|
|
m.mu.Unlock()
|
|
|
|
// Run backup polling in a separate goroutine to avoid blocking real-time stats
|
|
go func(startTime time.Time, inst string, pveClient PVEClientInterface) {
|
|
defer recoverFromPanic(fmt.Sprintf("pollPVEBackups-%s", inst))
|
|
timeout := m.calculateBackupOperationTimeout(inst)
|
|
log.Info().
|
|
Str("instance", inst).
|
|
Dur("timeout", timeout).
|
|
Msg("Starting background backup/snapshot polling")
|
|
|
|
// The per-cycle ctx is canceled as soon as the main polling loop finishes,
|
|
// so derive the backup poll context from the long-lived runtime context instead.
|
|
parentCtx := m.getRuntimeContext()
|
|
if parentCtx == nil {
|
|
parentCtx = context.Background()
|
|
}
|
|
|
|
m.pollPVEBackupsAndSnapshots(parentCtx, inst, pveClient, nodes, nodeEffectiveStatus, timeout)
|
|
|
|
duration := time.Since(startTime)
|
|
log.Info().
|
|
Str("instance", inst).
|
|
Dur("duration", duration).
|
|
Msg("Completed background backup/snapshot polling")
|
|
|
|
// Update timestamp after completion for accurate interval scheduling
|
|
m.mu.Lock()
|
|
m.lastPVEBackupPoll[inst] = time.Now()
|
|
m.mu.Unlock()
|
|
}(now, instanceName, client)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkMockAlerts checks alerts for mock data
|