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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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
*
Expand All @@ -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(",", "[", "]"));
Expand All @@ -182,6 +203,7 @@ public static class Builder {
private Status status;
private String physicalResourceId;
private boolean noEcho;
private String reason;

private Builder() {
}
Expand Down Expand Up @@ -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.
*
Expand All @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -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
Expand All @@ -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()
Expand Down
Loading