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