+ * A RegistryAuthSupplier for getting access tokens from a Amazon Web Services service or user + * account. This implementation uses the aos-java-sdk-core library to get an access + * token given an account's credentials, and will refresh the token if it expires within a + * configurable amount of time. + *
+ * + *+ * To construct a new instance, use any of the static methods that return a {@link Builder}. + *
+ */ +public class ElasticContainerRegistryAuthSupplier implements RegistryAuthSupplier { + private static final Logger log = LoggerFactory + .getLogger(ElasticContainerRegistryAuthSupplier.class); + + /** + * Constructs an ElasticContainerRegistryAuthSupplier using the default provider chain. + * + * @see DefaultAWSCredentialsProviderChain + * @see Builder + */ + public static Builder getApplicationDefaultDefault() throws IOException { + return forProvider(new DefaultAWSCredentialsProviderChain()); + } + + /** + * Constructs an ElasticContainerRegistryAuthSupplier using data from the program + * environment variables AWS_ACCESS_KEY and AWS_SECRET_ACCESS_KEY. + * + * @see EnvironmentVariableCredentialsProvider + * @see Builder + */ + public static Builder forEnvironmentVariables() throws IOException { + return forProvider(new EnvironmentVariableCredentialsProvider()); + } + + /** + * Constructs an ElasticContainerRegistryAuthSupplier using data from the program + * environment variables AWS_ACCESS_KEY and AWS_SECRET_ACCESS_KEY. + * + * @see SystemPropertiesCredentialsProvider + * @see Builder + */ + public static Builder forSystemProperties() throws IOException { + return forProvider(new SystemPropertiesCredentialsProvider()); + } + + /** + * Constructs an ElasticContainerRegistryAuthSupplier using the default profile + * from the system default profile config file. + * + * @see ProfileCredentialsProvider + * @see Builder + */ + public static Builder forProfile() throws IOException { + return forProfile("default"); + } + + /** + * Constructs an ElasticContainerRegistryAuthSupplier using the given profile + * from the system default profile config file. + * + * @see ProfileCredentialsProvider + * @see Builder + */ + public static Builder forProfile(String profile) throws IOException { + return forProvider(new ProfileCredentialsProvider(profile)); + } + + /** + * Constructs an ElasticContainerRegistryAuthSupplier using the given credentials. + * + * @see ProfileCredentialsProvider + * @see Builder + */ + public static Builder forCredentials( + String awsTokenId, + String awsTokenSecret) throws IOException { + return forCredentials(new BasicAWSCredentials(awsTokenId, awsTokenSecret)); + } + + /** + * Constructs an ElasticContainerRegistryAuthSupplier using the given credentials. + * + * @see ProfileCredentialsProvider + * @see Builder + */ + public static Builder forCredentials(AWSCredentials credentials) throws IOException { + return forProvider(new AWSStaticCredentialsProvider(credentials)); + } + + /** + * Constructs an ElasticContainerRegistryAuthSupplier using the given credentials. + * + * @see ProfileCredentialsProvider + * @see Builder + */ + public static Builder forProvider(AWSCredentialsProvider provider) throws IOException { + return new Builder(provider); + } + + /** + * A Builder of ContainerRegistryAuthSupplier. + *+ * The access tokens returned by the ContainerRegistryAuthSupplier are scoped to + * devstorage.read_write by default, these can be customized with {@link #withScopes(Collection)}. + *
+ *+ * 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 + * AccessToken for the GoogleCredentials expires within this amount of time. If it does, then the + * AccessToken is refreshed before being returned.
+ */ + public static class Builder { + private final AWSCredentialsProvider provider; + private long minimumExpiryMillis; + + public Builder(final AWSCredentialsProvider provider) { + this.provider = provider; + this.minimumExpiryMillis = TimeUnit.MINUTES.toMillis(1L); + } + + public AWSCredentialsProvider getProvider() { + return provider; + } + + public long getMinimumExpiryMillis() { + return minimumExpiryMillis; + } + + public Builder withMinimumExpiryMillis(long amount, TimeUnit unit) { + this.minimumExpiryMillis = TimeUnit.MILLISECONDS.convert(amount, unit); + return this; + } + + public ElasticContainerRegistryAuthSupplier build() { + // log some sort of identifier for the credentials, which requires looking at the + // instance type + log.info("Loading credentials from " + provider.getClass().getName()); + + return new ElasticContainerRegistryAuthSupplier(getMinimumExpiryMillis(), getProvider()); + } + } + + private final Clock clock; + private final long minimumExpiryMillis; + private final Authenticator.Factory authenticatorFactory; + + /** + * This is the "current" token. In ECR, most of the activity involves doing + * pushes and pulls from your "default" registry, which is scoped to your + * account. As a result, we only cache the most recent token. It would not be + * difficult to expand this to a Guava Cache if needed. + */ + private Authenticator.Authentication authentication; + + @VisibleForTesting + ElasticContainerRegistryAuthSupplier( + final long minimumExpiryMillis, + final AWSCredentialsProvider provider) { + this(Clock.SYSTEM, minimumExpiryMillis, new EcrAuthenticatorFactory(provider)); + } + + + @VisibleForTesting + ElasticContainerRegistryAuthSupplier( + final Clock clock, + final long minimumExpiryMillis, + final Authenticator.Factory authenticatorFactory) { + this.clock = clock; + this.minimumExpiryMillis = minimumExpiryMillis; + this.authenticatorFactory = authenticatorFactory; + } + + /** + * @param registry ECR registry, e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com. If + * null, this method returns a token for the user's default registry. + */ + protected RegistryAuth authForRegistry(final String registry) throws DockerException { + if (registry != null) { + final String[] registryParts = registry.split("\\.", 6); + if (registryParts.length < 6) { + // not an ECR registry + return null; + } + + // TODO: Validate account ID, region + // String accountId = registryParts[0]; + String dkr = registryParts[1]; + String ecr = registryParts[2]; + // String region = registryParts[3]; + String amazonaws = registryParts[4]; + String com = registryParts[5]; + if (!dkr.equals("dkr") || !ecr.equals("ecr") + || !amazonaws.equals("amazonaws") || !com.equals("com")) { + // not an image on ECR + return null; + } + } + + Authenticator.Authentication result; + if (authentication != null && covers(authentication, registry) && !expired(authentication)) { + result = authentication; + } else { + result = authentication = authenticate(registry); + } + + return result != null ? result.toRegistryAuth() : null; + } + + @Override + public RegistryAuth authFor(final String imageName) throws DockerException { + final String[] imageParts = imageName.split("/", 2); + if (imageParts.length < 2) { + // not an image on ECR + return null; + } + + // Example: 123456789.dkr.ecr.us-east-1.amazonaws.com + final String registry = imageParts[0]; + + return authForRegistry(registry); + } + + @Override + public RegistryAuth authForSwarm() throws DockerException { + return authForRegistry(null); + } + + @Override + public RegistryConfigs authForBuild() throws DockerException { + Authenticator.Authentication result; + if (authentication != null && covers(authentication, null) && !expired(authentication)) { + result = authentication; + } else { + result = authentication = authenticate(null); + } + return RegistryConfigs.create( + Collections.singletonMap(result.registry, result.toRegistryAuth())); + } + + protected Authenticator.Authentication authenticate(String registry) throws DockerException { + Authenticator.Authentication result; + try (Authenticator authenticator = getAuthenticatorFactory().getAuthenticator()) { + result = authenticator.authenticate(registry); + } + return result; + } + + protected boolean expired(Authenticator.Authentication authentication) { + boolean result; + + if (authentication.expiration == -1) { + result = false; + } else { + long now = clock.currentTimeMillis(); + long then = now + minimumExpiryMillis; + if (then > authentication.expiration) { + result = true; + } else { + result = false; + } + } + + return result; + } + + private Authenticator.Factory getAuthenticatorFactory() { + return authenticatorFactory; + } + + private static boolean covers(Authenticator.Authentication authentication, String registry) { + boolean result; + + if (authentication.def) { + result = registry == null || registry.equals(authentication.registry); + } else { + result = Objects.equals(registry, authentication.registry); + } + + return result; + } +} \ No newline at end of file diff --git a/src/main/java/com/spotify/docker/client/auth/ecr/package-info.java b/src/main/java/com/spotify/docker/client/auth/ecr/package-info.java new file mode 100644 index 000000000..22b4d8fd5 --- /dev/null +++ b/src/main/java/com/spotify/docker/client/auth/ecr/package-info.java @@ -0,0 +1,24 @@ +/*- + * -\-\- + * 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. + * -/-/- + */ + +/** + * Support for authenticating with Amazon Elastic Container Registry. + */ +package com.spotify.docker.client.auth.ecr; diff --git a/src/test/java/com/spotify/docker/client/auth/ecr/ElasticContainerRegistryAuthSupplierTest.java b/src/test/java/com/spotify/docker/client/auth/ecr/ElasticContainerRegistryAuthSupplierTest.java new file mode 100644 index 000000000..e36acc22d --- /dev/null +++ b/src/test/java/com/spotify/docker/client/auth/ecr/ElasticContainerRegistryAuthSupplierTest.java @@ -0,0 +1,420 @@ +/*- + * -\-\- + * 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 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.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.joda.time.DateTime; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +public class ElasticContainerRegistryAuthSupplierTest { + + @Rule + public final ExpectedException exception = ExpectedException.none(); + + private final int minimumExpirationSecs = 30; + private final DateTime now = new DateTime(2000, 1, 1, 12, 0, 0); + private final DateTime expiration = now.plusSeconds(2 * minimumExpirationSecs); + + private final Clock clock = mock(Clock.class); + + private final Authenticator.Factory authenticatorFactory = mock(Authenticator.Factory.class); + + private final ElasticContainerRegistryAuthSupplier supplier = + new ElasticContainerRegistryAuthSupplier( + clock, + TimeUnit.SECONDS.toMillis(minimumExpirationSecs), + authenticatorFactory); + + public static final String REGISTRY1 = + "123456789012.dkr.ecr.us-east-1.amazonaws.com"; + + public static final String IMAGE1 = + "123456789012.dkr.ecr.us-east-1.amazonaws.com/foo/barticus:latest"; + + @Test + public void testAuthForImage_NoRefresh() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + expiration.getMillis(), + false); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(REGISTRY1)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(now.plusSeconds(minimumExpirationSecs - 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + assertThat(supplier.authFor(IMAGE1), is(auth)); + + assertThat(supplier.authFor(IMAGE1), is(auth)); + + verify(authenticator, times(1)).authenticate(REGISTRY1); + } + + @Test + public void testAuthForImage_RefreshNeeded() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + expiration.getMillis(), + false); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(REGISTRY1)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(now.plusSeconds(minimumExpirationSecs + 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + assertThat(supplier.authFor(IMAGE1), is(auth)); + + assertThat(supplier.authFor(IMAGE1), is(auth)); + + verify(authenticator, times(2)).authenticate(REGISTRY1); + } + + @Test + public void testAuthForImage_TokenExpired() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + expiration.getMillis(), + false); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(REGISTRY1)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(expiration.plusSeconds(minimumExpirationSecs + 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + assertThat(supplier.authFor(IMAGE1), is(auth)); + + assertThat(supplier.authFor(IMAGE1), is(auth)); + + verify(authenticator, times(2)).authenticate(REGISTRY1); + } + + @Test + public void testAuthForImage_NonEcrImage() throws Exception { + when(clock.currentTimeMillis()) + .thenReturn(expiration.minusSeconds(minimumExpirationSecs + 1).getMillis()); + + assertThat(supplier.authFor("foobar"), is(nullValue())); + } + + @Test + public void testAuthForImage_ExceptionOnRefresh() throws Exception { + final DockerException ex = new DockerException("failure!!"); + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(REGISTRY1)) + .thenThrow(ex); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + // the exception should propagate up + exception.expect(DockerException.class); + + supplier.authFor(IMAGE1); + } + + @Test + public void testAuthForImage_TokenWithoutExpirationDoesNotCauseRefresh() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + -1L, + false); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(REGISTRY1)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(expiration.plusSeconds(minimumExpirationSecs + 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + assertThat(supplier.authFor(IMAGE1), is(auth)); + + assertThat(supplier.authFor(IMAGE1), is(auth)); + + verify(authenticator, times(1)).authenticate(REGISTRY1); + } + + @Test + public void testAuthForSwarm_NoRefresh() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + expiration.getMillis(), + true); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(null)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(now.plusSeconds(minimumExpirationSecs - 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + assertThat(supplier.authForSwarm(), is(auth)); + + assertThat(supplier.authForSwarm(), is(auth)); + + verify(authenticator, times(1)).authenticate(null); + } + + @Test + public void testAuthForSwarm_RefreshNeeded() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + expiration.getMillis(), + true); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(null)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(now.plusSeconds(minimumExpirationSecs + 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + assertThat(supplier.authForSwarm(), is(auth)); + + assertThat(supplier.authForSwarm(), is(auth)); + + verify(authenticator, times(2)).authenticate(null); + } + + @Test + public void testAuthForSwarm_ExceptionOnRefresh() throws Exception { + final DockerException ex = new DockerException("failure!!"); + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(null)) + .thenThrow(ex); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + // the exception should propagate up + exception.expect(DockerException.class); + + supplier.authForSwarm(); + } + + @Test + public void testAuthForSwarm_TokenWithoutExpirationDoesNotCauseRefresh() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + -1L, + true); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(null)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(expiration.plusSeconds(minimumExpirationSecs + 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + assertThat(supplier.authForSwarm(), is(auth)); + + assertThat(supplier.authForSwarm(), is(auth)); + + verify(authenticator, times(1)).authenticate(null); + } + + @Test + public void testAuthForBuild_NoRefresh() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + expiration.getMillis(), + true); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(null)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(now.plusSeconds(minimumExpirationSecs - 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + RegistryConfigs configs1 = supplier.authForBuild(); + assertThat(configs1.configs().values(), is(not(empty()))); + assertThat(configs1.configs().values(), everyItem(is(auth))); + + RegistryConfigs configs2 = supplier.authForBuild(); + assertThat(configs2.configs().values(), is(not(empty()))); + assertThat(configs2.configs().values(), everyItem(is(auth))); + + verify(authenticator, times(1)).authenticate(null); + } + + @Test + public void testAuthForBuild_RefreshNeeded() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + expiration.getMillis(), + true); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(null)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(now.plusSeconds(minimumExpirationSecs + 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + RegistryConfigs configs1 = supplier.authForBuild(); + assertThat(configs1.configs().values(), is(not(empty()))); + assertThat(configs1.configs().values(), everyItem(is(auth))); + + RegistryConfigs configs2 = supplier.authForBuild(); + assertThat(configs2.configs().values(), is(not(empty()))); + assertThat(configs2.configs().values(), everyItem(is(auth))); + + verify(authenticator, times(2)).authenticate(null); + } + + @Test + public void testAuthForBuild_ExceptionOnRefresh() throws Exception { + final DockerException ex = new DockerException("failure!!"); + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(null)) + .thenThrow(ex); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + // the exception should propagate up + exception.expect(DockerException.class); + + supplier.authForBuild(); + } + + @Test + public void testAuthForBuild_TokenWithoutExpirationDoesNotCauseRefresh() throws Exception { + Authenticator.Authentication authentication = new Authenticator.Authentication( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + REGISTRY1, + -1L, + true); + + Authenticator authenticator = mock(Authenticator.class); + when(authenticator.authenticate(null)) + .thenReturn(authentication); + + when(authenticatorFactory.getAuthenticator()) + .thenReturn(authenticator); + + when(clock.currentTimeMillis()) + .thenReturn(expiration.plusSeconds(minimumExpirationSecs + 1).getMillis()); + + RegistryAuth auth = authentication.toRegistryAuth(); + + RegistryConfigs configs1 = supplier.authForBuild(); + assertThat(configs1.configs().values(), is(not(empty()))); + assertThat(configs1.configs().values(), everyItem(is(auth))); + + RegistryConfigs configs2 = supplier.authForBuild(); + assertThat(configs2.configs().values(), is(not(empty()))); + assertThat(configs2.configs().values(), everyItem(is(auth))); + + verify(authenticator, times(1)).authenticate(null); + } +}