fix(master): review fixes for newer-tag suggestions (#11)

This commit is contained in:
2026-10-13 11:56:03 +02:00
parent c9cb1103ad
commit a5d0d1a070
11 changed files with 111 additions and 38 deletions
+3 -1
View File
@@ -83,7 +83,9 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
- **`services.newer_ref` is a suggestion only**: no auto jobs, not even for auto services. The - **`services.newer_ref` is a suggestion only**: no auto jobs, not even for auto services. The
poller writes it only on change; a failed tag list keeps the old value and never sets poller writes it only on change; a failed tag list keeps the old value and never sets
`last_poll_error`. The UI re-checks it at render time with `tagver.Newer`, so a stale value `last_poll_error`. The UI re-checks it at render time with `tagver.Newer`, so a stale value
is hidden until the next cycle. Tags with one numeric component or no digits (`7`, `pg16`, is hidden until the next cycle; it is also hidden when its repo is not the one the service
follows. The suggestion is computed from the service list read at the start of the cycle, so
if the service moved on meanwhile, the render-time check hides it. Tags with one numeric component or no digits (`7`, `pg16`,
`latest`) never get suggestions. `latest`) never get suggestions.
- **`image_versions` uses `''` instead of NULL** for a missing tag or digest, so - **`image_versions` uses `''` instead of NULL** for a missing tag or digest, so
`UNIQUE (repo, tag, digest)` deduplicates. The webhook records `(repo, tag, '')`; that `UNIQUE (repo, tag, digest)` deduplicates. The webhook records `(repo, tag, '')`; that
+35 -16
View File
@@ -164,16 +164,26 @@ func (p *Poller) Cycle(ctx context.Context) {
} }
p.record(write, l, byRef[l.ref], &stats) p.record(write, l, byRef[l.ref], &stats)
} }
// Publish after the digest phase too: listing tags can take a while and
// an "Update available" should not wait for it.
p.publishIfWrote(&stats)
p.syncNewer(ctx, write, svcs, &stats) p.syncNewer(ctx, write, svcs, &stats)
if stats.wrote { p.publishIfWrote(&stats)
p.pub.Publish(events.Services)
}
p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed, p.log.Info("poll_cycle", "refs", stats.refs, "ok", stats.ok, "failed", stats.failed,
"suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped, "suggested", stats.suggested, "jobs", stats.jobs, "skipped", stats.skipped,
"repos_listed", stats.reposListed, "tags_failed", stats.tagsFailed, "newer", stats.newer, "repos_listed", stats.reposListed, "tags_failed", stats.tagsFailed, "newer", stats.newer,
"duration_ms", p.now().Sub(began).Milliseconds()) "duration_ms", p.now().Sub(began).Milliseconds())
} }
// publishIfWrote tells the browsers to refresh if a phase of the cycle wrote
// anything, and starts the next phase with a clean slate.
func (p *Poller) publishIfWrote(stats *cycleStats) {
if stats.wrote {
p.pub.Publish(events.Services)
stats.wrote = false
}
}
// forEach calls fn(i) for i in [0, n), Config.Workers at a time, and stops // 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 // starting new calls once ctx is done. It returns when all started calls
// have returned. // have returned.
@@ -246,27 +256,35 @@ func (p *Poller) listAll(ctx context.Context, repos []string) []tagList {
return out return out
} }
// followedTag returns the repo and tag svc follows. ok is false unless svc is // Followed returns the repo and tag svc follows: a bare image follows
// polled and the tag can have a newer tag. // "latest", and a digest-only reference follows no tag.
func followedTag(svc model.Service) (repo, tag string, ok bool) { func Followed(svc model.Service) (repo, tag string) {
ref := imageref.Parse(jobs.FollowedRef(svc.ActualRef))
return ref.Repo, ref.Tag
}
// followedComparable returns the repo and tag svc follows. ok is false unless
// svc is polled and the tag can have a newer tag.
func followedComparable(svc model.Service) (repo, tag string, ok bool) {
if SkipReason(svc) != "" { if SkipReason(svc) != "" {
return "", "", false return "", "", false
} }
ref := imageref.Parse(jobs.FollowedRef(svc.ActualRef)) repo, tag = Followed(svc)
return ref.Repo, ref.Tag, tagver.Comparable(ref.Tag) return repo, tag, tagver.Comparable(tag)
} }
// syncNewer lists the tags of every repo that has a service following a // 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. // 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 // 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 // cleared; one whose repo could not be listed keeps it, unless it names
// another repo than the service follows now. Nothing is written
// when the value is unchanged. It never starts a job, not even for an auto // 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. // 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) { func (p *Poller) syncNewer(ctx, write context.Context, svcs []model.Service, stats *cycleStats) {
seen := map[string]bool{} seen := map[string]bool{}
var repos []string var repos []string
for _, svc := range svcs { for _, svc := range svcs {
if repo, _, ok := followedTag(svc); ok && svc.RemovedAt == nil && !seen[repo] { if repo, _, ok := followedComparable(svc); ok && svc.RemovedAt == nil && !seen[repo] {
seen[repo] = true seen[repo] = true
repos = append(repos, repo) repos = append(repos, repo)
} }
@@ -274,11 +292,10 @@ func (p *Poller) syncNewer(ctx, write context.Context, svcs []model.Service, sta
lists := map[string][]string{} lists := map[string][]string{}
for _, l := range p.listAll(ctx, repos) { for _, l := range p.listAll(ctx, repos) {
if l.err != nil { 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())) p.log.Warn("poll_tags_failed", "repo", l.repo, "error", truncate(l.err.Error()))
if ctx.Err() == nil { // cancelled mid-listing: not the registry's fault
stats.tagsFailed++
}
continue continue
} }
stats.reposListed++ stats.reposListed++
@@ -292,14 +309,15 @@ func (p *Poller) syncNewer(ctx, write context.Context, svcs []model.Service, sta
continue continue
} }
want := "" want := ""
if repo, tag, ok := followedTag(svc); ok { if repo, tag, ok := followedComparable(svc); ok {
tags, listed := lists[repo] tags, listed := lists[repo]
if !listed { if !listed && imageref.Parse(svc.NewerRef).Repo == repo {
if svc.NewerRef != "" { if svc.NewerRef != "" {
stats.newer++ stats.newer++
} }
continue // not listed this cycle: keep what is stored continue // not listed this cycle: keep what is stored
} }
if listed {
key := repoTag{repo, tag} key := repoTag{repo, tag}
n, done := newest[key] n, done := newest[key]
if !done { if !done {
@@ -310,6 +328,7 @@ func (p *Poller) syncNewer(ctx, write context.Context, svcs []model.Service, sta
want = repo + ":" + n want = repo + ":" + n
} }
} }
}
if want != "" { if want != "" {
stats.newer++ stats.newer++
} }
+32
View File
@@ -888,3 +888,35 @@ func TestCancelledListKeepsNewer(t *testing.T) {
assert.Empty(t, st.newer) assert.Empty(t, st.newer)
assert.Empty(t, pub.topics) 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
}
+1 -1
View File
@@ -103,7 +103,7 @@ func (c *Client) lookupError(verb, target, ref string, err error) error {
if errors.As(err, &terr) { if errors.As(err, &terr) {
return fmt.Errorf("%s %s: %s", verb, target, describe(terr)) return fmt.Errorf("%s %s: %s", verb, target, describe(terr))
} }
c.log.Warn("registry_lookup_failed", "ref", ref, "error", err) c.log.Warn("registry_request_failed", "ref", ref, "error", err)
return fmt.Errorf("%s %s: %s", verb, target, classify(err)) return fmt.Errorf("%s %s: %s", verb, target, classify(err))
} }
+4 -3
View File
@@ -107,8 +107,8 @@ type mergeRow struct {
// MergeService moves the live service toID onto the removed service fromID: // 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 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 and newer-tag // 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 // suggestions), becomes live, and the live row is deleted. The jobs of the live
// the old one. This is the only place a services row is deleted. // 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, // It returns *model.MergeRefusedError when the pair cannot be merged,
// model.ErrJobActive when either row has a queued or running job, // model.ErrJobActive when either row has a queued or running job,
@@ -213,8 +213,9 @@ 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, 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, 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, available_ref = $18, available_digest = $19, available_source = $20, available_at = $21,
last_poll_error = $22, newer_ref = $24, newer_at = $25, last_poll_error = $22,
last_tag_digest = CASE WHEN last_tag_digest <> '' THEN last_tag_digest ELSE $23 END, last_tag_digest = CASE WHEN last_tag_digest <> '' THEN last_tag_digest ELSE $23 END,
newer_ref = $24, newer_at = $25,
removed_at = NULL, awaiting_inventory = false removed_at = NULL, awaiting_inventory = false
WHERE id = $1`, WHERE id = $1`,
fromID, t.AgentID, t.LocationKey, t.ComposeProject, t.ComposeService, t.WorkingDir, fromID, t.AgentID, t.LocationKey, t.ComposeProject, t.ComposeService, t.WorkingDir,
+1 -1
View File
@@ -340,7 +340,7 @@ func (s *Store) GetImageVersion(ctx context.Context, id int64) (model.ImageVersi
return v, nil return v, nil
} }
// GetTagVersion returns the digest-less version row with an empty digest, which // GetTagVersion returns the version row of repo and tag with an empty digest, which
// SetNewer and the webhook create. Returns model.ErrNotFound if there is none. // 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) { func (s *Store) GetTagVersion(ctx context.Context, repo, tag string) (model.ImageVersion, error) {
var v model.ImageVersion var v model.ImageVersion
+1 -1
View File
@@ -393,7 +393,7 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
return data, err return data, err
} }
data.NewerTag = newerTag(svc) data.NewerTag = newerTag(svc)
if data.NewerTag != "" && !slices.ContainsFunc(versions, func(v model.ImageVersion) bool { return v.Tag == data.NewerTag }) { if data.NewerTag != "" && !slices.ContainsFunc(versions, func(v model.ImageVersion) bool { return v.Tag == data.NewerTag && v.Digest == "" }) {
// The suggested tag can be older than the listed versions; it must // The suggested tag can be older than the listed versions; it must
// still be pickable. // still be pickable.
v, err := s.store.GetTagVersion(r.Context(), svc.ImageRepo, data.NewerTag) v, err := s.store.GetTagVersion(r.Context(), svc.ImageRepo, data.NewerTag)
+1 -2
View File
@@ -106,10 +106,9 @@ summary.btn::-webkit-details-marker{display:none}
.tag em{font:500 11.5px var(--mono);font-style:normal;margin-left:6px} .tag em{font:500 11.5px var(--mono);font-style:normal;margin-left:6px}
.tag em:empty{display:none} .tag em:empty{display:none}
.f-up{color:var(--orange);background:var(--orangebg)} .f-up{color:var(--orange);background:var(--orangebg)}
.f-newer{color:var(--mut);box-shadow:inset 0 0 0 1px var(--line2)}
.f-newer em{color:var(--orange)} .f-newer em{color:var(--orange)}
.f-err{color:var(--red);background:var(--redbg)} .f-err{color:var(--red);background:var(--redbg)}
.f-notag{color:var(--mut);box-shadow:inset 0 0 0 1px var(--line2)} .f-newer,.f-notag{color:var(--mut);box-shadow:inset 0 0 0 1px var(--line2)}
.f-out{color:var(--dim);box-shadow:inset 0 0 0 1px var(--line)} .f-out{color:var(--dim);box-shadow:inset 0 0 0 1px var(--line)}
.mode{display:flex;flex-direction:column;align-items:flex-start;gap:2px} .mode{display:flex;flex-direction:column;align-items:flex-start;gap:2px}
.mode form{margin:0;display:flex;flex-direction:column;align-items:flex-start;gap:2px} .mode form{margin:0;display:flex;flex-direction:column;align-items:flex-start;gap:2px}
+15
View File
@@ -247,6 +247,21 @@ func TestServicePage_NewerTagAlreadyListedIsNotAppendedTwice(t *testing.T) {
assert.Equal(t, 1, strings.Count(body, `name="version"`)) assert.Equal(t, 1, strings.Count(body, `name="version"`))
} }
func TestServicePage_PinnedRowOfTheNewerTagDoesNotHideThePlainRow(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
store.services[0].NewerRef = sluskRepo + ":1.5.0"
store.versions = []model.ImageVersion{
{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:d", Source: "poll"},
{ID: 32, Repo: sluskRepo, Tag: "1.5.0", Source: "tags"},
}
_, body := get(t, srv, "/services/1")
assert.Equal(t, 2, strings.Count(body, `name="version"`))
assert.Contains(t, body, `<input type="hidden" name="version" value="32">`)
}
func TestServicePage_NoNewerTagRowWithoutSuggestion(t *testing.T) { func TestServicePage_NoNewerTagRowWithoutSuggestion(t *testing.T) {
srv, _, _, _, _ := newTestEnv(t) srv, _, _, _, _ := newTestEnv(t)
+9 -5
View File
@@ -249,17 +249,21 @@ func followsNoTag(svc model.Service) bool {
// newerTag returns the tag of the service's newer-tag suggestion, or "" when // newerTag returns the tag of the service's newer-tag suggestion, or "" when
// there is none or it no longer holds. The poller writes the suggestion once // there is none or it no longer holds. The poller writes the suggestion once
// per cycle, so after the service moved to another tag the stored value can // per cycle, so after the service moved to another tag the stored value can
// be stale until the next cycle; checking again here hides it meanwhile. // be stale until the next cycle; checking again here hides it meanwhile. A
// suggestion from another repo than the service follows now is hidden too.
func newerTag(s model.Service) string { func newerTag(s model.Service) string {
if s.RemovedAt != nil || s.NewerRef == "" { if s.RemovedAt != nil || s.NewerRef == "" {
return "" return ""
} }
tag := imageref.Parse(s.NewerRef).Tag newer := imageref.Parse(s.NewerRef)
followed := imageref.Parse(jobs.FollowedRef(s.ActualRef)).Tag repo, followed := poller.Followed(s)
if _, ok := tagver.Newer(followed, []string{tag}); !ok { if newer.Repo != repo {
return "" return ""
} }
return tag if _, ok := tagver.Newer(followed, []string{newer.Tag}); !ok {
return ""
}
return newer.Tag
} }
// versionRow is an image version as shown on the service page. // versionRow is an image version as shown on the service page.
+1
View File
@@ -223,6 +223,7 @@ func TestNewerTag(t *testing.T) {
{"bare image follows latest", model.Service{ActualRef: "gitea/gitea", NewerRef: "gitea/gitea:1.28.0"}, ""}, {"bare image follows latest", model.Service{ActualRef: "gitea/gitea", NewerRef: "gitea/gitea:1.28.0"}, ""},
{"stale after the tag changed", model.Service{ActualRef: "gitea/gitea:1.28.0", NewerRef: "gitea/gitea:1.28.0"}, ""}, {"stale after the tag changed", model.Service{ActualRef: "gitea/gitea:1.28.0", NewerRef: "gitea/gitea:1.28.0"}, ""},
{"stale after the tag moved past it", model.Service{ActualRef: "gitea/gitea:1.29.0", NewerRef: "gitea/gitea:1.28.0"}, ""}, {"stale after the tag moved past it", model.Service{ActualRef: "gitea/gitea:1.29.0", NewerRef: "gitea/gitea:1.28.0"}, ""},
{"other repo", model.Service{ActualRef: "redis:7.2.4", NewerRef: "bitnami/redis:7.2.5"}, ""},
{"removed", model.Service{ActualRef: "gitea/gitea:1.27.3", NewerRef: "gitea/gitea:1.28.0", RemovedAt: &removed}, ""}, {"removed", model.Service{ActualRef: "gitea/gitea:1.27.3", NewerRef: "gitea/gitea:1.28.0", RemovedAt: &removed}, ""},
{"empty", model.Service{ActualRef: "gitea/gitea:1.27.3"}, ""}, {"empty", model.Service{ActualRef: "gitea/gitea:1.27.3"}, ""},
} }