From c4f49d750bbbd111504dc531f3ffdf8340212c7b Mon Sep 17 00:00:00 2001 From: Sunny Date: Fri, 29 Apr 2022 04:52:34 +0530 Subject: [PATCH] Introduce registry package registry package consolidates all the registry provider logins. The registry/login package contains a login Manager which manages logins for all the providers. For testability, it provides methods to modify the provider client configurations. registry/{aws/azure/gcp} packages contain clients for logging into the respective registry. The client APIs are mostly similar across all the providers, except for the small details related to overriding certain configurations for testing purposes. Each of the providers have test coverage to solidify the expected behavior in different scenarios. Signed-off-by: Sunny --- internal/registry/aws/auth.go | 114 ++++++++++++ internal/registry/aws/auth_test.go | 205 +++++++++++++++++++++ internal/registry/azure/auth.go | 110 +++++++++++ internal/registry/azure/auth_test.go | 162 ++++++++++++++++ internal/{ => registry}/azure/exchanger.go | 34 ++-- internal/registry/azure/exchanger_test.go | 79 ++++++++ internal/registry/azure/fake.go | 22 +++ internal/registry/constants.go | 12 ++ internal/registry/errors.go | 9 + internal/registry/gcp/auth.go | 104 +++++++++++ internal/registry/gcp/auth_test.go | 150 +++++++++++++++ internal/registry/login/login.go | 91 +++++++++ internal/registry/login/login_test.go | 131 +++++++++++++ 13 files changed, 1210 insertions(+), 13 deletions(-) create mode 100644 internal/registry/aws/auth.go create mode 100644 internal/registry/aws/auth_test.go create mode 100644 internal/registry/azure/auth.go create mode 100644 internal/registry/azure/auth_test.go rename internal/{ => registry}/azure/exchanger.go (74%) create mode 100644 internal/registry/azure/exchanger_test.go create mode 100644 internal/registry/azure/fake.go create mode 100644 internal/registry/constants.go create mode 100644 internal/registry/errors.go create mode 100644 internal/registry/gcp/auth.go create mode 100644 internal/registry/gcp/auth_test.go create mode 100644 internal/registry/login/login.go create mode 100644 internal/registry/login/login_test.go diff --git a/internal/registry/aws/auth.go b/internal/registry/aws/auth.go new file mode 100644 index 00000000..b07409fe --- /dev/null +++ b/internal/registry/aws/auth.go @@ -0,0 +1,114 @@ +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" +) + +// 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) { + 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 +} + +// 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(1000) 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..cd074bf7 --- /dev/null +++ b/internal/registry/aws/auth_test.go @@ -0,0 +1,205 @@ +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..3b357e99 --- /dev/null +++ b/internal/registry/azure/auth.go @@ -0,0 +1,110 @@ +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. + 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..a20740fc --- /dev/null +++ b/internal/registry/azure/auth_test.go @@ -0,0 +1,162 @@ +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 74% rename from internal/azure/exchanger.go rename to internal/registry/azure/exchanger.go index 5db2eaf4..4d96ffb0 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 exchnage request: errors:%s", + return "", fmt.Errorf("unexpected status code %d from exchange request: errors:%s", resp.StatusCode, errors) } - return "", fmt.Errorf("unexpected status code %d from exchnage request", resp.StatusCode) + // 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..c213c203 --- /dev/null +++ b/internal/registry/azure/exchanger_test.go @@ -0,0 +1,79 @@ +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..c279d454 --- /dev/null +++ b/internal/registry/azure/fake.go @@ -0,0 +1,22 @@ +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..dd5e23fd --- /dev/null +++ b/internal/registry/constants.go @@ -0,0 +1,12 @@ +package registry + +// Provider is used to categorize the registry providers. +type Provider int + +// Registry providers. +const ( + ProviderGeneric Provider = iota + ProviderAWS + ProviderGCR + ProviderAzure +) diff --git a/internal/registry/errors.go b/internal/registry/errors.go new file mode 100644 index 00000000..241302c9 --- /dev/null +++ b/internal/registry/errors.go @@ -0,0 +1,9 @@ +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..5054f567 --- /dev/null +++ b/internal/registry/gcp/auth.go @@ -0,0 +1,104 @@ +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..a59748f8 --- /dev/null +++ b/internal/registry/gcp/auth_test.go @@ -0,0 +1,150 @@ +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..ff6cb76b --- /dev/null +++ b/internal/registry/login/login.go @@ -0,0 +1,91 @@ +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..d04590fd --- /dev/null +++ b/internal/registry/login/login_test.go @@ -0,0 +1,131 @@ +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)) + }) + } +}