feat(master): record image versions and track poll suggestions in the store (#9)

This commit is contained in:
2026-10-13 08:05:21 +02:00
parent 41a619b316
commit af61f17dee
8 changed files with 403 additions and 36 deletions
@@ -0,0 +1,160 @@
//go:build integration
package store
import (
"context"
"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"
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@sha256:87a6", Digest: "sha256:87a6", Status: "running"}
bare := protocol.Service{Kind: protocol.KindContainer, ContainerName: "pg", Image: "postgres", Digest: "sha256:pg1", 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, "sha256:d1", 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, "sha256:87a6", tl[0].Digest)
b := versionsOf(t, s, "postgres")
require.Len(t, b, 1)
assert.Equal(t, "", b[0].Tag, "a bare image is recorded without a tag, not as latest")
assert.Empty(t, versionsOf(t, s, "nodigest"))
}
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@sha256:d1"}, 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)
}