From 0fa1cf5fc1d87f1162ddd3e136bd11b9ea573b1c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 15 Apr 2026 16:46:20 +0100 Subject: [PATCH] Normalize legacy Unraid raw statuses Refs #1400 --- .../v6/internal/subsystems/monitoring.md | 7 +- internal/monitoring/monitor_agents.go | 37 +++++++++- .../monitoring/monitor_host_agents_test.go | 67 +++++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 1e8227c83..51ffc57c3 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -133,7 +133,12 @@ it exposes a direct CPU percentage and otherwise fall back to Podman's wall-clock delta semantics rather than Docker's multi-core normalization, and Proxmox Ceph status decoding must accept manager standby entries as either bare names or structured objects so collector payload variations do not break the -canonical monitoring path. +canonical monitoring path. That same compatibility boundary also owns legacy +Unraid raw-status normalization at host-agent ingest: when older agents send +`rawStatus` without the newer normalized `status`, `internal/monitoring/monitor_agents.go` +must derive the canonical disk status before storage-risk assessment runs so +v5 aggregate counters do not override clearly healthy per-disk state during v6 +compatibility operation. VMware vSphere now also has a locked phase-1 ingestion boundary under this lane. The admitted direction is vCenter-only in phase 1, and monitoring must stay API-first through the diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 2a69a3a79..76508e8a9 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -1740,12 +1740,18 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. if report.Unraid != nil { disks := make([]models.HostUnraidDisk, 0, len(report.Unraid.Disks)) for _, disk := range report.Unraid.Disks { + device := strings.TrimSpace(disk.Device) + rawStatus := strings.TrimSpace(disk.RawStatus) + status := strings.TrimSpace(disk.Status) + if status == "" { + status = normalizeLegacyUnraidDiskStatus(rawStatus, device) + } disks = append(disks, models.HostUnraidDisk{ Name: strings.TrimSpace(disk.Name), - Device: strings.TrimSpace(disk.Device), + Device: device, Role: strings.TrimSpace(disk.Role), - Status: strings.TrimSpace(disk.Status), - RawStatus: strings.TrimSpace(disk.RawStatus), + Status: status, + RawStatus: rawStatus, Serial: strings.TrimSpace(disk.Serial), Filesystem: strings.TrimSpace(disk.Filesystem), SizeBytes: disk.SizeBytes, @@ -2063,6 +2069,31 @@ func normalizeHostDiskDevice(device string) string { return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(device), "/dev/")) } +func normalizeLegacyUnraidDiskStatus(rawStatus, device string) string { + status := strings.ToUpper(strings.TrimSpace(rawStatus)) + switch { + case status == "": + if strings.TrimSpace(device) != "" { + return "online" + } + return "" + case strings.Contains(status, "DISK_OK") || status == "OK": + return "online" + case strings.Contains(status, "DISK_DSBL") || strings.Contains(status, "DISABLED"): + return "disabled" + case strings.Contains(status, "DISK_NP") || strings.Contains(status, "MISSING") || strings.Contains(status, "NOT_INSTALLED"): + return "missing" + case strings.Contains(status, "DISK_INVALID") || strings.Contains(status, "INVALID"): + return "invalid" + case strings.Contains(status, "DISK_WRONG") || strings.Contains(status, "WRONG"): + return "wrong" + case strings.Contains(status, "DISK_ERROR") || strings.Contains(status, "ERROR"): + return "error" + default: + return strings.ToLower(status) + } +} + func hostDiskIOMetricResourceID(host models.Host, io models.DiskIO) string { device := normalizeHostDiskDevice(io.Device) if device == "" { diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 416ea2c12..5a9d842bf 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -8,6 +8,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/storagehealth" agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" ) @@ -618,6 +619,72 @@ func TestApplyHostReportStoresUnraidTopology(t *testing.T) { } } +func TestApplyHostReportNormalizesLegacyUnraidRawStatuses(t *testing.T) { + t.Helper() + + monitor := &Monitor{ + state: models.NewState(), + alertManager: alerts.NewManager(), + hostTokenBindings: make(map[string]string), + config: &config.Config{}, + rateTracker: NewRateTracker(), + } + t.Cleanup(func() { monitor.alertManager.Stop() }) + + report := agentshost.Report{ + Agent: agentshost.AgentInfo{ + ID: "agent-tower-legacy", + Version: "5.1.27", + IntervalSeconds: 30, + }, + Host: agentshost.HostInfo{ + ID: "machine-tower-legacy", + Hostname: "tower", + MachineID: "machine-tower-legacy", + }, + Metrics: agentshost.Metrics{ + Memory: agentshost.MemoryMetric{TotalBytes: 1024, UsedBytes: 512, FreeBytes: 512, Usage: 50}, + }, + Unraid: &agentshost.UnraidStorage{ + ArrayStarted: true, + ArrayState: "STARTED", + SyncAction: "check", + SyncProgress: 55, + NumDisabled: 1, + NumInvalid: 1, + Disks: []agentshost.UnraidDisk{ + {Name: "parity", Device: "/dev/sdb", Role: "parity", RawStatus: "DISK_OK", Serial: "SERIAL-PARITY"}, + {Name: "disk1", Device: "/dev/sdc", Role: "data", RawStatus: "DISK_OK", Serial: "SERIAL-DATA"}, + }, + }, + Timestamp: time.Now().UTC(), + } + + host, err := monitor.ApplyHostReport(report, nil) + if err != nil { + t.Fatalf("ApplyHostReport: %v", err) + } + + if host.Unraid == nil { + t.Fatal("expected unraid topology on host") + } + for _, disk := range host.Unraid.Disks { + if disk.Status != "online" { + t.Fatalf("expected normalized online status from rawStatus, got %+v", host.Unraid.Disks) + } + } + + assessment := storagehealth.AssessUnraidStorage(*host.Unraid) + if assessment.Level != storagehealth.RiskWarning { + t.Fatalf("assessment level = %q, want %q", assessment.Level, storagehealth.RiskWarning) + } + for _, reason := range assessment.Reasons { + if reason.Code == "unraid_disabled_disks" || reason.Code == "unraid_invalid_disks" { + t.Fatalf("unexpected aggregate-count reason after raw-status normalization: %+v", assessment.Reasons) + } + } +} + func TestApplyHostReportFiltersVendorManagedSystemRAIDArrays(t *testing.T) { t.Helper()