-
Notifications
You must be signed in to change notification settings - Fork 69
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
13 changed files
with
1,210 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
}) | ||
} | ||
} |
Oops, something went wrong.