diff --git a/pom.xml b/pom.xml
index e7472779c..2987d1d8d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -121,6 +121,10 @@ THE SOFTWARE.
io.jenkins.plugins.aws-java-sdk2
aws-java-sdk2-ec2
+
+ io.jenkins.plugins.aws-java-sdk2
+ aws-java-sdk2-ssm
+
io.jenkins.plugins.mina-sshd-api
mina-sshd-api-core
diff --git a/src/main/java/hudson/plugins/ec2/EC2Cloud.java b/src/main/java/hudson/plugins/ec2/EC2Cloud.java
index a329cb6a4..584b375b6 100644
--- a/src/main/java/hudson/plugins/ec2/EC2Cloud.java
+++ b/src/main/java/hudson/plugins/ec2/EC2Cloud.java
@@ -46,6 +46,7 @@
import hudson.model.PeriodicWork;
import hudson.model.TaskListener;
import hudson.plugins.ec2.util.AmazonEC2Factory;
+import hudson.plugins.ec2.util.AmazonSSMFactory;
import hudson.plugins.ec2.util.FIPS140Utils;
import hudson.plugins.ec2.util.KeyPair;
import hudson.security.ACL;
@@ -136,6 +137,7 @@
import software.amazon.awssdk.services.ec2.model.SpotInstanceRequest;
import software.amazon.awssdk.services.ec2.model.SpotInstanceState;
import software.amazon.awssdk.services.ec2.model.Tag;
+import software.amazon.awssdk.services.ssm.SsmClient;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.services.sts.StsClientBuilder;
import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
@@ -211,6 +213,8 @@ public class EC2Cloud extends Cloud {
private boolean noDelayProvisioning;
+ private boolean useSSM;
+
private boolean cleanUpOrphanedNodes;
private transient volatile Ec2Client connection;
@@ -299,6 +303,9 @@ protected EC2Cloud(
@CheckForNull
public EC2PrivateKey resolvePrivateKey() {
+ if (useSSM) {
+ return null;
+ }
if (!System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {
LOGGER.fine(() -> "(resolvePrivateKey) secret key file configured, will load from disk");
return EC2PrivateKey.fetchFromDisk();
@@ -391,6 +398,15 @@ public void setNoDelayProvisioning(boolean noDelayProvisioning) {
this.noDelayProvisioning = noDelayProvisioning;
}
+ public boolean isUseSSM() {
+ return useSSM;
+ }
+
+ @DataBoundSetter
+ public void setUseSSM(boolean useSSM) {
+ this.useSSM = useSSM;
+ }
+
public boolean isCleanUpOrphanedNodes() {
return cleanUpOrphanedNodes;
}
@@ -640,6 +656,9 @@ public Collection getTemplates(Label label) {
*/
@CheckForNull
public synchronized KeyPair getKeyPair() throws SdkException, IOException {
+ if (useSSM) {
+ return null;
+ }
if (usableKeyPair == null) {
EC2PrivateKey ec2PrivateKey = this.resolvePrivateKey();
if (ec2PrivateKey != null) {
@@ -1334,6 +1353,11 @@ public Ec2Client connect() throws SdkException {
}
}
+ public SsmClient createSsmClient() {
+ return AmazonSSMFactory.getInstance()
+ .connect(createCredentialsProvider(), parseRegion(getRegion()), parseEndpoint(getAltEC2Endpoint()));
+ }
+
public static SdkHttpClient getHttpClient() {
Jenkins instance = Jenkins.getInstanceOrNull();
@@ -1463,12 +1487,17 @@ public FormValidation doCheckRoleSessionName(
@RequirePOST
public FormValidation doCheckSshKeysCredentialsId(
- @AncestorInPath ItemGroup context, @QueryParameter String value) throws IOException, ServletException {
+ @AncestorInPath ItemGroup context, @QueryParameter String value, @QueryParameter boolean useSSM)
+ throws IOException, ServletException {
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
// Don't do anything if the user is only reading the configuration
return FormValidation.ok();
}
+ if (useSSM) {
+ return FormValidation.ok();
+ }
+
String privateKey;
List validations = new ArrayList<>();
@@ -1559,7 +1588,8 @@ public FormValidation doTestConnection(
@QueryParameter String credentialsId,
@QueryParameter String sshKeysCredentialsId,
@QueryParameter String roleArn,
- @QueryParameter String roleSessionName)
+ @QueryParameter String roleSessionName,
+ @QueryParameter boolean useSSM)
throws IOException, ServletException {
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
return FormValidation.ok();
@@ -1568,30 +1598,6 @@ public FormValidation doTestConnection(
List validations = new ArrayList<>();
LOGGER.fine(() -> "begin doTestConnection()");
- String privateKey = "";
- if (System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {
- LOGGER.fine(() -> "static credential is in use");
- SSHUserPrivateKey sshCredential = getSshCredential(sshKeysCredentialsId, context);
- if (sshCredential != null) {
- privateKey = sshCredential.getPrivateKey();
- } else {
- return FormValidation.error(
- "Failed to find credential \"" + sshKeysCredentialsId + "\" in store.");
- }
- } else {
- EC2PrivateKey k = EC2PrivateKey.fetchFromDisk();
- if (k == null) {
- validations.add(FormValidation.error(
- "Failed to find private key file " + System.getProperty(SSH_PRIVATE_KEY_FILEPATH)));
- if (!StringUtils.isEmpty(sshKeysCredentialsId)) {
- validations.add(FormValidation.warning(
- "Private key file path defined, selected credential will be ignored"));
- }
- return FormValidation.aggregate(validations);
- }
- privateKey = k.getPrivateKey();
- }
- LOGGER.fine(() -> "private key found ok");
if (Util.fixEmpty(region) == null) {
region = DEFAULT_EC2_HOST;
@@ -1603,29 +1609,58 @@ public FormValidation doTestConnection(
.connect(credentialsProvider, parseRegion(region), parseEndpoint(altEC2Endpoint));
ec2.describeInstances();
- if (!privateKey.trim().isEmpty()) {
- // check if this key exists
- EC2PrivateKey pk = new EC2PrivateKey(privateKey);
- if (pk.find(ec2) == null) {
- validations.add(FormValidation.error(
- "The EC2 key pair private key isn't registered to this EC2 region (fingerprint is "
- + pk.getFingerprint() + ")"));
+ if (useSSM) {
+ validations.add(FormValidation.ok("SSM connection mode selected. SSH key validation skipped."));
+ } else {
+ String privateKey = "";
+ if (System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {
+ LOGGER.fine(() -> "static credential is in use");
+ SSHUserPrivateKey sshCredential = getSshCredential(sshKeysCredentialsId, context);
+ if (sshCredential != null) {
+ privateKey = sshCredential.getPrivateKey();
+ } else {
+ return FormValidation.error(
+ "Failed to find credential \"" + sshKeysCredentialsId + "\" in store.");
+ }
+ } else {
+ EC2PrivateKey k = EC2PrivateKey.fetchFromDisk();
+ if (k == null) {
+ validations.add(FormValidation.error(
+ "Failed to find private key file " + System.getProperty(SSH_PRIVATE_KEY_FILEPATH)));
+ if (!StringUtils.isEmpty(sshKeysCredentialsId)) {
+ validations.add(FormValidation.warning(
+ "Private key file path defined, selected credential will be ignored"));
+ }
+ return FormValidation.aggregate(validations);
+ }
+ privateKey = k.getPrivateKey();
+ }
+ LOGGER.fine(() -> "private key found ok");
+
+ if (!privateKey.trim().isEmpty()) {
+ // check if this key exists
+ EC2PrivateKey pk = new EC2PrivateKey(privateKey);
+ if (pk.find(ec2) == null) {
+ validations.add(FormValidation.error(
+ "The EC2 key pair private key isn't registered to this EC2 region (fingerprint is "
+ + pk.getFingerprint() + ")"));
+ }
}
- }
- if (!System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {
- if (!StringUtils.isEmpty(sshKeysCredentialsId)) {
- validations.add(
- FormValidation.warning("Using private key file instead of selected credential"));
- } else {
- validations.add(FormValidation.ok("Using private key file"));
+ if (!System.getProperty(SSH_PRIVATE_KEY_FILEPATH, "").isEmpty()) {
+ if (!StringUtils.isEmpty(sshKeysCredentialsId)) {
+ validations.add(
+ FormValidation.warning("Using private key file instead of selected credential"));
+ } else {
+ validations.add(FormValidation.ok("Using private key file"));
+ }
}
- }
- try {
- FIPS140Utils.ensurePrivateKeyInFipsMode(privateKey);
- } catch (IllegalArgumentException ex) {
- validations.add(FormValidation.error(ex, ex.getLocalizedMessage()));
+ try {
+ FIPS140Utils.ensurePrivateKeyInFipsMode(privateKey);
+ } catch (IllegalArgumentException ex) {
+ validations.add(FormValidation.error(ex, ex.getLocalizedMessage()));
+ }
}
validations.add(FormValidation.ok(Messages.EC2Cloud_Success()));
diff --git a/src/main/java/hudson/plugins/ec2/EC2OndemandSlave.java b/src/main/java/hudson/plugins/ec2/EC2OndemandSlave.java
index 1d8b51b57..eacbb2977 100644
--- a/src/main/java/hudson/plugins/ec2/EC2OndemandSlave.java
+++ b/src/main/java/hudson/plugins/ec2/EC2OndemandSlave.java
@@ -7,6 +7,7 @@
import hudson.plugins.ec2.ssh.EC2MacLauncher;
import hudson.plugins.ec2.ssh.EC2UnixLauncher;
import hudson.plugins.ec2.ssh.EC2WindowsSSHLauncher;
+import hudson.plugins.ec2.ssm.EC2SSMLauncher;
import hudson.plugins.ec2.win.EC2WindowsLauncher;
import hudson.slaves.NodeProperty;
import java.io.IOException;
@@ -433,6 +434,71 @@ public EC2OndemandSlave(
Boolean metadataSupported,
Boolean enclaveEnabled)
throws FormException, IOException {
+ this(
+ name,
+ instanceId,
+ templateDescription,
+ remoteFS,
+ numExecutors,
+ labelString,
+ mode,
+ initScript,
+ tmpDir,
+ nodeProperties,
+ remoteAdmin,
+ javaPath,
+ jvmopts,
+ stopOnTerminate,
+ idleTerminationMinutes,
+ publicDNS,
+ privateDNS,
+ tags,
+ cloudName,
+ launchTimeout,
+ amiType,
+ connectionStrategy,
+ maxTotalUses,
+ tenancy,
+ metadataEndpointEnabled,
+ metadataTokensRequired,
+ metadataHopsLimit,
+ metadataSupported,
+ enclaveEnabled,
+ false);
+ }
+
+ public EC2OndemandSlave(
+ String name,
+ String instanceId,
+ String templateDescription,
+ String remoteFS,
+ int numExecutors,
+ String labelString,
+ Mode mode,
+ String initScript,
+ String tmpDir,
+ List extends NodeProperty>> nodeProperties,
+ String remoteAdmin,
+ String javaPath,
+ String jvmopts,
+ boolean stopOnTerminate,
+ String idleTerminationMinutes,
+ String publicDNS,
+ String privateDNS,
+ List tags,
+ String cloudName,
+ int launchTimeout,
+ AMITypeData amiType,
+ ConnectionStrategy connectionStrategy,
+ int maxTotalUses,
+ Tenancy tenancy,
+ Boolean metadataEndpointEnabled,
+ Boolean metadataTokensRequired,
+ Integer metadataHopsLimit,
+ Boolean metadataSupported,
+ Boolean enclaveEnabled,
+ boolean useSSM)
+ throws FormException, IOException {
super(
name,
instanceId,
@@ -441,11 +507,13 @@ public EC2OndemandSlave(
numExecutors,
mode,
labelString,
- (amiType.isWinRMAgent()
- ? new EC2WindowsLauncher()
- : (amiType.isWindows()
- ? new EC2WindowsSSHLauncher()
- : (amiType.isMac() ? new EC2MacLauncher() : new EC2UnixLauncher()))),
+ useSSM
+ ? new EC2SSMLauncher()
+ : (amiType.isWinRMAgent()
+ ? new EC2WindowsLauncher()
+ : (amiType.isWindows()
+ ? new EC2WindowsSSHLauncher()
+ : (amiType.isMac() ? new EC2MacLauncher() : new EC2UnixLauncher()))),
new EC2RetentionStrategy(idleTerminationMinutes),
initScript,
tmpDir,
diff --git a/src/main/java/hudson/plugins/ec2/EC2SpotSlave.java b/src/main/java/hudson/plugins/ec2/EC2SpotSlave.java
index 7256dfe57..c06ac7e56 100644
--- a/src/main/java/hudson/plugins/ec2/EC2SpotSlave.java
+++ b/src/main/java/hudson/plugins/ec2/EC2SpotSlave.java
@@ -6,6 +6,7 @@
import hudson.model.Descriptor.FormException;
import hudson.plugins.ec2.ssh.EC2UnixLauncher;
import hudson.plugins.ec2.ssh.EC2WindowsSSHLauncher;
+import hudson.plugins.ec2.ssm.EC2SSMLauncher;
import hudson.plugins.ec2.win.EC2WindowsLauncher;
import hudson.slaves.NodeProperty;
import java.io.IOException;
@@ -137,6 +138,53 @@ public EC2SpotSlave(
ConnectionStrategy connectionStrategy,
int maxTotalUses)
throws FormException, IOException {
+ this(
+ name,
+ spotInstanceRequestId,
+ templateDescription,
+ remoteFS,
+ numExecutors,
+ mode,
+ initScript,
+ tmpDir,
+ labelString,
+ nodeProperties,
+ remoteAdmin,
+ javaPath,
+ jvmopts,
+ idleTerminationMinutes,
+ tags,
+ cloudName,
+ launchTimeout,
+ amiType,
+ connectionStrategy,
+ maxTotalUses,
+ false);
+ }
+
+ public EC2SpotSlave(
+ String name,
+ String spotInstanceRequestId,
+ String templateDescription,
+ String remoteFS,
+ int numExecutors,
+ Mode mode,
+ String initScript,
+ String tmpDir,
+ String labelString,
+ List extends NodeProperty>> nodeProperties,
+ String remoteAdmin,
+ String javaPath,
+ String jvmopts,
+ String idleTerminationMinutes,
+ List tags,
+ String cloudName,
+ int launchTimeout,
+ AMITypeData amiType,
+ ConnectionStrategy connectionStrategy,
+ int maxTotalUses,
+ boolean useSSM)
+ throws FormException, IOException {
super(
name,
@@ -146,9 +194,11 @@ public EC2SpotSlave(
numExecutors,
mode,
labelString,
- (amiType.isWinRMAgent()
- ? new EC2WindowsLauncher()
- : (amiType.isWindows() ? new EC2WindowsSSHLauncher() : new EC2UnixLauncher())),
+ useSSM
+ ? new EC2SSMLauncher()
+ : (amiType.isWinRMAgent()
+ ? new EC2WindowsLauncher()
+ : (amiType.isWindows() ? new EC2WindowsSSHLauncher() : new EC2UnixLauncher())),
new EC2RetentionStrategy(idleTerminationMinutes),
initScript,
tmpDir,
diff --git a/src/main/java/hudson/plugins/ec2/SlaveTemplate.java b/src/main/java/hudson/plugins/ec2/SlaveTemplate.java
index aeb8e5c4f..9acea3576 100644
--- a/src/main/java/hudson/plugins/ec2/SlaveTemplate.java
+++ b/src/main/java/hudson/plugins/ec2/SlaveTemplate.java
@@ -2181,17 +2181,19 @@ HashMap> makeRunInstancesRequestAndFilters(
diFilters.add(Filter.builder().name("image-id").values(imageId).build());
diFilters.add(Filter.builder().name("instance-type").values(type).build());
- KeyPair keyPair = getKeyPair(ec2);
- if (keyPair == null) {
- logProvisionInfo("Could not retrieve a valid key pair.");
- return null;
- }
riRequestBuilder.userData(Base64.getEncoder().encodeToString(userData.getBytes(StandardCharsets.UTF_8)));
- riRequestBuilder.keyName(keyPair.getKeyPairInfo().keyName());
- diFilters.add(Filter.builder()
- .name("key-name")
- .values(keyPair.getKeyPairInfo().keyName())
- .build());
+ if (!getParent().isUseSSM()) {
+ KeyPair keyPair = getKeyPair(ec2);
+ if (keyPair == null) {
+ logProvisionInfo("Could not retrieve a valid key pair.");
+ return null;
+ }
+ riRequestBuilder.keyName(keyPair.getKeyPairInfo().keyName());
+ diFilters.add(Filter.builder()
+ .name("key-name")
+ .values(keyPair.getKeyPairInfo().keyName())
+ .build());
+ }
Placement.Builder placementBuilder = Placement.builder();
if (StringUtils.isNotBlank(getZone())) {
@@ -2657,7 +2659,7 @@ private List provisionSpot(Image image, int number, EnumSet provisionSpot(Image image, int number, EnumSet 0) {
+ if (readinessNode.isReady()) {
+ break;
+ }
+ logInfo(computer, listener, "EC2 node not yet ready. Status: " + readinessNode.getEc2ReadinessStatus());
+ Thread.sleep(readinessSleepMs);
+ }
+
+ if (!readinessNode.isReady()) {
+ throw SdkException.builder()
+ .message("EC2 node not ready after " + (readinessTries * readinessSleepMs / 1000)
+ + "s. Status: " + readinessNode.getEc2ReadinessStatus())
+ .build();
+ }
+ }
+ }
+
+ private void waitForSSMReadiness(
+ SsmClient ssmClient, String instanceId, EC2Computer computer, TaskListener listener)
+ throws InterruptedException {
+ logInfo(computer, listener, "Waiting for SSM agent to register instance: " + instanceId);
+
+ for (int i = 0; i < readinessTries; i++) {
+ try {
+ DescribeInstanceInformationResponse response =
+ ssmClient.describeInstanceInformation(DescribeInstanceInformationRequest.builder()
+ .instanceInformationFilterList(InstanceInformationFilter.builder()
+ .key(InstanceInformationFilterKey.INSTANCE_IDS)
+ .valueSet(instanceId)
+ .build())
+ .build());
+
+ if (!response.instanceInformationList().isEmpty()) {
+ String pingStatus =
+ response.instanceInformationList().get(0).pingStatusAsString();
+ if ("Online".equals(pingStatus)) {
+ logInfo(computer, listener, "SSM agent is online for instance: " + instanceId);
+ return;
+ }
+ logInfo(
+ computer,
+ listener,
+ "SSM agent registered but ping status is: " + pingStatus + ". Waiting...");
+ } else {
+ logInfo(
+ computer,
+ listener,
+ "SSM agent not yet registered for instance: " + instanceId + ". Attempt " + (i + 1) + "/"
+ + readinessTries);
+ }
+ } catch (SsmException e) {
+ LOGGER.log(Level.FINE, "SSM describe error (will retry): " + e.getMessage());
+ }
+ Thread.sleep(readinessSleepMs);
+ }
+
+ throw SdkException.builder()
+ .message("SSM agent did not become ready for instance " + instanceId + " after "
+ + (readinessTries * readinessSleepMs / 1000) + "s")
+ .build();
+ }
+
+ private void runInitScript(
+ SsmClient ssmClient, String instanceId, EC2AbstractSlave node, EC2Computer computer, TaskListener listener)
+ throws InterruptedException, IOException {
+ String initScript = node.initScript;
+
+ if (StringUtils.isBlank(initScript)) {
+ logInfo(computer, listener, "No init script to run");
+ return;
+ }
+
+ // Check if init script has already been run
+ String checkResult =
+ runSSMCommand(ssmClient, instanceId, "test -e ~/.hudson-run-init && echo 'EXISTS' || echo 'NOT_FOUND'");
+ if (checkResult != null && checkResult.trim().contains("EXISTS")) {
+ logInfo(computer, listener, "Init script already executed (marker file exists)");
+ return;
+ }
+
+ logInfo(computer, listener, "Running init script via SSM SendCommand");
+
+ String commandId = sendSSMCommand(ssmClient, instanceId, initScript, computer, listener);
+ waitForCommand(ssmClient, instanceId, commandId, computer, listener);
+
+ GetCommandInvocationResponse result = ssmClient.getCommandInvocation(GetCommandInvocationRequest.builder()
+ .commandId(commandId)
+ .instanceId(instanceId)
+ .build());
+
+ if (result.responseCode() != 0) {
+ String output = result.standardOutputContent();
+ String error = result.standardErrorContent();
+ logWarning(computer, listener, "Init script failed with exit code: " + result.responseCode());
+ if (StringUtils.isNotBlank(output)) {
+ logInfo(computer, listener, "Init script stdout: " + output);
+ }
+ if (StringUtils.isNotBlank(error)) {
+ logWarning(computer, listener, "Init script stderr: " + error);
+ }
+ throw new IOException("Init script failed with exit code: " + result.responseCode());
+ }
+
+ logInfo(computer, listener, "Init script completed successfully");
+
+ // Set marker file
+ sendSSMCommandAndWait(ssmClient, instanceId, "touch ~/.hudson-run-init", computer, listener);
+ }
+
+ private void setupRemoting(
+ SsmClient ssmClient,
+ String instanceId,
+ String tmpDir,
+ String javaPath,
+ EC2Computer computer,
+ TaskListener listener)
+ throws InterruptedException, IOException {
+
+ // Create tmp directory
+ logInfo(computer, listener, "Creating tmp directory: " + tmpDir);
+ sendSSMCommandAndWait(ssmClient, instanceId, "mkdir -p " + tmpDir, computer, listener);
+
+ // Install Java if needed
+ logInfo(computer, listener, "Checking Java availability");
+ String javaCheck = javaPath + " -fullversion 2>&1 || echo 'JAVA_NOT_FOUND'";
+ String javaResult = runSSMCommand(ssmClient, instanceId, javaCheck);
+ if (javaResult != null && javaResult.contains("JAVA_NOT_FOUND")) {
+ logInfo(computer, listener, "Java not found, attempting to install");
+ sendSSMCommandAndWait(
+ ssmClient,
+ instanceId,
+ "sudo amazon-linux-extras install java-openjdk11 -y 2>/dev/null; sudo yum install -y fontconfig java-11-openjdk 2>/dev/null || true",
+ computer,
+ listener);
+ }
+
+ // Download remoting.jar
+ String jenkinsUrl = JenkinsLocationConfiguration.get().getUrl();
+ if (jenkinsUrl == null) {
+ throw new IOException("Jenkins URL is not configured. Please set the Jenkins URL in system configuration.");
+ }
+ // Ensure URL ends with /
+ if (!jenkinsUrl.endsWith("/")) {
+ jenkinsUrl += "/";
+ }
+
+ logInfo(computer, listener, "Downloading agent.jar to " + tmpDir);
+ String downloadCommand =
+ "curl -sO " + jenkinsUrl + "jnlpJars/agent.jar && mv agent.jar " + tmpDir + "/agent.jar";
+ sendSSMCommandAndWait(ssmClient, instanceId, downloadCommand, computer, listener);
+ }
+
+ private void launchRemotingAgent(
+ SsmClient ssmClient,
+ String instanceId,
+ String tmpDir,
+ String javaPath,
+ String jvmopts,
+ EC2Computer computer,
+ TaskListener listener)
+ throws IOException, InterruptedException {
+
+ String jenkinsUrl = JenkinsLocationConfiguration.get().getUrl();
+ if (jenkinsUrl == null) {
+ throw new IOException("Jenkins URL is not configured. Please set the Jenkins URL in system configuration.");
+ }
+ if (!jenkinsUrl.endsWith("/")) {
+ jenkinsUrl += "/";
+ }
+
+ String secret = computer.getJnlpMac();
+ String nodeName = computer.getName();
+
+ String launchCommand = String.format(
+ "nohup %s %s -jar %s/agent.jar -url '%s' -secret %s -name '%s' -webSocket > %s/agent.log 2>&1 &",
+ javaPath, (jvmopts != null ? jvmopts : ""), tmpDir, jenkinsUrl, secret, nodeName, tmpDir);
+
+ logInfo(computer, listener, "Launching remoting agent via SSM SendCommand (WebSocket inbound)");
+ sendSSMCommand(ssmClient, instanceId, launchCommand, computer, listener);
+
+ // Wait for the agent to connect back via WebSocket
+ logInfo(computer, listener, "Waiting for remoting agent to connect...");
+ for (int i = 0; i < readinessTries; i++) {
+ if (computer.getChannel() != null) {
+ logInfo(computer, listener, "Remoting agent connected successfully");
+ return;
+ }
+ Thread.sleep(readinessSleepMs);
+ }
+ throw new IOException(
+ "Remoting agent did not connect within " + (readinessTries * readinessSleepMs / 1000) + "s");
+ }
+
+ private String sendSSMCommand(
+ SsmClient ssmClient, String instanceId, String command, EC2Computer computer, TaskListener listener) {
+ List commands = new ArrayList<>();
+ commands.add(command);
+
+ SendCommandResponse response = ssmClient.sendCommand(SendCommandRequest.builder()
+ .instanceIds(instanceId)
+ .documentName("AWS-RunShellScript")
+ .parameters(Map.of("commands", commands))
+ .build());
+
+ return response.command().commandId();
+ }
+
+ private void waitForCommand(
+ SsmClient ssmClient, String instanceId, String commandId, EC2Computer computer, TaskListener listener)
+ throws InterruptedException, IOException {
+ for (int i = 0; i < commandPollTries; i++) {
+ try {
+ GetCommandInvocationResponse response =
+ ssmClient.getCommandInvocation(GetCommandInvocationRequest.builder()
+ .commandId(commandId)
+ .instanceId(instanceId)
+ .build());
+
+ CommandInvocationStatus status = response.status();
+ if (status == CommandInvocationStatus.SUCCESS
+ || status == CommandInvocationStatus.FAILED
+ || status == CommandInvocationStatus.TIMED_OUT
+ || status == CommandInvocationStatus.CANCELLED) {
+ return;
+ }
+ } catch (SsmException e) {
+ // InvocationDoesNotExist means the command hasn't been received by the instance yet
+ if (!"InvocationDoesNotExist".equals(e.awsErrorDetails().errorCode())) {
+ throw e;
+ }
+ }
+ Thread.sleep(commandPollSleepMs);
+ }
+
+ throw new IOException("SSM command " + commandId + " did not complete after "
+ + (commandPollTries * commandPollSleepMs / 1000) + "s");
+ }
+
+ private void sendSSMCommandAndWait(
+ SsmClient ssmClient, String instanceId, String command, EC2Computer computer, TaskListener listener)
+ throws InterruptedException, IOException {
+ String commandId = sendSSMCommand(ssmClient, instanceId, command, computer, listener);
+ waitForCommand(ssmClient, instanceId, commandId, computer, listener);
+ }
+
+ private String runSSMCommand(SsmClient ssmClient, String instanceId, String command) throws InterruptedException {
+ List commands = new ArrayList<>();
+ commands.add(command);
+
+ SendCommandResponse response = ssmClient.sendCommand(SendCommandRequest.builder()
+ .instanceIds(instanceId)
+ .documentName("AWS-RunShellScript")
+ .parameters(Map.of("commands", commands))
+ .build());
+
+ String commandId = response.command().commandId();
+
+ for (int i = 0; i < commandPollTries; i++) {
+ try {
+ GetCommandInvocationResponse invocation =
+ ssmClient.getCommandInvocation(GetCommandInvocationRequest.builder()
+ .commandId(commandId)
+ .instanceId(instanceId)
+ .build());
+
+ CommandInvocationStatus status = invocation.status();
+ if (status == CommandInvocationStatus.SUCCESS
+ || status == CommandInvocationStatus.FAILED
+ || status == CommandInvocationStatus.TIMED_OUT
+ || status == CommandInvocationStatus.CANCELLED) {
+ return invocation.standardOutputContent();
+ }
+ } catch (SsmException e) {
+ if (!"InvocationDoesNotExist".equals(e.awsErrorDetails().errorCode())) {
+ return null;
+ }
+ }
+ Thread.sleep(commandPollSleepMs);
+ }
+ return null;
+ }
+
+ private void logInfo(EC2Computer computer, TaskListener listener, String message) {
+ EC2Cloud.log(LOGGER, Level.INFO, listener, message);
+ }
+
+ private void logWarning(EC2Computer computer, TaskListener listener, String message) {
+ EC2Cloud.log(LOGGER, Level.WARNING, listener, message);
+ }
+}
diff --git a/src/main/java/hudson/plugins/ec2/util/AmazonSSMFactory.java b/src/main/java/hudson/plugins/ec2/util/AmazonSSMFactory.java
new file mode 100644
index 000000000..3683c80ba
--- /dev/null
+++ b/src/main/java/hudson/plugins/ec2/util/AmazonSSMFactory.java
@@ -0,0 +1,17 @@
+package hudson.plugins.ec2.util;
+
+import hudson.ExtensionList;
+import hudson.ExtensionPoint;
+import java.net.URI;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.ssm.SsmClient;
+
+public interface AmazonSSMFactory extends ExtensionPoint {
+
+ static AmazonSSMFactory getInstance() {
+ return ExtensionList.lookupFirst(AmazonSSMFactory.class);
+ }
+
+ SsmClient connect(AwsCredentialsProvider credentialsProvider, Region region, URI endpoint);
+}
diff --git a/src/main/java/hudson/plugins/ec2/util/AmazonSSMFactoryImpl.java b/src/main/java/hudson/plugins/ec2/util/AmazonSSMFactoryImpl.java
new file mode 100644
index 000000000..1d312066c
--- /dev/null
+++ b/src/main/java/hudson/plugins/ec2/util/AmazonSSMFactoryImpl.java
@@ -0,0 +1,28 @@
+package hudson.plugins.ec2.util;
+
+import hudson.Extension;
+import hudson.plugins.ec2.EC2Cloud;
+import java.net.URI;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.ssm.SsmClient;
+import software.amazon.awssdk.services.ssm.SsmClientBuilder;
+
+@Extension
+public class AmazonSSMFactoryImpl implements AmazonSSMFactory {
+
+ @Override
+ public SsmClient connect(AwsCredentialsProvider credentialsProvider, Region region, URI endpoint) {
+ SsmClientBuilder ssmClientBuilder = SsmClient.builder()
+ .credentialsProvider(credentialsProvider)
+ .httpClient(EC2Cloud.getHttpClient())
+ .overrideConfiguration(EC2Cloud.createClientOverrideConfiguration());
+ if (region != null) {
+ ssmClientBuilder.region(region);
+ }
+ if (endpoint != null) {
+ ssmClientBuilder.endpointOverride(endpoint);
+ }
+ return ssmClientBuilder.build();
+ }
+}
diff --git a/src/main/java/hudson/plugins/ec2/util/EC2AgentConfig.java b/src/main/java/hudson/plugins/ec2/util/EC2AgentConfig.java
index 27b69cb26..0caf3b12b 100644
--- a/src/main/java/hudson/plugins/ec2/util/EC2AgentConfig.java
+++ b/src/main/java/hudson/plugins/ec2/util/EC2AgentConfig.java
@@ -29,6 +29,7 @@ public abstract class EC2AgentConfig {
final AMITypeData amiType;
final ConnectionStrategy connectionStrategy;
final int maxTotalUses;
+ final boolean useSSM;
private EC2AgentConfig(Builder extends Builder, ? extends EC2AgentConfig> builder) {
this.name = builder.name;
@@ -50,6 +51,7 @@ private EC2AgentConfig(Builder extends Builder, ? extends EC2AgentConfig> buil
this.amiType = builder.amiType;
this.connectionStrategy = builder.connectionStrategy;
this.maxTotalUses = builder.maxTotalUses;
+ this.useSSM = builder.useSSM;
}
public static class OnDemand extends EC2AgentConfig {
@@ -117,6 +119,7 @@ private abstract static class Builder, C extends EC2Agen
private AMITypeData amiType;
private ConnectionStrategy connectionStrategy;
private int maxTotalUses;
+ private boolean useSSM;
public B withName(String name) {
this.name = name;
@@ -217,6 +220,11 @@ public B withMaxTotalUses(int maxTotalUses) {
return self();
}
+ public B withUseSSM(boolean useSSM) {
+ this.useSSM = useSSM;
+ return self();
+ }
+
protected abstract B self();
public abstract C build();
diff --git a/src/main/java/hudson/plugins/ec2/util/EC2AgentFactoryImpl.java b/src/main/java/hudson/plugins/ec2/util/EC2AgentFactoryImpl.java
index a42a0aca9..b9a1c0199 100644
--- a/src/main/java/hudson/plugins/ec2/util/EC2AgentFactoryImpl.java
+++ b/src/main/java/hudson/plugins/ec2/util/EC2AgentFactoryImpl.java
@@ -41,7 +41,8 @@ public EC2OndemandSlave createOnDemandAgent(EC2AgentConfig.OnDemand config)
config.metadataTokensRequired,
config.metadataHopsLimit,
config.metadataSupported,
- config.enclaveEnabled);
+ config.enclaveEnabled,
+ config.useSSM);
}
@Override
@@ -66,6 +67,7 @@ public EC2SpotSlave createSpotAgent(EC2AgentConfig.Spot config) throws Descripto
config.launchTimeout,
config.amiType,
config.connectionStrategy,
- config.maxTotalUses);
+ config.maxTotalUses,
+ config.useSSM);
}
}
diff --git a/src/main/resources/hudson/plugins/ec2/EC2Cloud/config-entries.jelly b/src/main/resources/hudson/plugins/ec2/EC2Cloud/config-entries.jelly
index 7746af4c6..4e9b83521 100644
--- a/src/main/resources/hudson/plugins/ec2/EC2Cloud/config-entries.jelly
+++ b/src/main/resources/hudson/plugins/ec2/EC2Cloud/config-entries.jelly
@@ -36,9 +36,20 @@ THE SOFTWARE.
-
-
-
+
+
+
+
+
+
+
+ Connects to instances using AWS Systems Manager instead of SSH.
+ Requires SSM Agent on instances and appropriate IAM permissions.
+ The AWS CLI and session-manager-plugin must be installed on the Jenkins controller.
+
+
@@ -56,5 +67,5 @@ THE SOFTWARE.
-
+
diff --git a/src/test/java/hudson/plugins/ec2/EC2CloudTest.java b/src/test/java/hudson/plugins/ec2/EC2CloudTest.java
index 685e8a2b0..e7d85d5b6 100644
--- a/src/test/java/hudson/plugins/ec2/EC2CloudTest.java
+++ b/src/test/java/hudson/plugins/ec2/EC2CloudTest.java
@@ -206,6 +206,53 @@ void testCustomSshCredentialTypes() throws IOException {
assertThat(actual.resolvePrivateKey(), notNullValue());
}
+ @Test
+ void testUseSSMDefaultFalse() {
+ EC2Cloud actual = r.jenkins.clouds.get(EC2Cloud.class);
+ assertFalse(actual.isUseSSM());
+ }
+
+ @Test
+ void testUseSSMSetterGetter() {
+ EC2Cloud actual = r.jenkins.clouds.get(EC2Cloud.class);
+ actual.setUseSSM(true);
+ assertTrue(actual.isUseSSM());
+ actual.setUseSSM(false);
+ assertFalse(actual.isUseSSM());
+ }
+
+ @Test
+ void testResolvePrivateKeyReturnsNullWhenUseSSM() {
+ EC2Cloud actual = r.jenkins.clouds.get(EC2Cloud.class);
+ actual.setUseSSM(true);
+ assertNull(actual.resolvePrivateKey());
+ }
+
+ @Test
+ void testGetKeyPairReturnsNullWhenUseSSM() throws Exception {
+ EC2Cloud actual = r.jenkins.clouds.get(EC2Cloud.class);
+ actual.setUseSSM(true);
+ assertNull(actual.getKeyPair());
+ }
+
+ @Test
+ void testDoCheckSshKeysCredentialsIdReturnsOkWhenUseSSM() throws Exception {
+ EC2Cloud actual = r.jenkins.clouds.get(EC2Cloud.class);
+ EC2Cloud.DescriptorImpl descriptor = (EC2Cloud.DescriptorImpl) actual.getDescriptor();
+ FormValidation result = descriptor.doCheckSshKeysCredentialsId(Jenkins.get(), "", true);
+ assertThat(result.kind, is(FormValidation.Kind.OK));
+ }
+
+ @Test
+ void testDoTestConnectionSkipsSshKeyValidationWhenUseSSM() throws Exception {
+ EC2Cloud actual = r.jenkins.clouds.get(EC2Cloud.class);
+ EC2Cloud.DescriptorImpl descriptor = (EC2Cloud.DescriptorImpl) actual.getDescriptor();
+ FormValidation result = descriptor.doTestConnection(
+ Jenkins.get(), "us-east-1", null, true, "abc", null, "roleArn", "roleSessionName", true);
+ // Should succeed without SSH key validation
+ assertNotEquals(FormValidation.Kind.ERROR, result.kind);
+ }
+
private HtmlForm getConfigForm() throws IOException, SAXException {
return r.createWebClient().goTo(cloud.getUrl() + "configure").getFormByName("config");
}
diff --git a/src/test/java/hudson/plugins/ec2/ssm/EC2SSMLauncherTest.java b/src/test/java/hudson/plugins/ec2/ssm/EC2SSMLauncherTest.java
new file mode 100644
index 000000000..f8b7c2246
--- /dev/null
+++ b/src/test/java/hudson/plugins/ec2/ssm/EC2SSMLauncherTest.java
@@ -0,0 +1,199 @@
+package hudson.plugins.ec2.ssm;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import hudson.plugins.ec2.*;
+import hudson.plugins.ec2.util.AmazonEC2FactoryMockImpl;
+import hudson.plugins.ec2.util.AmazonSSMFactoryMockImpl;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
+import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
+import software.amazon.awssdk.services.ssm.SsmClient;
+import software.amazon.awssdk.services.ssm.model.*;
+
+@WithJenkins
+class EC2SSMLauncherTest {
+
+ private JenkinsRule r;
+
+ @BeforeEach
+ void setUp(JenkinsRule rule) {
+ r = rule;
+ AmazonEC2FactoryMockImpl.mock = AmazonEC2FactoryMockImpl.createAmazonEC2Mock();
+ AmazonSSMFactoryMockImpl.mock = null;
+ }
+
+ @Test
+ void testLauncherInstantiation() {
+ EC2SSMLauncher launcher = new EC2SSMLauncher();
+ assertNotNull(launcher);
+ }
+
+ @Test
+ void testSSMReadinessPolling() {
+ SsmClient ssmClient = mock(SsmClient.class);
+
+ // First call: empty list (not registered yet)
+ // Second call: online
+ DescribeInstanceInformationResponse emptyResponse = DescribeInstanceInformationResponse.builder()
+ .instanceInformationList(Collections.emptyList())
+ .build();
+
+ InstanceInformation onlineInfo = InstanceInformation.builder()
+ .instanceId("i-12345")
+ .pingStatus(PingStatus.ONLINE)
+ .build();
+ DescribeInstanceInformationResponse onlineResponse = DescribeInstanceInformationResponse.builder()
+ .instanceInformationList(onlineInfo)
+ .build();
+
+ when(ssmClient.describeInstanceInformation(any(DescribeInstanceInformationRequest.class)))
+ .thenReturn(emptyResponse)
+ .thenReturn(onlineResponse);
+
+ // Verify the mock was set up correctly
+ DescribeInstanceInformationResponse first = ssmClient.describeInstanceInformation(
+ DescribeInstanceInformationRequest.builder().build());
+ assertTrue(first.instanceInformationList().isEmpty());
+
+ DescribeInstanceInformationResponse second = ssmClient.describeInstanceInformation(
+ DescribeInstanceInformationRequest.builder().build());
+ assertFalse(second.instanceInformationList().isEmpty());
+ assertEquals("Online", second.instanceInformationList().get(0).pingStatusAsString());
+ }
+
+ @Test
+ void testSendCommandMocking() {
+ SsmClient ssmClient = mock(SsmClient.class);
+
+ SendCommandResponse sendResponse = SendCommandResponse.builder()
+ .command(Command.builder().commandId("cmd-12345").build())
+ .build();
+
+ when(ssmClient.sendCommand(any(SendCommandRequest.class))).thenReturn(sendResponse);
+
+ GetCommandInvocationResponse invocationResponse = GetCommandInvocationResponse.builder()
+ .status(CommandInvocationStatus.SUCCESS)
+ .responseCode(0)
+ .standardOutputContent("output")
+ .standardErrorContent("")
+ .build();
+
+ when(ssmClient.getCommandInvocation(any(GetCommandInvocationRequest.class)))
+ .thenReturn(invocationResponse);
+
+ // Verify send command works
+ SendCommandResponse result = ssmClient.sendCommand(SendCommandRequest.builder()
+ .instanceIds("i-12345")
+ .documentName("AWS-RunShellScript")
+ .parameters(java.util.Map.of("commands", List.of("echo hello")))
+ .build());
+
+ assertEquals("cmd-12345", result.command().commandId());
+
+ // Verify get command invocation works
+ GetCommandInvocationResponse invocation = ssmClient.getCommandInvocation(GetCommandInvocationRequest.builder()
+ .commandId("cmd-12345")
+ .instanceId("i-12345")
+ .build());
+
+ assertEquals(CommandInvocationStatus.SUCCESS, invocation.status());
+ assertEquals(0, invocation.responseCode());
+ }
+
+ @Test
+ void testDocumentCreation() {
+ SsmClient ssmClient = mock(SsmClient.class);
+
+ // Simulate document not found
+ when(ssmClient.describeDocument(any(DescribeDocumentRequest.class)))
+ .thenThrow(SsmException.builder()
+ .message("Document not found")
+ .statusCode(404)
+ .awsErrorDetails(AwsErrorDetails.builder()
+ .errorCode("InvalidDocument")
+ .errorMessage("Document not found")
+ .build())
+ .build());
+
+ CreateDocumentResponse createResponse = CreateDocumentResponse.builder()
+ .documentDescription(DocumentDescription.builder()
+ .name("Jenkins-EC2-SSM-SessionDocument")
+ .build())
+ .build();
+
+ when(ssmClient.createDocument(any(CreateDocumentRequest.class))).thenReturn(createResponse);
+
+ // Verify describe throws
+ assertThrows(
+ SsmException.class,
+ () -> ssmClient.describeDocument(DescribeDocumentRequest.builder()
+ .name("Jenkins-EC2-SSM-SessionDocument")
+ .build()));
+
+ // Verify create succeeds
+ CreateDocumentResponse result = ssmClient.createDocument(CreateDocumentRequest.builder()
+ .name("Jenkins-EC2-SSM-SessionDocument")
+ .content("{}")
+ .documentType(DocumentType.SESSION)
+ .documentFormat(DocumentFormat.JSON)
+ .build());
+
+ assertEquals(
+ "Jenkins-EC2-SSM-SessionDocument", result.documentDescription().name());
+ }
+
+ @Test
+ void testCommandFailure() {
+ SsmClient ssmClient = mock(SsmClient.class);
+
+ SendCommandResponse sendResponse = SendCommandResponse.builder()
+ .command(Command.builder().commandId("cmd-fail").build())
+ .build();
+
+ when(ssmClient.sendCommand(any(SendCommandRequest.class))).thenReturn(sendResponse);
+
+ GetCommandInvocationResponse failedResponse = GetCommandInvocationResponse.builder()
+ .status(CommandInvocationStatus.FAILED)
+ .responseCode(1)
+ .standardOutputContent("")
+ .standardErrorContent("command not found")
+ .build();
+
+ when(ssmClient.getCommandInvocation(any(GetCommandInvocationRequest.class)))
+ .thenReturn(failedResponse);
+
+ GetCommandInvocationResponse result = ssmClient.getCommandInvocation(GetCommandInvocationRequest.builder()
+ .commandId("cmd-fail")
+ .instanceId("i-12345")
+ .build());
+
+ assertEquals(CommandInvocationStatus.FAILED, result.status());
+ assertEquals(1, result.responseCode());
+ assertEquals("command not found", result.standardErrorContent());
+ }
+
+ @Test
+ void testRemotingLaunchCommandConstruction() {
+ String javaPath = "java";
+ String jvmopts = "-Xmx512m";
+ String tmpDir = "/tmp";
+ String workDir = "/home/jenkins";
+ String prefix = "";
+ String suffix = "";
+
+ String launchCommand = prefix + " " + javaPath + " " + jvmopts + " -jar " + tmpDir + "/remoting.jar -workDir "
+ + workDir + suffix;
+
+ assertTrue(launchCommand.contains("java"));
+ assertTrue(launchCommand.contains("-Xmx512m"));
+ assertTrue(launchCommand.contains("/tmp/remoting.jar"));
+ assertTrue(launchCommand.contains("-workDir /home/jenkins"));
+ }
+}
diff --git a/src/test/java/hudson/plugins/ec2/util/AmazonSSMFactoryMockImpl.java b/src/test/java/hudson/plugins/ec2/util/AmazonSSMFactoryMockImpl.java
new file mode 100644
index 000000000..53a97f76f
--- /dev/null
+++ b/src/test/java/hudson/plugins/ec2/util/AmazonSSMFactoryMockImpl.java
@@ -0,0 +1,27 @@
+package hudson.plugins.ec2.util;
+
+import static org.mockito.Mockito.mock;
+
+import hudson.Extension;
+import java.net.URI;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.ssm.SsmClient;
+
+@Extension(ordinal = 100)
+public class AmazonSSMFactoryMockImpl implements AmazonSSMFactory {
+
+ public static SsmClient mock;
+
+ public static SsmClient createSsmClientMock() {
+ return mock(SsmClient.class);
+ }
+
+ @Override
+ public SsmClient connect(AwsCredentialsProvider credentialsProvider, Region region, URI endpoint) {
+ if (mock == null) {
+ mock = createSsmClientMock();
+ }
+ return mock;
+ }
+}