package store import ( "context" "fmt" "time" "github.com/jackc/pgx/v5/pgconn" "github.com/shcizo/package-updater/internal/imageref" "github.com/shcizo/package-updater/internal/master/model" "github.com/shcizo/package-updater/internal/protocol" ) type execer interface { Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) } const upsertServiceSQL = ` INSERT INTO services ( kind, location_key, agent_id, compose_project, compose_service, working_dir, swarm_cluster_id, swarm_service, container_name, image_repo, actual_ref, actual_digest, status, self, first_seen_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15) ON CONFLICT (location_key) DO UPDATE SET agent_id = EXCLUDED.agent_id, working_dir = EXCLUDED.working_dir, image_repo = EXCLUDED.image_repo, actual_ref = EXCLUDED.actual_ref, actual_digest = EXCLUDED.actual_digest, status = EXCLUDED.status, self = EXCLUDED.self, updated_at = EXCLUDED.updated_at, removed_at = NULL` func upsertService(ctx context.Context, db execer, agentID int64, clusterID string, svc protocol.Service, at time.Time) error { clusterCol := "" if svc.Kind == protocol.KindSwarm { clusterCol = clusterID } _, err := db.Exec(ctx, upsertServiceSQL, string(svc.Kind), model.LocationKey(agentID, clusterID, svc.Key()), agentID, svc.ComposeProject, svc.ComposeService, svc.WorkingDir, clusterCol, svc.SwarmService, svc.ContainerName, imageref.Parse(svc.Image).Repo, svc.Image, svc.Digest, svc.Status, svc.Self, at) if err != nil { return fmt.Errorf("upsert service %s: %w", svc.Key(), err) } return nil } // ApplySnapshot makes the stored inventory for this agent's scope equal to // services: everything listed is upserted, everything in scope but not // listed is marked removed. Scope is the agent's own Compose services and // containers, plus the cluster's Swarm services if the agent is a manager. // A manager without a cluster ID is treated as a non-manager, since its // Swarm rows could not be told apart from other agents' rows. func (s *Store) ApplySnapshot(ctx context.Context, agentID int64, h protocol.Hello, services []protocol.Service, at time.Time) error { tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("begin snapshot: %w", err) } defer func() { _ = tx.Rollback(ctx) }() isManager := h.SwarmManager && h.SwarmClusterID != "" keys := make([]string, 0, len(services)) for _, svc := range services { if svc.Kind == protocol.KindSwarm && !isManager { continue } if err := upsertService(ctx, tx, agentID, h.SwarmClusterID, svc, at); err != nil { return err } keys = append(keys, model.LocationKey(agentID, h.SwarmClusterID, svc.Key())) } if _, err := tx.Exec(ctx, ` UPDATE services SET removed_at = $1 WHERE removed_at IS NULL AND NOT (location_key = ANY($2)) AND ((kind IN ('compose', 'container') AND agent_id = $3) OR (kind = 'swarm' AND $4 AND swarm_cluster_id = $5))`, at, keys, agentID, isManager, h.SwarmClusterID); err != nil { return fmt.Errorf("mark removed services: %w", err) } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit snapshot: %w", err) } return nil } // UpsertService stores one changed or new service. func (s *Store) UpsertService(ctx context.Context, agentID int64, clusterID string, svc protocol.Service, at time.Time) error { return upsertService(ctx, s.pool, agentID, clusterID, svc, at) } // RemoveService marks the service with the agent-relative key as removed. func (s *Store) RemoveService(ctx context.Context, agentID int64, clusterID, agentKey string, at time.Time) error { if _, err := s.pool.Exec(ctx, `UPDATE services SET removed_at = $2 WHERE location_key = $1 AND removed_at IS NULL`, model.LocationKey(agentID, clusterID, agentKey), at); err != nil { return fmt.Errorf("remove service: %w", err) } return nil } // ListServices returns all services not marked removed: machine-bound // services grouped by agent name first, Swarm services last. func (s *Store) ListServices(ctx context.Context) ([]model.Service, error) { rows, err := s.pool.Query(ctx, ` SELECT s.id, s.kind, s.location_key, s.agent_id, COALESCE(a.name, ''), s.compose_project, s.compose_service, s.working_dir, s.swarm_cluster_id, s.swarm_service, s.container_name, s.image_repo, s.actual_ref, s.actual_digest, s.status, s.self, s.update_mode, s.first_seen_at, s.updated_at FROM services s LEFT JOIN agents a ON a.id = s.agent_id WHERE s.removed_at IS NULL ORDER BY s.kind = 'swarm', COALESCE(a.name, ''), s.location_key`) if err != nil { return nil, fmt.Errorf("list services: %w", err) } defer rows.Close() var out []model.Service for rows.Next() { var m model.Service var kind string if err := rows.Scan(&m.ID, &kind, &m.LocationKey, &m.AgentID, &m.AgentName, &m.ComposeProject, &m.ComposeService, &m.WorkingDir, &m.SwarmClusterID, &m.SwarmService, &m.ContainerName, &m.ImageRepo, &m.ActualRef, &m.ActualDigest, &m.Status, &m.Self, &m.UpdateMode, &m.FirstSeenAt, &m.UpdatedAt); err != nil { return nil, fmt.Errorf("scan service: %w", err) } m.Kind = protocol.Kind(kind) out = append(out, m) } return out, rows.Err() }