Files
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

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)
}
}