mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
emit backup_verification_stale finding from VerifyIntent
BuildBackupVerificationStaleFinding inspects a ProtectionRollup, and when VerifyIntent is "stale" returns a backup-category Finding compatible with the existing patrol intake (FindingsStore.Add / PatrolService.recordFinding). Dedup keys go through the same generateFindingID helper the LLM "case backup:" branch uses, so a stale-still-stale tick updates rather than duplicates. Severity is Watch by default and escalates to Warning once the last successful backup is itself older than twice the staleness window — the MVP's "multiple consecutive stale windows" heuristic without a separate historical counter. Tests cover: emission for a stale rollup; nil for verified / unknown / nil rollups; severity escalation across multiple windows; dedup in the FindingsStore across two patrol ticks; and the resolution path (Resolve(auto=true) + auto_resolved lifecycle event) once verification reappears.
This commit is contained in:
parent
bd62b6d8ad
commit
e69633daf8
2 changed files with 402 additions and 0 deletions
193
internal/ai/findings_backup_verification.go
Normal file
193
internal/ai/findings_backup_verification.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// findings_backup_verification.go emits the "this protected resource hasn't
|
||||
// had its backup verified recently" signal. The detector reads a
|
||||
// recovery.ProtectionRollup, decides whether the subject is stale relative
|
||||
// to its verification window, and builds a backup-category Finding suitable
|
||||
// for the existing patrol-finding intake path (FindingsStore.Add or
|
||||
// PatrolService.recordFinding).
|
||||
//
|
||||
// Severity is Watch by default and escalates to Warning once the last
|
||||
// successful backup is itself older than one stale window — that's the
|
||||
// MVP's "multiple consecutive stale windows" heuristic without depending on
|
||||
// a separate historical counter.
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
|
||||
)
|
||||
|
||||
// BackupVerificationStaleFindingKey is the stable, deduped key for the
|
||||
// backup_verification_stale signal. The finding store keys the dedup set
|
||||
// on (resourceID, category, key), so this constant must not drift.
|
||||
const BackupVerificationStaleFindingKey = "backup_verification_stale"
|
||||
|
||||
// backupVerificationStaleWarningMultiplier marks how many staleness windows
|
||||
// the last successful backup must be older than before the finding escalates
|
||||
// to Warning severity.
|
||||
const backupVerificationStaleWarningMultiplier = 2
|
||||
|
||||
// BuildBackupVerificationStaleFinding inspects a rollup and returns a Finding
|
||||
// representing the stale-verification signal, or nil if the rollup is not
|
||||
// stale. The caller is expected to feed the result into the standard patrol
|
||||
// finding intake (FindingsStore.Add). Returns nil for nil rollups.
|
||||
func BuildBackupVerificationStaleFinding(rollup *recovery.ProtectionRollup, now time.Time) *Finding {
|
||||
if rollup == nil || rollup.VerifyIntent != recovery.VerifyIntentStale {
|
||||
return nil
|
||||
}
|
||||
|
||||
resourceID := strings.TrimSpace(rollup.SubjectResourceID)
|
||||
if resourceID == "" {
|
||||
// Fall back to the rollup key for external refs; the dedup key must
|
||||
// be stable across patrol runs so the same subject keeps the same ID.
|
||||
resourceID = strings.TrimSpace(rollup.RollupID)
|
||||
}
|
||||
if resourceID == "" {
|
||||
// Without a stable identifier we cannot dedup; refuse to emit rather
|
||||
// than risk surfacing duplicate findings.
|
||||
return nil
|
||||
}
|
||||
|
||||
resourceName := backupVerificationDisplayLabel(rollup, resourceID)
|
||||
resourceType := backupVerificationResourceType(rollup)
|
||||
|
||||
severity := FindingSeverityWatch
|
||||
consecutiveWindows := backupVerificationConsecutiveStaleWindows(rollup, now)
|
||||
if consecutiveWindows >= backupVerificationStaleWarningMultiplier {
|
||||
severity = FindingSeverityWarning
|
||||
}
|
||||
|
||||
id := generateFindingID(resourceID, string(FindingCategoryBackup), BackupVerificationStaleFindingKey)
|
||||
|
||||
finding := &Finding{
|
||||
ID: id,
|
||||
Key: BackupVerificationStaleFindingKey,
|
||||
Severity: severity,
|
||||
Category: FindingCategoryBackup,
|
||||
ResourceID: resourceID,
|
||||
ResourceName: resourceName,
|
||||
ResourceType: resourceType,
|
||||
Title: fmt.Sprintf("Backup not verified recently on %s", resourceName),
|
||||
Description: fmt.Sprintf(
|
||||
"A successful backup exists for %s but no verification has landed in the last %s. Restore confidence drops the longer this goes unchecked.",
|
||||
resourceName, backupVerificationWindowDescription(),
|
||||
),
|
||||
Impact: "If the most recent backup is corrupt or otherwise unrestorable, you will not learn until a real restore is attempted.",
|
||||
Recommendation: "Run a verification job against the latest backup, or schedule a recurring verify so the staleness window does not lapse again.",
|
||||
Evidence: backupVerificationEvidence(rollup, now),
|
||||
Source: "patrol-rule",
|
||||
}
|
||||
|
||||
return finding
|
||||
}
|
||||
|
||||
func backupVerificationDisplayLabel(rollup *recovery.ProtectionRollup, fallback string) string {
|
||||
if rollup.Display != nil {
|
||||
if label := strings.TrimSpace(rollup.Display.SubjectLabel); label != "" {
|
||||
return label
|
||||
}
|
||||
}
|
||||
if rollup.SubjectRef != nil {
|
||||
if name := strings.TrimSpace(rollup.SubjectRef.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func backupVerificationResourceType(rollup *recovery.ProtectionRollup) string {
|
||||
if rollup.Display != nil {
|
||||
if t := strings.TrimSpace(rollup.Display.SubjectType); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
if rollup.SubjectRef != nil {
|
||||
if t := strings.TrimSpace(rollup.SubjectRef.Type); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return "backup-subject"
|
||||
}
|
||||
|
||||
// backupVerificationConsecutiveStaleWindows estimates how many staleness
|
||||
// windows the last successful backup has aged through. A return value >= 2
|
||||
// signals the rollup has been stale across multiple windows and the
|
||||
// emitter should escalate to Warning.
|
||||
func backupVerificationConsecutiveStaleWindows(rollup *recovery.ProtectionRollup, now time.Time) int {
|
||||
windowMs := recovery.BackupVerifyStaleWindow.Milliseconds()
|
||||
if windowMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Prefer the last successful backup timestamp as the anchor: the rollup
|
||||
// is stale relative to that point. If there is no success on file, fall
|
||||
// back to the last verification (which, being outside the window, also
|
||||
// counts as historical evidence).
|
||||
var anchorMs int64
|
||||
if rollup.LastSuccessAt != nil {
|
||||
anchorMs = rollup.LastSuccessAt.UTC().UnixMilli()
|
||||
} else if rollup.LastVerifiedAt != nil {
|
||||
anchorMs = rollup.LastVerifiedAt.UTC().UnixMilli()
|
||||
}
|
||||
if anchorMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
ageMs := now.UTC().UnixMilli() - anchorMs
|
||||
if ageMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(ageMs / windowMs)
|
||||
}
|
||||
|
||||
func backupVerificationWindowDescription() string {
|
||||
days := int(recovery.BackupVerifyStaleWindow / (24 * time.Hour))
|
||||
if days <= 0 {
|
||||
return recovery.BackupVerifyStaleWindow.String()
|
||||
}
|
||||
if days == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
return fmt.Sprintf("%d days", days)
|
||||
}
|
||||
|
||||
func backupVerificationEvidence(rollup *recovery.ProtectionRollup, now time.Time) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if rollup.LastSuccessAt != nil {
|
||||
parts = append(parts, fmt.Sprintf("last successful backup %s ago", durationCoarse(now.Sub(*rollup.LastSuccessAt))))
|
||||
}
|
||||
if rollup.LastVerifiedAt != nil {
|
||||
parts = append(parts, fmt.Sprintf("last verified %s ago", durationCoarse(now.Sub(*rollup.LastVerifiedAt))))
|
||||
} else {
|
||||
parts = append(parts, "no recorded verification on this rollup")
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("staleness window %s", backupVerificationWindowDescription()))
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func durationCoarse(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
days := int(d / (24 * time.Hour))
|
||||
if days >= 1 {
|
||||
if days == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
return fmt.Sprintf("%d days", days)
|
||||
}
|
||||
hours := int(d / time.Hour)
|
||||
if hours >= 1 {
|
||||
if hours == 1 {
|
||||
return "1 hour"
|
||||
}
|
||||
return fmt.Sprintf("%d hours", hours)
|
||||
}
|
||||
mins := int(d / time.Minute)
|
||||
if mins <= 1 {
|
||||
return "1 minute"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes", mins)
|
||||
}
|
||||
209
internal/ai/findings_backup_verification_test.go
Normal file
209
internal/ai/findings_backup_verification_test.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
|
||||
)
|
||||
|
||||
func staleRollup(t *testing.T, now time.Time) *recovery.ProtectionRollup {
|
||||
t.Helper()
|
||||
// Recent successful backup (within one window), no verification at all.
|
||||
successAt := now.Add(-2 * 24 * time.Hour)
|
||||
return &recovery.ProtectionRollup{
|
||||
RollupID: "res:vm-100",
|
||||
SubjectResourceID: "vm-100",
|
||||
Display: &recovery.RecoveryPointDisplay{
|
||||
SubjectLabel: "web-prod",
|
||||
SubjectType: "proxmox-vm",
|
||||
},
|
||||
LastSuccessAt: &successAt,
|
||||
LastAttemptAt: &successAt,
|
||||
LastOutcome: recovery.OutcomeSuccess,
|
||||
VerifyIntent: recovery.VerifyIntentStale,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupVerificationStaleFinding_EmitsFindingForStaleRollup(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
rollup := staleRollup(t, now)
|
||||
|
||||
f := BuildBackupVerificationStaleFinding(rollup, now)
|
||||
if f == nil {
|
||||
t.Fatal("expected a finding for a stale rollup, got nil")
|
||||
}
|
||||
if f.Category != FindingCategoryBackup {
|
||||
t.Fatalf("Category = %q, want %q", f.Category, FindingCategoryBackup)
|
||||
}
|
||||
if f.Key != BackupVerificationStaleFindingKey {
|
||||
t.Fatalf("Key = %q, want %q", f.Key, BackupVerificationStaleFindingKey)
|
||||
}
|
||||
// Default severity is Watch when only a single staleness window has elapsed.
|
||||
if f.Severity != FindingSeverityWatch {
|
||||
t.Fatalf("Severity = %q, want %q", f.Severity, FindingSeverityWatch)
|
||||
}
|
||||
if f.ResourceID != "vm-100" {
|
||||
t.Fatalf("ResourceID = %q, want %q", f.ResourceID, "vm-100")
|
||||
}
|
||||
if f.ResourceName != "web-prod" {
|
||||
t.Fatalf("ResourceName = %q, want preferred display label", f.ResourceName)
|
||||
}
|
||||
|
||||
// ID must be deterministic across two emissions for the same rollup.
|
||||
second := BuildBackupVerificationStaleFinding(rollup, now)
|
||||
if second == nil || second.ID != f.ID {
|
||||
t.Fatalf("expected deterministic finding ID across emissions, got %q vs %q", f.ID, secondID(second))
|
||||
}
|
||||
|
||||
// Different resource → different finding ID.
|
||||
other := staleRollup(t, now)
|
||||
other.SubjectResourceID = "vm-999"
|
||||
otherFinding := BuildBackupVerificationStaleFinding(other, now)
|
||||
if otherFinding == nil || otherFinding.ID == f.ID {
|
||||
t.Fatalf("expected distinct IDs for distinct resources, got %q vs %q", f.ID, secondID(otherFinding))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupVerificationStaleFinding_NilForNonStaleRollup(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
verifiedAt := now.Add(-24 * time.Hour)
|
||||
rollup := &recovery.ProtectionRollup{
|
||||
RollupID: "res:vm-100",
|
||||
SubjectResourceID: "vm-100",
|
||||
LastSuccessAt: &verifiedAt,
|
||||
LastVerifiedAt: &verifiedAt,
|
||||
LastOutcome: recovery.OutcomeSuccess,
|
||||
VerifyIntent: recovery.VerifyIntentVerified,
|
||||
}
|
||||
|
||||
if f := BuildBackupVerificationStaleFinding(rollup, now); f != nil {
|
||||
t.Fatalf("expected nil for verified rollup, got %#v", f)
|
||||
}
|
||||
|
||||
rollup.VerifyIntent = recovery.VerifyIntentUnknown
|
||||
if f := BuildBackupVerificationStaleFinding(rollup, now); f != nil {
|
||||
t.Fatalf("expected nil for unknown rollup, got %#v", f)
|
||||
}
|
||||
|
||||
if f := BuildBackupVerificationStaleFinding(nil, now); f != nil {
|
||||
t.Fatal("expected nil for nil rollup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupVerificationStaleFinding_EscalatesAfterMultipleWindows(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
// Last successful backup is 21 days old → 3 staleness windows of 7d.
|
||||
old := now.Add(-21 * 24 * time.Hour)
|
||||
rollup := &recovery.ProtectionRollup{
|
||||
RollupID: "res:vm-200",
|
||||
SubjectResourceID: "vm-200",
|
||||
Display: &recovery.RecoveryPointDisplay{
|
||||
SubjectLabel: "db-prod",
|
||||
SubjectType: "proxmox-vm",
|
||||
},
|
||||
LastSuccessAt: &old,
|
||||
LastAttemptAt: &old,
|
||||
LastOutcome: recovery.OutcomeSuccess,
|
||||
VerifyIntent: recovery.VerifyIntentStale,
|
||||
}
|
||||
|
||||
f := BuildBackupVerificationStaleFinding(rollup, now)
|
||||
if f == nil {
|
||||
t.Fatal("expected finding")
|
||||
}
|
||||
if f.Severity != FindingSeverityWarning {
|
||||
t.Fatalf("Severity = %q, want %q for multi-window stale", f.Severity, FindingSeverityWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupVerificationStaleFinding_PatrolIntakeDedupes(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
store := NewFindingsStore()
|
||||
rollup := staleRollup(t, now)
|
||||
|
||||
first := BuildBackupVerificationStaleFinding(rollup, now)
|
||||
if first == nil {
|
||||
t.Fatal("expected initial finding")
|
||||
}
|
||||
if !store.Add(first) {
|
||||
t.Fatal("expected first Add to create a new finding")
|
||||
}
|
||||
|
||||
// Same patrol tick, same rollup. Should dedup on (resourceID, category, key).
|
||||
second := BuildBackupVerificationStaleFinding(rollup, now.Add(15*time.Minute))
|
||||
if second == nil {
|
||||
t.Fatal("expected second emission for still-stale rollup")
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("expected dedup-stable ID, got %q (first) vs %q (second)", first.ID, second.ID)
|
||||
}
|
||||
if store.Add(second) {
|
||||
t.Fatal("expected second Add to update existing (return false), not create new")
|
||||
}
|
||||
|
||||
stored := store.Get(first.ID)
|
||||
if stored == nil {
|
||||
t.Fatal("expected stored finding to exist")
|
||||
}
|
||||
if stored.TimesRaised != 2 {
|
||||
t.Fatalf("TimesRaised = %d, want 2", stored.TimesRaised)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupVerificationStaleFinding_ResolutionPathFiresOnReverify(t *testing.T) {
|
||||
now := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC)
|
||||
store := NewFindingsStore()
|
||||
|
||||
rollup := staleRollup(t, now)
|
||||
finding := BuildBackupVerificationStaleFinding(rollup, now)
|
||||
if finding == nil {
|
||||
t.Fatal("expected stale finding")
|
||||
}
|
||||
if !store.Add(finding) {
|
||||
t.Fatal("expected first Add to create")
|
||||
}
|
||||
|
||||
// Verification reappears on the rollup. The detector returns nil; the
|
||||
// caller is expected to drive the resolution path. Mirror the lifecycle
|
||||
// fixtures: Resolve(auto=true) and confirm the auto_resolved event lands.
|
||||
verifiedAt := now.Add(5 * time.Minute)
|
||||
rollup.VerifyIntent = recovery.VerifyIntentVerified
|
||||
rollup.LastVerifiedAt = &verifiedAt
|
||||
|
||||
if reemitted := BuildBackupVerificationStaleFinding(rollup, now.Add(10*time.Minute)); reemitted != nil {
|
||||
t.Fatalf("expected detector to skip emission once verified, got %#v", reemitted)
|
||||
}
|
||||
|
||||
if !store.Resolve(finding.ID, true) {
|
||||
t.Fatal("expected Resolve to succeed")
|
||||
}
|
||||
|
||||
stored := store.Get(finding.ID)
|
||||
if stored == nil {
|
||||
t.Fatal("expected finding to exist after resolve")
|
||||
}
|
||||
if stored.ResolvedAt == nil {
|
||||
t.Fatal("expected ResolvedAt to be set after Resolve")
|
||||
}
|
||||
if !stored.AutoResolved {
|
||||
t.Fatal("expected AutoResolved=true after Resolve(true)")
|
||||
}
|
||||
foundAutoResolved := false
|
||||
for _, e := range stored.Lifecycle {
|
||||
if e.Type == "auto_resolved" {
|
||||
foundAutoResolved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundAutoResolved {
|
||||
t.Fatalf("expected auto_resolved lifecycle event; got: %+v", stored.Lifecycle)
|
||||
}
|
||||
}
|
||||
|
||||
func secondID(f *Finding) string {
|
||||
if f == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return f.ID
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue