Files
pulse/internal/monitoring/recovery_ingest.go
T
2026-07-23 22:24:52 +01:00

363 lines
11 KiB
Go

package monitoring
import (
"context"
"fmt"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
proxmoxmapper "github.com/rcourtman/pulse-go-rewrite/internal/recovery/mapper/proxmox"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
// recoveryReconcileScope identifies one class of points for one polled
// connection whose latest enumeration is authoritative: after the batch is
// upserted, points in scope that were not part of the batch are deleted.
type recoveryReconcileScope struct {
provider string
idPrefix string
instance string
}
// recoveryIngestBatch is one poll cycle's worth of recovery points, plus an
// optional reconcile scope when the points are a complete enumeration.
type recoveryIngestBatch struct {
points []recovery.RecoveryPoint
observations []recovery.ProtectionProviderObservation
reconcile *recoveryReconcileScope
}
type recoveryIngestCoalesceKey struct {
provider string
idPrefix string
instance string
}
func (batch recoveryIngestBatch) coalesceKey() (recoveryIngestCoalesceKey, bool) {
if batch.reconcile == nil {
return recoveryIngestCoalesceKey{}, false
}
return recoveryIngestCoalesceKey{
provider: batch.reconcile.provider,
idPrefix: batch.reconcile.idPrefix,
instance: batch.reconcile.instance,
}, true
}
func (m *Monitor) coalescePendingRecoveryIngest(batch recoveryIngestBatch) bool {
key, ok := batch.coalesceKey()
if !ok {
return false
}
for i := len(m.recoveryIngestPending) - 1; i >= 0; i-- {
pendingKey, pendingOK := m.recoveryIngestPending[i].coalesceKey()
if pendingOK && pendingKey == key {
m.recoveryIngestPending[i] = batch
return true
}
}
return false
}
func (m *Monitor) ingestRecoveryPointsAsync(points []recovery.RecoveryPoint) {
m.enqueueRecoveryIngest(recoveryIngestBatch{points: points})
}
// ingestAndReconcileRecoveryPointsAsync upserts a complete enumeration and
// removes points in scope that the source no longer reports. An empty points
// slice is meaningful here: it clears the scope entirely (#1580).
func (m *Monitor) ingestAndReconcileRecoveryPointsAsync(points []recovery.RecoveryPoint, scope recoveryReconcileScope) {
m.enqueueRecoveryIngest(recoveryIngestBatch{points: points, reconcile: &scope})
}
func (m *Monitor) ingestAndReconcileRecoveryPointsWithObservationsAsync(
points []recovery.RecoveryPoint,
observations []recovery.ProtectionProviderObservation,
scope recoveryReconcileScope,
) {
m.enqueueRecoveryIngest(recoveryIngestBatch{
points: points,
observations: observations,
reconcile: &scope,
})
}
func (m *Monitor) ingestProtectionProviderObservationsAsync(
observations []recovery.ProtectionProviderObservation,
) {
m.enqueueRecoveryIngest(recoveryIngestBatch{observations: observations})
}
func (m *Monitor) enqueueRecoveryIngest(batch recoveryIngestBatch) {
if m == nil ||
(len(batch.points) == 0 &&
len(batch.observations) == 0 &&
batch.reconcile == nil) {
return
}
m.recoveryIngestMu.Lock()
if m.recoveryIngestRunning {
// A reconcile batch is a complete current enumeration for one source
// scope, so only its newest pending value is authoritative. Distinct
// scopes and non-reconciling event batches remain FIFO; this preserves
// their facts without retaining every superseded full poll result.
if m.coalescePendingRecoveryIngest(batch) {
pending := len(m.recoveryIngestPending)
m.recoveryIngestMu.Unlock()
log.Debug().
Int("points", len(batch.points)).
Int("provider_observations", len(batch.observations)).
Int("pending_batches", pending).
Msg("Coalesced recovery point ingest behind active batch")
return
}
m.recoveryIngestPending = append(m.recoveryIngestPending, batch)
pending := len(m.recoveryIngestPending)
m.recoveryIngestMu.Unlock()
log.Debug().
Int("points", len(batch.points)).
Int("provider_observations", len(batch.observations)).
Int("pending_batches", pending).
Msg("Queued recovery point ingest behind active batch")
return
}
m.recoveryIngestRunning = true
m.recoveryIngestMu.Unlock()
go m.runRecoveryPointIngestLoop([]recoveryIngestBatch{batch})
}
func (m *Monitor) runRecoveryPointIngestLoop(batches []recoveryIngestBatch) {
for len(batches) > 0 {
for _, batch := range batches {
// SQLite upserts are usually quick, but allow a bit of time for large batches.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
m.ingestRecoveryPointsBestEffort(ctx, batch)
cancel()
}
m.recoveryIngestMu.Lock()
if len(m.recoveryIngestPending) == 0 {
m.recoveryIngestRunning = false
m.recoveryIngestMu.Unlock()
return
}
batches = m.recoveryIngestPending
m.recoveryIngestPending = nil
m.recoveryIngestMu.Unlock()
}
m.recoveryIngestMu.Lock()
m.recoveryIngestRunning = false
m.recoveryIngestMu.Unlock()
}
func (m *Monitor) ingestRecoveryPointsBestEffort(ctx context.Context, batch recoveryIngestBatch) {
if m == nil ||
(len(batch.points) == 0 &&
len(batch.observations) == 0 &&
batch.reconcile == nil) {
return
}
m.mu.RLock()
rm := m.recoveryManager
orgID := strings.TrimSpace(m.orgID)
m.mu.RUnlock()
if rm == nil {
return
}
if orgID == "" {
orgID = "default"
}
store, err := rm.StoreForOrg(orgID)
if err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to open recovery store for backup ingestion")
return
}
// Collection-wide evidence is the authority for how much provider history
// Pulse could actually see. Persist it before any point mutation so a large
// point batch, refresh timeout, or reconciliation failure cannot leave
// retained artifacts looking more trustworthy than the poll that produced
// them.
if err := store.UpsertProtectionProviderObservations(
ctx,
batch.observations,
); err != nil {
log.Warn().
Err(err).
Str("org_id", orgID).
Int("provider_observations", len(batch.observations)).
Msg("Failed to upsert provider protection observations")
return
}
if err := store.UpsertPoints(ctx, batch.points); err != nil {
log.Warn().Err(err).Str("org_id", orgID).Int("points", len(batch.points)).Msg("Failed to upsert recovery points from backup polling")
// Do not reconcile against a batch that failed to land; deleting on
// top of a failed upsert could drop points the source still reports.
return
}
if batch.reconcile == nil {
return
}
keepIDs := make([]string, 0, len(batch.points))
for _, p := range batch.points {
keepIDs = append(keepIDs, p.ID)
}
if _, err := store.ReconcileInstancePoints(ctx, batch.reconcile.provider, batch.reconcile.idPrefix, batch.reconcile.instance, keepIDs); err != nil {
log.Warn().
Err(err).
Str("org_id", orgID).
Str("instance", batch.reconcile.instance).
Str("idPrefix", batch.reconcile.idPrefix).
Msg("Failed to reconcile recovery points against source enumeration")
}
}
func (m *Monitor) purgeStalePVEPBSBackupsBestEffort(ctx context.Context) {
if m == nil {
return
}
m.mu.RLock()
rm := m.recoveryManager
orgID := strings.TrimSpace(m.orgID)
hasPBSDirectConnection := m.config != nil && len(m.config.PBSInstances) > 0
m.mu.RUnlock()
if !hasPBSDirectConnection || rm == nil {
return
}
if orgID == "" {
orgID = "default"
}
if ctx == nil {
ctx = context.Background()
}
purgeCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
store, err := rm.StoreForOrg(orgID)
if err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to open recovery store for stale PBS backup purge")
return
}
if err := store.PurgeStalePVEPBSBackups(purgeCtx); err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to purge stale PVE-sourced PBS backup entries")
}
}
func buildProxmoxGuestInfoIndex(readState unifiedresources.ReadState) map[string]proxmoxmapper.GuestInfo {
if readState == nil {
return map[string]proxmoxmapper.GuestInfo{}
}
vmViews := readState.VMs()
containerViews := readState.Containers()
out := make(map[string]proxmoxmapper.GuestInfo, len(vmViews)+len(containerViews))
for _, vm := range vmViews {
if vm == nil || vm.Template() {
continue
}
key := proxmoxmapperKey(vm.Instance(), vm.Node(), vm.VMID())
resourceID := strings.TrimSpace(vm.ID())
sourceID := strings.TrimSpace(vm.SourceID())
if sourceID == "" {
sourceID = makeGuestID(vm.Instance(), vm.Node(), vm.VMID())
}
out[key] = proxmoxmapper.GuestInfo{
ResourceID: resourceID,
SourceID: sourceID,
ResourceType: unifiedresources.ResourceTypeVM,
Name: strings.TrimSpace(vm.Name()),
}
}
for _, ct := range containerViews {
if ct == nil || ct.Template() {
continue
}
key := proxmoxmapperKey(ct.Instance(), ct.Node(), ct.VMID())
resourceID := strings.TrimSpace(ct.ID())
sourceID := strings.TrimSpace(ct.SourceID())
if sourceID == "" {
sourceID = makeGuestID(ct.Instance(), ct.Node(), ct.VMID())
}
out[key] = proxmoxmapper.GuestInfo{
ResourceID: resourceID,
SourceID: sourceID,
ResourceType: unifiedresources.ResourceTypeSystemContainer,
Name: strings.TrimSpace(ct.Name()),
}
}
return out
}
func buildPBSGuestCandidates(readState unifiedresources.ReadState) map[string][]proxmoxmapper.GuestCandidate {
if readState == nil {
return map[string][]proxmoxmapper.GuestCandidate{}
}
vmViews := readState.VMs()
containerViews := readState.Containers()
out := make(map[string][]proxmoxmapper.GuestCandidate, len(vmViews)+len(containerViews))
for _, vm := range vmViews {
if vm == nil || vm.Template() || vm.VMID() <= 0 {
continue
}
key := "vm:" + fmt.Sprintf("%d", vm.VMID())
resourceID := strings.TrimSpace(vm.ID())
sourceID := strings.TrimSpace(vm.SourceID())
if sourceID == "" {
sourceID = makeGuestID(vm.Instance(), vm.Node(), vm.VMID())
}
out[key] = append(out[key], proxmoxmapper.GuestCandidate{
ResourceID: resourceID,
SourceID: sourceID,
ResourceType: unifiedresources.ResourceTypeVM,
DisplayName: strings.TrimSpace(vm.Name()),
InstanceName: strings.TrimSpace(vm.Instance()),
NodeName: strings.TrimSpace(vm.Node()),
VMID: vm.VMID(),
})
}
for _, ct := range containerViews {
if ct == nil || ct.Template() || ct.VMID() <= 0 {
continue
}
key := "ct:" + fmt.Sprintf("%d", ct.VMID())
resourceID := strings.TrimSpace(ct.ID())
sourceID := strings.TrimSpace(ct.SourceID())
if sourceID == "" {
sourceID = makeGuestID(ct.Instance(), ct.Node(), ct.VMID())
}
out[key] = append(out[key], proxmoxmapper.GuestCandidate{
ResourceID: resourceID,
SourceID: sourceID,
ResourceType: unifiedresources.ResourceTypeSystemContainer,
DisplayName: strings.TrimSpace(ct.Name()),
InstanceName: strings.TrimSpace(ct.Instance()),
NodeName: strings.TrimSpace(ct.Node()),
VMID: ct.VMID(),
})
}
return out
}
func proxmoxmapperKey(instanceName, nodeName string, vmid int) string {
// Keep key format in one place so ingestion and mapping stay consistent.
return fmt.Sprintf("%s|%s|%d", strings.TrimSpace(instanceName), strings.TrimSpace(nodeName), vmid)
}