Skip to content
This repository has been archived by the owner on Mar 21, 2022. It is now read-only.

Add AWS Elastic Container Service authentication support #876

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,23 @@
</exclusion>
</exclusions>
</dependency>

<!-- TODO we should pull out the AWS support to a new library -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-ecr</artifactId>
<version>1.11.172</version>
<optional>true</optional>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--test deps-->
<dependency>
<groupId>junit</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/*-
* -\-\-
* docker-client
* --
* Copyright (C) 2016 - 2017 Spotify AB
* --
* 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 com.spotify.docker.client.auth.ecr;

import static java.nio.charset.StandardCharsets.UTF_8;

import com.amazonaws.services.ecr.AmazonECR;
import com.amazonaws.services.ecr.AmazonECRClientBuilder;
import com.amazonaws.services.ecr.model.AuthorizationData;
import com.amazonaws.util.Base64;
import com.google.api.client.util.Clock;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.spotify.docker.client.auth.RegistryAuthSupplier;
import com.spotify.docker.client.exceptions.DockerException;
import com.spotify.docker.client.messages.RegistryAuth;
import com.spotify.docker.client.messages.RegistryConfigs;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* A RegistryAuthSupplier for authenticating against an AWS Elastic Container Registry.
*/
public class ContainerRegistryAuthSupplier implements RegistryAuthSupplier {

private static final Logger log = LoggerFactory.getLogger(ContainerRegistryAuthSupplier.class);

/**
* Constructs a ContainerRegistryAuthSupplier using the Application Default Credentials.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it accurate to use "Application Default Credentials" here or should we simply call it "default AWS credentials"?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, AWS Default Credential Provider Chain is probably the most correct.

*
* @see Builder
*/
public static Builder forDefaultClient() throws IOException {
return new Builder(AmazonECRClientBuilder.defaultClient());
}

/**
* Constructs a ContainerRegistryAuthSupplier using the specified credentials.
*
* @see Builder
*/
public static Builder forEcrClient(final AmazonECR ecr) {
return new Builder(ecr);
}

/**
* A Builder of ContainerRegistryAuthSupplier.
* <p>
* The default value for the minimum expiry time of an access token is one minute. When the
* ContainerRegistryAuthSupplier is asked for a RegistryAuth, it will check if the existing
* authorization token for the AWS AuthorizationData expires within this amount of time.
* If it does, then the AuthorizationData is refreshed before being returned.
* </p>
*/
public static class Builder {

private final AmazonECR ecr;
private long minimumExpiryMillis = TimeUnit.MINUTES.toMillis(1);

public Builder(final AmazonECR ecr) {
this.ecr = ecr;
}

/**
* Changes the minimum expiry time used to refresh AccessTokens before they expire. The default
* value is one minute.
*/
public Builder withMinimumExpiry(long duration, TimeUnit timeUnit) {
this.minimumExpiryMillis = TimeUnit.MILLISECONDS.convert(duration, timeUnit);

return this;
}

public ContainerRegistryAuthSupplier build() {
final Clock clock = Clock.SYSTEM;

return new ContainerRegistryAuthSupplier(ecr, clock, minimumExpiryMillis,
new EcrCredentials(ecr));
}
}

private final AmazonECR ecr;

// TODO (mbrown): change to java.time.Clock once on Java 8
private final Clock clock;
private EcrCredentials credentials;
private final long minimumExpiryMillis;

@VisibleForTesting
ContainerRegistryAuthSupplier(final AmazonECR ecr, final Clock clock,
final long minimumExpiryMillis, final EcrCredentials credentials) {
Preconditions.checkArgument(ecr != null, "ecr");
this.ecr = ecr;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to pass ecr here? I see it's used as a sync object below, but can't you just lock on something else?

this.clock = clock;
this.minimumExpiryMillis = minimumExpiryMillis;
this.credentials = credentials;
}

/**
* Get an accessToken to use, possibly refreshing the token if it expires within the
* minimumExpiryMillis.
*/
private AuthorizationData getAccessToken() throws IOException {
// synchronize attempts to refresh the accessToken
synchronized (ecr) {
if (needsRefresh(credentials.getAuthorizationData())) {
credentials.refresh();
}
}

Preconditions.checkState(credentials.getAuthorizationData() != null,
"authorizationData should have been refreshed");

return credentials.getAuthorizationData();
}

boolean needsRefresh(final AuthorizationData accessToken) {
if (accessToken == null) {
// has not yet been fetched
return true;
}

final Date expirationTime = accessToken.getExpiresAt();

// Don't refresh if expiration time hasn't been provided.
if (expirationTime == null) {
return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to test this? Will accessToken.getExpiresAt() ever be null?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

// refresh the token if it expires "soon"
final long expiresIn = expirationTime.getTime() - clock.currentTimeMillis();

return expiresIn <= minimumExpiryMillis;
}

@Override
public RegistryAuth authFor(final String imageName) throws DockerException {
final String[] imageParts = imageName.split("/", 2);

if ((imageParts.length < 2) || !imageParts[0].contains(".dkr.ecr.")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about just doing a regex on the ECR repo format? Something along the lines of ^\d+\.dkr\.ecr\.[a-z-0-9]+\.amazonaws\.com\/

|| !imageParts[0].contains(".amazonaws.com")) {
// not an image on ECR
return null;
}

final AuthorizationData accessToken;

try {
accessToken = getAccessToken();
} catch (IOException e) {
throw new DockerException(e);
}

return authForAuthorizationData(accessToken);
}

// see http://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_AuthorizationData.html
private RegistryAuth authForAuthorizationData(final AuthorizationData accessToken) {
if (accessToken == null) {
throw new IllegalArgumentException();
}

String decoded = new String(Base64.decode(accessToken.getAuthorizationToken()), UTF_8);
String username = decoded.split(":")[0];
String password = decoded.split(":")[1];

return RegistryAuth.builder().username(username).password(password)
.serverAddress(accessToken.getProxyEndpoint()).build();
}

@Override
public RegistryAuth authForSwarm() throws DockerException {
final AuthorizationData accessToken;

try {
accessToken = getAccessToken();
} catch (IOException e) {
// ignore the exception, as the user may not care if swarm is authenticated to use GCR
log.warn("unable to get access token for AWS Elastic Container Registry due to exception, "
+ "configuration for Swarm will not contain RegistryAuth for ECR", e);

return null;
}

return authForAuthorizationData(accessToken);
}

@Override
public RegistryConfigs authForBuild() throws DockerException {
final AuthorizationData accessToken;

try {
accessToken = getAccessToken();
} catch (IOException e) {
// do not fail as the GCR access token may not be necessary for building the image
// currently
// being built
log.warn("unable to get access token for AWS Elastic Container Registry, "
+ "configuration for building image will not contain RegistryAuth for ECR", e);

return RegistryConfigs.empty();
}

final Map<String, RegistryAuth> configs = new HashMap<String, RegistryAuth>(1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: You can omit the explicit type args here in new HashMap<>.

configs.put(accessToken.getProxyEndpoint(), authForAuthorizationData(accessToken));

return RegistryConfigs.create(configs);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*-
* -\-\-
* docker-client
* --
* Copyright (C) 2016 - 2017 Spotify AB
* --
* 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 com.spotify.docker.client.auth.ecr;

import static com.google.common.base.Preconditions.checkState;

import com.amazonaws.services.ecr.AmazonECR;
import com.amazonaws.services.ecr.model.AuthorizationData;
import com.amazonaws.services.ecr.model.GetAuthorizationTokenRequest;
import com.amazonaws.services.ecr.model.GetAuthorizationTokenResult;
import java.io.IOException;

/**
* Makes getting the authorization data easier.
*/
class EcrCredentials {

private final AmazonECR ecr;
private AuthorizationData authorizationData;

EcrCredentials(final AmazonECR ecr) {
this(ecr, null);
}

EcrCredentials(final AmazonECR ecr, final AuthorizationData authorizationData) {
this.ecr = ecr;
this.authorizationData = authorizationData;
}

public AuthorizationData getAuthorizationData() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be package-private.

return authorizationData;
}

void refresh() throws IOException {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need the IOException here.

GetAuthorizationTokenResult authorizationToken = ecr
.getAuthorizationToken(new GetAuthorizationTokenRequest());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

javadocs for GetAuthorizationTokenRequest() say

A list of AWS account IDs that are associated with the registries for which to get authorization tokens. If you do not specify a registry, the default registry is assumed.

Will the user ever need to use registries other than the default?

checkState(authorizationToken != null, "Unable to get auth token result from ECR");

AuthorizationData data = authorizationToken.getAuthorizationData().get(0);
checkState(data != null, "Unable to get auth data from ECR");
this.authorizationData = data;
}
}
Loading