Skip to content

Commit

Permalink
Introduce registry package
Browse files Browse the repository at this point in the history
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 <darkowlzz@protonmail.com>
  • Loading branch information
darkowlzz committed Jun 21, 2022
1 parent 5ab6137 commit c4f49d7
Show file tree
Hide file tree
Showing 13 changed files with 1,210 additions and 13 deletions.
114 changes: 114 additions & 0 deletions internal/registry/aws/auth.go
Original file line number Diff line number Diff line change
@@ -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)
}
205 changes: 205 additions & 0 deletions internal/registry/aws/auth_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
Loading

0 comments on commit c4f49d7

Please sign in to comment.