Files
ITWorx-Pulse-Public/internal/agentsource/agentsource.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

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
}