From 1b3389cd680def3273829821868dd9ae17068301 Mon Sep 17 00:00:00 2001 From: Anita Bendelja Date: Fri, 19 Feb 2021 09:00:16 +0100 Subject: [PATCH] Option to launch encrypted EBS root volume from unencrypted AMI --- .../plugins/ec2/EbsEncryptRootVolume.java | 27 +++ .../hudson/plugins/ec2/SlaveTemplate.java | 177 ++++++++---------- .../plugins/ec2/SlaveTemplate/config.jelly | 4 + .../help-ebsEncryptRootVolume.html | 9 + .../plugins/ec2/SlaveTemplateUnitTest.java | 54 +++++- .../ec2/UnixDataExport-withAltEndpoint.yml | 1 + .../hudson/plugins/ec2/UnixDataExport.yml | 1 + 7 files changed, 164 insertions(+), 109 deletions(-) create mode 100644 src/main/java/hudson/plugins/ec2/EbsEncryptRootVolume.java create mode 100644 src/main/resources/hudson/plugins/ec2/SlaveTemplate/help-ebsEncryptRootVolume.html diff --git a/src/main/java/hudson/plugins/ec2/EbsEncryptRootVolume.java b/src/main/java/hudson/plugins/ec2/EbsEncryptRootVolume.java new file mode 100644 index 000000000..47ca5d15a --- /dev/null +++ b/src/main/java/hudson/plugins/ec2/EbsEncryptRootVolume.java @@ -0,0 +1,27 @@ +package hudson.plugins.ec2; + +import edu.umd.cs.findbugs.annotations.NonNull; + +public enum EbsEncryptRootVolume { + DEFAULT("Based on AMI", null), + ENCRYPTED("Encrypted", true), + UNENCRYPTED("Not Encrypted", false); + + private final String displayText; + private final Boolean value; + + EbsEncryptRootVolume(String displayText, Boolean value) { + this.displayText = displayText; + this.value = value; + } + + @NonNull + public String getDisplayText() { + return displayText; + } + + public Boolean getValue() { + return value; + } + +} diff --git a/src/main/java/hudson/plugins/ec2/SlaveTemplate.java b/src/main/java/hudson/plugins/ec2/SlaveTemplate.java index 55b426b09..709343152 100644 --- a/src/main/java/hudson/plugins/ec2/SlaveTemplate.java +++ b/src/main/java/hudson/plugins/ec2/SlaveTemplate.java @@ -23,20 +23,16 @@ import com.amazonaws.auth.AWSCredentialsProvider; import com.amazonaws.services.ec2.AmazonEC2; import com.amazonaws.services.ec2.model.AmazonEC2Exception; -import com.amazonaws.services.ec2.model.AvailabilityZone; import com.amazonaws.services.ec2.model.BlockDeviceMapping; import com.amazonaws.services.ec2.model.CancelSpotInstanceRequestsRequest; import com.amazonaws.services.ec2.model.CreateTagsRequest; import com.amazonaws.services.ec2.model.CreditSpecificationRequest; -import com.amazonaws.services.ec2.model.DescribeAvailabilityZonesResult; import com.amazonaws.services.ec2.model.DescribeImagesRequest; import com.amazonaws.services.ec2.model.DescribeInstancesRequest; import com.amazonaws.services.ec2.model.DescribeInstancesResult; import com.amazonaws.services.ec2.model.DescribeSecurityGroupsRequest; import com.amazonaws.services.ec2.model.DescribeSecurityGroupsResult; import com.amazonaws.services.ec2.model.DescribeSpotInstanceRequestsRequest; -import com.amazonaws.services.ec2.model.DescribeSpotPriceHistoryRequest; -import com.amazonaws.services.ec2.model.DescribeSpotPriceHistoryResult; import com.amazonaws.services.ec2.model.DescribeSubnetsRequest; import com.amazonaws.services.ec2.model.DescribeSubnetsResult; import com.amazonaws.services.ec2.model.Filter; @@ -61,7 +57,6 @@ import com.amazonaws.services.ec2.model.SpotInstanceRequest; import com.amazonaws.services.ec2.model.SpotMarketOptions; import com.amazonaws.services.ec2.model.SpotPlacement; -import com.amazonaws.services.ec2.model.SpotPrice; import com.amazonaws.services.ec2.model.StartInstancesRequest; import com.amazonaws.services.ec2.model.StartInstancesResult; import com.amazonaws.services.ec2.model.Subnet; @@ -127,92 +122,11 @@ import java.util.stream.Stream; import javax.annotation.CheckForNull; -import javax.servlet.ServletException; -import hudson.plugins.ec2.util.AmazonEC2Factory; -import hudson.plugins.ec2.util.DeviceMappingParser; -import hudson.plugins.ec2.util.EC2AgentConfig; -import hudson.plugins.ec2.util.EC2AgentFactory; -import hudson.plugins.ec2.util.MinimumInstanceChecker; -import hudson.plugins.ec2.util.MinimumNumberOfInstancesTimeRangeConfig; import edu.umd.cs.findbugs.annotations.NonNull; -import hudson.XmlFile; -import hudson.model.listeners.SaveableListener; import hudson.security.Permission; -import hudson.util.Secret; -import jenkins.model.Jenkins; -import jenkins.model.JenkinsLocationConfiguration; -import jenkins.slaves.iterators.api.NodeIterator; - -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.DataBoundSetter; -import org.kohsuke.stapler.QueryParameter; -import com.amazonaws.AmazonClientException; -import com.amazonaws.AmazonServiceException; -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.services.ec2.AmazonEC2; -import com.amazonaws.services.ec2.model.AmazonEC2Exception; -import com.amazonaws.services.ec2.model.BlockDeviceMapping; -import com.amazonaws.services.ec2.model.CancelSpotInstanceRequestsRequest; -import com.amazonaws.services.ec2.model.CreateTagsRequest; -import com.amazonaws.services.ec2.model.CreditSpecificationRequest; -import com.amazonaws.services.ec2.model.DescribeImagesRequest; -import com.amazonaws.services.ec2.model.DescribeInstancesRequest; -import com.amazonaws.services.ec2.model.DescribeInstancesResult; -import com.amazonaws.services.ec2.model.DescribeSecurityGroupsRequest; -import com.amazonaws.services.ec2.model.DescribeSecurityGroupsResult; -import com.amazonaws.services.ec2.model.DescribeSpotInstanceRequestsRequest; -import com.amazonaws.services.ec2.model.DescribeSubnetsRequest; -import com.amazonaws.services.ec2.model.DescribeSubnetsResult; -import com.amazonaws.services.ec2.model.Filter; -import com.amazonaws.services.ec2.model.IamInstanceProfileSpecification; -import com.amazonaws.services.ec2.model.Image; -import com.amazonaws.services.ec2.model.Instance; -import com.amazonaws.services.ec2.model.InstanceMarketOptionsRequest; -import com.amazonaws.services.ec2.model.InstanceNetworkInterfaceSpecification; -import com.amazonaws.services.ec2.model.InstanceStateName; -import com.amazonaws.services.ec2.model.InstanceType; -import com.amazonaws.services.ec2.model.KeyPair; -import com.amazonaws.services.ec2.model.LaunchSpecification; -import com.amazonaws.services.ec2.model.MarketType; -import com.amazonaws.services.ec2.model.Placement; -import com.amazonaws.services.ec2.model.RequestSpotInstancesRequest; -import com.amazonaws.services.ec2.model.RequestSpotInstancesResult; -import com.amazonaws.services.ec2.model.Reservation; -import com.amazonaws.services.ec2.model.ResourceType; -import com.amazonaws.services.ec2.model.RunInstancesRequest; -import com.amazonaws.services.ec2.model.SecurityGroup; -import com.amazonaws.services.ec2.model.ShutdownBehavior; -import com.amazonaws.services.ec2.model.SpotInstanceRequest; -import com.amazonaws.services.ec2.model.SpotMarketOptions; -import com.amazonaws.services.ec2.model.SpotPlacement; -import com.amazonaws.services.ec2.model.StartInstancesRequest; -import com.amazonaws.services.ec2.model.StartInstancesResult; -import com.amazonaws.services.ec2.model.Subnet; -import com.amazonaws.services.ec2.model.Tag; -import com.amazonaws.services.ec2.model.TagSpecification; - -import hudson.Extension; -import hudson.Util; -import hudson.model.Describable; -import hudson.model.Descriptor; -import hudson.model.Descriptor.FormException; -import hudson.model.Hudson; -import hudson.model.Label; -import hudson.model.Node; -import hudson.model.Saveable; -import hudson.model.TaskListener; -import hudson.model.labels.LabelAtom; -import hudson.slaves.NodeProperty; -import hudson.slaves.NodePropertyDescriptor; -import hudson.util.DescribableList; -import hudson.util.FormValidation; -import hudson.util.ListBoxModel; import org.kohsuke.stapler.Stapler; import org.kohsuke.stapler.interceptor.RequirePOST; @@ -308,6 +222,8 @@ public class SlaveTemplate implements Describable { public Tenancy tenancy; + public EbsEncryptRootVolume ebsEncryptRootVolume; + private transient/* almost final */ Set labelSet; private transient/* almost final */Set securityGroupSet; @@ -360,7 +276,8 @@ public SlaveTemplate(String ami, String zone, SpotConfiguration spotConfig, Stri boolean useEphemeralDevices, String launchTimeoutStr, boolean associatePublicIp, String customDeviceMapping, boolean connectBySSHProcess, boolean monitoring, boolean t2Unlimited, ConnectionStrategy connectionStrategy, int maxTotalUses, - List> nodeProperties, HostKeyVerificationStrategyEnum hostKeyVerificationStrategy, Tenancy tenancy) { + List> nodeProperties, HostKeyVerificationStrategyEnum hostKeyVerificationStrategy, Tenancy tenancy, EbsEncryptRootVolume ebsEncryptRootVolume) { + if(StringUtils.isNotBlank(remoteAdmin) || StringUtils.isNotBlank(jvmopts) || StringUtils.isNotBlank(tmpDir)){ LOGGER.log(Level.FINE, "As remoteAdmin, jvmopts or tmpDir is not blank, we must ensure the user has ADMINISTER rights."); // Can be null during tests @@ -425,10 +342,31 @@ public SlaveTemplate(String ami, String zone, SpotConfiguration spotConfig, Stri this.hostKeyVerificationStrategy = hostKeyVerificationStrategy != null ? hostKeyVerificationStrategy : HostKeyVerificationStrategyEnum.CHECK_NEW_SOFT; this.tenancy = tenancy != null ? tenancy : Tenancy.Default; - + this.ebsEncryptRootVolume = ebsEncryptRootVolume != null ? ebsEncryptRootVolume : EbsEncryptRootVolume.DEFAULT; readResolve(); // initialize } + @Deprecated + public SlaveTemplate(String ami, String zone, SpotConfiguration spotConfig, String securityGroups, String remoteFS, + InstanceType type, boolean ebsOptimized, String labelString, Node.Mode mode, String description, String initScript, + String tmpDir, String userData, String numExecutors, String remoteAdmin, AMITypeData amiType, String jvmopts, + boolean stopOnTerminate, String subnetId, List tags, String idleTerminationMinutes, int minimumNumberOfInstances, + int minimumNumberOfSpareInstances, String instanceCapStr, String iamInstanceProfile, boolean deleteRootOnTermination, + boolean useEphemeralDevices, String launchTimeoutStr, boolean associatePublicIp, + String customDeviceMapping, boolean connectBySSHProcess, boolean monitoring, + boolean t2Unlimited, ConnectionStrategy connectionStrategy, int maxTotalUses, + List> nodeProperties, HostKeyVerificationStrategyEnum hostKeyVerificationStrategy, Tenancy tenancy) { + this(ami, zone, spotConfig, securityGroups, remoteFS, + type, ebsOptimized, labelString, mode, description, initScript, + tmpDir, userData, numExecutors, remoteAdmin, amiType, jvmopts, + stopOnTerminate, subnetId, tags, idleTerminationMinutes, minimumNumberOfInstances, + minimumNumberOfSpareInstances, instanceCapStr, iamInstanceProfile, deleteRootOnTermination, + useEphemeralDevices, launchTimeoutStr, associatePublicIp, + customDeviceMapping, connectBySSHProcess, monitoring, + t2Unlimited, connectionStrategy, maxTotalUses, + nodeProperties, hostKeyVerificationStrategy, tenancy, null); + } + @Deprecated public SlaveTemplate(String ami, String zone, SpotConfiguration spotConfig, String securityGroups, String remoteFS, InstanceType type, boolean ebsOptimized, String labelString, Node.Mode mode, String description, String initScript, @@ -1212,17 +1150,24 @@ List findOrphansOrStopped(DescribeInstancesResult diResult, int number } private void setupRootDevice(Image image, List deviceMappings) { - if (deleteRootOnTermination && image.getRootDeviceType().equals("ebs")) { - // get the root device (only one expected in the blockmappings) - final List rootDeviceMappings = image.getBlockDeviceMappings(); - if (rootDeviceMappings.size() == 0) { - LOGGER.warning("AMI missing block devices"); - return; - } - BlockDeviceMapping rootMapping = rootDeviceMappings.get(0); - LOGGER.info("AMI had " + rootMapping.getDeviceName()); - LOGGER.info(rootMapping.getEbs().toString()); + if (!"ebs".equals(image.getRootDeviceType())) { + return; + } + + // get the root device (only one expected in the blockmappings) + final List rootDeviceMappings = image.getBlockDeviceMappings(); + if (rootDeviceMappings.size() == 0) { + LOGGER.warning("AMI missing block devices"); + return; + } + BlockDeviceMapping rootMapping = rootDeviceMappings.get(0); + LOGGER.info("AMI had " + rootMapping.getDeviceName()); + LOGGER.info(rootMapping.getEbs().toString()); + + // Create a shadow of the AMI mapping (doesn't like reusing rootMapping directly) + BlockDeviceMapping newMapping = rootMapping.clone(); + if (deleteRootOnTermination) { // Check if the root device is already in the mapping and update it for (final BlockDeviceMapping mapping : deviceMappings) { LOGGER.info("Request had " + mapping.getDeviceName()); @@ -1232,14 +1177,15 @@ private void setupRootDevice(Image image, List deviceMapping } } - // Create a shadow of the AMI mapping (doesn't like reusing rootMapping directly) - BlockDeviceMapping newMapping = rootMapping.clone(); + // pass deleteRootOnTermination to shadow of the AMI mapping newMapping.getEbs().setDeleteOnTermination(Boolean.TRUE); - //Per the documentation, "If you are creating a volume from a snapshot, you can't specify an encryption value. This is because only blank volumes can be encrypted on creation. " - //The root volume will always have a snapshot, so this value needs to be set to null to work correctly - newMapping.getEbs().setEncrypted(null); - deviceMappings.add(0, newMapping); } + + newMapping.getEbs().setEncrypted(ebsEncryptRootVolume.getValue()); + String message = String.format("EBS default encryption value set to: %s (%s)", ebsEncryptRootVolume.getDisplayText(), ebsEncryptRootVolume.getValue()); + logProvisionInfo(message); + deviceMappings.add(0, newMapping); + } private List getNewEphemeralDeviceMapping(Image image) { @@ -2128,5 +2074,28 @@ public ListBoxModel doFillTenancyItems(@QueryParameter String tenancy) { }) .collect(Collectors.toCollection(ListBoxModel::new)); } + public String getDefaultEbsEncryptRootVolume() { + return EbsEncryptRootVolume.DEFAULT.getDisplayText(); + } + + public ListBoxModel doFillEbsEncryptRootVolumeItems(@QueryParameter String ebsEncryptRootVolume ) { + return Stream.of(EbsEncryptRootVolume.values()) + .map(v -> { + if (v.name().equals(ebsEncryptRootVolume)) { + return new ListBoxModel.Option(v.getDisplayText(), v.name(), true); + } else { + return new ListBoxModel.Option(v.getDisplayText(), v.name(), false); + } + }) + .collect(Collectors.toCollection(ListBoxModel::new)); + } + + public FormValidation doEbsEncryptRootVolume(@QueryParameter String ebsEncryptRootVolume) { + Stream stream = Stream.of(EbsEncryptRootVolume.values()); + Stream filteredStream = stream.filter(v -> v.name().equals(ebsEncryptRootVolume)); + Optional matched = filteredStream.findFirst(); + Optional okResult = matched.map(s -> FormValidation.ok()); + return okResult.orElse(FormValidation.error(String.format("Could not find selected option (%s)", ebsEncryptRootVolume))); + } } } diff --git a/src/main/resources/hudson/plugins/ec2/SlaveTemplate/config.jelly b/src/main/resources/hudson/plugins/ec2/SlaveTemplate/config.jelly index c1b3a0eec..df9987575 100644 --- a/src/main/resources/hudson/plugins/ec2/SlaveTemplate/config.jelly +++ b/src/main/resources/hudson/plugins/ec2/SlaveTemplate/config.jelly @@ -194,6 +194,10 @@ THE SOFTWARE. + + + + diff --git a/src/main/resources/hudson/plugins/ec2/SlaveTemplate/help-ebsEncryptRootVolume.html b/src/main/resources/hudson/plugins/ec2/SlaveTemplate/help-ebsEncryptRootVolume.html new file mode 100644 index 000000000..d96ccff81 --- /dev/null +++ b/src/main/resources/hudson/plugins/ec2/SlaveTemplate/help-ebsEncryptRootVolume.html @@ -0,0 +1,9 @@ +
+ Option to launch encrypted EBS backed EC2 instances from unencrypted AMIs in a single step. +
    +
  • Based on AMI: EBS will be either encrypted or not encrypted based on what is defined in AMI
  • +
  • Not Encrypted: Launches not encrypted EBS
  • +
  • Encrypted: Launches encrypted EBS
  • +
+
+ diff --git a/src/test/java/hudson/plugins/ec2/SlaveTemplateUnitTest.java b/src/test/java/hudson/plugins/ec2/SlaveTemplateUnitTest.java index f55b6f38d..b7b327636 100644 --- a/src/test/java/hudson/plugins/ec2/SlaveTemplateUnitTest.java +++ b/src/test/java/hudson/plugins/ec2/SlaveTemplateUnitTest.java @@ -3,15 +3,13 @@ import com.amazonaws.AmazonServiceException; import com.amazonaws.services.ec2.AmazonEC2; import com.amazonaws.services.ec2.AmazonEC2Client; -import com.amazonaws.services.ec2.model.CreateTagsResult; -import com.amazonaws.services.ec2.model.DescribeImagesRequest; -import com.amazonaws.services.ec2.model.Filter; -import com.amazonaws.services.ec2.model.InstanceType; -import com.amazonaws.services.ec2.model.Tag; +import com.amazonaws.services.ec2.model.*; import hudson.model.Node; +import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.powermock.reflect.Whitebox; +import org.powermock.reflect.internal.WhiteboxImpl; import java.util.Arrays; import java.util.ArrayList; @@ -320,6 +318,52 @@ protected Object readResolve() { false); } } + + private Boolean checkEncryptedForSetupRootDevice(EbsEncryptRootVolume rootVolumeEnum) throws Exception { + SlaveTemplate template = new SlaveTemplate(null, EC2AbstractSlave.TEST_ZONE, null, "default", "foo", InstanceType.M1Large, false, "ttt", Node.Mode.NORMAL, "foo", "bar", "bbb", "aaa", "10", "fff", null, "-Xmx1g", false, "subnet 456", null, null, false, null, "", true, false, "", false, "") { + @Override + protected Object readResolve() { + return null; + } + }; + List deviceMappings = new ArrayList(); + deviceMappings.add(deviceMappings); + + Image image = new Image(); + image.setRootDeviceType("ebs"); + BlockDeviceMapping blockDeviceMapping = new BlockDeviceMapping(); + blockDeviceMapping.setEbs(new EbsBlockDevice()); + image.getBlockDeviceMappings().add(blockDeviceMapping); + if (rootVolumeEnum instanceof EbsEncryptRootVolume) { + template.ebsEncryptRootVolume = rootVolumeEnum; + }; + WhiteboxImpl.invokeMethod(template, "setupRootDevice", image, deviceMappings); + return image.getBlockDeviceMappings().get(0).getEbs().getEncrypted(); + } + + @Test + public void testSetupRootDeviceNull() throws Exception { + Boolean test = checkEncryptedForSetupRootDevice(null); + Assert.assertNull(test); + } + + @Test + public void testSetupRootDeviceDefault() throws Exception { + Boolean test = checkEncryptedForSetupRootDevice(EbsEncryptRootVolume.DEFAULT); + Assert.assertNull(test); + } + + @Test + public void testSetupRootDeviceNotEncrypted() throws Exception { + Boolean test = checkEncryptedForSetupRootDevice(EbsEncryptRootVolume.UNENCRYPTED); + Assert.assertFalse(test); + } + + @Test + public void testSetupRootDeviceEncrypted() throws Exception { + Boolean test = checkEncryptedForSetupRootDevice(EbsEncryptRootVolume.ENCRYPTED); + Assert.assertTrue(test); + } } class TestHandler extends Handler { diff --git a/src/test/resources/hudson/plugins/ec2/UnixDataExport-withAltEndpoint.yml b/src/test/resources/hudson/plugins/ec2/UnixDataExport-withAltEndpoint.yml index c8899ff8d..516e74b0b 100644 --- a/src/test/resources/hudson/plugins/ec2/UnixDataExport-withAltEndpoint.yml +++ b/src/test/resources/hudson/plugins/ec2/UnixDataExport-withAltEndpoint.yml @@ -15,6 +15,7 @@ connectBySSHProcess: false connectionStrategy: PRIVATE_IP deleteRootOnTermination: false + ebsEncryptRootVolume: DEFAULT ebsOptimized: false hostKeyVerificationStrategy: CHECK_NEW_SOFT labelString: "linux ubuntu" diff --git a/src/test/resources/hudson/plugins/ec2/UnixDataExport.yml b/src/test/resources/hudson/plugins/ec2/UnixDataExport.yml index 77824071c..f4809c941 100644 --- a/src/test/resources/hudson/plugins/ec2/UnixDataExport.yml +++ b/src/test/resources/hudson/plugins/ec2/UnixDataExport.yml @@ -14,6 +14,7 @@ connectBySSHProcess: false connectionStrategy: PRIVATE_IP deleteRootOnTermination: false + ebsEncryptRootVolume: DEFAULT ebsOptimized: false hostKeyVerificationStrategy: CHECK_NEW_SOFT labelString: "linux ubuntu"