hudson.remoting.Engine Maven / Gradle / Ivy
/*
* The MIT License
*
* Copyright (c) 2004-2009, Sun Microsystems, Inc., Kohsuke Kawaguchi
*
* 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 hudson.remoting;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
import java.net.HttpURLConnection;
import java.net.Socket;
import java.net.URL;
import java.util.Properties;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.List;
import java.util.Collections;
import java.util.logging.Logger;
import static java.util.logging.Level.SEVERE;
/**
* Slave agent engine that proactively connects to Hudson master.
*
* @author Kohsuke Kawaguchi
*/
public class Engine extends Thread {
/**
* Thread pool that sets {@link #CURRENT}.
*/
private final ExecutorService executor = Executors.newCachedThreadPool(new ThreadFactory() {
private final ThreadFactory defaultFactory = Executors.defaultThreadFactory();
public Thread newThread(final Runnable r) {
return defaultFactory.newThread(new Runnable() {
public void run() {
CURRENT.set(Engine.this);
r.run();
}
});
}
});
public final EngineListener listener;
/**
* To make Hudson more graceful against user error,
* JNLP agent can try to connect to multiple possible Hudson URLs.
* This field specifies those candidate URLs, such as
* "http://foo.bar/hudson/".
*/
private List candidateUrls;
/**
* URL that points to Hudson's tcp slage agent listener, like http://myhost/hudson/
*
*
* This value is determined from {@link #candidateUrls} after a successful connection.
* Note that this URL DOES NOT have "tcpSlaveAgentListener" in it.
*/
private URL hudsonUrl;
private final String secretKey;
public final String slaveName;
private String credentials;
/**
* See Main#tunnel in the jnlp-agent module for the details.
*/
private String tunnel;
private boolean noReconnect;
/**
* This cookie identifiesof the current connection, allowing us to force the server to drop
* the client if we initiate a reconnection from our end (even when the server still thinks
* the connection is alive.)
*/
private String cookie;
public Engine(EngineListener listener, List hudsonUrls, String secretKey, String slaveName) {
this.listener = listener;
this.candidateUrls = hudsonUrls;
this.secretKey = secretKey;
this.slaveName = slaveName;
if(candidateUrls.isEmpty())
throw new IllegalArgumentException("No URLs given");
}
public URL getHudsonUrl() {
return hudsonUrl;
}
public void setTunnel(String tunnel) {
this.tunnel = tunnel;
}
public void setCredentials(String creds) {
this.credentials = creds;
}
public void setNoReconnect(boolean noReconnect) {
this.noReconnect = noReconnect;
}
@SuppressWarnings({"ThrowableInstanceNeverThrown"})
@Override
public void run() {
try {
boolean first = true;
while(true) {
if(first) {
first = false;
} else {
if(noReconnect)
return; // exit
}
listener.status("Locating server among " + candidateUrls);
Throwable firstError=null;
String port=null;
for (URL url : candidateUrls) {
String s = url.toExternalForm();
if(!s.endsWith("/")) s+='/';
URL salURL = new URL(s+"tcpSlaveAgentListener/");
// find out the TCP port
HttpURLConnection con = (HttpURLConnection)salURL.openConnection();
if (con instanceof HttpURLConnection && credentials != null) {
String encoding = new sun.misc.BASE64Encoder().encode(credentials.getBytes());
con.setRequestProperty("Authorization", "Basic " + encoding);
}
try {
try {
con.setConnectTimeout(30000);
con.setReadTimeout(60000);
con.connect();
} catch (IOException x) {
if (firstError == null) {
firstError = new IOException("Failed to connect to " + salURL + ": " + x.getMessage()).initCause(x);
}
continue;
}
port = con.getHeaderField("X-Hudson-JNLP-Port");
if(con.getResponseCode()!=200) {
if(firstError==null)
firstError = new Exception(salURL+" is invalid: "+con.getResponseCode()+" "+con.getResponseMessage());
continue;
}
if(port ==null) {
if(firstError==null)
firstError = new Exception(url+" is not Hudson");
continue;
}
} finally {
con.disconnect();
}
// this URL works. From now on, only try this URL
hudsonUrl = url;
firstError = null;
candidateUrls = Collections.singletonList(hudsonUrl);
break;
}
if(firstError!=null) {
listener.error(firstError);
return;
}
Socket s = connect(port);
listener.status("Handshaking");
DataOutputStream dos = new DataOutputStream(s.getOutputStream());
BufferedInputStream in = new BufferedInputStream(s.getInputStream());
dos.writeUTF("Protocol:JNLP2-connect");
Properties props = new Properties();
props.put("Secret-Key", secretKey);
props.put("Node-Name", slaveName);
if (cookie!=null)
props.put("Cookie", cookie);
ByteArrayOutputStream o = new ByteArrayOutputStream();
props.store(o, null);
dos.writeUTF(o.toString("UTF-8"));
String greeting = readLine(in);
if (greeting.startsWith("Unknown protocol")) {
LOGGER.info("The server didn't understand the v2 handshake. Falling back to v1 handshake");
s.close();
s = connect(port);
in = new BufferedInputStream(s.getInputStream());
dos = new DataOutputStream(s.getOutputStream());
dos.writeUTF("Protocol:JNLP-connect");
dos.writeUTF(secretKey);
dos.writeUTF(slaveName);
greeting = readLine(in); // why, oh why didn't I use DataOutputStream when writing to the network?
if (!greeting.equals(GREETING_SUCCESS)) {
onConnectionRejected(greeting);
continue;
}
} else {
if (greeting.equals(GREETING_SUCCESS)) {
Properties responses = readResponseHeaders(in);
cookie = responses.getProperty("Cookie");
} else {
onConnectionRejected(greeting);
continue;
}
}
final Socket socket = s;
final Channel channel = new Channel("channel", executor,
in,
new BufferedOutputStream(s.getOutputStream()));
PingThread t = new PingThread(channel) {
protected void onDead() {
try {
if (!channel.isInClosed()) {
LOGGER.info("Ping failed. Terminating the socket.");
socket.close();
}
} catch (IOException e) {
LOGGER.log(SEVERE, "Failed to terminate the socket", e);
}
}
};
t.start();
listener.status("Connected");
channel.join();
listener.status("Terminated");
t.interrupt(); // make sure the ping thread is terminated
listener.onDisconnect();
if(noReconnect)
return; // exit
// try to connect back to the server every 10 secs.
waitForServerToBack();
}
} catch (Throwable e) {
listener.error(e);
}
}
private void onConnectionRejected(String greeting) throws InterruptedException {
listener.error(new Exception("The server rejected the connection: "+greeting));
Thread.sleep(10*1000);
}
private Properties readResponseHeaders(BufferedInputStream in) throws IOException {
Properties response = new Properties();
while (true) {
String line = readLine(in);
if (line.length()==0)
return response;
int idx = line.indexOf(':');
response.put(line.substring(0,idx).trim(), line.substring(idx+1).trim());
}
}
/**
* Read until '\n' and returns it as a string.
*/
private static String readLine(InputStream in) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
while (true) {
int ch = in.read();
if (ch<0 || ch=='\n')
return baos.toString().trim(); // trim off possible '\r'
baos.write(ch);
}
}
/**
* Connects to TCP slave port, with a few retries.
*/
private Socket connect(String port) throws IOException, InterruptedException {
String host = this.hudsonUrl.getHost();
if(tunnel!=null) {
String[] tokens = tunnel.split(":",3);
if(tokens.length!=2)
throw new IOException("Illegal tunneling parameter: "+tunnel);
if(tokens[0].length()>0) host = tokens[0];
if(tokens[1].length()>0) port = tokens[1];
}
String msg = "Connecting to " + host + ':' + port;
listener.status(msg);
int retry = 1;
while(true) {
try {
Socket s = new Socket(host, Integer.parseInt(port));
s.setTcpNoDelay(true); // we'll do buffering by ourselves
// set read time out to avoid infinite hang. the time out should be long enough so as not
// to interfere with normal operation. the main purpose of this is that when the other peer dies
// abruptly, we shouldn't hang forever, and at some point we should notice that the connection
// is gone.
s.setSoTimeout(30*60*1000); // 30 mins. See PingThread for the ping interval
return s;
} catch (IOException e) {
if(retry++>10)
throw (IOException)new IOException("Failed to connect to "+host+':'+port).initCause(e);
Thread.sleep(1000*10);
listener.status(msg+" (retrying:"+retry+")",e);
}
}
}
/**
* Waits for the server to come back.
*/
private void waitForServerToBack() throws InterruptedException {
while(true) {
Thread.sleep(1000*10);
try {
// Hudson top page might be read-protected. see http://www.nabble.com/more-lenient-retry-logic-in-Engine.waitForServerToBack-td24703172.html
HttpURLConnection con = (HttpURLConnection)new URL(hudsonUrl,"tcpSlaveAgentListener/").openConnection();
con.connect();
if(con.getResponseCode()==200)
return;
} catch (IOException e) {
// retry
}
}
}
/**
* When invoked from within remoted {@link Callable} (that is,
* from the thread that carries out the remote requests),
* this method returns the {@link Engine} in which the remote operations
* run.
*/
public static Engine current() {
return CURRENT.get();
}
private static final ThreadLocal CURRENT = new ThreadLocal();
private static final Logger LOGGER = Logger.getLogger(Engine.class.getName());
public static final String GREETING_SUCCESS = "Welcome";
}