diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index cc431fcdb..f11552168 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -238,6 +238,12 @@ back into the canonical read state through the unified-resources-owned overlay path instead of rebuilding registry truth locally. A server restart or v6 upgrade must not briefly forget an already admitted standalone host and misclassify its next report as a brand-new counted system. +That same standalone-host continuity boundary also owns host snapshot and +connection-list continuity during monitor reloads. `internal/monitoring/monitor.go` +must apply the same continuity overlay when `HostsSnapshot()` resolves its +canonical read state, so settings and other host-list consumers do not blank +previously admitted Pulse Agent rows during a config-driven monitor swap while +fresh reports are still in flight. That same mock-runtime boundary also owns freshness while demos are running. The mock update loop must keep provider-backed TrueNAS and VMware records plus legacy PBS and PMG summaries on current `LastSeen` and health state each tick, diff --git a/internal/api/connections_handlers_continuity_test.go b/internal/api/connections_handlers_continuity_test.go new file mode 100644 index 000000000..979cfac47 --- /dev/null +++ b/internal/api/connections_handlers_continuity_test.go @@ -0,0 +1,91 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" +) + +func TestConnectionsHandleListIncludesContinuityBackedHostAgents(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{DataPath: dir} + now := time.Now().UTC() + + seedMonitor, err := monitoring.New(cfg) + if err != nil { + t.Fatalf("monitoring.New seed monitor: %v", err) + } + t.Cleanup(seedMonitor.Stop) + + _, err = seedMonitor.ApplyHostReport(agentshost.Report{ + Agent: agentshost.AgentInfo{ + ID: "agent-1", + Version: "6.0.0-rc.1", + IntervalSeconds: 30, + }, + Host: agentshost.HostInfo{ + ID: "machine-1", + MachineID: "machine-1", + Hostname: "host-1.local", + Platform: "linux", + }, + Timestamp: now, + }, &config.APITokenRecord{ID: "token-1", Name: "Token One"}) + if err != nil { + t.Fatalf("ApplyHostReport seed continuity: %v", err) + } + + reloadedMonitor, err := monitoring.New(cfg) + if err != nil { + t.Fatalf("monitoring.New reloaded monitor: %v", err) + } + t.Cleanup(reloadedMonitor.Stop) + + handler := NewConnectionsHandlers( + func(context.Context) *config.Config { return cfg }, + func(context.Context) *config.ConfigPersistence { return nil }, + func(context.Context) *monitoring.Monitor { return reloadedMonitor }, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/connections", nil) + rec := httptest.NewRecorder() + handler.HandleList(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp ConnectionsListResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode connections response: %v", err) + } + if len(resp.Connections) != 1 { + t.Fatalf("expected 1 continuity-backed connection, got %d", len(resp.Connections)) + } + + conn := resp.Connections[0] + if conn.ID != "agent:machine-1" { + t.Fatalf("connection id = %q, want %q", conn.ID, "agent:machine-1") + } + if conn.Type != ConnectionTypeAgent { + t.Fatalf("connection type = %q, want %q", conn.Type, ConnectionTypeAgent) + } + if conn.Name != "host-1.local" || conn.Address != "host-1.local" { + t.Fatalf("unexpected continuity-backed connection identity: name=%q address=%q", conn.Name, conn.Address) + } + if conn.State != ConnectionStateActive { + t.Fatalf("connection state = %q, want %q", conn.State, ConnectionStateActive) + } + if conn.Source != ConnectionSourceAgent { + t.Fatalf("connection source = %q, want %q", conn.Source, ConnectionSourceAgent) + } + if conn.LastSeen == nil || conn.LastSeen.IsZero() { + t.Fatalf("expected continuity-backed connection to carry lastSeen, got %#v", conn.LastSeen) + } +} diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 965519a89..410cfe142 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -125,6 +125,8 @@ func TestMonitoredSystemUsageReadinessGuardrailsRemainCanonical(t *testing.T) { "SupplementalInventoryReadyAt(m *Monitor, orgID string) (time.Time, bool)", "hostContinuityStore *config.HostContinuityStore", "hostContinuityStore: config.NewHostContinuityStore(cfg.DataPath, nil),", + "func (m *Monitor) HostsSnapshot() []models.Host {", + "readState = m.readStateWithStandaloneHostContinuity(readState)", }, "monitored_system_usage.go": { "MonitoredSystemUsageUnavailableMonitorState", @@ -136,10 +138,10 @@ func TestMonitoredSystemUsageReadinessGuardrailsRemainCanonical(t *testing.T) { "UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled,", "if freshness.IsZero() || freshness.Before(readyAt) {", "UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryRebuildPending,", - "readState = m.monitoredSystemUsageReadStateWithHostContinuity(readState)", + "readState = m.readStateWithStandaloneHostContinuity(readState)", "Count: unifiedresources.MonitoredSystemCount(readState),", "Available: true,", - "func (m *Monitor) monitoredSystemUsageReadStateWithHostContinuity(", + "func (m *Monitor) readStateWithStandaloneHostContinuity(", "return unifiedresources.ReadStateWithRecords(readState, unifiedresources.SourceAgent, records)", }, "truenas_poller.go": { diff --git a/internal/monitoring/guest_config_conversion_coverage_test.go b/internal/monitoring/guest_config_conversion_coverage_test.go index da336adfa..5aae714c6 100644 --- a/internal/monitoring/guest_config_conversion_coverage_test.go +++ b/internal/monitoring/guest_config_conversion_coverage_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -795,6 +796,44 @@ func TestMonitorHostsSnapshot(t *testing.T) { t.Fatalf("expected empty hosts from live canonical read-state, got %#v", hosts) } }) + + t.Run("includes recent standalone host continuity when live read state is empty", func(t *testing.T) { + now := time.Now().UTC() + store := config.NewHostContinuityStore(t.TempDir(), nil) + if err := store.Upsert(config.HostContinuityEntry{ + HostID: "host-1", + ReportHostID: "machine-1", + Hostname: "host-1.local", + DisplayName: "Host One", + MachineID: "machine-1", + AgentVersion: "1.2.3", + Platform: "linux", + LastSeen: now, + }); err != nil { + t.Fatalf("Upsert continuity: %v", err) + } + + m := &Monitor{ + state: models.NewState(), + resourceStore: unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)), + hostContinuityStore: store, + } + + hosts := m.HostsSnapshot() + if len(hosts) != 1 { + t.Fatalf("expected one continuity-backed host, got %#v", hosts) + } + host := hosts[0] + if host.ID != "host-1" || host.Hostname != "host-1.local" || host.DisplayName != "Host One" { + t.Fatalf("unexpected continuity-backed host identity: %#v", host) + } + if host.MachineID != "machine-1" || host.AgentVersion != "1.2.3" || host.Platform != "linux" { + t.Fatalf("unexpected continuity-backed host metadata: %#v", host) + } + if !host.LastSeen.Equal(now) { + t.Fatalf("continuity-backed host lastSeen = %v, want %v", host.LastSeen, now) + } + }) } func TestMonitorDockerHostsSnapshot(t *testing.T) { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index c52765fc0..ca67662b8 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2776,6 +2776,7 @@ func (m *Monitor) HostsSnapshot() []models.Host { if readState == nil { return nil } + readState = m.readStateWithStandaloneHostContinuity(readState) hostViews := readState.Hosts() if len(hostViews) == 0 { diff --git a/internal/monitoring/monitored_system_usage.go b/internal/monitoring/monitored_system_usage.go index bae696223..81fafae77 100644 --- a/internal/monitoring/monitored_system_usage.go +++ b/internal/monitoring/monitored_system_usage.go @@ -55,7 +55,7 @@ func (m *Monitor) MonitoredSystemUsage() MonitoredSystemUsageSnapshot { } } - readState = m.monitoredSystemUsageReadStateWithHostContinuity(readState) + readState = m.readStateWithStandaloneHostContinuity(readState) return MonitoredSystemUsageSnapshot{ Count: unifiedresources.MonitoredSystemCount(readState), @@ -64,7 +64,7 @@ func (m *Monitor) MonitoredSystemUsage() MonitoredSystemUsageSnapshot { } } -func (m *Monitor) monitoredSystemUsageReadStateWithHostContinuity( +func (m *Monitor) readStateWithStandaloneHostContinuity( readState unifiedresources.ReadState, ) unifiedresources.ReadState { if m == nil || readState == nil {