Public source validation / validate (push) Failing after 3m8s
290 lines
10 KiB
Go
290 lines
10 KiB
Go
// Package agentsource turns the bounded snapshots pulse-agent writes through
|
|
// internal/agentstore into the normalized domain snapshots pulse-api serves.
|
|
//
|
|
// Every provider in this package follows the same three steps: read the newest snapshot
|
|
// for one capability, decide whether it is usable, and normalize it through the domain's
|
|
// own Adapter. Only the middle step is interesting, and it is the ADR-0008 enforcement
|
|
// point for the whole read path: telemetry that is missing, stale, or undecodable
|
|
// resolves to that domain's Unknown snapshot and never to Healthy.
|
|
//
|
|
// The freshness decision itself is delegated to internal/freshness so this package does
|
|
// not become another private copy of the rule. freshness.Evaluate is called with the
|
|
// snapshot presented as a required, currently healthy datasource.SourceHealth; if it
|
|
// answers with anything other than Healthy the snapshot is discarded as stale. That keeps
|
|
// one tested implementation of "missing telemetry never becomes Healthy" in the codebase.
|
|
//
|
|
// # Reason codes
|
|
//
|
|
// A provider that cannot serve real telemetry reports exactly one machine-readable reason
|
|
// from this closed set, which the web app maps onto localized copy:
|
|
//
|
|
// source_unavailable — nothing has been recorded for the capability yet, or the store
|
|
// could not be read at all.
|
|
// source_stale — the newest snapshot is older than the capability's freshness
|
|
// window, or its timestamps are not usable.
|
|
// source_invalid — the payload could not be decoded into the domain's RawSnapshot,
|
|
// or the domain rejected it during normalization.
|
|
//
|
|
// # Error handling
|
|
//
|
|
// Context cancellation and deadlines propagate to the caller. Any other store failure is
|
|
// reported as an Unknown snapshot with source_unavailable rather than an error: a
|
|
// monitoring surface that says explicitly "this source is unavailable" is more useful to
|
|
// an operator than a bare 503, and it keeps a single database hiccup from taking every
|
|
// monitoring page down at once.
|
|
package agentsource
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/itworx/pulse/internal/agentstore"
|
|
"github.com/itworx/pulse/internal/datasource"
|
|
"github.com/itworx/pulse/internal/freshness"
|
|
)
|
|
|
|
// Reason codes reported when a provider cannot serve real telemetry. The set is closed;
|
|
// see the package documentation for the meaning of each code.
|
|
const (
|
|
ReasonUnavailable = "source_unavailable"
|
|
ReasonStale = "source_stale"
|
|
ReasonInvalid = "source_invalid"
|
|
)
|
|
|
|
// Default freshness windows per capability. They mirror the freshness policy defaults of
|
|
// the domain packages, so a snapshot that passes this gate is never marked stale again
|
|
// further down the pipeline, and they follow the agent's sampling cadence: the host is
|
|
// sampled every few seconds, processes and containers roughly every half minute, and
|
|
// share usage is an expensive scan that runs far less often.
|
|
const (
|
|
DefaultHostWindow = 30 * time.Second
|
|
DefaultProcessesWindow = 60 * time.Second
|
|
DefaultContainersWindow = 60 * time.Second
|
|
DefaultArrayWindow = 60 * time.Second
|
|
DefaultDisksWindow = 60 * time.Second
|
|
DefaultPoolsWindow = 60 * time.Second
|
|
DefaultSharesWindow = 2 * time.Minute
|
|
)
|
|
|
|
// Windows configures how old a snapshot may be before its capability resolves to Unknown.
|
|
// A zero field takes the documented default for that capability.
|
|
type Windows struct {
|
|
Host time.Duration
|
|
Processes time.Duration
|
|
Containers time.Duration
|
|
Array time.Duration
|
|
Disks time.Duration
|
|
Pools time.Duration
|
|
Shares time.Duration
|
|
}
|
|
|
|
// WithDefaults fills every unset window with its documented default.
|
|
func (w Windows) WithDefaults() Windows {
|
|
if w.Host == 0 {
|
|
w.Host = DefaultHostWindow
|
|
}
|
|
if w.Processes == 0 {
|
|
w.Processes = DefaultProcessesWindow
|
|
}
|
|
if w.Containers == 0 {
|
|
w.Containers = DefaultContainersWindow
|
|
}
|
|
if w.Array == 0 {
|
|
w.Array = DefaultArrayWindow
|
|
}
|
|
if w.Disks == 0 {
|
|
w.Disks = DefaultDisksWindow
|
|
}
|
|
if w.Pools == 0 {
|
|
w.Pools = DefaultPoolsWindow
|
|
}
|
|
if w.Shares == 0 {
|
|
w.Shares = DefaultSharesWindow
|
|
}
|
|
return w
|
|
}
|
|
|
|
// Validate reports whether every configured window is inside safe bounds. Windows are
|
|
// bounded above at 24 hours because datasource.FreshnessPolicy refuses anything longer:
|
|
// a source nobody has heard from for a day is not fresh under any reading.
|
|
func (w Windows) Validate() error {
|
|
for capability, window := range w.WithDefaults().byCapability() {
|
|
if window <= 0 || window > 24*time.Hour {
|
|
return fmt.Errorf("freshness window for %q is outside safe bounds", capability)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// For returns the freshness window for one capability, applying defaults.
|
|
func (w Windows) For(capability agentstore.Capability) time.Duration {
|
|
if window, ok := w.WithDefaults().byCapability()[capability]; ok {
|
|
return window
|
|
}
|
|
// An unrecognized capability cannot be served at all; the tightest window keeps a
|
|
// caller that ignores that from treating anything as fresh.
|
|
return DefaultHostWindow
|
|
}
|
|
|
|
func (w Windows) byCapability() map[agentstore.Capability]time.Duration {
|
|
return map[agentstore.Capability]time.Duration{
|
|
agentstore.CapabilityHost: w.Host,
|
|
agentstore.CapabilityProcesses: w.Processes,
|
|
agentstore.CapabilityContainers: w.Containers,
|
|
agentstore.CapabilityArray: w.Array,
|
|
agentstore.CapabilityDisks: w.Disks,
|
|
agentstore.CapabilityPools: w.Pools,
|
|
agentstore.CapabilityShares: w.Shares,
|
|
}
|
|
}
|
|
|
|
// staticRaw presents an already decoded RawSnapshot as the raw source interface each
|
|
// domain Adapter expects, so normalization keeps running through the domain's own code.
|
|
type staticRaw[R any] struct{ raw R }
|
|
|
|
func (s staticRaw[R]) Snapshot(context.Context) (R, error) { return s.raw, nil }
|
|
|
|
// resolve implements the shared read path. It is generic over the domain's RawSnapshot
|
|
// and normalized Snapshot so every capability enforces the same rules in the same order.
|
|
func resolve[R any, S any](
|
|
ctx context.Context,
|
|
reader agentstore.Reader,
|
|
capability agentstore.Capability,
|
|
window time.Duration,
|
|
now time.Time,
|
|
unknown func(time.Time, string) S,
|
|
normalize func(R, time.Time) (S, error),
|
|
) (S, error) {
|
|
var zero S
|
|
if err := ctx.Err(); err != nil {
|
|
return zero, err
|
|
}
|
|
if reader == nil {
|
|
return unknown(now, ReasonUnavailable), nil
|
|
}
|
|
stored, err := reader.Latest(ctx, capability)
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return zero, err
|
|
}
|
|
return unknown(now, ReasonUnavailable), nil
|
|
}
|
|
if reason := usableReason(string(capability), stored, window, now); reason != "" {
|
|
return unknown(now, reason), nil
|
|
}
|
|
var raw R
|
|
if err := json.Unmarshal(stored.Payload, &raw); err != nil {
|
|
return unknown(now, ReasonInvalid), nil
|
|
}
|
|
snapshot, err := normalize(raw, now)
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return zero, err
|
|
}
|
|
return unknown(now, ReasonInvalid), nil
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
// usableReason returns an empty string when the snapshot may be normalized, or the reason
|
|
// code that must be reported instead. The freshness judgement is made by
|
|
// internal/freshness rather than re-derived here.
|
|
func usableReason(sourceID string, stored agentstore.Snapshot, window time.Duration, now time.Time) string {
|
|
result, err := freshness.Evaluate(freshness.Input{
|
|
SourceID: sourceID,
|
|
Required: true,
|
|
Now: now,
|
|
Health: datasource.SourceHealth{
|
|
State: datasource.HealthHealthy,
|
|
ObservedAt: stored.ObservedAt,
|
|
ReceivedAt: stored.ReceivedAt,
|
|
LastSuccess: stored.ObservedAt,
|
|
Policy: datasource.FreshnessPolicy{MaxAge: window},
|
|
},
|
|
})
|
|
if err != nil {
|
|
// Unusable timestamps (missing, or materially in the future) are indistinguishable
|
|
// from staleness for a consumer: in both cases the observation cannot be trusted
|
|
// to describe the present.
|
|
return ReasonStale
|
|
}
|
|
if result.State != datasource.HealthHealthy {
|
|
return ReasonStale
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// clockNow resolves the injected clock, defaulting to the wall clock in UTC.
|
|
func clockNow(clock func() time.Time) time.Time {
|
|
if clock == nil {
|
|
return time.Now().UTC()
|
|
}
|
|
return clock().UTC()
|
|
}
|
|
|
|
func fixedClock(now time.Time) func() time.Time { return func() time.Time { return now } }
|
|
|
|
// Health summarizes a bounded set of agent capabilities into one datasource health
|
|
// observation. Every requested capability is required: a source is only Healthy when
|
|
// each capability has a recent snapshot. This is used by self-observability so it reads
|
|
// the same persisted transport as the domain providers instead of inferring agent
|
|
// configuration from API process environment variables.
|
|
func Health(ctx context.Context, reader agentstore.Reader, capabilities []agentstore.Capability, windows Windows, now time.Time) (datasource.SourceHealth, error) {
|
|
now = now.UTC()
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
}
|
|
health := datasource.SourceHealth{
|
|
State: datasource.HealthUnknown,
|
|
ReceivedAt: now,
|
|
Policy: datasource.FreshnessPolicy{MaxAge: DefaultHostWindow},
|
|
ReasonCode: ReasonUnavailable,
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return datasource.SourceHealth{}, err
|
|
}
|
|
if reader == nil || len(capabilities) == 0 {
|
|
return health, nil
|
|
}
|
|
for _, capability := range capabilities {
|
|
if window := windows.For(capability); window > health.Policy.MaxAge {
|
|
health.Policy.MaxAge = window
|
|
}
|
|
}
|
|
oldestObserved := now
|
|
oldestReceived := now
|
|
for _, capability := range capabilities {
|
|
if !capability.Valid() {
|
|
return datasource.SourceHealth{}, fmt.Errorf("summarize unknown agent capability %q", capability)
|
|
}
|
|
stored, err := reader.Latest(ctx, capability)
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return datasource.SourceHealth{}, err
|
|
}
|
|
return health, nil
|
|
}
|
|
if reason := usableReason(string(capability), stored, windows.For(capability), now); reason != "" {
|
|
health.ObservedAt = stored.ObservedAt.UTC()
|
|
health.ReceivedAt = stored.ReceivedAt.UTC()
|
|
health.ReasonCode = reason
|
|
return health, nil
|
|
}
|
|
if stored.ObservedAt.Before(oldestObserved) {
|
|
oldestObserved = stored.ObservedAt.UTC()
|
|
}
|
|
if stored.ReceivedAt.Before(oldestReceived) {
|
|
oldestReceived = stored.ReceivedAt.UTC()
|
|
}
|
|
}
|
|
health.State = datasource.HealthHealthy
|
|
health.ObservedAt = oldestObserved
|
|
health.ReceivedAt = oldestReceived
|
|
health.LastSuccess = oldestObserved
|
|
health.ReasonCode = "source_sampled"
|
|
return health, nil
|
|
}
|