diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index fa20b1f24..19aa5ecee 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1971,6 +1971,16 @@ a new API state machine, queue contract, or verification-accounting field. only a fetch lifecycle rule; it must not synthesize rows, downgrade backend fleet state, or replace the shared connection projection with page-local placeholders. + In mock mode that ledger is a clean room, composed in + `internal/api/platform_mock_connections.go`: real configured PVE, PBS, PMG, + vSphere, TrueNAS, and availability sources are dropped from the aggregator + inputs and only authored fixtures compose the payload, matching the + substitution `/api/config/nodes` already performs. Real clients are never + initialised while mock mode is on, so retaining those rows would publish + real connection names and addresses through an otherwise synthetic payload + and report a collection state that mock mode itself suspended. The single + exception is the `PULSE_MOCK_KEEP_REAL_POLLING` opt-in, where the configured + sources genuinely do collect and remain in the ledger. 2b. Route agentless availability target kind changes through `internal/api/availability_handlers.go`, `internal/api/platform_mock_connections.go`, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index e62b481a7..0506de468 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1864,6 +1864,14 @@ must not treat starter data remains inventory-only context and must not be treated as proof of restore capability, recovery artifacts, or widened platform recovery support. + The mock connections ledger composed in + `internal/api/platform_mock_connections.go` is bounded the same way: while + mock mode is on, real configured TrueNAS and vSphere sources are dropped + from the aggregator inputs and only authored fixtures compose the storage + source rows, so a demo payload never advertises a real storage appliance + that mock mode has suspended from collection. The + `PULSE_MOCK_KEEP_REAL_POLLING` opt-in is the single exception, because + those sources genuinely do collect under it. 13. Keep runtime mock platform context derived from one shared fixture graph. When shared `internal/api/` and monitoring wiring surface mock storage/recovery-adjacent inventory or recovery artifacts, that data must diff --git a/internal/api/connections_alerts.go b/internal/api/connections_alerts.go index 4cf1eca92..8916cfaa6 100644 --- a/internal/api/connections_alerts.go +++ b/internal/api/connections_alerts.go @@ -71,18 +71,7 @@ func buildAggregatorInputsWithRuntimeSources( inputs.availabilityStatuses = map[string]monitoring.AvailabilityProbeStatus{} } if mock.IsMockEnabled() { - mockTargets, mockStatuses := mockAvailabilityConnectionInputs() - inputs.availabilityTargets = mergeAvailabilityTargets(inputs.availabilityTargets, mockTargets) - inputs.availabilityStatuses = mergeAvailabilityStatuses(inputs.availabilityStatuses, mockStatuses) - // Mock vSphere/TrueNAS pollers feed the fabric but never persistence, - // so without these the mock ledger has no platform source rows for - // the machines those integrations monitor. - if len(inputs.vmwareInstances) == 0 { - inputs.vmwareInstances, inputs.vmwareSummaries = mockVMwareLedgerInputs() - } - if len(inputs.truenasInstances) == 0 { - inputs.truenasInstances, inputs.truenasSummaries = mockTrueNASLedgerInputs() - } + inputs = applyMockLedgerInputs(inputs) } inputs.expectedAgentVersion = currentAgentTargetVersion() _ = ctx diff --git a/internal/api/connections_handlers_mock_test.go b/internal/api/connections_handlers_mock_test.go index a03c65ab0..cb73456a8 100644 --- a/internal/api/connections_handlers_mock_test.go +++ b/internal/api/connections_handlers_mock_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -68,3 +69,73 @@ func TestConnectionsHandleListIncludesMockAvailabilityTargets(t *testing.T) { t.Fatalf("expected availability error metadata, got %+v", door.LastError) } } + +// TestConnectionsHandleListDropsRealSourcesInMockMode asserts the served +// /api/connections payload, not just the aggregator inputs: mock mode is a +// clean room, so no real configured source may reach the wire, by name or by +// address. +func TestConnectionsHandleListDropsRealSourcesInMockMode(t *testing.T) { + previous := mock.IsMockEnabled() + if err := mock.SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { _ = mock.SetEnabled(previous) }) + + cfg := &config.Config{ + PVEInstances: []config.PVEInstance{{Name: "minipc", Host: "https://minipc:8006"}}, + PBSInstances: []config.PBSInstance{{Name: "backup-vault", Host: "https://backup-vault:8007"}}, + PMGInstances: []config.PMGInstance{{Name: "mail-relay", Host: "https://mail-relay:8006"}}, + } + + handler := NewConnectionsHandlers( + func(context.Context) *config.Config { return cfg }, + func(context.Context) *config.ConfigPersistence { return nil }, + func(context.Context) *monitoring.Monitor { return nil }, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/connections", nil) + rec := httptest.NewRecorder() + handler.HandleList(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("HandleList status = %d, body=%s", rec.Code, rec.Body.String()) + } + + body := rec.Body.String() + for _, secret := range []string{"minipc", "backup-vault", "mail-relay"} { + if strings.Contains(body, secret) { + t.Fatalf("real source %q leaked into the mock connections payload: %s", secret, body) + } + } + + var response ConnectionsListResponse + if err := json.NewDecoder(strings.NewReader(body)).Decode(&response); err != nil { + t.Fatalf("decode connections response: %v", err) + } + for _, conn := range response.Connections { + switch conn.Type { + case ConnectionTypePVE, ConnectionTypePBS, ConnectionTypePMG: + t.Fatalf("mock ledger must not carry configured platform rows, got %+v", conn) + } + } + if len(response.Connections) == 0 { + t.Fatal("mock ledger must still compose authored fixture rows") + } +} + +func TestConnectionsLedgerKeepsRealSourcesWhenRealPollingRetained(t *testing.T) { + previous := mock.IsMockEnabled() + if err := mock.SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { _ = mock.SetEnabled(previous) }) + t.Setenv("PULSE_MOCK_KEEP_REAL_POLLING", "true") + + cfg := &config.Config{ + PVEInstances: []config.PVEInstance{{Name: "minipc", Host: "https://minipc:8006"}}, + } + + inputs := buildAggregatorInputsWithRuntimeSources(context.Background(), cfg, nil, nil, aggregatorRuntimeSources{}) + if len(inputs.pveInstances) != 1 { + t.Fatalf("real polling opt-in must keep configured sources, got %+v", inputs.pveInstances) + } +} diff --git a/internal/api/platform_mock_connections.go b/internal/api/platform_mock_connections.go index 57ce512f2..3af7b9b68 100644 --- a/internal/api/platform_mock_connections.go +++ b/internal/api/platform_mock_connections.go @@ -1,6 +1,8 @@ package api import ( + "os" + "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -9,6 +11,60 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/truenas" ) +// mockKeepsRealPolling reports whether the operator opted real PVE/PBS/PMG +// polling back in while mock mode is enabled. The monitor reads the same +// variable to decide whether to build real clients at all (see +// keepRealPollingInMockMode in internal/monitoring/monitor.go); the ledger +// needs the same answer to tell a source that mock mode suspended apart from +// one that is genuinely still waiting on its first poll. +func mockKeepsRealPolling() bool { + switch strings.TrimSpace(strings.ToLower(os.Getenv("PULSE_MOCK_KEEP_REAL_POLLING"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// applyMockLedgerInputs shapes the connections aggregator inputs for mock mode. +// +// Mock mode is a clean room: real PVE/PBS/PMG clients are never initialised +// while it is on and the platform pollers do not run, so every configured real +// source can only sit at "awaiting first poll" forever. Leaving those rows in +// the ledger publishes real connection names and addresses through a payload +// that is otherwise entirely authored fixtures, and reports a collection state +// that mock mode itself suspended. /api/config/nodes already substitutes mock +// entries wholesale and rejects node mutations outright, so the ledger is held +// to the same rule. The one exception is the real-polling opt-in, where the +// configured sources genuinely do collect and belong in the ledger. +func applyMockLedgerInputs(inputs aggregatorInputs) aggregatorInputs { + if !mockKeepsRealPolling() { + inputs.pveInstances = nil + inputs.pbsInstances = nil + inputs.pmgInstances = nil + inputs.vmwareInstances = nil + inputs.vmwareSummaries = nil + inputs.truenasInstances = nil + inputs.truenasSummaries = nil + inputs.availabilityTargets = nil + } + + mockTargets, mockStatuses := mockAvailabilityConnectionInputs() + inputs.availabilityTargets = mergeAvailabilityTargets(inputs.availabilityTargets, mockTargets) + inputs.availabilityStatuses = mergeAvailabilityStatuses(inputs.availabilityStatuses, mockStatuses) + + // Mock vSphere/TrueNAS pollers feed the fabric but never persistence, so + // without these the mock ledger has no platform source rows for the + // machines those integrations monitor. + if len(inputs.vmwareInstances) == 0 { + inputs.vmwareInstances, inputs.vmwareSummaries = mockVMwareLedgerInputs() + } + if len(inputs.truenasInstances) == 0 { + inputs.truenasInstances, inputs.truenasSummaries = mockTrueNASLedgerInputs() + } + return inputs +} + func mockTrueNASConnectionResponses() []trueNASConnectionResponse { fixture := mock.DefaultTrueNASConnectionFixture()