junit
diff --git a/src/main/java/com/spotify/docker/client/auth/ecr/ContainerRegistryAuthSupplier.java b/src/main/java/com/spotify/docker/client/auth/ecr/ContainerRegistryAuthSupplier.java
new file mode 100644
index 000000000..b38b47860
--- /dev/null
+++ b/src/main/java/com/spotify/docker/client/auth/ecr/ContainerRegistryAuthSupplier.java
@@ -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.
+ *
+ * @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.
+ *
+ * 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.
+ *
+ */
+ 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;
+ 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;
+ }
+
+ // 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.")
+ || !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 configs = new HashMap(1);
+ configs.put(accessToken.getProxyEndpoint(), authForAuthorizationData(accessToken));
+
+ return RegistryConfigs.create(configs);
+ }
+}
diff --git a/src/main/java/com/spotify/docker/client/auth/ecr/EcrCredentials.java b/src/main/java/com/spotify/docker/client/auth/ecr/EcrCredentials.java
new file mode 100644
index 000000000..c57f0cbdb
--- /dev/null
+++ b/src/main/java/com/spotify/docker/client/auth/ecr/EcrCredentials.java
@@ -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() {
+ return authorizationData;
+ }
+
+ void refresh() throws IOException {
+ GetAuthorizationTokenResult authorizationToken = ecr
+ .getAuthorizationToken(new GetAuthorizationTokenRequest());
+ 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;
+ }
+}
diff --git a/src/test/java/com/spotify/docker/client/auth/ecr/ContainerRegistryAuthSupplierTest.java b/src/test/java/com/spotify/docker/client/auth/ecr/ContainerRegistryAuthSupplierTest.java
new file mode 100644
index 000000000..ca968b9a2
--- /dev/null
+++ b/src/test/java/com/spotify/docker/client/auth/ecr/ContainerRegistryAuthSupplierTest.java
@@ -0,0 +1,222 @@
+/*-
+ * -\-\-
+ * 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.amazonaws.util.Base64.encode;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.everyItem;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.Assert.assertThat;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+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 com.amazonaws.util.Base64;
+import com.google.api.client.util.Clock;
+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 org.hamcrest.FeatureMatcher;
+import org.hamcrest.Matcher;
+import org.joda.time.DateTime;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.mockito.Matchers;
+
+public class ContainerRegistryAuthSupplierTest {
+
+ @Rule
+ public final ExpectedException exception = ExpectedException.none();
+
+ private final DateTime expiration = new DateTime(2017, 5, 23, 16, 25);
+ private final String tokenValue = new String(encode("test-username:test-password".getBytes()));
+
+ private AmazonECR ecrClient = mock(AmazonECR.class);
+
+ private final AuthorizationData authData1 = new AuthorizationData()
+ .withAuthorizationToken(tokenValue).withExpiresAt(expiration.toDate())
+ .withProxyEndpoint("http://proxy");
+
+ private final Clock clock = mock(Clock.class);
+ private final int minimumExpirationSecs = 30;
+ private final EcrCredentials credentials = spy(new EcrCredentials(ecrClient, authData1));
+ private final ContainerRegistryAuthSupplier supplier = spy(new ContainerRegistryAuthSupplier(
+ ecrClient, clock, minimumExpirationSecs, credentials));
+
+ private static Matcher matchesAccessToken(final AuthorizationData accessToken) {
+ String decoded = new String(Base64.decode(accessToken.getAuthorizationToken()));
+ final String username = decoded.split(":")[0];
+ final String password = decoded.split(":")[1];
+
+ final Matcher usernameMatcher = new FeatureMatcher(
+ is(username), "username", "username") {
+ @Override
+ protected String featureValueOf(final RegistryAuth actual) {
+ return actual.username();
+ }
+ };
+
+ final Matcher passwordMatcher = new FeatureMatcher(
+ is(password), "password", "password") {
+ @Override
+ protected String featureValueOf(final RegistryAuth actual) {
+ return actual.password();
+ }
+ };
+
+ return allOf(usernameMatcher, passwordMatcher);
+ }
+
+ @Before
+ public void before() {
+ GetAuthorizationTokenResult res = new GetAuthorizationTokenResult()
+ .withAuthorizationData(authData1);
+ doReturn(res).when(ecrClient).getAuthorizationToken(
+ Matchers.any(GetAuthorizationTokenRequest.class));
+ }
+
+ @Test
+ public void testAuthForImage_NoRefresh() throws Exception {
+ when(clock.currentTimeMillis()).thenReturn(
+ expiration.minusSeconds(minimumExpirationSecs + 1).getMillis());
+
+ assertThat(
+ supplier.authFor("1234567890.dkr.ecr.eu-west-1.amazonaws.com/foobar/barfoo:latest"),
+ matchesAccessToken(authData1));
+
+ verify(credentials, never()).refresh();
+ }
+
+ @Test
+ public void testAuthForImage_RefreshNeeded() throws Exception {
+ doReturn(true).when(supplier).needsRefresh(Matchers.any(AuthorizationData.class));
+
+ assertThat(
+ supplier.authFor("1234567890.dkr.ecr.eu-west-1.amazonaws.com/foobar/barfoo:latest"),
+ matchesAccessToken(authData1));
+
+ verify(credentials).refresh();
+ }
+
+ @Test
+ public void testAuthForImage_TokenExpired() throws Exception {
+ doReturn(true).when(supplier).needsRefresh(Matchers.any(AuthorizationData.class));
+
+ assertThat(
+ supplier.authFor("1234567890.dkr.ecr.eu-west-1.amazonaws.com/foobar/barfoo:latest"),
+ matchesAccessToken(authData1));
+
+ verify(credentials).refresh();
+ }
+
+ @Test
+ public void testAuthForImage_NonEcrImage() throws Exception {
+ assertThat(supplier.authFor("foobar"), is(nullValue()));
+ }
+
+ @Test
+ public void testAuthForImage_ExceptionOnRefresh() throws Exception {
+ doReturn(true).when(supplier).needsRefresh(Matchers.any(AuthorizationData.class));
+
+ final IOException ex = new IOException("failure!!");
+ doThrow(ex).when(credentials).refresh();
+
+ // the exception should propagate up
+ exception.expect(DockerException.class);
+ exception.expectCause(is(ex));
+
+ supplier.authFor("1234567890.dkr.ecr.eu-west-1.amazonaws.com/example/foobar:1.2.3");
+ }
+
+ @Test
+ public void testAuthForSwarm_NoRefresh() throws Exception {
+ doReturn(false).when(supplier).needsRefresh(Matchers.any(AuthorizationData.class));
+
+ assertThat(supplier.authForSwarm(), matchesAccessToken(authData1));
+
+ verify(credentials, never()).refresh();
+ }
+
+ @Test
+ public void testAuthForSwarm_RefreshNeeded() throws Exception {
+ doReturn(true).when(supplier).needsRefresh(Matchers.any(AuthorizationData.class));
+
+ assertThat(supplier.authForSwarm(), matchesAccessToken(authData1));
+
+ verify(credentials).refresh();
+ }
+
+ @Test
+ public void testAuthForSwarm_ExceptionOnRefresh() throws Exception {
+ doReturn(true).when(supplier).needsRefresh(Matchers.any(AuthorizationData.class));
+
+ doThrow(new IOException("failure!!")).when(credentials).refresh();
+
+ assertThat(supplier.authForSwarm(), is(nullValue()));
+ }
+
+ @Test
+ public void testAuthForBuild_NoRefresh() throws Exception {
+ doReturn(false).when(supplier).needsRefresh(Matchers.any(AuthorizationData.class));
+
+ final RegistryConfigs configs = supplier.authForBuild();
+ assertThat(configs.configs().values(), is(not(empty())));
+ assertThat(configs.configs().values(), everyItem(matchesAccessToken(authData1)));
+
+ verify(credentials, never()).refresh();
+ }
+
+ @Test
+ public void testAuthForBuild_RefreshNeeded() throws Exception {
+ doReturn(true).when(supplier).needsRefresh(Matchers.any(AuthorizationData.class));
+
+ final RegistryConfigs configs = supplier.authForBuild();
+ assertThat(configs.configs().values(), is(not(empty())));
+ assertThat(configs.configs().values(), everyItem(matchesAccessToken(authData1)));
+
+ verify(credentials).refresh();
+ }
+
+ @Test
+ public void testAuthForBuild_ExceptionOnRefresh() throws Exception {
+ doReturn(true).when(supplier).needsRefresh(Matchers.any(AuthorizationData.class));
+
+ doThrow(new IOException("failure!!")).when(credentials).refresh();
+
+ final RegistryConfigs configs = supplier.authForBuild();
+ assertThat(configs.configs().values(), is(empty()));
+ }
+
+}