This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/host"
|
||||
"github.com/itworx/pulse/internal/service"
|
||||
)
|
||||
|
||||
type Aggregator struct {
|
||||
Host host.Provider
|
||||
Services service.Provider
|
||||
Limits Limits
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (a Aggregator) Snapshot(ctx context.Context) (Snapshot, error) {
|
||||
if ctx == nil {
|
||||
return Snapshot{}, errors.New("network context is nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if a.Now != nil {
|
||||
now = a.Now().UTC()
|
||||
}
|
||||
hostSnapshot := host.UnknownSnapshot(now, "host", "agent", "source_unavailable")
|
||||
if a.Host != nil {
|
||||
next, err := a.Host.Snapshot(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
hostSnapshot = host.UnknownSnapshot(now, "host", "agent", "source_unavailable")
|
||||
} else {
|
||||
hostSnapshot = next
|
||||
}
|
||||
}
|
||||
serviceSnapshot := service.UnknownSnapshot(now, "source_unavailable")
|
||||
if a.Services != nil {
|
||||
next, err := a.Services.Snapshot(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
serviceSnapshot = service.UnknownSnapshot(now, "source_unavailable")
|
||||
} else {
|
||||
serviceSnapshot = next
|
||||
}
|
||||
}
|
||||
return BuildSnapshot(now, hostSnapshot, serviceSnapshot, nil, nil, a.Limits)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/host"
|
||||
"github.com/itworx/pulse/internal/service"
|
||||
)
|
||||
|
||||
type errorHostProvider struct{}
|
||||
|
||||
func (errorHostProvider) Snapshot(context.Context) (host.Snapshot, error) {
|
||||
return host.Snapshot{}, errors.New("host unavailable")
|
||||
}
|
||||
|
||||
type staticServiceProvider struct{ snapshot service.Snapshot }
|
||||
|
||||
func (p staticServiceProvider) Snapshot(context.Context) (service.Snapshot, error) {
|
||||
return p.snapshot, nil
|
||||
}
|
||||
|
||||
func TestAggregatorKeepsPartialSourcesAvailable(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
provider := Aggregator{Host: errorHostProvider{}, Services: staticServiceProvider{snapshot: service.Snapshot{ObservedAt: now}}, Now: func() time.Time { return now }}
|
||||
snapshot, err := provider.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Source.State != StateUnknown || snapshot.Health[0].State != StateUnknown || snapshot.Health[2].State != StateUnknown {
|
||||
t.Fatalf("partial source did not remain unknown: %+v", snapshot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/host"
|
||||
"github.com/itworx/pulse/internal/service"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
const (
|
||||
ScopeInternal = "internal"
|
||||
ScopeGateway = "gateway"
|
||||
ScopeDNS = "dns"
|
||||
ScopeInternet = "internet"
|
||||
|
||||
StateUp = "up"
|
||||
StateDegraded = "degraded"
|
||||
StateDown = "down"
|
||||
StateUnknown = "unknown"
|
||||
)
|
||||
|
||||
type Interface struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
RxBytes uint64 `json:"rxBytes"`
|
||||
TxBytes uint64 `json:"txBytes"`
|
||||
RxErrors uint64 `json:"rxErrors"`
|
||||
TxErrors uint64 `json:"txErrors"`
|
||||
RxDrops uint64 `json:"rxDrops"`
|
||||
TxDrops uint64 `json:"txDrops"`
|
||||
}
|
||||
|
||||
type Health struct {
|
||||
Scope string `json:"scope"`
|
||||
State string `json:"state"`
|
||||
CapabilityState string `json:"capabilityState"`
|
||||
ConfigurationState string `json:"configurationState"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
SourceID string `json:"sourceId,omitempty"`
|
||||
Freshness string `json:"freshness"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
LatencyMS *int `json:"latencyMs,omitempty"`
|
||||
}
|
||||
|
||||
type Certificate struct {
|
||||
ID string `json:"id"`
|
||||
ServiceID string `json:"serviceId"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
HostnameValid *bool `json:"hostnameValid,omitempty"`
|
||||
VerificationState string `json:"verificationState"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Source Source `json:"source"`
|
||||
Health []Health `json:"health"`
|
||||
Interfaces []Interface `json:"interfaces"`
|
||||
Certificates []Certificate `json:"certificates"`
|
||||
Events []Event `json:"events"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID string `json:"id"`
|
||||
Freshness string `json:"freshness"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type Signal struct {
|
||||
Scope string
|
||||
State string
|
||||
Reason string
|
||||
SourceID string
|
||||
Freshness string
|
||||
ObservedAt time.Time
|
||||
LatencyMS *int
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
MaxInterfaces int
|
||||
MaxCertificates int
|
||||
MaxEvents int
|
||||
}
|
||||
|
||||
func (l Limits) withDefaults() Limits {
|
||||
if l.MaxInterfaces == 0 {
|
||||
l.MaxInterfaces = 128
|
||||
}
|
||||
if l.MaxCertificates == 0 {
|
||||
l.MaxCertificates = 100
|
||||
}
|
||||
if l.MaxEvents == 0 {
|
||||
l.MaxEvents = 100
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l Limits) Validate() error {
|
||||
if l.MaxInterfaces < 1 || l.MaxInterfaces > 256 || l.MaxCertificates < 1 || l.MaxCertificates > 500 || l.MaxEvents < 1 || l.MaxEvents > 500 {
|
||||
return errors.New("network limits are outside safe bounds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
}
|
||||
|
||||
func BuildSnapshot(now time.Time, hostSnapshot host.Snapshot, serviceSnapshot service.Snapshot, signals []Signal, events []Event, limits Limits) (Snapshot, error) {
|
||||
limits = limits.withDefaults()
|
||||
if err := limits.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
now = now.UTC()
|
||||
interfaces := make([]Interface, 0, minInt(limits.MaxInterfaces, len(hostSnapshot.Network)))
|
||||
hostFresh := hostSnapshot.Source.Freshness == host.Fresh && hostSnapshot.Source.State != host.StatusUnknown
|
||||
for _, item := range hostSnapshot.Network {
|
||||
if len(interfaces) >= limits.MaxInterfaces || strings.TrimSpace(item.Name) == "" {
|
||||
break
|
||||
}
|
||||
state := normalizeState(item.State)
|
||||
if !hostFresh {
|
||||
state = StateUnknown
|
||||
}
|
||||
interfaces = append(interfaces, Interface{Name: item.Name, State: state, RxBytes: item.RxBytes, TxBytes: item.TxBytes, RxErrors: item.RxErrors, TxErrors: item.TxErrors, RxDrops: item.RxDrops, TxDrops: item.TxDrops})
|
||||
}
|
||||
sort.SliceStable(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name })
|
||||
health := []Health{
|
||||
{Scope: ScopeInternal, State: internalState(hostSnapshot, hostFresh), CapabilityState: capabilityFromReason(hostSnapshot.Source.Reason), ConfigurationState: service.ConfigurationConfigured, Reason: internalReason(hostSnapshot, hostFresh), SourceID: hostSnapshot.Source.ID, Freshness: hostSnapshot.Source.Freshness, ObservedAt: timeValue(hostSnapshot.ObservedAt, now)},
|
||||
{Scope: ScopeGateway, State: StateUnknown, CapabilityState: service.CapabilityAvailable, ConfigurationState: service.ConfigurationNotConfigured, Reason: "not_configured", Freshness: "unavailable", ObservedAt: now},
|
||||
{Scope: ScopeDNS, State: dnsState(serviceSnapshot), CapabilityState: dnsCapability(serviceSnapshot), ConfigurationState: dnsConfiguration(serviceSnapshot), Reason: dnsReason(serviceSnapshot), Freshness: serviceFreshness(serviceSnapshot), ObservedAt: timeValue(serviceSnapshot.ObservedAt, now)},
|
||||
{Scope: ScopeInternet, State: StateUnknown, CapabilityState: service.CapabilityAvailable, ConfigurationState: service.ConfigurationNotConfigured, Reason: "not_configured", Freshness: "unavailable", ObservedAt: now},
|
||||
}
|
||||
for _, signal := range signals {
|
||||
if err := validateSignal(signal); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
for index := range health {
|
||||
if health[index].Scope == signal.Scope {
|
||||
health[index] = Health{Scope: signal.Scope, State: normalizeState(signal.State), CapabilityState: service.CapabilityAvailable, ConfigurationState: service.ConfigurationConfigured, Reason: boundText(signal.Reason, 128), SourceID: boundText(signal.SourceID, 128), Freshness: boundText(signal.Freshness, 32), ObservedAt: timeValue(signal.ObservedAt, now), LatencyMS: boundedLatency(signal.LatencyMS)}
|
||||
}
|
||||
}
|
||||
}
|
||||
certificates := certificatesFromServices(serviceSnapshot, limits.MaxCertificates)
|
||||
boundedEvents := append([]Event(nil), events...)
|
||||
if len(boundedEvents) > limits.MaxEvents {
|
||||
boundedEvents = boundedEvents[:limits.MaxEvents]
|
||||
}
|
||||
sort.SliceStable(boundedEvents, func(i, j int) bool {
|
||||
if !boundedEvents[i].OccurredAt.Equal(boundedEvents[j].OccurredAt) {
|
||||
return boundedEvents[i].OccurredAt.After(boundedEvents[j].OccurredAt)
|
||||
}
|
||||
return boundedEvents[i].ID < boundedEvents[j].ID
|
||||
})
|
||||
return Snapshot{ContractVersion: ContractVersion, ObservedAt: now, Source: Source{ID: "network-aggregate", Freshness: aggregateFreshness(hostSnapshot, serviceSnapshot), ObservedAt: now, State: aggregateState(health)}, Health: health, Interfaces: interfaces, Certificates: certificates, Events: boundedEvents}, nil
|
||||
}
|
||||
|
||||
func validateSignal(signal Signal) error {
|
||||
if signal.Scope != ScopeInternal && signal.Scope != ScopeGateway && signal.Scope != ScopeDNS && signal.Scope != ScopeInternet {
|
||||
return errors.New("network signal scope is invalid")
|
||||
}
|
||||
if signal.State != StateUp && signal.State != StateDegraded && signal.State != StateDown && signal.State != StateUnknown {
|
||||
return errors.New("network signal state is invalid")
|
||||
}
|
||||
if signal.LatencyMS != nil && *signal.LatencyMS < 0 {
|
||||
return errors.New("network signal latency is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func internalState(snapshot host.Snapshot, fresh bool) string {
|
||||
if !fresh {
|
||||
return StateUnknown
|
||||
}
|
||||
switch snapshot.Status.State {
|
||||
case host.StatusHealthy:
|
||||
return StateUp
|
||||
case host.StatusDegraded:
|
||||
return StateDegraded
|
||||
default:
|
||||
return StateUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func internalReason(snapshot host.Snapshot, fresh bool) string {
|
||||
if !fresh {
|
||||
if snapshot.Source.Reason != "" {
|
||||
return snapshot.Source.Reason
|
||||
}
|
||||
return "source_stale"
|
||||
}
|
||||
if len(snapshot.Status.Reasons) > 0 {
|
||||
return snapshot.Status.Reasons[0].Code
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
func dnsState(snapshot service.Snapshot) string {
|
||||
states := make([]string, 0)
|
||||
for _, item := range snapshot.Services {
|
||||
for _, config := range item.Probes {
|
||||
if config.Type != "dns" {
|
||||
continue
|
||||
}
|
||||
if item.State == service.StateUnknown {
|
||||
states = append(states, StateUnknown)
|
||||
continue
|
||||
}
|
||||
for _, result := range item.History {
|
||||
if result.ProbeID == config.ID {
|
||||
states = append(states, normalizeState(result.State))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return StateUnknown
|
||||
}
|
||||
for _, state := range states {
|
||||
if state == StateDown {
|
||||
return StateDown
|
||||
}
|
||||
}
|
||||
for _, state := range states {
|
||||
if state == StateUnknown {
|
||||
return StateUnknown
|
||||
}
|
||||
}
|
||||
for _, state := range states {
|
||||
if state == StateDegraded {
|
||||
return StateDegraded
|
||||
}
|
||||
}
|
||||
return StateUp
|
||||
}
|
||||
|
||||
func dnsReason(snapshot service.Snapshot) string {
|
||||
switch dnsState(snapshot) {
|
||||
case StateDown:
|
||||
return "dns_probe_failed"
|
||||
case StateDegraded:
|
||||
return "dns_probe_degraded"
|
||||
case StateUp:
|
||||
return "dns_probe_healthy"
|
||||
default:
|
||||
if dnsConfiguration(snapshot) == service.ConfigurationNotConfigured {
|
||||
return "not_configured"
|
||||
}
|
||||
for _, item := range snapshot.Services {
|
||||
for _, config := range item.Probes {
|
||||
if config.Type == "dns" && item.Reason != "" {
|
||||
return item.Reason
|
||||
}
|
||||
}
|
||||
}
|
||||
return "source_unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
func dnsConfiguration(snapshot service.Snapshot) string {
|
||||
for _, item := range snapshot.Services {
|
||||
for _, config := range item.Probes {
|
||||
if config.Type == "dns" {
|
||||
return service.ConfigurationConfigured
|
||||
}
|
||||
}
|
||||
}
|
||||
if snapshot.CapabilityState == service.CapabilityUnavailable || snapshot.ConfigurationState == service.ConfigurationUnknown {
|
||||
return service.ConfigurationUnknown
|
||||
}
|
||||
return service.ConfigurationNotConfigured
|
||||
}
|
||||
|
||||
func dnsCapability(snapshot service.Snapshot) string {
|
||||
if snapshot.CapabilityState == service.CapabilityUnsupported {
|
||||
return service.CapabilityUnsupported
|
||||
}
|
||||
if snapshot.CapabilityState == service.CapabilityUnavailable {
|
||||
return service.CapabilityUnavailable
|
||||
}
|
||||
return service.CapabilityAvailable
|
||||
}
|
||||
|
||||
func capabilityFromReason(reason string) string {
|
||||
if reason == "unsupported" {
|
||||
return service.CapabilityUnsupported
|
||||
}
|
||||
if reason == "source_unavailable" {
|
||||
return service.CapabilityUnavailable
|
||||
}
|
||||
return service.CapabilityAvailable
|
||||
}
|
||||
|
||||
func serviceFreshness(snapshot service.Snapshot) string {
|
||||
for _, item := range snapshot.Services {
|
||||
if item.State != service.StateUnknown {
|
||||
return "fresh"
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func certificatesFromServices(snapshot service.Snapshot, limit int) []Certificate {
|
||||
byID := make(map[string]Certificate)
|
||||
for _, item := range snapshot.Services {
|
||||
if item.Certificate != nil && item.Certificate.ID != "" {
|
||||
certificate := item.Certificate
|
||||
byID[certificate.ID] = Certificate{ID: certificate.ID, ServiceID: certificate.ServiceID, ObservedAt: certificate.ObservedAt.UTC(), ExpiresAt: certificate.ExpiresAt, Issuer: certificate.Issuer, Subject: certificate.Subject, HostnameValid: certificate.HostnameValid, VerificationState: certificate.VerificationState}
|
||||
}
|
||||
for _, result := range item.History {
|
||||
if result.Certificate == nil || result.Certificate.ID == "" {
|
||||
continue
|
||||
}
|
||||
certificate := result.Certificate
|
||||
byID[certificate.ID] = Certificate{ID: certificate.ID, ServiceID: certificate.ServiceID, ObservedAt: certificate.ObservedAt.UTC(), ExpiresAt: certificate.ExpiresAt, Issuer: certificate.Issuer, Subject: certificate.Subject, HostnameValid: certificate.HostnameValid, VerificationState: certificate.VerificationState}
|
||||
}
|
||||
}
|
||||
items := make([]Certificate, 0, len(byID))
|
||||
for _, item := range byID {
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if !items[i].ObservedAt.Equal(items[j].ObservedAt) {
|
||||
return items[i].ObservedAt.After(items[j].ObservedAt)
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func aggregateFreshness(hostSnapshot host.Snapshot, serviceSnapshot service.Snapshot) string {
|
||||
if hostSnapshot.Source.Freshness == host.Fresh || serviceFreshness(serviceSnapshot) == "fresh" {
|
||||
return "fresh"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func aggregateState(health []Health) string {
|
||||
for _, item := range health {
|
||||
if item.State == StateDown {
|
||||
return StateDown
|
||||
}
|
||||
}
|
||||
for _, item := range health {
|
||||
if item.State == StateDegraded {
|
||||
return StateDegraded
|
||||
}
|
||||
}
|
||||
for _, item := range health {
|
||||
if item.State == StateUnknown {
|
||||
return StateUnknown
|
||||
}
|
||||
}
|
||||
return StateUp
|
||||
}
|
||||
|
||||
func normalizeState(value string) string {
|
||||
switch value {
|
||||
case StateUp, host.StatusHealthy:
|
||||
return StateUp
|
||||
case StateDegraded:
|
||||
return StateDegraded
|
||||
case StateDown:
|
||||
return StateDown
|
||||
default:
|
||||
return StateUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func boundedLatency(value *int) *int {
|
||||
if value == nil || *value < 0 {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
|
||||
func timeValue(value, fallback time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return fallback
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func boundText(value string, max int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > max {
|
||||
return value[:max]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func minInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/host"
|
||||
"github.com/itworx/pulse/internal/probe"
|
||||
"github.com/itworx/pulse/internal/service"
|
||||
)
|
||||
|
||||
func TestBuildSnapshotSeparatesInternalInternetAndDNSHealth(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
hostSnapshot := host.Snapshot{Source: host.Source{ID: "agent-1", Freshness: host.Fresh, State: host.StatusHealthy, ObservedAt: now}, Status: host.Status{State: host.StatusHealthy}, ObservedAt: now, Network: []host.NetworkInterface{{Name: "eth0", State: "up", RxBytes: 100, TxBytes: 200, RxErrors: 2, TxErrors: 1, RxDrops: 3}}}
|
||||
serviceSnapshot := service.Snapshot{ObservedAt: now, Services: []service.ServiceStatus{{ID: "dns-service", State: service.StateUp, Probes: []service.ProbeConfig{{ID: "dns-probe", Type: probe.TypeDNS}}, History: []probe.Result{{ProbeID: "dns-probe", State: service.StateDown, ObservedAt: now}}}}}
|
||||
latency := 42
|
||||
snapshot, err := BuildSnapshot(now, hostSnapshot, serviceSnapshot, []Signal{{Scope: ScopeInternet, State: StateDown, Reason: "internet_target_failed", SourceID: "probe-internet", Freshness: "fresh", ObservedAt: now, LatencyMS: &latency}}, nil, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
states := make(map[string]Health, len(snapshot.Health))
|
||||
for _, item := range snapshot.Health {
|
||||
states[item.Scope] = item
|
||||
}
|
||||
if states[ScopeInternal].State != StateUp || states[ScopeDNS].State != StateDown || states[ScopeInternet].State != StateDown || states[ScopeGateway].State != StateUnknown {
|
||||
t.Fatalf("health scopes conflated: %+v", states)
|
||||
}
|
||||
if len(snapshot.Interfaces) != 1 || snapshot.Interfaces[0].RxErrors != 2 || snapshot.Interfaces[0].RxDrops != 3 {
|
||||
t.Fatalf("interface counters were not preserved: %+v", snapshot.Interfaces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotMapsStaleHostToUnknownAndPreservesCertificate(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
expires := now.Add(30 * 24 * time.Hour)
|
||||
hostSnapshot := host.Snapshot{Source: host.Source{ID: "agent-1", Freshness: host.Stale, State: host.StatusHealthy, Reason: "telemetry_stale", ObservedAt: now.Add(-time.Hour)}, Status: host.Status{State: host.StatusHealthy}, ObservedAt: now.Add(-time.Hour), Network: []host.NetworkInterface{{Name: "eth0", State: "up"}}}
|
||||
valid := true
|
||||
serviceSnapshot := service.Snapshot{ObservedAt: now, Services: []service.ServiceStatus{{ID: "tls-service", State: service.StateUp, Certificate: &probe.Certificate{ID: "cert-1", ServiceID: "tls-service", ObservedAt: now, ExpiresAt: &expires, HostnameValid: &valid, VerificationState: "valid"}}}}
|
||||
snapshot, err := BuildSnapshot(now, hostSnapshot, serviceSnapshot, nil, nil, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Health[0].State != StateUnknown || snapshot.Interfaces[0].State != StateUnknown || snapshot.Health[0].Reason != "telemetry_stale" {
|
||||
t.Fatalf("stale host was not unknown: %+v", snapshot)
|
||||
}
|
||||
if len(snapshot.Certificates) != 1 || snapshot.Certificates[0].ID != "cert-1" {
|
||||
t.Fatalf("certificate inventory missing: %+v", snapshot.Certificates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotRejectsInvalidSignalsAndBounds(t *testing.T) {
|
||||
if _, err := BuildSnapshot(time.Now(), host.Snapshot{}, service.Snapshot{}, []Signal{{Scope: "unknown", State: StateUp}}, nil, Limits{}); err == nil {
|
||||
t.Fatal("expected invalid scope")
|
||||
}
|
||||
if _, err := BuildSnapshot(time.Now(), host.Snapshot{}, service.Snapshot{}, nil, nil, Limits{MaxInterfaces: 0, MaxCertificates: 0, MaxEvents: 501}); err == nil {
|
||||
t.Fatal("expected invalid limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotDistinguishesDNSConfigurationStalenessAndAvailability(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC)
|
||||
hostSnapshot := host.Snapshot{Source: host.Source{Freshness: host.Fresh, State: host.StatusHealthy}, Status: host.Status{State: host.StatusHealthy}, ObservedAt: now}
|
||||
cases := []struct {
|
||||
name, reason, capability, configuration string
|
||||
snapshot service.Snapshot
|
||||
}{
|
||||
{name: "not configured", snapshot: service.Snapshot{CapabilityState: service.CapabilityAvailable, ConfigurationState: service.ConfigurationNotConfigured}, reason: "not_configured", capability: service.CapabilityAvailable, configuration: service.ConfigurationNotConfigured},
|
||||
{name: "stale", snapshot: service.Snapshot{CapabilityState: service.CapabilityAvailable, ConfigurationState: service.ConfigurationConfigured, Services: []service.ServiceStatus{{ID: "dns", State: service.StateUnknown, Reason: "stale_probe", Probes: []service.ProbeConfig{{ID: "dns-probe", Type: probe.TypeDNS, Enabled: true}}}}}, reason: "stale_probe", capability: service.CapabilityAvailable, configuration: service.ConfigurationConfigured},
|
||||
{name: "unavailable", snapshot: service.UnknownSnapshot(now, "source_unavailable"), reason: "source_unavailable", capability: service.CapabilityUnavailable, configuration: service.ConfigurationUnknown},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
snapshot, err := BuildSnapshot(now, hostSnapshot, testCase.snapshot, nil, nil, Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dns := snapshot.Health[2]
|
||||
if dns.Reason != testCase.reason || dns.CapabilityState != testCase.capability || dns.ConfigurationState != testCase.configuration {
|
||||
t.Fatalf("DNS state was conflated: %+v", dns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotTargetScaleIsBounded(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
hostSnapshot := host.Snapshot{Source: host.Source{Freshness: host.Fresh, State: host.StatusHealthy}, Status: host.Status{State: host.StatusHealthy}, ObservedAt: now}
|
||||
for index := 0; index < 300; index++ {
|
||||
hostSnapshot.Network = append(hostSnapshot.Network, host.NetworkInterface{Name: fmt.Sprintf("eth-%03d", index), State: "up"})
|
||||
}
|
||||
serviceSnapshot := service.Snapshot{ObservedAt: now}
|
||||
valid := true
|
||||
for index := 0; index < 150; index++ {
|
||||
serviceSnapshot.Services = append(serviceSnapshot.Services, service.ServiceStatus{ID: fmt.Sprintf("service-%03d", index), State: service.StateUp, History: []probe.Result{{ProbeID: fmt.Sprintf("probe-%03d", index), ObservedAt: now, Certificate: &probe.Certificate{ID: fmt.Sprintf("cert-%03d", index), ServiceID: fmt.Sprintf("service-%03d", index), ObservedAt: now, HostnameValid: &valid, VerificationState: "valid"}}}})
|
||||
}
|
||||
snapshot, err := BuildSnapshot(now, hostSnapshot, serviceSnapshot, nil, nil, Limits{MaxInterfaces: 128, MaxCertificates: 100, MaxEvents: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Interfaces) != 128 || len(snapshot.Certificates) != 100 {
|
||||
t.Fatalf("network target bounds failed: interfaces=%d certificates=%d", len(snapshot.Interfaces), len(snapshot.Certificates))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user