Public source validation / validate (push) Failing after 3m8s
213 lines
7.5 KiB
Go
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), "/"))
|
|
}
|