diff --git a/setup.cfg b/setup.cfg index 4933275c..d401f8a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,7 @@ include_trailing_comma = true combine_as_imports = True force_grid_wrap = 0 known_first_party = rpdk -known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,jinja2,jsonschema,nested_lookup,ordered_set,pkg_resources,pytest,pytest_localserver,requests,setuptools,yaml +known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,jinja2,jsonpatch,jsonschema,nested_lookup,ordered_set,pkg_resources,pytest,pytest_localserver,requests,setuptools,yaml [tool:pytest] # can't do anything about 3rd part modules, so don't spam us diff --git a/setup.py b/setup.py index 03c9532d..85295e57 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ def find_version(*file_paths): "boto3>=1.10.20", "Jinja2>=3.1.2", "markupsafe>=2.1.0", + "jsonpatch", "jsonschema>=3.0.0,<=4.17.3", "pytest>=4.5.0", "pytest-random-order>=1.0.4", @@ -56,6 +57,7 @@ def find_version(*file_paths): "cfn_flip>=1.2.3", "nested-lookup", "botocore>=1.31.17", + "resource-schema-guard-rail>=0.0.12", ], entry_points={ "console_scripts": ["cfn-cli = rpdk.core.cli:main", "cfn = rpdk.core.cli:main"] diff --git a/src/rpdk/core/__init__.py b/src/rpdk/core/__init__.py index d1855829..3aab09fa 100644 --- a/src/rpdk/core/__init__.py +++ b/src/rpdk/core/__init__.py @@ -1,5 +1,5 @@ import logging -__version__ = "0.2.36" +__version__ = "0.2.38" logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/src/rpdk/core/boto_helpers.py b/src/rpdk/core/boto_helpers.py index c4f326b8..81290daf 100644 --- a/src/rpdk/core/boto_helpers.py +++ b/src/rpdk/core/boto_helpers.py @@ -53,7 +53,6 @@ def inject_confused_deputy_headers(params, **kwargs): params["headers"]["x-amz-source-arn"] = headers["source_arn"] sts_client.meta.events.register("before-call", inject_confused_deputy_headers) - LOG.info(headers) if role_arn: session_name = f"CloudFormationContractTest-{datetime.now():%Y%m%d%H%M%S}" try: diff --git a/src/rpdk/core/data/schema/base.definition.schema.v1.json b/src/rpdk/core/data/schema/base.definition.schema.v1.json index b5b844dc..df2fcbd9 100644 --- a/src/rpdk/core/data/schema/base.definition.schema.v1.json +++ b/src/rpdk/core/data/schema/base.definition.schema.v1.json @@ -70,6 +70,38 @@ "AttributeList" ] }, + "relationshipRef": { + "$comment": "The relationshipRef relate a property in the resource to that in another resource", + "type": "object", + "properties": { + "typeName": { + "$comment": "Name of the related resource", + "type": "string", + "pattern": "^[a-zA-Z0-9]{2,64}::[a-zA-Z0-9]{2,64}::[a-zA-Z0-9]{2,64}$" + }, + "propertyPath": { + "$comment": "Path of the property in the related resource schema", + "type": "string", + "pattern": "^(\/properties\/)[A-Za-z0-9]*$" + }, + "publisherId": { + "$comment": "Id of the related third party resource publisher", + "type": "string", + "pattern": "[0-9a-zA-Z]{12,40}" + }, + "majorVersion": { + "$comment": "Major version of the related resource", + "type": "integer", + "minimum": 1, + "maximum": 10000 + } + }, + "required": [ + "typeName", + "propertyPath" + ], + "additionalProperties": false + }, "$ref": { "$ref": "http://json-schema.org/draft-07/schema#/properties/$ref" }, diff --git a/src/rpdk/core/data_loaders.py b/src/rpdk/core/data_loaders.py index 38a6d79b..b4b8b752 100644 --- a/src/rpdk/core/data_loaders.py +++ b/src/rpdk/core/data_loaders.py @@ -3,6 +3,7 @@ import os import re import shutil +from copy import deepcopy from io import TextIOWrapper from pathlib import Path @@ -12,6 +13,9 @@ from jsonschema.exceptions import RefResolutionError, ValidationError from nested_lookup import nested_lookup +from rpdk.guard_rail.core.data_types import Stateful, Stateless +from rpdk.guard_rail.core.runner import exec_compliance + from .exceptions import InternalError, SpecValidationError from .jsonutils.flattener import JsonSchemaFlattener from .jsonutils.inliner import RefInliner @@ -21,6 +25,7 @@ TIMEOUT_IN_SECONDS = 10 STDIN_NAME = "" +MAX_CONFIGURATION_SCHEMA_LENGTH = 60 * 1024 # 60 KiB def resource_stream(package_name, resource_name, encoding="utf-8"): @@ -144,18 +149,45 @@ def get_file_base_uri(file): return path.resolve().as_uri() -def load_resource_spec(resource_spec_file): # pylint: disable=R # noqa: C901 +def sgr_stateless_eval(schema): + schema_copy = deepcopy(schema) + result = exec_compliance(Stateless([schema_copy], []))[0] + result.display() + if result.non_compliant.items() != {}: + LOG.warning("Issues detected: please see the schema compliance report above") + + +def sgr_stateful_eval(schema, original_schema): + result = exec_compliance( + Stateful(current_schema=schema, previous_schema=original_schema, rules=[]) + )[0] + result.display() + if result.non_compliant.items() != {}: + LOG.warning("Issues detected: please see the schema compliance report above\n") + + +def load_resource_spec( # pylint: disable=R # noqa: C901 + resource_spec_file, original_schema_raw=None +): """Load a resource provider definition from a file, and validate it.""" try: resource_spec = json.load(resource_spec_file) + except ValueError as e: LOG.debug("Resource spec decode failed", exc_info=True) raise SpecValidationError(str(e)) from e + # check TypeConfiguration schema size + if len(json.dumps(resource_spec).encode("utf-8")) > MAX_CONFIGURATION_SCHEMA_LENGTH: + raise SpecValidationError( + "TypeConfiguration schema exceeds maximum length of 60 KiB" + ) + validator = make_resource_validator() additional_properties_validator = ( make_resource_validator_with_additional_properties_check() ) + try: validator.validate(resource_spec) except ValidationError as e: @@ -382,6 +414,23 @@ def load_resource_spec(resource_spec_file): # pylint: disable=R # noqa: C901 LOG.debug("Inlined schema is no longer valid", exc_info=True) raise InternalError() from e + LOG.warning( + "Resource schema metadata is valid. Running a schema compliance evaluation:\n" + ) + + # Run SGR checks once Schema Metadata is checked + original_resource_spec = None + if original_schema_raw: + print( + "Type Exists in CloudFormation Registry. " + "Evaluating Resource Schema Backward Compatibility Compliance", + ) + original_resource_spec = json.loads(original_schema_raw) + sgr_stateful_eval(resource_spec, original_resource_spec) + + print("Evaluating Resource Schema Compliance") + sgr_stateless_eval(resource_spec) + return inlined diff --git a/src/rpdk/core/generate.py b/src/rpdk/core/generate.py index 58383055..3ca00f57 100644 --- a/src/rpdk/core/generate.py +++ b/src/rpdk/core/generate.py @@ -11,7 +11,7 @@ def generate(args): project = Project() - project.load() + project.load(args) project.generate( args.endpoint_url, args.region, @@ -20,7 +20,9 @@ def generate(args): args.profile, ) project.generate_docs() - + project.generate_canary_files( + args.local_code_generation, + ) LOG.warning("Generated files for %s", project.type_name) @@ -38,3 +40,8 @@ def setup_subparser(subparsers, parents): "--target-schemas", help="Path to target schemas.", nargs="*", default=[] ) parser.add_argument("--profile", help="AWS profile to use.") + parser.add_argument( + "--local-code-generation", + action="store_true", + help="Enable local code generation.", + ) diff --git a/src/rpdk/core/project.py b/src/rpdk/core/project.py index 1649bdd8..d2082a67 100644 --- a/src/rpdk/core/project.py +++ b/src/rpdk/core/project.py @@ -2,13 +2,17 @@ import json import logging import os +import re import shutil import sys import zipfile from pathlib import Path from tempfile import TemporaryFile +from typing import Any, Dict from uuid import uuid4 +import jsonpatch +import yaml from botocore.exceptions import ClientError, WaiterError from jinja2 import Environment, PackageLoader, select_autoescape from jsonschema import Draft7Validator @@ -56,12 +60,45 @@ ARTIFACT_TYPE_RESOURCE = "RESOURCE" ARTIFACT_TYPE_MODULE = "MODULE" ARTIFACT_TYPE_HOOK = "HOOK" - +TARGET_CANARY_ROOT_FOLDER = "canary-bundle" +TARGET_CANARY_FOLDER = "canary-bundle/canary" +RPDK_CONFIG_FILE = ".rpdk-config" +CANARY_FILE_PREFIX = "canary" +CANARY_FILE_CREATE_SUFFIX = "001" +CANARY_FILE_UPDATE_SUFFIX = "002" +CANARY_SUPPORTED_PATCH_INPUT_OPERATIONS = {"replace", "remove", "add"} +CREATE_INPUTS_KEY = "CreateInputs" +PATCH_INPUTS_KEY = "PatchInputs" +PATCH_VALUE_KEY = "value" +PATCH_OPERATION_KEY = "op" +CONTRACT_TEST_DEPENDENCY_FILE_NAME = "dependencies.yml" +CANARY_DEPENDENCY_FILE_NAME = "bootstrap.yaml" +CANARY_SETTINGS = "canarySettings" +TYPE_NAME = "typeName" +CONTRACT_TEST_FILE_NAMES = "contract_test_file_names" +INPUT1_FILE_NAME = "inputs_1.json" +CONTRACT_TEST_FOLDER = "contract-tests-artifacts" +CONTRACT_TEST_INPUT_PREFIX = "inputs_*" +CONTRACT_TEST_DEPENDENCY_FILE_NAME = "dependencies.yml" +TYPE_NAME = "typeName" +CONTRACT_TEST_FILE_NAMES = "contract_test_file_names" +FN_SUB = "Fn::Sub" +FN_IMPORT_VALUE = "Fn::ImportValue" +UUID = "uuid" +DYNAMIC_VALUES_MAP = { + "region": "${AWS::Region}", + "partition": "${AWS::Partition}", + "account": "${AWS::AccountId}", +} DEFAULT_ROLE_TIMEOUT_MINUTES = 120 # 2 hours # min and max are according to CreateRole API restrictions # https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateRole.html MIN_ROLE_TIMEOUT_SECONDS = 3600 # 1 hour MAX_ROLE_TIMEOUT_SECONDS = 43200 # 12 hours +MAX_RPDK_CONFIG_LENGTH = 10 * 1024 # 10 KiB +MAX_CONFIGURATION_SCHEMA_LENGTH = 60 * 1024 # 60 KiB + +PROTOCOL_VERSION_VALUES = frozenset({"1.0.0", "2.0.0"}) CFN_METADATA_FILENAME = ".cfn_metadata.json" @@ -145,6 +182,7 @@ def __init__(self, overwrite_enabled=False, root=None): self.test_entrypoint = None self.executable_entrypoint = None self.fragment_dir = None + self.canary_settings = {} self.target_info = {} self.env = Environment( @@ -207,6 +245,32 @@ def target_schemas_path(self): def target_info_path(self): return self.root / TARGET_INFO_FILENAME + @property + def target_canary_root_path(self): + return self.root / TARGET_CANARY_ROOT_FOLDER + + @property + def target_canary_folder_path(self): + return self.root / TARGET_CANARY_FOLDER + + @property + def rpdk_config(self): + return self.root / RPDK_CONFIG_FILE + + @property + def file_generation_enabled(self): + if self.canary_settings == {}: + return False + return True + + @property + def contract_test_file_names(self): + return self.canary_settings.get(CONTRACT_TEST_FILE_NAMES, [INPUT1_FILE_NAME]) + + @property + def target_contract_test_folder_path(self): + return self.root / CONTRACT_TEST_FOLDER + @staticmethod def _raise_invalid_project(msg, e): LOG.debug(msg, exc_info=e) @@ -222,6 +286,25 @@ def load_settings(self): f"Project file '{self.settings_path}' is invalid", e ) + # check size of RPDK config + if len(json.dumps(raw_settings).encode("utf-8")) > MAX_RPDK_CONFIG_LENGTH: + raise InvalidProjectError( + f"Project file '{self.settings_path}' exceeds maximum length of 10 KiB." + ) + # validate protocol version, if specified + if "settings" in raw_settings and "protocolVersion" in raw_settings["settings"]: + protocol_version = raw_settings["settings"]["protocolVersion"] + if protocol_version not in PROTOCOL_VERSION_VALUES: + raise InvalidProjectError( + f"Invalid 'protocolVersion' settings in '{self.settings_path}" + ) + else: + LOG.warning( + "No protovolVersion found: this will default to version 1.0.0 during registration. " + "Please consider upgrading to CFN-CLI 2.0 following the guide: " + "https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/what-is-cloudformation-cli.html" + ) + # backward compatible if "artifact_type" not in raw_settings: raw_settings["artifact_type"] = ARTIFACT_TYPE_RESOURCE @@ -277,6 +360,7 @@ def validate_and_load_resource_settings(self, raw_settings): self.executable_entrypoint = raw_settings.get("executableEntrypoint") self._plugin = load_plugin(raw_settings["language"]) self.settings = raw_settings.get("settings", {}) + self.canary_settings = raw_settings.get("canarySettings", {}) def _write_example_schema(self): self.schema = resource_json( @@ -338,6 +422,7 @@ def _write_resource_settings(f): "testEntrypoint": self.test_entrypoint, "settings": self.settings, **executable_entrypoint_dict, + "canarySettings": self.canary_settings, }, f, indent=4, @@ -391,6 +476,9 @@ def init(self, type_name, language, settings=None): self.language = language self._plugin = load_plugin(language) self.settings = settings or {} + self.canary_settings = { + CONTRACT_TEST_FILE_NAMES: [INPUT1_FILE_NAME], + } self._write_example_schema() self._write_example_inputs() self._plugin.init(self) @@ -421,14 +509,38 @@ def load_hook_schema(self): with self.schema_path.open("r", encoding="utf-8") as f: self.schema = load_hook_spec(f) - def load_schema(self): + def load_schema(self, args=None): if not self.type_info: msg = "Internal error (Must load settings first)" LOG.critical(msg) raise InternalError(msg) + type_name = f"{self.type_info[0]}::{self.type_info[1]}::{self.type_info[2]}" + + original_schema = self._retrieve_global_schema(type_name, args) + with self.schema_path.open("r", encoding="utf-8") as f: - self.schema = load_resource_spec(f) + self.schema = load_resource_spec(f, original_schema) + + def _retrieve_global_schema(self, type_name, args): + try: + session = create_sdk_session(args.region, args.profile) + cfn_client = session.client( + "cloudformation", endpoint_url=args.endpoint_url + ) + _response = cfn_client.describe_type(Type="RESOURCE", TypeName=type_name) + return _response["Schema"] + except ClientError as e: + LOG.warning( + "Attempted to retrieve latest schema from registry for ResourceType %s", + type_name, + ) + LOG.warning(str(e)) + return None + # pylint: disable=broad-exception-caught + except Exception as ex: + print(str(ex)) + return None def load_configuration_schema(self): if not self.schema: @@ -546,7 +658,7 @@ def generate( self._plugin.generate(self) - def load(self): + def load(self, args=None): try: self.load_settings() except FileNotFoundError as e: @@ -561,14 +673,16 @@ def load(self): elif self.artifact_type == ARTIFACT_TYPE_HOOK: self._load_hooks_project() else: - self._load_resources_project() + self._load_resources_project(args) - def _load_resources_project(self): + def _load_resources_project(self, args): LOG.info("Validating your resource specification...") try: - self.load_schema() + self.load_schema(args) self.load_configuration_schema() - LOG.warning("Resource schema is valid.") + LOG.warning( + "\nAll resource schema validation checks have completed -- Please see output above for any errors" + ) except FileNotFoundError as e: self._raise_invalid_project("Resource schema not found.", e) except SpecValidationError as e: @@ -779,13 +893,15 @@ def generate_docs(self): target_names = ( self.target_info.keys() if self.target_info - else { - target_name - for handler in self.schema.get("handlers", {}).values() - for target_name in handler.get("targetNames", []) - } - if self.artifact_type == ARTIFACT_TYPE_HOOK - else [] + else ( + { + target_name + for handler in self.schema.get("handlers", {}).values() + for target_name in handler.get("targetNames", []) + } + if self.artifact_type == ARTIFACT_TYPE_HOOK + else [] + ) ) LOG.debug("Removing generated docs: %s", docs_path) @@ -1251,3 +1367,154 @@ def _load_target_info( ) return type_info + + def generate_canary_files(self, local_code_generation=False) -> None: + if ( + not self.file_generation_enabled + or not Path(self.target_contract_test_folder_path).exists() + or not local_code_generation + ): + LOG.info("Skipping Canary Auto-Generation") + return + LOG.info("Starting Canary Auto-Generation...") + self._setup_stack_template_environment() + self._generate_stack_template_files() + LOG.info("Finished Canary Auto-Generation") + + def _setup_stack_template_environment(self) -> None: + stack_template_root = Path(self.target_canary_root_path) + stack_template_folder = Path(self.target_canary_folder_path) + stack_template_folder.mkdir(parents=True, exist_ok=True) + dependencies_file = ( + Path(self.target_contract_test_folder_path) + / CONTRACT_TEST_DEPENDENCY_FILE_NAME + ) + bootstrap_file = stack_template_root / CANARY_DEPENDENCY_FILE_NAME + if dependencies_file.exists(): + LOG.debug("Writing: %s", bootstrap_file) + shutil.copy(str(dependencies_file), str(bootstrap_file)) + else: + LOG.debug( + "Not found: %s. Not writing to: %s", dependencies_file, bootstrap_file + ) + + def _generate_stack_template_files(self) -> None: + stack_template_folder = Path(self.target_canary_folder_path) + contract_test_folder = Path(self.target_contract_test_folder_path) + contract_test_files = [ + file + for file in contract_test_folder.glob(CONTRACT_TEST_INPUT_PREFIX) + if file.is_file() and file.name in self.contract_test_file_names + ] + contract_test_files = sorted(contract_test_files) + for count, ct_file in enumerate(contract_test_files, start=1): + LOG.debug("Loading contract test input file: %s", ct_file) + with ct_file.open("r") as f: + json_data = json.load(f) + resource_name = self.type_info[2] + + self._save_stack_template_data( + resource_name, + count, + stack_template_folder, + self._replace_dynamic_values( + json_data[CREATE_INPUTS_KEY], + ), + CANARY_FILE_CREATE_SUFFIX, + ) + if PATCH_INPUTS_KEY in json_data: + supported_patch_inputs = self._translate_supported_patch_inputs( + json_data[PATCH_INPUTS_KEY] + ) + patch_data = jsonpatch.apply_patch( + json_data[CREATE_INPUTS_KEY], supported_patch_inputs, in_place=False + ) + self._save_stack_template_data( + resource_name, + count, + stack_template_folder, + patch_data, + CANARY_FILE_UPDATE_SUFFIX, + ) + + def _save_stack_template_data( + self, + resource_name, + contract_test_input_count, + stack_template_folder, + properties_data, + suffix, + ): + stack_template_data = { + "Description": f"Template for {self.type_name}", + "Resources": { + f"{resource_name}": { + "Type": self.type_name, + "Properties": properties_data, + } + }, + } + stack_template_file_name = ( + f"{CANARY_FILE_PREFIX}{contract_test_input_count}_{suffix}.yaml" + ) + stack_template_file_path = stack_template_folder / stack_template_file_name + LOG.debug("Writing Canary Stack Template File: %s", stack_template_file_path) + with stack_template_file_path.open("w") as stack_template_file: + yaml.dump(stack_template_data, stack_template_file, indent=2) + + def _translate_supported_patch_inputs(self, patch_inputs: Any) -> Any: + output = [] + for patch_input in patch_inputs: + if ( + patch_input.get(PATCH_OPERATION_KEY) + in CANARY_SUPPORTED_PATCH_INPUT_OPERATIONS + ): + if PATCH_VALUE_KEY in patch_input: + self._replace_dynamic_values_with_root_key( + patch_input, PATCH_VALUE_KEY + ) + output.append(patch_input) + return output + + def _replace_dynamic_values(self, properties: Dict[str, Any]) -> Dict[str, Any]: + for key, value in properties.items(): + if isinstance(value, dict): + properties[key] = self._replace_dynamic_values(value) + elif isinstance(value, list): + properties[key] = [self._replace_dynamic_value(item) for item in value] + else: + return_value = self._replace_dynamic_value(value) + properties[key] = return_value + return properties + + def _replace_dynamic_values_with_root_key( + self, properties: Dict[str, Any], root_key=None + ) -> Dict[str, Any]: + value = properties[root_key] + if isinstance(value, dict): + properties[root_key] = self._replace_dynamic_values(value) + elif isinstance(value, list): + properties[root_key] = [self._replace_dynamic_value(item) for item in value] + else: + return_value = self._replace_dynamic_value(value) + properties[root_key] = return_value + return properties + + def _replace_dynamic_value(self, original_value: Any) -> Any: + pattern = r"\{\{(.*?)\}\}" + + def replace_token(match): + token = match.group(1) + if UUID in token: + return str(uuid4()) + if token in DYNAMIC_VALUES_MAP: + return DYNAMIC_VALUES_MAP[token] + return f'{{"{FN_IMPORT_VALUE}": "{token.strip()}"}}' + + replaced_value = re.sub(pattern, replace_token, str(original_value)) + + if any(value in replaced_value for value in DYNAMIC_VALUES_MAP.values()): + replaced_value = {FN_SUB: replaced_value} + if FN_IMPORT_VALUE in replaced_value: + replaced_value = json.loads(replaced_value) + return replaced_value diff --git a/src/rpdk/core/submit.py b/src/rpdk/core/submit.py index 8d6acefd..b48dd760 100644 --- a/src/rpdk/core/submit.py +++ b/src/rpdk/core/submit.py @@ -11,7 +11,7 @@ def submit(args): project = Project() - project.load() + project.load(args) # Use CLI override opposed to config file if use-docker or no-docker switch used if args.use_docker or args.no_docker: project.settings["use_docker"] = args.use_docker diff --git a/src/rpdk/core/validate.py b/src/rpdk/core/validate.py index d5380bfc..45dd3a6e 100644 --- a/src/rpdk/core/validate.py +++ b/src/rpdk/core/validate.py @@ -9,11 +9,17 @@ LOG = logging.getLogger(__name__) +# validations for cfn validate are done in both project.py and data_loaders.py def validate(_args): project = Project() - project.load() + project.load(_args) def setup_subparser(subparsers, parents): + parser = subparsers.add_parser("validate", description=__doc__, parents=parents) + parser.add_argument("--endpoint-url", help="CloudFormation endpoint to use.") + parser.add_argument("--region", help="AWS Region to submit the resource type.") + parser.add_argument("--profile", help="AWS profile to use.") + parser.set_defaults(command=validate) diff --git a/tests/test_data_loaders.py b/tests/test_data_loaders.py index 265514c4..20821904 100644 --- a/tests/test_data_loaders.py +++ b/tests/test_data_loaders.py @@ -383,6 +383,90 @@ def test_load_resource_spec_without_array_type_valid(): assert result == schema +def test_load_resource_spec_with_relationship_valid(): + schema = { + "typeName": "AWS::FOO::BAR", + "description": "test schema", + "additionalProperties": False, + "properties": { + "foo": { + "type": "string", + "relationshipRef": { + "typeName": "ABC::DEF::GHI", + "propertyPath": "/properties/id", + }, + }, + "bar": {"type": "string"}, + }, + "definitions": { + "XYZ": { + "type": "object", + "additionalProperties": False, + "properties": {"Value": {"type": "string"}, "Key": {"type": "string"}}, + } + }, + "primaryIdentifier": ["/properties/foo"], + "readOnlyProperties": ["/properties/foo"], + "createOnlyProperties": ["/properties/foo"], + "conditionalCreateOnlyProperties": ["/properties/bar"], + } + result = load_resource_spec(json_s(schema)) + assert result == schema + + +def test_load_resource_spec_with_relationship_invalid(): + schema = { + "typeName": "AWS::FOO::BAR", + "description": "test schema", + "additionalProperties": False, + "properties": { + "foo": {"type": "object", "relationshipRef": {"typeName": "ABC::DEF::GHI"}}, + "bar": {"type": "string"}, + }, + "definitions": { + "XYZ": { + "type": "object", + "additionalProperties": False, + "properties": {"Value": {"type": "string"}, "Key": {"type": "string"}}, + } + }, + "primaryIdentifier": ["/properties/foo"], + "readOnlyProperties": ["/properties/foo"], + "createOnlyProperties": ["/properties/foo"], + "conditionalCreateOnlyProperties": ["/properties/bar"], + } + with pytest.raises(SpecValidationError) as excinfo: + load_resource_spec(json_s(schema)) + + assert "Failed validating" in str(excinfo.value) + + +def test_load_resource_spec_with_relationship_invalid_pattern(): + schema = { + "typeName": "AWS::FOO::BAR", + "description": "test schema", + "additionalProperties": False, + "properties": { + "foo": { + "type": "string", + "relationshipRef": { + "typeName": "string", + "propertyPath": "string", + }, + }, + "bar": {"type": "string"}, + }, + "primaryIdentifier": ["/properties/foo"], + "readOnlyProperties": ["/properties/foo"], + "createOnlyProperties": ["/properties/foo"], + "conditionalCreateOnlyProperties": ["/properties/bar"], + } + with pytest.raises(SpecValidationError) as excinfo: + load_resource_spec(json_s(schema)) + + assert "does not match '^[a-zA-Z0-9]{2,64}::[a-zA-Z0-9]{2,64}" in str(excinfo.value) + + def test_load_hook_spec_properties_key_is_invalid(): schema = { "typeName": "AWS::FOO::BAR", diff --git a/tests/test_project.py b/tests/test_project.py index dbbb5eda..a6de1b3a 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -5,8 +5,11 @@ import logging import os import random +import re +import shutil import string import sys +import uuid import zipfile from contextlib import contextmanager from io import StringIO @@ -14,6 +17,7 @@ from shutil import copyfile from unittest.mock import ANY, MagicMock, Mock, call, patch +import jsonpatch import pytest import yaml from botocore.exceptions import ClientError, WaiterError @@ -28,11 +32,18 @@ ) from rpdk.core.plugin_base import LanguagePlugin from rpdk.core.project import ( + CANARY_DEPENDENCY_FILE_NAME, + CANARY_FILE_PREFIX, CFN_METADATA_FILENAME, CONFIGURATION_SCHEMA_UPLOAD_FILENAME, + CONTRACT_TEST_DEPENDENCY_FILE_NAME, + CONTRACT_TEST_FILE_NAMES, + CONTRACT_TEST_FOLDER, OVERRIDES_FILENAME, SCHEMA_UPLOAD_FILENAME, SETTINGS_FILENAME, + TARGET_CANARY_FOLDER, + TARGET_CANARY_ROOT_FOLDER, TARGET_INFO_FILENAME, Project, escape_markdown, @@ -46,6 +57,8 @@ ARTIFACT_TYPE_RESOURCE = "RESOURCE" ARTIFACT_TYPE_MODULE = "MODULE" ARTIFACT_TYPE_HOOK = "HOOK" +CANARY_CREATE_FILE_SUFFIX = "001" +CANARY_PATCH_FILE_SUFFIX = "002" LANGUAGE = "BQHDBC" TYPE_NAME = "AWS::Color::Red" MODULE_TYPE_NAME = "AWS::Color::Red::MODULE" @@ -160,6 +173,36 @@ def test_load_settings_invalid_hooks_settings(project): mock_open.assert_called_once_with("r", encoding="utf-8") +def test_load_settings_invalid_protocol_version(project): + with patch_settings( + project, '{"settings": {"protocolVersion": "3.0.0"}}' + ) as mock_open: + with pytest.raises(InvalidProjectError): + project.load_settings() + mock_open.assert_called_once_with("r", encoding="utf-8") + + +def test_load_settings_missing_protocol_version(project): + plugin = object() + data = json.dumps( + {"artifact_type": "MODULE", "typeName": MODULE_TYPE_NAME, "settings": {}} + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_not_called() + assert project.type_info == ("AWS", "Color", "Red", "MODULE") + assert project.type_name == MODULE_TYPE_NAME + assert project.language is None + assert project.artifact_type == ARTIFACT_TYPE_MODULE + assert project._plugin is None + assert project.settings == {} + + def test_load_settings_valid_json_for_resource(project): plugin = object() data = json.dumps( @@ -279,6 +322,57 @@ def test_load_settings_valid_json_for_hook(project): assert project.settings == {} +def test_load_settings_valid_protocol_version(project): + plugin = object() + data = json.dumps( + { + "artifact_type": "MODULE", + "typeName": MODULE_TYPE_NAME, + "settings": {"protocolVersion": "2.0.0"}, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_not_called() + assert project.type_info == ("AWS", "Color", "Red", "MODULE") + assert project.type_name == MODULE_TYPE_NAME + assert project.language is None + assert project.artifact_type == ARTIFACT_TYPE_MODULE + assert project._plugin is None + assert project.settings == {"protocolVersion": "2.0.0"} + + +def test_load_settings_missing_settings(project): + plugin = object() + data = json.dumps( + { + "artifact_type": "MODULE", + "typeName": MODULE_TYPE_NAME, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_not_called() + assert project.type_info == ("AWS", "Color", "Red", "MODULE") + assert project.type_name == MODULE_TYPE_NAME + assert project.language is None + assert project.artifact_type == ARTIFACT_TYPE_MODULE + assert project._plugin is None + assert project.settings == {} + + def test_load_schema_settings_not_loaded(project): with pytest.raises(InternalError): project.load_schema() @@ -337,6 +431,7 @@ def test_configuration_schema_filename(project): ) +# TODO: def test_load_schema_with_typeconfiguration(project): patch_settings = patch.object(project, "load_settings") patch_schema = patch.object(project, "load_schema") @@ -345,7 +440,7 @@ def test_load_schema_with_typeconfiguration(project): project.load() mock_settings.assert_called_once_with() - mock_schema.assert_called_once_with() + mock_schema.assert_called_once_with(None) mock_configuration_schema.assert_called_once_with() @@ -1096,6 +1191,7 @@ def test_init_module(project): assert f.read() == b"\n" +# TODO: def test_load_invalid_schema(project): patch_settings = patch.object(project, "load_settings") patch_schema = patch.object( @@ -1107,7 +1203,7 @@ def test_load_invalid_schema(project): project.load() mock_settings.assert_called_once_with() - mock_schema.assert_called_once_with() + mock_schema.assert_called_once_with(None) assert "invalid" in str(excinfo.value) @@ -1198,7 +1294,7 @@ def test_schema_not_found(project): project.load() mock_settings.assert_called_once_with() - mock_schema.assert_called_once_with() + mock_schema.assert_called_once_with(None) assert "not found" in str(excinfo.value) @@ -2733,3 +2829,1092 @@ def test__load_target_info_for_hooks_local_only(project): sorted(test_type_info.keys()), local_schemas=ANY, local_info=test_type_info ) assert len(mock_loader.call_args[1]["local_schemas"]) == 4 + + +def setup_contract_test_data(tmp_path, contract_test_data=None): + root_path = tmp_path + contract_test_folder = root_path / CONTRACT_TEST_FOLDER + contract_test_folder.mkdir(parents=True, exist_ok=True) + assert contract_test_folder.exists() + # Create a dummy JSON file in the canary_root_path directory + create_dummy_json_file(contract_test_folder, "inputs_1.json", contract_test_data) + create_dummy_json_file(contract_test_folder, "inputs_2.json", contract_test_data) + (contract_test_folder / CONTRACT_TEST_DEPENDENCY_FILE_NAME).touch() + assert contract_test_folder.exists() + return Project(str(root_path)) + + +def create_dummy_json_file(directory: Path, file_name: str, dummy_data=None): + """Create a dummy JSON file in the given directory.""" + dummy_json_file = directory / file_name + if not dummy_data: + dummy_data = { + "CreateInputs": { + "Property1": "Value1", + "Property2": "Value1", + } + } + with dummy_json_file.open("w") as f: + json.dump(dummy_data, f) + + +def create_folder(folder: Path): + if os.path.exists(folder): + shutil.rmtree(folder) + folder.mkdir() + + +def test_generate_canary_files(project): + setup_contract_test_data(project.root) + tmp_path = project.root + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + canary_root_path = tmp_path / TARGET_CANARY_ROOT_FOLDER + canary_folder_path = tmp_path / TARGET_CANARY_FOLDER + assert canary_root_path.exists() + assert canary_folder_path.exists() + + canary_files = list(canary_folder_path.glob(f"{CANARY_FILE_PREFIX}*")) + assert len(canary_files) == 2 + canary_files.sort() + assert canary_files[0].name == f"{CANARY_FILE_PREFIX}1_001.yaml" + assert canary_files[1].name == f"{CANARY_FILE_PREFIX}2_001.yaml" + + bootstrap_file = canary_root_path / CANARY_DEPENDENCY_FILE_NAME + assert bootstrap_file.exists() + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file(mock_yaml_dump, project): + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property2": "{{test123}}", + "Property3": {"Nested": "{{partition}}"}, + "Property4": ["{{region}}", "Value2"], + "Property5": "{{uuid}}", + "Property6": "{{account}}", + "Property7": "prefix-{{uuid}}-sufix", + } + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property2": {"Fn::ImportValue": ANY}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, "Value2"], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = mock_yaml_dump.call_args + assert args[0] == expected_template_data + assert kwargs + # Assert UUID generation + replaced_properties = args[0]["Resources"]["Resource"]["Properties"] + assert isinstance(replaced_properties["Property5"], str) + assert len(replaced_properties["Property5"]) == 36 # Standard UUID length + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + replaced_properties["Property5"], + ) + + # Assert the generated UUID is a valid UUID + generated_uuid = replaced_properties["Property5"] + assert uuid.UUID(generated_uuid) + property7_value = replaced_properties["Property7"] + # Assert the replaced value + assert isinstance(property7_value, str) + assert "prefix-" in property7_value + assert "-sufix" in property7_value + # Extract the UUID part + property7_value = property7_value.replace("prefix-", "").replace("-sufix", "") + # Assert the UUID format + assert len(property7_value) == 36 # Standard UUID length + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", property7_value + ) + # Assert the UUID is a valid UUID + assert uuid.UUID(property7_value) + + +def setup_rpdk_config(project, rpdk_config): + root_path = project.root + plugin = object() + data = json.dumps(rpdk_config) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + contract_test_folder = root_path / CONTRACT_TEST_FOLDER + contract_test_folder.mkdir(parents=True, exist_ok=True) + # Create a dummy JSON file in the canary_root_path directory + create_dummy_json_file(contract_test_folder, "inputs_1.json") + create_dummy_json_file(contract_test_folder, "inputs_2.json") + (contract_test_folder / CONTRACT_TEST_DEPENDENCY_FILE_NAME).touch() + + +def test_generate_canary_files_no_canary_settings(project): + rpdk_config = { + ARTIFACT_TYPE_RESOURCE: "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + } + tmp_path = project.root + setup_rpdk_config(project, rpdk_config) + project.generate_canary_files(local_code_generation=True) + + canary_root_path = tmp_path / TARGET_CANARY_ROOT_FOLDER + canary_folder_path = tmp_path / TARGET_CANARY_FOLDER + assert not canary_root_path.exists() + assert not canary_folder_path.exists() + + +def test_generate_canary_files_no_local_code_generation(project): + rpdk_config = { + ARTIFACT_TYPE_RESOURCE: "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + tmp_path = project.root + setup_rpdk_config(project, rpdk_config) + project.generate_canary_files() + + canary_root_path = tmp_path / TARGET_CANARY_ROOT_FOLDER + canary_folder_path = tmp_path / TARGET_CANARY_FOLDER + assert not canary_root_path.exists() + assert not canary_folder_path.exists() + + +def test_generate_canary_files_false_local_code_generation(project): + rpdk_config = { + ARTIFACT_TYPE_RESOURCE: "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + tmp_path = project.root + setup_rpdk_config(project, rpdk_config) + project.generate_canary_files(local_code_generation=False) + + canary_root_path = tmp_path / TARGET_CANARY_ROOT_FOLDER + canary_folder_path = tmp_path / TARGET_CANARY_FOLDER + assert not canary_root_path.exists() + assert not canary_folder_path.exists() + + +def test_generate_canary_files_empty_input_files(project): + rpdk_config = { + ARTIFACT_TYPE_RESOURCE: "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + "contract_test_file_names": [], + }, + } + tmp_path = project.root + setup_rpdk_config(project, rpdk_config) + project.generate_canary_files(local_code_generation=True) + + canary_root_path = tmp_path / TARGET_CANARY_ROOT_FOLDER + canary_folder_path = tmp_path / TARGET_CANARY_FOLDER + assert canary_root_path.exists() + assert canary_folder_path.exists() + canary_files = list(canary_folder_path.glob(f"{CANARY_FILE_PREFIX}*")) + assert not canary_files + + +def test_generate_canary_files_empty_canary_settings(project): + rpdk_config = { + ARTIFACT_TYPE_RESOURCE: "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": {}, + } + tmp_path = project.root + setup_rpdk_config(project, rpdk_config) + project.generate_canary_files(local_code_generation=True) + canary_root_path = tmp_path / TARGET_CANARY_ROOT_FOLDER + canary_folder_path = tmp_path / TARGET_CANARY_FOLDER + assert not canary_root_path.exists() + assert not canary_folder_path.exists() + + +def _get_mock_yaml_dump_call_arg( + call_args_list, canary_operation_suffix, arg_index=0, contract_test_count="2" +): + pattern = ( + rf"{CANARY_FILE_PREFIX}{contract_test_count}_{canary_operation_suffix}\.yaml$" + ) + return [ + call_item + for call_item in call_args_list + if re.search(pattern, call_item.args[1].name) + ][arg_index] + + +@patch("rpdk.core.project.yaml.dump") +def test_generate_canary_files_with_patch_inputs(mock_yaml_dump, project): + tmp_path = project.root + update_value_1 = "Value1b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + }, + "PatchInputs": [ + { + "op": "replace", + "path": "/Property1", + "value": update_value_1, + } + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + canary_root_path = tmp_path / TARGET_CANARY_ROOT_FOLDER + canary_folder_path = tmp_path / TARGET_CANARY_FOLDER + assert canary_root_path.exists() + assert canary_folder_path.exists() + + canary_files = list(canary_folder_path.glob(f"{CANARY_FILE_PREFIX}*")) + assert len(canary_files) == 4 + canary_files.sort() + assert canary_files[0].name == f"{CANARY_FILE_PREFIX}1_001.yaml" + assert canary_files[1].name == f"{CANARY_FILE_PREFIX}1_002.yaml" + assert canary_files[2].name == f"{CANARY_FILE_PREFIX}2_001.yaml" + assert canary_files[3].name == f"{CANARY_FILE_PREFIX}2_002.yaml" + + bootstrap_file = canary_root_path / CANARY_DEPENDENCY_FILE_NAME + assert bootstrap_file.exists() + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_patch_inputs(mock_yaml_dump, project): + update_value_1 = "Value1b" + update_value_2 = "Value2b" + + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property2": "{{test123}}", + "Property3": {"Nested": "{{partition}}"}, + "Property4": ["{{region}}", "Value2"], + "Property5": "{{uuid}}", + "Property6": "{{account}}", + "Property7": "prefix-{{uuid}}-sufix", + }, + "PatchInputs": [ + { + "op": "replace", + "path": "/Property1", + "value": update_value_1, + }, + { + "op": "replace", + "path": "/Property2", + "value": "{{test1234}}", + }, + { + "op": "replace", + "path": "/Property3", + "value": {"Nested": "{{partition}}"}, + }, + { + "op": "replace", + "path": "/Property4", + "value": ["{{region}}", update_value_2], + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": update_value_1, + "Property2": {"Fn::ImportValue": "test1234"}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, update_value_2], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + # verify that dynamically generated variables will be equal between patch and create canaries + patch_property5 = args[0]["Resources"]["Resource"]["Properties"]["Property5"] + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property2": {"Fn::ImportValue": "test123"}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, "Value2"], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs + assert ( + patch_property5 == args[0]["Resources"]["Resource"]["Properties"]["Property5"] + ) + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_by_list_index(mock_yaml_dump, project): + update_value_1 = "Value1b" + update_value_2 = "Value2b" + contract_test_data = { + "CreateInputs": { + "Property1": ["{{region}}", "Value1"], + "Property2": ["{{region}}", "Value2"], + }, + "PatchInputs": [ + { + "op": "replace", + "path": "/Property1/1", + "value": update_value_1, + }, + { + "op": "add", + "path": "/Property2/1", + "value": update_value_2, + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": [{"Fn::Sub": "${AWS::Region}"}, update_value_1], + "Property2": [ + {"Fn::Sub": "${AWS::Region}"}, + update_value_2, + "Value2", + ], + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_skipped_patch_operation(mock_yaml_dump, project): + update_value_1 = "Value1b" + update_value_2 = "Value2b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property2": "{{test123}}", + "Property3": {"Nested": "{{partition}}"}, + "Property4": ["{{region}}", "Value2"], + "Property5": "{{uuid}}", + "Property6": "{{account}}", + "Property7": "prefix-{{uuid}}-sufix", + }, + "PatchInputs": [ + { + "op": "test", + "path": "/Property1", + "value": update_value_1, + }, + { + "op": "move", + "path": "/Property4", + "value": update_value_2, + }, + {"op": "copy", "from": "Property4", "path": "/Property2"}, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property2": {"Fn::ImportValue": ANY}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, "Value2"], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_patch_inputs_missing_from_create( + mock_yaml_dump, project +): + update_value_2 = "Value2b" + update_value_8 = "Value8" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property2": "{{test123}}", + "Property3": {"Nested": "{{partition}}"}, + "Property5": "{{uuid}}", + "Property6": "{{account}}", + "Property7": "prefix-{{uuid}}-sufix", + }, + "PatchInputs": [ + { + "op": "add", + "path": "/Property4", + "value": ["{{region}}", update_value_2], + }, + { + "op": "add", + "path": "/Property8", + "value": update_value_8, + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property2": {"Fn::ImportValue": ANY}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, update_value_2], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + "Property8": update_value_8, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property2": {"Fn::ImportValue": ANY}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_throws_error_with_invalid_path(mock_yaml_dump, project): + update_value1 = "Value1b" + update_value_2 = "Value2b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + }, + "PatchInputs": [ + { + "op": "replace", + "path": "/Property1", + "value": update_value1, + }, + { + "op": "add", + "path": "/Property4/SubProperty4", + "value": update_value_2, + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + with pytest.raises(jsonpatch.JsonPointerException): + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_nested_replace_patch_inputs(mock_yaml_dump, project): + update_value_1 = "Value_Nested1b" + update_value_2 = "Value_Nested2b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": ["{{region}}", "Value_Nested2"], + } + }, + }, + "PatchInputs": [ + { + "op": "replace", + "path": "/Property8/Nested/PropertyA", + "value": update_value_1, + }, + { + "op": "replace", + "path": "/Property8/Nested/PropertyB", + "value": ["{{region}}", update_value_2], + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": update_value_1, + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + update_value_2, + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + "Value_Nested2", + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_nested_remove_patch_inputs(mock_yaml_dump, project): + update_value_1 = "Value_Nested1b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": ["{{region}}", "Value_Nested2"], + } + }, + }, + "PatchInputs": [ + { + "op": "replace", + "path": "/Property8/Nested/PropertyA", + "value": update_value_1, + }, + { + "op": "remove", + "path": "/Property8/Nested/PropertyB/1", + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": update_value_1, + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + "Value_Nested2", + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_nested_add_patch_inputs(mock_yaml_dump, project): + update_value_2 = "Value_Nested2b" + contract_test_data = { + "CreateInputs": { + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": ["{{region}}", "Value_Nested2"], + } + }, + }, + "PatchInputs": [ + { + "op": "add", + "path": "/Property8/Nested/PropertyB/2", + "value": update_value_2, + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files(local_code_generation=True) + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + "Value_Nested2", + update_value_2, + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + "Value_Nested2", + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs