From c05eed4306d77ce8d82c451c940908510486f45a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 28 May 2026 12:53:46 +0100 Subject: [PATCH] Fix TrueNAS unavailable SMART disk health Refs #1474 Separate TrueNAS native disk state from SMART health so null or unavailable smart_status projects as UNKNOWN without replacement risk, while explicit SMART failure and native failure states still alert. Proof: - go test ./internal/truenas ./internal/unifiedresources ./internal/storagehealth - npm --prefix frontend-modern test -- src/features/storageBackups/__tests__/diskPresentation.test.ts --- ...ga-truenas-smart-unavailable-2026-05-28.md | 51 +++++ docs/release-control/v6/internal/status.json | 6 + .../v6/internal/subsystems/monitoring.md | 11 + .../v6/internal/subsystems/registry.json | 1 + internal/truenas/client.go | 27 ++- internal/truenas/client_test.go | 54 ++++- internal/truenas/disk_health.go | 82 ++++++++ internal/truenas/provider.go | 39 +++- internal/truenas/provider_health_test.go | 117 +++++++++++ internal/truenas/provider_test.go | 198 ++++++++++++++++++ internal/truenas/types.go | 2 + 11 files changed, 567 insertions(+), 21 deletions(-) create mode 100644 docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-truenas-smart-unavailable-2026-05-28.md create mode 100644 internal/truenas/disk_health.go diff --git a/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-truenas-smart-unavailable-2026-05-28.md b/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-truenas-smart-unavailable-2026-05-28.md new file mode 100644 index 000000000..7e1932086 --- /dev/null +++ b/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-truenas-smart-unavailable-2026-05-28.md @@ -0,0 +1,51 @@ +# Known RC Issue Closure For GA TrueNAS SMART Unavailable Record + +- Date: `2026-05-28` +- Gate: `known-rc-issue-closure-for-ga` +- Lane: `L13` +- Issue: `https://github.com/rcourtman/Pulse/issues/1474` +- Result: `fixed-local-proof` + +## Context + +Issue `#1474` reports Pulse v6.0.0-rc.5 showing every TrueNAS passed-through +disk as `Replace Now - Disk reports health status FAILED` when TrueNAS cannot +read SMART data inside a Proxmox-hosted VM. The user-visible failure is not +that TrueNAS lacks SMART in this topology. The failure is that unavailable +SMART telemetry is projected as a replacement-required disk failure. + +The canonical v6 fix belongs in TrueNAS ingestion and physical-disk projection, +not in the frontend. The frontend should keep treating real `FAILED` health or +critical disk-risk evidence as replacement-required. + +## Disposition + +The v6 TrueNAS disk path now separates native disk state from explicit SMART +health: + +- REST `/disk` and RPC `disk.query` payloads parse `smart_status` into an + explicit disk-health channel. +- `smart_status: null`, empty, unknown, unavailable, `N/A`, and equivalent + unavailable values normalize to `UNKNOWN`. +- Explicit SMART failures still normalize to `FAILED` and produce a canonical + disk-health risk. +- Native TrueNAS disk states such as `FAULTED`, `FAILED`, `OFFLINE`, + `REMOVED`, and `UNAVAIL` still produce critical `truenas_disk_state` risk. +- Unknown or unavailable SMART telemetry no longer creates replacement-required + physical-disk risk or incidents. + +This keeps the root ownership in the provider and shared physical-disk contract +while preserving the frontend's existing replacement-warning semantics for real +failures. + +## Proof + +- `go test ./internal/truenas ./internal/unifiedresources ./internal/storagehealth` +- `npm --prefix frontend-modern test -- src/features/storageBackups/__tests__/diskPresentation.test.ts` + +## Outcome + +The v6 code path for the reported false replacement alert is fixed with local +automated proof. No public GitHub comment, issue retitle, label change, or issue +closure was made during this work. Public issue closure should wait for the +maintainer's normal issue hygiene or a current TrueNAS/proxmox topology retest. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index d45d9ae8f..253c71560 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -6018,6 +6018,12 @@ "kind": "file", "evidence_tier": "managed-runtime-exercise" }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-truenas-smart-unavailable-2026-05-28.md", + "kind": "file", + "evidence_tier": "test-proof" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-v5-129-delta-2026-05-01.md", diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 4f29e0ca5..60e7a3e8e 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -67,6 +67,10 @@ truth for live infrastructure data. 43. `internal/kubernetesagent/agent.go` 44. `pkg/agents/kubernetes/report.go` 45. `internal/monitoring/temperature.go` +46. `internal/truenas/client.go` +47. `internal/truenas/disk_health.go` +48. `internal/truenas/provider.go` +49. `internal/truenas/types.go` ## Shared Boundaries @@ -1055,6 +1059,13 @@ projection. When TrueNAS raises disk-local SMART alerts such as into the canonical physical-disk risk payload instead of leaving SMART failure state trapped in incident/status-only decorations that storage consumers do not read. +The same boundary owns TrueNAS `smart_status` normalization. `internal/truenas/client.go` +must parse REST and RPC SMART status separately from native disk state, and +`internal/truenas/disk_health.go` plus `internal/truenas/provider.go` must map +null, empty, missing, unknown, or unavailable SMART telemetry to canonical +`UNKNOWN` health with no replacement-required risk. Explicit SMART failure and +native failure states such as `FAULTED`, `FAILED`, `OFFLINE`, `REMOVED`, and +`UNAVAIL` must continue to produce canonical disk-health risk. That same boundary now also owns recent aggregate TrueNAS disk temperature history. `internal/truenas/client.go` must ingest `disk.temperature_agg`, and `internal/truenas/provider.go` must project the returned min/avg/max readings diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 44f158411..6e62c4450 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -3560,6 +3560,7 @@ "match_prefixes": [], "match_files": [ "internal/truenas/client.go", + "internal/truenas/disk_health.go", "internal/truenas/fixtures.go", "internal/truenas/provider.go", "internal/truenas/types.go" diff --git a/internal/truenas/client.go b/internal/truenas/client.go index 50e8e7183..d37a2d8d4 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -448,12 +448,15 @@ func (c *Client) getDisksREST(ctx context.Context) ([]Disk, error) { if diskID == "" { diskID = strings.TrimSpace(item.Name) } + health, healthPresent := diskSMARTHealthFromRaw(item.SmartStatus) disks = append(disks, Disk{ ID: diskID, Name: strings.TrimSpace(item.Name), Pool: strings.TrimSpace(item.Pool), Status: strings.TrimSpace(item.Status), + Health: health, + HealthStatusPresent: healthPresent, Model: strings.TrimSpace(item.Model), Serial: strings.TrimSpace(item.Serial), SizeBytes: item.Size, @@ -497,11 +500,14 @@ func (c *Client) disksFromMaps(ctx context.Context, response []map[string]any) ( diskID = strings.TrimSpace(readStringAny(item, "name", "devname")) } name := strings.TrimSpace(readStringAny(item, "name", "devname")) + health, healthPresent := diskSMARTHealthFromMap(item) disk := Disk{ ID: diskID, Name: name, Pool: strings.TrimSpace(readStringAny(item, "pool", "pool_name", "poolName")), Status: strings.TrimSpace(readStringAny(item, "status")), + Health: health, + HealthStatusPresent: healthPresent, Model: strings.TrimSpace(readStringAny(item, "model")), Serial: strings.TrimSpace(readStringAny(item, "serial")), SizeBytes: readInt64Any(item, "size", "size_bytes", "sizeBytes"), @@ -3699,16 +3705,17 @@ type datasetResponse struct { } type diskResponse struct { - Identifier string `json:"identifier"` - Name string `json:"name"` - Serial string `json:"serial"` - Size int64 `json:"size"` - Model string `json:"model"` - Type string `json:"type"` - Pool string `json:"pool"` - Bus string `json:"bus"` - Status string `json:"status"` - RotationRate int `json:"rotationrate"` + Identifier string `json:"identifier"` + Name string `json:"name"` + Serial string `json:"serial"` + Size int64 `json:"size"` + Model string `json:"model"` + Type string `json:"type"` + Pool string `json:"pool"` + Bus string `json:"bus"` + Status string `json:"status"` + SmartStatus json.RawMessage `json:"smart_status"` + RotationRate int `json:"rotationrate"` } type alertResponse struct { diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index 8c3a30d3f..1ef0b0d9d 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -412,7 +412,9 @@ func TestGetDisksUsesNativeDiskQueryShape(t *testing.T) { if !ok || len(params) != 2 { t.Fatalf("expected disk.query filters/options params, got %#v", request.Params) } - writeRPCResult(t, conn, request.ID, defaultRoutePayloadMaps(t, "/api/v2.0/disk")[:1]) + payload := defaultRoutePayloadMaps(t, "/api/v2.0/disk") + payload[0]["smart_status"] = "FAILED" + writeRPCResult(t, conn, request.ID, payload[:1]) case 2: if request.Method != "disk.temperature_agg" { t.Fatalf("expected disk.temperature_agg, got %q", request.Method) @@ -439,11 +441,59 @@ func TestGetDisksUsesNativeDiskQueryShape(t *testing.T) { if len(disks) != 1 { t.Fatalf("expected 1 disk, got %d", len(disks)) } - if disks[0].Name != "sda" || disks[0].Temperature != 34 || disks[0].TemperatureAggregate.MaxCelsius != 38 { + if disks[0].Name != "sda" || disks[0].Temperature != 34 || disks[0].TemperatureAggregate.MaxCelsius != 38 || disks[0].Health != "FAILED" || !disks[0].HealthStatusPresent { t.Fatalf("unexpected native disk mapping: %+v", disks[0]) } } +func TestGetDisksParsesRESTSmartStatus(t *testing.T) { + server := newMockServer(t, map[string]apiResponse{ + "/api/v2.0/disk": { + body: `[ + {"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE","smart_status":null}, + {"identifier":"{disk-2}","name":"sdb","serial":"SER-B","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE","smart_status":""}, + {"identifier":"{disk-3}","name":"sdc","serial":"SER-C","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE","smart_status":"FAILED"}, + {"identifier":"{disk-4}","name":"sdd","serial":"SER-D","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE"} + ]`, + }, + "/api/v2.0/disk/temperatures": { + body: `{}`, + }, + }, nil) + t.Cleanup(server.Close) + + client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"}) + disks, err := client.GetDisks(context.Background()) + if err != nil { + t.Fatalf("GetDisks() error = %v", err) + } + byName := make(map[string]Disk, len(disks)) + for _, disk := range disks { + byName[disk.Name] = disk + } + tests := []struct { + name string + wantHealth string + wantPresent bool + }{ + {name: "sda", wantHealth: "UNKNOWN", wantPresent: true}, + {name: "sdb", wantHealth: "UNKNOWN", wantPresent: true}, + {name: "sdc", wantHealth: "FAILED", wantPresent: true}, + {name: "sdd", wantHealth: "", wantPresent: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + disk, ok := byName[tt.name] + if !ok { + t.Fatalf("missing disk %q in %+v", tt.name, disks) + } + if disk.Health != tt.wantHealth || disk.HealthStatusPresent != tt.wantPresent { + t.Fatalf("disk %s health=(%q,%v), want (%q,%v)", tt.name, disk.Health, disk.HealthStatusPresent, tt.wantHealth, tt.wantPresent) + } + }) + } +} + func TestGetVMsParsesNativeVMQueryShape(t *testing.T) { server := newMockServerWithRPC(t, map[string]apiResponse{}, nil, func(t *testing.T, conn *websocket.Conn) { authReq := readRPCRequest(t, conn) diff --git a/internal/truenas/disk_health.go b/internal/truenas/disk_health.go new file mode 100644 index 000000000..4cb67b47d --- /dev/null +++ b/internal/truenas/disk_health.go @@ -0,0 +1,82 @@ +package truenas + +import ( + "encoding/json" + "fmt" + "strings" +) + +func diskSMARTHealthFromRaw(raw json.RawMessage) (string, bool) { + if len(raw) == 0 { + return "", false + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return "UNKNOWN", true + } + return normalizeExplicitDiskHealth(value), true +} + +func diskSMARTHealthFromMap(record map[string]any) (string, bool) { + if record == nil { + return "", false + } + for _, key := range []string{"smart_status", "smartStatus", "smartstatus"} { + value, ok := record[key] + if ok { + return normalizeExplicitDiskHealth(value), true + } + } + return "", false +} + +func normalizeExplicitDiskHealth(value any) string { + switch typed := value.(type) { + case nil: + return "UNKNOWN" + case string: + return normalizeDiskHealthText(typed) + case bool: + if typed { + return "PASSED" + } + return "FAILED" + case json.Number: + return normalizeDiskHealthText(typed.String()) + case float64: + return normalizeDiskHealthText(fmt.Sprintf("%g", typed)) + case int: + return normalizeDiskHealthText(fmt.Sprintf("%d", typed)) + case int64: + return normalizeDiskHealthText(fmt.Sprintf("%d", typed)) + case map[string]any: + for _, key := range []string{"passed", "healthy"} { + if nested, ok := typed[key]; ok { + return normalizeExplicitDiskHealth(nested) + } + } + for _, key := range []string{"status", "health", "value", "rawvalue", "parsed", "raw"} { + if nested, ok := typed[key]; ok { + return normalizeExplicitDiskHealth(nested) + } + } + return "UNKNOWN" + default: + return normalizeDiskHealthText(fmt.Sprint(typed)) + } +} + +func normalizeDiskHealthText(health string) string { + switch strings.ToUpper(strings.TrimSpace(health)) { + case "PASSED", "PASS", "OK", "ONLINE", "HEALTHY", "TRUE", "1": + return "PASSED" + case "FAILED", "FAIL", "FAILING", "UNHEALTHY", "FALSE", "0": + return "FAILED" + case "DEGRADED": + return "DEGRADED" + case "", "UNKNOWN", "UNAVAILABLE", "N/A", "NA", "NOT AVAILABLE", "SMART UNAVAILABLE", "UNSUPPORTED", "NULL", "NONE": + return "UNKNOWN" + default: + return "UNKNOWN" + } +} diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index 833123037..a31c0fe2a 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -1156,7 +1156,7 @@ func assessDisk(disk Disk) storagehealth.Assessment { Wearout: -1, }) - stateUpper := strings.ToUpper(strings.TrimSpace(disk.Status)) + stateUpper := normalizedDiskStatus(disk) stateAssessment := storagehealth.Assessment{Level: storagehealth.RiskHealthy} switch stateUpper { case "DEGRADED": @@ -1168,7 +1168,7 @@ func assessDisk(disk Disk) storagehealth.Assessment { Summary: fmt.Sprintf("TrueNAS disk %s is DEGRADED", strings.TrimSpace(disk.Name)), }}, } - case "FAULTED", "OFFLINE", "REMOVED", "UNAVAIL": + case "FAULTED", "FAILED", "OFFLINE", "REMOVED", "UNAVAIL": stateAssessment = storagehealth.Assessment{ Level: storagehealth.RiskCritical, Reasons: []storagehealth.Reason{{ @@ -1935,12 +1935,12 @@ func dockerNetworksFromTrueNASApp(app App) []unifiedresources.DockerNetworkMeta } func statusFromDisk(disk Disk) unifiedresources.ResourceStatus { - switch strings.ToUpper(strings.TrimSpace(disk.Status)) { - case "ONLINE": + switch normalizedDiskStatus(disk) { + case "ONLINE", "OK", "PASSED", "HEALTHY": return unifiedresources.StatusOnline case "DEGRADED": return unifiedresources.StatusWarning - case "FAULTED", "OFFLINE", "REMOVED", "UNAVAIL": + case "FAULTED", "FAILED", "OFFLINE", "REMOVED", "UNAVAIL": return unifiedresources.StatusOffline default: return unifiedresources.StatusUnknown @@ -1948,18 +1948,28 @@ func statusFromDisk(disk Disk) unifiedresources.ResourceStatus { } func healthFromDisk(disk Disk) string { - switch strings.ToUpper(strings.TrimSpace(disk.Status)) { - case "ONLINE": + if health, ok := explicitDiskHealth(disk); ok { + return health + } + switch normalizedDiskStatus(disk) { + case "ONLINE", "OK", "PASSED", "HEALTHY": return "PASSED" case "DEGRADED": return "DEGRADED" - default: + case "FAULTED", "FAILED", "OFFLINE", "REMOVED", "UNAVAIL": return "FAILED" + default: + // TrueNAS can omit disk health/status when SMART data is unavailable. + // Missing or provider-unknown telemetry is not a disk failure signal. + return "UNKNOWN" } } func healthForAssessment(disk Disk) string { - switch strings.ToUpper(strings.TrimSpace(disk.Status)) { + if health, ok := explicitDiskHealth(disk); ok { + return health + } + switch normalizedDiskStatus(disk) { case "DEGRADED": return "" default: @@ -1967,6 +1977,17 @@ func healthForAssessment(disk Disk) string { } } +func normalizedDiskStatus(disk Disk) string { + return strings.ToUpper(strings.TrimSpace(disk.Status)) +} + +func explicitDiskHealth(disk Disk) (string, bool) { + if !disk.HealthStatusPresent && strings.TrimSpace(disk.Health) == "" { + return "", false + } + return normalizeExplicitDiskHealth(disk.Health), true +} + func rpmFromDisk(disk Disk) int { if disk.Rotational { return 7200 diff --git a/internal/truenas/provider_health_test.go b/internal/truenas/provider_health_test.go index 868fe2948..cc191e48d 100644 --- a/internal/truenas/provider_health_test.go +++ b/internal/truenas/provider_health_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/rcourtman/pulse-go-rewrite/internal/storagehealth" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -60,6 +61,122 @@ func TestStatusFromDatasetStates(t *testing.T) { } } +func TestDiskHealthMappingDistinguishesUnavailableTelemetryFromFailure(t *testing.T) { + tests := []struct { + name string + status string + health string + hasHealth bool + wantHealth string + wantStatus unifiedresources.ResourceStatus + wantLevel storagehealth.RiskLevel + }{ + { + name: "online passed", + status: "ONLINE", + wantHealth: "PASSED", + wantStatus: unifiedresources.StatusOnline, + wantLevel: storagehealth.RiskHealthy, + }, + { + name: "online explicit smart unavailable", + status: "ONLINE", + health: "UNAVAILABLE", + hasHealth: true, + wantHealth: "UNKNOWN", + wantStatus: unifiedresources.StatusOnline, + wantLevel: storagehealth.RiskHealthy, + }, + { + name: "online explicit smart failed", + status: "ONLINE", + health: "FAILED", + hasHealth: true, + wantHealth: "FAILED", + wantStatus: unifiedresources.StatusOnline, + wantLevel: storagehealth.RiskCritical, + }, + { + name: "degraded warning", + status: "DEGRADED", + wantHealth: "DEGRADED", + wantStatus: unifiedresources.StatusWarning, + wantLevel: storagehealth.RiskWarning, + }, + { + name: "failed state", + status: "FAILED", + wantHealth: "FAILED", + wantStatus: unifiedresources.StatusOffline, + wantLevel: storagehealth.RiskCritical, + }, + { + name: "faulted state", + status: "FAULTED", + wantHealth: "FAILED", + wantStatus: unifiedresources.StatusOffline, + wantLevel: storagehealth.RiskCritical, + }, + { + name: "zfs unavailable state", + status: "UNAVAIL", + wantHealth: "FAILED", + wantStatus: unifiedresources.StatusOffline, + wantLevel: storagehealth.RiskCritical, + }, + { + name: "null status decoded as empty", + status: "", + wantHealth: "UNKNOWN", + wantStatus: unifiedresources.StatusUnknown, + wantLevel: storagehealth.RiskHealthy, + }, + { + name: "unknown status", + status: "UNKNOWN", + wantHealth: "UNKNOWN", + wantStatus: unifiedresources.StatusUnknown, + wantLevel: storagehealth.RiskHealthy, + }, + { + name: "smart unavailable", + status: "UNAVAILABLE", + wantHealth: "UNKNOWN", + wantStatus: unifiedresources.StatusUnknown, + wantLevel: storagehealth.RiskHealthy, + }, + { + name: "not available", + status: "N/A", + wantHealth: "UNKNOWN", + wantStatus: unifiedresources.StatusUnknown, + wantLevel: storagehealth.RiskHealthy, + }, + { + name: "provider-specific unavailable text", + status: "SMART unavailable", + wantHealth: "UNKNOWN", + wantStatus: unifiedresources.StatusUnknown, + wantLevel: storagehealth.RiskHealthy, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + disk := Disk{Name: "sda", Status: tt.status, Health: tt.health, HealthStatusPresent: tt.hasHealth, Model: "Generic SATA"} + if got := healthFromDisk(disk); got != tt.wantHealth { + t.Fatalf("healthFromDisk(%q) = %q, want %q", tt.status, got, tt.wantHealth) + } + if got := statusFromDisk(disk); got != tt.wantStatus { + t.Fatalf("statusFromDisk(%q) = %q, want %q", tt.status, got, tt.wantStatus) + } + if got := assessDisk(disk).Level; got != tt.wantLevel { + t.Fatalf("assessDisk(%q).Level = %q, want %q", tt.status, got, tt.wantLevel) + } + }) + } +} + func TestDatasetStateTag(t *testing.T) { tests := []struct { name string diff --git a/internal/truenas/provider_test.go b/internal/truenas/provider_test.go index c81697481..d0607aac2 100644 --- a/internal/truenas/provider_test.go +++ b/internal/truenas/provider_test.go @@ -887,6 +887,167 @@ func TestRecordsElevateOnlineDiskWhenTemperatureCritical(t *testing.T) { } } +func TestRecordsProjectUnavailableDiskTelemetryAsUnknownWithoutRisk(t *testing.T) { + for _, status := range []string{"", "UNKNOWN", "UNAVAILABLE", "N/A", "SMART unavailable"} { + t.Run(status, func(t *testing.T) { + record := truenasDiskRecordForStatus(t, status) + if record.Resource.Status != unifiedresources.StatusUnknown { + t.Fatalf("status %q projected resource status %q, want %q", status, record.Resource.Status, unifiedresources.StatusUnknown) + } + if record.Resource.PhysicalDisk == nil { + t.Fatal("expected physical disk metadata") + } + if record.Resource.PhysicalDisk.Health != "UNKNOWN" { + t.Fatalf("status %q projected health %q, want UNKNOWN", status, record.Resource.PhysicalDisk.Health) + } + if record.Resource.PhysicalDisk.Risk != nil { + t.Fatalf("status %q should not project replacement risk, got %+v", status, record.Resource.PhysicalDisk.Risk) + } + if len(record.Resource.Incidents) != 0 { + t.Fatalf("status %q should not project incidents, got %+v", status, record.Resource.Incidents) + } + }) + } +} + +func TestRecordsProjectExplicitUnavailableSMARTAsUnknownWithoutRisk(t *testing.T) { + record := truenasDiskRecordForDisk(t, Disk{ + ID: "disk-sda", + Name: "sda", + Pool: "tank", + Status: "ONLINE", + Health: "UNAVAILABLE", + HealthStatusPresent: true, + Model: "Generic SATA", + Serial: "SER-SDA", + SizeBytes: 1_000_000, + Transport: "sata", + Rotational: true, + Temperature: 34, + }) + if record.Resource.Status != unifiedresources.StatusUnknown { + t.Fatalf("projected resource status %q, want %q", record.Resource.Status, unifiedresources.StatusUnknown) + } + if record.Resource.PhysicalDisk == nil { + t.Fatal("expected physical disk metadata") + } + if record.Resource.PhysicalDisk.Health != "UNKNOWN" { + t.Fatalf("projected health %q, want UNKNOWN", record.Resource.PhysicalDisk.Health) + } + if record.Resource.PhysicalDisk.Risk != nil { + t.Fatalf("unavailable SMART should not project replacement risk, got %+v", record.Resource.PhysicalDisk.Risk) + } + if len(record.Resource.Incidents) != 0 { + t.Fatalf("unavailable SMART should not project incidents, got %+v", record.Resource.Incidents) + } +} + +func TestRecordsProjectExplicitFailedSMARTAsReplacementRisk(t *testing.T) { + record := truenasDiskRecordForDisk(t, Disk{ + ID: "disk-sda", + Name: "sda", + Pool: "tank", + Status: "ONLINE", + Health: "FAILED", + HealthStatusPresent: true, + Model: "Generic SATA", + Serial: "SER-SDA", + SizeBytes: 1_000_000, + Transport: "sata", + Rotational: true, + Temperature: 34, + }) + if record.Resource.Status != unifiedresources.StatusWarning { + t.Fatalf("projected resource status %q, want %q", record.Resource.Status, unifiedresources.StatusWarning) + } + if record.Resource.PhysicalDisk == nil { + t.Fatal("expected physical disk metadata") + } + if record.Resource.PhysicalDisk.Health != "FAILED" { + t.Fatalf("projected health %q, want FAILED", record.Resource.PhysicalDisk.Health) + } + if record.Resource.PhysicalDisk.Risk == nil { + t.Fatal("explicit failed SMART should project replacement risk") + } + if !containsRiskReason(record.Resource.PhysicalDisk.Risk.Reasons, "health_status") { + t.Fatalf("missing health_status risk, got %+v", record.Resource.PhysicalDisk.Risk.Reasons) + } +} + +func TestRecordsPreservePassedDegradedAndFailedDiskProjection(t *testing.T) { + tests := []struct { + name string + status string + wantHealth string + wantStatus unifiedresources.ResourceStatus + wantRiskLevel storagehealth.RiskLevel + wantRiskReason string + wantRiskNil bool + }{ + { + name: "passed", + status: "ONLINE", + wantHealth: "PASSED", + wantStatus: unifiedresources.StatusOnline, + wantRiskNil: true, + }, + { + name: "degraded", + status: "DEGRADED", + wantHealth: "DEGRADED", + wantStatus: unifiedresources.StatusWarning, + wantRiskLevel: storagehealth.RiskWarning, + wantRiskReason: "truenas_disk_state", + }, + { + name: "failed", + status: "FAILED", + wantHealth: "FAILED", + wantStatus: unifiedresources.StatusWarning, + wantRiskLevel: storagehealth.RiskCritical, + wantRiskReason: "truenas_disk_state", + }, + { + name: "faulted", + status: "FAULTED", + wantHealth: "FAILED", + wantStatus: unifiedresources.StatusWarning, + wantRiskLevel: storagehealth.RiskCritical, + wantRiskReason: "truenas_disk_state", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record := truenasDiskRecordForStatus(t, tt.status) + if record.Resource.Status != tt.wantStatus { + t.Fatalf("status %q projected resource status %q, want %q", tt.status, record.Resource.Status, tt.wantStatus) + } + if record.Resource.PhysicalDisk == nil { + t.Fatal("expected physical disk metadata") + } + if record.Resource.PhysicalDisk.Health != tt.wantHealth { + t.Fatalf("status %q projected health %q, want %q", tt.status, record.Resource.PhysicalDisk.Health, tt.wantHealth) + } + if tt.wantRiskNil { + if record.Resource.PhysicalDisk.Risk != nil { + t.Fatalf("status %q should not project risk, got %+v", tt.status, record.Resource.PhysicalDisk.Risk) + } + return + } + if record.Resource.PhysicalDisk.Risk == nil { + t.Fatalf("status %q should project risk", tt.status) + } + if record.Resource.PhysicalDisk.Risk.Level != tt.wantRiskLevel { + t.Fatalf("status %q projected risk level %q, want %q", tt.status, record.Resource.PhysicalDisk.Risk.Level, tt.wantRiskLevel) + } + if !containsRiskReason(record.Resource.PhysicalDisk.Risk.Reasons, tt.wantRiskReason) { + t.Fatalf("status %q missing risk reason %q, got %+v", tt.status, tt.wantRiskReason, record.Resource.PhysicalDisk.Risk.Reasons) + } + }) + } +} + func TestRecordsProjectDiskTemperatureAggregatesIntoCanonicalMetadata(t *testing.T) { previous := IsFeatureEnabled() SetFeatureEnabled(true) @@ -920,6 +1081,43 @@ func TestRecordsProjectDiskTemperatureAggregatesIntoCanonicalMetadata(t *testing t.Fatal("expected sda physical disk record") } +func truenasDiskRecordForStatus(t *testing.T, status string) unifiedresources.IngestRecord { + t.Helper() + + return truenasDiskRecordForDisk(t, Disk{ + ID: "disk-sda", + Name: "sda", + Pool: "tank", + Status: status, + Model: "Generic SATA", + Serial: "SER-SDA", + SizeBytes: 1_000_000, + Transport: "sata", + Rotational: true, + Temperature: 34, + }) +} + +func truenasDiskRecordForDisk(t *testing.T, disk Disk) unifiedresources.IngestRecord { + t.Helper() + + records := FixtureRecords(FixtureSnapshot{ + CollectedAt: time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC), + System: SystemInfo{ + Hostname: "truenas-test", + Healthy: true, + }, + Disks: []Disk{disk}, + }) + for _, record := range records { + if record.Resource.Type == unifiedresources.ResourceTypePhysicalDisk { + return record + } + } + t.Fatal("expected physical disk record") + return unifiedresources.IngestRecord{} +} + func TestProviderPhysicalDiskTemperatureHistoryUsesCanonicalMetricIDs(t *testing.T) { fixtures := DefaultFixtures() now := time.Date(2026, 3, 29, 20, 0, 0, 0, time.UTC) diff --git a/internal/truenas/types.go b/internal/truenas/types.go index 27abd6815..696550217 100644 --- a/internal/truenas/types.go +++ b/internal/truenas/types.go @@ -66,6 +66,8 @@ type Disk struct { Name string Pool string Status string + Health string + HealthStatusPresent bool Model string Serial string SizeBytes int64