Walkthrough: Develop a resource type
In this walkthrough, we'll use the CloudFormation CLI to create a sample resource type,
Example::Testing::WordPress. This includes modeling the schema, developing
the handlers to test those handlers, all the way to performing a dry run to get the resource
type ready to submit to the CloudFormation registry. We'll be coding our new resource type in
Java, and using the us-west-2 Region.
Note
This walkthrough may reference sample resources that have been deleted. For a resource
creation workflow, including a walkthrough of an example resource type in Python, see
the Resource Types walkthrough
Prerequisites
For purposes of this walkthrough, it's assumed you have already set up the CloudFormation CLI and associated tooling for your Java development environment:
Set up your environment for extension development and install the CLI
Create the resource type development project
Before we can actually design and implement our resource type, we'll need to generate a new resource type project, and then import it into our IDE.
Note
This walkthrough uses the Community Edition of the IntelliJ IDEA
Initiate the project
-
Use the
initcommand to create your resource type project and generate the files it requires.$cfn init Initializing new project -
The
initcommand launches a wizard that walks you through setting up the project, including specifying the resource name. For this walkthrough, specifyExample::Testing::WordPress.Enter resource type identifier (Organization::Service::Resource): Example::Testing::WordPressThe wizard then enables you to select the appropriate language plugin. Currently, the only language plugin available is for Java:
One language plugin found, defaulting to java -
Specify the package name. For this walkthrough, use
com.example.testing.wordpressEnter a package name (empty for default 'com.example.testing.wordpress'): com.example.testing.wordpress Initialized a new project in /workplace/tobflem/example-testing-wordpress
Initiating the project includes generating the files needed to develop the resource type. For example:
$ ls -1 README.md example-testing-wordpress.json lombok.config pom.xml rpdk.log src target template.yml
Import the project into your IDE
In order to guarantee that any project dependencies are correctly resolved, you must import the generated project into your IDE with Maven support.
For example, if you are using IntelliJ IDEA, you would need to do the following:
-
From the File menu, choose New, then choose Project From Existing Sources.
-
Navigate to the project directory.
-
In the Import Project dialog box, choose Import project from external model and then choose Maven.
-
Choose Next and accept any defaults to complete importing the project.
Model the resource type
When you initiate the resource type project, an example resource type schema file is
included to help start you modeling your resource type. This is a JSON file named for
your resource, and contains an example of a typical resource type schema. In the case of
our example resource, the schema file is named
example-testing-wordpress.json.
-
In your IDE, open
example-testing-wordpress.json. -
Paste the following schema in place of the default example schema currently in the file.
This schema defines a resource,
Example::Testing::WordPress, that provisions a WordPress site. The resource itself contains four properties, only two of which can be set by users:Name, andSubnetId. The other two properties,InstanceIdandPublicIp, are read-only, meaning they can't be set by users, but will be assigned during resource creation. Both of these properties also serve as identifiers for the resource when it's provisioned.As we'll see later in the walkthrough, creating a WordPress site actually requires more information than represented in our resource model. However, we'll be handling that information on behalf of the user in the code for the resource
createhandler.{ "typeName": "Example::Testing::WordPress", "description": "An example resource that creates a website based on WordPress 5.2.2.", "sourceUrl": "https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/resource-type-walkthrough.html", "properties": { "Name": { "description": "A name associated with the website.", "type": "string", "pattern": "^[a-zA-Z0-9]{1,219}\\Z", "minLength": 1, "maxLength": 219 }, "SubnetId": { "description": "A subnet in which to host the website.", "pattern": "^(subnet-[a-f0-9]{13})|(subnet-[a-f0-9]{8})\\Z", "type": "string" }, "InstanceId": { "description": "The ID of the instance that backs the WordPress site.", "type": "string" }, "PublicIp": { "description": "The public IP for the WordPress site.", "type": "string" } }, "required": [ "Name", "SubnetId" ], "handlers": { "create": { "permissions": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup", "ec2:DescribeInstances", "ec2:DescribeSubnets", "ec2:CreateTags", "ec2:RunInstances" ] }, "read": { "permissions": [ "ec2:DescribeInstances" ] }, "delete": { "permissions": [ "ec2:DeleteSecurityGroup", "ec2:DescribeInstances", "ec2:TerminateInstances" ] } }, "additionalProperties": false, "primaryIdentifier": [ "/properties/PublicIp", "/properties/InstanceId" ], "readOnlyProperties": [ "/properties/PublicIp", "/properties/InstanceId" ] } -
Update the auto-generated files in the resource type package so that they reflect the changes we've made to the resource type schema.
When we first initiated the resource type project, the CloudFormation CLI generated supporting files and code for our resource type. Since we've made changes to the resource type schema, we'll need to regenerate that code to ensure that it reflects the updated schema. To do this, we use the generate command:
$cfn generateGenerated files for Example::Testing::WordPressNote
When using Maven, as part of the build process the
generatecommand is automatically run before the code is compiled. So your changes will never get out of sync with the generated code.Be aware the CloudFormation CLI must be in a location Maven/the system can find. For more information, see Set up your environment for extension development and install the CLI.
Implement the Resource handler
Now that we have our resource type schema specified, we can start implementing the behavior we want the resource type to exhibit during each resource operation. To do this, we'll have to implement the various event handlers, including:
-
Adding any necessary dependencies
-
Writing code to implement the various resource operation handlers.
Add dependencies
To actually make WordPress handlers that call the associated Amazon EC2 API operations,
we need to declare the Amazon EC2 SDK as a dependency in Maven's pom.xml file. To enable
this, we need to add a dependency on the AWS SDK for Java
-
In your IDE, open the project's
pom.xmlfile. -
Add the following dependency in the
dependenciessection.<dependency> <groupId>com.amazonaws</groupId> <artifactId>aws-java-sdk-ec2</artifactId> <version>1.11.606</version> </dependency>This artifact will be added by Maven from the Maven Repository
.
For more information on how to add dependencies, see the Maven documentation
Note
Depending on your IDE, you may have to take additional steps for your IDE to include the new dependency.
In IntelliJ IDEA, a dialog should appear to enable you to import these changes. We recommend allowing automatic importing.
Implement the Create handler
With the necessary dependency specified, we can now start writing the handlers
that actually implement the resource's functionality. For our example resource,
we'll implement just the create and delete operation
handlers.
To create a WordPress site, our resource create handler will have to
accomplish the following:
-
Gather and define inputs that we'll need to create the underlying AWS resources on behalf of the user. These are details we're managing for them, since this is a very high-level resource type.
-
Create an Amazon EC2 instance using a special AMI vended by Bitnami from the AMI Marketplace that bootstraps WordPress.
-
Create a security group that the instance will belong to so you can access the WordPress site from your browser.
-
Change the security group rules to dictate what the networking rules are for web access to the WordPress site.
-
If something goes wrong with creating the resource, attempt to delete the security group.
Define the CallbackContext
Because our create handler is more complex than simply calling a single API,
it takes some time to complete. However, each handler times out after one
minute. To work around this issue, we'll write our handlers as state machines. A
handler can exit with one of three states: SUCCESS,
IN_PROGRESS, and FAILED. To wait on stabilization
of underlying resources, we can return an IN_PROGRESS state with a
CallbackContext. The CallbackContext will hold details about the current state
of the execution. When we return an IN_PROGRESS state and a
CallbackContext, CloudFormation will re-invoke the handler and pass the
CallbackContext in with the request. You can then make decisions based on what
is included in the context.
The CallbackContext is modeled as a POJO so you can define what information you want to pass between state transitions explicitly.
-
In your IDE, open the
CallbackContext.javafile, located in thesrc/main/java/com/example/testing/wordpressfolder. -
Replace the entire contents of the
CallbackContext.javafile with the following code.package com.example.testing.wordpress; import com.amazonaws.services.ec2.model.Instance; import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Builder(toBuilder = true) @Data @NoArgsConstructor @AllArgsConstructor public class CallbackContext { private Instance instance; private Integer stabilizationRetriesRemaining; private List<String> instanceSecurityGroups; }
Code the Create handler
-
In your IDE, open the
CreateHandler.javafile, located in thesrc/main/java/com/example/testing/wordpress/CreateHandler.javafolder. -
Replace the entire contents of the
CreateHandler.javafile with the following code.package com.example.testing.wordpress; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.OperationStatus; import software.amazon.cloudformation.proxy.ProgressEvent; import software.amazon.cloudformation.proxy.ResourceHandlerRequest; import com.amazonaws.services.ec2.AmazonEC2; import com.amazonaws.services.ec2.AmazonEC2ClientBuilder; import com.amazonaws.services.ec2.model.AuthorizeSecurityGroupIngressRequest; import com.amazonaws.services.ec2.model.CreateSecurityGroupRequest; import com.amazonaws.services.ec2.model.DeleteSecurityGroupRequest; import com.amazonaws.services.ec2.model.DescribeInstancesRequest; import com.amazonaws.services.ec2.model.DescribeInstancesResult; import com.amazonaws.services.ec2.model.DescribeSubnetsRequest; import com.amazonaws.services.ec2.model.DescribeSubnetsResult; import com.amazonaws.services.ec2.model.Instance; import com.amazonaws.services.ec2.model.InstanceNetworkInterfaceSpecification; import com.amazonaws.services.ec2.model.IpPermission; import com.amazonaws.services.ec2.model.IpRange; import com.amazonaws.services.ec2.model.Reservation; import com.amazonaws.services.ec2.model.RunInstancesRequest; import com.amazonaws.services.ec2.model.Subnet; import com.amazonaws.services.ec2.model.Tag; import com.amazonaws.services.ec2.model.TagSpecification; import java.util.List; import java.util.UUID; public class CreateHandler extends BaseHandler<CallbackContext> { private static final String SUPPORTED_REGION = "us-west-2"; private static final String WORDPRESS_AMI_ID = "ami-04fb0368671b6f138"; private static final String INSTANCE_TYPE = "m4.large"; private static final String SITE_NAME_TAG_KEY = "Name"; private static final String AVAILABLE_INSTANCE_STATE = "running"; private static final int NUMBER_OF_STATE_POLL_RETRIES = 60; private static final int POLL_RETRY_DELAY_IN_MS = 5000; private static final String TIMED_OUT_MESSAGE = "Timed out waiting for instance to become available."; private AmazonWebServicesClientProxy clientProxy; private AmazonEC2 ec2Client; @Override public ProgressEvent<ResourceModel, CallbackContext> handleRequest( final AmazonWebServicesClientProxy proxy, final ResourceHandlerRequest<ResourceModel> request, final CallbackContext callbackContext, final Logger logger) { final ResourceModel model = request.getDesiredResourceState(); clientProxy = proxy; ec2Client = AmazonEC2ClientBuilder.standard().withRegion(SUPPORTED_REGION).build(); final CallbackContext currentContext = callbackContext == null ? CallbackContext.builder().stabilizationRetriesRemaining(NUMBER_OF_STATE_POLL_RETRIES).build() : callbackContext; // This Lambda will continually be re-invoked with the current state of the instance, finally succeeding when state stabilizes. return createInstanceAndUpdateProgress(model, currentContext); } private ProgressEvent<ResourceModel, CallbackContext> createInstanceAndUpdateProgress(ResourceModel model, CallbackContext callbackContext) { // This Lambda will continually be re-invoked with the current state of the instance, finally succeeding when state stabilizes. final Instance instanceStateSoFar = callbackContext.getInstance(); if (callbackContext.getStabilizationRetriesRemaining() == 0) { throw new RuntimeException(TIMED_OUT_MESSAGE); } if (instanceStateSoFar == null) { return ProgressEvent.<ResourceModel, CallbackContext>builder() .resourceModel(model) .status(OperationStatus.IN_PROGRESS) .callbackContext(CallbackContext.builder() .instance(createEC2Instance(model)) .stabilizationRetriesRemaining(NUMBER_OF_STATE_POLL_RETRIES) .build()) .build(); } else if (instanceStateSoFar.getState().getName().equals(AVAILABLE_INSTANCE_STATE)) { model.setInstanceId(instanceStateSoFar.getInstanceId()); model.setPublicIp(instanceStateSoFar.getPublicIpAddress()); return ProgressEvent.<ResourceModel, CallbackContext>builder() .resourceModel(model) .status(OperationStatus.SUCCESS) .build(); } else { try { Thread.sleep(POLL_RETRY_DELAY_IN_MS); } catch (InterruptedException e) { throw new RuntimeException(e); } return ProgressEvent.<ResourceModel, CallbackContext>builder() .resourceModel(model) .status(OperationStatus.IN_PROGRESS) .callbackContext(CallbackContext.builder() .instance(updatedInstanceProgress(instanceStateSoFar.getInstanceId())) .stabilizationRetriesRemaining(callbackContext.getStabilizationRetriesRemaining() - 1) .build()) .build(); } } private Instance createEC2Instance(ResourceModel model) { final String securityGroupId = createSecurityGroupForInstance(model); final RunInstancesRequest runInstancesRequest = new RunInstancesRequest() .withInstanceType(INSTANCE_TYPE) .withImageId(WORDPRESS_AMI_ID) .withNetworkInterfaces(new InstanceNetworkInterfaceSpecification() .withAssociatePublicIpAddress(true) .withDeviceIndex(0) .withGroups(securityGroupId) .withSubnetId(model.getSubnetId())) .withMaxCount(1) .withMinCount(1) .withTagSpecifications(buildTagFromSiteName(model.getName())); try { return clientProxy.injectCredentialsAndInvoke(runInstancesRequest, ec2Client::runInstances) .getReservation() .getInstances() .stream() .findFirst() .orElse(new Instance()); } catch (Throwable e) { attemptToCleanUpSecurityGroup(securityGroupId); throw new RuntimeException(e); } } private String createSecurityGroupForInstance(ResourceModel model) { String vpcId; try { vpcId = getVpcIdFromSubnetId(model.getSubnetId()); } catch (Throwable e) { throw new RuntimeException(e); } final String securityGroupName = model.getName() + "-" + UUID.randomUUID().toString(); final CreateSecurityGroupRequest createSecurityGroupRequest = new CreateSecurityGroupRequest() .withGroupName(securityGroupName) .withDescription("Created for the test WordPress blog: " + model.getName()) .withVpcId(vpcId); final String securityGroupId = clientProxy.injectCredentialsAndInvoke(createSecurityGroupRequest, ec2Client::createSecurityGroup) .getGroupId(); final AuthorizeSecurityGroupIngressRequest authorizeSecurityGroupIngressRequest = new AuthorizeSecurityGroupIngressRequest() .withGroupId(securityGroupId) .withIpPermissions(openHTTP(), openHTTPS()); clientProxy.injectCredentialsAndInvoke(authorizeSecurityGroupIngressRequest, ec2Client::authorizeSecurityGroupIngress); return securityGroupId; } private String getVpcIdFromSubnetId(String subnetId) throws Throwable { final DescribeSubnetsRequest describeSubnetsRequest = new DescribeSubnetsRequest() .withSubnetIds(subnetId); final DescribeSubnetsResult describeSubnetsResult = clientProxy.injectCredentialsAndInvoke(describeSubnetsRequest, ec2Client::describeSubnets); return describeSubnetsResult.getSubnets() .stream() .map(Subnet::getVpcId) .findFirst() .orElseThrow(() -> { throw new RuntimeException("Subnet " + subnetId + " not found"); }); } private IpPermission openHTTP() { return new IpPermission().withIpProtocol("tcp") .withFromPort(80) .withToPort(80) .withIpv4Ranges(new IpRange().withCidrIp("0.0.0.0/0")); } private IpPermission openHTTPS() { return new IpPermission().withIpProtocol("tcp") .withFromPort(443) .withToPort(443) .withIpv4Ranges(new IpRange().withCidrIp("0.0.0.0/0")); } private TagSpecification buildTagFromSiteName(String siteName) { return new TagSpecification() .withResourceType("instance") .withTags(new Tag().withKey(SITE_NAME_TAG_KEY).withValue(siteName)); } private Instance updatedInstanceProgress(String instanceId) { DescribeInstancesRequest describeInstancesRequest; DescribeInstancesResult describeInstancesResult; describeInstancesRequest = new DescribeInstancesRequest().withInstanceIds(instanceId); describeInstancesResult = clientProxy.injectCredentialsAndInvoke(describeInstancesRequest, ec2Client::describeInstances); return describeInstancesResult.getReservations() .stream() .map(Reservation::getInstances) .flatMap(List::stream) .findFirst() .orElse(new Instance()); } private void attemptToCleanUpSecurityGroup(String securityGroupId) { final DeleteSecurityGroupRequest deleteSecurityGroupRequest = new DeleteSecurityGroupRequest().withGroupId(securityGroupId); clientProxy.injectCredentialsAndInvoke(deleteSecurityGroupRequest, ec2Client::deleteSecurityGroup); } }
Update the Create handler unit test
Because our resource type is a high-level abstraction, a lot of implementation behavior isn't apparent by the name alone. As such, we'll need to make some additions to our unit tests so that we're not calling the live API operations that are necessary to create the WordPress site.
-
In your IDE, open the
CreateHandlerTest.javafile, located in thesrc/test/java/com/example/testing/wordpressfolder. -
Replace the entire contents of the
CreateHandlerTest.javafile with the following code.package com.example.testing.wordpress; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.OperationStatus; import software.amazon.cloudformation.proxy.ProgressEvent; import software.amazon.cloudformation.proxy.ResourceHandlerRequest; import com.amazonaws.services.ec2.model.AuthorizeSecurityGroupIngressRequest; import com.amazonaws.services.ec2.model.AuthorizeSecurityGroupIngressResult; import com.amazonaws.services.ec2.model.CreateSecurityGroupRequest; import com.amazonaws.services.ec2.model.CreateSecurityGroupResult; import com.amazonaws.services.ec2.model.DescribeInstancesRequest; import com.amazonaws.services.ec2.model.DescribeInstancesResult; import com.amazonaws.services.ec2.model.DescribeSubnetsRequest; import com.amazonaws.services.ec2.model.DescribeSubnetsResult; import com.amazonaws.services.ec2.model.GroupIdentifier; import com.amazonaws.services.ec2.model.Instance; import com.amazonaws.services.ec2.model.InstanceState; import com.amazonaws.services.ec2.model.Reservation; import com.amazonaws.services.ec2.model.RunInstancesRequest; import com.amazonaws.services.ec2.model.RunInstancesResult; import com.amazonaws.services.ec2.model.Subnet; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) public class CreateHandlerTest { private static String EXPECTED_TIMEOUT_MESSAGE = "Timed out waiting for instance to become available."; @Mock private AmazonWebServicesClientProxy proxy; @Mock private Logger logger; @BeforeEach public void setup() { proxy = mock(AmazonWebServicesClientProxy.class); logger = mock(Logger.class); } @Test public void testSuccessState() { final InstanceState inProgressState = new InstanceState().withName("running"); final GroupIdentifier group = new GroupIdentifier().withGroupId("sg-1234"); final Instance instance = new Instance().withInstanceId("i-1234").withState(inProgressState).withPublicIpAddress("54.0.0.0").withSecurityGroups(group); final CreateHandler handler = new CreateHandler(); final ResourceModel model = ResourceModel.builder() .name("MyWordPressSite") .subnetId("subnet-1234") .build(); final ResourceModel desiredOutputModel = ResourceModel.builder() .instanceId("i-1234") .publicIp("54.0.0.0") .name("MyWordPressSite") .subnetId("subnet-1234") .build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final CallbackContext context = CallbackContext.builder() .stabilizationRetriesRemaining(1) .instance(instance) .build(); final ProgressEvent<ResourceModel, CallbackContext> response = handler.handleRequest(proxy, request, context, logger); assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModel()).isEqualTo(desiredOutputModel); assertThat(response.getResourceModels()).isNull(); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @Test public void testInProgressStateInstanceCreationNotInvoked() { final InstanceState inProgressState = new InstanceState().withName("in-progress"); final GroupIdentifier group = new GroupIdentifier().withGroupId("sg-1234"); final Instance instance = new Instance().withState(inProgressState).withPublicIpAddress("54.0.0.0").withSecurityGroups(group); doReturn(new DescribeSubnetsResult().withSubnets(new Subnet().withVpcId("vpc-1234"))).when(proxy).injectCredentialsAndInvoke(any(DescribeSubnetsRequest.class), any()); doReturn(new RunInstancesResult().withReservation(new Reservation().withInstances(instance))).when(proxy).injectCredentialsAndInvoke(any(RunInstancesRequest.class), any()); doReturn(new CreateSecurityGroupResult().withGroupId("sg-1234")).when(proxy).injectCredentialsAndInvoke(any(CreateSecurityGroupRequest.class), any()); doReturn(new AuthorizeSecurityGroupIngressResult()).when(proxy).injectCredentialsAndInvoke(any(AuthorizeSecurityGroupIngressRequest.class), any()); final CreateHandler handler = new CreateHandler(); final ResourceModel model = ResourceModel.builder().name("MyWordPressSite").subnetId("subnet-1234").build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final ProgressEvent<ResourceModel, CallbackContext> response = handler.handleRequest(proxy, request, null, logger); final CallbackContext desiredOutputContext = CallbackContext.builder() .stabilizationRetriesRemaining(60) .instance(instance) .build(); assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.IN_PROGRESS); assertThat(response.getCallbackContext()).isEqualToComparingFieldByField(desiredOutputContext); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); assertThat(response.getResourceModels()).isNull(); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @Test public void testInProgressStateInstanceCreationInvoked() { final InstanceState inProgressState = new InstanceState().withName("in-progress"); final GroupIdentifier group = new GroupIdentifier().withGroupId("sg-1234"); final Instance instance = new Instance().withState(inProgressState).withPublicIpAddress("54.0.0.0").withSecurityGroups(group); final DescribeInstancesResult describeInstancesResult = new DescribeInstancesResult().withReservations(new Reservation().withInstances(instance)); doReturn(describeInstancesResult).when(proxy).injectCredentialsAndInvoke(any(DescribeInstancesRequest.class), any()); final CreateHandler handler = new CreateHandler(); final ResourceModel model = ResourceModel.builder().name("MyWordPressSite").subnetId("subnet-1234").build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final CallbackContext context = CallbackContext.builder() .stabilizationRetriesRemaining(60) .instance(instance) .build(); final ProgressEvent<ResourceModel, CallbackContext> response = handler.handleRequest(proxy, request, context, logger); final CallbackContext desiredOutputContext = CallbackContext.builder() .stabilizationRetriesRemaining(59) .instance(instance) .build(); assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.IN_PROGRESS); assertThat(response.getCallbackContext()).isEqualToComparingFieldByField(desiredOutputContext); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); assertThat(response.getResourceModels()).isNull(); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @Test public void testStabilizationTimeout() { final CreateHandler handler = new CreateHandler(); final ResourceModel model = ResourceModel.builder().name("MyWordPressSite").subnetId("subnet-1234").build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final CallbackContext context = CallbackContext.builder() .stabilizationRetriesRemaining(0) .instance(new Instance().withState(new InstanceState().withName("in-progress"))) .build(); try { handler.handleRequest(proxy, request, context, logger); } catch (RuntimeException e) { assertThat(e.getMessage()).isEqualTo(EXPECTED_TIMEOUT_MESSAGE); } } }
Implement the Delete handler
We'll also need to implement a delete handler. At a high level, the delete handler needs to accomplish the following:
-
Find the security groups attached to the Amazon EC2 instance that's hosting the WordPress page.
-
Delete the instance.
-
Delete the security groups.
Again, we'll implement the delete handler as a state machine.
Code the Delete handler
-
In your IDE, open the
DeleteHandler.javafile, located in thesrc/main/java/com/example/testing/wordpressfolder. -
Replace the entire contents of the
DeleteHandler.javafile with the following code.package com.example.testing.wordpress; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.HandlerErrorCode; import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.OperationStatus; import software.amazon.cloudformation.proxy.ProgressEvent; import software.amazon.cloudformation.proxy.ResourceHandlerRequest; import com.amazonaws.services.ec2.AmazonEC2; import com.amazonaws.services.ec2.AmazonEC2ClientBuilder; import com.amazonaws.services.ec2.model.DeleteSecurityGroupRequest; import com.amazonaws.services.ec2.model.DescribeInstancesRequest; import com.amazonaws.services.ec2.model.DescribeInstancesResult; import com.amazonaws.services.ec2.model.GroupIdentifier; import com.amazonaws.services.ec2.model.Instance; import com.amazonaws.services.ec2.model.Reservation; import com.amazonaws.services.ec2.model.TerminateInstancesRequest; import java.util.List; import java.util.stream.Collectors; public class DeleteHandler extends BaseHandler<CallbackContext> { private static final String SUPPORTED_REGION = "us-west-2"; private static final String DELETED_INSTANCE_STATE = "terminated"; private static final int NUMBER_OF_STATE_POLL_RETRIES = 60; private static final int POLL_RETRY_DELAY_IN_MS = 5000; private static final String TIMED_OUT_MESSAGE = "Timed out waiting for instance to terminate."; private AmazonWebServicesClientProxy clientProxy; private AmazonEC2 ec2Client; @Override public ProgressEvent<ResourceModel, CallbackContext> handleRequest ( final AmazonWebServicesClientProxy proxy, final ResourceHandlerRequest<ResourceModel> request, final CallbackContext callbackContext, final Logger logger) { final ResourceModel model = request.getDesiredResourceState(); clientProxy = proxy; ec2Client = AmazonEC2ClientBuilder.standard().withRegion(SUPPORTED_REGION).build(); final CallbackContext currentContext = callbackContext == null ? CallbackContext.builder().stabilizationRetriesRemaining(NUMBER_OF_STATE_POLL_RETRIES).build() : callbackContext; // This Lambda will continually be re-invoked with the current state of the instance, finally succeeding when state stabilizes. return deleteInstanceAndUpdateProgress(model, currentContext); } private ProgressEvent<ResourceModel, CallbackContext> deleteInstanceAndUpdateProgress(ResourceModel model, CallbackContext callbackContext) { if (callbackContext.getStabilizationRetriesRemaining() == 0) { throw new RuntimeException(TIMED_OUT_MESSAGE); } if (callbackContext.getInstanceSecurityGroups() == null) { final Instance currentInstanceState = currentInstanceState(model.getInstanceId()); if (DELETED_INSTANCE_STATE.equals(currentInstanceState.getState().getName())) { return ProgressEvent.<ResourceModel, CallbackContext>builder() .status(OperationStatus.FAILED) .errorCode(HandlerErrorCode.NotFound) .build(); } final List<String> instanceSecurityGroups = currentInstanceState .getSecurityGroups() .stream() .map(GroupIdentifier::getGroupId) .collect(Collectors.toList()); return ProgressEvent.<ResourceModel, CallbackContext>builder() .resourceModel(model) .status(OperationStatus.IN_PROGRESS) .callbackContext(CallbackContext.builder() .stabilizationRetriesRemaining(NUMBER_OF_STATE_POLL_RETRIES) .instanceSecurityGroups(instanceSecurityGroups) .build()) .build(); } if (callbackContext.getInstance() == null) { return ProgressEvent.<ResourceModel, CallbackContext>builder() .resourceModel(model) .status(OperationStatus.IN_PROGRESS) .callbackContext(CallbackContext.builder() .instance(deleteInstance(model.getInstanceId())) .instanceSecurityGroups(callbackContext.getInstanceSecurityGroups()) .stabilizationRetriesRemaining(NUMBER_OF_STATE_POLL_RETRIES) .build()) .build(); } else if (callbackContext.getInstance().getState().getName().equals(DELETED_INSTANCE_STATE)) { callbackContext.getInstanceSecurityGroups().forEach(this::deleteSecurityGroup); return ProgressEvent.<ResourceModel, CallbackContext>builder() .resourceModel(model) .status(OperationStatus.SUCCESS) .build(); } else { try { Thread.sleep(POLL_RETRY_DELAY_IN_MS); } catch (InterruptedException e) { throw new RuntimeException(e); } return ProgressEvent.<ResourceModel, CallbackContext>builder() .resourceModel(model) .status(OperationStatus.IN_PROGRESS) .callbackContext(CallbackContext.builder() .instance(currentInstanceState(model.getInstanceId())) .instanceSecurityGroups(callbackContext.getInstanceSecurityGroups()) .stabilizationRetriesRemaining(callbackContext.getStabilizationRetriesRemaining() - 1) .build()) .build(); } } private Instance deleteInstance(String instanceId) { final TerminateInstancesRequest terminateInstancesRequest = new TerminateInstancesRequest().withInstanceIds(instanceId); return clientProxy.injectCredentialsAndInvoke(terminateInstancesRequest, ec2Client::terminateInstances) .getTerminatingInstances() .stream() .map(instance -> new Instance().withState(instance.getCurrentState()).withInstanceId(instance.getInstanceId())) .findFirst() .orElse(new Instance()); } private Instance currentInstanceState(String instanceId) { DescribeInstancesRequest describeInstancesRequest; DescribeInstancesResult describeInstancesResult; describeInstancesRequest = new DescribeInstancesRequest().withInstanceIds(instanceId); describeInstancesResult = clientProxy.injectCredentialsAndInvoke(describeInstancesRequest, ec2Client::describeInstances); return describeInstancesResult.getReservations() .stream() .map(Reservation::getInstances) .flatMap(List::stream) .findFirst() .orElse(new Instance()); } private void deleteSecurityGroup(String securityGroupId) { final DeleteSecurityGroupRequest deleteSecurityGroupRequest = new DeleteSecurityGroupRequest().withGroupId(securityGroupId); clientProxy.injectCredentialsAndInvoke(deleteSecurityGroupRequest, ec2Client::deleteSecurityGroup); } }
Update the Delete handler unit test
We'll also need to update the unit test for the delete handler.
-
In your IDE, open the
DeleteHandlerTest.javafile, located in thesrc/test/java/com/example/testing/wordpressfolder. -
Replace the entire contents of the
DeleteHandlerTest.javafile with the following code.package com.example.testing.wordpress; import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; import software.amazon.cloudformation.proxy.HandlerErrorCode; import software.amazon.cloudformation.proxy.Logger; import software.amazon.cloudformation.proxy.OperationStatus; import software.amazon.cloudformation.proxy.ProgressEvent; import software.amazon.cloudformation.proxy.ResourceHandlerRequest; import com.amazonaws.services.ec2.model.DeleteSecurityGroupRequest; import com.amazonaws.services.ec2.model.DeleteSecurityGroupResult; import com.amazonaws.services.ec2.model.DescribeInstancesRequest; import com.amazonaws.services.ec2.model.DescribeInstancesResult; import com.amazonaws.services.ec2.model.GroupIdentifier; import com.amazonaws.services.ec2.model.Instance; import com.amazonaws.services.ec2.model.InstanceState; import com.amazonaws.services.ec2.model.InstanceStateChange; import com.amazonaws.services.ec2.model.Reservation; import com.amazonaws.services.ec2.model.TerminateInstancesRequest; import com.amazonaws.services.ec2.model.TerminateInstancesResult; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) public class DeleteHandlerTest { private static String EXPECTED_TIMEOUT_MESSAGE = "Timed out waiting for instance to terminate."; @Mock private AmazonWebServicesClientProxy proxy; @Mock private Logger logger; @BeforeEach public void setup() { proxy = mock(AmazonWebServicesClientProxy.class); logger = mock(Logger.class); } @Test public void testSuccessState() { final DeleteSecurityGroupResult deleteSecurityGroupResult = new DeleteSecurityGroupResult(); doReturn(deleteSecurityGroupResult).when(proxy).injectCredentialsAndInvoke(any(DeleteSecurityGroupRequest.class), any()); final DeleteHandler handler = new DeleteHandler(); final ResourceModel model = ResourceModel.builder().instanceId("i-1234").build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final CallbackContext context = CallbackContext.builder() .stabilizationRetriesRemaining(1) .instanceSecurityGroups(Arrays.asList("sg-1234")) .instance(new Instance().withState(new InstanceState().withName("terminated"))) .build(); final ProgressEvent<ResourceModel, CallbackContext> response = handler.handleRequest(proxy, request, context, logger); assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); assertThat(response.getResourceModels()).isNull(); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @Test public void testHandlerInvokedWhenInstanceIsAlreadyTerminated() { final DescribeInstancesResult describeInstancesResult = new DescribeInstancesResult().withReservations(new Reservation().withInstances(new Instance().withState(new InstanceState().withName("terminated")) .withSecurityGroups(new GroupIdentifier().withGroupId("sg-1234")))); doReturn(describeInstancesResult).when(proxy).injectCredentialsAndInvoke(any(DescribeInstancesRequest.class), any()); final DeleteHandler handler = new DeleteHandler(); final ResourceModel model = ResourceModel.builder().instanceId("i-1234").build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final ProgressEvent<ResourceModel, CallbackContext> response = handler.handleRequest(proxy, request, null, logger); assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.FAILED); assertThat(response.getCallbackContext()).isNull(); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModel()).isNull(); assertThat(response.getResourceModels()).isNull(); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isEqualTo(HandlerErrorCode.NotFound); } @Test public void testInProgressStateSecurityGroupsNotGathered() { final DescribeInstancesResult describeInstancesResult = new DescribeInstancesResult().withReservations(new Reservation().withInstances(new Instance().withState(new InstanceState().withName("running")) .withSecurityGroups(new GroupIdentifier().withGroupId("sg-1234")))); doReturn(describeInstancesResult).when(proxy).injectCredentialsAndInvoke(any(DescribeInstancesRequest.class), any()); final DeleteHandler handler = new DeleteHandler(); final ResourceModel model = ResourceModel.builder().instanceId("i-1234").build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final ProgressEvent<ResourceModel, CallbackContext> response = handler.handleRequest(proxy, request, null, logger); final CallbackContext desiredOutputContext = CallbackContext.builder() .stabilizationRetriesRemaining(60) .instanceSecurityGroups(Arrays.asList("sg-1234")) .build(); assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.IN_PROGRESS); assertThat(response.getCallbackContext()).isEqualToComparingFieldByField(desiredOutputContext); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); assertThat(response.getResourceModels()).isNull(); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @Test public void testInProgressStateSecurityGroupsGathered() { final InstanceState inProgressState = new InstanceState().withName("in-progress"); final TerminateInstancesResult terminateInstancesResult = new TerminateInstancesResult().withTerminatingInstances(new InstanceStateChange().withCurrentState(inProgressState)); doReturn(terminateInstancesResult).when(proxy).injectCredentialsAndInvoke(any(TerminateInstancesRequest.class), any()); final DeleteHandler handler = new DeleteHandler(); final ResourceModel model = ResourceModel.builder().instanceId("i-1234").build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final CallbackContext context = CallbackContext.builder() .stabilizationRetriesRemaining(60) .instanceSecurityGroups(Arrays.asList("sg-1234")) .build(); final ProgressEvent<ResourceModel, CallbackContext> response = handler.handleRequest(proxy, request, context, logger); final CallbackContext desiredOutputContext = CallbackContext.builder() .stabilizationRetriesRemaining(60) .instanceSecurityGroups(context.getInstanceSecurityGroups()) .instance(new Instance().withState(inProgressState)) .build(); assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.IN_PROGRESS); assertThat(response.getCallbackContext()).isEqualToComparingFieldByField(desiredOutputContext); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); assertThat(response.getResourceModels()).isNull(); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @Test public void testInProgressStateInstanceTerminationInvoked() { final InstanceState inProgressState = new InstanceState().withName("in-progress"); final GroupIdentifier group = new GroupIdentifier().withGroupId("sg-1234"); final Instance instance = new Instance().withState(inProgressState).withSecurityGroups(group); final DescribeInstancesResult describeInstancesResult = new DescribeInstancesResult().withReservations(new Reservation().withInstances(instance)); doReturn(describeInstancesResult).when(proxy).injectCredentialsAndInvoke(any(DescribeInstancesRequest.class), any()); final DeleteHandler handler = new DeleteHandler(); final ResourceModel model = ResourceModel.builder().instanceId("i-1234").build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final CallbackContext context = CallbackContext.builder() .stabilizationRetriesRemaining(60) .instance(new Instance().withState(inProgressState).withSecurityGroups(group)) .instanceSecurityGroups(Arrays.asList("sg-1234")) .build(); final ProgressEvent<ResourceModel, CallbackContext> response = handler.handleRequest(proxy, request, context, logger); final CallbackContext desiredOutputContext = CallbackContext.builder() .stabilizationRetriesRemaining(59) .instanceSecurityGroups(context.getInstanceSecurityGroups()) .instance(new Instance().withState(inProgressState).withSecurityGroups(group)) .build(); assertThat(response).isNotNull(); assertThat(response.getStatus()).isEqualTo(OperationStatus.IN_PROGRESS); assertThat(response.getCallbackContext()).isEqualToComparingFieldByField(desiredOutputContext); assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState()); assertThat(response.getResourceModels()).isNull(); assertThat(response.getMessage()).isNull(); assertThat(response.getErrorCode()).isNull(); } @Test public void testStabilizationTimeout() { final DeleteHandler handler = new DeleteHandler(); final ResourceModel model = ResourceModel.builder().instanceId("i-1234").build(); final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder() .desiredResourceState(model) .build(); final CallbackContext context = CallbackContext.builder() .stabilizationRetriesRemaining(0) .instanceSecurityGroups(Arrays.asList("sg-1234")) .instance(new Instance().withState(new InstanceState().withName("terminated"))) .build(); try { handler.handleRequest(proxy, request, context, logger); } catch (RuntimeException e) { assertThat(e.getMessage()).isEqualTo(EXPECTED_TIMEOUT_MESSAGE); } } }
Test the resource type
Next, we'll use the AWS SAM CLI to test locally that our resource will work as expected once we submit it to the CloudFormation registry. To do this, we'll need to define tests for the SAM to run against our create and delete handlers.
Create the SAM test files
-
Create two files:
-
package-root/sam-tests/create.json -
package-root/sam-tests/delete.json
Where
package-rootis the root of the resource project. For our walkthrough example, the files would be:-
example-testing-wordpress/sam-tests/create.json -
example-testing-wordpress/sam-tests/delete.json
-
-
In
example-testing-wordpress/sam-tests/create.json, paste the following test.Add the necessary information, such as credentials and log group name, and remove any comments in the file before testing.
For
credentials, use temporary credentials for an IAM role (such asPowerUserAccessorDeveloper) in your personal AWS account. Specify theaccessKeyId,secretAccessKey, andsessionTokenwith their corresponding values. For instructions on how to copy IAM role credentials from the AWS access portal, see Manual credential refresh in the AWS IAM Identity Center User Guide. The settings for the IAM role you choose determine how long the temporary credentials are valid.Using the AWS CLI, you can call an AWS STS API like
AssumeRoleorGetFederationTokenand then capture the resulting output. For more information, see Using temporary credentials with AWS resources in the IAM User Guide.