Cover incomplete TrueNAS pool capacity across snapshot ingestion

Exercise the real TLS legacy client, provider and registry with absent, partial and malformed pool values; retain measured empty and full pools, independent dataset readings and pool health. Add exact JSON-RPC integer and incomplete host aggregate controls.

Contract-Neutral: Add regression controls for existing pool-capacity semantics without changing runtime or contracts.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-05 22:49:16 +01:00
parent 7252258947
commit c5104fbb64
+179
View File
@@ -0,0 +1,179 @@
package truenas
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/websocket"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// These are declared protocol controls, not an alternative native CORE
// capacity source. #2510's absent pool bytes must still remain unavailable.
func TestTrueNASPoolCapacitySnapshot(t *testing.T) {
enableTrueNASFeatureFlag(t)
for _, tc := range []struct {
name string
fields string
known bool
used int64
}{
{"flat", `,"size":1000,"allocated":400,"free":600`, true, 400},
{"empty", `,"size":1000,"allocated":0,"free":1000`, true, 0},
{"full", `,"size":1000,"allocated":1000,"free":0`, true, 1000},
{"numeric strings", `,"size":"1000","allocated":"400","free":"600"`, true, 400},
{"derived free from pool bytes", `,"size":1000,"allocated":400`, true, 400},
{"no capacity", ``, false, 0},
{"size alone", `,"size":1000`, false, 0},
{"null usage", `,"size":1000,"allocated":null,"free":600`, false, 0},
{"unknown usage", `,"size":1000,"allocated":"unknown","free":600`, false, 0},
{"object usage", `,"size":1000,"allocated":{"unexpected":400},"free":600`, false, 0},
{"fractional usage", `,"size":1000,"allocated":0.5,"free":999.5`, false, 0},
{"negative usage", `,"size":1000,"allocated":-1,"free":1001`, false, 0},
{"over capacity", `,"size":1000,"allocated":1001,"free":0`, false, 0},
{"contradictory free", `,"size":1000,"allocated":400,"free":999`, false, 0},
{"unknown free", `,"size":1000,"allocated":400,"free":"unknown"`, false, 0},
{"overflow", `,"size":9223372036854775808,"allocated":400,"free":600`, false, 0},
{"nested hypothesis not a REST source", `,"properties":{"size":{"parsed":1000},"allocated":{"parsed":400},"free":{"parsed":600}}`, false, 0},
} {
t.Run(tc.name, func(t *testing.T) {
responses := defaultAPIResponses()
responses["/api/v2.0/system/info"] = apiResponse{body: `{"hostname":"core","version":"TrueNAS-13.0-U6.8","cores":4,"physmem":4096}`}
responses["/api/v2.0/pool"] = apiResponse{body: `[{"id":1,"guid":"pool-guid","name":"tank","status":"DEGRADED","scan":{"function":"SCRUB","state":"FINISHED"},"topology":{"data":[{"type":"DISK","disk":"sda","status":"DEGRADED"}]}` + tc.fields + `}]`}
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/current" {
http.NotFound(w, r)
return
}
response, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(response.body))
}))
t.Cleanup(server.Close)
client := protocolFixtureClient(t, server.URL, ClientConfig{APIKey: "synthetic-key"})
t.Cleanup(client.Close)
snapshot, err := client.FetchSnapshot(context.Background())
if err != nil {
t.Fatalf("capacity metadata discarded the whole snapshot: %v", err)
}
if len(snapshot.Pools) != 1 || len(snapshot.Datasets) != 1 || len(snapshot.Disks) != 2 || len(snapshot.Alerts) != 1 {
t.Fatal("capacity metadata lost unrelated inventory")
}
pool := snapshot.Pools[0]
if pool.GUID != "pool-guid" || pool.Status != "DEGRADED" || pool.Scan == nil || pool.Scan.Function != "SCRUB" || len(pool.DiskMembers) != 1 {
t.Fatalf("capacity metadata lost pool health/identity/topology/scan: %+v", pool)
}
if tc.known {
if pool.TotalBytes != 1000 || pool.UsedBytes != tc.used || pool.FreeBytes != 1000-tc.used {
t.Fatalf("capacity = %d/%d/%d", pool.TotalBytes, pool.UsedBytes, pool.FreeBytes)
}
} else if pool.TotalBytes != 0 || pool.UsedBytes != 0 || pool.FreeBytes != 0 {
t.Fatalf("incomplete/invalid capacity became a usable subtotal: %+v", pool)
}
registry := unifiedresources.NewRegistry(unifiedresources.NewMemoryStore())
registry.IngestRecords(unifiedresources.SourceTrueNAS, NewProvider(*snapshot).Records())
foundPool, foundDataset, foundHost := false, false, false
for _, resource := range registry.List() {
if resource.Type == unifiedresources.ResourceTypeAgent {
foundHost = true
if (resource.Metrics.Disk != nil) != tc.known {
t.Fatal("host capacity availability disagrees with its only pool")
}
}
if resource.Storage == nil {
continue
}
if resource.Storage.Type == "zfs-pool" {
foundPool = true
metric := resource.Metrics.Disk
if (metric != nil) != tc.known {
t.Fatalf("unknown capacity became a measured zero: %+v", metric)
}
if tc.known && (metric.Used == nil || *metric.Used != tc.used || metric.Total == nil || *metric.Total != 1000 || metric.Percent != float64(tc.used)/10) {
t.Fatalf("pool bytes changed at ingestion: %+v", metric)
}
} else if resource.Storage.Type == "zfs-dataset" {
foundDataset = true
if resource.Metrics.Disk == nil || *resource.Metrics.Disk.Used != 12345 || *resource.Metrics.Disk.Total != 12900 {
t.Fatal("pool availability changed independent dataset capacity")
}
}
}
if !foundPool || !foundDataset || !foundHost {
t.Fatal("snapshot/provider/registry path lost resources")
}
})
}
}
func TestTrueNASPoolCapacityRPCExactBytes(t *testing.T) {
const size = int64(9007199254740993) // Above JSON float64's exact integer range.
for _, method := range []string{"pool.query", "boot.get_state"} {
t.Run(method, func(t *testing.T) {
server := newMockServerWithRPC(t, nil, nil, func(t *testing.T, conn *websocket.Conn) {
auth := readRPCRequest(t, conn)
writeRPCResult(t, conn, auth.ID, true)
req := readRPCRequest(t, conn)
if req.Method != method {
t.Fatalf("method = %q, want %q", req.Method, method)
}
row := fmt.Sprintf(`{"name":"tank","status":"ONLINE","size":%d,"allocated":1,"free":%d}`, size, size-1)
if method == "pool.query" {
row = "[" + row + "]"
}
writeRPCResult(t, conn, req.ID, json.RawMessage(row))
})
t.Cleanup(server.Close)
client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "synthetic-key"})
t.Cleanup(client.Close)
var pool Pool
if method == "pool.query" {
pools, err := client.GetPools(context.Background())
if err != nil || len(pools) != 1 {
t.Fatalf("GetPools: %v", err)
}
pool = pools[0]
} else {
boot, err := client.GetBootPool(context.Background())
if err != nil || boot == nil {
t.Fatalf("GetBootPool: %v", err)
}
pool = *boot
}
if pool.TotalBytes != size || pool.UsedBytes != 1 || pool.FreeBytes != size-1 {
t.Fatalf("native integer bytes were rounded: %+v", pool)
}
})
}
}
func TestTrueNASPoolCapacityAggregate(t *testing.T) {
for _, tc := range []struct {
name string
pools []Pool
total int64
used int64
}{
{"complete", []Pool{{TotalBytes: 1000, UsedBytes: 400}, {TotalBytes: 1000, UsedBytes: 0}}, 2000, 400},
{"unknown data with known boot", []Pool{{Name: "tank"}, {Name: "boot", IsBoot: true, TotalBytes: 1000, UsedBytes: 400}}, 0, 0},
{"over capacity", []Pool{{TotalBytes: 1000, UsedBytes: 1001}}, 0, 0},
{"negative used", []Pool{{TotalBytes: 1000, UsedBytes: -1}}, 0, 0},
{"total overflow", []Pool{{TotalBytes: math.MaxInt64, UsedBytes: 1}, {TotalBytes: 1, UsedBytes: 0}}, 0, 0},
} {
t.Run(tc.name, func(t *testing.T) {
total, used := aggregatePoolUsage(tc.pools)
if total != tc.total || used != tc.used {
t.Fatalf("host subtotal %d/%d, want %d/%d", total, used, tc.total, tc.used)
}
})
}
}