From b6cf0109e53bc9d4e740a4eee92b66c4d1e0f379 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 6 Aug 2026 11:05:05 +0100 Subject: [PATCH] fix(mock): keep real configured sources out of the mock connections ledger Mock mode never initialises real PVE/PBS/PMG clients and does not run the platform pollers, so every configured real source sat in the connections ledger at "awaiting first poll" forever. That published real connection names and addresses through /api/connections while the rest of the payload was authored fixtures, and surfaced them on monitoring copy: the Proxmox workloads empty state rendered "Collection pending: minipc" next to three mock nodes. /api/config/nodes already substitutes mock entries wholesale in mock mode and rejects node mutations outright, so the ledger was the one surface that had not been brought in line. Move the mock-mode input shaping into applyMockLedgerInputs in platform_mock_connections.go, which already owns the mock vSphere, TrueNAS, and availability ledger fixtures, and drop the config and persistence derived sources there. PULSE_MOCK_KEEP_REAL_POLLING keeps the previous behaviour, since those sources do collect under it. Proof is at the payload level rather than the aggregator inputs: the new handler test asserts the served /api/connections body contains no real source by name or address, and was verified red before the fix. Contract-Neutral: agent-lifecycle is named only by the broad internal/api/ Extension Points prefix and this change does not move that boundary: agent rows come from the monitor hosts snapshot, which is untouched. The api-contracts and storage-recovery deltas cover the boundaries actually moved. No payload field was added or changed, so the backend-API-payload proof list does not apply; the handler-level payload proof is TestConnectionsHandleListDropsRealSourcesInMockMode in internal/api/connections_handlers_mock_test.go, verified red without the fix. --- .../v6/internal/subsystems/api-contracts.md | 10 +++ .../internal/subsystems/storage-recovery.md | 8 +++ internal/api/connections_alerts.go | 13 +--- .../api/connections_handlers_mock_test.go | 71 +++++++++++++++++++ internal/api/platform_mock_connections.go | 56 +++++++++++++++ 5 files changed, 146 insertions(+), 12 deletions(-) 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()