Files
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

213 lines
7.5 KiB
Go

package agentsource
import (
"context"
"errors"
"strings"
"time"
"github.com/itworx/pulse/internal/application"
"github.com/itworx/pulse/internal/container"
"github.com/itworx/pulse/internal/service"
)
// ApplicationProvider derives application health from two existing surfaces rather than
// from a capability of its own: the container inventory the agent records, and the
// service probe results the API already stores. Containers are grouped into applications
// by their Compose project (falling back to the container name for a standalone
// container), and a probe result for a service with the same name refines the component
// status of the matching container.
//
// Fresh container evidence is required. Service probes refine matching
// components when configured; their absence does not erase container-runtime
// availability, because those are separate claims and surfaces.
type ApplicationProvider struct {
Containers container.Provider
Services service.Provider
// MaxApplications bounds the number of groups built before the payload is refused.
// Zero takes DefaultMaxApplications.
MaxApplications int
Now func() time.Time
}
var _ application.Provider = ApplicationProvider{}
// DefaultMaxApplications matches the bound application.BuildSnapshot enforces, so an
// oversized inventory is reported as Unknown instead of failing the request.
const DefaultMaxApplications = 150
func (p ApplicationProvider) Snapshot(ctx context.Context) (application.Snapshot, error) {
if err := ctx.Err(); err != nil {
return application.Snapshot{}, err
}
now := clockNow(p.Now)
unknown := func(reason string) application.Snapshot {
return application.UnknownSnapshot(now, applicationSources, agentSourceType, reason)
}
if p.Containers == nil {
return unknown(ReasonUnavailable), nil
}
containers, err := p.Containers.Snapshot(ctx)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return application.Snapshot{}, err
}
return unknown(ReasonUnavailable), nil
}
if reason := unusableContainerReason(containers); reason != "" {
return unknown(reason), nil
}
// Service probes refine application health when configured, but are not an
// identity prerequisite. Fresh container state is complete evidence for
// runtime availability; an empty/disabled probe module must not erase every
// discovered application from the product.
services := service.Snapshot{ContractVersion: service.ContractVersion, ObservedAt: containers.Source.ObservedAt}
if p.Services != nil {
observed, serviceErr := p.Services.Snapshot(ctx)
if serviceErr != nil {
if errors.Is(serviceErr, context.Canceled) || errors.Is(serviceErr, context.DeadlineExceeded) {
return application.Snapshot{}, serviceErr
}
} else if strings.TrimSpace(observed.Reason) == "" {
services = observed
}
}
maximum := p.MaxApplications
if maximum <= 0 {
maximum = DefaultMaxApplications
}
discovered := groupApplications(containers, services, maximum)
if discovered == nil {
return unknown(ReasonInvalid), nil
}
source := application.Source{
ID: applicationSources,
Type: agentSourceType,
ObservedAt: containers.Source.ObservedAt,
ReceivedAt: containers.Source.ReceivedAt,
}
snapshot, err := application.BuildSnapshot(source, discovered, nil, now)
if err != nil {
return unknown(ReasonInvalid), nil
}
return snapshot, nil
}
// unusableContainerReason maps the container source state onto an application reason code.
// The container provider has already applied the freshness rule, so an Unknown container
// source here means the same thing for applications.
func unusableContainerReason(snapshot container.Snapshot) string {
if snapshot.Source.State == "" || snapshot.Source.State == "unknown" {
switch snapshot.Source.Reason {
case ReasonStale, "stale_source":
return ReasonStale
case ReasonInvalid:
return ReasonInvalid
default:
return ReasonUnavailable
}
}
return ""
}
// groupApplications builds one discovered application per Compose project. It returns nil
// when the inventory exceeds the configured bound.
func groupApplications(containers container.Snapshot, services service.Snapshot, maximum int) []application.DiscoveredApplication {
states := make(map[string]application.State, len(services.Services))
for _, item := range services.Services {
states[normalizeKey(item.Name)] = serviceState(item.State)
}
// application.evaluateComponent normalizes an empty service state to Unknown, so a
// component with no probe would drag its application to Unknown even though the
// container telemetry is fresh. There is no probe constraint on such a component, and
// Healthy is the neutral element of the domain's aggregation: it leaves the component
// status equal to the container status instead of inventing an unknown.
stateFor := func(name string) application.State {
if state, ok := states[normalizeKey(name)]; ok {
return state
}
return application.StateHealthy
}
order := make([]string, 0, len(containers.Containers))
groups := make(map[string]*application.DiscoveredApplication, len(containers.Containers))
for _, item := range containers.Containers {
key := strings.TrimSpace(item.Project)
if key == "" {
key = strings.TrimPrefix(strings.TrimSpace(item.Name), "/")
}
if key == "" {
continue
}
group, seen := groups[key]
if !seen {
if len(order) >= maximum {
return nil
}
group = &application.DiscoveredApplication{ID: application.StableApplicationID(application.SourceID, key), Name: key}
groups[key] = group
order = append(order, key)
}
group.Components = append(group.Components, application.ComponentInput{
ID: item.ID,
Name: strings.TrimPrefix(item.Name, "/"),
Kind: "container",
ContainerState: containerState(item),
ServiceState: stateFor(item.Name),
// Every discovered component is critical until an operator overrides it;
// treating an unmapped component as optional would hide real failures.
Critical: true,
})
}
discovered := make([]application.DiscoveredApplication, 0, len(order))
for _, key := range order {
discovered = append(discovered, *groups[key])
}
return discovered
}
// containerState maps a normalized container onto the application state vocabulary. A
// container the operator stopped on purpose is not a failure; anything the collector
// could not classify is Unknown rather than Healthy.
func containerState(item container.Container) application.State {
switch strings.ToLower(strings.TrimSpace(item.State)) {
case "running":
switch strings.ToLower(strings.TrimSpace(item.Health)) {
case "healthy":
return application.StateHealthy
case "unhealthy":
return application.StateDegraded
case "starting", "unknown", "":
return application.StateUnknown
default:
return application.StateUnknown
}
case "restarting", "paused", "removing":
return application.StateDegraded
case "exited", "dead", "stopped":
if item.IntentionalStop {
return application.StateUnknown
}
return application.StateDown
default:
return application.StateUnknown
}
}
// serviceState maps a probe verdict onto the application state vocabulary.
func serviceState(state string) application.State {
switch strings.ToLower(strings.TrimSpace(state)) {
case service.StateUp:
return application.StateHealthy
case service.StateDegraded:
return application.StateDegraded
case service.StateDown:
return application.StateDown
default:
return application.StateUnknown
}
}
func normalizeKey(value string) string {
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), "/"))
}