/*
* The MIT License
*
* Copyright (c) 2004-, Kohsuke Kawaguchi, Sun Microsystems, Inc., and a number of other of contributors
*
* 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.plugins.ec2.ssh;
import hudson.FilePath;
import hudson.Util;
import hudson.ProxyConfiguration;
import hudson.model.Descriptor;
import hudson.model.TaskListener;
import hudson.plugins.ec2.EC2AbstractSlave;
import hudson.plugins.ec2.EC2Cloud;
import hudson.plugins.ec2.EC2ComputerLauncher;
import hudson.plugins.ec2.EC2Computer;
import hudson.plugins.ec2.SlaveTemplate;
import hudson.remoting.Channel;
import hudson.remoting.Channel.Listener;
import hudson.slaves.CommandLauncher;
import hudson.slaves.ComputerLauncher;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintStream;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.model.Jenkins;
import org.apache.commons.io.IOUtils;
import com.amazonaws.AmazonClientException;
import com.amazonaws.services.ec2.model.Instance;
import com.amazonaws.services.ec2.model.KeyPair;
import com.trilead.ssh2.Connection;
import com.trilead.ssh2.HTTPProxyData;
import com.trilead.ssh2.SCPClient;
import com.trilead.ssh2.ServerHostKeyVerifier;
import com.trilead.ssh2.Session;
/**
* {@link ComputerLauncher} that connects to a Unix slave on EC2 by using SSH.
*
* @author Kohsuke Kawaguchi
*/
public class EC2UnixLauncher extends EC2ComputerLauncher {
private static final Logger LOGGER = Logger.getLogger(EC2UnixLauncher.class.getName());
private static final String BOOTSTRAP_AUTH_SLEEP_MS = "jenkins.ec2.bootstrapAuthSleepMs";
private static final String BOOTSTRAP_AUTH_TRIES= "jenkins.ec2.bootstrapAuthTries";
private static int bootstrapAuthSleepMs = 30000;
private static int bootstrapAuthTries = 30;
static {
String prop = System.getProperty(BOOTSTRAP_AUTH_SLEEP_MS);
if (prop != null)
bootstrapAuthSleepMs = Integer.parseInt(prop);
prop = System.getProperty(BOOTSTRAP_AUTH_TRIES);
if (prop != null)
bootstrapAuthTries = Integer.parseInt(prop);
}
private final int FAILED = -1;
protected void log(Level level, EC2Computer computer, TaskListener listener, String message) {
EC2Cloud cloud = computer.getCloud();
if (cloud != null)
cloud.log(LOGGER, level, listener, message);
}
protected void logException(EC2Computer computer, TaskListener listener, String message, Throwable exception) {
EC2Cloud cloud = computer.getCloud();
if (cloud != null)
cloud.log(LOGGER, Level.WARNING, listener, message, exception);
}
protected void logInfo(EC2Computer computer, TaskListener listener, String message) {
log(Level.INFO, computer, listener, message);
}
protected void logWarning(EC2Computer computer, TaskListener listener, String message) {
log(Level.WARNING, computer, listener, message);
}
protected String buildUpCommand(EC2Computer computer, String command) {
if (!computer.getRemoteAdmin().equals("root")) {
command = computer.getRootCommandPrefix() + " " + command;
}
return command;
}
@Override
protected void launch(EC2Computer computer, TaskListener listener, Instance inst) throws IOException,
AmazonClientException, InterruptedException {
final Connection bootstrapConn;
final Connection conn;
Connection cleanupConn = null; // java's code path analysis for final
// doesn't work that well.
boolean successful = false;
PrintStream logger = listener.getLogger();
logInfo(computer, listener, "Launching instance: " + computer.getNode().getInstanceId());
try {
boolean isBootstrapped = bootstrap(computer, listener);
if (isBootstrapped) {
// connect fresh as ROOT
logInfo(computer, listener, "connect fresh as root");
cleanupConn = connectToSsh(computer, listener);
KeyPair key = computer.getCloud().getKeyPair();
if (!cleanupConn.authenticateWithPublicKey(computer.getRemoteAdmin(), key.getKeyMaterial().toCharArray(), "")) {
logWarning(computer, listener, "Authentication failed");
return; // failed to connect as root.
}
} else {
logWarning(computer, listener, "bootstrapresult failed");
return; // bootstrap closed for us.
}
conn = cleanupConn;
SCPClient scp = conn.createSCPClient();
String initScript = computer.getNode().initScript;
String tmpDir = (Util.fixEmptyAndTrim(computer.getNode().tmpDir) != null ? computer.getNode().tmpDir
: "/tmp");
logInfo(computer, listener, "Creating tmp directory (" + tmpDir + ") if it does not exist");
conn.exec("mkdir -p " + tmpDir, logger);
if (initScript != null && initScript.trim().length() > 0
&& conn.exec("test -e ~/.hudson-run-init", logger) != 0) {
logInfo(computer, listener, "Executing init script");
scp.put(initScript.getBytes("UTF-8"), "init.sh", tmpDir, "0700");
Session sess = conn.openSession();
sess.requestDumbPTY(); // so that the remote side bundles stdout
// and stderr
sess.execCommand(buildUpCommand(computer, tmpDir + "/init.sh"));
sess.getStdin().close(); // nothing to write here
sess.getStderr().close(); // we are not supposed to get anything
// from stderr
IOUtils.copy(sess.getStdout(), logger);
int exitStatus = waitCompletion(sess);
if (exitStatus != 0) {
logWarning(computer, listener, "init script failed: exit code=" + exitStatus);
return;
}
sess.close();
// Needs a tty to run sudo.
sess = conn.openSession();
sess.requestDumbPTY(); // so that the remote side bundles stdout
// and stderr
sess.execCommand(buildUpCommand(computer, "touch ~/.hudson-run-init"));
sess.close();
}
// TODO: parse the version number. maven-enforcer-plugin might help
logInfo(computer, listener, "Verifying that java exists");
if (conn.exec("java -fullversion", logger) != 0) {
logInfo(computer, listener, "Installing Java");
String jdk = "java1.6.0_12";
String path = "/hudson-ci/jdk/linux-i586/" + jdk + ".tgz";
URL url = computer.getCloud().buildPresignedURL(path);
if (conn.exec("wget -nv -O " + tmpDir + "/" + jdk + ".tgz '" + url + "'", logger) != 0) {
logWarning(computer, listener, "Failed to download Java");
return;
}
if (conn.exec(buildUpCommand(computer, "tar xz -C /usr -f " + tmpDir + "/" + jdk + ".tgz"), logger) != 0) {
logWarning(computer, listener, "Failed to install Java");
return;
}
if (conn.exec(buildUpCommand(computer, "ln -s /usr/" + jdk + "/bin/java /bin/java"), logger) != 0) {
logWarning(computer, listener, "Failed to symlink Java");
return;
}
}
// TODO: on Windows with ec2-sshd, this scp command ends up just
// putting slave.jar as c:\tmp
// bug in ec2-sshd?
logInfo(computer, listener, "Copying slave.jar");
scp.put(Jenkins.getInstance().getJnlpJars("slave.jar").readFully(), "slave.jar", tmpDir);
String jvmopts = computer.getNode().jvmopts;
String launchString = "java " + (jvmopts != null ? jvmopts : "") + " -jar " + tmpDir + "/slave.jar";
SlaveTemplate slaveTemplate = computer.getSlaveTemplate();
if (slaveTemplate != null && slaveTemplate.isConnectBySSHProcess()) {
EC2AbstractSlave node = computer.getNode();
File identityKeyFile = createIdentityKeyFile(computer);
try {
// Obviously the master must have an installed ssh client.
String sshClientLaunchString = String.format("ssh -o StrictHostKeyChecking=no -i %s %s@%s -p %d %s", identityKeyFile.getAbsolutePath(), node.remoteAdmin, getEC2HostAddress(computer, inst), node.getSshPort(), launchString);
logInfo(computer, listener, "Launching slave agent (via SSH client process): " + sshClientLaunchString);
CommandLauncher commandLauncher = new CommandLauncher(sshClientLaunchString);
commandLauncher.launch(computer, listener);
} finally {
identityKeyFile.delete();
}
} else {
logInfo(computer, listener, "Launching slave agent (via Trilead SSH2 Connection): " + launchString);
final Session sess = conn.openSession();
sess.execCommand(launchString);
computer.setChannel(sess.getStdout(), sess.getStdin(), logger, new Listener() {
@Override
public void onClosed(Channel channel, IOException cause) {
sess.close();
conn.close();
}
});
}
successful = true;
} finally {
if (cleanupConn != null && !successful)
cleanupConn.close();
}
}
private File createIdentityKeyFile(EC2Computer computer) throws IOException {
String privateKey = computer.getCloud().getPrivateKey().getPrivateKey();
File tempFile = File.createTempFile("ec2_", ".pem");
try {
FileOutputStream fileOutputStream = new FileOutputStream(tempFile);
OutputStreamWriter writer = new OutputStreamWriter(fileOutputStream, StandardCharsets.UTF_8);
try {
writer.write(privateKey);
writer.flush();
} finally {
writer.close();
fileOutputStream.close();
}
FilePath filePath = new FilePath(tempFile);
filePath.chmod(0400); // octal file mask - readonly by owner
return tempFile;
} catch (Exception e) {
tempFile.delete();
throw new IOException("Error creating temporary identity key file for connecting to EC2 slave.", e);
}
}
private boolean bootstrap(EC2Computer computer, TaskListener listener) throws IOException,
InterruptedException, AmazonClientException {
logInfo(computer, listener, "bootstrap()");
Connection bootstrapConn = null;
try {
int tries = bootstrapAuthTries;
boolean isAuthenticated = false;
logInfo(computer, listener, "Getting keypair...");
KeyPair key = computer.getCloud().getKeyPair();
logInfo(computer, listener, "Using key: " + key.getKeyName() + "\n" + key.getKeyFingerprint() + "\n"
+ key.getKeyMaterial().substring(0, 160));
while (tries-- > 0) {
logInfo(computer, listener, "Authenticating as " + computer.getRemoteAdmin());
try {
bootstrapConn = connectToSsh(computer, listener);
isAuthenticated = bootstrapConn.authenticateWithPublicKey(computer.getRemoteAdmin(), key.getKeyMaterial().toCharArray(), "");
} catch(IOException e) {
logException(computer, listener, "Exception trying to authenticate", e);
bootstrapConn.close();
}
if (isAuthenticated) {
break;
}
logWarning(computer, listener, "Authentication failed. Trying again...");
Thread.sleep(bootstrapAuthSleepMs);
}
if (!isAuthenticated) {
logWarning(computer, listener, "Authentication failed");
return false;
}
} finally {
bootstrapConn.close();
}
return true;
}
private Connection connectToSsh(EC2Computer computer, TaskListener listener) throws AmazonClientException,
InterruptedException {
final long timeout = computer.getNode().getLaunchTimeoutInMillis();
final long startTime = System.currentTimeMillis();
while (true) {
try {
long waitTime = System.currentTimeMillis() - startTime;
if (timeout > 0 && waitTime > timeout) {
throw new AmazonClientException("Timed out after " + (waitTime / 1000)
+ " seconds of waiting for ssh to become available. (maximum timeout configured is "
+ (timeout / 1000) + ")");
}
Instance instance = computer.updateInstanceDescription();
String host = getEC2HostAddress(computer, instance);
if ("0.0.0.0".equals(host)) {
logWarning(computer, listener, "Invalid host 0.0.0.0, your host is most likely waiting for an ip address.");
throw new IOException("goto sleep");
}
int port = computer.getSshPort();
Integer slaveConnectTimeout = Integer.getInteger("jenkins.ec2.slaveConnectTimeout", 10000);
logInfo(computer, listener, "Connecting to " + host + " on port " + port + ", with timeout " + slaveConnectTimeout
+ ".");
Connection conn = new Connection(host, port);
ProxyConfiguration proxyConfig = Jenkins.getInstance().proxy;
Proxy proxy = proxyConfig == null ? Proxy.NO_PROXY : proxyConfig.createProxy(host);
if (!proxy.equals(Proxy.NO_PROXY) && proxy.address() instanceof InetSocketAddress) {
InetSocketAddress address = (InetSocketAddress) proxy.address();
HTTPProxyData proxyData = null;
if (null != proxyConfig.getUserName()) {
proxyData = new HTTPProxyData(address.getHostName(), address.getPort(), proxyConfig.getUserName(), proxyConfig.getPassword());
} else {
proxyData = new HTTPProxyData(address.getHostName(), address.getPort());
}
conn.setProxyData(proxyData);
logInfo(computer, listener, "Using HTTP Proxy Configuration");
}
// currently OpenSolaris offers no way of verifying the host
// certificate, so just accept it blindly,
// hoping that no man-in-the-middle attack is going on.
conn.connect(new ServerHostKeyVerifier() {
public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm, byte[] serverHostKey)
throws Exception {
return true;
}
}, slaveConnectTimeout, slaveConnectTimeout);
logInfo(computer, listener, "Connected via SSH.");
return conn; // successfully connected
} catch (IOException e) {
// keep retrying until SSH comes up
logInfo(computer, listener, "Failed to connect via ssh: " + e.getMessage());
logInfo(computer, listener, "Waiting for SSH to come up. Sleeping 5.");
Thread.sleep(5000);
}
}
}
private String getEC2HostAddress(EC2Computer computer, Instance inst) {
if (computer.getNode().usePrivateDnsName) {
return inst.getPrivateDnsName();
} else {
String host = inst.getPublicDnsName();
// If we fail to get a public DNS name, try to get the public IP
// (but only if the plugin config let us use the public IP to
// connect to the slave).
if (host == null || host.equals("")) {
SlaveTemplate slaveTemplate = computer.getSlaveTemplate();
if (inst.getPublicIpAddress() != null && slaveTemplate.isConnectUsingPublicIp()) {
host = inst.getPublicIpAddress();
}
}
// If we fail to get a public DNS name or public IP, use the private
// IP.
if (host == null || host.equals("")) {
host = inst.getPrivateIpAddress();
}
return host;
}
}
private int waitCompletion(Session session) throws InterruptedException {
// I noticed that the exit status delivery often gets delayed. Wait up
// to 1 sec.
for (int i = 0; i < 10; i++) {
Integer r = session.getExitStatus();
if (r != null)
return r;
Thread.sleep(100);
}
return -1;
}
@Override
public Descriptor<ComputerLauncher> getDescriptor() {
throw new UnsupportedOperationException();
}
}