diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 13b97e1cec..743401a8ff 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -4411,6 +4411,25 @@ and the live-generation guard. ### Native pool-health collection and appliance isolation +CORE's legacy REST disk inventory treats `rotationrate` as optional provider +metadata, just like the RPC reader: integers and numeric strings retain their +classification, while null, absent, unknown and unrecognised values provide no +rotation evidence. The existing explicit HDD/SSD/NVMe type fallback remains; +an unknown QEMU/VTSCSI disk gains no SMART health, temperature, serial or SSD +type assertion. Required disk-field decoding, complete JSON inventory shape +and HTTP errors still fail collection. Both inventory and disk-temperature +identifier discovery use the tolerant shape. + +`TestRESTDiskRotationRateSnapshot` and +`TestRESTDiskRotationRatePreservesErrorsAndTopology` in +`internal/truenas/client_api_shapes_test.go` bind complete snapshots, mixed +alert arguments, RPC parity, healthy neighbours and actual topology evidence. +`TestTrueNASPollerCOREUnknownRotationRate` in +`internal/monitoring/truenas_poller_test.go` binds saved-config ordinary polling, +canonical unknowns, failure without renewed evidence, recovery and disable. +These synthetic controls reproduce and repair #2466's disk failure; they do +not establish native appliance acceptance or a containing release. + TrueNAS monitoring preserves the complete native `pool.query` observation needed by the shared storage-health contract: pool GUID and status detail, structured scrub or resilver state, pool and vdev read/write/checksum counters, diff --git a/internal/monitoring/truenas_poller_test.go b/internal/monitoring/truenas_poller_test.go index 505ddcc393..eedb2c6eb4 100644 --- a/internal/monitoring/truenas_poller_test.go +++ b/internal/monitoring/truenas_poller_test.go @@ -2947,3 +2947,111 @@ func TestTrueNASPollerStorageDiscoveryDoesNotReuseUnreadableConnectionConfig(t * t.Fatal("unreadable authoritative config reused old credentials or records") } } + +func TestTrueNASPollerCOREUnknownRotationRate(t *testing.T) { + previous := truenas.IsFeatureEnabled() + truenas.SetFeatureEnabled(true) + t.Cleanup(func() { truenas.SetFeatureEnabled(previous) }) + var diskFailure atomic.Bool + var diskRequests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v2.0/system/info": + _, _ = w.Write([]byte(`{"hostname":"core-poll","version":"TrueNAS-13.0-U6.8"}`)) + case "/api/v2.0/pool": + _, _ = w.Write([]byte(`[{"id":1,"name":"tank","status":"ONLINE","size":1000,"allocated":400,"free":600}]`)) + case "/api/v2.0/pool/dataset": + _, _ = w.Write([]byte(`[{"id":"tank/data","name":"tank/data","pool":"tank","used":{"parsed":123},"available":{"parsed":456},"mountpoint":"/mnt/tank/data"}]`)) + case "/api/v2.0/disk": + diskRequests.Add(1) + if diskFailure.Load() { + _, _ = w.Write([]byte(`[{"rotationrate":`)) + } else { + _, _ = w.Write([]byte(`[{"identifier":"{uuid}synthetic-da0","name":"da0","serial":"","size":34359738368,"model":"QEMU QEMU HARDDISK","rotationrate":"unknown","type":"UNKNOWN","bus":"VTSCSI"},{"identifier":"{uuid}synthetic-da1","name":"da1","serial":"","size":34359738368,"model":"QEMU QEMU HARDDISK","rotationrate":null,"type":"UNKNOWN","bus":"VTSCSI"}]`)) + } + case "/api/v2.0/alert/list": + _, _ = w.Write([]byte(`[{"id":"string","level":"WARNING","formatted":"Pool warning","args":"boot-pool","datetime":{"$date":1707400000000}},{"id":"object","level":"INFO","formatted":"Notice","args":{},"datetime":{"$date":1707400000000}}]`)) + case "/api/v2.0/vm": + _, _ = w.Write([]byte(`[{"id":42,"name":"core-vm","memory":1024,"vcpus":2,"status":{"state":"RUNNING"}}]`)) + case "/api/v2.0/zfs/snapshot": + _, _ = w.Write([]byte(`[{"id":"tank/data@daily","dataset":"tank/data","snapshot_name":"daily","created_at":1707400000}]`)) + case "/api/v2.0/replication", "/api/v2.0/service", "/api/v2.0/app", "/api/v2.0/sharing/smb", "/api/v2.0/sharing/nfs": + _, _ = w.Write([]byte(`[]`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + mtp, persistence := newTestTenantPersistence(t) + connection := trueNASInstanceForServer(t, "core-rotation", server.URL, true) + if err := persistence.SaveTrueNASConfig([]config.TrueNASInstance{connection}); err != nil { + t.Fatal(err) + } + poller := NewTrueNASPoller(mtp, 0, nil) + t.Cleanup(func() { + for _, byConnection := range poller.providersByOrg { + for _, provider := range byConnection { + provider.Close() + } + } + }) + poller.syncConnections() + // Explicit cycles avoid wall-clock sleeps while exercising the ordinary + // saved-config provider, client, projection and runtime summary boundaries. + for cycle := 0; cycle < 4; cycle++ { + diskFailure.Store(cycle == 2) + if cycle > 0 { + poller.mu.Lock() + // Zero re-derives the normal interval from lastAttemptAt. Use a + // nonzero past deadline to exercise the next explicit due cycle. + poller.statusByOrg["default"][connection.ID].nextPollAt = time.Unix(1, 0) + poller.mu.Unlock() + } + prior := poller.ConnectionSummaries("default", []config.TrueNASInstance{connection})[connection.ID] + priorRequests := diskRequests.Load() + poller.pollAll(context.Background()) + if diskRequests.Load() <= priorRequests { + t.Fatalf("cycle %d never reached the disk endpoint", cycle) + } + summary := poller.ConnectionSummaries("default", []config.TrueNASInstance{connection})[connection.ID] + if summary.Poll == nil || summary.Poll.LastAttemptAt == nil { + t.Fatalf("no ordinary poll attempt in cycle %d: %+v", cycle, summary) + } + if cycle == 2 { + if summary.Poll.LastError == nil || summary.Poll.ConsecutiveFailures != 1 || summary.Poll.LastSuccessAt == nil || !summary.Poll.LastSuccessAt.Equal(*prior.Poll.LastSuccessAt) || summary.Observed == nil || !summary.Observed.CollectedAt.Equal(*prior.Observed.CollectedAt) { + t.Fatalf("malformed transport must fail without renewing last-known evidence: %+v", summary) + } + } else if summary.Poll.LastError != nil || summary.Poll.ConsecutiveFailures != 0 || summary.Poll.LastSuccessAt == nil || summary.Observed == nil || summary.Observed.Systems != 1 || summary.Observed.StoragePools != 1 || summary.Observed.Datasets != 1 || summary.Observed.Disks != 2 || summary.Observed.VMs != 1 || summary.Observed.RecoveryArtifacts != 1 || summary.Transport == nil || !summary.Transport.Connected { + t.Fatalf("CORE unknown rate prevented complete ordinary poll, cycle %d: %+v", cycle, summary) + } + if !hasTrueNASHostForOrg(poller, "default", "core-poll") { + t.Fatal("ordinary CORE poll lost the appliance") + } + disks := 0 + for _, record := range poller.GetCurrentRecordsForOrg("default") { + if meta := record.Resource.PhysicalDisk; meta != nil { + disks++ + if meta.Health != "UNKNOWN" || meta.DiskType != "vtscsi" || meta.RPM != 0 || meta.Temperature != 0 || meta.Serial != "" { + t.Fatalf("poll projection invented virtual-disk observations: %+v", meta) + } + } + } + if disks != 2 { + t.Fatalf("ordinary poll contributed %d disks, want both virtual disks", disks) + } + if s := poller.providersByOrg["default"][connection.ID].Snapshot(); s == nil || len(s.Alerts) != 2 || len(s.ZFSSnapshots) != 1 || len(s.VMs) != 1 { + t.Fatalf("mixed alert args or recovery inventory still aborted snapshot: %+v", s) + } + } + before := diskRequests.Load() + connection.Enabled = false + if err := persistence.SaveTrueNASConfig([]config.TrueNASInstance{connection}); err != nil { + t.Fatal(err) + } + poller.syncConnections() + poller.pollAll(context.Background()) + if diskRequests.Load() != before || len(poller.GetCurrentRecordsForOrg("default")) != 0 { + t.Fatal("disabled CORE connection kept polling or retained current inventory") + } +} diff --git a/internal/truenas/client.go b/internal/truenas/client.go index b057e40d18..75069af77c 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -1147,7 +1147,13 @@ func (c *Client) getDisksREST(ctx context.Context) ([]Disk, error) { disks := make([]Disk, 0, len(response)) for _, item := range response { - rotationRate := item.RotationRate + // CORE reports "unknown" (and sometimes numeric strings) for this + // optional classifier. Match the RPC reader without weakening the + // required disk fields or treating missing SMART data as healthy. + rotationRate, _ := parseInt64Any(item.RotationRate) + if rotationRate < math.MinInt32 || rotationRate > math.MaxInt32 { + rotationRate = 0 + } rotational := rotationRate > 0 if rotationRate == 0 { switch strings.ToLower(strings.TrimSpace(item.Type)) { @@ -4862,7 +4868,7 @@ type diskResponse struct { Bus string `json:"bus"` Status string `json:"status"` SmartStatus json.RawMessage `json:"smart_status"` - RotationRate int `json:"rotationrate"` + RotationRate any `json:"rotationrate"` } type alertResponse struct { diff --git a/internal/truenas/client_api_shapes_test.go b/internal/truenas/client_api_shapes_test.go index 659abe6d10..af98ff81cb 100644 --- a/internal/truenas/client_api_shapes_test.go +++ b/internal/truenas/client_api_shapes_test.go @@ -2,8 +2,13 @@ package truenas import ( "context" + "encoding/json" + "fmt" "net/http" + "reflect" + "strings" "testing" + "time" ) // The real pool.dataset.query response has never carried a "mounted" field on @@ -411,3 +416,135 @@ func TestParsePoolStatePreservesScanTopologyErrorsAndNativeMissingEvidence(t *te t.Fatalf("synthetic unavailable disk = %+v", disks[1]) } } + +// The CORE 13 virtual-disk shape from #2466, with synthetic identity. Exercise +// the complete real client path, not just the optional field's decoder. +func TestRESTDiskRotationRateSnapshot(t *testing.T) { + for _, tc := range []struct { + name, field, diskType string + rotational bool + }{ + {"unknown", `,"rotationrate":"unknown"`, "UNKNOWN", false}, + {"ssd-sentinel", `,"rotationrate":"ssd"`, "UNKNOWN", false}, + {"integer", `,"rotationrate":7200`, "UNKNOWN", true}, + {"numeric-string", `,"rotationrate":" 7200 "`, "UNKNOWN", true}, + {"zero", `,"rotationrate":0`, "UNKNOWN", false}, + {"null", `,"rotationrate":null`, "UNKNOWN", false}, + {"absent", "", "UNKNOWN", false}, + {"hdd-fallback", `,"rotationrate":"unknown"`, "HDD", true}, + {"ssd-fallback", `,"rotationrate":null`, "SSD", false}, + {"out-of-range", `,"rotationrate":4294967296`, "UNKNOWN", false}, + } { + t.Run(tc.name, func(t *testing.T) { + routes := alertArgsTransport(defaultAPIResponses()) + routes["/api/v2.0/system/info"] = apiResponse{body: `{"hostname":"core-nas","version":"TrueNAS-13.0-U6.8"}`} + // A bad optional classifier on one disk must not remove its healthy + // neighbours, their actual SMART evidence or their temperatures. + body := fmt.Sprintf(`[{"identifier":"{uuid}synthetic-da0","name":"da0","serial":"","size":34359738368,"model":"QEMU QEMU HARDDISK","transfermode":"Auto","hddstandby":"ALWAYS ON","togglesmart":true,"type":%q,"bus":"VTSCSI"%s},{"identifier":"{disk-1}","name":"sda","serial":"SER-A","size":1000000,"model":"Seagate","type":"HDD","pool":"tank","bus":"SATA","rotationrate":7200,"status":"ONLINE","smart_status":"FAILED"},{"identifier":"{disk-2}","name":"nvme0n1","serial":"SER-B","size":2000000,"model":"Samsung","type":"SSD","pool":"tank","bus":"NVMe","rotationrate":0,"status":"ONLINE"}]`, tc.diskType, tc.field) + routes["/api/v2.0/disk"] = apiResponse{body: body} + routes["/api/v2.0/alert/list"] = apiResponse{body: `[{"id":"smart","level":"CRITICAL","formatted":"Disk errors","klass":"SMARTUncorrectedErrorsAlert","args":{"ue":53,"name":"/dev/sda","serial":"SER-A"},"datetime":{"$date":1707400000000}},{"id":"string","level":"WARNING","formatted":"Pool warning","args":"boot-pool","datetime":{"$date":1707400000000}},{"id":"null","level":"INFO","formatted":"Notice","args":null,"datetime":{"$date":1707400000000}}]`} + routes["/api/v2.0/zfs/snapshot"] = apiResponse{body: `[{"id":"tank/apps@daily","dataset":"tank/apps","snapshot_name":"daily","created_at":1707400000}]`} + routes["/api/v2.0/replication"] = apiResponse{body: `[{"id":1,"name":"daily-copy","source_datasets":["tank/apps"],"target_dataset":"remote/apps","direction":"PUSH","transport":"SSH","state":"FINISHED"}]`} + client, err := NewClient(ClientConfig{Host: "http://truenas.invalid", APIKey: "synthetic"}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + client.httpClient.Transport = routes + client.mode = TransportLegacyREST + ctx := context.Background() + if err := client.TestConnection(ctx); err != nil { + t.Fatal(err) + } + provider := NewLiveProviderForConnection(&APIFetcher{Client: client}, "core-connection") + for cycle := 0; cycle < 2; cycle++ { + if err := provider.Refresh(ctx); err != nil { + t.Fatalf("successful Test Connection must permit complete inventory, cycle %d: %v", cycle, err) + } + s := provider.Snapshot() + if s == nil || s.System.Hostname != "core-nas" || s.CollectedAt.IsZero() || len(s.Pools) != 1 || len(s.Datasets) != 1 || len(s.Disks) != 3 || len(s.Alerts) != 3 || len(s.Services) != 2 || len(s.Apps) != 1 || len(s.VMs) != 1 || len(s.Shares) != 2 || len(s.ZFSSnapshots) != 1 || len(s.ReplicationTasks) != 1 { + t.Fatalf("incomplete CORE snapshot: %+v", s) + } + d := s.Disks[0] + if d.ID != "{uuid}synthetic-da0" || d.Name != "da0" || d.SizeBytes != 34359738368 || d.Model != "QEMU QEMU HARDDISK" || d.Transport != "vtscsi" || d.Rotational != tc.rotational || d.Serial != "" || d.Pool != "" || d.Status != "" || d.HealthStatusPresent || d.Health != "" || d.Temperature != 0 { + t.Fatalf("lost identity or fabricated virtual-disk observations: %+v", d) + } + if h := s.Disks[1]; h.Serial != "SER-A" || h.Pool != "tank" || h.Transport != "sata" || !h.Rotational || !h.HealthStatusPresent || h.Health != "FAILED" || h.Temperature != 34 { + t.Fatalf("lost independent HDD evidence: %+v", h) + } + if h := s.Disks[2]; h.Serial != "SER-B" || h.Transport != "nvme" || h.Rotational || h.Temperature != 49 { + t.Fatalf("lost independent SSD evidence: %+v", h) + } + if a := s.Alerts[0]; !a.SMARTUncorrectedReported || a.SMARTUncorrectedErrors != 53 || a.DiskName != "/dev/sda" || a.DiskSerial != "SER-A" { + t.Fatalf("lost object alert evidence: %+v", a) + } + for _, a := range s.Alerts[1:] { + if a.SMARTUncorrectedReported || a.SMARTAvailableSpareReported || a.DiskName != "" || a.DiskSerial != "" || !a.Datetime.Equal(time.UnixMilli(1707400000000).UTC()) { + t.Fatalf("invented metadata from mixed alert args: %+v", a) + } + } + for _, record := range provider.RecordsFromSnapshot(s) { + if record.Resource.Name == "da0" { + meta := record.Resource.PhysicalDisk + if meta == nil || meta.Health != "UNKNOWN" || meta.DiskType != "vtscsi" || meta.Temperature != 0 || (!tc.rotational && meta.RPM != 0) { + t.Fatalf("unknown disk must remain unknown in canonical projection: %+v", meta) + } + } + } + } + var rpcShape []map[string]any + if err := json.Unmarshal([]byte(body), &rpcShape); err != nil { + t.Fatal(err) + } + rest, err := client.GetDisks(ctx) + if err != nil { + t.Fatal(err) + } + rpc, err := client.disksFromMaps(ctx, rpcShape) + if err != nil || !reflect.DeepEqual(rest, rpc) { + t.Fatalf("REST/RPC disk evidence differs: REST=%+v RPC=%+v err=%v", rest, rpc, err) + } + ids, err := client.listDiskReportingIdentifiers(ctx) + if err != nil || !reflect.DeepEqual(ids, []string{"da0", "sda", "nvme0n1"}) { + t.Fatalf("temperature discovery must tolerate the same field: %v, %v", ids, err) + } + }) + } +} + +func TestRESTDiskRotationRatePreservesErrorsAndTopology(t *testing.T) { + routes := alertArgsTransport(defaultAPIResponses()) + routes["/api/v2.0/pool"] = apiResponse{body: `[{"id":1,"name":"tank","status":"DEGRADED","topology":{"data":[{"type":"DISK","disk":"da0","status":"FAULTED","path":"/dev/da0p2","stats":{"read_errors":2,"write_errors":3,"checksum_errors":4}}]}}]`} + routes["/api/v2.0/disk"] = apiResponse{body: `[{"identifier":"{uuid}synthetic","name":"da0","model":"QEMU QEMU HARDDISK","size":34359738368,"type":"UNKNOWN","bus":"VTSCSI","rotationrate":"unknown"}]`} + client, err := NewClient(ClientConfig{Host: "http://truenas.invalid", APIKey: "synthetic"}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + client.httpClient.Transport = routes + client.mode = TransportLegacyREST + s, err := client.FetchSnapshot(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(s.Disks) != 1 || s.Disks[0].Pool != "tank" || s.Disks[0].Status != "FAULTED" || healthFromDisk(s.Disks[0]) != "FAILED" || len(s.Pools[0].DiskMembers) != 1 || s.Pools[0].DiskMembers[0].ReadErrors != 2 { + t.Fatalf("tolerant rotation rate lost real topology/health: %+v", s) + } + for _, tc := range []struct { + name string + response apiResponse + }{ + {"malformed-json", apiResponse{body: `[{"rotationrate":`}}, + {"wrong-inventory-shape", apiResponse{body: `{"rotationrate":"unknown"}`}}, + {"invalid-required-field", apiResponse{body: `[{"name":"da0","rotationrate":"unknown","size":"not-a-size"}]`}}, + {"authentication", apiResponse{status: 401, body: `{}`}}, + {"server", apiResponse{status: 503, body: `{}`}}, + } { + t.Run(tc.name, func(t *testing.T) { + routes["/api/v2.0/disk"] = tc.response + if result, err := client.FetchSnapshot(context.Background()); err == nil || result != nil || !strings.Contains(err.Error(), "fetch truenas disks:") { + t.Fatalf("disk response failure must remain a failed snapshot: %+v, %v", result, err) + } + }) + } +} diff --git a/internal/unifiedresources/clone_test.go b/internal/unifiedresources/clone_test.go index 078799b8eb..113205f28f 100644 --- a/internal/unifiedresources/clone_test.go +++ b/internal/unifiedresources/clone_test.go @@ -13,6 +13,10 @@ import ( func TestCloneResourceRefreshesScopeAndPolicyMetadata(t *testing.T) { // Registry mutation paths can leave derived metadata stale until the next // read. Keep refresh on every clone, including after source/tag edits. + observation := models.MemoryObservation{ + State: "last-known", Source: "guest-agent-meminfo", + ObservedAt: time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC), + } original := &Resource{ ID: "app-container:lab", Type: ResourceTypeAppContainer, Sources: []DataSource{SourceDocker, SourceAgent, SourceDocker}, @@ -21,6 +25,9 @@ func TestCloneResourceRefreshesScopeAndPolicyMetadata(t *testing.T) { Docker: &DockerData{HostSourceID: "proxmox-lxc-docker:lab:node:100"}, Policy: &ResourcePolicy{Sensitivity: ResourceSensitivityRestricted}, AISafeSummary: "stale", + Metrics: &ResourceMetrics{Memory: &MetricValue{ + Percent: 50, Source: SourceProxmox, Observation: observation, + }}, } for _, test := range []struct { name string @@ -44,6 +51,14 @@ func TestCloneResourceRefreshesScopeAndPolicyMetadata(t *testing.T) { } cloned.PlatformScopes[0] = "mutated" cloned.Policy.Sensitivity = ResourceSensitivityPublic + if cloned.Metrics == original.Metrics || cloned.Metrics.Memory == original.Metrics.Memory || cloned.Metrics.Memory.Observation != observation || cloned.Metrics.Memory.Source != SourceProxmox { + t.Fatal("metadata read aliased or renewed the selected memory observation") + } + cloned.Metrics.Memory.Observation.State = "current" + cloned.Metrics.Memory.Observation.ObservedAt = observation.ObservedAt.Add(time.Hour) + if original.Metrics.Memory.Observation != observation { + t.Fatal("borrowed metadata readers lost detached metric ownership") + } if !reflect.DeepEqual(original.PlatformScopes, []string{"stale"}) || original.Policy.Sensitivity != ResourceSensitivityRestricted || original.AISafeSummary != "stale" { t.Fatal("clone modified the original derived metadata") } diff --git a/internal/unifiedresources/metadata_bench_test.go b/internal/unifiedresources/metadata_bench_test.go index 5a5bc958d5..fd3737b0d8 100644 --- a/internal/unifiedresources/metadata_bench_test.go +++ b/internal/unifiedresources/metadata_bench_test.go @@ -25,6 +25,6 @@ func BenchmarkClassifyResourceSensitivity(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - benchmarkSensitivity = classifyResourceSensitivity(resource) + benchmarkSensitivity = classifyResourceSensitivity(&resource) } } diff --git a/internal/unifiedresources/platform_scopes.go b/internal/unifiedresources/platform_scopes.go index 774e1c44a6..0456db40e6 100644 --- a/internal/unifiedresources/platform_scopes.go +++ b/internal/unifiedresources/platform_scopes.go @@ -47,8 +47,8 @@ func RefreshPlatformScopes(resource *Resource) { add(platformScopeForSource(source)) } - addPlatformScopesForFacets(add, *resource) - if shouldAddDockerPlatformScope(*resource) { + addPlatformScopesForFacets(add, resource) + if shouldAddDockerPlatformScope(resource) { add("docker") } @@ -88,7 +88,9 @@ func platformScopeForSource(source DataSource) string { } } -func addPlatformScopesForFacets(add func(string), resource Resource) { +// Facet readers borrow the caller-owned resource without retaining or mutating +// it; only RefreshPlatformScopes replaces its derived membership slice. +func addPlatformScopesForFacets(add func(string), resource *Resource) { if resource.Agent != nil { add("agent") } @@ -110,12 +112,12 @@ func addPlatformScopesForFacets(add func(string), resource Resource) { if resource.VMware != nil { add("vmware-vsphere") } - if len(AvailabilityChecksForResource(resource)) > 0 || CanonicalResourceType(resource.Type) == ResourceTypeNetworkEndpoint { + if len(AvailabilityChecksForResource(*resource)) > 0 || CanonicalResourceType(resource.Type) == ResourceTypeNetworkEndpoint { add("availability") } } -func shouldAddDockerPlatformScope(resource Resource) bool { +func shouldAddDockerPlatformScope(resource *Resource) bool { if resource.Docker == nil { return false } diff --git a/internal/unifiedresources/policy_metadata.go b/internal/unifiedresources/policy_metadata.go index e7ccc952f6..f7b1f52ec8 100644 --- a/internal/unifiedresources/policy_metadata.go +++ b/internal/unifiedresources/policy_metadata.go @@ -77,7 +77,7 @@ func RefreshPolicyMetadata(resource *Resource) { return } - sensitivity := classifyResourceSensitivity(*resource) + sensitivity := classifyResourceSensitivity(resource) resource.Policy = &ResourcePolicy{ Sensitivity: sensitivity, Routing: buildResourceRoutingPolicy(*resource, sensitivity), @@ -112,7 +112,10 @@ func RefreshCanonicalMetadataSlice(resources []Resource) []Resource { return out } -func classifyResourceSensitivity(resource Resource) ResourceSensitivity { +// Borrow the detached resource for this read-only scan. A full value copy on +// every registry read is unnecessary, including when tag precedence returns +// before any of the resource's other fields are read. +func classifyResourceSensitivity(resource *Resource) ResourceSensitivity { // Clones classify every registry read. Scan once without building a tag // map, preserving public > restricted > sensitive precedence independently // of tag order, and normalising each tag only once. diff --git a/internal/unifiedresources/policy_metadata_test.go b/internal/unifiedresources/policy_metadata_test.go index 459002e787..dcc7c9aba4 100644 --- a/internal/unifiedresources/policy_metadata_test.go +++ b/internal/unifiedresources/policy_metadata_test.go @@ -17,13 +17,13 @@ func TestClassifyResourceSensitivityTagAliasesAndPrecedence(t *testing.T) { t.Run(tag, func(t *testing.T) { normalised := " " + strings.ToUpper(tag) + " " for _, tags := range [][]string{{normalised, "unknown", normalised}, {"unknown", normalised}} { - if got := classifyResourceSensitivity(Resource{Type: ResourceTypeVM, Tags: tags}); got != group.want { + if got := classifyResourceSensitivity(&Resource{Type: ResourceTypeVM, Tags: tags}); got != group.want { t.Fatalf("tags %v sensitivity = %q, want %q", tags, got, group.want) } } for _, tags := range [][]string{{" PUBLIC ", normalised}, {normalised, "public"}} { // Explicit public wins even over secret-type defaults, as before. - if got := classifyResourceSensitivity(Resource{Type: ResourceTypeK8sSecret, Tags: tags}); got != ResourceSensitivityPublic { + if got := classifyResourceSensitivity(&Resource{Type: ResourceTypeK8sSecret, Tags: tags}); got != ResourceSensitivityPublic { t.Fatalf("public precedence changed for %v: %q", tags, got) } }