From f1a165ece036eae8ebc0ce3e85e9c67919c404c6 Mon Sep 17 00:00:00 2001 From: Rajesh Duraisamy <118321621+rajdnp@users.noreply.github.com> Date: Thu, 30 May 2024 10:18:36 -0700 Subject: [PATCH] =?UTF-8?q?Implement=20canary=20file=20generation=20functi?= =?UTF-8?q?onality=20from=20contract=20test=20inp=E2=80=A6=20(#1069)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rpdk/core/generate.py | 2 +- src/rpdk/core/project.py | 141 ++++++++++++++++++- tests/test_project.py | 284 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 425 insertions(+), 2 deletions(-) diff --git a/src/rpdk/core/generate.py b/src/rpdk/core/generate.py index 58383055..3f99eb78 100644 --- a/src/rpdk/core/generate.py +++ b/src/rpdk/core/generate.py @@ -20,7 +20,7 @@ def generate(args): args.profile, ) project.generate_docs() - + project.generate_canary_files() LOG.warning("Generated files for %s", project.type_name) diff --git a/src/rpdk/core/project.py b/src/rpdk/core/project.py index 1649bdd8..0c907a5d 100644 --- a/src/rpdk/core/project.py +++ b/src/rpdk/core/project.py @@ -2,13 +2,16 @@ 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 yaml from botocore.exceptions import ClientError, WaiterError from jinja2 import Environment, PackageLoader, select_autoescape from jsonschema import Draft7Validator @@ -56,7 +59,32 @@ 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" +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" +FILE_GENERATION_ENABLED = "file_generation_enabled" +CONTRACT_TEST_FOLDER = "contract-tests-artifacts" +CONTRACT_TEST_INPUT_PREFIX = "inputs_*" +CONTRACT_TEST_DEPENDENCY_FILE_NAME = "dependencies.yml" +FILE_GENERATION_ENABLED = "file_generation_enabled" +TYPE_NAME = "typeName" +CONTRACT_TEST_FILE_NAMES = "contract_test_file_names" +INPUT1_FILE_NAME = "inputs_1.json" +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 @@ -145,6 +173,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 +236,30 @@ 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): + return self.canary_settings.get(FILE_GENERATION_ENABLED, False) + + @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) @@ -277,6 +330,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 +392,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 +446,10 @@ def init(self, type_name, language, settings=None): self.language = language self._plugin = load_plugin(language) self.settings = settings or {} + self.canary_settings = { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: [INPUT1_FILE_NAME], + } self._write_example_schema() self._write_example_inputs() self._plugin.init(self) @@ -1251,3 +1310,83 @@ def _load_target_info( ) return type_info + + def generate_canary_files(self) -> None: + if ( + not self.file_generation_enabled + or not Path(self.target_contract_test_folder_path).exists() + ): + return + self._setup_stack_template_environment() + self._generate_stack_template_files() + + 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(): + shutil.copy(str(dependencies_file), str(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): + with ct_file.open("r") as f: + json_data = json.load(f) + resource_name = self.type_info[2] + stack_template_data = { + "Description": f"Template for {self.type_name}", + "Resources": { + f"{resource_name}": { + "Type": self.type_name, + "Properties": self._replace_dynamic_values( + json_data["CreateInputs"] + ), + } + }, + } + stack_template_file_name = f"{CANARY_FILE_PREFIX}{count}_001.yaml" + stack_template_file_path = stack_template_folder / stack_template_file_name + with stack_template_file_path.open("w") as stack_template_file: + yaml.dump(stack_template_data, stack_template_file, indent=2) + + 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_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/tests/test_project.py b/tests/test_project.py index dbbb5eda..1d52fef0 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 @@ -28,11 +31,19 @@ ) 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, + FILE_GENERATION_ENABLED, OVERRIDES_FILENAME, SCHEMA_UPLOAD_FILENAME, SETTINGS_FILENAME, + TARGET_CANARY_FOLDER, + TARGET_CANARY_ROOT_FOLDER, TARGET_INFO_FILENAME, Project, escape_markdown, @@ -2733,3 +2744,276 @@ 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": { + FILE_GENERATION_ENABLED: True, + 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() + 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": { + FILE_GENERATION_ENABLED: True, + 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() + 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_when_not_enabled(project): + rpdk_config = { + ARTIFACT_TYPE_RESOURCE: "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: False, + "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_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() + + 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": { + FILE_GENERATION_ENABLED: True, + "contract_test_file_names": [], + }, + } + 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 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() + 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()