Skip to content

Commit 351c69a

Browse files
committed
Merge remote-tracking branch 'origin/fix/oci-client-gar-tokenless'
2 parents d0f4093 + abcfec8 commit 351c69a

2 files changed

Lines changed: 353 additions & 49 deletions

File tree

internal/registry/oci.go

Lines changed: 114 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -30,82 +30,112 @@ import (
3030
)
3131

3232
// ociClient speaks the Docker Distribution / OCI v2 tag-listing protocol.
33-
// It handles any public registry that issues anonymous Bearer tokens, including
34-
// Google Artifact Registry (us-docker.pkg.dev) and Amazon ECR Public (public.ecr.aws).
33+
// It handles public OCI registries with two flavors:
34+
//
35+
// - Google Artifact Registry (us-docker.pkg.dev): public repos are
36+
// anonymous-readable; tags are returned in a non-standard nested shape
37+
// under {"manifest": {digest: {"tag": [...]}}}.
38+
// - Standard OCI registries (e.g. public.ecr.aws): require a bearer token
39+
// fetched from /token; tags are returned as {"tags": [...]}.
3540
type ociClient struct {
3641
host string
3742
http *http.Client
3843
}
3944

45+
const garHost = "us-docker.pkg.dev"
46+
4047
func (c *ociClient) httpClient() *http.Client {
4148
if c.http == nil {
4249
c.http = &http.Client{Timeout: 15 * time.Second}
4350
}
4451
return c.http
4552
}
4653

47-
type ociTokenResponse struct {
48-
Token string `json:"token"`
49-
AccessToken string `json:"access_token"` // ECR Public uses this field
50-
}
54+
// LatestTags fetches tags from the configured OCI registry and filters them by
55+
// policy.TagPattern. The maxResults cap applies after filtering.
56+
func (c *ociClient) LatestTags(ctx context.Context, policy adapters.ChainVersionPolicy, maxResults int) ([]TagEntry, error) {
57+
repo := policy.Repository
58+
if strings.HasPrefix(repo, c.host+"/") {
59+
repo = strings.TrimPrefix(repo, c.host+"/")
60+
}
5161

52-
// getToken obtains an anonymous pull token via the standard OAuth2 scope URL.
53-
// Both GAR and ECR Public follow the same www-authenticate/token pattern.
54-
func (c *ociClient) getToken(ctx context.Context, repo string) (string, error) {
55-
tokenURL := fmt.Sprintf("https://%s/token?scope=repository:%s:pull&service=%s",
56-
c.host, url.QueryEscape(repo), c.host)
62+
var (
63+
tags []string
64+
err error
65+
)
66+
if c.host == garHost {
67+
tags, err = c.fetchGARTags(ctx, repo)
68+
} else {
69+
tags, err = c.fetchStandardTags(ctx, repo, maxResults)
70+
}
71+
if err != nil {
72+
return nil, err
73+
}
5774

58-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
75+
pattern, perr := regexp.Compile(policy.TagPattern)
76+
if perr != nil {
77+
return nil, fmt.Errorf("compile tag pattern %q: %w", policy.TagPattern, perr)
78+
}
79+
80+
entries := make([]TagEntry, 0, maxResults)
81+
for _, tag := range tags {
82+
if !pattern.MatchString(tag) {
83+
continue
84+
}
85+
entries = append(entries, TagEntry{Tag: tag})
86+
if len(entries) >= maxResults {
87+
break
88+
}
89+
}
90+
return entries, nil
91+
}
92+
93+
// fetchGARTags reads all tags from a GAR repository. GAR ignores ?n= pagination
94+
// and dumps every digest's tag list in one response, so we accept the full
95+
// payload (typically a few MB for active op-stack repos) and flatten it.
96+
func (c *ociClient) fetchGARTags(ctx context.Context, repo string) ([]string, error) {
97+
tagsURL := fmt.Sprintf("https://%s/v2/%s/tags/list", c.host, repo)
98+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tagsURL, nil)
5999
if err != nil {
60-
return "", fmt.Errorf("build token request: %w", err)
100+
return nil, fmt.Errorf("build GAR tags request: %w", err)
61101
}
102+
req.Header.Set("Accept", "application/json")
62103

63104
resp, err := c.httpClient().Do(req)
64105
if err != nil {
65-
return "", fmt.Errorf("fetch token from %s: %w", c.host, err)
106+
return nil, fmt.Errorf("GAR tags request: %w", err)
66107
}
67108
defer resp.Body.Close()
68109

69110
if resp.StatusCode != http.StatusOK {
70-
return "", fmt.Errorf("token endpoint %s returned %d", c.host, resp.StatusCode)
111+
return nil, fmt.Errorf("GAR returned %d for %s", resp.StatusCode, repo)
71112
}
72113

73-
var tr ociTokenResponse
74-
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
75-
return "", fmt.Errorf("decode token response: %w", err)
114+
var result garTagsResponse
115+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
116+
return nil, fmt.Errorf("decode GAR tags response: %w", err)
76117
}
77118

78-
if tr.Token != "" {
79-
return tr.Token, nil
119+
out := make([]string, 0, len(result.Manifest))
120+
for _, m := range result.Manifest {
121+
out = append(out, m.Tag...)
80122
}
81-
return tr.AccessToken, nil
123+
return out, nil
82124
}
83125

84-
type ociTagsResponse struct {
85-
Tags []string `json:"tags"`
86-
// next page link is in the Link response header — ignored for now since
87-
// we request enough tags in one shot via ?n=
88-
}
89-
90-
func (c *ociClient) LatestTags(ctx context.Context, policy adapters.ChainVersionPolicy, maxResults int) ([]TagEntry, error) {
91-
token, err := c.getToken(ctx, policy.Repository)
126+
// fetchStandardTags uses the Docker Distribution token flow: anonymous bearer
127+
// token from /token, then /v2/{repo}/tags/list with the token attached.
128+
func (c *ociClient) fetchStandardTags(ctx context.Context, repo string, maxResults int) ([]string, error) {
129+
token, err := c.getToken(ctx, repo)
92130
if err != nil {
93131
return nil, err
94132
}
95133

96-
// Request more than maxResults so we have room to filter by pattern.
97134
fetchN := maxResults * 10
98135
if fetchN < 100 {
99136
fetchN = 100
100137
}
101138

102-
// Normalize repo path: strip leading "us-docker.pkg.dev/" or host prefix
103-
// if the caller accidentally included it.
104-
repo := policy.Repository
105-
if strings.HasPrefix(repo, c.host+"/") {
106-
repo = strings.TrimPrefix(repo, c.host+"/")
107-
}
108-
109139
tagsURL := fmt.Sprintf("https://%s/v2/%s/tags/list?n=%d", c.host, repo, fetchN)
110140
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tagsURL, nil)
111141
if err != nil {
@@ -128,21 +158,56 @@ func (c *ociClient) LatestTags(ctx context.Context, policy adapters.ChainVersion
128158
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
129159
return nil, fmt.Errorf("decode tags response: %w", err)
130160
}
161+
return result.Tags, nil
162+
}
163+
164+
type ociTokenResponse struct {
165+
Token string `json:"token"`
166+
AccessToken string `json:"access_token"` // ECR Public uses this field
167+
}
168+
169+
// getToken obtains an anonymous pull token via the standard OAuth2 scope URL.
170+
// ECR Public follows the standard www-authenticate/token pattern.
171+
func (c *ociClient) getToken(ctx context.Context, repo string) (string, error) {
172+
tokenURL := fmt.Sprintf("https://%s/token?scope=repository:%s:pull&service=%s",
173+
c.host, url.QueryEscape(repo), c.host)
131174

132-
pattern, err := regexp.Compile(policy.TagPattern)
175+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
133176
if err != nil {
134-
return nil, fmt.Errorf("compile tag pattern %q: %w", policy.TagPattern, err)
177+
return "", fmt.Errorf("build token request: %w", err)
135178
}
136179

137-
entries := make([]TagEntry, 0, maxResults)
138-
for _, tag := range result.Tags {
139-
if !pattern.MatchString(tag) {
140-
continue
141-
}
142-
entries = append(entries, TagEntry{Tag: tag})
143-
if len(entries) >= maxResults {
144-
break
145-
}
180+
resp, err := c.httpClient().Do(req)
181+
if err != nil {
182+
return "", fmt.Errorf("fetch token from %s: %w", c.host, err)
146183
}
147-
return entries, nil
184+
defer resp.Body.Close()
185+
186+
if resp.StatusCode != http.StatusOK {
187+
return "", fmt.Errorf("token endpoint %s returned %d", c.host, resp.StatusCode)
188+
}
189+
190+
var tr ociTokenResponse
191+
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
192+
return "", fmt.Errorf("decode token response: %w", err)
193+
}
194+
195+
if tr.Token != "" {
196+
return tr.Token, nil
197+
}
198+
return tr.AccessToken, nil
199+
}
200+
201+
type ociTagsResponse struct {
202+
Tags []string `json:"tags"`
203+
}
204+
205+
// garTagsResponse is the non-standard envelope returned by Google Artifact
206+
// Registry. Tags are buried inside per-digest manifest entries.
207+
type garTagsResponse struct {
208+
Manifest map[string]garManifestEntry `json:"manifest"`
209+
}
210+
211+
type garManifestEntry struct {
212+
Tag []string `json:"tag"`
148213
}

0 commit comments

Comments
 (0)