Files
pulse/internal/monitoring/monitor_backups.go
T
rcourtman 06b7f5a0b9 Keep fixture alerts from outliving a mock-mode switch
SetMockMode(false) switched the mock package off and then cleared active
alerts, but every path that evaluates mode-dependent data checked the
mode once, up front. A mock alert pass (started from the ticker with go,
so passes overlap), a frontend or ingest refresh evaluating a GetState
snapshot, a backup-age check over fixture recovery rollups, or a
connection check over the mock ledger could already hold fixture data
when the switch happened. Its evaluations then landed after the clear,
in live mode, where nothing evaluates or resolves fixture resources, so
the alerts stayed until the 24-hour stale sweep.

A pass-level lock around checkMockAlerts was tried and reverted: it left
the other paths open and made the mock-mode API wait behind a whole pass
of synchronous event-log writes.

The monitor now owns a mock-mode epoch fence. The mock pass, backup-age
and snapshot checks, connection checks, Docker pruning and the
resource-store refresh take a scope before reading anything
mode-dependent and send each alert-manager call through it. SetMockMode,
now serialized, flips the mode, ends the epoch, waits only for calls
already admitted, and then clears. Unified metrics are evaluated one
resource per call, so the wait covers single calls, not passes.

Registry rebuilds are tracked rather than waited on. A refresh evaluates
the registry it read back only if no rebuild from an ended epoch
overlapped it, and the readers that open or remove alerts from the
inventory use current state until a clean rebuild in the new epoch
replaces the previous mode's registry. The mock supplemental fixture
sources now serve nothing outside mock mode: the router swaps them for
the real pollers only after the switch, refreshing between sources, so
a single switch to live mode used to raise fixture vSphere incidents.

The new tests park a real evaluation at a fixed point (a held lock or a
blocking supplier), switch modes and release it. Against the unfenced
code the mock pass left about 40 fixture alerts, the frontend refresh 5
vSphere incidents and the backup check 137 backup-age alerts in live
mode, and a mock-mode connection observation counted toward a live
connection's confirmation. The drain, republish and pruning tests fail
against a fence without its wait, overlap check or registry tracking.
Switching mock mode with several tenant monitors running remains
uncovered and is noted in the monitoring contract.
2026-10-07 12:03:38 +01:00

2602 lines
79 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
// runningVzdumpClockSkew is the slack applied when deciding whether a
// backup file's ctime falls inside a running vzdump task's window, so a
// small clock offset between Pulse and the node cannot misclassify the
// task's own partial output as a completed backup.
runningVzdumpClockSkew = 2 * time.Minute
// 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
successfulNodeQueries := 0 // Number of nodes whose storage list was enumerated
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
permissionFailureCount := 0 // Number of node/storage reads rejected by authorization
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)
// vzdump writes archives under their final name, so a backup that is
// still running is already listed by the storage content API as a
// growing partial file. Correlate content entries with the live vzdump
// tasks polled just before this pass (pollBackupTasks runs first in the
// cycle): a guest backup file created at or after a running task's start
// is that task's partial output, not a completed backup.
runningVzdumpStart := make(map[int]time.Time) // VMID -> earliest running task start
for _, task := range snapshot.PVEBackups.BackupTasks {
if task.Instance != instanceName || task.VMID <= 0 || !task.EndTime.IsZero() {
continue
}
status := strings.ToLower(strings.TrimSpace(task.Status))
if status != "" && status != "running" {
continue
}
if existing, ok := runningVzdumpStart[task.VMID]; !ok || task.StartTime.Before(existing) {
runningVzdumpStart[task.VMID] = task.StartTime
}
}
// 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 {
if isPVEBackupPermissionError(err) {
hadPermissionError = true
permissionFailureCount++
}
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
successfulNodeQueries++
// 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)
// Check if this is a permission error
if isPVEBackupPermissionError(err) {
hadPermissionError = true
permissionFailureCount++
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
}
}
}
// Partial-output window: the file belongs to a vzdump task
// that is still running. PBS-type storages additionally list
// in-flight snapshots without a size (no manifest yet), which
// covers backups submitted by another cluster or host.
inProgress := false
if content.VMID > 0 {
if start, ok := runningVzdumpStart[content.VMID]; ok &&
!time.Unix(content.CTime, 0).Before(start.Add(-runningVzdumpClockSkew)) {
inProgress = true
}
if isPBSStorage && content.Size == 0 {
inProgress = true
}
}
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,
InProgress: inProgress,
}
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")
}
protectionObservedAt := time.Now().UTC()
protectionObservation, protectionObservationErr := buildPVEProtectionProviderObservation(
instanceName,
pveProtectionCollectionStats{
NodeCount: len(nodes),
SuccessfulNodeQueries: successfulNodeQueries,
BackupStorageCount: storagesWithBackup,
ContentSuccesses: contentSuccess,
ContentFailures: contentFailures,
PermissionFailureCount: permissionFailureCount,
},
protectionObservedAt,
)
if protectionObservationErr != nil {
log.Warn().
Err(protectionObservationErr).
Str("instance", instanceName).
Msg("Failed to build PVE protection provider observation")
}
// Decide whether to keep existing backups when every query failed
if shouldPreserveBackups(len(nodes), hadSuccessfulNode, storagesWithBackup, contentSuccess) {
if protectionObservationErr == nil {
m.ingestProtectionProviderObservationsAsync(
[]recovery.ProtectionProviderObservation{protectionObservation},
)
}
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)
points, evidenceErr := proxmoxrecoverymapper.FromPVEStorageBackupsWithEvidence(
allBackups,
guestInfo,
protectionObservedAt,
)
if evidenceErr != nil {
log.Warn().
Err(evidenceErr).
Str("instance", instanceName).
Msg("Failed to attach PVE recovery evidence; preserving existing points")
if protectionObservationErr == nil {
m.ingestProtectionProviderObservationsAsync(
[]recovery.ProtectionProviderObservation{protectionObservation},
)
}
} else {
observations := []recovery.ProtectionProviderObservation{}
if protectionObservationErr == nil {
observations = append(observations, protectionObservation)
}
m.ingestAndReconcileRecoveryPointsWithObservationsAsync(
points,
observations,
recoveryReconcileScope{
provider: string(recovery.ProviderProxmoxPVE),
idPrefix: "pve-backup:",
instance: instanceName,
},
)
}
// Sync backup times to VMs/Containers and republish them to canonical resources.
m.syncGuestBackupTimesAndResourceStore()
m.checkBackupAlerts(ctx)
// 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()
scope := m.mockModeFence.begin()
m.updateResourceStore(m.state.GetSnapshot(), scope)
}
func (m *Monitor) backupReadStateForInstance(instanceName string) unifiedresources.ReadState {
if m == nil {
return nil
}
readState := m.currentModeReadState()
if backupReadStateHasGuestForInstance(readState, instanceName) || m.state == nil {
return readState
}
scope := m.mockModeFence.begin()
snapshot := m.state.GetSnapshot()
if !backupSnapshotHasGuestForInstance(snapshot, instanceName) {
return readState
}
m.updateResourceStore(snapshot, scope)
readState = m.currentModeReadState()
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) {
if err := metadataStore.RememberIdentity(guestKey, name, guestType); err != nil {
log.Error().Err(err).Str("guestKey", guestKey).Msg("failed to admit guest identity update")
}
}
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")
// The guest inventory comes from the fixture read view in mock mode.
scope := m.mockModeFence.begin()
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)
points, evidenceErr := proxmoxrecoverymapper.FromPVEGuestSnapshotsWithEvidence(
allSnapshots,
guestInfo,
time.Now().UTC(),
)
if evidenceErr != nil {
log.Warn().
Err(evidenceErr).
Str("instance", instanceName).
Msg("Failed to attach PVE snapshot recovery evidence; preserving existing points")
} else {
m.ingestAndReconcileRecoveryPointsAsync(
points,
recoveryReconcileScope{
provider: string(recovery.ProviderProxmoxPVE),
idPrefix: "pve-snapshot:",
instance: instanceName,
},
)
}
if m.alertManager != nil {
scope.run(func() { 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
// A cached in-flight snapshot must be re-fetched every poll:
// when it completes, neither the group's last-backup time nor
// its count changes (the listing already included it), so the
// normal reuse conditions would keep showing "backup running"
// until the TTL expired.
cachedHasInProgress := false
for _, cachedSnapshot := range cached.snapshots {
if cachedSnapshot.InProgress {
cachedHasInProgress = true
break
}
}
// 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 &&
!cachedHasInProgress &&
!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
}
// A manifestless snapshot is incomplete, but it is not necessarily still
// being written: failed backup and PBS-to-PBS sync tasks can leave that
// shape behind. Correlate incomplete artifacts with PBS's live task view
// before they feed guest "Backup Running" state. If task visibility is
// denied or transiently unavailable, keep the previous conservative
// inference rather than claiming that no write is active.
if hasIncompletePBSBackup(allBackups) {
// Write-activity evidence belongs to this poll, not to the snapshot
// cache. A failed snapshot refresh may have reused an incomplete
// artifact whose previous poll authoritatively found no writer. Clear
// that old observation before asking for the current task view so a
// task-query failure leaves visibility unknown instead of publishing a
// stale terminal-failure conclusion.
clearPBSWriteActivityObservation(allBackups)
runningDataTasks, runningTasksErr := client.ListRunningDataTasks(ctx)
if runningTasksErr != nil {
log.Warn().Err(runningTasksErr).
Str("instance", instanceName).
Msg("Could not correlate incomplete PBS snapshots with running tasks")
} else {
applyPBSWriteActivity(allBackups, runningDataTasks)
}
}
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()
m.checkBackupAlerts(context.Background())
// Immediately broadcast the updated state so frontend sees new backups
m.broadcastStateUpdate()
}
func hasIncompletePBSBackup(backups []models.PBSBackup) bool {
for _, backup := range backups {
if backup.InProgress {
return true
}
}
return false
}
func clearPBSWriteActivityObservation(backups []models.PBSBackup) {
for i := range backups {
backups[i].WriteActivityObserved = false
backups[i].WriteActive = false
}
}
// applyPBSWriteActivity marks whether each incomplete snapshot is accounted
// for by a task which PBS currently reports as running. Backup task worker IDs
// normally identify "<store>:<type>/<id>". If a server version returns a less
// specific backup ID, the task remains a conservative instance-wide writer.
// Sync tasks can touch many groups and therefore apply instance-wide.
func applyPBSWriteActivity(backups []models.PBSBackup, tasks []pbs.RunningDataTask) {
for i := range backups {
if !backups[i].InProgress {
continue
}
backups[i].WriteActivityObserved = true
backups[i].WriteActive = runningPBSTaskMatchesBackup(tasks, backups[i])
}
}
func runningPBSTaskMatchesBackup(tasks []pbs.RunningDataTask, backup models.PBSBackup) bool {
subject := strings.ToLower(strings.TrimSpace(backup.BackupType + "/" + backup.VMID))
store := strings.ToLower(strings.TrimSpace(backup.Datastore))
for _, task := range tasks {
switch strings.ToLower(strings.TrimSpace(task.WorkerType)) {
case "syncjob":
return true
case "backup":
workerID := strings.ToLower(strings.TrimSpace(task.WorkerID))
if workerID == "" {
return true
}
if !strings.Contains(workerID, "/") {
// Unknown legacy/new worker-ID shape: do not hide a real write.
return true
}
if !strings.HasSuffix(workerID, subject) {
continue
}
if store == "" || strings.HasPrefix(workerID, store+":") || strings.Contains(workerID, ":"+store+":") {
return true
}
}
}
return false
}
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)
}
}
}
// A snapshot the PBS API lists while the backup is still being
// written has no manifest yet: no size field (decoded as 0) and no
// index.json.blob in files - typically only the guest config blob.
// Treat it as in-progress, not as a completed backup.
hasManifest := false
for _, name := range fileNames {
if name == "index.json.blob" {
hasManifest = true
break
}
}
inProgress := snapshot.Size == 0 && !hasManifest
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,
InProgress: inProgress,
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))
points, evidenceErr := proxmoxrecoverymapper.FromPVEBackupTasksWithEvidence(
backupTasks,
guestInfo,
time.Now().UTC(),
)
if evidenceErr != nil {
log.Warn().
Err(evidenceErr).
Str("instance", instanceName).
Msg("Failed to attach PVE backup-task recovery evidence; preserving existing points")
return
}
m.ingestRecoveryPointsAsync(points)
}
// 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(
instanceName string,
instanceCfg *config.PVEInstance,
client PVEClientInterface,
nodes []proxmox.Node,
nodeEffectiveStatus map[string]string,
) {
// Poll backups if enabled - respect configured interval or cycle gating
if !instanceCfg.MonitorBackups {
return
}
if !m.backupPollingEnabledSetting() {
log.Debug().
Str("instance", instanceName).
Msg("Skipping backup polling - globally disabled")
return
}
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
}
// Set initial timestamp before starting goroutine (prevents concurrent starts).
// This optional work owns a runtime-scoped timeout below; the completed core
// PVE generation must not be reclassified as unreachable merely because its
// per-cycle context expired before background scheduling.
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)
}
// checkMockAlerts checks alerts for mock data