diff --git a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryEndpointCaller.java b/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryEndpointCaller.java index fe66fad40b..ee228555dc 100644 --- a/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryEndpointCaller.java +++ b/jib-core/src/main/java/com/google/cloud/tools/jib/registry/RegistryEndpointCaller.java @@ -26,12 +26,15 @@ import com.google.cloud.tools.jib.json.JsonTemplateMapper; import com.google.cloud.tools.jib.registry.json.ErrorEntryTemplate; import com.google.cloud.tools.jib.registry.json.ErrorResponseTemplate; +import com.google.common.annotations.VisibleForTesting; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; +import java.util.function.Function; import javax.annotation.Nullable; import javax.net.ssl.SSLPeerUnverifiedException; import org.apache.http.NoHttpResponseException; +import org.apache.http.conn.HttpHostConnectException; /** * Makes requests to a registry endpoint. @@ -50,8 +53,7 @@ private static class RequestState { /** * @param authorization authentication credentials - * @param url the endpoint URL to call, or {@code null} to use default from {@code - * registryEndpointProvider} + * @param url the endpoint URL to call */ private RequestState(@Nullable Authorization authorization, URL url) { this.authorization = authorization; @@ -59,6 +61,21 @@ private RequestState(@Nullable Authorization authorization, URL url) { } } + /** + * Converts the {@link URL}'s protocol to HTTP. + * + * @param url the URL to conver to HTTP + * @return the URL with protocol set to HTTP + */ + private static URL urlWithHttp(URL url) { + GenericUrl httpUrl = new GenericUrl(url); + httpUrl.setScheme("http"); + return httpUrl.toURL(); + } + + /** Makes a {@link Connection} to the specified {@link URL}. */ + private final Function connectionFactory; + private final RequestState initialRequestState; private final String userAgent; private final RegistryEndpointProvider registryEndpointProvider; @@ -81,6 +98,24 @@ private RequestState(@Nullable Authorization authorization, URL url) { @Nullable Authorization authorization, RegistryEndpointProperties registryEndpointProperties) throws MalformedURLException { + this( + userAgent, + apiRouteBase, + registryEndpointProvider, + authorization, + registryEndpointProperties, + Connection::new); + } + + @VisibleForTesting + RegistryEndpointCaller( + String userAgent, + String apiRouteBase, + RegistryEndpointProvider registryEndpointProvider, + @Nullable Authorization authorization, + RegistryEndpointProperties registryEndpointProperties, + Function connectionFactory) + throws MalformedURLException { this.initialRequestState = new RequestState( authorization, @@ -88,6 +123,7 @@ private RequestState(@Nullable Authorization authorization, URL url) { this.userAgent = userAgent; this.registryEndpointProvider = registryEndpointProvider; this.registryEndpointProperties = registryEndpointProperties; + this.connectionFactory = connectionFactory; } /** @@ -102,10 +138,18 @@ T call() throws IOException, RegistryException { return call(initialRequestState); } - /** Calls the registry endpoint with a certain {@link RequestState}. */ + /** + * Calls the registry endpoint with a certain {@link RequestState}. + * + * @param requestState the state of the request - determines how to make the request and how to + * process the response + * @return an object representing the response, or {@code null} + * @throws IOException for most I/O exceptions when making the request + * @throws RegistryException for known exceptions when interacting with the registry + */ @Nullable private T call(RequestState requestState) throws IOException, RegistryException { - try (Connection connection = new Connection(requestState.url)) { + try (Connection connection = connectionFactory.apply(requestState.url)) { Request request = Request.builder() .setAuthorization(requestState.authorization) @@ -161,14 +205,16 @@ private T call(RequestState requestState) throws IOException, RegistryException } } + } catch (HttpHostConnectException | SSLPeerUnverifiedException ex) { + // Tries to call with HTTP protocol if HTTPS failed to connect. + if ("https".equals(requestState.url.getProtocol())) { + return call(new RequestState(requestState.authorization, urlWithHttp(requestState.url))); + } + + throw ex; + } catch (NoHttpResponseException ex) { throw new RegistryNoResponseException(ex); - - } catch (SSLPeerUnverifiedException ex) { - // Fall-back to HTTP - GenericUrl httpUrl = new GenericUrl(requestState.url); - httpUrl.setScheme("http"); - return call(new RequestState(requestState.authorization, httpUrl.toURL())); } } } diff --git a/jib-core/src/test/java/com/google/cloud/tools/jib/registry/RegistryEndpointCallerTest.java b/jib-core/src/test/java/com/google/cloud/tools/jib/registry/RegistryEndpointCallerTest.java new file mode 100644 index 0000000000..eb6ab98347 --- /dev/null +++ b/jib-core/src/test/java/com/google/cloud/tools/jib/registry/RegistryEndpointCallerTest.java @@ -0,0 +1,277 @@ +/* + * Copyright 2018 Google LLC. All rights reserved. + * + * 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.http.HttpHeaders; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.HttpStatusCodes; +import com.google.cloud.tools.jib.blob.Blobs; +import com.google.cloud.tools.jib.http.Authorizations; +import com.google.cloud.tools.jib.http.BlobHttpContent; +import com.google.cloud.tools.jib.http.Connection; +import com.google.cloud.tools.jib.http.Response; +import com.google.cloud.tools.jib.json.JsonTemplateMapper; +import com.google.cloud.tools.jib.registry.json.ErrorEntryTemplate; +import com.google.cloud.tools.jib.registry.json.ErrorResponseTemplate; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import javax.annotation.Nullable; +import javax.net.ssl.SSLPeerUnverifiedException; +import org.apache.http.NoHttpResponseException; +import org.apache.http.conn.HttpHostConnectException; +import org.hamcrest.CoreMatchers; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +/** Tests for {@link RegistryEndpointCaller}. */ +@RunWith(MockitoJUnitRunner.class) +public class RegistryEndpointCallerTest { + + /** Implementation of {@link RegistryEndpointProvider} for testing. */ + private static class TestRegistryEndpointProvider implements RegistryEndpointProvider { + + @Override + public String getHttpMethod() { + return "httpMethod"; + } + + @Override + public URL getApiRoute(String apiRouteBase) throws MalformedURLException { + return new URL(apiRouteBase + "/api"); + } + + @Nullable + @Override + public BlobHttpContent getContent() { + return null; + } + + @Override + public List getAccept() { + return Collections.emptyList(); + } + + @Nullable + @Override + public String handleResponse(Response response) throws IOException { + return Blobs.writeToString(response.getBody()); + } + + @Override + public String getActionDescription() { + return "actionDescription"; + } + } + + @Mock private Connection mockConnection; + @Mock private Response mockResponse; + @Mock private Function mockConnectionFactory; + @Mock private HttpResponse mockHttpResponse; + + private RegistryEndpointCaller testRegistryEndpointCaller; + + @Before + public void setUp() throws IOException { + testRegistryEndpointCaller = + new RegistryEndpointCaller<>( + "userAgent", + "apiRouteBase", + new TestRegistryEndpointProvider(), + Authorizations.withBasicToken("token"), + new RegistryEndpointProperties("serverUrl", "imageName"), + mockConnectionFactory); + + Mockito.when(mockConnectionFactory.apply(Mockito.any())).thenReturn(mockConnection); + Mockito.when(mockHttpResponse.parseAsString()).thenReturn(""); + Mockito.when(mockHttpResponse.getHeaders()).thenReturn(new HttpHeaders()); + } + + @Test + public void testCall_retryWithHttp() throws IOException, RegistryException { + verifyRetriesWithHttp(HttpHostConnectException.class); + } + + @Test + public void testCall_httpsPeerUnverified() throws IOException, RegistryException { + verifyRetriesWithHttp(SSLPeerUnverifiedException.class); + } + + @Test + public void testCall_noHttpResponse() throws IOException, RegistryException { + NoHttpResponseException mockNoHttpResponseException = + Mockito.mock(NoHttpResponseException.class); + Mockito.when(mockConnection.send(Mockito.eq("httpMethod"), Mockito.any())) + .thenThrow(mockNoHttpResponseException); + + try { + testRegistryEndpointCaller.call(); + Assert.fail("Call should have failed"); + + } catch (RegistryNoResponseException ex) { + Assert.assertSame(mockNoHttpResponseException, ex.getCause()); + } + } + + @Test + public void testCall_unauthorized() throws IOException, RegistryException { + verifyThrowsRegistryUnauthorizedException(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); + } + + @Test + public void testCall_forbidden() throws IOException, RegistryException { + verifyThrowsRegistryUnauthorizedException(HttpStatusCodes.STATUS_CODE_FORBIDDEN); + } + + @Test + public void testCall_badRequest() throws IOException, RegistryException { + verifyThrowsRegistryErrorException(HttpStatusCodes.STATUS_CODE_BAD_REQUEST); + } + + @Test + public void testCall_notFound() throws IOException, RegistryException { + verifyThrowsRegistryErrorException(HttpStatusCodes.STATUS_CODE_NOT_FOUND); + } + + @Test + public void testCall_methodNotAllowed() throws IOException, RegistryException { + verifyThrowsRegistryErrorException(HttpStatusCodes.STATUS_CODE_METHOD_NOT_ALLOWED); + } + + @Test + public void testCall_unknown() throws IOException, RegistryException { + Mockito.when(mockHttpResponse.getStatusCode()) + .thenReturn(HttpStatusCodes.STATUS_CODE_MOVED_PERMANENTLY); + HttpResponseException httpResponseException = new HttpResponseException(mockHttpResponse); + + Mockito.when(mockConnection.send(Mockito.eq("httpMethod"), Mockito.any())) + .thenThrow(httpResponseException); + + try { + testRegistryEndpointCaller.call(); + Assert.fail("Call should have failed"); + + } catch (HttpResponseException ex) { + Assert.assertSame(httpResponseException, ex); + } + } + + @Test + public void testCall_temporaryRedirect() throws IOException, RegistryException { + // Mocks a response for temporary redirect to a new location. + Mockito.when(mockHttpResponse.getStatusCode()) + .thenReturn(HttpStatusCodes.STATUS_CODE_TEMPORARY_REDIRECT); + Mockito.when(mockHttpResponse.getHeaders()) + .thenReturn(new HttpHeaders().setLocation("https://newlocation")); + + // Has mockConnection.send throw first, then succeed. + HttpResponseException httpResponseException = new HttpResponseException(mockHttpResponse); + Mockito.when(mockConnection.send(Mockito.eq("httpMethod"), Mockito.any())) + .thenThrow(httpResponseException) + .thenReturn(mockResponse); + Mockito.when(mockResponse.getBody()).thenReturn(Blobs.from("body")); + + Assert.assertEquals("body", testRegistryEndpointCaller.call()); + + // Checks that the URL was changed to the new location. + ArgumentCaptor urlArgumentCaptor = ArgumentCaptor.forClass(URL.class); + Mockito.verify(mockConnectionFactory, Mockito.times(2)).apply(urlArgumentCaptor.capture()); + Assert.assertEquals( + new URL("https://apiRouteBase/api"), urlArgumentCaptor.getAllValues().get(0)); + Assert.assertEquals(new URL("https://newlocation"), urlArgumentCaptor.getAllValues().get(1)); + } + + /** Verifies a request is retried with HTTP protocol if {@code exceptionClass} is thrown. */ + private void verifyRetriesWithHttp(Class exceptionClass) + throws IOException, RegistryException { + // Has mockConnection.send throw first, then succeed. + Mockito.when(mockConnection.send(Mockito.eq("httpMethod"), Mockito.any())) + .thenThrow(Mockito.mock(exceptionClass)) + .thenReturn(mockResponse); + Mockito.when(mockResponse.getBody()).thenReturn(Blobs.from("body")); + + Assert.assertEquals("body", testRegistryEndpointCaller.call()); + + // Checks that the URL protocol was first HTTPS, then HTTP. + ArgumentCaptor urlArgumentCaptor = ArgumentCaptor.forClass(URL.class); + Mockito.verify(mockConnectionFactory, Mockito.times(2)).apply(urlArgumentCaptor.capture()); + Assert.assertEquals("https", urlArgumentCaptor.getAllValues().get(0).getProtocol()); + Assert.assertEquals("http", urlArgumentCaptor.getAllValues().get(1).getProtocol()); + } + + /** + * Verifies that a response with {@code httpStatusCode} throws {@link + * RegistryUnauthorizedException}. + */ + private void verifyThrowsRegistryUnauthorizedException(int httpStatusCode) + throws IOException, RegistryException { + Mockito.when(mockHttpResponse.getStatusCode()).thenReturn(httpStatusCode); + HttpResponseException httpResponseException = new HttpResponseException(mockHttpResponse); + + Mockito.when(mockConnection.send(Mockito.eq("httpMethod"), Mockito.any())) + .thenThrow(httpResponseException); + + try { + testRegistryEndpointCaller.call(); + Assert.fail("Call should have failed"); + + } catch (RegistryUnauthorizedException ex) { + Assert.assertEquals("serverUrl", ex.getRegistry()); + Assert.assertEquals("imageName", ex.getRepository()); + Assert.assertSame(httpResponseException, ex.getHttpResponseException()); + } + } + + /** + * Verifies that a response with {@code httpStatusCode} throws {@link + * RegistryUnauthorizedException}. + */ + private void verifyThrowsRegistryErrorException(int httpStatusCode) + throws IOException, RegistryException { + ErrorResponseTemplate errorResponseTemplate = + new ErrorResponseTemplate().addError(new ErrorEntryTemplate("code", "message")); + + Mockito.when(mockHttpResponse.getStatusCode()).thenReturn(httpStatusCode); + Mockito.when(mockHttpResponse.parseAsString()) + .thenReturn(Blobs.writeToString(JsonTemplateMapper.toBlob(errorResponseTemplate))); + HttpResponseException httpResponseException = new HttpResponseException(mockHttpResponse); + + Mockito.when(mockConnection.send(Mockito.eq("httpMethod"), Mockito.any())) + .thenThrow(httpResponseException); + + try { + testRegistryEndpointCaller.call(); + Assert.fail("Call should have failed"); + + } catch (RegistryErrorException ex) { + Assert.assertThat( + ex.getMessage(), + CoreMatchers.containsString( + "Tried to actionDescription but failed because: unknown: message")); + } + } +} diff --git a/jib-gradle-plugin/CHANGELOG.md b/jib-gradle-plugin/CHANGELOG.md index 2bb57161cb..b98b2ae0f4 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 +- Registries without TLS now supported ([#388](https://github.com/GoogleContainerTools/jib/issues/388)) + ## 0.9.0 ### Added diff --git a/jib-maven-plugin/CHANGELOG.md b/jib-maven-plugin/CHANGELOG.md index 969a1ba376..0433edf610 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 +- Registries without TLS now supported ([#388](https://github.com/GoogleContainerTools/jib/issues/388)) + ## 0.9.0 ### Added - Better feedback for build failures ([#197](https://github.com/google/jib/pull/197))