Files
ITWorx-Pulse-Public/internal/workerruntime/discoveryjob.go
T
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

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
}