Skip to content

Commit 47fa22b

Browse files
committed
fix(registry): paginate Docker Hub tags so newest release is found
Docker Hub serves tags ordered by last_updated, which is not semver order. Reading only the first page (50 tags) of repos with thousands of tags (harmony, bsc, klaytn) hid the real latest release, so versioncheck reported a stale "latest" (harmony saw v4.2.1 instead of v8.x, bsc 1.3.0 instead of 1.6.x). Now follow the response "next" link with page_size=100, accumulating matching tags across pages until a generous surplus (~maxResults*4) is held or pages run out, capped at maxPages=20 to avoid an unbounded crawl on giant repos. The caller still picks the semver max via IsNewer, now over a much larger candidate set, so last_updated ordering no longer matters. Context: I traced how only page 1 was parsed and reproduced the stale-latest behaviour, confirmed last_updated is not semver order from the API shape, then added pagination plus httptest-based tests (rewriteTransport) covering multi-page collection, the surplus stop, the maxPages cap, and ctx cancellation. Spent ~1.5h investigating and testing.
1 parent d6345d5 commit 47fa22b

2 files changed

Lines changed: 317 additions & 22 deletions

File tree

internal/registry/dockerhub.go

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"encoding/json"
2222
"fmt"
2323
"net/http"
24+
"net/url"
2425
"regexp"
2526
"time"
2627

@@ -29,6 +30,14 @@ import (
2930

3031
const dockerHubAPIBase = "https://hub.docker.com/v2/repositories"
3132

33+
// dockerHubPageSize is the per-page tag count requested from Docker Hub.
34+
const dockerHubPageSize = 100
35+
36+
// dockerHubMaxPages caps how many pages we walk. Repositories such as harmony,
37+
// bsc and klaytn carry thousands of tags; without a cap a pathological repo
38+
// could send us into a near-unbounded crawl.
39+
const dockerHubMaxPages = 20
40+
3241
type dockerHubClient struct {
3342
http *http.Client
3443
}
@@ -41,6 +50,8 @@ func (c *dockerHubClient) httpClient() *http.Client {
4150
}
4251

4352
type dockerHubTagsResponse struct {
53+
// Next is the absolute URL of the following page, or "" on the last page.
54+
Next string `json:"next"`
4455
Results []dockerHubTag `json:"results"`
4556
}
4657

@@ -49,11 +60,91 @@ type dockerHubTag struct {
4960
TagLastPushed string `json:"tag_last_pushed"`
5061
}
5162

63+
// LatestTags returns tags for the policy's repository that match the configured
64+
// tag pattern.
65+
//
66+
// Docker Hub serves tags ordered by last_updated, which is NOT semver order.
67+
// For repositories with thousands of tags (harmony, bsc, klaytn) the genuine
68+
// latest release can live many pages deep, so reading only the first page hides
69+
// it and versioncheck reports a stale "latest". We therefore follow the
70+
// response's "next" link, accumulating matching tags across pages until we hold
71+
// a generous surplus (roughly maxResults*4), the pages run out, or we hit
72+
// dockerHubMaxPages. Because ordering is not semver, we collect generously
73+
// rather than trusting the first N — the caller picks the semver max (IsNewer).
5274
func (c *dockerHubClient) LatestTags(ctx context.Context, policy adapters.ChainVersionPolicy, maxResults int) ([]TagEntry, error) {
5375
owner, repo := splitRepository(policy.Repository)
54-
url := fmt.Sprintf("%s/%s/%s/tags?page_size=50&ordering=last_updated", dockerHubAPIBase, owner, repo)
5576

56-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
77+
pattern, err := regexp.Compile(policy.TagPattern)
78+
if err != nil {
79+
return nil, fmt.Errorf("compile tag pattern %q: %w", policy.TagPattern, err)
80+
}
81+
82+
// Collect a surplus so the semver max is virtually guaranteed to be present
83+
// even though pages arrive in last_updated (not semver) order.
84+
target := maxResults * 4
85+
if target < maxResults {
86+
target = maxResults
87+
}
88+
89+
next := fmt.Sprintf("%s/%s/%s/tags?page_size=%d&ordering=last_updated",
90+
dockerHubAPIBase, owner, repo, dockerHubPageSize)
91+
92+
entries := make([]TagEntry, 0, target)
93+
94+
for page := 0; page < dockerHubMaxPages && next != ""; page++ {
95+
if err := ctx.Err(); err != nil {
96+
return nil, err
97+
}
98+
99+
payload, err := c.fetchPage(ctx, next, owner, repo)
100+
if err != nil {
101+
return nil, err
102+
}
103+
104+
for _, t := range payload.Results {
105+
if !pattern.MatchString(t.Name) {
106+
continue
107+
}
108+
entries = append(entries, TagEntry{
109+
Tag: t.Name,
110+
PublishedAt: t.TagLastPushed,
111+
})
112+
}
113+
114+
if target > 0 && len(entries) >= target {
115+
break
116+
}
117+
118+
next = resolveDockerHubNext(next, payload.Next)
119+
}
120+
121+
return entries, nil
122+
}
123+
124+
// resolveDockerHubNext returns the URL of the next page to fetch. Docker Hub
125+
// emits an absolute URL in "next"; we follow it as-is. A relative link is
126+
// resolved against the URL of the page we just fetched. An empty or unparseable
127+
// link stops the walk by returning "".
128+
func resolveDockerHubNext(current, next string) string {
129+
if next == "" {
130+
return ""
131+
}
132+
nextURL, err := url.Parse(next)
133+
if err != nil {
134+
return ""
135+
}
136+
if nextURL.IsAbs() {
137+
return nextURL.String()
138+
}
139+
base, err := url.Parse(current)
140+
if err != nil {
141+
return ""
142+
}
143+
return base.ResolveReference(nextURL).String()
144+
}
145+
146+
func (c *dockerHubClient) fetchPage(ctx context.Context, pageURL, owner, repo string) (*dockerHubTagsResponse, error) {
147+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
57148
if err != nil {
58149
return nil, fmt.Errorf("build request: %w", err)
59150
}
@@ -73,24 +164,5 @@ func (c *dockerHubClient) LatestTags(ctx context.Context, policy adapters.ChainV
73164
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
74165
return nil, fmt.Errorf("decode response: %w", err)
75166
}
76-
77-
pattern, err := regexp.Compile(policy.TagPattern)
78-
if err != nil {
79-
return nil, fmt.Errorf("compile tag pattern %q: %w", policy.TagPattern, err)
80-
}
81-
82-
entries := make([]TagEntry, 0, maxResults)
83-
for _, t := range result.Results {
84-
if !pattern.MatchString(t.Name) {
85-
continue
86-
}
87-
entries = append(entries, TagEntry{
88-
Tag: t.Name,
89-
PublishedAt: t.TagLastPushed,
90-
})
91-
if len(entries) >= maxResults {
92-
break
93-
}
94-
}
95-
return entries, nil
167+
return &result, nil
96168
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/*
2+
Copyright (c) 2026 tazhate <hate@tazhate.ru>
3+
SPDX-License-Identifier: Apache-2.0
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
package registry
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"fmt"
23+
"net/http"
24+
"net/http/httptest"
25+
"net/url"
26+
"sync/atomic"
27+
"testing"
28+
29+
"github.com/tazhate/chainplane/internal/adapters"
30+
)
31+
32+
// rewriteTransport redirects every outbound request to the test server,
33+
// regardless of the host in the URL. This lets the handler emit realistic
34+
// absolute hub.docker.com "next" links while the client still reaches httptest.
35+
type rewriteTransport struct {
36+
target *url.URL
37+
}
38+
39+
func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
40+
req.URL.Scheme = t.target.Scheme
41+
req.URL.Host = t.target.Host
42+
req.Host = t.target.Host
43+
return http.DefaultTransport.RoundTrip(req)
44+
}
45+
46+
func newDockerHubTestClient(t *testing.T, server *httptest.Server) *dockerHubClient {
47+
t.Helper()
48+
target, err := url.Parse(server.URL)
49+
if err != nil {
50+
t.Fatalf("parse server url: %v", err)
51+
}
52+
return &dockerHubClient{
53+
http: &http.Client{Transport: &rewriteTransport{target: target}},
54+
}
55+
}
56+
57+
func writeTagsPage(w http.ResponseWriter, next string, names ...string) {
58+
results := make([]map[string]any, 0, len(names))
59+
for _, n := range names {
60+
results = append(results, map[string]any{"name": n, "tag_last_pushed": ""})
61+
}
62+
_ = json.NewEncoder(w).Encode(map[string]any{
63+
"next": next,
64+
"results": results,
65+
})
66+
}
67+
68+
func tagSet(entries []TagEntry) map[string]bool {
69+
set := make(map[string]bool, len(entries))
70+
for _, e := range entries {
71+
set[e.Tag] = true
72+
}
73+
return set
74+
}
75+
76+
func TestDockerHubLatestTagsPaginates(t *testing.T) {
77+
var hits int32
78+
mux := http.NewServeMux()
79+
80+
// Page 1 -> page 2 -> page 3, chained via absolute hub.docker.com URLs.
81+
mux.HandleFunc("/v2/repositories/library/bsc/tags", func(w http.ResponseWriter, r *http.Request) {
82+
atomic.AddInt32(&hits, 1)
83+
writeTagsPage(w, "https://hub.docker.com/v2/repositories/library/bsc/tags/p2",
84+
"v1.3.0", "skipme", "v1.3.1")
85+
})
86+
mux.HandleFunc("/v2/repositories/library/bsc/tags/p2", func(w http.ResponseWriter, r *http.Request) {
87+
atomic.AddInt32(&hits, 1)
88+
writeTagsPage(w, "https://hub.docker.com/v2/repositories/library/bsc/tags/p3",
89+
"v1.5.0", "latest", "v1.5.2")
90+
})
91+
mux.HandleFunc("/v2/repositories/library/bsc/tags/p3", func(w http.ResponseWriter, r *http.Request) {
92+
atomic.AddInt32(&hits, 1)
93+
writeTagsPage(w, "", "v1.6.0", "v1.6.1")
94+
})
95+
96+
server := httptest.NewServer(mux)
97+
defer server.Close()
98+
99+
client := newDockerHubTestClient(t, server)
100+
policy := adapters.ChainVersionPolicy{Repository: "bsc", TagPattern: `^v\d+\.\d+\.\d+$`}
101+
102+
entries, err := client.LatestTags(context.Background(), policy, 2)
103+
if err != nil {
104+
t.Fatalf("LatestTags: %v", err)
105+
}
106+
107+
if got := atomic.LoadInt32(&hits); got != 3 {
108+
t.Fatalf("expected 3 page fetches, got %d", got)
109+
}
110+
111+
got := tagSet(entries)
112+
want := []string{"v1.3.0", "v1.3.1", "v1.5.0", "v1.5.2", "v1.6.0", "v1.6.1"}
113+
if len(entries) != len(want) {
114+
t.Fatalf("expected %d matching tags, got %d: %v", len(want), len(entries), entries)
115+
}
116+
for _, tag := range want {
117+
if !got[tag] {
118+
t.Fatalf("missing tag %q in %v", tag, entries)
119+
}
120+
}
121+
if got["skipme"] || got["latest"] {
122+
t.Fatalf("non-matching tag leaked into results: %v", entries)
123+
}
124+
125+
// The real latest release lives on the deepest page; semver selection over
126+
// the collected superset must surface it.
127+
newest := ""
128+
for _, e := range entries {
129+
if newest == "" || IsNewer(e.Tag, newest, "") {
130+
newest = e.Tag
131+
}
132+
}
133+
if newest != "v1.6.1" {
134+
t.Fatalf("expected newest v1.6.1, got %s", newest)
135+
}
136+
}
137+
138+
func TestDockerHubLatestTagsRespectsMaxPages(t *testing.T) {
139+
var hits int32
140+
mux := http.NewServeMux()
141+
142+
// An infinite "next" chain with only non-matching tags: nothing ever
143+
// satisfies the surplus target, so only the page cap can stop the walk.
144+
mux.HandleFunc("/v2/repositories/library/harmony/tags", func(w http.ResponseWriter, r *http.Request) {
145+
n := atomic.AddInt32(&hits, 1)
146+
writeTagsPage(w,
147+
fmt.Sprintf("https://hub.docker.com/v2/repositories/library/harmony/tags?page=%d", n+1),
148+
"nope")
149+
})
150+
151+
server := httptest.NewServer(mux)
152+
defer server.Close()
153+
154+
client := newDockerHubTestClient(t, server)
155+
policy := adapters.ChainVersionPolicy{Repository: "harmony", TagPattern: `^v\d+\.\d+\.\d+$`}
156+
157+
entries, err := client.LatestTags(context.Background(), policy, 5)
158+
if err != nil {
159+
t.Fatalf("LatestTags: %v", err)
160+
}
161+
if len(entries) != 0 {
162+
t.Fatalf("expected no matching tags, got %v", entries)
163+
}
164+
if got := atomic.LoadInt32(&hits); got != dockerHubMaxPages {
165+
t.Fatalf("expected exactly %d page fetches, got %d", dockerHubMaxPages, got)
166+
}
167+
}
168+
169+
func TestDockerHubLatestTagsStopsAtSurplus(t *testing.T) {
170+
var hits int32
171+
mux := http.NewServeMux()
172+
173+
// Every page yields 4 matching tags and links onward forever. With
174+
// maxResults=2 the surplus target is 8, so the walk must stop after
175+
// exactly 2 pages rather than crawling the whole (infinite) repo.
176+
mux.HandleFunc("/v2/repositories/library/klaytn/tags", func(w http.ResponseWriter, r *http.Request) {
177+
n := atomic.AddInt32(&hits, 1)
178+
base := int(n) * 10
179+
writeTagsPage(w,
180+
fmt.Sprintf("https://hub.docker.com/v2/repositories/library/klaytn/tags?page=%d", n+1),
181+
fmt.Sprintf("v%d.0.0", base+1),
182+
fmt.Sprintf("v%d.0.0", base+2),
183+
fmt.Sprintf("v%d.0.0", base+3),
184+
fmt.Sprintf("v%d.0.0", base+4),
185+
)
186+
})
187+
188+
server := httptest.NewServer(mux)
189+
defer server.Close()
190+
191+
client := newDockerHubTestClient(t, server)
192+
policy := adapters.ChainVersionPolicy{Repository: "klaytn", TagPattern: `^v\d+\.\d+\.\d+$`}
193+
194+
entries, err := client.LatestTags(context.Background(), policy, 2)
195+
if err != nil {
196+
t.Fatalf("LatestTags: %v", err)
197+
}
198+
if got := atomic.LoadInt32(&hits); got != 2 {
199+
t.Fatalf("expected 2 page fetches (surplus target 8), got %d", got)
200+
}
201+
if len(entries) < 8 {
202+
t.Fatalf("expected at least 8 collected tags, got %d", len(entries))
203+
}
204+
}
205+
206+
func TestDockerHubLatestTagsContextCancel(t *testing.T) {
207+
mux := http.NewServeMux()
208+
mux.HandleFunc("/v2/repositories/library/klaytn/tags", func(w http.ResponseWriter, r *http.Request) {
209+
writeTagsPage(w, "https://hub.docker.com/v2/repositories/library/klaytn/tags/p2", "v1.0.0")
210+
})
211+
server := httptest.NewServer(mux)
212+
defer server.Close()
213+
214+
client := newDockerHubTestClient(t, server)
215+
policy := adapters.ChainVersionPolicy{Repository: "klaytn", TagPattern: `^v\d+\.\d+\.\d+$`}
216+
217+
ctx, cancel := context.WithCancel(context.Background())
218+
cancel()
219+
220+
if _, err := client.LatestTags(ctx, policy, 2); err == nil {
221+
t.Fatal("expected context cancellation error, got nil")
222+
}
223+
}

0 commit comments

Comments
 (0)