Files
pulse/internal/api/runtime_inventory_sources_test.go
T
rcourtman 8823440f33 Stop mock-mode callers that read identity rebuilding the estate view
In mock mode the unified view is keyed on the fixture data version, which
advances on every metric tick, and building it for the default
1,776-resource estate costs about half a second of CPU (many seconds on a
starved process). #2778 moved the metrics-target lookup onto the fixture
structure revision; the other callers that read only what the estate
lists and how it connects still rebuilt the whole view after each tick.
At startup they made 42 view requests, all misses, and with no client
connected the monitor tick alone rebuilt the estate about every nine
seconds.

Callers that read identity and topology now share the view built for the
current structure revision and operator links (currentStructureUnifiedStateView,
GetUnifiedStructureReadState): the Docker-alert prune, the workload chart
prewarm, the demo Patrol resolver, and the host-agent evaluation, which
also lists nodes only when an offline host names a linked node. The
connection-degraded alert feed keeps only platform rows, so it no longer
reads the hosts, their desired-config fingerprints or the PBS instances
that only feed agent rows and a host alias its snapshot drops.

The mock storage chart is not one of them: a linked pool takes its
capacity from whichever source was seen last, which a tick can move
without a structure change. It keeps the data-version view, read once per
request instead of once per uncached pool. The per-node polling lookups
of a monitor that keeps real polling in mock mode (linkedHostForNode,
getHostAgentTemperatureForNode, carriedTemperatureOutlivesAgentLease)
read a host's status, disks and sensors, which a tick moves, so they stay
on the data-version view.

On pulse-dev (one pinned core, host load 30 to 110, probe builds): a
start built the estate view twice before the listener answered with the
wiring change alone and once with this; with no client connected the
process built the estate 33 times in 299 s and used 12.3 s of CPU over a
180 s window, against twice and 3.3 s with this change.
2026-10-10 12:37:42 +01:00

464 lines
16 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"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"
)
func TestRuntimeInventorySourcesProjectsOnlyBlockingWorkloadCoverage(t *testing.T) {
sources := runtimeInventorySources([]Connection{
{
ID: "pve:blocked",
Type: ConnectionTypePVE,
Name: "Blocked PVE",
State: ConnectionStateStale,
Enabled: true,
Surfaces: []string{"backups", "containers", "storage", "vms"},
Scope: map[string]bool{"containers": false, "storage": true, "vms": true},
},
{
ID: "vmware:healthy",
Type: ConnectionTypeVMware,
Name: "Healthy vCenter",
State: ConnectionStateActive,
Enabled: true,
Surfaces: []string{"vms"},
},
{
ID: "docker:disabled",
Type: ConnectionTypeDocker,
Name: "Disabled Docker",
State: ConnectionStateUnreachable,
Enabled: false,
Surfaces: []string{"containers"},
},
{
ID: "pbs:blocked",
Type: ConnectionTypePBS,
Name: "Blocked PBS",
State: ConnectionStateUnreachable,
Enabled: true,
Surfaces: []string{"backups"},
},
})
want := []RuntimeInventorySource{{
Type: ConnectionTypePVE,
Name: "Blocked PVE",
State: ConnectionStateStale,
Surfaces: []string{"vms"},
}}
if !reflect.DeepEqual(sources, want) {
t.Fatalf("runtime inventory sources = %#v, want %#v", sources, want)
}
}
func TestRuntimeInventorySourcesProjectsDegradedCompletenessWithoutRawErrors(t *testing.T) {
sources := runtimeInventorySources([]Connection{{
Type: ConnectionTypeVMware,
Name: "Production vCenter",
State: ConnectionStateActive,
Enabled: true,
Surfaces: []string{"vms"},
inventoryCompleteness: &RuntimeInventoryCompleteness{
State: "degraded",
IssueCount: 3,
Issues: []RuntimeInventoryCompletenessIssue{{
Stage: "tags",
Category: "permission",
Occurrences: 3,
}},
},
}})
if len(sources) != 1 {
t.Fatalf("sources = %d, want 1", len(sources))
}
if sources[0].State != ConnectionStateActive || sources[0].Completeness == nil {
t.Fatalf("source = %#v, want active source with degraded completeness", sources[0])
}
if sources[0].Completeness.IssueCount != 3 || sources[0].Completeness.Issues[0].Stage != "tags" {
t.Fatalf("completeness = %#v", sources[0].Completeness)
}
}
func TestRuntimeVMwareInventoryCompletenessDropsMessagesAndEntityIdentity(t *testing.T) {
completeness := runtimeVMwareInventoryCompleteness(&monitoring.VMwareConnectionObservedSummary{
Degraded: true,
IssueCount: 1,
Issues: []monitoring.VMwareConnectionObservedIssue{{
Stage: "guest-details",
Category: "permission",
Message: "vm-42 at https://vcenter.secret.local/sdk denied",
Occurrences: 1,
}},
})
if completeness == nil {
t.Fatal("expected degraded completeness")
}
payload, err := json.Marshal(completeness)
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, forbidden := range []string{"vm-42", "vcenter.secret.local", "denied"} {
if strings.Contains(string(payload), forbidden) {
t.Fatalf("completeness leaked %q: %s", forbidden, payload)
}
}
}
func TestRuntimeInventorySourcesNormalizesCredentialFailureWithoutPublishingFleet(t *testing.T) {
expiredAt := time.Now().UTC().Add(-time.Hour)
sources := runtimeInventorySources([]Connection{
{
Type: ConnectionTypeVMware,
Name: "vCenter",
State: ConnectionStateActive,
Enabled: true,
Surfaces: []string{"vms", "vms", "storage"},
Fleet: ConnectionFleetGovernance{
CredentialHealth: &ConnectionFleetCredentialHealth{
Status: "expired",
ExpiresAt: &expiredAt,
},
},
},
})
if len(sources) != 1 {
t.Fatalf("sources = %d, want 1", len(sources))
}
if sources[0].State != ConnectionStateUnauthorized {
t.Fatalf("state = %q, want normalized unauthorized", sources[0].State)
}
if !reflect.DeepEqual(sources[0].Surfaces, []string{"vms"}) {
t.Fatalf("surfaces = %#v, want workload-only deduplicated coverage", sources[0].Surfaces)
}
}
func TestRuntimeInventorySourceWireShapeIsAnExactWhitelist(t *testing.T) {
payload, err := json.Marshal(RuntimeInventorySource{})
if err != nil {
t.Fatalf("marshal: %v", err)
}
var fields map[string]any
if err := json.Unmarshal(payload, &fields); err != nil {
t.Fatalf("unmarshal: %v", err)
}
want := map[string]struct{}{
"type": {},
"name": {},
"state": {},
"surfaces": {},
}
if len(fields) != len(want) {
t.Fatalf("wire fields = %v, want exactly %v", fields, want)
}
for field := range fields {
if _, allowed := want[field]; !allowed {
t.Fatalf("monitoring projection grew unapproved field %q", field)
}
}
}
func TestRuntimeInventorySourcesOmitAdministrativeAndSensitiveFacts(t *testing.T) {
now := time.Now().UTC()
sources := runtimeInventorySources([]Connection{{
ID: "vmware:secret-internal-id",
Type: ConnectionTypeVMware,
Name: "Primary vCenter",
Address: "https://vcenter.corp.local:443",
HostAliases: []string{"10.0.1.5", "vcenter-old.corp.local"},
State: ConnectionStateUnreachable,
StateReason: `Get "https://vcenter.corp.local/sdk": dial tcp 10.0.1.5:443: i/o timeout`,
Enabled: true,
Surfaces: []string{"vms", "storage"},
LastSeen: &now,
LastError: &ConnectionError{
At: now,
Message: "credential token secret-value rejected",
},
AgentIdentity: &ConnectionAgentIdentity{
Hostname: "collector-01",
ReportIP: "10.0.1.9",
OSName: "Debian",
},
AgentVersion: "6.2.0",
Fleet: ConnectionFleetGovernance{
CommandPolicy: &ConnectionFleetCommandPolicy{Reason: "privileged policy detail"},
},
}})
if len(sources) != 1 {
t.Fatalf("sources = %d, want 1", len(sources))
}
payload, err := json.Marshal(sources[0])
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, forbidden := range []string{
"secret-internal-id",
"vcenter.corp.local",
"10.0.1.5",
"secret-value",
"collector-01",
"10.0.1.9",
"Debian",
"6.2.0",
"privileged policy detail",
"storage",
} {
if strings.Contains(string(payload), forbidden) {
t.Fatalf("monitoring projection leaked %q: %s", forbidden, payload)
}
}
if !strings.Contains(string(payload), "Primary vCenter") {
t.Fatalf("projection omitted the operator-facing source label: %s", payload)
}
}
func TestRuntimeInventorySourcesHandlerFailsClosedWhenUnavailable(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/runtime/inventory-sources", nil)
rec := httptest.NewRecorder()
var handler *ConnectionsHandlers
handler.HandleRuntimeInventorySources(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 (%s)", rec.Code, rec.Body.String())
}
}
func TestRuntimeInventorySourcesRouteAuthorizationKeepsAdminLedgerPrivate(t *testing.T) {
cfg := &config.Config{
DataPath: t.TempDir(),
ProxyAuthSecret: "proxy-secret",
ProxyAuthUserHeader: "X-Proxy-User",
ProxyAuthRoleHeader: "X-Proxy-Roles",
ProxyAuthAdminRole: "admin",
}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
request := func(path, roles string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
req.Header.Set(cfg.ProxyAuthUserHeader, "alice")
req.Header.Set(cfg.ProxyAuthRoleHeader, roles)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
return rec
}
if rec := request("/api/runtime/inventory-sources", "viewer"); rec.Code != http.StatusOK {
t.Fatalf("viewer runtime projection = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
if rec := request("/api/connections", "viewer"); rec.Code != http.StatusForbidden {
t.Fatalf("viewer admin ledger = %d, want 403 (%s)", rec.Code, rec.Body.String())
}
if rec := request("/api/runtime/inventory-sources", "viewer|admin"); rec.Code != http.StatusOK {
t.Fatalf("admin runtime projection = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
if rec := request("/api/connections", "viewer|admin"); rec.Code != http.StatusOK {
t.Fatalf("admin ledger = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
}
func TestRuntimeInventorySourcesRouteRequiresAuthenticationAndMonitoringScope(t *testing.T) {
monitoringToken := "runtime-inventory-monitoring.12345678"
settingsToken := "runtime-inventory-settings.12345678"
cfg := newTestConfigWithTokens(t,
newTokenRecord(t, monitoringToken, []string{config.ScopeMonitoringRead}, nil),
newTokenRecord(t, settingsToken, []string{config.ScopeSettingsRead}, nil),
)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
request := func(rawToken string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/runtime/inventory-sources", nil)
if rawToken != "" {
req.Header.Set("X-API-Token", rawToken)
}
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
return rec
}
if rec := request(""); rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status = %d, want 401 (%s)", rec.Code, rec.Body.String())
}
if rec := request(settingsToken); rec.Code != http.StatusForbidden {
t.Fatalf("settings-only token status = %d, want 403 (%s)", rec.Code, rec.Body.String())
}
if rec := request(monitoringToken); rec.Code != http.StatusOK {
t.Fatalf("monitoring token status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
}
func TestRuntimeInventorySourcesHandlerRejectsNonGET(t *testing.T) {
handler := NewConnectionsHandlers(
func(context.Context) *config.Config { return nil },
func(context.Context) *config.ConfigPersistence { return nil },
func(context.Context) *monitoring.Monitor { return nil },
)
req := httptest.NewRequest(http.MethodPost, "/api/runtime/inventory-sources", nil)
rec := httptest.NewRecorder()
handler.HandleRuntimeInventorySources(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405 (%s)", rec.Code, rec.Body.String())
}
}
// mockMonitorWithStaleView returns a mock-mode monitor whose estate view was
// built before the fixture last ticked, and the structure read state it holds.
func mockMonitorWithStaleView(t *testing.T) (*monitoring.Monitor, *config.Config) {
t.Helper()
previous := mock.IsMockEnabled()
previousConfig := mock.GetConfig()
estate := mock.DefaultConfig
estate.NodeCount = 4
estate.UpdateInterval = time.Second
mock.SetMockConfig(estate)
if err := mock.SetEnabled(true); err != nil {
t.Fatalf("enable mock mode: %v", err)
}
t.Cleanup(func() {
_ = mock.SetEnabled(false)
mock.SetMockConfig(previousConfig)
_ = mock.SetEnabled(previous)
})
cfg := &config.Config{DataPath: t.TempDir()}
monitor, err := monitoring.New(cfg)
if err != nil {
t.Fatalf("new monitor: %v", err)
}
t.Cleanup(monitor.Stop)
monitor.GetUnifiedReadStateOrSnapshot()
tick := mock.FixtureDataVersion()
deadline := time.Now().Add(5 * time.Minute)
for mock.FixtureDataVersion() <= tick {
if time.Now().After(deadline) {
t.Fatal("the mock fixture did not tick")
}
time.Sleep(20 * time.Millisecond)
}
return monitor, cfg
}
// The connection-degraded alert feed keeps the platform rows (PVE, PBS, PMG,
// VMware, TrueNAS) and drops every agent row, so it has no use for the
// monitor's hosts or PBS instances. In mock mode each of those reads rebuilds
// the whole estate view once a metric tick has made it stale, and the feed
// runs on startup's critical path.
func TestAlertConnectionSnapshotsDoNotRebuildTheMockViewAfterFixtureTicks(t *testing.T) {
monitor, cfg := mockMonitorWithStaleView(t)
warm := monitor.GetUnifiedStructureReadState()
buildAlertConnectionSnapshotsWithRuntimeSources(context.Background(), cfg, nil, monitor, aggregatorRuntimeSources{orgID: "default"})
if monitor.GetUnifiedStructureReadState() != warm {
t.Fatal("the alert connection feed rebuilt the unified view after a metric tick; it reads no host or PBS instance")
}
// Control: the connections endpoint does read them, and the test would
// pass vacuously if a rebuild left no trace.
buildAggregatorInputsWithRuntimeSources(context.Background(), cfg, nil, monitor, aggregatorRuntimeSources{orgID: "default"})
if monitor.GetUnifiedStructureReadState() == warm {
t.Fatal("reading the hosts did not rebuild the stale view, so this test cannot see a rebuild")
}
}
// Leaving the hosts and PBS instances out must not change a platform row. The
// lean inputs are the full ones with exactly what platformRowsOnly omits taken
// away, so a supplemental fixture tick between two reads cannot make the rows
// differ for a reason other than the omission.
func TestAlertConnectionSnapshotsMatchTheFullAggregatorOnPlatformRows(t *testing.T) {
monitor, cfg := mockMonitorWithStaleView(t)
full := buildAggregatorInputsWithRuntimeSources(context.Background(), cfg, nil, monitor, aggregatorRuntimeSources{orgID: "default"})
lean := buildAggregatorInputsWithRuntimeSources(context.Background(), cfg, nil, monitor, aggregatorRuntimeSources{orgID: "default", platformRowsOnly: true})
if len(full.hosts) == 0 {
t.Fatal("the mock estate lists no host, so the comparison proves nothing")
}
if len(lean.hosts) != 0 || lean.agentDesiredConfigs != nil || lean.pbsReportedNodeNames != nil {
t.Fatalf("platformRowsOnly kept hosts=%d desiredConfigs=%v pbsNodeNames=%v", len(lean.hosts), lean.agentDesiredConfigs, lean.pbsReportedNodeNames)
}
stripped := full
stripped.hosts = lean.hosts
stripped.agentDesiredConfigs = nil
stripped.pbsReportedNodeNames = nil
want := snapshotConnectionsForAlerts(buildConnections(full))
got := snapshotConnectionsForAlerts(buildConnections(stripped))
if len(want) == 0 {
t.Fatal("the mock estate yields no platform connection")
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("platform connections changed without the hosts:\n got %+v\nwant %+v", got, want)
}
}
// The patrol resolver turns a finding's resource reference into a canonical ID.
// It reads identity only, so in mock mode it must not rebuild the estate view
// after a metric tick (it runs once per finding).
func TestPatrolOperatorStateResolverDoesNotRebuildTheMockViewAfterFixtureTicks(t *testing.T) {
monitor, cfg := mockMonitorWithStaleView(t)
warm := monitor.GetUnifiedStructureReadState()
router := &Router{
resourceHandlers: NewResourceHandlers(&config.Config{DataPath: cfg.DataPath}),
monitor: monitor,
}
t.Cleanup(router.ShutdownResourceStores)
provider := router.patrolResourceOperatorStateProvider("default")
provider.OperatorStateProjection("some-finding-resource", time.Now())
if monitor.GetUnifiedStructureReadState() != warm {
t.Fatal("the patrol operator-state resolver rebuilt the unified view after a metric tick; it only resolves identity")
}
}
// Mock mode drops the configured PVE, PBS and PMG instances from the ledger, so
// their rows are checked here with mock mode off: the platform rows an alert
// is raised from are the same with and without the hosts and PBS reads.
func TestAlertConnectionSnapshotsKeepConfiguredPlatformRowsWithoutTheHosts(t *testing.T) {
previousMock := mock.IsMockEnabled()
if err := mock.SetEnabled(false); err != nil {
t.Fatalf("disable mock mode: %v", err)
}
t.Cleanup(func() { _ = mock.SetEnabled(previousMock) })
monitor, err := monitoring.New(&config.Config{DataPath: t.TempDir()})
if err != nil {
t.Fatalf("new monitor: %v", err)
}
t.Cleanup(monitor.Stop)
cfg := &config.Config{
DataPath: t.TempDir(),
PVEInstances: []config.PVEInstance{{Name: "pve-lab", Host: "https://pve-lab.example:8006"}},
PBSInstances: []config.PBSInstance{{Name: "pbs-lab", Host: "https://pbs-lab.example:8007"}},
PMGInstances: []config.PMGInstance{{Name: "pmg-lab", Host: "https://pmg-lab.example:8006"}},
}
full := buildAggregatorInputsWithRuntimeSources(context.Background(), cfg, nil, monitor, aggregatorRuntimeSources{orgID: "default"})
lean := buildAggregatorInputsWithRuntimeSources(context.Background(), cfg, nil, monitor, aggregatorRuntimeSources{orgID: "default", platformRowsOnly: true})
lean.now = full.now
want := snapshotConnectionsForAlerts(buildConnections(full))
got := snapshotConnectionsForAlerts(buildConnections(lean))
if len(want) != 3 {
t.Fatalf("expected the PVE, PBS and PMG rows, got %+v", want)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("platform connections changed without the hosts:\n got %+v\nwant %+v", got, want)
}
}