Preserve agent rows across monitor reloads

This commit is contained in:
rcourtman
2026-04-22 20:48:16 +01:00
parent 18469f52b5
commit 0a4584535e
6 changed files with 143 additions and 4 deletions
@@ -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,
@@ -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)
}
}
@@ -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": {
@@ -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) {
+1
View File
@@ -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 {
@@ -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 {