This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/reconciliation"
|
||||
)
|
||||
|
||||
const ContractVersion = "v1"
|
||||
|
||||
// SourceID is the stable logical source used for derived application IDs. It
|
||||
// is deliberately not a database datasource UUID: the observations remain
|
||||
// owned by the underlying container datasource while IDs stay stable across
|
||||
// API and worker projections.
|
||||
const SourceID = "applications"
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateHealthy State = "healthy"
|
||||
StateDegraded State = "degraded"
|
||||
StateUnknown State = "unknown"
|
||||
StateDown State = "down"
|
||||
)
|
||||
|
||||
type Source struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Freshness string `json:"freshness"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ComponentInput struct {
|
||||
ID string
|
||||
Name string
|
||||
Kind string
|
||||
ContainerState State
|
||||
ServiceState State
|
||||
Critical bool
|
||||
}
|
||||
type DiscoveredApplication struct {
|
||||
ID string
|
||||
Name string
|
||||
Components []ComponentInput
|
||||
}
|
||||
type ApplicationOverride struct {
|
||||
ApplicationID string
|
||||
Name string
|
||||
CriticalByComponent map[string]bool
|
||||
}
|
||||
|
||||
type Reason struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ComponentID string `json:"componentId,omitempty"`
|
||||
Critical bool `json:"critical"`
|
||||
}
|
||||
type Component struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Critical bool `json:"critical"`
|
||||
ContainerState State `json:"containerState"`
|
||||
ServiceState State `json:"serviceState"`
|
||||
Status State `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
type Application struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status State `json:"status"`
|
||||
Overridden bool `json:"overridden"`
|
||||
Components []Component `json:"components"`
|
||||
Reasons []Reason `json:"reasons,omitempty"`
|
||||
}
|
||||
type Snapshot struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
Source Source `json:"source"`
|
||||
Applications []Application `json:"applications"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
type Provider interface {
|
||||
Snapshot(context.Context) (Snapshot, error)
|
||||
}
|
||||
|
||||
func UnknownSnapshot(now time.Time, id, typ, reason string) Snapshot {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: Source{ID: id, Type: typ, ReceivedAt: now.UTC(), Freshness: "unavailable", State: "unknown", Reason: reason}, Applications: []Application{}}
|
||||
}
|
||||
func StableApplicationID(sourceID, groupKey string) string {
|
||||
return reconciliation.StableEntityID(sourceID, "application", groupKey)
|
||||
}
|
||||
func BuildSnapshot(source Source, discovered []DiscoveredApplication, overrides []ApplicationOverride, now time.Time) (Snapshot, error) {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
now = now.UTC()
|
||||
if strings.TrimSpace(source.ID) == "" {
|
||||
return Snapshot{}, errors.New("application source id is required")
|
||||
}
|
||||
if len(discovered) > 150 {
|
||||
return Snapshot{}, errors.New("application count exceeds bounds")
|
||||
}
|
||||
if source.ReceivedAt.IsZero() {
|
||||
source.ReceivedAt = now
|
||||
}
|
||||
if source.ObservedAt.IsZero() {
|
||||
source.ObservedAt = source.ReceivedAt
|
||||
}
|
||||
source.ObservedAt, source.ReceivedAt = source.ObservedAt.UTC(), source.ReceivedAt.UTC()
|
||||
if source.ObservedAt.After(now.Add(time.Minute)) {
|
||||
return Snapshot{}, errors.New("application observation is materially in the future")
|
||||
}
|
||||
source.Freshness, source.State = "fresh", "healthy"
|
||||
if now.Sub(source.ObservedAt) > time.Minute {
|
||||
source.Freshness, source.State, source.Reason = "stale", "unknown", "stale_source"
|
||||
}
|
||||
overrideByID := make(map[string]ApplicationOverride, len(overrides))
|
||||
for _, override := range overrides {
|
||||
if strings.TrimSpace(override.ApplicationID) == "" {
|
||||
return Snapshot{}, errors.New("application override id is required")
|
||||
}
|
||||
if _, exists := overrideByID[override.ApplicationID]; exists {
|
||||
return Snapshot{}, errors.New("duplicate application override")
|
||||
}
|
||||
overrideByID[override.ApplicationID] = override
|
||||
}
|
||||
apps := make([]Application, 0, len(discovered))
|
||||
seenApps := make(map[string]struct{}, len(discovered))
|
||||
componentCount := 0
|
||||
for _, group := range discovered {
|
||||
if strings.TrimSpace(group.ID) == "" || strings.TrimSpace(group.Name) == "" {
|
||||
return Snapshot{}, errors.New("application identity is required")
|
||||
}
|
||||
if _, exists := seenApps[group.ID]; exists {
|
||||
return Snapshot{}, errors.New("duplicate application identity")
|
||||
}
|
||||
seenApps[group.ID] = struct{}{}
|
||||
componentCount += len(group.Components)
|
||||
if componentCount > 1000 {
|
||||
return Snapshot{}, errors.New("application component count exceeds bounds")
|
||||
}
|
||||
override, overridden := overrideByID[group.ID]
|
||||
name := group.Name
|
||||
if strings.TrimSpace(override.Name) != "" {
|
||||
name = strings.TrimSpace(override.Name)
|
||||
}
|
||||
components := make([]Component, 0, len(group.Components))
|
||||
seenComponents := make(map[string]struct{}, len(group.Components))
|
||||
for _, input := range group.Components {
|
||||
if strings.TrimSpace(input.ID) == "" || strings.TrimSpace(input.Name) == "" {
|
||||
return Snapshot{}, errors.New("application component identity is required")
|
||||
}
|
||||
if _, exists := seenComponents[input.ID]; exists {
|
||||
return Snapshot{}, errors.New("duplicate application component")
|
||||
}
|
||||
seenComponents[input.ID] = struct{}{}
|
||||
critical := input.Critical
|
||||
if value, ok := override.CriticalByComponent[input.ID]; ok {
|
||||
critical = value
|
||||
}
|
||||
components = append(components, evaluateComponent(input, critical))
|
||||
}
|
||||
sort.Slice(components, func(i, j int) bool { return components[i].ID < components[j].ID })
|
||||
status, reasons := aggregate(components)
|
||||
apps = append(apps, Application{ID: group.ID, Name: name, Status: status, Overridden: overridden, Components: components, Reasons: reasons})
|
||||
}
|
||||
sort.Slice(apps, func(i, j int) bool { return apps[i].ID < apps[j].ID })
|
||||
if source.State == "unknown" {
|
||||
for i := range apps {
|
||||
apps[i].Status = StateUnknown
|
||||
apps[i].Reasons = append(apps[i].Reasons, Reason{Code: "source_unknown", Message: "Applicatiebron is onbekend.", Critical: true})
|
||||
}
|
||||
}
|
||||
return Snapshot{ContractVersion: ContractVersion, Source: source, Applications: apps, Total: len(apps)}, nil
|
||||
}
|
||||
func evaluateComponent(input ComponentInput, critical bool) Component {
|
||||
container, service := normalizeState(input.ContainerState), normalizeState(input.ServiceState)
|
||||
status, reason := container, ""
|
||||
if container == StateHealthy && service != "" {
|
||||
status = service
|
||||
}
|
||||
if status != StateHealthy {
|
||||
if service != "" && service != StateHealthy && container == StateHealthy {
|
||||
reason = "service_" + string(service)
|
||||
} else {
|
||||
reason = "container_" + string(status)
|
||||
}
|
||||
}
|
||||
return Component{ID: input.ID, Name: input.Name, Kind: input.Kind, Critical: critical, ContainerState: container, ServiceState: service, Status: status, Reason: reason}
|
||||
}
|
||||
func aggregate(components []Component) (State, []Reason) {
|
||||
reasons := make([]Reason, 0)
|
||||
criticalFailure, criticalUnknown, optionalFailure := false, false, false
|
||||
for _, component := range components {
|
||||
if component.Status == StateHealthy {
|
||||
continue
|
||||
}
|
||||
reasons = append(reasons, Reason{Code: component.Reason, Message: component.Name + " is " + string(component.Status) + ".", ComponentID: component.ID, Critical: component.Critical})
|
||||
if component.Critical {
|
||||
if component.Status == StateUnknown {
|
||||
criticalUnknown = true
|
||||
} else {
|
||||
criticalFailure = true
|
||||
}
|
||||
} else {
|
||||
optionalFailure = true
|
||||
}
|
||||
}
|
||||
sort.Slice(reasons, func(i, j int) bool {
|
||||
if reasons[i].ComponentID != reasons[j].ComponentID {
|
||||
return reasons[i].ComponentID < reasons[j].ComponentID
|
||||
}
|
||||
return reasons[i].Code < reasons[j].Code
|
||||
})
|
||||
if criticalFailure {
|
||||
return StateDegraded, reasons
|
||||
}
|
||||
if criticalUnknown {
|
||||
return StateUnknown, reasons
|
||||
}
|
||||
if optionalFailure {
|
||||
return StateDegraded, reasons
|
||||
}
|
||||
return StateHealthy, reasons
|
||||
}
|
||||
func normalizeState(state State) State {
|
||||
state = State(strings.ToLower(strings.TrimSpace(string(state))))
|
||||
switch state {
|
||||
case StateHealthy, StateDegraded, StateUnknown, StateDown:
|
||||
return state
|
||||
default:
|
||||
return StateUnknown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func appSource(now time.Time) Source {
|
||||
return Source{ID: "agent", Type: "agent", ObservedAt: now, ReceivedAt: now}
|
||||
}
|
||||
|
||||
func TestCriticalAndOptionalAggregationAndReasons(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
|
||||
snapshot, err := BuildSnapshot(appSource(now), []DiscoveredApplication{{ID: "app-1", Name: "Pulse", Components: []ComponentInput{
|
||||
{ID: "db", Name: "Database", Kind: "container", ContainerState: StateHealthy, ServiceState: StateHealthy, Critical: true},
|
||||
{ID: "worker", Name: "Worker", Kind: "container", ContainerState: StateDown, ServiceState: StateDown, Critical: false},
|
||||
}}}, nil, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Applications[0].Status != StateDegraded || len(snapshot.Applications[0].Reasons) != 1 || snapshot.Applications[0].Reasons[0].Critical {
|
||||
t.Fatalf("unexpected optional aggregation: %+v", snapshot.Applications[0])
|
||||
}
|
||||
}
|
||||
func TestServiceDownWhileContainerRunningDegradesCriticalApplication(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
|
||||
snapshot, err := BuildSnapshot(appSource(now), []DiscoveredApplication{{ID: "app-1", Name: "Pulse", Components: []ComponentInput{
|
||||
{ID: "api", Name: "API", Kind: "container", ContainerState: StateHealthy, ServiceState: StateDown, Critical: true},
|
||||
}}}, nil, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := snapshot.Applications[0]
|
||||
if app.Status != StateDegraded || len(app.Reasons) != 1 || app.Reasons[0].Code != "service_down" || !app.Reasons[0].Critical {
|
||||
t.Fatalf("service failure not explained: %+v", app)
|
||||
}
|
||||
}
|
||||
func TestUnknownCriticalIsNotHealthy(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
|
||||
snapshot, err := BuildSnapshot(appSource(now), []DiscoveredApplication{{ID: "app-1", Name: "Pulse", Components: []ComponentInput{{ID: "db", Name: "Database", Critical: true, ContainerState: StateUnknown}}}}, nil, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Applications[0].Status != StateUnknown {
|
||||
t.Fatalf("unknown critical became healthy: %+v", snapshot.Applications[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateNormalizationIsCaseInsensitive(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
|
||||
snapshot, err := BuildSnapshot(appSource(now), []DiscoveredApplication{{ID: "app-1", Name: "Pulse", Components: []ComponentInput{{ID: "api", Name: "API", Critical: true, ContainerState: "HEALTHY", ServiceState: "HEALTHY"}}}}, nil, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Applications[0].Status != StateHealthy {
|
||||
t.Fatalf("uppercase healthy state was not normalized: %+v", snapshot.Applications[0])
|
||||
}
|
||||
}
|
||||
func TestUserOverridePersistsAcrossDiscoveryProjection(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC)
|
||||
discovered := []DiscoveredApplication{{ID: "app-1", Name: "Discovered name", Components: []ComponentInput{{ID: "worker", Name: "Worker", Critical: true, ContainerState: StateHealthy}}}}
|
||||
override := []ApplicationOverride{{ApplicationID: "app-1", Name: "Handmatige naam", CriticalByComponent: map[string]bool{"worker": false}}}
|
||||
first, err := BuildSnapshot(appSource(now), discovered, override, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := BuildSnapshot(appSource(now.Add(time.Minute)), discovered, override, now.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, snapshot := range []Snapshot{first, second} {
|
||||
if snapshot.Applications[0].Name != "Handmatige naam" || !snapshot.Applications[0].Overridden || snapshot.Applications[0].Components[0].Critical {
|
||||
t.Fatalf("override not retained: %+v", snapshot.Applications[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestStableApplicationIDIsDeterministic(t *testing.T) {
|
||||
if StableApplicationID("agent", "pulse") != StableApplicationID("agent", "pulse") {
|
||||
t.Fatal("stable application id changed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user