This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
package agentsource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/agentstore"
|
||||
"github.com/itworx/pulse/internal/array"
|
||||
"github.com/itworx/pulse/internal/container"
|
||||
"github.com/itworx/pulse/internal/disk"
|
||||
"github.com/itworx/pulse/internal/host"
|
||||
"github.com/itworx/pulse/internal/pool"
|
||||
"github.com/itworx/pulse/internal/process"
|
||||
"github.com/itworx/pulse/internal/share"
|
||||
)
|
||||
|
||||
// stubReader stands in for the PostgreSQL store so the read path can be exercised without
|
||||
// a database.
|
||||
type stubReader struct {
|
||||
snapshot agentstore.Snapshot
|
||||
err error
|
||||
asked agentstore.Capability
|
||||
}
|
||||
|
||||
func (r *stubReader) Latest(_ context.Context, capability agentstore.Capability) (agentstore.Snapshot, error) {
|
||||
r.asked = capability
|
||||
if r.err != nil {
|
||||
return agentstore.Snapshot{}, r.err
|
||||
}
|
||||
return r.snapshot, nil
|
||||
}
|
||||
|
||||
// observation is the part of every domain snapshot this package is responsible for.
|
||||
type observation struct {
|
||||
state string
|
||||
reason string
|
||||
healthy bool
|
||||
}
|
||||
|
||||
// domainUnderTest describes one capability generically so every domain runs the same
|
||||
// table: no snapshot, store failure, stale snapshot, corrupt payload, payload the domain
|
||||
// rejects, and a good payload.
|
||||
type domainUnderTest struct {
|
||||
name string
|
||||
capability agentstore.Capability
|
||||
window time.Duration
|
||||
good func(now time.Time) any
|
||||
rejected func(now time.Time) any
|
||||
observe func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error)
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 { return &value }
|
||||
|
||||
func domains() []domainUnderTest {
|
||||
return []domainUnderTest{
|
||||
{
|
||||
name: "host",
|
||||
capability: agentstore.CapabilityHost,
|
||||
window: DefaultHostWindow,
|
||||
good: func(now time.Time) any {
|
||||
used := uint64(6 * 1024 * 1024 * 1024)
|
||||
return host.RawSnapshot{
|
||||
Source: host.Source{ID: "agent-1", Type: "agent"},
|
||||
Identity: host.HostIdentity{Name: "pulse-host", Version: "7.2.2", Arch: "amd64"},
|
||||
UptimeSeconds: 3600,
|
||||
CPU: host.RawCPU{TotalPercent: floatPtr(42.5), PerCore: []float64{40, 45}, IOWaitPercent: floatPtr(2)},
|
||||
Load: host.RawLoad{One: 0.2, Five: 0.1, Fifteen: 0.1},
|
||||
Memory: host.RawMemory{TotalBytes: 8 * 1024 * 1024 * 1024, AvailableBytes: 2 * 1024 * 1024 * 1024, UsedBytes: &used},
|
||||
Filesystems: []host.RawFilesystem{{Mount: "/", Filesystem: "xfs", CapacityBytes: 100, UsedBytes: 25}},
|
||||
Network: []host.RawNetworkInterface{{Name: "eth0", State: "up"}},
|
||||
Time: host.RawTime{Synchronized: true, OffsetSeconds: 0.002, Stratum: 2},
|
||||
ObservedAt: now, ReceivedAt: now,
|
||||
}
|
||||
},
|
||||
rejected: func(now time.Time) any {
|
||||
return host.RawSnapshot{Identity: host.HostIdentity{Name: ""}, ObservedAt: now, ReceivedAt: now}
|
||||
},
|
||||
observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
|
||||
snapshot, err := HostProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
|
||||
return observation{
|
||||
state: snapshot.Source.State,
|
||||
reason: snapshot.Source.Reason,
|
||||
healthy: snapshot.Source.Freshness == host.Fresh && snapshot.Status.State == host.StatusHealthy,
|
||||
}, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "processes",
|
||||
capability: agentstore.CapabilityProcesses,
|
||||
window: DefaultProcessesWindow,
|
||||
good: func(now time.Time) any {
|
||||
return process.RawSnapshot{
|
||||
Source: process.Source{ID: "agent-1", Type: "agent"},
|
||||
Processes: []process.RawProcess{{PID: 1, Name: "init", State: "sleeping", RuntimeSeconds: 100, CPUPercent: 1, MemoryBytes: 500}},
|
||||
ObservedAt: now, ReceivedAt: now,
|
||||
}
|
||||
},
|
||||
rejected: func(now time.Time) any {
|
||||
return process.RawSnapshot{Processes: []process.RawProcess{{PID: 0, Name: ""}}, ObservedAt: now, ReceivedAt: now}
|
||||
},
|
||||
observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
|
||||
snapshot, err := ProcessProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
|
||||
return observation{state: snapshot.Source.State, reason: snapshot.Source.Reason, healthy: snapshot.Source.State == "healthy"}, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "containers",
|
||||
capability: agentstore.CapabilityContainers,
|
||||
window: DefaultContainersWindow,
|
||||
good: func(now time.Time) any {
|
||||
return container.RawSnapshot{
|
||||
Source: container.Source{ID: "agent-1", Type: "agent"},
|
||||
Containers: []container.RawContainer{{ID: "a", Name: "alpha", State: "running", Health: "healthy"}},
|
||||
ObservedAt: now, ReceivedAt: now,
|
||||
}
|
||||
},
|
||||
rejected: func(now time.Time) any {
|
||||
return container.RawSnapshot{Containers: []container.RawContainer{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
|
||||
},
|
||||
observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
|
||||
snapshot, err := ContainerProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
|
||||
return observation{
|
||||
state: snapshot.Source.State,
|
||||
reason: snapshot.Source.Reason,
|
||||
healthy: snapshot.Source.Freshness == "fresh" && snapshot.Source.State == "healthy",
|
||||
}, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "array",
|
||||
capability: agentstore.CapabilityArray,
|
||||
window: DefaultArrayWindow,
|
||||
good: func(now time.Time) any {
|
||||
return array.RawSnapshot{
|
||||
Source: array.Source{ID: "agent-1", Type: "unraid"},
|
||||
State: array.StateOperational,
|
||||
Parity: array.RawParity{Present: true, State: "idle"},
|
||||
Members: []array.RawMember{
|
||||
{ID: "disk1", Name: "Disk 1", Role: "data", State: "online", CapacityBytes: 100},
|
||||
{ID: "parity", Name: "Parity", Role: "parity", State: "online", CapacityBytes: 100},
|
||||
},
|
||||
ObservedAt: now, ReceivedAt: now,
|
||||
}
|
||||
},
|
||||
rejected: func(now time.Time) any {
|
||||
return array.RawSnapshot{State: array.StateOperational, Members: []array.RawMember{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
|
||||
},
|
||||
observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
|
||||
snapshot, err := ArrayProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
|
||||
return observation{
|
||||
state: snapshot.Source.State,
|
||||
reason: snapshot.Source.Reason,
|
||||
healthy: snapshot.Source.Freshness == array.Fresh && snapshot.State == array.StateOperational,
|
||||
}, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disks",
|
||||
capability: agentstore.CapabilityDisks,
|
||||
window: DefaultDisksWindow,
|
||||
good: func(now time.Time) any {
|
||||
return disk.RawSnapshot{
|
||||
Source: disk.Source{ID: "agent-1", Type: "unraid"},
|
||||
Disks: []disk.RawDisk{{ID: "disk1", Name: "Disk 1", Role: "data", State: disk.StateOnline, SizeBytes: 100}},
|
||||
ObservedAt: now, ReceivedAt: now,
|
||||
}
|
||||
},
|
||||
rejected: func(now time.Time) any {
|
||||
return disk.RawSnapshot{Disks: []disk.RawDisk{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
|
||||
},
|
||||
observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
|
||||
snapshot, err := DiskProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
|
||||
return observation{
|
||||
state: snapshot.Source.State,
|
||||
reason: snapshot.Source.Reason,
|
||||
healthy: snapshot.Source.Freshness == disk.Fresh && snapshot.Source.State != disk.StateUnknown,
|
||||
}, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pools",
|
||||
capability: agentstore.CapabilityPools,
|
||||
window: DefaultPoolsWindow,
|
||||
good: func(now time.Time) any {
|
||||
return pool.RawSnapshot{
|
||||
Source: pool.Source{ID: "agent-1", Type: "unraid"},
|
||||
Pools: []pool.RawPool{{ID: "cache", Name: "Cache", Filesystem: "btrfs", State: pool.StateHealthy, UsableBytes: 1000, UsedBytes: 100}},
|
||||
ObservedAt: now, ReceivedAt: now,
|
||||
}
|
||||
},
|
||||
rejected: func(now time.Time) any {
|
||||
return pool.RawSnapshot{Pools: []pool.RawPool{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
|
||||
},
|
||||
observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
|
||||
snapshot, err := PoolProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
|
||||
return observation{
|
||||
state: snapshot.Source.State,
|
||||
reason: snapshot.Source.Reason,
|
||||
healthy: snapshot.Source.Freshness == pool.Fresh && snapshot.Source.State != pool.StateUnknown,
|
||||
}, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shares",
|
||||
capability: agentstore.CapabilityShares,
|
||||
window: DefaultSharesWindow,
|
||||
good: func(now time.Time) any {
|
||||
return share.RawSnapshot{
|
||||
Source: share.Source{ID: "agent-1", Type: "unraid"},
|
||||
Shares: []share.RawShare{{ID: "share-media", Name: "Media", UsedBytes: 700, SizeObservedAt: now, SizeState: share.SizeCached}},
|
||||
ObservedAt: now, ReceivedAt: now,
|
||||
}
|
||||
},
|
||||
rejected: func(now time.Time) any {
|
||||
return share.RawSnapshot{Shares: []share.RawShare{{ID: "", Name: ""}}, ObservedAt: now, ReceivedAt: now}
|
||||
},
|
||||
observe: func(ctx context.Context, reader agentstore.Reader, now time.Time) (observation, error) {
|
||||
snapshot, err := ShareProvider{Reader: reader, Now: fixedClock(now)}.Snapshot(ctx)
|
||||
return observation{
|
||||
state: snapshot.Source.State,
|
||||
reason: snapshot.Source.Reason,
|
||||
healthy: snapshot.Source.Freshness == share.Fresh && snapshot.Source.State != share.StateUnknown,
|
||||
}, err
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func encode(t *testing.T, value any) json.RawMessage {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestProvidersMapUnusableTelemetryToUnknown(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
for _, domain := range domains() {
|
||||
t.Run(domain.name, func(t *testing.T) {
|
||||
fresh := agentstore.Snapshot{AgentID: "agent-1", Capability: domain.capability, ObservedAt: now, ReceivedAt: now}
|
||||
cases := []struct {
|
||||
name string
|
||||
reader *stubReader
|
||||
reason string
|
||||
}{
|
||||
{"no snapshot", &stubReader{err: agentstore.ErrNoSnapshot}, ReasonUnavailable},
|
||||
{"store unreachable", &stubReader{err: errors.New("connection refused")}, ReasonUnavailable},
|
||||
{"stale snapshot", &stubReader{snapshot: func() agentstore.Snapshot {
|
||||
stale := fresh
|
||||
stale.ObservedAt = now.Add(-domain.window - time.Second)
|
||||
stale.Payload = encode(t, domain.good(stale.ObservedAt))
|
||||
return stale
|
||||
}()}, ReasonStale},
|
||||
{"missing observation time", &stubReader{snapshot: func() agentstore.Snapshot {
|
||||
broken := fresh
|
||||
broken.ObservedAt = time.Time{}
|
||||
broken.Payload = encode(t, domain.good(now))
|
||||
return broken
|
||||
}()}, ReasonStale},
|
||||
{"corrupt payload", &stubReader{snapshot: func() agentstore.Snapshot {
|
||||
corrupt := fresh
|
||||
corrupt.Payload = json.RawMessage(`{"source":"not-an-object"}`)
|
||||
return corrupt
|
||||
}()}, ReasonInvalid},
|
||||
{"payload the domain rejects", &stubReader{snapshot: func() agentstore.Snapshot {
|
||||
invalid := fresh
|
||||
invalid.Payload = encode(t, domain.rejected(now))
|
||||
return invalid
|
||||
}()}, ReasonInvalid},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got, err := domain.observe(context.Background(), testCase.reader, now)
|
||||
if err != nil {
|
||||
t.Fatalf("unusable telemetry must not fail the request: %v", err)
|
||||
}
|
||||
if got.healthy {
|
||||
t.Fatal("unusable telemetry became healthy, violating ADR-0008")
|
||||
}
|
||||
if got.state != "unknown" {
|
||||
t.Fatalf("source state = %q, want unknown", got.state)
|
||||
}
|
||||
if got.reason != testCase.reason {
|
||||
t.Fatalf("reason = %q, want %q", got.reason, testCase.reason)
|
||||
}
|
||||
if testCase.reader.asked != domain.capability {
|
||||
t.Fatalf("read capability %q, want %q", testCase.reader.asked, domain.capability)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersNormalizeGoodTelemetry(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
for _, domain := range domains() {
|
||||
t.Run(domain.name, func(t *testing.T) {
|
||||
observed := now.Add(-time.Second)
|
||||
reader := &stubReader{snapshot: agentstore.Snapshot{
|
||||
AgentID: "agent-1", Capability: domain.capability, ObservedAt: observed, ReceivedAt: observed,
|
||||
Payload: encode(t, domain.good(observed)),
|
||||
}}
|
||||
got, err := domain.observe(context.Background(), reader, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.healthy {
|
||||
t.Fatalf("fresh telemetry did not normalize to a healthy source: %+v", got)
|
||||
}
|
||||
if got.reason != "" {
|
||||
t.Fatalf("healthy source carries reason %q", got.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersTreatTheWindowBoundaryAsFresh(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
for _, domain := range domains() {
|
||||
t.Run(domain.name, func(t *testing.T) {
|
||||
observed := now.Add(-domain.window)
|
||||
reader := &stubReader{snapshot: agentstore.Snapshot{
|
||||
AgentID: "agent-1", Capability: domain.capability, ObservedAt: observed, ReceivedAt: observed,
|
||||
Payload: encode(t, domain.good(observed)),
|
||||
}}
|
||||
got, err := domain.observe(context.Background(), reader, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.state == "unknown" {
|
||||
t.Fatalf("a snapshot exactly at the window edge must still be usable: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersWithoutAReaderReportUnavailable(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
for _, domain := range domains() {
|
||||
t.Run(domain.name, func(t *testing.T) {
|
||||
got, err := domain.observe(context.Background(), nil, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.healthy || got.state != "unknown" || got.reason != ReasonUnavailable {
|
||||
t.Fatalf("unexpected observation %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersPropagateContextCancellation(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
for _, domain := range domains() {
|
||||
t.Run(domain.name, func(t *testing.T) {
|
||||
reader := &stubReader{err: context.Canceled}
|
||||
if _, err := domain.observe(ctx, reader, now); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected cancellation, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsApplyDocumentedDefaults(t *testing.T) {
|
||||
windows := Windows{}
|
||||
expected := map[agentstore.Capability]time.Duration{
|
||||
agentstore.CapabilityHost: DefaultHostWindow,
|
||||
agentstore.CapabilityProcesses: DefaultProcessesWindow,
|
||||
agentstore.CapabilityContainers: DefaultContainersWindow,
|
||||
agentstore.CapabilityArray: DefaultArrayWindow,
|
||||
agentstore.CapabilityDisks: DefaultDisksWindow,
|
||||
agentstore.CapabilityPools: DefaultPoolsWindow,
|
||||
agentstore.CapabilityShares: DefaultSharesWindow,
|
||||
}
|
||||
for _, capability := range agentstore.Capabilities() {
|
||||
if got := windows.For(capability); got != expected[capability] {
|
||||
t.Fatalf("window for %q = %s, want %s", capability, got, expected[capability])
|
||||
}
|
||||
}
|
||||
if windows.For(agentstore.CapabilityHost) >= windows.For(agentstore.CapabilityShares) {
|
||||
t.Fatal("the host is sampled far more often than shares and must have the tighter window")
|
||||
}
|
||||
if err := windows.Validate(); err != nil {
|
||||
t.Fatalf("defaults must validate: %v", err)
|
||||
}
|
||||
if err := (Windows{Host: 48 * time.Hour}).Validate(); err == nil {
|
||||
t.Fatal("a window beyond a day must be rejected")
|
||||
}
|
||||
if err := (Windows{Shares: -time.Second}).Validate(); err == nil {
|
||||
t.Fatal("a negative window must be rejected")
|
||||
}
|
||||
custom := Windows{Host: 5 * time.Second}.WithDefaults()
|
||||
if custom.Host != 5*time.Second || custom.Shares != DefaultSharesWindow {
|
||||
t.Fatalf("configuration was not preserved: %+v", custom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredWindowOverridesTheDefault(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
observed := now.Add(-10 * time.Second)
|
||||
payload := encode(t, process.RawSnapshot{
|
||||
Source: process.Source{ID: "agent-1", Type: "agent"},
|
||||
Processes: []process.RawProcess{{PID: 1, Name: "init", State: "sleeping"}},
|
||||
ObservedAt: observed, ReceivedAt: observed,
|
||||
})
|
||||
reader := &stubReader{snapshot: agentstore.Snapshot{AgentID: "agent-1", Capability: agentstore.CapabilityProcesses, ObservedAt: observed, ReceivedAt: observed, Payload: payload}}
|
||||
provider := ProcessProvider{Reader: reader, Windows: Windows{Processes: 5 * time.Second}, Now: fixedClock(now)}
|
||||
snapshot, err := provider.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Source.State != "unknown" || snapshot.Source.Reason != ReasonStale {
|
||||
t.Fatalf("a tightened window must make the snapshot stale: %+v", snapshot.Source)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user