Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cfn-custom-resource): Add optional 'reason' field for detailed failure reporting #1758

Merged
merged 9 commits into from
Mar 21, 2025
8 changes: 4 additions & 4 deletions examples/powertools-examples-core/kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -14,9 +14,9 @@ dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2")
implementation("com.amazonaws:aws-lambda-java-events:3.11.0")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.2")
aspect("software.amazon.lambda:powertools-tracing:1.18.0")
aspect("software.amazon.lambda:powertools-logging:1.18.0")
aspect("software.amazon.lambda:powertools-metrics:1.18.0")
aspect("software.amazon.lambda:powertools-tracing:1.19.0")
aspect("software.amazon.lambda:powertools-logging:1.19.0")
aspect("software.amazon.lambda:powertools-metrics:1.19.0")
testImplementation("junit:junit:4.13.2")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}
@@ -36,4 +36,4 @@ tasks.compileTestKotlin {
// If using JDK 11 or higher, use the following instead:
//kotlin {
// jvmToolchain(11)
//}
//}
Original file line number Diff line number Diff line change
@@ -36,6 +36,7 @@
import software.amazon.awssdk.http.SdkHttpMethod;
import software.amazon.awssdk.http.SdkHttpRequest;
import software.amazon.awssdk.utils.StringInputStream;
import software.amazon.awssdk.utils.StringUtils;

/**
* Client for sending responses to AWS CloudFormation custom resources by way of a response URL, which is an Amazon S3
@@ -148,7 +149,9 @@ StringInputStream responseBodyStream(CloudFormationCustomResourceEvent event,
ObjectNode node = body.toObjectNode(null);
return new StringInputStream(node.toString());
} else {

if (!StringUtils.isBlank(resp.getReason())) {
reason = resp.getReason();
}
String physicalResourceId = resp.getPhysicalResourceId() != null ? resp.getPhysicalResourceId() :
event.getPhysicalResourceId() != null ? event.getPhysicalResourceId() :
context.getLogStreamName();
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import software.amazon.awssdk.utils.StringUtils;

/**
* Models the arbitrary data to be sent to the custom resource in response to a CloudFormation event. This object
@@ -30,12 +31,22 @@ public class Response {
private final Status status;
private final String physicalResourceId;
private final boolean noEcho;
private final String reason;

private Response(JsonNode jsonNode, Status status, String physicalResourceId, boolean noEcho) {
this.jsonNode = jsonNode;
this.status = status;
this.physicalResourceId = physicalResourceId;
this.noEcho = noEcho;
this.reason = null;
}

private Response(JsonNode jsonNode, Status status, String physicalResourceId, boolean noEcho, String reason) {
this.jsonNode = jsonNode;
this.status = status;
this.physicalResourceId = physicalResourceId;
this.noEcho = noEcho;
this.reason = reason;
}

/**
@@ -149,6 +160,15 @@ public boolean isNoEcho() {
return noEcho;
}

/**
* The reason for the failure.
*
* @return a potentially null reason
*/
public String getReason() {
return reason;
}

/**
* Includes all Response attributes, including its value in JSON format
*
@@ -161,6 +181,7 @@ public String toString() {
attributes.put("Status", status);
attributes.put("PhysicalResourceId", physicalResourceId);
attributes.put("NoEcho", noEcho);
attributes.put("Reason", reason);
return attributes.entrySet().stream()
.map(entry -> entry.getKey() + " = " + entry.getValue())
.collect(Collectors.joining(",", "[", "]"));
@@ -182,6 +203,7 @@ public static class Builder {
private Status status;
private String physicalResourceId;
private boolean noEcho;
private String reason;

private Builder() {
}
@@ -263,6 +285,20 @@ public Builder noEcho(boolean noEcho) {
return this;
}

/**
* Reason for the response.
* Reason is optional for Success responses, but required for Failed responses.
* If not provided it will be replaced with cloudwatch log stream name.
*
* @param reason if null, the default reason will be used
* @return a reference to this builder
*/

public Builder reason(String reason) {
this.reason = reason;
return this;
}

/**
* Builds a Response object for the value.
*
@@ -277,6 +313,9 @@ public Response build() {
node = mapper.valueToTree(value);
}
Status responseStatus = this.status != null ? this.status : Status.SUCCESS;
if (StringUtils.isNotBlank(this.reason)) {
return new Response(node, responseStatus, physicalResourceId, noEcho, reason);
}
return new Response(node, responseStatus, physicalResourceId, noEcho);
}
}
Original file line number Diff line number Diff line change
@@ -324,4 +324,27 @@ void responseBodyStreamFailedResponse() throws Exception {
"}";
assertThat(stream.getString()).isEqualTo(expectedJson);
}

@Test
void responseBodyStreamFailedResponseWithReason() throws Exception {
CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent();
Context context = mock(Context.class);
CloudFormationResponse cfnResponse = testableCloudFormationResponse();
String failureReason = "Failed test reason";
Response failedResponseWithReason = Response.builder().
status(Response.Status.FAILED).reason(failureReason).build();
StringInputStream stream = cfnResponse.responseBodyStream(event, context, failedResponseWithReason);

String expectedJson = "{" +
"\"Status\":\"FAILED\"," +
"\"Reason\":\"" + failureReason + "\"," +
"\"PhysicalResourceId\":null," +
"\"StackId\":null," +
"\"RequestId\":null," +
"\"LogicalResourceId\":null," +
"\"NoEcho\":false," +
"\"Data\":null" +
"}";
assertThat(stream.getString()).isEqualTo(expectedJson);
}
}
Original file line number Diff line number Diff line change
@@ -33,11 +33,13 @@ void defaultValues() {
assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS);
assertThat(response.getPhysicalResourceId()).isNull();
assertThat(response.isNoEcho()).isFalse();
assertThat(response.getReason()).isNull();

assertThat(response.toString()).contains("JSON = null");
assertThat(response.toString()).contains("Status = SUCCESS");
assertThat(response.toString()).contains("PhysicalResourceId = null");
assertThat(response.toString()).contains("NoEcho = false");
assertThat(response.toString()).contains("Reason = null");
}

@Test
@@ -61,6 +63,27 @@ void explicitNullValues() {
assertThat(response.toString()).contains("NoEcho = false");
}

@Test
void explicitReasonWithDefaultValues() {
String reason = "test";
Response response = Response.builder()
.reason(reason)
.build();
assertThat(response).isNotNull();
assertThat(response.getJsonNode()).isNull();
assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS);
assertThat(response.getPhysicalResourceId()).isNull();
assertThat(response.isNoEcho()).isFalse();
assertThat(response.getReason()).isNotNull();
assertThat(response.getReason()).isEqualTo(reason);

assertThat(response.toString()).contains("JSON = null");
assertThat(response.toString()).contains("Status = SUCCESS");
assertThat(response.toString()).contains("PhysicalResourceId = null");
assertThat(response.toString()).contains("NoEcho = false");
assertThat(response.toString()).contains("Reason = "+reason);
}

@Test
void customNonJsonRelatedValues() {
Response response = Response.builder()