From 61b650e7b746ac632e5909ccbbc2a8fe9c8b3356 Mon Sep 17 00:00:00 2001 From: Omar Date: Tue, 26 Nov 2024 12:09:18 -0800 Subject: [PATCH] Add proxy support to Jenkins client (#1755) * Remove unused methods Cleans up some unused methods * Add proxy support to Jenkins client Add the following new properties to the `JenkinsFactory` configuration: * `useProxy` * `httpProxyAddr` * `httpProxyPort` These properties will be used in the `HttpClient` in the `Jenkins` class in order to support Jenkins calls through a proxy. Testing done: * Add unit tests which verify the proxy is used and `Host` header is sent * Will deploy to nonprod environment --- .../deployservice/ServiceContext.java | 20 +- .../deployservice/common/Jenkins.java | 52 ++--- .../deployservice/common/JenkinsTest.java | 187 ++++++++++++++++++ .../com/pinterest/teletraan/ConfigHelper.java | 10 +- .../teletraan/config/JenkinsFactory.java | 32 ++- .../worker/HotfixStateTransitioner.java | 11 +- 6 files changed, 264 insertions(+), 48 deletions(-) create mode 100644 deploy-service/common/src/test/java/com/pinterest/deployservice/common/JenkinsTest.java diff --git a/deploy-service/common/src/main/java/com/pinterest/deployservice/ServiceContext.java b/deploy-service/common/src/main/java/com/pinterest/deployservice/ServiceContext.java index 9044716791..0db7c74374 100644 --- a/deploy-service/common/src/main/java/com/pinterest/deployservice/ServiceContext.java +++ b/deploy-service/common/src/main/java/com/pinterest/deployservice/ServiceContext.java @@ -18,6 +18,7 @@ import com.pinterest.deployservice.allowlists.Allowlist; import com.pinterest.deployservice.buildtags.BuildTagsManager; import com.pinterest.deployservice.chat.ChatManager; +import com.pinterest.deployservice.common.Jenkins; import com.pinterest.deployservice.dao.AgentCountDAO; import com.pinterest.deployservice.dao.AgentDAO; import com.pinterest.deployservice.dao.AgentErrorDAO; @@ -96,8 +97,7 @@ public class ServiceContext { private boolean deployCacheEnabled; private String deployBoardUrlPrefix; private String changeFeedUrl; - private String jenkinsUrl; - private String jenkinsRemoteToken; + private Jenkins jenkins; private List pingRequestValidators; private Long agentCountCacheTtl; private Long maxParallelThreshold; @@ -395,20 +395,12 @@ public void setChangeFeedUrl(String changeFeedUrl) { this.changeFeedUrl = changeFeedUrl; } - public String getJenkinsUrl() { - return jenkinsUrl; + public Jenkins getJenkins() { + return jenkins; } - public void setJenkinsUrl(String jenkinsUrl) { - this.jenkinsUrl = jenkinsUrl; - } - - public String getJenkinsRemoteToken() { - return jenkinsRemoteToken; - } - - public void setJenkinsRemoteToken(String jenkinsRemoteToken) { - this.jenkinsRemoteToken = jenkinsRemoteToken; + public void setJenkins(Jenkins jenkins) { + this.jenkins = jenkins; } public TagDAO getTagDAO() { diff --git a/deploy-service/common/src/main/java/com/pinterest/deployservice/common/Jenkins.java b/deploy-service/common/src/main/java/com/pinterest/deployservice/common/Jenkins.java index 08341607cb..1ff86269c8 100644 --- a/deploy-service/common/src/main/java/com/pinterest/deployservice/common/Jenkins.java +++ b/deploy-service/common/src/main/java/com/pinterest/deployservice/common/Jenkins.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2016-2017 Pinterest, Inc. + * Copyright (c) 2016-2024 Pinterest, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,8 +18,6 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.pinterest.teletraan.universal.http.HttpClient; -import java.util.HashMap; -import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,13 +25,34 @@ /** Wrapper for Jenkins API calls */ public class Jenkins { private static final Logger LOG = LoggerFactory.getLogger(Jenkins.class); - private static final HttpClient httpClient = HttpClient.builder().build(); - private String jenkinsUrl; - private String jenkinsRemoteToken; + private final HttpClient httpClient; + private final String jenkinsUrl; + private final String jenkinsRemoteToken; - public Jenkins(String jenkinsUrl, String jenkinsRemoteToken) { + public Jenkins( + String jenkinsUrl, + String jenkinsRemoteToken, + boolean useProxy, + String httpProxyAddr, + String httpProxyPort) { this.jenkinsUrl = jenkinsUrl; this.jenkinsRemoteToken = jenkinsRemoteToken; + + int httpProxyPortInt; + HttpClient.HttpClientBuilder clientBuilder = HttpClient.builder(); + if (useProxy) { + try { + httpProxyPortInt = Integer.parseInt(httpProxyPort); + } catch (NumberFormatException exception) { + LOG.error("Failed to parse Jenkins port: {}", httpProxyPort, exception); + throw exception; + } + clientBuilder + .useProxy(true) + .httpProxyAddr(httpProxyAddr) + .httpProxyPort(httpProxyPortInt); + } + this.httpClient = clientBuilder.build(); } public static class Build { @@ -81,23 +100,8 @@ public int getProgress() { } } - public boolean isPinterestJenkinsUrl(String url) { - return url.startsWith(this.jenkinsUrl); - } - - String getJenkinsToken() throws Exception { - String url = String.format("%s/%s", this.jenkinsUrl, "crumbIssuer/api/json"); - String ret = httpClient.get(url, null, null); - JsonObject json = (JsonObject) JsonParser.parseString(ret); - return json.get("crumb").getAsString(); - } - - public void startBuild(String url) throws Exception { - String token = getJenkinsToken(); - Map headers = new HashMap<>(1); - headers.put(".crumb", token); - LOG.debug("Calling jenkins with url " + url + " and token " + token); - httpClient.post(url, null, headers); + public String getJenkinsUrl() { + return jenkinsUrl; } public void startBuild(String jobName, String buildParams) throws Exception { diff --git a/deploy-service/common/src/test/java/com/pinterest/deployservice/common/JenkinsTest.java b/deploy-service/common/src/test/java/com/pinterest/deployservice/common/JenkinsTest.java new file mode 100644 index 0000000000..a7053f43f7 --- /dev/null +++ b/deploy-service/common/src/test/java/com/pinterest/deployservice/common/JenkinsTest.java @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2024 Pinterest, 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.pinterest.deployservice.common; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.gson.JsonObject; +import javax.ws.rs.ClientErrorException; +import javax.ws.rs.ServerErrorException; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class JenkinsTest { + private static final String TEST_JOB = "testJob"; + private static final String TEST_PARAMS = "testKey1=testValue1&testKey2=testValue2"; + private static final String TEST_JENKINS_HOST = "example.com"; + private static final String TEST_JENKINS_URL = + String.format("http://%s/testUrl", TEST_JENKINS_HOST); + private static final String TEST_REMOTE_TOKEN = "testRemoteToken"; + private static final String TEST_BUILD_NUMBER = "testBuildNumber"; + private static final String TEST_BUILD_RESULT = "testBuildResult"; + private static final boolean TEST_BUILD_BUILDING = true; + private static final long TEST_BUILD_TIMESTAMP = 7; + private static final int TEST_BUILD_ESTIMATED_DURATION = 8; + private static final int TEST_BUILD_DURATION = 9; + + private static MockWebServer mockWebServer; + private Jenkins sut; + + @BeforeEach + public void setUpEach() throws Exception { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + sut = + new Jenkins( + TEST_JENKINS_URL, + TEST_REMOTE_TOKEN, + true, + mockWebServer.getHostName(), + String.valueOf(mockWebServer.getPort())); + } + + @AfterEach + public void tearDown() throws Exception { + mockWebServer.shutdown(); + } + + @Test + void testConstructorInValidProxyConfig() { + assertThrows( + NumberFormatException.class, + () -> { + new Jenkins( + TEST_JENKINS_URL, TEST_REMOTE_TOKEN, true, "localhost", "invalidPort"); + }); + } + + @Test + void testStartBuildOk() throws InterruptedException { + mockWebServer.enqueue(new MockResponse()); + + assertDoesNotThrow( + () -> { + sut.startBuild(TEST_JOB, TEST_PARAMS); + }); + + assertEquals(1, mockWebServer.getRequestCount()); + RecordedRequest request = mockWebServer.takeRequest(); + assertEquals( + String.format( + "GET %s/job/%s/buildWithParameters?token=%s&%s HTTP/1.1", + TEST_JENKINS_URL, TEST_JOB, TEST_REMOTE_TOKEN, TEST_PARAMS), + request.getRequestLine()); + assertEquals(ImmutableList.of(TEST_JENKINS_HOST), request.getHeaders().values("Host")); + } + + @Test + void testStartBuildClientError() { + mockWebServer.enqueue(new MockResponse().setResponseCode(404)); + + ClientErrorException exception = + assertThrows( + ClientErrorException.class, + () -> { + sut.startBuild(TEST_JOB, TEST_PARAMS); + }); + assertEquals(404, exception.getResponse().getStatus()); + assertEquals(1, mockWebServer.getRequestCount()); + } + + @Test + void testStartBuildServerError() { + mockWebServer.setDispatcher(new ServerErrorDispatcher()); + ServerErrorException exception = + assertThrows( + ServerErrorException.class, + () -> { + sut.startBuild(TEST_JOB, TEST_PARAMS); + }); + assertEquals(500, exception.getResponse().getStatus()); + assertEquals(3, mockWebServer.getRequestCount()); + } + + @Test + void testGetBuildOk() throws Exception { + JsonObject mockResponseBody = new JsonObject(); + mockResponseBody.addProperty("number", TEST_BUILD_NUMBER); + mockResponseBody.addProperty("result", TEST_BUILD_RESULT); + mockResponseBody.addProperty("building", TEST_BUILD_BUILDING); + mockResponseBody.addProperty("timestamp", TEST_BUILD_TIMESTAMP); + mockResponseBody.addProperty("estimatedDuration", TEST_BUILD_ESTIMATED_DURATION); + mockResponseBody.addProperty("duration", TEST_BUILD_DURATION); + mockWebServer.enqueue(new MockResponse().setBody(mockResponseBody.toString())); + + Jenkins.Build build = sut.getBuild(TEST_JOB, TEST_BUILD_NUMBER); + assertEquals('"' + TEST_BUILD_NUMBER + '"', build.buildId); + assertEquals('"' + TEST_BUILD_RESULT + '"', build.result); + assertEquals(TEST_BUILD_BUILDING, build.isBuilding); + assertEquals(TEST_BUILD_TIMESTAMP, build.startTimestamp); + assertEquals(TEST_BUILD_ESTIMATED_DURATION, build.estimateDuration); + assertEquals(TEST_BUILD_DURATION, build.duration); + + assertEquals(1, mockWebServer.getRequestCount()); + RecordedRequest request = mockWebServer.takeRequest(); + assertEquals( + String.format( + "GET %s/job/%s/%s/api/json HTTP/1.1", + TEST_JENKINS_URL, TEST_JOB, TEST_BUILD_NUMBER), + request.getRequestLine()); + assertEquals(ImmutableList.of(TEST_JENKINS_HOST), request.getHeaders().values("Host")); + } + + @Test + void testGetBuildClientError() { + mockWebServer.enqueue(new MockResponse().setResponseCode(404)); + + ClientErrorException exception = + assertThrows( + ClientErrorException.class, + () -> { + sut.getBuild(TEST_JOB, TEST_BUILD_NUMBER); + }); + assertEquals(404, exception.getResponse().getStatus()); + assertEquals(1, mockWebServer.getRequestCount()); + } + + @Test + void testGetBuildServerError() { + mockWebServer.setDispatcher(new ServerErrorDispatcher()); + ServerErrorException exception = + assertThrows( + ServerErrorException.class, + () -> { + sut.getBuild(TEST_JOB, TEST_BUILD_NUMBER); + }); + assertEquals(500, exception.getResponse().getStatus()); + assertEquals(3, mockWebServer.getRequestCount()); + } + + static class ServerErrorDispatcher extends Dispatcher { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse().setResponseCode(500); + } + } +} diff --git a/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/ConfigHelper.java b/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/ConfigHelper.java index 7e881cde94..e5ec424d12 100644 --- a/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/ConfigHelper.java +++ b/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/ConfigHelper.java @@ -17,6 +17,7 @@ import com.pinterest.deployservice.allowlists.BuildAllowlistImpl; import com.pinterest.deployservice.buildtags.BuildTagsManagerImpl; +import com.pinterest.deployservice.common.Jenkins; import com.pinterest.deployservice.db.DBAgentCountDAOImpl; import com.pinterest.deployservice.db.DBAgentDAOImpl; import com.pinterest.deployservice.db.DBAgentErrorDAOImpl; @@ -191,8 +192,13 @@ public static TeletraanServiceContext setupContext( JenkinsFactory jenkinsFactory = configuration.getJenkinsFactory(); if (jenkinsFactory != null) { - context.setJenkinsUrl(jenkinsFactory.getJenkinsUrl()); - context.setJenkinsRemoteToken(jenkinsFactory.getRemoteToken()); + context.setJenkins( + new Jenkins( + jenkinsFactory.getJenkinsUrl(), + jenkinsFactory.getRemoteToken(), + jenkinsFactory.getUseProxy(), + jenkinsFactory.getHttpProxyAddr(), + jenkinsFactory.getHttpProxyPort())); } LOG.info("External alert factory is {}", configuration.getExternalAlertsConfigs()); diff --git a/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/config/JenkinsFactory.java b/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/config/JenkinsFactory.java index 71987d6eee..4bd7eb8180 100644 --- a/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/config/JenkinsFactory.java +++ b/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/config/JenkinsFactory.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2017 Pinterest, Inc. + * Copyright (c) 2024 Pinterest, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,12 @@ public class JenkinsFactory { @JsonProperty private String remoteToken; + @JsonProperty private boolean useProxy; + + @JsonProperty private String httpProxyAddr; + + @JsonProperty private String httpProxyPort; + public String getJenkinsUrl() { return jenkinsUrl; } @@ -37,4 +43,28 @@ public String getRemoteToken() { public void setRemoteToken(String remoteToken) { this.remoteToken = remoteToken; } + + public boolean getUseProxy() { + return useProxy; + } + + public void setUseProxy(boolean useProxy) { + this.useProxy = useProxy; + } + + public String getHttpProxyAddr() { + return httpProxyAddr; + } + + public void setHttpProxyAddr(String httpProxyAddr) { + this.httpProxyAddr = httpProxyAddr; + } + + public String getHttpProxyPort() { + return httpProxyPort; + } + + public void setHttpProxyPort(String httpProxyPort) { + this.httpProxyPort = httpProxyPort; + } } diff --git a/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/worker/HotfixStateTransitioner.java b/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/worker/HotfixStateTransitioner.java index 0702c85294..a2749f636f 100644 --- a/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/worker/HotfixStateTransitioner.java +++ b/deploy-service/teletraanservice/src/main/java/com/pinterest/teletraan/worker/HotfixStateTransitioner.java @@ -43,8 +43,7 @@ public class HotfixStateTransitioner implements Runnable { private UtilDAO utilDAO; private EnvironDAO environDAO; private CommonHandler commonHandler; - private String jenkinsUrl; - private String jenkinsRemoteToken; + private Jenkins jenkins; private Counter errorBudgetSuccess; private Counter errorBudgetFailure; // TODO make this configurable @@ -57,8 +56,7 @@ public HotfixStateTransitioner(ServiceContext serviceContext) { utilDAO = serviceContext.getUtilDAO(); environDAO = serviceContext.getEnvironDAO(); commonHandler = new CommonHandler(serviceContext); - jenkinsUrl = serviceContext.getJenkinsUrl(); - jenkinsRemoteToken = serviceContext.getJenkinsRemoteToken(); + jenkins = serviceContext.getJenkins(); errorBudgetSuccess = ErrorBudgetCounterFactory.createSuccessCounter(this.getClass().getSimpleName()); @@ -128,7 +126,6 @@ public void transitionHotfixState(HotfixBean hotBean) throws Exception { HotfixState state = hotBean.getState(); String hotfixId = hotBean.getId(); String jobNum = hotBean.getJob_num(); - Jenkins jenkins = new Jenkins(jenkinsUrl, jenkinsRemoteToken); DeployBean deployBean = deployDAO.getById(hotBean.getBase_deploy()); BuildBean buildBean = buildDAO.getById(deployBean.getBuild_id()); @@ -196,7 +193,7 @@ public void transitionHotfixState(HotfixBean hotBean) throws Exception { hotBean.setState(HotfixState.FAILED); hotBean.setError_message( "Failed to create hotfix, see " - + jenkinsUrl + + jenkins.getJenkinsUrl() + "/" + hotBean.getJob_name() + "/" @@ -233,7 +230,7 @@ public void transitionHotfixState(HotfixBean hotBean) throws Exception { hotBean.setState(HotfixState.FAILED); hotBean.setError_message( "Failed to build hotfix, see " - + jenkinsUrl + + jenkins.getJenkinsUrl() + hotBean.getJob_name() + "/" + hotBean.getJob_num()