diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 29db5e5e7..6742f7567 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -266,6 +266,11 @@ running server between real and mock data, the canonical TrueNAS and VMware settings routes must keep surfacing through the same Platform connections workspace and handoff URLs instead of depending on process-start-only wiring or a mock-only alternate shell. +That same lifecycle-owned mock path now also requires one shared fixture owner +for API-backed platform onboarding. TrueNAS and VMware connection-list payloads +shown in Platform connections must be assembled from the canonical +`internal/mock/` platform fixture layer, so settings handoff metadata cannot +drift from the runtime mock inventory and shared storage/recovery context. That same hosted continuity contract also applies to the older direct tenant magic-link path. Lifecycle-adjacent control-plane redirects through `/auth/cloud-handoff` must preserve canonical account/user/role identity in the diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 413321f2e..07f7e656a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -712,6 +712,12 @@ inventory through `source=truenas` and `source=vmware-vsphere`. Shared query parsing may accept `vmware-vsphere` as the operator-facing VMware alias, but the emitted canonical resource source remains the shared `vmware` source family rather than a second backend source key. +That same runtime mock contract now also owns fixture authority. Mock +connection payloads returned from `/api/truenas/connections` and +`/api/vmware/connections` must derive from the shared `internal/mock/` +platform fixture owner rather than handler-local fixture assembly, so settings +payloads stay aligned with the unified runtime mock graph, storage/recovery +context, and seeded monitoring history. That same VMware test contract now also owns structured setup-failure classification. When `POST /api/vmware/connections/test` or `POST /api/vmware/connections/{id}/test` fails, the backend payload must diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index dc0d9743d..62d519f9d 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -365,6 +365,11 @@ When `/api/system/mock-mode` changes on a live server, the TrueNAS supplemental provider must rebind immediately and repopulate the canonical read state so settings, infrastructure, storage, and other shared consumers see the same mock-backed inventory without restart. +That same runtime mock ownership now also includes fixture authority. Mock +TrueNAS and VMware inventory plus mock metrics-history seeding must derive from +one shared platform fixture owner in `internal/mock/` so settings payloads, +supplemental ingest, unified read-state, and seeded charts cannot drift from +each other when the v6 runtime runs in mock mode. That same boundary now also owns native disk-history fallback when Pulse's own history is shallow. `internal/truenas/client.go`, `internal/truenas/provider.go`, `internal/monitoring/truenas_poller.go`, and diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 1e98685f0..19e143a07 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -148,6 +148,12 @@ querying, and the operator-facing storage health presentation layer. data remains inventory-only context and must not be treated as proof of restore capability, recovery artifacts, or widened platform recovery support. +11. Keep runtime mock platform context derived from one shared fixture owner. + When shared `internal/api/` and monitoring wiring surface mock TrueNAS or + VMware storage/recovery-adjacent inventory, that data must come from the + canonical `internal/mock/` platform fixture layer so settings payloads, + unified inventory, and recovery/storage context stay aligned instead of + drifting through recovery-local fixture assembly. ## Current State @@ -1671,6 +1677,12 @@ runtime mock data. Mock TrueNAS pools/datasets and mock VMware datastores may surface through the shared storage and recovery-adjacent pages as canonical inventory context, but that visibility still does not widen Pulse's recovery support claim or imply restore capability for either platform. +That same bounded mock contract now also requires one shared fixture authority. +When storage or recovery surfaces render mock TrueNAS pools, datasets, +snapshots, replication context, or mock VMware datastores, that inventory must +come from the same `internal/mock/` platform fixture owner that drives settings +payloads and unified runtime inventory, rather than recovery-local fixture +assembly or page-local compatibility fallbacks. Storage and recovery must not infer VMware restore support, recovery rollups, or VMware-local protection semantics from the presence of those datastores or VM snapshot-read context on the shared pages. diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 173592bf6..74a40e7f9 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -348,6 +348,121 @@ func TestContract_PlatformMockToggleRebindsRuntimeConnectionsAndResources(t *tes assertResourceSource("/api/resources?source=vmware-vsphere", unifiedresources.SourceVMware) } +func TestContract_PlatformMockConnectionListsUseSharedFixtureMetadata(t *testing.T) { + setTrueNASFeatureForTest(t, true) + setVMwareFeatureForTest(t, true) + + prevMock := mock.IsMockEnabled() + mock.SetEnabled(true) + t.Cleanup(func() { + mock.SetEnabled(prevMock) + }) + + t.Run("truenas", func(t *testing.T) { + fixture := mock.DefaultTrueNASConnectionFixture() + if fixture.CollectedAt.IsZero() { + t.Fatal("expected canonical truenas mock fixture timestamp") + } + + handler, _, _ := newTrueNASHandlersForTest(t, nil) + req := httptest.NewRequest(http.MethodGet, "/api/truenas/connections", nil) + rec := httptest.NewRecorder() + handler.HandleList(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var responses []trueNASConnectionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &responses); err != nil { + t.Fatalf("decode truenas mock list response: %v", err) + } + if len(responses) != 1 { + t.Fatalf("expected 1 mock truenas connection, got %d", len(responses)) + } + + response := responses[0] + if response.ID != fixture.ID || response.Name != fixture.Name || response.Host != fixture.Host || response.Port != fixture.Port { + t.Fatalf("unexpected truenas mock connection metadata: got %+v want fixture %+v", response.TrueNASInstance, fixture) + } + if response.APIKey != "********" { + t.Fatalf("expected redacted truenas api key, got %q", response.APIKey) + } + if response.Poll == nil || response.Poll.IntervalSeconds != fixture.PollIntervalSeconds { + t.Fatalf("expected truenas poll interval %d, got %+v", fixture.PollIntervalSeconds, response.Poll) + } + if response.Poll.LastSuccessAt == nil || !response.Poll.LastSuccessAt.Equal(fixture.CollectedAt) { + t.Fatalf("expected truenas last success at %s, got %+v", fixture.CollectedAt.Format(time.RFC3339), response.Poll) + } + if response.Observed == nil { + t.Fatal("expected truenas observed summary") + } + if response.Observed.Host != fixture.Host || + response.Observed.ResourceID != fixture.ResourceID || + response.Observed.Systems != fixture.Systems || + response.Observed.StoragePools != fixture.StoragePools || + response.Observed.Datasets != fixture.Datasets || + response.Observed.Apps != fixture.Apps || + response.Observed.Disks != fixture.Disks || + response.Observed.RecoveryArtifacts != fixture.RecoveryArtifacts { + t.Fatalf("unexpected truenas observed summary: got %+v want fixture %+v", response.Observed, fixture) + } + if response.Observed.CollectedAt == nil || !response.Observed.CollectedAt.Equal(fixture.CollectedAt) { + t.Fatalf("expected truenas observed collectedAt %s, got %+v", fixture.CollectedAt.Format(time.RFC3339), response.Observed) + } + }) + + t.Run("vmware", func(t *testing.T) { + fixture := mock.DefaultVMwareConnectionFixture() + if fixture.CollectedAt.IsZero() { + t.Fatal("expected canonical vmware mock fixture timestamp") + } + + handler, _ := newVMwareHandlersForTest(t) + req := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil) + rec := httptest.NewRecorder() + handler.HandleList(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var responses []vmwareConnectionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &responses); err != nil { + t.Fatalf("decode vmware mock list response: %v", err) + } + if len(responses) != 1 { + t.Fatalf("expected 1 mock vmware connection, got %d", len(responses)) + } + + response := responses[0] + if response.ID != fixture.ID || response.Name != fixture.Name || response.Host != fixture.Host || response.Port != fixture.Port || response.Username != fixture.Username { + t.Fatalf("unexpected vmware mock connection metadata: got %+v want fixture %+v", response.VMwareVCenterInstance, fixture) + } + if response.Password != "********" { + t.Fatalf("expected redacted vmware password, got %q", response.Password) + } + if response.Poll == nil || response.Poll.IntervalSeconds != fixture.PollIntervalSeconds { + t.Fatalf("expected vmware poll interval %d, got %+v", fixture.PollIntervalSeconds, response.Poll) + } + if response.Poll.LastSuccessAt == nil || !response.Poll.LastSuccessAt.Equal(fixture.CollectedAt) { + t.Fatalf("expected vmware last success at %s, got %+v", fixture.CollectedAt.Format(time.RFC3339), response.Poll) + } + if response.Observed == nil { + t.Fatal("expected vmware observed summary") + } + if response.Observed.Hosts != fixture.Hosts || + response.Observed.VMs != fixture.VMs || + response.Observed.Datastores != fixture.Datastores || + response.Observed.VIRelease != fixture.VIRelease { + t.Fatalf("unexpected vmware observed summary: got %+v want fixture %+v", response.Observed, fixture) + } + if response.Observed.CollectedAt == nil || !response.Observed.CollectedAt.Equal(fixture.CollectedAt) { + t.Fatalf("expected vmware observed collectedAt %s, got %+v", fixture.CollectedAt.Format(time.RFC3339), response.Observed) + } + }) +} + func TestContract_VMwareConnectionListCarriesDegradedObservedSummary(t *testing.T) { setVMwareFeatureForTest(t, true) diff --git a/internal/api/platform_mock_connections.go b/internal/api/platform_mock_connections.go index 602a40f42..8f0dce91e 100644 --- a/internal/api/platform_mock_connections.go +++ b/internal/api/platform_mock_connections.go @@ -1,87 +1,78 @@ package api import ( - "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" - "github.com/rcourtman/pulse-go-rewrite/internal/truenas" - "github.com/rcourtman/pulse-go-rewrite/internal/vmware" ) -const defaultMockPlatformPollIntervalSeconds = 60 - func mockTrueNASConnectionResponses() []trueNASConnectionResponse { - fixtures := truenas.DefaultFixtures() - collectedAt := fixtures.CollectedAt - if collectedAt.IsZero() { - collectedAt = fixtures.System.CollectedAt - } + fixture := mock.DefaultTrueNASConnectionFixture() instance := config.TrueNASInstance{ - ID: "truenas-mock-1", - Name: "Archive NAS", - Host: strings.TrimSpace(fixtures.System.Hostname), - Port: 443, - APIKey: "mock-truenas-api-key", - UseHTTPS: true, - Enabled: true, - PollIntervalSecs: defaultMockPlatformPollIntervalSeconds, + ID: fixture.ID, + Name: fixture.Name, + Host: fixture.Host, + Port: fixture.Port, + APIKey: fixture.APIKey, + UseHTTPS: fixture.UseHTTPS, + Enabled: fixture.Enabled, + PollIntervalSecs: fixture.PollIntervalSeconds, } instance.ApplyDefaults() observed := &monitoring.TrueNASConnectionObservedSummary{ - Host: strings.TrimSpace(fixtures.System.Hostname), - ResourceID: strings.TrimSpace(fixtures.System.Hostname), - Systems: 1, - StoragePools: len(fixtures.Pools), - Datasets: len(fixtures.Datasets), - Apps: len(fixtures.Apps), - Disks: len(fixtures.Disks), - RecoveryArtifacts: len(fixtures.ZFSSnapshots) + len(fixtures.ReplicationTasks), - CollectedAt: mockPlatformTimePointer(collectedAt), + Host: fixture.Host, + ResourceID: fixture.ResourceID, + Systems: fixture.Systems, + StoragePools: fixture.StoragePools, + Datasets: fixture.Datasets, + Apps: fixture.Apps, + Disks: fixture.Disks, + RecoveryArtifacts: fixture.RecoveryArtifacts, + CollectedAt: mockPlatformTimePointer(fixture.CollectedAt), } return []trueNASConnectionResponse{{ TrueNASInstance: instance.Redacted(), Poll: &monitoring.TrueNASConnectionPollStatus{ IntervalSeconds: instance.EffectivePollIntervalSecs(), - LastAttemptAt: mockPlatformTimePointer(collectedAt), - LastSuccessAt: mockPlatformTimePointer(collectedAt), + LastAttemptAt: mockPlatformTimePointer(fixture.CollectedAt), + LastSuccessAt: mockPlatformTimePointer(fixture.CollectedAt), }, Observed: observed, }} } func mockVMwareConnectionResponses() []vmwareConnectionResponse { - snapshot := vmware.DefaultFixtures() - collectedAt := snapshot.CollectedAt + fixture := mock.DefaultVMwareConnectionFixture() instance := config.VMwareVCenterInstance{ - ID: strings.TrimSpace(snapshot.ConnectionID), - Name: strings.TrimSpace(snapshot.ConnectionName), - Host: strings.TrimSpace(snapshot.VCenterHost), - Port: 443, - Username: "administrator@vsphere.local", - Password: "mock-vcenter-password", - Enabled: true, + ID: fixture.ID, + Name: fixture.Name, + Host: fixture.Host, + Port: fixture.Port, + Username: fixture.Username, + Password: fixture.Password, + Enabled: fixture.Enabled, } instance.ApplyDefaults() return []vmwareConnectionResponse{{ VMwareVCenterInstance: instance.Redacted(), Poll: &monitoring.VMwareConnectionPollStatus{ - IntervalSeconds: defaultMockPlatformPollIntervalSeconds, - LastAttemptAt: mockPlatformTimePointer(collectedAt), - LastSuccessAt: mockPlatformTimePointer(collectedAt), + IntervalSeconds: fixture.PollIntervalSeconds, + LastAttemptAt: mockPlatformTimePointer(fixture.CollectedAt), + LastSuccessAt: mockPlatformTimePointer(fixture.CollectedAt), }, Observed: &monitoring.VMwareConnectionObservedSummary{ - CollectedAt: mockPlatformTimePointer(collectedAt), - Hosts: len(snapshot.Hosts), - VMs: len(snapshot.VMs), - Datastores: len(snapshot.Datastores), - VIRelease: strings.TrimSpace(snapshot.VIRelease), + CollectedAt: mockPlatformTimePointer(fixture.CollectedAt), + Hosts: fixture.Hosts, + VMs: fixture.VMs, + Datastores: fixture.Datastores, + VIRelease: fixture.VIRelease, }, }} } diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index 26d70bb0c..2e2a5f7fb 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -3546,7 +3546,7 @@ func TestResourceListWithoutSupplementalProvider(t *testing.T) { func TestSupplementalSnapshotOwnedSources_TrueNASProviders(t *testing.T) { sources := supplementalSnapshotOwnedSources(map[unified.DataSource]SupplementalRecordsProvider{ - unified.SourceTrueNAS: trueNASRecordsAdapter{}, + unified.SourceTrueNAS: mockSupplementalRecordsAdapter{source: unified.SourceTrueNAS}, }, "default") if len(sources) != 1 || sources[0] != unified.SourceTrueNAS { diff --git a/internal/api/router.go b/internal/api/router.go index 5ab0243c5..74f57c06c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1262,8 +1262,8 @@ func (r *Router) syncPlatformSupplementalProviders(mockEnabled bool) { truenas.SetFeatureEnabled(true) vmware.SetFeatureEnabled(true) - trueNASAdapter := trueNASRecordsAdapter{provider: truenas.NewDefaultProvider()} - vmwareAdapter := vmwareRecordsAdapter{provider: vmware.NewDefaultProvider()} + trueNASAdapter := mockSupplementalRecordsAdapter{source: unifiedresources.SourceTrueNAS} + vmwareAdapter := mockSupplementalRecordsAdapter{source: unifiedresources.SourceVMware} if r.resourceHandlers != nil { r.resourceHandlers.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, trueNASAdapter) @@ -1274,6 +1274,9 @@ func (r *Router) syncPlatformSupplementalProviders(mockEnabled bool) { return } + truenas.ResetFeatureEnabledFromEnv() + vmware.ResetFeatureEnabledFromEnv() + if r.resourceHandlers != nil { r.resourceHandlers.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, r.trueNASPoller) r.resourceHandlers.SetSupplementalRecordsProvider(unifiedresources.SourceVMware, r.vmwarePoller) @@ -9063,66 +9066,35 @@ func (w *knowledgeStoreProviderWrapper) GetKnowledge(resourceID string, category return result } -// trueNASRecordsAdapter wraps a truenas.Provider to satisfy SupplementalRecordsProvider. -type trueNASRecordsAdapter struct { - provider *truenas.Provider +type mockSupplementalRecordsAdapter struct { + source unifiedresources.DataSource } -func (a trueNASRecordsAdapter) GetCurrentRecords() []unifiedresources.IngestRecord { - // Legacy behavior: treat this mock adapter as default-org scoped. +func (a mockSupplementalRecordsAdapter) GetCurrentRecords() []unifiedresources.IngestRecord { return a.GetCurrentRecordsForOrg("default") } -func (a trueNASRecordsAdapter) GetCurrentRecordsForOrg(orgID string) []unifiedresources.IngestRecord { +func (a mockSupplementalRecordsAdapter) GetCurrentRecordsForOrg(orgID string) []unifiedresources.IngestRecord { if strings.TrimSpace(orgID) != "" && strings.TrimSpace(orgID) != "default" { return nil } - if a.provider == nil { - return nil - } - return a.provider.Records() + return mock.SupplementalRecords(a.source) } -func (a trueNASRecordsAdapter) SupplementalRecords(_ *monitoring.Monitor, orgID string) []unifiedresources.IngestRecord { +func (a mockSupplementalRecordsAdapter) SupplementalRecords(_ *monitoring.Monitor, orgID string) []unifiedresources.IngestRecord { return a.GetCurrentRecordsForOrg(orgID) } -func (a trueNASRecordsAdapter) SnapshotOwnedSources() []unifiedresources.DataSource { - return []unifiedresources.DataSource{unifiedresources.SourceTrueNAS} -} - -func (a trueNASRecordsAdapter) SnapshotOwnedSourcesForOrg(string) []unifiedresources.DataSource { - return []unifiedresources.DataSource{unifiedresources.SourceTrueNAS} -} - -type vmwareRecordsAdapter struct { - provider *vmware.Provider -} - -func (a vmwareRecordsAdapter) GetCurrentRecords() []unifiedresources.IngestRecord { - return a.GetCurrentRecordsForOrg("default") -} - -func (a vmwareRecordsAdapter) GetCurrentRecordsForOrg(orgID string) []unifiedresources.IngestRecord { - if strings.TrimSpace(orgID) != "" && strings.TrimSpace(orgID) != "default" { +func (a mockSupplementalRecordsAdapter) SnapshotOwnedSources() []unifiedresources.DataSource { + normalized := normalizeDataSourceAlias(a.source) + if normalized == "" { return nil } - if a.provider == nil { - return nil - } - return a.provider.Records() + return []unifiedresources.DataSource{normalized} } -func (a vmwareRecordsAdapter) SupplementalRecords(_ *monitoring.Monitor, orgID string) []unifiedresources.IngestRecord { - return a.GetCurrentRecordsForOrg(orgID) -} - -func (a vmwareRecordsAdapter) SnapshotOwnedSources() []unifiedresources.DataSource { - return []unifiedresources.DataSource{unifiedresources.SourceVMware} -} - -func (a vmwareRecordsAdapter) SnapshotOwnedSourcesForOrg(string) []unifiedresources.DataSource { - return []unifiedresources.DataSource{unifiedresources.SourceVMware} +func (a mockSupplementalRecordsAdapter) SnapshotOwnedSourcesForOrg(string) []unifiedresources.DataSource { + return a.SnapshotOwnedSources() } // trigger rebuild Fri Jan 16 10:52:41 UTC 2026 diff --git a/internal/api/router_mock_platforms_test.go b/internal/api/router_mock_platforms_test.go index 9b51298b7..f91e98d4a 100644 --- a/internal/api/router_mock_platforms_test.go +++ b/internal/api/router_mock_platforms_test.go @@ -8,7 +8,9 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/mock" + "github.com/rcourtman/pulse-go-rewrite/internal/truenas" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/internal/vmware" ) func TestRouterMockMode_SeedsTrueNASAndVMwareSupplementalResources(t *testing.T) { @@ -67,3 +69,39 @@ func TestRouterMockMode_SeedsTrueNASAndVMwareSupplementalResources(t *testing.T) }) } } + +func TestRouterMockMode_RestoresPlatformFeatureFlagsAfterDisable(t *testing.T) { + t.Setenv(truenas.FeatureTrueNAS, "false") + t.Setenv(vmware.FeatureVMware, "false") + + previousTrueNAS := truenas.IsFeatureEnabled() + previousVMware := vmware.IsFeatureEnabled() + truenas.ResetFeatureEnabledFromEnv() + vmware.ResetFeatureEnabledFromEnv() + t.Cleanup(func() { + truenas.SetFeatureEnabled(previousTrueNAS) + vmware.SetFeatureEnabled(previousVMware) + }) + + cfg := &config.Config{DataPath: t.TempDir()} + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + t.Cleanup(func() { + router.shutdownBackgroundWorkers() + }) + + router.syncPlatformSupplementalProviders(true) + if !truenas.IsFeatureEnabled() { + t.Fatal("expected mock mode to force-enable TrueNAS feature flag") + } + if !vmware.IsFeatureEnabled() { + t.Fatal("expected mock mode to force-enable VMware feature flag") + } + + router.syncPlatformSupplementalProviders(false) + if truenas.IsFeatureEnabled() { + t.Fatal("expected disabling mock mode to restore TrueNAS feature flag from env") + } + if vmware.IsFeatureEnabled() { + t.Fatal("expected disabling mock mode to restore VMware feature flag from env") + } +} diff --git a/internal/mock/platform_fixtures.go b/internal/mock/platform_fixtures.go new file mode 100644 index 000000000..957a9a11c --- /dev/null +++ b/internal/mock/platform_fixtures.go @@ -0,0 +1,174 @@ +package mock + +import ( + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/truenas" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/internal/vmware" +) + +const DefaultPlatformPollIntervalSeconds = 60 + +type PlatformFixtures struct { + TrueNAS truenas.FixtureSnapshot + VMware vmware.InventorySnapshot +} + +type TrueNASConnectionFixture struct { + ID string + Name string + Host string + Port int + APIKey string + UseHTTPS bool + Enabled bool + PollIntervalSeconds int + CollectedAt time.Time + ResourceID string + Systems int + StoragePools int + Datasets int + Apps int + Disks int + RecoveryArtifacts int +} + +type VMwareConnectionFixture struct { + ID string + Name string + Host string + Port int + Username string + Password string + Enabled bool + PollIntervalSeconds int + CollectedAt time.Time + Hosts int + VMs int + Datastores int + VIRelease string +} + +func DefaultPlatformFixtures() PlatformFixtures { + return PlatformFixtures{ + TrueNAS: truenas.DefaultFixtures(), + VMware: vmware.DefaultFixtures(), + } +} + +func DefaultTrueNASConnectionFixture() TrueNASConnectionFixture { + fixtures := DefaultPlatformFixtures().TrueNAS + collectedAt := trueNASCollectedAt(fixtures) + host := strings.TrimSpace(fixtures.System.Hostname) + + return TrueNASConnectionFixture{ + ID: "truenas-mock-1", + Name: "Archive NAS", + Host: host, + Port: 443, + APIKey: "mock-truenas-api-key", + UseHTTPS: true, + Enabled: true, + PollIntervalSeconds: DefaultPlatformPollIntervalSeconds, + CollectedAt: collectedAt, + ResourceID: host, + Systems: 1, + StoragePools: len(fixtures.Pools), + Datasets: len(fixtures.Datasets), + Apps: len(fixtures.Apps), + Disks: len(fixtures.Disks), + RecoveryArtifacts: len(fixtures.ZFSSnapshots) + len(fixtures.ReplicationTasks), + } +} + +func DefaultVMwareConnectionFixture() VMwareConnectionFixture { + fixtures := DefaultPlatformFixtures().VMware + + return VMwareConnectionFixture{ + ID: strings.TrimSpace(fixtures.ConnectionID), + Name: strings.TrimSpace(fixtures.ConnectionName), + Host: strings.TrimSpace(fixtures.VCenterHost), + Port: 443, + Username: "administrator@vsphere.local", + Password: "mock-vcenter-password", + Enabled: true, + PollIntervalSeconds: DefaultPlatformPollIntervalSeconds, + CollectedAt: fixtures.CollectedAt, + Hosts: len(fixtures.Hosts), + VMs: len(fixtures.VMs), + Datastores: len(fixtures.Datastores), + VIRelease: strings.TrimSpace(fixtures.VIRelease), + } +} + +func SupplementalRecords(source unifiedresources.DataSource) []unifiedresources.IngestRecord { + switch normalizePlatformSource(source) { + case unifiedresources.SourceTrueNAS: + return truenas.FixtureRecords(DefaultPlatformFixtures().TrueNAS) + case unifiedresources.SourceVMware: + return vmware.FixtureRecords(DefaultPlatformFixtures().VMware) + default: + return nil + } +} + +func PlatformOwnedSources() []unifiedresources.DataSource { + return []unifiedresources.DataSource{ + unifiedresources.SourceTrueNAS, + unifiedresources.SourceVMware, + } +} + +func UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { + if !IsMockEnabled() { + return nil, time.Time{} + } + + fixtures := DefaultPlatformFixtures() + snapshot := GetMockState() + + registry := unifiedresources.NewRegistry(nil) + registry.IngestSnapshot(unifiedresources.SnapshotWithoutSources(snapshot, PlatformOwnedSources())) + for _, source := range PlatformOwnedSources() { + records := SupplementalRecords(source) + if len(records) == 0 { + continue + } + registry.IngestRecords(source, records) + } + + freshness := snapshot.LastUpdate + for _, candidate := range []time.Time{ + trueNASCollectedAt(fixtures.TrueNAS), + fixtures.VMware.CollectedAt, + } { + if candidate.IsZero() { + continue + } + if freshness.IsZero() || candidate.After(freshness) { + freshness = candidate + } + } + + return registry.List(), freshness +} + +func trueNASCollectedAt(fixtures truenas.FixtureSnapshot) time.Time { + if !fixtures.CollectedAt.IsZero() { + return fixtures.CollectedAt + } + return fixtures.System.CollectedAt +} + +func normalizePlatformSource(source unifiedresources.DataSource) unifiedresources.DataSource { + switch strings.ToLower(strings.TrimSpace(string(source))) { + case "truenas": + return unifiedresources.SourceTrueNAS + case "vmware", "vmware-vsphere": + return unifiedresources.SourceVMware + default: + return "" + } +} diff --git a/internal/mock/platform_fixtures_test.go b/internal/mock/platform_fixtures_test.go new file mode 100644 index 000000000..e5dc3197b --- /dev/null +++ b/internal/mock/platform_fixtures_test.go @@ -0,0 +1,44 @@ +package mock + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +func TestUnifiedResourceSnapshotIncludesPlatformFixtures(t *testing.T) { + previous := IsMockEnabled() + SetEnabled(true) + t.Cleanup(func() { SetEnabled(previous) }) + + resources, freshness := UnifiedResourceSnapshot() + if len(resources) == 0 { + t.Fatal("expected unified resources in mock mode") + } + if freshness.IsZero() { + t.Fatal("expected non-zero freshness for mock unified resources") + } + + wantNames := map[string]bool{ + "truenas-main": false, + "esxi-01.lab.local": false, + "orders-api-01": false, + } + for _, resource := range resources { + if _, ok := wantNames[resource.Name]; ok { + wantNames[resource.Name] = true + } + } + for name, found := range wantNames { + if !found { + t.Fatalf("expected mock unified resources to include %q", name) + } + } +} + +func TestSupplementalRecordsNormalizesVMwareAlias(t *testing.T) { + records := SupplementalRecords(unifiedresources.DataSource("vmware-vsphere")) + if len(records) == 0 { + t.Fatal("expected records for vmware-vsphere alias") + } +} diff --git a/internal/mock/recovery_points.go b/internal/mock/recovery_points.go index 78644961e..70fe3f589 100644 --- a/internal/mock/recovery_points.go +++ b/internal/mock/recovery_points.go @@ -376,12 +376,22 @@ func generateMockRecoveryPoints() []recovery.RecoveryPoint { } // TrueNAS: 3 dataset subjects with multiple points over time. - truenasConnID := "truenas-mock-1" - truenasHost := "truenas.local" - truenasDatasets := []string{ - "tank/apps/postgres", - "tank/apps/minio", - "tank/media/photos", + truenasConnection := DefaultTrueNASConnectionFixture() + truenasConnID := truenasConnection.ID + truenasHost := truenasConnection.Host + truenasDatasets := make([]string, 0, 3) + for _, dataset := range DefaultPlatformFixtures().TrueNAS.Datasets { + name := strings.TrimSpace(dataset.Name) + if name == "" { + continue + } + truenasDatasets = append(truenasDatasets, name) + if len(truenasDatasets) == 3 { + break + } + } + if len(truenasDatasets) == 0 { + truenasDatasets = []string{"tank/apps", "tank/media", "archive/backups"} } // ZFS snapshots (snapshot / snapshot): success points with meaningful details. diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 3d2242183..46a6c597a 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -260,6 +260,23 @@ func TestUnifiedAppContainerMetricsUseCanonicalGuestHistoryPath(t *testing.T) { } } +func TestMockUnifiedStateViewUsesCanonicalMockFixtureGraph(t *testing.T) { + data, err := os.ReadFile("monitor.go") + if err != nil { + t.Fatalf("failed to read monitor.go: %v", err) + } + source := string(data) + requiredSnippets := []string{ + "resources, freshness := mock.UnifiedResourceSnapshot()", + "return monitorUnifiedStateViewFromResources(resources, freshness)", + } + for _, snippet := range requiredSnippets { + if !strings.Contains(source, snippet) { + t.Fatalf("monitor.go must contain %q", snippet) + } + } +} + func TestUnifiedAgentMetricsUseCanonicalHostHistoryPath(t *testing.T) { data, err := os.ReadFile("monitor.go") if err != nil { diff --git a/internal/monitoring/mock_metrics_history.go b/internal/monitoring/mock_metrics_history.go index 806909d72..c9c6cb294 100644 --- a/internal/monitoring/mock_metrics_history.go +++ b/internal/monitoring/mock_metrics_history.go @@ -11,7 +11,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" - "github.com/rcourtman/pulse-go-rewrite/internal/truenas" "github.com/rcourtman/pulse-go-rewrite/internal/vmware" "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" "github.com/rs/zerolog/log" @@ -876,13 +875,13 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, state models. time.Sleep(50 * time.Millisecond) } - // Seed TrueNAS pool/dataset disk-usage metrics - fixtures := truenas.DefaultFixtures() - log.Debug().Int("pools", len(fixtures.Pools)).Int("datasets", len(fixtures.Datasets)).Msg("mock seeding: processing TrueNAS fixtures") + platformFixtures := mock.DefaultPlatformFixtures() + trueNASFixtures := platformFixtures.TrueNAS + log.Debug().Int("pools", len(trueNASFixtures.Pools)).Int("datasets", len(trueNASFixtures.Datasets)).Msg("mock seeding: processing TrueNAS fixtures") // System host: aggregated disk usage across all pools totalCap, totalUsed := int64(0), int64(0) - for _, pool := range fixtures.Pools { + for _, pool := range trueNASFixtures.Pools { totalCap += pool.TotalBytes totalUsed += pool.UsedBytes } @@ -890,10 +889,10 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, state models. if totalCap > 0 { systemDisk = float64(totalUsed) / float64(totalCap) * 100 } - systemKey := "system:" + fixtures.System.Hostname - recordGuest(systemKey, "truenas", fixtures.System.Hostname, 0, 0, systemDisk, 0, 0, 0, 0, true, false, false) + systemKey := "system:" + trueNASFixtures.System.Hostname + recordGuest(systemKey, "truenas", trueNASFixtures.System.Hostname, 0, 0, systemDisk, 0, 0, 0, 0, true, false, false) - for _, pool := range fixtures.Pools { + for _, pool := range trueNASFixtures.Pools { poolKey := "pool:" + pool.Name diskPercent := float64(0) if pool.TotalBytes > 0 { @@ -910,7 +909,7 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, state models. queueMetric("pool", pool.Name, "disk", diskPercent, now) } - for _, dataset := range fixtures.Datasets { + for _, dataset := range trueNASFixtures.Datasets { dsKey := "dataset:" + dataset.Name totalBytes := dataset.UsedBytes + dataset.AvailBytes diskPercent := float64(0) @@ -928,7 +927,7 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, state models. queueMetric("dataset", dataset.Name, "disk", diskPercent, now) } - vmwareFixtures := vmware.DefaultFixtures() + vmwareFixtures := platformFixtures.VMware vmwareDatastoreUsage := vmwareDatastoreUsageByID(vmwareFixtures.Datastores) log.Debug(). Int("hosts", len(vmwareFixtures.Hosts)). @@ -1008,7 +1007,7 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, state models. // recordTrueNASFixturesMetrics records disk usage metrics for TrueNAS pools and datasets. func recordTrueNASFixturesMetrics(mh *MetricsHistory, ms *metrics.Store, ts time.Time) { - fixtures := truenas.DefaultFixtures() + fixtures := mock.DefaultPlatformFixtures().TrueNAS totalCap, totalUsed := int64(0), int64(0) for _, pool := range fixtures.Pools { @@ -1049,7 +1048,7 @@ func recordTrueNASFixturesMetrics(mh *MetricsHistory, ms *metrics.Store, ts time } func recordVMwareFixturesMetrics(mh *MetricsHistory, ms *metrics.Store, ts time.Time) { - snapshot := vmware.DefaultFixtures() + snapshot := mock.DefaultPlatformFixtures().VMware datastoreUsage := vmwareDatastoreUsageByID(snapshot.Datastores) for _, host := range snapshot.Hosts { diff --git a/internal/monitoring/mock_metrics_history_test.go b/internal/monitoring/mock_metrics_history_test.go index bc5f7db96..1b935ea06 100644 --- a/internal/monitoring/mock_metrics_history_test.go +++ b/internal/monitoring/mock_metrics_history_test.go @@ -3,6 +3,7 @@ package monitoring import ( "context" "math" + "strings" "testing" "time" @@ -310,6 +311,51 @@ func TestSeedMockMetricsHistory_SeedsVMwareMetricsStore(t *testing.T) { } } +func TestSeedMockMetricsHistory_SeedsTrueNASMetricsStore(t *testing.T) { + now := time.Now() + state := models.StateSnapshot{} + fixtures := mock.DefaultPlatformFixtures().TrueNAS + + if strings.TrimSpace(fixtures.System.Hostname) == "" { + t.Fatal("expected canonical truenas system hostname fixture") + } + if len(fixtures.Datasets) == 0 { + t.Fatal("expected canonical truenas dataset fixtures") + } + + cfg := metrics.DefaultConfig(t.TempDir()) + cfg.RetentionRaw = 90 * 24 * time.Hour + cfg.RetentionMinute = 90 * 24 * time.Hour + cfg.RetentionHourly = 90 * 24 * time.Hour + cfg.RetentionDaily = 90 * 24 * time.Hour + cfg.WriteBufferSize = 500 + + store, err := metrics.NewStore(cfg) + if err != nil { + t.Fatalf("failed to create metrics store: %v", err) + } + defer store.Close() + + mh := NewMetricsHistory(1000, 7*24*time.Hour) + seedMockMetricsHistory(mh, store, state, now, 7*24*time.Hour, time.Minute) + + systemPoints, err := store.Query("truenas", fixtures.System.Hostname, "disk", now.Add(-7*24*time.Hour), now, 3600) + if err != nil { + t.Fatalf("failed to query TrueNAS system disk metrics: %v", err) + } + if len(systemPoints) == 0 { + t.Fatal("expected metrics store to have seeded TrueNAS system disk points") + } + + datasetPoints, err := store.Query("dataset", fixtures.Datasets[0].Name, "disk", now.Add(-7*24*time.Hour), now, 3600) + if err != nil { + t.Fatalf("failed to query TrueNAS dataset disk metrics: %v", err) + } + if len(datasetPoints) == 0 { + t.Fatal("expected metrics store to have seeded TrueNAS dataset disk points") + } +} + func TestStartMockMetricsSampler_DoesNotClearExistingMetricsStoreData(t *testing.T) { t.Setenv("PULSE_MOCK_NODES", "1") t.Setenv("PULSE_MOCK_VMS_PER_NODE", "0") diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 708a083ab..19b726687 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -4004,6 +4004,10 @@ func (m *Monitor) currentUnifiedStateView() monitorUnifiedStateView { } if mock.IsMockEnabled() { + resources, freshness := mock.UnifiedResourceSnapshot() + if len(resources) > 0 || !freshness.IsZero() { + return monitorUnifiedStateViewFromResources(resources, freshness) + } return monitorUnifiedStateViewFromSnapshot(m.GetState()) } @@ -4058,8 +4062,8 @@ func unifiedResourceFreshness(store ResourceStoreInterface, state *models.State) } // UnifiedResourceSnapshot returns a canonical unified-resource seed plus the -// associated freshness marker. It respects mock mode by falling back to the -// current mock-aware state snapshot instead of the live resource store. +// associated freshness marker. In mock mode it returns the shared mock +// unified-resource fixture graph rather than the live resource store. func (m *Monitor) UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { view := m.currentUnifiedStateView() return view.resources, view.freshness diff --git a/internal/monitoring/monitor_unified_state_test.go b/internal/monitoring/monitor_unified_state_test.go index b6191182d..f4b7d3026 100644 --- a/internal/monitoring/monitor_unified_state_test.go +++ b/internal/monitoring/monitor_unified_state_test.go @@ -177,7 +177,7 @@ func TestMonitorUnifiedResourceSnapshotPrefersStoreFreshness(t *testing.T) { } } -func TestMonitorGetUnifiedReadStateOrSnapshotUsesMockSnapshotInsteadOfStore(t *testing.T) { +func TestMonitorGetUnifiedReadStateOrSnapshotUsesCanonicalMockUnifiedResources(t *testing.T) { mock.SetEnabled(true) t.Cleanup(func() { mock.SetEnabled(false) }) @@ -207,7 +207,14 @@ func TestMonitorGetUnifiedReadStateOrSnapshotUsesMockSnapshotInsteadOfStore(t *t t.Fatalf("expected read-state adapter with GetAll, got %T", readState) } - if hasUnifiedResource(getter.GetAll(), "store-only-resource") { + resources := getter.GetAll() + if hasUnifiedResource(resources, "store-only-resource") { t.Fatal("expected mock-mode read-state to ignore live resource store data") } + if !hasUnifiedResourceName(resources, "truenas-main") { + t.Fatalf("expected mock-mode read-state to include TrueNAS mock resources, got %#v", resources) + } + if !hasUnifiedResourceName(resources, "esxi-01.lab.local") { + t.Fatalf("expected mock-mode read-state to include VMware mock resources, got %#v", resources) + } } diff --git a/internal/truenas/fixture_records_test.go b/internal/truenas/fixture_records_test.go new file mode 100644 index 000000000..8e14622f9 --- /dev/null +++ b/internal/truenas/fixture_records_test.go @@ -0,0 +1,23 @@ +package truenas + +import "testing" + +func TestFixtureRecordsIgnoreFeatureFlag(t *testing.T) { + previous := IsFeatureEnabled() + SetFeatureEnabled(false) + t.Cleanup(func() { SetFeatureEnabled(previous) }) + + records := FixtureRecords(DefaultFixtures()) + if len(records) == 0 { + t.Fatal("expected fixture records even when feature flag is disabled") + } + + systemID := systemSourceID("truenas-main") + for _, record := range records { + if record.SourceID == systemID { + return + } + } + + t.Fatalf("expected fixture records to include system source %q", systemID) +} diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index 67fb90939..ded44294c 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -40,6 +40,12 @@ func SetFeatureEnabled(enabled bool) { featureTrueNASEnabled.Store(enabled) } +// ResetFeatureEnabledFromEnv restores the feature flag from the current +// environment configuration. +func ResetFeatureEnabledFromEnv() { + featureTrueNASEnabled.Store(parseFeatureEnabled(os.Getenv(FeatureTrueNAS))) +} + // Fetcher loads a TrueNAS snapshot from a concrete source. type Fetcher interface { Fetch(ctx context.Context) (*FixtureSnapshot, error) @@ -415,20 +421,36 @@ func (p *Provider) Snapshot() *FixtureSnapshot { return snapshot } +// FixtureRecords projects a TrueNAS fixture snapshot into canonical unified +// resource ingest records without consulting the runtime feature flag. +func FixtureRecords(snapshot FixtureSnapshot) []unifiedresources.IngestRecord { + return truenasRecordsFromSnapshot(&snapshot, nil) +} + // Records returns unified records if the feature flag is enabled. func (p *Provider) Records() []unifiedresources.IngestRecord { if p == nil || !IsFeatureEnabled() { return nil } - snapshot := p.Snapshot() + return truenasRecordsFromSnapshot(p.Snapshot(), p.now) +} + +func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, now func() time.Time) []unifiedresources.IngestRecord { if snapshot == nil { return nil } collectedAt := snapshot.CollectedAt if collectedAt.IsZero() { - collectedAt = p.now() + collectedAt = snapshot.System.CollectedAt + } + if collectedAt.IsZero() { + if now != nil { + collectedAt = now().UTC() + } else { + collectedAt = time.Now().UTC() + } } systemSourceID := systemSourceID(snapshot.System.Hostname) systemAssessment := assessSystemStorage(snapshot) diff --git a/internal/vmware/client.go b/internal/vmware/client.go index 41d4cf7ee..b97ec1b18 100644 --- a/internal/vmware/client.go +++ b/internal/vmware/client.go @@ -45,6 +45,12 @@ func SetFeatureEnabled(enabled bool) { featureVMwareEnabled.Store(enabled) } +// ResetFeatureEnabledFromEnv restores the feature flag from the current +// environment configuration. +func ResetFeatureEnabledFromEnv() { + featureVMwareEnabled.Store(parseFeatureEnabled(os.Getenv(FeatureVMware))) +} + func parseFeatureEnabled(raw string) bool { switch strings.TrimSpace(strings.ToLower(raw)) { case "", "1", "true", "yes", "on": diff --git a/internal/vmware/fixture_records_test.go b/internal/vmware/fixture_records_test.go new file mode 100644 index 000000000..d60f84aaa --- /dev/null +++ b/internal/vmware/fixture_records_test.go @@ -0,0 +1,24 @@ +package vmware + +import "testing" + +func TestFixtureRecordsIgnoreFeatureFlag(t *testing.T) { + previous := IsFeatureEnabled() + SetFeatureEnabled(false) + t.Cleanup(func() { SetFeatureEnabled(previous) }) + + snapshot := DefaultFixtures() + records := FixtureRecords(snapshot) + if len(records) == 0 { + t.Fatal("expected fixture records even when feature flag is disabled") + } + + hostID := vmwareSourceID(snapshot.ConnectionID, "host", snapshot.Hosts[0].Host) + for _, record := range records { + if record.SourceID == hostID { + return + } + } + + t.Fatalf("expected fixture records to include host source %q", hostID) +} diff --git a/internal/vmware/provider.go b/internal/vmware/provider.go index 8ccedcbb7..5401af88a 100644 --- a/internal/vmware/provider.go +++ b/internal/vmware/provider.go @@ -311,20 +311,33 @@ func (p *Provider) Snapshot() *InventorySnapshot { return snapshot } +// FixtureRecords projects a VMware fixture snapshot into canonical unified +// resource ingest records without consulting the runtime feature flag. +func FixtureRecords(snapshot InventorySnapshot) []unifiedresources.IngestRecord { + return vmwareRecordsFromSnapshot(&snapshot, nil) +} + // Records returns canonical VMware unified resources if the integration is enabled. func (p *Provider) Records() []unifiedresources.IngestRecord { if p == nil || !IsFeatureEnabled() { return nil } - snapshot := p.Snapshot() + return vmwareRecordsFromSnapshot(p.Snapshot(), p.now) +} + +func vmwareRecordsFromSnapshot(snapshot *InventorySnapshot, now func() time.Time) []unifiedresources.IngestRecord { if snapshot == nil { return nil } collectedAt := snapshot.CollectedAt if collectedAt.IsZero() { - collectedAt = p.now() + if now != nil { + collectedAt = now().UTC() + } else { + collectedAt = time.Now().UTC() + } } connectionName := firstNonEmptyTrimmed(snapshot.ConnectionName, snapshot.VCenterHost, snapshot.ConnectionID)