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