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 }