Skip to content

Commit

Permalink
[JENKINS-74970] Advise if Bitbucket Server rejects build status
Browse files Browse the repository at this point in the history
If this plugin posts a build status to Bitbucket Server or Data Center,
but Bitbucket responds with HTTP status 401 (Unauthorized) or
403 (Forbidden), then log extra information to help users grant
Jenkins the required permission on the repository:

* The permission that is needed: REPO_READ.
* The Bitbucket user name.  Currently, this comes from the "X-AUSERNAME"
  response header field.
* The repository to which the request was sent.
* The project or user that owns the repository.

Because BitbucketServerAPIClient does not have direct access to a
TaskListener, it adds this information to the message of the
BitbucketRequestException, and the caller then logs it from there.

The behaviour on Bitbucket Cloud is unchanged.
  • Loading branch information
KalleOlaviNiemitalo committed Jan 13, 2025
1 parent e2da34a commit 26ef0dd
Show file tree
Hide file tree
Showing 3 changed files with 89 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import org.apache.commons.lang.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.auth.AuthScope;
Expand Down Expand Up @@ -97,9 +98,14 @@ protected String truncateMiddle(@CheckForNull String value, int maxLength) {
}
}

protected BitbucketRequestException buildResponseException(CloseableHttpResponse response, String errorMessage) {
protected BitbucketRequestException buildResponseException(CloseableHttpResponse response,
String errorMessage,
@CheckForNull String advice) {
String headers = StringUtils.join(response.getAllHeaders(), "\n");
String message = String.format("HTTP request error.%nStatus: %s%nResponse: %s%n%s", response.getStatusLine(), errorMessage, headers);
if (advice != null) {

Check warning on line 106 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 106 is only partially covered, one branch is missing
message = advice + System.lineSeparator() + message;

Check warning on line 107 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 107 is not covered by tests
}
return new BitbucketRequestException(response.getStatusLine().getStatusCode(), message);
}

Expand Down Expand Up @@ -234,6 +240,12 @@ protected CloseableHttpResponse executeMethod(HttpHost host, HttpRequestBase htt
}

protected String doRequest(HttpRequestBase request, boolean requireAuthentication) throws IOException {
return doRequest(request, requireAuthentication, HttpErrorAdvisor.NULL);
}

protected String doRequest(HttpRequestBase request,
boolean requireAuthentication,
HttpErrorAdvisor advisor) throws IOException {
try (CloseableHttpResponse response = executeMethod(getHost(), request, requireAuthentication)) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode == HttpStatus.SC_NOT_FOUND) {
Expand All @@ -247,7 +259,7 @@ protected String doRequest(HttpRequestBase request, boolean requireAuthenticatio
String content = getResponseContent(response);
EntityUtils.consume(response.getEntity());
if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_CREATED) {
throw buildResponseException(response, content);
throw buildResponseException(response, content, advisor.getAdvice(response));
}
return content;
} catch (BitbucketRequestException e) {
Expand Down Expand Up @@ -296,7 +308,7 @@ protected InputStream getRequestAsInputStream(String path) throws IOException {
}
if (statusCode != HttpStatus.SC_OK) {
String content = getResponseContent(response);
throw buildResponseException(response, content);
throw buildResponseException(response, content, null);

Check warning on line 311 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/impl/client/AbstractBitbucketApi.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 311 is not covered by tests
}
return new ClosingConnectionInputStream(response, httpget, getConnectionManager());
}
Expand Down Expand Up @@ -351,4 +363,25 @@ public void close() throws Exception {
protected BitbucketAuthenticator getAuthenticator() {
return authenticator;
}

/**
* REST API operation methods in classes derived from {@link AbstractBitbucketApi}
* can implement this interface to explain to users why
* {@link #doRequest(HttpRequestBase, boolean, HttpErrorAdvisor)} failed.
*/
@FunctionalInterface
protected interface HttpErrorAdvisor {
/**
* Gets user-readable advice on why Bitbucket returned an error HTTP status.
*
* @param response The HTTP response from Bitbucket.
* @return Advice to the user on why the request failed, or {@code null}.
*/
@CheckForNull String getAdvice(HttpResponse response);

/**
* A trivial {@link HttpErrorAdvisor} implementation that never has advice.
*/
public static final HttpErrorAdvisor NULL = response -> null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/
package com.cloudbees.jenkins.plugins.bitbucket.server.client;

import com.cloudbees.jenkins.plugins.bitbucket.Messages;
import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketApi;
import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator;
import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketBuildStatus;
Expand Down Expand Up @@ -87,10 +88,15 @@
import jenkins.scm.api.SCMFile;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.message.BasicNameValuePair;
Expand Down Expand Up @@ -509,7 +515,11 @@ public void postBuildStatus(@NonNull BitbucketBuildStatus status) throws IOExcep
.set("repo", repositoryName)
.set("hash", newStatus.getHash())
.expand();
postRequest(url, JsonParser.toJson(newStatus));

HttpPost request = new HttpPost(url);
request.setEntity(new StringEntity(JsonParser.toJson(newStatus),
ContentType.create("application/json", "UTF-8")));
doRequest(request, true, this::adviceForBuildStatusError);
}

/**
Expand Down Expand Up @@ -1051,4 +1061,45 @@ private Map<String,Object> collectLines(String response, final List<String> line
return content;
}

// Gets user-visible advice for an HTTP error response when
// Bitbucket Server rejects a build status.
// Implements AbstractBitbucketApi.HttpErrorAdvisor#getAdvice.
@CheckForNull
private String adviceForBuildStatusError(HttpResponse response) {
// If the HTTP request failed because of an authorization
// problem, then make the exception message also show the
// Bitbucket user name with which Jenkins authenticated,
// the project name, and the repository name.
//
// Such an authorization problem can occur especially in a
// pull request from a personal fork: if Jenkins has been
// granted REPO_READ access on the target repository of the PR
// but no access on the fork, then it can read the PR
// information from the target repository and check out the
// files, but cannot post a build status to the fork.
// Showing the name of the fork will help the user or
// administrator grant the required access.
//
// If the HTTP request already includes valid credentials,
// but the Bitbucket user has not been granted access on the
// repository, then Bitbucket Server responds with HTTP status
// 401 (Unauthorized) and a WWW-Authenticate header field that
// requests OAuth, even though RFC 7235 section 2.1 recommends
// 403 (Forbidden). Let's recognize both 401 and 403.
int httpStatus = response.getStatusLine().getStatusCode();
if (httpStatus == HttpStatus.SC_UNAUTHORIZED || httpStatus == HttpStatus.SC_FORBIDDEN) {
Header userNameHeader = response.getFirstHeader("X-AUSERNAME");
if (userNameHeader != null
&& !userNameHeader.getValue().equals("anonymous")) {
// Posting a build status requires REPO_READ access.
// https://docs.atlassian.com/bitbucket-server/rest/7.4.0/bitbucket-rest.html#idp219
return Messages.BitbucketServerAPIClient_adviceForBuildStatusError(
userNameHeader.getValue(),
getUserCentricOwner(),
getRepositoryName());
}
}

return null;

Check warning on line 1103 in src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClient.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 1089-1103 are not covered by tests
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ BitbucketTagSCMHead.Pronoun=Tag
TagDiscoveryTrait.authorityDisplayName=Trust origin tags
BitbucketBuildStatusNotificationsTrait.displayName=Bitbucket build status notifications
DiscardOldBranchTrait.displayName=Discard branch older than given days
BitbucketServerAPIClient.adviceForBuildStatusError=Please verify that the Bitbucket user "{0}" is granted REPO_READ access on the repository "{1}/{2}".

0 comments on commit 26ef0dd

Please sign in to comment.