Public source validation / validate (push) Failing after 3m8s
424 lines
16 KiB
Go
424 lines
16 KiB
Go
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)
|
|
}
|
|
}
|