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:
@@ -38,6 +38,9 @@ var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
type Store interface {
|
||||
ListServices(ctx context.Context) ([]model.Service, error)
|
||||
GetService(ctx context.Context, id int64) (model.Service, error)
|
||||
// GetTagVersion returns the version of repo with this tag and no digest;
|
||||
// model.ErrNotFound if there is none.
|
||||
GetTagVersion(ctx context.Context, repo, tag string) (model.ImageVersion, error)
|
||||
// RecentVersions returns up to limit versions of repo the master has
|
||||
// seen, most recently first seen first.
|
||||
RecentVersions(ctx context.Context, repo string, limit int) ([]model.ImageVersion, error)
|
||||
@@ -202,6 +205,7 @@ type pageData struct {
|
||||
Reason string // why Service cannot be updated; "" when it can
|
||||
AutoBlocked string // why Service cannot be set to auto; "" when it can
|
||||
Versions []versionRow
|
||||
NewerTag string // Service's newer tag while it still holds; "" otherwise
|
||||
Polling string // what the service page says about registry polling
|
||||
PollFailed bool // Polling is the last registry error
|
||||
// RestoreBlocked is why a drifted Service cannot be restored; "" when it
|
||||
@@ -388,6 +392,17 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
data.NewerTag = newerTag(svc)
|
||||
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
|
||||
// still be pickable.
|
||||
v, err := s.store.GetTagVersion(r.Context(), svc.ImageRepo, data.NewerTag)
|
||||
if err == nil {
|
||||
versions = append(versions, v)
|
||||
} else if !errors.Is(err, model.ErrNotFound) {
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
data.Versions = versionRows(versions, svc.ActualDigest)
|
||||
data.Polling, data.PollFailed = pollingStatus(svc, s.cfg.PollInterval)
|
||||
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
|
||||
|
||||
@@ -790,3 +790,34 @@ func TestServicePage_AutoBlockedDisablesToggle(t *testing.T) {
|
||||
_, body := get(t, srv, "/services/1")
|
||||
assert.Contains(t, body, "Auto unavailable: "+model.ReasonContainer+".")
|
||||
}
|
||||
|
||||
// GetTagVersion returns the digest-less version of repo with this tag.
|
||||
func (f *fakeStore) GetTagVersion(_ context.Context, repo, tag string) (model.ImageVersion, error) {
|
||||
for _, v := range f.versions {
|
||||
if v.Repo == repo && v.Tag == tag && v.Digest == "" {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return model.ImageVersion{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
func TestOverview_ShowsNewerTagChipBesideUpdateChip(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.4.2"
|
||||
store.services[0].NewerRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
||||
|
||||
for _, path := range []string{"/", "/fragments/services"} {
|
||||
_, body := get(t, srv, path)
|
||||
assert.Contains(t, body, `class="tag f-newer" title="gitea.shcizo.se/shcizo/slusk:1.5.0">Newer tag<em>1.5.0</em>`, path)
|
||||
assert.Contains(t, body, `class="tag f-up"`, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverview_HidesStaleNewerTag(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.services[0].NewerRef = "gitea.shcizo.se/shcizo/slusk:1.4.1" // not newer than 1.4.2
|
||||
|
||||
_, body := get(t, srv, "/")
|
||||
|
||||
assert.NotContains(t, body, "f-newer")
|
||||
}
|
||||
|
||||
@@ -106,8 +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:empty{display:none}
|
||||
.f-up{color:var(--orange);background:var(--orangebg)}
|
||||
.f-newer em{color:var(--orange)}
|
||||
.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)}
|
||||
.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}
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
</div>
|
||||
<div class="flags">
|
||||
{{if .AvailableRef}}<span class="tag f-up" title="{{.AvailableRef}}">Update available<em>{{.AvailableTag}}</em></span>{{end}}
|
||||
{{if .NewerTag}}<span class="tag f-newer" title="{{.NewerRef}}">Newer tag<em>{{.NewerTag}}</em></span>{{end}}
|
||||
{{if followsNoTag .Service}}<span class="tag f-notag">Follows no tag</span>{{end}}
|
||||
{{if .LastPollError}}<span class="tag f-err" title="{{.LastPollError}}">Poll failed</span>{{end}}
|
||||
{{if .Self}}<span class="tag f-out">This agent</span>{{end}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<tr><th>Status</th><td class="status status-{{.Service.Status}}">{{.Service.Status}}</td></tr>
|
||||
{{if .Service.DesiredRef}}<tr><th>Desired image</th><td><code>{{shortRef .Service.DesiredRef}}</code></td></tr>{{end}}
|
||||
{{if .Service.AvailableRef}}<tr><th>Available</th><td><code>{{.Service.AvailableRef}}</code> <span class="muted">via {{.Service.AvailableSource}}{{with .Service.AvailableAt}}, {{.Format "2006-01-02 15:04"}}{{end}}</span></td></tr>{{end}}
|
||||
{{if .NewerTag}}<tr><th>Newer tag</th><td><code>{{.Service.NewerRef}}</code> <span class="muted">from the registry's tag list{{with .Service.NewerAt}}, {{.Format "2006-01-02 15:04"}}{{end}}; deploy it from Versions</span></td></tr>{{end}}
|
||||
<tr><th>Mode</th><td>{{.Service.UpdateMode}}</td></tr>
|
||||
<tr><th>Polling</th><td{{if .PollFailed}} class="error"{{end}}>{{.Polling}}</td></tr>
|
||||
{{if .ConfigFiles}}<tr><th>Config files</th><td>{{range .ConfigFiles}}<code>{{.}}</code><br>{{end}}</td></tr>{{end}}
|
||||
|
||||
@@ -219,3 +219,53 @@ func TestOverview_ShowsFollowsNoTagAndPollFailedBadges(t *testing.T) {
|
||||
assert.Equal(t, 1, strings.Count(body, "Poll failed"), path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePage_VersionPickerAppendsNewerTagBeyondLimit(t *testing.T) {
|
||||
srv, store, _, _, _ := newTestEnv(t)
|
||||
store.services[0].ImageRepo = sluskRepo
|
||||
store.services[0].NewerRef = sluskRepo + ":1.5.0"
|
||||
for i := int64(1); i <= 12; i++ {
|
||||
store.versions = append(store.versions, model.ImageVersion{ID: i, Repo: sluskRepo, Tag: "t", Digest: "sha256:d", Source: "poll"})
|
||||
}
|
||||
store.versions = append(store.versions, model.ImageVersion{ID: 99, Repo: sluskRepo, Tag: "1.5.0", Source: "tags"})
|
||||
|
||||
_, body := get(t, srv, "/services/1")
|
||||
|
||||
assert.Equal(t, 11, strings.Count(body, `name="version"`), "the ten recent versions and the newer tag")
|
||||
assert.Contains(t, body, `<input type="hidden" name="version" value="99">`)
|
||||
assert.Contains(t, body, "<th>Newer tag</th>")
|
||||
}
|
||||
|
||||
func TestServicePage_NewerTagAlreadyListedIsNotAppendedTwice(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", Source: "tags"}}
|
||||
|
||||
_, body := get(t, srv, "/services/1")
|
||||
|
||||
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) {
|
||||
srv, _, _, _, _ := newTestEnv(t)
|
||||
|
||||
_, body := get(t, srv, "/services/1")
|
||||
|
||||
assert.NotContains(t, body, "Newer tag")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/shcizo/package-updater/internal/master/jobs"
|
||||
"github.com/shcizo/package-updater/internal/master/model"
|
||||
"github.com/shcizo/package-updater/internal/master/poller"
|
||||
"github.com/shcizo/package-updater/internal/master/tagver"
|
||||
"github.com/shcizo/package-updater/internal/master/web/icons"
|
||||
"github.com/shcizo/package-updater/internal/protocol"
|
||||
)
|
||||
@@ -45,6 +46,7 @@ type serviceRow struct {
|
||||
Tag string // ":tag", or ""
|
||||
Digest string // "@sha256:<12 hex>…" when the image has no tag, else ""
|
||||
AvailableTag string // the tag of AvailableRef, or ""
|
||||
NewerTag string // the tag of NewerRef while it is still newer than the followed tag, or ""
|
||||
Icon string // Simple Icons slug; "" when there is no icon
|
||||
Letter string // shown instead of an icon
|
||||
LetterClass string // lc-0 .. lc-3
|
||||
@@ -66,6 +68,7 @@ func newServiceRow(s model.Service, autoBlocked string) serviceRow {
|
||||
}
|
||||
r.Repo, r.Tag, r.Digest = imageParts(s.ActualRef)
|
||||
r.AvailableTag = imageref.Parse(s.AvailableRef).Tag
|
||||
r.NewerTag = newerTag(s)
|
||||
r.Icon = icons.ForImage(s.ActualRef)
|
||||
r.Letter = "?"
|
||||
if first, _ := utf8.DecodeRuneInString(r.Name); first != utf8.RuneError {
|
||||
@@ -243,6 +246,26 @@ func followsNoTag(svc model.Service) bool {
|
||||
return jobs.FollowedRef(svc.ActualRef) == ""
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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. A
|
||||
// suggestion from another repo than the service follows now is hidden too.
|
||||
func newerTag(s model.Service) string {
|
||||
if s.RemovedAt != nil || s.NewerRef == "" {
|
||||
return ""
|
||||
}
|
||||
newer := imageref.Parse(s.NewerRef)
|
||||
repo, followed := poller.Followed(s)
|
||||
if newer.Repo != repo {
|
||||
return ""
|
||||
}
|
||||
if _, ok := tagver.Newer(followed, []string{newer.Tag}); !ok {
|
||||
return ""
|
||||
}
|
||||
return newer.Tag
|
||||
}
|
||||
|
||||
// versionRow is an image version as shown on the service page.
|
||||
type versionRow struct {
|
||||
model.ImageVersion
|
||||
|
||||
@@ -211,3 +211,25 @@ func TestGroupServices(t *testing.T) {
|
||||
assert.Equal(t, "because", groups[0].Services[0].AutoBlocked)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewerTag(t *testing.T) {
|
||||
removed := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
svc model.Service
|
||||
want string
|
||||
}{
|
||||
{"newer", model.Service{ActualRef: "gitea/gitea:1.27.3", NewerRef: "gitea/gitea:1.28.0"}, "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 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}, ""},
|
||||
{"empty", model.Service{ActualRef: "gitea/gitea:1.27.3"}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, newerTag(tt.svc))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user