Skip to content

Commit dec8fa8

Browse files
authored
feat(remote): add ClientBuilder for constructing configured registries (#1328)
* feat(properties): add TokenFlow to select the bearer token flow Registries that implement only the distribution-spec token endpoint cannot serve the OAuth2 password grant, which is a Docker extension rather than part of the OCI distribution spec. There was no way to ask for the spec'd flow short of supplying a custom TokenFetcher. Add properties.TokenFlow with three values: TokenFlowDefault (leave the choice to the client, which uses OAuth2), TokenFlowOAuth2, and TokenFlowDistribution. Surface it in registries.conf as the ORAS-specific "token-flow" key. An unrecognized token-flow value is rejected rather than ignored. Silently falling back to the default would leave a user who typo'd the flow authenticating a way they did not ask for, and the resulting failure surfaces as bad credentials, which is a confusing place to start debugging from. Signed-off-by: Terry Howe <terrylhowe@gmail.com> * feat(remote): add ClientBuilder for constructing configured registries Assembling a Repository from a config file meant wiring the TLS config, CA pool, transport, retry policy, credential func, and headers together by hand at every call site, with the mirror clients built the same way again. Add ClientBuilder, which turns a *properties.Registry into a configured *auth.Client, plus NewRegistryWithProperties and NewRepositoryWithProperties that build on it. A caller-supplied TokenFetcher wins over the registries.conf token-flow setting: the former is the caller speaking directly, the latter is a config file. Credentials are scoped to the registry they were configured for. The credential func matches the requested host against that registry and returns EmptyCredential for anything else, so a credential is never handed to a mirror or to a host that merely shares a suffix with the configured one. The docker.io alias is normalized to registry-1.docker.io, since that is the host the transport actually dials. Signed-off-by: Terry Howe <terrylhowe@gmail.com> --------- Signed-off-by: Terry Howe <terrylhowe@gmail.com>
1 parent 7d0e8f7 commit dec8fa8

8 files changed

Lines changed: 1478 additions & 0 deletions

File tree

registry/remote/builder.go

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
/*
2+
Copyright The ORAS Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package remote
17+
18+
import (
19+
"context"
20+
"crypto/tls"
21+
"crypto/x509"
22+
"fmt"
23+
"log/slog"
24+
"net/http"
25+
"os"
26+
"strings"
27+
28+
"github.com/oras-project/oras-go/v3/registry"
29+
"github.com/oras-project/oras-go/v3/registry/remote/auth"
30+
"github.com/oras-project/oras-go/v3/registry/remote/credentials"
31+
"github.com/oras-project/oras-go/v3/registry/remote/policy"
32+
"github.com/oras-project/oras-go/v3/registry/remote/properties"
33+
"github.com/oras-project/oras-go/v3/registry/remote/retry"
34+
)
35+
36+
// ClientBuilder creates auth.Client instances from registry properties.
37+
// It handles TLS configuration, retry policies, caching, and credential
38+
// resolution for connecting to container registries.
39+
type ClientBuilder struct {
40+
// BaseTransport is the underlying HTTP transport.
41+
// If nil, http.DefaultTransport is used.
42+
BaseTransport http.RoundTripper
43+
44+
// RetryPolicy returns a retry policy for HTTP requests.
45+
// If nil, retry.DefaultPolicy is used.
46+
RetryPolicy func() retry.Policy
47+
48+
// CacheFactory creates a cache for the given registry.
49+
// If nil, a new cache is created for each registry.
50+
CacheFactory func(registry string) auth.Cache
51+
52+
// CredentialStore is used to resolve credentials when not specified
53+
// in the registry properties.
54+
// If nil, no credential store fallback is used.
55+
CredentialStore credentials.Store
56+
57+
// UserAgent is the User-Agent header value for HTTP requests.
58+
// If empty, no User-Agent header is set.
59+
UserAgent string
60+
61+
// TokenFetcher is an optional custom token fetcher.
62+
// If nil, the default token fetching behavior is used.
63+
TokenFetcher auth.TokenFetcher
64+
65+
// PolicyEvaluator is an optional policy evaluator for allow/deny decisions.
66+
// If set, repositories created by NewRepositoryWithProperties will
67+
// automatically enforce policy on read/write operations.
68+
PolicyEvaluator *policy.Evaluator
69+
70+
// Logger enables HTTP request/response debug logging when non-nil.
71+
// Each retry attempt is logged individually. If nil, no logging transport
72+
// is added. Use slog.Default() to log to the default handler.
73+
Logger *slog.Logger
74+
}
75+
76+
// NewClientBuilder creates a new ClientBuilder with default settings.
77+
func NewClientBuilder() *ClientBuilder {
78+
return &ClientBuilder{
79+
BaseTransport: http.DefaultTransport,
80+
RetryPolicy: func() retry.Policy { return retry.DefaultPolicy },
81+
CacheFactory: func(registry string) auth.Cache { return auth.NewCache() },
82+
}
83+
}
84+
85+
// Build creates an auth.Client for the given registry properties.
86+
func (b *ClientBuilder) Build(props *properties.Registry) (*auth.Client, error) {
87+
if props == nil {
88+
return nil, fmt.Errorf("registry properties cannot be nil")
89+
}
90+
91+
// Build TLS configuration
92+
tlsConfig, err := b.buildTLSConfig(props.Transport)
93+
if err != nil {
94+
return nil, fmt.Errorf("failed to configure TLS: %w", err)
95+
}
96+
97+
// Build transport with TLS
98+
transport := b.buildTransport(tlsConfig)
99+
100+
// Build HTTP client with retry
101+
httpClient := b.buildHTTPClient(transport)
102+
103+
// Build credential function
104+
credentialFunc := b.buildCredentialFunc(props)
105+
106+
// Build headers
107+
header := b.buildHeader(props.Transport)
108+
109+
// Build cache
110+
var cache auth.Cache
111+
if b.CacheFactory != nil {
112+
cache = b.CacheFactory(props.Reference.Registry)
113+
}
114+
115+
// Create auth client
116+
client := &auth.Client{
117+
Client: httpClient,
118+
Header: header,
119+
CredentialFunc: credentialFunc,
120+
Cache: cache,
121+
TokenFetcher: b.tokenFetcher(props, httpClient, header),
122+
}
123+
124+
return client, nil
125+
}
126+
127+
// tokenFetcher resolves the token acquisition strategy for a registry.
128+
//
129+
// An explicitly configured ClientBuilder.TokenFetcher wins: it is the caller
130+
// speaking directly, whereas the registry properties come from a config file.
131+
// Otherwise a distribution-spec flow requested via registries.conf is honoured
132+
// by handing the client a composite fetcher in legacy mode. A nil result leaves
133+
// the client on its own default, which is OAuth2.
134+
func (b *ClientBuilder) tokenFetcher(props *properties.Registry, client *http.Client, header http.Header) auth.TokenFetcher {
135+
if b.TokenFetcher != nil {
136+
return b.TokenFetcher
137+
}
138+
if props.Attributes.TokenFlow == properties.TokenFlowDistribution {
139+
return auth.NewCompositeTokenFetcher(client, header, "", true)
140+
}
141+
return nil
142+
}
143+
144+
// buildTLSConfig creates a TLS configuration from transport properties.
145+
func (b *ClientBuilder) buildTLSConfig(transport properties.Transport) (*tls.Config, error) {
146+
tlsConfig := &tls.Config{
147+
InsecureSkipVerify: transport.Insecure,
148+
}
149+
150+
// Collect all CA certificate paths.
151+
var caPaths []string
152+
if transport.CACert != "" {
153+
caPaths = append(caPaths, transport.CACert)
154+
}
155+
caPaths = append(caPaths, transport.CACerts...)
156+
157+
if len(caPaths) > 0 {
158+
caCertPool := x509.NewCertPool()
159+
for _, p := range caPaths {
160+
caCert, err := os.ReadFile(p)
161+
if err != nil {
162+
return nil, fmt.Errorf("failed to read CA certificate %s: %w", p, err)
163+
}
164+
if !caCertPool.AppendCertsFromPEM(caCert) {
165+
return nil, fmt.Errorf("failed to parse CA certificate %s", p)
166+
}
167+
}
168+
tlsConfig.RootCAs = caCertPool
169+
}
170+
171+
// Load client certificate if specified
172+
if transport.Cert != "" && transport.Key != "" {
173+
cert, err := tls.LoadX509KeyPair(transport.Cert, transport.Key)
174+
if err != nil {
175+
return nil, fmt.Errorf("failed to load client certificate: %w", err)
176+
}
177+
tlsConfig.Certificates = []tls.Certificate{cert}
178+
}
179+
180+
return tlsConfig, nil
181+
}
182+
183+
// buildTransport creates an HTTP transport with TLS config.
184+
func (b *ClientBuilder) buildTransport(tlsConfig *tls.Config) http.RoundTripper {
185+
// Clone the base transport or use default
186+
base := b.BaseTransport
187+
if base == nil {
188+
base = http.DefaultTransport
189+
}
190+
191+
// If we have TLS config and the base is an *http.Transport, configure it
192+
if tlsConfig != nil {
193+
if httpTransport, ok := base.(*http.Transport); ok {
194+
// Clone the transport to avoid modifying the original
195+
cloned := httpTransport.Clone()
196+
cloned.TLSClientConfig = tlsConfig
197+
base = cloned
198+
}
199+
}
200+
201+
// Wrap with retry transport
202+
var transport http.RoundTripper = &retry.Transport{
203+
Base: base,
204+
Policy: b.RetryPolicy,
205+
}
206+
207+
// Wrap with logging transport if a logger is configured.
208+
// Placed outside retry so each attempt is individually logged.
209+
if b.Logger != nil {
210+
transport = NewLoggingTransport(transport, b.Logger)
211+
}
212+
213+
return transport
214+
}
215+
216+
// buildHTTPClient creates an HTTP client with the given transport.
217+
func (b *ClientBuilder) buildHTTPClient(transport http.RoundTripper) *http.Client {
218+
return &http.Client{
219+
Transport: transport,
220+
}
221+
}
222+
223+
// buildCredentialFunc creates a credential function that resolves credentials
224+
// from properties or falls back to the credential store.
225+
//
226+
// The credential carried in props belongs to one registry, so it is only
227+
// returned for that registry's host. Every other host — a mirror, a redirect
228+
// target, a bearer realm on another origin — resolves through the credential
229+
// store, which is keyed per host. Returning the static credential
230+
// unconditionally would hand it to whichever server the client happened to be
231+
// talking to.
232+
func (b *ClientBuilder) buildCredentialFunc(props *properties.Registry) credentials.CredentialFunc {
233+
// Match what the transport actually dials: Host() maps the "docker.io"
234+
// alias onto "registry-1.docker.io", and the credential func is called with
235+
// the request host.
236+
host := registry.Reference{Registry: props.Reference.Registry}.Host()
237+
238+
return func(ctx context.Context, reg string) (credentials.Credential, error) {
239+
// Credential specified in properties, for its own registry only.
240+
if props.Credential != credentials.EmptyCredential && strings.EqualFold(reg, host) {
241+
return props.Credential, nil
242+
}
243+
244+
// Fall back to credential store if available
245+
if b.CredentialStore != nil {
246+
cred, err := b.CredentialStore.Get(ctx, reg)
247+
if err != nil {
248+
return credentials.EmptyCredential, err
249+
}
250+
return cred, nil
251+
}
252+
253+
return credentials.EmptyCredential, nil
254+
}
255+
}
256+
257+
// buildHeader creates HTTP headers from transport properties.
258+
func (b *ClientBuilder) buildHeader(transport properties.Transport) http.Header {
259+
header := http.Header{}
260+
261+
// Set User-Agent if specified
262+
if b.UserAgent != "" {
263+
header.Set("User-Agent", b.UserAgent)
264+
}
265+
266+
// Add custom headers from properties
267+
for key, value := range transport.HeaderFlags {
268+
header.Set(key, value)
269+
}
270+
271+
return header
272+
}
273+
274+
// NewRegistryWithProperties creates a Registry from registry properties
275+
// using the given ClientBuilder.
276+
func NewRegistryWithProperties(props *properties.Registry, builder *ClientBuilder) (*Registry, error) {
277+
if props == nil {
278+
return nil, fmt.Errorf("registry properties cannot be nil")
279+
}
280+
if builder == nil {
281+
builder = NewClientBuilder()
282+
}
283+
284+
// Build auth client
285+
client, err := builder.Build(props)
286+
if err != nil {
287+
return nil, err
288+
}
289+
290+
// Create registry
291+
reg := &Registry{
292+
Client: client,
293+
Reference: registry.Reference{Registry: props.Reference.Registry},
294+
PlainHTTP: props.Transport.PlainHTTP,
295+
Policy: builder.PolicyEvaluator,
296+
}
297+
298+
if builder.Logger != nil {
299+
reg.HandleWarning = NewWarningLogger(props.Reference.Registry, builder.Logger)
300+
}
301+
302+
return reg, nil
303+
}
304+
305+
// NewRepositoryWithProperties creates a Repository from registry properties
306+
// using the given ClientBuilder.
307+
func NewRepositoryWithProperties(props *properties.Registry, builder *ClientBuilder) (*Repository, error) {
308+
if builder == nil {
309+
builder = NewClientBuilder()
310+
}
311+
312+
// Share one construction path with NewRegistryWithProperties so the two
313+
// cannot drift in what they configure.
314+
reg, err := NewRegistryWithProperties(props, builder)
315+
if err != nil {
316+
return nil, err
317+
}
318+
319+
// Create repository
320+
repo := &Repository{
321+
Registry: reg,
322+
RepositoryName: props.Reference.Repository,
323+
}
324+
325+
// Set Referrers API capability if specified
326+
switch props.Attributes.ReferrersAPI {
327+
case properties.ReferrersAPISupported:
328+
repo.SetReferrersCapability(true)
329+
case properties.ReferrersAPIUnsupported:
330+
repo.SetReferrersCapability(false)
331+
}
332+
333+
// Build mirror repositories
334+
mirrors, err := buildMirrorRepositories(props, builder)
335+
if err != nil {
336+
return nil, err
337+
}
338+
repo.mirrors = mirrors
339+
340+
return repo, nil
341+
}
342+
343+
// buildMirrorRepositories creates mirror Repository instances from the
344+
// mirror properties. Each mirror gets its own auth.Client built from the
345+
// mirror's transport settings.
346+
func buildMirrorRepositories(props *properties.Registry, builder *ClientBuilder) ([]mirrorRepository, error) {
347+
if len(props.Mirrors) == 0 {
348+
return nil, nil
349+
}
350+
351+
mirrors := make([]mirrorRepository, 0, len(props.Mirrors))
352+
for _, m := range props.Mirrors {
353+
// Build mirror properties from the mirror's own transport settings.
354+
//
355+
// The primary's credential is deliberately not propagated: a mirror is
356+
// a different host and may be operated by a different party. Mirror
357+
// credentials are resolved per host through the ClientBuilder's
358+
// credential store, the same way any other registry's are.
359+
mirrorProps := &properties.Registry{
360+
Reference: properties.Reference{
361+
Registry: m.Location,
362+
Repository: props.Reference.Repository,
363+
},
364+
Transport: m.Transport,
365+
Attributes: props.Attributes,
366+
}
367+
368+
mirrorClient, err := builder.Build(mirrorProps)
369+
if err != nil {
370+
return nil, fmt.Errorf("failed to build mirror client for %s: %w", m.Location, err)
371+
}
372+
373+
mirrorReg := &Registry{
374+
Client: mirrorClient,
375+
Reference: registry.Reference{Registry: m.Location},
376+
PlainHTTP: m.Transport.PlainHTTP,
377+
}
378+
if builder.Logger != nil {
379+
mirrorReg.HandleWarning = NewWarningLogger(m.Location, builder.Logger)
380+
}
381+
382+
pullPolicy := m.PullFromMirror
383+
if pullPolicy == "" {
384+
pullPolicy = PullFromMirrorAll
385+
}
386+
387+
mirrors = append(mirrors, mirrorRepository{
388+
Repository: &Repository{
389+
Registry: mirrorReg,
390+
RepositoryName: props.Reference.Repository,
391+
},
392+
pullFromMirror: pullPolicy,
393+
})
394+
}
395+
396+
return mirrors, nil
397+
}

0 commit comments

Comments
 (0)