@@ -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": [...]}.
3540type ociClient struct {
3641 host string
3742 http * http.Client
3843}
3944
45+ const garHost = "us-docker.pkg.dev"
46+
4047func (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