|
2 | 2 | import json
|
3 | 3 | import logging
|
4 | 4 | import os
|
| 5 | +import re |
5 | 6 | import shutil
|
6 | 7 | import sys
|
7 | 8 | import zipfile
|
8 | 9 | from pathlib import Path
|
9 | 10 | from tempfile import TemporaryFile
|
| 11 | +from typing import Any, Dict |
10 | 12 | from uuid import uuid4
|
11 | 13 |
|
| 14 | +import yaml |
12 | 15 | from botocore.exceptions import ClientError, WaiterError
|
13 | 16 | from jinja2 import Environment, PackageLoader, select_autoescape
|
14 | 17 | from jsonschema import Draft7Validator
|
|
56 | 59 | ARTIFACT_TYPE_RESOURCE = "RESOURCE"
|
57 | 60 | ARTIFACT_TYPE_MODULE = "MODULE"
|
58 | 61 | ARTIFACT_TYPE_HOOK = "HOOK"
|
59 |
| - |
| 62 | +TARGET_CANARY_ROOT_FOLDER = "canary-bundle" |
| 63 | +TARGET_CANARY_FOLDER = "canary-bundle/canary" |
| 64 | +RPDK_CONFIG_FILE = ".rpdk-config" |
| 65 | +CANARY_FILE_PREFIX = "canary" |
| 66 | +CONTRACT_TEST_DEPENDENCY_FILE_NAME = "dependencies.yml" |
| 67 | +CANARY_DEPENDENCY_FILE_NAME = "bootstrap.yaml" |
| 68 | +CANARY_SETTINGS = "canarySettings" |
| 69 | +TYPE_NAME = "typeName" |
| 70 | +CONTRACT_TEST_FILE_NAMES = "contract_test_file_names" |
| 71 | +INPUT1_FILE_NAME = "inputs_1.json" |
| 72 | +FILE_GENERATION_ENABLED = "file_generation_enabled" |
| 73 | +CONTRACT_TEST_FOLDER = "contract-tests-artifacts" |
| 74 | +CONTRACT_TEST_INPUT_PREFIX = "inputs_*" |
| 75 | +CONTRACT_TEST_DEPENDENCY_FILE_NAME = "dependencies.yml" |
| 76 | +FILE_GENERATION_ENABLED = "file_generation_enabled" |
| 77 | +TYPE_NAME = "typeName" |
| 78 | +CONTRACT_TEST_FILE_NAMES = "contract_test_file_names" |
| 79 | +INPUT1_FILE_NAME = "inputs_1.json" |
| 80 | +FN_SUB = "Fn::Sub" |
| 81 | +FN_IMPORT_VALUE = "Fn::ImportValue" |
| 82 | +UUID = "uuid" |
| 83 | +DYNAMIC_VALUES_MAP = { |
| 84 | + "region": "${AWS::Region}", |
| 85 | + "partition": "${AWS::Partition}", |
| 86 | + "account": "${AWS::AccountId}", |
| 87 | +} |
60 | 88 | DEFAULT_ROLE_TIMEOUT_MINUTES = 120 # 2 hours
|
61 | 89 | # min and max are according to CreateRole API restrictions
|
62 | 90 | # https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateRole.html
|
@@ -145,6 +173,7 @@ def __init__(self, overwrite_enabled=False, root=None):
|
145 | 173 | self.test_entrypoint = None
|
146 | 174 | self.executable_entrypoint = None
|
147 | 175 | self.fragment_dir = None
|
| 176 | + self.canary_settings = {} |
148 | 177 | self.target_info = {}
|
149 | 178 |
|
150 | 179 | self.env = Environment(
|
@@ -207,6 +236,30 @@ def target_schemas_path(self):
|
207 | 236 | def target_info_path(self):
|
208 | 237 | return self.root / TARGET_INFO_FILENAME
|
209 | 238 |
|
| 239 | + @property |
| 240 | + def target_canary_root_path(self): |
| 241 | + return self.root / TARGET_CANARY_ROOT_FOLDER |
| 242 | + |
| 243 | + @property |
| 244 | + def target_canary_folder_path(self): |
| 245 | + return self.root / TARGET_CANARY_FOLDER |
| 246 | + |
| 247 | + @property |
| 248 | + def rpdk_config(self): |
| 249 | + return self.root / RPDK_CONFIG_FILE |
| 250 | + |
| 251 | + @property |
| 252 | + def file_generation_enabled(self): |
| 253 | + return self.canary_settings.get(FILE_GENERATION_ENABLED, False) |
| 254 | + |
| 255 | + @property |
| 256 | + def contract_test_file_names(self): |
| 257 | + return self.canary_settings.get(CONTRACT_TEST_FILE_NAMES, [INPUT1_FILE_NAME]) |
| 258 | + |
| 259 | + @property |
| 260 | + def target_contract_test_folder_path(self): |
| 261 | + return self.root / CONTRACT_TEST_FOLDER |
| 262 | + |
210 | 263 | @staticmethod
|
211 | 264 | def _raise_invalid_project(msg, e):
|
212 | 265 | LOG.debug(msg, exc_info=e)
|
@@ -277,6 +330,7 @@ def validate_and_load_resource_settings(self, raw_settings):
|
277 | 330 | self.executable_entrypoint = raw_settings.get("executableEntrypoint")
|
278 | 331 | self._plugin = load_plugin(raw_settings["language"])
|
279 | 332 | self.settings = raw_settings.get("settings", {})
|
| 333 | + self.canary_settings = raw_settings.get("canarySettings", {}) |
280 | 334 |
|
281 | 335 | def _write_example_schema(self):
|
282 | 336 | self.schema = resource_json(
|
@@ -338,6 +392,7 @@ def _write_resource_settings(f):
|
338 | 392 | "testEntrypoint": self.test_entrypoint,
|
339 | 393 | "settings": self.settings,
|
340 | 394 | **executable_entrypoint_dict,
|
| 395 | + "canarySettings": self.canary_settings, |
341 | 396 | },
|
342 | 397 | f,
|
343 | 398 | indent=4,
|
@@ -391,6 +446,10 @@ def init(self, type_name, language, settings=None):
|
391 | 446 | self.language = language
|
392 | 447 | self._plugin = load_plugin(language)
|
393 | 448 | self.settings = settings or {}
|
| 449 | + self.canary_settings = { |
| 450 | + FILE_GENERATION_ENABLED: True, |
| 451 | + CONTRACT_TEST_FILE_NAMES: [INPUT1_FILE_NAME], |
| 452 | + } |
394 | 453 | self._write_example_schema()
|
395 | 454 | self._write_example_inputs()
|
396 | 455 | self._plugin.init(self)
|
@@ -1251,3 +1310,83 @@ def _load_target_info(
|
1251 | 1310 | )
|
1252 | 1311 |
|
1253 | 1312 | return type_info
|
| 1313 | + |
| 1314 | + def generate_canary_files(self) -> None: |
| 1315 | + if ( |
| 1316 | + not self.file_generation_enabled |
| 1317 | + or not Path(self.target_contract_test_folder_path).exists() |
| 1318 | + ): |
| 1319 | + return |
| 1320 | + self._setup_stack_template_environment() |
| 1321 | + self._generate_stack_template_files() |
| 1322 | + |
| 1323 | + def _setup_stack_template_environment(self) -> None: |
| 1324 | + stack_template_root = Path(self.target_canary_root_path) |
| 1325 | + stack_template_folder = Path(self.target_canary_folder_path) |
| 1326 | + stack_template_folder.mkdir(parents=True, exist_ok=True) |
| 1327 | + dependencies_file = ( |
| 1328 | + Path(self.target_contract_test_folder_path) |
| 1329 | + / CONTRACT_TEST_DEPENDENCY_FILE_NAME |
| 1330 | + ) |
| 1331 | + bootstrap_file = stack_template_root / CANARY_DEPENDENCY_FILE_NAME |
| 1332 | + if dependencies_file.exists(): |
| 1333 | + shutil.copy(str(dependencies_file), str(bootstrap_file)) |
| 1334 | + |
| 1335 | + def _generate_stack_template_files(self) -> None: |
| 1336 | + stack_template_folder = Path(self.target_canary_folder_path) |
| 1337 | + contract_test_folder = Path(self.target_contract_test_folder_path) |
| 1338 | + contract_test_files = [ |
| 1339 | + file |
| 1340 | + for file in contract_test_folder.glob(CONTRACT_TEST_INPUT_PREFIX) |
| 1341 | + if file.is_file() and file.name in self.contract_test_file_names |
| 1342 | + ] |
| 1343 | + contract_test_files = sorted(contract_test_files) |
| 1344 | + for count, ct_file in enumerate(contract_test_files, start=1): |
| 1345 | + with ct_file.open("r") as f: |
| 1346 | + json_data = json.load(f) |
| 1347 | + resource_name = self.type_info[2] |
| 1348 | + stack_template_data = { |
| 1349 | + "Description": f"Template for {self.type_name}", |
| 1350 | + "Resources": { |
| 1351 | + f"{resource_name}": { |
| 1352 | + "Type": self.type_name, |
| 1353 | + "Properties": self._replace_dynamic_values( |
| 1354 | + json_data["CreateInputs"] |
| 1355 | + ), |
| 1356 | + } |
| 1357 | + }, |
| 1358 | + } |
| 1359 | + stack_template_file_name = f"{CANARY_FILE_PREFIX}{count}_001.yaml" |
| 1360 | + stack_template_file_path = stack_template_folder / stack_template_file_name |
| 1361 | + with stack_template_file_path.open("w") as stack_template_file: |
| 1362 | + yaml.dump(stack_template_data, stack_template_file, indent=2) |
| 1363 | + |
| 1364 | + def _replace_dynamic_values(self, properties: Dict[str, Any]) -> Dict[str, Any]: |
| 1365 | + for key, value in properties.items(): |
| 1366 | + if isinstance(value, dict): |
| 1367 | + properties[key] = self._replace_dynamic_values(value) |
| 1368 | + elif isinstance(value, list): |
| 1369 | + properties[key] = [self._replace_dynamic_value(item) for item in value] |
| 1370 | + else: |
| 1371 | + return_value = self._replace_dynamic_value(value) |
| 1372 | + properties[key] = return_value |
| 1373 | + return properties |
| 1374 | + |
| 1375 | + def _replace_dynamic_value(self, original_value: Any) -> Any: |
| 1376 | + pattern = r"\{\{(.*?)\}\}" |
| 1377 | + |
| 1378 | + def replace_token(match): |
| 1379 | + token = match.group(1) |
| 1380 | + if UUID in token: |
| 1381 | + return str(uuid4()) |
| 1382 | + if token in DYNAMIC_VALUES_MAP: |
| 1383 | + return DYNAMIC_VALUES_MAP[token] |
| 1384 | + return f'{{"{FN_IMPORT_VALUE}": "{token.strip()}"}}' |
| 1385 | + |
| 1386 | + replaced_value = re.sub(pattern, replace_token, str(original_value)) |
| 1387 | + |
| 1388 | + if any(value in replaced_value for value in DYNAMIC_VALUES_MAP.values()): |
| 1389 | + replaced_value = {FN_SUB: replaced_value} |
| 1390 | + if FN_IMPORT_VALUE in replaced_value: |
| 1391 | + replaced_value = json.loads(replaced_value) |
| 1392 | + return replaced_value |
0 commit comments