mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(truenas): recognise finished replication outcomes (#1892)
TrueNAS emits FINISHED for successful replication, but the recovery mapper treated it as unknown. Recognise that provider state while preserving error precedence and the missing-run guard; add regression coverage reproducing the reported outcome. Change-source: pulse-maintainer
This commit is contained in:
@@ -21,6 +21,13 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
TrueNAS replication state `FINISHED` is successful completion evidence when a
|
||||
last-run timestamp exists, matching the provider success event. Explicit error
|
||||
text still takes precedence; a missing last-run timestamp remains unknown.
|
||||
Persisted recovery rollups must retain this outcome and success time rather
|
||||
than classify a completed replication as unknown. This does not imply restore
|
||||
verification or resolve provider INFO alert lifecycle behaviour.
|
||||
|
||||
Unraid `StorageMeta.numDisks` is optional source evidence: zero denotes an explicitly empty parity array, not proof that pools are protected or that a recovery point exists. Suppressing the no-parity warning for zero must not erase disk-failure evidence or grant storage/recovery authority.
|
||||
|
||||
A manifestless PBS snapshot never becomes a successful recovery point and
|
||||
|
||||
@@ -21,7 +21,7 @@ func outcomeFromTrueNASReplication(state string, errText string) recovery.Outcom
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(state))
|
||||
switch normalized {
|
||||
case "success", "succeeded", "ok", "complete", "completed":
|
||||
case "success", "succeeded", "ok", "complete", "completed", "finished":
|
||||
return recovery.OutcomeSuccess
|
||||
case "warning", "partial", "partiallyfailed", "partially_failed":
|
||||
return recovery.OutcomeWarning
|
||||
|
||||
@@ -198,6 +198,9 @@ func TestOutcomeFromTrueNASReplication(t *testing.T) {
|
||||
{"ok", "ok", "", recovery.OutcomeSuccess},
|
||||
{"complete", "complete", "", recovery.OutcomeSuccess},
|
||||
{"completed", "completed", "", recovery.OutcomeSuccess},
|
||||
{"finished uppercase", "FINISHED", "", recovery.OutcomeSuccess},
|
||||
{"finished normalized", " Finished ", " ", recovery.OutcomeSuccess},
|
||||
{"finished error takes precedence", "FINISHED", "replication failed", recovery.OutcomeFailed},
|
||||
{"warning", "warning", "", recovery.OutcomeWarning},
|
||||
{"partial", "partial", "", recovery.OutcomeWarning},
|
||||
{"partiallyfailed", "partiallyfailed", "", recovery.OutcomeWarning},
|
||||
@@ -259,3 +262,35 @@ func TestSplitSnapshotName(t *testing.T) {
|
||||
func ptrInt64(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
// FINISHED is emitted by TrueNAS zettarepl for ReplicationTaskSuccess (#1892).
|
||||
func TestFromTrueNASSnapshot_FinishedReplication(t *testing.T) {
|
||||
lastRun := time.Date(2026, 9, 4, 5, 45, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
lastRun *time.Time
|
||||
errText string
|
||||
want recovery.Outcome
|
||||
}{
|
||||
{"completed", &lastRun, "", recovery.OutcomeSuccess},
|
||||
{"error retained", &lastRun, "transfer failed", recovery.OutcomeFailed},
|
||||
{"missing run remains unknown", nil, "", recovery.OutcomeUnknown},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
points := FromTrueNASSnapshot("conn", &truenas.FixtureSnapshot{
|
||||
ReplicationTasks: []truenas.ReplicationTask{{
|
||||
ID: "1", LastState: "FINISHED", LastRun: tc.lastRun, LastError: tc.errText,
|
||||
}},
|
||||
})
|
||||
if len(points) != 1 {
|
||||
t.Fatalf("got %d points", len(points))
|
||||
}
|
||||
if points[0].Outcome != tc.want {
|
||||
t.Errorf("outcome = %v, want %v", points[0].Outcome, tc.want)
|
||||
}
|
||||
if points[0].Details["lastState"] != "FINISHED" {
|
||||
t.Error("provider state lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
|
||||
truenasmapper "github.com/rcourtman/pulse-go-rewrite/internal/recovery/mapper/truenas"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
@@ -1114,3 +1116,60 @@ func assertRecoveryColumnExists(t *testing.T, dbPath string, column string) {
|
||||
}
|
||||
t.Fatalf("expected recovery_points column %q to exist after migration", column)
|
||||
}
|
||||
|
||||
func TestStore_TrueNASFinishedReplicationRollup(t *testing.T) {
|
||||
at := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
lastRun *time.Time
|
||||
errText string
|
||||
want recovery.Outcome
|
||||
}{
|
||||
{"finished", &at, "", recovery.OutcomeSuccess},
|
||||
{"explicit error", &at, "transfer failed", recovery.OutcomeFailed},
|
||||
{"missing run", nil, "", recovery.OutcomeUnknown},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "recovery.db")
|
||||
db, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
points := truenasmapper.FromTrueNASSnapshot("nas", &truenas.FixtureSnapshot{
|
||||
CollectedAt: at,
|
||||
ReplicationTasks: []truenas.ReplicationTask{{
|
||||
ID: "1", Name: "backup", LastState: "FINISHED", LastRun: tc.lastRun, LastError: tc.errText,
|
||||
}},
|
||||
})
|
||||
if err := db.UpsertPoints(context.Background(), points); err != nil {
|
||||
_ = db.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err = Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
rows, total, err := db.ListRollups(context.Background(), recovery.ListPointsOptions{Page: 1, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("rollups total=%d len=%d, want 1/1", total, len(rows))
|
||||
}
|
||||
if rows[0].LastOutcome != tc.want {
|
||||
t.Fatalf("outcome=%v, want %v", rows[0].LastOutcome, tc.want)
|
||||
}
|
||||
if tc.want == recovery.OutcomeSuccess {
|
||||
if rows[0].LastSuccessAt == nil || !rows[0].LastSuccessAt.Equal(at) {
|
||||
t.Fatalf("success time=%v, want %v", rows[0].LastSuccessAt, at)
|
||||
}
|
||||
} else if rows[0].LastSuccessAt != nil {
|
||||
t.Fatalf("unexpected success time: %v", rows[0].LastSuccessAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user