diff --git a/pom.xml b/pom.xml index 096ee5fea..ec528789b 100644 --- a/pom.xml +++ b/pom.xml @@ -182,7 +182,23 @@ - + + + com.amazonaws + aws-java-sdk-ecr + 1.11.172 + true + + + com.google.guava + guava + + + org.apache.httpcomponents + httpclient + + + 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())); + } + +}