This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
type SourceType string
|
||||
|
||||
const (
|
||||
SourcePrometheus SourceType = "prometheus"
|
||||
SourceUnraid SourceType = "unraid"
|
||||
SourceAgent SourceType = "agent"
|
||||
SourceExporter SourceType = "exporter"
|
||||
)
|
||||
|
||||
func (s SourceType) Valid() bool {
|
||||
switch s {
|
||||
case SourcePrometheus, SourceUnraid, SourceAgent, SourceExporter:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type HealthState string
|
||||
|
||||
const (
|
||||
HealthHealthy HealthState = "healthy"
|
||||
HealthDegraded HealthState = "degraded"
|
||||
HealthUnknown HealthState = "unknown"
|
||||
HealthDisabled HealthState = "disabled"
|
||||
)
|
||||
|
||||
type CapabilityState string
|
||||
|
||||
const (
|
||||
CapabilityEnabled CapabilityState = "enabled"
|
||||
CapabilityUnsupported CapabilityState = "unsupported"
|
||||
CapabilityUnavailable CapabilityState = "unavailable"
|
||||
CapabilityDisabled CapabilityState = "disabled"
|
||||
)
|
||||
|
||||
type FreshnessPolicy struct {
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
func (p FreshnessPolicy) Validate() error {
|
||||
if p.MaxAge <= 0 || p.MaxAge > 24*time.Hour {
|
||||
return errors.New("freshness max age must be between 1 second and 24 hours")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SourceHealth struct {
|
||||
State HealthState
|
||||
ObservedAt time.Time
|
||||
ReceivedAt time.Time
|
||||
LastSuccess time.Time
|
||||
Policy FreshnessPolicy
|
||||
ReasonCode string
|
||||
}
|
||||
|
||||
func (h SourceHealth) Validate(now time.Time) error {
|
||||
if h.State != HealthHealthy && h.State != HealthDegraded && h.State != HealthUnknown && h.State != HealthDisabled {
|
||||
return fmt.Errorf("invalid health state %q", h.State)
|
||||
}
|
||||
if err := h.Policy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.ReceivedAt.IsZero() {
|
||||
return errors.New("received timestamp is required")
|
||||
}
|
||||
if h.ObservedAt.IsZero() && h.State == HealthHealthy {
|
||||
return errors.New("healthy source requires observed timestamp")
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
if !h.ObservedAt.IsZero() && h.ObservedAt.After(now.Add(time.Minute)) {
|
||||
return errors.New("observed timestamp cannot be materially in the future")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h SourceHealth) Fresh(now time.Time) bool {
|
||||
if h.State == HealthDisabled || h.State == HealthUnknown || h.ObservedAt.IsZero() || h.Policy.MaxAge <= 0 {
|
||||
return false
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return !h.ObservedAt.Before(now.Add(-h.Policy.MaxAge))
|
||||
}
|
||||
|
||||
func (h SourceHealth) EffectiveState(now time.Time) HealthState {
|
||||
if h.State == HealthHealthy && !h.Fresh(now) {
|
||||
return HealthUnknown
|
||||
}
|
||||
return h.State
|
||||
}
|
||||
|
||||
type Capability struct {
|
||||
ID string
|
||||
Version string
|
||||
State CapabilityState
|
||||
Description string
|
||||
ReasonCode string
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
func (c Capability) Validate() error {
|
||||
if strings.TrimSpace(c.ID) == "" || len(c.ID) > 120 {
|
||||
return errors.New("capability id must be 1-120 characters")
|
||||
}
|
||||
if strings.TrimSpace(c.Version) == "" || len(c.Version) > 32 {
|
||||
return errors.New("capability version must be 1-32 characters")
|
||||
}
|
||||
switch c.State {
|
||||
case CapabilityEnabled, CapabilityUnsupported, CapabilityUnavailable, CapabilityDisabled:
|
||||
default:
|
||||
return fmt.Errorf("invalid capability state %q", c.State)
|
||||
}
|
||||
if len(c.Description) > 500 || len(c.ReasonCode) > 80 {
|
||||
return errors.New("capability text exceeds bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CapabilitySet []Capability
|
||||
|
||||
func (s CapabilitySet) Validate() error {
|
||||
if len(s) > 100 {
|
||||
return errors.New("capability set exceeds 100 entries")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(s))
|
||||
for _, capability := range s {
|
||||
if err := capability.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
key := capability.ID + "@" + capability.Version
|
||||
if _, exists := seen[key]; exists {
|
||||
return fmt.Errorf("duplicate capability %q", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s CapabilitySet) Find(id string) (Capability, bool) {
|
||||
for _, capability := range s {
|
||||
if capability.ID == id {
|
||||
return capability, true
|
||||
}
|
||||
}
|
||||
return Capability{}, false
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID string
|
||||
Name string
|
||||
Type SourceType
|
||||
Enabled bool
|
||||
Contract string
|
||||
Health SourceHealth
|
||||
Capabilities CapabilitySet
|
||||
}
|
||||
|
||||
func (s Source) Validate(now time.Time) error {
|
||||
if strings.TrimSpace(s.ID) == "" || len(s.ID) > 120 || strings.TrimSpace(s.Name) == "" || len(s.Name) > 255 {
|
||||
return errors.New("source id and name are required and bounded")
|
||||
}
|
||||
if !s.Type.Valid() {
|
||||
return fmt.Errorf("invalid source type %q", s.Type)
|
||||
}
|
||||
if s.Contract != ContractVersion {
|
||||
return fmt.Errorf("unsupported datasource contract %q", s.Contract)
|
||||
}
|
||||
if err := s.Health.Validate(now); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Capabilities.Validate()
|
||||
}
|
||||
|
||||
type DiscoveryResult struct {
|
||||
Source Source
|
||||
}
|
||||
|
||||
type Entity struct {
|
||||
ExternalType string
|
||||
ExternalID string
|
||||
EntityType string
|
||||
CanonicalName string
|
||||
DisplayName string
|
||||
Status string
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
type InventoryResult struct {
|
||||
Entities []Entity
|
||||
}
|
||||
|
||||
type MetricBinding struct {
|
||||
SemanticName string
|
||||
Version string
|
||||
CapabilityID string
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string
|
||||
Summary string
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Source Source
|
||||
Inventory InventoryResult
|
||||
MetricBindings []MetricBinding
|
||||
Events []Event
|
||||
}
|
||||
|
||||
type Adapter interface {
|
||||
Discover(context.Context) (DiscoveryResult, error)
|
||||
Health(context.Context) (SourceHealth, error)
|
||||
Inventory(context.Context) (InventoryResult, error)
|
||||
MetricsBindings(context.Context) ([]MetricBinding, error)
|
||||
Events(context.Context) ([]Event, error)
|
||||
Capabilities(context.Context) (CapabilitySet, error)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSourceHealthBecomesUnknownWhenStale(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
health := SourceHealth{State: HealthHealthy, ObservedAt: now.Add(-2 * time.Minute), ReceivedAt: now, Policy: FreshnessPolicy{MaxAge: time.Minute}}
|
||||
if got := health.EffectiveState(now); got != HealthUnknown {
|
||||
t.Fatalf("effective state = %q, want unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitySetRejectsDuplicateVersions(t *testing.T) {
|
||||
set := CapabilitySet{{ID: "inventory", Version: "v1", State: CapabilityEnabled}, {ID: "inventory", Version: "v1", State: CapabilityUnavailable}}
|
||||
if err := set.Validate(); err == nil {
|
||||
t.Fatal("expected duplicate capability validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceRejectsUnknownContractAndUnboundedHealth(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
source := Source{ID: "source-1", Name: "Prometheus", Type: SourcePrometheus, Enabled: true, Contract: "v2", Health: SourceHealth{State: HealthUnknown, ReceivedAt: now, Policy: FreshnessPolicy{MaxAge: 25 * time.Hour}}}
|
||||
if err := source.Validate(now); err == nil {
|
||||
t.Fatal("expected source validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterContractIsTransportFree(t *testing.T) {
|
||||
var adapter Adapter = fakeAdapter{}
|
||||
if _, err := adapter.Discover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAdapter struct{}
|
||||
|
||||
func (fakeAdapter) Discover(context.Context) (DiscoveryResult, error) { return DiscoveryResult{}, nil }
|
||||
func (fakeAdapter) Health(context.Context) (SourceHealth, error) { return SourceHealth{}, nil }
|
||||
func (fakeAdapter) Inventory(context.Context) (InventoryResult, error) { return InventoryResult{}, nil }
|
||||
func (fakeAdapter) MetricsBindings(context.Context) ([]MetricBinding, error) { return nil, nil }
|
||||
func (fakeAdapter) Events(context.Context) ([]Event, error) { return nil, nil }
|
||||
func (fakeAdapter) Capabilities(context.Context) (CapabilitySet, error) { return nil, nil }
|
||||
Reference in New Issue
Block a user