+ * The method itself does nothing but subclasses may override to provide additional logging or handling logic. All + * arguments provided are for contextual purposes. + *
+ * Exceptions should not be thrown by this method. + * + * @param event the event + * @param context execution context + * @param response the response object that was attempted to be sent to the custom resource + * @param exception the exception caught when attempting to call the custom resource URL + */ + @SuppressWarnings("unused") + protected void onSendFailure(CloudFormationCustomResourceEvent event, + Context context, + Response response, + Exception exception) { + // intentionally empty + } + + /** + * Returns the response object to send to the custom CloudFormation resource upon its creation. If this method + * returns null, then the handler will send a successful but empty response to the CloudFormation resource. If this + * method throws a RuntimeException, the handler will send an empty failed response to the resource. + * + * @param event an event of request type Create + * @param context execution context + * @return the response object or null + */ + protected abstract Response create(CloudFormationCustomResourceEvent event, Context context); + + /** + * Returns the response object to send to the custom CloudFormation resource upon its modification. If the method + * returns null, then the handler will send a successful but empty response to the CloudFormation resource. If this + * method throws a RuntimeException, the handler will send an empty failed response to the resource. + * + * @param event an event of request type Update + * @param context execution context + * @return the response object or null + */ + protected abstract Response update(CloudFormationCustomResourceEvent event, Context context); + + /** + * Returns the response object to send to the custom CloudFormation resource upon its deletion. If this method + * returns null, then the handler will send a successful but empty response to the CloudFormation resource. If this + * method throws a RuntimeException, the handler will send an empty failed response to the resource. + * + * @param event an event of request type Delete + * @param context execution context + * @return the response object or null + */ + protected abstract Response delete(CloudFormationCustomResourceEvent event, Context context); +} diff --git a/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java new file mode 100644 index 000000000..33cc533d2 --- /dev/null +++ b/powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java @@ -0,0 +1,218 @@ +package software.amazon.lambda.powertools.cloudformation; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.CloudFormationCustomResourceEvent; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.node.ObjectNode; +import software.amazon.awssdk.http.Header; +import software.amazon.awssdk.http.HttpExecuteRequest; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.utils.StringInputStream; + +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Client for sending responses to AWS CloudFormation custom resources by way of a response URL, which is an Amazon S3 + * pre-signed URL. + *
+ * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html + *
+ * This class is thread-safe provided the SdkHttpClient instance used is also thread-safe.
+ */
+class CloudFormationResponse {
+
+ /**
+ * Internal representation of the payload to be sent to the event target URL. Retains all properties of the payload
+ * except for "Data". This is done so that the serialization of the non-"Data" properties and the serialization of
+ * the value of "Data" can be handled by separate ObjectMappers, if need be. The former properties are dictated by
+ * the custom resource but the latter is dictated by the implementor of the custom resource handler.
+ */
+ @SuppressWarnings("unused")
+ static class ResponseBody {
+ static final ObjectMapper MAPPER = new ObjectMapper()
+ .setPropertyNamingStrategy(PropertyNamingStrategies.UPPER_CAMEL_CASE);
+ private static final String DATA_PROPERTY_NAME = "Data";
+
+ private final String status;
+ private final String reason;
+ private final String physicalResourceId;
+ private final String stackId;
+ private final String requestId;
+ private final String logicalResourceId;
+ private final boolean noEcho;
+
+ ResponseBody(CloudFormationCustomResourceEvent event,
+ Context context,
+ Response.Status responseStatus,
+ String physicalResourceId,
+ boolean noEcho) {
+ Objects.requireNonNull(event, "CloudFormationCustomResourceEvent cannot be null");
+ Objects.requireNonNull(context, "Context cannot be null");
+ this.physicalResourceId = physicalResourceId != null ? physicalResourceId : context.getLogStreamName();
+ this.reason = "See the details in CloudWatch Log Stream: " + context.getLogStreamName();
+ this.status = responseStatus == null ? Response.Status.SUCCESS.name() : responseStatus.name();
+ this.stackId = event.getStackId();
+ this.requestId = event.getRequestId();
+ this.logicalResourceId = event.getLogicalResourceId();
+ this.noEcho = noEcho;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public String getReason() {
+ return reason;
+ }
+
+ public String getPhysicalResourceId() {
+ return physicalResourceId;
+ }
+
+ public String getStackId() {
+ return stackId;
+ }
+
+ public String getRequestId() {
+ return requestId;
+ }
+
+ public String getLogicalResourceId() {
+ return logicalResourceId;
+ }
+
+ public boolean isNoEcho() {
+ return noEcho;
+ }
+
+ /**
+ * Returns this ResponseBody as an ObjectNode with the provided JsonNode as the value of its "Data" property.
+ *
+ * @param dataNode the value of the "Data" property for the returned node; may be null
+ * @return an ObjectNode representation of this ResponseBody and the provided dataNode
+ */
+ ObjectNode toObjectNode(JsonNode dataNode) {
+ ObjectNode node = MAPPER.valueToTree(this);
+ if (dataNode == null) {
+ node.putNull(DATA_PROPERTY_NAME);
+ } else {
+ node.set(DATA_PROPERTY_NAME, dataNode);
+ }
+ return node;
+ }
+ }
+
+ private final SdkHttpClient client;
+
+ /**
+ * Creates a new CloudFormationResponse that uses the provided HTTP client and default JSON serialization format.
+ *
+ * @param client HTTP client to use for sending requests; cannot be null
+ */
+ CloudFormationResponse(SdkHttpClient client) {
+ this.client = Objects.requireNonNull(client, "SdkHttpClient cannot be null");
+ }
+
+ /**
+ * The underlying SdkHttpClient used by this class.
+ *
+ * @return a non-null client
+ */
+ SdkHttpClient getClient() {
+ return client;
+ }
+
+ /**
+ * Forwards a response containing a custom payload to the target resource specified by the event. The payload is
+ * formed from the event and context data. Status is assumed to be SUCCESS.
+ *
+ * @param event custom CF resource event. Cannot be null.
+ * @param context used to specify when the function and any callbacks have completed execution, or to
+ * access information from within the Lambda execution environment. Cannot be null.
+ * @return the response object
+ * @throws IOException when unable to send the request
+ * @throws CustomResourceResponseException when unable to synthesize or serialize the response payload
+ */
+ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event,
+ Context context) throws IOException, CustomResourceResponseException {
+ return send(event, context, null);
+ }
+
+ /**
+ * Forwards a response containing a custom payload to the target resource specified by the event. The payload is
+ * formed from the event, context, and response data.
+ *
+ * @param event custom CF resource event. Cannot be null.
+ * @param context used to specify when the function and any callbacks have completed execution, or to
+ * access information from within the Lambda execution environment. Cannot be null.
+ * @param responseData response to send, e.g. a list of name-value pairs. If null, an empty success is assumed.
+ * @return the response object
+ * @throws IOException when unable to generate or send the request
+ * @throws CustomResourceResponseException when unable to serialize the response payload
+ */
+ public HttpExecuteResponse send(CloudFormationCustomResourceEvent event,
+ Context context,
+ Response responseData) throws IOException, CustomResourceResponseException {
+ // no need to explicitly close in-memory stream
+ StringInputStream stream = responseBodyStream(event, context, responseData);
+ URI uri = URI.create(event.getResponseUrl());
+ SdkHttpRequest request = SdkHttpRequest.builder()
+ .uri(uri)
+ .method(SdkHttpMethod.PUT)
+ .headers(headers(stream.available()))
+ .build();
+ HttpExecuteRequest httpExecuteRequest = HttpExecuteRequest.builder()
+ .request(request)
+ .contentStreamProvider(() -> stream)
+ .build();
+ return client.prepareRequest(httpExecuteRequest).call();
+ }
+
+ /**
+ * Generates HTTP headers to be supplied in the CloudFormation request.
+ *
+ * @param contentLength the length of the payload
+ * @return HTTP headers
+ */
+ protected Map
+ * We strongly recommend not using these mechanisms to include sensitive information, such as passwords or
+ * secrets.
+ *
+ * For more information about using noEcho to mask sensitive information, see
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/best-practices.html#creds
+ *
+ * By default, this value is false.
+ *
+ * @param noEcho when true, masks certain output
+ * @return a reference to this builder
+ */
+ public Builder noEcho(boolean noEcho) {
+ this.noEcho = noEcho;
+ return this;
+ }
+
+ /**
+ * Builds a Response object for the value.
+ *
+ * @return a Response object wrapping the initially provided value.
+ */
+ public Response build() {
+ JsonNode node;
+ if (value == null) {
+ node = null;
+ } else {
+ ObjectMapper mapper = objectMapper != null ? objectMapper : CloudFormationResponse.ResponseBody.MAPPER;
+ node = mapper.valueToTree(value);
+ }
+ Status responseStatus = this.status != null ? this.status : Status.SUCCESS;
+ return new Response(node, responseStatus, physicalResourceId, noEcho);
+ }
+ }
+
+ /**
+ * Creates a builder for constructing a Response wrapping the provided value.
+ *
+ * @return a builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Creates an empty, failed Response.
+ *
+ * @return a failed Response with no value.
+ */
+ public static Response failed() {
+ return new Response(null, Status.FAILED, null, false);
+ }
+
+ /**
+ * Creates an empty, successful Response.
+ *
+ * @return a failed Response with no value.
+ */
+ public static Response success() {
+ return new Response(null, Status.SUCCESS, null, false);
+ }
+
+ private final JsonNode jsonNode;
+ private final Status status;
+ private final String physicalResourceId;
+ private final boolean noEcho;
+
+ private Response(JsonNode jsonNode, Status status, String physicalResourceId, boolean noEcho) {
+ this.jsonNode = jsonNode;
+ this.status = status;
+ this.physicalResourceId = physicalResourceId;
+ this.noEcho = noEcho;
+ }
+
+ /**
+ * Returns a JsonNode representation of the Response.
+ *
+ * @return a non-null JsonNode representation
+ */
+ JsonNode getJsonNode() {
+ return jsonNode;
+ }
+
+ /**
+ * The success/failed status of the Response.
+ *
+ * @return a non-null Status
+ */
+ public Status getStatus() {
+ return status;
+ }
+
+ /**
+ * The physical resource ID. If null, the default physical resource ID will be provided to the custom resource.
+ *
+ * @return a potentially null physical resource ID
+ */
+ public String getPhysicalResourceId() {
+ return physicalResourceId;
+ }
+
+ /**
+ * Whether to mask custom resource output (true) or not (false).
+ *
+ * @return true if custom resource output is to be masked, false otherwise
+ */
+ public boolean isNoEcho() {
+ return noEcho;
+ }
+
+ /**
+ * Includes all Response attributes, including its value in JSON format
+ *
+ * @return a full description of the Response
+ */
+ @Override
+ public String toString() {
+ Map
+ *
+ *