From 2b9b1ec573bc5f3b23fa4fdf6df4c10af2d51f4e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 15 May 2026 14:21:39 +0100 Subject: [PATCH] Fix TrueNAS system info buildtime decoding Fixes #1469 --- .../v6/internal/subsystems/monitoring.md | 6 ++ internal/truenas/client.go | 63 ++++++++++++++++--- internal/truenas/client_test.go | 18 ++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 77240b9b3..8ffa688cb 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -767,6 +767,12 @@ stopping at disconnected toast notifications. That same runtime owner also defines the feature-default contract for TrueNAS: the API-backed integration is on by default, and `PULSE_ENABLE_TRUENAS` is an explicit opt-out switch rather than a required bootstrap toggle. +That same TrueNAS monitoring boundary owns system identity compatibility for +`/system/info`. `internal/truenas/client.go` must tolerate provider-version +drift in non-identity display fields such as `buildtime`, including structured +date/value wrappers, and still preserve the canonical hostname, version, +machine ID, capacity, and poll-health path instead of failing connection tests +or background refreshes during JSON decoding. That same monitoring boundary now also owns live TrueNAS disk temperatures. `internal/truenas/client.go` and `internal/truenas/provider.go` must ingest `disk.temperatures` from the TrueNAS API, fall back to modern diff --git a/internal/truenas/client.go b/internal/truenas/client.go index 9af5d2f06..8f71cba9e 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -158,7 +158,7 @@ func (c *Client) GetSystemInfo(ctx context.Context) (*SystemInfo, error) { machineID = strings.TrimSpace(response.Hostname) } - build := strings.TrimSpace(response.BuildTime) + build := strings.TrimSpace(response.BuildTime.String()) if build == "" { build = strings.TrimSpace(response.Version) } @@ -2868,15 +2868,30 @@ func normalizeFingerprint(fingerprint string) (string, error) { } type systemInfoResponse struct { - Hostname string `json:"hostname"` - Version string `json:"version"` - BuildTime string `json:"buildtime"` - UptimeSeconds int64 `json:"uptime_seconds"` - SystemSerial string `json:"system_serial"` - SystemVendor string `json:"system_manufacturer"` - Cores int `json:"cores"` - PhysicalCores int `json:"physical_cores"` - Physmem int64 `json:"physmem"` + Hostname string `json:"hostname"` + Version string `json:"version"` + BuildTime textResponseField `json:"buildtime"` + UptimeSeconds int64 `json:"uptime_seconds"` + SystemSerial string `json:"system_serial"` + SystemVendor string `json:"system_manufacturer"` + Cores int `json:"cores"` + PhysicalCores int `json:"physical_cores"` + Physmem int64 `json:"physmem"` +} + +type textResponseField string + +func (f textResponseField) String() string { + return string(f) +} + +func (f *textResponseField) UnmarshalJSON(data []byte) error { + value, err := parseTextResponseField(data) + if err != nil { + return err + } + *f = textResponseField(value) + return nil } type poolResponse struct { @@ -3068,6 +3083,34 @@ func readStringAny(record map[string]any, keys ...string) string { return "" } +func parseTextResponseField(raw json.RawMessage) (string, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return "", nil + } + + var decoded any + decoder := json.NewDecoder(bytes.NewReader(trimmed)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return "", err + } + return textFromDecodedAny(decoded), nil +} + +func textFromDecodedAny(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return strings.TrimSpace(typed.String()) + case map[string]any: + return readStringAny(typed, "rawvalue", "value", "parsed", "$date", "date", "datetime", "text", "string") + default: + return "" + } +} + func readStringSliceAny(record map[string]any, keys ...string) []string { if record == nil { return nil diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index 006c3127a..0e0798b1d 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -172,6 +172,24 @@ func TestClientAuthHeaderBasic(t *testing.T) { } } +func TestGetSystemInfoAcceptsStructuredBuildTime(t *testing.T) { + server := newMockServer(t, map[string]apiResponse{ + "/api/v2.0/system/info": { + body: `{"hostname":"nas","version":"TrueNAS-SCALE-25.10.3.1","buildtime":{"$date":"2026-05-14T18:24:01+02:00"},"uptime_seconds":1}`, + }, + }, nil) + t.Cleanup(server.Close) + + client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "test-key"}) + system, err := client.GetSystemInfo(context.Background()) + if err != nil { + t.Fatalf("GetSystemInfo() error = %v", err) + } + if system.Build != "2026-05-14T18:24:01+02:00" { + t.Fatalf("Build = %q, want structured buildtime date", system.Build) + } +} + func TestTestConnectionSuccessAndFailure(t *testing.T) { successServer := newMockServer(t, map[string]apiResponse{ "/api/v2.0/system/info": {body: `{"hostname":"nas","version":"v","buildtime":"b","uptime_seconds":1}`},