Project: m2release-plugin
/*
 * 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 Base64 
   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()); 
   
  
 
}