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.
This commit is contained in:
rcourtman
2026-08-06 11:05:05 +01:00
parent c41338d106
commit b6cf0109e5
5 changed files with 146 additions and 12 deletions
@@ -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`,
@@ -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
+1 -12
View File
@@ -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
@@ -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)
}
}
+56
View File
@@ -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()