Public source validation / validate (push) Failing after 3m8s
621 lines
26 KiB
Go
621 lines
26 KiB
Go
package workerruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/itworx/pulse/internal/application"
|
|
"github.com/itworx/pulse/internal/container"
|
|
"github.com/itworx/pulse/internal/discovery"
|
|
"github.com/itworx/pulse/internal/inventory"
|
|
"github.com/itworx/pulse/internal/lifecycle"
|
|
"github.com/itworx/pulse/internal/reconciliation"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// MaxDiscoveredContainers bounds one discovery pass. It sits above the
|
|
// documented scale target of 150 containers (SYSTEM_ARCHITECTURE section 7) so
|
|
// a normal host is never truncated while a runaway source still cannot flood
|
|
// the database.
|
|
const MaxDiscoveredContainers = 400
|
|
|
|
// ContainerAliasRecord is one runtime container as last observed. It carries the
|
|
// reconciliation identity plus the runtime facts lifecycle event derivation
|
|
// needs to decide whether anything actually changed.
|
|
type ContainerAliasRecord struct {
|
|
reconciliation.ContainerAlias
|
|
State string
|
|
Health string
|
|
RestartCount int
|
|
IntentionalStop bool
|
|
}
|
|
|
|
// ContainerAliasStore persists the previous container observation per source.
|
|
type ContainerAliasStore interface {
|
|
List(ctx context.Context, sourceID string) ([]ContainerAliasRecord, error)
|
|
Save(ctx context.Context, sourceID string, records []ContainerAliasRecord) error
|
|
}
|
|
|
|
// InventoryStore is the narrow inventory surface the discovery job writes to.
|
|
// *inventory.Repository satisfies it.
|
|
type InventoryStore interface {
|
|
PersistDiscovery(ctx context.Context, entity inventory.Entity, alias inventory.Alias, facts []inventory.Fact, relations []inventory.Relation) error
|
|
}
|
|
|
|
// DiscoveryJob observes containers, reconciles them against the previous
|
|
// snapshot and emits the resulting lifecycle events.
|
|
//
|
|
// It is strictly observational (ADR-0001): it reads a snapshot and writes Pulse
|
|
// state only. Nothing here starts, stops or otherwise mutates a container, the
|
|
// array, a volume or the host.
|
|
type DiscoveryJob struct {
|
|
// SourceID is the data_sources UUID the observations belong to.
|
|
SourceID string
|
|
// Provider supplies the container snapshot.
|
|
Provider container.Provider
|
|
// Aliases stores the previous observation used for identity and diffing.
|
|
Aliases ContainerAliasStore
|
|
// Inventory persists reconciled entities. It may be nil, in which case the
|
|
// job only derives and emits events.
|
|
Inventory InventoryStore
|
|
// Runner claims the discovery window and emits events idempotently.
|
|
Runner discovery.Runner
|
|
// Now is injectable for tests.
|
|
Now func() time.Time
|
|
}
|
|
|
|
func (j DiscoveryJob) now() time.Time {
|
|
if j.Now != nil {
|
|
return j.Now().UTC()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
// Run performs one discovery and reconciliation pass.
|
|
func (j DiscoveryJob) Run(ctx context.Context) (Outcome, error) {
|
|
if j.Provider == nil || j.Aliases == nil || j.Runner.Store == nil {
|
|
return Outcome{Disabled: true, Reason: "container_source_not_configured"}, nil
|
|
}
|
|
if j.SourceID == "" {
|
|
return Outcome{Disabled: true, Reason: "container_source_not_registered"}, nil
|
|
}
|
|
snapshot, err := j.Provider.Snapshot(ctx)
|
|
if err != nil {
|
|
return Outcome{}, fmt.Errorf("read container snapshot: %w", err)
|
|
}
|
|
// A source that is not healthy and fresh must not drive reconciliation:
|
|
// tombstoning every container because a collector is down would be exactly
|
|
// the "source failure tombstones all inventory" failure the standards
|
|
// forbid, and reporting success would be a silent fallback to green.
|
|
if snapshot.Source.State != "healthy" || snapshot.Source.Freshness != "fresh" {
|
|
reason := strings.TrimSpace(snapshot.Source.Reason)
|
|
if reason == "" {
|
|
reason = "source_" + defaultReason(snapshot.Source.State, "unknown")
|
|
}
|
|
return Outcome{Skipped: true, Reason: boundedCode(reason)}, nil
|
|
}
|
|
if len(snapshot.Containers) > MaxDiscoveredContainers {
|
|
return Outcome{}, fmt.Errorf("container snapshot of %d exceeds the %d bound", len(snapshot.Containers), MaxDiscoveredContainers)
|
|
}
|
|
counts := map[string]int64{}
|
|
observedAt := snapshot.Source.ObservedAt.UTC()
|
|
if observedAt.IsZero() {
|
|
observedAt = j.now()
|
|
}
|
|
// The job key contains the observation window, so re-running the same
|
|
// window is claimed once and a retry of a partially applied pass repeats
|
|
// safely: entity upserts and event inserts are both keyed by identity.
|
|
jobKey := "container:" + j.SourceID + ":" + observedAt.Truncate(time.Minute).UTC().Format(time.RFC3339)
|
|
runErr := j.Runner.Run(ctx, jobKey, func(runCtx context.Context) ([]discovery.Event, error) {
|
|
events, applied, err := j.reconcile(runCtx, snapshot, observedAt)
|
|
for key, value := range applied {
|
|
counts[key] = value
|
|
}
|
|
return events, err
|
|
})
|
|
if runErr != nil {
|
|
return Outcome{Counts: counts}, runErr
|
|
}
|
|
counts["containers"] = int64(len(snapshot.Containers))
|
|
return Outcome{Counts: counts}, nil
|
|
}
|
|
|
|
func (j DiscoveryJob) reconcile(ctx context.Context, snapshot container.Snapshot, observedAt time.Time) ([]discovery.Event, map[string]int64, error) {
|
|
counts := map[string]int64{}
|
|
previous, err := j.Aliases.List(ctx, j.SourceID)
|
|
if err != nil {
|
|
return nil, counts, fmt.Errorf("list container aliases: %w", err)
|
|
}
|
|
previousByRuntime := make(map[string]ContainerAliasRecord, len(previous))
|
|
priorAliases := make([]reconciliation.ContainerAlias, 0, len(previous))
|
|
for _, record := range previous {
|
|
previousByRuntime[record.RuntimeID] = record
|
|
priorAliases = append(priorAliases, record.ContainerAlias)
|
|
}
|
|
observations := make([]reconciliation.ContainerObservation, 0, len(snapshot.Containers))
|
|
byRuntime := make(map[string]container.Container, len(snapshot.Containers))
|
|
for _, item := range snapshot.Containers {
|
|
project, service := composeIdentity(item)
|
|
observations = append(observations, reconciliation.ContainerObservation{
|
|
SourceID: j.SourceID, RuntimeID: item.ID, Name: item.Name, Project: project,
|
|
Service: service, ImageDigest: item.ImageDigest, ObservedAt: observedAt,
|
|
})
|
|
byRuntime[item.ID] = item
|
|
}
|
|
result, err := reconciliation.ReconcileContainers(observations, priorAliases, observedAt)
|
|
if err != nil {
|
|
return nil, counts, fmt.Errorf("reconcile containers: %w", err)
|
|
}
|
|
counts["added"] = int64(len(result.Added))
|
|
counts["updated"] = int64(len(result.Updated))
|
|
counts["recreated"] = int64(len(result.Recreated))
|
|
events := make([]lifecycle.Event, 0, len(result.Aliases))
|
|
records := make([]ContainerAliasRecord, 0, len(result.Aliases))
|
|
tombstoned := 0
|
|
for _, alias := range result.Aliases {
|
|
record := ContainerAliasRecord{ContainerAlias: alias}
|
|
current, observed := byRuntime[alias.RuntimeID]
|
|
if observed {
|
|
record.State, record.Health = current.State, current.Health
|
|
record.RestartCount, record.IntentionalStop = current.RestartCount, current.IntentionalStop
|
|
after := runtimeState{State: current.State, Health: current.Health, RestartCount: current.RestartCount, IntentionalStop: current.IntentionalStop}
|
|
// A container observed for the first time has no "before", so it
|
|
// produces an addition rather than a stream of change events.
|
|
if prior, ok := previousByRuntime[alias.RuntimeID]; ok {
|
|
before := runtimeState{State: prior.State, Health: prior.Health, RestartCount: prior.RestartCount, IntentionalStop: prior.IntentionalStop}
|
|
if before != after {
|
|
events = append(events, lifecycle.ContainerTransitionEvents(j.SourceID, alias.EntityID, before.container(), after.container(), observedAt, observedAt)...)
|
|
}
|
|
}
|
|
} else if prior, ok := previousByRuntime[alias.RuntimeID]; ok {
|
|
record.State, record.Health = prior.State, prior.Health
|
|
record.RestartCount, record.IntentionalStop = prior.RestartCount, prior.IntentionalStop
|
|
}
|
|
if !alias.TombstonedAt.IsZero() {
|
|
tombstoned++
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
counts["tombstoned"] = int64(tombstoned)
|
|
for _, recreated := range result.Recreated {
|
|
dedup := recreated.EntityID + ":recreated:" + recreated.CurrentRuntimeID
|
|
events = append(events, lifecycle.Event{
|
|
ID: dedup, EventType: recreated.EventType, Severity: lifecycle.SeverityAttention, EntityID: recreated.EntityID,
|
|
SourceID: j.SourceID, OccurredAt: recreated.OccurredAt, ReceivedAt: observedAt, DedupKey: dedup,
|
|
Summary: "Container was recreated.",
|
|
})
|
|
}
|
|
counts["warnings"] = int64(len(result.Warnings))
|
|
if err := j.persist(ctx, result, byRuntime, records); err != nil {
|
|
return nil, counts, err
|
|
}
|
|
normalized, err := lifecycle.Normalize(events, observedAt)
|
|
if err != nil {
|
|
return nil, counts, fmt.Errorf("normalize lifecycle events: %w", err)
|
|
}
|
|
counts["events"] = int64(len(normalized))
|
|
discovered := make([]discovery.Event, 0, len(normalized))
|
|
for _, event := range normalized {
|
|
discovered = append(discovered, discovery.Event{
|
|
SourceID: j.SourceID, DedupKey: event.DedupKey, Type: event.EventType, Summary: event.Summary,
|
|
EntityID: event.EntityID, Severity: string(event.Severity), OccurredAt: event.OccurredAt,
|
|
})
|
|
}
|
|
return discovered, counts, nil
|
|
}
|
|
|
|
// persist writes reconciled inventory before events are emitted, so an event
|
|
// can always reference an entity that exists. Every write is an upsert keyed by
|
|
// identity, which is what makes a repeated pass safe.
|
|
func (j DiscoveryJob) persist(ctx context.Context, result reconciliation.ContainerIdentityResult, byRuntime map[string]container.Container, records []ContainerAliasRecord) error {
|
|
if j.Inventory != nil {
|
|
groups, err := applicationGroups(result.Current, byRuntime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Applications are persisted before their container relations so every
|
|
// foreign-key target exists even on the first discovery pass.
|
|
for _, group := range groups {
|
|
firstSeen, lastSeen := group.FirstSeenAt, group.LastSeenAt
|
|
entity := inventory.Entity{
|
|
ID: group.ID, EntityType: "application", CanonicalName: group.Name,
|
|
DisplayName: group.Name, Status: group.Status, FirstSeenAt: &firstSeen, LastSeenAt: &lastSeen,
|
|
}
|
|
alias := inventory.Alias{EntityID: group.ID, SourceID: j.SourceID, ExternalType: group.ExternalType, ExternalID: group.Name}
|
|
facts, factErr := applicationFacts(group, j.SourceID)
|
|
if factErr != nil {
|
|
return factErr
|
|
}
|
|
if err := j.Inventory.PersistDiscovery(ctx, entity, alias, facts, nil); err != nil {
|
|
return fmt.Errorf("persist application entity: %w", err)
|
|
}
|
|
}
|
|
activeEntities := make(map[string]struct{}, len(result.Current))
|
|
activeGroups := make(map[string]struct{}, len(groups))
|
|
for _, group := range groups {
|
|
activeGroups[group.Name] = struct{}{}
|
|
}
|
|
// Missing application groups are materialized before missing-container
|
|
// relations are tombstoned, preserving the relation foreign key even
|
|
// when upgrading a legacy database that did not yet persist apps.
|
|
for _, group := range tombstonedApplicationGroups(result.Aliases, activeGroups) {
|
|
firstSeen, lastSeen, tombstoned := group.FirstSeenAt, group.LastSeenAt, group.TombstonedAt
|
|
entity := inventory.Entity{ID: group.ID, EntityType: "application", CanonicalName: group.Name, DisplayName: group.Name, Status: "unknown", FirstSeenAt: &firstSeen, LastSeenAt: &lastSeen, TombstonedAt: &tombstoned}
|
|
alias := inventory.Alias{EntityID: group.ID, SourceID: j.SourceID, ExternalType: group.ExternalType, ExternalID: group.Name}
|
|
if err := j.Inventory.PersistDiscovery(ctx, entity, alias, nil, nil); err != nil {
|
|
return fmt.Errorf("tombstone application entity: %w", err)
|
|
}
|
|
}
|
|
for _, alias := range result.Current {
|
|
activeEntities[alias.EntityID] = struct{}{}
|
|
current := byRuntime[alias.RuntimeID]
|
|
firstSeen := alias.FirstSeenAt
|
|
lastSeen := alias.LastSeenAt
|
|
entity := inventory.Entity{
|
|
ID: alias.EntityID, EntityType: "container", CanonicalName: canonicalContainerName(alias),
|
|
DisplayName: alias.Name, Status: containerStatus(current), FirstSeenAt: &firstSeen, LastSeenAt: &lastSeen,
|
|
}
|
|
externalType, externalID := containerExternalIdentity(alias)
|
|
aliasRow := inventory.Alias{EntityID: alias.EntityID, SourceID: j.SourceID, ExternalType: externalType, ExternalID: externalID}
|
|
facts, factErr := containerFacts(alias.EntityID, j.SourceID, current, lastSeen)
|
|
if factErr != nil {
|
|
return factErr
|
|
}
|
|
groupKey, _ := applicationGroupKey(current)
|
|
applicationID := application.StableApplicationID(application.SourceID, groupKey)
|
|
relationFirst, relationLast := firstSeen, lastSeen
|
|
relation := inventory.Relation{
|
|
ID: reconciliation.StableEntityID(j.SourceID, "relation", alias.EntityID+"|member_of|"+applicationID),
|
|
SourceEntityID: alias.EntityID, RelationType: "member_of", TargetEntityID: applicationID,
|
|
SourceID: j.SourceID, Confidence: 1, Confirmed: true,
|
|
FirstSeenAt: &relationFirst, LastSeenAt: &relationLast,
|
|
}
|
|
if err := j.Inventory.PersistDiscovery(ctx, entity, aliasRow, facts, []inventory.Relation{relation}); err != nil {
|
|
return fmt.Errorf("persist container entity: %w", err)
|
|
}
|
|
}
|
|
for _, alias := range result.Aliases {
|
|
if alias.Active || alias.TombstonedAt.IsZero() {
|
|
continue
|
|
}
|
|
// A recreation may retire an old runtime alias while reusing the
|
|
// same logical entity. The active observation wins and must not be
|
|
// tombstoned by the historical alias later in this loop.
|
|
if _, active := activeEntities[alias.EntityID]; active {
|
|
continue
|
|
}
|
|
firstSeen := alias.FirstSeenAt
|
|
lastSeen := alias.LastSeenAt
|
|
tombstoned := alias.TombstonedAt
|
|
entity := inventory.Entity{
|
|
ID: alias.EntityID, EntityType: "container", CanonicalName: canonicalContainerName(alias),
|
|
DisplayName: alias.Name, Status: "unknown", FirstSeenAt: &firstSeen, LastSeenAt: &lastSeen, TombstonedAt: &tombstoned,
|
|
}
|
|
externalType, externalID := containerExternalIdentity(alias)
|
|
groupKey, _ := applicationGroupKeyFromAlias(alias)
|
|
applicationID := application.StableApplicationID(application.SourceID, groupKey)
|
|
relation := inventory.Relation{
|
|
ID: reconciliation.StableEntityID(j.SourceID, "relation", alias.EntityID+"|member_of|"+applicationID),
|
|
SourceEntityID: alias.EntityID, RelationType: "member_of", TargetEntityID: applicationID,
|
|
SourceID: j.SourceID, Confidence: 1, Confirmed: true,
|
|
FirstSeenAt: &firstSeen, LastSeenAt: &lastSeen, TombstonedAt: &tombstoned,
|
|
}
|
|
if err := j.Inventory.PersistDiscovery(ctx, entity, inventory.Alias{EntityID: alias.EntityID, SourceID: j.SourceID, ExternalType: externalType, ExternalID: externalID}, nil, []inventory.Relation{relation}); err != nil {
|
|
return fmt.Errorf("tombstone container entity: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if err := j.Aliases.Save(ctx, j.SourceID, records); err != nil {
|
|
return fmt.Errorf("save container aliases: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type discoveredApplicationGroup struct {
|
|
ID, Name, Status, ExternalType string
|
|
ComponentCount int
|
|
FirstSeenAt, LastSeenAt time.Time
|
|
TombstonedAt time.Time
|
|
}
|
|
|
|
func applicationGroups(aliases []reconciliation.ContainerAlias, containers map[string]container.Container) ([]discoveredApplicationGroup, error) {
|
|
byName := make(map[string]*discoveredApplicationGroup, len(aliases))
|
|
order := make([]string, 0, len(aliases))
|
|
for _, alias := range aliases {
|
|
item, ok := containers[alias.RuntimeID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
key, compose := applicationGroupKey(item)
|
|
if key == "" {
|
|
return nil, errors.New("container application identity is incomplete")
|
|
}
|
|
group := byName[key]
|
|
if group == nil {
|
|
externalType := "application-instance"
|
|
if compose {
|
|
externalType = "application-project"
|
|
}
|
|
group = &discoveredApplicationGroup{ID: application.StableApplicationID(application.SourceID, key), Name: key, Status: "healthy", ExternalType: externalType, FirstSeenAt: alias.FirstSeenAt, LastSeenAt: alias.LastSeenAt}
|
|
byName[key] = group
|
|
order = append(order, key)
|
|
}
|
|
group.ComponentCount++
|
|
if alias.FirstSeenAt.Before(group.FirstSeenAt) {
|
|
group.FirstSeenAt = alias.FirstSeenAt
|
|
}
|
|
if alias.LastSeenAt.After(group.LastSeenAt) {
|
|
group.LastSeenAt = alias.LastSeenAt
|
|
}
|
|
group.Status = worstApplicationStatus(group.Status, containerStatus(item))
|
|
}
|
|
sort.Strings(order)
|
|
result := make([]discoveredApplicationGroup, 0, len(order))
|
|
for _, key := range order {
|
|
result = append(result, *byName[key])
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func applicationGroupKey(item container.Container) (string, bool) {
|
|
project := strings.TrimSpace(item.Project)
|
|
if project == "" {
|
|
project = strings.TrimSpace(item.Labels["com.docker.compose.project"])
|
|
}
|
|
if project != "" {
|
|
return project, true
|
|
}
|
|
return strings.TrimPrefix(strings.TrimSpace(item.Name), "/"), false
|
|
}
|
|
|
|
func applicationGroupKeyFromAlias(alias reconciliation.ContainerAlias) (string, bool) {
|
|
if project := strings.TrimSpace(alias.Project); project != "" {
|
|
return project, true
|
|
}
|
|
return strings.TrimPrefix(strings.TrimSpace(alias.Name), "/"), false
|
|
}
|
|
|
|
func tombstonedApplicationGroups(aliases []reconciliation.ContainerAlias, active map[string]struct{}) []discoveredApplicationGroup {
|
|
groups := make(map[string]*discoveredApplicationGroup)
|
|
for _, alias := range aliases {
|
|
if alias.Active || alias.TombstonedAt.IsZero() {
|
|
continue
|
|
}
|
|
key, compose := applicationGroupKeyFromAlias(alias)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := active[key]; exists {
|
|
continue
|
|
}
|
|
group := groups[key]
|
|
if group == nil {
|
|
externalType := "application-instance"
|
|
if compose {
|
|
externalType = "application-project"
|
|
}
|
|
group = &discoveredApplicationGroup{ID: application.StableApplicationID(application.SourceID, key), Name: key, ExternalType: externalType, FirstSeenAt: alias.FirstSeenAt, LastSeenAt: alias.LastSeenAt, TombstonedAt: alias.TombstonedAt}
|
|
groups[key] = group
|
|
}
|
|
if alias.FirstSeenAt.Before(group.FirstSeenAt) {
|
|
group.FirstSeenAt = alias.FirstSeenAt
|
|
}
|
|
if alias.LastSeenAt.After(group.LastSeenAt) {
|
|
group.LastSeenAt = alias.LastSeenAt
|
|
}
|
|
if alias.TombstonedAt.After(group.TombstonedAt) {
|
|
group.TombstonedAt = alias.TombstonedAt
|
|
}
|
|
}
|
|
keys := make([]string, 0, len(groups))
|
|
for key := range groups {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
result := make([]discoveredApplicationGroup, 0, len(keys))
|
|
for _, key := range keys {
|
|
result = append(result, *groups[key])
|
|
}
|
|
return result
|
|
}
|
|
|
|
func worstApplicationStatus(current, candidate string) string {
|
|
rank := map[string]int{"healthy": 0, "up": 0, "unknown": 1, "degraded": 2, "down": 2}
|
|
if rank[candidate] > rank[current] {
|
|
if candidate == "down" {
|
|
return "degraded"
|
|
}
|
|
return candidate
|
|
}
|
|
return current
|
|
}
|
|
|
|
func applicationFacts(group discoveredApplicationGroup, sourceID string) ([]inventory.Fact, error) {
|
|
mode := "standalone"
|
|
if group.ExternalType == "application-project" {
|
|
mode = "compose"
|
|
}
|
|
return inventoryFacts(group.ID, sourceID, group.LastSeenAt, map[string]any{
|
|
"groupingMode": mode, "componentCount": group.ComponentCount,
|
|
})
|
|
}
|
|
|
|
func containerFacts(entityID, sourceID string, item container.Container, observedAt time.Time) ([]inventory.Fact, error) {
|
|
values := map[string]any{
|
|
"runtimeState": item.State, "health": item.Health, "restartCount": item.RestartCount,
|
|
"intentionalStop": item.IntentionalStop, "metricsAvailable": item.MetricsAvailable,
|
|
"lifecycleAvailable": item.LifecycleAvailable,
|
|
}
|
|
if value := strings.TrimSpace(item.Image); value != "" {
|
|
values["image"] = value
|
|
}
|
|
if value := strings.TrimSpace(item.Project); value != "" {
|
|
values["project"] = value
|
|
}
|
|
if value := strings.TrimSpace(item.Labels["com.docker.compose.service"]); value != "" {
|
|
values["composeService"] = value
|
|
}
|
|
return inventoryFacts(entityID, sourceID, observedAt, values)
|
|
}
|
|
|
|
func inventoryFacts(entityID, sourceID string, observedAt time.Time, values map[string]any) ([]inventory.Fact, error) {
|
|
fields := make([]string, 0, len(values))
|
|
for field := range values {
|
|
fields = append(fields, field)
|
|
}
|
|
sort.Strings(fields)
|
|
validUntil := observedAt.Add(2 * time.Minute)
|
|
facts := make([]inventory.Fact, 0, len(fields))
|
|
for _, field := range fields {
|
|
value, err := inventory.MarshalValue(values[field])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal %s fact: %w", field, err)
|
|
}
|
|
facts = append(facts, inventory.Fact{EntityID: entityID, FieldName: field, SourceID: sourceID, Value: value, ObservedAt: observedAt, Confidence: 1, ValidUntil: &validUntil})
|
|
}
|
|
return facts, nil
|
|
}
|
|
|
|
// runtimeState is the comparable subset of a container observation that decides
|
|
// whether a lifecycle event is warranted. container.Container itself holds
|
|
// slices and maps and cannot be compared directly.
|
|
type runtimeState struct {
|
|
State string
|
|
Health string
|
|
RestartCount int
|
|
IntentionalStop bool
|
|
}
|
|
|
|
func (s runtimeState) container() container.Container {
|
|
return container.Container{State: s.State, Health: s.Health, RestartCount: s.RestartCount, IntentionalStop: s.IntentionalStop}
|
|
}
|
|
|
|
func composeIdentity(item container.Container) (string, string) {
|
|
project := strings.TrimSpace(item.Project)
|
|
if project == "" {
|
|
project = strings.TrimSpace(item.Labels["com.docker.compose.project"])
|
|
}
|
|
service := strings.TrimSpace(item.Labels["com.docker.compose.service"])
|
|
if project == "" || service == "" {
|
|
return "", ""
|
|
}
|
|
return project, service
|
|
}
|
|
|
|
func canonicalContainerName(alias reconciliation.ContainerAlias) string {
|
|
if alias.Project != "" && alias.Service != "" {
|
|
return alias.Project + "/" + alias.Service
|
|
}
|
|
return alias.Name
|
|
}
|
|
|
|
func containerExternalIdentity(alias reconciliation.ContainerAlias) (string, string) {
|
|
if alias.Project != "" && alias.Service != "" {
|
|
return "container-service", alias.Project + "/" + alias.Service
|
|
}
|
|
return "container-instance", alias.RuntimeID
|
|
}
|
|
|
|
// containerStatus maps a runtime state onto the shared status vocabulary. An
|
|
// unrecognized state is unknown, never healthy (ADR-0008).
|
|
func containerStatus(item container.Container) string {
|
|
switch strings.ToLower(strings.TrimSpace(item.State)) {
|
|
case "running":
|
|
if strings.EqualFold(item.Health, "unhealthy") {
|
|
return "degraded"
|
|
}
|
|
return "up"
|
|
case "exited", "dead", "removing":
|
|
return "down"
|
|
case "":
|
|
return "unknown"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// PostgresContainerAliasStore persists container aliases in container_aliases.
|
|
type PostgresContainerAliasStore struct {
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
// List returns every recorded alias for one source.
|
|
func (s PostgresContainerAliasStore) List(ctx context.Context, sourceID string) ([]ContainerAliasRecord, error) {
|
|
if s.Pool == nil {
|
|
return nil, fmt.Errorf("%w: container alias store has no database pool", ErrInvalidConfig)
|
|
}
|
|
rows, err := s.Pool.Query(ctx, `SELECT entity_id::text,runtime_id,name,project,service,image_digest,observed_state,observed_health,restart_count,intentional_stop,first_seen_at,last_seen_at,tombstoned_at FROM container_aliases WHERE source_id=$1 ORDER BY runtime_id ASC LIMIT $2`, sourceID, MaxDiscoveredContainers*2)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list container aliases: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
records := make([]ContainerAliasRecord, 0, 32)
|
|
for rows.Next() {
|
|
var record ContainerAliasRecord
|
|
var tombstoned *time.Time
|
|
if err := rows.Scan(&record.EntityID, &record.RuntimeID, &record.Name, &record.Project, &record.Service, &record.ImageDigest,
|
|
&record.State, &record.Health, &record.RestartCount, &record.IntentionalStop, &record.FirstSeenAt, &record.LastSeenAt, &tombstoned); err != nil {
|
|
return nil, fmt.Errorf("scan container alias: %w", err)
|
|
}
|
|
record.SourceID = sourceID
|
|
if tombstoned != nil {
|
|
record.TombstonedAt = tombstoned.UTC()
|
|
}
|
|
record.Active = tombstoned == nil
|
|
records = append(records, record)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("read container alias rows: %w", err)
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
// Save replaces the recorded aliases for one source in a single transaction.
|
|
// Aliases that disappeared from the reconciliation result are removed only
|
|
// after reconciliation already decided they are gone; a live alias is upserted,
|
|
// never deleted and recreated, so its first_seen_at survives.
|
|
func (s PostgresContainerAliasStore) Save(ctx context.Context, sourceID string, records []ContainerAliasRecord) error {
|
|
if s.Pool == nil {
|
|
return fmt.Errorf("%w: container alias store has no database pool", ErrInvalidConfig)
|
|
}
|
|
if len(records) > MaxDiscoveredContainers*2 {
|
|
return fmt.Errorf("container alias count %d exceeds bounds", len(records))
|
|
}
|
|
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("begin container alias save: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
keep := make([]string, 0, len(records))
|
|
for _, record := range records {
|
|
if strings.TrimSpace(record.RuntimeID) == "" || strings.TrimSpace(record.EntityID) == "" || strings.TrimSpace(record.Name) == "" {
|
|
return errors.New("container alias identity is incomplete")
|
|
}
|
|
var tombstoned any
|
|
if !record.TombstonedAt.IsZero() {
|
|
tombstoned = record.TombstonedAt.UTC()
|
|
}
|
|
if _, err := tx.Exec(ctx, `INSERT INTO container_aliases (source_id,runtime_id,entity_id,name,project,service,image_digest,observed_state,observed_health,restart_count,intentional_stop,first_seen_at,last_seen_at,tombstoned_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
|
ON CONFLICT (source_id,runtime_id) DO UPDATE SET entity_id=EXCLUDED.entity_id,name=EXCLUDED.name,project=EXCLUDED.project,service=EXCLUDED.service,image_digest=EXCLUDED.image_digest,observed_state=EXCLUDED.observed_state,observed_health=EXCLUDED.observed_health,restart_count=EXCLUDED.restart_count,intentional_stop=EXCLUDED.intentional_stop,last_seen_at=EXCLUDED.last_seen_at,tombstoned_at=EXCLUDED.tombstoned_at`,
|
|
sourceID, record.RuntimeID, record.EntityID, record.Name, record.Project, record.Service, record.ImageDigest,
|
|
record.State, record.Health, record.RestartCount, record.IntentionalStop, record.FirstSeenAt.UTC(), record.LastSeenAt.UTC(), tombstoned); err != nil {
|
|
return fmt.Errorf("save container alias: %w", err)
|
|
}
|
|
keep = append(keep, record.RuntimeID)
|
|
}
|
|
sort.Strings(keep)
|
|
if _, err := tx.Exec(ctx, `DELETE FROM container_aliases WHERE source_id=$1 AND NOT (runtime_id = ANY($2::text[]))`, sourceID, keep); err != nil {
|
|
return fmt.Errorf("prune container aliases: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit container alias save: %w", err)
|
|
}
|
|
return nil
|
|
}
|