diff --git a/src/main/java/hudson/plugins/ec2/AMITypeData.java b/src/main/java/hudson/plugins/ec2/AMITypeData.java index 6cb3b623f..0ea3a045a 100644 --- a/src/main/java/hudson/plugins/ec2/AMITypeData.java +++ b/src/main/java/hudson/plugins/ec2/AMITypeData.java @@ -6,4 +6,6 @@ public abstract class AMITypeData extends AbstractDescribableImpl { public abstract boolean isWindows(); public abstract boolean isUnix(); + + public abstract boolean isMac(); } diff --git a/src/main/java/hudson/plugins/ec2/EC2AbstractSlave.java b/src/main/java/hudson/plugins/ec2/EC2AbstractSlave.java index e70a2f370..e6964ee06 100644 --- a/src/main/java/hudson/plugins/ec2/EC2AbstractSlave.java +++ b/src/main/java/hudson/plugins/ec2/EC2AbstractSlave.java @@ -393,6 +393,8 @@ public EC2Cloud getCloud() { return 240; case M5a24xlarge: return 240; + case Mac1Metal: + return 1; // We don't have a suggestion, but we don't want to fail completely // surely? default: @@ -515,21 +517,21 @@ public String getRemoteAdmin() { } String getRootCommandPrefix() { - String commandPrefix = amiType.isUnix() ? ((UnixData) amiType).getRootCommandPrefix() : ""; + String commandPrefix = (amiType.isUnix() ? ((UnixData) amiType).getRootCommandPrefix() : (amiType.isMac() ? ((MacData) amiType).getRootCommandPrefix() : "")); if (commandPrefix == null || commandPrefix.length() == 0) return ""; return commandPrefix + " "; } String getSlaveCommandPrefix() { - String commandPrefix = amiType.isUnix() ? ((UnixData) amiType).getSlaveCommandPrefix() : ""; + String commandPrefix = (amiType.isUnix() ? ((UnixData) amiType).getSlaveCommandPrefix() :(amiType.isMac() ? ((MacData) amiType).getSlaveCommandPrefix() : "")); if (commandPrefix == null || commandPrefix.length() == 0) return ""; return commandPrefix + " "; } String getSlaveCommandSuffix() { - String commandSuffix = amiType.isUnix() ? ((UnixData) amiType).getSlaveCommandSuffix() : ""; + String commandSuffix = (amiType.isUnix() ? ((UnixData) amiType).getSlaveCommandSuffix() :(amiType.isMac() ? ((MacData) amiType).getSlaveCommandSuffix() : "")); if (commandSuffix == null || commandSuffix.length() == 0) return ""; return " " + commandSuffix; @@ -540,7 +542,7 @@ String getJvmopts() { } public int getSshPort() { - String sshPort = amiType.isUnix() ? ((UnixData) amiType).getSshPort() : "22"; + String sshPort = (amiType.isUnix() ? ((UnixData) amiType).getSshPort() :(amiType.isMac() ? ((MacData) amiType).getSshPort() : "22")); if (sshPort == null || sshPort.length() == 0) return 22; diff --git a/src/main/java/hudson/plugins/ec2/EC2HostAddressProvider.java b/src/main/java/hudson/plugins/ec2/EC2HostAddressProvider.java index b8286212d..0be9d394c 100644 --- a/src/main/java/hudson/plugins/ec2/EC2HostAddressProvider.java +++ b/src/main/java/hudson/plugins/ec2/EC2HostAddressProvider.java @@ -23,13 +23,28 @@ public static String unix(Instance instance, ConnectionStrategy strategy) { } } + public static String mac(Instance instance, ConnectionStrategy strategy) { + switch (strategy) { + case PUBLIC_DNS: + return filterNonEmpty(getPublicDnsName(instance)).orElse(getPublicIpAddress(instance)); + case PUBLIC_IP: + return getPublicIpAddress(instance); + case PRIVATE_DNS: + return filterNonEmpty(getPrivateDnsName(instance)).orElse(getPrivateIpAddress(instance)); + case PRIVATE_IP: + return getPrivateIpAddress(instance); + default: + throw new IllegalArgumentException("Could not mac host address for strategy = " + strategy.toString()); + } + } + public static String windows(Instance instance, ConnectionStrategy strategy) { if (strategy.equals(PRIVATE_DNS) || strategy.equals(PRIVATE_IP)) { return getPrivateIpAddress(instance); } else if (strategy.equals(PUBLIC_DNS) || strategy.equals(PUBLIC_IP)) { return getPublicIpAddress(instance); } else { - throw new IllegalArgumentException("Could not unix host address for strategy = " + strategy.toString()); + throw new IllegalArgumentException("Could not windows host address for strategy = " + strategy.toString()); } } diff --git a/src/main/java/hudson/plugins/ec2/EC2OndemandSlave.java b/src/main/java/hudson/plugins/ec2/EC2OndemandSlave.java index 6b2feb2bc..8bf9bf6f8 100644 --- a/src/main/java/hudson/plugins/ec2/EC2OndemandSlave.java +++ b/src/main/java/hudson/plugins/ec2/EC2OndemandSlave.java @@ -6,6 +6,7 @@ import hudson.model.Node; import hudson.plugins.ec2.ssh.EC2UnixLauncher; import hudson.plugins.ec2.win.EC2WindowsLauncher; +import hudson.plugins.ec2.ssh.EC2MacLauncher; import hudson.slaves.NodeProperty; import java.io.IOException; @@ -62,8 +63,8 @@ public EC2OndemandSlave(String name, String instanceId, String templateDescripti public EC2OndemandSlave(String name, String instanceId, String templateDescription, String remoteFS, int numExecutors, String labelString, Mode mode, String initScript, String tmpDir, List> nodeProperties, String remoteAdmin, String jvmopts, boolean stopOnTerminate, String idleTerminationMinutes, String publicDNS, String privateDNS, List tags, String cloudName, int launchTimeout, AMITypeData amiType, ConnectionStrategy connectionStrategy, int maxTotalUses, Tenancy tenancy) throws FormException, IOException { - super(name, instanceId, templateDescription, remoteFS, numExecutors, mode, labelString, amiType.isWindows() ? new EC2WindowsLauncher() - : new EC2UnixLauncher(), new EC2RetentionStrategy(idleTerminationMinutes), initScript, tmpDir, nodeProperties, remoteAdmin, jvmopts, stopOnTerminate, idleTerminationMinutes, tags, cloudName, launchTimeout, amiType, connectionStrategy, maxTotalUses, tenancy); + super(name, instanceId, templateDescription, remoteFS, numExecutors, mode, labelString, (amiType.isWindows() ? new EC2WindowsLauncher() : (amiType.isMac() ? new EC2MacLauncher(): + new EC2UnixLauncher())), new EC2RetentionStrategy(idleTerminationMinutes), initScript, tmpDir, nodeProperties, remoteAdmin, jvmopts, stopOnTerminate, idleTerminationMinutes, tags, cloudName, launchTimeout, amiType, connectionStrategy, maxTotalUses, tenancy); this.publicDNS = publicDNS; this.privateDNS = privateDNS; diff --git a/src/main/java/hudson/plugins/ec2/MacData.java b/src/main/java/hudson/plugins/ec2/MacData.java new file mode 100644 index 000000000..fba4c8312 --- /dev/null +++ b/src/main/java/hudson/plugins/ec2/MacData.java @@ -0,0 +1,142 @@ +package hudson.plugins.ec2; + +import hudson.Extension; +import hudson.model.Descriptor; +import hudson.util.FormValidation; +import jenkins.model.Jenkins; +import org.apache.commons.lang.StringUtils; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.QueryParameter; + +public class MacData extends AMITypeData { + private final String rootCommandPrefix; + private final String slaveCommandPrefix; + private final String slaveCommandSuffix; + private final String sshPort; + + @DataBoundConstructor + public MacData(String rootCommandPrefix, String slaveCommandPrefix, String slaveCommandSuffix, String sshPort) { + this.rootCommandPrefix = rootCommandPrefix; + this.slaveCommandPrefix = slaveCommandPrefix; + this.slaveCommandSuffix = slaveCommandSuffix; + this.sshPort = sshPort; + + this.readResolve(); + } + + protected Object readResolve() { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + return this; + } + + @Override + public boolean isWindows() { + return false; + } + + @Override + public boolean isUnix() { + return false; + } + + @Override + public boolean isMac() { + return true; + } + + @Extension + public static class DescriptorImpl extends Descriptor { + @Override + public String getDisplayName() { + return "mac"; + } + + @Restricted(NoExternalUse.class) + public FormValidation doCheckRootCommandPrefix(@QueryParameter String value){ + if(StringUtils.isBlank(value) || Jenkins.get().hasPermission(Jenkins.ADMINISTER)){ + return FormValidation.ok(); + }else{ + return FormValidation.error(Messages.General_MissingPermission()); + } + } + + @Restricted(NoExternalUse.class) + public FormValidation doCheckSlaveCommandPrefix(@QueryParameter String value){ + if(StringUtils.isBlank(value) || Jenkins.get().hasPermission(Jenkins.ADMINISTER)){ + return FormValidation.ok(); + }else{ + return FormValidation.error(Messages.General_MissingPermission()); + } + } + + @Restricted(NoExternalUse.class) + public FormValidation doCheckSlaveCommandSuffix(@QueryParameter String value){ + if(StringUtils.isBlank(value) || Jenkins.get().hasPermission(Jenkins.ADMINISTER)){ + return FormValidation.ok(); + }else{ + return FormValidation.error(Messages.General_MissingPermission()); + } + } + } + + public String getRootCommandPrefix() { + return rootCommandPrefix; + } + + public String getSlaveCommandPrefix() { + return slaveCommandPrefix; + } + + public String getSlaveCommandSuffix() { + return slaveCommandSuffix; + } + + public String getSshPort() { + return sshPort == null || sshPort.isEmpty() ? "22" : sshPort; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((rootCommandPrefix == null) ? 0 : rootCommandPrefix.hashCode()); + result = prime * result + ((slaveCommandPrefix == null) ? 0 : slaveCommandPrefix.hashCode()); + result = prime * result + ((slaveCommandSuffix == null) ? 0 : slaveCommandSuffix.hashCode()); + result = prime * result + ((sshPort == null) ? 0 : sshPort.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (this.getClass() != obj.getClass()) + return false; + final MacData other = (MacData) obj; + if (StringUtils.isEmpty(rootCommandPrefix)) { + if (!StringUtils.isEmpty(other.rootCommandPrefix)) + return false; + } else if (!rootCommandPrefix.equals(other.rootCommandPrefix)) + return false; + if (StringUtils.isEmpty(slaveCommandPrefix)) { + if (!StringUtils.isEmpty(other.slaveCommandPrefix)) + return false; + } else if (!slaveCommandPrefix.equals(other.slaveCommandPrefix)) + return false; + if (StringUtils.isEmpty(slaveCommandSuffix)) { + if (!StringUtils.isEmpty(other.slaveCommandSuffix)) + return false; + } else if (!slaveCommandSuffix.equals(other.slaveCommandSuffix)) + return false; + if (StringUtils.isEmpty(sshPort)) { + if (!StringUtils.isEmpty(other.sshPort)) + return false; + } else if (!sshPort.equals(other.sshPort)) + return false; + return true; + } +} diff --git a/src/main/java/hudson/plugins/ec2/SlaveTemplate.java b/src/main/java/hudson/plugins/ec2/SlaveTemplate.java index 55b426b09..4e6a4be57 100644 --- a/src/main/java/hudson/plugins/ec2/SlaveTemplate.java +++ b/src/main/java/hudson/plugins/ec2/SlaveTemplate.java @@ -649,6 +649,9 @@ public int getSshPort() { if (amiType.isUnix()) { sshPort = ((UnixData) amiType).getSshPort(); } + if (amiType.isMac()) { + sshPort = ((MacData) amiType).getSshPort(); + } return Integer.parseInt(sshPort); } catch (NumberFormatException e) { return 22; @@ -660,15 +663,15 @@ public String getRemoteAdmin() { } public String getRootCommandPrefix() { - return amiType.isUnix() ? ((UnixData) amiType).getRootCommandPrefix() : ""; + return (amiType.isUnix() ? ((UnixData) amiType).getRootCommandPrefix() : (amiType.isMac() ? ((MacData) amiType).getRootCommandPrefix():"")); } public String getSlaveCommandPrefix() { - return amiType.isUnix() ? ((UnixData) amiType).getSlaveCommandPrefix() : ""; + return (amiType.isUnix() ? ((UnixData) amiType).getSlaveCommandPrefix() : (amiType.isMac() ? ((MacData) amiType).getSlaveCommandPrefix() : "")); } public String getSlaveCommandSuffix() { - return amiType.isUnix() ? ((UnixData) amiType).getSlaveCommandSuffix() : ""; + return (amiType.isUnix() ? ((UnixData) amiType).getSlaveCommandSuffix() : (amiType.isMac() ? ((MacData) amiType).getSlaveCommandSuffix() : "")); } public String chooseSubnetId() { @@ -1738,6 +1741,10 @@ public boolean isUnixSlave() { return amiType.isUnix(); } + public boolean isMacAgent() { + return amiType.isMac(); + } + public Secret getAdminPassword() { return amiType.isWindows() ? ((WindowsData) amiType).getPassword() : Secret.fromString(""); } diff --git a/src/main/java/hudson/plugins/ec2/UnixData.java b/src/main/java/hudson/plugins/ec2/UnixData.java index b46b0bc1d..2f61cce5d 100644 --- a/src/main/java/hudson/plugins/ec2/UnixData.java +++ b/src/main/java/hudson/plugins/ec2/UnixData.java @@ -42,6 +42,11 @@ public boolean isUnix() { return true; } + @Override + public boolean isMac() { + return false; + } + @Extension public static class DescriptorImpl extends Descriptor { @Override diff --git a/src/main/java/hudson/plugins/ec2/WindowsData.java b/src/main/java/hudson/plugins/ec2/WindowsData.java index 22cb2aa57..4eba6b25f 100644 --- a/src/main/java/hudson/plugins/ec2/WindowsData.java +++ b/src/main/java/hudson/plugins/ec2/WindowsData.java @@ -50,6 +50,11 @@ public boolean isUnix() { return false; } + @Override + public boolean isMac() { + return false; + } + public Secret getPassword() { return password; } diff --git a/src/main/java/hudson/plugins/ec2/ssh/EC2MacLauncher.java b/src/main/java/hudson/plugins/ec2/ssh/EC2MacLauncher.java new file mode 100644 index 000000000..4d5a90e44 --- /dev/null +++ b/src/main/java/hudson/plugins/ec2/ssh/EC2MacLauncher.java @@ -0,0 +1,462 @@ +/* + * 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 com.amazonaws.AmazonClientException; +import com.amazonaws.services.ec2.model.Instance; +import com.amazonaws.services.ec2.model.KeyPair; +import com.trilead.ssh2.*; +import hudson.FilePath; +import hudson.ProxyConfiguration; +import hudson.Util; +import hudson.model.Descriptor; +import hudson.model.TaskListener; +import hudson.plugins.ec2.*; +import hudson.plugins.ec2.ssh.verifiers.HostKey; +import hudson.plugins.ec2.ssh.verifiers.Messages; +import hudson.remoting.Channel; +import hudson.remoting.Channel.Listener; +import hudson.slaves.CommandLauncher; +import hudson.slaves.ComputerLauncher; +import jenkins.model.Jenkins; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; + +import java.io.*; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * {@link ComputerLauncher} that connects to a Unix slave on EC2 by using SSH. + * + * @author Kohsuke Kawaguchi + */ +public class EC2MacLauncher extends EC2ComputerLauncher { + + private static final Logger LOGGER = Logger.getLogger(EC2MacLauncher.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 final String READINESS_SLEEP_MS = "jenkins.ec2.readinessSleepMs"; + private static final String READINESS_TRIES= "jenkins.ec2.readinessTries"; + + private static int bootstrapAuthSleepMs = 30000; + private static int bootstrapAuthTries = 30; + + private static int readinessSleepMs = 1000; + private static int readinessTries = 120; + + 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); + prop = System.getProperty(READINESS_TRIES); + if (prop != null) + readinessTries = Integer.parseInt(prop); + prop = System.getProperty(READINESS_SLEEP_MS); + if (prop != null) + readinessSleepMs = Integer.parseInt(prop); + } + + protected void log(Level level, EC2Computer computer, TaskListener listener, String message) { + EC2Cloud.log(LOGGER, level, listener, message); + } + + protected void logException(EC2Computer computer, TaskListener listener, String message, Throwable exception) { + EC2Cloud.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) { + String remoteAdmin = computer.getRemoteAdmin(); + if (remoteAdmin != null && !remoteAdmin.equals("root")) { + command = computer.getRootCommandPrefix() + " " + command; + } + return command; + } + + @Override + protected void launchScript(EC2Computer computer, TaskListener listener) throws IOException, + AmazonClientException, InterruptedException { + 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(); + EC2AbstractSlave node = computer.getNode(); + SlaveTemplate template = computer.getSlaveTemplate(); + + if(node == null) { + throw new IllegalStateException(); + } + + if (template == null) { + throw new IOException("Could not find corresponding slave template for " + computer.getDisplayName()); + } + + if (node instanceof EC2Readiness) { + EC2Readiness readinessNode = (EC2Readiness) node; + int tries = readinessTries; + + while (tries-- > 0) { + if (readinessNode.isReady()) { + break; + } + + logInfo(computer, listener, "Node still not ready. Current status: " + readinessNode.getEc2ReadinessStatus()); + Thread.sleep(readinessSleepMs); + } + + if (!readinessNode.isReady()) { + throw new AmazonClientException("Node still not ready, timed out after " + (readinessTries * readinessSleepMs / 1000) + "s with status " + readinessNode.getEc2ReadinessStatus()); + } + } + + logInfo(computer, listener, "Launching instance: " + node.getInstanceId()); + + try { + boolean isBootstrapped = bootstrap(computer, listener, template); + if (isBootstrapped) { + // connect fresh as ROOT + logInfo(computer, listener, "connect fresh as root"); + cleanupConn = connectToSsh(computer, listener, template); + KeyPair key = computer.getCloud().getKeyPair(); + if (key == null || !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 = node.initScript; + String tmpDir = (Util.fixEmptyAndTrim(node.tmpDir) != null ? node.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(); + + logInfo(computer, listener, "Creating ~/.hudson-run-init"); + + // 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.getStdin().close(); // nothing to write here + sess.getStderr().close(); // we are not supposed to get anything + // from stderr + IOUtils.copy(sess.getStdout(), logger); + + exitStatus = waitCompletion(sess); + if (exitStatus != 0) { + logWarning(computer, listener, "init script failed: exit code=" + exitStatus); + return; + } + sess.close(); + } + + // TODO: parse the version number. maven-enforcer-plugin might help + executeRemote(computer, conn, "java -fullversion", "curl -L -O https://corretto.aws/downloads/latest/amazon-corretto-8-x64-macos-jdk.pkg; sudo installer -pkg amazon-corretto-8-x64-macos-jdk.pkg -target /", logger, listener); + + // Always copy so we get the most recent slave.jar + logInfo(computer, listener, "Copying remoting.jar to: " + tmpDir); + scp.put(Jenkins.get().getJnlpJars("remoting.jar").readFully(), "remoting.jar", tmpDir); + + final String jvmopts = node.jvmopts; + final String prefix = computer.getSlaveCommandPrefix(); + final String suffix = computer.getSlaveCommandSuffix(); + final String remoteFS = node.getRemoteFS(); + final String workDir = Util.fixEmptyAndTrim(remoteFS) != null ? remoteFS : tmpDir; + String launchString = prefix + " java " + (jvmopts != null ? jvmopts : "") + " -jar " + tmpDir + "/remoting.jar -workDir " + workDir + suffix; + // launchString = launchString.trim(); + + SlaveTemplate slaveTemplate = computer.getSlaveTemplate(); + + if (slaveTemplate != null && slaveTemplate.isConnectBySSHProcess()) { + File identityKeyFile = createIdentityKeyFile(computer); + + try { + // Obviously the master must have an installed ssh client. + // Depending on the strategy selected on the UI, we set the StrictHostKeyChecking flag + String sshClientLaunchString = String.format("ssh -o StrictHostKeyChecking=%s -i %s %s@%s -p %d %s", slaveTemplate.getHostKeyVerificationStrategy().getSshCommandEquivalentFlag(), identityKeyFile.getAbsolutePath(), node.remoteAdmin, getEC2HostAddress(computer, template), node.getSshPort(), launchString); + + logInfo(computer, listener, "Launching remoting agent (via SSH client process): " + sshClientLaunchString); + CommandLauncher commandLauncher = new CommandLauncher(sshClientLaunchString, null); + commandLauncher.launch(computer, listener); + } finally { + if(!identityKeyFile.delete()) { + LOGGER.log(Level.WARNING, "Failed to delete identity key file"); + } + } + } else { + logInfo(computer, listener, "Launching remoting 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 boolean executeRemote(EC2Computer computer, Connection conn, String checkCommand, String command, PrintStream logger, TaskListener listener) + throws IOException, InterruptedException { + logInfo(computer, listener,"Verifying: " + checkCommand); + if (conn.exec(checkCommand, logger) != 0) { + logInfo(computer, listener, "Installing: " + command); + if (conn.exec(command, logger) != 0) { + logWarning(computer, listener, "Failed to install: " + command); + return false; + } + } + return true; + } + + private File createIdentityKeyFile(EC2Computer computer) throws IOException { + EC2PrivateKey ec2PrivateKey = computer.getCloud().resolvePrivateKey(); + String privateKey = ""; + if (ec2PrivateKey != null){ + privateKey = ec2PrivateKey.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) { + if (!tempFile.delete()) { + LOGGER.log(Level.WARNING, "Failed to delete identity key file"); + } + throw new IOException("Error creating temporary identity key file for connecting to EC2 agent.", e); + } + } + + private boolean bootstrap(EC2Computer computer, TaskListener listener, SlaveTemplate template) 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(); + if (key == null){ + logWarning(computer, listener, "Could not retrieve a valid key pair."); + return false; + } + logInfo(computer, listener, + String.format("Using private key %s (SHA-1 fingerprint %s)", key.getKeyName(), key.getKeyFingerprint())); + while (tries-- > 0) { + logInfo(computer, listener, "Authenticating as " + computer.getRemoteAdmin()); + try { + bootstrapConn = connectToSsh(computer, listener, template); + 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 { + if (bootstrapConn != null) { + bootstrapConn.close(); + } + } + return true; + } + + private Connection connectToSsh(EC2Computer computer, TaskListener listener, SlaveTemplate template) throws AmazonClientException, + InterruptedException { + final EC2AbstractSlave node = computer.getNode(); + final long timeout = node == null ? 0L : node.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) + ")"); + } + String host = getEC2HostAddress(computer, template); + + if ((node instanceof EC2SpotSlave) && computer.getInstanceId() == null) { + // getInstanceId() on EC2SpotSlave can return null if the spot request doesn't yet know + // the instance id that it is starting. Continue to wait until the instanceId is set. + logInfo(computer, listener, "empty instanceId for Spot Slave."); + throw new IOException("goto sleep"); + } + + 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.get().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"); + } + + conn.connect(new ServerHostKeyVerifierImpl(computer, listener), 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()); + + // If the computer was set offline because it's not trusted, we avoid persisting in connecting to it. + // The computer is offline for a long period + if (computer.isOffline() && StringUtils.isNotBlank(computer.getOfflineCauseReason()) && computer.getOfflineCauseReason().equals(Messages.OfflineCause_SSHKeyCheckFailed())) { + throw new AmazonClientException("The connection couldn't be established and the computer is now offline", e); + } else { + logInfo(computer, listener, "Waiting for SSH to come up. Sleeping 5."); + Thread.sleep(5000); + } + } + } + } + + /** + * Our host key verifier just pick up the right strategy and call its verify method. + */ + private static class ServerHostKeyVerifierImpl implements ServerHostKeyVerifier { + + private final EC2Computer computer; + private final TaskListener listener; + + public ServerHostKeyVerifierImpl(final EC2Computer computer, final TaskListener listener) { + this.computer = computer; + this.listener = listener; + } + + @Override + public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm, byte[] serverHostKey) throws Exception { + SlaveTemplate template = computer.getSlaveTemplate(); + return template != null && template.getHostKeyVerificationStrategy().getStrategy().verify(computer, new HostKey(serverHostKeyAlgorithm, serverHostKey), listener); + } + } + + private static String getEC2HostAddress(EC2Computer computer, SlaveTemplate template) throws InterruptedException { + Instance instance = computer.updateInstanceDescription(); + ConnectionStrategy strategy = template.connectionStrategy; + return EC2HostAddressProvider.unix(instance, strategy); + } + + 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 getDescriptor() { + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/hudson/plugins/ec2/ssh/verifiers/SshHostKeyVerificationAdministrativeMonitor.java b/src/main/java/hudson/plugins/ec2/ssh/verifiers/SshHostKeyVerificationAdministrativeMonitor.java index 07536e2f2..57e49f11f 100644 --- a/src/main/java/hudson/plugins/ec2/ssh/verifiers/SshHostKeyVerificationAdministrativeMonitor.java +++ b/src/main/java/hudson/plugins/ec2/ssh/verifiers/SshHostKeyVerificationAdministrativeMonitor.java @@ -110,7 +110,7 @@ private boolean gatherInsecureTemplate(EC2Cloud cloud) { List templates = cloud.getTemplates(); for (SlaveTemplate template : templates) { // It's only for unix templates - if (!template.isUnixSlave()) { + if (!template.isUnixSlave() || !template.isMacAgent()) { continue; } diff --git a/src/main/resources/hudson/plugins/ec2/MacData/config.jelly b/src/main/resources/hudson/plugins/ec2/MacData/config.jelly new file mode 100644 index 000000000..0c9bd8c35 --- /dev/null +++ b/src/main/resources/hudson/plugins/ec2/MacData/config.jelly @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/hudson/plugins/ec2/ConfigurationAsCodeTest.java b/src/test/java/hudson/plugins/ec2/ConfigurationAsCodeTest.java index 108514a5c..eb8b94f67 100644 --- a/src/test/java/hudson/plugins/ec2/ConfigurationAsCodeTest.java +++ b/src/test/java/hudson/plugins/ec2/ConfigurationAsCodeTest.java @@ -193,4 +193,66 @@ public void testAmi() throws Exception { expectedFilters = Collections.emptyList(); assertEquals(expectedFilters, slaveTemplate.getAmiFilters()); } + + @Test + @ConfiguredWithCode("MacData.yml") + public void testMacData() throws Exception { + final AmazonEC2Cloud ec2Cloud = (AmazonEC2Cloud) Jenkins.get().getCloud("ec2-production"); + assertNotNull(ec2Cloud); + assertTrue(ec2Cloud.isUseInstanceProfileForCredentials()); + + final List templates = ec2Cloud.getTemplates(); + assertEquals(1, templates.size()); + final SlaveTemplate slaveTemplate = templates.get(0); + assertEquals("ami-12345", slaveTemplate.getAmi()); + assertEquals("/Users/ec2-user", slaveTemplate.remoteFS); + + assertEquals("mac metal", slaveTemplate.getLabelString()); + assertEquals(2, slaveTemplate.getLabelSet().size()); + + assertTrue(ec2Cloud.canProvision(new LabelAtom("metal"))); + assertTrue(ec2Cloud.canProvision(new LabelAtom("mac"))); + + final AMITypeData amiType = slaveTemplate.getAmiType(); + assertTrue(amiType.isMac()); + assertTrue(amiType instanceof MacData); + final MacData macData = (MacData) amiType; + assertEquals("sudo", macData.getRootCommandPrefix()); + assertEquals("sudo -u jenkins", macData.getSlaveCommandPrefix()); + assertEquals("-fakeFlag", macData.getSlaveCommandSuffix()); + assertEquals("22", macData.getSshPort()); + } + + @Test + @ConfiguredWithCode("Mac.yml") + public void testMac() throws Exception { + final AmazonEC2Cloud ec2Cloud = (AmazonEC2Cloud) Jenkins.get().getCloud("ec2-staging"); + assertNotNull(ec2Cloud); + assertTrue(ec2Cloud.isUseInstanceProfileForCredentials()); + + final List templates = ec2Cloud.getTemplates(); + assertEquals(1, templates.size()); + final SlaveTemplate slaveTemplate = templates.get(0); + assertEquals("ami-5678", slaveTemplate.getAmi()); + assertEquals("/Users/jenkins", slaveTemplate.remoteFS); + + assertEquals("mac clear", slaveTemplate.getLabelString()); + assertEquals(2, slaveTemplate.getLabelSet().size()); + + assertTrue(ec2Cloud.canProvision(new LabelAtom("clear"))); + assertTrue(ec2Cloud.canProvision(new LabelAtom("mac"))); + + assertEquals(null, slaveTemplate.spotConfig); + } + + @Test + @ConfiguredWithCode("MacData.yml") + public void testMacCloudConfigAsCodeExport() throws Exception { + ConfiguratorRegistry registry = ConfiguratorRegistry.get(); + ConfigurationContext context = new ConfigurationContext(registry); + CNode clouds = getJenkinsRoot(context).get("clouds"); + String exported = toYamlString(clouds); + String expected = toStringFromYamlFile(this, "MacDataExport.yml"); + assertEquals(expected, exported); + } } diff --git a/src/test/java/hudson/plugins/ec2/SlaveTemplateTest.java b/src/test/java/hudson/plugins/ec2/SlaveTemplateTest.java index 3d936f9fc..fc9790e33 100644 --- a/src/test/java/hudson/plugins/ec2/SlaveTemplateTest.java +++ b/src/test/java/hudson/plugins/ec2/SlaveTemplateTest.java @@ -645,4 +645,28 @@ public void testTenancy() throws Exception { SlaveTemplate received = ((EC2Cloud) r.jenkins.clouds.iterator().next()).getTemplate(description); r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,useEphemeralDevices,connectionStrategy,hostKeyVerificationStrategy,tenancy"); } + + @Test + public void testMacConfig() throws Exception { + String ami = "ami1"; + String description = "foo ami"; + + EC2Tag tag1 = new EC2Tag("name1", "value1"); + EC2Tag tag2 = new EC2Tag("name2", "value2"); + List tags = new ArrayList(); + tags.add(tag1); + tags.add(tag2); + + SlaveTemplate orig = new SlaveTemplate("ami-123", EC2AbstractSlave.TEST_ZONE, null, "default", "foo", InstanceType.Mac1Metal, false, "ttt", Node.Mode.NORMAL, description, "bar", "bbb", "aaa", "10", "fff", null, "-Xmx1g", false, "subnet 456", null, null, 0, 0, null, "", true, false, "", false, "", false, false, false, ConnectionStrategy.PUBLIC_IP, -1, null, null, Tenancy.Default); + + List templates = new ArrayList(); + templates.add(orig); + + AmazonEC2Cloud ac = new AmazonEC2Cloud("us-east-1", false, "abc", "us-east-1", "ghi", "3", templates, null, null); + r.jenkins.clouds.add(ac); + + r.submit(r.createWebClient().goTo("configure").getFormByName("config")); + SlaveTemplate received = ((EC2Cloud) r.jenkins.clouds.iterator().next()).getTemplate(description); + r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,useEphemeralDevices,connectionStrategy,hostKeyVerificationStrategy,tenancy"); + } } diff --git a/src/test/resources/hudson/plugins/ec2/Mac.yml b/src/test/resources/hudson/plugins/ec2/Mac.yml new file mode 100644 index 000000000..9795f3d88 --- /dev/null +++ b/src/test/resources/hudson/plugins/ec2/Mac.yml @@ -0,0 +1,14 @@ +--- +jenkins: + clouds: + - amazonEC2: + cloudName: "staging" + useInstanceProfileForCredentials: true + privateKey: "${PRIVATE_KEY}" + templates: + - description: + ami: "ami-5678" + labelString: "mac clear" + type: "Mac1Metal" + remoteFS: "/Users/jenkins" + mode: "NORMAL" diff --git a/src/test/resources/hudson/plugins/ec2/MacData.yml b/src/test/resources/hudson/plugins/ec2/MacData.yml new file mode 100644 index 000000000..c63a7fe6b --- /dev/null +++ b/src/test/resources/hudson/plugins/ec2/MacData.yml @@ -0,0 +1,21 @@ +--- +jenkins: + clouds: + - amazonEC2: + cloudName: "production" + useInstanceProfileForCredentials: true + sshKeysCredentialsId: "random credentials id" + templates: + - description: + ami: "ami-12345" + labelString: "mac metal" + type: "Mac1Metal" + remoteFS: "/Users/ec2-user" + mode: "NORMAL" + tenancy: Host + amiType: + macData: + rootCommandPrefix: "sudo" + slaveCommandPrefix: "sudo -u jenkins" + slaveCommandSuffix: "-fakeFlag" + sshPort: "22" diff --git a/src/test/resources/hudson/plugins/ec2/MacDataExport.yml b/src/test/resources/hudson/plugins/ec2/MacDataExport.yml new file mode 100644 index 000000000..55b4c9f61 --- /dev/null +++ b/src/test/resources/hudson/plugins/ec2/MacDataExport.yml @@ -0,0 +1,32 @@ +- amazonEC2: + cloudName: "production" + region: "us-east-1" + sshKeysCredentialsId: "random credentials id" + templates: + - ami: "ami-12345" + amiType: + macData: + rootCommandPrefix: "sudo" + slaveCommandPrefix: "sudo -u jenkins" + slaveCommandSuffix: "-fakeFlag" + sshPort: "22" + associatePublicIp: false + connectBySSHProcess: false + connectionStrategy: PRIVATE_IP + deleteRootOnTermination: false + ebsOptimized: false + hostKeyVerificationStrategy: CHECK_NEW_SOFT + labelString: "mac metal" + maxTotalUses: -1 + minimumNumberOfInstances: 0 + minimumNumberOfSpareInstances: 0 + mode: NORMAL + monitoring: false + numExecutors: 1 + remoteFS: "/Users/ec2-user" + stopOnTerminate: false + t2Unlimited: false + tenancy: Host + type: Mac1Metal + useEphemeralDevices: false + useInstanceProfileForCredentials: true