This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
// Package agentstore is the transport boundary between pulse-agent and pulse-api.
|
||||
//
|
||||
// The agent runs with the narrow read-only host access it needs and never exposes a
|
||||
// network endpoint; the API never reaches out to the host. Both processes already share
|
||||
// PostgreSQL on the internal-only Compose network (see deploy/compose.yaml and ADR-0010),
|
||||
// so the agent writes bounded, normalized snapshots into the database and the API reads
|
||||
// the most recent one per capability. This keeps the privilege separation required by
|
||||
// SYSTEM_ARCHITECTURE section "pulse-agent" without adding an inbound port to the agent.
|
||||
//
|
||||
// Freshness is deliberately the reader's problem, not the writer's: a snapshot carries
|
||||
// the time the agent observed it, and the reader decides whether that is still usable.
|
||||
// A capability with no snapshot, or one older than its freshness window, resolves to
|
||||
// Unknown and never to Healthy (ADR-0008).
|
||||
package agentstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Capability identifies one bounded telemetry surface an agent can report. The set is
|
||||
// closed: a reader must never accept a capability it does not recognize, because an
|
||||
// unknown capability cannot be normalized or bounded.
|
||||
type Capability string
|
||||
|
||||
const (
|
||||
CapabilityHost Capability = "host"
|
||||
CapabilityProcesses Capability = "processes"
|
||||
CapabilityContainers Capability = "containers"
|
||||
CapabilityArray Capability = "array"
|
||||
CapabilityDisks Capability = "disks"
|
||||
CapabilityPools Capability = "pools"
|
||||
CapabilityShares Capability = "shares"
|
||||
)
|
||||
|
||||
// Capabilities lists every capability the platform recognizes, in a stable order.
|
||||
func Capabilities() []Capability {
|
||||
return []Capability{
|
||||
CapabilityHost, CapabilityProcesses, CapabilityContainers,
|
||||
CapabilityArray, CapabilityDisks, CapabilityPools, CapabilityShares,
|
||||
}
|
||||
}
|
||||
|
||||
// Valid reports whether the capability is one this platform recognizes.
|
||||
func (c Capability) Valid() bool {
|
||||
for _, known := range Capabilities() {
|
||||
if c == known {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MaxPayloadBytes bounds a single snapshot. The largest realistic payload is the process
|
||||
// inventory at the documented scale target; this leaves generous headroom while keeping a
|
||||
// misbehaving or compromised agent from filling the database.
|
||||
const MaxPayloadBytes = 2 << 20
|
||||
|
||||
// ErrNoSnapshot is returned by Reader.Latest when the capability has never been reported.
|
||||
// It is an expected condition on a fresh install, not a failure: callers translate it into
|
||||
// an Unknown status with an explicit reason.
|
||||
var ErrNoSnapshot = errors.New("no agent snapshot recorded")
|
||||
|
||||
// Snapshot is one bounded observation of a single capability.
|
||||
type Snapshot struct {
|
||||
// AgentID identifies the reporting agent.
|
||||
AgentID string
|
||||
// Capability is the telemetry surface this payload describes.
|
||||
Capability Capability
|
||||
// ObservedAt is when the agent read the underlying source, in UTC.
|
||||
ObservedAt time.Time
|
||||
// ReceivedAt is when the store accepted the snapshot, in UTC. It is set by the
|
||||
// writer implementation, never by the agent, so a skewed agent clock cannot make
|
||||
// stale data look fresh.
|
||||
ReceivedAt time.Time
|
||||
// Payload is the domain RawSnapshot for this capability, JSON encoded.
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
// Age reports how long ago the agent observed this snapshot.
|
||||
func (s Snapshot) Age(now time.Time) time.Duration { return now.Sub(s.ObservedAt) }
|
||||
|
||||
// Writer is the narrow interface pulse-agent depends on. The agent must not be able to
|
||||
// read other agents' data or mutate anything else.
|
||||
type Writer interface {
|
||||
// Put records the newest snapshot for one capability, replacing any previous one.
|
||||
// Implementations reject an unknown capability, an oversized payload, a zero or
|
||||
// future ObservedAt, and payloads that are not valid JSON objects.
|
||||
Put(ctx context.Context, snapshot Snapshot) error
|
||||
}
|
||||
|
||||
// Reader is the narrow interface pulse-api depends on.
|
||||
type Reader interface {
|
||||
// Latest returns the most recent snapshot for the capability, or ErrNoSnapshot.
|
||||
Latest(ctx context.Context, capability Capability) (Snapshot, error)
|
||||
}
|
||||
|
||||
// Store is the combined boundary. Only the migration-owning implementation satisfies it.
|
||||
type Store interface {
|
||||
Writer
|
||||
Reader
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package agentstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// MaxAgentIDBytes bounds the reporting agent identifier. It matches the column check in
|
||||
// migration 0016 so a rejection surfaces as a terse store error rather than a constraint
|
||||
// violation from PostgreSQL.
|
||||
const MaxAgentIDBytes = 128
|
||||
|
||||
// MaxClockSkew is the only tolerance granted to an agent clock that runs ahead of the
|
||||
// database. The agent and the database share one host in the supported topology, so real
|
||||
// skew is sub-millisecond; one second absorbs scheduling jitter while staying far below
|
||||
// the tightest freshness window, so a skewed clock cannot mask meaningful staleness.
|
||||
const MaxClockSkew = time.Second
|
||||
|
||||
// ErrUnavailable is returned when the store has no database pool. Callers translate it
|
||||
// into an Unknown status exactly as they do ErrNoSnapshot.
|
||||
var ErrUnavailable = errors.New("agent snapshot store is unavailable")
|
||||
|
||||
// PostgresStore is the only implementation of Store. It owns the agent_snapshots table
|
||||
// created by migration 0016 and keeps exactly one row per (agent, capability).
|
||||
//
|
||||
// Transaction boundary: Put commits the latest snapshot and any derived capacity samples
|
||||
// atomically. Latest remains a single read statement.
|
||||
type PostgresStore struct {
|
||||
// Pool is the shared pgx pool. A nil pool makes every call return ErrUnavailable.
|
||||
Pool *pgxpool.Pool
|
||||
// Clock supplies ReceivedAt and the future-observation check. It exists for tests;
|
||||
// production leaves it nil and the store uses the wall clock in UTC.
|
||||
Clock func() time.Time
|
||||
}
|
||||
|
||||
var _ Store = PostgresStore{}
|
||||
|
||||
func (s PostgresStore) now() time.Time {
|
||||
if s.Clock == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return s.Clock().UTC()
|
||||
}
|
||||
|
||||
// Put records the newest snapshot for one capability, replacing any previous one for the
|
||||
// same agent. It rejects an unknown capability, a missing or oversized payload, a payload
|
||||
// that is not a JSON object, and a zero or future ObservedAt.
|
||||
//
|
||||
// ReceivedAt is always taken from the store clock: the caller's value is ignored so a
|
||||
// skewed or hostile agent clock cannot make stale data look freshly received.
|
||||
//
|
||||
// A snapshot that is older than the row already stored is accepted but does not overwrite
|
||||
// it, so a delayed retry cannot resurrect superseded telemetry.
|
||||
func (s PostgresStore) Put(ctx context.Context, snapshot Snapshot) error {
|
||||
row, err := prepare(snapshot, s.now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Pool == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin agent snapshot write: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = tx.Rollback(rollbackCtx)
|
||||
}()
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO agent_snapshots (agent_id, capability, observed_at, received_at, payload)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
ON CONFLICT (agent_id, capability) DO UPDATE
|
||||
SET observed_at = EXCLUDED.observed_at, received_at = EXCLUDED.received_at, payload = EXCLUDED.payload
|
||||
WHERE agent_snapshots.observed_at <= EXCLUDED.observed_at`,
|
||||
row.AgentID, string(row.Capability), row.ObservedAt, row.ReceivedAt, []byte(row.Payload)); err != nil {
|
||||
return fmt.Errorf("write agent snapshot: %w", err)
|
||||
}
|
||||
if err := persistCapacitySamples(ctx, tx, row); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit agent snapshot write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type capacitySample struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
CapacityBytes uint64 `json:"capacity_bytes"`
|
||||
}
|
||||
|
||||
func persistCapacitySamples(ctx context.Context, tx pgx.Tx, snapshot Snapshot) error {
|
||||
samples, err := capacitySamples(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(samples)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode capacity samples: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capacity_samples (entity_kind,entity_id,entity_name,source_id,sampled_at,observed_at,used_bytes,capacity_bytes)
|
||||
SELECT sample.kind,sample.id,sample.name,$1,
|
||||
date_trunc('day',sample.observed_at) + floor(extract(hour FROM sample.observed_at)/6)*interval '6 hours',
|
||||
sample.observed_at,sample.used_bytes,sample.capacity_bytes
|
||||
FROM jsonb_to_recordset($2::jsonb) AS sample(kind text,id text,name text,observed_at timestamptz,used_bytes bigint,capacity_bytes bigint)
|
||||
ON CONFLICT (entity_kind,entity_id,source_id,sampled_at) DO UPDATE
|
||||
SET entity_name=EXCLUDED.entity_name,observed_at=EXCLUDED.observed_at,used_bytes=EXCLUDED.used_bytes,capacity_bytes=EXCLUDED.capacity_bytes
|
||||
WHERE capacity_samples.observed_at <= EXCLUDED.observed_at`, snapshot.AgentID, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persist capacity samples: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func capacitySamples(snapshot Snapshot) ([]capacitySample, error) {
|
||||
kind := ""
|
||||
collection := ""
|
||||
capacityField := ""
|
||||
switch snapshot.Capability {
|
||||
case CapabilityShares:
|
||||
kind, collection = "share", "shares"
|
||||
case CapabilityPools:
|
||||
kind, collection, capacityField = "pool", "pools", "usableBytes"
|
||||
case CapabilityDisks:
|
||||
kind, collection, capacityField = "disk", "disks", "sizeBytes"
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
var document map[string]json.RawMessage
|
||||
if err := json.Unmarshal(snapshot.Payload, &document); err != nil {
|
||||
return nil, errors.New("decode capacity snapshot payload")
|
||||
}
|
||||
var items []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(document[collection], &items); err != nil {
|
||||
return nil, fmt.Errorf("decode %s capacity collection", kind)
|
||||
}
|
||||
samples := make([]capacitySample, 0, len(items))
|
||||
for _, item := range items {
|
||||
var id, name string
|
||||
var used, capacity uint64
|
||||
if json.Unmarshal(item["id"], &id) != nil || json.Unmarshal(item["name"], &name) != nil || json.Unmarshal(item["usedBytes"], &used) != nil {
|
||||
continue
|
||||
}
|
||||
if capacityField != "" {
|
||||
if json.Unmarshal(item[capacityField], &capacity) != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
observed := snapshot.ObservedAt
|
||||
if kind == "share" {
|
||||
var sizeObserved time.Time
|
||||
if json.Unmarshal(item["sizeObservedAt"], &sizeObserved) == nil && !sizeObserved.IsZero() {
|
||||
observed = sizeObserved.UTC()
|
||||
}
|
||||
}
|
||||
id, name = strings.TrimSpace(id), strings.TrimSpace(name)
|
||||
if id == "" || name == "" || len(id) > 128 || len(name) > 255 || used > math.MaxInt64 || capacity > math.MaxInt64 || observed.IsZero() || observed.After(snapshot.ReceivedAt.Add(MaxClockSkew)) {
|
||||
continue
|
||||
}
|
||||
samples = append(samples, capacitySample{Kind: kind, ID: id, Name: name, ObservedAt: observed, UsedBytes: used, CapacityBytes: capacity})
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
// Latest returns the most recent snapshot for the capability across every reporting
|
||||
// agent, or ErrNoSnapshot when none has been recorded.
|
||||
func (s PostgresStore) Latest(ctx context.Context, capability Capability) (Snapshot, error) {
|
||||
if !capability.Valid() {
|
||||
return Snapshot{}, fmt.Errorf("unknown agent capability %q", capability)
|
||||
}
|
||||
if s.Pool == nil {
|
||||
return Snapshot{}, ErrUnavailable
|
||||
}
|
||||
return scanSnapshot(s.Pool.QueryRow(ctx, `SELECT agent_id, capability, observed_at, received_at, payload
|
||||
FROM agent_snapshots WHERE capability = $1 ORDER BY observed_at DESC, received_at DESC LIMIT 1`,
|
||||
string(capability)))
|
||||
}
|
||||
|
||||
// scanSnapshot reads one row and maps the absence of a row onto ErrNoSnapshot, which is an
|
||||
// expected condition on a fresh install rather than a failure.
|
||||
func scanSnapshot(row pgx.Row) (Snapshot, error) {
|
||||
var (
|
||||
snapshot Snapshot
|
||||
name string
|
||||
payload []byte
|
||||
)
|
||||
err := row.Scan(&snapshot.AgentID, &name, &snapshot.ObservedAt, &snapshot.ReceivedAt, &payload)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Snapshot{}, ErrNoSnapshot
|
||||
}
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read agent snapshot: %w", err)
|
||||
}
|
||||
snapshot.Capability = Capability(name)
|
||||
snapshot.ObservedAt = snapshot.ObservedAt.UTC()
|
||||
snapshot.ReceivedAt = snapshot.ReceivedAt.UTC()
|
||||
snapshot.Payload = json.RawMessage(payload)
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// prepare enforces every bound the Writer contract documents and returns the row the
|
||||
// store persists. It is the single place where an inbound snapshot is trusted, and it
|
||||
// overwrites ReceivedAt with the store clock so the caller cannot influence freshness.
|
||||
func prepare(snapshot Snapshot, now time.Time) (Snapshot, error) {
|
||||
if snapshot.AgentID == "" || len(snapshot.AgentID) > MaxAgentIDBytes {
|
||||
return Snapshot{}, errors.New("agent id is required and bounded")
|
||||
}
|
||||
if !snapshot.Capability.Valid() {
|
||||
return Snapshot{}, fmt.Errorf("unknown agent capability %q", snapshot.Capability)
|
||||
}
|
||||
if len(snapshot.Payload) == 0 {
|
||||
return Snapshot{}, errors.New("agent snapshot payload is required")
|
||||
}
|
||||
if len(snapshot.Payload) > MaxPayloadBytes {
|
||||
return Snapshot{}, fmt.Errorf("agent snapshot payload exceeds %d bytes", MaxPayloadBytes)
|
||||
}
|
||||
if snapshot.ObservedAt.IsZero() {
|
||||
return Snapshot{}, errors.New("agent snapshot observed time is required")
|
||||
}
|
||||
if snapshot.ObservedAt.After(now.Add(MaxClockSkew)) {
|
||||
return Snapshot{}, errors.New("agent snapshot observed time is in the future")
|
||||
}
|
||||
var object map[string]json.RawMessage
|
||||
if err := json.Unmarshal(snapshot.Payload, &object); err != nil || object == nil {
|
||||
return Snapshot{}, errors.New("agent snapshot payload must be a JSON object")
|
||||
}
|
||||
snapshot.ObservedAt = snapshot.ObservedAt.UTC()
|
||||
snapshot.ReceivedAt = now
|
||||
return snapshot, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package agentstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
)
|
||||
|
||||
// TestPostgreSQLSnapshotRoundTrip exercises the real table created by migration 0016. It
|
||||
// skips unless PULSE_TEST_DATABASE_URL points at a disposable database.
|
||||
func TestPostgreSQLSnapshotRoundTrip(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `DELETE FROM agent_snapshots WHERE agent_id = 'integration-agent'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `DELETE FROM capacity_samples WHERE source_id = 'integration-agent'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanup, cancelCleanup := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancelCleanup()
|
||||
_, _ = pool.Exec(cleanup, `DELETE FROM agent_snapshots WHERE agent_id = 'integration-agent'`)
|
||||
_, _ = pool.Exec(cleanup, `DELETE FROM capacity_samples WHERE source_id = 'integration-agent'`)
|
||||
})
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
store := PostgresStore{Pool: pool, Clock: func() time.Time { return now }}
|
||||
readOwn := func() Snapshot {
|
||||
t.Helper()
|
||||
stored := Snapshot{AgentID: "integration-agent", Capability: CapabilityPools}
|
||||
var payload []byte
|
||||
if err := pool.QueryRow(ctx, `SELECT observed_at,received_at,payload FROM agent_snapshots WHERE agent_id=$1 AND capability=$2`, stored.AgentID, stored.Capability).Scan(&stored.ObservedAt, &stored.ReceivedAt, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored.Payload = payload
|
||||
return stored
|
||||
}
|
||||
if _, err := store.Latest(ctx, CapabilityPools); err != nil && !errors.Is(err, ErrNoSnapshot) {
|
||||
t.Fatalf("unexpected error reading an empty capability: %v", err)
|
||||
}
|
||||
|
||||
first := Snapshot{AgentID: "integration-agent", Capability: CapabilityPools, ObservedAt: now.Add(-30 * time.Second), ReceivedAt: now.Add(72 * time.Hour), Payload: json.RawMessage(`{"pools":[{"id":"cache","name":"Cache","usedBytes":100,"usableBytes":1000}],"generation":1}`)}
|
||||
if err := store.Put(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := readOwn()
|
||||
if !stored.ReceivedAt.Equal(now) {
|
||||
t.Fatalf("received at = %s, want the store clock %s", stored.ReceivedAt, now)
|
||||
}
|
||||
if !stored.ObservedAt.Equal(first.ObservedAt) {
|
||||
t.Fatalf("observed at = %s, want %s", stored.ObservedAt, first.ObservedAt)
|
||||
}
|
||||
|
||||
newer := first
|
||||
newer.ObservedAt = now.Add(-5 * time.Second)
|
||||
newer.Payload = json.RawMessage(`{"pools":[{"id":"cache","name":"Cache","usedBytes":200,"usableBytes":1000}],"generation":2}`)
|
||||
if err := store.Put(ctx, newer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
older := first
|
||||
older.ObservedAt = now.Add(-120 * time.Second)
|
||||
older.Payload = json.RawMessage(`{"pools":[{"id":"cache","name":"Cache","usedBytes":50,"usableBytes":1000}],"generation":3}`)
|
||||
if err := store.Put(ctx, older); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored = readOwn()
|
||||
var decoded struct {
|
||||
Generation int `json:"generation"`
|
||||
}
|
||||
if err := json.Unmarshal(stored.Payload, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Generation != 2 {
|
||||
t.Fatalf("a delayed retry must not resurrect superseded telemetry, got generation %d", decoded.Generation)
|
||||
}
|
||||
|
||||
var rows int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM agent_snapshots WHERE agent_id = 'integration-agent'`).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Fatalf("agent snapshot rows = %d, want exactly one per (agent, capability)", rows)
|
||||
}
|
||||
var sampleRows int
|
||||
var usedBytes int64
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*),max(used_bytes) FROM capacity_samples WHERE source_id='integration-agent' AND entity_kind='pool' AND entity_id='cache'`).Scan(&sampleRows, &usedBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sampleRows != 1 || usedBytes != 200 {
|
||||
t.Fatalf("six-hour bucket was not idempotent or accepted an older retry: rows=%d used=%d", sampleRows, usedBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package agentstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func fixedClock(now time.Time) func() time.Time { return func() time.Time { return now } }
|
||||
|
||||
func TestPutRejectsEveryDocumentedBound(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
valid := Snapshot{AgentID: "agent-1", Capability: CapabilityHost, ObservedAt: now.Add(-5 * time.Second), Payload: json.RawMessage(`{"identity":{"name":"tower"}}`)}
|
||||
oversized := make([]byte, MaxPayloadBytes+1)
|
||||
oversized[0] = '{'
|
||||
for i := 1; i < len(oversized)-1; i++ {
|
||||
oversized[i] = ' '
|
||||
}
|
||||
oversized[len(oversized)-1] = '}'
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(Snapshot) Snapshot
|
||||
contains string
|
||||
}{
|
||||
{"missing agent", func(s Snapshot) Snapshot { s.AgentID = ""; return s }, "agent id"},
|
||||
{"oversized agent", func(s Snapshot) Snapshot { s.AgentID = strings.Repeat("a", MaxAgentIDBytes+1); return s }, "agent id"},
|
||||
{"unknown capability", func(s Snapshot) Snapshot { s.Capability = "gpu"; return s }, "unknown agent capability"},
|
||||
{"empty capability", func(s Snapshot) Snapshot { s.Capability = ""; return s }, "unknown agent capability"},
|
||||
{"missing payload", func(s Snapshot) Snapshot { s.Payload = nil; return s }, "payload is required"},
|
||||
{"oversized payload", func(s Snapshot) Snapshot { s.Payload = oversized; return s }, "exceeds"},
|
||||
{"zero observed at", func(s Snapshot) Snapshot { s.ObservedAt = time.Time{}; return s }, "observed time is required"},
|
||||
{"future observed at", func(s Snapshot) Snapshot { s.ObservedAt = now.Add(time.Hour); return s }, "in the future"},
|
||||
{"array payload", func(s Snapshot) Snapshot { s.Payload = json.RawMessage(`[]`); return s }, "JSON object"},
|
||||
{"scalar payload", func(s Snapshot) Snapshot { s.Payload = json.RawMessage(`42`); return s }, "JSON object"},
|
||||
{"null payload", func(s Snapshot) Snapshot { s.Payload = json.RawMessage(`null`); return s }, "JSON object"},
|
||||
{"corrupt payload", func(s Snapshot) Snapshot { s.Payload = json.RawMessage(`{"a":`); return s }, "JSON object"},
|
||||
}
|
||||
store := PostgresStore{Clock: fixedClock(now)}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := store.Put(context.Background(), testCase.mutate(valid))
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("bounds must be enforced before availability: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), testCase.contains) {
|
||||
t.Fatalf("error %q does not mention %q", err, testCase.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := store.Put(context.Background(), valid); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("a valid snapshot with no pool must report unavailability, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutAcceptsEveryKnownCapability(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
for _, capability := range Capabilities() {
|
||||
snapshot := Snapshot{AgentID: "agent-1", Capability: capability, ObservedAt: now, Payload: json.RawMessage(`{}`)}
|
||||
if _, err := prepare(snapshot, now); err != nil {
|
||||
t.Fatalf("capability %q rejected: %v", capability, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapacitySamplesAreBoundedAndCapabilityAware(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
|
||||
snapshot := Snapshot{AgentID: "agent-1", Capability: CapabilityShares, ObservedAt: now, ReceivedAt: now, Payload: json.RawMessage(`{"shares":[{"id":"media","name":"Media","usedBytes":123,"sizeObservedAt":"2026-08-12T01:00:00Z"},{"id":"","name":"invalid","usedBytes":1}]}`)}
|
||||
samples, err := capacitySamples(snapshot)
|
||||
if err != nil || len(samples) != 1 || samples[0].Kind != "share" || samples[0].ID != "media" || samples[0].UsedBytes != 123 {
|
||||
t.Fatalf("share capacity extraction failed: %+v, %v", samples, err)
|
||||
}
|
||||
snapshot.Capability = CapabilityHost
|
||||
samples, err = capacitySamples(snapshot)
|
||||
if err != nil || len(samples) != 0 {
|
||||
t.Fatalf("non-capacity capability emitted samples: %+v, %v", samples, err)
|
||||
}
|
||||
snapshot.Capability = CapabilityPools
|
||||
snapshot.Payload = json.RawMessage(`{"pools":[{"id":"cache","name":"Cache","usedBytes":"invalid","usableBytes":1000}]}`)
|
||||
samples, err = capacitySamples(snapshot)
|
||||
if err != nil || len(samples) != 0 {
|
||||
t.Fatalf("malformed capacity values must fail closed: %+v, %v", samples, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTakesReceivedAtFromStoreClock(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
// A hostile agent claims it was received in the future and observed just now.
|
||||
row, err := prepare(Snapshot{
|
||||
AgentID: "agent-1",
|
||||
Capability: CapabilityProcesses,
|
||||
ObservedAt: now.Add(-90 * time.Second),
|
||||
ReceivedAt: now.Add(48 * time.Hour),
|
||||
Payload: json.RawMessage(`{"processes":[]}`),
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !row.ReceivedAt.Equal(now) {
|
||||
t.Fatalf("received at = %s, want the store clock %s", row.ReceivedAt, now)
|
||||
}
|
||||
if age := row.Age(now); age != 90*time.Second {
|
||||
t.Fatalf("age = %s, want 90s", age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareToleratesOnlyBenignClockSkew(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
base := Snapshot{AgentID: "agent-1", Capability: CapabilityHost, Payload: json.RawMessage(`{}`)}
|
||||
base.ObservedAt = now.Add(MaxClockSkew)
|
||||
if _, err := prepare(base, now); err != nil {
|
||||
t.Fatalf("skew within tolerance must be accepted: %v", err)
|
||||
}
|
||||
base.ObservedAt = now.Add(MaxClockSkew + time.Millisecond)
|
||||
if _, err := prepare(base, now); err == nil {
|
||||
t.Fatal("skew beyond tolerance must be rejected")
|
||||
}
|
||||
if MaxClockSkew >= 30*time.Second {
|
||||
t.Fatal("clock skew tolerance must stay well below the tightest freshness window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareNormalizesObservedAtToUTC(t *testing.T) {
|
||||
zone := time.FixedZone("CEST", 2*60*60)
|
||||
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
row, err := prepare(Snapshot{AgentID: "agent-1", Capability: CapabilityShares, ObservedAt: now.Add(-time.Minute).In(zone), Payload: json.RawMessage(`{}`)}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.ObservedAt.Location() != time.UTC {
|
||||
t.Fatalf("observed at location = %s, want UTC", row.ObservedAt.Location())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestRejectsUnknownCapabilityBeforeTouchingTheDatabase(t *testing.T) {
|
||||
store := PostgresStore{}
|
||||
if _, err := store.Latest(context.Background(), "gpu"); err == nil || !strings.Contains(err.Error(), "unknown agent capability") {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
if _, err := store.Latest(context.Background(), CapabilityHost); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stubRow stands in for one PostgreSQL row so the ErrNoSnapshot and decoding paths of
|
||||
// Latest can be exercised without a live database.
|
||||
type stubRow struct {
|
||||
err error
|
||||
values []any
|
||||
}
|
||||
|
||||
func (r stubRow) Scan(dest ...any) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
for index, target := range dest {
|
||||
switch typed := target.(type) {
|
||||
case *string:
|
||||
*typed = r.values[index].(string)
|
||||
case *time.Time:
|
||||
*typed = r.values[index].(time.Time)
|
||||
case *[]byte:
|
||||
*typed = r.values[index].([]byte)
|
||||
default:
|
||||
return errors.New("unsupported destination")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLatestReportsErrNoSnapshotWhenNothingWasRecorded(t *testing.T) {
|
||||
if _, err := scanSnapshot(stubRow{err: pgx.ErrNoRows}); !errors.Is(err, ErrNoSnapshot) {
|
||||
t.Fatalf("missing row must map to ErrNoSnapshot, got %v", err)
|
||||
}
|
||||
failure := errors.New("connection reset")
|
||||
_, err := scanSnapshot(stubRow{err: failure})
|
||||
if err == nil || errors.Is(err, ErrNoSnapshot) || !errors.Is(err, failure) {
|
||||
t.Fatalf("a read failure must not look like an absent snapshot, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestDecodesRowIntoUTCSnapshot(t *testing.T) {
|
||||
zone := time.FixedZone("CEST", 2*60*60)
|
||||
observed := time.Date(2026, 8, 4, 12, 0, 0, 0, zone)
|
||||
received := observed.Add(time.Second)
|
||||
snapshot, err := scanSnapshot(stubRow{values: []any{"agent-1", string(CapabilityDisks), observed, received, []byte(`{"disks":[]}`)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.AgentID != "agent-1" || snapshot.Capability != CapabilityDisks {
|
||||
t.Fatalf("unexpected identity %+v", snapshot)
|
||||
}
|
||||
if snapshot.ObservedAt.Location() != time.UTC || snapshot.ReceivedAt.Location() != time.UTC {
|
||||
t.Fatalf("timestamps must be UTC: %+v", snapshot)
|
||||
}
|
||||
if string(snapshot.Payload) != `{"disks":[]}` {
|
||||
t.Fatalf("unexpected payload %s", snapshot.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreClockDefaultsToWallClockInUTC(t *testing.T) {
|
||||
store := PostgresStore{}
|
||||
if location := store.now().Location(); location != time.UTC {
|
||||
t.Fatalf("store clock location = %s, want UTC", location)
|
||||
}
|
||||
fixed := time.Date(2026, 8, 4, 12, 0, 0, 0, time.FixedZone("CEST", 2*60*60))
|
||||
if got := (PostgresStore{Clock: fixedClock(fixed)}).now(); got.Location() != time.UTC || !got.Equal(fixed) {
|
||||
t.Fatalf("store clock = %s, want the injected instant in UTC", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user