Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
package host
import (
"context"
"time"
)
// RawSource is the narrow read-only boundary implemented by an authenticated
// agent or another approved host telemetry source. It returns one bounded
// source snapshot; it cannot execute arbitrary host commands.
type RawSource interface {
Snapshot(context.Context) (RawSnapshot, error)
}
type Adapter struct {
Source RawSource
Limits Limits
Policy Policy
Now func() time.Time
}
func (a Adapter) Snapshot(ctx context.Context) (Snapshot, error) {
if err := ctx.Err(); err != nil {
return Snapshot{}, err
}
if a.Source == nil {
return UnknownSnapshot(time.Now().UTC(), "host", "agent", "source_unavailable"), nil
}
raw, err := a.Source.Snapshot(ctx)
if err != nil {
return Snapshot{}, err
}
now := time.Now().UTC()
if a.Now != nil {
now = a.Now()
}
return Normalize(raw, now, a.Limits, a.Policy)
}
+300
View File
@@ -0,0 +1,300 @@
package host
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
)
const (
CapabilityThermal = "host.thermal"
CapabilityFans = "host.fans"
CapabilityGPU = "host.gpu"
)
type HardwareLimits struct {
MaxTemperatures int
MaxFans int
MaxGPUs int
MaxCapabilities int
}
func (l HardwareLimits) withDefaults() HardwareLimits {
if l.MaxTemperatures == 0 {
l.MaxTemperatures = 256
}
if l.MaxFans == 0 {
l.MaxFans = 256
}
if l.MaxGPUs == 0 {
l.MaxGPUs = 16
}
if l.MaxCapabilities == 0 {
l.MaxCapabilities = 32
}
return l
}
func (l HardwareLimits) Validate() error {
if l.MaxTemperatures < 1 || l.MaxTemperatures > 512 || l.MaxFans < 1 || l.MaxFans > 512 || l.MaxGPUs < 1 || l.MaxGPUs > 64 || l.MaxCapabilities < 1 || l.MaxCapabilities > 100 {
return errors.New("hardware limits are outside safe bounds")
}
return nil
}
type ThermalPolicy struct {
AttentionCelsius float64
CriticalCelsius float64
}
func (p ThermalPolicy) withDefaults() ThermalPolicy {
if p.AttentionCelsius == 0 {
p.AttentionCelsius = 75
}
if p.CriticalCelsius == 0 {
p.CriticalCelsius = 85
}
return p
}
func (p ThermalPolicy) Validate() error {
if p.AttentionCelsius <= 0 || p.CriticalCelsius <= p.AttentionCelsius || p.CriticalCelsius > 150 {
return errors.New("thermal policy is outside safe bounds")
}
return nil
}
type Capability struct {
ID string `json:"id"`
Version string `json:"version"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
}
type RawTemperature struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Celsius float64 `json:"celsius"`
}
type Temperature struct {
ID string `json:"id"`
Name string `json:"name"`
Celsius float64 `json:"celsius"`
}
type RawFan struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
RPM int `json:"rpm"`
}
type Fan struct {
ID string `json:"id"`
Name string `json:"name"`
RPM int `json:"rpm"`
}
type RawGPU struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Vendor string `json:"vendor,omitempty"`
Utilization *float64 `json:"utilizationPercent,omitempty"`
MemoryUsedBytes uint64 `json:"memoryUsedBytes,omitempty"`
MemoryTotalBytes uint64 `json:"memoryTotalBytes,omitempty"`
TemperatureCelsius *float64 `json:"temperatureCelsius,omitempty"`
}
type GPU struct {
ID string `json:"id"`
Name string `json:"name"`
Vendor string `json:"vendor,omitempty"`
Utilization *float64 `json:"utilizationPercent,omitempty"`
MemoryUsedBytes uint64 `json:"memoryUsedBytes,omitempty"`
MemoryTotalBytes uint64 `json:"memoryTotalBytes,omitempty"`
MemoryUtilizationPercent float64 `json:"memoryUtilizationPercent,omitempty"`
TemperatureCelsius *float64 `json:"temperatureCelsius,omitempty"`
}
type RawHardware struct {
Capabilities []Capability `json:"capabilities"`
Temperatures []RawTemperature `json:"temperatures"`
Fans []RawFan `json:"fans"`
GPUs []RawGPU `json:"gpus"`
}
type HardwareStatus struct {
State string `json:"state"`
Reasons []StatusReason `json:"reasons,omitempty"`
}
type HardwareSnapshot struct {
Capabilities []Capability `json:"capabilities"`
Temperatures []Temperature `json:"temperatures"`
Fans []Fan `json:"fans"`
GPUs []GPU `json:"gpus"`
Status HardwareStatus `json:"status"`
}
type HardwareSource interface {
Hardware(context.Context) (RawHardware, error)
}
type HardwareAdapter struct {
Source HardwareSource
Limits HardwareLimits
Policy ThermalPolicy
}
func (a HardwareAdapter) Snapshot(ctx context.Context, sourceID string) (HardwareSnapshot, error) {
if err := ctx.Err(); err != nil {
return HardwareSnapshot{}, err
}
if a.Source == nil {
return DisabledHardware(), nil
}
raw, err := a.Source.Hardware(ctx)
if err != nil {
return HardwareSnapshot{}, err
}
return NormalizeHardware(raw, sourceID, a.Limits, a.Policy)
}
func DisabledHardware() HardwareSnapshot {
capabilities := []Capability{
{ID: CapabilityThermal, Version: ContractVersion, State: "disabled", Reason: "capability_absent"},
{ID: CapabilityFans, Version: ContractVersion, State: "disabled", Reason: "capability_absent"},
{ID: CapabilityGPU, Version: ContractVersion, State: "disabled", Reason: "capability_absent"},
}
return HardwareSnapshot{Capabilities: capabilities, Status: HardwareStatus{State: StatusUnknown, Reasons: []StatusReason{{Code: "hardware_capability_absent", Message: "Optionele hardwaretelemetrie is niet beschikbaar."}}}}
}
func NormalizeHardware(raw RawHardware, sourceID string, limits HardwareLimits, policy ThermalPolicy) (HardwareSnapshot, error) {
limits = limits.withDefaults()
policy = policy.withDefaults()
if err := limits.Validate(); err != nil {
return HardwareSnapshot{}, err
}
if err := policy.Validate(); err != nil {
return HardwareSnapshot{}, err
}
if len(raw.Temperatures) > limits.MaxTemperatures || len(raw.Fans) > limits.MaxFans || len(raw.GPUs) > limits.MaxGPUs || len(raw.Capabilities) > limits.MaxCapabilities {
return HardwareSnapshot{}, errors.New("hardware collection exceeds bounds")
}
capabilities := normalizeCapabilities(raw.Capabilities)
for _, required := range []string{CapabilityThermal, CapabilityFans, CapabilityGPU} {
if _, ok := capabilityByID(capabilities, required); !ok {
capabilities = append(capabilities, Capability{ID: required, Version: ContractVersion, State: "disabled", Reason: "capability_absent"})
}
}
sort.Slice(capabilities, func(i, j int) bool { return capabilities[i].ID < capabilities[j].ID })
temperatures := make([]Temperature, 0, len(raw.Temperatures))
for _, item := range raw.Temperatures {
if err := validateName(item.Name, 128); err != nil || math.IsNaN(item.Celsius) || math.IsInf(item.Celsius, 0) || item.Celsius < -100 || item.Celsius > 150 {
return HardwareSnapshot{}, errors.New("invalid temperature sensor")
}
temperatures = append(temperatures, Temperature{ID: stableSensorID(sourceID, "temperature", item.ID, item.Name), Name: item.Name, Celsius: item.Celsius})
}
sort.Slice(temperatures, func(i, j int) bool { return temperatures[i].ID < temperatures[j].ID })
fans := make([]Fan, 0, len(raw.Fans))
for _, item := range raw.Fans {
if err := validateName(item.Name, 128); err != nil || item.RPM < 0 || item.RPM > 100000 {
return HardwareSnapshot{}, errors.New("invalid fan sensor")
}
fans = append(fans, Fan{ID: stableSensorID(sourceID, "fan", item.ID, item.Name), Name: item.Name, RPM: item.RPM})
}
sort.Slice(fans, func(i, j int) bool { return fans[i].ID < fans[j].ID })
gpus := make([]GPU, 0, len(raw.GPUs))
for _, item := range raw.GPUs {
if err := validateName(item.Name, 128); err != nil {
return HardwareSnapshot{}, errors.New("invalid GPU name")
}
if item.Utilization != nil && (*item.Utilization < 0 || *item.Utilization > 100 || math.IsNaN(*item.Utilization) || math.IsInf(*item.Utilization, 0)) {
return HardwareSnapshot{}, errors.New("invalid GPU utilization")
}
if item.MemoryTotalBytes > 0 && item.MemoryUsedBytes > item.MemoryTotalBytes {
return HardwareSnapshot{}, errors.New("invalid GPU memory")
}
if item.TemperatureCelsius != nil && (*item.TemperatureCelsius < -100 || *item.TemperatureCelsius > 150 || math.IsNaN(*item.TemperatureCelsius) || math.IsInf(*item.TemperatureCelsius, 0)) {
return HardwareSnapshot{}, errors.New("invalid GPU temperature")
}
gpu := GPU{ID: stableSensorID(sourceID, "gpu", item.ID, item.Name), Name: item.Name, Vendor: item.Vendor, Utilization: cloneFloat(item.Utilization), MemoryUsedBytes: item.MemoryUsedBytes, MemoryTotalBytes: item.MemoryTotalBytes, TemperatureCelsius: cloneFloat(item.TemperatureCelsius)}
if item.MemoryTotalBytes > 0 {
gpu.MemoryUtilizationPercent = float64(item.MemoryUsedBytes) / float64(item.MemoryTotalBytes) * 100
}
gpus = append(gpus, gpu)
}
sort.Slice(gpus, func(i, j int) bool { return gpus[i].ID < gpus[j].ID })
status := evaluateHardwareStatus(temperatures, gpus, policy)
return HardwareSnapshot{Capabilities: capabilities, Temperatures: temperatures, Fans: fans, GPUs: gpus, Status: status}, nil
}
func evaluateHardwareStatus(temperatures []Temperature, gpus []GPU, policy ThermalPolicy) HardwareStatus {
result := HardwareStatus{State: StatusHealthy}
max := -math.MaxFloat64
sensor := ""
for _, item := range temperatures {
if item.Celsius > max {
max = item.Celsius
sensor = item.Name
}
}
for _, item := range gpus {
if item.TemperatureCelsius != nil && *item.TemperatureCelsius > max {
max = *item.TemperatureCelsius
sensor = item.Name
}
}
if sensor == "" {
return HardwareStatus{State: StatusUnknown, Reasons: []StatusReason{{Code: "thermal_data_absent", Message: "Er is geen betrouwbare temperatuursensor beschikbaar."}}}
}
if max >= policy.CriticalCelsius {
return HardwareStatus{State: "critical", Reasons: []StatusReason{{Code: "thermal_critical", Message: fmt.Sprintf("Temperatuur %.1f °C bij %s overschrijdt de kritieke grens.", max, sensor)}}}
}
if max >= policy.AttentionCelsius {
return HardwareStatus{State: StatusDegraded, Reasons: []StatusReason{{Code: "thermal_attention", Message: fmt.Sprintf("Temperatuur %.1f °C bij %s vraagt aandacht.", max, sensor)}}}
}
return result
}
func normalizeCapabilities(values []Capability) []Capability {
result := make([]Capability, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
if value.ID == "" || seen[value.ID] {
continue
}
if value.Version == "" {
value.Version = ContractVersion
}
if value.State == "" {
value.State = "unavailable"
}
seen[value.ID] = true
result = append(result, value)
}
return result
}
func capabilityByID(values []Capability, id string) (Capability, bool) {
for _, value := range values {
if value.ID == id {
return value, true
}
}
return Capability{}, false
}
func validateName(value string, max int) error {
if strings.TrimSpace(value) == "" || len(value) > max {
return errors.New("name is empty or too long")
}
return nil
}
func stableSensorID(sourceID, kind, externalID, name string) string {
identity := strings.TrimSpace(externalID)
if identity == "" {
identity = strings.TrimSpace(name)
}
identity = strings.ToLower(strings.TrimSpace(identity))
identity = strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-").Replace(identity)
return strings.ToLower(strings.TrimSpace(sourceID)) + "/" + kind + "/" + identity
}
+70
View File
@@ -0,0 +1,70 @@
package host
import (
"context"
"errors"
"testing"
)
func TestNormalizeHardwareAbsentCapabilitiesAreDisabled(t *testing.T) {
snapshot, err := NormalizeHardware(RawHardware{}, "agent-1", HardwareLimits{}, ThermalPolicy{})
if err != nil {
t.Fatal(err)
}
if len(snapshot.Capabilities) != 3 || snapshot.Status.State != StatusUnknown || snapshot.Capabilities[0].State != "disabled" {
t.Fatalf("absent hardware was treated as failure: %+v", snapshot)
}
if snapshot.Capabilities[0].Reason != "capability_absent" {
t.Fatalf("capabilities=%+v", snapshot.Capabilities)
}
}
func TestNormalizeHardwareStableIDsAndThermalReason(t *testing.T) {
hot := 91.5
snapshot, err := NormalizeHardware(RawHardware{
Capabilities: []Capability{{ID: CapabilityThermal, Version: ContractVersion, State: "enabled"}, {ID: CapabilityGPU, Version: ContractVersion, State: "enabled"}},
Temperatures: []RawTemperature{{ID: "core:0", Name: "CPU package", Celsius: 91.5}},
GPUs: []RawGPU{{ID: "pci/0000:01:00.0", Name: "RTX", Vendor: "nvidia", TemperatureCelsius: &hot, Utilization: floatPtr(55)}},
}, "agent-1", HardwareLimits{}, ThermalPolicy{})
if err != nil {
t.Fatal(err)
}
if snapshot.Status.State != "critical" || snapshot.Status.Reasons[0].Code != "thermal_critical" {
t.Fatalf("status=%+v", snapshot.Status)
}
if snapshot.Temperatures[0].ID != "agent-1/temperature/core-0" || snapshot.GPUs[0].ID != "agent-1/gpu/pci-0000-01-00.0" {
t.Fatalf("unstable IDs: temperatures=%+v gpus=%+v", snapshot.Temperatures, snapshot.GPUs)
}
if snapshot.GPUs[0].MemoryUtilizationPercent != 0 {
t.Fatalf("unexpected GPU memory ratio: %+v", snapshot.GPUs[0])
}
}
func TestNormalizeHardwarePreservesUnsupportedCapability(t *testing.T) {
snapshot, err := NormalizeHardware(RawHardware{Capabilities: []Capability{{ID: CapabilityGPU, Version: ContractVersion, State: "unsupported", Reason: "no_driver"}}}, "agent-1", HardwareLimits{}, ThermalPolicy{})
if err != nil {
t.Fatal(err)
}
capability, ok := capabilityByID(snapshot.Capabilities, CapabilityGPU)
if !ok || capability.State != "unsupported" || capability.Reason != "no_driver" {
t.Fatalf("capability=%+v", capability)
}
}
type hardwareSource struct{ value RawHardware }
func (s hardwareSource) Hardware(ctx context.Context) (RawHardware, error) {
if err := ctx.Err(); err != nil {
return RawHardware{}, err
}
return s.value, nil
}
func TestHardwareAdapterHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := (HardwareAdapter{Source: hardwareSource{}}).Snapshot(ctx, "agent-1")
if !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
}
+456
View File
@@ -0,0 +1,456 @@
package host
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
)
const ContractVersion = "v1"
const (
StatusHealthy = "healthy"
StatusDegraded = "degraded"
StatusUnknown = "unknown"
Fresh = "fresh"
Stale = "stale"
Unavailable = "unavailable"
)
type Limits struct {
MaxCores int
MaxFilesystems int
MaxInterfaces int
MaxWarnings int
}
func (l Limits) withDefaults() Limits {
if l.MaxCores == 0 {
l.MaxCores = 256
}
if l.MaxFilesystems == 0 {
l.MaxFilesystems = 256
}
if l.MaxInterfaces == 0 {
l.MaxInterfaces = 128
}
if l.MaxWarnings == 0 {
l.MaxWarnings = 20
}
return l
}
func (l Limits) Validate() error {
if l.MaxCores < 1 || l.MaxCores > 512 || l.MaxFilesystems < 1 || l.MaxFilesystems > 512 || l.MaxInterfaces < 1 || l.MaxInterfaces > 256 || l.MaxWarnings < 1 || l.MaxWarnings > 100 {
return errors.New("host limits are outside safe bounds")
}
return nil
}
type Policy struct {
FreshnessMaxAge time.Duration
HighLoadPerCore float64
SaturationLoadPerCore float64
HighMemoryPercent float64
}
func (p Policy) withDefaults() Policy {
if p.FreshnessMaxAge == 0 {
p.FreshnessMaxAge = 30 * time.Second
}
if p.HighLoadPerCore == 0 {
p.HighLoadPerCore = 1
}
if p.SaturationLoadPerCore == 0 {
p.SaturationLoadPerCore = 2
}
if p.HighMemoryPercent == 0 {
p.HighMemoryPercent = 92
}
return p
}
func (p Policy) Validate() error {
if p.FreshnessMaxAge <= 0 || p.FreshnessMaxAge > 24*time.Hour || p.HighLoadPerCore <= 0 || p.SaturationLoadPerCore < p.HighLoadPerCore || p.HighMemoryPercent <= 0 || p.HighMemoryPercent > 100 {
return errors.New("host policy is outside safe bounds")
}
return nil
}
type Source struct {
ID string `json:"id"`
Type string `json:"type"`
CapabilityVersion string `json:"capabilityVersion"`
ObservedAt time.Time `json:"observedAt"`
ReceivedAt time.Time `json:"receivedAt"`
Freshness string `json:"freshness"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
}
type HostIdentity struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Kernel string `json:"kernel,omitempty"`
Arch string `json:"architecture,omitempty"`
}
type RawCPU struct {
TotalPercent *float64 `json:"totalPercent,omitempty"`
PerCore []float64 `json:"perCore,omitempty"`
IOWaitPercent *float64 `json:"iowaitPercent,omitempty"`
}
type CPU struct {
TotalPercent *float64 `json:"totalPercent,omitempty"`
PerCore []float64 `json:"perCore,omitempty"`
IOWaitPercent *float64 `json:"iowaitPercent,omitempty"`
}
type RawLoad struct{ One, Five, Fifteen float64 }
type Load struct {
One, Five, Fifteen float64 `json:"-"`
}
func (l Load) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`{"one":%s,"five":%s,"fifteen":%s}`, formatNumber(l.One), formatNumber(l.Five), formatNumber(l.Fifteen))), nil
}
type RawMemory struct {
TotalBytes uint64 `json:"totalBytes"`
AvailableBytes uint64 `json:"availableBytes"`
UsedBytes *uint64 `json:"usedBytes,omitempty"`
SwapTotalBytes uint64 `json:"swapTotalBytes,omitempty"`
SwapUsedBytes uint64 `json:"swapUsedBytes,omitempty"`
}
type Memory struct {
TotalBytes uint64 `json:"totalBytes"`
AvailableBytes uint64 `json:"availableBytes"`
UsedBytes uint64 `json:"usedBytes"`
UtilizationPercent float64 `json:"utilizationPercent"`
SwapTotalBytes uint64 `json:"swapTotalBytes"`
SwapUsedBytes uint64 `json:"swapUsedBytes"`
SwapUtilizationPercent float64 `json:"swapUtilizationPercent"`
}
type RawFilesystem struct {
Mount string `json:"mount"`
Filesystem string `json:"filesystem,omitempty"`
CapacityBytes uint64 `json:"capacityBytes"`
UsedBytes uint64 `json:"usedBytes"`
Inodes *RawInodes `json:"inodes,omitempty"`
}
type RawInodes struct{ Total, Used uint64 }
type Filesystem struct {
Mount string `json:"mount"`
Filesystem string `json:"filesystem,omitempty"`
CapacityBytes uint64 `json:"capacityBytes"`
UsedBytes uint64 `json:"usedBytes"`
UtilizationPercent float64 `json:"utilizationPercent"`
Inodes *Inodes `json:"inodes,omitempty"`
}
type Inodes struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
type RawNetworkInterface struct {
Name string `json:"name"`
State string `json:"state,omitempty"`
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 NetworkInterface struct {
Name string `json:"name"`
State string `json:"state,omitempty"`
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 RawTime struct {
Synchronized bool
OffsetSeconds float64
Stratum int
}
type TimeHealth struct {
Synchronized bool `json:"synchronized"`
OffsetSeconds float64 `json:"offsetSeconds"`
Stratum int `json:"stratum,omitempty"`
State string `json:"state"`
}
type RawSnapshot struct {
Source Source `json:"source"`
Identity HostIdentity `json:"identity"`
UptimeSeconds float64 `json:"uptimeSeconds"`
BootTime *time.Time `json:"bootTime,omitempty"`
CPU RawCPU `json:"cpu"`
Load RawLoad `json:"load"`
Memory RawMemory `json:"memory"`
Filesystems []RawFilesystem `json:"filesystems"`
Network []RawNetworkInterface `json:"network"`
Time RawTime `json:"time"`
Hardware RawHardware `json:"hardware"`
ObservedAt time.Time `json:"observedAt"`
ReceivedAt time.Time `json:"receivedAt"`
Warnings []string `json:"warnings,omitempty"`
}
type StatusReason struct {
Code string `json:"code"`
Message string `json:"message"`
}
type Status struct {
State string `json:"state"`
Reasons []StatusReason `json:"reasons,omitempty"`
}
type Snapshot struct {
ContractVersion string `json:"contractVersion"`
Source Source `json:"source"`
Identity HostIdentity `json:"identity"`
UptimeSeconds float64 `json:"uptimeSeconds"`
BootTime *time.Time `json:"bootTime,omitempty"`
CPU CPU `json:"cpu"`
Load Load `json:"load"`
Memory Memory `json:"memory"`
Filesystems []Filesystem `json:"filesystems"`
Network []NetworkInterface `json:"network"`
Time TimeHealth `json:"time"`
Hardware HardwareSnapshot `json:"hardware"`
Status Status `json:"status"`
ObservedAt time.Time `json:"observedAt"`
ReceivedAt time.Time `json:"receivedAt"`
Warnings []string `json:"warnings,omitempty"`
}
type Provider interface {
Snapshot(context.Context) (Snapshot, error)
}
type UnknownProvider struct {
SourceID, SourceType, Reason string
Policy Policy
}
func (p UnknownProvider) Snapshot(ctx context.Context) (Snapshot, error) {
if err := ctx.Err(); err != nil {
return Snapshot{}, err
}
now := time.Now().UTC()
return UnknownSnapshot(now, p.SourceID, p.SourceType, p.Reason), nil
}
func UnknownSnapshot(now time.Time, sourceID, sourceType, reason string) Snapshot {
if now.IsZero() {
now = time.Now().UTC()
}
if reason == "" {
reason = "source_unavailable"
}
snapshot := Snapshot{ContractVersion: ContractVersion, Source: Source{ID: sourceID, Type: sourceType, CapabilityVersion: ContractVersion, ReceivedAt: now, Freshness: Unavailable, State: StatusUnknown, Reason: reason}, Status: Status{State: StatusUnknown, Reasons: []StatusReason{{Code: reason, Message: "De hosttelemetrie is niet beschikbaar."}}}, ReceivedAt: now}
snapshot.Hardware = DisabledHardware()
return snapshot
}
func Normalize(raw RawSnapshot, now time.Time, limits Limits, policy Policy) (Snapshot, error) {
limits = limits.withDefaults()
policy = policy.withDefaults()
if err := limits.Validate(); err != nil {
return Snapshot{}, err
}
if err := policy.Validate(); err != nil {
return Snapshot{}, err
}
if now.IsZero() {
now = time.Now().UTC()
}
if raw.ReceivedAt.IsZero() {
raw.ReceivedAt = now
}
if raw.ObservedAt.IsZero() {
raw.ObservedAt = raw.ReceivedAt
}
if raw.ObservedAt.After(now.Add(time.Minute)) {
return Snapshot{}, errors.New("host observation is materially in the future")
}
if strings.TrimSpace(raw.Identity.Name) == "" || len(raw.Identity.Name) > 255 {
return Snapshot{}, errors.New("host identity name is required and bounded")
}
if raw.UptimeSeconds < 0 || raw.UptimeSeconds > 100*365*24*60*60 || math.IsNaN(raw.UptimeSeconds) || math.IsInf(raw.UptimeSeconds, 0) {
return Snapshot{}, errors.New("host uptime is outside bounds")
}
if len(raw.CPU.PerCore) > limits.MaxCores || len(raw.Filesystems) > limits.MaxFilesystems || len(raw.Network) > limits.MaxInterfaces || len(raw.Warnings) > limits.MaxWarnings {
return Snapshot{}, errors.New("host collection exceeds bounds")
}
for _, value := range append(append([]float64{}, raw.CPU.PerCore...), raw.CPU.IOWaitPercentValue()) {
if err := percent(value); err != nil {
return Snapshot{}, err
}
}
if raw.CPU.TotalPercent != nil {
if err := percent(*raw.CPU.TotalPercent); err != nil {
return Snapshot{}, err
}
}
if raw.Memory.TotalBytes == 0 || raw.Memory.AvailableBytes > raw.Memory.TotalBytes {
return Snapshot{}, errors.New("host memory totals are invalid")
}
if raw.Memory.UsedBytes != nil && *raw.Memory.UsedBytes > raw.Memory.TotalBytes {
return Snapshot{}, errors.New("host memory used bytes are invalid")
}
if raw.Memory.SwapUsedBytes > raw.Memory.SwapTotalBytes && raw.Memory.SwapTotalBytes > 0 {
return Snapshot{}, errors.New("host swap values are invalid")
}
for _, value := range []float64{raw.Load.One, raw.Load.Five, raw.Load.Fifteen, raw.Time.OffsetSeconds} {
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return Snapshot{}, errors.New("host load or time values are invalid")
}
}
filesystems := make([]Filesystem, 0, len(raw.Filesystems))
for _, fs := range raw.Filesystems {
if strings.TrimSpace(fs.Mount) == "" || len(fs.Mount) > 512 || fs.CapacityBytes == 0 || fs.UsedBytes > fs.CapacityBytes {
return Snapshot{}, errors.New("host filesystem values are invalid")
}
item := Filesystem{Mount: fs.Mount, Filesystem: fs.Filesystem, CapacityBytes: fs.CapacityBytes, UsedBytes: fs.UsedBytes, UtilizationPercent: ratioPercent(fs.UsedBytes, fs.CapacityBytes)}
if fs.Inodes != nil {
if fs.Inodes.Total == 0 || fs.Inodes.Used > fs.Inodes.Total {
return Snapshot{}, errors.New("host inode values are invalid")
}
item.Inodes = &Inodes{Total: fs.Inodes.Total, Used: fs.Inodes.Used}
}
filesystems = append(filesystems, item)
}
sort.Slice(filesystems, func(i, j int) bool { return filesystems[i].Mount < filesystems[j].Mount })
network := make([]NetworkInterface, 0, len(raw.Network))
for _, iface := range raw.Network {
if strings.TrimSpace(iface.Name) == "" || len(iface.Name) > 128 {
return Snapshot{}, errors.New("host network interface name is invalid")
}
network = append(network, NetworkInterface{Name: iface.Name, State: iface.State, RxBytes: iface.RxBytes, TxBytes: iface.TxBytes, RxErrors: iface.RxErrors, TxErrors: iface.TxErrors, RxDrops: iface.RxDrops, TxDrops: iface.TxDrops})
}
sort.Slice(network, func(i, j int) bool { return network[i].Name < network[j].Name })
warnings := append([]string(nil), raw.Warnings...)
sort.Strings(warnings)
source := raw.Source
source.ObservedAt = raw.ObservedAt.UTC()
source.ReceivedAt = raw.ReceivedAt.UTC()
source.Freshness = Fresh
source.State = StatusHealthy
if now.Sub(raw.ObservedAt) > policy.FreshnessMaxAge {
source.Freshness = Stale
source.State = StatusUnknown
source.Reason = "stale_source"
}
if source.ID == "" {
source.ID = "host"
}
if source.Type == "" {
source.Type = "agent"
}
if source.CapabilityVersion == "" {
source.CapabilityVersion = ContractVersion
}
hardware, err := NormalizeHardware(raw.Hardware, source.ID, HardwareLimits{}, ThermalPolicy{})
if err != nil {
return Snapshot{}, err
}
var used uint64
if raw.Memory.UsedBytes != nil {
used = *raw.Memory.UsedBytes
} else {
used = raw.Memory.TotalBytes - raw.Memory.AvailableBytes
}
memory := Memory{TotalBytes: raw.Memory.TotalBytes, AvailableBytes: raw.Memory.AvailableBytes, UsedBytes: used, UtilizationPercent: ratioPercent(used, raw.Memory.TotalBytes), SwapTotalBytes: raw.Memory.SwapTotalBytes, SwapUsedBytes: raw.Memory.SwapUsedBytes}
if raw.Memory.SwapTotalBytes > 0 {
memory.SwapUtilizationPercent = ratioPercent(raw.Memory.SwapUsedBytes, raw.Memory.SwapTotalBytes)
}
cpu := CPU{TotalPercent: cloneFloat(raw.CPU.TotalPercent), PerCore: append([]float64(nil), raw.CPU.PerCore...), IOWaitPercent: cloneFloat(raw.CPU.IOWaitPercent)}
snapshot := Snapshot{ContractVersion: ContractVersion, Source: source, Identity: raw.Identity, UptimeSeconds: raw.UptimeSeconds, BootTime: utcPtr(raw.BootTime), CPU: cpu, Load: Load{One: raw.Load.One, Five: raw.Load.Five, Fifteen: raw.Load.Fifteen}, Memory: memory, Filesystems: filesystems, Network: network, Time: TimeHealth{Synchronized: raw.Time.Synchronized, OffsetSeconds: raw.Time.OffsetSeconds, Stratum: raw.Time.Stratum, State: timeState(raw.Time.Synchronized, source.Freshness)}, ObservedAt: raw.ObservedAt.UTC(), ReceivedAt: raw.ReceivedAt.UTC(), Warnings: warnings}
snapshot.Hardware = hardware
snapshot.Status = EvaluateStatus(snapshot, now, policy)
return snapshot, nil
}
func EvaluateStatus(snapshot Snapshot, now time.Time, policy Policy) Status {
policy = policy.withDefaults()
result := Status{State: StatusHealthy}
if snapshot.Source.Freshness != Fresh || snapshot.Source.State == StatusUnknown || snapshot.ObservedAt.IsZero() || (!now.IsZero() && now.Sub(snapshot.ObservedAt) > policy.FreshnessMaxAge) {
return Status{State: StatusUnknown, Reasons: []StatusReason{{Code: "stale_source", Message: "De hosttelemetrie is verouderd; status is Onbekend."}}}
}
cores := len(snapshot.CPU.PerCore)
if cores < 1 {
cores = 1
}
if snapshot.Load.One >= float64(cores)*policy.SaturationLoadPerCore && snapshot.Load.Five >= float64(cores)*policy.HighLoadPerCore {
result.State = StatusDegraded
result.Reasons = append(result.Reasons, StatusReason{Code: "load_sustained_saturation", Message: fmt.Sprintf("Load %.2f blijft hoog voor %d cores; verzadiging is waarschijnlijk.", snapshot.Load.One, cores)})
} else if snapshot.Load.One >= float64(cores)*policy.HighLoadPerCore {
result.State = StatusDegraded
result.Reasons = append(result.Reasons, StatusReason{Code: "load_high", Message: fmt.Sprintf("Load %.2f is hoog voor %d cores; controleer de trend.", snapshot.Load.One, cores)})
}
if snapshot.Memory.UtilizationPercent >= policy.HighMemoryPercent {
result.State = StatusDegraded
result.Reasons = append(result.Reasons, StatusReason{Code: "memory_high", Message: fmt.Sprintf("Geheugengebruik is %.1f%%; beschikbare marge is beperkt.", snapshot.Memory.UtilizationPercent)})
}
if !snapshot.Time.Synchronized {
result.State = StatusDegraded
result.Reasons = append(result.Reasons, StatusReason{Code: "time_unsynchronized", Message: "De hostklok is niet gesynchroniseerd."})
}
return result
}
func (r RawCPU) IOWaitPercentValue() float64 {
if r.IOWaitPercent == nil {
return 0
}
return *r.IOWaitPercent
}
func percent(value float64) error {
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value > 100 {
return errors.New("host percentage is outside 0-100")
}
return nil
}
func ratioPercent(part, total uint64) float64 {
if total == 0 {
return 0
}
return float64(part) / float64(total) * 100
}
func formatNumber(value float64) string { return fmt.Sprintf("%.6g", value) }
func cloneFloat(value *float64) *float64 {
if value == nil {
return nil
}
copy := *value
return &copy
}
func utcPtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
copy := value.UTC()
return &copy
}
func timeState(synchronized bool, freshness string) string {
if freshness != Fresh {
return StatusUnknown
}
if synchronized {
return StatusHealthy
}
return StatusDegraded
}
+108
View File
@@ -0,0 +1,108 @@
package host
import (
"context"
"errors"
"testing"
"time"
)
func floatPtr(value float64) *float64 { return &value }
func fixtureRaw(now time.Time) RawSnapshot {
used := uint64(6 * 1024 * 1024 * 1024)
return RawSnapshot{
Source: Source{ID: "agent-1", Type: "agent", CapabilityVersion: ContractVersion},
Identity: HostIdentity{Name: "pulse-host", Version: "7.2.2", Arch: "amd64"},
UptimeSeconds: 3600,
CPU: RawCPU{TotalPercent: floatPtr(42.5), PerCore: []float64{40, 45, 42.5}, IOWaitPercent: floatPtr(2)},
Load: RawLoad{One: 1.2, Five: 0.8, Fifteen: 0.4},
Memory: RawMemory{TotalBytes: 8 * 1024 * 1024 * 1024, AvailableBytes: 2 * 1024 * 1024 * 1024, UsedBytes: &used},
Filesystems: []RawFilesystem{{Mount: "/", Filesystem: "xfs", CapacityBytes: 100, UsedBytes: 25, Inodes: &RawInodes{Total: 1000, Used: 100}}},
Network: []RawNetworkInterface{{Name: "eth0", State: "up", RxBytes: 10, TxBytes: 20}},
Time: RawTime{Synchronized: true, OffsetSeconds: 0.002, Stratum: 2},
ObservedAt: now.Add(-2 * time.Second), ReceivedAt: now,
}
}
func TestNormalizePreservesUnitsAndSortsCollections(t *testing.T) {
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
raw := fixtureRaw(now)
raw.Filesystems = append(raw.Filesystems, RawFilesystem{Mount: "/data", CapacityBytes: 1000, UsedBytes: 500})
raw.Network = append(raw.Network, RawNetworkInterface{Name: "bond0", State: "up"})
snapshot, err := Normalize(raw, now, Limits{}, Policy{})
if err != nil {
t.Fatal(err)
}
if snapshot.CPU.TotalPercent == nil || *snapshot.CPU.TotalPercent != 42.5 || snapshot.Memory.UsedBytes != 6*1024*1024*1024 || snapshot.Memory.UtilizationPercent != 75 {
t.Fatalf("normalized units changed: %+v", snapshot)
}
if len(snapshot.CPU.PerCore) != 3 || len(snapshot.Filesystems) != 2 || snapshot.Filesystems[0].Mount != "/" || snapshot.Network[0].Name != "bond0" {
t.Fatalf("bounded deterministic ordering failed: %+v", snapshot)
}
if snapshot.Status.State != StatusHealthy || snapshot.Source.Freshness != Fresh || snapshot.Time.State != StatusHealthy {
t.Fatalf("unexpected healthy state: %+v source=%+v time=%+v", snapshot.Status, snapshot.Source, snapshot.Time)
}
}
func TestNormalizeStaleSourceIsUnknown(t *testing.T) {
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
raw := fixtureRaw(now)
raw.ObservedAt = now.Add(-time.Minute)
snapshot, err := Normalize(raw, now, Limits{}, Policy{FreshnessMaxAge: 30 * time.Second})
if err != nil {
t.Fatal(err)
}
if snapshot.Source.State != StatusUnknown || snapshot.Source.Freshness != Stale || snapshot.Status.State != StatusUnknown {
t.Fatalf("stale source was not made unknown: %+v", snapshot)
}
}
func TestHighLoadFixtureExplainsSustainedSaturation(t *testing.T) {
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
raw := fixtureRaw(now)
raw.CPU.PerCore = []float64{80, 80, 80, 80}
raw.Load = RawLoad{One: 10, Five: 9, Fifteen: 8}
snapshot, err := Normalize(raw, now, Limits{}, Policy{})
if err != nil {
t.Fatal(err)
}
if snapshot.Status.State != StatusDegraded || len(snapshot.Status.Reasons) == 0 || snapshot.Status.Reasons[0].Code != "load_sustained_saturation" {
t.Fatalf("high-load policy was not explained: %+v", snapshot.Status)
}
}
func TestNormalizeRejectsOutOfRangeAndUnboundedPayloads(t *testing.T) {
now := time.Now().UTC()
raw := fixtureRaw(now)
raw.CPU.TotalPercent = floatPtr(101)
if _, err := Normalize(raw, now, Limits{}, Policy{}); err == nil {
t.Fatal("expected percentage validation error")
}
raw = fixtureRaw(now)
raw.CPU.PerCore = make([]float64, 257)
if _, err := Normalize(raw, now, Limits{}, Policy{}); err == nil {
t.Fatal("expected collection bound error")
}
}
type testRawSource struct {
raw RawSnapshot
err error
}
func (s testRawSource) Snapshot(ctx context.Context) (RawSnapshot, error) {
if err := ctx.Err(); err != nil {
return RawSnapshot{}, err
}
return s.raw, s.err
}
func TestAdapterHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
adapter := Adapter{Source: testRawSource{raw: fixtureRaw(time.Now().UTC())}}
if _, err := adapter.Snapshot(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
}