diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 47e5c4897e..84db1e7f7a 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -6474,6 +6474,18 @@ That availability contract also applies when recovery points are the only storag `internal/ai/tools/executor.go` must keep `pulse_storage` exposed whenever a `RecoveryPointsProvider` is configured, so tenant and self-hosted Chat surfaces do not lose recovery-backed snapshot and backup-task reads just because backup/read-state adapters are absent. +Storage pool rows built by `storagePoolSummaryFromResource` in +`internal/ai/tools/tools_storage.go` (`pulse_storage` pools and `pulse_query` +storage pools) take `enabled` from the storage facet's own +`StorageMeta.Enabled`, so storage that reported itself unavailable (Proxmox +`active=0`, unified status `offline`) never reads as administratively disabled +to Patrol or Assistant. `active` is the facet's `Active` flag and also requires +the row not to be `offline`, because some sources (PBS datastores) report +`Active` even while unavailable. Only a row without a storage facet falls back +to the status-derived value. +`TestStoragePoolSummaryReadsStorageFacetFlags` in +`internal/ai/tools/tools_storage_test.go` pins unavailable, disabled, online, +offline-but-flagged-active and facet-less rows. Tenant-scoped AI services must now also follow canonical runtime ownership: Patrol may initialize and operate from tenant `ReadState` and unified-resource providers without requiring a tenant snapshot-provider bridge, and diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 6d91671d13..9e559ec78a 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -4904,6 +4904,15 @@ remain separate through refresh, cache rebuild, restart, and registry ingest. Replication-target readonly classification remains a separate native-evidence step and cannot hide locked or unmounted dataset state. +TrueNAS pool and dataset storage facets carry `StorageMeta.Enabled` and +`Active` like every other storage producer, because the AI storage tools and +storage platform payloads read those flags directly. TrueNAS has no disabled +state for an imported pool or a listed dataset, so both are always `Enabled`. A +pool is `Active` unless its native state is FAULTED, OFFLINE, REMOVED or +UNAVAIL, and a dataset is `Active` only while it is mounted and unlocked. +`TestProviderRecordsPopulateStorageEnabledAndActive` in +`internal/truenas/provider_test.go` pins both. + Ceph monitoring may enter the provider-neutral pool-health envelope only from the native cluster health state and native health-check map. It preserves check codes, severity, and summaries in deterministic order. A cluster-level diff --git a/internal/ai/tools/tools_storage.go b/internal/ai/tools/tools_storage.go index a84805a494..d7953950e0 100644 --- a/internal/ai/tools/tools_storage.go +++ b/internal/ai/tools/tools_storage.go @@ -338,8 +338,16 @@ func storagePoolSummaryFromResource(r unifiedresources.Resource) StoragePoolSumm freeBytes = totalBytes - usedBytes } + // Enabled is the source's configuration flag, so an unavailable storage + // (Proxmox active=0) must not read as administratively disabled. Active + // also needs the row itself to be up: PBS datastores report Active even + // while unavailable, and an offline row is not usable whatever its flag. active := r.Status != unifiedresources.StatusOffline - enabled := r.Status != unifiedresources.StatusOffline + enabled := active + if r.Storage != nil { + enabled = r.Storage.Enabled + active = active && r.Storage.Active + } id := r.ID if id == "" { diff --git a/internal/ai/tools/tools_storage_test.go b/internal/ai/tools/tools_storage_test.go index f1f0cbfdae..517c9a1a85 100644 --- a/internal/ai/tools/tools_storage_test.go +++ b/internal/ai/tools/tools_storage_test.go @@ -279,6 +279,65 @@ func TestStorageUsagePercentNotDoubled(t *testing.T) { } } +func TestStoragePoolSummaryReadsStorageFacetFlags(t *testing.T) { + storage := func(enabled, active bool) *unifiedresources.StorageMeta { + return &unifiedresources.StorageMeta{Type: "dir", Enabled: enabled, Active: active} + } + cases := []struct { + name string + status unifiedresources.ResourceStatus + storage *unifiedresources.StorageMeta + wantEnabled bool + wantActive bool + listSkipsRow bool + }{ + // Proxmox storage that reports active=0 is unavailable, not disabled. + {"unavailable storage stays enabled", unifiedresources.StatusOffline, storage(true, false), true, false, false}, + {"disabled storage", unifiedresources.StatusWarning, storage(false, false), false, false, false}, + {"online storage", unifiedresources.StatusOnline, storage(true, true), true, true, false}, + // PBS datastores report Active even while unavailable. + {"offline row whose source reports active", unifiedresources.StatusOffline, storage(true, true), true, false, false}, + {"no facet online", unifiedresources.StatusOnline, nil, true, true, true}, + {"no facet offline", unifiedresources.StatusOffline, nil, false, false, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resource := unifiedresources.Resource{ + ID: "local-lvm", + Name: "local-lvm", + Type: unifiedresources.ResourceTypeStorage, + Status: tc.status, + Storage: tc.storage, + } + + got := storagePoolSummaryFromResource(resource) + if got.Enabled != tc.wantEnabled || got.Active != tc.wantActive { + t.Fatalf("summary enabled/active = %v/%v, want %v/%v", got.Enabled, got.Active, tc.wantEnabled, tc.wantActive) + } + if tc.listSkipsRow { + return + } + + executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}}) + executor.unifiedResourceProvider = &stubUnifiedResourceProvider{resources: []unifiedresources.Resource{resource}} + result, err := executor.executeListStorage(context.Background(), map[string]interface{}{}) + if err != nil { + t.Fatalf("executeListStorage: %v", err) + } + var resp StorageResponse + if err := json.Unmarshal([]byte(result.Content[0].Text), &resp); err != nil { + t.Fatalf("decode storage response: %v", err) + } + if len(resp.Pools) != 1 { + t.Fatalf("pools = %+v, want one", resp.Pools) + } + if resp.Pools[0].Enabled != tc.wantEnabled || resp.Pools[0].Active != tc.wantActive { + t.Fatalf("listed enabled/active = %v/%v, want %v/%v", resp.Pools[0].Enabled, resp.Pools[0].Active, tc.wantEnabled, tc.wantActive) + } + }) + } +} + func TestStorageResponsesUseCanonicalEmptyCollections(t *testing.T) { backupsPayload, err := json.Marshal(EmptyBackupsResponse()) if err != nil { diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index c20af6a3be..8e1e1d0785 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -590,6 +590,7 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, connectionID string, risk := unifiedresources.StorageRiskFromAssessment(assessment) incidents := incidentAssignments.Pools[strings.TrimSpace(pool.Name)] zfsPool := zfsPoolFromPool(pool) + poolStatus := statusFromPool(pool) poolSourceID := scopedPoolSourceID(systemSourceID, pool.Name) records = append(records, unifiedresources.IngestRecord{ SourceID: poolSourceID, @@ -598,14 +599,19 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, connectionID string, Resource: unifiedresources.Resource{ Type: unifiedresources.ResourceTypeStorage, Name: pool.Name, - Status: unifiedresources.IncidentsStatus(statusFromPool(pool), incidents), + Status: unifiedresources.IncidentsStatus(poolStatus, incidents), LastSeen: collectedAt, UpdatedAt: collectedAt, Metrics: &unifiedresources.ResourceMetrics{ Disk: diskMetric(pool.TotalBytes, pool.UsedBytes), }, + // TrueNAS has no disabled state for an imported pool, so + // Enabled is always true; Active follows whether the pool is + // usable now. Storage: &unifiedresources.StorageMeta{ Type: "zfs-pool", + Enabled: true, + Active: poolStatus != unifiedresources.StatusOffline, IsZFS: true, Platform: "truenas", Topology: "pool", @@ -654,6 +660,8 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, connectionID string, }, Storage: &unifiedresources.StorageMeta{ Type: "zfs-dataset", + Enabled: true, + Active: dataset.Mounted && !dataset.Locked, IsZFS: true, Platform: "truenas", Topology: "dataset", diff --git a/internal/truenas/provider_test.go b/internal/truenas/provider_test.go index 34dc1b6451..926a5fcdd3 100644 --- a/internal/truenas/provider_test.go +++ b/internal/truenas/provider_test.go @@ -261,6 +261,53 @@ func TestProviderRecordsSynthesizesIncidentFromUnhealthyPoolStatus(t *testing.T) } } +func TestProviderRecordsPopulateStorageEnabledAndActive(t *testing.T) { + records := FixtureRecords(FixtureSnapshot{ + CollectedAt: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC), + System: SystemInfo{Hostname: "truenas-main", Healthy: true}, + Pools: []Pool{ + {ID: "pool-tank", Name: "tank", Status: "ONLINE", TotalBytes: 1000, UsedBytes: 400}, + {ID: "pool-cold", Name: "cold", Status: "DEGRADED", TotalBytes: 1000, UsedBytes: 400}, + {ID: "pool-gone", Name: "gone", Status: "UNAVAIL"}, + {ID: "pool-odd", Name: "odd", Status: ""}, + }, + Datasets: []Dataset{ + {ID: "tank/apps", Name: "tank/apps", Pool: "tank", Mounted: true}, + {ID: "tank/spare", Name: "tank/spare", Pool: "tank", Mounted: false}, + {ID: "tank/vault", Name: "tank/vault", Pool: "tank", Mounted: false, Locked: true}, + }, + }) + + cases := []struct { + name string + record unifiedresources.IngestRecord + active bool + }{ + {"online pool", requirePoolRecord(t, records, "tank"), true}, + {"degraded pool", requirePoolRecord(t, records, "cold"), true}, + {"unavailable pool", requirePoolRecord(t, records, "gone"), false}, + // An unreported pool state is not known offline, matching its status. + {"unknown-state pool", requirePoolRecord(t, records, "odd"), true}, + {"mounted dataset", requireRecordByNameAndType(t, records, "tank/apps", unifiedresources.ResourceTypeStorage), true}, + {"unmounted dataset", requireRecordByNameAndType(t, records, "tank/spare", unifiedresources.ResourceTypeStorage), false}, + {"locked dataset", requireRecordByNameAndType(t, records, "tank/vault", unifiedresources.ResourceTypeStorage), false}, + } + for _, tc := range cases { + storage := tc.record.Resource.Storage + if storage == nil { + t.Fatalf("%s: missing storage facet", tc.name) + } + // Consumers read these flags directly, so the zero value would mark + // every TrueNAS pool and dataset as disabled and inactive. + if !storage.Enabled { + t.Errorf("%s: Enabled = false, want true", tc.name) + } + if storage.Active != tc.active { + t.Errorf("%s: Active = %v, want %v", tc.name, storage.Active, tc.active) + } + } +} + func TestProviderRecordsDoesNotDuplicateNativePoolAlert(t *testing.T) { alertTime := time.Date(2026, 6, 29, 11, 30, 0, 0, time.UTC) records := FixtureRecords(FixtureSnapshot{