feat(master): suggest newer version tags for third-party images (#11) (#31)
Docker Build / docker (push) Successful in 45s
Docker Build / docker (push) Successful in 45s
This commit was merged in pull request #31.
This commit is contained in:
@@ -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) {
|
||||
@@ -667,3 +719,222 @@ 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)
|
||||
}
|
||||
|
||||
func TestFailedListClearsNewerFromAnotherRepo(t *testing.T) {
|
||||
s := newerSvc(1, repoB, "1.27.3")
|
||||
s.NewerRef = repoA + ":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, _ := 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 TestDigestWritesArePublishedBeforeTagsAreListed(t *testing.T) {
|
||||
st := &fakeStore{services: []model.Service{svc(1, repoB+":1.27.3")}}
|
||||
reg := newerRegistry(map[string][]string{repoB: {"1.27.3"}}, repoB+":1.27.3")
|
||||
reg.tagRelease = make(chan struct{})
|
||||
reg.tagStarted = make(chan struct{}, 1)
|
||||
p, pub := newPoller(st, &fakeJobs{}, reg, Config{})
|
||||
done := make(chan struct{})
|
||||
go func() { p.Cycle(context.Background()); close(done) }()
|
||||
<-reg.tagStarted
|
||||
|
||||
pub.mu.Lock()
|
||||
published := append([]events.Topic(nil), pub.topics...)
|
||||
pub.mu.Unlock()
|
||||
assert.Equal(t, []events.Topic{events.Services}, published, "published while the listing is still blocked")
|
||||
|
||||
close(reg.tagRelease)
|
||||
<-done
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user