Files
pulse/internal/monitoring/monitor_alert_intent_test.go
T
courtmanr@gmail.com a402d23503 fix(backups): synthesize per-guest task status from vzdump job logs
Scheduled multi-guest vzdump jobs run under a single UPID whose VMID slot
is empty, so pollBackupTasks stored them with VMID 0 and the guest-centric
backups coverage view dropped them entirely: only individually backed-up
guests ever showed task status. (Regressed with the v6.0.0 guest-centric
redesign, which removed the flat task table that used to render job runs.)

pollBackupTasks now fetches the job task's log and parses the per-guest
markers ("Starting Backup of VM", "Finished Backup of VM (duration)",
"Backup of VM failed - reason") into synthetic per-guest BackupTask
entries. Their IDs embed the parent UPID, keeping them stable across polls
and distinct from individually-run backups; per-guest times are
reconstructed from the job start plus the printed durations. Finished
jobs' logs are immutable, so results are cached per instance|UPID and each
finished run is fetched at most once, with a per-cycle fetch cap so a
historical backlog trickles in without stalling the backup poll budget.

The task listing now uses source=all + typefilter=vzdump, so running jobs
are visible too: guests covered by an in-progress job get a "running"
synthetic task, which also feeds resolveBackupIntentContext and
suppresses offline/backup alerts for guests the job is actively backing
up. The frontend needs no changes - synthetic tasks carry real VMIDs and
flow through the existing coverage model, recovery mapper, and alert
intent paths.

Contract: monitoring.md completion obligation 13 records the per-guest
synthesis boundary; proofs land in monitor_backup_job_tasks_test.go,
monitor_alert_intent_test.go, and cluster_client_api_test.go.

Reported by Johannes Strasser (support thread "PBS Bug").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:03:46 +01:00

110 lines
3.7 KiB
Go

package monitoring
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
func TestResolveBackupIntentContextRequiresFreshActiveMatchingEvidence(t *testing.T) {
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
state := models.NewState()
state.UpdateBackupTasksForInstance("pve-a", []models.BackupTask{
{
ID: "active-101",
Instance: "pve-a",
Node: "node-a",
VMID: 101,
Status: "running",
ObservedAt: now.Add(-time.Minute),
},
{
ID: "stale-102",
Instance: "pve-a",
Node: "node-a",
VMID: 102,
Status: "running",
ObservedAt: now.Add(-backupIntentEvidenceMaxAge - time.Second),
},
{
ID: "finished-103",
Instance: "pve-a",
Node: "node-a",
VMID: 103,
Status: "OK",
ObservedAt: now.Add(-time.Minute),
EndTime: now.Add(-30 * time.Second),
},
})
monitor := &Monitor{state: state}
context, found := monitor.resolveBackupIntentContext("", "pve-a", "node-a", 101, now)
if !found || !context.Active {
t.Fatalf("fresh active task did not resolve: found=%v context=%+v", found, context)
}
if context.ObservedAt != now.Add(-time.Minute) {
t.Fatalf("observedAt = %v, want %v", context.ObservedAt, now.Add(-time.Minute))
}
if context.Evidence != "pve_vzdump_task:active-101" {
t.Fatalf("evidence = %q, want active task identity", context.Evidence)
}
for _, tc := range []struct {
name string
instance string
node string
vmid int
}{
{name: "wrong instance", instance: "pve-b", node: "node-a", vmid: 101},
{name: "wrong node", instance: "pve-a", node: "node-b", vmid: 101},
{name: "stale", instance: "pve-a", node: "node-a", vmid: 102},
{name: "finished", instance: "pve-a", node: "node-a", vmid: 103},
} {
t.Run(tc.name, func(t *testing.T) {
if got, ok := monitor.resolveBackupIntentContext("", tc.instance, tc.node, tc.vmid, now); ok {
t.Fatalf("unexpected backup intent context: %+v", got)
}
})
}
}
// A guest covered by a running multi-guest vzdump job has no task of its own;
// pollBackupTasks synthesizes one from the job log. That synthetic task must
// count as backup-intent evidence so the guest's alerts are suppressed while
// the job is backing it up, and must stop counting once its section finishes.
func TestResolveBackupIntentContextAcceptsSynthesizedJobGuestTask(t *testing.T) {
now := time.Date(2026, 7, 25, 2, 5, 0, 0, time.UTC)
jobTask := models.BackupTask{
ID: "pve-a-UPID:node-a:000E9F2C:0AC734B2:68A1B2C3:vzdump::root@pam:",
Node: "node-a",
Instance: "pve-a",
Type: "vzdump",
StartTime: now.Add(-4 * time.Minute),
// no EndTime: the job is still running
}
synthesized := parseVzdumpJobLog(jobTask, "UPID:node-a:000E9F2C:0AC734B2:68A1B2C3:vzdump::root@pam:", []proxmox.TaskLogLine{
{LineNumber: 1, Text: "INFO: Starting Backup of VM 101 (qemu)"},
{LineNumber: 2, Text: "INFO: Finished Backup of VM 101 (00:01:30)"},
{LineNumber: 3, Text: "INFO: Starting Backup of VM 102 (lxc)"},
})
for i := range synthesized {
synthesized[i].ObservedAt = now.Add(-30 * time.Second)
}
state := models.NewState()
state.UpdateBackupTasksForInstance("pve-a", append([]models.BackupTask{jobTask}, synthesized...))
monitor := &Monitor{state: state}
context, found := monitor.resolveBackupIntentContext("", "pve-a", "node-a", 102, now)
if !found || !context.Active {
t.Fatalf("guest being backed up by running job did not resolve: found=%v context=%+v", found, context)
}
if got, ok := monitor.resolveBackupIntentContext("", "pve-a", "node-a", 101, now); ok {
t.Fatalf("guest whose job section already finished should not carry intent: %+v", got)
}
}