Skip to content

Commit 63c9439

Browse files
committed
Implement OCI auth for cloud providers
Signed-off-by: Stefan Prodan <[email protected]>
1 parent 8cc8798 commit 63c9439

File tree

5 files changed

+137
-63
lines changed

5 files changed

+137
-63
lines changed

controllers/ocirepository_controller.go

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ import (
5050
"sigs.k8s.io/controller-runtime/pkg/ratelimiter"
5151

5252
"github.com/fluxcd/pkg/apis/meta"
53+
"github.com/fluxcd/pkg/oci"
54+
"github.com/fluxcd/pkg/oci/auth/login"
5355
"github.com/fluxcd/pkg/runtime/conditions"
5456
helper "github.com/fluxcd/pkg/runtime/controller"
5557
"github.com/fluxcd/pkg/runtime/events"
@@ -64,14 +66,6 @@ import (
6466
"github.com/fluxcd/source-controller/internal/util"
6567
)
6668

67-
const (
68-
ClientCert = "certFile"
69-
ClientKey = "keyFile"
70-
CACert = "caFile"
71-
OCISourceKey = "org.opencontainers.image.source"
72-
OCIRevisionKey = "org.opencontainers.image.revision"
73-
)
74-
7569
// ociRepositoryReadyCondition contains the information required to summarize a
7670
// v1beta2.OCIRepository Ready Condition.
7771
var ociRepositoryReadyCondition = summarize.Conditions{
@@ -297,7 +291,9 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
297291
ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration)
298292
defer cancel()
299293

300-
// Generate the registry credential keychain
294+
options := r.craneOptions(ctxTimeout)
295+
296+
// Generate the registry credential keychain either from static credentials or using cloud OIDC
301297
keychain, err := r.keychain(ctx, obj)
302298
if err != nil {
303299
e := serror.NewGeneric(
@@ -307,6 +303,22 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
307303
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
308304
return sreconcile.ResultEmpty, e
309305
}
306+
options = append(options, crane.WithAuthFromKeychain(keychain))
307+
308+
if obj.Spec.Provider != sourcev1.GenericOCIProvider {
309+
auth, authErr := r.oidcAuth(ctxTimeout, obj)
310+
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
311+
e := serror.NewGeneric(
312+
fmt.Errorf("failed to get credential from %s: %w", obj.Spec.Provider, authErr),
313+
sourcev1.AuthenticationFailedReason,
314+
)
315+
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
316+
return sreconcile.ResultEmpty, e
317+
}
318+
if auth != nil {
319+
options = append(options, crane.WithAuth(auth))
320+
}
321+
}
310322

311323
// Generate the transport for remote operations
312324
transport, err := r.transport(ctx, obj)
@@ -318,9 +330,12 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
318330
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
319331
return sreconcile.ResultEmpty, e
320332
}
333+
if transport != nil {
334+
options = append(options, crane.WithTransport(transport))
335+
}
321336

322337
// Determine which artifact revision to pull
323-
url, err := r.getArtifactURL(ctxTimeout, obj, keychain, transport)
338+
url, err := r.getArtifactURL(obj, options)
324339
if err != nil {
325340
e := serror.NewGeneric(
326341
fmt.Errorf("failed to determine the artifact address for '%s': %w", obj.Spec.URL, err),
@@ -330,7 +345,7 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
330345
}
331346

332347
// Pull artifact from the remote container registry
333-
img, err := crane.Pull(url, r.craneOptions(ctxTimeout, keychain, transport)...)
348+
img, err := crane.Pull(url, options...)
334349
if err != nil {
335350
e := serror.NewGeneric(
336351
fmt.Errorf("failed to pull artifact from '%s': %w", obj.Spec.URL, err),
@@ -437,12 +452,16 @@ func (r *OCIRepositoryReconciler) parseRepositoryURL(obj *sourcev1.OCIRepository
437452
return "", err
438453
}
439454

455+
imageName := strings.TrimPrefix(url, ref.Context().RegistryStr())
456+
if s := strings.Split(imageName, ":"); len(s) > 1 {
457+
return "", fmt.Errorf("URL must not contain a tag; remove ':%s'", s[1])
458+
}
459+
440460
return ref.Context().Name(), nil
441461
}
442462

443463
// getArtifactURL determines which tag or digest should be used and returns the OCI artifact FQN.
444-
func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context,
445-
obj *sourcev1.OCIRepository, keychain authn.Keychain, transport http.RoundTripper) (string, error) {
464+
func (r *OCIRepositoryReconciler) getArtifactURL(obj *sourcev1.OCIRepository, options []crane.Option) (string, error) {
446465
url, err := r.parseRepositoryURL(obj)
447466
if err != nil {
448467
return "", err
@@ -454,7 +473,7 @@ func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context,
454473
}
455474

456475
if obj.Spec.Reference.SemVer != "" {
457-
tag, err := r.getTagBySemver(ctx, url, obj.Spec.Reference.SemVer, keychain, transport)
476+
tag, err := r.getTagBySemver(url, obj.Spec.Reference.SemVer, options)
458477
if err != nil {
459478
return "", err
460479
}
@@ -471,9 +490,8 @@ func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context,
471490

472491
// getTagBySemver call the remote container registry, fetches all the tags from the repository,
473492
// and returns the latest tag according to the semver expression.
474-
func (r *OCIRepositoryReconciler) getTagBySemver(ctx context.Context,
475-
url, exp string, keychain authn.Keychain, transport http.RoundTripper) (string, error) {
476-
tags, err := crane.ListTags(url, r.craneOptions(ctx, keychain, transport)...)
493+
func (r *OCIRepositoryReconciler) getTagBySemver(url, exp string, options []crane.Option) (string, error) {
494+
tags, err := crane.ListTags(url, options...)
477495
if err != nil {
478496
return "", err
479497
}
@@ -567,20 +585,20 @@ func (r *OCIRepositoryReconciler) transport(ctx context.Context, obj *sourcev1.O
567585
transport := remote.DefaultTransport.Clone()
568586
tlsConfig := transport.TLSClientConfig
569587

570-
if clientCert, ok := certSecret.Data[ClientCert]; ok {
588+
if clientCert, ok := certSecret.Data[oci.ClientCert]; ok {
571589
// parse and set client cert and secret
572-
if clientKey, ok := certSecret.Data[ClientKey]; ok {
590+
if clientKey, ok := certSecret.Data[oci.ClientKey]; ok {
573591
cert, err := tls.X509KeyPair(clientCert, clientKey)
574592
if err != nil {
575593
return nil, err
576594
}
577595
tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
578596
} else {
579-
return nil, fmt.Errorf("'%s' found in secret, but no %s", ClientCert, ClientKey)
597+
return nil, fmt.Errorf("'%s' found in secret, but no %s", oci.ClientCert, oci.ClientKey)
580598
}
581599
}
582600

583-
if caCert, ok := certSecret.Data[CACert]; ok {
601+
if caCert, ok := certSecret.Data[oci.CACert]; ok {
584602
syscerts, err := x509.SystemCertPool()
585603
if err != nil {
586604
return nil, err
@@ -592,20 +610,34 @@ func (r *OCIRepositoryReconciler) transport(ctx context.Context, obj *sourcev1.O
592610

593611
}
594612

613+
// oidcAuth generates the OIDC credential authenticator based on the specified cloud provider.
614+
func (r *OCIRepositoryReconciler) oidcAuth(ctx context.Context, obj *sourcev1.OCIRepository) (authn.Authenticator, error) {
615+
url := strings.TrimPrefix(obj.Spec.URL, sourcev1.OCIRepositoryPrefix)
616+
ref, err := name.ParseReference(url)
617+
if err != nil {
618+
return nil, fmt.Errorf("failed to parse URL '%s': %w", obj.Spec.URL, err)
619+
}
620+
621+
opts := login.ProviderOptions{}
622+
switch obj.Spec.Provider {
623+
case sourcev1.AmazonOCIProvider:
624+
opts.AwsAutoLogin = true
625+
case sourcev1.AzureOCIProvider:
626+
opts.AzureAutoLogin = true
627+
case sourcev1.GoogleOCIProvider:
628+
opts.GcpAutoLogin = true
629+
}
630+
631+
return login.NewManager().Login(ctx, url, ref, opts)
632+
}
633+
595634
// craneOptions sets the auth headers, timeout and user agent
596635
// for all operations against remote container registries.
597-
func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context,
598-
keychain authn.Keychain, transport http.RoundTripper) []crane.Option {
636+
func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context) []crane.Option {
599637
options := []crane.Option{
600638
crane.WithContext(ctx),
601-
crane.WithUserAgent("flux/v2"),
602-
crane.WithAuthFromKeychain(keychain),
639+
crane.WithUserAgent(oci.UserAgent),
603640
}
604-
605-
if transport != nil {
606-
options = append(options, crane.WithTransport(transport))
607-
}
608-
609641
return options
610642
}
611643

@@ -834,10 +866,10 @@ func (r *OCIRepositoryReconciler) notify(ctx context.Context,
834866
// enrich message with upstream annotations if found
835867
if info := newObj.GetArtifact().Metadata; info != nil {
836868
var source, revision string
837-
if val, ok := info[OCISourceKey]; ok {
869+
if val, ok := info[oci.SourceAnnotation]; ok {
838870
source = val
839871
}
840-
if val, ok := info[OCIRevisionKey]; ok {
872+
if val, ok := info[oci.RevisionAnnotation]; ok {
841873
revision = val
842874
}
843875
if source != "" && revision != "" {

controllers/ocirepository_controller_test.go

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,9 @@ import (
3636
"testing"
3737
"time"
3838

39-
corev1 "k8s.io/api/core/v1"
40-
"k8s.io/client-go/tools/record"
41-
4239
"github.com/darkowlzz/controller-check/status"
4340
"github.com/fluxcd/pkg/apis/meta"
41+
"github.com/fluxcd/pkg/oci"
4442
"github.com/fluxcd/pkg/runtime/conditions"
4543
"github.com/fluxcd/pkg/runtime/patch"
4644
"github.com/fluxcd/pkg/untar"
@@ -54,8 +52,10 @@ import (
5452
gcrv1 "github.com/google/go-containerregistry/pkg/v1"
5553
"github.com/google/go-containerregistry/pkg/v1/mutate"
5654
. "github.com/onsi/gomega"
55+
corev1 "k8s.io/api/core/v1"
5756
apierrors "k8s.io/apimachinery/pkg/api/errors"
5857
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
58+
"k8s.io/client-go/tools/record"
5959
kstatus "sigs.k8s.io/cli-utils/pkg/kstatus/status"
6060
"sigs.k8s.io/controller-runtime/pkg/client"
6161
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -172,8 +172,8 @@ func TestOCIRepository_Reconcile(t *testing.T) {
172172
g.Expect(obj.Status.Artifact.Revision).To(Equal(tt.digest))
173173

174174
// Check if the metadata matches the expected annotations
175-
g.Expect(obj.Status.Artifact.Metadata[OCISourceKey]).To(ContainSubstring("podinfo"))
176-
g.Expect(obj.Status.Artifact.Metadata[OCIRevisionKey]).To(ContainSubstring(tt.tag))
175+
g.Expect(obj.Status.Artifact.Metadata[oci.SourceAnnotation]).To(ContainSubstring("podinfo"))
176+
g.Expect(obj.Status.Artifact.Metadata[oci.RevisionAnnotation]).To(ContainSubstring(tt.tag))
177177

178178
// Check if the artifact storage path matches the expected file path
179179
localPath := testStorage.LocalPath(*obj.Status.Artifact)
@@ -516,7 +516,9 @@ func TestOCIRepository_reconcileSource_authStrategy(t *testing.T) {
516516
Storage: testStorage,
517517
}
518518

519-
repoURL, err := r.getArtifactURL(ctx, obj, nil, nil)
519+
opts := r.craneOptions(ctx)
520+
opts = append(opts, crane.WithAuthFromKeychain(authn.DefaultKeychain))
521+
repoURL, err := r.getArtifactURL(obj, opts)
520522
g.Expect(err).To(BeNil())
521523

522524
assertConditions := tt.assertConditions
@@ -566,9 +568,9 @@ func TestOCIRepository_CertSecret(t *testing.T) {
566568

567569
tlsSecretClientCert := corev1.Secret{
568570
StringData: map[string]string{
569-
CACert: string(rootCertPEM),
570-
ClientCert: string(clientCertPEM),
571-
ClientKey: string(clientKeyPEM),
571+
oci.CACert: string(rootCertPEM),
572+
oci.ClientCert: string(clientCertPEM),
573+
oci.ClientKey: string(clientKeyPEM),
572574
},
573575
}
574576

@@ -601,9 +603,9 @@ func TestOCIRepository_CertSecret(t *testing.T) {
601603
digest: pi.digest,
602604
certSecret: &corev1.Secret{
603605
StringData: map[string]string{
604-
CACert: string(rootCertPEM),
605-
ClientCert: string(clientCertPEM),
606-
ClientKey: string("invalid-key"),
606+
oci.CACert: string(rootCertPEM),
607+
oci.ClientCert: string(clientCertPEM),
608+
oci.ClientKey: string("invalid-key"),
607609
},
608610
},
609611
expectreadyconition: false,
@@ -1049,7 +1051,9 @@ func TestOCIRepository_getArtifactURL(t *testing.T) {
10491051
obj.Spec.Reference = tt.reference
10501052
}
10511053

1052-
got, err := r.getArtifactURL(ctx, obj, authn.DefaultKeychain, nil)
1054+
opts := r.craneOptions(ctx)
1055+
opts = append(opts, crane.WithAuthFromKeychain(authn.DefaultKeychain))
1056+
got, err := r.getArtifactURL(obj, opts)
10531057
if tt.wantErr {
10541058
g.Expect(err).To(HaveOccurred())
10551059
return
@@ -1266,8 +1270,8 @@ func TestOCIRepositoryReconciler_notify(t *testing.T) {
12661270
Revision: "xxx",
12671271
Checksum: "yyy",
12681272
Metadata: map[string]string{
1269-
OCISourceKey: "https://github.com/stefanprodan/podinfo",
1270-
OCIRevisionKey: "6.1.8/b3b00fe35424a45d373bf4c7214178bc36fd7872",
1273+
oci.SourceAnnotation: "https://github.com/stefanprodan/podinfo",
1274+
oci.RevisionAnnotation: "6.1.8/b3b00fe35424a45d373bf4c7214178bc36fd7872",
12711275
},
12721276
}
12731277
},
@@ -1438,8 +1442,8 @@ func pushMultiplePodinfoImages(serverURL string, versions ...string) (map[string
14381442

14391443
func setPodinfoImageAnnotations(img gcrv1.Image, tag string) gcrv1.Image {
14401444
metadata := map[string]string{
1441-
OCISourceKey: "https://github.com/stefanprodan/podinfo",
1442-
OCIRevisionKey: fmt.Sprintf("%s/SHA", tag),
1445+
oci.SourceAnnotation: "https://github.com/stefanprodan/podinfo",
1446+
oci.RevisionAnnotation: fmt.Sprintf("%s/SHA", tag),
14431447
}
14441448
return mutate.Annotations(img, metadata).(gcrv1.Image)
14451449
}

docs/spec/v1beta2/ocirepositories.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,33 @@ container image repository in the format `oci://<host>:<port>/<org-name>/<repo-n
101101

102102
**Note:** that specifying a tag or digest is not in accepted for this field.
103103

104+
### Provider
105+
106+
`.spec.provider` is an optional field that allows specifying an OIDC provider used for
107+
authentication purposes.
108+
109+
Supported options are:
110+
111+
- `generic`
112+
- `aws`
113+
- `azure`
114+
- `gcp`
115+
116+
The `generic` provider can be used for public repositories or when
117+
static credentials are used for authentication, either with
118+
`spec.secretRef` or `spec.serviceAccountName`.
119+
If you do not specify `.spec.provider`, it defaults to `generic`.
120+
121+
The `aws` provider can be used when the source-controller service account
122+
is associate with an AWS IAM Role using IRSA that grants read-only access to ECR.
123+
124+
The `azure` provider can be used when the source-controller pods are associate
125+
with an Azure AAD Pod Identity that grants read-only access to ACR.
126+
127+
The `gcp` provider can be used when the source-controller service account
128+
is associate with a GCP IAM Role using Workload Identity that grants
129+
read-only access to Artifact Registry.
130+
104131
### Secret reference
105132

106133
`.spec.secretRef.name` is an optional field to specify a name reference to a

0 commit comments

Comments
 (0)