This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user