From f75e7e1465c389b9adfe9a3e77c62d4f89181b2b Mon Sep 17 00:00:00 2001 From: Javier Ramos Date: Thu, 26 Feb 2026 11:54:54 +0100 Subject: [PATCH] Add support for remote agents with SSM --- pom.xml | 4 + .../java/hudson/plugins/ec2/EC2Cloud.java | 125 ++++-- .../hudson/plugins/ec2/EC2OndemandSlave.java | 78 +++- .../java/hudson/plugins/ec2/EC2SpotSlave.java | 56 ++- .../hudson/plugins/ec2/SlaveTemplate.java | 33 +- .../plugins/ec2/ssm/EC2SSMLauncher.java | 394 ++++++++++++++++++ .../plugins/ec2/util/AmazonSSMFactory.java | 17 + .../ec2/util/AmazonSSMFactoryImpl.java | 28 ++ .../plugins/ec2/util/EC2AgentConfig.java | 8 + .../plugins/ec2/util/EC2AgentFactoryImpl.java | 6 +- .../plugins/ec2/EC2Cloud/config-entries.jelly | 19 +- .../java/hudson/plugins/ec2/EC2CloudTest.java | 47 +++ .../plugins/ec2/ssm/EC2SSMLauncherTest.java | 199 +++++++++ .../ec2/util/AmazonSSMFactoryMockImpl.java | 27 ++ 14 files changed, 970 insertions(+), 71 deletions(-) create mode 100644 src/main/java/hudson/plugins/ec2/ssm/EC2SSMLauncher.java create mode 100644 src/main/java/hudson/plugins/ec2/util/AmazonSSMFactory.java create mode 100644 src/main/java/hudson/plugins/ec2/util/AmazonSSMFactoryImpl.java create mode 100644 src/test/java/hudson/plugins/ec2/ssm/EC2SSMLauncherTest.java create mode 100644 src/test/java/hudson/plugins/ec2/util/AmazonSSMFactoryMockImpl.java 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> 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> 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 builder) { this.name = builder.name; @@ -50,6 +51,7 @@ private EC2AgentConfig(Builder 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; + } +}