feat(master): suggest newer version tags for third-party images (#11)

This commit is contained in:
2026-10-13 11:50:22 +02:00
parent ec28d1e027
commit c9cb1103ad
26 changed files with 1013 additions and 44 deletions
@@ -275,6 +275,7 @@ func TestMergeService_MovesHistoryModeAndDesiredToTheNewLocation(t *testing.T) {
require.NoError(t, s.SetLastTagDigest(ctx, f.to, "sha256:fromto"))
require.NoError(t, s.SetAvailable(ctx, f.to, model.Available{Ref: sluskDrift + ":3", Digest: digNew, Source: model.SourcePoll, At: t0}))
require.NoError(t, s.SetPollError(ctx, []int64{f.to}, "registry down"))
require.NoError(t, s.SetNewer(ctx, f.to, sluskDrift+":3.1", t0))
// A job on the old row is finished, and awaiting_inventory is set on both.
before, err := s.GetService(ctx, f.from)
require.NoError(t, err)
@@ -297,6 +298,9 @@ func TestMergeService_MovesHistoryModeAndDesiredToTheNewLocation(t *testing.T) {
assert.Equal(t, sluskDrift+":3", got.AvailableRef)
assert.Equal(t, digNew, got.AvailableDigest)
assert.Equal(t, "registry down", got.LastPollError)
assert.Equal(t, sluskDrift+":3.1", got.NewerRef, "newer tag from the duplicate")
require.NotNil(t, got.NewerAt)
assert.True(t, t0.Equal(*got.NewerAt))
assert.Equal(t, before.LastTagDigest, got.LastTagDigest, "an existing last_tag_digest wins")
_, err = s.GetService(ctx, f.to)
+6 -6
View File
@@ -106,8 +106,8 @@ type mergeRow struct {
// MergeService moves the live service toID onto the removed service fromID:
// the old row keeps its id, first_seen_at, update mode and desired ref, takes
// the location and inventory of the live row (and its available suggestion),
// becomes live, and the live row is deleted. The jobs of the live row move to
// the location and inventory of the live row (and its available and newer-tag
// suggestions), becomes live, and the live row is deleted. The jobs of the live row move to
// the old one. This is the only place a services row is deleted.
//
// It returns *model.MergeRefusedError when the pair cannot be merged,
@@ -195,12 +195,12 @@ func (s *Store) MergeService(ctx context.Context, fromID, toID int64) error {
SELECT agent_id, location_key, compose_project, compose_service, working_dir, config_files, built,
swarm_cluster_id, swarm_service, container_name, image_repo, actual_ref, actual_digest, status, self,
updated_at, available_ref, available_digest, available_source, available_at, last_poll_error,
last_tag_digest
last_tag_digest, newer_ref, newer_at
FROM services WHERE id = $1`, toID).Scan(
&t.AgentID, &t.LocationKey, &t.ComposeProject, &t.ComposeService, &t.WorkingDir, &t.ConfigFiles, &t.Built,
&t.SwarmClusterID, &t.SwarmService, &t.ContainerName, &t.ImageRepo, &t.ActualRef, &t.ActualDigest, &t.Status, &t.Self,
&t.UpdatedAt, &t.AvailableRef, &t.AvailableDigest, &t.AvailableSource, &t.AvailableAt, &t.LastPollError,
&lastTagDigest)
&lastTagDigest, &t.NewerRef, &t.NewerAt)
if err != nil {
return fmt.Errorf("merge: read new service: %w", err)
}
@@ -213,7 +213,7 @@ func (s *Store) MergeService(ctx context.Context, fromID, toID int64) error {
config_files = $7, built = $8, swarm_cluster_id = $9, swarm_service = $10, container_name = $11,
image_repo = $12, actual_ref = $13, actual_digest = $14, status = $15, self = $16, updated_at = $17,
available_ref = $18, available_digest = $19, available_source = $20, available_at = $21,
last_poll_error = $22,
last_poll_error = $22, newer_ref = $24, newer_at = $25,
last_tag_digest = CASE WHEN last_tag_digest <> '' THEN last_tag_digest ELSE $23 END,
removed_at = NULL, awaiting_inventory = false
WHERE id = $1`,
@@ -221,7 +221,7 @@ func (s *Store) MergeService(ctx context.Context, fromID, toID int64) error {
t.ConfigFiles, t.Built, t.SwarmClusterID, t.SwarmService, t.ContainerName,
t.ImageRepo, t.ActualRef, t.ActualDigest, t.Status, t.Self, t.UpdatedAt,
t.AvailableRef, t.AvailableDigest, t.AvailableSource, t.AvailableAt,
t.LastPollError, lastTagDigest); err != nil {
t.LastPollError, lastTagDigest, t.NewerRef, t.NewerAt); err != nil {
return fmt.Errorf("merge: update old service: %w", err)
}
if err := tx.Commit(ctx); err != nil {
@@ -0,0 +1,9 @@
-- Newer version tag of the followed tag, found in the registry's tag list.
-- A suggestion only: nothing deploys it automatically.
ALTER TABLE services ADD COLUMN newer_ref text NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN newer_at timestamptz;
-- A suggested tag is recorded in image_versions with source 'tags'.
ALTER TABLE image_versions DROP CONSTRAINT image_versions_source_check;
ALTER TABLE image_versions ADD CONSTRAINT image_versions_source_check
CHECK (source IN ('webhook', 'poll', 'running', 'tags'));
+46 -2
View File
@@ -175,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.awaiting_inventory, s.first_seen_at, s.updated_at, s.removed_at`
s.last_poll_error, s.newer_ref, s.newer_at, 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
@@ -184,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.AwaitingInventory, &m.FirstSeenAt, &m.UpdatedAt, &m.RemovedAt)
&m.LastPollError, &m.NewerRef, &m.NewerAt, &m.LastTagDigest, &m.AwaitingInventory, &m.FirstSeenAt, &m.UpdatedAt, &m.RemovedAt)
m.Kind = protocol.Kind(kind)
return m, err
}
@@ -246,6 +246,33 @@ func (s *Store) SetAvailable(ctx context.Context, id int64, a model.Available) e
return nil
}
// SetNewer sets the service's newer-tag suggestion (model.Service.NewerRef);
// ref "" clears it. A non-empty ref is also recorded in image_versions with
// source "tags", in the same transaction, unless that (repo, tag) is already
// there. The digest is unknown, so the row has none.
func (s *Store) SetNewer(ctx context.Context, id int64, ref string, at time.Time) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("set newer: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
UPDATE services SET newer_ref = $2, newer_at = CASE WHEN $2 = '' THEN NULL ELSE $3::timestamptz END
WHERE id = $1`, id, ref, at); err != nil {
return fmt.Errorf("set newer: %w", err)
}
if ref != "" {
p := imageref.Parse(ref)
if err := insertVersion(ctx, tx, p.Repo, p.Tag, "", model.SourceTags, at); err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("set newer: commit: %w", err)
}
return nil
}
// SetPollError sets last_poll_error on the given services; msg "" clears it.
func (s *Store) SetPollError(ctx context.Context, ids []int64, msg string) error {
if len(ids) == 0 {
@@ -313,6 +340,23 @@ func (s *Store) GetImageVersion(ctx context.Context, id int64) (model.ImageVersi
return v, nil
}
// GetTagVersion returns the digest-less version row with an empty digest, which
// SetNewer and the webhook create. Returns model.ErrNotFound if there is none.
func (s *Store) GetTagVersion(ctx context.Context, repo, tag string) (model.ImageVersion, error) {
var v model.ImageVersion
err := s.pool.QueryRow(ctx, `
SELECT id, repo, tag, digest, source, first_seen_at FROM image_versions
WHERE repo = $1 AND tag = $2 AND digest = ''`, repo, tag,
).Scan(&v.ID, &v.Repo, &v.Tag, &v.Digest, &v.Source, &v.FirstSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return model.ImageVersion{}, model.ErrNotFound
}
if err != nil {
return model.ImageVersion{}, fmt.Errorf("get tag version: %w", err)
}
return v, nil
}
// SetUpdateMode sets the service's update mode (model.ModeManual or
// model.ModeAuto). Returns model.ErrNotFound if the service does not exist
// or is marked removed.
@@ -214,3 +214,79 @@ func TestSetLastTagDigest(t *testing.T) {
require.Len(t, list, 1)
assert.Equal(t, "sha256:n1", list[0].LastTagDigest)
}
func TestSetNewer_SetsAndClears(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
_, svc := jobFixture(t, s, "a")
require.NoError(t, s.SetNewer(ctx, svc, sluskRepo+":1.5.0", t0))
got, err := s.GetService(ctx, svc)
require.NoError(t, err)
assert.Equal(t, sluskRepo+":1.5.0", got.NewerRef)
require.NotNil(t, got.NewerAt)
assert.True(t, t0.Equal(*got.NewerAt))
v, err := s.GetTagVersion(ctx, sluskRepo, "1.5.0")
require.NoError(t, err)
assert.Equal(t, model.SourceTags, v.Source)
assert.Empty(t, v.Digest)
assert.True(t, t0.Equal(v.FirstSeenAt))
require.NoError(t, s.SetNewer(ctx, svc, "", t0.Add(time.Hour)))
got, err = s.GetService(ctx, svc)
require.NoError(t, err)
assert.Empty(t, got.NewerRef)
assert.Nil(t, got.NewerAt)
_, err = s.GetTagVersion(ctx, sluskRepo, "1.5.0")
assert.NoError(t, err, "clearing the suggestion keeps the version")
}
func TestSetNewer_KeepsEarlierVersionSource(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
_, svc := jobFixture(t, s, "a")
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":1.5.0", Source: model.SourceWebhook, At: t0}))
require.NoError(t, s.SetNewer(ctx, svc, sluskRepo+":1.5.0", t0.Add(time.Hour)))
v, err := s.GetTagVersion(ctx, sluskRepo, "1.5.0")
require.NoError(t, err)
assert.Equal(t, model.SourceWebhook, v.Source)
assert.True(t, t0.Equal(v.FirstSeenAt))
}
func TestRecentVersions_TagsRowHiddenOnceDigestKnown(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
_, svc := jobFixture(t, s, "a")
require.NoError(t, s.SetNewer(ctx, svc, sluskRepo+":1.5.0", t0.Add(time.Minute)))
require.Len(t, versionsOf(t, s, sluskRepo), 2, "running 1.4.2 and the tags row")
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":1.5.0", Digest: "sha256:n1", Source: model.SourcePoll, At: t0.Add(2 * time.Minute)}))
var refs []string
for _, v := range versionsOf(t, s, sluskRepo) {
refs = append(refs, v.Ref())
}
assert.Equal(t, []string{sluskRepo + ":1.5.0@sha256:n1", sluskRepo + ":1.4.2@" + digD1}, refs)
}
func TestGetTagVersion(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
_, svc := jobFixture(t, s, "a")
require.NoError(t, s.SetNewer(ctx, svc, sluskRepo+":1.5.0", t0))
got, err := s.GetTagVersion(ctx, sluskRepo, "1.5.0")
require.NoError(t, err)
assert.Equal(t, sluskRepo+":1.5.0", got.Ref())
_, err = s.GetTagVersion(ctx, sluskRepo, "9.9.9")
assert.ErrorIs(t, err, model.ErrNotFound)
_, err = s.GetTagVersion(ctx, "other/repo", "1.5.0")
assert.ErrorIs(t, err, model.ErrNotFound)
_, err = s.GetTagVersion(ctx, sluskRepo, "1.4.2")
assert.ErrorIs(t, err, model.ErrNotFound, "a row with a digest is not a tag version")
}