mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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
This commit is contained in:
parent
d8389d9adc
commit
c05eed4306
11 changed files with 567 additions and 21 deletions
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
82
internal/truenas/disk_health.go
Normal file
82
internal/truenas/disk_health.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ type Disk struct {
|
|||
Name string
|
||||
Pool string
|
||||
Status string
|
||||
Health string
|
||||
HealthStatusPresent bool
|
||||
Model string
|
||||
Serial string
|
||||
SizeBytes int64
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue