Skip to content

Commit

Permalink
Introduce a channel name to ID cache (#965)
Browse files Browse the repository at this point in the history
  • Loading branch information
timja authored May 7, 2024
1 parent eb11748 commit 62ffe7c
Show file tree
Hide file tree
Showing 10 changed files with 291 additions and 85 deletions.
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
<groupId>io.jenkins.plugins</groupId>
<artifactId>commons-lang3-api</artifactId>
</dependency>
<dependency>
<groupId>io.jenkins.plugins</groupId>
<artifactId>caffeine-api</artifactId>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>structs</artifactId>
Expand Down
22 changes: 14 additions & 8 deletions src/main/java/jenkins/plugins/slack/HttpClient.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package jenkins.plugins.slack;

import hudson.ProxyConfiguration;
import jenkins.plugins.slack.NoProxyHostCheckerRoutePlanner;
import hudson.util.Secret;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.Credentials;
Expand All @@ -20,7 +20,7 @@
@Restricted(NoExternalUse.class)
public class HttpClient {

public static CloseableHttpClient getCloseableHttpClient(ProxyConfiguration proxy) {
public static HttpClientBuilder getCloseableHttpClientBuilder(ProxyConfiguration proxy) {
int timeoutInSeconds = 60;

RequestConfig config = RequestConfig.custom()
Expand All @@ -29,9 +29,9 @@ public static CloseableHttpClient getCloseableHttpClient(ProxyConfiguration prox
.setSocketTimeout(timeoutInSeconds * 1000).build();

final HttpClientBuilder clientBuilder = HttpClients
.custom()
.useSystemProperties()
.setDefaultRequestConfig(config);
.custom()
.useSystemProperties()
.setDefaultRequestConfig(config);
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
clientBuilder.setDefaultCredentialsProvider(credentialsProvider);

Expand All @@ -41,14 +41,20 @@ public static CloseableHttpClient getCloseableHttpClient(ProxyConfiguration prox
clientBuilder.setRoutePlanner(routePlanner);

String username = proxy.getUserName();
String password = proxy.getPassword();
Secret secretPassword = proxy.getSecretPassword();
String password = Secret.toString(secretPassword);
// Consider it to be passed if username specified. Sufficient?
if (username != null && !"".equals(username.trim())) {
if (username != null && !username.trim().isEmpty()) {

Check warning on line 47 in src/main/java/jenkins/plugins/slack/HttpClient.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 44-47 are not covered by tests
credentialsProvider.setCredentials(new AuthScope(proxyHost),
createCredentials(username, password));
}
}
return clientBuilder.build();
return clientBuilder;

}

public static CloseableHttpClient getCloseableHttpClient(ProxyConfiguration proxy) {
return getCloseableHttpClientBuilder(proxy).build();
}

private static Credentials createCredentials(String userName, String password) {
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/jenkins/plugins/slack/SlackNotifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.util.logging.Logger;
import java.util.stream.Stream;
import jenkins.model.Jenkins;
import jenkins.plugins.slack.cache.SlackChannelIdCache;
import jenkins.plugins.slack.config.GlobalCredentialMigrator;
import jenkins.plugins.slack.logging.BuildAwareLogger;
import jenkins.plugins.slack.logging.BuildKey;
Expand Down Expand Up @@ -870,6 +871,16 @@ public String getDisplayName() {
return PLUGIN_DISPLAY_NAME;
}

@POST
public FormValidation doClearCache() {
Jenkins.get().checkPermission(Jenkins.ADMINISTER);

logger.info("Clearing channel ID cache");
SlackChannelIdCache.clearCache();

return FormValidation.ok("Cache cleared");

Check warning on line 881 in src/main/java/jenkins/plugins/slack/SlackNotifier.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 876-881 are not covered by tests
}

@POST
public FormValidation doTestConnectionGlobal(
@QueryParameter("baseUrl") final String baseUrl,
Expand Down
13 changes: 12 additions & 1 deletion src/main/java/jenkins/plugins/slack/StandardSlackService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jenkins.plugins.slack;

import com.google.common.annotations.VisibleForTesting;
import hudson.AbortException;
import hudson.FilePath;
import hudson.ProxyConfiguration;
import hudson.Util;
Expand All @@ -20,6 +21,7 @@
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import jenkins.model.Jenkins;
import jenkins.plugins.slack.cache.SlackChannelIdCache;
import jenkins.plugins.slack.pipeline.SlackFileRequest;
import jenkins.plugins.slack.pipeline.SlackUploadFileRunner;
import jenkins.plugins.slack.user.SlackUserIdResolver;
Expand Down Expand Up @@ -256,8 +258,17 @@ public boolean upload(FilePath workspace, String artifactIncludes, TaskListener
boolean result = true;
if(workspace!=null) {
for(String roomId : roomIds) {
String channelId;
try {
channelId = SlackChannelIdCache.getChannelId(populatedToken, roomId);
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException(e);
} catch (AbortException e) {
return false;
}

Check warning on line 268 in src/main/java/jenkins/plugins/slack/StandardSlackService.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 263-268 are not covered by tests

SlackFileRequest slackFileRequest = new SlackFileRequest(
workspace, populatedToken, roomId, null, artifactIncludes);
workspace, populatedToken, channelId, null, artifactIncludes, null);
try {
workspace.getChannel().callAsync(new SlackUploadFileRunner(log, Jenkins.get().proxy, slackFileRequest)).get();
} catch (IllegalStateException | InterruptedException e) {
Expand Down
159 changes: 159 additions & 0 deletions src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package jenkins.plugins.slack.cache;

import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import hudson.AbortException;
import java.io.IOException;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.logging.Logger;
import jenkins.model.Jenkins;
import jenkins.plugins.slack.HttpClient;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.ServiceUnavailableRetryStrategy;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;
import org.json.JSONArray;
import org.json.JSONObject;

public class SlackChannelIdCache {

Check warning on line 30 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 30 is not covered by tests

private static final String UPLOAD_FAILED_TEMPLATE = "Failed to retrieve channel names. Response: ";
private static final Logger logger = Logger.getLogger(SlackChannelIdCache.class.getName());

// cache that includes all channel names and IDs for each workspace used
private static final LoadingCache<String, Map<String, String>> CHANNEL_METADATA_CACHE = Caffeine.newBuilder()
.maximumSize(100)
.refreshAfterWrite(Duration.ofHours(24))
.build(SlackChannelIdCache::populateCache);
private static final int MAX_RETRIES = 10;

private static Map<String, String> populateCache(String token) {
HttpClientBuilder closeableHttpClientBuilder = HttpClient.getCloseableHttpClientBuilder(Jenkins.get().getProxy())
.setRetryHandler((exception, executionCount, context) -> executionCount <= MAX_RETRIES)

Check warning on line 44 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 44 is not covered by tests
.setServiceUnavailableRetryStrategy(new ServiceUnavailableRetryStrategy() {

long retryInterval;

@Override
public boolean retryRequest(HttpResponse response, int executionCount, HttpContext context) {
boolean shouldRetry = executionCount <= MAX_RETRIES &&

Check warning on line 51 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 51 is only partially covered, one branch is missing
response.getStatusLine().getStatusCode() == HttpStatus.SC_TOO_MANY_REQUESTS;

Check warning on line 52 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 52 is only partially covered, one branch is missing
if (shouldRetry) {

Check warning on line 53 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 53 is only partially covered, one branch is missing
Header firstHeader = response.getFirstHeader("Retry-After");
if (firstHeader != null) {
retryInterval = Long.parseLong(firstHeader.getValue()) * 1000L;
logger.info(String.format("Rate limited by Slack, retrying in %dms", retryInterval));

Check warning on line 57 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 54-57 are not covered by tests
}
}
return shouldRetry;
}

@Override
public long getRetryInterval() {
return retryInterval;

Check warning on line 65 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 65 is not covered by tests
}
});
try (CloseableHttpClient client = closeableHttpClientBuilder.build()) {
return convertChannelNameToId(client, token, new HashMap<>(), null);
} catch (IOException e) {
throw new RuntimeException(e);

Check warning on line 71 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 70-71 are not covered by tests
}
}

public static String getChannelId(String botUserToken, String channelName) throws ExecutionException, InterruptedException, AbortException {
Map<String, String> channelNameToIdMap = CHANNEL_METADATA_CACHE.get(botUserToken);
String channelId = channelNameToIdMap.get(channelName);

// most likely is that a new channel has been created since the last cache refresh
// or a typo in the channel name, a bit risky in larger workspaces but shouldn't happen too often
if (channelId == null) {

Check warning on line 81 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 81 is only partially covered, one branch is missing
try {
CompletableFuture<Map<String, String>> newResult = CHANNEL_METADATA_CACHE.refresh(botUserToken);
channelNameToIdMap = newResult.get();
} catch (CompletionException e) {
throw new AbortException("Failed uploading file to slack, channel not found: " + channelName + ", error: " + e.getMessage());

Check warning on line 86 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 85-86 are not covered by tests
}

channelId = channelNameToIdMap.get(channelName);
}

return channelId;
}

private static Map<String, String> convertChannelNameToId(CloseableHttpClient client, String token, Map<String, String> channels, String cursor) throws IOException {
RequestBuilder requestBuilder = RequestBuilder.get("https://slack.com/api/conversations.list")
.addHeader("Authorization", "Bearer " + token)
.addParameter("exclude_archived", "true")
.addParameter("types", "public_channel,private_channel");

if (cursor != null) {

Check warning on line 101 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 101 is only partially covered, one branch is missing
requestBuilder.addParameter("cursor", cursor);

Check warning on line 102 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 102 is not covered by tests
}
ResponseHandler<JSONObject> standardResponseHandler = getStandardResponseHandler();
JSONObject result = client.execute(requestBuilder.build(), standardResponseHandler);

if (!result.getBoolean("ok")) {

Check warning on line 107 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 107 is only partially covered, one branch is missing
logger.warning("Couldn't convert channel name to ID in Slack: " + result);
return channels;
}

JSONArray channelsArray = result.getJSONArray("channels");
for (int i = 0; i < channelsArray.length(); i++) {
JSONObject channel = channelsArray.getJSONObject(i);

String channelName = channel.getString("name");
String channelId = channel.getString("id");

channels.put(channelName, channelId);
}

cursor = result.getJSONObject("response_metadata").getString("next_cursor");
if (cursor != null && !cursor.isEmpty()) {
return convertChannelNameToId(client, token, channels, cursor);
}

return channels;

Check warning on line 127 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 112-127 are not covered by tests
}

private static ResponseHandler<JSONObject> getStandardResponseHandler() {
return response -> {
int status = response.getStatusLine().getStatusCode();
if (status >= 200 && status < 300) {

Check warning on line 133 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 133 is only partially covered, 2 branches are missing
HttpEntity entity = response.getEntity();
return entity != null ? new JSONObject(EntityUtils.toString(entity)) : null;

Check warning on line 135 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 135 is only partially covered, one branch is missing
} else {
String errorMessage = UPLOAD_FAILED_TEMPLATE + status + " " + EntityUtils.toString(response.getEntity());
throw new HttpStatusCodeException(response.getStatusLine().getStatusCode(), errorMessage);
}
};
}

public static class HttpStatusCodeException extends RuntimeException {
private final int statusCode;

public HttpStatusCodeException(int statusCode, String message) {
super(message);
this.statusCode = statusCode;
}

public int getStatusCode() {
return statusCode;
}
}

public static void clearCache() {
CHANNEL_METADATA_CACHE.invalidateAll();
}

Check warning on line 158 in src/main/java/jenkins/plugins/slack/cache/SlackChannelIdCache.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 137-158 are not covered by tests
}
16 changes: 11 additions & 5 deletions src/main/java/jenkins/plugins/slack/pipeline/SlackFileRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,27 @@
public class SlackFileRequest {
private final String fileToUploadPath;
private final String token;
private final String channels;
private final String channelId;
private final String threadTs;

private final String initialComment;
private final FilePath filePath;

public SlackFileRequest(FilePath filePath, String token, String channels, String initialComment, String fileToUploadPath) {
public SlackFileRequest(FilePath filePath, String token, String channelId, String initialComment, String fileToUploadPath, String threadTs) {
this.token = token;
this.channels = channels;
this.channelId = channelId;
this.initialComment = initialComment;
this.filePath = filePath;
this.fileToUploadPath = fileToUploadPath;
this.threadTs = threadTs;
}

public String getToken() {
return token;
}

public String getChannels() {
return channels;
public String getChannelId() {
return channelId;
}

public String getInitialComment() {
Expand All @@ -40,4 +42,8 @@ public FilePath getFilePath() {
public String getFileToUploadPath() {
return fileToUploadPath;
}

public String getThreadTs() {
return threadTs;

Check warning on line 47 in src/main/java/jenkins/plugins/slack/pipeline/SlackFileRequest.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 17-47 are not covered by tests
}
}
Loading

0 comments on commit 62ffe7c

Please sign in to comment.