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
+142 -16
View File
@@ -1,6 +1,8 @@
// Package poller checks the registry for new digests of the tags the
// services follow. A new digest becomes the service's available version, and
// a service in auto mode gets an update job.
// a service in auto mode gets an update job. It also lists the tags of the
// repos whose followed tag looks like a version and suggests a newer one;
// that suggestion never starts a job.
package poller
import (
@@ -11,14 +13,19 @@ import (
"sync"
"time"
"github.com/shcizo/package-updater/internal/imageref"
"github.com/shcizo/package-updater/internal/master/events"
"github.com/shcizo/package-updater/internal/master/jobs"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/master/tagver"
)
// lookupTimeout bounds one registry lookup.
const lookupTimeout = 30 * time.Second
// listTimeout bounds one tag listing, which may take several requests.
const listTimeout = 60 * time.Second
// maxErrorBytes caps the registry error stored in services.last_poll_error.
const maxErrorBytes = 500
@@ -28,6 +35,7 @@ type Store interface {
GetService(ctx context.Context, id int64) (model.Service, error)
SetAvailable(ctx context.Context, id int64, a model.Available) error
SetLastTagDigest(ctx context.Context, id int64, digest string) error
SetNewer(ctx context.Context, id int64, ref string, at time.Time) error
SetPollError(ctx context.Context, ids []int64, msg string) error
}
@@ -36,10 +44,11 @@ type Jobs interface {
Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error)
}
// Registry looks up the digest a "repo:tag" ref points at. *registry.Client
// satisfies it.
// Registry looks up the digest a "repo:tag" ref points at and lists the tags
// of a repo. *registry.Client satisfies it.
type Registry interface {
Digest(ctx context.Context, ref string) (string, error)
Tags(ctx context.Context, repo string) ([]string, error)
}
// Publisher receives change signals. *events.Broker satisfies it.
@@ -49,7 +58,7 @@ type Publisher interface{ Publish(t events.Topic) }
type Config struct {
Interval time.Duration // time between the end of a cycle and the start of the next (POLL_INTERVAL)
FirstDelay time.Duration // wait before the first cycle; 1 minute in production
Workers int // registry lookups in flight at once; 4 in production
Workers int // registry requests in flight at once; 4 in production
}
// Poller runs polling cycles.
@@ -112,13 +121,16 @@ type lookup struct {
// cycleStats is what one cycle did, for the poll_cycle log line.
type cycleStats struct {
refs, ok, failed, suggested, jobs, skipped int
reposListed, tagsFailed, newer int
wrote bool
}
// Cycle runs one poll over all services. It looks up every distinct followed
// ref once, with at most Config.Workers lookups at a time, then records the
// results. Writing results and starting jobs is not cut short by ctx ending
// during the lookups, so a job that was marked is always started.
// results. Then it lists the tags of each repo that has a service following a
// version-like tag, once per repo, and records newer tags. Writing results and
// starting jobs is not cut short by ctx ending during the lookups, so a job
// that was marked is always started.
func (p *Poller) Cycle(ctx context.Context) {
began := p.now()
svcs, err := p.store.ListServices(ctx)
@@ -152,19 +164,20 @@ func (p *Poller) Cycle(ctx context.Context) {
}
p.record(write, l, byRef[l.ref], &stats)
}
p.syncNewer(ctx, write, svcs, &stats)
if stats.wrote {
p.pub.Publish(events.Services)
}
p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed,
"suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped,
"repos_listed", stats.reposListed, "tags_failed", stats.tagsFailed, "newer", stats.newer,
"duration_ms", p.now().Sub(began).Milliseconds())
}
// lookupAll asks the registry for every ref, Config.Workers at a time. It
// returns the answers in ref order and stops starting new lookups once ctx
// is done; refs never looked up are missing from the result.
func (p *Poller) lookupAll(ctx context.Context, refs []string) []lookup {
results := make([]*lookup, len(refs))
// forEach calls fn(i) for i in [0, n), Config.Workers at a time, and stops
// starting new calls once ctx is done. It returns when all started calls
// have returned.
func (p *Poller) forEach(ctx context.Context, n int, fn func(i int)) {
next := make(chan int)
var wg sync.WaitGroup
for w := 0; w < p.cfg.Workers; w++ {
@@ -172,15 +185,12 @@ func (p *Poller) lookupAll(ctx context.Context, refs []string) []lookup {
go func() {
defer wg.Done()
for i := range next {
lctx, cancel := context.WithTimeout(ctx, lookupTimeout)
d, err := p.reg.Digest(lctx, refs[i])
cancel()
results[i] = &lookup{ref: refs[i], digest: d, err: err}
fn(i)
}
}()
}
feed:
for i := range refs {
for i := 0; i < n; i++ {
select {
case next <- i:
case <-ctx.Done():
@@ -189,6 +199,19 @@ feed:
}
close(next)
wg.Wait()
}
// lookupAll asks the registry for every ref, Config.Workers at a time. It
// returns the answers in ref order and stops starting new lookups once ctx
// is done; refs never looked up are missing from the result.
func (p *Poller) lookupAll(ctx context.Context, refs []string) []lookup {
results := make([]*lookup, len(refs))
p.forEach(ctx, len(refs), func(i int) {
lctx, cancel := context.WithTimeout(ctx, lookupTimeout)
d, err := p.reg.Digest(lctx, refs[i])
cancel()
results[i] = &lookup{ref: refs[i], digest: d, err: err}
})
var out []lookup
for _, r := range results {
if r != nil {
@@ -198,6 +221,109 @@ feed:
return out
}
// tagList is the registry's answer for the tags of one repo.
type tagList struct {
repo string
tags []string
err error
}
// listAll lists the tags of every repo like lookupAll looks up refs.
func (p *Poller) listAll(ctx context.Context, repos []string) []tagList {
results := make([]*tagList, len(repos))
p.forEach(ctx, len(repos), func(i int) {
lctx, cancel := context.WithTimeout(ctx, listTimeout)
tags, err := p.reg.Tags(lctx, repos[i])
cancel()
results[i] = &tagList{repo: repos[i], tags: tags, err: err}
})
var out []tagList
for _, r := range results {
if r != nil {
out = append(out, *r)
}
}
return out
}
// followedTag returns the repo and tag svc follows. ok is false unless svc is
// polled and the tag can have a newer tag.
func followedTag(svc model.Service) (repo, tag string, ok bool) {
if SkipReason(svc) != "" {
return "", "", false
}
ref := imageref.Parse(jobs.FollowedRef(svc.ActualRef))
return ref.Repo, ref.Tag, tagver.Comparable(ref.Tag)
}
// syncNewer lists the tags of every repo that has a service following a
// version-like tag, once per repo, and stores the newer tag of each service.
// A service whose tag cannot have a newer tag any more gets its suggestion
// cleared; one whose repo could not be listed keeps it. Nothing is written
// when the value is unchanged. It never starts a job, not even for an auto
// service: a newer tag may be a major step the user has not chosen.
func (p *Poller) syncNewer(ctx, write context.Context, svcs []model.Service, stats *cycleStats) {
seen := map[string]bool{}
var repos []string
for _, svc := range svcs {
if repo, _, ok := followedTag(svc); ok && svc.RemovedAt == nil && !seen[repo] {
seen[repo] = true
repos = append(repos, repo)
}
}
lists := map[string][]string{}
for _, l := range p.listAll(ctx, repos) {
if l.err != nil {
if ctx.Err() != nil {
continue // cancelled mid-listing: not the registry's fault
}
stats.tagsFailed++
p.log.Warn("poll_tags_failed", "repo", l.repo, "error", truncate(l.err.Error()))
continue
}
stats.reposListed++
lists[l.repo] = l.tags
}
type repoTag struct{ repo, tag string }
newest := map[repoTag]string{}
for _, svc := range svcs {
if svc.RemovedAt != nil {
continue
}
want := ""
if repo, tag, ok := followedTag(svc); ok {
tags, listed := lists[repo]
if !listed {
if svc.NewerRef != "" {
stats.newer++
}
continue // not listed this cycle: keep what is stored
}
key := repoTag{repo, tag}
n, done := newest[key]
if !done {
n, _ = tagver.Newer(tag, tags)
newest[key] = n
}
if n != "" {
want = repo + ":" + n
}
}
if want != "" {
stats.newer++
}
if want == svc.NewerRef {
continue
}
if err := p.store.SetNewer(write, svc.ID, want, p.now()); err != nil {
p.log.Error("poll_set_newer_failed", "service_id", svc.ID, "ref", want, "error", err)
continue
}
stats.wrote = true
}
}
// record stores one lookup result for the services that follow l.ref.
func (p *Poller) record(ctx context.Context, l lookup, svcs []model.Service, stats *cycleStats) {
if l.err != nil {
+239
View File
@@ -30,6 +30,12 @@ type pollErrorCall struct {
msg string
}
type newerCall struct {
id int64
ref string
at time.Time
}
type lastTagCall struct {
id int64
digest string
@@ -40,6 +46,7 @@ type fakeStore struct {
services []model.Service
fresh map[int64]model.Service // what GetService returns instead of the listed row
lastTag []lastTagCall
newer []newerCall
listErr error
available []availableCall
pollErrors []pollErrorCall
@@ -86,6 +93,21 @@ func (f *fakeStore) SetAvailable(ctx context.Context, id int64, a model.Availabl
return nil
}
// SetNewer records the call and, like the database, changes the row the
// next cycle lists.
func (f *fakeStore) SetNewer(ctx context.Context, id int64, ref string, at time.Time) error {
f.mu.Lock()
defer f.mu.Unlock()
f.ctxErrs = append(f.ctxErrs, ctx.Err())
f.newer = append(f.newer, newerCall{id, ref, at})
for i := range f.services {
if f.services[i].ID == id {
f.services[i].NewerRef = ref
}
}
return nil
}
func (f *fakeStore) SetPollError(ctx context.Context, ids []int64, msg string) error {
f.mu.Lock()
defer f.mu.Unlock()
@@ -125,6 +147,36 @@ type fakeRegistry struct {
started chan struct{} // receives a value whenever a lookup starts, if set
release chan struct{} // lookups wait for it to be closed, if set
onResult func() // called after a lookup computed its answer
tags map[string][]string // repo -> tags
tagErrs map[string]error // repo -> error
tagCalls []string
tagStarted chan struct{} // receives a value whenever a tag listing starts, if set
tagRelease chan struct{} // tag listings wait for it to be closed, if set
onTags func() // called after a tag listing computed its answer
}
func (f *fakeRegistry) Tags(ctx context.Context, repo string) ([]string, error) {
f.mu.Lock()
f.tagCalls = append(f.tagCalls, repo)
f.mu.Unlock()
if f.tagStarted != nil {
f.tagStarted <- struct{}{}
}
if f.tagRelease != nil {
select {
case <-f.tagRelease:
case <-ctx.Done():
return nil, ctx.Err()
}
}
if f.onTags != nil {
f.onTags()
}
if err := f.tagErrs[repo]; err != nil {
return nil, err
}
return f.tags[repo], nil
}
func (f *fakeRegistry) Digest(ctx context.Context, ref string) (string, error) {
@@ -649,3 +701,190 @@ func TestSeedsTheTagDigestOfARunningService(t *testing.T) {
assert.Empty(t, j.starts, "the rollback stays")
assert.Len(t, st.lastTag, 1, "nothing more to seed")
}
// newTagRegistry returns a fake registry that lists tags by repo.
func newTagRegistry(tags map[string][]string) *fakeRegistry {
return &fakeRegistry{digests: map[string]string{}, tags: tags}
}
// newerSvc is a polled service running tag of repo that already runs the
// registry's digest, so the digest phase writes nothing.
func newerSvc(id int64, repo, tag string) model.Service {
s := svc(id, repo+":"+tag)
s.ActualDigest = "sha256:new"
return s
}
// newerRegistry is newTagRegistry that also answers sha256:new for refs.
func newerRegistry(tags map[string][]string, refs ...string) *fakeRegistry {
reg := newTagRegistry(tags)
for _, r := range refs {
reg.digests[r] = "sha256:new"
}
return reg
}
func TestNewerTagSuggested(t *testing.T) {
ref := repoB + ":1.27.3"
st := &fakeStore{services: []model.Service{newerSvc(1, repoB, "1.27.3")}}
reg := newerRegistry(map[string][]string{repoB: {"1.27.4", "1.28.0", "2.0.0", "latest", "1.28.1-rootless"}}, ref)
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
p.Cycle(context.Background())
assert.Equal(t, []newerCall{{1, repoB + ":1.28.0", time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}}, st.newer)
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
}
func TestOneTagListPerRepo(t *testing.T) {
st := &fakeStore{services: []model.Service{
newerSvc(1, repoB, "1.27.3"),
newerSvc(2, repoB, "2.1.0"),
newerSvc(3, repoB, "1.27.3"),
}}
reg := newerRegistry(map[string][]string{repoB: {"2.1.1", "1.27.4"}},
repoB+":1.27.3", repoB+":2.1.0")
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
p.Cycle(context.Background())
assert.Equal(t, []string{repoB}, reg.tagCalls)
got := map[int64]string{}
for _, c := range st.newer {
got[c.id] = c.ref
}
assert.Equal(t, map[int64]string{1: repoB + ":1.27.4", 2: repoB + ":2.1.1", 3: repoB + ":1.27.4"}, got)
}
func TestNoTagListWithoutComparableTag(t *testing.T) {
built := newerSvc(5, repoB, "1.2.3")
built.Built = true
noDigest := newerSvc(6, repoB, "1.2.3")
noDigest.ActualDigest = ""
st := &fakeStore{services: []model.Service{
newerSvc(1, repoB, "latest"),
newerSvc(2, repoB, "7"),
newerSvc(3, repoB, "pg16"),
newerSvc(4, repoB+"@sha256:abc", ""),
built,
noDigest,
}}
st.services[3].ActualRef = repoB + "@sha256:abc"
reg := newTagRegistry(map[string][]string{repoB: {"1.2.4"}})
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
p.Cycle(context.Background())
assert.Empty(t, reg.tagCalls)
assert.Empty(t, st.newer)
}
func TestNewerClearedWhenNoCandidate(t *testing.T) {
s := newerSvc(1, repoB, "1.27.3")
s.NewerRef = repoB + ":1.27.4"
st := &fakeStore{services: []model.Service{s}}
reg := newerRegistry(map[string][]string{repoB: {"1.27.3", "2.0.0"}}, repoB+":1.27.3")
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
p.Cycle(context.Background())
assert.Equal(t, []newerCall{{1, "", time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}}, st.newer)
}
func TestNewerClearedWhenTagNoLongerComparable(t *testing.T) {
moved := newerSvc(1, repoB, "latest")
moved.NewerRef = repoB + ":1.27.4"
built := newerSvc(2, repoB, "1.27.3")
built.Built = true
built.NewerRef = repoB + ":1.27.4"
st := &fakeStore{services: []model.Service{moved, built}}
reg := newerRegistry(map[string][]string{repoB: {"1.27.4"}}, repoB+":latest")
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
p.Cycle(context.Background())
require.Len(t, st.newer, 2)
assert.Equal(t, "", st.newer[0].ref)
assert.Equal(t, "", st.newer[1].ref)
assert.Empty(t, reg.tagCalls)
assert.Equal(t, []events.Topic{events.Services}, pub.topics)
}
func TestTagListErrorKeepsNewerAndPollError(t *testing.T) {
s := newerSvc(1, repoB, "1.27.3")
s.NewerRef = repoB + ":1.27.4"
st := &fakeStore{services: []model.Service{s}}
reg := newerRegistry(nil, repoB+":1.27.3")
reg.tagErrs = map[string]error{repoB: errors.New("list x: registry answered 500 Internal Server Error")}
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
p.Cycle(context.Background())
assert.Empty(t, st.newer, "the stored suggestion stays")
assert.Empty(t, st.pollErrors, "a failed tag list is not a poll error")
assert.Empty(t, pub.topics)
}
func TestNewerIdempotent(t *testing.T) {
s := newerSvc(1, repoB, "1.27.3")
s.NewerRef = repoB + ":1.28.0"
st := &fakeStore{services: []model.Service{s}}
reg := newerRegistry(map[string][]string{repoB: {"1.28.0"}}, repoB+":1.27.3")
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
p.Cycle(context.Background())
assert.Empty(t, st.newer)
assert.Empty(t, pub.topics)
}
func TestNewerNeverStartsJobs(t *testing.T) {
s := newerSvc(1, repoB, "1.27.3")
s.UpdateMode = model.ModeAuto
st := &fakeStore{services: []model.Service{s}}
reg := newerRegistry(map[string][]string{repoB: {"1.27.4"}}, repoB+":1.27.3")
j := &fakeJobs{}
p, _ := newPoller(st, j, reg, Config{})
p.Cycle(context.Background())
require.Len(t, st.newer, 1)
assert.Empty(t, j.starts)
}
func TestNewerWritesSurviveCancel(t *testing.T) {
st := &fakeStore{services: []model.Service{newerSvc(1, repoB, "1.27.3")}}
ctx, cancel := context.WithCancel(context.Background())
reg := newerRegistry(map[string][]string{repoB: {"1.27.4"}}, repoB+":1.27.3")
reg.onTags = cancel
p, _ := newPoller(st, &fakeJobs{}, reg, Config{})
p.Cycle(ctx)
require.Len(t, st.newer, 1)
assert.NoError(t, st.ctxErrs[0], "the write does not see the cancelled context")
}
func TestCancelledListKeepsNewer(t *testing.T) {
s := newerSvc(1, repoB, "1.27.3")
s.NewerRef = repoB + ":1.27.4"
st := &fakeStore{services: []model.Service{s}}
reg := newerRegistry(map[string][]string{repoB: {"1.28.0"}}, repoB+":1.27.3")
reg.tagRelease = make(chan struct{})
reg.tagStarted = make(chan struct{}, 1)
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { p.Cycle(ctx); close(done) }()
<-reg.tagStarted
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Cycle did not return after cancel")
}
assert.Empty(t, st.newer)
assert.Empty(t, pub.topics)
}