From 35cd7396391e7a44d8a5a6d2718f7a36698a7ab7 Mon Sep 17 00:00:00 2001 From: Matteo Rinaudo Date: Thu, 13 Nov 2025 17:58:37 -0500 Subject: [PATCH] Add support for Hook Annotations. --- .pre-commit-config.yaml | 1 + pom.xml | 2 +- setup.py | 2 +- .../cloudformation/AbstractWrapper.java | 6 ++ .../cloudformation/HookAbstractWrapper.java | 1 + .../cloudformation/proxy/ProgressEvent.java | 27 ++++++- .../proxy/hook/HookAnnotation.java | 57 ++++++++++++++ .../hook/HookAnnotationSeverityLevel.java | 23 ++++++ .../proxy/hook/HookAnnotationStatus.java | 21 +++++ .../proxy/hook/HookProgressEvent.java | 8 ++ .../HookExecutableWrapperTest.java | 1 + .../cloudformation/HookLambdaWrapperTest.java | 76 ++++++++----------- .../cloudformation/HookWrapperTest.java | 1 + .../proxy/HookProgressEventTest.java | 29 +++++++ 14 files changed, 207 insertions(+), 48 deletions(-) create mode 100644 src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotation.java create mode 100644 src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotationSeverityLevel.java create mode 100644 src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotationStatus.java diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca194d99..2ebe0b09 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,6 +45,7 @@ repos: hooks: - id: bandit files: "^python/" + additional_dependencies: [pbr] - repo: local hooks: - id: pylint-local diff --git a/pom.xml b/pom.xml index f4019106..d06fc51f 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 software.amazon.cloudformation aws-cloudformation-rpdk-java-plugin - 2.2.3 + 2.2.4 AWS CloudFormation RPDK Java Plugin The CloudFormation Resource Provider Development Kit (RPDK) allows you to author your own resource providers that can be used by CloudFormation. This plugin library helps to provide runtime bindings for the execution of your providers by CloudFormation. diff --git a/setup.py b/setup.py index 1277b03e..eacdbc32 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def find_version(*file_paths): # package_data -> use MANIFEST.in instead include_package_data=True, zip_safe=True, - install_requires=["cloudformation-cli>=0.2.23"], + install_requires=["cloudformation-cli>=0.2.23", "setuptools"], python_requires=">=3.8", entry_points={"rpdk.v1.languages": ["java = rpdk.java.codegen:JavaLanguagePlugin"]}, license="Apache License 2.0", diff --git a/src/main/java/software/amazon/cloudformation/AbstractWrapper.java b/src/main/java/software/amazon/cloudformation/AbstractWrapper.java index 8bedfad1..2b4dad6b 100644 --- a/src/main/java/software/amazon/cloudformation/AbstractWrapper.java +++ b/src/main/java/software/amazon/cloudformation/AbstractWrapper.java @@ -414,6 +414,12 @@ protected void writeResponse(final OutputStream outputStream, final ProgressEven response.setResult(null); } + if (response.getAnnotations() != null) { + // Same as above: remove any non-resource specific fields + // from response - in this case, expunge Hook Annotations. + response.setAnnotations(null); + } + String output = this.serializer.serialize(response); outputStream.write(output.getBytes(StandardCharsets.UTF_8)); outputStream.flush(); diff --git a/src/main/java/software/amazon/cloudformation/HookAbstractWrapper.java b/src/main/java/software/amazon/cloudformation/HookAbstractWrapper.java index 070cbb2e..f77f483a 100644 --- a/src/main/java/software/amazon/cloudformation/HookAbstractWrapper.java +++ b/src/main/java/software/amazon/cloudformation/HookAbstractWrapper.java @@ -373,6 +373,7 @@ private HookProgressEvent createProgressResponse(final ProgressEvent< if (request != null) { response.setClientRequestToken(request.getClientRequestToken()); } + response.setAnnotations(progressEvent.getAnnotations()); return response; } diff --git a/src/main/java/software/amazon/cloudformation/proxy/ProgressEvent.java b/src/main/java/software/amazon/cloudformation/proxy/ProgressEvent.java index 0b900ea2..7de75662 100644 --- a/src/main/java/software/amazon/cloudformation/proxy/ProgressEvent.java +++ b/src/main/java/software/amazon/cloudformation/proxy/ProgressEvent.java @@ -21,6 +21,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import software.amazon.cloudformation.proxy.hook.HookAnnotation; @Data @AllArgsConstructor @@ -81,6 +82,15 @@ public class ProgressEvent { */ private String nextToken; + /** + * The optional list of HookAnnotation objects that, if used by a CloudFormation + * Hook, contain additional, user-defined metadata and information on the + * results of a hook's evaluation. + * + * Note: this field is ignored for resource handlers. + */ + private List annotations; + /** * Convenience method for constructing a FAILED response * @@ -158,7 +168,8 @@ public static ProgressEvent success public static ProgressEvent success(ResourceT model, CallbackT cxt, String message) { - return success(model, cxt, message, null); + return ProgressEvent.builder().resourceModel(model).callbackContext(cxt).message(message) + .status(OperationStatus.SUCCESS).build(); } public static ProgressEvent success(ResourceT model, Callback .result(result).status(OperationStatus.SUCCESS).build(); } + public static + ProgressEvent + success(ResourceT model, CallbackT cxt, String message, List annotations) { + return ProgressEvent.builder().resourceModel(model).callbackContext(cxt).message(message) + .annotations(annotations).status(OperationStatus.SUCCESS).build(); + } + + public static + ProgressEvent + success(ResourceT model, CallbackT cxt, String message, String result, List annotations) { + return ProgressEvent.builder().resourceModel(model).callbackContext(cxt).message(message) + .result(result).annotations(annotations).status(OperationStatus.SUCCESS).build(); + } + public ProgressEvent onSuccess(Function, ProgressEvent> func) { return (status != null && status == OperationStatus.SUCCESS) ? func.apply(this) : this; diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotation.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotation.java new file mode 100644 index 00000000..c9da7c25 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotation.java @@ -0,0 +1,57 @@ +/* +* Copyright 2010-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ +package software.amazon.cloudformation.proxy.hook; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class HookAnnotation { + /** + * The name of the annotation; this is mandatory. + */ + private String annotationName; + + /** + * The status for the hook annotation: this is mandatory. + */ + private HookAnnotationStatus status; + + /** + * The optional status message for the annotation. + */ + private String statusMessage; + + /** + * The optional remediation message for the annotation. + */ + private String remediationMessage; + + /** + * The optional remediation link for the annotation. + */ + private String remediationLink; + + /** + * The optional severity level for the annotation. + */ + private HookAnnotationSeverityLevel severityLevel; + +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotationSeverityLevel.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotationSeverityLevel.java new file mode 100644 index 00000000..93d8fbec --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotationSeverityLevel.java @@ -0,0 +1,23 @@ +/* +* Copyright 2010-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ +package software.amazon.cloudformation.proxy.hook; + +public enum HookAnnotationSeverityLevel { + INFORMATIONAL, + LOW, + MEDIUM, + HIGH, + CRITICAL, +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotationStatus.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotationStatus.java new file mode 100644 index 00000000..ffd542d5 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookAnnotationStatus.java @@ -0,0 +1,21 @@ +/* +* Copyright 2010-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ +package software.amazon.cloudformation.proxy.hook; + +public enum HookAnnotationStatus { + PASSED, + FAILED, + SKIPPED, +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookProgressEvent.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookProgressEvent.java index 51beccfc..f973e995 100644 --- a/src/main/java/software/amazon/cloudformation/proxy/hook/HookProgressEvent.java +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookProgressEvent.java @@ -15,6 +15,7 @@ package software.amazon.cloudformation.proxy.hook; import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.List; import java.util.function.Function; import lombok.AllArgsConstructor; import lombok.Builder; @@ -71,6 +72,13 @@ public class HookProgressEvent { */ private String result; + /** + * The optional list of HookAnnotation objects that, if used by a CloudFormation + * Hook, contain additional, user-defined metadata and information on the + * results of a hook's evaluation. + */ + private List annotations; + /** * Convenience method for constructing a FAILED response * diff --git a/src/test/java/software/amazon/cloudformation/HookExecutableWrapperTest.java b/src/test/java/software/amazon/cloudformation/HookExecutableWrapperTest.java index d702dacb..4c56430d 100644 --- a/src/test/java/software/amazon/cloudformation/HookExecutableWrapperTest.java +++ b/src/test/java/software/amazon/cloudformation/HookExecutableWrapperTest.java @@ -114,6 +114,7 @@ private void verifyHandlerResponse(final OutputStream out, final HookProgressEve assertThat(handlerResponse.getResult()).isEqualTo(expected.getResult()); assertThat(handlerResponse.getCallbackContext()).isEqualTo(expected.getCallbackContext()); assertThat(handlerResponse.getCallbackDelaySeconds()).isEqualTo(expected.getCallbackDelaySeconds()); + assertThat(handlerResponse.getAnnotations()).isEqualTo(expected.getAnnotations()); } @ParameterizedTest diff --git a/src/test/java/software/amazon/cloudformation/HookLambdaWrapperTest.java b/src/test/java/software/amazon/cloudformation/HookLambdaWrapperTest.java index f7315e5e..076fe23c 100644 --- a/src/test/java/software/amazon/cloudformation/HookLambdaWrapperTest.java +++ b/src/test/java/software/amazon/cloudformation/HookLambdaWrapperTest.java @@ -145,6 +145,7 @@ private void verifyHandlerResponse(final OutputStream out, final HookProgressEve assertThat(handlerResponse.getResult()).isEqualTo(expected.getResult()); assertThat(handlerResponse.getCallbackContext()).isEqualTo(expected.getCallbackContext()); assertThat(handlerResponse.getCallbackDelaySeconds()).isEqualTo(expected.getCallbackDelaySeconds()); + assertThat(handlerResponse.getAnnotations()).isEqualTo(expected.getAnnotations()); } @ParameterizedTest @@ -342,21 +343,11 @@ public void invokeHandler_WithStackLevelHook_returnsSuccess(final String request lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); - wrapper.setHookInvocationPayloadFromS3(Map.of( - "Template", "template string here", - "PreviousTemplate", "previous template string here", - "ResolvedTemplate", "resolved template string here", - "ChangedResources", List.of( - Map.of( - "LogicalResourceId", "SomeLogicalResourceId", - "ResourceType", "AWS::S3::Bucket", - "Action", "CREATE", - "LineNumber", 3, - "ResourceProperties", "", - "PreviousResourceProperties", "" - ) - ) - )); + wrapper.setHookInvocationPayloadFromS3(Map.of("Template", "template string here", "PreviousTemplate", + "previous template string here", "ResolvedTemplate", "resolved template string here", "ChangedResources", + List.of(Map.of("LogicalResourceId", "SomeLogicalResourceId", "ResourceType", "AWS::S3::Bucket", "Action", "CREATE", + "LineNumber", 3, "ResourceProperties", "", "PreviousResourceProperties", + "")))); try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { final Context context = getLambdaContext(); @@ -394,38 +385,33 @@ public void invokeHandler_WithStackLevelHook_returnsSuccess(final String request } } - @Test - public void testIsHookInvocationPayloadRemote() { - List invalidHookRequestDataObjects = ImmutableList.of( - HookRequestData.builder().targetModel(null).build(), - HookRequestData.builder().targetModel(null).payload(null).build(), - HookRequestData.builder().targetModel(Collections.emptyMap()).payload(null).build() - ); + @Test + public void testIsHookInvocationPayloadRemote() { + List< + HookRequestData> invalidHookRequestDataObjects = ImmutableList.of(HookRequestData.builder().targetModel(null).build(), + HookRequestData.builder().targetModel(null).payload(null).build(), + HookRequestData.builder().targetModel(Collections.emptyMap()).payload(null).build()); - invalidHookRequestDataObjects.forEach(requestData -> { + invalidHookRequestDataObjects.forEach(requestData -> { Assertions.assertThrows(TerminalException.class, () -> wrapper.isHookInvocationPayloadRemote(requestData)); - }); - - Assertions.assertThrows(TerminalException.class, () -> wrapper.isHookInvocationPayloadRemote(null)); - - HookRequestData bothFieldsPopulated = HookRequestData.builder() - .targetModel(ImmutableMap.of("foo", "bar")) - .payload("http://s3PresignedUrl") - .build(); - HookRequestData onlyTargetModelPopulated = HookRequestData.builder() - .targetModel(ImmutableMap.of("foo", "bar")) - .payload(null).build(); - HookRequestData onlyPayloadPopulated = HookRequestData.builder() - .targetModel(Collections.emptyMap()) - .payload("http://s3PresignedUrl").build(); - HookRequestData onlyPayloadPopulatedWithNullTargetModel = HookRequestData.builder().targetModel(null) - .payload("http://s3PresignedUrl").build(); - - Assertions.assertFalse(wrapper.isHookInvocationPayloadRemote(bothFieldsPopulated)); - Assertions.assertFalse(wrapper.isHookInvocationPayloadRemote(onlyTargetModelPopulated)); - Assertions.assertTrue(wrapper.isHookInvocationPayloadRemote(onlyPayloadPopulated)); - Assertions.assertTrue(wrapper.isHookInvocationPayloadRemote(onlyPayloadPopulatedWithNullTargetModel)); - } + }); + + Assertions.assertThrows(TerminalException.class, () -> wrapper.isHookInvocationPayloadRemote(null)); + + HookRequestData bothFieldsPopulated = HookRequestData.builder().targetModel(ImmutableMap.of("foo", "bar")) + .payload("http://s3PresignedUrl").build(); + HookRequestData onlyTargetModelPopulated = HookRequestData.builder().targetModel(ImmutableMap.of("foo", "bar")) + .payload(null).build(); + HookRequestData onlyPayloadPopulated = HookRequestData.builder().targetModel(Collections.emptyMap()) + .payload("http://s3PresignedUrl").build(); + HookRequestData onlyPayloadPopulatedWithNullTargetModel = HookRequestData.builder().targetModel(null) + .payload("http://s3PresignedUrl").build(); + + Assertions.assertFalse(wrapper.isHookInvocationPayloadRemote(bothFieldsPopulated)); + Assertions.assertFalse(wrapper.isHookInvocationPayloadRemote(onlyTargetModelPopulated)); + Assertions.assertTrue(wrapper.isHookInvocationPayloadRemote(onlyPayloadPopulated)); + Assertions.assertTrue(wrapper.isHookInvocationPayloadRemote(onlyPayloadPopulatedWithNullTargetModel)); + } private final String expectedStringWhenStrictDeserializingWithExtraneousFields = "Unrecognized field \"targetName\" (class software.amazon.cloudformation.proxy.hook.HookInvocationRequest), not marked as ignorable (10 known properties: \"requestContext\", \"stackId\", \"clientRequestToken\", \"hookModel\", \"hookTypeName\", \"requestData\", \"actionInvocationPoint\", \"awsAccountId\", \"changeSetId\", \"hookTypeVersion\"])\n" + " at [Source: (String)\"{\n" + " \"clientRequestToken\": \"123456\",\n" + " \"awsAccountId\": \"123456789012\",\n" diff --git a/src/test/java/software/amazon/cloudformation/HookWrapperTest.java b/src/test/java/software/amazon/cloudformation/HookWrapperTest.java index e42bd399..1d62df01 100644 --- a/src/test/java/software/amazon/cloudformation/HookWrapperTest.java +++ b/src/test/java/software/amazon/cloudformation/HookWrapperTest.java @@ -131,6 +131,7 @@ private void verifyHandlerResponse(final OutputStream out, final HookProgressEve assertThat(handlerResponse.getResult()).isEqualTo(expected.getResult()); assertThat(handlerResponse.getCallbackContext()).isEqualTo(expected.getCallbackContext()); assertThat(handlerResponse.getCallbackDelaySeconds()).isEqualTo(expected.getCallbackDelaySeconds()); + assertThat(handlerResponse.getAnnotations()).isEqualTo(expected.getAnnotations()); } @ParameterizedTest diff --git a/src/test/java/software/amazon/cloudformation/proxy/HookProgressEventTest.java b/src/test/java/software/amazon/cloudformation/proxy/HookProgressEventTest.java index 933575e0..5468afcc 100644 --- a/src/test/java/software/amazon/cloudformation/proxy/HookProgressEventTest.java +++ b/src/test/java/software/amazon/cloudformation/proxy/HookProgressEventTest.java @@ -16,8 +16,12 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.core.JsonProcessingException; +import java.util.List; import org.junit.jupiter.api.Test; import software.amazon.cloudformation.TestContext; +import software.amazon.cloudformation.proxy.hook.HookAnnotation; +import software.amazon.cloudformation.proxy.hook.HookAnnotationSeverityLevel; +import software.amazon.cloudformation.proxy.hook.HookAnnotationStatus; import software.amazon.cloudformation.proxy.hook.HookProgressEvent; import software.amazon.cloudformation.proxy.hook.HookStatus; import software.amazon.cloudformation.resource.Serializer; @@ -38,6 +42,7 @@ public void testFailedHandler() { assertThat(progressEvent.isComplete()).isFalse(); assertThat(progressEvent.isInProgress()).isFalse(); assertThat(progressEvent.canContinueProgress()).isFalse(); + assertThat(progressEvent.getAnnotations()).isNull(); } @Test @@ -54,6 +59,7 @@ public void testInProgressHandler() { assertThat(progressEvent.isComplete()).isFalse(); assertThat(progressEvent.isInProgress()).isTrue(); assertThat(progressEvent.canContinueProgress()).isFalse(); + assertThat(progressEvent.getAnnotations()).isNull(); } @Test @@ -70,6 +76,7 @@ public void testInProgressHandler_isContinuable() { assertThat(progressEvent.isComplete()).isFalse(); assertThat(progressEvent.isInProgress()).isTrue(); assertThat(progressEvent.canContinueProgress()).isTrue(); + assertThat(progressEvent.getAnnotations()).isNull(); } @Test @@ -85,6 +92,7 @@ public void testCompleteHandler() { assertThat(progressEvent.isComplete()).isTrue(); assertThat(progressEvent.isInProgress()).isFalse(); assertThat(progressEvent.canContinueProgress()).isFalse(); + assertThat(progressEvent.getAnnotations()).isNull(); } @Test @@ -101,6 +109,7 @@ public void testDefaultFailedHandler() { assertThat(progressEvent.isComplete()).isFalse(); assertThat(progressEvent.isInProgress()).isFalse(); assertThat(progressEvent.canContinueProgress()).isFalse(); + assertThat(progressEvent.getAnnotations()).isNull(); } @Test @@ -117,6 +126,7 @@ public void testDefaultInProgressHandler() { assertThat(progressEvent.isComplete()).isFalse(); assertThat(progressEvent.isInProgress()).isTrue(); assertThat(progressEvent.canContinueProgress()).isFalse(); + assertThat(progressEvent.getAnnotations()).isNull(); } @Test @@ -132,6 +142,7 @@ public void testDefaultCompleteHandler() { assertThat(progressEvent.isComplete()).isTrue(); assertThat(progressEvent.isInProgress()).isFalse(); assertThat(progressEvent.canContinueProgress()).isFalse(); + assertThat(progressEvent.getAnnotations()).isNull(); } @Test @@ -148,6 +159,7 @@ public void testOnCompleteChaining() { assertThat(chained.isFailed()).isEqualTo(true); assertThat(chained.isInProgress()).isEqualTo(false); assertThat(chained.isInProgressCallbackDelay()).isEqualTo(false); + assertThat(progressEvent.getAnnotations()).isNull(); } @Test @@ -158,4 +170,21 @@ public void progressEvent_serialize_shouldReturnJson() throws JsonProcessingExce assertThat(json).isEqualTo("{\"hookStatus\":\"SUCCESS\",\"callbackDelaySeconds\":0}"); } + + @Test + public void progressEvent_with_annotations_serialize_shouldReturnJson() throws JsonProcessingException { + final List annotations = List.of( + HookAnnotation.builder().annotationName("test1").status(HookAnnotationStatus.PASSED).build(), + HookAnnotation.builder().annotationName("test2").status(HookAnnotationStatus.FAILED).statusMessage("test-message-2") + .remediationMessage("test-remediation-message-2").remediationLink("https://localhost") + .severityLevel(HookAnnotationSeverityLevel.CRITICAL).build()); + + final HookProgressEvent< + Object> progressEvent = HookProgressEvent.builder().hookStatus(HookStatus.SUCCESS).annotations(annotations).build(); + final Serializer serializer = new Serializer(); + final String json = serializer.serialize(progressEvent); + + assertThat(json).isEqualTo( + "{\"hookStatus\":\"SUCCESS\",\"callbackDelaySeconds\":0,\"annotations\":[{\"annotationName\":\"test1\",\"status\":\"PASSED\"},{\"annotationName\":\"test2\",\"status\":\"FAILED\",\"statusMessage\":\"test-message-2\",\"remediationMessage\":\"test-remediation-message-2\",\"remediationLink\":\"https://localhost\",\"severityLevel\":\"CRITICAL\"}]}"); + } }