Skip to content

Commit d666854

Browse files
authored
Implements a client for Sonatype Nexus (#544)
By default when you deploy a release to Nexus, it uses a feature known as auto-staging. This uses a heuristic to determine how subsequent PUT requests are aggregated into common staging repositories. Invariably Gradle's publish plugin confuses this, possibly because it creates new TCP connections for every artifact (you can see this when running with --debug), or maybe because the Nexus server is overloaded. Rather than coerce the children into playing nice, this change creates a plugin which creates a named staging repository and deploys the release into it. The plugin functionality boils down to: 1. Discover the staging profiles on Nexus with a GET request. 2. Create a new staging repository with a POST request. 3. Override the nexus URL given to the maven-publish plugin with https://oss.sonatype.org/service/local/staging/deployByRepositoryId/. There's more detailed documentation in the code. The implementation was based on these docs: https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Staging-Repository-via-REST-API Change-Id: I19767b3ccd99461b167997ea15730f109dff9443
1 parent 596c166 commit d666854

3 files changed

Lines changed: 279 additions & 21 deletions

File tree

buildSrc/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,8 @@ plugins {
2424
repositories {
2525
gradlePluginPortal()
2626
}
27+
28+
dependencies {
29+
implementation "com.google.http-client:google-http-client:1.40.1"
30+
implementation "com.google.http-client:google-http-client-xml:1.40.1"
31+
}

buildSrc/src/main/groovy/com.google.api-ads.java-conventions.gradle

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import java.util.regex.Matcher
2424

2525
plugins {
2626
id 'java-library'
27-
id 'maven-publish'
27+
id 'com.google.api-ads.nexus-publish'
2828
id 'java-test-fixtures'
2929
id 'signing'
3030
}
@@ -57,26 +57,6 @@ javadoc {
5757
options.addStringOption('Xdoclint:none', '-quiet')
5858
}
5959

60-
publishing {
61-
publications {
62-
maven(MavenPublication) {
63-
from(components.java)
64-
}
65-
}
66-
repositories {
67-
maven {
68-
url 'https://oss.sonatype.org/service/local/staging/deploy/maven2'
69-
name = "sonatype"
70-
credentials {
71-
// Avoids storing Sonatype credentials in plain-text. Specify these on
72-
// the command line: -PsonatypeUser=foo
73-
username project.properties.get("sonatypeUser")
74-
password project.properties.get("sonatypePassword")
75-
}
76-
}
77-
}
78-
}
79-
8060
components.java.withVariantsFromConfiguration(configurations.testFixturesApiElements) { skip() }
8161
components.java.withVariantsFromConfiguration(configurations.testFixturesRuntimeElements) { skip() }
8262

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/*
2+
* Copyright 2021 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Defines a plugin which will automatically configure gradle publish.
19+
*
20+
* Test this class by setting a non-snapshot version in gradle.properties.
21+
* Nexus rejects snapshot artifacts with HTTP 400 (and little else).
22+
*/
23+
24+
plugins {
25+
id 'maven-publish'
26+
}
27+
28+
import com.google.api.client.http.GenericUrl
29+
import com.google.api.client.http.HttpHeaders
30+
import com.google.api.client.http.HttpRequest
31+
import com.google.api.client.http.HttpRequestInitializer
32+
import com.google.api.client.http.javanet.NetHttpTransport
33+
import com.google.api.client.http.xml.XmlHttpContent
34+
import com.google.api.client.util.Key
35+
import com.google.api.client.xml.XmlNamespaceDictionary
36+
import com.google.api.client.xml.XmlObjectParser
37+
import java.nio.charset.StandardCharsets
38+
import java.util.concurrent.ConcurrentHashMap
39+
import org.gradle.api.GradleException
40+
41+
// ---------------- Google HTTP client data binding classes --------------------
42+
// Nexus REST API uses XML over HTTP - yey!
43+
// Leverages the data-binding in google-java-http-client to avoid handling XML.
44+
45+
/** Defines data binding class for HTTP basic auth headers. */
46+
class AuthHeaders extends HttpHeaders {
47+
@Key("Authorization")
48+
public String authorization
49+
50+
AuthHeaders(String user, String password) {
51+
// Generates the base 64 encoded version of user:pass which is specified
52+
// in headers.
53+
def encodedCredential = Base64.encoder.encode("${user}:${password}".bytes)
54+
this.authorization = "Basic " + new String(encodedCredential, StandardCharsets.UTF_8)
55+
}
56+
}
57+
58+
/**
59+
* Defines data-binding to retrieve the staging profiles from sonatype. Staging repositories are
60+
* created from a staging profile.
61+
*/
62+
class StagingProfiles {
63+
@Key
64+
public StagingProfilesData data
65+
66+
static class StagingProfilesData {
67+
@Key
68+
public List<StagingProfile> stagingProfile
69+
70+
static class StagingProfile {
71+
@Key
72+
public String resourceURI
73+
}
74+
}
75+
76+
def getStagingProfileUrls() {
77+
return data.stagingProfile.collect { it.resourceURI }
78+
}
79+
}
80+
81+
/** Defines data binding class for Nexus POST request to create staging repo. */
82+
class PromoteRequestXmlContent extends XmlHttpContent {
83+
PromoteRequestXmlContent(namespaceDictionary, description) {
84+
super(namespaceDictionary,
85+
"promoteRequest",
86+
new PromoteRequest(description))
87+
}
88+
89+
/** Defines top-level payload of PromoteRequest. */
90+
static class PromoteRequest {
91+
@Key
92+
public PromoteRequestData data
93+
94+
PromoteRequest() {}
95+
96+
PromoteRequest(String description) {
97+
data = new PromoteRequestData(description)
98+
}
99+
100+
static class PromoteRequestData {
101+
/** Appears next to the staging repo in the Nexus UI. */
102+
@Key
103+
public String description
104+
105+
/** Returned by Nexus. This is the newly created repository ID. */
106+
@Key
107+
public String stagedRepositoryId
108+
109+
PromoteRequestData() {}
110+
111+
PromoteRequestData(String description) {
112+
this.description = description
113+
}
114+
}
115+
}
116+
}
117+
118+
// -------------------------- Nexus client implementation ----------------------
119+
120+
/**
121+
* Implements a client for Sonatype Nexus.
122+
*
123+
* Pass --info to gradle to see the HTTP traffic.
124+
* Logging of raw HTTP is handled by the the HTTP client (google-java-http-client) which uses JUL.
125+
*
126+
* This implementation is based on:
127+
* https://support.sonatype.com/hc/en-us/articles/213465868-Uploading-to-a-Staging-Repository-via-REST-API
128+
*
129+
* Note that there are two versions of Nexus: v2 and v3. The OSS instance (which hosts our project)
130+
* uses v2.
131+
*
132+
* The Nexus docs are here: https://help.sonatype.com/repomanager2
133+
* The REST docs are here: https://repository.sonatype.org/nexus-restlet1x-plugin/default/docs/index.html
134+
* The docs are very patchy!
135+
*/
136+
class SonatypeClient {
137+
138+
private static final logger = org.slf4j.LoggerFactory.getLogger(SonatypeClient.class)
139+
private static final AUTO_STAGING_URL =
140+
"https://oss.sonatype.org/service/local/staging/deploy/maven2"
141+
private static final DEPLOY_BY_REPO_URL_STEM =
142+
"https://oss.sonatype.org/service/local/staging/deployByRepositoryId/"
143+
private static final STAGING_PROFILES_URL = new GenericUrl(
144+
"https://oss.sonatype.org/service/local/staging/profiles")
145+
private static final NAMESPACE_DICTIONARY = new XmlNamespaceDictionary().set("", "")
146+
private static final REQUEST_FACTORY = new NetHttpTransport()
147+
.createRequestFactory(new HttpRequestInitializer() {
148+
@Override
149+
void initialize(HttpRequest request) throws IOException {
150+
request.setParser(new XmlObjectParser(NAMESPACE_DICTIONARY))
151+
}
152+
})
153+
/**
154+
* Global storage of URLs for a rootProject.
155+
*/
156+
private static final STAGING_URL_CACHE = new ConcurrentHashMap();
157+
private final project
158+
private final authHeaders
159+
160+
SonatypeClient(project) {
161+
this.project = project
162+
this.authHeaders = new AuthHeaders(
163+
project.properties.get("sonatypeUser"),
164+
project.properties.get("sonatypePassword"))
165+
}
166+
167+
/**
168+
* Provides the Sonatype repository URL to use for deploying releases.
169+
*
170+
* Attempts to create a named staging repository to avoid using Nexus auto-staging if
171+
* possible. If not possible to create a named staging repository, falls back to auto-staging.
172+
*
173+
* This is motivated by the observation that auto-staging's heuristic for which artifacts
174+
* belong in the same repository is often confused by Gradle's publish mechanism, which
175+
* appears to create a new TCP connection for each artifact. This also appears to be flaky
176+
* based on the sonatype server load.
177+
*/
178+
def discoverSonatypeRepositoryUrl() {
179+
try {
180+
// Ensures that we don't create spurious repositories if we're not running a release.
181+
if (project.properties.containsKey("release")) {
182+
// Gradle may reuse the demon (hence its classloader) so this uses a map to store
183+
// the staging repo for each build.
184+
return STAGING_URL_CACHE.computeIfAbsent(
185+
project.rootProject, { project -> this.createStagingRepository() })
186+
} else {
187+
logger.debug("Release not enabled, defaulting to auto-staging")
188+
return AUTO_STAGING_URL
189+
}
190+
} catch (Exception ex) {
191+
// Handles issues by falling back to auto-staging.
192+
logger.warn("Failed to create new staging repo. Attempting to deploy with " +
193+
"auto-staging. The release *may* complete as planned. You can proceed by" +
194+
" making sure that all release artifacts are present in the auto-staging " +
195+
"repository. Please investigate why staging failed to create.", ex)
196+
return AUTO_STAGING_URL
197+
}
198+
}
199+
200+
/** Creates a staging repository on Sonatype. */
201+
private def createStagingRepository() {
202+
logger.info("Creating staging repository")
203+
def stagingProfileUrls = getStagingProfileUrls()
204+
if (stagingProfileUrls.size() == 0) {
205+
throw new GradleException("No staging profiles found")
206+
} else if (stagingProfileUrls.size() > 1) {
207+
logger.warn("Found multiple staging profiles, using the first one")
208+
}
209+
def stagingStartUrl = new GenericUrl(stagingProfileUrls[0] + "/start")
210+
def content = new PromoteRequestXmlContent(
211+
NAMESPACE_DICTIONARY, generateStagingRepoDescription())
212+
def response = REQUEST_FACTORY
213+
.buildPostRequest(stagingStartUrl, content)
214+
.setHeaders(authHeaders)
215+
.execute()
216+
// Checks the response and returns the repository URL, throwing if we
217+
// didn't get a valid response.
218+
if (response.getStatusCode() == 201) { // HTTP 201 = Created
219+
def parsed = response.parseAs(PromoteRequestXmlContent.PromoteRequest.class)
220+
logger.info("Staging repository created with ID ${parsed.data.stagedRepositoryId}")
221+
return DEPLOY_BY_REPO_URL_STEM + parsed.data.stagedRepositoryId
222+
} else {
223+
throw new GradleException("Nexus failed to create staging repo: " +
224+
"${response.getStatusCode()}: ${response.getStatusMessage()}")
225+
}
226+
}
227+
228+
/** Retrieves the staging profiles from Nexus. */
229+
private def getStagingProfileUrls() {
230+
logger.debug("Retrieving staging profiles")
231+
def response = REQUEST_FACTORY
232+
.buildGetRequest(STAGING_PROFILES_URL)
233+
.setHeaders(authHeaders)
234+
.execute()
235+
if (response.getStatusCode() == 200) {
236+
def parsed = response.parseAs(StagingProfiles.class)
237+
return parsed.getStagingProfileUrls()
238+
} else {
239+
throw new GradleException("Failed to retrieve staging profiles: HTTP " +
240+
"${response.getStatusCode()}: ${response.getStatusMessage()}")
241+
}
242+
}
243+
244+
/** Generates a pseudo-unique and human readable description for a staging repo. */
245+
private def generateStagingRepoDescription() {
246+
return "Release of Google Ads API v${project.version} " +
247+
"on ${InetAddress.getLocalHost().getCanonicalHostName()} " +
248+
"${new Date()}"
249+
}
250+
}
251+
252+
// ----------------------- Gradle project configuration ------------------------
253+
254+
// Configures gradle's native publishing now that debacle is over.
255+
publishing {
256+
publications {
257+
maven(MavenPublication) {
258+
from(components.java)
259+
}
260+
}
261+
repositories {
262+
maven {
263+
url new SonatypeClient(project).discoverSonatypeRepositoryUrl()
264+
name = "sonatype"
265+
credentials {
266+
// Avoids storing Sonatype credentials in plain-text. Specify these on
267+
// the command line: -PsonatypeUser=foo
268+
username project.properties.get("sonatypeUser")
269+
password project.properties.get("sonatypePassword")
270+
}
271+
}
272+
}
273+
}

0 commit comments

Comments
 (0)