Pulse/internal/monitoring/monitor_backups.go
rcourtman f4c2fd0c38 Fail closed on unknown remediation lock state for autonomous dispatches
The AI action broker treated an unreadable operator lock as unlocked:
isResourceRemediationLocked returned (false, nil) with no audit store
wired, and the caller logged store errors then dispatched anyway. An
operator's NeverAutoRemediate=true could be silently ignored whenever
the policy store was missing or erroring, which is unacceptable while
Patrol and Assistant run at assisted or full autonomy.

Posture change at the dispatch decision point:
- isResourceRemediationLocked now reports unknown state (nil store or
  lookup failure) as an ErrRemediationLockStateUnknown-wrapped error
  instead of silently defaulting to unlocked.
- New checkRemediationLockForDispatch gate: dispatches without an
  approved human decision fail CLOSED on unknown lock state and
  surface "remediation lock state unknown; operator approval
  required". Human-approved dispatches keep the historical fail-open
  behavior with a warning log. A confirmed lock still refuses even
  approved dispatches, as before.
- executeNativeActionWithAudit (TrueNAS app start/stop/restart) now
  enforces the lock too; it previously skipped the check entirely.
- Refusals persist Failed audit records with stable
  remediation_lock_state_unknown: / resource_remediation_locked:
  ErrorMessage prefixes.
- ai-runtime subsystem contract updated to pin the new posture.

Tests cover store-error and nil-store at both autonomy postures on
both dispatch paths; routing/control tests now wire an in-memory
audit store since autonomous dispatch without one is refused.
2026-07-10 00:14:01 +01:00

2030 lines
60 KiB
Go

package monitoring
import (
"context"
"fmt"
"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"
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
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)
}
isPBSStorage := strings.HasPrefix(storage.Storage, "pbs-") || storage.Type == "pbs"
if isPBSStorage && hasPBSDirectConnection {
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")
}
// 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)
// Best-effort ingestion into recovery store (for rollups / unified backups UX).
guestInfo := buildProxmoxGuestInfoIndex(readState)
m.ingestRecoveryPointsAsync(proxmoxrecoverymapper.FromPVEStorageBackups(allBackups, guestInfo))
// 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()
}
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
}
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.ingestRecoveryPointsAsync(proxmoxrecoverymapper.FromPVEGuestSnapshots(allSnapshots, guestInfo))
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.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")
// Decide whether to keep existing backups when all queries failed
if shouldPreservePBSBackupsWithTerminal(datastoreCount, datastoreFetches, datastoreTerminalFailures) {
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())
m.ingestRecoveryPointsAsync(proxmoxrecoverymapper.FromPBSBackups(allBackups, candidates))
// 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
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),
}
if task.EndTime > 0 {
backupTask.EndTime = time.Unix(task.EndTime, 0)
}
backupTasks = append(backupTasks, backupTask)
}
// 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))
}
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.config.EnableBackupPolling {
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