feat: master/agent phase 4 — registry polling and version history (#9) (#23)
Docker Build / docker (push) Successful in 47s

This commit was merged in pull request #23.
This commit is contained in:
2026-10-13 07:02:34 +00:00
parent 41a619b316
commit 1664e47183
46 changed files with 3357 additions and 130 deletions
+18 -8
View File
@@ -189,9 +189,11 @@ func (s *Store) JobProgress(ctx context.Context, agentID, jobID int64, chunk str
// started_at (the agent's, or the existing one if the agent sent none),
// finished_at; to_ref = f.Ref when set. On success it also sets the
// service's desired_ref = f.Ref and clears its available ref when that is
// the ref the job was created for (ignoring any @digest) and was reported no
// later than the job was created (that version is now running), in the same
// transaction. Returns false if no
// the ref the job was created for and was reported no later than the job was
// created (that version is now running), in the same transaction. A job
// created for "repo:tag@digest" clears only a suggestion with that same
// digest, so rolling back to an older digest keeps a newer suggestion (and a
// suggestion without a digest, such as a webhook's). Returns false if no
// such active job belongs to agentID.
func (s *Store) FinishJob(ctx context.Context, agentID, jobID int64, f model.JobFinish) (bool, error) {
tx, err := s.pool.Begin(ctx)
@@ -226,13 +228,21 @@ func (s *Store) FinishJob(ctx context.Context, agentID, jobID int64, f model.Job
return false, fmt.Errorf("finish job: %w", err)
}
if f.Status == model.JobSucceeded {
requestedRef, _, _ = strings.Cut(requestedRef, "@")
requestedRef, requestedDigest, _ := strings.Cut(requestedRef, "@")
// A plain tag job, whoever started it, deployed the tag at the digest
// the agent pinned; remember it. Pinned and tagless picks do not.
tagDigest := ""
if _, d, _ := strings.Cut(f.Ref, "@"); requestedDigest == "" && digestPattern.MatchString(d) {
tagDigest = d
}
if _, err := tx.Exec(ctx, `
UPDATE services SET desired_ref = $2,
available_ref = CASE WHEN available_ref = $4 AND available_at <= $3 THEN '' ELSE available_ref END,
available_source = CASE WHEN available_ref = $4 AND available_at <= $3 THEN '' ELSE available_source END,
available_at = CASE WHEN available_ref = $4 AND available_at <= $3 THEN NULL ELSE available_at END
WHERE id = $1`, serviceID, f.Ref, createdAt, requestedRef); err != nil {
last_tag_digest = CASE WHEN $6 <> '' THEN $6 ELSE last_tag_digest END,
available_ref = CASE WHEN available_ref = $4 AND available_at <= $3 AND ($5 = '' OR available_digest = $5) THEN '' ELSE available_ref END,
available_digest = CASE WHEN available_ref = $4 AND available_at <= $3 AND ($5 = '' OR available_digest = $5) THEN '' ELSE available_digest END,
available_source = CASE WHEN available_ref = $4 AND available_at <= $3 AND ($5 = '' OR available_digest = $5) THEN '' ELSE available_source END,
available_at = CASE WHEN available_ref = $4 AND available_at <= $3 AND ($5 = '' OR available_digest = $5) THEN NULL ELSE available_at END
WHERE id = $1`, serviceID, f.Ref, createdAt, requestedRef, requestedDigest, tagDigest); err != nil {
return false, fmt.Errorf("finish job: desired ref: %w", err)
}
}
@@ -0,0 +1,20 @@
ALTER TABLE services DROP CONSTRAINT services_available_source_check;
ALTER TABLE services ADD CONSTRAINT services_available_source_check
CHECK (available_source IN ('', 'webhook', 'poll'));
ALTER TABLE services ADD COLUMN available_digest text NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN last_poll_error text NOT NULL DEFAULT '';
ALTER TABLE services ADD COLUMN last_tag_digest text NOT NULL DEFAULT '';
-- Every version the master has seen. '' means "no tag" / "digest unknown";
-- never NULL, so the unique constraint deduplicates.
CREATE TABLE image_versions (
id bigserial PRIMARY KEY,
repo text NOT NULL,
tag text NOT NULL DEFAULT '',
digest text NOT NULL DEFAULT '',
source text NOT NULL CHECK (source IN ('webhook', 'poll', 'running')),
first_seen_at timestamptz NOT NULL,
CONSTRAINT image_versions_tag_or_digest CHECK (tag <> '' OR digest <> ''),
CONSTRAINT image_versions_unique UNIQUE (repo, tag, digest)
);
CREATE INDEX image_versions_recent ON image_versions (repo, first_seen_at DESC, id DESC);
+117 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"regexp"
"time"
"github.com/jackc/pgx/v5"
@@ -56,7 +57,11 @@ ON CONFLICT (location_key) DO UPDATE SET
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}$`)
func upsertService(ctx context.Context, db execer, agentID int64, clusterID string, svc protocol.Service, at time.Time) error {
image := imageref.Parse(svc.Image)
clusterCol := ""
if svc.Kind == protocol.KindSwarm {
clusterCol = clusterID
@@ -65,11 +70,34 @@ func upsertService(ctx context.Context, db execer, agentID int64, clusterID stri
string(svc.Kind), model.LocationKey(agentID, clusterID, svc.Key()), agentID,
svc.ComposeProject, svc.ComposeService, svc.WorkingDir, svc.ConfigFiles, svc.Built,
clusterCol, svc.SwarmService, svc.ContainerName,
imageref.Parse(svc.Image).Repo, svc.Image, svc.Digest,
image.Repo, svc.Image, svc.Digest,
svc.Status, svc.Self, at)
if err != nil {
return fmt.Errorf("upsert service %s: %w", svc.Key(), err)
}
if digestPattern.MatchString(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 == "" {
image.Tag = "latest"
}
if err := insertVersion(ctx, db, image.Repo, image.Tag, svc.Digest, model.SourceRunning, at); err != nil {
return err
}
}
return nil
}
// insertVersion records a version in image_versions unless the same
// (repo, tag, digest) is already there; the first sighting keeps its source
// and first_seen_at.
func insertVersion(ctx context.Context, db execer, repo, tag, digest, source string, at time.Time) error {
if _, err := db.Exec(ctx, `
INSERT INTO image_versions (repo, tag, digest, source, first_seen_at)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING`,
repo, tag, digest, source, at); err != nil {
return fmt.Errorf("record version %s: %w", imageref.Ref{Repo: repo, Tag: tag, Digest: digest}, err)
}
return nil
}
@@ -149,8 +177,8 @@ func (s *Store) RemoveService(ctx context.Context, agentID int64, clusterID, age
const serviceColumns = `s.id, s.kind, s.location_key, s.agent_id, COALESCE(a.name, ''),
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_source, s.available_at,
s.first_seen_at, s.updated_at, s.removed_at`
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`
func scanService(row pgx.Row) (model.Service, error) {
var m model.Service
@@ -158,8 +186,8 @@ func scanService(row pgx.Row) (model.Service, error) {
err := row.Scan(&m.ID, &kind, &m.LocationKey, &m.AgentID, &m.AgentName,
&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.AvailableSource, &m.AvailableAt,
&m.FirstSeenAt, &m.UpdatedAt, &m.RemovedAt)
&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.Kind = protocol.Kind(kind)
return m, err
}
@@ -198,16 +226,96 @@ func (s *Store) queryServices(ctx context.Context, what, extraWhere string, args
}
// SetAvailable records a newer ref for the service, replacing any earlier
// suggestion.
// suggestion, and records the version in image_versions in the same
// transaction (the tag is parsed from a.Ref).
func (s *Store) SetAvailable(ctx context.Context, id int64, a model.Available) error {
if _, err := s.pool.Exec(ctx, `
UPDATE services SET available_ref = $2, available_source = $3, available_at = $4
WHERE id = $1`, id, a.Ref, a.Source, a.At); err != nil {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("set available: begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
UPDATE services SET available_ref = $2, available_digest = $3, available_source = $4, available_at = $5
WHERE id = $1`, id, a.Ref, a.Digest, a.Source, a.At); err != nil {
return fmt.Errorf("set available: %w", err)
}
ref := imageref.Parse(a.Ref)
if err := insertVersion(ctx, tx, ref.Repo, ref.Tag, a.Digest, a.Source, a.At); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("set available: 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 {
return nil
}
if _, err := s.pool.Exec(ctx,
`UPDATE services SET last_poll_error = $2 WHERE id = ANY($1)`, ids, msg); err != nil {
return fmt.Errorf("set poll error: %w", err)
}
return nil
}
// SetLastTagDigest sets last_tag_digest; see model.Service.LastTagDigest. The
// poller calls it, and FinishJob sets the same column after a tag job.
// Inventory writes never change it.
func (s *Store) SetLastTagDigest(ctx context.Context, id int64, digest string) error {
if _, err := s.pool.Exec(ctx,
`UPDATE services SET last_tag_digest = $2 WHERE id = $1`, id, digest); err != nil {
return fmt.Errorf("set last tag digest: %w", err)
}
return nil
}
// RecentVersions returns up to limit versions of repo, most recently first
// seen first. A version without a digest is left out when a version with the
// same tag and a digest was first seen at or after it.
func (s *Store) RecentVersions(ctx context.Context, repo string, limit int) ([]model.ImageVersion, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, repo, tag, digest, source, first_seen_at FROM image_versions v
WHERE v.repo = $1
AND NOT (v.digest = '' AND EXISTS (
SELECT 1 FROM image_versions d
WHERE d.repo = v.repo AND d.tag = v.tag AND d.digest <> ''
AND d.first_seen_at >= v.first_seen_at))
ORDER BY v.first_seen_at DESC, v.id DESC
LIMIT $2`, repo, limit)
if err != nil {
return nil, fmt.Errorf("recent versions: %w", err)
}
defer rows.Close()
var out []model.ImageVersion
for rows.Next() {
var v model.ImageVersion
if err := rows.Scan(&v.ID, &v.Repo, &v.Tag, &v.Digest, &v.Source, &v.FirstSeenAt); err != nil {
return nil, fmt.Errorf("scan version: %w", err)
}
out = append(out, v)
}
return out, rows.Err()
}
// GetImageVersion returns one version. Returns model.ErrNotFound if the ID
// does not exist.
func (s *Store) GetImageVersion(ctx context.Context, id int64) (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 id = $1`, id,
).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 image 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.
@@ -17,9 +17,9 @@ import (
var (
t0 = time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
slusk = protocol.Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", WorkingDir: "/srv/slusk", Image: "gitea.shcizo.se/shcizo/slusk:1.4.2", Digest: "sha256:d1", Status: "running"}
slusk = protocol.Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", WorkingDir: "/srv/slusk", Image: "gitea.shcizo.se/shcizo/slusk:1.4.2", Digest: digD1, Status: "running"}
adhoc = protocol.Service{Kind: protocol.KindContainer, ContainerName: "adhoc", Image: "redis:alpine", Status: "stopped"}
gitea = protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", Image: "gitea/gitea:1.27.3@sha256:87a6", Digest: "sha256:87a6", Status: "running"}
gitea = protocol.Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", Image: "gitea/gitea:1.27.3@sha256:87a6", Digest: digGitea, Status: "running"}
worker = protocol.Hello{Hostname: "node-2"}
manager = protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "c1"}
)
@@ -53,7 +53,7 @@ func TestApplySnapshot_InsertsAsManualWithAgentName(t *testing.T) {
assert.Equal(t, "node-2", got[0].AgentName)
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk", got[0].ImageRepo)
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:1.4.2", got[0].ActualRef)
assert.Equal(t, "sha256:d1", got[0].ActualDigest)
assert.Equal(t, digD1, got[0].ActualDigest)
assert.Equal(t, "compose|"+itoa(id)+"|slusk|web", got[0].LocationKey)
}
@@ -0,0 +1,216 @@
//go:build integration
package store
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/protocol"
)
const sluskRepo = "gitea.shcizo.se/shcizo/slusk"
// Digests of the fixture services; only well-formed ones are recorded as versions.
var (
digD1 = "sha256:" + strings.Repeat("d", 63) + "1"
digGitea = "sha256:" + strings.Repeat("a", 63) + "1"
digPg = "sha256:" + strings.Repeat("b", 63) + "1"
)
func versionsOf(t *testing.T, s *Store, repo string) []model.ImageVersion {
t.Helper()
got, err := s.RecentVersions(context.Background(), repo, 100)
require.NoError(t, err)
return got
}
func TestSetAvailable_RecordsVersion(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", Digest: "sha256:n1", Source: model.SourcePoll, At: t0}))
got, err := s.GetService(ctx, svc)
require.NoError(t, err)
assert.Equal(t, sluskRepo+":1.5.0", got.AvailableRef)
assert.Equal(t, "sha256:n1", got.AvailableDigest)
assert.Equal(t, model.SourcePoll, got.AvailableSource)
vs := versionsOf(t, s, sluskRepo)
var found model.ImageVersion
for _, v := range vs {
if v.Digest == "sha256:n1" {
found = v
}
}
require.NotZero(t, found.ID)
assert.Equal(t, "1.5.0", found.Tag)
assert.Equal(t, model.SourcePoll, found.Source)
// Seeing the same version again keeps the first sighting.
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":1.5.0", Digest: "sha256:n1", Source: model.SourceWebhook, At: t0.Add(time.Hour)}))
again, err := s.GetImageVersion(ctx, found.ID)
require.NoError(t, err)
assert.Equal(t, model.SourcePoll, again.Source)
assert.True(t, t0.Equal(again.FirstSeenAt))
assert.Len(t, versionsOf(t, s, sluskRepo), len(vs))
}
func TestSnapshotRecordsRunningVersions(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
id := newAgent(t, s, "node-2")
tagless := protocol.Service{Kind: protocol.KindSwarm, SwarmService: "g_g", Image: "gitea/gitea@" + digGitea, Digest: digGitea, Status: "running"}
bare := protocol.Service{Kind: protocol.KindContainer, ContainerName: "pg", Image: "postgres", Digest: digPg, Status: "running"}
nodigest := protocol.Service{Kind: protocol.KindContainer, ContainerName: "nd", Image: "nodigest:1", Status: "running"}
hello := protocol.Hello{Hostname: "node-2", SwarmManager: true, SwarmClusterID: "c1"}
svcs := []protocol.Service{slusk, tagless, bare, nodigest}
require.NoError(t, s.ApplySnapshot(ctx, id, hello, svcs, t0))
require.NoError(t, s.ApplySnapshot(ctx, id, hello, svcs, t0.Add(time.Hour)))
got := versionsOf(t, s, sluskRepo)
require.Len(t, got, 1)
assert.Equal(t, "1.4.2", got[0].Tag)
assert.Equal(t, digD1, got[0].Digest)
assert.Equal(t, model.SourceRunning, got[0].Source)
assert.True(t, t0.Equal(got[0].FirstSeenAt), "re-snapshot does not bump first_seen_at")
tl := versionsOf(t, s, "gitea/gitea")
require.Len(t, tl, 1)
assert.Equal(t, "", tl[0].Tag)
assert.Equal(t, digGitea, tl[0].Digest)
b := versionsOf(t, s, "postgres")
require.Len(t, b, 1)
assert.Equal(t, "latest", b[0].Tag, "a bare image runs latest, like the followed ref")
assert.Empty(t, versionsOf(t, s, "nodigest"))
}
func TestSnapshotRecordsOnlyWellFormedDigests(t *testing.T) {
s := openTestStore(t)
id := newAgent(t, s, "node-2")
bad := protocol.Service{Kind: protocol.KindContainer, ContainerName: "bad", Image: "bad:1", Digest: "sha256:short", Status: "running"}
upper := protocol.Service{Kind: protocol.KindContainer, ContainerName: "up", Image: "up:1", Digest: "sha256:" + strings.Repeat("A", 64), Status: "running"}
require.NoError(t, s.ApplySnapshot(context.Background(), id, protocol.Hello{Hostname: "node-2"}, []protocol.Service{bad, upper}, t0))
assert.Empty(t, versionsOf(t, s, "bad"))
assert.Empty(t, versionsOf(t, s, "up"))
}
func TestRecentVersions(t *testing.T) {
ctx := context.Background()
t.Run("order, limit and repo filter", func(t *testing.T) {
s := openTestStore(t)
_, svc := jobFixture(t, s, "a")
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: "other/repo:x", Digest: "sha256:ox", Source: model.SourcePoll, At: t0}))
// 12 versions of the service's repo, one per minute.
for i := 0; i < 12; i++ {
tag := "t" + string(rune('a'+i))
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":" + tag, Digest: "sha256:" + tag, Source: model.SourcePoll, At: t0.Add(time.Duration(i+1) * time.Minute)}))
}
got, err := s.RecentVersions(ctx, sluskRepo, 10)
require.NoError(t, err)
require.Len(t, got, 10)
assert.Equal(t, "tl", got[0].Tag, "newest first")
assert.Equal(t, "tc", got[9].Tag)
for _, v := range got {
assert.Equal(t, sluskRepo, v.Repo)
}
})
t.Run("digest-less row is hidden once its tag has a digest", func(t *testing.T) {
s := openTestStore(t)
_, svc := jobFixture(t, s, "a")
add := func(tag, digest string, at time.Time) {
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":" + tag, Digest: digest, Source: model.SourceWebhook, At: at}))
}
add("main", "", t0.Add(time.Minute))
add("solo", "", t0.Add(2*time.Minute))
require.Len(t, versionsOf(t, s, sluskRepo), 3, "running 1.4.2 plus two digest-less rows")
add("main", "sha256:m1", t0.Add(3*time.Minute))
var refs []string
for _, v := range versionsOf(t, s, sluskRepo) {
refs = append(refs, v.Ref())
}
assert.Equal(t, []string{sluskRepo + ":main@sha256:m1", sluskRepo + ":solo", sluskRepo + ":1.4.2@" + digD1}, refs)
})
t.Run("digest-less row newer than the digest row of its tag is shown", func(t *testing.T) {
s := openTestStore(t)
_, svc := jobFixture(t, s, "a")
add := func(tag, digest string, at time.Time) {
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":" + tag, Digest: digest, Source: model.SourceWebhook, At: at}))
}
add("main", "sha256:m1", t0.Add(time.Minute))
add("main", "", 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 + ":main", sluskRepo + ":main@sha256:m1", sluskRepo + ":1.4.2@" + digD1}, refs)
})
}
func TestGetImageVersion_NotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.GetImageVersion(context.Background(), 99999)
assert.ErrorIs(t, err, model.ErrNotFound)
}
func TestSetPollError(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
agent, svc := jobFixture(t, s, "a")
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskRepo + ":1.5.0", Digest: "sha256:n1", Source: model.SourcePoll, At: t0}))
require.NoError(t, s.SetPollError(ctx, []int64{svc}, "boom"))
require.NoError(t, s.SetPollError(ctx, nil, "ignored"))
got, err := s.GetService(ctx, svc)
require.NoError(t, err)
assert.Equal(t, "boom", got.LastPollError)
require.NoError(t, s.UpsertService(ctx, agent, "", slusk, t0.Add(time.Hour)))
got, err = s.GetService(ctx, svc)
require.NoError(t, err)
assert.Equal(t, "boom", got.LastPollError, "inventory writes do not touch the poll error")
assert.Equal(t, "sha256:n1", got.AvailableDigest, "nor the suggestion")
require.NoError(t, s.SetPollError(ctx, []int64{svc}, ""))
got, err = s.GetService(ctx, svc)
require.NoError(t, err)
assert.Empty(t, got.LastPollError)
}
func TestSetLastTagDigest(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
agent, svc := jobFixture(t, s, "a")
got, err := s.GetService(ctx, svc)
require.NoError(t, err)
assert.Empty(t, got.LastTagDigest)
require.NoError(t, s.SetLastTagDigest(ctx, svc, "sha256:n1"))
require.NoError(t, s.UpsertService(ctx, agent, "", slusk, t0.Add(time.Hour)))
got, err = s.GetService(ctx, svc)
require.NoError(t, err)
assert.Equal(t, "sha256:n1", got.LastTagDigest, "inventory writes do not touch it")
list, err := s.ListServices(ctx)
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, "sha256:n1", list[0].LastTagDigest)
}
@@ -4,6 +4,7 @@ package store
import (
"context"
"strings"
"testing"
"time"
@@ -91,27 +92,34 @@ func TestUpsertKeepsUpdateMode(t *testing.T) {
func TestFinishJob_ClearsAvailableOnlyWhenThatVersionWasDeployed(t *testing.T) {
succeed := model.JobFinish{Status: model.JobSucceeded, Ref: "r:2@sha256:d", StartedAt: t0, FinishedAt: t0}
tests := []struct {
name string
availableRef string
availableAt time.Time // job is created at t0 for r:2
finish model.JobFinish
wantCleared bool
name string
availableRef string
availableDigest string
availableAt time.Time // the job is created at t0
toRef string // the job's to_ref as created
finish model.JobFinish
wantCleared bool
}{
{"same ref, suggestion before the job", "r:2", t0.Add(-time.Minute), succeed, true},
{"same ref, suggestion at the same instant", "r:2", t0, succeed, true},
{"same ref, suggestion after the job was created", "r:2", t0.Add(time.Minute), succeed, false},
{"different ref keeps the suggestion", "r:3", t0.Add(-time.Minute), succeed, false},
{"failed job keeps the suggestion", "r:2", t0.Add(-time.Minute),
{"same ref, suggestion before the job", "r:2", "", t0.Add(-time.Minute), "r:2", succeed, true},
{"same ref, suggestion at the same instant", "r:2", "", t0, "r:2", succeed, true},
{"same ref, suggestion after the job was created", "r:2", "", t0.Add(time.Minute), "r:2", succeed, false},
{"different ref keeps the suggestion", "r:3", "", t0.Add(-time.Minute), "r:2", succeed, false},
{"failed job keeps the suggestion", "r:2", "", t0.Add(-time.Minute), "r:2",
model.JobFinish{Status: model.JobFailed, Error: "boom", StartedAt: t0, FinishedAt: t0}, false},
{"tag job clears a poll suggestion with a digest", "r:2", "sha256:new", t0.Add(-time.Minute), "r:2", succeed, true},
{"pick of the suggested digest clears it", "r:2", "sha256:new", t0.Add(-time.Minute), "r:2@sha256:new", succeed, true},
{"rollback to an older digest keeps the newer suggestion", "r:2", "sha256:new", t0.Add(-time.Minute), "r:2@sha256:old", succeed, false},
{"digest pick keeps a suggestion without digest", "r:2", "", t0.Add(-time.Minute), "r:2@sha256:old", succeed, false},
{"tagless pick keeps the suggestion", "r:2", "sha256:new", t0.Add(-time.Minute), "r@sha256:old", succeed, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
agent, svc := jobFixture(t, s, "a")
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: tt.availableRef, Source: model.SourceWebhook, At: tt.availableAt}))
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: tt.availableRef, Digest: tt.availableDigest, Source: model.SourcePoll, At: tt.availableAt}))
nj := newJob(svc, agent)
nj.ToRef = "r:2"
nj.ToRef = tt.toRef
j, err := s.CreateJob(ctx, nj)
require.NoError(t, err)
@@ -123,11 +131,13 @@ func TestFinishJob_ClearsAvailableOnlyWhenThatVersionWasDeployed(t *testing.T) {
require.NoError(t, err)
if tt.wantCleared {
assert.Empty(t, got.AvailableRef)
assert.Empty(t, got.AvailableDigest)
assert.Empty(t, got.AvailableSource)
assert.Nil(t, got.AvailableAt)
} else {
assert.Equal(t, tt.availableRef, got.AvailableRef)
assert.Equal(t, model.SourceWebhook, got.AvailableSource)
assert.Equal(t, tt.availableDigest, got.AvailableDigest)
assert.Equal(t, model.SourcePoll, got.AvailableSource)
assert.NotNil(t, got.AvailableAt)
}
})
@@ -151,3 +161,48 @@ func TestFinishJob_WithoutAvailableLeavesItEmpty(t *testing.T) {
assert.Nil(t, got.AvailableAt)
assert.Equal(t, "r:1@sha256:d", got.DesiredRef)
}
func TestFinishJob_RemembersTheDeployedDigestOfATagJob(t *testing.T) {
good := "sha256:" + strings.Repeat("a", 64)
old := "sha256:" + strings.Repeat("b", 64)
ok := func(ref string) model.JobFinish {
return model.JobFinish{Status: model.JobSucceeded, Ref: ref, StartedAt: t0, FinishedAt: t0}
}
tests := []struct {
name string
trigger model.JobTrigger
toRef string
finish model.JobFinish
want string
}{
{"webhook tag job", model.TriggerWebhook, "r:2", ok("r:2@" + good), good},
{"manual tag job", model.TriggerManual, "r:2", ok("r:2@" + good), good},
{"poll tag job", model.TriggerPoll, "r:2", ok("r:2@" + good), good},
{"pinned pick leaves it", model.TriggerManual, "r:2@" + old, ok("r:2@" + old), "sha256:before"},
{"tagless pick leaves it", model.TriggerManual, "r@" + old, ok("r@" + old), "sha256:before"},
{"failed job leaves it", model.TriggerWebhook, "r:2",
model.JobFinish{Status: model.JobFailed, Error: "boom", Ref: "r:2@" + good, StartedAt: t0, FinishedAt: t0}, "sha256:before"},
{"malformed digest leaves it", model.TriggerWebhook, "r:2", ok("r:2@sha256:d"), "sha256:before"},
{"no pinned ref leaves it", model.TriggerWebhook, "r:2", ok(""), "sha256:before"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
agent, svc := jobFixture(t, s, "a")
require.NoError(t, s.SetLastTagDigest(ctx, svc, "sha256:before"))
nj := newJob(svc, agent)
nj.ToRef, nj.Trigger = tt.toRef, tt.trigger
j, err := s.CreateJob(ctx, nj)
require.NoError(t, err)
done, err := s.FinishJob(ctx, agent, j.ID, tt.finish)
require.NoError(t, err)
require.True(t, done)
got, err := s.GetService(ctx, svc)
require.NoError(t, err)
assert.Equal(t, tt.want, got.LastTagDigest)
})
}
}