/*
* The MIT License
*
* Copyright (c) 2010, NDS Group Ltd., James Nord
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.jvnet.hudson.plugins.m2release.nexus;
import hudson.util.IOUtils;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathException;
import javax.xml.xpath.XPathFactory;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* The Stage client acts as the interface to Nexus Pro staging via the Nexus REST APIs.
*
* @author James Nord
* @version 0.5
*/
public class StageClient {
private Logger log = LoggerFactory.getLogger(StageClient.class);
private URL nexusURL;
private String username;
private String password;
/**
* Create a new StageClient to handle communicating to a Nexus Pro server Staging suite.
* @param nexusURL the base URL for the Nexus server.
* @param username user name to use with staging privileges.
* @param password password for the user.
*/
public StageClient(URL nexusURL, String username, String password) {
this.nexusURL = nexusURL;
this.username = username;
this.password = password;
}
/**
* Get the ID for the Staging repository that holds the specified GAV.
*
* @param groupId
* groupID to search for.
* @param artifactId
* artifactID to search for.
* @param version
* version of the group/artifact to search for - may be <code>null</code>.
* @return the stageID or null if no machine stage was found.
* @throws StageException if any issue occurred whilst locating the open stage.
*/
public Stage getOpenStageID(String group, String artifact, String version) throws StageException {
log.debug("Looking for stage repo for {}:{}:{}", new Object[] {group, artifact, version});
List<Stage> stages = getOpenStageIDs();
Stage stage = null;
for (Stage testStage : stages) {
if (checkStageForGAV(testStage, group, artifact, version)) {
if (stage == null) {
stage = testStage;
log.debug("Found stage repo {} for {}:{}:{}", new Object[] {stage, group, artifact, version});
}
else {
// multiple stages match!!!
log.warn("Found a matching stage ({}) for {}:{} but already found a matchine one ({})", new Object[] {testStage, group, artifact, stage});
}
}
}
return stage;
}
/**
* Close the specified stage.
*
* @param stage
* the stage to close.
* @throws StageException
* if any issue occurred whilst closing the stage.
*/
public void closeStage(Stage stage, String description) throws StageException {
performStageAction(StageAction.CLOSE, stage, description);
}
/**
* Drop the stage from Nexus staging.
*
* @param stage
* the Stage to drop.
* @throws StageException
* if any issue occurred whilst dropping the stage.
*/
public void dropStage(Stage stage) throws StageException {
performStageAction(StageAction.DROP, stage, null);
}
/**
* Promote the stage from Nexus staging into the default repository for the stage.
*
* @param stage
* the Stage to promote.
* @throws StageException
* if any issue occurred whilst promoting the stage.
*/
public void promoteStage(Stage stage) throws StageException {
throw new UnsupportedOperationException("not implemented");
// need to get the first repo target id for the stage...
// performStageAction(StageAction.PROMOTE, stage, null);
}
/**
* Check if we have the required permissions for nexus staging.
*
* @return
* @throws StageException if an exception occurred whilst checking the authorisation.
*/
public void checkAuthentication() throws StageException {
try {
URL url = new URL(nexusURL.toString() + "/service/local/status");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
addAuthHeader(conn);
int status = conn.getResponseCode();
if (status == 200) {
DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = builder.parse(conn.getInputStream());
/*
* check for the following permissions:
*/
String[] requiredPerms = new String[] {"nexus:stagingprofiles", "nexus:stagingfinish",
// "nexus:stagingpromote",
"nexus:stagingdrop"};
XPath xpath = XPathFactory.newInstance().newXPath();
for (String perm : requiredPerms) {
String expression = "//clientPermissions/permissions/permission[id=\"" + perm + "\"]/value";
Node node = (Node) xpath.evaluate(expression, doc, XPathConstants.NODE);
if (node == null) {
throw new StageException("Invalid reponse from server - is the URL a Nexus Professional server?");
}
int val = Integer.parseInt(node.getTextContent());
if (val == 0) {
throw new StageException("User has insufficient privaledges to perform staging actions (" + perm
+ ")");
}
}
}
else {
drainOutput(conn);
if (status == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw new IOException("Incorrect username / password supplied.");
}
else if (status == HttpURLConnection.HTTP_NOT_FOUND) {
throw new IOException("Service not found - is this a Nexus server?");
}
else {
throw new IOException("Server returned error code " + status + ".");
}
}
}
catch (IOException ex) {
throw createStageExceptionForIOException(nexusURL, ex);
}
catch (XPathException ex) {
throw new StageException(ex);
}
catch (ParserConfigurationException ex) {
throw new StageException(ex);
}
catch (SAXException ex) {
throw new StageException(ex);
}
}
public List<Stage> getOpenStageIDs() throws StageException {
log.debug("retreiving list of stages");
try {
List<Stage> openStages = new ArrayList<Stage>();
URL url = new URL(nexusURL.toString() + "/service/local/staging/profiles");
Document doc = getDocument(url);
String profileExpression = "//stagingProfile/id";
XPath xpathProfile = XPathFactory.newInstance().newXPath();
NodeList profileNodes = (NodeList) xpathProfile.evaluate(profileExpression, doc, XPathConstants.NODESET);
for (int i = 0; i < profileNodes.getLength(); i++) {
Node profileNode = profileNodes.item(i);
String profileID = profileNode.getTextContent();
String statgeExpression = "../stagingRepositoryIds/string";
XPath xpathStage = XPathFactory.newInstance().newXPath();
NodeList stageNodes = (NodeList) xpathStage.evaluate(statgeExpression, profileNode,
XPathConstants.NODESET);
for (int j = 0; j < stageNodes.getLength(); j++) {
Node stageNode = stageNodes.item(j);
// XXX need to also get the stage profile
openStages.add(new Stage(profileID, stageNode.getTextContent()));
}
}
return openStages;
}
catch (IOException ex) {
throw createStageExceptionForIOException(nexusURL, ex);
}
catch (XPathException ex) {
throw new StageException(ex);
}
}
public boolean checkStageForGAV(Stage stage, String group, String artifact, String version)
throws StageException {
// do we always know the version???
// to browse an open repo
// /service/local/repositories/${stageID}/content/...
// the stage repos are not listed via a call to
// /service/local/repositories/ but are in existence!
boolean found = false;
try {
URL url;
if (version == null) {
url = new URL(nexusURL.toString() + "/service/local/repositories/" + stage.getStageID() + "/content/"
+ group.replace('.', '/') + '/' + artifact + "/?isLocal");
}
else {
url = new URL(nexusURL.toString() + "/service/local/repositories/" + stage.getStageID() + "/content/"
+ group.replace('.', '/') + '/' + artifact + '/' + version + "/?isLocal");
}
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
addAuthHeader(conn);
conn.setRequestMethod("HEAD");
int response = conn.getResponseCode();
if (response == HttpURLConnection.HTTP_OK) {
// we found our baby - may be a different version but we don't
// always have that to hand (if Maven did the auto numbering)
found = true;
}
else if (response == HttpURLConnection.HTTP_NOT_FOUND) {
// not this repo
}
else {
log.warn("Server returned HTTP status {} when we only expected a 200 or 404.", Integer.toString(response));
}
conn.disconnect();
}
catch (IOException ex) {
throw createStageExceptionForIOException(nexusURL, ex);
}
return found;
}
private Document getDocument(URL url) throws StageException {
try {
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
addAuthHeader(conn);
int status = conn.getResponseCode();
if (status == 200) {
DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = builder.parse(conn.getInputStream());
conn.disconnect();
return doc;
}
else {
drainOutput(conn);
if (status == 401) {
throw new IOException("Incorrect Crediantials for " + url.toString());
}
else {
throw new IOException("Server returned error code " + status + " for " + url.toString());
}
}
}
catch (IOException ex) {
throw createStageExceptionForIOException(nexusURL, ex);
}
catch (ParserConfigurationException ex) {
throw new StageException(ex);
}
catch (SAXException ex) {
throw new StageException(ex);
}
}
/**
* Construct the XML message for a promoteRequest.
*
* @param stage
* The stage to target
* @param description
* the description (used for promote - ignored otherwise)
* @return The XML for the promoteRequest.
*/
private String createPromoteRequestPayload(Stage stage, String description) {
// TODO? this is missing the targetRepoID which is needed for promote...
// XXX lets hope that the description never contains "]]>"
return String.format("<?xml version=\"1.0\" encoding=\"UTF-8\"?><promoteRequest><data><stagedRepositoryId>%s</stagedRepositoryId><description><![CDATA[%s]]></description></data></promoteRequest>",
stage.getStageID(), description);
}
/**
* Perform a staging action.
* @param action the action to perform.
* @param stage the stage on which to perform the action.
* @param description description to pass to the server for the action (e.g. the description of the stage repo).
* @throws StageException if an exception occurs whilst performing the action.
*/
private void performStageAction(StageAction action, Stage stage, String description) throws StageException {
log.debug("Performing action {} on stage {}", new Object[] {action, stage});
try {
URL url = action.getURL(nexusURL, stage);
String payload = createPromoteRequestPayload(stage, description);
byte[] payloadBytes = payload.getBytes("UTF-8");
int contentLen = payloadBytes.length;
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
addAuthHeader(conn);
conn.setRequestProperty("Content-Length", Integer.toString(contentLen));
conn.setRequestProperty("Content-Type", "application/xml; charset=UTF-8");
conn.setRequestProperty("Accept", "application/xml");
conn.setRequestMethod("POST");
conn.setDoOutput(true);
OutputStream out = conn.getOutputStream();
out.write(payloadBytes);
out.flush();
int status = conn.getResponseCode();
log.debug("Server returned HTTP Status {} for {} stage request to {}.", new Object[] { Integer.toString(status),
action.name(), stage });
if (status == HttpURLConnection.HTTP_CREATED) {
drainOutput(conn);
conn.disconnect();
}
else {
log.warn("Server returned HTTP Status {} for {} stage request to {}.",
new Object[] { Integer.toString(status), action.name(), stage });
drainOutput(conn);
conn.disconnect();
throw new IOException(String.format("server responded with status:%s", Integer.toString(status)));
}
}
catch (IOException ex) {
String message = String.format("Failed to perform %s action to nexus stage(%s)", action.name(), stage.toString());
throw new StageException(message, ex);
}
}
/**
* Add the BASIC Authentication header to the HTTP connection.
* @param conn the HTTP URL Connection
*/
private void addAuthHeader(HttpURLConnection conn) {
// java.net.Authenticator is brain damaged as it is global and no way to delegate for just one server...
try {
String auth = username + ":" + password;
// there is a lot of debate about password and non ISO-8859-1 characters...
// see https://bugzilla.mozilla.org/show_bug.cgi?id=41489
// Base64 adds a trailing newline - just strip it as whitespace is illegal in Bsae64
String encodedAuth = new Base64().encodeToString(auth.getBytes("ISO-8859-1")).trim();
conn.setRequestProperty("Authorization", "Basic " + encodedAuth);
log.debug("Encoded Authentication is: "+encodedAuth);
}
catch (UnsupportedEncodingException ex) {
String msg = "JVM does not conform to java specification. Mandatory CharSet ISO-8859-1 is not available.";
log.error(msg);
throw new RuntimeException(msg, ex);
}
}
private StageException createStageExceptionForIOException(URL url, IOException ex) {
if (ex instanceof StageException) {
return (StageException)ex;
}
if (ex.getMessage().equals(url.toString())) {
// Sun JRE (and probably others too) often return just the URL in the error.
return new StageException("Unable to connect to " + url, ex);
}
else {
return new StageException(ex.getMessage(), ex);
}
}
private void drainOutput(HttpURLConnection conn) throws IOException {
// for things like unauthorised (401) we won't have any content and getting the inputStream will
// cause an IOException as we are in error - but there is no really way to tell this so check the
// length instead.
if (conn.getContentLength() > 0) {
if (conn.getErrorStream() != null) {
IOUtils.skip(conn.getErrorStream(), conn.getContentLength());
}
else {
IOUtils.skip(conn.getInputStream(), conn.getContentLength());
}
}
}
}