diff --git a/controllers/imagerepository_controller.go b/controllers/imagerepository_controller.go index 3e9ff2a1..7822239e 100644 --- a/controllers/imagerepository_controller.go +++ b/controllers/imagerepository_controller.go @@ -21,11 +21,9 @@ import ( "context" "crypto/tls" "crypto/x509" - "encoding/base64" "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "regexp" @@ -49,20 +47,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/predicate" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/ecr" - "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/runtime/events" "github.com/fluxcd/pkg/runtime/metrics" "github.com/fluxcd/pkg/runtime/predicates" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta1" - "github.com/fluxcd/image-reflector-controller/internal/azure" + "github.com/fluxcd/image-reflector-controller/internal/registry/login" ) // These are intended to match the keys used in e.g., @@ -87,10 +78,7 @@ type ImageRepositoryReconciler struct { DatabaseWriter DatabaseReader } - - AwsAutoLogin bool // automatically attempt to get credentials for images in ECR - GcpAutoLogin bool // automatically attempt to get credentials for images in GCP - AzureAutoLogin bool // automatically attempt to get credentials for images in ACR + login.ProviderOptions } type ImageRepositoryReconcilerOptions struct { @@ -101,12 +89,6 @@ type dockerConfig struct { Auths map[string]authn.AuthConfig } -type gceToken struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - TokenType string `json:"token_type"` -} - // +kubebuilder:rbac:groups=image.toolkit.fluxcd.io,resources=imagerepositories,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=image.toolkit.fluxcd.io,resources=imagerepositories/status,verbs=get;update;patch // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch @@ -246,102 +228,16 @@ func parseImageReference(url string) (name.Reference, error) { return ref, nil } -// parseAwsImage returns the AWS account ID and region and `true` if -// the image repository is hosted in AWS's Elastic Container Registry, -// otherwise empty strings and `false`. -func parseAwsImage(image string) (accountId, awsEcrRegion string, ok bool) { - registryPartRe := regexp.MustCompile(`([0-9+]*).dkr.ecr.([^/.]*)\.(amazonaws\.com[.cn]*)/([^:]+):?(.*)`) - registryParts := registryPartRe.FindAllStringSubmatch(image, -1) - if len(registryParts) < 1 { - return "", "", false - } - return registryParts[0][1], registryParts[0][2], true -} - -// getAwsEcrLoginAuth obtains authentication for ECR given the account -// ID and region (taken from the image). This assumes that the pod has -// IAM permissions to get an authentication token, which will usually -// be the case if it's running in EKS, and may need additional setup -// otherwise (visit -// https://docs.aws.amazon.com/sdk-for-go/api/aws/session/ as a -// starting point). -func getAwsECRLoginAuth(accountId, awsEcrRegion string) (authn.AuthConfig, error) { - // No caching of tokens is attempted; the quota for getting an - // auth token is high enough that getting a token every time you - // scan an image is viable for O(1000) images per region. See - // https://docs.aws.amazon.com/general/latest/gr/ecr.html. - var authConfig authn.AuthConfig - - accountIDs := []string{accountId} - ecrService := ecr.New(session.Must(session.NewSession(&aws.Config{Region: aws.String(awsEcrRegion)}))) - ecrToken, err := ecrService.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{ - RegistryIds: aws.StringSlice(accountIDs), - }) - if err != nil { - return authConfig, err - } - - token, err := base64.StdEncoding.DecodeString(*ecrToken.AuthorizationData[0].AuthorizationToken) - if err != nil { - return authConfig, err - } - - tokenSplit := strings.Split(string(token), ":") - authConfig = authn.AuthConfig{ - Username: tokenSplit[0], - Password: tokenSplit[1], - } - return authConfig, nil -} - -// getGCRLoginAuth obtains authentication for the image by -// getting a token from the metadata API on GCP. This assumes that -// the pod has right to pull the image which would be the case if it -// is hosted on GCP. It works with both service account and workload identity -// enabled clusters. -func getGCRLoginAuth(ctx context.Context) (authn.AuthConfig, error) { - var authConfig authn.AuthConfig - const gcpDefaultTokenURL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" - - request, err := http.NewRequestWithContext(ctx, http.MethodGet, gcpDefaultTokenURL, nil) - if err != nil { - return authConfig, err - } - - request.Header.Add("Metadata-Flavor", "Google") - - client := &http.Client{} - response, err := client.Do(request) - if err != nil { - return authConfig, err - } - defer response.Body.Close() - defer io.Copy(io.Discard, response.Body) - - if response.StatusCode != http.StatusOK { - return authConfig, fmt.Errorf("unexpected status from metadata service: %s", response.Status) - } - - var accessToken gceToken - decoder := json.NewDecoder(response.Body) - if err := decoder.Decode(&accessToken); err != nil { - return authConfig, err - } - - authConfig = authn.AuthConfig{ - Username: "oauth2accesstoken", - Password: accessToken.AccessToken, - } - return authConfig, nil -} - func (r *ImageRepositoryReconciler) scan(ctx context.Context, imageRepo *imagev1.ImageRepository, ref name.Reference) error { timeout := imageRepo.GetTimeout() ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + // Configure authentication strategy to access the registry. var options []remote.Option var authSecret corev1.Secret + var auth authn.Authenticator + var authErr error if imageRepo.Spec.SecretRef != nil { if err := r.Get(ctx, types.NamespacedName{ Namespace: imageRepo.GetNamespace(), @@ -355,79 +251,25 @@ func (r *ImageRepositoryReconciler) scan(ctx context.Context, imageRepo *imagev1 ) return err } - auth, err := authFromSecret(authSecret, ref) - if err != nil { - imagev1.SetImageRepositoryReadiness( - imageRepo, - metav1.ConditionFalse, - imagev1.ReconciliationFailedReason, - err.Error(), - ) - return err - } + auth, authErr = authFromSecret(authSecret, ref) + } else { + // Use the registry provider options to attempt registry login. + auth, authErr = login.NewManager().Login(ctx, imageRepo.Spec.Image, ref, r.ProviderOptions) + } + if authErr != nil { + imagev1.SetImageRepositoryReadiness( + imageRepo, + metav1.ConditionFalse, + imagev1.ReconciliationFailedReason, + authErr.Error(), + ) + return authErr + } + if auth != nil { options = append(options, remote.WithAuth(auth)) - } else if accountId, awsEcrRegion, ok := parseAwsImage(imageRepo.Spec.Image); ok { - if r.AwsAutoLogin { - ctrl.LoggerFrom(ctx).Info("Logging in to AWS ECR for " + imageRepo.Spec.Image) - - authConfig, err := getAwsECRLoginAuth(accountId, awsEcrRegion) - if err != nil { - imagev1.SetImageRepositoryReadiness( - imageRepo, - metav1.ConditionFalse, - imagev1.ReconciliationFailedReason, - err.Error(), - ) - return err - } - - auth := authn.FromConfig(authConfig) - options = append(options, remote.WithAuth(auth)) - } else { - ctrl.LoggerFrom(ctx).Info("No image credentials secret referenced, and ECR authentication is not enabled. To enable, set the controller flag --aws-autologin-for-ecr") - } - } else if hostIsGoogleContainerRegistry(ref.Context().RegistryStr()) { - if r.GcpAutoLogin { - ctrl.LoggerFrom(ctx).Info("Logging in to GCP GCR for " + imageRepo.Spec.Image) - authConfig, err := getGCRLoginAuth(ctx) - if err != nil { - ctrl.LoggerFrom(ctx).Info("error logging into GCP " + err.Error()) - imagev1.SetImageRepositoryReadiness( - imageRepo, - metav1.ConditionFalse, - imagev1.ReconciliationFailedReason, - err.Error(), - ) - return err - } - - auth := authn.FromConfig(authConfig) - options = append(options, remote.WithAuth(auth)) - } else { - ctrl.LoggerFrom(ctx).Info("No image credentials secret referenced, and GCR authentication is not enabled. To enable, set the controller flag --gcp-autologin-for-gcr") - } - } else if hostIsAzureContainerRegistry(ref.Context().RegistryStr()) { - if r.AzureAutoLogin { - ctrl.LoggerFrom(ctx).Info("Logging in to Azure ACR for " + imageRepo.Spec.Image) - authConfig, err := getAzureLoginAuth(ctx, ref) - if err != nil { - ctrl.LoggerFrom(ctx).Info("error logging into ACR " + err.Error()) - imagev1.SetImageRepositoryReadiness( - imageRepo, - metav1.ConditionFalse, - imagev1.ReconciliationFailedReason, - err.Error(), - ) - return err - } - - auth := authn.FromConfig(authConfig) - options = append(options, remote.WithAuth(auth)) - } else { - ctrl.LoggerFrom(ctx).Info("No image credentials secret referenced, and ACR authentication is not enabled. To enable, set the controller flag --azure-autologin-for-acr") - } } + // Load any provided certificate. if imageRepo.Spec.CertSecretRef != nil { var certSecret corev1.Secret if imageRepo.Spec.SecretRef != nil && imageRepo.Spec.SecretRef.Name == imageRepo.Spec.CertSecretRef.Name { @@ -779,47 +621,3 @@ func getURLHost(urlStr string) (string, error) { return u.Host, nil } - -// getAzureLoginAuth returns authentication for ACR. The details needed for authentication -// are gotten from environment variable so there is not need to mount a host path. -func getAzureLoginAuth(ctx context.Context, ref name.Reference) (authn.AuthConfig, error) { - var authConfig authn.AuthConfig - - cred, err := azidentity.NewDefaultAzureCredential(nil) - if err != nil { - return authConfig, err - } - armToken, err := cred.GetToken(ctx, policy.TokenRequestOptions{ - Scopes: []string{string(arm.AzurePublicCloud) + ".default"}, - }) - if err != nil { - return authConfig, err - } - - ex := azure.NewExchanger(ref.Context().RegistryStr()) - accessToken, err := ex.ExchangeACRAccessToken(string(armToken.Token)) - if err != nil { - return authConfig, fmt.Errorf("error exchanging token: %w", err) - } - - return authn.AuthConfig{ - // this is the acr username used by Azure - // See documentation: https://docs.microsoft.com/en-us/azure/container-registry/container-registry-authentication?tabs=azure-cli#az-acr-login-with---expose-token - Username: "00000000-0000-0000-0000-000000000000", - Password: accessToken, - }, nil -} - -// List from https://github.com/kubernetes/kubernetes/blob/v1.23.1/pkg/credentialprovider/azure/azure_credentials.go#L55 -func hostIsAzureContainerRegistry(host string) bool { - for _, v := range []string{".azurecr.io", ".azurecr.cn", ".azurecr.de", ".azurecr.us"} { - if strings.HasSuffix(host, v) { - return true - } - } - return false -} - -func hostIsGoogleContainerRegistry(host string) bool { - return host == "gcr.io" || strings.HasSuffix(host, ".gcr.io") || strings.HasSuffix(host, "-docker.pkg.dev") -} diff --git a/internal/registry/aws/auth.go b/internal/registry/aws/auth.go new file mode 100644 index 00000000..79645e41 --- /dev/null +++ b/internal/registry/aws/auth.go @@ -0,0 +1,131 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package aws + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ecr" + "github.com/google/go-containerregistry/pkg/authn" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/fluxcd/image-reflector-controller/internal/registry" +) + +var registryPartRe = regexp.MustCompile(`([0-9+]*).dkr.ecr.([^/.]*)\.(amazonaws\.com[.cn]*)/([^:]+):?(.*)`) + +// ParseImage returns the AWS account ID and region and `true` if +// the image repository is hosted in AWS's Elastic Container Registry, +// otherwise empty strings and `false`. +func ParseImage(image string) (accountId, awsEcrRegion string, ok bool) { + registryParts := registryPartRe.FindAllStringSubmatch(image, -1) + if len(registryParts) < 1 || len(registryParts[0]) < 3 { + return "", "", false + } + return registryParts[0][1], registryParts[0][2], true +} + +// Client is a AWS ECR client which can log into the registry and return +// authorization information. +type Client struct { + *aws.Config +} + +// NewClient creates a new ECR client with default configurations. +func NewClient() *Client { + return &Client{Config: aws.NewConfig()} +} + +// getLoginAuth obtains authentication for ECR given the account +// ID and region (taken from the image). This assumes that the pod has +// IAM permissions to get an authentication token, which will usually +// be the case if it's running in EKS, and may need additional setup +// otherwise (visit +// https://docs.aws.amazon.com/sdk-for-go/api/aws/session/ as a +// starting point). +func (c *Client) getLoginAuth(accountId, awsEcrRegion string) (authn.AuthConfig, error) { + // No caching of tokens is attempted; the quota for getting an + // auth token is high enough that getting a token every time you + // scan an image is viable for O(500) images per region. See + // https://docs.aws.amazon.com/general/latest/gr/ecr.html. + var authConfig authn.AuthConfig + accountIDs := []string{accountId} + + // Configure session. + cfg := c.Config.WithRegion(awsEcrRegion) + ecrService := ecr.New(session.Must(session.NewSession(cfg))) + ecrToken, err := ecrService.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{ + RegistryIds: aws.StringSlice(accountIDs), + }) + if err != nil { + return authConfig, err + } + + // Validate the authorization data. + if len(ecrToken.AuthorizationData) == 0 { + return authConfig, errors.New("no authorization data") + } + if ecrToken.AuthorizationData[0].AuthorizationToken == nil { + return authConfig, fmt.Errorf("no authorization token") + } + token, err := base64.StdEncoding.DecodeString(*ecrToken.AuthorizationData[0].AuthorizationToken) + if err != nil { + return authConfig, err + } + + tokenSplit := strings.Split(string(token), ":") + // Validate the tokens. + if len(tokenSplit) != 2 { + // NOTE: Maybe think of some better error message? + return authConfig, fmt.Errorf("invalid authorization token, expected to be of length 2, have %d", len(tokenSplit)) + } + authConfig = authn.AuthConfig{ + Username: tokenSplit[0], + Password: tokenSplit[1], + } + return authConfig, nil +} + +// Login attempts to get the authentication material for ECR. It extracts +// the account and region information from the image URI. The caller can ensure +// that the passed image is a valid ECR image using ParseImage(). +func (c *Client) Login(ctx context.Context, autoLogin bool, image string) (authn.Authenticator, error) { + if autoLogin { + ctrl.LoggerFrom(ctx).Info("logging in to AWS ECR for " + image) + accountId, awsEcrRegion, ok := ParseImage(image) + if !ok { + return nil, errors.New("failed to parse AWS ECR image, invalid ECR image") + } + + authConfig, err := c.getLoginAuth(accountId, awsEcrRegion) + if err != nil { + return nil, err + } + + auth := authn.FromConfig(authConfig) + return auth, nil + } + ctrl.LoggerFrom(ctx).Info("ECR authentication is not enabled. To enable, set the controller flag --aws-autologin-for-ecr") + return nil, fmt.Errorf("ECR authentication failed: %w", registry.ErrUnconfiguredProvider) +} diff --git a/internal/registry/aws/auth_test.go b/internal/registry/aws/auth_test.go new file mode 100644 index 00000000..3ced59fa --- /dev/null +++ b/internal/registry/aws/auth_test.go @@ -0,0 +1,221 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package aws + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/google/go-containerregistry/pkg/authn" + . "github.com/onsi/gomega" +) + +const ( + testValidECRImage = "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1" +) + +func TestParseImage(t *testing.T) { + tests := []struct { + image string + wantAccountID string + wantRegion string + wantOK bool + }{ + { + image: "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1", + wantAccountID: "012345678901", + wantRegion: "us-east-1", + wantOK: true, + }, + { + image: "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo", + wantAccountID: "012345678901", + wantRegion: "us-east-1", + wantOK: true, + }, + { + image: "012345678901.dkr.ecr.us-east-1.amazonaws.com", + wantOK: false, + }, + { + image: "gcr.io/foo/bar:baz", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.image, func(t *testing.T) { + g := NewWithT(t) + + accId, region, ok := ParseImage(tt.image) + g.Expect(ok).To(Equal(tt.wantOK), "unexpected OK") + g.Expect(accId).To(Equal(tt.wantAccountID), "unexpected account IDs") + g.Expect(region).To(Equal(tt.wantRegion), "unexpected regions") + }) + } +} + +func TestGetLoginAuth(t *testing.T) { + tests := []struct { + name string + responseBody []byte + statusCode int + wantErr bool + wantAuthConfig authn.AuthConfig + }{ + { + // NOTE: The authorizationToken is base64 encoded. + name: "success", + responseBody: []byte(`{ + "authorizationData": [ + { + "authorizationToken": "c29tZS1rZXk6c29tZS1zZWNyZXQ=" + } + ] +}`), + statusCode: http.StatusOK, + wantAuthConfig: authn.AuthConfig{ + Username: "some-key", + Password: "some-secret", + }, + }, + { + name: "fail", + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + { + name: "invalid token", + responseBody: []byte(`{ + "authorizationData": [ + { + "authorizationToken": "c29tZS10b2tlbg==" + } + ] +}`), + statusCode: http.StatusOK, + wantErr: true, + }, + { + name: "invalid data", + responseBody: []byte(`{ + "authorizationData": [ + { + "foo": "bar" + } + ] +}`), + statusCode: http.StatusOK, + wantErr: true, + }, + { + name: "invalid response", + responseBody: []byte(`{}`), + statusCode: http.StatusOK, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.responseBody)) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(func() { + srv.Close() + }) + + // Configure the client. + ec := NewClient() + ec.Config = ec.WithEndpoint(srv.URL). + WithCredentials(credentials.NewStaticCredentials("x", "y", "z")) + + a, err := ec.getLoginAuth("some-account-id", "us-east-1") + g.Expect(err != nil).To(Equal(tt.wantErr)) + if tt.statusCode == http.StatusOK { + g.Expect(a).To(Equal(tt.wantAuthConfig)) + } + }) + } +} + +func TestLogin(t *testing.T) { + tests := []struct { + name string + autoLogin bool + image string + statusCode int + wantErr bool + }{ + { + name: "no auto login", + autoLogin: false, + image: testValidECRImage, + statusCode: http.StatusOK, + wantErr: true, + }, + { + name: "with auto login", + autoLogin: true, + image: testValidECRImage, + statusCode: http.StatusOK, + }, + { + name: "login failure", + autoLogin: true, + image: testValidECRImage, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + { + name: "non ECR image", + autoLogin: true, + image: "gcr.io/foo/bar:v1", + statusCode: http.StatusOK, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(`{"authorizationData": [{"authorizationToken": "c29tZS1rZXk6c29tZS1zZWNyZXQ="}]}`)) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(func() { + srv.Close() + }) + + ecrClient := NewClient() + ecrClient.Config = ecrClient.WithEndpoint(srv.URL). + WithCredentials(credentials.NewStaticCredentials("x", "y", "z")) + + _, err := ecrClient.Login(context.TODO(), tt.autoLogin, tt.image) + g.Expect(err != nil).To(Equal(tt.wantErr)) + }) + } +} diff --git a/internal/registry/azure/auth.go b/internal/registry/azure/auth.go new file mode 100644 index 00000000..87943d70 --- /dev/null +++ b/internal/registry/azure/auth.go @@ -0,0 +1,127 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package azure + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/fluxcd/image-reflector-controller/internal/registry" +) + +// Client is an Azure ACR client which can log into the registry and return +// authorization information. +type Client struct { + credential azcore.TokenCredential + scheme string +} + +// NewClient creates a new ACR client with default configurations. +func NewClient() *Client { + return &Client{scheme: "https"} +} + +// WithTokenCredential sets the token credential used by the ACR client. +func (c *Client) WithTokenCredential(tc azcore.TokenCredential) *Client { + c.credential = tc + return c +} + +// WithScheme sets the scheme of the http request that the client makes. +func (c *Client) WithScheme(scheme string) *Client { + c.scheme = scheme + return c +} + +// getLoginAuth returns authentication for ACR. The details needed for authentication +// are gotten from environment variable so there is not need to mount a host path. +func (c *Client) getLoginAuth(ctx context.Context, ref name.Reference) (authn.AuthConfig, error) { + var authConfig authn.AuthConfig + + // Use default credentials if no token credential is provided. + // NOTE: NewDefaultAzureCredential() performs a lot of environment lookup + // for creating default token credential. Load it only when it's needed. + if c.credential == nil { + cred, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return authConfig, err + } + c.credential = cred + } + + // Obtain access token using the token credential. + // TODO: Add support for other azure endpoints as well. + armToken, err := c.credential.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{string(arm.AzurePublicCloud) + ".default"}, + }) + if err != nil { + return authConfig, err + } + + // Obtain ACR access token using exchanger. + endpoint := fmt.Sprintf("%s://%s", c.scheme, ref.Context().RegistryStr()) + ex := newExchanger(endpoint) + accessToken, err := ex.ExchangeACRAccessToken(string(armToken.Token)) + if err != nil { + return authConfig, fmt.Errorf("error exchanging token: %w", err) + } + + return authn.AuthConfig{ + // This is the acr username used by Azure + // See documentation: https://docs.microsoft.com/en-us/azure/container-registry/container-registry-authentication?tabs=azure-cli#az-acr-login-with---expose-token + Username: "00000000-0000-0000-0000-000000000000", + Password: accessToken, + }, nil +} + +// ValidHost returns if a given host is a Azure container registry. +// List from https://github.com/kubernetes/kubernetes/blob/v1.23.1/pkg/credentialprovider/azure/azure_credentials.go#L55 +func ValidHost(host string) bool { + for _, v := range []string{".azurecr.io", ".azurecr.cn", ".azurecr.de", ".azurecr.us"} { + if strings.HasSuffix(host, v) { + return true + } + } + return false +} + +// Login attempts to get the authentication material for ACR. The caller can +// ensure that the passed image is a valid ACR image using ValidHost(). +func (c *Client) Login(ctx context.Context, autoLogin bool, image string, ref name.Reference) (authn.Authenticator, error) { + if autoLogin { + ctrl.LoggerFrom(ctx).Info("logging in to Azure ACR for " + image) + authConfig, err := c.getLoginAuth(ctx, ref) + if err != nil { + ctrl.LoggerFrom(ctx).Info("error logging into ACR " + err.Error()) + return nil, err + } + + auth := authn.FromConfig(authConfig) + return auth, nil + } + ctrl.LoggerFrom(ctx).Info("ACR authentication is not enabled. To enable, set the controller flag --azure-autologin-for-acr") + return nil, fmt.Errorf("ACR authentication failed: %w", registry.ErrUnconfiguredProvider) +} diff --git a/internal/registry/azure/auth_test.go b/internal/registry/azure/auth_test.go new file mode 100644 index 00000000..6482b974 --- /dev/null +++ b/internal/registry/azure/auth_test.go @@ -0,0 +1,178 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package azure + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "path" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + . "github.com/onsi/gomega" +) + +func TestGetAzureLoginAuth(t *testing.T) { + tests := []struct { + name string + tokenCredential azcore.TokenCredential + responseBody string + statusCode int + wantErr bool + wantAuthConfig authn.AuthConfig + }{ + { + name: "success", + tokenCredential: &FakeTokenCredential{Token: "foo"}, + responseBody: `{"refresh_token": "bbbbb"}`, + statusCode: http.StatusOK, + wantAuthConfig: authn.AuthConfig{ + Username: "00000000-0000-0000-0000-000000000000", + Password: "bbbbb", + }, + }, + { + name: "fail to get access token", + tokenCredential: &FakeTokenCredential{Err: errors.New("no access token")}, + wantErr: true, + }, + { + name: "error from exchange service", + tokenCredential: &FakeTokenCredential{Token: "foo"}, + responseBody: `[{"code": "111","message": "error message 1"}]`, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // Run a test server. + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.responseBody)) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(func() { + srv.Close() + }) + + // Construct an image repo name against the test server. + u, err := url.Parse(srv.URL) + g.Expect(err).ToNot(HaveOccurred()) + image := path.Join(u.Host, "foo/bar:v1") + ref, err := name.ParseReference(image) + g.Expect(err).ToNot(HaveOccurred()) + + // Configure new client with test token credential. + c := NewClient(). + WithTokenCredential(tt.tokenCredential). + WithScheme("http") + + auth, err := c.getLoginAuth(context.TODO(), ref) + g.Expect(err != nil).To(Equal(tt.wantErr)) + if tt.statusCode == http.StatusOK { + g.Expect(auth).To(Equal(tt.wantAuthConfig)) + } + }) + } +} + +func TestValidHost(t *testing.T) { + tests := []struct { + host string + result bool + }{ + {"foo.azurecr.io", true}, + {"foo.azurecr.cn", true}, + {"foo.azurecr.de", true}, + {"foo.azurecr.us", true}, + {"gcr.io", false}, + {"docker.io", false}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + g := NewWithT(t) + g.Expect(ValidHost(tt.host)).To(Equal(tt.result)) + }) + } +} + +func TestLogin(t *testing.T) { + tests := []struct { + name string + autoLogin bool + statusCode int + wantErr bool + }{ + { + name: "no auto login", + autoLogin: false, + statusCode: http.StatusOK, + wantErr: true, + }, + { + name: "with auto login", + autoLogin: true, + statusCode: http.StatusOK, + }, + { + name: "login failure", + autoLogin: true, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // Run a test server. + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(`{"refresh_token": "bbbbb"}`)) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(func() { + srv.Close() + }) + + // Construct an image repo name against the test server. + u, err := url.Parse(srv.URL) + g.Expect(err).ToNot(HaveOccurred()) + image := path.Join(u.Host, "foo/bar:v1") + ref, err := name.ParseReference(image) + g.Expect(err).ToNot(HaveOccurred()) + + ac := NewClient(). + WithTokenCredential(&FakeTokenCredential{Token: "foo"}). + WithScheme("http") + + _, err = ac.Login(context.TODO(), tt.autoLogin, image, ref) + g.Expect(err != nil).To(Equal(tt.wantErr)) + }) + } +} diff --git a/internal/azure/exchanger.go b/internal/registry/azure/exchanger.go similarity index 77% rename from internal/azure/exchanger.go rename to internal/registry/azure/exchanger.go index 72a938fd..00dccacc 100644 --- a/internal/azure/exchanger.go +++ b/internal/registry/azure/exchanger.go @@ -49,6 +49,7 @@ import ( "fmt" "net/http" "net/url" + "path" ) type tokenResponse struct { @@ -63,48 +64,55 @@ type acrError struct { Message string `json:"message"` } -type Exchanger struct { - acrFQDN string +type exchanger struct { + endpoint string } -func NewExchanger(acrEndpoint string) *Exchanger { - return &Exchanger{ - acrFQDN: acrEndpoint, +// newExchanger returns an Azure Exchanger for Azure Container Registry with +// a given endpoint, for example https://azurecr.io. +func newExchanger(endpoint string) *exchanger { + return &exchanger{ + endpoint: endpoint, } } -func (e *Exchanger) ExchangeACRAccessToken(armToken string) (string, error) { - exchangeUrl := fmt.Sprintf("https://%s/oauth2/exchange", e.acrFQDN) - parsedURL, err := url.Parse(exchangeUrl) +// ExchangeACRAccessToken exchanges an access token for a refresh token with the +// exchange service. +func (e *exchanger) ExchangeACRAccessToken(armToken string) (string, error) { + // Construct the exchange URL. + exchangeURL, err := url.Parse(e.endpoint) if err != nil { return "", err } + exchangeURL.Path = path.Join(exchangeURL.Path, "oauth2/exchange") parameters := url.Values{} parameters.Add("grant_type", "access_token") - parameters.Add("service", parsedURL.Hostname()) + parameters.Add("service", exchangeURL.Hostname()) parameters.Add("access_token", armToken) - resp, err := http.PostForm(exchangeUrl, parameters) + resp, err := http.PostForm(exchangeURL.String(), parameters) if err != nil { return "", fmt.Errorf("failed to send token exchange request: %w", err) } if resp.StatusCode != http.StatusOK { + // Parse the error response. var errors []acrError decoder := json.NewDecoder(resp.Body) if err = decoder.Decode(&errors); err == nil { - return "", fmt.Errorf("unexpected status code %d from exchange request: errors:%s", + return "", fmt.Errorf("unexpected status code %d from exchange request: %s", resp.StatusCode, errors) } + // Error response could not be parsed, return a generic error. return "", fmt.Errorf("unexpected status code %d from exchange request", resp.StatusCode) } var tokenResp tokenResponse decoder := json.NewDecoder(resp.Body) if err = decoder.Decode(&tokenResp); err != nil { - return "", err + return "", fmt.Errorf("failed to decode the response: %w", err) } return tokenResp.RefreshToken, nil } diff --git a/internal/registry/azure/exchanger_test.go b/internal/registry/azure/exchanger_test.go new file mode 100644 index 00000000..0c84079f --- /dev/null +++ b/internal/registry/azure/exchanger_test.go @@ -0,0 +1,95 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package azure + +import ( + "net/http" + "net/http/httptest" + "testing" + + . "github.com/onsi/gomega" +) + +func TestExchanger_ExchangeACRAccessToken(t *testing.T) { + tests := []struct { + name string + responseBody string + statusCode int + wantErr bool + wantToken string + }{ + { + name: "successful", + responseBody: `{ + "access_token": "aaaaa", + "refresh_token": "bbbbb", + "resource": "ccccc", + "token_type": "ddddd" +}`, + statusCode: http.StatusOK, + wantToken: "bbbbb", + }, + { + name: "fail", + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + { + name: "invalid response", + responseBody: "foo", + statusCode: http.StatusOK, + wantErr: true, + }, + { + name: "error response", + responseBody: `[ + { + "code": "111", + "message": "error message 1" + }, + { + "code": "112", + "message": "error message 2" + } +]`, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.responseBody)) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(func() { + srv.Close() + }) + + ex := newExchanger(srv.URL) + token, err := ex.ExchangeACRAccessToken("some-access-token") + g.Expect(err != nil).To(Equal(tt.wantErr)) + if tt.statusCode == http.StatusOK { + g.Expect(token).To(Equal(tt.wantToken)) + } + }) + } +} diff --git a/internal/registry/azure/fake.go b/internal/registry/azure/fake.go new file mode 100644 index 00000000..d528c06f --- /dev/null +++ b/internal/registry/azure/fake.go @@ -0,0 +1,38 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package azure + +import ( + "context" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +type FakeTokenCredential struct { + Token string + ExpiresOn time.Time + Err error +} + +func (tc *FakeTokenCredential) GetToken(ctx context.Context, options policy.TokenRequestOptions) (*azcore.AccessToken, error) { + if tc.Err != nil { + return nil, tc.Err + } + return &azcore.AccessToken{Token: tc.Token, ExpiresOn: tc.ExpiresOn}, nil +} diff --git a/internal/registry/constants.go b/internal/registry/constants.go new file mode 100644 index 00000000..871fdd68 --- /dev/null +++ b/internal/registry/constants.go @@ -0,0 +1,30 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +// Provider is used to categorize the registry providers. +type Provider int + +// Registry providers. +const ( + // ProviderGeneric is used to categorize registry provider for which we + // don't support autologin. + ProviderGeneric Provider = iota + ProviderAWS + ProviderGCR + ProviderAzure +) diff --git a/internal/registry/errors.go b/internal/registry/errors.go new file mode 100644 index 00000000..519e1e91 --- /dev/null +++ b/internal/registry/errors.go @@ -0,0 +1,25 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import "errors" + +var ( + // ErrUnconfiguredProvider is returned when the image registry provider is + // not configured for login. + ErrUnconfiguredProvider = errors.New("registry provider not configured for login") +) diff --git a/internal/registry/gcp/auth.go b/internal/registry/gcp/auth.go new file mode 100644 index 00000000..3fdf67fc --- /dev/null +++ b/internal/registry/gcp/auth.go @@ -0,0 +1,120 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/fluxcd/image-reflector-controller/internal/registry" +) + +type gceToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +// GCP_TOKEN_URL is the default GCP metadata endpoint used for authentication. +const GCP_TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" + +// ValidHost returns if a given host is a valid GCR host. +func ValidHost(host string) bool { + return host == "gcr.io" || strings.HasSuffix(host, ".gcr.io") || strings.HasSuffix(host, "-docker.pkg.dev") +} + +// Client is a GCP GCR client which can log into the registry and return +// authorization information. +type Client struct { + tokenURL string +} + +// NewClient creates a new GCR client with default configurations. +func NewClient() *Client { + return &Client{tokenURL: GCP_TOKEN_URL} +} + +// WithTokenURL sets the token URL used by the GCR client. +func (c *Client) WithTokenURL(url string) *Client { + c.tokenURL = url + return c +} + +// getLoginAuth obtains authentication by getting a token from the metadata API +// on GCP. This assumes that the pod has right to pull the image which would be +// the case if it is hosted on GCP. It works with both service account and +// workload identity enabled clusters. +func (c *Client) getLoginAuth(ctx context.Context) (authn.AuthConfig, error) { + var authConfig authn.AuthConfig + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.tokenURL, nil) + if err != nil { + return authConfig, err + } + + request.Header.Add("Metadata-Flavor", "Google") + + client := &http.Client{} + response, err := client.Do(request) + if err != nil { + return authConfig, err + } + defer response.Body.Close() + defer io.Copy(io.Discard, response.Body) + + if response.StatusCode != http.StatusOK { + return authConfig, fmt.Errorf("unexpected status from metadata service: %s", response.Status) + } + + var accessToken gceToken + decoder := json.NewDecoder(response.Body) + if err := decoder.Decode(&accessToken); err != nil { + return authConfig, err + } + + authConfig = authn.AuthConfig{ + Username: "oauth2accesstoken", + Password: accessToken.AccessToken, + } + return authConfig, nil +} + +// Login attempts to get the authentication material for GCR. The caller can +// ensure that the passed image is a valid GCR image using ValidHost(). +func (c *Client) Login(ctx context.Context, autoLogin bool, image string, ref name.Reference) (authn.Authenticator, error) { + if autoLogin { + ctrl.LoggerFrom(ctx).Info("logging in to GCP GCR for " + image) + authConfig, err := c.getLoginAuth(ctx) + if err != nil { + ctrl.LoggerFrom(ctx).Info("error logging into GCP " + err.Error()) + return nil, err + } + + auth := authn.FromConfig(authConfig) + return auth, nil + } + ctrl.LoggerFrom(ctx).Info("GCR authentication is not enabled. To enable, set the controller flag --gcp-autologin-for-gcr") + return nil, fmt.Errorf("GCR authentication failed: %w", registry.ErrUnconfiguredProvider) +} diff --git a/internal/registry/gcp/auth_test.go b/internal/registry/gcp/auth_test.go new file mode 100644 index 00000000..d6fb6ffa --- /dev/null +++ b/internal/registry/gcp/auth_test.go @@ -0,0 +1,166 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcp + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + . "github.com/onsi/gomega" +) + +const testValidGCRImage = "gcr.io/foo/bar:v1" + +func TestGetLoginAuth(t *testing.T) { + tests := []struct { + name string + responseBody string + statusCode int + wantErr bool + wantAuthConfig authn.AuthConfig + }{ + { + name: "success", + responseBody: `{ + "access_token": "some-token", + "expires_in": 10, + "token_type": "foo" +}`, + statusCode: http.StatusOK, + wantAuthConfig: authn.AuthConfig{ + Username: "oauth2accesstoken", + Password: "some-token", + }, + }, + { + name: "fail", + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + { + name: "invalid response", + responseBody: "foo", + statusCode: http.StatusOK, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.responseBody)) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(func() { + srv.Close() + }) + + gc := NewClient().WithTokenURL(srv.URL) + a, err := gc.getLoginAuth(context.TODO()) + g.Expect(err != nil).To(Equal(tt.wantErr)) + if tt.statusCode == http.StatusOK { + g.Expect(a).To(Equal(tt.wantAuthConfig)) + } + }) + } +} + +func TestValidHost(t *testing.T) { + tests := []struct { + host string + result bool + }{ + {"gcr.io", true}, + {"foo.gcr.io", true}, + {"foo-docker.pkg.dev", true}, + {"docker.io", false}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + g := NewWithT(t) + g.Expect(ValidHost(tt.host)).To(Equal(tt.result)) + }) + } +} + +func TestLogin(t *testing.T) { + tests := []struct { + name string + autoLogin bool + image string + statusCode int + wantErr bool + }{ + { + name: "no auto login", + autoLogin: false, + image: testValidGCRImage, + statusCode: http.StatusOK, + wantErr: true, + }, + { + name: "with auto login", + autoLogin: true, + image: testValidGCRImage, + statusCode: http.StatusOK, + }, + { + name: "login failure", + autoLogin: true, + image: testValidGCRImage, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + { + name: "non GCR image", + autoLogin: true, + image: "foo/bar:v1", + statusCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(`{"access_token": "some-token","expires_in": 10, "token_type": "foo"}`)) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(func() { + srv.Close() + }) + + ref, err := name.ParseReference(tt.image) + g.Expect(err).ToNot(HaveOccurred()) + + gc := NewClient().WithTokenURL(srv.URL) + + _, err = gc.Login(context.TODO(), tt.autoLogin, tt.image, ref) + g.Expect(err != nil).To(Equal(tt.wantErr)) + }) + } +} diff --git a/internal/registry/login/login.go b/internal/registry/login/login.go new file mode 100644 index 00000000..0a6bb839 --- /dev/null +++ b/internal/registry/login/login.go @@ -0,0 +1,107 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package login + +import ( + "context" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + + "github.com/fluxcd/image-reflector-controller/internal/registry" + "github.com/fluxcd/image-reflector-controller/internal/registry/aws" + "github.com/fluxcd/image-reflector-controller/internal/registry/azure" + "github.com/fluxcd/image-reflector-controller/internal/registry/gcp" +) + +// ImageRegistryProvider analyzes the provided image and returns the identified +// container image registry provider. +func ImageRegistryProvider(image string, ref name.Reference) registry.Provider { + _, _, ok := aws.ParseImage(image) + if ok { + return registry.ProviderAWS + } + if gcp.ValidHost(ref.Context().RegistryStr()) { + return registry.ProviderGCR + } + if azure.ValidHost(ref.Context().RegistryStr()) { + return registry.ProviderAzure + } + return registry.ProviderGeneric +} + +// ProviderOptions contains options for registry provider login. +type ProviderOptions struct { + // AwsAutoLogin enables automatic attempt to get credentials for images in + // ECR. + AwsAutoLogin bool + // GcpAutoLogin enables automatic attempt to get credentials for images in + // GCP. + GcpAutoLogin bool + // AzureAutoLogin enables automatic attempt to get credentials for images in + // ACR. + AzureAutoLogin bool +} + +// Manager is a login manager for various registry providers. +type Manager struct { + ecr *aws.Client + gcr *gcp.Client + acr *azure.Client +} + +// NewManager initializes a Manager with default registry clients +// configurations. +func NewManager() *Manager { + return &Manager{ + ecr: aws.NewClient(), + gcr: gcp.NewClient(), + acr: azure.NewClient(), + } +} + +// WithECRClient allows overriding the default ECR client. +func (m *Manager) WithECRClient(c *aws.Client) *Manager { + m.ecr = c + return m +} + +// WithGCRClient allows overriding the default GCR client. +func (m *Manager) WithGCRClient(c *gcp.Client) *Manager { + m.gcr = c + return m +} + +// WithACRClient allows overriding the default ACR client. +func (m *Manager) WithACRClient(c *azure.Client) *Manager { + m.acr = c + return m +} + +// Login performs authentication against a registry and returns the +// authentication material. For generic registry provider, it is no-op. +func (m *Manager) Login(ctx context.Context, image string, ref name.Reference, opts ProviderOptions) (authn.Authenticator, error) { + switch ImageRegistryProvider(image, ref) { + case registry.ProviderAWS: + return m.ecr.Login(ctx, opts.AwsAutoLogin, image) + case registry.ProviderGCR: + return m.gcr.Login(ctx, opts.GcpAutoLogin, image, ref) + case registry.ProviderAzure: + return m.acr.Login(ctx, opts.AzureAutoLogin, image, ref) + } + return nil, nil +} diff --git a/internal/registry/login/login_test.go b/internal/registry/login/login_test.go new file mode 100644 index 00000000..94c8763e --- /dev/null +++ b/internal/registry/login/login_test.go @@ -0,0 +1,147 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package login + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/google/go-containerregistry/pkg/name" + . "github.com/onsi/gomega" + + "github.com/fluxcd/image-reflector-controller/internal/registry" + "github.com/fluxcd/image-reflector-controller/internal/registry/aws" + "github.com/fluxcd/image-reflector-controller/internal/registry/azure" + "github.com/fluxcd/image-reflector-controller/internal/registry/gcp" +) + +func TestImageRegistryProvider(t *testing.T) { + tests := []struct { + name string + image string + want registry.Provider + }{ + {"ecr", "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1", registry.ProviderAWS}, + {"gcr", "gcr.io/foo/bar:v1", registry.ProviderGCR}, + {"acr", "foo.azurecr.io/bar:v1", registry.ProviderAzure}, + {"docker.io", "foo/bar:v1", registry.ProviderGeneric}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + ref, err := name.ParseReference(tt.image) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ImageRegistryProvider(tt.image, ref)).To(Equal(tt.want)) + }) + } +} + +func TestLogin(t *testing.T) { + tests := []struct { + name string + responseBody string + statusCode int + providerOpts ProviderOptions + beforeFunc func(serverURL string, mgr *Manager, image *string) + wantErr bool + }{ + { + name: "ecr", + responseBody: `{"authorizationData": [{"authorizationToken": "c29tZS1rZXk6c29tZS1zZWNyZXQ="}]}`, + providerOpts: ProviderOptions{AwsAutoLogin: true}, + beforeFunc: func(serverURL string, mgr *Manager, image *string) { + // Create ECR client and configure the manager. + ecrClient := aws.NewClient() + ecrClient.Config = ecrClient.WithEndpoint(serverURL). + WithCredentials(credentials.NewStaticCredentials("x", "y", "z")) + mgr.WithECRClient(ecrClient) + + *image = "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1" + }, + }, + { + name: "gcr", + responseBody: `{"access_token": "some-token","expires_in": 10, "token_type": "foo"}`, + providerOpts: ProviderOptions{GcpAutoLogin: true}, + beforeFunc: func(serverURL string, mgr *Manager, image *string) { + // Create GCR client and configure the manager. + gcrClient := gcp.NewClient().WithTokenURL(serverURL) + mgr.WithGCRClient(gcrClient) + + *image = "gcr.io/foo/bar:v1" + }, + }, + { + name: "acr", + responseBody: `{"refresh_token": "bbbbb"}`, + providerOpts: ProviderOptions{AzureAutoLogin: true}, + beforeFunc: func(serverURL string, mgr *Manager, image *string) { + acrClient := azure.NewClient().WithTokenCredential(&azure.FakeTokenCredential{Token: "foo"}).WithScheme("http") + mgr.WithACRClient(acrClient) + + *image = "foo.azurecr.io/bar:v1" + }, + // NOTE: This fails because the azure exchanger uses the image host + // to exchange token which can't be modified here without + // interfering image name based categorization of the login + // provider, that's actually being tested here. This is tested in + // detail in the azure package. + wantErr: true, + }, + { + name: "generic", + providerOpts: ProviderOptions{}, + beforeFunc: func(serverURL string, mgr *Manager, image *string) { + *image = "foo/bar:v1" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // Create test server. + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.responseBody)) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(func() { + srv.Close() + }) + + mgr := NewManager() + var image string + + if tt.beforeFunc != nil { + tt.beforeFunc(srv.URL, mgr, &image) + } + + ref, err := name.ParseReference(image) + g.Expect(err).ToNot(HaveOccurred()) + + _, err = mgr.Login(context.TODO(), image, ref, tt.providerOpts) + g.Expect(err != nil).To(Equal(tt.wantErr)) + }) + } +} diff --git a/internal/test/registry.go b/internal/test/registry.go index 8f02175a..45677adb 100644 --- a/internal/test/registry.go +++ b/internal/test/registry.go @@ -1,3 +1,19 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package test import ( diff --git a/main.go b/main.go index fafb0dd5..712b38fa 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,7 @@ import ( // +kubebuilder:scaffold:imports "github.com/fluxcd/image-reflector-controller/controllers" "github.com/fluxcd/image-reflector-controller/internal/database" + "github.com/fluxcd/image-reflector-controller/internal/registry/login" ) const controllerName = "image-reflector-controller" @@ -149,9 +150,11 @@ func main() { EventRecorder: eventRecorder, MetricsRecorder: metricsRecorder, Database: db, - AwsAutoLogin: awsAutoLogin, - GcpAutoLogin: gcpAutoLogin, - AzureAutoLogin: azureAutoLogin, + ProviderOptions: login.ProviderOptions{ + AwsAutoLogin: awsAutoLogin, + GcpAutoLogin: gcpAutoLogin, + AzureAutoLogin: azureAutoLogin, + }, }).SetupWithManager(mgr, controllers.ImageRepositoryReconcilerOptions{ MaxConcurrentReconciles: concurrent, }); err != nil {