diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/PushBlobStep.java b/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/PushBlobStep.java index 662b37a3cd..aa97eaa8fa 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/PushBlobStep.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/builder/steps/PushBlobStep.java @@ -96,8 +96,16 @@ public BlobDescriptor call() throws IOException, RegistryException, ExecutionExc return blobDescriptor; } - // todo: leverage cross-repository mounts - registryClient.pushBlob(blobDescriptor.getDigest(), blob, null, throttledProgressReporter); + // If base and target images are in the same registry, then use mount/from to try mounting the + // BLOB from the base image repository to the target image repository and possibly avoid + // having to push the BLOB. See + // https://docs.docker.com/registry/spec/api/#cross-repository-blob-mount for details. + String baseRegistry = buildConfiguration.getBaseImageConfiguration().getImageRegistry(); + String baseRepository = buildConfiguration.getBaseImageConfiguration().getImageRepository(); + String targetRegistry = buildConfiguration.getTargetImageConfiguration().getImageRegistry(); + String sourceRepository = targetRegistry.equals(baseRegistry) ? baseRepository : null; + registryClient.pushBlob( + blobDescriptor.getDigest(), blob, sourceRepository, throttledProgressReporter); return blobDescriptor; } diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/configuration/BuildConfiguration.java b/jib-core/src/main/java/com/google/cloud/tools/jib/configuration/BuildConfiguration.java index 1c9ca20b53..e4d102d5bd 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/configuration/BuildConfiguration.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/configuration/BuildConfiguration.java @@ -449,6 +449,18 @@ public RegistryClient.Factory newBaseImageRegistryClientFactory() { * @return a new {@link RegistryClient.Factory} */ public RegistryClient.Factory newTargetImageRegistryClientFactory() { + // if base and target are on the same registry, try enabling cross-repository mounts + if (baseImageConfiguration + .getImageRegistry() + .equals(targetImageConfiguration.getImageRegistry())) { + return RegistryClient.factory( + getEventHandlers(), + targetImageConfiguration.getImageRegistry(), + targetImageConfiguration.getImageRepository(), + baseImageConfiguration.getImageRepository()) + .setAllowInsecureRegistries(getAllowInsecureRegistries()) + .setUserAgentSuffix(getToolName()); + } return newRegistryClientFactory(targetImageConfiguration); } diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/global/JibSystemProperties.java b/jib-core/src/main/java/com/google/cloud/tools/jib/global/JibSystemProperties.java index 9f8829558e..cf78836645 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/global/JibSystemProperties.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/global/JibSystemProperties.java @@ -25,6 +25,8 @@ public class JibSystemProperties { @VisibleForTesting public static final String HTTP_TIMEOUT = "jib.httpTimeout"; + @VisibleForTesting static final String CROSS_REPOSITORY_BLOB_MOUNTS = "jib.blobMounts"; + @VisibleForTesting public static final String SEND_CREDENTIALS_OVER_HTTP = "sendCredentialsOverHttp"; @@ -46,6 +48,18 @@ public static int getHttpTimeout() { return Integer.getInteger(HTTP_TIMEOUT); } + /** + * Gets whether or not to use cross-repository blob mounts when uploading image layers + * ({@code mount/from}). This is defined by the {@code jib.blobMounts} system property. + * + * @return {@code true} if {@code mount/from} should be used, {@code false} if not, defaulting to + * {@code true} + */ + public static boolean useCrossRepositoryBlobMounts() { + return System.getProperty(CROSS_REPOSITORY_BLOB_MOUNTS) == null + || Boolean.getBoolean(CROSS_REPOSITORY_BLOB_MOUNTS); + } + /** * Gets whether or not to serialize Jib's execution. This is defined by the {@code jibSerialize} * system property. diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/http/Authorization.java b/jib-core/src/main/java/com/google/cloud/tools/jib/http/Authorization.java index 7c764f4f2f..ff1f365862 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/http/Authorization.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/http/Authorization.java @@ -29,14 +29,6 @@ */ public class Authorization { - /** - * @param token the token - * @return an {@link Authorization} with a {@code Bearer} token - */ - public static Authorization fromBearerToken(String token) { - return new Authorization("Bearer", token); - } - /** * @param username the username * @param secret the secret @@ -56,6 +48,14 @@ public static Authorization fromBasicToken(String token) { return new Authorization("Basic", token); } + /** + * @param token the token + * @return an {@link Authorization} with a {@code Bearer} token + */ + public static Authorization fromBearerToken(String token) { + return new Authorization("Bearer", token); + } + private final String scheme; private final String token; diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/json/JsonTemplateMapper.java b/jib-core/src/main/java/com/google/cloud/tools/jib/json/JsonTemplateMapper.java index fe462059f6..45747d3f29 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/json/JsonTemplateMapper.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/json/JsonTemplateMapper.java @@ -104,6 +104,20 @@ public static T readJson(String jsonString, Class te return objectMapper.readValue(jsonString, templateClass); } + /** + * Deserializes a JSON object from a JSON byte array. + * + * @param child type of {@link JsonTemplate} + * @param jsonBytes a JSON byte array + * @param templateClass the template to deserialize the string to + * @return the template filled with the values parsed from {@code jsonBytes} + * @throws IOException if an error occurred during parsing the JSON + */ + public static T readJson(byte[] jsonBytes, Class templateClass) + throws IOException { + return objectMapper.readValue(jsonBytes, templateClass); + } + /** * Deserializes a JSON object list from a JSON string. * diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryAuthenticator.java b/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryAuthenticator.java index f67f08644a..7820595a1c 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryAuthenticator.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryAuthenticator.java @@ -30,6 +30,7 @@ import com.google.cloud.tools.jib.json.JsonTemplateMapper; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Verify; +import com.google.common.collect.ImmutableMap; import com.google.common.io.CharStreams; import com.google.common.net.MediaType; import java.io.IOException; @@ -37,6 +38,8 @@ import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Map.Entry; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; @@ -180,26 +183,30 @@ public Authorization authenticatePush(@Nullable Credential credential) } @VisibleForTesting - String getServiceScopeRequestParameters(String scope) { - return "service=" - + service - + "&scope=repository:" - + registryEndpointRequestProperties.getImageName() - + ":" - + scope; + String getServiceScopeRequestParameters(Map repositoryScopes) { + StringBuilder parameters = new StringBuilder("service=").append(service); + for (Entry pair : repositoryScopes.entrySet()) { + parameters + .append("&scope=repository:") + .append(pair.getKey()) + .append(":") + .append(pair.getValue()); + } + return parameters.toString(); } @VisibleForTesting - URL getAuthenticationUrl(@Nullable Credential credential, String scope) + URL getAuthenticationUrl(@Nullable Credential credential, Map repositoryScopes) throws MalformedURLException { return isOAuth2Auth(credential) ? new URL(realm) // Required parameters will be sent via POST . - : new URL(realm + "?" + getServiceScopeRequestParameters(scope)); + : new URL(realm + "?" + getServiceScopeRequestParameters(repositoryScopes)); } @VisibleForTesting - String getAuthRequestParameters(@Nullable Credential credential, String scope) { - String serviceScope = getServiceScopeRequestParameters(scope); + String getAuthRequestParameters( + @Nullable Credential credential, Map repositoryScopes) { + String serviceScope = getServiceScopeRequestParameters(repositoryScopes); return isOAuth2Auth(credential) ? serviceScope // https://github.com/GoogleContainerTools/jib/pull/1545 @@ -227,15 +234,43 @@ boolean isOAuth2Auth(@Nullable Credential credential) { */ private Authorization authenticate(@Nullable Credential credential, String scope) throws RegistryAuthenticationFailedException { + // try authorizing against both the main repository and the source repository too + // to enable cross-repository mounts on pushes + if (registryEndpointRequestProperties.getSourceImageName() != null) { + try { + Map scopes = + ImmutableMap.of( + registryEndpointRequestProperties.getImageName(), + scope, + registryEndpointRequestProperties.getSourceImageName(), + "pull"); + Authorization auth = authenticate(credential, scopes); + if (auth != null) { + return auth; + } + } catch (RegistryAuthenticationFailedException ex) { + // Unable to obtain authorization with source image: fallthrough and try without + } + } + Map repositoryScopes = + ImmutableMap.of(registryEndpointRequestProperties.getImageName(), scope); + Authorization auth = authenticate(credential, repositoryScopes); + return auth; + } + + private Authorization authenticate( + @Nullable Credential credential, Map repositoryScopes) + throws RegistryAuthenticationFailedException { try (Connection connection = - Connection.getConnectionFactory().apply(getAuthenticationUrl(credential, scope))) { + Connection.getConnectionFactory() + .apply(getAuthenticationUrl(credential, repositoryScopes))) { Request.Builder requestBuilder = Request.builder() .setHttpTimeout(JibSystemProperties.getHttpTimeout()) .setUserAgent(userAgent); if (isOAuth2Auth(credential)) { - String parameters = getAuthRequestParameters(credential, scope); + String parameters = getAuthRequestParameters(credential, repositoryScopes); requestBuilder.setBody( new BlobHttpContent(Blobs.from(parameters), MediaType.FORM_DATA.toString())); } else if (credential != null) { @@ -257,9 +292,9 @@ private Authorization authenticate(@Nullable Credential credential, String scope registryEndpointRequestProperties.getServerUrl(), registryEndpointRequestProperties.getImageName(), "Did not get token in authentication response from " - + getAuthenticationUrl(credential, scope) + + getAuthenticationUrl(credential, repositoryScopes) + "; parameters: " - + getAuthRequestParameters(credential, scope)); + + getAuthRequestParameters(credential, repositoryScopes)); } return Authorization.fromBearerToken(responseJson.getToken()); diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryClient.java b/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryClient.java index b1b7e9e2c9..4dfe8dd52b 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryClient.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryClient.java @@ -16,6 +16,8 @@ package com.google.cloud.tools.jib.registry; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.google.api.client.util.Base64; import com.google.cloud.tools.jib.ProjectInfo; import com.google.cloud.tools.jib.api.DescriptorDigest; import com.google.cloud.tools.jib.api.RegistryException; @@ -30,12 +32,18 @@ import com.google.cloud.tools.jib.image.json.ManifestTemplate; import com.google.cloud.tools.jib.image.json.V21ManifestTemplate; import com.google.cloud.tools.jib.image.json.V22ManifestTemplate; +import com.google.cloud.tools.jib.json.JsonTemplate; +import com.google.cloud.tools.jib.json.JsonTemplateMapper; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.base.Verify; +import com.google.common.collect.ImmutableSetMultimap; +import com.google.common.collect.Multimap; import java.io.IOException; import java.net.URL; +import java.util.List; import java.util.function.Consumer; +import java.util.stream.Stream; import javax.annotation.Nullable; /** Interfaces with a registry. */ @@ -141,6 +149,92 @@ public static Factory factory(EventHandlers eventHandlers, String serverUrl, Str return new Factory(eventHandlers, new RegistryEndpointRequestProperties(serverUrl, imageName)); } + public static Factory factory( + EventHandlers eventHandlers, String serverUrl, String imageName, String sourceImageName) { + return new Factory( + eventHandlers, + new RegistryEndpointRequestProperties(serverUrl, imageName, sourceImageName)); + } + + /** + * A simple class representing the payload of a Docker Registry v2 Bearer Token + * which lists the set of access claims granted. + * + *
+   * {"access":[{"type": "repository","name": "library/openjdk","actions":["push","pull"]}]}
+   * 
+ * + * @see AccessClaim + */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static class TokenPayloadTemplate implements JsonTemplate { + @Nullable private List access; + } + + /** + * Represents an access claim for a repository in a Docker Registry Bearer Token payload. + * + *
{"type": "repository","name": "library/openjdk","actions":["push","pull"]}
+ */ + @JsonIgnoreProperties(ignoreUnknown = true) + private static class AccessClaim implements JsonTemplate { + @Nullable private String type; + @Nullable private String name; + @Nullable private List actions; + } + + /** + * Decode the Docker Registry v2 Bearer + * Token to list the granted repositories with their levels of access. + * + * @param token a Docker Registry Bearer Token + * @return a mapping of repository to granted access scopes, or {@code null} if the token is not a + * Docker Registry Bearer Token + */ + @VisibleForTesting + @Nullable + static Multimap decodeTokenRepositoryGrants(String token) { + // Docker Registry Bearer Tokens are based on JWT. A valid JWT is a set of 3 base64-encoded + // parts (header, payload, signature), collated with a ".". The header and payload are + // JSON objects. + String[] jwtParts = token.split("\\.", -1); + byte[] payloadData; + if (jwtParts.length != 3 || (payloadData = Base64.decodeBase64(jwtParts[1])) == null) { + return null; + } + + // The payload looks like: + // { + // "access":[{"type":"repository","name":"repository/name","actions":["pull"]}], + // "aud":"registry.docker.io", + // "iss":"auth.docker.io", + // "exp":999, + // "iat":999, + // "jti":"zzzz", + // "nbf":999, + // "sub":"e3ae001d-xxx" + // } + // + try { + TokenPayloadTemplate payload = + JsonTemplateMapper.readJson(payloadData, TokenPayloadTemplate.class); + if (payload.access == null) { + return null; + } + return payload + .access + .stream() + .filter(claim -> "repository".equals(claim.type)) + .collect( + ImmutableSetMultimap.flatteningToImmutableSetMultimap( + claim -> claim.name, + claim -> claim.actions == null ? Stream.empty() : claim.actions.stream())); + } catch (IOException ex) { + return null; + } + } + private final EventHandlers eventHandlers; @Nullable private final Authorization authorization; private final RegistryEndpointRequestProperties registryEndpointRequestProperties; @@ -290,6 +384,13 @@ public boolean pushBlob( @Nullable String sourceRepository, Consumer writtenByteCountListener) throws IOException, RegistryException { + + if (sourceRepository != null + && !(JibSystemProperties.useCrossRepositoryBlobMounts() + && canAttemptBlobMount(authorization, sourceRepository))) { + // don't bother requesting a cross-repository blob-mount if we don't have access + sourceRepository = null; + } BlobPusher blobPusher = new BlobPusher(registryEndpointRequestProperties, blobDigest, blob, sourceRepository); @@ -298,8 +399,8 @@ public boolean pushBlob( try (TimerEventDispatcher timerEventDispatcher2 = timerEventDispatcher.subTimer("pushBlob POST " + blobDigest)) { - // POST /v2//blobs/uploads/ OR // POST /v2//blobs/uploads/?mount={blob.digest}&from={sourceRepository} + // POST /v2//blobs/uploads/ URL patchLocation = callRegistryEndpoint(blobPusher.initializer()); if (patchLocation == null) { // The BLOB exists already. @@ -323,6 +424,29 @@ public boolean pushBlob( } } + /** + * Check if the authorization allows using the specified repository can be mounted by the remote + * registry as a source for blobs. More specifically, we can only check if the repository is not + * disallowed. + * + * @param repository repository in question + * @return {@code true} if the repository appears to be mountable + */ + @VisibleForTesting + static boolean canAttemptBlobMount(@Nullable Authorization authorization, String repository) { + if (authorization == null || !"bearer".equalsIgnoreCase(authorization.getScheme())) { + // Authorization methods other than the Docker Container Registry Token don't provide + // information as to which repositories are accessible. The caller should attempt the mount + // and rely on the registry fallback as required by the spec. + // https://docs.docker.com/registry/spec/api/#pushing-an-image + return true; + } + // if null then does not appear to be a DCRT + Multimap repositoryGrants = + decodeTokenRepositoryGrants(authorization.getToken()); + return repositoryGrants == null || repositoryGrants.containsEntry(repository, "pull"); + } + /** @return the registry endpoint's API root, without the protocol */ @VisibleForTesting String getApiRouteBase() { diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryEndpointRequestProperties.java b/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryEndpointRequestProperties.java index 4483344f74..2e125ec847 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryEndpointRequestProperties.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryEndpointRequestProperties.java @@ -16,19 +16,33 @@ package com.google.cloud.tools.jib.registry; +import javax.annotation.Nullable; + /** Properties of registry endpoint requests. */ class RegistryEndpointRequestProperties { private final String serverUrl; private final String imageName; + @Nullable private final String sourceImageName; /** * @param serverUrl the server URL for the registry (for example, {@code gcr.io}) * @param imageName the image/repository name (also known as, namespace) */ RegistryEndpointRequestProperties(String serverUrl, String imageName) { + this(serverUrl, imageName, null); + } + + /** + * @param serverUrl the server URL for the registry (for example, {@code gcr.io}) + * @param imageName the image/repository name (also known as, namespace) + * @param sourceImageName additional source image to request pull permission from the registry + */ + RegistryEndpointRequestProperties( + String serverUrl, String imageName, @Nullable String sourceImageName) { this.serverUrl = serverUrl; this.imageName = imageName; + this.sourceImageName = sourceImageName; } String getServerUrl() { @@ -38,4 +52,9 @@ String getServerUrl() { String getImageName() { return imageName; } + + @Nullable + String getSourceImageName() { + return sourceImageName; + } } diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/credentials/RegistryCredentials.java b/jib-core/src/main/java/com/google/cloud/tools/jib/registry/credentials/RegistryCredentials.java deleted file mode 100644 index e2fc2ccb14..0000000000 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/credentials/RegistryCredentials.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2018 Google LLC. - * - * 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.google.cloud.tools.jib.registry.credentials; - -import com.google.cloud.tools.jib.http.Authorization; - -/** - * Stores retrieved registry credentials and their source. - * - *

The credentials are referred to by the registry they are used for. - */ -public class RegistryCredentials { - - private final Authorization authorization; - - /** - * A string representation of where the credentials were retrieved from. This is useful for - * letting the user know which credentials were used. - */ - private final String credentialSource; - - public RegistryCredentials(String credentialSource, Authorization authorization) { - this.authorization = authorization; - this.credentialSource = credentialSource; - } - - public Authorization getAuthorization() { - return authorization; - } - - public String getCredentialSource() { - return credentialSource; - } -} diff --git a/jib-core/src/test/java/com/google/cloud/tools/jib/global/JibSystemPropertiesTest.java b/jib-core/src/test/java/com/google/cloud/tools/jib/global/JibSystemPropertiesTest.java index f055a3c789..4fca87bef0 100644 --- a/jib-core/src/test/java/com/google/cloud/tools/jib/global/JibSystemPropertiesTest.java +++ b/jib-core/src/test/java/com/google/cloud/tools/jib/global/JibSystemPropertiesTest.java @@ -45,6 +45,7 @@ public void tearDown() { if (httpsProxyPortSaved != null) { System.setProperty("https.proxyPort", httpsProxyPortSaved); } + System.clearProperty(JibSystemProperties.CROSS_REPOSITORY_BLOB_MOUNTS); } @Test @@ -152,4 +153,33 @@ public void testCheckHttpProxyPortProperty_stringValue() { Assert.assertEquals("https.proxyPort must be an integer: some string", ex.getMessage()); } } + + @Test + public void testUseBlobMountsPropertyName() { + Assert.assertEquals("jib.blobMounts", JibSystemProperties.CROSS_REPOSITORY_BLOB_MOUNTS); + } + + @Test + public void testUseBlobMounts_undefined() { + System.clearProperty(JibSystemProperties.CROSS_REPOSITORY_BLOB_MOUNTS); + Assert.assertTrue(JibSystemProperties.useCrossRepositoryBlobMounts()); + } + + @Test + public void testUseBlobMounts_true() { + System.setProperty(JibSystemProperties.CROSS_REPOSITORY_BLOB_MOUNTS, "true"); + Assert.assertTrue(JibSystemProperties.useCrossRepositoryBlobMounts()); + } + + @Test + public void testUseBlobMounts_false() { + System.setProperty(JibSystemProperties.CROSS_REPOSITORY_BLOB_MOUNTS, "false"); + Assert.assertFalse(JibSystemProperties.useCrossRepositoryBlobMounts()); + } + + @Test + public void testUseBlobMounts_other() { + System.setProperty(JibSystemProperties.CROSS_REPOSITORY_BLOB_MOUNTS, "nonbool"); + Assert.assertFalse(JibSystemProperties.useCrossRepositoryBlobMounts()); + } } diff --git a/jib-core/src/test/java/com/google/cloud/tools/jib/registry/AuthenticationMethodRetrieverTest.java b/jib-core/src/test/java/com/google/cloud/tools/jib/registry/AuthenticationMethodRetrieverTest.java index 7eae71f001..d1e96c1787 100644 --- a/jib-core/src/test/java/com/google/cloud/tools/jib/registry/AuthenticationMethodRetrieverTest.java +++ b/jib-core/src/test/java/com/google/cloud/tools/jib/registry/AuthenticationMethodRetrieverTest.java @@ -23,6 +23,7 @@ import com.google.cloud.tools.jib.http.Response; import java.net.MalformedURLException; import java.net.URL; +import java.util.Collections; import org.hamcrest.CoreMatchers; import org.junit.Assert; import org.junit.Test; @@ -149,6 +150,7 @@ public void testHandleHttpResponseException_pass() Assert.assertEquals( new URL("https://somerealm?service=someservice&scope=repository:someImageName:someScope"), - registryAuthenticator.getAuthenticationUrl(null, "someScope")); + registryAuthenticator.getAuthenticationUrl( + null, Collections.singletonMap("someImageName", "someScope"))); } } diff --git a/jib-core/src/test/java/com/google/cloud/tools/jib/registry/DockerRegistryBearerTokenTest.java b/jib-core/src/test/java/com/google/cloud/tools/jib/registry/DockerRegistryBearerTokenTest.java new file mode 100644 index 0000000000..451d8ba1da --- /dev/null +++ b/jib-core/src/test/java/com/google/cloud/tools/jib/registry/DockerRegistryBearerTokenTest.java @@ -0,0 +1,139 @@ +/* + * Copyright 2019 Google Inc. + * + * 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.google.cloud.tools.jib.registry; + +import com.google.api.client.util.Base64; +import com.google.cloud.tools.jib.http.Authorization; +import com.google.common.collect.Multimap; +import java.nio.charset.StandardCharsets; +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; + +/** + * Tests for {@link RegistryClient} around handling of Docker Registry Bearer Tokens. + * + *

JWTs were generated from jwt.io's JWT Debugger. Set the + * algorithm to HS256, and paste the JSON shown as the Payload. + */ +public class DockerRegistryBearerTokenTest { + @Test + public void testDecode_dockerToken() { + // A genuine token from accessing docker.io's openjdk: + // {"access":[{"type":"repository","name":"library/openjdk","actions":["pull"]}] + // Generated by + // $ cd examples/helloworld + // $ mvn package jib:dockerBuild -Djib.from.image=openjdk \ + // -Djava.util.logging.config.file= + Multimap decoded = + RegistryClient.decodeTokenRepositoryGrants( + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUlDK2pDQ0FwK2dBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakJHTVVRd1FnWURWUVFERXpzeVYwNVpPbFZMUzFJNlJFMUVVanBTU1U5Rk9reEhOa0U2UTFWWVZEcE5SbFZNT2tZelNFVTZOVkF5VlRwTFNqTkdPa05CTmxrNlNrbEVVVEFlRncweE9UQXhNVEl3TURJeU5EVmFGdzB5TURBeE1USXdNREl5TkRWYU1FWXhSREJDQmdOVkJBTVRPMUpMTkZNNlMwRkxVVHBEV0RWRk9rRTJSMVE2VTBwTVR6cFFNbEpMT2tOWlZVUTZTMEpEU0RwWFNVeE1Pa3hUU2xrNldscFFVVHBaVWxsRU1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBcjY2bXkveXpHN21VUzF3eFQ3dFplS2pqRzcvNnBwZFNMY3JCcko5VytwcndzMGtIUDVwUHRkMUpkcFdEWU1OZWdqQXhpUWtRUUNvd25IUnN2ODVUalBUdE5wUkdKVTRkeHJkeXBvWGc4TVhYUEUzL2lRbHhPS2VNU0prNlRKbG5wNGFtWVBHQlhuQXRoQzJtTlR5ak1zdFh2ZmNWN3VFYWpRcnlOVUcyUVdXQ1k1Ujl0a2k5ZG54Z3dCSEF6bG8wTzJCczFmcm5JbmJxaCtic3ZSZ1FxU3BrMWhxYnhSU3AyRlNrL2tBL1gyeUFxZzJQSUJxWFFMaTVQQ3krWERYZElJczV6VG9ZbWJUK0pmbnZaMzRLcG5mSkpNalpIRW4xUVJtQldOZXJZcVdtNVhkQVhUMUJrQU9aditMNFVwSTk3NFZFZ2ppY1JINVdBeWV4b1BFclRRSURBUUFCbzRHeU1JR3ZNQTRHQTFVZER3RUIvd1FFQXdJSGdEQVBCZ05WSFNVRUNEQUdCZ1JWSFNVQU1FUUdBMVVkRGdROUJEdFNTelJUT2t0QlMxRTZRMWcxUlRwQk5rZFVPbE5LVEU4NlVESlNTenBEV1ZWRU9rdENRMGc2VjBsTVREcE1VMHBaT2xwYVVGRTZXVkpaUkRCR0JnTlZIU01FUHpBOWdEc3lWMDVaT2xWTFMxSTZSRTFFVWpwU1NVOUZPa3hITmtFNlExVllWRHBOUmxWTU9rWXpTRVU2TlZBeVZUcExTak5HT2tOQk5sazZTa2xFVVRBS0JnZ3Foa2pPUFFRREFnTkpBREJHQWlFQXFOSXEwMFdZTmM5Z2tDZGdSUzRSWUhtNTRZcDBTa05Rd2lyMm5hSWtGd3dDSVFEMjlYdUl5TmpTa1cvWmpQaFlWWFB6QW9TNFVkRXNvUUhyUVZHMDd1N3ZsUT09Il19" + + ".eyJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6ImxpYnJhcnkvb3BlbmpkayIsImFjdGlvbnMiOlsicHVsbCJdfV0sImF1ZCI6InJlZ2lzdHJ5LmRvY2tlci5pbyIsImV4cCI6MTU2MTA0MzkwNSwiaWF0IjoxNTYxMDQzNjA1LCJpc3MiOiJhdXRoLmRvY2tlci5pbyIsImp0aSI6Ikc5bWpiOE9GeU5STFlpY3ZUMFZxIiwibmJmIjoxNTYxMDQzMzA1LCJzdWIiOiIifQ" + + ".jblwG_taIVf3IRiv200ivsc8q_IUj-M9QePKPAULfXdSZlY6H9n_XWtT6lw43k-J6QHfmnY4Yuh3eZq61KS7AT9yggM1VuolRCvYztSZ-MZHMIlvSE2KCc0wXa5gNQarjmDJloYduZuyLaKaRUUbO4osk1MuruODY_c2g2j16ce0Z8XVJ-7R8_J_Z8g0GdtFAfPO4bqpg9dj31MA8AKl3h-ru8NXcs3y1PkrYHpEGCgpcGcUQwLY7uiIrzjr0trCUbsLsv6iq2XTXnN_tTrfvL1R3yTB6gITvXZdsnU3r_UIDTzexTtlZWdntucJAGKX9HMA_jYEcTZ4ZhyEzETGpw"); + Assert.assertEquals(1, decoded.size()); + Assert.assertTrue(decoded.containsEntry("library/openjdk", "pull")); + Assert.assertFalse(decoded.containsEntry("library/openjdk", "push")); + Assert.assertFalse(decoded.containsEntry("randomrepo", "push")); + } + + @Test + public void testDecode_nonToken() { + String base64Text = + Base64.encodeBase64String( + "something other than a JWT token".getBytes(StandardCharsets.UTF_8)); + Multimap decoded = RegistryClient.decodeTokenRepositoryGrants(base64Text); + Assert.assertNull(decoded); + } + + @Test + public void testDecode_invalidToken_accessString() { + // a JWT with an "access" field that is not an array: {"access": "string"} + String jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJzdHJpbmcifQ.12ODBkkfh6J79qEejxwlD5AfOa9mjObPCzOnUL75NSQ"; + Multimap decoded = RegistryClient.decodeTokenRepositoryGrants(jwt); + Assert.assertNull(decoded); + } + + @Test + public void testDecode_invalidToken_accessArray() { + // a JWT with an "access" field that is an array of non-claim objects: {"access":["string"]} + String jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOlsic3RyaW5nIl19.gWZ9J4sO_w0hIVVxrfuuUC2lNhqkU3P0_z46xMCXfwU"; + Multimap decoded = RegistryClient.decodeTokenRepositoryGrants(jwt); + Assert.assertNull(decoded); + } + + @Test + @Ignore("Annotate AccessClaim.actions to disallow coercion of integers to strings") + public void testDecode_invalidToken_actionsArray() { + // a JWT with an "access" field that is an action array of non-strings: + // {"access":[{"type": "repository","name": "library/openjdk","actions":[1]}]} + String jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6ImxpYnJhcnkvb3BlbmpkayIsImFjdGlvbnMiOlsxXX1dfQ.12HZGeFvthXw0PP9ZKdttJRh2qsRfFNTeZV3_lZiI10"; + Multimap decoded = RegistryClient.decodeTokenRepositoryGrants(jwt); + Assert.assertNull(decoded); + } + + @Test + public void testDecode_invalidToken_randoJwt() { + // the JWT example token from jwt.io + String jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + Multimap decoded = RegistryClient.decodeTokenRepositoryGrants(jwt); + Assert.assertNull(decoded); + } + + /** Basic credential should allow access to all. */ + @Test + public void testCanAttemptBlobMount_basicCredential() { + Authorization fixture = Authorization.fromBasicCredentials("foo", "bar"); + Assert.assertTrue(RegistryClient.canAttemptBlobMount(fixture, "random")); + } + + /** Basic token should allow access to all. */ + @Test + public void testCanAttemptBlobMount_basicToken() { + // basic tokens are assumed to allow all repositories to be mounted + Authorization fixture = Authorization.fromBasicToken("gobbledygook"); + Assert.assertTrue(RegistryClient.canAttemptBlobMount(fixture, "library/openjdk")); + Assert.assertTrue(RegistryClient.canAttemptBlobMount(fixture, "randomrepo")); + } + + @Test + public void testCanAttemptBlobMount_bearer_withToken() { + // a synthetic token for accessing docker.io's openjdk with push and pull + // {"access":[{"type":"repository","name":"library/openjdk","actions":["pull","push"]}]} + String token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOlt7InR5cGUiOiJyZXBvc2l0b3J5IiwibmFtZSI6ImxpYnJhcnkvb3BlbmpkayIsImFjdGlvbnMiOlsicHVsbCIsInB1c2giXX1dfQ.VEn96Ug4eseKHX3WwP3PlgR9P7Y6VuYmMm-YRUjngFg"; + Authorization authorization = Authorization.fromBearerToken(token); + Assert.assertNotNull(authorization); + Assert.assertTrue(RegistryClient.canAttemptBlobMount(authorization, "library/openjdk")); + Assert.assertFalse(RegistryClient.canAttemptBlobMount(authorization, "randomrepo")); + } + + @Test + public void testCanAttemptBlobMount_bearer_withNonToken() { + // non-Docker Registry Bearer Tokens are assumed to allow access to all + // the JWT example token from jwt.io + String jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + Authorization authorization = Authorization.fromBearerToken(jwt); + Assert.assertNotNull(authorization); + Assert.assertTrue(RegistryClient.canAttemptBlobMount(authorization, "library/openjdk")); + Assert.assertTrue(RegistryClient.canAttemptBlobMount(authorization, "randomrepo")); + } +} diff --git a/jib-core/src/test/java/com/google/cloud/tools/jib/registry/RegistryAuthenticatorTest.java b/jib-core/src/test/java/com/google/cloud/tools/jib/registry/RegistryAuthenticatorTest.java index d6481b8271..0f498051e6 100644 --- a/jib-core/src/test/java/com/google/cloud/tools/jib/registry/RegistryAuthenticatorTest.java +++ b/jib-core/src/test/java/com/google/cloud/tools/jib/registry/RegistryAuthenticatorTest.java @@ -24,6 +24,7 @@ import java.net.URISyntaxException; import java.net.URL; import java.security.GeneralSecurityException; +import java.util.Collections; import org.hamcrest.CoreMatchers; import org.junit.Assert; import org.junit.Before; @@ -55,7 +56,8 @@ public void testFromAuthenticationMethod_bearer() "user-agent"); Assert.assertEquals( new URL("https://somerealm?service=someservice&scope=repository:someimage:scope"), - registryAuthenticator.getAuthenticationUrl(null, "scope")); + registryAuthenticator.getAuthenticationUrl( + null, Collections.singletonMap("someimage", "scope"))); registryAuthenticator = RegistryAuthenticator.fromAuthenticationMethod( @@ -64,14 +66,16 @@ public void testFromAuthenticationMethod_bearer() "user-agent"); Assert.assertEquals( new URL("https://somerealm?service=someservice&scope=repository:someimage:scope"), - registryAuthenticator.getAuthenticationUrl(null, "scope")); + registryAuthenticator.getAuthenticationUrl( + null, Collections.singletonMap("someimage", "scope"))); } @Test public void testAuthRequestParameters_basicAuth() { Assert.assertEquals( "service=someservice&scope=repository:someimage:scope", - registryAuthenticator.getAuthRequestParameters(null, "scope")); + registryAuthenticator.getAuthRequestParameters( + null, Collections.singletonMap("someimage", "scope"))); } @Test @@ -81,7 +85,8 @@ public void testAuthRequestParameters_oauth2() { "service=someservice&scope=repository:someimage:scope" + "&client_id=jib.da031fe481a93ac107a95a96462358f9" + "&grant_type=refresh_token&refresh_token=oauth2_access_token", - registryAuthenticator.getAuthRequestParameters(credential, "scope")); + registryAuthenticator.getAuthRequestParameters( + credential, Collections.singletonMap("someimage", "scope"))); } @Test @@ -105,7 +110,8 @@ public void isOAuth2Auth_oauth2() { public void getAuthenticationUrl_basicAuth() throws MalformedURLException { Assert.assertEquals( new URL("https://somerealm?service=someservice&scope=repository:someimage:scope"), - registryAuthenticator.getAuthenticationUrl(null, "scope")); + registryAuthenticator.getAuthenticationUrl( + null, Collections.singletonMap("someimage", "scope"))); } @Test @@ -113,7 +119,7 @@ public void istAuthenticationUrl_oauth2() throws MalformedURLException { Credential credential = Credential.from("", "oauth2_token"); Assert.assertEquals( new URL("https://somerealm"), - registryAuthenticator.getAuthenticationUrl(credential, "scope")); + registryAuthenticator.getAuthenticationUrl(credential, Collections.emptyMap())); } @Test @@ -176,7 +182,8 @@ public void testFromAuthenticationMethod_noService() Assert.assertEquals( new URL("https://somerealm?service=someserver&scope=repository:someimage:scope"), - registryAuthenticator.getAuthenticationUrl(null, "scope")); + registryAuthenticator.getAuthenticationUrl( + null, Collections.singletonMap("someimage", "scope"))); } @Test diff --git a/jib-gradle-plugin/CHANGELOG.md b/jib-gradle-plugin/CHANGELOG.md index 11e4788230..9fe1da2973 100644 --- a/jib-gradle-plugin/CHANGELOG.md +++ b/jib-gradle-plugin/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file. ### Fixed +- Re-enabled cross-repository blob mounts ([#1793](https://github.com/GoogleContainerTools/jib/pull/1793)) + ## 1.3.0 ### Changed diff --git a/jib-maven-plugin/CHANGELOG.md b/jib-maven-plugin/CHANGELOG.md index 72c2f7badc..8b01eaeae1 100644 --- a/jib-maven-plugin/CHANGELOG.md +++ b/jib-maven-plugin/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file. ### Fixed +- Re-enabled cross-repository blob mounts ([#1793](https://github.com/GoogleContainerTools/jib/pull/1793)) + ## 1.3.0 ### Changed