Skip to content

Commit 8479a7f

Browse files
authored
feat(cfn-custom-resource): Add optional 'reason' field for detailed failure reporting (#1810)
1 parent 26f8b10 commit 8479a7f

File tree

4 files changed

+89
-1
lines changed

4 files changed

+89
-1
lines changed

powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponse.java

+4-1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import software.amazon.awssdk.http.SdkHttpMethod;
3737
import software.amazon.awssdk.http.SdkHttpRequest;
3838
import software.amazon.awssdk.utils.StringInputStream;
39+
import software.amazon.awssdk.utils.StringUtils;
3940

4041
/**
4142
* 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,
148149
ObjectNode node = body.toObjectNode(null);
149150
return new StringInputStream(node.toString());
150151
} else {
151-
152+
if (!StringUtils.isBlank(resp.getReason())) {
153+
reason = resp.getReason();
154+
}
152155
String physicalResourceId = resp.getPhysicalResourceId() != null ? resp.getPhysicalResourceId() :
153156
event.getPhysicalResourceId() != null ? event.getPhysicalResourceId() :
154157
context.getLogStreamName();

powertools-cloudformation/src/main/java/software/amazon/lambda/powertools/cloudformation/Response.java

+39
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.HashMap;
2020
import java.util.Map;
2121
import java.util.stream.Collectors;
22+
import software.amazon.awssdk.utils.StringUtils;
2223

2324
/**
2425
* 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 {
3031
private final Status status;
3132
private final String physicalResourceId;
3233
private final boolean noEcho;
34+
private final String reason;
3335

3436
private Response(JsonNode jsonNode, Status status, String physicalResourceId, boolean noEcho) {
3537
this.jsonNode = jsonNode;
3638
this.status = status;
3739
this.physicalResourceId = physicalResourceId;
3840
this.noEcho = noEcho;
41+
this.reason = null;
42+
}
43+
44+
private Response(JsonNode jsonNode, Status status, String physicalResourceId, boolean noEcho, String reason) {
45+
this.jsonNode = jsonNode;
46+
this.status = status;
47+
this.physicalResourceId = physicalResourceId;
48+
this.noEcho = noEcho;
49+
this.reason = reason;
3950
}
4051

4152
/**
@@ -115,6 +126,15 @@ public boolean isNoEcho() {
115126
return noEcho;
116127
}
117128

129+
/**
130+
* The reason for the failure.
131+
*
132+
* @return a potentially null reason
133+
*/
134+
public String getReason() {
135+
return reason;
136+
}
137+
118138
/**
119139
* Includes all Response attributes, including its value in JSON format
120140
*
@@ -127,6 +147,7 @@ public String toString() {
127147
attributes.put("Status", status);
128148
attributes.put("PhysicalResourceId", physicalResourceId);
129149
attributes.put("NoEcho", noEcho);
150+
attributes.put("Reason", reason);
130151
return attributes.entrySet().stream()
131152
.map(entry -> entry.getKey() + " = " + entry.getValue())
132153
.collect(Collectors.joining(",", "[", "]"));
@@ -148,6 +169,7 @@ public static class Builder {
148169
private Status status;
149170
private String physicalResourceId;
150171
private boolean noEcho;
172+
private String reason;
151173

152174
private Builder() {
153175
}
@@ -229,6 +251,20 @@ public Builder noEcho(boolean noEcho) {
229251
return this;
230252
}
231253

254+
/**
255+
* Reason for the response.
256+
* Reason is optional for Success responses, but required for Failed responses.
257+
* If not provided it will be replaced with cloudwatch log stream name.
258+
*
259+
* @param reason if null, the default reason will be used
260+
* @return a reference to this builder
261+
*/
262+
263+
public Builder reason(String reason) {
264+
this.reason = reason;
265+
return this;
266+
}
267+
232268
/**
233269
* Builds a Response object for the value.
234270
*
@@ -243,6 +279,9 @@ public Response build() {
243279
node = mapper.valueToTree(value);
244280
}
245281
Status responseStatus = this.status != null ? this.status : Status.SUCCESS;
282+
if (StringUtils.isNotBlank(this.reason)) {
283+
return new Response(node, responseStatus, physicalResourceId, noEcho, reason);
284+
}
246285
return new Response(node, responseStatus, physicalResourceId, noEcho);
247286
}
248287
}

powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/CloudFormationResponseTest.java

+23
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,27 @@ void responseBodyStreamFailedResponse() throws Exception {
324324
"}";
325325
assertThat(stream.getString()).isEqualTo(expectedJson);
326326
}
327+
328+
@Test
329+
void responseBodyStreamFailedResponseWithReason() throws Exception {
330+
CloudFormationCustomResourceEvent event = mockCloudFormationCustomResourceEvent();
331+
Context context = mock(Context.class);
332+
CloudFormationResponse cfnResponse = testableCloudFormationResponse();
333+
String failureReason = "Failed test reason";
334+
Response failedResponseWithReason = Response.builder().
335+
status(Response.Status.FAILED).reason(failureReason).build();
336+
StringInputStream stream = cfnResponse.responseBodyStream(event, context, failedResponseWithReason);
337+
338+
String expectedJson = "{" +
339+
"\"Status\":\"FAILED\"," +
340+
"\"Reason\":\"" + failureReason + "\"," +
341+
"\"PhysicalResourceId\":null," +
342+
"\"StackId\":null," +
343+
"\"RequestId\":null," +
344+
"\"LogicalResourceId\":null," +
345+
"\"NoEcho\":false," +
346+
"\"Data\":null" +
347+
"}";
348+
assertThat(stream.getString()).isEqualTo(expectedJson);
349+
}
327350
}

powertools-cloudformation/src/test/java/software/amazon/lambda/powertools/cloudformation/ResponseTest.java

+23
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ void defaultValues() {
3333
assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS);
3434
assertThat(response.getPhysicalResourceId()).isNull();
3535
assertThat(response.isNoEcho()).isFalse();
36+
assertThat(response.getReason()).isNull();
3637

3738
assertThat(response.toString()).contains("JSON = null");
3839
assertThat(response.toString()).contains("Status = SUCCESS");
3940
assertThat(response.toString()).contains("PhysicalResourceId = null");
4041
assertThat(response.toString()).contains("NoEcho = false");
42+
assertThat(response.toString()).contains("Reason = null");
4143
}
4244

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

66+
@Test
67+
void explicitReasonWithDefaultValues() {
68+
String reason = "test";
69+
Response response = Response.builder()
70+
.reason(reason)
71+
.build();
72+
assertThat(response).isNotNull();
73+
assertThat(response.getJsonNode()).isNull();
74+
assertThat(response.getStatus()).isEqualTo(Response.Status.SUCCESS);
75+
assertThat(response.getPhysicalResourceId()).isNull();
76+
assertThat(response.isNoEcho()).isFalse();
77+
assertThat(response.getReason()).isNotNull();
78+
assertThat(response.getReason()).isEqualTo(reason);
79+
80+
assertThat(response.toString()).contains("JSON = null");
81+
assertThat(response.toString()).contains("Status = SUCCESS");
82+
assertThat(response.toString()).contains("PhysicalResourceId = null");
83+
assertThat(response.toString()).contains("NoEcho = false");
84+
assertThat(response.toString()).contains("Reason = "+reason);
85+
}
86+
6487
@Test
6588
void customNonJsonRelatedValues() {
6689
Response response = Response.builder()

0 commit comments

Comments
 (0)