Skip to content
This repository was archived by the owner on Aug 17, 2025. It is now read-only.

Commit 2d6388b

Browse files
fix: enhanced auth for OCI registries (#5588)
1 parent 73fbd07 commit 2d6388b

File tree

2 files changed

+106
-51
lines changed

2 files changed

+106
-51
lines changed

cmd/ftl/cmd_buildimage.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"os"
66
"path/filepath"
7-
"strings"
87

98
errors "github.com/alecthomas/errors"
109

@@ -100,10 +99,6 @@ func (b *buildImageCmd) Run(
10099
}
101100
}
102101
tgt := b.RegistryConfig.Registry
103-
if !strings.HasSuffix(tgt, "/") {
104-
tgt += "/"
105-
}
106-
tgt += moduleSch.Name
107102
tgt += ":"
108103
tgt += b.Tag
109104
targets := []artefacts.ImageTarget{}

internal/artefacts/oci_registry.go

Lines changed: 106 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"path/filepath"
1313
"regexp"
1414
"strings"
15+
"sync"
1516
"time"
1617

1718
"github.com/alecthomas/atomic"
@@ -88,16 +89,12 @@ type RegistryConfig struct {
8889
}
8990

9091
type OCIArtefactService struct {
91-
auth *atomic.Value[authn.AuthConfig]
92-
puller *googleremote.Puller
93-
registry string
94-
allowInsecure bool
95-
logger *log.Logger
96-
}
97-
98-
func (s *OCIArtefactService) Authorization() (*authn.AuthConfig, error) {
99-
out := s.auth.Load()
100-
return &out, nil
92+
originalContext context.Context
93+
puller *googleremote.Puller
94+
targetConfig RegistryConfig
95+
logger *log.Logger
96+
registries map[string]*registryAuth
97+
registryLock sync.Mutex
10198
}
10299

103100
type ArtefactRepository struct {
@@ -114,6 +111,25 @@ type ArtefactBlobs struct {
114111
Size int64
115112
}
116113

114+
type registryAuth struct {
115+
delegate authn.Authenticator
116+
auth atomic.Value[*authn.AuthConfig]
117+
}
118+
119+
// Authorization implements authn.Authenticator.
120+
func (r *registryAuth) Authorization() (*authn.AuthConfig, error) {
121+
if r.delegate != nil {
122+
auth, err := r.delegate.Authorization()
123+
if err != nil {
124+
return nil, errors.Wrapf(err, "failed to authorize container registry")
125+
}
126+
if auth != nil {
127+
return auth, nil
128+
}
129+
}
130+
return r.auth.Load(), nil
131+
}
132+
117133
func NewForTesting() *OCIArtefactService {
118134
storage, err := NewOCIRegistryStorage(context.TODO(), RegistryConfig{Registry: "127.0.0.1:15000/ftl-tests", AllowInsecure: true})
119135
if err != nil {
@@ -132,47 +148,81 @@ func NewOCIRegistryStorage(ctx context.Context, config RegistryConfig) (*OCIArte
132148

133149
logger := log.FromContext(ctx)
134150
o := &OCIArtefactService{
135-
auth: &atomic.Value[authn.AuthConfig]{},
136-
registry: config.Registry,
137-
allowInsecure: config.AllowInsecure,
138-
logger: logger,
151+
originalContext: ctx,
152+
registries: map[string]*registryAuth{},
153+
targetConfig: config,
154+
logger: logger,
139155
}
140-
puller, err := googleremote.NewPuller(googleremote.WithAuth(o))
156+
157+
puller, err := googleremote.NewPuller(googleremote.WithAuthFromKeychain(o))
141158
if err != nil {
142-
return nil, errors.Wrapf(err, "unable to create puller for registry '%s'", config.Registry)
159+
return nil, errors.Wrapf(err, "unable to create puller")
143160
}
144161
o.puller = puller
162+
return o, nil
163+
}
164+
165+
// Resolve implements authn.Keychain.
166+
func (s *OCIArtefactService) Resolve(r authn.Resource) (authn.Authenticator, error) {
167+
s.registryLock.Lock()
168+
defer s.registryLock.Unlock()
145169

146-
if isECRRepository(config.Registry) {
170+
logger := log.FromContext(s.originalContext)
171+
registry := r.String()
172+
existing := s.registries[registry]
173+
if existing != nil {
174+
return existing, nil
175+
}
176+
cfg := &registryAuth{}
177+
s.registries[registry] = cfg
178+
cfg.auth.Store(&authn.AuthConfig{})
179+
180+
if registry == s.targetConfig.Registry &&
181+
s.targetConfig.Username != "" &&
182+
s.targetConfig.Password != "" {
183+
// The user has explicitly supplied credentials, lets use them
184+
cfg.auth.Store(&authn.AuthConfig{
185+
Username: s.targetConfig.Username,
186+
Password: s.targetConfig.Password,
187+
})
188+
return cfg, nil
189+
}
147190

148-
username, password, err := getECRCredentials(ctx)
191+
dctx, err := authn.DefaultKeychain.ResolveContext(s.originalContext, r)
192+
if err == nil {
193+
// Local docker config takes precidence
194+
cfg.delegate = dctx
195+
return cfg, nil
196+
}
197+
198+
if isECRRepository(registry) {
199+
200+
username, password, err := getECRCredentials(s.originalContext)
149201
if err != nil {
150202
return nil, errors.WithStack(err)
151203
}
152-
logger.Debugf("Using ECR credentials for registry '%s'", config.Registry)
153-
o.auth.Store(authn.AuthConfig{Username: username, Password: password})
204+
logger.Debugf("Using ECR credentials for registry '%s'", registry)
205+
cfg.auth.Store(&authn.AuthConfig{Username: username, Password: password})
154206
go func() {
155207
for {
156208
select {
157-
case <-ctx.Done():
209+
case <-s.originalContext.Done():
158210
return
159211
case <-time.After(time.Hour):
160-
username, password, err := getECRCredentials(ctx)
212+
username, password, err := getECRCredentials(s.originalContext)
161213
if err != nil {
162214
logger.Errorf(err, "failed to refresh ECR credentials")
163215
}
164-
o.auth.Store(authn.AuthConfig{Username: username, Password: password})
216+
cfg.auth.Store(&authn.AuthConfig{Username: username, Password: password})
165217
}
166218
}
167219
}()
168-
} else {
169-
o.auth.Store(authn.AuthConfig{Username: config.Username, Password: config.Password})
170220
}
171-
return o, nil
221+
return cfg, nil
172222
}
173223

174224
func (s *OCIArtefactService) GetRegistry() string {
175-
return s.registry
225+
return s.targetConfig.Registry
176226
}
177227

178228
func getECRCredentials(ctx context.Context) (string, string, error) {
@@ -212,7 +262,7 @@ func getECRCredentials(ctx context.Context) (string, string, error) {
212262
func (s *OCIArtefactService) GetDigestsKeys(ctx context.Context, digests []sha256.SHA256) (keys []ArtefactKey, missing []sha256.SHA256, err error) {
213263
repo, err := s.repoFactory()
214264
if err != nil {
215-
return nil, nil, errors.Wrapf(err, "unable to connect to container registry '%s'", s.registry)
265+
return nil, nil, errors.Wrapf(err, "unable to connect to container registry '%s'", s.targetConfig.Registry)
216266
}
217267
set := make(map[sha256.SHA256]bool)
218268
for _, d := range digests {
@@ -243,7 +293,7 @@ func (s *OCIArtefactService) Upload(ctx context.Context, artefact ArtefactUpload
243293
repo, err := s.repoFactory()
244294
logger := log.FromContext(ctx).Scope("oci:" + artefact.Digest.String())
245295
if err != nil {
246-
return errors.Wrapf(err, "unable to connect to repository '%s'", s.registry)
296+
return errors.Wrapf(err, "unable to connect to repository '%s'", s.targetConfig.Registry)
247297
}
248298

249299
parseSHA256, err := sha256.ParseSHA256(artefact.Digest.String())
@@ -298,15 +348,14 @@ func (s *OCIArtefactService) Download(ctx context.Context, dg sha256.SHA256) (io
298348
// So we are using google's go-containerregistry to do the actual download
299349
// This is not great, we should remove oras at some point
300350
opts := []name.Option{}
301-
if s.allowInsecure {
351+
if s.targetConfig.AllowInsecure {
302352
opts = append(opts, name.Insecure)
303353
}
304-
newDigest, err := name.NewDigest(fmt.Sprintf("%s@sha256:%s", s.registry, dg.String()), opts...)
354+
newDigest, err := name.NewDigest(fmt.Sprintf("%s@sha256:%s", s.targetConfig.Registry, dg.String()), opts...)
305355
if err != nil {
306356
return nil, errors.Wrapf(err, "unable to create digest '%s'", dg)
307357
}
308-
auth := s.auth.Load()
309-
layer, err := googleremote.Layer(newDigest, googleremote.WithAuth(authn.FromConfig(auth)), googleremote.Reuse(s.puller))
358+
layer, err := googleremote.Layer(newDigest, googleremote.WithAuthFromKeychain(s), googleremote.Reuse(s.puller))
310359
if err != nil {
311360
return nil, errors.Wrapf(err, "unable to read layer '%s'", newDigest)
312361
}
@@ -318,24 +367,36 @@ func (s *OCIArtefactService) Download(ctx context.Context, dg sha256.SHA256) (io
318367
}
319368

320369
func (s *OCIArtefactService) repoFactory() (*remote.Repository, error) {
321-
reg, err := remote.NewRepository(s.registry)
370+
reg, err := remote.NewRepository(s.targetConfig.Registry)
371+
if err != nil {
372+
return nil, errors.Wrapf(err, "unable to connect to container registry '%s'", s.targetConfig.Registry)
373+
}
374+
375+
ref, err := name.NewRepository(s.targetConfig.Registry)
376+
if err != nil {
377+
return nil, errors.Wrapf(err, "failed to parse registry")
378+
}
379+
a, err := s.Resolve(ref)
380+
if err != nil {
381+
return nil, errors.Wrapf(err, "failed to resolve authenticator")
382+
}
383+
acfg, err := a.Authorization()
322384
if err != nil {
323-
return nil, errors.Wrapf(err, "unable to connect to container registry '%s'", s.registry)
385+
return nil, errors.Wrapf(err, "failed to authenticate")
324386
}
325387

326-
a := s.auth.Load()
327-
s.logger.Debugf("Connecting to registry '%s'", s.registry)
388+
s.logger.Debugf("Connecting to registry '%s'", s.targetConfig.Registry)
328389
reg.Client = &auth.Client{
329390
Client: retry.DefaultClient,
330391
Cache: auth.NewCache(),
331392
Credential: func(ctx context.Context, hostport string) (auth.Credential, error) {
332393
return auth.Credential{
333-
Username: a.Username,
334-
Password: a.Password,
394+
Username: acfg.Username,
395+
Password: acfg.Password,
335396
}, nil
336397
},
337398
}
338-
reg.PlainHTTP = s.allowInsecure
399+
reg.PlainHTTP = s.targetConfig.AllowInsecure
339400
return reg, nil
340401
}
341402

@@ -418,12 +479,11 @@ func (s *OCIArtefactService) DownloadArtifacts(ctx context.Context, dest string,
418479
func WithRemotePush() ImageTarget {
419480
return func(ctx context.Context, s *OCIArtefactService, targetImage name.Tag, imageIndex v1.ImageIndex, image v1.Image, layers []v1.Layer) error {
420481
logger := log.FromContext(ctx)
421-
repo, err := name.NewRepository(s.registry)
482+
repo, err := name.NewRepository(s.targetConfig.Registry)
422483
if err != nil {
423484
return errors.Wrapf(err, "unable to parse repo")
424485
}
425-
auth := s.auth.Load()
426-
authOpt := googleremote.WithAuth(authn.FromConfig(auth))
486+
authOpt := googleremote.WithAuthFromKeychain(s)
427487

428488
for _, l := range layers {
429489
if err := googleremote.WriteLayer(repo, l, authOpt); err != nil {
@@ -489,7 +549,8 @@ func (s *OCIArtefactService) BuildOCIImage(ctx context.Context, baseImage string
489549
}
490550

491551
opts := []name.Option{}
492-
if s.allowInsecure {
552+
// TODO: use http:// scheme for allow/disallow insecure
553+
if s.targetConfig.AllowInsecure {
493554
opts = append(opts, name.Insecure)
494555
}
495556
logger := log.FromContext(ctx)
@@ -503,8 +564,7 @@ func (s *OCIArtefactService) BuildOCIImage(ctx context.Context, baseImage string
503564
return errors.Wrapf(err, "failed to parse target image")
504565
}
505566

506-
auth := s.auth.Load()
507-
desc, err := googleremote.Get(ref, googleremote.WithContext(ctx), googleremote.WithAuth(authn.FromConfig(auth)), googleremote.Reuse(s.puller))
567+
desc, err := googleremote.Get(ref, googleremote.WithContext(ctx), googleremote.WithAuthFromKeychain(s), googleremote.Reuse(s.puller))
508568
if err != nil {
509569
return errors.Errorf("getting base image metadata: %w", err)
510570
}

0 commit comments

Comments
 (0)