feat(store): drift state, move candidates and service merge (#10)

Adds services.awaiting_inventory, Service.Drifted/RunsRef/RunningRef, AcceptRunning, MoveCandidates, MergeSources and MergeService, and maps a job for a deleted service to ErrNotFound.
This commit is contained in:
2026-10-13 10:28:34 +02:00
parent 307e46e4c7
commit 061243f5da
11 changed files with 1036 additions and 14 deletions
+33 -8
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"regexp"
"time"
"github.com/jackc/pgx/v5"
@@ -55,10 +54,8 @@ ON CONFLICT (location_key) DO UPDATE SET
status = EXCLUDED.status,
self = EXCLUDED.self,
updated_at = EXCLUDED.updated_at,
removed_at = NULL`
// digestPattern is the form of a digest worth recording as a version.
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
removed_at = NULL,
awaiting_inventory = false`
func upsertService(ctx context.Context, db execer, agentID int64, clusterID string, svc protocol.Service, at time.Time) error {
image := imageref.Parse(svc.Image)
@@ -75,7 +72,7 @@ func upsertService(ctx context.Context, db execer, agentID int64, clusterID stri
if err != nil {
return fmt.Errorf("upsert service %s: %w", svc.Key(), err)
}
if digestPattern.MatchString(svc.Digest) {
if imageref.IsDigest(svc.Digest) {
// A bare image runs "latest", as jobs.FollowedRef sees it, so it
// shares its row with the polled one. repo@digest has no tag.
if image.Tag == "" && image.Digest == "" {
@@ -178,7 +175,7 @@ const serviceColumns = `s.id, s.kind, s.location_key, s.agent_id, COALESCE(a.nam
s.compose_project, s.compose_service, s.working_dir, s.config_files, s.built, s.swarm_cluster_id,
s.swarm_service, s.container_name, s.image_repo, s.desired_ref, s.actual_ref, s.actual_digest,
s.status, s.self, s.update_mode, s.available_ref, s.available_digest, s.available_source, s.available_at,
s.last_poll_error, s.last_tag_digest, s.first_seen_at, s.updated_at, s.removed_at`
s.last_poll_error, s.last_tag_digest, s.awaiting_inventory, s.first_seen_at, s.updated_at, s.removed_at`
func scanService(row pgx.Row) (model.Service, error) {
var m model.Service
@@ -187,7 +184,7 @@ func scanService(row pgx.Row) (model.Service, error) {
&m.ComposeProject, &m.ComposeService, &m.WorkingDir, &m.ConfigFiles, &m.Built, &m.SwarmClusterID,
&m.SwarmService, &m.ContainerName, &m.ImageRepo, &m.DesiredRef, &m.ActualRef, &m.ActualDigest,
&m.Status, &m.Self, &m.UpdateMode, &m.AvailableRef, &m.AvailableDigest, &m.AvailableSource, &m.AvailableAt,
&m.LastPollError, &m.LastTagDigest, &m.FirstSeenAt, &m.UpdatedAt, &m.RemovedAt)
&m.LastPollError, &m.LastTagDigest, &m.AwaitingInventory, &m.FirstSeenAt, &m.UpdatedAt, &m.RemovedAt)
m.Kind = protocol.Kind(kind)
return m, err
}
@@ -331,6 +328,34 @@ func (s *Store) SetUpdateMode(ctx context.Context, id int64, mode string) error
return nil
}
// AcceptRunning makes the running version the desired one: desired_ref =
// desired, provided the service is live, has been updated before and still
// reports actualRef and actualDigest (what the caller saw when it decided).
// It starts no job and leaves last_tag_digest, the available suggestion and
// the update mode alone. Returns model.ErrNotFound for an unknown or removed
// service and model.ErrConflict when the service was never updated or the
// running version changed meanwhile.
func (s *Store) AcceptRunning(ctx context.Context, id int64, actualRef, actualDigest, desired string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE services SET desired_ref = $4, awaiting_inventory = false
WHERE id = $1 AND removed_at IS NULL AND desired_ref <> ''
AND actual_ref = $2 AND actual_digest = $3`, id, actualRef, actualDigest, desired)
if err != nil {
return fmt.Errorf("accept running: %w", err)
}
if tag.RowsAffected() == 1 {
return nil
}
svc, err := s.GetService(ctx, id)
if err != nil {
return err
}
if svc.RemovedAt != nil {
return model.ErrNotFound
}
return model.ErrConflict
}
// GetService returns one service, including a removed one (RemovedAt set).
// Returns model.ErrNotFound if the ID does not exist.
func (s *Store) GetService(ctx context.Context, id int64) (model.Service, error) {