Merge core runtime Unraid pool-only alert fix

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-04 23:50:24 +01:00
17 changed files with 187 additions and 1 deletions
@@ -15,6 +15,8 @@
## Purpose
Unraid collection preserves optional nonnegative `mdNumDisks` as `numDisks` through the host report. Explicit zero survives JSON encoding; absent, negative, or malformed source counts remain unknown. This telemetry does not change enrollment or command authority; older agents retain unknown-count behaviour.
### Portable installer lifecycle ownership
The shared shell installer lifecycle directory (outside the least-privilege
@@ -17,6 +17,8 @@
## Purpose
Unraid host ingestion and canonical read-state reconstruction preserve the optional array disk count, distinguishing explicit zero from unknown. Storage assessment suppresses only the no-parity warning for an explicit zero-disk array; unknown counts retain the prior warning and disabled, invalid, or missing member evidence remains effective.
Direct PBS backup polling correlates manifestless snapshots with current
writer tasks before publishing guest backup-running state. The client queries
running `backup` and `syncjob` task families separately with bounded pagination;
@@ -21,6 +21,8 @@
## Purpose
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
never advances backup age. Recovery mapping reports it as running while a live
writer accounts for it or current-task visibility is unavailable; after a
@@ -15,6 +15,8 @@
## Purpose
Unraid adapters preserve optional `numDisks` in both host and storage metadata, including explicit zero in JSON and absence for unknown counts. Disk count is topology evidence only: changing it must not change canonical host/storage identity. The storage projection uses the monitoring-owned assessment so an explicit pool-only array does not acquire a no-parity warning.
Own canonical resource identity, type normalization, typed views, and
cross-source deduplication.
Storage metadata may expose source-authored alias IDs as compatibility evidence
+5
View File
@@ -185,6 +185,11 @@ func parseUnraidStatusOutput(output string) (*agentshost.UnraidStorage, error) {
NumMissing: parseUnraidIntField(fields, "mdNumMissing"),
}
// Zero is meaningful for pool-only systems; missing or invalid is unknown.
if count, err := strconv.Atoi(strings.TrimSpace(fields["mdNumDisks"])); err == nil && count >= 0 {
storage.NumDisks = &count
}
indexes := collectUnraidIndexes(fields)
disks := make([]agentshost.UnraidDisk, 0, len(indexes))
for _, idx := range indexes {
+52
View File
@@ -2,6 +2,7 @@ package hostagent
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
@@ -384,3 +385,54 @@ func TestCollectUnraidStorageUsesResolvedMdcmd(t *testing.T) {
t.Fatalf("CollectUnraidStorage() = %#v, want populated storage", storage)
}
}
func TestParseUnraidStatusArrayDiskCount(t *testing.T) {
for _, tc := range []struct {
name, field string
want *int
}{
{"pool-only", "mdNumDisks=0", newIntForArrayCount(0)},
{"array", "mdNumDisks=3", newIntForArrayCount(3)},
{"missing", "", nil},
{"invalid", "mdNumDisks=unknown", nil},
{"negative", "mdNumDisks=-1", nil},
} {
t.Run(tc.name, func(t *testing.T) {
storage, err := parseUnraidStatusOutput("mdState=STARTED\n" + tc.field + "\n")
if err != nil {
t.Fatal(err)
}
if tc.want == nil {
if storage.NumDisks != nil {
t.Fatalf("unexpected count %d", *storage.NumDisks)
}
} else if storage.NumDisks == nil || *storage.NumDisks != *tc.want {
t.Fatalf("count = %v, want %d", storage.NumDisks, *tc.want)
}
if !storage.ArrayStarted {
t.Fatal("disk count must not change service state")
}
wire, err := json.Marshal(storage)
if err != nil {
t.Fatal(err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(wire, &fields); err != nil {
t.Fatal(err)
}
count, present := fields["numDisks"]
if present != (tc.want != nil) {
t.Fatalf("count presence in %s", wire)
}
if tc.want != nil {
var got int
if err := json.Unmarshal(count, &got); err != nil || got != *tc.want {
t.Fatalf("wire count = %s", count)
}
}
})
}
}
func newIntForArrayCount(n int) *int { return &n }
+1
View File
@@ -739,6 +739,7 @@ type HostUnraidStorage struct {
SyncAction string `json:"syncAction,omitempty"`
SyncProgress float64 `json:"syncProgress,omitempty"`
SyncErrors int64 `json:"syncErrors,omitempty"`
NumDisks *int `json:"numDisks,omitempty"` // nil means the agent did not report an array disk count.
NumProtected int `json:"numProtected,omitempty"`
NumDisabled int `json:"numDisabled,omitempty"`
NumInvalid int `json:"numInvalid,omitempty"`
+1
View File
@@ -3896,6 +3896,7 @@ func hostUnraidFromReadStateView(unraid *unifiedresources.HostUnraidMeta) *model
SyncAction: unraid.SyncAction,
SyncProgress: unraid.SyncProgress,
SyncErrors: unraid.SyncErrors,
NumDisks: unraid.NumDisks,
NumProtected: unraid.NumProtected,
NumDisabled: unraid.NumDisabled,
NumInvalid: unraid.NumInvalid,
+1
View File
@@ -3415,6 +3415,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
SyncAction: syncAction,
SyncProgress: syncProgress,
SyncErrors: report.Unraid.SyncErrors,
NumDisks: report.Unraid.NumDisks,
NumProtected: numProtected,
NumDisabled: numDisabled,
NumInvalid: numInvalid,
@@ -5654,3 +5654,34 @@ func TestMonitorConstructionWiresNotificationDeliveryReconciliation(t *testing.T
}
}
}
func TestApplyHostReportPreservesPoolOnlyUnraidCount(t *testing.T) {
monitor := &Monitor{
state: models.NewState(), alertManager: alerts.NewManager(),
hostTokenBindings: make(map[string]string), config: &config.Config{},
rateTracker: NewRateTracker(),
}
t.Cleanup(func() { monitor.alertManager.Stop() })
var report agentshost.Report
// Exercise the agent wire format: explicit zero must survive omitempty.
err := json.Unmarshal([]byte(`{"agent":{"id":"pool-only","version":"test"},"host":{"id":"pool-only","hostname":"pool-only"},"unraid":{"arrayStarted":true,"numDisks":0}}`), &report)
if err != nil {
t.Fatal(err)
}
report.Timestamp = time.Now().UTC()
host, err := monitor.ApplyHostReport(report, nil)
if err != nil {
t.Fatal(err)
}
if host.Unraid == nil || host.Unraid.NumDisks == nil || *host.Unraid.NumDisks != 0 {
t.Fatalf("lost explicit zero: %+v", host.Unraid)
}
record := unifiedresources.HostIngestRecord(host)
restored := hostUnraidFromReadStateView(record.Resource.Agent.Unraid)
if restored == nil || restored.NumDisks == nil || *restored.NumDisks != 0 {
t.Fatalf("canonical round trip lost explicit zero: %+v", restored)
}
if assessment := storagehealth.AssessUnraidStorage(*restored); assessment.Level != storagehealth.RiskHealthy {
t.Fatalf("pool-only host raised storage risk: %+v", assessment)
}
}
+3 -1
View File
@@ -299,7 +299,9 @@ func AssessUnraidStorage(storage models.HostUnraidStorage) Assessment {
}
}
if storage.ArrayStarted && !parityConfigured {
// Unraid can start pool services with no array. Only an explicit zero
// suppresses this warning, preserving behaviour for older agents.
if storage.ArrayStarted && !parityConfigured && (storage.NumDisks == nil || *storage.NumDisks != 0) {
addReason("unraid_no_parity", RiskWarning, "Unraid array is running without parity protection")
}
if storage.ArrayStarted && parityConfigured && !parityHealthy {
+35
View File
@@ -382,3 +382,38 @@ func TestAssessPBSDatastoreHighUsage(t *testing.T) {
t.Fatalf("unexpected reasons %+v", assessment.Reasons)
}
}
func TestAssessUnraidStoragePoolOnlyParity(t *testing.T) {
zero, three := 0, 3
for _, tc := range []struct {
name string
count *int
wantWarning bool
}{
{"explicit pool-only", &zero, false},
{"array without parity", &three, true},
{"legacy unknown count", nil, true},
} {
t.Run(tc.name, func(t *testing.T) {
assessment := AssessUnraidStorage(models.HostUnraidStorage{
ArrayStarted: true, NumDisks: tc.count,
Disks: []models.HostUnraidDisk{{Name: "cache", Role: "cache", Status: "online"}},
})
found := false
for _, reason := range assessment.Reasons {
if reason.Code == "unraid_no_parity" {
found = true
}
}
if found != tc.wantWarning {
t.Fatalf("no-parity warning = %v; reasons %+v", found, assessment.Reasons)
}
})
}
assessment := AssessUnraidStorage(models.HostUnraidStorage{
ArrayStarted: true, NumDisks: &zero, NumDisabled: 1,
})
if assessment.Level != RiskCritical {
t.Fatalf("zero count masked disk failure: %+v", assessment)
}
}
+2
View File
@@ -416,6 +416,7 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) {
SyncAction: host.Unraid.SyncAction,
SyncProgress: host.Unraid.SyncProgress,
SyncErrors: host.Unraid.SyncErrors,
NumDisks: host.Unraid.NumDisks,
NumProtected: host.Unraid.NumProtected,
NumDisabled: host.Unraid.NumDisabled,
NumInvalid: host.Unraid.NumInvalid,
@@ -726,6 +727,7 @@ func resourceFromHostUnraidStorage(host models.Host) (Resource, ResourceIdentity
ArrayState: host.Unraid.ArrayState,
SyncAction: host.Unraid.SyncAction,
SyncProgress: host.Unraid.SyncProgress,
NumDisks: host.Unraid.NumDisks,
NumProtected: host.Unraid.NumProtected,
NumDisabled: host.Unraid.NumDisabled,
NumInvalid: host.Unraid.NumInvalid,
@@ -1969,3 +1969,14 @@ func TestResourceFromGuestSetsProxmoxGuestKey(t *testing.T) {
t.Fatalf("keyless VM must not carry a guest key, got %q", keyless.ProxmoxGuestKey)
}
}
func TestUnraidArrayDiskCountAdapters(t *testing.T) {
for _, count := range []*int{nil, new(int), func() *int { n := 3; return &n }()} {
host := models.Host{ID: "pool-only", Hostname: "pool-only", Unraid: &models.HostUnraidStorage{NumDisks: count}}
resource, _ := resourceFromHost(host)
storage, _ := resourceFromHostUnraidStorage(host)
if !reflect.DeepEqual(resource.Agent.Unraid.NumDisks, count) || !reflect.DeepEqual(storage.Storage.NumDisks, count) {
t.Fatalf("adapters lost disk count %v", count)
}
}
}
@@ -554,3 +554,37 @@ func TestWearoutUnreportedSentinelIsNegativeOne(t *testing.T) {
t.Fatal("the unreported sentinel must not fall inside the real 0-100 reporting range")
}
}
func TestUnraidDiskCountJSONAndIdentity(t *testing.T) {
host := models.Host{ID: "pool-only", Hostname: "pool-only", Unraid: &models.HostUnraidStorage{}}
_, unknownIdentity := resourceFromHostUnraidStorage(host)
zero := 0
host.Unraid.NumDisks = &zero
resource, zeroIdentity := resourceFromHostUnraidStorage(host)
if unknownIdentity.MachineID != zeroIdentity.MachineID {
t.Fatal("disk count changed storage identity")
}
data, err := json.Marshal(resource.Storage)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"numDisks":0`) {
t.Fatalf("explicit zero lost: %s", data)
}
var restored StorageMeta
if err := json.Unmarshal(data, &restored); err != nil {
t.Fatal(err)
}
if restored.NumDisks == nil || *restored.NumDisks != 0 {
t.Fatal("zero lost in round trip")
}
host.Unraid.NumDisks = nil
resource, _ = resourceFromHostUnraidStorage(host)
data, err = json.Marshal(resource.Storage)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), `"numDisks"`) {
t.Fatalf("unknown count became known: %s", data)
}
}
+2
View File
@@ -454,6 +454,7 @@ type StorageMeta struct {
ArrayState string `json:"arrayState,omitempty"`
SyncAction string `json:"syncAction,omitempty"`
SyncProgress float64 `json:"syncProgress,omitempty"`
NumDisks *int `json:"numDisks,omitempty"` // nil means the agent did not report an array disk count.
NumProtected int `json:"numProtected,omitempty"`
NumDisabled int `json:"numDisabled,omitempty"`
NumInvalid int `json:"numInvalid,omitempty"`
@@ -748,6 +749,7 @@ type HostUnraidMeta struct {
SyncAction string `json:"syncAction,omitempty"`
SyncProgress float64 `json:"syncProgress,omitempty"`
SyncErrors int64 `json:"syncErrors,omitempty"`
NumDisks *int `json:"numDisks,omitempty"` // nil means the agent did not report an array disk count.
NumProtected int `json:"numProtected,omitempty"`
NumDisabled int `json:"numDisabled,omitempty"`
NumInvalid int `json:"numInvalid,omitempty"`
+1
View File
@@ -491,6 +491,7 @@ type UnraidStorage struct {
SyncAction string `json:"syncAction,omitempty"`
SyncProgress float64 `json:"syncProgress,omitempty"`
SyncErrors int64 `json:"syncErrors,omitempty"`
NumDisks *int `json:"numDisks,omitempty"` // nil means the agent did not report an array disk count.
NumProtected int `json:"numProtected,omitempty"`
NumDisabled int `json:"numDisabled,omitempty"`
NumInvalid int `json:"numInvalid,omitempty"`