Skip to content

Commit

Permalink
Implement OCI auth for cloud providers
Browse files Browse the repository at this point in the history
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
  • Loading branch information
stefanprodan committed Aug 4, 2022
1 parent 8cc8798 commit 63c9439
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 63 deletions.
96 changes: 64 additions & 32 deletions controllers/ocirepository_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/ratelimiter"

"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/oci"
"github.com/fluxcd/pkg/oci/auth/login"
"github.com/fluxcd/pkg/runtime/conditions"
helper "github.com/fluxcd/pkg/runtime/controller"
"github.com/fluxcd/pkg/runtime/events"
Expand All @@ -64,14 +66,6 @@ import (
"github.com/fluxcd/source-controller/internal/util"
)

const (
ClientCert = "certFile"
ClientKey = "keyFile"
CACert = "caFile"
OCISourceKey = "org.opencontainers.image.source"
OCIRevisionKey = "org.opencontainers.image.revision"
)

// ociRepositoryReadyCondition contains the information required to summarize a
// v1beta2.OCIRepository Ready Condition.
var ociRepositoryReadyCondition = summarize.Conditions{
Expand Down Expand Up @@ -297,7 +291,9 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration)
defer cancel()

// Generate the registry credential keychain
options := r.craneOptions(ctxTimeout)

// Generate the registry credential keychain either from static credentials or using cloud OIDC
keychain, err := r.keychain(ctx, obj)
if err != nil {
e := serror.NewGeneric(
Expand All @@ -307,6 +303,22 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
return sreconcile.ResultEmpty, e
}
options = append(options, crane.WithAuthFromKeychain(keychain))

if obj.Spec.Provider != sourcev1.GenericOCIProvider {
auth, authErr := r.oidcAuth(ctxTimeout, obj)
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
e := serror.NewGeneric(
fmt.Errorf("failed to get credential from %s: %w", obj.Spec.Provider, authErr),
sourcev1.AuthenticationFailedReason,
)
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
return sreconcile.ResultEmpty, e
}
if auth != nil {
options = append(options, crane.WithAuth(auth))
}
}

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

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

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

imageName := strings.TrimPrefix(url, ref.Context().RegistryStr())
if s := strings.Split(imageName, ":"); len(s) > 1 {
return "", fmt.Errorf("URL must not contain a tag; remove ':%s'", s[1])
}

return ref.Context().Name(), nil
}

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

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

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

if clientCert, ok := certSecret.Data[ClientCert]; ok {
if clientCert, ok := certSecret.Data[oci.ClientCert]; ok {
// parse and set client cert and secret
if clientKey, ok := certSecret.Data[ClientKey]; ok {
if clientKey, ok := certSecret.Data[oci.ClientKey]; ok {
cert, err := tls.X509KeyPair(clientCert, clientKey)
if err != nil {
return nil, err
}
tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
} else {
return nil, fmt.Errorf("'%s' found in secret, but no %s", ClientCert, ClientKey)
return nil, fmt.Errorf("'%s' found in secret, but no %s", oci.ClientCert, oci.ClientKey)
}
}

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

}

// oidcAuth generates the OIDC credential authenticator based on the specified cloud provider.
func (r *OCIRepositoryReconciler) oidcAuth(ctx context.Context, obj *sourcev1.OCIRepository) (authn.Authenticator, error) {
url := strings.TrimPrefix(obj.Spec.URL, sourcev1.OCIRepositoryPrefix)
ref, err := name.ParseReference(url)
if err != nil {
return nil, fmt.Errorf("failed to parse URL '%s': %w", obj.Spec.URL, err)
}

opts := login.ProviderOptions{}
switch obj.Spec.Provider {
case sourcev1.AmazonOCIProvider:
opts.AwsAutoLogin = true
case sourcev1.AzureOCIProvider:
opts.AzureAutoLogin = true
case sourcev1.GoogleOCIProvider:
opts.GcpAutoLogin = true
}

return login.NewManager().Login(ctx, url, ref, opts)
}

// craneOptions sets the auth headers, timeout and user agent
// for all operations against remote container registries.
func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context,
keychain authn.Keychain, transport http.RoundTripper) []crane.Option {
func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context) []crane.Option {
options := []crane.Option{
crane.WithContext(ctx),
crane.WithUserAgent("flux/v2"),
crane.WithAuthFromKeychain(keychain),
crane.WithUserAgent(oci.UserAgent),
}

if transport != nil {
options = append(options, crane.WithTransport(transport))
}

return options
}

Expand Down Expand Up @@ -834,10 +866,10 @@ func (r *OCIRepositoryReconciler) notify(ctx context.Context,
// enrich message with upstream annotations if found
if info := newObj.GetArtifact().Metadata; info != nil {
var source, revision string
if val, ok := info[OCISourceKey]; ok {
if val, ok := info[oci.SourceAnnotation]; ok {
source = val
}
if val, ok := info[OCIRevisionKey]; ok {
if val, ok := info[oci.RevisionAnnotation]; ok {
revision = val
}
if source != "" && revision != "" {
Expand Down
38 changes: 21 additions & 17 deletions controllers/ocirepository_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,9 @@ import (
"testing"
"time"

corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/tools/record"

"github.com/darkowlzz/controller-check/status"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/oci"
"github.com/fluxcd/pkg/runtime/conditions"
"github.com/fluxcd/pkg/runtime/patch"
"github.com/fluxcd/pkg/untar"
Expand All @@ -54,8 +52,10 @@ import (
gcrv1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/mutate"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/record"
kstatus "sigs.k8s.io/cli-utils/pkg/kstatus/status"
"sigs.k8s.io/controller-runtime/pkg/client"
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
Expand Down Expand Up @@ -172,8 +172,8 @@ func TestOCIRepository_Reconcile(t *testing.T) {
g.Expect(obj.Status.Artifact.Revision).To(Equal(tt.digest))

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

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

repoURL, err := r.getArtifactURL(ctx, obj, nil, nil)
opts := r.craneOptions(ctx)
opts = append(opts, crane.WithAuthFromKeychain(authn.DefaultKeychain))
repoURL, err := r.getArtifactURL(obj, opts)
g.Expect(err).To(BeNil())

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

tlsSecretClientCert := corev1.Secret{
StringData: map[string]string{
CACert: string(rootCertPEM),
ClientCert: string(clientCertPEM),
ClientKey: string(clientKeyPEM),
oci.CACert: string(rootCertPEM),
oci.ClientCert: string(clientCertPEM),
oci.ClientKey: string(clientKeyPEM),
},
}

Expand Down Expand Up @@ -601,9 +603,9 @@ func TestOCIRepository_CertSecret(t *testing.T) {
digest: pi.digest,
certSecret: &corev1.Secret{
StringData: map[string]string{
CACert: string(rootCertPEM),
ClientCert: string(clientCertPEM),
ClientKey: string("invalid-key"),
oci.CACert: string(rootCertPEM),
oci.ClientCert: string(clientCertPEM),
oci.ClientKey: string("invalid-key"),
},
},
expectreadyconition: false,
Expand Down Expand Up @@ -1049,7 +1051,9 @@ func TestOCIRepository_getArtifactURL(t *testing.T) {
obj.Spec.Reference = tt.reference
}

got, err := r.getArtifactURL(ctx, obj, authn.DefaultKeychain, nil)
opts := r.craneOptions(ctx)
opts = append(opts, crane.WithAuthFromKeychain(authn.DefaultKeychain))
got, err := r.getArtifactURL(obj, opts)
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
return
Expand Down Expand Up @@ -1266,8 +1270,8 @@ func TestOCIRepositoryReconciler_notify(t *testing.T) {
Revision: "xxx",
Checksum: "yyy",
Metadata: map[string]string{
OCISourceKey: "https://github.com/stefanprodan/podinfo",
OCIRevisionKey: "6.1.8/b3b00fe35424a45d373bf4c7214178bc36fd7872",
oci.SourceAnnotation: "https://github.com/stefanprodan/podinfo",
oci.RevisionAnnotation: "6.1.8/b3b00fe35424a45d373bf4c7214178bc36fd7872",
},
}
},
Expand Down Expand Up @@ -1438,8 +1442,8 @@ func pushMultiplePodinfoImages(serverURL string, versions ...string) (map[string

func setPodinfoImageAnnotations(img gcrv1.Image, tag string) gcrv1.Image {
metadata := map[string]string{
OCISourceKey: "https://github.com/stefanprodan/podinfo",
OCIRevisionKey: fmt.Sprintf("%s/SHA", tag),
oci.SourceAnnotation: "https://github.com/stefanprodan/podinfo",
oci.RevisionAnnotation: fmt.Sprintf("%s/SHA", tag),
}
return mutate.Annotations(img, metadata).(gcrv1.Image)
}
Expand Down
27 changes: 27 additions & 0 deletions docs/spec/v1beta2/ocirepositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,33 @@ container image repository in the format `oci://<host>:<port>/<org-name>/<repo-n

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

### Provider

`.spec.provider` is an optional field that allows specifying an OIDC provider used for
authentication purposes.

Supported options are:

- `generic`
- `aws`
- `azure`
- `gcp`

The `generic` provider can be used for public repositories or when
static credentials are used for authentication, either with
`spec.secretRef` or `spec.serviceAccountName`.
If you do not specify `.spec.provider`, it defaults to `generic`.

The `aws` provider can be used when the source-controller service account
is associate with an AWS IAM Role using IRSA that grants read-only access to ECR.

The `azure` provider can be used when the source-controller pods are associate
with an Azure AAD Pod Identity that grants read-only access to ACR.

The `gcp` provider can be used when the source-controller service account
is associate with a GCP IAM Role using Workload Identity that grants
read-only access to Artifact Registry.

### Secret reference

`.spec.secretRef.name` is an optional field to specify a name reference to a
Expand Down
Loading

0 comments on commit 63c9439

Please sign in to comment.