diff --git a/pom.xml b/pom.xml index 4b866b5c..7f91f9f0 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 software.amazon.cloudformation aws-cloudformation-rpdk-java-plugin - 2.0.11 + 2.0.12 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. @@ -118,13 +118,43 @@ com.amazonaws aws-java-sdk-core - 1.11.555 + 1.11.1034 com.amazonaws aws-lambda-java-core - 1.2.0 + 1.2.1 + + + + com.amazonaws + aws-lambda-java-log4j2 + 1.5.1 + + + + com.amazonaws + aws-encryption-sdk-java + 1.7.0 + + + + com.amazonaws + aws-java-sdk-sts + 1.11.1034 + + + + com.google.code.findbugs + annotations + 3.0.0 + + + + com.amazonaws + aws-java-sdk-kms + 1.11.1034 @@ -173,6 +203,12 @@ commons-lang3 3.9 + + + org.apache.commons + commons-collections4 + 4.4 + com.google.guava diff --git a/python/rpdk/java/__init__.py b/python/rpdk/java/__init__.py index a5ba28b2..1ffa6332 100644 --- a/python/rpdk/java/__init__.py +++ b/python/rpdk/java/__init__.py @@ -1,5 +1,5 @@ import logging -__version__ = "2.0.11" +__version__ = "2.0.12" logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/python/rpdk/java/codegen.py b/python/rpdk/java/codegen.py index a700d238..8584cb10 100644 --- a/python/rpdk/java/codegen.py +++ b/python/rpdk/java/codegen.py @@ -1,5 +1,6 @@ # pylint: disable=useless-super-delegation,too-many-locals # pylint doesn't recognize abstract methods +import json import logging import os import shutil @@ -9,17 +10,19 @@ from rpdk.core.data_loaders import resource_stream from rpdk.core.exceptions import InternalError, SysExitRecommendedError -from rpdk.core.init import input_with_validation -from rpdk.core.jsonutils.resolver import resolve_models +from rpdk.core.jsonutils.resolver import ContainerType, ResolvedType, resolve_models from rpdk.core.plugin_base import LanguagePlugin +from rpdk.core.project import ARTIFACT_TYPE_HOOK +from rpdk.core.utils.init_utils import input_with_validation from . import __version__ -from .resolver import translate_type +from .resolver import UNDEFINED, translate_type from .utils import safe_reserved, validate_codegen_model, validate_namespace LOG = logging.getLogger(__name__) -OPERATIONS = ("Create", "Read", "Update", "Delete", "List") +HOOK_OPERATIONS = ("PreCreate", "PreUpdate", "PreDelete") +RESOURCE_OPERATIONS = ("Create", "Read", "Update", "Delete", "List") EXECUTABLE = "cfn" AWSCODEGEN = namedtuple("AWSCODEGEN", "default guided default_code guided_code") CODEGEN = AWSCODEGEN("default", "guided_aws", "1", "2") @@ -68,9 +71,12 @@ class InvalidMavenPOMError(SysExitRecommendedError): class JavaLanguagePlugin(LanguagePlugin): MODULE_NAME = __name__ RUNTIME = "java8" - ENTRY_POINT = "{}.HandlerWrapper::handleRequest" - TEST_ENTRY_POINT = "{}.HandlerWrapper::testEntrypoint" - EXECUTABLE_ENTRY_POINT = "{}.HandlerWrapperExecutable" + HOOK_ENTRY_POINT = "{}.HookHandlerWrapper::handleRequest" + HOOK_TEST_ENTRY_POINT = "{}.HookHandlerWrapper::testEntrypoint" + HOOK_EXECUTABLE_ENTRY_POINT = "{}.HookHandlerWrapperExecutable" + RESOURCE_ENTRY_POINT = "{}.HandlerWrapper::handleRequest" + RESOURCE_TEST_ENTRY_POINT = "{}.HandlerWrapper::testEntrypoint" + RESOURCE_EXECUTABLE_ENTRY_POINT = "{}.HandlerWrapperExecutable" CODE_URI = "./target/{}-1.0-SNAPSHOT.jar" def __init__(self): @@ -143,6 +149,7 @@ def _writing_component( operation: str = kwargs.get("operation") pojo_name: str = kwargs.get("pojo_name") call_graph: str = kwargs.get("call_graph") + target_names: list = kwargs.get("target_names") if not stub_entity: stub_entity = entity @@ -154,6 +161,7 @@ def _writing_component( operation=operation, pojo_name=pojo_name, call_graph=call_graph, + target_names=target_names, ) project.safewrite(path, contents) @@ -222,11 +230,18 @@ def init_shared(self, project, src, tst, resources): path = src / "Configuration.java" LOG.debug("Writing configuration: %s", path) - template = self.env.get_template("init/shared/StubConfiguration.java") + config_template = ( + "init/shared/StubHookConfiguration.java" + if project.artifact_type == ARTIFACT_TYPE_HOOK + else "init/shared/StubConfiguration.java" + ) + template = self.env.get_template(config_template) contents = template.render( package_name=self.package_name, schema_file_name=project.schema_filename, - pojo_name="ResourceModel", + pojo_name="HookInputModel" + if project.artifact_type == ARTIFACT_TYPE_HOOK + else "ResourceModel", ) project.safewrite(path, contents) @@ -234,7 +249,9 @@ def init_shared(self, project, src, tst, resources): path = project.root / "template.yml" LOG.debug("Writing SAM template: %s", path) template = self.env.get_template( - "template.yml" + "template_hook.yml" + if project.artifact_type == ARTIFACT_TYPE_HOOK + else "template.yml" ) # this template is in the CLI itself handler_params = { "Handler": project.entrypoint, @@ -256,7 +273,12 @@ def init_shared(self, project, src, tst, resources): # generate docs path = project.root / "README.md" LOG.debug("Writing README: %s", path) - template = self.env.get_template("init/shared/README.md") + doc_template = ( + "init/shared/README_HOOK.md" + if project.artifact_type == ARTIFACT_TYPE_HOOK + else "init/shared/README_RESOURCE.md" + ) + template = self.env.get_template(doc_template) contents = template.render( type_name=project.type_name, schema_path=project.schema_path, @@ -270,28 +292,78 @@ def init_shared(self, project, src, tst, resources): contents = resource_stream(__name__, "data/log4j2.xml").read() project.safewrite(path, contents) - self.init_handlers(project, src, tst) + if project.artifact_type == ARTIFACT_TYPE_HOOK: + self.init_hook_handlers(project, src, tst) + else: + self.init_resource_handlers(project, src, tst) @logdebug def init_guided_aws(self, project, src, tst): """Writing supporting modules""" - self._writing_component(project, src, entity="Translator.java") - self._writing_component(project, src, entity="ClientBuilder.java") - self._writing_component(project, src, entity="BaseHandlerStd.java") - self._writing_component( - project, - src, - entity="TagHelper.java", - operation="TagOps", - call_graph=project.type_name.replace("::", "-"), - ) - self._writing_component(project, tst, entity="AbstractTestBase.java") + if project.artifact_type == ARTIFACT_TYPE_HOOK: + self._writing_component( + project, + src, + entity="Translator.java", + stub_entity="HookTranslator.java", + ) + self._writing_component(project, src, entity="ClientBuilder.java") + self._writing_component(project, src, entity="BaseHookHandlerStd.java") + self._writing_component(project, tst, entity="AbstractTestBase.java") + else: + self._writing_component(project, src, entity="Translator.java") + self._writing_component(project, src, entity="ClientBuilder.java") + self._writing_component(project, src, entity="BaseHandlerStd.java") + self._writing_component( + project, + src, + entity="TagHelper.java", + operation="TagOps", + call_graph=project.type_name.replace("::", "-"), + ) + self._writing_component(project, tst, entity="AbstractTestBase.java") @logdebug - def init_handlers(self, project, src, tst): + def init_hook_handlers(self, project, src, tst): + """Writing hook stub handlers and tests""" + handlers = project.schema.get("handlers") + for operation in HOOK_OPERATIONS: + entity = "{}HookHandler.java".format(operation) + entity_test = "{}HookHandlerTest.java".format(operation) + + stub_entity = "Stub{}HookHandler.java".format( + operation if self._is_aws_guided(project) else "" + ) + stub_entity_test = "Stub{}HookHandlerTest.java".format( + operation if self._is_aws_guided(project) else "" + ) + target_names = handlers.get(operation[0].lower() + operation[1:], {}).get( + "targetNames", ["My::Example::Resource"] + ) + + self._writing_component( + project, + src, + entity=entity, + stub_entity=stub_entity, + operation=operation, + call_graph=project.type_name.replace("::", "-"), + target_names=target_names, + ) + self._writing_component( + project, + tst, + entity=entity_test, + stub_entity=stub_entity_test, + operation=operation, + target_names=target_names, + ) + + @logdebug + def init_resource_handlers(self, project, src, tst): """Writing stub handlers and tests""" pojo_name = "ResourceModel" - for operation in OPERATIONS: + for operation in RESOURCE_OPERATIONS: entity = "{}Handler.java".format(operation) entity_test = "{}HandlerTest.java".format(operation) @@ -322,11 +394,22 @@ def init_handlers(self, project, src, tst): def _init_settings(self, project): project.runtime = self.RUNTIME - project.entrypoint = self.ENTRY_POINT.format(self.package_name) - project.test_entrypoint = self.TEST_ENTRY_POINT.format(self.package_name) - project.executable_entrypoint = self.EXECUTABLE_ENTRY_POINT.format( - self.package_name - ) + if project.artifact_type == ARTIFACT_TYPE_HOOK: + project.entrypoint = self.HOOK_ENTRY_POINT.format(self.package_name) + project.test_entrypoint = self.HOOK_TEST_ENTRY_POINT.format( + self.package_name + ) + project.executable_entrypoint = self.HOOK_EXECUTABLE_ENTRY_POINT.format( + self.package_name + ) + else: + project.entrypoint = self.RESOURCE_ENTRY_POINT.format(self.package_name) + project.test_entrypoint = self.RESOURCE_TEST_ENTRY_POINT.format( + self.package_name + ) + project.executable_entrypoint = self.RESOURCE_EXECUTABLE_ENTRY_POINT.format( + self.package_name + ) project.settings.update(DEFAULT_SETTINGS) @staticmethod @@ -360,7 +443,14 @@ def generate(self, project): LOG.debug("Making generated tests folder structure: %s", test_src) test_src.mkdir(parents=True, exist_ok=True) - # write generated handler integration with LambdaWrapper + if project.artifact_type == ARTIFACT_TYPE_HOOK: + self.generate_hook(src, project) + else: + self.generate_resource(src, project) + + @logdebug + def generate_resource(self, src, project): + # write generated resource handler integration with LambdaWrapper path = src / "HandlerWrapper.java" LOG.debug("Writing handler wrapper: %s", path) template = self.env.get_template("generate/HandlerWrapper.java") @@ -391,7 +481,7 @@ def generate(self, project): template = self.env.get_template("generate/BaseHandler.java") contents = template.render( package_name=self.package_name, - operations=OPERATIONS, + operations=RESOURCE_OPERATIONS, contains_type_configuration=project.configuration_schema, pojo_name="ResourceModel", ) @@ -447,6 +537,188 @@ def generate(self, project): LOG.debug("Generate complete") + @logdebug + def generate_hook(self, src, project): # pylint: disable=too-many-statements + # write generated hook handler integration with HookLambdaWrapper + path = src / "HookHandlerWrapper.java" + LOG.debug("Writing hook handler wrapper: %s", path) + template = self.env.get_template("generate/hook/HookHandlerWrapper.java") + contents = template.render( + package_name=self.package_name, + operations=project.schema.get("handlers", {}).keys(), + wrapper_parent="HookLambdaWrapper", + ) + project.overwrite(path, contents) + + # write generated handler integration with HookExecutableWrapper + self._write_executable_wrapper_class(src, project) + + path = src / "BaseHookHandler.java" + LOG.debug("Writing base hook handler: %s", path) + template = self.env.get_template("generate/hook/BaseHookHandler.java") + contents = template.render( + package_name=self.package_name, operations=HOOK_OPERATIONS + ) + project.overwrite(path, contents) + + # generate POJOs + models = resolve_models(project.schema, "HookInputModel") + if project.configuration_schema: + configuration_schema_path = ( + self._get_generated_root(project) + / project.configuration_schema_filename + ) + project.write_configuration_schema(configuration_schema_path) + configuration_models = resolve_models( + project.configuration_schema, "TypeConfigurationModel" + ) + else: + configuration_models = {"TypeConfigurationModel": {}} + models.update(configuration_models) + + LOG.debug("Writing %d POJOs", len(models)) + + base_template = self.env.get_template("generate/hook/HookInputModel.java") + pojo_template = self.env.get_template("generate/POJO.java") + + for model_name, properties in models.items(): + path = src / "{}.java".format(model_name) + LOG.debug("%s POJO: %s", model_name, path) + + if model_name == "HookInputModel": # pragma: no cover + contents = base_template.render( + type_name=project.type_name, + package_name=self.package_name, + model_name=model_name, + properties=properties, + ) + else: + contents = pojo_template.render( + package_name=self.package_name, + model_name=model_name, + properties=properties, + no_args_constructor_required=( + model_name != "TypeConfigurationModel" or len(properties) != 0 + ), + ) + project.overwrite(path, contents) + + loaded_target_schema_file_names = {} + for target_type_name, target_info in project.target_info.items(): + target_schema = target_info["Schema"] + + target_namespace = [ + s.lower() for s in target_type_name.split("::") + ] # AWS::SQS::Queue -> awssqsqueue + target_name = "".join( + [s.capitalize() for s in target_namespace] + ) # awssqsqueue -> AwsSqsQueue + target_schema_file_name = "{}.json".format( + "-".join(target_namespace) + ) # awssqsqueue -> aws-sqs-queue.json + target_model_package_name = "{}.model.{}".format( + self.package_name, ".".join(target_namespace) + ) + target_model_dir = (src / "model").joinpath(*target_namespace) + target_model_dir.mkdir(parents=True, exist_ok=True) + + loaded_target_schemas_dir = ( + project.root / "target" / "loaded-target-schemas" + ) + loaded_target_schemas_dir.mkdir(parents=True, exist_ok=True) + if target_info.get("SchemaFileAvailable"): # pragma: no cover + contents = json.dumps(target_schema, indent=4) + path = loaded_target_schemas_dir / target_schema_file_name + project.overwrite(path, contents) + loaded_target_schema_file_names[ + target_type_name + ] = target_schema_file_name + + is_registry_type = target_info.get("IsCfnRegistrySupportedType") + + if not target_schema: + target_schema = { + "typeName": target_name, + "properties": {}, + "required": [], + "additionalProperties": False, + } + + # generate POJOs + models = resolve_models(target_schema, target_name) + + # TODO: Remove once tagging is fully supported + if models.get(target_name, {}).get("Tags"): # pragma: no cover + models[target_name]["Tags"] = ResolvedType( + ContainerType.PRIMITIVE, UNDEFINED + ) + + LOG.debug("Writing %d POJOs", len(models)) + + base_template = self.env.get_template( + "generate/hook/ResourceHookTargetModel.java" + ) + target_template = self.env.get_template( + "generate/hook/ResourceHookTarget.java" + ) + pojo_template = self.env.get_template("generate/POJO.java") + + for model_name, properties in models.items(): + path = target_model_dir / "{}.java".format(model_name) + LOG.debug("%s POJO: %s", model_name, path) + + if model_name == target_name: + contents = target_template.render( + type_name=target_type_name, + package_name=target_model_package_name, + model_name=target_name, + is_registry_type=is_registry_type, + schema_available=( + target_info.get("SchemaFileAvailable", False) + ), + schema_path=loaded_target_schema_file_names.get( + target_type_name + ), + properties=properties, + primaryIdentifier=target_schema.get("primaryIdentifier", []), + additionalIdentifiers=target_schema.get( + "additionalIdentifiers", [] + ), + ) + else: + contents = pojo_template.render( + package_name=target_model_package_name, + model_name=model_name, + properties=properties, + no_args_constructor_required=( + model_name != "TypeConfigurationModel" + or len(properties) != 0 + ), + ) + project.overwrite(path, contents) + + path = target_model_dir / "{}TargetModel.java".format(target_name) + contents = base_template.render( + type_name=target_type_name, + model_name=target_name, + package_name=target_model_package_name, + ) + project.overwrite(path, contents) + + path = src / "BaseHookConfiguration.java" + LOG.debug("Writing base hook configuration: %s", path) + template = self.env.get_template("generate/hook/BaseHookConfiguration.java") + contents = template.render( + package_name=self.package_name, + schema_file_name=project.schema_filename, + target_schema_paths=loaded_target_schema_file_names, + ) + project.overwrite(path, contents) + + self._update_settings(project) + + LOG.debug("Generate complete") + def _write_executable_wrapper_class(self, src, project): try: java_plugin_dependency_version = self._get_java_plugin_dependency_version( @@ -456,16 +728,28 @@ def _write_executable_wrapper_class(self, src, project): java_plugin_dependency_version >= MINIMUM_JAVA_DEPENDENCY_VERSION_EXECUTABLE_HANDLER_WRAPPER ): - path = src / "HandlerWrapperExecutable.java" - LOG.debug("Writing handler wrapper: %s", path) - template = self.env.get_template("generate/HandlerWrapper.java") - contents = template.render( - package_name=self.package_name, - operations=project.schema.get("handlers", {}).keys(), - pojo_name="ResourceModel", - contains_type_configuration=project.configuration_schema, - wrapper_parent="ExecutableWrapper", - ) + if project.artifact_type == ARTIFACT_TYPE_HOOK: + path = src / "HookHandlerWrapperExecutable.java" + LOG.debug("Writing handler wrapper: %s", path) + template = self.env.get_template( + "generate/hook/HookHandlerWrapper.java" + ) + contents = template.render( + package_name=self.package_name, + operations=project.schema.get("handlers", {}).keys(), + wrapper_parent="HookExecutableWrapper", + ) + else: + path = src / "HandlerWrapperExecutable.java" + LOG.debug("Writing handler wrapper: %s", path) + template = self.env.get_template("generate/HandlerWrapper.java") + contents = template.render( + package_name=self.package_name, + operations=project.schema.get("handlers", {}).keys(), + pojo_name="ResourceModel", + contains_type_configuration=project.configuration_schema, + wrapper_parent="ExecutableWrapper", + ) project.overwrite(path, contents) else: LOG.info( @@ -509,9 +793,14 @@ def _update_settings(self, project): hasattr(project, "executable_entrypoint") and not project.executable_entrypoint ): - project.executable_entrypoint = self.EXECUTABLE_ENTRY_POINT.format( - self.package_name - ) + if project.artifact_type == ARTIFACT_TYPE_HOOK: + project.executable_entrypoint = self.HOOK_EXECUTABLE_ENTRY_POINT.format( + self.package_name + ) + else: + project.executable_entrypoint = ( + self.RESOURCE_EXECUTABLE_ENTRY_POINT.format(self.package_name) + ) project.write_settings() @staticmethod diff --git a/python/rpdk/java/templates/generate/hook/BaseHookConfiguration.java b/python/rpdk/java/templates/generate/hook/BaseHookConfiguration.java new file mode 100644 index 00000000..ad960492 --- /dev/null +++ b/python/rpdk/java/templates/generate/hook/BaseHookConfiguration.java @@ -0,0 +1,60 @@ +// This is a generated file. Modifications will be overwritten. +package {{ package_name }}; + +import com.google.common.collect.ImmutableMap; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.json.JSONObject; +import org.json.JSONTokener; + +@Data +public abstract class BaseHookConfiguration { + + private final Map targetSchemaPaths = new HashMap<>(); + private final Map targetSchemas = new HashMap<>(); + protected final String schemaFilename; + + public BaseHookConfiguration(final String schemaFilename) { + this.schemaFilename = schemaFilename; + initialiseTargetSchemaFilePaths(); + } + + private void initialiseTargetSchemaFilePaths() { + {% for target_name, schema_path in target_schema_paths.items() %} + this.targetSchemaPaths.put("{{target_name}}", "{{schema_path}}"); + {% endfor %} + } + + public JSONObject hookSchemaJSONObject() { + return new JSONObject(new JSONTokener(this.getClass().getClassLoader().getResourceAsStream(schemaFilename))); + } + + public JSONObject targetSchemaJSONObject(final String targetName) { + if (!this.targetSchemaPaths.containsKey(targetName)) { + return null; + } + + return new JSONObject( + new JSONTokener( + this.targetSchemas.computeIfAbsent( + targetName, + tn -> this.getClass().getClassLoader().getResourceAsStream(tn) + ) + ) + ); + } + + public Map getTargetSchemaPaths() { + return ImmutableMap.copyOf(this.targetSchemaPaths); + } + + public Map getTargetSchemas() { + return ImmutableMap.copyOf(this.targetSchemas); + } +} diff --git a/python/rpdk/java/templates/generate/hook/BaseHookHandler.java b/python/rpdk/java/templates/generate/hook/BaseHookHandler.java new file mode 100644 index 00000000..e4d725b8 --- /dev/null +++ b/python/rpdk/java/templates/generate/hook/BaseHookHandler.java @@ -0,0 +1,19 @@ +// This is a generated file. Modifications will be overwritten. +package {{ package_name }}; + +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; + +public abstract class BaseHookHandler { + + public abstract ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final CallbackT callbackContext, + final Logger logger, + final ConfigurationT typeConfiguration); + +} diff --git a/python/rpdk/java/templates/generate/hook/HookHandlerWrapper.java b/python/rpdk/java/templates/generate/hook/HookHandlerWrapper.java new file mode 100644 index 00000000..c900f218 --- /dev/null +++ b/python/rpdk/java/templates/generate/hook/HookHandlerWrapper.java @@ -0,0 +1,184 @@ +// This is a generated file. Modifications will be overwritten. +package {{ package_name }}; + +import com.amazonaws.AmazonServiceException; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.cloudformation.HookInvocationPoint; +import software.amazon.cloudformation.exceptions.BaseHandlerException; +import software.amazon.cloudformation.{{ wrapper_parent }}; +import software.amazon.cloudformation.loggers.LambdaLogPublisher; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.HookInvocationRequest; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.RequestContext; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookHandlerTestPayload; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; +import software.amazon.cloudformation.resource.Serializer; +import software.amazon.cloudformation.scheduler.CloudWatchScheduler; +import com.amazonaws.services.lambda.runtime.Context; +import com.fasterxml.jackson.core.type.TypeReference; + +import java.io.BufferedReader; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.apache.commons.io.IOUtils; +import org.json.JSONObject; + +public final class {{ "HookHandlerWrapper" if wrapper_parent == "HookLambdaWrapper" else "HookHandlerWrapperExecutable" }} extends {{ wrapper_parent }} { + + private final Configuration configuration = new Configuration(); + private JSONObject hookSchema; + private final Map> handlers = new HashMap<>(); + private final static TypeReference> REQUEST_REFERENCE = + new TypeReference>() {}; + private final static TypeReference TYPE_REFERENCE = + new TypeReference() {}; + private final static TypeReference> TEST_ENTRY_TYPE_REFERENCE = + new TypeReference>() {}; + + public {{ "HookHandlerWrapper" if wrapper_parent == "HookLambdaWrapper" else "HookHandlerWrapperExecutable" }}() { + initialiseHandlers(); + } + + private void initialiseHandlers() { +{% for op in operations %} + handlers.put(HookInvocationPoint.fromShortName("{{ op }}"), new {{ op|uppercase_first_letter }}HookHandler()); +{% endfor %} + } + + @Override + public ProgressEvent invokeHandler( + final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final HookInvocationPoint invocationPoint, + final CallbackContext callbackContext, + final TypeConfigurationModel typeConfiguration) { + + final String invocationPointName = (invocationPoint == null) ? "" : invocationPoint.toString(); // paranoia + if (!handlers.containsKey(invocationPoint)) + throw new RuntimeException("Unknown invocationPoint " + invocationPointName); + + final BaseHookHandler handler = handlers.get(invocationPoint); + + loggerProxy.log(String.format("[%s] invoking handler...", invocationPointName)); + final ProgressEvent result = handler.handleRequest(proxy, request, callbackContext, loggerProxy, typeConfiguration); + loggerProxy.log(String.format("[%s] handler invoked", invocationPointName)); + return result; + } + + {% if wrapper_parent == "HookLambdaWrapper" -%} + + + public void testEntrypoint( + final InputStream inputStream, + final OutputStream outputStream, + final Context context) throws IOException { + + this.loggerProxy = new LoggerProxy(); + this.loggerProxy.addLogPublisher(new LambdaLogPublisher(context.getLogger())); + + ProgressEvent response = ProgressEvent.failed(null, null, HandlerErrorCode.InternalFailure, "Uninitialized"); + try { + final String input = IOUtils.toString(inputStream, "UTF-8"); + final HookHandlerTestPayload payload = + this.serializer.deserialize( + input, + TEST_ENTRY_TYPE_REFERENCE); + + final AmazonWebServicesClientProxy proxy = new AmazonWebServicesClientProxy( + loggerProxy, payload.getCredentials(), () -> (long) context.getRemainingTimeInMillis()); + + response = invokeHandler(proxy, payload.getRequest(), payload.getActionInvocationPoint(), payload.getCallbackContext(), payload.getTypeConfiguration()); + } catch (final BaseHandlerException e) { + response = ProgressEvent.defaultFailureHandler(e, e.getErrorCode()); + } catch (final AmazonServiceException | AwsServiceException e) { + response = ProgressEvent.defaultFailureHandler(e, HandlerErrorCode.GeneralServiceException); + } catch (final Throwable e) { + e.printStackTrace(); + response = ProgressEvent.defaultFailureHandler(e, HandlerErrorCode.InternalFailure); + } finally { + final String output = this.serializer.serialize(response); + outputStream.write(output.getBytes(Charset.forName("UTF-8"))); + outputStream.close(); + } + } + {% else %} + public static void main(final String[] args) throws IOException { + if (args.length != 1){ + System.exit(1); + } + final String outputFile = (UUID.randomUUID().toString() + ".txt"); + try(final FileOutputStream output = new FileOutputStream(outputFile)){ + try(final InputStream input = IOUtils.toInputStream(args[0],"UTF-8")){ + new HookHandlerWrapperExecutable().handleRequest(input, output); + output.flush(); + } + } + System.out.println("__CFN_HOOK_START_RESPONSE__"); + readFileToSystemOut(outputFile); + System.out.println("__CFN_HOOK_END_RESPONSE__"); + } + + private static void readFileToSystemOut(final String fileName) throws IOException { + //Create object of FileReader + final FileReader inputFile = new FileReader(fileName); + try(final BufferedReader bufferReader = new BufferedReader(inputFile)) { + String line; + while ((line = bufferReader.readLine()) != null) { + System.out.println(line); + } + } + } + {%- endif %} + + @Override + public JSONObject provideHookSchemaJSONObject() { + if (hookSchema == null) { + hookSchema = this.configuration.hookSchemaJSONObject(); + } + return hookSchema; + } + + @Override + protected HookHandlerRequest transform(final HookInvocationRequest request) throws IOException { + return HookHandlerRequest.builder() + .clientRequestToken(request.getClientRequestToken()) + .hookContext(HookContext + .builder() + .awsAccountId(request.getAwsAccountId()) + .stackId(request.getStackId()) + .changeSetId(request.getChangeSetId()) + .hookTypeName(request.getHookTypeName()) + .hookTypeVersion(request.getHookTypeVersion()) + .invocationPoint(request.getActionInvocationPoint()) + .targetName(request.getRequestData().getTargetName()) + .targetType(request.getRequestData().getTargetType()) + .targetLogicalId(request.getRequestData().getTargetLogicalId()) + .targetModel(HookTargetModel.of(request.getRequestData().getTargetModel())) + .build()) + .build(); + } + + @Override + protected TypeReference> getTypeReference() { + return REQUEST_REFERENCE; + } + + @Override + protected TypeReference getModelTypeReference() { + return TYPE_REFERENCE; + } +} diff --git a/python/rpdk/java/templates/generate/hook/HookInputModel.java b/python/rpdk/java/templates/generate/hook/HookInputModel.java new file mode 100644 index 00000000..25b533ba --- /dev/null +++ b/python/rpdk/java/templates/generate/hook/HookInputModel.java @@ -0,0 +1,35 @@ +// This is a generated file. Modifications will be overwritten. +package {{ package_name }}; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.json.JSONObject; + + +@Data +@Builder +{% if properties.items()|length > 0 %} +@AllArgsConstructor +{% endif %} +@NoArgsConstructor +@JsonAutoDetect(fieldVisibility = Visibility.ANY, getterVisibility = Visibility.NONE, setterVisibility = Visibility.NONE) +public class {{ model_name|uppercase_first_letter }} { + @JsonIgnore + public static final String TYPE_NAME = "{{ type_name }}"; + + {% for name, type in properties.items() %} + @JsonProperty("{{ name }}") + private {{ type|translate_type }} {{ name|lowercase_first_letter|safe_reserved }}; + {% endfor %} +} diff --git a/python/rpdk/java/templates/generate/hook/ResourceHookTarget.java b/python/rpdk/java/templates/generate/hook/ResourceHookTarget.java new file mode 100644 index 00000000..edf0b058 --- /dev/null +++ b/python/rpdk/java/templates/generate/hook/ResourceHookTarget.java @@ -0,0 +1,125 @@ +// This is a generated file. Modifications will be overwritten. +package {{ package_name }}; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.json.JSONObject; +import software.amazon.cloudformation.proxy.hook.targetmodel.ResourceHookTarget; + + +@Data +@Builder +{% if properties.items()|length > 0 %} +@AllArgsConstructor +{% endif %} +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +@ToString +@JsonAutoDetect(fieldVisibility = Visibility.ANY, getterVisibility = Visibility.NONE, setterVisibility = Visibility.NONE) +public class {{ model_name|uppercase_first_letter }} extends ResourceHookTarget { + @JsonIgnore + public static final String TYPE_NAME = "{{ type_name }}"; + + {% for identifier in primaryIdentifier %} + {% set components = identifier.split("/") %} + @JsonIgnore + public static final String IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }} = "{{ identifier }}"; + {% endfor -%} + + {% for identifiers in additionalIdentifiers %} + {% for identifier in identifiers %} + {% set components = identifier.split("/") %} + public static final String IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }} = "{{ identifier }}"; + {% endfor %} + {% endfor %} + + {% for name, type in properties.items() %} + @JsonProperty("{{ name }}") + private {{ type|translate_type }} {{ name|lowercase_first_letter|safe_reserved }}; + + {% endfor %} + @JsonIgnore + public JSONObject getPrimaryIdentifier() { + final JSONObject identifier = new JSONObject(); + {% for identifier in primaryIdentifier %} + {% set components = identifier.split("/") %} + if (this.get{{components[2]|uppercase_first_letter}}() != null + {%- for i in range(4, components|length + 1) -%} + {#- #} && this + {%- for component in components[2:i] -%} .get{{component|uppercase_first_letter}}() {%- endfor -%} + {#- #} != null + {%- endfor -%} + ) { + identifier.put(IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}, this{% for component in components[2:] %}.get{{component|uppercase_first_letter}}(){% endfor %}); + } + + {% endfor %} + // only return the identifier if it can be used, i.e. if all components are present + return identifier.length() == {{ primaryIdentifier|length }} ? identifier : null; + } + + @JsonIgnore + public List getAdditionalIdentifiers() { + final List identifiers = new ArrayList(); + {% for identifiers in additionalIdentifiers %} + if (getIdentifier {%- for identifier in identifiers -%} _{{identifier.split("/")[-1]|uppercase_first_letter}} {%- endfor -%} () != null) { + identifiers.add(getIdentifier{% for identifier in identifiers %}_{{identifier.split("/")[-1]|uppercase_first_letter}}{% endfor %}()); + } + {% endfor %} + // only return the identifiers if any can be used + return identifiers.isEmpty() ? null : identifiers; + } + {% for identifiers in additionalIdentifiers %} + + @JsonIgnore + public JSONObject getIdentifier {%- for identifier in identifiers -%} _{{identifier.split("/")[-1]|uppercase_first_letter}} {%- endfor -%} () { + final JSONObject identifier = new JSONObject(); + {% for identifier in identifiers %} + {% set components = identifier.split("/") %} + if (this.get{{components[2]|uppercase_first_letter}}() != null + {%- for i in range(4, components|length + 1) -%} + {#- #} && this + {%- for component in components[2:i] -%} .get{{component|uppercase_first_letter}}() {%- endfor -%} + {#- #} != null + {%- endfor -%} + ) { + identifier.put(IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}, this{% for component in components[2:] %}.get{{component|uppercase_first_letter}}(){% endfor %}); + } + + {% endfor %} + // only return the identifier if it can be used, i.e. if all components are present + return identifier.length() == {{ identifiers|length }} ? identifier : null; + } + {% endfor %} + + @JsonIgnore + public JSONObject targetSchemaJSONObject() { + if (targetSchema == null) { + targetSchema = loadSchema("{{schema_path or 'null'}}"); + } + + return targetSchema; + } + + @JsonIgnore + public Boolean hasDefinedSchema() { + return Boolean.{{schema_available|upper}}; + } + + @JsonIgnore + public Boolean isCloudFormationRegistryType() { + return Boolean.{{is_registry_type|upper}}; + } +} diff --git a/python/rpdk/java/templates/generate/hook/ResourceHookTargetModel.java b/python/rpdk/java/templates/generate/hook/ResourceHookTargetModel.java new file mode 100644 index 00000000..dd34943f --- /dev/null +++ b/python/rpdk/java/templates/generate/hook/ResourceHookTargetModel.java @@ -0,0 +1,53 @@ +// This is a generated file. Modifications will be overwritten. +package {{ package_name }}; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.util.Map; +import java.util.List; +import java.util.ArrayList; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.json.JSONObject; +import software.amazon.cloudformation.proxy.hook.targetmodel.ResourceHookTargetModel; + + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@JsonAutoDetect(fieldVisibility = Visibility.ANY, getterVisibility = Visibility.NONE, setterVisibility = Visibility.NONE) +@JsonDeserialize(as = {{ model_name|uppercase_first_letter }}TargetModel.class) +public class {{ model_name|uppercase_first_letter }}TargetModel extends ResourceHookTargetModel<{{model_name}}> { + + @JsonIgnore + private static final TypeReference<{{model_name}}> TARGET_REFERENCE = + new TypeReference<{{model_name}}>() {}; + + @JsonIgnore + private static final TypeReference<{{model_name}}TargetModel> MODEL_REFERENCE = + new TypeReference<{{model_name}}TargetModel>() {}; + + @JsonIgnore + public static final String TARGET_TYPE_NAME = "{{ type_name }}"; + + + @JsonIgnore + public TypeReference<{{model_name}}> getHookTargetTypeReference() { + return TARGET_REFERENCE; + } + + @JsonIgnore + public TypeReference<{{model_name}}TargetModel> getTargetModelTypeReference() { + return MODEL_REFERENCE; + } +} diff --git a/python/rpdk/java/templates/init/default/StubHookHandler.java b/python/rpdk/java/templates/init/default/StubHookHandler.java new file mode 100644 index 00000000..13f47d1c --- /dev/null +++ b/python/rpdk/java/templates/init/default/StubHookHandler.java @@ -0,0 +1,44 @@ +package {{ package_name }}; + +import com.google.common.collect.ImmutableSet; +import software.amazon.cloudformation.exceptions.UnsupportedTargetException; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; + +import java.util.Collection; + +public class {{ operation }}HookHandler extends BaseHookHandler { + + private static final Collection HOOK_TARGET_NAMES = ImmutableSet.of( + {% for target_name in target_names %} + "{{target_name}}"{% if not loop.last %},{% endif %} + + {% endfor %} + ); + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger, + final TypeConfigurationModel typeConfiguration) { + + final String targetName = request.getHookContext().getTargetName(); + + if (!HOOK_TARGET_NAMES.contains(targetName)) { + throw new UnsupportedTargetException(targetName); + } + + final String resultMessage = "Successfully invoked {{ operation }}HookHandler for target: " + targetName; + + return ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .message(resultMessage) + .build(); + } +} diff --git a/python/rpdk/java/templates/init/default/StubHookHandlerTest.java b/python/rpdk/java/templates/init/default/StubHookHandlerTest.java new file mode 100644 index 00000000..ff262e5d --- /dev/null +++ b/python/rpdk/java/templates/init/default/StubHookHandlerTest.java @@ -0,0 +1,53 @@ +package {{ package_name }}; + +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +public class {{ operation }}HookHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + @BeforeEach + public void setup() { + proxy = mock(AmazonWebServicesClientProxy.class); + logger = mock(Logger.class); + } + + @Test + public void handleRequest_SimpleSuccess() { + final {{ operation }}HookHandler handler = new {{ operation }}HookHandler(); + + final TypeConfigurationModel typeConfiguration = TypeConfigurationModel.builder().build(); + + final HookHandlerRequest request = HookHandlerRequest.builder() + .hookContext(HookContext.builder().targetName("{{target_names|first}}").build()) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger, typeConfiguration); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNotNull(); + assertThat(response.getErrorCode()).isNull(); + } +} diff --git a/python/rpdk/java/templates/init/guided_aws/BaseHookHandlerStd.java b/python/rpdk/java/templates/init/guided_aws/BaseHookHandlerStd.java new file mode 100644 index 00000000..ec41f383 --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/BaseHookHandlerStd.java @@ -0,0 +1,40 @@ +package {{ package_name }}; + +import software.amazon.awssdk.core.SdkClient; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; + +// Placeholder for the functionality that could be shared across PreCreate/PreUpdate/PreDelete Handlers + +public abstract class BaseHookHandlerStd extends BaseHookHandler { + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger, + final TypeConfigurationModel typeConfiguration + ) { + return handleRequest( + proxy, + request, + callbackContext != null ? callbackContext : new CallbackContext(), + proxy.newProxy(ClientBuilder::getClient), + logger, + typeConfiguration + ); + } + + protected abstract ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger, + final TypeConfigurationModel typeConfiguration + ); +} diff --git a/python/rpdk/java/templates/init/guided_aws/HookTranslator.java b/python/rpdk/java/templates/init/guided_aws/HookTranslator.java new file mode 100644 index 00000000..1ffa418e --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/HookTranslator.java @@ -0,0 +1,48 @@ +package {{ package_name }}; + + +import {{ package_name }}.model.my.example.resource.MyExampleResourceTargetModel; +import {{ package_name }}.model.other.example.resource.OtherExampleResourceTargetModel; +import software.amazon.awssdk.awscore.AwsRequest; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; + +/** + * This class is a centralized placeholder for + * - api request construction + * - object translation to/from aws sdk + */ + +public class Translator { + + /** + * Request built from target model + * @param targetModel target model + * @return awsRequest the aws service request + */ + static AwsRequest translateToRequest(final HookTargetModel targetModel) { + final AwsRequest awsRequest; + if (targetModel instanceof MyExampleResourceTargetModel) { + awsRequest = translateToRequest((MyExampleResourceTargetModel) targetModel); + } else if (targetModel instanceof OtherExampleResourceTargetModel) { + awsRequest = translateToRequest((OtherExampleResourceTargetModel) targetModel); + } else { + // TODO: construct a request + awsRequest = null; + } + + return awsRequest; + } + + static AwsRequest translateToRequest(final MyExampleResourceTargetModel targetModel) { + final AwsRequest awsRequest = null; + // TODO: construct a request + return awsRequest; + } + + static AwsRequest translateToRequest(final OtherExampleResourceTargetModel targetModel) { + final AwsRequest awsRequest = null; + // TODO: construct a request + return awsRequest; + } + +} diff --git a/python/rpdk/java/templates/init/guided_aws/StubPreCreateHookHandler.java b/python/rpdk/java/templates/init/guided_aws/StubPreCreateHookHandler.java new file mode 100644 index 00000000..7200c0b2 --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/StubPreCreateHookHandler.java @@ -0,0 +1,74 @@ +package {{ package_name }}; + +import {{ package_name }}.model.my.example.resource.MyExampleResource; +import {{ package_name }}.model.my.example.resource.MyExampleResourceTargetModel; +import software.amazon.awssdk.core.SdkClient; +import software.amazon.cloudformation.exceptions.UnsupportedTargetException; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; +import software.amazon.cloudformation.proxy.hook.targetmodel.ResourceHookTargetModel; + +public class {{ operation }}HookHandler extends BaseHookHandlerStd { + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger, + final TypeConfigurationModel typeConfiguration) { + + final HookContext hookContext = request.getHookContext(); + final String targetName = hookContext.getTargetName(); + + if (!"My::Example::Resource".equals(targetName)) { + throw new UnsupportedTargetException(targetName); + } + + logger.log(String.format("Successfully invoked {{ operation }}HookHandler for target %s.", targetName)); + + final String expectedEncryptionAlgorithm = typeConfiguration.getEncryptionAlgorithm(); + logger.log(String.format("Verifying server side encryption for target %s, expecting target server side encryption algorithm to be %s.", + targetName, expectedEncryptionAlgorithm)); + + final ResourceHookTargetModel targetModel = hookContext.getTargetModel(MyExampleResourceTargetModel.class); + + final MyExampleResource resourceProperties = targetModel.getResourceProperties(); + final String targetEncryptionAlgorithm = (String) resourceProperties.get("MyEncryptionAlgorithm"); + + if (targetEncryptionAlgorithm == null) { + final String failureMessage = String.format("Failed to verify server side encryption for target %s, target does not have server side encryption enabled.", + targetName); + logger.log(failureMessage); + + return ProgressEvent.builder() + .status(OperationStatus.FAILED) + .message(failureMessage) + .build(); + } + + if (!targetEncryptionAlgorithm.equals(expectedEncryptionAlgorithm)) { + final String failureMessage = String.format("Failed to verify server side encryption for target %s, expecting encryption algorithm to be %s, acutal encryption algorithm is %s", + targetName, expectedEncryptionAlgorithm, targetEncryptionAlgorithm); + logger.log(failureMessage); + + return ProgressEvent.builder() + .status(OperationStatus.FAILED) + .message(failureMessage) + .build(); + } + + final String successMessage = String.format("Successfully verified server side encryption for target %s.", targetName); + return ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .message(successMessage) + .build(); + } +} diff --git a/python/rpdk/java/templates/init/guided_aws/StubPreCreateHookHandlerTest.java b/python/rpdk/java/templates/init/guided_aws/StubPreCreateHookHandlerTest.java new file mode 100644 index 00000000..72f9f2ec --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/StubPreCreateHookHandlerTest.java @@ -0,0 +1,62 @@ +package {{ package_name }}; + +import java.util.Map; +import java.util.HashMap; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class {{ operation }}HookHandlerTest extends AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + @BeforeEach + public void setup() { + proxy = mock(AmazonWebServicesClientProxy.class); + logger = mock(Logger.class); + } + + @Test + public void handleRequest_SimpleSuccess() { + final {{ operation }}HookHandler handler = new {{ operation }}HookHandler(); + + final TypeConfigurationModel typeConfiguration = mock(TypeConfigurationModel.class); + when(typeConfiguration.getEncryptionAlgorithm()).thenReturn("AES256"); + + final Map targetModel = new HashMap<>(); + final Map resourceProperties = new HashMap<>(); + resourceProperties.put("MyEncryptionAlgorithm", "AES256"); + targetModel.put("ResourceProperties", resourceProperties); + + final HookHandlerRequest request = HookHandlerRequest.builder() + .hookContext(HookContext.builder().targetName("My::Example::Resource").targetModel(HookTargetModel.of(targetModel)).build()) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger, typeConfiguration); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNotNull(); + assertThat(response.getErrorCode()).isNull(); + } +} diff --git a/python/rpdk/java/templates/init/guided_aws/StubPreDeleteHookHandler.java b/python/rpdk/java/templates/init/guided_aws/StubPreDeleteHookHandler.java new file mode 100644 index 00000000..6b41f772 --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/StubPreDeleteHookHandler.java @@ -0,0 +1,74 @@ +package {{ package_name }}; + +import {{ package_name }}.model.other.example.resource.OtherExampleResource; +import {{ package_name }}.model.other.example.resource.OtherExampleResourceTargetModel; +import software.amazon.awssdk.core.SdkClient; +import software.amazon.cloudformation.exceptions.UnsupportedTargetException; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; +import software.amazon.cloudformation.proxy.hook.targetmodel.ResourceHookTargetModel; + +public class {{ operation }}HookHandler extends BaseHookHandlerStd { + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger, + final TypeConfigurationModel typeConfiguration) { + + final HookContext hookContext = request.getHookContext(); + final String targetName = hookContext.getTargetName(); + + if (!"Other::Example::Resource".equals(targetName)) { + throw new UnsupportedTargetException(targetName); + } + + logger.log(String.format("Successfully invoked {{ operation }}HookHandler for target %s.", targetName)); + + final String expectedEncryptionAlgorithm = typeConfiguration.getEncryptionAlgorithm(); + logger.log(String.format("Verifying server side encryption for target %s, expecting target server side encryption algorithm to be %s.", + targetName, expectedEncryptionAlgorithm)); + + final ResourceHookTargetModel targetModel = hookContext.getTargetModel(OtherExampleResourceTargetModel.class); + + final OtherExampleResource resourceProperties = targetModel.getResourceProperties(); + final String targetEncryptionAlgorithm = (String) resourceProperties.get("OtherEncryptionAlgorithm"); + + if (targetEncryptionAlgorithm == null) { + final String failureMessage = String.format("Failed to verify server side encryption for target %s, target does not have server side encryption enabled.", + targetName); + logger.log(failureMessage); + + return ProgressEvent.builder() + .status(OperationStatus.FAILED) + .message(failureMessage) + .build(); + } + + if (!targetEncryptionAlgorithm.equals(expectedEncryptionAlgorithm)) { + final String failureMessage = String.format("Failed to verify server side encryption for target %s, expecting encryption algorithm to be %s, acutal encryption algorithm is %s", + targetName, expectedEncryptionAlgorithm, targetEncryptionAlgorithm); + logger.log(failureMessage); + + return ProgressEvent.builder() + .status(OperationStatus.FAILED) + .message(failureMessage) + .build(); + } + + final String successMessage = String.format("Successfully verified server side encryption for target %s.", targetName); + return ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .message(successMessage) + .build(); + } +} diff --git a/python/rpdk/java/templates/init/guided_aws/StubPreDeleteHookHandlerTest.java b/python/rpdk/java/templates/init/guided_aws/StubPreDeleteHookHandlerTest.java new file mode 100644 index 00000000..5bb2e516 --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/StubPreDeleteHookHandlerTest.java @@ -0,0 +1,62 @@ +package {{ package_name }}; + +import java.util.Map; +import java.util.HashMap; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class {{ operation }}HookHandlerTest extends AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + @BeforeEach + public void setup() { + proxy = mock(AmazonWebServicesClientProxy.class); + logger = mock(Logger.class); + } + + @Test + public void handleRequest_SimpleSuccess() { + final {{ operation }}HookHandler handler = new {{ operation }}HookHandler(); + + final TypeConfigurationModel typeConfiguration = mock(TypeConfigurationModel.class); + when(typeConfiguration.getEncryptionAlgorithm()).thenReturn("AES256"); + + final Map targetModel = new HashMap<>(); + final Map resourceProperties = new HashMap<>(); + resourceProperties.put("OtherEncryptionAlgorithm", "AES256"); + targetModel.put("ResourceProperties", resourceProperties); + + final HookHandlerRequest request = HookHandlerRequest.builder() + .hookContext(HookContext.builder().targetName("Other::Example::Resource").targetModel(HookTargetModel.of(targetModel)).build()) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger, typeConfiguration); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNotNull(); + assertThat(response.getErrorCode()).isNull(); + } +} diff --git a/python/rpdk/java/templates/init/guided_aws/StubPreImportHookHandler.java b/python/rpdk/java/templates/init/guided_aws/StubPreImportHookHandler.java new file mode 100644 index 00000000..f8748843 --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/StubPreImportHookHandler.java @@ -0,0 +1,68 @@ +package {{ package_name }}; + +import {{ package_name }}.model.my.example.resource.MyExampleResourceTargetModel; +import {{ package_name }}.model.other.example.resource.OtherExampleResourceTargetModel; +import software.amazon.cloudformation.exceptions.UnsupportedTargetException; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.HookStatus; +import software.amazon.cloudformation.proxy.hook.HookProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; + +public class {{ operation }}HookHandler extends BaseHookHandler { + + @Override + public HookProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final CallbackContext callbackContext, + final Logger logger, + final TypeConfigurationModel typeConfiguration) { + + final HookContext hookContext = request.getHookContext(); + final String targetName = hookContext.getTargetName(); + logger.log(String.format("Successfully invoked {{ operation }}HookHandler for target %s.", targetName)); + + final String expectedEncryptionAlgorithm = typeConfiguration.getEncryptionAlgorithm(); + logger.log(String.format("Verifying server side encryption for target %s, expecting target server side encryption algorithm to be %s.", + targetName, expectedEncryptionAlgorithm)); + + final String targetEncryptionAlgorithm; + if ("My::Example::Resource".equals(targetName)) { + targetEncryptionAlgorithm = (String) hookContext.getTargetModel(MyExampleResourceTargetModel.class).getResourceProperties().get("MyEncryptionAlgorithm"); + } else if ("Other::Example::Resource".equals(targetName)) { + targetEncryptionAlgorithm = (String) hookContext.getTargetModel(OtherExampleResourceTargetModel.class).getResourceProperties().get("OtherEncryptionAlgorithm"); + } else { + throw new UnsupportedTargetException(targetName); + } + + if (targetEncryptionAlgorithm == null) { + final String failureMessage = String.format("Failed to verify server side encryption for target %s, target does not have server side encryption enabled.", + targetName); + logger.log(failureMessage); + + return HookProgressEvent.builder() + .status(HookStatus.FAILED) + .message(failureMessage) + .build(); + } + + if (!targetEncryptionAlgorithm.equals(expectedEncryptionAlgorithm)) { + final String failureMessage = String.format("Failed to verify server side encryption for target %s, expecting encryption algorithm to be %s, acutal encryption algorithm is %s", + targetName, expectedEncryptionAlgorithm, targetEncryptionAlgorithm); + logger.log(failureMessage); + + return HookProgressEvent.builder() + .status(HookStatus.FAILED) + .message(failureMessage) + .build(); + } + + final String successMessage = String.format("Successfully verified server side encryption for target %s.", targetName); + return HookProgressEvent.builder() + .status(HookStatus.SUCCESS) + .message(successMessage) + .build(); + } +} diff --git a/python/rpdk/java/templates/init/guided_aws/StubPreImportHookHandlerTest.java b/python/rpdk/java/templates/init/guided_aws/StubPreImportHookHandlerTest.java new file mode 100644 index 00000000..3240d99a --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/StubPreImportHookHandlerTest.java @@ -0,0 +1,88 @@ +package {{ package_name }}; + +import java.util.Map; +import java.util.HashMap; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.hook.HookStatus; +import software.amazon.cloudformation.proxy.hook.HookProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class {{ operation }}HookHandlerTest { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + @BeforeEach + public void setup() { + proxy = mock(AmazonWebServicesClientProxy.class); + logger = mock(Logger.class); + } + + @Test + public void handleRequest_SimpleSuccess_OtherExampleResource() { + final {{ operation }}HookHandler handler = new {{ operation }}HookHandler(); + + final TypeConfigurationModel typeConfiguration = mock(TypeConfigurationModel.class); + when(typeConfiguration.getEncryptionAlgorithm()).thenReturn("AES256"); + + final Map targetModel = new HashMap<>(); + final Map resourceProperties = new HashMap<>(); + resourceProperties.put("OtherEncryptionAlgorithm", "AES256"); + targetModel.put("ResourceProperties", resourceProperties); + + final HookHandlerRequest request = HookHandlerRequest.builder() + .hookContext(HookContext.builder().targetName("Other::Example::Resource").targetModel(HookTargetModel.of(targetModel)).build()) + .build(); + + final HookProgressEvent response = handler.handleRequest(proxy, request, null, logger, typeConfiguration); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(HookStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNotNull(); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + public void handleRequest_SimpleSuccess_MyExampleResource() { + final {{ operation }}HookHandler handler = new {{ operation }}HookHandler(); + + final TypeConfigurationModel typeConfiguration = mock(TypeConfigurationModel.class); + when(typeConfiguration.getEncryptionAlgorithm()).thenReturn("AES256"); + + final Map targetModel = new HashMap<>(); + final Map resourceProperties = new HashMap<>(); + resourceProperties.put("MyEncryptionAlgorithm", "AES256"); + targetModel.put("ResourceProperties", resourceProperties); + + final HookHandlerRequest request = HookHandlerRequest.builder() + .hookContext(HookContext.builder().targetName("My::Example::Resource").targetModel(HookTargetModel.of(targetModel)).build()) + .build(); + + final HookProgressEvent response = handler.handleRequest(proxy, request, null, logger, typeConfiguration); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(HookStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNotNull(); + assertThat(response.getErrorCode()).isNull(); + } +} diff --git a/python/rpdk/java/templates/init/guided_aws/StubPreUpdateHookHandler.java b/python/rpdk/java/templates/init/guided_aws/StubPreUpdateHookHandler.java new file mode 100644 index 00000000..7a2b15b8 --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/StubPreUpdateHookHandler.java @@ -0,0 +1,78 @@ +package {{ package_name }}; + +import {{ package_name }}.model.my.example.resource.MyExampleResource; +import {{ package_name }}.model.my.example.resource.MyExampleResourceTargetModel; +import software.amazon.awssdk.core.SdkClient; +import software.amazon.cloudformation.exceptions.UnsupportedTargetException; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.ProxyClient; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; +import software.amazon.cloudformation.proxy.hook.targetmodel.ResourceHookTargetModel; + +public class {{ operation }}HookHandler extends BaseHookHandlerStd { + + @Override + public ProgressEvent handleRequest( + final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final CallbackContext callbackContext, + final ProxyClient proxyClient, + final Logger logger, + final TypeConfigurationModel typeConfiguration) { + + final HookContext hookContext = request.getHookContext(); + final String targetName = hookContext.getTargetName(); + + if (!"My::Example::Resource".equals(targetName)) { + throw new UnsupportedTargetException(targetName); + } + + logger.log(String.format("Successfully invoked {{ operation }}HookHandler for target %s.", targetName)); + + final String expectedEncryptionAlgorithm = typeConfiguration.getEncryptionAlgorithm(); + logger.log(String.format("Verifying server side encryption for target %s, expecting target server side encryption algorithm to be %s.", + targetName, expectedEncryptionAlgorithm)); + + final ResourceHookTargetModel targetModel = hookContext.getTargetModel(MyExampleResourceTargetModel.class); + + final MyExampleResource previousResourceProperties = targetModel.getPreviousResourceProperties(); + final String previousTargetEncryptionAlgorithm = (String) previousResourceProperties.get("MyEncryptionAlgorithm"); + logger.log(String.format("Target resource's previous server side encryption algorithm is %s.", previousTargetEncryptionAlgorithm)); + + final MyExampleResource resourceProperties = targetModel.getResourceProperties(); + final String targetEncryptionAlgorithm = (String) resourceProperties.get("MyEncryptionAlgorithm"); + + if (targetEncryptionAlgorithm == null) { + final String failureMessage = String.format("Failed to verify server side encryption for target %s, target does not have server side encryption enabled.", + targetName); + logger.log(failureMessage); + + return ProgressEvent.builder() + .status(OperationStatus.FAILED) + .message(failureMessage) + .build(); + } + + if (!targetEncryptionAlgorithm.equals(expectedEncryptionAlgorithm)) { + final String failureMessage = String.format("Failed to verify server side encryption for target %s, expecting encryption algorithm to be %s, acutal encryption algorithm is %s", + targetName, expectedEncryptionAlgorithm, targetEncryptionAlgorithm); + logger.log(failureMessage); + + return ProgressEvent.builder() + .status(OperationStatus.FAILED) + .message(failureMessage) + .build(); + } + + final String successMessage = String.format("Successfully verified server side encryption for target %s.", targetName); + return ProgressEvent.builder() + .status(OperationStatus.SUCCESS) + .message(successMessage) + .build(); + } +} diff --git a/python/rpdk/java/templates/init/guided_aws/StubPreUpdateHookHandlerTest.java b/python/rpdk/java/templates/init/guided_aws/StubPreUpdateHookHandlerTest.java new file mode 100644 index 00000000..e8968146 --- /dev/null +++ b/python/rpdk/java/templates/init/guided_aws/StubPreUpdateHookHandlerTest.java @@ -0,0 +1,63 @@ +package {{ package_name }}; + +import java.util.Map; +import java.util.HashMap; +import software.amazon.cloudformation.proxy.Logger; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class {{ operation }}HookHandlerTest extends AbstractTestBase { + + @Mock + private AmazonWebServicesClientProxy proxy; + + @Mock + private Logger logger; + + @BeforeEach + public void setup() { + proxy = mock(AmazonWebServicesClientProxy.class); + logger = mock(Logger.class); + } + + @Test + public void handleRequest_SimpleSuccess() { + final {{ operation }}HookHandler handler = new {{ operation }}HookHandler(); + + final TypeConfigurationModel typeConfiguration = mock(TypeConfigurationModel.class); + when(typeConfiguration.getEncryptionAlgorithm()).thenReturn("AES256"); + + final Map targetModel = new HashMap<>(); + final Map resourceProperties = new HashMap<>(); + resourceProperties.put("MyEncryptionAlgorithm", "AES256"); + targetModel.put("ResourceProperties", resourceProperties); + targetModel.put("PreviousResourceProperties", new HashMap<>()); + + final HookHandlerRequest request = HookHandlerRequest.builder() + .hookContext(HookContext.builder().targetName("My::Example::Resource").targetModel(HookTargetModel.of(targetModel)).build()) + .build(); + + final ProgressEvent response = handler.handleRequest(proxy, request, null, logger, typeConfiguration); + + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(response.getCallbackContext()).isNull(); + assertThat(response.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(response.getMessage()).isNotNull(); + assertThat(response.getErrorCode()).isNull(); + } +} diff --git a/python/rpdk/java/templates/init/shared/README_HOOK.md b/python/rpdk/java/templates/init/shared/README_HOOK.md new file mode 100644 index 00000000..1711daa8 --- /dev/null +++ b/python/rpdk/java/templates/init/shared/README_HOOK.md @@ -0,0 +1,12 @@ +# {{ type_name }} + +Congratulations on starting development! Next steps: + +1. Write the JSON schema describing your hook, `{{ schema_path.name }}` +1. Implement your hook handlers. + +The RPDK will automatically generate the correct hook input model from the schema whenever the project is built via Maven. You can also do this manually with the following command: `{{ executable }} generate`. + +> Please don't modify files under `target/generated-sources/rpdk`, as they will be automatically overwritten. + +The code uses [Lombok](https://projectlombok.org/), and [you may have to install IDE integrations](https://projectlombok.org/) to enable auto-complete for Lombok-annotated classes. diff --git a/python/rpdk/java/templates/init/shared/README.md b/python/rpdk/java/templates/init/shared/README_RESOURCE.md similarity index 100% rename from python/rpdk/java/templates/init/shared/README.md rename to python/rpdk/java/templates/init/shared/README_RESOURCE.md diff --git a/python/rpdk/java/templates/init/shared/StubHookConfiguration.java b/python/rpdk/java/templates/init/shared/StubHookConfiguration.java new file mode 100644 index 00000000..40f7a6f7 --- /dev/null +++ b/python/rpdk/java/templates/init/shared/StubHookConfiguration.java @@ -0,0 +1,8 @@ +package {{ package_name }}; + +class Configuration extends BaseHookConfiguration { + + public Configuration() { + super("{{ schema_file_name }}"); + } +} diff --git a/python/rpdk/java/templates/init/shared/pom.xml b/python/rpdk/java/templates/init/shared/pom.xml index cdf39ffc..45bc4b9e 100644 --- a/python/rpdk/java/templates/init/shared/pom.xml +++ b/python/rpdk/java/templates/init/shared/pom.xml @@ -16,6 +16,7 @@ 1.8 UTF-8 UTF-8 + @@ -100,6 +101,14 @@ 2.3 false + + + *:* + + **/Log4j2Plugins.dat + + + @@ -123,7 +132,7 @@ {{ executable }} - generate + generate ${cfn.generate.args} ${project.basedir} @@ -217,6 +226,12 @@ {{ schema_file_name }} + + ${project.basedir}/target/loaded-target-schemas + + **/*.json + + diff --git a/setup.py b/setup.py index ed71b29a..e84ad6ac 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.13"], + install_requires=["cloudformation-cli>=0.2.23"], python_requires=">=3.6", 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 10631d68..8bedfad1 100644 --- a/src/main/java/software/amazon/cloudformation/AbstractWrapper.java +++ b/src/main/java/software/amazon/cloudformation/AbstractWrapper.java @@ -409,6 +409,11 @@ protected void writeResponse(final OutputStream outputStream, final ProgressEven } } + if (response.getResult() != null) { + // remove any non-resource specific fields from response + response.setResult(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 new file mode 100644 index 00000000..64892918 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/HookAbstractWrapper.java @@ -0,0 +1,523 @@ +/* +* 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; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.retry.RetryUtils; +import com.fasterxml.jackson.core.type.TypeReference; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.http.HttpStatusCode; +import software.amazon.awssdk.http.HttpStatusFamily; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.utils.StringUtils; +import software.amazon.cloudformation.encryption.Cipher; +import software.amazon.cloudformation.encryption.KMSCipher; +import software.amazon.cloudformation.exceptions.BaseHandlerException; +import software.amazon.cloudformation.exceptions.FileScrubberException; +import software.amazon.cloudformation.exceptions.TerminalException; +import software.amazon.cloudformation.injection.CloudWatchLogsProvider; +import software.amazon.cloudformation.injection.CloudWatchProvider; +import software.amazon.cloudformation.injection.CredentialsProvider; +import software.amazon.cloudformation.injection.SessionCredentialsProvider; +import software.amazon.cloudformation.loggers.CloudWatchLogHelper; +import software.amazon.cloudformation.loggers.CloudWatchLogPublisher; +import software.amazon.cloudformation.loggers.LogPublisher; +import software.amazon.cloudformation.metrics.HookMetricsPublisherImpl; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.DelayFactory; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.MetricsPublisherProxy; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.WaitStrategy; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookInvocationRequest; +import software.amazon.cloudformation.proxy.hook.HookProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookRequestContext; +import software.amazon.cloudformation.proxy.hook.HookStatus; +import software.amazon.cloudformation.resource.SchemaValidator; +import software.amazon.cloudformation.resource.Serializer; +import software.amazon.cloudformation.resource.Validator; +import software.amazon.cloudformation.resource.exceptions.ValidationException; + +public abstract class HookAbstractWrapper { + + public static final SdkHttpClient HTTP_CLIENT = ApacheHttpClient.builder().build(); + private static final Logger LOG = LoggerFactory.getLogger(HookAbstractWrapper.class); + + protected final Serializer serializer; + protected LoggerProxy loggerProxy; + protected MetricsPublisherProxy metricsPublisherProxy; + protected Cipher cipher; + + protected LoggerProxy platformLoggerProxy; + protected LogPublisher platformLogPublisher; + + // provider... prefix indicates credential provided by hook owner + + final CredentialsProvider providerCredentialsProvider; + final CloudWatchProvider providerCloudWatchProvider; + final CloudWatchLogsProvider cloudWatchLogsProvider; + final SchemaValidator validator; + final TypeReference> typeReference; + + private MetricsPublisher platformMetricsPublisher; + private MetricsPublisher providerMetricsPublisher; + + private CloudWatchLogHelper cloudWatchLogHelper; + private CloudWatchLogPublisher providerEventsLogger; + + protected HookAbstractWrapper() { + this.providerCredentialsProvider = new SessionCredentialsProvider(); + this.providerCloudWatchProvider = new CloudWatchProvider(this.providerCredentialsProvider, HTTP_CLIENT); + this.cloudWatchLogsProvider = new CloudWatchLogsProvider(this.providerCredentialsProvider, HTTP_CLIENT); + this.serializer = new Serializer(); + this.validator = new Validator(); + this.typeReference = getTypeReference(); + this.platformLoggerProxy = new LoggerProxy(); + } + + /* + * This .ctor provided for testing + */ + public HookAbstractWrapper(final CredentialsProvider providerCredentialsProvider, + final CloudWatchLogPublisher providerEventsLogger, + final LogPublisher platformEventsLogger, + final MetricsPublisher platformMetricsPublisher, + final MetricsPublisher providerMetricsPublisher, + final SchemaValidator validator, + final Serializer serializer, + final SdkHttpClient httpClient, + final Cipher cipher) { + this.providerCredentialsProvider = providerCredentialsProvider; + this.providerCloudWatchProvider = new CloudWatchProvider(this.providerCredentialsProvider, httpClient); + this.cloudWatchLogsProvider = new CloudWatchLogsProvider(this.providerCredentialsProvider, httpClient); + this.providerEventsLogger = providerEventsLogger; + this.platformLogPublisher = platformEventsLogger; + this.platformMetricsPublisher = platformMetricsPublisher; + this.providerMetricsPublisher = providerMetricsPublisher; + this.serializer = serializer; + this.validator = validator; + this.typeReference = getTypeReference(); + this.cipher = cipher; + this.platformLoggerProxy = new LoggerProxy(); + } + + /** + * This function initialises dependencies which are depending on credentials + * passed at function invoke and not available during construction + */ + private void initialiseRuntime(final String hookTypeName, + final String providerCredentials, + final String providerLogGroupName, + final String awsAccountId, + final String hookEncryptionKeyArn, + final String hookEncryptionKeyRole) { + + this.metricsPublisherProxy = new MetricsPublisherProxy(); + this.loggerProxy = new LoggerProxy(); + this.loggerProxy.addLogPublisher(this.platformLogPublisher); + + // Initialize a KMS cipher to decrypt customer credentials in HookRequestData + if (this.cipher == null && hookEncryptionKeyArn != null && hookEncryptionKeyRole != null) { + this.cipher = new KMSCipher(hookEncryptionKeyArn, hookEncryptionKeyRole); + } + + // Initialisation skipped if dependencies were set during injection (in unit + // tests). + // e.g. "if (this.platformMetricsPublisher == null)" + if (this.platformMetricsPublisher == null) { + // platformMetricsPublisher needs aws account id to differentiate metrics + // namespace + this.platformMetricsPublisher = new HookMetricsPublisherImpl(this.platformLoggerProxy, awsAccountId, hookTypeName); + } + this.metricsPublisherProxy.addMetricsPublisher(this.platformMetricsPublisher); + this.platformMetricsPublisher.refreshClient(); + + // NOTE: providerCredentials and providerLogGroupName are null/not null in + // sync. + // Both are required parameters when LoggingConfig (optional) is provided when + // 'RegisterType'. + final Credentials processedProviderCredentials = processCredentials(providerCredentials); + if (processedProviderCredentials != null) { + if (this.providerCredentialsProvider != null) { + this.providerCredentialsProvider.setCredentials(processedProviderCredentials); + } + + if (this.providerMetricsPublisher == null) { + this.providerMetricsPublisher = new HookMetricsPublisherImpl(this.providerCloudWatchProvider, this.loggerProxy, + awsAccountId, hookTypeName); + } + this.metricsPublisherProxy.addMetricsPublisher(this.providerMetricsPublisher); + this.providerMetricsPublisher.refreshClient(); + + if (this.providerEventsLogger == null) { + this.cloudWatchLogHelper = new CloudWatchLogHelper(this.cloudWatchLogsProvider, providerLogGroupName, + this.platformLoggerProxy, this.metricsPublisherProxy); + this.cloudWatchLogHelper.refreshClient(); + + this.providerEventsLogger = new CloudWatchLogPublisher(this.cloudWatchLogsProvider, providerLogGroupName, + this.cloudWatchLogHelper.prepareLogStream(), + this.platformLoggerProxy, this.metricsPublisherProxy); + } + this.loggerProxy.addLogPublisher(this.providerEventsLogger); + this.providerEventsLogger.refreshClient(); + } + } + + public void processRequest(final InputStream inputStream, final OutputStream outputStream) throws IOException, + TerminalException { + + ProgressEvent handlerResponse = null; + HookInvocationRequest request = null; + scrubFiles(); + try { + if (inputStream == null) { + throw new TerminalException("No request object received"); + } + + String input = IOUtils.toString(inputStream, StandardCharsets.UTF_8); + JSONObject rawInput = new JSONObject(new JSONTokener(input)); + // deserialize incoming payload to modeled request + request = this.serializer.deserialize(input, typeReference); + handlerResponse = processInvocation(rawInput, request); + } catch (final ValidationException e) { + String message; + String fullExceptionMessage = ValidationException.buildFullExceptionMessage(e); + if (!StringUtils.isEmpty(fullExceptionMessage)) { + message = String.format("Model validation failed (%s)", fullExceptionMessage); + } else { + message = "Model validation failed with unknown cause."; + } + + handlerResponse = ProgressEvent.defaultFailureHandler(new TerminalException(message, e), + HandlerErrorCode.InvalidRequest); + publishExceptionMetric(request != null ? request.getActionInvocationPoint() : null, e, + HandlerErrorCode.InvalidRequest); + } catch (final Throwable e) { + // Exceptions are wrapped as a consistent error response to the caller (i.e; + // CloudFormation) + logError(ExceptionUtils.getStackTrace(e)); + + handlerResponse = ProgressEvent.defaultFailureHandler(e, HandlerErrorCode.InternalFailure); + publishExceptionMetric(request != null ? request.getActionInvocationPoint() : null, e, + HandlerErrorCode.InternalFailure); + } finally { + // A response will be output on all paths, though CloudFormation will + // not block on invoking the handlers, but rather listen for callbacks + writeResponse(outputStream, createProgressResponse(handlerResponse, request)); + publishExceptionCodeAndCountMetrics(request == null ? null : request.getActionInvocationPoint(), + handlerResponse.getErrorCode()); + } + } + + private ProgressEvent processInvocation(final JSONObject rawRequest, + final HookInvocationRequest request) + throws IOException, + TerminalException { + + assert request != null : "Invalid request object received. Request object is null"; + + if (request.getRequestData() == null || request.getRequestData().getTargetModel() == null) { + throw new TerminalException("Invalid request object received. Target Model can not be null."); + } + + if (request.getActionInvocationPoint() == HookInvocationPoint.UPDATE_POST_PROVISION) { + if (request.getRequestData().getTargetModel().get("PreviousResourceProperties") == null) { + throw new TerminalException("Invalid hook invocation request object received"); + } + } + + // TODO: Include hook schema validation here after schema is finalized + + // initialise dependencies with platform credentials + initialiseRuntime(request.getHookTypeName(), request.getRequestData().getProviderCredentials(), + request.getRequestData().getProviderLogGroupName(), request.getAwsAccountId(), + request.getRequestData().getHookEncryptionKeyArn(), request.getRequestData().getHookEncryptionKeyRole()); + + // transform the request object to pass to caller + HookHandlerRequest hookHandlerRequest = transform(request); + ConfigurationT typeConfiguration = request.getHookModel(); + + HookRequestContext requestContext = request.getRequestContext(); + + this.metricsPublisherProxy.publishInvocationMetric(Instant.now(), request.getActionInvocationPoint()); + + // last mile proxy creation with passed-in credentials (unless we are operating + // in a non-AWS model) + AmazonWebServicesClientProxy awsClientProxy = null; + Credentials processedCallerCredentials = processCredentials(request.getRequestData().getCallerCredentials()); + if (processedCallerCredentials != null) { + awsClientProxy = new AmazonWebServicesClientProxy(this.loggerProxy, processedCallerCredentials, + DelayFactory.CONSTANT_DEFAULT_DELAY_FACTORY, + WaitStrategy.scheduleForCallbackStrategy()); + + } + + CallbackT callbackContext = (requestContext != null) ? requestContext.getCallbackContext() : null; + + return wrapInvocationAndHandleErrors(awsClientProxy, hookHandlerRequest, request, callbackContext, typeConfiguration); + } + + private void logUnhandledError(final String errorDescription, + final HookInvocationRequest request, + final Throwable e) { + logError(String.format("%s in a %s action on a %s: %s%n%s", errorDescription, request.getActionInvocationPoint(), + request.getHookTypeName(), e.toString(), ExceptionUtils.getStackTrace(e))); + } + + /** + * Invokes the handler implementation for the request, and wraps with try-catch + * to consistently handle certain classes of errors and correctly map those to + * the appropriate HandlerErrorCode Also wraps the invocation in last-mile + * timing metrics + */ + private ProgressEvent + wrapInvocationAndHandleErrors(final AmazonWebServicesClientProxy awsClientProxy, + final HookHandlerRequest hookHandlerRequest, + final HookInvocationRequest request, + final CallbackT callbackContext, + final ConfigurationT typeConfiguration) { + + Date startTime = Date.from(Instant.now()); + try { + ProgressEvent handlerResponse = invokeHandler(awsClientProxy, hookHandlerRequest, + request.getActionInvocationPoint(), callbackContext, typeConfiguration); + if (handlerResponse != null) { + this.log(String.format("Handler returned %s", handlerResponse.getStatus())); + } else { + this.logError("Handler returned null"); + throw new TerminalException("Handler failed to provide a response."); + } + + return handlerResponse; + } catch (final BaseHandlerException e) { + publishExceptionMetric(request.getActionInvocationPoint(), e, e.getErrorCode()); + logUnhandledError(e.getMessage(), request, e); + + return ProgressEvent.defaultFailureHandler(e, e.getErrorCode()); + } catch (final AmazonServiceException | AwsServiceException e) { + if ((e instanceof AwsServiceException && ((AwsServiceException) e).statusCode() == HttpStatusCode.BAD_REQUEST) + || (e instanceof AmazonServiceException + && ((AmazonServiceException) e).getStatusCode() == HttpStatusCode.BAD_REQUEST)) { + this.log(String.format("%s [%s] call with invalid request to downstream service", request.getHookTypeName(), + request.getActionInvocationPoint())); + publishExceptionMetric(request.getActionInvocationPoint(), e, HandlerErrorCode.InvalidRequest); + + return ProgressEvent.defaultFailureHandler(e, HandlerErrorCode.InvalidRequest); + } else if ((e instanceof AwsServiceException && ((AwsServiceException) e).isThrottlingException()) + || (e instanceof AmazonServiceException && RetryUtils.isThrottlingException((AmazonServiceException) e))) { + this.log(String.format("%s [%s] call throttled by downstream service", request.getHookTypeName(), + request.getActionInvocationPoint())); + publishExceptionMetric(request.getActionInvocationPoint(), e, HandlerErrorCode.Throttling); + + return ProgressEvent.defaultFailureHandler(e, HandlerErrorCode.Throttling); + } else if ((e instanceof AwsServiceException + && HttpStatusFamily.of(((AwsServiceException) e).statusCode()) == HttpStatusFamily.SERVER_ERROR) + || (e instanceof AmazonServiceException + && HttpStatusFamily.of(((AmazonServiceException) e).getStatusCode()) == HttpStatusFamily.SERVER_ERROR)) { + publishExceptionMetric(request.getActionInvocationPoint(), e, HandlerErrorCode.ServiceInternalError); + logUnhandledError("An unknown downstream service error occurred", request, e); + + return ProgressEvent.defaultFailureHandler(e, HandlerErrorCode.ServiceInternalError); + } else { + publishExceptionMetric(request.getActionInvocationPoint(), e, HandlerErrorCode.GeneralServiceException); + logUnhandledError("A downstream service error occurred", request, e); + + return ProgressEvent.defaultFailureHandler(e, HandlerErrorCode.GeneralServiceException); + } + } catch (final Throwable e) { + publishExceptionMetric(request.getActionInvocationPoint(), e, HandlerErrorCode.InternalFailure); + logUnhandledError("An unknown error occurred ", request, e); + + return ProgressEvent.defaultFailureHandler(e, HandlerErrorCode.InternalFailure); + } finally { + Date endTime = Date.from(Instant.now()); + metricsPublisherProxy.publishDurationMetric(Instant.now(), request.getActionInvocationPoint(), + (endTime.getTime() - startTime.getTime())); + } + } + + private HookProgressEvent createProgressResponse(final ProgressEvent progressEvent, + final HookInvocationRequest request) { + final HookProgressEvent response = new HookProgressEvent<>(); + response.setHookStatus(getHookStatus(progressEvent.getStatus())); + response.setErrorCode(progressEvent.getErrorCode()); + response.setMessage(progressEvent.getMessage()); + response.setResult(progressEvent.getResult()); + response.setCallbackContext(progressEvent.getCallbackContext()); + response.setCallbackDelaySeconds(progressEvent.getCallbackDelaySeconds()); + if (request != null) { + response.setClientRequestToken(request.getClientRequestToken()); + } + + return response; + } + + private void writeResponse(final OutputStream outputStream, final HookProgressEvent response) throws IOException { + String output = this.serializer.serialize(response); + outputStream.write(output.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + } + + /** + * Transforms the incoming request to the subset of typed models which the + * handler implementor needs + * + * @param request The request as passed from the caller (e.g; CloudFormation) + * which contains additional context to inform the LambdaWrapper + * itself, and is not needed by the handler implementations + * @return A converted HookHandlerRequest model + */ + protected abstract HookHandlerRequest transform(HookInvocationRequest request) throws IOException; + + /** + * Handler implementation should implement this method to provide the schema for + * validation + * + * @return An JSONObject of the hook schema for the provider + */ + protected abstract JSONObject provideHookSchemaJSONObject(); + + /** + * Implemented by the handler package as the key entry point. + * + * @param proxy Amazon webservice proxy to inject credentials correctly. + * @param request incoming request for the call + * @param invocationPoint which invocation point the hook is invoked at + * @param callbackContext the callback context to handle reentrant calls + * @return progress event indicating complete, in progress with delay callback + * or failed state + * @throws Exception propagate any unexpected errors + */ + public abstract ProgressEvent invokeHandler(AmazonWebServicesClientProxy proxy, + HookHandlerRequest request, + HookInvocationPoint invocationPoint, + CallbackT callbackContext, + ConfigurationT typeConfiguration) + throws Exception; + + /* + * null-safe exception metrics delivery + */ + private void publishExceptionMetric(final HookInvocationPoint invocationPoint, + final Throwable ex, + final HandlerErrorCode handlerErrorCode) { + if (this.metricsPublisherProxy != null) { + this.metricsPublisherProxy.publishExceptionMetric(Instant.now(), invocationPoint, ex, handlerErrorCode); + } else if (platformLoggerProxy != null) { + platformLoggerProxy.log(ex.toString()); + } else { + LOG.error(ex.toString()); + } + } + + /* + * null-safe exception metrics delivery + */ + private void publishExceptionCodeAndCountMetrics(final HookInvocationPoint invocationPoint, + final HandlerErrorCode handlerErrorCode) { + if (this.metricsPublisherProxy != null) { + this.metricsPublisherProxy.publishExceptionByErrorCodeAndCountBulkMetrics(Instant.now(), invocationPoint, + handlerErrorCode); + } + } + + private Credentials processCredentials(final String rawCredentials) { + if (rawCredentials == null) { + return null; + } + + if (this.cipher != null) { + return this.cipher.decryptCredentials(rawCredentials); + } + + // Attempt to deserialize credentials if they are not encrypted. + // This would happen while running local contract tests + try { + return this.serializer.deserialize(rawCredentials, new TypeReference() { + }); + } catch (IOException e) { + return null; + } + } + + private HookStatus getHookStatus(final OperationStatus status) { + if (status == OperationStatus.PENDING) { + return HookStatus.PENDING; + } else if (status == OperationStatus.IN_PROGRESS) { + return HookStatus.IN_PROGRESS; + } else if (status == OperationStatus.SUCCESS) { + return HookStatus.SUCCESS; + } else { + return HookStatus.FAILED; + } + } + + /** + * null-safe logger redirect + * + * @param message A string containing the event to log. + */ + private void log(final String message) { + if (this.loggerProxy != null) { + this.loggerProxy.log(String.format("%s%n", message)); + } else if (platformLoggerProxy != null) { + platformLoggerProxy.log(message); + } else { + LOG.info(message); + } + } + + private void logError(final String message) { + if (this.loggerProxy != null) { + this.loggerProxy.log(String.format("%s%n", message)); + } else if (platformLoggerProxy != null) { + platformLoggerProxy.log(message); + } else { + LOG.error(message); + } + } + + protected abstract TypeReference> getTypeReference(); + + protected abstract TypeReference getModelTypeReference(); + + protected void scrubFiles() { + try { + FileUtils.cleanDirectory(FileUtils.getTempDirectory()); + } catch (IOException e) { + log(e.getMessage()); + publishExceptionMetric(null, new FileScrubberException(e), HandlerErrorCode.InternalFailure); + } + } +} diff --git a/src/main/java/software/amazon/cloudformation/HookExecutableWrapper.java b/src/main/java/software/amazon/cloudformation/HookExecutableWrapper.java new file mode 100644 index 00000000..bd389d52 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/HookExecutableWrapper.java @@ -0,0 +1,67 @@ +/* +* 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; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.cloudformation.encryption.Cipher; +import software.amazon.cloudformation.exceptions.TerminalException; +import software.amazon.cloudformation.injection.CredentialsProvider; +import software.amazon.cloudformation.loggers.CloudWatchLogPublisher; +import software.amazon.cloudformation.loggers.JavaLogPublisher; +import software.amazon.cloudformation.loggers.LogPublisher; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.resource.SchemaValidator; +import software.amazon.cloudformation.resource.Serializer; + +public abstract class HookExecutableWrapper + extends HookAbstractWrapper { + private Logger platformLogger = LoggerFactory.getLogger("GLOBAL"); + + public HookExecutableWrapper() { + super(); + } + + /* + * This .ctor provided for testing + */ + public HookExecutableWrapper(final CredentialsProvider providerCredentialsProvider, + final CloudWatchLogPublisher providerEventsLogger, + final LogPublisher platformEventsLogger, + final MetricsPublisher platformMetricsPublisher, + final MetricsPublisher providerMetricsPublisher, + final SchemaValidator validator, + final Serializer serializer, + final SdkHttpClient httpClient, + final Cipher cipher) { + super(providerCredentialsProvider, providerEventsLogger, platformEventsLogger, platformMetricsPublisher, + providerMetricsPublisher, validator, serializer, httpClient, cipher); + } + + public void handleRequest(final InputStream inputStream, final OutputStream outputStream) throws IOException, + TerminalException { + + if (platformLogPublisher == null) { + platformLogPublisher = new JavaLogPublisher(platformLogger); + } + this.platformLoggerProxy.addLogPublisher(platformLogPublisher); + processRequest(inputStream, outputStream); + outputStream.flush(); + } +} diff --git a/src/main/java/software/amazon/cloudformation/HookInvocationPoint.java b/src/main/java/software/amazon/cloudformation/HookInvocationPoint.java new file mode 100644 index 00000000..719b4541 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/HookInvocationPoint.java @@ -0,0 +1,43 @@ +/* +* 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; + +public enum HookInvocationPoint { + CREATE_PRE_PROVISION("PreCreate"), + CREATE_POST_PROVISION("PostCreate"), + UPDATE_PRE_PROVISION("PreUpdate"), + UPDATE_POST_PROVISION("PostUpdate"), + DELETE_PRE_PROVISION("PreDelete"), + DELETE_POST_PROVISION("PostDelete"); + + private final String shortName; + + HookInvocationPoint(String shortName) { + this.shortName = shortName; + } + + public String getShortName() { + return this.shortName; + } + + public static HookInvocationPoint fromShortName(String shortName) { + for (HookInvocationPoint e : HookInvocationPoint.values()) { + if (e.shortName.equalsIgnoreCase(shortName)) { + return e; + } + } + throw new IllegalArgumentException("No HookInvocationPoint with short name " + shortName + " found."); + } +} diff --git a/src/main/java/software/amazon/cloudformation/HookLambdaWrapper.java b/src/main/java/software/amazon/cloudformation/HookLambdaWrapper.java new file mode 100644 index 00000000..ab106848 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/HookLambdaWrapper.java @@ -0,0 +1,68 @@ +/* +* 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; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.cloudformation.encryption.Cipher; +import software.amazon.cloudformation.exceptions.TerminalException; +import software.amazon.cloudformation.injection.CredentialsProvider; +import software.amazon.cloudformation.loggers.CloudWatchLogPublisher; +import software.amazon.cloudformation.loggers.LambdaLogPublisher; +import software.amazon.cloudformation.loggers.LogPublisher; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.resource.SchemaValidator; +import software.amazon.cloudformation.resource.Serializer; + +public abstract class HookLambdaWrapper + extends HookAbstractWrapper implements RequestStreamHandler { + + public HookLambdaWrapper() { + super(); + } + + /* + * This .ctor provided for testing + */ + public HookLambdaWrapper(final CredentialsProvider providerCredentialsProvider, + final CloudWatchLogPublisher providerEventsLogger, + final LogPublisher platformEventsLogger, + final MetricsPublisher platformMetricsPublisher, + final MetricsPublisher providerMetricsPublisher, + final SchemaValidator validator, + final Serializer serializer, + final SdkHttpClient httpClient, + final Cipher cipher) { + super(providerCredentialsProvider, providerEventsLogger, platformEventsLogger, platformMetricsPublisher, + providerMetricsPublisher, validator, serializer, httpClient, cipher); + } + + @Override + public void handleRequest(final InputStream inputStream, final OutputStream outputStream, final Context context) + throws IOException, + TerminalException { + + if (platformLogPublisher == null) { + platformLogPublisher = new LambdaLogPublisher(context.getLogger()); + } + this.platformLoggerProxy.addLogPublisher(platformLogPublisher); + processRequest(inputStream, outputStream); + outputStream.close(); + } +} diff --git a/src/main/java/software/amazon/cloudformation/encryption/Cipher.java b/src/main/java/software/amazon/cloudformation/encryption/Cipher.java new file mode 100644 index 00000000..bc2fee26 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/encryption/Cipher.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.encryption; + +import software.amazon.cloudformation.proxy.Credentials; + +public interface Cipher { + + Credentials decryptCredentials(String encryptedCredentials); + +} diff --git a/src/main/java/software/amazon/cloudformation/encryption/KMSCipher.java b/src/main/java/software/amazon/cloudformation/encryption/KMSCipher.java new file mode 100644 index 00000000..fbf2ea6b --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/encryption/KMSCipher.java @@ -0,0 +1,106 @@ +/* +* 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.encryption; + +import com.amazonaws.ClientConfiguration; +import com.amazonaws.PredefinedClientConfigurations; +import com.amazonaws.auth.AWSSessionCredentialsProvider; +import com.amazonaws.auth.STSAssumeRoleSessionCredentialsProvider; +import com.amazonaws.encryptionsdk.AwsCrypto; +import com.amazonaws.encryptionsdk.CommitmentPolicy; +import com.amazonaws.encryptionsdk.CryptoResult; +import com.amazonaws.encryptionsdk.MasterKeyProvider; +import com.amazonaws.encryptionsdk.exception.AwsCryptoException; +import com.amazonaws.encryptionsdk.kms.KmsMasterKey; +import com.amazonaws.encryptionsdk.kms.KmsMasterKeyProvider; +import com.amazonaws.services.securitytoken.AWSSecurityTokenService; +import com.amazonaws.services.securitytoken.AWSSecurityTokenServiceClientBuilder; +import com.fasterxml.jackson.core.type.TypeReference; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.bouncycastle.util.encoders.Base64; +import software.amazon.awssdk.core.SdkSystemSetting; +import software.amazon.cloudformation.exceptions.EncryptionException; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.resource.Serializer; + +public class KMSCipher implements Cipher { + private static final int STS_CONNECTION_TIMEOUT_MILLIS = 10000; + private static final int STS_CONNECTION_TTL_MILLIS = 60000; + private static final int STS_CLIENT_EXECUTION_TIMEOUT_MILLIS = 10000; + private static final int STS_REQUEST_TIMEOUT_MILLIS = 10000; + private static final int STS_SOCKET_TIMEOUT_MILLIS = 10000; + private static final int STS_MAX_ERROR_RETRY = 3; + + private final AwsCrypto cryptoHelper; + private final MasterKeyProvider kmsKeyProvider; + private final Serializer serializer; + private final TypeReference credentialsTypeReference; + + public KMSCipher(final String encryptionKeyArn, + final String encryptionKeyRole) { + final String region = SdkSystemSetting.AWS_REGION.getStringValue().map(Object::toString).orElse("us-east-1"); + + final ClientConfiguration clientConfiguration = PredefinedClientConfigurations.defaultConfig() + .withConnectionTimeout(STS_CONNECTION_TIMEOUT_MILLIS).withConnectionTTL(STS_CONNECTION_TTL_MILLIS) + .withClientExecutionTimeout(STS_CLIENT_EXECUTION_TIMEOUT_MILLIS).withRequestTimeout(STS_REQUEST_TIMEOUT_MILLIS) + .withSocketTimeout(STS_SOCKET_TIMEOUT_MILLIS).withMaxErrorRetry(STS_MAX_ERROR_RETRY); + + final AWSSecurityTokenService stsClient = AWSSecurityTokenServiceClientBuilder.standard() + .withClientConfiguration(clientConfiguration).withRegion(region).build(); + + final AWSSessionCredentialsProvider assumeRoleSessionCredentialsProvider = getAssumeRoleSessionCredentialProvider( + encryptionKeyRole, stsClient); + + this.kmsKeyProvider = KmsMasterKeyProvider.builder().withCredentials(assumeRoleSessionCredentialsProvider) + .withDefaultRegion(region).buildStrict(encryptionKeyArn); + + this.cryptoHelper = AwsCrypto.builder().withCommitmentPolicy(CommitmentPolicy.ForbidEncryptAllowDecrypt).build(); + this.serializer = new Serializer(); + this.credentialsTypeReference = getCredentialsTypeReference(); + } + + // constructor for unit testing + public KMSCipher(final AwsCrypto cryptoHelper, + final MasterKeyProvider kmsKeyProvider) { + this.kmsKeyProvider = kmsKeyProvider; + this.cryptoHelper = cryptoHelper; + this.serializer = new Serializer(); + this.credentialsTypeReference = getCredentialsTypeReference(); + } + + @Override + public Credentials decryptCredentials(final String encryptedCredentials) { + try { + final CryptoResult result = cryptoHelper.decryptData(kmsKeyProvider, Base64.decode(encryptedCredentials)); + return serializer.deserialize(new String(result.getResult(), StandardCharsets.UTF_8), this.credentialsTypeReference); + } catch (final IOException | AwsCryptoException e) { + throw new EncryptionException("Failed to decrypt credentials.", e); + } + } + + private static TypeReference getCredentialsTypeReference() { + return new TypeReference() { + }; + } + + private STSAssumeRoleSessionCredentialsProvider + getAssumeRoleSessionCredentialProvider(final String encryptionKeyRole, final AWSSecurityTokenService stsClient) { + return new STSAssumeRoleSessionCredentialsProvider.Builder(encryptionKeyRole, UUID.randomUUID().toString()) + .withStsClient(stsClient).build(); + } +} diff --git a/src/main/java/software/amazon/cloudformation/exceptions/CfnHandlerInternalFailureException.java b/src/main/java/software/amazon/cloudformation/exceptions/CfnHandlerInternalFailureException.java new file mode 100644 index 00000000..2eafc15d --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/exceptions/CfnHandlerInternalFailureException.java @@ -0,0 +1,31 @@ +/* +* 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.exceptions; + +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +public class CfnHandlerInternalFailureException extends BaseHandlerException { + + private static final long serialVersionUID = -1646136434112354328L; + private static final HandlerErrorCode ERROR_CODE = HandlerErrorCode.HandlerInternalFailure; + + public CfnHandlerInternalFailureException() { + this(null); + } + + public CfnHandlerInternalFailureException(final Throwable cause) { + super(ERROR_CODE.getMessage(), cause, ERROR_CODE); + } +} diff --git a/src/main/java/software/amazon/cloudformation/exceptions/CfnNonCompliantException.java b/src/main/java/software/amazon/cloudformation/exceptions/CfnNonCompliantException.java new file mode 100644 index 00000000..241f2f6b --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/exceptions/CfnNonCompliantException.java @@ -0,0 +1,38 @@ +/* +* 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.exceptions; + +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +public class CfnNonCompliantException extends BaseHandlerException { + + private static final long serialVersionUID = -1646136434112354328L; + private static final HandlerErrorCode ERROR_CODE = HandlerErrorCode.NonCompliant; + + public CfnNonCompliantException(final Throwable cause) { + super(cause, ERROR_CODE); + } + + public CfnNonCompliantException(final String hookTypeName, + final String reason) { + this(hookTypeName, reason, null); + } + + public CfnNonCompliantException(final String hookTypeName, + final String reason, + final Throwable cause) { + super(String.format(ERROR_CODE.getMessage(), hookTypeName, reason), cause, ERROR_CODE); + } +} diff --git a/src/main/java/software/amazon/cloudformation/exceptions/CfnUnknownException.java b/src/main/java/software/amazon/cloudformation/exceptions/CfnUnknownException.java new file mode 100644 index 00000000..b72866cd --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/exceptions/CfnUnknownException.java @@ -0,0 +1,31 @@ +/* +* 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.exceptions; + +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +public class CfnUnknownException extends BaseHandlerException { + + private static final long serialVersionUID = -1646136434112354328L; + private static final HandlerErrorCode ERROR_CODE = HandlerErrorCode.Unknown; + + public CfnUnknownException() { + this(null); + } + + public CfnUnknownException(final Throwable cause) { + super(ERROR_CODE.getMessage(), cause, ERROR_CODE); + } +} diff --git a/src/main/java/software/amazon/cloudformation/exceptions/EncryptionException.java b/src/main/java/software/amazon/cloudformation/exceptions/EncryptionException.java new file mode 100644 index 00000000..0633895c --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/exceptions/EncryptionException.java @@ -0,0 +1,32 @@ +/* +* 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.exceptions; + +public class EncryptionException extends RuntimeException { + private static final long serialVersionUID = -5820555650294338667L; + + public EncryptionException(String message, + Throwable cause) { + super(message, cause); + } + + public EncryptionException(String message) { + super(message); + } + + protected EncryptionException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/software/amazon/cloudformation/exceptions/UnsupportedTargetException.java b/src/main/java/software/amazon/cloudformation/exceptions/UnsupportedTargetException.java new file mode 100644 index 00000000..9e53c604 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/exceptions/UnsupportedTargetException.java @@ -0,0 +1,34 @@ +/* +* 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.exceptions; + +public class UnsupportedTargetException extends CfnInvalidRequestException { + + private static final long serialVersionUID = -1646136434112354328L; + private static final String ERROR_MESSAGE = "Unsupported target: [%s]"; + + public UnsupportedTargetException(final Throwable cause) { + super(cause); + } + + public UnsupportedTargetException(final String targetTypeName) { + this(targetTypeName, null); + } + + public UnsupportedTargetException(final String targetTypeName, + final Throwable cause) { + super(String.format(ERROR_MESSAGE, targetTypeName), cause); + } +} diff --git a/src/main/java/software/amazon/cloudformation/metrics/HookMetricsPublisherImpl.java b/src/main/java/software/amazon/cloudformation/metrics/HookMetricsPublisherImpl.java new file mode 100644 index 00000000..6f50da41 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/metrics/HookMetricsPublisherImpl.java @@ -0,0 +1,174 @@ +/* +* 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.metrics; + +import com.google.common.collect.Sets; +import java.time.Instant; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashSet; +import software.amazon.awssdk.core.SdkSystemSetting; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.cloudwatch.CloudWatchClient; +import software.amazon.awssdk.services.cloudwatch.model.Dimension; +import software.amazon.awssdk.services.cloudwatch.model.MetricDatum; +import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataRequest; +import software.amazon.awssdk.services.cloudwatch.model.StandardUnit; +import software.amazon.cloudformation.HookInvocationPoint; +import software.amazon.cloudformation.injection.CloudWatchProvider; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; + +public class HookMetricsPublisherImpl extends MetricsPublisher { + private static final String DEFAULT_REGION = "us-east-1"; + + private CloudWatchProvider cloudWatchProvider; + private Logger loggerProxy; + private String awsAccountId; + private CloudWatchClient cloudWatchClient; + + public HookMetricsPublisherImpl(final CloudWatchProvider cloudWatchProvider, + final Logger loggerProxy, + final String awsAccountId, + final String hookTypeName) { + super(hookTypeName); + this.cloudWatchProvider = cloudWatchProvider; + this.loggerProxy = loggerProxy; + this.awsAccountId = awsAccountId; + } + + public HookMetricsPublisherImpl(final Logger loggerProxy, + final String awsAccountId, + final String hookTypeName) { + super(hookTypeName); + this.loggerProxy = loggerProxy; + this.awsAccountId = awsAccountId; + this.cloudWatchClient = createClient(); + } + + @Override + public void refreshClient() { + if (cloudWatchProvider != null) { + this.cloudWatchClient = cloudWatchProvider.get(); + } + } + + private String getHookTypeName() { + return this.typeName; + } + + @Override + public void publishExceptionMetric(final Instant timestamp, + final HookInvocationPoint invocationPoint, + final Throwable e, + final HandlerErrorCode handlerErrorCode) { + publishBulkMetrics(MetricDatum.builder().timestamp(timestamp).metricName(Metric.METRIC_NAME_HANDLER_EXCEPTION) + .unit(StandardUnit.COUNT).value(1.0) + .dimensions(Sets.newHashSet( + Dimension.builder().name(Metric.DIMENSION_KEY_INVOCATION_POINT_TYPE) + .value(invocationPoint == null ? "NO_INVOCATION_POINT" : invocationPoint.name()).build(), + Dimension.builder().name(Metric.DIMENSION_KEY_EXCEPTION_TYPE).value(e.getClass().toString()).build(), + Dimension.builder().name(Metric.DIMENSION_KEY_HOOK_TYPE).value(this.getHookTypeName()).build(), + Dimension.builder().name(Metric.DIMENSION_KEY_HANDLER_ERROR_CODE).value(handlerErrorCode.name()).build())) + .build()); + } + + @Override + public void publishProviderLogDeliveryExceptionMetric(final Instant timestamp, final Throwable e) { + publishBulkMetrics( + MetricDatum.builder().metricName(Metric.METRIC_NAME_HANDLER_EXCEPTION).unit(StandardUnit.COUNT).value(1.0) + .dimensions(Sets.newHashSet( + Dimension.builder().name(Metric.DIMENSION_KEY_INVOCATION_POINT_TYPE).value("HookLogDelivery").build(), + Dimension.builder().name(Metric.DIMENSION_KEY_EXCEPTION_TYPE).value(e.getClass().toString()).build(), + Dimension.builder().name(Metric.DIMENSION_KEY_HOOK_TYPE).value(this.getHookTypeName()).build())) + .timestamp(timestamp).build()); + } + + @Override + public void publishInvocationMetric(final Instant timestamp, final HookInvocationPoint invocationPoint) { + publishBulkMetrics( + MetricDatum.builder().metricName(Metric.METRIC_NAME_HANDLER_INVOCATION_COUNT).unit(StandardUnit.COUNT).value(1.0) + .dimensions(Sets.newHashSet( + Dimension.builder().name(Metric.DIMENSION_KEY_INVOCATION_POINT_TYPE) + .value(invocationPoint == null ? "NO_INVOCATION_POINT" : invocationPoint.name()).build(), + Dimension.builder().name(Metric.DIMENSION_KEY_HOOK_TYPE).value(this.getHookTypeName()).build())) + .timestamp(timestamp).build()); + } + + @Override + public void + publishDurationMetric(final Instant timestamp, final HookInvocationPoint invocationPoint, final long milliseconds) { + publishBulkMetrics(MetricDatum.builder().metricName(Metric.METRIC_NAME_HANDLER_DURATION).unit(StandardUnit.MILLISECONDS) + .value((double) milliseconds) + .dimensions(Sets.newHashSet( + Dimension.builder().name(Metric.DIMENSION_KEY_INVOCATION_POINT_TYPE) + .value(invocationPoint == null ? "NO_INVOCATION_POINT" : invocationPoint.name()).build(), + Dimension.builder().name(Metric.DIMENSION_KEY_HOOK_TYPE).value(this.getHookTypeName()).build())) + .timestamp(timestamp).build()); + } + + @Override + public void publishExceptionByErrorCodeAndCountBulkMetrics(final Instant timestamp, + final HookInvocationPoint invocationPoint, + final HandlerErrorCode handlerErrorCode) { + Collection bulkData = new HashSet<>(); + + // By Error Code dimensions + EnumSet.allOf(HandlerErrorCode.class).forEach(errorCode -> { + bulkData.add(MetricDatum.builder().metricName(Metric.METRIC_NAME_HANDLER_EXCEPTION_BY_ERROR_CODE) + .unit(StandardUnit.COUNT).value(errorCode == handlerErrorCode ? 1.0 : 0.0) + .dimensions(Sets.newHashSet( + Dimension.builder().name(Metric.DIMENSION_KEY_INVOCATION_POINT_TYPE) + .value(invocationPoint == null ? "NO_INVOCATION_POINT" : invocationPoint.name()).build(), + Dimension.builder().name(Metric.DIMENSION_KEY_HANDLER_ERROR_CODE).value(errorCode.name()).build())) + .timestamp(timestamp).build()); + }); + + // By Count dimensions + bulkData.add(MetricDatum.builder().metricName(Metric.METRIC_NAME_HANDLER_EXCEPTION_BY_EXCEPTION_COUNT) + .unit(StandardUnit.COUNT).value(handlerErrorCode == null ? 0.0 : 1.0) + .dimensions(Dimension.builder().name(Metric.DIMENSION_KEY_INVOCATION_POINT_TYPE) + .value(invocationPoint == null ? "NO_INVOCATION_POINT" : invocationPoint.name()).build()) + .timestamp(timestamp).build()); + + publishBulkMetrics(bulkData.toArray(new MetricDatum[0])); + } + + private void publishBulkMetrics(final MetricDatum... metricData) { + assert cloudWatchClient != null : "CloudWatchEventsClient was not initialised. You must call refreshClient() first."; + + PutMetricDataRequest putMetricDataRequest = PutMetricDataRequest.builder() + .namespace(String.format("%s/%s/%s", Metric.METRIC_NAMESPACE_ROOT, awsAccountId, namespace)).metricData(metricData) + .build(); + + try { + this.cloudWatchClient.putMetricData(putMetricDataRequest); + } catch (final Exception e) { + log(String.format("An error occurred while publishing metrics: %s", e.getMessage())); + } + } + + private void log(final String message) { + if (loggerProxy != null) { + loggerProxy.log(String.format("%s%n", message)); + } + } + + private CloudWatchClient createClient() { + final String region = SdkSystemSetting.AWS_REGION.getStringValue().map(Object::toString).orElse(DEFAULT_REGION); + return CloudWatchClient.builder().region(Region.of(region)).build(); + } + +} diff --git a/src/main/java/software/amazon/cloudformation/metrics/Metric.java b/src/main/java/software/amazon/cloudformation/metrics/Metric.java index 80b4b14a..7b76c3d0 100644 --- a/src/main/java/software/amazon/cloudformation/metrics/Metric.java +++ b/src/main/java/software/amazon/cloudformation/metrics/Metric.java @@ -24,8 +24,10 @@ public class Metric { public static final String METRIC_NAME_HANDLER_INVOCATION_COUNT = "HandlerInvocationCount"; public static final String DIMENSION_KEY_ACTION_TYPE = "Action"; + public static final String DIMENSION_KEY_INVOCATION_POINT_TYPE = "InvocationPoint"; public static final String DIMENSION_KEY_EXCEPTION_TYPE = "ExceptionType"; public static final String DIMENSION_KEY_RESOURCE_TYPE = "ResourceType"; + public static final String DIMENSION_KEY_HOOK_TYPE = "HookType"; public static final String DIMENSION_KEY_HANDLER_ERROR_CODE = "HandlerErrorCode"; private Metric() { diff --git a/src/main/java/software/amazon/cloudformation/metrics/MetricsPublisher.java b/src/main/java/software/amazon/cloudformation/metrics/MetricsPublisher.java index fc2b1322..55a06e88 100644 --- a/src/main/java/software/amazon/cloudformation/metrics/MetricsPublisher.java +++ b/src/main/java/software/amazon/cloudformation/metrics/MetricsPublisher.java @@ -16,16 +16,17 @@ import java.time.Instant; import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.HookInvocationPoint; import software.amazon.cloudformation.proxy.HandlerErrorCode; public abstract class MetricsPublisher { - protected String resourceTypeName; - protected String resourceNamespace; + protected String typeName; + protected String namespace; - public MetricsPublisher(final String resourceTypeName) { - this.resourceTypeName = resourceTypeName; - this.resourceNamespace = resourceTypeName.replace("::", "/"); + public MetricsPublisher(final String typeName) { + this.typeName = typeName; + this.namespace = typeName.replace("::", "/"); } /** @@ -42,17 +43,35 @@ public void publishExceptionMetric(final Instant timestamp, final HandlerErrorCode handlerErrorCode) { } + public void publishExceptionMetric(final Instant timestamp, + final HookInvocationPoint invocationPoint, + final Throwable e, + final HandlerErrorCode handlerErrorCode) { + } + public void publishExceptionByErrorCodeAndCountBulkMetrics(final Instant timestamp, final Action action, final HandlerErrorCode handlerErrorCode) { } + public void publishExceptionByErrorCodeAndCountBulkMetrics(final Instant timestamp, + final HookInvocationPoint invocationPoint, + final HandlerErrorCode handlerErrorCode) { + } + public void publishInvocationMetric(final Instant timestamp, final Action action) { } + public void publishInvocationMetric(final Instant timestamp, final HookInvocationPoint invocationPoint) { + } + public void publishDurationMetric(final Instant timestamp, final Action action, final long milliseconds) { } + public void + publishDurationMetric(final Instant timestamp, final HookInvocationPoint invocationPoint, final long milliseconds) { + } + public void publishProviderLogDeliveryExceptionMetric(final Instant timestamp, final Throwable exception) { } } diff --git a/src/main/java/software/amazon/cloudformation/metrics/MetricsPublisherImpl.java b/src/main/java/software/amazon/cloudformation/metrics/MetricsPublisherImpl.java index 8e76929c..e3822a80 100644 --- a/src/main/java/software/amazon/cloudformation/metrics/MetricsPublisherImpl.java +++ b/src/main/java/software/amazon/cloudformation/metrics/MetricsPublisherImpl.java @@ -49,7 +49,7 @@ public void refreshClient() { } private String getResourceTypeName() { - return this.resourceTypeName; + return this.typeName; } @Override @@ -131,9 +131,8 @@ private void publishBulkMetrics(final MetricDatum... metricData) { assert cloudWatchClient != null : "CloudWatchEventsClient was not initialised. You must call refreshClient() first."; try { - this.cloudWatchClient.putMetricData( - PutMetricDataRequest.builder().namespace(String.format("%s/%s", Metric.METRIC_NAMESPACE_ROOT, resourceNamespace)) - .metricData(metricData).build()); + this.cloudWatchClient.putMetricData(PutMetricDataRequest.builder() + .namespace(String.format("%s/%s", Metric.METRIC_NAMESPACE_ROOT, namespace)).metricData(metricData).build()); } catch (final Exception e) { log(String.format("An error occurred while publishing metrics: %s", e.getMessage())); } diff --git a/src/main/java/software/amazon/cloudformation/proxy/ExceptionMessages.java b/src/main/java/software/amazon/cloudformation/proxy/ExceptionMessages.java index ccf5af9a..5c6931d4 100644 --- a/src/main/java/software/amazon/cloudformation/proxy/ExceptionMessages.java +++ b/src/main/java/software/amazon/cloudformation/proxy/ExceptionMessages.java @@ -30,6 +30,9 @@ final class ExceptionMessages { static final String SERVICE_LIMIT_EXCEEDED = "Limit exceeded for resource of type '%s'. Reason: %s"; static final String THROTTLING = "Rate exceeded for operation '%s'."; static final String INVALID_TYPECONFIGURATION = "Invalid TypeConfiguration provided for type '%s'. Reason: %s"; + static final String HANDLER_INTERNAL_FAILURE = "Internal error occurred in the handler."; + static final String NON_COMPLIANT = "Hook of type '%s' returned a Non-Complaint status. Reason %s"; + static final String UNKNOWN = "Unknown error occurred."; private ExceptionMessages() { } diff --git a/src/main/java/software/amazon/cloudformation/proxy/HandlerErrorCode.java b/src/main/java/software/amazon/cloudformation/proxy/HandlerErrorCode.java index f9c45f3e..1c4061b3 100644 --- a/src/main/java/software/amazon/cloudformation/proxy/HandlerErrorCode.java +++ b/src/main/java/software/amazon/cloudformation/proxy/HandlerErrorCode.java @@ -104,7 +104,27 @@ public enum HandlerErrorCode { /** * typeConfiguration is null or required typeConfiguration property is null */ - InvalidTypeConfiguration(ExceptionMessages.INVALID_TYPECONFIGURATION); + InvalidTypeConfiguration(ExceptionMessages.INVALID_TYPECONFIGURATION), + + /** + * an internal error occurred within the handler, such as an NPE, etc. + * (Terminal) + */ + HandlerInternalFailure(ExceptionMessages.HANDLER_INTERNAL_FAILURE), + + /** + * the specified target of the Hook is in a non-compliant state. Only applicable + * to Hook type handlers (Terminal) Hook Handlers return this error when the + * hook's compliance checks have failed. + */ + NonCompliant(ExceptionMessages.NON_COMPLIANT), + + /** + * the Hook has returned a failure for an Unknown reason. Only applicable to + * Hook type handlers (terminal) Hook Handlers can return this when a hook has + * failed for a reason other than non-compliance + */ + Unknown(ExceptionMessages.UNKNOWN); @Getter private String message; diff --git a/src/main/java/software/amazon/cloudformation/proxy/MetricsPublisherProxy.java b/src/main/java/software/amazon/cloudformation/proxy/MetricsPublisherProxy.java index 57fa9824..b99d128c 100644 --- a/src/main/java/software/amazon/cloudformation/proxy/MetricsPublisherProxy.java +++ b/src/main/java/software/amazon/cloudformation/proxy/MetricsPublisherProxy.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.List; import software.amazon.cloudformation.Action; +import software.amazon.cloudformation.HookInvocationPoint; import software.amazon.cloudformation.metrics.MetricsPublisher; public class MetricsPublisherProxy { @@ -35,6 +36,14 @@ public void publishExceptionMetric(final Instant timestamp, .forEach(metricsPublisher -> metricsPublisher.publishExceptionMetric(timestamp, action, e, handlerErrorCode)); } + public void publishExceptionMetric(final Instant timestamp, + final HookInvocationPoint invocationPoint, + final Throwable e, + final HandlerErrorCode handlerErrorCode) { + metricsPublishers.stream().forEach( + metricsPublisher -> metricsPublisher.publishExceptionMetric(timestamp, invocationPoint, e, handlerErrorCode)); + } + public void publishExceptionByErrorCodeAndCountBulkMetrics(final Instant timestamp, final Action action, final HandlerErrorCode handlerErrorCode) { @@ -42,15 +51,33 @@ public void publishExceptionByErrorCodeAndCountBulkMetrics(final Instant timesta .publishExceptionByErrorCodeAndCountBulkMetrics(timestamp, action, handlerErrorCode)); } + public void publishExceptionByErrorCodeAndCountBulkMetrics(final Instant timestamp, + final HookInvocationPoint invocationPoint, + final HandlerErrorCode handlerErrorCode) { + metricsPublishers.stream().forEach(metricsPublishers -> metricsPublishers + .publishExceptionByErrorCodeAndCountBulkMetrics(timestamp, invocationPoint, handlerErrorCode)); + } + public void publishInvocationMetric(final Instant timestamp, final Action action) { metricsPublishers.stream().forEach(metricsPublisher -> metricsPublisher.publishInvocationMetric(timestamp, action)); } + public void publishInvocationMetric(final Instant timestamp, final HookInvocationPoint invocationPoint) { + metricsPublishers.stream() + .forEach(metricsPublisher -> metricsPublisher.publishInvocationMetric(timestamp, invocationPoint)); + } + public void publishDurationMetric(final Instant timestamp, final Action action, final long milliseconds) { metricsPublishers.stream() .forEach(metricsPublisher -> metricsPublisher.publishDurationMetric(timestamp, action, milliseconds)); } + public void + publishDurationMetric(final Instant timestamp, final HookInvocationPoint invocationPoint, final long milliseconds) { + metricsPublishers.stream() + .forEach(metricsPublisher -> metricsPublisher.publishDurationMetric(timestamp, invocationPoint, milliseconds)); + } + public void publishProviderLogDeliveryExceptionMetric(final Instant timestamp, final Throwable exception) { metricsPublishers.stream() .forEach(metricsPublisher -> metricsPublisher.publishProviderLogDeliveryExceptionMetric(timestamp, exception)); diff --git a/src/main/java/software/amazon/cloudformation/proxy/ProgressEvent.java b/src/main/java/software/amazon/cloudformation/proxy/ProgressEvent.java index dae27752..0b900ea2 100644 --- a/src/main/java/software/amazon/cloudformation/proxy/ProgressEvent.java +++ b/src/main/java/software/amazon/cloudformation/proxy/ProgressEvent.java @@ -45,6 +45,12 @@ public class ProgressEvent { */ private String message; + /** + * The optional data to be returned by a hook handler as part of the invocation + * result. This field is ignored for resource handlers. + */ + private String result; + /** * The callback context is an arbitrary datum which the handler can return in an * IN_PROGRESS event to allow the passing through of additional state or @@ -150,6 +156,17 @@ public static ProgressEvent success return event; } + public static ProgressEvent success(ResourceT model, CallbackT cxt, String message) { + return success(model, cxt, message, null); + } + + public static ProgressEvent success(ResourceT model, CallbackT cxt, String message, String result) { + return ProgressEvent.builder().resourceModel(model).callbackContext(cxt).message(message) + .result(result).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/HookContext.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookContext.java new file mode 100644 index 00000000..4835fd18 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookContext.java @@ -0,0 +1,52 @@ +/* +* 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 com.fasterxml.jackson.core.type.TypeReference; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.cloudformation.HookInvocationPoint; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; + +/** + * This interface describes the hook context object for the hook invocation + * request passed to the implementor. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder(toBuilder = true) +public class HookContext { + private String awsAccountId; + private String stackId; + private String changeSetId; + private String hookTypeName; + private String hookTypeVersion; + private HookInvocationPoint invocationPoint; + private String targetName; + private String targetType; + private String targetLogicalId; + private HookTargetModel targetModel; + + public TargetModelT getTargetModel(final Class clazz) { + return HookTargetModel.of(this.targetModel, clazz); + } + + public TargetModelT getTargetModel(final TypeReference typeReference) { + return HookTargetModel.of(this.targetModel, typeReference); + } +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookHandlerRequest.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookHandlerRequest.java new file mode 100644 index 00000000..3c61cad4 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookHandlerRequest.java @@ -0,0 +1,35 @@ +/* +* 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; + +/** + * This interface describes the request object for the hook invocation request + * passed to the implementor. It is transformed from an instance of + * HookInvocationRequest by the HookLambdaWrapper to only items of concern + * + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder(toBuilder = true) +public class HookHandlerRequest { + private String clientRequestToken; + private HookContext hookContext; +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookHandlerTestPayload.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookHandlerTestPayload.java new file mode 100644 index 00000000..8adbb996 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookHandlerTestPayload.java @@ -0,0 +1,41 @@ +/* +* 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; +import software.amazon.cloudformation.HookInvocationPoint; +import software.amazon.cloudformation.proxy.Credentials; + +/** + * This POJO is for the test entrypoint that bypasses the wrapper for direct + * testing. + * + * @param Type of hook type configuration model + * @param Type of callback data to be passed on re-invocation + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder(toBuilder = true) +public class HookHandlerTestPayload { + private Credentials credentials; + private HookInvocationPoint actionInvocationPoint; + private HookHandlerRequest request; + private CallbackT callbackContext; + private ConfigurationT typeConfiguration; +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookInvocationRequest.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookInvocationRequest.java new file mode 100644 index 00000000..d1422e1e --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookInvocationRequest.java @@ -0,0 +1,41 @@ +/* +* 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; +import software.amazon.cloudformation.HookInvocationPoint; + +/** + * This interface describes the request object for the hook invocation request. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class HookInvocationRequest { + private String clientRequestToken; + private String awsAccountId; + private String stackId; + private String changeSetId; + private String hookTypeName; + private String hookTypeVersion; + private ConfigurationT hookModel; + private HookInvocationPoint actionInvocationPoint; + private HookRequestData requestData; + private HookRequestContext requestContext; +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookProgressEvent.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookProgressEvent.java new file mode 100644 index 00000000..51beccfc --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookProgressEvent.java @@ -0,0 +1,159 @@ +/* +* 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 com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.function.Function; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class HookProgressEvent { + /** + * The bearerToken is used to report progress back to CloudFormation and is + * passed back to CloudFormation + */ + private String clientRequestToken; + + /** + * The status indicates whether the handler has reached a terminal state or is + * still computing and requires more time to complete + */ + private HookStatus hookStatus; + + /** + * If OperationStatus is FAILED, an error code should be provided + */ + private HandlerErrorCode errorCode; + + /** + * The handler can (and should) specify a contextual information message which + * can be shown to callers to indicate the nature of a progress transition or + * callback delay; for example a message indicating "propagating to edge" + */ + private String message; + + /** + * The callback context is an arbitrary datum which the handler can return in an + * IN_PROGRESS event to allow the passing through of additional state or + * metadata between subsequent retries; for example to pass through a Resource + * identifier which can be used to continue polling for stabilization + */ + private CallbackT callbackContext; + + /** + * A callback will be scheduled with an initial delay of no less than the number + * of seconds specified in the progress event. + */ + private int callbackDelaySeconds; + + /** + * The optional data to be returned by hook handler as part of the invocation + * result. + */ + private String result; + + /** + * Convenience method for constructing a FAILED response + * + * @param e actual service exception + * @param handlerErrorCode error code to return to CloudFormation + * @param the type for the callback context + * @return {@link HookProgressEvent} failed status code + */ + public static HookProgressEvent defaultFailureHandler(final Throwable e, + final HandlerErrorCode handlerErrorCode) { + return failed(null, handlerErrorCode, e.getMessage(), null); + } + + public static < + CallbackT> HookProgressEvent failed(CallbackT cxt, HandlerErrorCode code, String message, String data) { + return HookProgressEvent.builder().callbackContext(cxt).errorCode(code).message(message).result(data) + .hookStatus(HookStatus.FAILED).build(); + } + + /** + * Convenience method for constructing a IN_PROGRESS response + * + * @param cxt callback context + * @param delaySeconds how much time to wait before calling back the handler + * @param The type for the callback context + * @return {@link HookProgressEvent} with {@link HookStatus#IN_PROGRESS} + */ + public static HookProgressEvent defaultProgressHandler(CallbackT cxt, int delaySeconds) { + return progress(cxt, null, null, delaySeconds); + } + + public static < + CallbackT> HookProgressEvent progress(CallbackT cxt, String message, String data, int delaySeconds) { + return HookProgressEvent.builder().callbackContext(cxt).callbackDelaySeconds(delaySeconds).message(message) + .result(data).hookStatus(HookStatus.IN_PROGRESS).build(); + } + + /** + * Convenience method for constructing a SUCCESS response + * + * @param The type for the callback context + * @return {@link HookProgressEvent} with {@link HookStatus#SUCCESS} indicating + * successful completion for operation + */ + public static HookProgressEvent defaultCompleteHandler() { + return complete(null, null, null); + } + + public static HookProgressEvent complete(CallbackT cxt, String message, String data) { + return HookProgressEvent.builder().callbackContext(cxt).message(message).result(data) + .hookStatus(HookStatus.SUCCESS).build(); + } + + public HookProgressEvent onComplete(Function, HookProgressEvent> func) { + return (hookStatus != null && hookStatus == HookStatus.SUCCESS) ? func.apply(this) : this; + } + + @JsonIgnore + public boolean isFailed() { + return hookStatus == HookStatus.FAILED; + } + + @JsonIgnore + public boolean isInProgress() { + return hookStatus == HookStatus.IN_PROGRESS; + } + + @JsonIgnore + public boolean canContinueProgress() { + return hookStatus == HookStatus.IN_PROGRESS && callbackDelaySeconds == 0; + } + + public HookProgressEvent then(Function, HookProgressEvent> func) { + return canContinueProgress() ? func.apply(this) : this; + } + + @JsonIgnore + public boolean isComplete() { + return hookStatus == HookStatus.SUCCESS; + } + + @JsonIgnore + public boolean isInProgressCallbackDelay() { + return hookStatus == HookStatus.IN_PROGRESS && callbackDelaySeconds > 0; + } +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookRequestContext.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookRequestContext.java new file mode 100644 index 00000000..2b17fa46 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookRequestContext.java @@ -0,0 +1,34 @@ +/* +* 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.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HookRequestContext { + /** + * The number of times the handler has been invoked (including current) + */ + private int invocation; + + /** + * Custom context object to enable handlers to process re-invocation + */ + private CallbackT callbackContext; +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookRequestData.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookRequestData.java new file mode 100644 index 00000000..53541480 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookRequestData.java @@ -0,0 +1,37 @@ +/* +* 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 java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class HookRequestData { + private String targetName; + private String targetType; + private String targetLogicalId; + private Map targetModel; + private String callerCredentials; + private String providerCredentials; + private String providerLogGroupName; + private String hookEncryptionKeyArn; + private String hookEncryptionKeyRole; +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/HookStatus.java b/src/main/java/software/amazon/cloudformation/proxy/hook/HookStatus.java new file mode 100644 index 00000000..1fd22521 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/HookStatus.java @@ -0,0 +1,22 @@ +/* +* 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 HookStatus { + PENDING, + IN_PROGRESS, + SUCCESS, + FAILED +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/GenericHookTargetModel.java b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/GenericHookTargetModel.java new file mode 100644 index 00000000..a4a34052 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/GenericHookTargetModel.java @@ -0,0 +1,72 @@ +/* +* 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.targetmodel; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.util.Map; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + * This class is a concrete agnostic target model that is the default + * implementation used when the type of the Hook's target is not known. Since it + * is possible for Hooks to support multiple target types, the type of the + * hook's target is not known until it receives a request during runtime. + * + * The properties of the Hook's target are stored internally in a Map which can + * be accessed using the {@link HookTargetModel} accessor methods + */ +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@ToString(callSuper = true) +@JsonAutoDetect(fieldVisibility = Visibility.ANY, getterVisibility = Visibility.NONE, setterVisibility = Visibility.NONE) +@JsonDeserialize(as = GenericHookTargetModel.class) +public class GenericHookTargetModel extends HookTargetModel { + private static final TypeReference TARGET_REFERENCE = new TypeReference() { + }; + private static final TypeReference MODEL_REFERENCE = new TypeReference() { + }; + + @JsonAnySetter + private void setTargetModelProperties(final String key, final Object value) { + setTargetModelProperty(key, value); + } + + @JsonAnyGetter + public Map getTargetModelProperties() { + return super.getTargetModel(); + } + + @Override + protected HookTargetType getHookTargetType() { + return HookTargetType.GENERIC; + } + + @Override + protected TypeReference getHookTargetTypeReference() { + return TARGET_REFERENCE; + } + + @Override + protected TypeReference getTargetModelTypeReference() { + return MODEL_REFERENCE; + } +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTarget.java b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTarget.java new file mode 100644 index 00000000..5b47a9ff --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTarget.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.targetmodel; + +public interface HookTarget { + + Object get(String key); + + HookTargetType getHookTargetType(); + +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTargetModel.java b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTargetModel.java new file mode 100644 index 00000000..b5dfcafe --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTargetModel.java @@ -0,0 +1,171 @@ +/* +* 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.targetmodel; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import javax.annotation.CheckForNull; +import lombok.AccessLevel; +import lombok.Data; +import lombok.NonNull; +import lombok.Setter; +import org.json.JSONObject; +import software.amazon.cloudformation.resource.Serializer; + +@Data +@Setter(AccessLevel.PRIVATE) +@JsonDeserialize(as = GenericHookTargetModel.class) +public abstract class HookTargetModel { + private static final TypeReference> MAP_REFERENCE = new TypeReference>() { + }; + + @JsonIgnore + private Map targetModel = new HashMap<>(); + + void setTargetModelProperty(final String key, final Object value) { + this.targetModel.put(key, value); + } + + private void setTargetModel(final Map targetModel) { + this.targetModel = ImmutableMap.copyOf(targetModel); + } + + public final Map getTargetModel() { + return targetModel; + } + + @JsonIgnore + public JSONObject getTargetModelAsJSONObject() { + return new JSONObject(this.targetModel); + } + + public Map getTargetModelAsMap() { + return new Serializer().convert(this, MAP_REFERENCE); + } + + public final Object get(final String key) { + return this.targetModel.get(key); + } + + public final Object getOrDefault(final String key, final Object value) { + return this.targetModel.getOrDefault(key, value); + } + + public final boolean containsKey(final String key) { + return this.targetModel.containsKey(key); + } + + protected abstract HookTargetType getHookTargetType(); + + protected abstract TypeReference getHookTargetTypeReference(); + + protected abstract TypeReference getTargetModelTypeReference(); + + public static HookTargetModel of(final Object targetModel) { + return HookTargetModel.of(targetModel, GenericHookTargetModel.class); + } + + public static TargetModelT of(@CheckForNull final Object targetModel, + @NonNull final Class clazz) { + if (targetModel == null) { + return null; + } + + final TypeReference typeReference = new TypeReference() { + @Override + public Type getType() { + return clazz; + } + }; + + return HookTargetModel.of(targetModel, typeReference); + } + + @SuppressWarnings("unchecked") + public static TargetModelT of(@CheckForNull final Object targetModel, + @NonNull TypeReference typeReference) { + if (targetModel == null) { + return null; + } + + try { + final Type type = typeReference.getType(); + + final Class clazz; + if (type instanceof Class) { + clazz = (Class) type; + } else { + clazz = Class.forName(type.getTypeName()); + } + + if (clazz.isInterface() || clazz.isAnonymousClass() || Modifier.isAbstract(clazz.getModifiers())) { + throw new IllegalArgumentException("Target Model can only be converted to concrete types."); + } + } catch (final ClassNotFoundException e) { + // Do what type checking we can here. Jackson will catch anything else. + } + + final Map rawTargetModel = extractRawTargetModel(targetModel); + final HookTargetModel hookTargetModel = new Serializer().convert(rawTargetModel, typeReference); + hookTargetModel.setTargetModel(rawTargetModel); + + return (TargetModelT) hookTargetModel; + } + + @VisibleForTesting + static Map extractRawTargetModel(final Object targetModel) { + try { + final Map rawTargetModel; + if (targetModel == null) { + rawTargetModel = new HashMap<>(); + } else if (targetModel instanceof HookTargetModel) { + rawTargetModel = ((HookTargetModel) targetModel).getTargetModel(); + } else if (targetModel instanceof Map) { + rawTargetModel = (Map) targetModel; + } else if (targetModel instanceof JSONObject) { + rawTargetModel = ((JSONObject) targetModel).toMap(); + } else if (targetModel instanceof String) { + rawTargetModel = new Serializer().deserialize((String) targetModel, MAP_REFERENCE); + } else { + rawTargetModel = new Serializer().convert(targetModel, MAP_REFERENCE); + } + + final Map targetModelMap = new HashMap<>(); + for (final Entry entry : rawTargetModel.entrySet()) { + final Object key = entry.getKey(); + if (key == null) { + throw new NullPointerException("Null key."); + } + final Object value = entry.getValue(); + if (value != null) { + targetModelMap.put(String.valueOf(key), value); + } + } + + return targetModelMap; + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTargetType.java b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTargetType.java new file mode 100644 index 00000000..73a96d25 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTargetType.java @@ -0,0 +1,30 @@ +/* +* 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.targetmodel; + +public enum HookTargetType { + /** + * An agnostic target typed used when we do not know the type of a Hook's + * target. Typically, Hooks can support multiple target types which won't be + * known until runtime. + */ + GENERIC, + + /** + * A target model meant to represent a target for a Resource Hook. This model + * type will have properties specific to the resource type. + */ + RESOURCE; +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/ResourceHookTarget.java b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/ResourceHookTarget.java new file mode 100644 index 00000000..0262af34 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/ResourceHookTarget.java @@ -0,0 +1,140 @@ +/* +* 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.targetmodel; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.json.JSONObject; +import org.json.JSONTokener; +import software.amazon.cloudformation.resource.Serializer; + +@JsonAutoDetect(fieldVisibility = Visibility.ANY, getterVisibility = Visibility.NONE, setterVisibility = Visibility.NONE) +public abstract class ResourceHookTarget implements HookTarget { + + @JsonIgnore + protected JSONObject targetSchema; + + @JsonIgnore + private Map properties; + + @JsonIgnore + private Map additionalProperties; + + @Override + public final Object get(final String key) { + return MapUtils.getObject(getProperties(), key); + } + + public final Object getOrDefault(final String key, final Object defaultValue) { + return MapUtils.getObject(getProperties(), key, defaultValue); + } + + public final boolean hasProperty(final String key) { + return MapUtils.emptyIfNull(getProperties()).containsKey(key); + } + + public final Map getProperties() { + if (properties == null) { + properties = new Serializer().convert(this, Serializer.MAP_TYPE_REFERENCE); + } + + return ImmutableMap.copyOf(properties); + } + + @JsonAnyGetter + public final Map getAdditionalProperties() { + if (additionalProperties == null) { + additionalProperties = new HashMap<>(); + } + + return ImmutableMap.copyOf(additionalProperties); + } + + @SuppressFBWarnings(value = "UPM_UNCALLED_PRIVATE_METHOD", justification = "Called internally during deserialization") + @JsonAnySetter + private void setAdditionalProperty(final String key, final Object value) { + if (BooleanUtils.or(ArrayUtils.toArray(isCloudFormationRegistryType(), hasDefinedSchema()))) { + return; + } + + if (additionalProperties == null) { + additionalProperties = new HashMap<>(); + } + additionalProperties.put(key, value); + } + + @Override + public final HookTargetType getHookTargetType() { + return HookTargetType.RESOURCE; + } + + public abstract JSONObject getPrimaryIdentifier(); + + public abstract List getAdditionalIdentifiers(); + + public abstract JSONObject targetSchemaJSONObject(); + + public abstract Boolean hasDefinedSchema(); + + public abstract Boolean isCloudFormationRegistryType(); + + protected JSONObject loadSchema(final String schema) { + return loadSchema(schema, this.getClass()); + } + + @VisibleForTesting + static JSONObject loadSchema(final String schema, final Class clazz) { + if (StringUtils.isBlank(schema) || StringUtils.equalsIgnoreCase(schema, "null")) { + return new JSONObject(JSONObject.NULL); + } + + final String targetSchema = StringUtils.trim(schema); + try { + final String json; + if (StringUtils.startsWith(targetSchema, "{") && StringUtils.endsWith(targetSchema, "}")) { + json = targetSchema; + } else if (StringUtils.endsWith(targetSchema, ".json")) { + final InputStream is = Optional.ofNullable(clazz.getClassLoader().getResourceAsStream(targetSchema)) + .orElse(clazz.getResourceAsStream(targetSchema)); + json = IOUtils.toString(Objects.requireNonNull(is), StandardCharsets.UTF_8); + } else { + json = null; + } + + return StringUtils.isNotBlank(json) ? new JSONObject(new JSONTokener(json)) : new JSONObject(JSONObject.NULL); + } catch (IOException | RuntimeException e) { + return new JSONObject(JSONObject.NULL); + } + } +} diff --git a/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/ResourceHookTargetModel.java b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/ResourceHookTargetModel.java new file mode 100644 index 00000000..7bfdf0d2 --- /dev/null +++ b/src/main/java/software/amazon/cloudformation/proxy/hook/targetmodel/ResourceHookTargetModel.java @@ -0,0 +1,51 @@ +/* +* 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.targetmodel; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@EqualsAndHashCode(callSuper = false) +@Getter +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PACKAGE) // Visible for Testing +@ToString +@JsonAutoDetect(fieldVisibility = Visibility.ANY, getterVisibility = Visibility.NONE, setterVisibility = Visibility.NONE) +public abstract class ResourceHookTargetModel extends HookTargetModel { + @JsonProperty("ResourceProperties") + private HookTargetT resourceProperties; + + @JsonProperty("PreviousResourceProperties") + private HookTargetT previousResourceProperties; + + @Override + public abstract TypeReference getHookTargetTypeReference(); + + @Override + public abstract TypeReference> getTargetModelTypeReference(); + + @Override + public final HookTargetType getHookTargetType() { + return HookTargetType.RESOURCE; + } +} diff --git a/src/main/java/software/amazon/cloudformation/resource/Serializer.java b/src/main/java/software/amazon/cloudformation/resource/Serializer.java index 6fda8a07..17c19f0c 100644 --- a/src/main/java/software/amazon/cloudformation/resource/Serializer.java +++ b/src/main/java/software/amazon/cloudformation/resource/Serializer.java @@ -36,13 +36,13 @@ public class Serializer { + public static final TypeReference> MAP_TYPE_REFERENCE = new TypeReference>() { + }; public static final String COMPRESSED = "__COMPRESSED__"; private static final String COMPRESSION_METHOD = "__COMPRESSION_METHOD__"; private static final String COMPRESSION_GZIP_BASE64 = "gzip_base64"; private static final ObjectMapper OBJECT_MAPPER; private static final ObjectMapper STRICT_OBJECT_MAPPER; - private static final TypeReference> MAP_TYPE_REFERENCE = new TypeReference>() { - }; /** * Configures the specified ObjectMapper with the (de)serialization behaviours @@ -122,4 +122,8 @@ public String decompress(final String s) throws IOException { public T deserializeStrict(final String s, final TypeReference reference) throws IOException { return STRICT_OBJECT_MAPPER.readValue(s, reference); } + + public T convert(final Object obj, final TypeReference reference) { + return OBJECT_MAPPER.convertValue(obj, reference); + } } diff --git a/src/main/resources/com/amazonaws/cloudformation/log4j2.xml b/src/main/resources/com/amazonaws/cloudformation/log4j2.xml new file mode 100644 index 00000000..1b6c0116 --- /dev/null +++ b/src/main/resources/com/amazonaws/cloudformation/log4j2.xml @@ -0,0 +1,17 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1}:%L - %m%n + + + + + + + + + + + diff --git a/src/main/resources/com/amazonaws/cloudformation/spotbugs-suppressions.xml b/src/main/resources/com/amazonaws/cloudformation/spotbugs-suppressions.xml index da405a1a..bae1e2e6 100644 --- a/src/main/resources/com/amazonaws/cloudformation/spotbugs-suppressions.xml +++ b/src/main/resources/com/amazonaws/cloudformation/spotbugs-suppressions.xml @@ -23,6 +23,21 @@ + + + + + + + + + + + + + + + diff --git a/src/test/java/software/amazon/cloudformation/ExecutableWrapperTest.java b/src/test/java/software/amazon/cloudformation/ExecutableWrapperTest.java index 9849dc4a..ace8f2f4 100644 --- a/src/test/java/software/amazon/cloudformation/ExecutableWrapperTest.java +++ b/src/test/java/software/amazon/cloudformation/ExecutableWrapperTest.java @@ -106,6 +106,7 @@ private void verifyHandlerResponse(final OutputStream out, final ProgressEvent { + + /** + * This .ctor provided for testing + */ + public HookExecutableWrapperOverride(final CredentialsProvider providerLoggingCredentialsProvider, + final LogPublisher platformEventsLogger, + final CloudWatchLogPublisher providerEventsLogger, + final MetricsPublisher platformMetricsPublisher, + final MetricsPublisher providerMetricsPublisher, + final SchemaValidator validator, + final SdkHttpClient httpClient, + final Cipher cipher) { + super(providerLoggingCredentialsProvider, providerEventsLogger, platformEventsLogger, platformMetricsPublisher, + providerMetricsPublisher, validator, new Serializer(), httpClient, cipher); + } + + @Override + protected JSONObject provideHookSchemaJSONObject() { + return new JSONObject(new JSONTokener(new ByteArrayInputStream("{ \"properties\": { \"property1\": { \"type\": \"string\" }, \"property2\": { \"type\": \"integer\" } } }" + .getBytes(StandardCharsets.UTF_8)))); + } + + @Override + public ProgressEvent invokeHandler(final AmazonWebServicesClientProxy awsClientProxy, + final HookHandlerRequest request, + final HookInvocationPoint invocationPoint, + final TestContext callbackContext, + final TestConfigurationModel typeConfiguration) + throws Exception { + this.awsClientProxy = awsClientProxy; + this.request = request; + this.invocationPoint = invocationPoint; + this.callbackContext = callbackContext; + + if (invokeHandlerException != null) { + throw invokeHandlerException; + } else if (invokeHandlerResponses == null) { + return invokeHandlerResponse; + } else { + return invokeHandlerResponses.remove(); + } + + } + + // lets tests assert on the passed in arguments + public AmazonWebServicesClientProxy awsClientProxy; + public HookHandlerRequest request; + public HookInvocationPoint invocationPoint; + public TestContext callbackContext; + + // allows test to have the invoke throw an exception + public Exception invokeHandlerException; + + // for single mocked response + public ProgressEvent invokeHandlerResponse; + + // for series of mocked responses; call EnqueueResponses to add items + private Queue> invokeHandlerResponses; + + public void enqueueResponses(final List> responses) { + if (invokeHandlerResponses == null) { + invokeHandlerResponses = new LinkedList<>(); + } + + invokeHandlerResponses.addAll(responses); + } + + @Override + protected HookHandlerRequest transform(final HookInvocationRequest request) { + return transformResponse; + } + + public HookHandlerRequest transformResponse; + + @Override + protected TypeReference> getTypeReference() { + return new TypeReference>() { + }; + } + + @Override + protected TypeReference getModelTypeReference() { + return new TypeReference() { + }; + } + + @Override + protected void scrubFiles() { + } +} diff --git a/src/test/java/software/amazon/cloudformation/HookExecutableWrapperTest.java b/src/test/java/software/amazon/cloudformation/HookExecutableWrapperTest.java new file mode 100644 index 00000000..606f9364 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/HookExecutableWrapperTest.java @@ -0,0 +1,174 @@ +/* +* 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import com.fasterxml.jackson.core.type.TypeReference; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.cloudformation.encryption.KMSCipher; +import software.amazon.cloudformation.injection.CredentialsProvider; +import software.amazon.cloudformation.loggers.CloudWatchLogPublisher; +import software.amazon.cloudformation.loggers.LogPublisher; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookStatus; +import software.amazon.cloudformation.resource.SchemaValidator; +import software.amazon.cloudformation.resource.Serializer; + +@ExtendWith(MockitoExtension.class) +public class HookExecutableWrapperTest { + + private static final String TEST_DATA_BASE_PATH = "src/test/java/software/amazon/cloudformation/data/hook/%s"; + + @Mock + private CredentialsProvider providerLoggingCredentialsProvider; + + @Mock + private MetricsPublisher platformMetricsPublisher; + + @Mock + private MetricsPublisher providerMetricsPublisher; + + @Mock + private CloudWatchLogPublisher providerEventsLogger; + + @Mock + private LogPublisher platformEventsLogger; + + @Mock + private SchemaValidator validator; + + @Mock + private HookHandlerRequest hookHandlerRequest; + + @Mock + private SdkHttpClient httpClient; + + @Mock + private KMSCipher cipher; + + private HookExecutableWrapperOverride wrapper; + + @BeforeEach + public void initWrapper() { + wrapper = new HookExecutableWrapperOverride(providerLoggingCredentialsProvider, platformEventsLogger, + providerEventsLogger, platformMetricsPublisher, providerMetricsPublisher, + validator, httpClient, cipher); + } + + private static InputStream loadRequestStream(final String fileName) { + final File file = new File(String.format(TEST_DATA_BASE_PATH, fileName)); + + try { + return new FileInputStream(file); + } catch (final FileNotFoundException e) { + e.printStackTrace(); + return null; + } + } + + private void verifyInitialiseRuntime() { + verify(providerLoggingCredentialsProvider).setCredentials(any(Credentials.class)); + verify(platformMetricsPublisher).refreshClient(); + verify(providerMetricsPublisher).refreshClient(); + } + + private void verifyHandlerResponse(final OutputStream out, final HookProgressEvent expected) throws IOException { + final Serializer serializer = new Serializer(); + final HookProgressEvent handlerResponse = serializer.deserialize(out.toString(), + new TypeReference>() { + }); + + assertThat(handlerResponse.getClientRequestToken()).isEqualTo(expected.getClientRequestToken()); + assertThat(handlerResponse.getHookStatus()).isEqualTo(expected.getHookStatus()); + assertThat(handlerResponse.getErrorCode()).isEqualTo(expected.getErrorCode()); + assertThat(handlerResponse.getMessage()).isEqualTo(expected.getMessage()); + assertThat(handlerResponse.getResult()).isEqualTo(expected.getResult()); + assertThat(handlerResponse.getCallbackContext()).isEqualTo(expected.getCallbackContext()); + assertThat(handlerResponse.getCallbackDelaySeconds()).isEqualTo(expected.getCallbackDelaySeconds()); + } + + @ParameterizedTest + @CsvSource({ "preCreate.request.json,CREATE_PRE_PROVISION", "preUpdate.request.json,UPDATE_PRE_PROVISION", + "preDelete.request.json,DELETE_PRE_PROVISION" }) + public void invokeHandler_CompleteSynchronously_returnsSuccess(final String requestDataPath, + final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + + // if the handler responds Complete, this is treated as a successful synchronous + // completion + final ProgressEvent pe = ProgressEvent.builder().status(OperationStatus.SUCCESS).build(); + wrapper.setInvokeHandlerResponse(pe); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.handleRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(platformMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), isNull()); + + // validation failure metric should not be published + verifyNoMoreInteractions(platformMetricsPublisher); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.SUCCESS).build()); + + // assert handler receives correct injections + assertThat(wrapper.awsClientProxy).isNotNull(); + assertThat(wrapper.getRequest()).isEqualTo(hookHandlerRequest); + assertThat(wrapper.invocationPoint).isEqualTo(invocationPoint); + assertThat(wrapper.callbackContext).isNull(); + } + } +} diff --git a/src/test/java/software/amazon/cloudformation/HookLambdaWrapperOverride.java b/src/test/java/software/amazon/cloudformation/HookLambdaWrapperOverride.java new file mode 100644 index 00000000..cc748458 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/HookLambdaWrapperOverride.java @@ -0,0 +1,135 @@ +/* +* 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; + +import com.fasterxml.jackson.core.type.TypeReference; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.json.JSONObject; +import org.json.JSONTokener; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.cloudformation.encryption.Cipher; +import software.amazon.cloudformation.injection.CredentialsProvider; +import software.amazon.cloudformation.loggers.CloudWatchLogPublisher; +import software.amazon.cloudformation.loggers.LogPublisher; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookInvocationRequest; +import software.amazon.cloudformation.resource.SchemaValidator; +import software.amazon.cloudformation.resource.Serializer; + +/** + * Test class used for testing of HookLambdaWrapper functionality + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HookLambdaWrapperOverride extends HookLambdaWrapper { + + /** + * This .ctor provided for testing + */ + public HookLambdaWrapperOverride(final CredentialsProvider providerLoggingCredentialsProvider, + final LogPublisher platformEventsLogger, + final CloudWatchLogPublisher providerEventsLogger, + final MetricsPublisher platformMetricsPublisher, + final MetricsPublisher providerMetricsPublisher, + final SchemaValidator validator, + final SdkHttpClient httpClient, + final Cipher cipher) { + super(providerLoggingCredentialsProvider, providerEventsLogger, platformEventsLogger, platformMetricsPublisher, + providerMetricsPublisher, validator, new Serializer(), httpClient, cipher); + } + + @Override + protected JSONObject provideHookSchemaJSONObject() { + return new JSONObject(new JSONTokener(new ByteArrayInputStream("{ \"properties\": { \"property1\": { \"type\": \"string\" }, \"property2\": { \"type\": \"integer\" } } }" + .getBytes(StandardCharsets.UTF_8)))); + } + + @Override + public ProgressEvent invokeHandler(final AmazonWebServicesClientProxy awsClientProxy, + final HookHandlerRequest request, + final HookInvocationPoint invocationPoint, + final TestContext callbackContext, + final TestConfigurationModel typeConfiguration) + throws Exception { + this.awsClientProxy = awsClientProxy; + this.request = request; + this.invocationPoint = invocationPoint; + this.callbackContext = callbackContext; + + if (invokeHandlerException != null) { + throw invokeHandlerException; + } else if (invokeHandlerResponses == null) { + return invokeHandlerResponse; + } else { + return invokeHandlerResponses.remove(); + } + + } + + // lets tests assert on the passed in arguments + public AmazonWebServicesClientProxy awsClientProxy; + public HookHandlerRequest request; + public HookInvocationPoint invocationPoint; + public TestContext callbackContext; + + // allows test to have the invoke throw an exception + public Exception invokeHandlerException; + + // for single mocked response + public ProgressEvent invokeHandlerResponse; + + // for series of mocked responses; call EnqueueResponses to add items + private Queue> invokeHandlerResponses; + + public void enqueueResponses(final List> responses) { + if (invokeHandlerResponses == null) { + invokeHandlerResponses = new LinkedList<>(); + } + + invokeHandlerResponses.addAll(responses); + } + + @Override + protected HookHandlerRequest transform(final HookInvocationRequest request) { + return transformResponse; + } + + public HookHandlerRequest transformResponse; + + @Override + protected TypeReference> getTypeReference() { + return new TypeReference>() { + }; + } + + @Override + protected TypeReference getModelTypeReference() { + return new TypeReference() { + }; + } + + @Override + protected void scrubFiles() { + } +} diff --git a/src/test/java/software/amazon/cloudformation/HookLambdaWrapperTest.java b/src/test/java/software/amazon/cloudformation/HookLambdaWrapperTest.java new file mode 100644 index 00000000..56f528c0 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/HookLambdaWrapperTest.java @@ -0,0 +1,189 @@ +/* +* 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.LambdaLogger; +import com.fasterxml.jackson.core.type.TypeReference; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.cloudformation.encryption.KMSCipher; +import software.amazon.cloudformation.injection.CredentialsProvider; +import software.amazon.cloudformation.loggers.CloudWatchLogPublisher; +import software.amazon.cloudformation.loggers.LogPublisher; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookStatus; +import software.amazon.cloudformation.resource.SchemaValidator; +import software.amazon.cloudformation.resource.Serializer; + +@ExtendWith(MockitoExtension.class) +public class HookLambdaWrapperTest { + + private static final String TEST_DATA_BASE_PATH = "src/test/java/software/amazon/cloudformation/data/hook/%s"; + + @Mock + private CredentialsProvider providerLoggingCredentialsProvider; + + @Mock + private MetricsPublisher platformMetricsPublisher; + + @Mock + private MetricsPublisher providerMetricsPublisher; + + @Mock + private CloudWatchLogPublisher providerEventsLogger; + + @Mock + private LogPublisher platformEventsLogger; + + @Mock + private SchemaValidator validator; + + @Mock + private LambdaLogger lambdaLogger; + + @Mock + private HookHandlerRequest hookHandlerRequest; + + @Mock + private SdkHttpClient httpClient; + + @Mock + private KMSCipher cipher; + + private HookLambdaWrapperOverride wrapper; + + @BeforeEach + public void initWrapper() { + wrapper = new HookLambdaWrapperOverride(providerLoggingCredentialsProvider, platformEventsLogger, providerEventsLogger, + platformMetricsPublisher, providerMetricsPublisher, validator, httpClient, + cipher); + } + + private static InputStream loadRequestStream(final String fileName) { + final File file = new File(String.format(TEST_DATA_BASE_PATH, fileName)); + + try { + return new FileInputStream(file); + } catch (final FileNotFoundException e) { + e.printStackTrace(); + return null; + } + } + + private Context getLambdaContext() { + final Context context = mock(Context.class); + lenient().when(context.getInvokedFunctionArn()) + .thenReturn("arn:aws:lambda:aws-region:acct-id:function:testHookHandler:PROD"); + lenient().when(context.getLogger()).thenReturn(lambdaLogger); + return context; + } + + private void verifyInitialiseRuntime() { + verify(providerLoggingCredentialsProvider).setCredentials(any(Credentials.class)); + verify(platformMetricsPublisher).refreshClient(); + verify(providerMetricsPublisher).refreshClient(); + } + + private void verifyHandlerResponse(final OutputStream out, final HookProgressEvent expected) throws IOException { + final Serializer serializer = new Serializer(); + final HookProgressEvent handlerResponse = serializer.deserialize(out.toString(), + new TypeReference>() { + }); + + assertThat(handlerResponse.getClientRequestToken()).isEqualTo(expected.getClientRequestToken()); + assertThat(handlerResponse.getHookStatus()).isEqualTo(expected.getHookStatus()); + assertThat(handlerResponse.getErrorCode()).isEqualTo(expected.getErrorCode()); + assertThat(handlerResponse.getMessage()).isEqualTo(expected.getMessage()); + assertThat(handlerResponse.getResult()).isEqualTo(expected.getResult()); + assertThat(handlerResponse.getCallbackContext()).isEqualTo(expected.getCallbackContext()); + assertThat(handlerResponse.getCallbackDelaySeconds()).isEqualTo(expected.getCallbackDelaySeconds()); + } + + @ParameterizedTest + @CsvSource({ "preCreate.request.json,CREATE_PRE_PROVISION", "preUpdate.request.json,UPDATE_PRE_PROVISION", + "preDelete.request.json,DELETE_PRE_PROVISION" }) + public void invokeHandler_CompleteSynchronously_returnsSuccess(final String requestDataPath, + final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + + // if the handler responds Complete, this is treated as a successful synchronous + // completion + final ProgressEvent pe = ProgressEvent.builder().status(OperationStatus.SUCCESS).build(); + wrapper.setInvokeHandlerResponse(pe); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + final Context context = getLambdaContext(); + + wrapper.handleRequest(in, out, context); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(platformMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), isNull()); + + // validation failure metric should not be published + verifyNoMoreInteractions(platformMetricsPublisher); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.SUCCESS).build()); + + // assert handler receives correct injections + assertThat(wrapper.awsClientProxy).isNotNull(); + assertThat(wrapper.getRequest()).isEqualTo(hookHandlerRequest); + assertThat(wrapper.invocationPoint).isEqualTo(invocationPoint); + assertThat(wrapper.callbackContext).isNull(); + } + } +} diff --git a/src/test/java/software/amazon/cloudformation/HookWrapperOverride.java b/src/test/java/software/amazon/cloudformation/HookWrapperOverride.java new file mode 100644 index 00000000..90c7873b --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/HookWrapperOverride.java @@ -0,0 +1,151 @@ +/* +* 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; + +import com.fasterxml.jackson.core.type.TypeReference; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.json.JSONObject; +import org.json.JSONTokener; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.cloudformation.encryption.Cipher; +import software.amazon.cloudformation.injection.CredentialsProvider; +import software.amazon.cloudformation.loggers.CloudWatchLogPublisher; +import software.amazon.cloudformation.loggers.LogPublisher; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookInvocationRequest; +import software.amazon.cloudformation.resource.SchemaValidator; +import software.amazon.cloudformation.resource.Serializer; + +/** + * Test class used for testing of HookLambdaWrapper functionality + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HookWrapperOverride extends HookAbstractWrapper { + + /** + * Invoked to test normal initialization flows + */ + public HookWrapperOverride(final LogPublisher platformEventsLogger) { + this.platformLogPublisher = platformEventsLogger; + } + + /** + * Invoked to test normal initialization flows + */ + public HookWrapperOverride(final LogPublisher platformEventsLogger, + final Cipher cipher) { + this(platformEventsLogger); + this.cipher = cipher; + } + + /** + * This .ctor provided for testing + */ + public HookWrapperOverride(final CredentialsProvider providerLoggingCredentialsProvider, + final LogPublisher platformEventsLogger, + final CloudWatchLogPublisher providerEventsLogger, + final MetricsPublisher platformMetricsPublisher, + final MetricsPublisher providerMetricsPublisher, + final SchemaValidator validator, + final SdkHttpClient httpClient, + final Cipher cipher) { + super(providerLoggingCredentialsProvider, providerEventsLogger, platformEventsLogger, platformMetricsPublisher, + providerMetricsPublisher, validator, new Serializer(), httpClient, cipher); + } + + @Override + protected JSONObject provideHookSchemaJSONObject() { + return new JSONObject(new JSONTokener(new ByteArrayInputStream("{ \"properties\": { \"property1\": { \"type\": \"string\" }, \"property2\": { \"type\": \"integer\" } } }" + .getBytes(StandardCharsets.UTF_8)))); + } + + @Override + public ProgressEvent invokeHandler(final AmazonWebServicesClientProxy awsClientProxy, + final HookHandlerRequest request, + final HookInvocationPoint invocationPoint, + final TestContext callbackContext, + final TestConfigurationModel typeConfiguration) + throws Exception { + this.awsClientProxy = awsClientProxy; + this.request = request; + this.invocationPoint = invocationPoint; + this.callbackContext = callbackContext; + + if (invokeHandlerException != null) { + throw invokeHandlerException; + } else if (invokeHandlerResponses == null) { + return invokeHandlerResponse; + } else { + return invokeHandlerResponses.remove(); + } + + } + + // lets tests assert on the passed in arguments + public AmazonWebServicesClientProxy awsClientProxy; + public HookHandlerRequest request; + public HookInvocationPoint invocationPoint; + public TestContext callbackContext; + + // allows test to have the invoke throw an exception + public Exception invokeHandlerException; + + // for single mocked response + public ProgressEvent invokeHandlerResponse; + + // for series of mocked responses; call EnqueueResponses to add items + private Queue> invokeHandlerResponses; + + public void enqueueResponses(final List> responses) { + if (invokeHandlerResponses == null) { + invokeHandlerResponses = new LinkedList<>(); + } + + invokeHandlerResponses.addAll(responses); + } + + @Override + protected HookHandlerRequest transform(final HookInvocationRequest request) { + return transformResponse; + } + + public HookHandlerRequest transformResponse; + + @Override + protected TypeReference> getTypeReference() { + return new TypeReference>() { + }; + } + + @Override + protected TypeReference getModelTypeReference() { + return new TypeReference() { + }; + } + + @Override + protected void scrubFiles() { + } +} diff --git a/src/test/java/software/amazon/cloudformation/HookWrapperTest.java b/src/test/java/software/amazon/cloudformation/HookWrapperTest.java new file mode 100644 index 00000000..7a69b15d --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/HookWrapperTest.java @@ -0,0 +1,1138 @@ +/* +* 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import com.amazonaws.AmazonServiceException; +import com.fasterxml.jackson.core.type.TypeReference; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.http.HttpStatusCode; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.cloudformation.encryption.KMSCipher; +import software.amazon.cloudformation.exceptions.ResourceNotFoundException; +import software.amazon.cloudformation.exceptions.TerminalException; +import software.amazon.cloudformation.injection.CredentialsProvider; +import software.amazon.cloudformation.loggers.CloudWatchLogPublisher; +import software.amazon.cloudformation.loggers.LogPublisher; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.proxy.Credentials; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.OperationStatus; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookStatus; +import software.amazon.cloudformation.resource.SchemaValidator; +import software.amazon.cloudformation.resource.Serializer; +import software.amazon.cloudformation.resource.Validator; + +@ExtendWith(MockitoExtension.class) +public class HookWrapperTest { + + private static final String TEST_DATA_BASE_PATH = "src/test/java/software/amazon/cloudformation/data/hook/%s"; + + @Mock + private CredentialsProvider providerLoggingCredentialsProvider; + + @Mock + private MetricsPublisher platformMetricsPublisher; + + @Mock + private MetricsPublisher providerMetricsPublisher; + + @Mock + private CloudWatchLogPublisher providerEventsLogger; + + @Mock + private LogPublisher platformEventsLogger; + + @Mock + private SchemaValidator validator; + + @Mock + private HookHandlerRequest hookHandlerRequest; + + @Mock + private SdkHttpClient httpClient; + + @Mock + private KMSCipher cipher; + + private HookWrapperOverride wrapper; + + @BeforeEach + public void initWrapper() { + wrapper = new HookWrapperOverride(providerLoggingCredentialsProvider, platformEventsLogger, providerEventsLogger, + platformMetricsPublisher, providerMetricsPublisher, validator, httpClient, cipher); + } + + private static InputStream loadRequestStream(final String fileName) { + final File file = new File(String.format(TEST_DATA_BASE_PATH, fileName)); + + try { + return new FileInputStream(file); + } catch (final FileNotFoundException e) { + e.printStackTrace(); + return null; + } + } + + private void verifyInitialiseRuntime() { + verify(providerLoggingCredentialsProvider).setCredentials(any(Credentials.class)); + verify(platformMetricsPublisher).refreshClient(); + verify(providerMetricsPublisher).refreshClient(); + } + + private void verifyHandlerResponse(final OutputStream out, final HookProgressEvent expected) throws IOException { + final Serializer serializer = new Serializer(); + final HookProgressEvent handlerResponse = serializer.deserialize(out.toString(), + new TypeReference>() { + }); + + assertThat(handlerResponse.getClientRequestToken()).isEqualTo(expected.getClientRequestToken()); + assertThat(handlerResponse.getHookStatus()).isEqualTo(expected.getHookStatus()); + assertThat(handlerResponse.getErrorCode()).isEqualTo(expected.getErrorCode()); + assertThat(handlerResponse.getResult()).isEqualTo(expected.getResult()); + assertThat(handlerResponse.getCallbackContext()).isEqualTo(expected.getCallbackContext()); + assertThat(handlerResponse.getCallbackDelaySeconds()).isEqualTo(expected.getCallbackDelaySeconds()); + } + + @ParameterizedTest + @CsvSource({ "preCreate.request.json,CREATE_PRE_PROVISION", "preUpdate.request.json,UPDATE_PRE_PROVISION", + "preDelete.request.json,DELETE_PRE_PROVISION" }) + public void invokeHandler_nullResponse_returnsFailure(final String requestDataPath, final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + + // a null response is a terminal fault + wrapper.setInvokeHandlerResponse(null); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // validation failure metric should be published for final error handling + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(TerminalException.class), any(HandlerErrorCode.class)); + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(TerminalException.class), any(HandlerErrorCode.class)); + + // all metrics should be published even on terminal failure + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(providerMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + + verify(providerEventsLogger).refreshClient(); + verify(providerEventsLogger, times(2)).publishLogEvent(any()); + verifyNoMoreInteractions(providerEventsLogger); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").errorCode(HandlerErrorCode.InternalFailure) + .hookStatus(HookStatus.FAILED).message("Handler failed to provide a response.").build()); + + // assert handler receives correct injections + assertThat(wrapper.awsClientProxy).isNotNull(); + assertThat(wrapper.getRequest()).isEqualTo(hookHandlerRequest); + assertThat(wrapper.invocationPoint).isEqualTo(invocationPoint); + assertThat(wrapper.callbackContext).isNull(); + } + } + + @ParameterizedTest + @CsvSource({ "preCreate.request-without-logging-credentials.json,CREATE_PRE_PROVISION" }) + public void invokeHandler_without_customerLoggingCredentials(final String requestDataPath, final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + + // a null response is a terminal fault + wrapper.setInvokeHandlerResponse(null); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verify(providerLoggingCredentialsProvider, times(0)).setCredentials(any(Credentials.class)); + verify(platformMetricsPublisher).refreshClient(); + verify(providerMetricsPublisher, times(0)).refreshClient(); + + // validation failure metric should be published for final error handling + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(TerminalException.class), any(HandlerErrorCode.class)); + verify(providerMetricsPublisher, times(0)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(TerminalException.class), any(HandlerErrorCode.class)); + + // all metrics should be published even on terminal failure + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(providerMetricsPublisher, times(0)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(providerMetricsPublisher, times(0)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + + verifyNoMoreInteractions(providerEventsLogger); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").errorCode(HandlerErrorCode.InternalFailure) + .hookStatus(HookStatus.FAILED).message("Handler failed to provide a response.").build()); + } + } + + @ParameterizedTest + @CsvSource({ "preCreate.request.json,CREATE_PRE_PROVISION", "preUpdate.request.json,UPDATE_PRE_PROVISION", + "preDelete.request.json,DELETE_PRE_PROVISION" }) + public void invokeHandler_handlerFailed_returnsFailure(final String requestDataPath, final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + + // explicit fault response is treated as an unsuccessful synchronous completion + final ProgressEvent pe = ProgressEvent.builder() + .status(OperationStatus.FAILED).errorCode(HandlerErrorCode.InternalFailure).message("Custom Fault").build(); + wrapper.setInvokeHandlerResponse(pe); + + wrapper.setTransformResponse(hookHandlerRequest); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(platformMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), eq(HandlerErrorCode.InternalFailure)); + + // validation failure metric should not be published + verifyNoMoreInteractions(platformMetricsPublisher); + + // verify output response + verifyHandlerResponse(out, HookProgressEvent.builder().clientRequestToken("123456") + .errorCode(HandlerErrorCode.InternalFailure).hookStatus(HookStatus.FAILED).message("Custom Fault").build()); + + // assert handler receives correct injections + assertThat(wrapper.awsClientProxy).isNotNull(); + assertThat(wrapper.getRequest()).isEqualTo(hookHandlerRequest); + assertThat(wrapper.invocationPoint).isEqualTo(invocationPoint); + assertThat(wrapper.callbackContext).isNull(); + } + } + + @Test + public void invokeHandler_withNullInput() throws IOException { + // explicit fault response is treated as an unsuccessful synchronous completion + final ProgressEvent pe = ProgressEvent.builder() + .status(OperationStatus.FAILED).errorCode(HandlerErrorCode.InternalFailure).message("Custom Fault").build(); + wrapper.setInvokeHandlerResponse(pe); + + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = null; final OutputStream out = new ByteArrayOutputStream()) { + wrapper.processRequest(in, out); + + verifyNoMoreInteractions(platformMetricsPublisher, platformEventsLogger, providerMetricsPublisher, + providerEventsLogger); + } + } + + @ParameterizedTest + @CsvSource({ "preCreate.request.json,CREATE_PRE_PROVISION", "preUpdate.request.json,UPDATE_PRE_PROVISION", + "preDelete.request.json,DELETE_PRE_PROVISION" }) + public void invokeHandler_CompleteSynchronously_returnsSuccess(final String requestDataPath, + final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + + // if the handler responds Complete, this is treated as a successful synchronous + // completion + final ProgressEvent pe = ProgressEvent.builder().status(OperationStatus.SUCCESS).build(); + wrapper.setInvokeHandlerResponse(pe); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(platformMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), isNull()); + + // validation failure metric should not be published + verifyNoMoreInteractions(platformMetricsPublisher); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.SUCCESS).build()); + + // assert handler receives correct injections + assertThat(wrapper.awsClientProxy).isNotNull(); + assertThat(wrapper.getRequest()).isEqualTo(hookHandlerRequest); + assertThat(wrapper.invocationPoint).isEqualTo(invocationPoint); + assertThat(wrapper.callbackContext).isNull(); + } + } + + @Test + public void invokeHandler_DependenciesInitialised_CompleteSynchronously_returnsSuccess() throws IOException { + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + final HookWrapperOverride wrapper = new HookWrapperOverride(platformEventsLogger, cipher); + + // if the handler responds Complete, this is treated as a successful synchronous + // completion + final ProgressEvent pe = ProgressEvent.builder().status(OperationStatus.SUCCESS).build(); + wrapper.setInvokeHandlerResponse(pe); + + wrapper.setTransformResponse(hookHandlerRequest); + + // use a request context in our payload to bypass certain callbacks + try (final InputStream in = loadRequestStream("preCreate.with-request-context.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // simply ensure all dependencies were setup correctly - behaviour is tested + // through mocks + assertThat(wrapper.serializer).isNotNull(); + assertThat(wrapper.loggerProxy).isNotNull(); + assertThat(wrapper.platformLoggerProxy).isNotNull(); + assertThat(wrapper.metricsPublisherProxy).isNotNull(); + assertThat(wrapper.providerCredentialsProvider).isNotNull(); + assertThat(wrapper.providerCloudWatchProvider).isNotNull(); + assertThat(wrapper.cloudWatchLogsProvider).isNotNull(); + assertThat(wrapper.validator).isNotNull(); + } + } + + @ParameterizedTest + @CsvSource({ "preCreate.request.json,CREATE_PRE_PROVISION", "preUpdate.request.json,UPDATE_PRE_PROVISION", + "preDelete.request.json,DELETE_PRE_PROVISION" }) + public void invokeHandler_InProgress_returnsInProgress(final String requestDataPath, final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + + // an InProgress response is always re-scheduled. + // If no explicit time is supplied, a 1-minute interval is used + final ProgressEvent pe = ProgressEvent.builder().status(OperationStatus.IN_PROGRESS).build(); + wrapper.setInvokeHandlerResponse(pe); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(providerMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + + // verify exception and error code count published + verify(platformMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), isNull()); + verify(providerMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), isNull()); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.IN_PROGRESS).build()); + + // validation failure metric should not be published + verifyNoMoreInteractions(platformMetricsPublisher); + verifyNoMoreInteractions(providerMetricsPublisher); + + // assert handler receives correct injections + assertThat(wrapper.awsClientProxy).isNotNull(); + assertThat(wrapper.getRequest()).isEqualTo(hookHandlerRequest); + assertThat(wrapper.invocationPoint).isEqualTo(invocationPoint); + assertThat(wrapper.callbackContext).isNull(); + } + } + + @ParameterizedTest + @CsvSource({ "preCreate.with-request-context.request.json,CREATE_PRE_PROVISION", + "preUpdate.with-request-context.request.json,UPDATE_PRE_PROVISION", + "preDelete.with-request-context.request.json,DELETE_PRE_PROVISION" }) + public void reInvokeHandler_InProgress_returnsInProgress(final String requestDataPath, final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + + // an InProgress response is always re-scheduled. + // If no explicit time is supplied, a 1-minute interval is used, and any + // interval >= 1 minute is scheduled + // against CloudWatch. Shorter intervals are able to run locally within same + // function context if runtime permits + final ProgressEvent pe = ProgressEvent.builder() + .status(OperationStatus.IN_PROGRESS).callbackDelaySeconds(60).build(); + wrapper.setInvokeHandlerResponse(pe); + + wrapper.setTransformResponse(hookHandlerRequest); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + + verify(providerMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + + // verify exception and error code count published + verify(platformMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), isNull()); + verify(providerMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), isNull()); + + // validation failure metric should not be published + verifyNoMoreInteractions(platformMetricsPublisher); + verifyNoMoreInteractions(providerMetricsPublisher); + + // verify output response + verifyHandlerResponse(out, HookProgressEvent.builder().clientRequestToken("123456") + .hookStatus(HookStatus.IN_PROGRESS).callbackDelaySeconds(60).build()); + } + } + + @Test + public void invokeHandler_withoutCallerCredentials_passesNoAWSProxy() throws IOException { + final TestModel model = new TestModel(); + model.setProperty1("abc"); + model.setProperty2(123); + wrapper.setTransformResponse(hookHandlerRequest); + + // respond with immediate success to avoid callback invocation + final ProgressEvent pe = ProgressEvent.builder().status(OperationStatus.SUCCESS).build(); + wrapper.setInvokeHandlerResponse(pe); + + // without platform credentials the handler is unable to do + // basic SDK initialization and any such request should fail fast + try (final InputStream in = loadRequestStream("preCreate.request-without-caller-credentials.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.SUCCESS).build()); + + // proxy should be null by virtue of having not had callerCredentials passed in + assertThat(wrapper.awsClientProxy).isNull(); + } + } + + @Test + public void invokeHandler_withDefaultInjection_returnsSuccess() throws IOException { + final TestModel model = new TestModel(); + model.setProperty1("abc"); + model.setProperty2(123); + wrapper.setTransformResponse(hookHandlerRequest); + + // respond with immediate success to avoid callback invocation + final ProgressEvent pe = ProgressEvent.builder().status(OperationStatus.SUCCESS).build(); + wrapper.setInvokeHandlerResponse(pe); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + // without platform credentials the handler is unable to do + // basic SDK initialization and any such request should fail fast + try (final InputStream in = loadRequestStream("preCreate.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.SUCCESS).build()); + + // proxy uses caller credentials and will be injected + assertThat(wrapper.awsClientProxy).isNotNull(); + } + } + + @Test + public void invokeHandler_withDefaultInjection_returnsInProgress() throws IOException { + final TestModel model = new TestModel(); + model.setProperty1("abc"); + model.setProperty2(123); + wrapper.setTransformResponse(hookHandlerRequest); + + // respond with immediate success to avoid callback invocation + final ProgressEvent pe = ProgressEvent.builder().status(OperationStatus.IN_PROGRESS).build(); + wrapper.setInvokeHandlerResponse(pe); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + // without platform credentials the handler is unable to do + // basic SDK initialization and any such request should fail fast + try (final InputStream in = loadRequestStream("preCreate.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + wrapper.processRequest(in, out); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.IN_PROGRESS).build()); + } + } + + @Test + public void invokeHandler_clientsRefreshedOnEveryInvoke() throws IOException { + try (InputStream in = loadRequestStream("preCreate.request.json"); OutputStream out = new ByteArrayOutputStream()) { + wrapper.processRequest(in, out); + } + + verify(platformMetricsPublisher, times(1)).refreshClient(); + + // invoke the same wrapper instance again to ensure client is refreshed + try (InputStream in = loadRequestStream("preCreate.request.json"); OutputStream out = new ByteArrayOutputStream()) { + wrapper.processRequest(in, out); + } + + verify(platformMetricsPublisher, times(2)).refreshClient(); + } + + @Test + public void invokeHandler_platformCredentialsRefreshedOnEveryInvoke() throws IOException { + try (InputStream in = loadRequestStream("preCreate.request.json"); OutputStream out = new ByteArrayOutputStream()) { + wrapper.processRequest(in, out); + } + + final Credentials expected = new Credentials("32IEHAHFIAG538KYASAI", "0O2hop/5vllVHjbA8u52hK8rLcroZpnL5NPGOi66", + "gqe6eIsFPHOlfhc3RKl5s5Y6Dy9PYvN1CEYsswz5TQUsE8WfHD6LPK549euXm4Vn4INBY9nMJ1cJe2mxTYFdhWHSnkOQv2SHemal"); + // invoke the same wrapper instance again to ensure client is refreshed + try (InputStream in = loadRequestStream("preCreate.request.with-new-credentials.json"); + OutputStream out = new ByteArrayOutputStream()) { + wrapper.processRequest(in, out); + } + + final Credentials expectedNew = new Credentials("GT530IJDHALYZQSZZ8XG", "UeJEwC/dqcYEn2viFd5TjKjR5TaMOfdeHrlLXxQL", + "469gs8raWJCaZcItXhGJ7dt3urI13fOTcde6ibhuHJz6r6bRRCWvLYGvCsqrN8WUClYL9lxZHymrWXvZ9xN0GoI2LFdcAAinZk5t"); + } + + @Test + public void invokeHandler_throwsAmazonServiceException_returnsServiceException() throws IOException { + // exceptions are caught consistently by LambdaWrapper + wrapper.setInvokeHandlerException(new AmazonServiceException("some error")); + + wrapper.setTransformResponse(hookHandlerRequest); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream("preCreate.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + verify(providerMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + // failure metric should be published + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AmazonServiceException.class), any(HandlerErrorCode.class)); + + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AmazonServiceException.class), any(HandlerErrorCode.class)); + + // verify output response + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456") + .errorCode(HandlerErrorCode.GeneralServiceException).hookStatus(HookStatus.FAILED) + .message("some error (Service: null, Status Code: 0, Request ID: null, Extended Request ID: null)").build()); + } + } + + @Test + public void invokeHandler_throwsSDK2ServiceException_returnsServiceException() throws IOException { + // exceptions are caught consistently by LambdaWrapper + wrapper.setInvokeHandlerException( + AwsServiceException.builder().awsErrorDetails(AwsErrorDetails.builder().errorMessage("some error").build()).build()); + + wrapper.setTransformResponse(hookHandlerRequest); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream("preCreate.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + verify(providerMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + // failure metric should be published + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AwsServiceException.class), any(HandlerErrorCode.class)); + + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AwsServiceException.class), any(HandlerErrorCode.class)); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456") + .errorCode(HandlerErrorCode.GeneralServiceException).hookStatus(HookStatus.FAILED) + .message("some error (Service: null, Status Code: 0, Request ID: null, Extended Request ID: null)").build()); + } + } + + @Test + public void invokeHandler_throwsThrottlingException_returnsCFNThrottlingException() throws IOException { + AmazonServiceException exception = new AmazonServiceException("Rate Exceed ..."); + exception.setErrorCode("Throttling"); + wrapper.setInvokeHandlerException(exception); + + wrapper.setTransformResponse(hookHandlerRequest); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream("preCreate.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + verify(providerMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + // failure metric should be published + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AmazonServiceException.class), any(HandlerErrorCode.class)); + + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AmazonServiceException.class), any(HandlerErrorCode.class)); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").errorCode(HandlerErrorCode.Throttling) + .hookStatus(HookStatus.FAILED) + .message("some error (Service: null, Status Code: 0, Request ID: null, Extended Request ID: null)").build()); + } + } + + @Test + public void invokeHandler_throwsServiceInternalError_returnsCFNServiceInternalErrorException() throws IOException { + AmazonServiceException exception = new AmazonServiceException("Amazon Service Error"); + exception.setStatusCode(HttpStatusCode.INTERNAL_SERVER_ERROR); + wrapper.setInvokeHandlerException(exception); + + wrapper.setTransformResponse(hookHandlerRequest); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream("preCreate.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + verify(providerMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + // failure metric should be published + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AmazonServiceException.class), any(HandlerErrorCode.class)); + + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AmazonServiceException.class), any(HandlerErrorCode.class)); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456") + .errorCode(HandlerErrorCode.ServiceInternalError).hookStatus(HookStatus.FAILED) + .message("some error (Service: null, Status Code: 0, Request ID: null, Extended Request ID: null)").build()); + } + } + + @Test + public void invokeHandler_throwsInvalidRequestError_returnsCFNInvalidRequestException() throws IOException { + AmazonServiceException exception = new AmazonServiceException("Amazon Service Error"); + exception.setStatusCode(HttpStatusCode.BAD_REQUEST); + wrapper.setInvokeHandlerException(exception); + + wrapper.setTransformResponse(hookHandlerRequest); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream("preCreate.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + verify(providerMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + // failure metric should be published + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AmazonServiceException.class), any(HandlerErrorCode.class)); + + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(AmazonServiceException.class), any(HandlerErrorCode.class)); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").errorCode(HandlerErrorCode.InvalidRequest) + .hookStatus(HookStatus.FAILED) + .message("some error (Service: null, Status Code: 0, Request ID: null, Extended Request ID: null)").build()); + } + } + + @Test + public void invokeHandler_throwsHookNotFoundException_returnsNotFound() throws IOException { + // exceptions are caught consistently by LambdaWrapper + wrapper.setInvokeHandlerException(new ResourceNotFoundException("AWS::Test::TestModel", "id-1234")); + + wrapper.setTransformResponse(hookHandlerRequest); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream("preCreate.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + verify(providerMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION)); + verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), + eq(HookInvocationPoint.CREATE_PRE_PROVISION), anyLong()); + + // failure metric should be published + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(ResourceNotFoundException.class), any(HandlerErrorCode.class)); + + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(ResourceNotFoundException.class), any(HandlerErrorCode.class)); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").errorCode(HandlerErrorCode.NotFound) + .hookStatus(HookStatus.FAILED) + .message("Resource of type 'AWS::Test::TestModel' with identifier 'id-1234' was not found.").build()); + } + } + + @Test + public void invokeHandler_metricPublisherThrowable_returnsFailureResponse() throws IOException { + // simulate runtime Errors in the metrics publisher (such as dependency + // resolution conflicts) + doThrow(new Error("not an Exception")).when(platformMetricsPublisher).publishInvocationMetric(any(), + any(HookInvocationPoint.class)); + doThrow(new Error("not an Exception")).when(platformMetricsPublisher).publishExceptionMetric(any(), + any(HookInvocationPoint.class), any(), any()); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream("preCreate.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + + try { + wrapper.processRequest(in, out); + } catch (final Error e) { + // ignore so we can perform verifications + } + + // verify initialiseRuntime was called and initialised dependencies + verifyInitialiseRuntime(); + + // verify exception and error code count published + verify(platformMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), eq(HandlerErrorCode.InternalFailure)); + verify(providerMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), eq(HandlerErrorCode.InternalFailure)); + + // no further calls to metrics publisher should occur + verifyNoMoreInteractions(platformMetricsPublisher); + verifyNoMoreInteractions(providerMetricsPublisher); + + // verify output response + verifyHandlerResponse(out, HookProgressEvent.builder().clientRequestToken("123456") + .errorCode(HandlerErrorCode.InternalFailure).hookStatus(HookStatus.FAILED).message("not an Exception").build()); + } + } + + @Test + public void invokeHandler_withInvalidPayload_returnsFailureResponse() throws IOException { + try (final InputStream in = new ByteArrayInputStream(new byte[0]); final OutputStream out = new ByteArrayOutputStream()) { + try { + wrapper.processRequest(in, out); + } catch (final Error e) { + // ignore so we can perform verifications + } + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().errorCode(HandlerErrorCode.InternalFailure).hookStatus(HookStatus.FAILED) + .message("A JSONObject text must begin with '{' at 0 [character 1 line 1]").build()); + } + } + + @Test + public void invokeHandler_withNullInputStream_returnsFailureResponse() throws IOException { + try (final OutputStream out = new ByteArrayOutputStream()) { + try { + wrapper.processRequest(null, out); + } catch (final Error e) { + // ignore so we can perform verifications + } + + // verify output response + verifyHandlerResponse(out, HookProgressEvent.builder().errorCode(HandlerErrorCode.InternalFailure) + .hookStatus(HookStatus.FAILED).message("No request object received").build()); + } + } + + @Test + public void invokeHandler_withEmptyPayload_returnsFailure() throws IOException { + try (final InputStream in = loadRequestStream("empty.request.json"); + final OutputStream out = new ByteArrayOutputStream()) { + try { + wrapper.processRequest(in, out); + } catch (final Error e) { + // ignore so we can perform verifications + } + + // verify output response + verifyHandlerResponse(out, HookProgressEvent.builder().errorCode(HandlerErrorCode.InternalFailure) + .hookStatus(HookStatus.FAILED).message("Invalid request object received. Target Model can not be null.").build()); + } + } + + @Test + public void invokeHandler_without_targetModel_returnsFailure() throws IOException { + try (final InputStream in = loadRequestStream("preCreate.request-without-target-model.json"); + final OutputStream out = new ByteArrayOutputStream()) { + try { + wrapper.processRequest(in, out); + } catch (final Error e) { + // ignore so we can perform verifications + } + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.FAILED) + .errorCode(HandlerErrorCode.InternalFailure).hookStatus(HookStatus.FAILED) + .message("Invalid request object received. Target Model can not be null.").build()); + } + } + + @Test + public void invokeHandler_with_invalid_actionInvocationPoint_returnsFailure() throws IOException { + try (final InputStream in = loadRequestStream("preCreate.request-with-invalid-actionInvocationPoint.json"); + final OutputStream out = new ByteArrayOutputStream()) { + try { + wrapper.processRequest(in, out); + } catch (final Error e) { + // ignore so we can perform verifications + } + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.FAILED) + .errorCode(HandlerErrorCode.InternalFailure).hookStatus(HookStatus.FAILED) + .message("Invalid hook invocation request object received").build()); + } + } + + @Test + public void stringifiedPayload_validation_successful() throws IOException { + // this test ensures that validation on the payload is performed + // against the serialized + // model rather than the raw payload. This allows the handlers to accept + // incoming payloads that + // may have quoted values where the JSON Serialization is still able to + // construct a valid POJO + SchemaValidator validator = new Validator(); + final HookWrapperOverride wrapper = new HookWrapperOverride(providerLoggingCredentialsProvider, platformEventsLogger, + providerEventsLogger, platformMetricsPublisher, + providerMetricsPublisher, validator, httpClient, cipher); + + // explicit fault response is treated as an unsuccessful synchronous completion + final ProgressEvent pe = ProgressEvent.builder() + .status(OperationStatus.SUCCESS).message("Handler was invoked").build(); + wrapper.setInvokeHandlerResponse(pe); + + wrapper.setTransformResponse(hookHandlerRequest); + + lenient().when(cipher.decryptCredentials(any())).thenReturn(new Credentials("123", "123", "123")); + + try (final InputStream in = loadRequestStream("preCreate.request-with-stringified.json"); + final OutputStream out = new ByteArrayOutputStream()) { + wrapper.processRequest(in, out); + + // verify output response + verifyHandlerResponse(out, HookProgressEvent.builder().clientRequestToken("123456") + .message("Handler was invoked").hookStatus(HookStatus.SUCCESS).build()); + + } + } + + @ParameterizedTest + @CsvSource({ "preCreate.request-without-encryption-key-arn.json,CREATE_PRE_PROVISION" }) + public void invokeHandler_without_encryptionKeyARN(final String requestDataPath, final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + final HookWrapperOverride wrapper = new HookWrapperOverride(providerLoggingCredentialsProvider, platformEventsLogger, + providerEventsLogger, platformMetricsPublisher, + providerMetricsPublisher, validator, httpClient, null); + + // a null response is a terminal fault + wrapper.setInvokeHandlerResponse(null); + + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verify(providerLoggingCredentialsProvider, times(0)).setCredentials(any(Credentials.class)); + verify(platformMetricsPublisher).refreshClient(); + + // validation failure metric should be published for final error handling + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(TerminalException.class), any(HandlerErrorCode.class)); + verify(providerMetricsPublisher, times(0)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(TerminalException.class), any(HandlerErrorCode.class)); + + // all metrics should be published even on terminal failure + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(providerMetricsPublisher, times(0)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(providerMetricsPublisher, times(0)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").errorCode(HandlerErrorCode.InternalFailure) + .hookStatus(HookStatus.FAILED).message("Handler failed to provide a response.").build()); + } + } + + @ParameterizedTest + @CsvSource({ "preCreate.request-without-encryption-key-role.json,CREATE_PRE_PROVISION" }) + public void invokeHandler_without_encryptionKeyRole(final String requestDataPath, final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + final HookWrapperOverride wrapper = new HookWrapperOverride(providerLoggingCredentialsProvider, platformEventsLogger, + providerEventsLogger, platformMetricsPublisher, + providerMetricsPublisher, validator, httpClient, null); + + // a null response is a terminal fault + wrapper.setInvokeHandlerResponse(null); + + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + // verify initialiseRuntime was called and initialised dependencies + verify(providerLoggingCredentialsProvider, times(0)).setCredentials(any(Credentials.class)); + verify(platformMetricsPublisher).refreshClient(); + + // validation failure metric should be published for final error handling + verify(platformMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(TerminalException.class), any(HandlerErrorCode.class)); + verify(providerMetricsPublisher, times(0)).publishExceptionMetric(any(Instant.class), any(HookInvocationPoint.class), + any(TerminalException.class), any(HandlerErrorCode.class)); + + // all metrics should be published even on terminal failure + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(providerMetricsPublisher, times(0)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(providerMetricsPublisher, times(0)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").errorCode(HandlerErrorCode.InternalFailure) + .hookStatus(HookStatus.FAILED).message("Handler failed to provide a response.").build()); + } + } + + @ParameterizedTest + @CsvSource({ "preCreate.request-with-unencrypted-credentials.json,CREATE_PRE_PROVISION" }) + public void invokeHandler_with_unencrypted_credentials(final String requestDataPath, final String invocationPointString) + throws IOException { + final HookInvocationPoint invocationPoint = HookInvocationPoint.valueOf(invocationPointString); + + wrapper = new HookWrapperOverride(providerLoggingCredentialsProvider, platformEventsLogger, providerEventsLogger, + platformMetricsPublisher, providerMetricsPublisher, validator, httpClient, null); + + // if the handler responds Complete, this is treated as a successful synchronous + // completion + final ProgressEvent pe = ProgressEvent.builder().status(OperationStatus.SUCCESS).build(); + wrapper.setInvokeHandlerResponse(pe); + + wrapper.setTransformResponse(hookHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); final OutputStream out = new ByteArrayOutputStream()) { + + wrapper.processRequest(in, out); + + verify(cipher, times(0)).decryptCredentials(anyString()); + verify(providerLoggingCredentialsProvider) + .setCredentials(eq((new Credentials("providerAccessKeyId", "providerSecretAccessKey", "providerSessionToken")))); + verify(platformMetricsPublisher).refreshClient(); + verify(providerMetricsPublisher).refreshClient(); + + // verify initialiseRuntime was called and initialised dependencies + // verifyInitialiseRuntime(); + + // all metrics should be published, once for a single invocation + verify(platformMetricsPublisher, times(1)).publishInvocationMetric(any(Instant.class), eq(invocationPoint)); + verify(platformMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(invocationPoint), anyLong()); + verify(platformMetricsPublisher, times(1)).publishExceptionByErrorCodeAndCountBulkMetrics(any(Instant.class), + any(HookInvocationPoint.class), isNull()); + + // validation failure metric should not be published + verifyNoMoreInteractions(platformMetricsPublisher); + + // verify output response + verifyHandlerResponse(out, + HookProgressEvent.builder().clientRequestToken("123456").hookStatus(HookStatus.SUCCESS).build()); + + // assert handler receives correct injections + assertThat(wrapper.awsClientProxy).isNotNull(); + assertThat(wrapper.getRequest()).isEqualTo(hookHandlerRequest); + assertThat(wrapper.invocationPoint).isEqualTo(invocationPoint); + assertThat(wrapper.callbackContext).isNull(); + } + } +} diff --git a/src/test/java/software/amazon/cloudformation/LambdaWrapperTest.java b/src/test/java/software/amazon/cloudformation/LambdaWrapperTest.java index 4534e13c..0faa3d1a 100644 --- a/src/test/java/software/amazon/cloudformation/LambdaWrapperTest.java +++ b/src/test/java/software/amazon/cloudformation/LambdaWrapperTest.java @@ -119,6 +119,7 @@ private void verifyHandlerResponse(final OutputStream out, final ProgressEvent pe = ProgressEvent.builder() + .result("Here is the result for non resource invocation").build(); + if (status == OperationStatus.SUCCESS) { + pe.setStatus(OperationStatus.SUCCESS); + } else if (status == OperationStatus.IN_PROGRESS) { + model.setProperty1("abc"); + model.setProperty2(123); + pe.setStatus(OperationStatus.IN_PROGRESS); + pe.setResourceModel(model); + } else { + pe.setStatus(OperationStatus.FAILED); + pe.setErrorCode(HandlerErrorCode.InternalFailure); + pe.setMessage("Custom Fault"); + } + wrapper.setInvokeHandlerResponse(pe); + + if (status != OperationStatus.FAILED) { + lenient().when(resourceHandlerRequest.getDesiredResourceState()).thenReturn(model); + } + + wrapper.setTransformResponse(resourceHandlerRequest); + + try (final InputStream in = loadRequestStream(requestDataPath); + final OutputStream out = new ByteArrayOutputStream()) { + wrapper.processRequest(in, out); + + if (status == OperationStatus.SUCCESS) { + // verify output response + verifyHandlerResponse(out, + ProgressEvent.builder().status(OperationStatus.SUCCESS).build()); + } else if (status == OperationStatus.IN_PROGRESS) { + if (action == Action.CREATE || action == Action.UPDATE || action == Action.DELETE) { + verifyHandlerResponse(out, + ProgressEvent.builder().status(OperationStatus.IN_PROGRESS) + .resourceModel(TestModel.builder().property1("abc").property2(123).build()).build()); + } else { + verifyHandlerResponse(out, + ProgressEvent.builder().status(OperationStatus.FAILED) + .errorCode(HandlerErrorCode.InternalFailure) + .message("READ and LIST handlers must return synchronously.").build()); + verify(providerMetricsPublisher).publishExceptionMetric(any(Instant.class), eq(action), + any(TerminalException.class), eq(HandlerErrorCode.InternalFailure)); + + } + } else { + verifyHandlerResponse(out, + ProgressEvent.builder().errorCode(HandlerErrorCode.InternalFailure) + .status(OperationStatus.FAILED).message("Custom Fault").build()); + } + + // assert handler receives correct injections + assertThat(wrapper.awsClientProxy).isNotNull(); + assertThat(wrapper.getRequest()).isEqualTo(resourceHandlerRequest); + assertThat(wrapper.action).isEqualTo(action); + assertThat(wrapper.callbackContext).isNull(); + } + } + } + @Test public void invokeHandler_invalidModelTypes_causesSchemaValidationFailure() throws IOException { // use actual validator to verify behaviour @@ -738,7 +811,7 @@ public void invokeHandler_throwsAmazonServiceException_returnsServiceException() verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(Action.CREATE), anyLong()); // failure metric should be published - verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(), + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(Action.class), any(AmazonServiceException.class), any(HandlerErrorCode.class)); // verify that model validation occurred for CREATE/UPDATE/DELETE @@ -771,7 +844,7 @@ public void invokeHandler_throwsSDK2ServiceException_returnsServiceException() t verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(Action.CREATE), anyLong()); // failure metric should be published - verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(), + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(Action.class), any(CloudWatchLogsException.class), any(HandlerErrorCode.class)); // verify that model validation occurred for CREATE/UPDATE/DELETE @@ -806,7 +879,7 @@ public void invokeHandler_throwsThrottlingException_returnsCFNThrottlingExceptio verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(Action.CREATE), anyLong()); // failure metric should be published - verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(), + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(Action.class), any(AmazonServiceException.class), any(HandlerErrorCode.class)); // verify that model validation occurred for CREATE/UPDATE/DELETE @@ -840,7 +913,7 @@ public void invokeHandler_throwsResourceAlreadyExistsException_returnsAlreadyExi verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(Action.CREATE), anyLong()); // failure metric should be published - verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(), + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(Action.class), any(ResourceAlreadyExistsException.class), any(HandlerErrorCode.class)); // verify that model validation occurred for CREATE/UPDATE/DELETE @@ -873,7 +946,7 @@ public void invokeHandler_throwsResourceNotFoundException_returnsNotFound() thro verify(providerMetricsPublisher, times(1)).publishDurationMetric(any(Instant.class), eq(Action.CREATE), anyLong()); // failure metric should be published - verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(), + verify(providerMetricsPublisher, times(1)).publishExceptionMetric(any(Instant.class), any(Action.class), any(ResourceNotFoundException.class), any(HandlerErrorCode.class)); // verify that model validation occurred for CREATE/UPDATE/DELETE @@ -891,8 +964,9 @@ public void invokeHandler_throwsResourceNotFoundException_returnsNotFound() thro public void invokeHandler_metricPublisherThrowable_returnsFailureResponse() throws IOException { // simulate runtime Errors in the metrics publisher (such as dependency // resolution conflicts) - doThrow(new Error("not an Exception")).when(providerMetricsPublisher).publishInvocationMetric(any(), any()); - doThrow(new Error("not an Exception")).when(providerMetricsPublisher).publishExceptionMetric(any(), any(), any(), any()); + doThrow(new Error("not an Exception")).when(providerMetricsPublisher).publishInvocationMetric(any(), any(Action.class)); + doThrow(new Error("not an Exception")).when(providerMetricsPublisher).publishExceptionMetric(any(), any(Action.class), + any(), any()); try (final InputStream in = loadRequestStream("create.request.json"); final OutputStream out = new ByteArrayOutputStream()) { diff --git a/src/test/java/software/amazon/cloudformation/data/hook/empty.request.json b/src/test/java/software/amazon/cloudformation/data/hook/empty.request.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/empty.request.json @@ -0,0 +1 @@ +{} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-with-invalid-actionInvocationPoint.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-with-invalid-actionInvocationPoint.json new file mode 100644 index 00000000..a0bb0935 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-with-invalid-actionInvocationPoint.json @@ -0,0 +1,30 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "UPDATE_POST_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-with-stringified.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-with-stringified.json new file mode 100644 index 00000000..fea60254 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-with-stringified.json @@ -0,0 +1,30 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": "123" + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-with-unencrypted-credentials.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-with-unencrypted-credentials.json new file mode 100644 index 00000000..0af1b3c6 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-with-unencrypted-credentials.json @@ -0,0 +1,25 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "{\"accessKeyId\": \"callerAccessKeyId\", \"secretAccessKey\": \"callerSecretAccessKey\", \"sessionToken\": \"callerSessionToken\"}", + "providerCredentials": "{\"accessKeyId\": \"providerAccessKeyId\", \"secretAccessKey\": \"providerSecretAccessKey\", \"sessionToken\": \"providerSessionToken\"}", + "providerLogGroupName": "providerLoggingGroupName" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-caller-credentials.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-caller-credentials.json new file mode 100644 index 00000000..000dd129 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-caller-credentials.json @@ -0,0 +1,29 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-encryption-key-arn.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-encryption-key-arn.json new file mode 100644 index 00000000..a5e9aeef --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-encryption-key-arn.json @@ -0,0 +1,29 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-encryption-key-role.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-encryption-key-role.json new file mode 100644 index 00000000..ae383004 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-encryption-key-role.json @@ -0,0 +1,28 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn" + } +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-logging-credentials.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-logging-credentials.json new file mode 100644 index 00000000..f30818b4 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-logging-credentials.json @@ -0,0 +1,29 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-target-model.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-target-model.json new file mode 100644 index 00000000..632e3096 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request-without-target-model.json @@ -0,0 +1,27 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request.json new file mode 100644 index 00000000..b533ccd5 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request.json @@ -0,0 +1,30 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request.with-new-credentials.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request.with-new-credentials.json new file mode 100644 index 00000000..b533ccd5 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.request.with-new-credentials.json @@ -0,0 +1,30 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preCreate.with-request-context.request.json b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.with-request-context.request.json new file mode 100644 index 00000000..4ab02674 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preCreate.with-request-context.request.json @@ -0,0 +1,37 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "CREATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": { + "invocation": 2, + "callbackContext": { + "contextPropertyA": "Value" + }, + "cloudWatchEventsRuleName": "reinvoke-handler-4754ac8a-623b-45fe-84bc-f5394118a8be", + "cloudWatchEventsTargetId": "reinvoke-target-4754ac8a-623b-45fe-84bc-f5394118a8be" + } +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preDelete.request.json b/src/test/java/software/amazon/cloudformation/data/hook/preDelete.request.json new file mode 100644 index 00000000..15512d17 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preDelete.request.json @@ -0,0 +1,30 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "DELETE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preDelete.with-request-context.request.json b/src/test/java/software/amazon/cloudformation/data/hook/preDelete.with-request-context.request.json new file mode 100644 index 00000000..4105f24c --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preDelete.with-request-context.request.json @@ -0,0 +1,37 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "DELETE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": { + "invocation": 2, + "callbackContext": { + "contextPropertyA": "Value" + }, + "cloudWatchEventsRuleName": "reinvoke-handler-4754ac8a-623b-45fe-84bc-f5394118a8be", + "cloudWatchEventsTargetId": "reinvoke-target-4754ac8a-623b-45fe-84bc-f5394118a8be" + } +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preUpdate.request.json b/src/test/java/software/amazon/cloudformation/data/hook/preUpdate.request.json new file mode 100644 index 00000000..5c644fb4 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preUpdate.request.json @@ -0,0 +1,30 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "UPDATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": {} +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/preUpdate.with-request-context.request.json b/src/test/java/software/amazon/cloudformation/data/hook/preUpdate.with-request-context.request.json new file mode 100644 index 00000000..69065fed --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/preUpdate.with-request-context.request.json @@ -0,0 +1,37 @@ +{ + "clientRequestToken": "123456", + "awsAccountId": "123456789012", + "stackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/e722ae60-fe62-11e8-9a0e-0ae8cc519968", + "changeSetId": "arn:aws:cloudformation:us-east-1:123456789012:changeSet/SampleChangeSet-conditional/1a2345b6-0000-00a0-a123-00abc0abc000", + "hookTypeName": "AWS::Test::TestModel", + "hookTypeVersion": "1.0", + "hookModel": { + "property1": "abc", + "property2": 123 + }, + "actionInvocationPoint": "UPDATE_PRE_PROVISION", + "invocationId": "34325", + "invocationPlanId": "12345457557866789012", + "encryptedFas": "fas", + "requestData": { + "targetName": "AWS::Example::ResourceTarget", + "targetType": "RESOURCE", + "targetLogicalId": "myResource", + "targetModel": { + "resourceProperties": {} + }, + "callerCredentials": "callerCredentials", + "providerCredentials": "providerCredentials", + "providerLogGroupName": "providerLoggingGroupName", + "hookEncryptionKeyArn": "hookEncryptionKeyArn", + "hookEncryptionKeyRole": "hookEncryptionKeyRole" + }, + "requestContext": { + "invocation": 2, + "callbackContext": { + "contextPropertyA": "Value" + }, + "cloudWatchEventsRuleName": "reinvoke-handler-4754ac8a-623b-45fe-84bc-f5394118a8be", + "cloudWatchEventsTargetId": "reinvoke-target-4754ac8a-623b-45fe-84bc-f5394118a8be" + } +} diff --git a/src/test/java/software/amazon/cloudformation/data/hook/test-target-schema.json b/src/test/java/software/amazon/cloudformation/data/hook/test-target-schema.json new file mode 100644 index 00000000..c7adfabd --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/data/hook/test-target-schema.json @@ -0,0 +1,22 @@ +{ + "typeName": "Test::Resource::Type", + "description": "a test schema", + "properties": { + "Property1": { + "type": "string" + }, + "Property2": { + "type": "integer" + }, + "Tags": { + "type": "object" + } + }, + "primaryIdentifier": [ + "/properties/Property1" + ], + "readOnlyProperties": [ + "/properties/Property1" + ], + "additionalProperties": false +} diff --git a/src/test/java/software/amazon/cloudformation/encryption/KMSCipherTest.java b/src/test/java/software/amazon/cloudformation/encryption/KMSCipherTest.java new file mode 100644 index 00000000..9fa27b33 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/encryption/KMSCipherTest.java @@ -0,0 +1,85 @@ +/* +* 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.encryption; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import com.amazonaws.encryptionsdk.AwsCrypto; +import com.amazonaws.encryptionsdk.CryptoResult; +import com.amazonaws.encryptionsdk.kms.KmsMasterKey; +import com.amazonaws.encryptionsdk.kms.KmsMasterKeyProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.cloudformation.proxy.Credentials; + +@ExtendWith(MockitoExtension.class) +public class KMSCipherTest { + @Mock + private AwsCrypto cryptoHelper; + + @Mock + private CryptoResult result; + + @Mock + private KmsMasterKeyProvider kmsKeyProvider; + + private KMSCipher cipher; + + @Test + public void constructKMSCipher_constructSuccess() { + try { + cipher = new KMSCipher("encryptionKeyArn", "encryptionKeyRole"); + assertThat(cipher).isNotNull(); + } catch (final Exception ex) { + } + } + + @Test + public void decryptCredentials_decryptSuccess() { + cipher = new KMSCipher(cryptoHelper, kmsKeyProvider); + lenient().when(cryptoHelper.decryptData(any(KmsMasterKeyProvider.class), any(byte[].class))).thenReturn(result); + lenient().when(result.getResult()).thenReturn("{\"test\":\"test\"}".getBytes()); + + try { + Credentials decryptedCredentials = cipher + .decryptCredentials("ewogICAgICAgICAgICAiYWNjZXNzS2V5SWQiOiAiSUFTQVlLODM1R0FJRkhBSEVJMjMiLAogICAg\n" + + "ICAgICAgICAic2VjcmV0QWNjZXNzS2V5IjogIjY2aU9HUE41TG5wWm9yY0xyOEtoMjV1OEFiakhW\n" + + "bGx2NS9wb2gyTzAiLAogICAgICAgICAgICAic2Vzc2lvblRva2VuIjogImxhbWVIUzJ2UU9rblNI\n" + + "V2hkRllUeG0yZUpjMUpNbjlZQk5JNG5WNG1YdWU5NDVLUEw2REhmVzhFc1VRVDV6d3NzWUVDMU52\n" + + "WVA5eUQ2WTVzNWxLUjNjaGZsT0hQRnNJZTZlcWciCiAgICAgICAgfQ=="); + assertThat(decryptedCredentials).isNotNull(); + } catch (final Exception ex) { + } + + } + + @Test + public void decryptCredentials_decryptFailure() { + cipher = new KMSCipher("encryptionKeyArn", "encryptionKeyRole"); + try { + Credentials decryptedCredentials = cipher + .decryptCredentials("ewogICAgICAgICAgICAiYWNjZXNzS2V5SWQiOiAiSUFTQVlLODM1R0FJRkhBSEVJMjMiLAogICAg\n" + + "ICAgICAgICAic2VjcmV0QWNjZXNzS2V5IjogIjY2aU9HUE41TG5wWm9yY0xyOEtoMjV1OEFiakhW\n" + + "bGx2NS9wb2gyTzAiLAogICAgICAgICAgICAic2Vzc2lvblRva2VuIjogImxhbWVIUzJ2UU9rblNI\n" + + "V2hkRllUeG0yZUpjMUpNbjlZQk5JNG5WNG1YdWU5NDVLUEw2REhmVzhFc1VRVDV6d3NzWUVDMU52\n" + + "WVA5eUQ2WTVzNWxLUjNjaGZsT0hQRnNJZTZlcWciCiAgICAgICAgfQ=="); + assertThat(decryptedCredentials).isNotNull(); + } catch (final Exception ex) { + } + } +} diff --git a/src/test/java/software/amazon/cloudformation/exceptions/CfnHandlerInternalFailureExceptionTests.java b/src/test/java/software/amazon/cloudformation/exceptions/CfnHandlerInternalFailureExceptionTests.java new file mode 100644 index 00000000..c00d377b --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/exceptions/CfnHandlerInternalFailureExceptionTests.java @@ -0,0 +1,50 @@ +/* +* 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.exceptions; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +public class CfnHandlerInternalFailureExceptionTests { + @Test + public void cfnHandlerInternalFailureException_isBaseHandlerException() { + assertThatExceptionOfType(BaseHandlerException.class).isThrownBy(() -> { + throw new CfnHandlerInternalFailureException(new RuntimeException()); + }); + } + + @Test + public void cfnHandlerInternalFailureException_singleArgConstructorHasMessage() { + assertThatExceptionOfType(CfnHandlerInternalFailureException.class).isThrownBy(() -> { + throw new CfnHandlerInternalFailureException(new RuntimeException()); + }).withCauseInstanceOf(RuntimeException.class).withMessageContaining("Internal error occurred in the handler"); + } + + @Test + public void cfnHandlerInternalFailureException_noCauseGiven() { + assertThatExceptionOfType(CfnHandlerInternalFailureException.class).isThrownBy(() -> { + throw new CfnHandlerInternalFailureException(); + }).withNoCause().withMessageContaining("Internal error occurred in the handler"); + } + + @Test + public void cfnHandlerInternalFailureException_errorCodeIsAppropriate() { + assertThatExceptionOfType(CfnHandlerInternalFailureException.class).isThrownBy(() -> { + throw new CfnHandlerInternalFailureException(new RuntimeException()); + }).satisfies(exception -> Assertions.assertEquals(HandlerErrorCode.HandlerInternalFailure, exception.getErrorCode())); + } +} diff --git a/src/test/java/software/amazon/cloudformation/exceptions/CfnInvalidRequestExceptionTests.java b/src/test/java/software/amazon/cloudformation/exceptions/CfnInvalidRequestExceptionTests.java index 847c2d08..0080b765 100644 --- a/src/test/java/software/amazon/cloudformation/exceptions/CfnInvalidRequestExceptionTests.java +++ b/src/test/java/software/amazon/cloudformation/exceptions/CfnInvalidRequestExceptionTests.java @@ -55,4 +55,33 @@ public void cfnInvalidRequestException_errorCodeMessage() { throw new CfnInvalidRequestException(new RuntimeException("something wrong")); }).satisfies(exception -> assertEquals("something wrong", exception.getMessage())); } + + @Test + public void unsupportedTargetException_isCfnInvalidRequestException() { + assertThatExceptionOfType(CfnInvalidRequestException.class).isThrownBy(() -> { + throw new UnsupportedTargetException("AWS::Type::Target", new RuntimeException()); + }).withCauseInstanceOf(RuntimeException.class).withMessageContaining("AWS::Type::Target") + .withMessageContaining("Unsupported target"); + } + + @Test + public void unsupportedTargetException_singleArgConstructorHasNoMessage() { + assertThatExceptionOfType(UnsupportedTargetException.class).isThrownBy(() -> { + throw new UnsupportedTargetException(new RuntimeException()); + }).withRootCauseExactlyInstanceOf(RuntimeException.class).withMessage(null); + } + + @Test + public void unsupportedTargetException_noCauseGiven() { + assertThatExceptionOfType(UnsupportedTargetException.class).isThrownBy(() -> { + throw new UnsupportedTargetException("AWS::Type::Target"); + }).withNoCause().withMessageContaining("AWS::Type::Target").withMessageContaining("Unsupported target"); + } + + @Test + public void unsupportedTargetException_errorCodeIsAppropriate() { + assertThatExceptionOfType(UnsupportedTargetException.class).isThrownBy(() -> { + throw new UnsupportedTargetException(new RuntimeException()); + }).satisfies(exception -> assertEquals(HandlerErrorCode.InvalidRequest, exception.getErrorCode())); + } } diff --git a/src/test/java/software/amazon/cloudformation/exceptions/CfnNonCompliantExceptionTests.java b/src/test/java/software/amazon/cloudformation/exceptions/CfnNonCompliantExceptionTests.java new file mode 100644 index 00000000..29b92085 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/exceptions/CfnNonCompliantExceptionTests.java @@ -0,0 +1,63 @@ +/* +* 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.exceptions; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +public class CfnNonCompliantExceptionTests { + private static final String TYPE = "AWS::Type::Hook"; + private static final String REASON = "target is not compliant"; + private static final String STATUS = "Non-Complaint"; + private static final String ERROR_MESSAGE = "something wrong"; + + @Test + public void cfnNonCompliantException_isBaseHandlerException() { + assertThatExceptionOfType(BaseHandlerException.class).isThrownBy(() -> { + throw new CfnNonCompliantException(TYPE, REASON, new RuntimeException()); + }).withCauseInstanceOf(RuntimeException.class).withMessageContaining(TYPE).withMessageContaining(REASON) + .withMessageContaining(STATUS); + } + + @Test + public void cfnNonCompliantException_singleArgConstructorHasNoMessage() { + assertThatExceptionOfType(CfnNonCompliantException.class).isThrownBy(() -> { + throw new CfnNonCompliantException(new RuntimeException()); + }).withCauseInstanceOf(RuntimeException.class).withMessage(null); + } + + @Test + public void cfnNonCompliantException_noCauseGiven() { + assertThatExceptionOfType(CfnNonCompliantException.class).isThrownBy(() -> { + throw new CfnNonCompliantException(TYPE, REASON); + }).withNoCause().withMessageContaining(TYPE).withMessageContaining(REASON).withMessageContaining(STATUS); + } + + @Test + public void cfnNonCompliantException_errorCodeIsAppropriate() { + assertThatExceptionOfType(CfnNonCompliantException.class).isThrownBy(() -> { + throw new CfnNonCompliantException(new RuntimeException()); + }).satisfies(exception -> Assertions.assertEquals(HandlerErrorCode.NonCompliant, exception.getErrorCode())); + } + + @Test + public void cfnNonCompliantException_errorMessage() { + assertThatExceptionOfType(CfnNonCompliantException.class).isThrownBy(() -> { + throw new CfnNonCompliantException(new RuntimeException(ERROR_MESSAGE)); + }).satisfies(exception -> Assertions.assertEquals(ERROR_MESSAGE, exception.getMessage())); + } +} diff --git a/src/test/java/software/amazon/cloudformation/exceptions/CfnUnknownExceptionTests.java b/src/test/java/software/amazon/cloudformation/exceptions/CfnUnknownExceptionTests.java new file mode 100644 index 00000000..c4392256 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/exceptions/CfnUnknownExceptionTests.java @@ -0,0 +1,50 @@ +/* +* 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.exceptions; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import software.amazon.cloudformation.proxy.HandlerErrorCode; + +public class CfnUnknownExceptionTests { + @Test + public void cfnUnknownException_isBaseHandlerException() { + assertThatExceptionOfType(BaseHandlerException.class).isThrownBy(() -> { + throw new CfnUnknownException(new RuntimeException()); + }); + } + + @Test + public void cfnUnknownException_singleArgConstructorHasMessage() { + assertThatExceptionOfType(CfnUnknownException.class).isThrownBy(() -> { + throw new CfnUnknownException(new RuntimeException()); + }).withCauseInstanceOf(RuntimeException.class).withMessageContaining("Unknown error"); + } + + @Test + public void cfnUnknownException_noCauseGiven() { + assertThatExceptionOfType(CfnUnknownException.class).isThrownBy(() -> { + throw new CfnUnknownException(); + }).withNoCause().withMessageContaining("Unknown error"); + } + + @Test + public void cfnUnknownException_errorCodeIsAppropriate() { + assertThatExceptionOfType(CfnUnknownException.class).isThrownBy(() -> { + throw new CfnUnknownException(new RuntimeException()); + }).satisfies(exception -> Assertions.assertEquals(HandlerErrorCode.Unknown, exception.getErrorCode())); + } +} diff --git a/src/test/java/software/amazon/cloudformation/metrics/HookMetricsPublisherImplTest.java b/src/test/java/software/amazon/cloudformation/metrics/HookMetricsPublisherImplTest.java new file mode 100644 index 00000000..0948bbc4 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/metrics/HookMetricsPublisherImplTest.java @@ -0,0 +1,192 @@ +/* +* 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.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import java.time.Instant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.services.cloudwatch.CloudWatchClient; +import software.amazon.awssdk.services.cloudwatch.model.Dimension; +import software.amazon.awssdk.services.cloudwatch.model.MetricDatum; +import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataRequest; +import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataResponse; +import software.amazon.awssdk.services.cloudwatch.model.StandardUnit; +import software.amazon.cloudformation.HookInvocationPoint; +import software.amazon.cloudformation.injection.CloudWatchProvider; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.Logger; + +@ExtendWith(MockitoExtension.class) +public class HookMetricsPublisherImplTest { + + @Mock + private Logger loggerProxy; + + @Mock + private CloudWatchProvider platformCloudWatchProvider; + + @Mock + private CloudWatchProvider providerCloudWatchProvider; + + @Mock + private CloudWatchClient platformCloudWatchClient; + + @Mock + private CloudWatchClient providerCloudWatchClient; + + private String awsAccountId = "77384178834"; + private final String hookTypeName = "AWS::Test::TestModel"; + + @BeforeEach + public void beforeEach() { + when(platformCloudWatchProvider.get()).thenReturn(platformCloudWatchClient); + when(providerCloudWatchProvider.get()).thenReturn(providerCloudWatchClient); + when(platformCloudWatchClient.putMetricData(any(PutMetricDataRequest.class))) + .thenReturn(mock(PutMetricDataResponse.class)); + when(providerCloudWatchClient.putMetricData(any(PutMetricDataRequest.class))) + .thenReturn(mock(PutMetricDataResponse.class)); + } + + @AfterEach + public void afterEach() { + verifyNoMoreInteractions(platformCloudWatchProvider); + verifyNoMoreInteractions(platformCloudWatchClient); + verifyNoMoreInteractions(providerCloudWatchProvider); + verifyNoMoreInteractions(providerCloudWatchClient); + } + + @Test + public void testPublishDurationMetric() { + final HookMetricsPublisherImpl platformMetricsPublisher = new HookMetricsPublisherImpl(platformCloudWatchProvider, + loggerProxy, awsAccountId, + hookTypeName); + platformMetricsPublisher.refreshClient(); + + final HookMetricsPublisherImpl providerMetricsPublisher = new HookMetricsPublisherImpl(providerCloudWatchProvider, + loggerProxy, awsAccountId, + hookTypeName); + providerMetricsPublisher.refreshClient(); + + final Instant instant = Instant.parse("2019-06-04T17:50:00Z"); + platformMetricsPublisher.publishDurationMetric(instant, HookInvocationPoint.CREATE_PRE_PROVISION, 123456); + providerMetricsPublisher.publishDurationMetric(instant, HookInvocationPoint.CREATE_PRE_PROVISION, 123456); + + final ArgumentCaptor argument1 = ArgumentCaptor.forClass(PutMetricDataRequest.class); + final ArgumentCaptor argument2 = ArgumentCaptor.forClass(PutMetricDataRequest.class); + verify(platformCloudWatchClient).putMetricData(argument1.capture()); + verify(providerCloudWatchClient).putMetricData(argument2.capture()); + + final PutMetricDataRequest request = argument1.getValue(); + assertThat(request.namespace()) + .isEqualTo(String.format("%s/%s/%s", "AWS/CloudFormation", awsAccountId, "AWS/Test/TestModel")); + + assertThat(request.metricData()).hasSize(1); + final MetricDatum metricDatum = request.metricData().get(0); + assertThat(metricDatum.metricName()).isEqualTo("HandlerInvocationDuration"); + assertThat(metricDatum.unit()).isEqualTo(StandardUnit.MILLISECONDS); + assertThat(metricDatum.value()).isEqualTo(123456); + assertThat(metricDatum.timestamp()).isEqualTo(Instant.parse("2019-06-04T17:50:00Z")); + assertThat(metricDatum.dimensions()).containsExactlyInAnyOrder( + Dimension.builder().name("InvocationPoint").value("CREATE_PRE_PROVISION").build(), + Dimension.builder().name("HookType").value(hookTypeName).build()); + } + + @Test + public void testPublishExceptionMetric() { + final HookMetricsPublisherImpl platformMetricsPublisher = new HookMetricsPublisherImpl(platformCloudWatchProvider, + loggerProxy, awsAccountId, + hookTypeName); + platformMetricsPublisher.refreshClient(); + + final HookMetricsPublisherImpl providerMetricsPublisher = new HookMetricsPublisherImpl(providerCloudWatchProvider, + loggerProxy, awsAccountId, + hookTypeName); + providerMetricsPublisher.refreshClient(); + + final Instant instant = Instant.parse("2019-06-03T17:50:00Z"); + final RuntimeException e = new RuntimeException("some error"); + platformMetricsPublisher.publishExceptionMetric(instant, HookInvocationPoint.CREATE_PRE_PROVISION, e, + HandlerErrorCode.InternalFailure); + providerMetricsPublisher.publishDurationMetric(instant, HookInvocationPoint.DELETE_PRE_PROVISION, 123456); + + final ArgumentCaptor argument1 = ArgumentCaptor.forClass(PutMetricDataRequest.class); + final ArgumentCaptor argument2 = ArgumentCaptor.forClass(PutMetricDataRequest.class); + verify(platformCloudWatchClient).putMetricData(argument1.capture()); + verify(providerCloudWatchClient).putMetricData(argument2.capture()); + + final PutMetricDataRequest request = argument1.getValue(); + assertThat(request.namespace()) + .isEqualTo(String.format("%s/%s/%s", "AWS/CloudFormation", awsAccountId, "AWS/Test/TestModel")); + + assertThat(request.metricData()).hasSize(1); + final MetricDatum metricDatum = request.metricData().get(0); + assertThat(metricDatum.metricName()).isEqualTo("HandlerException"); + assertThat(metricDatum.unit()).isEqualTo(StandardUnit.COUNT); + assertThat(metricDatum.value()).isEqualTo(1.0); + assertThat(metricDatum.timestamp()).isEqualTo(Instant.parse("2019-06-03T17:50:00Z")); + assertThat(metricDatum.dimensions()).containsExactlyInAnyOrder( + Dimension.builder().name("InvocationPoint").value("CREATE_PRE_PROVISION").build(), + Dimension.builder().name("ExceptionType").value("class java.lang.RuntimeException").build(), + Dimension.builder().name("HookType").value(hookTypeName).build(), + Dimension.builder().name("HandlerErrorCode").value("InternalFailure").build()); + } + + @Test + public void testPublishInvocationMetric() { + final HookMetricsPublisherImpl platformMetricsPublisher = new HookMetricsPublisherImpl(platformCloudWatchProvider, + loggerProxy, awsAccountId, + hookTypeName); + platformMetricsPublisher.refreshClient(); + + final HookMetricsPublisherImpl providerMetricsPublisher = new HookMetricsPublisherImpl(providerCloudWatchProvider, + loggerProxy, awsAccountId, + hookTypeName); + providerMetricsPublisher.refreshClient(); + + final Instant instant = Instant.parse("2019-06-04T17:50:00Z"); + platformMetricsPublisher.publishInvocationMetric(instant, HookInvocationPoint.UPDATE_PRE_PROVISION); + providerMetricsPublisher.publishDurationMetric(instant, HookInvocationPoint.UPDATE_PRE_PROVISION, 123456); + + final ArgumentCaptor argument1 = ArgumentCaptor.forClass(PutMetricDataRequest.class); + final ArgumentCaptor argument2 = ArgumentCaptor.forClass(PutMetricDataRequest.class); + verify(platformCloudWatchClient).putMetricData(argument1.capture()); + verify(providerCloudWatchClient).putMetricData(argument2.capture()); + + final PutMetricDataRequest request = argument1.getValue(); + assertThat(request.namespace()) + .isEqualTo(String.format("%s/%s/%s", "AWS/CloudFormation", awsAccountId, "AWS/Test/TestModel")); + + assertThat(request.metricData()).hasSize(1); + final MetricDatum metricDatum = request.metricData().get(0); + assertThat(metricDatum.metricName()).isEqualTo("HandlerInvocationCount"); + assertThat(metricDatum.unit()).isEqualTo(StandardUnit.COUNT); + assertThat(metricDatum.value()).isEqualTo(1.0); + assertThat(metricDatum.timestamp()).isEqualTo(Instant.parse("2019-06-04T17:50:00Z")); + assertThat(metricDatum.dimensions()).containsExactlyInAnyOrder( + Dimension.builder().name("InvocationPoint").value("UPDATE_PRE_PROVISION").build(), + Dimension.builder().name("HookType").value(hookTypeName).build()); + } +} diff --git a/src/test/java/software/amazon/cloudformation/proxy/HookProgressEventTest.java b/src/test/java/software/amazon/cloudformation/proxy/HookProgressEventTest.java new file mode 100644 index 00000000..933575e0 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/proxy/HookProgressEventTest.java @@ -0,0 +1,161 @@ +/* +* 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; + +import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.Test; +import software.amazon.cloudformation.TestContext; +import software.amazon.cloudformation.proxy.hook.HookProgressEvent; +import software.amazon.cloudformation.proxy.hook.HookStatus; +import software.amazon.cloudformation.resource.Serializer; + +public class HookProgressEventTest { + + @Test + public void testFailedHandler() { + final HookProgressEvent< + TestContext> progressEvent = HookProgressEvent.failed(null, HandlerErrorCode.InternalFailure, "test error", null); + + assertThat(progressEvent.getCallbackContext()).isNull(); + assertThat(progressEvent.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.InternalFailure); + assertThat(progressEvent.getMessage()).isEqualTo("test error"); + assertThat(progressEvent.getHookStatus()).isEqualTo(HookStatus.FAILED); + assertThat(progressEvent.isFailed()).isTrue(); + assertThat(progressEvent.isComplete()).isFalse(); + assertThat(progressEvent.isInProgress()).isFalse(); + assertThat(progressEvent.canContinueProgress()).isFalse(); + } + + @Test + public void testInProgressHandler() { + final TestContext callbackContext = TestContext.builder().contextPropertyA("def").build(); + final HookProgressEvent progressEvent = HookProgressEvent.progress(callbackContext, null, null, 3); + + assertThat(progressEvent.getCallbackContext()).isEqualTo(callbackContext); + assertThat(progressEvent.getCallbackDelaySeconds()).isEqualTo(3); + assertThat(progressEvent.getErrorCode()).isNull(); + assertThat(progressEvent.getMessage()).isNull(); + assertThat(progressEvent.getHookStatus()).isEqualTo(HookStatus.IN_PROGRESS); + assertThat(progressEvent.isFailed()).isFalse(); + assertThat(progressEvent.isComplete()).isFalse(); + assertThat(progressEvent.isInProgress()).isTrue(); + assertThat(progressEvent.canContinueProgress()).isFalse(); + } + + @Test + public void testInProgressHandler_isContinuable() { + final TestContext callbackContext = TestContext.builder().contextPropertyA("def").build(); + final HookProgressEvent progressEvent = HookProgressEvent.progress(callbackContext, null, null, 0); + + assertThat(progressEvent.getCallbackContext()).isEqualTo(callbackContext); + assertThat(progressEvent.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(progressEvent.getErrorCode()).isNull(); + assertThat(progressEvent.getMessage()).isNull(); + assertThat(progressEvent.getHookStatus()).isEqualTo(HookStatus.IN_PROGRESS); + assertThat(progressEvent.isFailed()).isFalse(); + assertThat(progressEvent.isComplete()).isFalse(); + assertThat(progressEvent.isInProgress()).isTrue(); + assertThat(progressEvent.canContinueProgress()).isTrue(); + } + + @Test + public void testCompleteHandler() { + final HookProgressEvent progressEvent = HookProgressEvent.complete(null, null, null); + + assertThat(progressEvent.getCallbackContext()).isNull(); + assertThat(progressEvent.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(progressEvent.getErrorCode()).isNull(); + assertThat(progressEvent.getMessage()).isNull(); + assertThat(progressEvent.getHookStatus()).isEqualTo(HookStatus.SUCCESS); + assertThat(progressEvent.isFailed()).isFalse(); + assertThat(progressEvent.isComplete()).isTrue(); + assertThat(progressEvent.isInProgress()).isFalse(); + assertThat(progressEvent.canContinueProgress()).isFalse(); + } + + @Test + public void testDefaultFailedHandler() { + final HookProgressEvent progressEvent = HookProgressEvent.defaultFailureHandler(new Exception("test error"), + HandlerErrorCode.InternalFailure); + + assertThat(progressEvent.getCallbackContext()).isNull(); + assertThat(progressEvent.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(progressEvent.getErrorCode()).isEqualTo(HandlerErrorCode.InternalFailure); + assertThat(progressEvent.getMessage()).isEqualTo("test error"); + assertThat(progressEvent.getHookStatus()).isEqualTo(HookStatus.FAILED); + assertThat(progressEvent.isFailed()).isTrue(); + assertThat(progressEvent.isComplete()).isFalse(); + assertThat(progressEvent.isInProgress()).isFalse(); + assertThat(progressEvent.canContinueProgress()).isFalse(); + } + + @Test + public void testDefaultInProgressHandler() { + final TestContext callbackContext = TestContext.builder().contextPropertyA("def").build(); + final HookProgressEvent progressEvent = HookProgressEvent.defaultProgressHandler(callbackContext, 3); + + assertThat(progressEvent.getCallbackContext()).isEqualTo(callbackContext); + assertThat(progressEvent.getCallbackDelaySeconds()).isEqualTo(3); + assertThat(progressEvent.getErrorCode()).isNull(); + assertThat(progressEvent.getMessage()).isNull(); + assertThat(progressEvent.getHookStatus()).isEqualTo(HookStatus.IN_PROGRESS); + assertThat(progressEvent.isFailed()).isFalse(); + assertThat(progressEvent.isComplete()).isFalse(); + assertThat(progressEvent.isInProgress()).isTrue(); + assertThat(progressEvent.canContinueProgress()).isFalse(); + } + + @Test + public void testDefaultCompleteHandler() { + final HookProgressEvent progressEvent = HookProgressEvent.defaultCompleteHandler(); + + assertThat(progressEvent.getCallbackContext()).isNull(); + assertThat(progressEvent.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(progressEvent.getErrorCode()).isNull(); + assertThat(progressEvent.getMessage()).isNull(); + assertThat(progressEvent.getHookStatus()).isEqualTo(HookStatus.SUCCESS); + assertThat(progressEvent.isFailed()).isFalse(); + assertThat(progressEvent.isComplete()).isTrue(); + assertThat(progressEvent.isInProgress()).isFalse(); + assertThat(progressEvent.canContinueProgress()).isFalse(); + } + + @Test + public void testOnCompleteChaining() { + final HookProgressEvent progressEvent = HookProgressEvent.complete(null, null, null); + final HookProgressEvent chained = progressEvent + .onComplete(e -> HookProgressEvent.failed(null, HandlerErrorCode.InternalFailure, "test error", null)); + + assertThat(chained.getCallbackContext()).isNull(); + assertThat(chained.getCallbackDelaySeconds()).isEqualTo(0); + assertThat(chained.getErrorCode()).isEqualTo(HandlerErrorCode.InternalFailure); + assertThat(chained.getHookStatus()).isEqualTo(HookStatus.FAILED); + assertThat(chained.getMessage()).isEqualTo("test error"); + assertThat(chained.isFailed()).isEqualTo(true); + assertThat(chained.isInProgress()).isEqualTo(false); + assertThat(chained.isInProgressCallbackDelay()).isEqualTo(false); + } + + @Test + public void progressEvent_serialize_shouldReturnJson() throws JsonProcessingException { + final HookProgressEvent progressEvent = HookProgressEvent.complete(null, null, null); + final Serializer serializer = new Serializer(); + final String json = serializer.serialize(progressEvent); + + assertThat(json).isEqualTo("{\"hookStatus\":\"SUCCESS\",\"callbackDelaySeconds\":0}"); + } +} diff --git a/src/test/java/software/amazon/cloudformation/proxy/ProgressEventTest.java b/src/test/java/software/amazon/cloudformation/proxy/ProgressEventTest.java index 63b2356a..5c1f23b8 100644 --- a/src/test/java/software/amazon/cloudformation/proxy/ProgressEventTest.java +++ b/src/test/java/software/amazon/cloudformation/proxy/ProgressEventTest.java @@ -36,6 +36,7 @@ public void testDefaultFailedHandler() { assertThat(progressEvent.getResourceModel()).isNull(); assertThat(progressEvent.getResourceModels()).isNull(); assertThat(progressEvent.getStatus()).isEqualTo(OperationStatus.FAILED); + assertThat(progressEvent.isFailed()).isTrue(); } @Test @@ -52,6 +53,7 @@ public void testDefaultInProgressHandler() { assertThat(progressEvent.getResourceModel()).isEqualTo(model); assertThat(progressEvent.getResourceModels()).isNull(); assertThat(progressEvent.getStatus()).isEqualTo(OperationStatus.IN_PROGRESS); + assertThat(progressEvent.isInProgress()).isTrue(); } @Test @@ -66,6 +68,7 @@ public void testDefaultSuccessHandler() { assertThat(progressEvent.getResourceModel()).isEqualTo(model); assertThat(progressEvent.getResourceModels()).isNull(); assertThat(progressEvent.getStatus()).isEqualTo(OperationStatus.SUCCESS); + assertThat(progressEvent.isSuccess()).isTrue(); } @Test diff --git a/src/test/java/software/amazon/cloudformation/proxy/handler/hook/HookModel.java b/src/test/java/software/amazon/cloudformation/proxy/handler/hook/HookModel.java new file mode 100644 index 00000000..0b8b6907 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/proxy/handler/hook/HookModel.java @@ -0,0 +1,30 @@ +/* +* 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.handler.hook; + +import com.fasterxml.jackson.annotation.JsonProperty; + +@lombok.Builder +@lombok.Data +@lombok.EqualsAndHashCode +@lombok.ToString +@lombok.NoArgsConstructor +@lombok.AllArgsConstructor +public class HookModel { + public static final String TYPE_NAME = "AWS::CFN::Hook"; + + @JsonProperty("InputParameter") + private String inputParameter; +} diff --git a/src/test/java/software/amazon/cloudformation/proxy/handler/hook/HookServiceHandlerWrapper.java b/src/test/java/software/amazon/cloudformation/proxy/handler/hook/HookServiceHandlerWrapper.java new file mode 100644 index 00000000..3436aad6 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/proxy/handler/hook/HookServiceHandlerWrapper.java @@ -0,0 +1,109 @@ +/* +* 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.handler.hook; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.mockito.Mockito; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.cloudformation.HookInvocationPoint; +import software.amazon.cloudformation.HookLambdaWrapper; +import software.amazon.cloudformation.encryption.KMSCipher; +import software.amazon.cloudformation.injection.CredentialsProvider; +import software.amazon.cloudformation.loggers.CloudWatchLogPublisher; +import software.amazon.cloudformation.loggers.LogPublisher; +import software.amazon.cloudformation.metrics.MetricsPublisher; +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.HandlerErrorCode; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.StdCallbackContext; +import software.amazon.cloudformation.proxy.handler.Model; +import software.amazon.cloudformation.proxy.handler.TypeConfigurationModel; +import software.amazon.cloudformation.proxy.hook.HookContext; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.hook.HookInvocationRequest; +import software.amazon.cloudformation.proxy.hook.targetmodel.HookTargetModel; +import software.amazon.cloudformation.proxy.service.ServiceClient; +import software.amazon.cloudformation.resource.SchemaValidator; +import software.amazon.cloudformation.resource.Serializer; + +public class HookServiceHandlerWrapper extends HookLambdaWrapper { + + private final ServiceClient serviceClient; + + public HookServiceHandlerWrapper(final CredentialsProvider providerLoggingCredentialsProvider, + final CloudWatchLogPublisher providerEventsLogger, + final LogPublisher platformEventsLogger, + final MetricsPublisher platformMetricsPublisher, + final MetricsPublisher providerMetricsPublisher, + final SchemaValidator validator, + final Serializer serializer, + final ServiceClient client, + final SdkHttpClient httpClient, + final KMSCipher cipher) { + super(providerLoggingCredentialsProvider, providerEventsLogger, platformEventsLogger, platformMetricsPublisher, + providerMetricsPublisher, validator, serializer, httpClient, cipher); + this.serviceClient = client; + } + + @Override + protected HookHandlerRequest transform(final HookInvocationRequest request) { + return HookHandlerRequest.builder().clientRequestToken(request.getClientRequestToken()) + .hookContext(HookContext.builder().awsAccountId(request.getAwsAccountId()).stackId(request.getStackId()) + .changeSetId(request.getChangeSetId()).hookTypeName(request.getHookTypeName()) + .hookTypeVersion(request.getHookTypeVersion()).invocationPoint(request.getActionInvocationPoint()) + .targetName(request.getRequestData().getTargetName()).targetType(request.getRequestData().getTargetType()) + .targetLogicalId(request.getRequestData().getTargetLogicalId()) + .targetModel(HookTargetModel.of(request.getRequestData().getTargetModel())).build()) + .build(); + } + + @Override + protected JSONObject provideHookSchemaJSONObject() { + return new JSONObject(new JSONTokener(this.getClass().getResourceAsStream("hookModel.json"))); + } + + @Override + public ProgressEvent invokeHandler(final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final HookInvocationPoint invocationPoint, + final StdCallbackContext callbackContext, + final TypeConfigurationModel typeConfiguration) { + if (invocationPoint == HookInvocationPoint.CREATE_PRE_PROVISION) { + return new PreCreateHandler(serviceClient).handleRequest(proxy, request, callbackContext, + Mockito.mock(LoggerProxy.class)); + } else { + return ProgressEvent.failed(null, callbackContext, HandlerErrorCode.GeneralServiceException, "Not Implemented"); + } + } + + @Override + protected TypeReference> getTypeReference() { + return new TypeReference>() { + }; + } + + @Override + protected TypeReference getModelTypeReference() { + return new TypeReference() { + }; + } + + @Override + protected void scrubFiles() { + } +} diff --git a/src/test/java/software/amazon/cloudformation/proxy/handler/hook/PreCreateHandler.java b/src/test/java/software/amazon/cloudformation/proxy/handler/hook/PreCreateHandler.java new file mode 100644 index 00000000..25358735 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/proxy/handler/hook/PreCreateHandler.java @@ -0,0 +1,41 @@ +/* +* 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.handler.hook; + +import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy; +import software.amazon.cloudformation.proxy.LoggerProxy; +import software.amazon.cloudformation.proxy.ProgressEvent; +import software.amazon.cloudformation.proxy.StdCallbackContext; +import software.amazon.cloudformation.proxy.handler.Model; +import software.amazon.cloudformation.proxy.hook.HookHandlerRequest; +import software.amazon.cloudformation.proxy.service.ServiceClient; + +public class PreCreateHandler { + + private final ServiceClient client; + + public PreCreateHandler(ServiceClient client) { + this.client = client; + } + + public ProgressEvent handleRequest(final AmazonWebServicesClientProxy proxy, + final HookHandlerRequest request, + final StdCallbackContext context, + final LoggerProxy loggerProxy) { + + final StdCallbackContext cxt = context == null ? new StdCallbackContext() : context; + return ProgressEvent.success(null, cxt, "Hook invocation completed."); + } +} diff --git a/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/GenericTestResource.java b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/GenericTestResource.java new file mode 100644 index 00000000..f20bfbc8 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/GenericTestResource.java @@ -0,0 +1,51 @@ +/* +* 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.targetmodel; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.List; +import org.json.JSONObject; + +/** + * This test class represents a resource where the resource schema was not + * provided at build time. + */ +public class GenericTestResource extends ResourceHookTarget { + @Override + public JSONObject getPrimaryIdentifier() { + return null; + } + + @Override + public List getAdditionalIdentifiers() { + return null; + } + + @Override + public JSONObject targetSchemaJSONObject() { + return null; + } + + @Override + public Boolean hasDefinedSchema() { + return false; + } + + @JsonIgnore + @Override + public Boolean isCloudFormationRegistryType() { + return false; + } +} diff --git a/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/GenericTestResourceHookTargetModel.java b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/GenericTestResourceHookTargetModel.java new file mode 100644 index 00000000..b25e7b6d --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/GenericTestResourceHookTargetModel.java @@ -0,0 +1,33 @@ +/* +* 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.targetmodel; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(as = GenericTestResourceHookTargetModel.class) +public class GenericTestResourceHookTargetModel extends ResourceHookTargetModel { + @Override + public TypeReference getHookTargetTypeReference() { + return new TypeReference() { + }; + } + + @Override + public TypeReference getTargetModelTypeReference() { + return new TypeReference() { + }; + } +} diff --git a/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTargetModelTest.java b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTargetModelTest.java new file mode 100644 index 00000000..b8e2bb8c --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/HookTargetModelTest.java @@ -0,0 +1,424 @@ +/* +* 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.targetmodel; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; +import org.apache.commons.io.IOUtils; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class HookTargetModelTest { + private static final String TEST_DATA_BASE_PATH = "src/test/java/software/amazon/cloudformation/data/hook/%s"; + private static final String RESOURCE_PROPERTIES_KEY = "ResourceProperties"; + private static final String PREVIOUS_RESOURCE_PROPERTIES_KEY = "PreviousResourceProperties"; + private static final String TEST_PROPERTY_1_KEY = "Property1"; + private static final String TEST_PROPERTY_1 = "property"; + private static final String UPDATED_TEST_PROPERTY_1 = "updated_property"; + private static final String TEST_PROPERTY_2_KEY = "Property2"; + private static final Integer TEST_PROPERTY_2 = 21; + private static final String TEST_TAGS_KEY = "Tags"; + private static final Map TEST_TAGS = ImmutableMap.of("Key", "Value"); + + private static final Map TEST_RESOURCE_PROPERTIES_MAP = ImmutableMap.of(TEST_PROPERTY_1_KEY, TEST_PROPERTY_1, + TEST_PROPERTY_2_KEY, TEST_PROPERTY_2, TEST_TAGS_KEY, TEST_TAGS); + private static final Map UPDATED_TEST_RESOURCE_PROPERTIES_MAP = ImmutableMap.of(TEST_PROPERTY_1_KEY, + UPDATED_TEST_PROPERTY_1, TEST_PROPERTY_2_KEY, TEST_PROPERTY_2, TEST_TAGS_KEY, TEST_TAGS); + + private static final TestResource TEST_RESOURCE = new TestResource(TEST_PROPERTY_1, TEST_PROPERTY_2, TEST_TAGS); + private static final TestResource UPDATED_TEST_RESOURCE = new TestResource(UPDATED_TEST_PROPERTY_1, TEST_PROPERTY_2, + TEST_TAGS); + + private static final Map TEST_TARGET_MODEL_MAP = ImmutableMap.of(RESOURCE_PROPERTIES_KEY, TEST_RESOURCE_PROPERTIES_MAP); + private static final Map UPDATED_TEST_TARGET_MODEL_MAP = ImmutableMap.of(RESOURCE_PROPERTIES_KEY, + UPDATED_TEST_RESOURCE_PROPERTIES_MAP, PREVIOUS_RESOURCE_PROPERTIES_KEY, TEST_RESOURCE_PROPERTIES_MAP); + + private static final TestResourceHookTargetModel TEST_TARGET_MODEL = new TestResourceHookTargetModel(TEST_RESOURCE); + private static final TestResourceHookTargetModel UPDATED_TARGET_MODEL = new TestResourceHookTargetModel(UPDATED_TEST_RESOURCE, + TEST_RESOURCE); + private static final String TEST_TARGET_MODEL_JSON = "{\"ResourceProperties\":{\"Property1\":\"property\",\"Property2\":21,\"Tags\":{\"Key\":\"Value\"}},\"PreviousResourceProperties\":null}"; + private static final String UPDATED_TEST_TARGET_MODEL_JSON = "{\"ResourceProperties\":{\"Property1\":\"updated_property\",\"Property2\":21,\"Tags\":{\"Key\":\"Value\"}},\"PreviousResourceProperties\":{\"Property1\":\"property\",\"Property2\":21,\"Tags\":{\"Key\":\"Value\"}}}"; + + private static final TypeReference< + TestResourceHookTargetModel> TEST_TYPE_REFERENCE = new TypeReference() { + }; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + public void testHookTargetModel() { + final HookTargetModel targetModel = HookTargetModel.of(TEST_TARGET_MODEL_MAP); + Assertions.assertNotNull(targetModel); + Assertions.assertTrue(targetModel.containsKey(RESOURCE_PROPERTIES_KEY)); + Assertions.assertEquals(TEST_RESOURCE_PROPERTIES_MAP, targetModel.get(RESOURCE_PROPERTIES_KEY)); + } + + @Test + public void testHookTargetModel_forUpdateHookTarget() { + final HookTargetModel targetModel = HookTargetModel.of(UPDATED_TEST_TARGET_MODEL_MAP); + Assertions.assertNotNull(targetModel); + Assertions.assertEquals(UPDATED_TEST_RESOURCE_PROPERTIES_MAP, targetModel.get(RESOURCE_PROPERTIES_KEY)); + Assertions.assertEquals(TEST_RESOURCE_PROPERTIES_MAP, targetModel.get(PREVIOUS_RESOURCE_PROPERTIES_KEY)); + } + + @Test + public void testHookTargetModel_fromJsonObject() { + final HookTargetModel targetModel = HookTargetModel.of(new JSONObject(TEST_TARGET_MODEL_MAP)); + Assertions.assertNotNull(targetModel); + Assertions.assertTrue(targetModel.containsKey(RESOURCE_PROPERTIES_KEY)); + Assertions.assertEquals(TEST_RESOURCE_PROPERTIES_MAP, targetModel.get(RESOURCE_PROPERTIES_KEY)); + } + + @Test + public void testHookTargetModel_getOrDefault() { + final HookTargetModel targetModel = HookTargetModel.of(new JSONObject(TEST_TARGET_MODEL_MAP)); + Assertions.assertNotNull(targetModel); + Assertions.assertTrue(targetModel.containsKey(RESOURCE_PROPERTIES_KEY)); + Assertions.assertEquals(TEST_RESOURCE_PROPERTIES_MAP, targetModel.get(RESOURCE_PROPERTIES_KEY)); + Assertions.assertNull(targetModel.get(PREVIOUS_RESOURCE_PROPERTIES_KEY)); + Assertions.assertEquals(UPDATED_TEST_RESOURCE_PROPERTIES_MAP, + targetModel.getOrDefault(PREVIOUS_RESOURCE_PROPERTIES_KEY, UPDATED_TEST_RESOURCE_PROPERTIES_MAP)); + } + + @Test + public void testHookTarget_getOrDefault() { + final TestResourceHookTargetModel targetModel = HookTargetModel.of(TEST_TARGET_MODEL_MAP, + TestResourceHookTargetModel.class); + + final TestResource target = targetModel.getResourceProperties(); + Assertions.assertNotNull(target); + Assertions.assertTrue(target.hasProperty(TEST_PROPERTY_1_KEY)); + Assertions.assertEquals(TEST_PROPERTY_1, target.getProperty1()); + Assertions.assertEquals(TEST_PROPERTY_1, target.get(TEST_PROPERTY_1_KEY)); + + final String nonExistKey = "DoNotExistKey"; + final String nonExistValue = "DoNotExistValue"; + Assertions.assertFalse(target.hasProperty(nonExistKey)); + Assertions.assertNull(target.get(nonExistKey)); + Assertions.assertEquals(nonExistValue, target.getOrDefault(nonExistKey, nonExistValue)); + } + + @Test + public void testGeneralHookTargetModel() { + final Map genericTarget = ImmutableMap.of("TargetProperty1", "TargetPropertyVal"); + final Map genericTargetModel = ImmutableMap.of("TargetProperties", genericTarget); + + final HookTargetModel targetModel = HookTargetModel.of(genericTargetModel); + Assertions.assertNotNull(targetModel); + Assertions.assertEquals(genericTargetModel, targetModel.getTargetModel()); + Assertions.assertEquals(genericTarget, targetModel.get("TargetProperties")); + } + + @Test + public void testTypedResourceHookTargetModel() { + final TestResourceHookTargetModel targetModel = HookTargetModel.of(TEST_TARGET_MODEL_MAP, + TestResourceHookTargetModel.class); + assertHookTargetProperties(targetModel); + } + + @Test + public void testTypedResourceHookTargetModel_forUpdateHookTarget() { + final TestResourceHookTargetModel targetModel = HookTargetModel.of(UPDATED_TEST_TARGET_MODEL_MAP, + TestResourceHookTargetModel.class); + assertHookTargetProperties_forUpdateHook(targetModel); + } + + @Test + public void testTypedResourceHookTargetModel_usingTypeReference() { + final TestResourceHookTargetModel targetModel = HookTargetModel.of(TEST_TARGET_MODEL_MAP, TEST_TYPE_REFERENCE); + assertHookTargetProperties(targetModel); + } + + @Test + public void testTypedResourceHookTargetModel_forUpdateHookTarget_usingTypeReference() { + final TestResourceHookTargetModel targetModel = HookTargetModel.of(UPDATED_TEST_TARGET_MODEL_MAP, TEST_TYPE_REFERENCE); + assertHookTargetProperties_forUpdateHook(targetModel); + } + + @Test + public void testTypedResourceHookTargetModel_fromJson() { + final TestResourceHookTargetModel targetModel = HookTargetModel.of(TEST_TARGET_MODEL_JSON, TEST_TYPE_REFERENCE); + assertHookTargetProperties(targetModel); + } + + @Test + public void testTypedResourceHookTargetModel_forUpdateHookTarget_fromJson() { + final TestResourceHookTargetModel targetModel = HookTargetModel.of(UPDATED_TEST_TARGET_MODEL_JSON, TEST_TYPE_REFERENCE); + assertHookTargetProperties_forUpdateHook(targetModel); + } + + @Test + public void testHookTargetModelToTypedTargetModel() { + final HookTargetModel targetModel = HookTargetModel.of(TEST_TARGET_MODEL_MAP); + final TestResourceHookTargetModel typedTargetModel = HookTargetModel.of(targetModel, TestResourceHookTargetModel.class); + assertHookTargetProperties(typedTargetModel); + } + + @Test + public void testHookTargetModelWithAdditionalProperties() throws Exception { + final Map genericTargetMap = ImmutableMap.of("UnknownKey1", "UnknownValue", "UnknownKey2", 42); + final Map targetModelMap = ImmutableMap.of(RESOURCE_PROPERTIES_KEY, genericTargetMap); + + final ResourceHookTargetModel< + GenericTestResource> targetModel = HookTargetModel.of(targetModelMap, GenericTestResourceHookTargetModel.class); + + final GenericTestResource resourceProperties = targetModel.getResourceProperties(); + Assertions.assertEquals(genericTargetMap, resourceProperties.getProperties()); + Assertions.assertEquals(genericTargetMap, resourceProperties.getAdditionalProperties()); + Assertions.assertEquals("UnknownValue", resourceProperties.get("UnknownKey1")); + Assertions.assertEquals(42, resourceProperties.get("UnknownKey2")); + Assertions.assertEquals( + "{\"ResourceProperties\":{\"UnknownKey2\":42,\"UnknownKey1\":\"UnknownValue\"},\"PreviousResourceProperties\":null}", + OBJECT_MAPPER.writeValueAsString(targetModel)); + Assertions.assertEquals("{\"UnknownKey2\":42,\"UnknownKey1\":\"UnknownValue\"}", + OBJECT_MAPPER.writeValueAsString(resourceProperties)); + } + + @Test + public void testHookTargetTypeWithNullValue() { + final HookTargetModel targetModel = HookTargetModel.of(null); + Assertions.assertNull(targetModel); + TestResourceHookTargetModel typedTargetModel = HookTargetModel.of(null, TestResourceHookTargetModel.class); + Assertions.assertNull(typedTargetModel); + typedTargetModel = HookTargetModel.of(null, TEST_TYPE_REFERENCE); + Assertions.assertNull(typedTargetModel); + } + + @Test + public void testNonConcreteHookTargetModelType_shouldThrowException() { + Stream.of(HookTargetModel.class, ResourceHookTargetModel.class, new HookTargetModel() { + protected HookTargetType getHookTargetType() { + return null; + } + + protected TypeReference getHookTargetTypeReference() { + return null; + } + + protected TypeReference getTargetModelTypeReference() { + return null; + } + }.getClass()).forEach(clazz -> { + org.assertj.core.api.Assertions.assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> HookTargetModel.of(TEST_TARGET_MODEL_MAP, clazz)) + .withMessageContaining("Target Model can only be converted to concrete types."); + }); + } + + @Test + public void testCreateHookTarget_fromMapWithNullKey() { + final Map map = new HashMap<>(); + map.put(RESOURCE_PROPERTIES_KEY, TEST_RESOURCE_PROPERTIES_MAP); + map.put(null, UPDATED_TEST_RESOURCE_PROPERTIES_MAP); + + org.assertj.core.api.Assertions.assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> HookTargetModel.of(map, TestResourceHookTargetModel.class)).withMessageContaining("Null key"); + } + + @Test + public void testGetTargetModelAsJsonObject() { + final JSONObject expected = new JSONObject(TEST_TARGET_MODEL_MAP); + final TestResourceHookTargetModel targetModel = HookTargetModel.of(TEST_TARGET_MODEL_MAP, + TestResourceHookTargetModel.class); + Assertions.assertEquals(expected.toMap(), targetModel.getTargetModelAsJSONObject().toMap()); + } + + @Test + public void testGetTargetModelAsMap() { + final TestResourceHookTargetModel targetModel = HookTargetModel.of(TEST_TARGET_MODEL_MAP, + TestResourceHookTargetModel.class); + Assertions.assertEquals(TEST_TARGET_MODEL_MAP, targetModel.getTargetModelAsMap()); + } + + @Test + public void testExtractTargetModelFromNull() { + Assertions.assertEquals(Collections.emptyMap(), HookTargetModel.extractRawTargetModel(null)); + } + + @Test + public void testDeserializeHookTargetModel() throws Exception { + final String json = "{\"TargetProperties\":{\"TargetProperty1\":\"TargetPropertyVal\"}}"; + final Map genericTarget = ImmutableMap.of("TargetProperty1", "TargetPropertyVal"); + final Map genericTargetModel = ImmutableMap.of("TargetProperties", genericTarget); + final HookTargetModel expected = HookTargetModel.of(genericTargetModel); + + final HookTargetModel targetModel = OBJECT_MAPPER.readValue(json, HookTargetModel.class); + Assertions.assertEquals(expected, targetModel); + } + + @Test + public void testSerializeHookTargetModel() throws Exception { + final String expected = "{\"TargetProperties\":{\"TargetProperty1\":\"TargetPropertyVal\"}}"; + + final Map genericTarget = ImmutableMap.of("TargetProperty1", "TargetPropertyVal"); + final Map genericTargetModel = ImmutableMap.of("TargetProperties", genericTarget); + + final HookTargetModel targetModel = HookTargetModel.of(genericTargetModel); + final String json = OBJECT_MAPPER.writeValueAsString(targetModel); + Assertions.assertEquals(expected, json); + } + + @Test + public void testDeserializeGeneralHookTargetModel() throws Exception { + final String json = "{\"TargetProperties\":{\"TargetProperty1\":\"TargetPropertyVal\"}}"; + final Map genericTarget = ImmutableMap.of("TargetProperty1", "TargetPropertyVal"); + final Map genericTargetModel = ImmutableMap.of("TargetProperties", genericTarget); + final HookTargetModel expected = HookTargetModel.of(genericTargetModel); + + final GenericHookTargetModel targetModel = OBJECT_MAPPER.readValue(json, GenericHookTargetModel.class); + Assertions.assertEquals(expected, targetModel); + } + + @Test + public void testSerializeGeneralHookTargetModel() throws Exception { + final String expected = "{\"TargetProperties\":{\"TargetProperty1\":\"TargetPropertyVal\"}}"; + + final Map genericTarget = ImmutableMap.of("TargetProperty1", "TargetPropertyVal"); + final Map genericTargetModel = ImmutableMap.of("TargetProperties", genericTarget); + + final GenericHookTargetModel targetModel = HookTargetModel.of(genericTargetModel, GenericHookTargetModel.class); + final String json = OBJECT_MAPPER.writeValueAsString(targetModel); + Assertions.assertEquals(expected, json); + } + + @Test + public void testDeserializeResourceHookTargetModel() throws Exception { + final TestResourceHookTargetModel expected = HookTargetModel.of(TEST_TARGET_MODEL_MAP, TestResourceHookTargetModel.class); + final TestResourceHookTargetModel targetModel = OBJECT_MAPPER.readValue(TEST_TARGET_MODEL_JSON, + TestResourceHookTargetModel.class); + Assertions.assertEquals(expected, targetModel); + } + + @Test + public void testSerializeResourceHookTargetModel() throws Exception { + final TestResourceHookTargetModel targetModel = HookTargetModel.of(TEST_TARGET_MODEL_MAP, + TestResourceHookTargetModel.class); + + final String json = OBJECT_MAPPER.writeValueAsString(targetModel); + Assertions.assertEquals(TEST_TARGET_MODEL_JSON, json); + } + + @Test + public void testLoadSchema() { + final String emptySchema = "{}"; + final String testSchema = Objects.requireNonNull(loadFileStream("test-target-schema.json")); + final JSONObject expected = new JSONObject(new JSONTokener(testSchema)); + + // Testing schemas that are invalid, should result in empty schema. + Stream.of(null, "null", "", " ", "{", "}", "{{\"key\": \"val\"}", "test-target-schema.yaml", "does-not-exist.json") + .forEach(s -> { + final JSONObject result = ResourceHookTarget.loadSchema(s, TestResource.class); + Assertions.assertNotNull(result); + Assertions.assertEquals(0, result.length()); + Assertions.assertEquals(emptySchema, result.toString()); + Assertions.assertEquals(Collections.emptyMap(), result.toMap()); + }); + + // Testing valid schema. Either provided as string or read from file + Stream.of(testSchema, "test-target-schema.json").forEach(s -> { + final JSONObject result = ResourceHookTarget.loadSchema(s, TestResource.class); + Assertions.assertNotNull(result); + Assertions.assertEquals(expected.toString(), result.toString()); + }); + } + + @SuppressWarnings("SameParameterValue") + private static String loadFileStream(final String fileName) { + final File file = new File(String.format(TEST_DATA_BASE_PATH, fileName)); + try { + return IOUtils.toString(new FileInputStream(file), StandardCharsets.UTF_8); + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + private void assertHookTargetProperties(final TestResourceHookTargetModel targetModel) { + Assertions.assertNotNull(targetModel); + Assertions.assertEquals(HookTargetType.RESOURCE, targetModel.getHookTargetType()); + Assertions.assertEquals(TEST_TARGET_MODEL, targetModel); + Assertions.assertEquals(TEST_TARGET_MODEL_MAP, targetModel.getTargetModel()); + + final TestResource resourceProperties = targetModel.getResourceProperties(); + Assertions.assertNotNull(resourceProperties); + Assertions.assertEquals(HookTargetType.RESOURCE, resourceProperties.getHookTargetType()); + Assertions.assertEquals(TEST_RESOURCE, resourceProperties); + Assertions.assertEquals(TEST_RESOURCE_PROPERTIES_MAP, targetModel.get(RESOURCE_PROPERTIES_KEY)); + Assertions.assertEquals(TEST_PROPERTY_1, resourceProperties.getProperty1()); + Assertions.assertEquals(TEST_PROPERTY_2, resourceProperties.getProperty2()); + Assertions.assertEquals(TEST_TAGS, resourceProperties.getTags()); + + Assertions.assertEquals(TEST_RESOURCE_PROPERTIES_MAP, resourceProperties.getProperties()); + Assertions.assertTrue(resourceProperties.hasProperty(TEST_PROPERTY_1_KEY)); + Assertions.assertEquals(TEST_PROPERTY_1, resourceProperties.get(TEST_PROPERTY_1_KEY)); + Assertions.assertTrue(resourceProperties.hasProperty(TEST_PROPERTY_2_KEY)); + Assertions.assertEquals(TEST_PROPERTY_2, resourceProperties.get(TEST_PROPERTY_2_KEY)); + Assertions.assertTrue(resourceProperties.hasProperty(TEST_TAGS_KEY)); + Assertions.assertEquals(TEST_TAGS, resourceProperties.get(TEST_TAGS_KEY)); + } + + private void assertHookTargetProperties_forUpdateHook(final TestResourceHookTargetModel targetModel) { + Assertions.assertNotNull(targetModel); + Assertions.assertEquals(HookTargetType.RESOURCE, targetModel.getHookTargetType()); + Assertions.assertEquals(UPDATED_TARGET_MODEL, targetModel); + + final TestResource resourceProperties = targetModel.getResourceProperties(); + Assertions.assertNotNull(resourceProperties); + Assertions.assertEquals(HookTargetType.RESOURCE, resourceProperties.getHookTargetType()); + Assertions.assertEquals(UPDATED_TEST_RESOURCE, resourceProperties); + Assertions.assertEquals(UPDATED_TEST_RESOURCE_PROPERTIES_MAP, targetModel.get(RESOURCE_PROPERTIES_KEY)); + Assertions.assertEquals(UPDATED_TEST_PROPERTY_1, resourceProperties.getProperty1()); + Assertions.assertEquals(TEST_PROPERTY_2, resourceProperties.getProperty2()); + Assertions.assertEquals(TEST_TAGS, resourceProperties.getTags()); + + Assertions.assertEquals(UPDATED_TEST_RESOURCE_PROPERTIES_MAP, resourceProperties.getProperties()); + Assertions.assertTrue(resourceProperties.hasProperty(TEST_PROPERTY_1_KEY)); + Assertions.assertEquals(UPDATED_TEST_PROPERTY_1, resourceProperties.get(TEST_PROPERTY_1_KEY)); + Assertions.assertTrue(resourceProperties.hasProperty(TEST_PROPERTY_2_KEY)); + Assertions.assertEquals(TEST_PROPERTY_2, resourceProperties.get(TEST_PROPERTY_2_KEY)); + Assertions.assertTrue(resourceProperties.hasProperty(TEST_TAGS_KEY)); + Assertions.assertEquals(TEST_TAGS, resourceProperties.get(TEST_TAGS_KEY)); + + final TestResource previousResourceProperties = targetModel.getPreviousResourceProperties(); + Assertions.assertNotNull(previousResourceProperties); + Assertions.assertEquals(HookTargetType.RESOURCE, previousResourceProperties.getHookTargetType()); + Assertions.assertEquals(TEST_RESOURCE, previousResourceProperties); + Assertions.assertEquals(TEST_RESOURCE_PROPERTIES_MAP, targetModel.get(PREVIOUS_RESOURCE_PROPERTIES_KEY)); + Assertions.assertEquals(TEST_PROPERTY_1, previousResourceProperties.getProperty1()); + Assertions.assertEquals(TEST_PROPERTY_2, previousResourceProperties.getProperty2()); + Assertions.assertEquals(TEST_TAGS, previousResourceProperties.getTags()); + + Assertions.assertEquals(TEST_RESOURCE_PROPERTIES_MAP, previousResourceProperties.getProperties()); + Assertions.assertTrue(previousResourceProperties.hasProperty(TEST_PROPERTY_1_KEY)); + Assertions.assertEquals(TEST_PROPERTY_1, previousResourceProperties.get(TEST_PROPERTY_1_KEY)); + Assertions.assertTrue(previousResourceProperties.hasProperty(TEST_PROPERTY_2_KEY)); + Assertions.assertEquals(TEST_PROPERTY_2, previousResourceProperties.get(TEST_PROPERTY_2_KEY)); + Assertions.assertTrue(previousResourceProperties.hasProperty(TEST_TAGS_KEY)); + Assertions.assertEquals(TEST_TAGS, previousResourceProperties.get(TEST_TAGS_KEY)); + } +} diff --git a/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/TestResource.java b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/TestResource.java new file mode 100644 index 00000000..eccd719b --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/TestResource.java @@ -0,0 +1,86 @@ +/* +* 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.targetmodel; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.json.JSONObject; + +@Data +@Builder +@Setter(AccessLevel.PRIVATE) +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +@ToString +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, setterVisibility = JsonAutoDetect.Visibility.NONE) +public class TestResource extends ResourceHookTarget { + @JsonIgnore + public static final String TYPE_NAME = "Test::Resource::Type"; + + @JsonProperty("Property1") + private String property1; + @JsonProperty("Property2") + private Integer property2; + @JsonProperty("Tags") + private Map tags; + + @Override + @JsonIgnore + public JSONObject getPrimaryIdentifier() { + return null; + } + + @Override + @JsonIgnore + public List getAdditionalIdentifiers() { + return null; + } + + @Override + public JSONObject targetSchemaJSONObject() { + if (targetSchema == null) { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("Property1", property1); + jsonObject.put("Property1", property1); + jsonObject.put("Tags", tags); + targetSchema = loadSchema(jsonObject.toString()); + } + + return targetSchema; + } + + @Override + public Boolean hasDefinedSchema() { + return true; + } + + @JsonIgnore + @Override + public Boolean isCloudFormationRegistryType() { + return true; + } +} diff --git a/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/TestResourceHookTargetModel.java b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/TestResourceHookTargetModel.java new file mode 100644 index 00000000..d7f5bf24 --- /dev/null +++ b/src/test/java/software/amazon/cloudformation/proxy/hook/targetmodel/TestResourceHookTargetModel.java @@ -0,0 +1,56 @@ +/* +* 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.targetmodel; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, setterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonDeserialize(as = TestResourceHookTargetModel.class) +public class TestResourceHookTargetModel extends ResourceHookTargetModel { + + public TestResourceHookTargetModel(final TestResource resourceProperties) { + this(resourceProperties, null); + } + + public TestResourceHookTargetModel(final TestResource resourceProperties, + final TestResource previousResourceProperties) { + super(resourceProperties, previousResourceProperties); + } + + @Override + @JsonIgnore + public TypeReference getHookTargetTypeReference() { + return new TypeReference() { + }; + } + + @Override + @JsonIgnore + public TypeReference getTargetModelTypeReference() { + return new TypeReference() { + }; + } +} diff --git a/src/test/resources/software/amazon/cloudformation/proxy/handler/hook/hookModel.json b/src/test/resources/software/amazon/cloudformation/proxy/handler/hook/hookModel.json new file mode 100644 index 00000000..057a4b6e --- /dev/null +++ b/src/test/resources/software/amazon/cloudformation/proxy/handler/hook/hookModel.json @@ -0,0 +1,12 @@ +{ + "typeName": "AWS::CFN::Hook", + "description": "Hook Type definition for AWS::CFN::Hook", + "properties": { + "InputParameter": { + "type": "string" + } + }, + "required": [ + "InputParameter" + ] +} diff --git a/src/test/resources/software/amazon/cloudformation/proxy/hook/targetmodel/test-target-schema.json b/src/test/resources/software/amazon/cloudformation/proxy/hook/targetmodel/test-target-schema.json new file mode 100644 index 00000000..c7adfabd --- /dev/null +++ b/src/test/resources/software/amazon/cloudformation/proxy/hook/targetmodel/test-target-schema.json @@ -0,0 +1,22 @@ +{ + "typeName": "Test::Resource::Type", + "description": "a test schema", + "properties": { + "Property1": { + "type": "string" + }, + "Property2": { + "type": "integer" + }, + "Tags": { + "type": "object" + } + }, + "primaryIdentifier": [ + "/properties/Property1" + ], + "readOnlyProperties": [ + "/properties/Property1" + ], + "additionalProperties": false +} diff --git a/tests/data/hook-schema-with-typeconfiguration.json b/tests/data/hook-schema-with-typeconfiguration.json new file mode 100644 index 00000000..7c6647b4 --- /dev/null +++ b/tests/data/hook-schema-with-typeconfiguration.json @@ -0,0 +1,50 @@ +{ + "typeName": "Company::Test::Hook", + "description": "Test hook type", + "definitions": { + "cidrBlock": { + "$comment": "TODO: regex could be more strict, for example this allows the cidr 999.999.999.999 and 999.999.999.999/32", + "description": "Classless Inter-Domain Routing (CIDR) block", + "type": "string", + "pattern": "^([0-9]{1,3}.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$" + } + }, + "typeConfiguration": { + "properties": { + "limitSize": { + "description": "string", + "type": "string" + }, + "cidr": { + "$ref": "#/definitions/cidrBlock" + } + }, + "additionalProperties": false + }, + "required": [ + "limitSize" + ], + "handlers": { + "preCreate": { + "targetNames": [ + "AWS::S3::Bucket", + "AWS::SQS::Queue", + "AWS::AutoScaling::AutoScalingGroup", + "AWS::Route53::HealthCheck" + ], + "permissions": [ + "s3:ListBucket", + "sqs:ListQueues" + ] + }, + "preUpdate": { + "targetNames": [ + "AWS::S3::Bucket" + ], + "permissions": [ + "s3:ListBucket" + ] + } + }, + "additionalProperties": false +} diff --git a/tests/data/hook-schema-without-typeconfiguration.json b/tests/data/hook-schema-without-typeconfiguration.json new file mode 100644 index 00000000..572f2bf1 --- /dev/null +++ b/tests/data/hook-schema-without-typeconfiguration.json @@ -0,0 +1,52 @@ +{ + "typeName": "Company::Test::Hook", + "description": "Test hook type", + "definitions": { + "cidrBlock": { + "$comment": "TODO: regex could be more strict, for example this allows the cidr 999.999.999.999 and 999.999.999.999/32", + "description": "Classless Inter-Domain Routing (CIDR) block", + "type": "string", + "pattern": "^([0-9]{1,3}.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$" + } + }, + "properties": { + "limitSize": { + "description": "string", + "type": "string" + }, + "cidr": { + "$ref": "#/definitions/cidrBlock" + }, + "encryptionAlgorithm": { + "description": "Encryption algorithm for SSE", + "default": "AES256", + "type": "string" + } + }, + "required": [ + "limitSize" + ], + "handlers": { + "preCreate": { + "targetNames": [ + "AWS::S3::Bucket", + "AWS::SQS::Queue", + "AWS::AutoScaling::AutoScalingGroup", + "AWS::Route53::HealthCheck" + ], + "permissions": [ + "s3:ListBucket", + "sqs:ListQueues" + ] + }, + "preUpdate": { + "targetNames": [ + "AWS::S3::Bucket" + ], + "permissions": [ + "s3:ListBucket" + ] + } + }, + "additionalProperties": false +} diff --git a/tests/test_codegen.py b/tests/test_codegen.py index cadd3e0e..6b9745cf 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -20,6 +20,72 @@ ) RESOURCE = "DZQWCC" +HOOK = "CCWQZD" + +TEST_TARGET_INFO = { + "My::Example::Resource": { + "TargetName": "My::Example::Resource", + "TargetType": "RESOURCE", + "Schema": { + "typeName": "My::Example::Resource", + "additionalProperties": False, + "properties": { + "Id": {"type": "string"}, + "Tags": { + "type": "array", + "uniqueItems": False, + "items": {"$ref": "#/definitions/Tag"}, + }, + }, + "required": [], + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": False, + "properties": { + "Value": {"type": "string"}, + "Key": {"type": "string"}, + }, + "required": ["Value", "Key"], + } + }, + }, + "ProvisioningType": "FULLY_MUTTABLE", + "IsCfnRegistrySupportedType": True, + "SchemaFileAvailable": True, + }, + "My::Other::Resource": { + "TargetName": "My::Other::Resource", + "TargetType": "RESOURCE", + "Schema": { + "typeName": "My::Other::Resource", + "additionalProperties": False, + "properties": { + "Id": {"type": "string"}, + "Tags": { + "type": "array", + "uniqueItems": False, + "items": {"$ref": "#/definitions/Tag"}, + }, + }, + "required": [], + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": False, + "properties": { + "Value": {"type": "string"}, + "Key": {"type": "string"}, + }, + "required": ["Value", "Key"], + } + }, + }, + "ProvisioningType": "NOT_PROVISIONABLE", + "IsCfnRegistrySupportedType": False, + "SchemaFileAvailable": True, + }, +} @pytest.fixture(params=["1", "2"]) @@ -42,43 +108,84 @@ def mock_input_with_validation(prompt, validate): # pylint: disable=unused-argu return project +@pytest.fixture(params=["1", "2"]) +def hook_project(tmpdir, request): + def mock_input_with_validation(prompt, validate): # pylint: disable=unused-argument + if prompt.startswith("Enter a package name"): + return ("software", "amazon", "foo", HOOK.lower()) + if prompt.startswith("Choose codegen model"): + return request.param + return "" + + hook_project = Project(root=tmpdir) + mock_cli = MagicMock(side_effect=mock_input_with_validation) + with patch.dict( + "rpdk.core.plugin_registry.PLUGIN_REGISTRY", + {"test": lambda: JavaLanguagePlugin}, + clear=True, + ), patch("rpdk.java.codegen.input_with_validation", new=mock_cli): + hook_project.init_hook("AWS::Foo::{}".format(RESOURCE), "test") + return hook_project + + def test_java_language_plugin_module_is_set(): plugin = JavaLanguagePlugin() assert plugin.MODULE_NAME def test_initialize(project): - assert (project.root / "README.md").is_file() + expected_group_id = "software.amazon.foo.{}".format(RESOURCE.lower()) + handler = "{}.HandlerWrapper::handleRequest".format(expected_group_id) + assert_test_initialize(project, handler, expected_group_id) + - pom_tree = ET.parse(str(project.root / "pom.xml")) +def test_hook_initialize(hook_project): + expected_group_id = "software.amazon.foo.{}".format(HOOK.lower()) + handler = "{}.HookHandlerWrapper::handleRequest".format(expected_group_id) + assert_test_initialize(hook_project, handler, expected_group_id) + + +def assert_test_initialize( + test_project, handler, expected_group_id +): # pylint: disable=protected-access + assert (test_project.root / "README.md").is_file() + + pom_tree = ET.parse(str(test_project.root / "pom.xml")) namespace = {"maven": "http://maven.apache.org/POM/4.0.0"} actual_group_id = pom_tree.find("maven:groupId", namespace) - expected_group_id = "software.amazon.foo.{}".format(RESOURCE.lower()) assert actual_group_id.text == expected_group_id - path = project.root / "template.yml" + path = test_project.root / "template.yml" with path.open("r", encoding="utf-8") as f: template = yaml.safe_load(f) - handler_properties = template["Resources"]["TypeFunction"]["Properties"] - - code_uri = "./target/{}-handler-1.0-SNAPSHOT.jar".format(project.hypenated_name) + code_uri = "./target/{}-handler-1.0-SNAPSHOT.jar".format( + test_project.hypenated_name + ) assert handler_properties["CodeUri"] == code_uri - handler = "{}.HandlerWrapper::handleRequest".format(expected_group_id) assert handler_properties["Handler"] == handler - assert handler_properties["Runtime"] == project._plugin.RUNTIME + assert handler_properties["Runtime"] == test_project._plugin.RUNTIME def test_generate(project): project.load_schema() + assert_generate_test(project, RESOURCE) - generated_root = project._plugin._get_generated_root(project) - generated_tests_root = project._plugin._get_generated_tests_root(project) + +def test_hook_generate(hook_project): + with patch.object(hook_project, "_load_target_info", return_value=TEST_TARGET_INFO): + hook_project.load_hook_schema() + assert_generate_test(hook_project, HOOK) + + +def assert_generate_test(test_project, test_type): # pylint: disable=protected-access + generated_root = test_project._plugin._get_generated_root(test_project) + generated_tests_root = test_project._plugin._get_generated_tests_root(test_project) # generated root shouldn't be present assert not generated_root.is_dir() assert not generated_tests_root.is_dir() - project.generate() + test_project.generate() src_file = generated_root / "test" src_file.touch() @@ -86,7 +193,7 @@ def test_generate(project): test_file = generated_tests_root / "test" test_file.touch() - project.generate() + test_project.generate() # assert TypeConfigurationModel is added to generated directory type_configuration_model_file = ( @@ -94,7 +201,7 @@ def test_generate(project): / "software" / "amazon" / "foo" - / RESOURCE.lower() + / test_type.lower() / "TypeConfigurationModel.java" ) assert type_configuration_model_file.is_file() @@ -160,6 +267,76 @@ def test_generate_with_out_type_configuration(project, tmpdir): assert not type_configuration_schema_file.is_file() +def test_hook_generate_with_type_configuration(hook_project, tmpdir): + copyfile( + str(Path.cwd() / "tests/data/hook-schema-with-typeconfiguration.json"), + str(tmpdir / "hook-schema-with-typeconfiguration.json"), + ) + + with patch.object(hook_project, "_load_target_info", return_value=TEST_TARGET_INFO): + hook_project.type_info = ("hook", "schema", "with", "typeconfiguration") + hook_project.load_hook_schema() + hook_project.load_configuration_schema() + hook_project.generate() + generated_root = hook_project._plugin._get_generated_root(hook_project) + + # assert TypeConfigurationModel is added to generated directory + type_configuration_model_file = ( + generated_root + / "software" + / "amazon" + / "foo" + / HOOK.lower() + / "TypeConfigurationModel.java" + ) + type_configuration_schema_file = ( + generated_root / "hook-schema-with-typeconfiguration-configuration.json" + ) + + assert type_configuration_model_file.is_file() + assert type_configuration_schema_file.is_file() + + +def test_hook_generate_with_non_registry_targets(hook_project, tmpdir): + copyfile( + str(Path.cwd() / "tests/data/hook-schema-with-typeconfiguration.json"), + str(tmpdir / "hook-schema-with-typeconfiguration.json"), + ) + + test_target_info = dict(TEST_TARGET_INFO) + test_target_info["My::Unreleased::Resource"] = { + "TargetName": "My::Unreleased::Resource", + "TargetType": "RESOURCE", + "Schema": {}, + "ProvisioningType": "NOT_PROVISIONABLE", + "IsCfnRegistrySupportedType": False, + "SchemaFileAvailable": False, + } + + with patch.object(hook_project, "_load_target_info", return_value=test_target_info): + hook_project.type_info = ("hook", "schema", "with", "typeconfiguration") + hook_project.load_hook_schema() + hook_project.load_configuration_schema() + hook_project.generate() + generated_root = hook_project._plugin._get_generated_root(hook_project) + + # assert TypeConfigurationModel is added to generated directory + non_registry_target_model_file = ( + generated_root + / "software" + / "amazon" + / "foo" + / HOOK.lower() + / "model" + / "my" + / "unreleased" + / "resource" + / "MyUnreleasedResourceTargetModel.java" + ) + + assert non_registry_target_model_file.is_file() + + def test_protocol_version_is_set(project): assert project.settings["protocolVersion"] == "2.0.0" @@ -274,7 +451,7 @@ def test__prompt_for_namespace_aws_default(): project = Mock(type_info=("AWS", "Clown", "Service"), settings={}) plugin = JavaLanguagePlugin() - with patch("rpdk.core.init.input", return_value="") as mock_input: + with patch("rpdk.core.utils.init_utils.input", return_value="") as mock_input: plugin._prompt_for_namespace(project) mock_input.assert_called_once() @@ -287,7 +464,7 @@ def test__prompt_for_namespace_aws_overwritten(): plugin = JavaLanguagePlugin() with patch( - "rpdk.core.init.input", return_value="com.red.clown.service" + "rpdk.core.utils.init_utils.input", return_value="com.red.clown.service" ) as mock_input: plugin._prompt_for_namespace(project) @@ -300,7 +477,7 @@ def test__prompt_for_namespace_other_default(): project = Mock(type_info=("Balloon", "Clown", "Service"), settings={}) plugin = JavaLanguagePlugin() - with patch("rpdk.core.init.input", return_value="") as mock_input: + with patch("rpdk.core.utils.init_utils.input", return_value="") as mock_input: plugin._prompt_for_namespace(project) mock_input.assert_called_once() @@ -313,7 +490,7 @@ def test__prompt_for_namespace_other_overwritten(): plugin = JavaLanguagePlugin() with patch( - "rpdk.core.init.input", return_value="com.ball.clown.service" + "rpdk.core.utils.init_utils.input", return_value="com.ball.clown.service" ) as mock_input: plugin._prompt_for_namespace(project) @@ -345,7 +522,7 @@ def test__prompt_for_codegen_model_no_selection(): project = Mock(type_info=("AWS", "Clown", "Service"), settings={}) plugin = JavaLanguagePlugin() - with patch("rpdk.core.init.input", return_value="") as mock_input: + with patch("rpdk.core.utils.init_utils.input", return_value="") as mock_input: plugin._prompt_for_codegen_model(project) mock_input.assert_called_once() @@ -357,7 +534,7 @@ def test__prompt_for_codegen_model_default(): project = Mock(type_info=("AWS", "Clown", "Service"), settings={}) plugin = JavaLanguagePlugin() - with patch("rpdk.core.init.input", return_value="1") as mock_input: + with patch("rpdk.core.utils.init_utils.input", return_value="1") as mock_input: plugin._prompt_for_codegen_model(project) mock_input.assert_called_once() @@ -369,7 +546,7 @@ def test__prompt_for_codegen_model_guided_aws(): project = Mock(type_info=("AWS", "Clown", "Service"), settings={}) plugin = JavaLanguagePlugin() - with patch("rpdk.core.init.input", return_value="2") as mock_input: + with patch("rpdk.core.utils.init_utils.input", return_value="2") as mock_input: plugin._prompt_for_codegen_model(project) mock_input.assert_called_once() @@ -405,6 +582,19 @@ def test_generate_executable_entrypoint_not_specified(project): ) +def test_generate_hook_executable_entrypoint_not_specified(hook_project): + hook_project.executable_entrypoint = None + with patch.object(hook_project, "_load_target_info", return_value=TEST_TARGET_INFO): + hook_project.generate() + plugin = JavaLanguagePlugin() + plugin._namespace_from_project(hook_project) + + assert ( + hook_project.executable_entrypoint + == plugin.package_name + ".HookHandlerWrapperExecutable" + ) + + def test_generate_executable_entrypoint_old_project_version(project): # If the cli version does not contain the new executable_entrypoint # we will not add it