diff --git a/strong_opx/hcl/runner.py b/strong_opx/hcl/runner.py index 4e36040..aa53e40 100644 --- a/strong_opx/hcl/runner.py +++ b/strong_opx/hcl/runner.py @@ -1,6 +1,6 @@ import os import subprocess -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from strong_opx.exceptions import UndefinedVariableError from strong_opx.hcl.extractor import HCLVariableExtractor @@ -18,6 +18,51 @@ def __init__(self, environment: "Environment", directory: str): self.environment = environment self.directory = directory + @staticmethod + def _serialize_value(value: Any) -> str: + """ + Converts a variable value to a string that can be used as an environment variable that will be passed to a + Terraform or Packer command. + + Per Terraform documentation: + https://developer.hashicorp.com/terraform/cli/commands/plan#input-variables-on-the-command-line + + string, number, and bool values are expected to be passed as strings with no special punctuation. For all other + type constraints, including list, map, and set types and the special any keyword, you must write a valid + Terraform language expression representing the value, and write any necessary quoting or escape characters to + ensure it will pass through your shell literally to Terraform. + + Packer documentation does not explicitly call this out, but, in testing, this code works for Packer as well. + + NOTE: This method does NOT handle Windows command line escaping, so it should only be used in Unix environments. + strong-opx does not currently support Windows, but I wanted to explicitly call it out here. + """ + if isinstance(value, list) or isinstance(value, tuple): + build_string = "[" + for item in value: + if isinstance(item, str): + build_string += f'"{item}",' + else: + build_string += f"{HCLRunner._serialize_value(item)}," + if build_string.endswith(","): + build_string = build_string[:-1] # remove trailing comma + build_string += "]" + return build_string + elif isinstance(value, dict): + build_string = "{" + for key, val in value.items(): + if isinstance(val, str): + build_string += f'"{key}": "{val}",' + else: + build_string += f'"{key}": {HCLRunner._serialize_value(val)},' + if build_string.endswith(","): + build_string = build_string[:-1] # remove trailing comma + + build_string += "}" + + return build_string + return str(value) + def extract_vars(self) -> dict[str, str]: env_dict = {} extractor = HCLVariableExtractor() @@ -36,14 +81,14 @@ def extract_vars(self) -> dict[str, str]: missing_vars.append(var) continue - env_dict[f"{self.env_var_prefix}_{var}"] = str(context[var]) + env_dict[f"{self.env_var_prefix}_{var}"] = self._serialize_value(context[var]) if missing_vars: raise UndefinedVariableError(*missing_vars) for var in extractor.optional_vars: if var in context: - env_dict[f"{self.env_var_prefix}_{var}"] = str(context[var]) + env_dict[f"{self.env_var_prefix}_{var}"] = self._serialize_value(context[var]) return env_dict diff --git a/tests/hcl/test_runner.py b/tests/hcl/test_runner.py index 6d31734..41d4277 100644 --- a/tests/hcl/test_runner.py +++ b/tests/hcl/test_runner.py @@ -55,15 +55,38 @@ def test_variable_extraction_only_from_whitelisted_files( @mock.patch("strong_opx.hcl.runner.HCLVariableExtractor") def test_required_and_optional_vars_with_none_missing(self, extractor_mock: mock.Mock, listdir_mock: mock.Mock): listdir_mock.return_value = [] - extractor_mock.return_value.required_vars = ["VAR_1", "VAR_2"] - extractor_mock.return_value.optional_vars = ["VAR_3"] + extractor_mock.return_value.required_vars = ["VAR_1", "VAR_2", "ARRAY_OF_INTS", "MIXED_ARRAY", "TUPLE"] + extractor_mock.return_value.optional_vars = ["ARRAY_OF_STRINGS", "ARRAY_OF_ARRAYS_OF_STRINGS", "NESTED_MIX"] runner = TestHCLRunner() - runner.environment.context = Context({"VAR_1": "VAL_1", "VAR_2": "VAL_2", "VAR_3": "VAL_3"}) + runner.environment.context = Context( + { + "VAR_1": "VAL_1", + "VAR_2": 2, + "ARRAY_OF_STRINGS": ["VAL_3_1", "VAL_3_2"], + "ARRAY_OF_INTS": [1, 2, 3], + "ARRAY_OF_ARRAYS_OF_STRINGS": [["VAL_4_1_1", "VAL_4_1_2"], ["VAL_4_2_1", "VAL_4_2_2"]], + "MIXED_ARRAY": ["VAL_5_1", 2, ["VAL_5_3_1", "VAL_5_3_2"]], + "NESTED_MIX": {"KEY1": "VAL_6_1", "KEY2": ["VAL_6_2_1", "VAL_6_2_2"]}, + "TUPLE": ("TUPLE_1", "TUPLE_2"), + } + ) actual_result = runner.extract_vars() - self.assertDictEqual({"T_VAR_1": "VAL_1", "T_VAR_2": "VAL_2", "T_VAR_3": "VAL_3"}, actual_result) + self.assertDictEqual( + { + "T_VAR_1": "VAL_1", + "T_VAR_2": "2", + "T_ARRAY_OF_STRINGS": '["VAL_3_1","VAL_3_2"]', + "T_ARRAY_OF_INTS": "[1,2,3]", + "T_ARRAY_OF_ARRAYS_OF_STRINGS": '[["VAL_4_1_1","VAL_4_1_2"],["VAL_4_2_1","VAL_4_2_2"]]', + "T_MIXED_ARRAY": '["VAL_5_1",2,["VAL_5_3_1","VAL_5_3_2"]]', + "T_NESTED_MIX": '{"KEY1": "VAL_6_1","KEY2": ["VAL_6_2_1","VAL_6_2_2"]}', + "T_TUPLE": '["TUPLE_1","TUPLE_2"]', + }, + actual_result, + ) @mock.patch("strong_opx.hcl.runner.os.listdir") @mock.patch("strong_opx.hcl.runner.HCLVariableExtractor")