From a48238c945dd1988a603ce3867f8c50b86ff80a2 Mon Sep 17 00:00:00 2001 From: Sanath Kumar Ramesh Date: Fri, 21 Sep 2018 13:29:11 -0700 Subject: [PATCH] feat(intrinsics): Support !Ref of template parameters (#657) --- samcli/cli/types.py | 68 ++++++ .../local/cli_common/invoke_context.py | 21 +- samcli/commands/local/cli_common/options.py | 7 + samcli/commands/local/invoke/cli.py | 15 +- .../commands/local/lib/local_api_service.py | 4 +- samcli/commands/local/lib/sam_api_provider.py | 4 +- .../commands/local/lib/sam_base_provider.py | 141 ++++++++++++- .../local/lib/sam_function_provider.py | 6 +- samcli/commands/local/start_api/cli.py | 13 +- samcli/commands/local/start_lambda/cli.py | 10 +- .../local/invoke/invoke_integ_base.py | 12 +- .../local/invoke/test_integrations_cli.py | 52 ++++- tests/integration/testdata/invoke/main.py | 6 +- .../integration/testdata/invoke/template.yml | 38 +++- tests/unit/cli/test_types.py | 97 +++++++++ .../local/cli_common/test_invoke_context.py | 5 +- tests/unit/commands/local/invoke/test_cli.py | 22 +- .../local/lib/test_local_api_service.py | 4 +- .../local/lib/test_sam_base_provider.py | 193 ++++++++++++++++++ .../local/lib/test_sam_function_provider.py | 12 +- .../unit/commands/local/start_api/test_cli.py | 7 +- .../commands/local/start_lambda/test_cli.py | 7 +- 22 files changed, 698 insertions(+), 46 deletions(-) create mode 100644 samcli/cli/types.py create mode 100644 tests/unit/cli/test_types.py create mode 100644 tests/unit/commands/local/lib/test_sam_base_provider.py diff --git a/samcli/cli/types.py b/samcli/cli/types.py new file mode 100644 index 0000000000..859e80d4b9 --- /dev/null +++ b/samcli/cli/types.py @@ -0,0 +1,68 @@ +""" +Implementation of custom click parameter types +""" + +import re +import click + + +class CfnParameterOverridesType(click.ParamType): + """ + Custom Click options type to accept values for CloudFormation template parameters. You can pass values for + parameters as "ParameterKey=KeyPairName,ParameterValue=MyKey ParameterKey=InstanceType,ParameterValue=t1.micro" + """ + + __EXAMPLE = "ParameterKey=KeyPairName,ParameterValue=MyKey ParameterKey=InstanceType,ParameterValue=t1.micro" + + # Regex that parses CloudFormation parameter key-value pairs: https://regex101.com/r/xqfSjW/2 + _pattern = r'(?:ParameterKey=([A-Za-z0-9\"]+),ParameterValue=(\"(?:\\.|[^\"\\]+)*\"|(?:\\.|[^ \"\\]+)+))' + + name = '' + + def convert(self, value, param, ctx): + result = {} + if not value: + return result + + groups = re.findall(self._pattern, value) + if not groups: + return self.fail( + "{} is not in valid format. It must look something like '{}'".format(value, self.__EXAMPLE), + param, + ctx + ) + + # 'groups' variable is a list of tuples ex: [(key1, value1), (key2, value2)] + for key, param_value in groups: + result[self._unquote(key)] = self._unquote(param_value) + + return result + + @staticmethod + def _unquote(value): + r""" + Removes wrapping double quotes and any '\ ' characters. They are usually added to preserve spaces when passing + value thru shell. + + Examples + -------- + >>> _unquote('val\ ue') + value + + >>> _unquote("hel\ lo") + hello + + Parameters + ---------- + value : str + Input to unquote + + Returns + ------- + Unquoted string + """ + if value and (value[0] == value[-1] == '"'): + # Remove quotes only if the string is wrapped in quotes + value = value.strip('"') + + return value.replace("\\ ", " ").replace('\\"', '"') diff --git a/samcli/commands/local/cli_common/invoke_context.py b/samcli/commands/local/cli_common/invoke_context.py index 3e2b4fa459..06f58e2a86 100644 --- a/samcli/commands/local/cli_common/invoke_context.py +++ b/samcli/commands/local/cli_common/invoke_context.py @@ -56,7 +56,8 @@ def __init__(self, debug_port=None, debug_args=None, debugger_path=None, - aws_region=None): + aws_region=None, + parameter_overrides=None): """ Initialize the context @@ -85,6 +86,13 @@ def __init__(self, Additional arguments passed to the debugger debugger_path str Path to the directory of the debugger to mount on Docker + aws_profile str + AWS Credential profile to use + aws_region str + AWS region to use + parameter_overrides dict + Values for the template parameters + """ self._template_file = template_file self._function_identifier = function_identifier @@ -98,6 +106,7 @@ def __init__(self, self._debug_port = debug_port self._debug_args = debug_args self._debugger_path = debugger_path + self._parameter_overrides = parameter_overrides or {} self._template_dict = None self._function_provider = None @@ -114,7 +123,7 @@ def __enter__(self): # Grab template from file and create a provider self._template_dict = self._get_template_data(self._template_file) - self._function_provider = SamFunctionProvider(self._template_dict) + self._function_provider = SamFunctionProvider(self._template_dict, self.parameter_overrides) self._env_vars_value = self._get_env_vars_value(self._env_vars_file) self._log_file_handle = self._setup_log_file(self._log_file) @@ -248,6 +257,14 @@ def get_cwd(self): return cwd + @property + def parameter_overrides(self): + # Override certain CloudFormation pseudo-parameters based on values provided by customer + if self._aws_region: + self._parameter_overrides["AWS::Region"] = self._aws_region + + return self._parameter_overrides + @staticmethod def _get_template_data(template_file): """ diff --git a/samcli/commands/local/cli_common/options.py b/samcli/commands/local/cli_common/options.py index aecb13bc02..477f535d88 100644 --- a/samcli/commands/local/cli_common/options.py +++ b/samcli/commands/local/cli_common/options.py @@ -4,6 +4,7 @@ import os import click +from samcli.cli.types import CfnParameterOverridesType _TEMPLATE_OPTION_DEFAULT_VALUE = "template.[yaml|yml]" @@ -103,6 +104,12 @@ def invoke_common_options(f): type=click.Path(exists=True), help="JSON file containing values for Lambda function's environment variables."), + click.option("--parameter-overrides", + type=CfnParameterOverridesType(), + help="Optional. A string that contains CloudFormation parameter overrides encoded as key=value " + "pairs. Use the same format as the AWS CLI, e.g. 'ParameterKey=KeyPairName," + "ParameterValue=MyKey ParameterKey=InstanceType,ParameterValue=t1.micro'"), + click.option('--debug-port', '-d', help="When specified, Lambda function container will start in debug mode and will expose this " "port on localhost.", diff --git a/samcli/commands/local/invoke/cli.py b/samcli/commands/local/invoke/cli.py index f8c7067d23..c35727b941 100644 --- a/samcli/commands/local/invoke/cli.py +++ b/samcli/commands/local/invoke/cli.py @@ -39,19 +39,21 @@ @invoke_common_options @cli_framework_options @click.argument('function_identifier', required=False) -@pass_context -def cli(ctx, function_identifier, template, event, no_event, env_vars, debug_port, debug_args, debugger_path, - docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region): +@pass_context # pylint: disable=R0914 +def cli(ctx, function_identifier, template, event, no_event, env_vars, debug_port, + debug_args, debugger_path, docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region, + parameter_overrides): # All logic must be implemented in the ``do_cli`` method. This helps with easy unit testing do_cli(ctx, function_identifier, template, event, no_event, env_vars, debug_port, debug_args, debugger_path, - docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region) # pragma: no cover + docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region, + parameter_overrides) # pragma: no cover def do_cli(ctx, function_identifier, template, event, no_event, env_vars, debug_port, # pylint: disable=R0914 debug_args, debugger_path, docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, - region): + region, parameter_overrides): """ Implementation of the ``cli`` method, just separated out for unit testing purposes """ @@ -81,7 +83,8 @@ def do_cli(ctx, function_identifier, template, event, no_event, env_vars, debug_ debug_port=debug_port, debug_args=debug_args, debugger_path=debugger_path, - aws_region=region) as context: + aws_region=region, + parameter_overrides=parameter_overrides) as context: # Invoke the function context.local_lambda_runner.invoke(context.function_name, diff --git a/samcli/commands/local/lib/local_api_service.py b/samcli/commands/local/lib/local_api_service.py index e0243192c8..ea82c1b60f 100644 --- a/samcli/commands/local/lib/local_api_service.py +++ b/samcli/commands/local/lib/local_api_service.py @@ -38,7 +38,9 @@ def __init__(self, self.static_dir = static_dir self.cwd = lambda_invoke_context.get_cwd() - self.api_provider = SamApiProvider(lambda_invoke_context.template, cwd=self.cwd) + self.api_provider = SamApiProvider(lambda_invoke_context.template, + parameter_overrides=lambda_invoke_context.parameter_overrides, + cwd=self.cwd) self.lambda_runner = lambda_invoke_context.local_lambda_runner self.stderr_stream = lambda_invoke_context.stderr diff --git a/samcli/commands/local/lib/sam_api_provider.py b/samcli/commands/local/lib/sam_api_provider.py index 901dfbd8ac..19b0559a3b 100644 --- a/samcli/commands/local/lib/sam_api_provider.py +++ b/samcli/commands/local/lib/sam_api_provider.py @@ -34,7 +34,7 @@ class SamApiProvider(ApiProvider): "OPTIONS", "PATCH"] - def __init__(self, template_dict, cwd=None): + def __init__(self, template_dict, parameter_overrides=None, cwd=None): """ Initialize the class with SAM template data. The template_dict (SAM Templated) is assumed to be valid, normalized and a dictionary. template_dict should be normalized by running any and all @@ -52,7 +52,7 @@ def __init__(self, template_dict, cwd=None): Optional working directory with respect to which we will resolve relative path to Swagger file """ - self.template_dict = SamBaseProvider.get_template(template_dict) + self.template_dict = SamBaseProvider.get_template(template_dict, parameter_overrides) self.resources = self.template_dict.get("Resources", {}) LOG.debug("%d resources found in the template", len(self.resources)) diff --git a/samcli/commands/local/lib/sam_base_provider.py b/samcli/commands/local/lib/sam_base_provider.py index 50c6f94b78..7b26f3dbb5 100644 --- a/samcli/commands/local/lib/sam_base_provider.py +++ b/samcli/commands/local/lib/sam_base_provider.py @@ -2,7 +2,14 @@ Base class for SAM Template providers """ +import logging + from samcli.lib.samlib.wrapper import SamTranslatorWrapper +from samtranslator.intrinsics.resolver import IntrinsicsResolver +from samtranslator.intrinsics.actions import RefAction + + +LOG = logging.getLogger(__name__) class SamBaseProvider(object): @@ -10,10 +17,142 @@ class SamBaseProvider(object): Base class for SAM Template providers """ + # There is not much benefit in infering real values for these parameters in local development context. These values + # are usually representative of an AWS environment and stack, but in local development scenario they don't make + # sense. If customers choose to, they can always override this value through the CLI interface. + _DEFAULT_PSEUDO_PARAM_VALUES = { + "AWS::AccountId": "123456789012", + "AWS::Partition": "aws", + + "AWS::Region": "us-east-1", + + "AWS::StackName": "local", + "AWS::StackId": "arn:aws:cloudformation:us-east-1:123456789012:stack/" + "local/51af3dc0-da77-11e4-872e-1234567db123", + "AWS::URLSuffix": "localhost" + } + + # Only Ref is supported when resolving template parameters + _SUPPORTED_INTRINSICS = [RefAction] + @staticmethod - def get_template(template_dict): + def get_template(template_dict, parameter_overrides=None): + """ + Given a SAM template dictionary, return a cleaned copy of the template where SAM plugins have been run + and parameter values have been substituted. + + Parameters + ---------- + template_dict : dict + unprocessed SAM template dictionary + + parameter_overrides: dict + Optional dictionary of values for template parameters + + Returns + ------- + dict + Processed SAM template + """ + template_dict = template_dict or {} if template_dict: template_dict = SamTranslatorWrapper(template_dict).run_plugins() + template_dict = SamBaseProvider._resolve_parameters(template_dict, parameter_overrides) return template_dict + + @staticmethod + def _resolve_parameters(template_dict, parameter_overrides): + """ + In the given template, apply parameter values to resolve intrinsic functions + + Parameters + ---------- + template_dict : dict + SAM Template + + parameter_overrides : dict + Values for template parameters provided by user + + Returns + ------- + dict + Resolved SAM template + """ + + parameter_values = SamBaseProvider._get_parameter_values(template_dict, parameter_overrides) + + supported_intrinsics = {action.intrinsic_name: action() for action in SamBaseProvider._SUPPORTED_INTRINSICS} + + # Intrinsics resolver will mutate the original template + return IntrinsicsResolver(parameters=parameter_values, supported_intrinsics=supported_intrinsics)\ + .resolve_parameter_refs(template_dict) + + @staticmethod + def _get_parameter_values(template_dict, parameter_overrides): + """ + Construct a final list of values for CloudFormation template parameters based on user-supplied values, + default values provided in template, and sane defaults for pseudo-parameters. + + Parameters + ---------- + template_dict : dict + SAM template dictionary + + parameter_overrides : dict + User-supplied values for CloudFormation template parameters + + Returns + ------- + dict + Values for template parameters to substitute in template with + """ + + default_values = SamBaseProvider._get_default_parameter_values(template_dict) + + # NOTE: Ordering of following statements is important. It makes sure that any user-supplied values + # override the defaults + parameter_values = {} + parameter_values.update(SamBaseProvider._DEFAULT_PSEUDO_PARAM_VALUES) + parameter_values.update(default_values) + parameter_values.update(parameter_overrides or {}) + + return parameter_values + + @staticmethod + def _get_default_parameter_values(sam_template): + """ + Method to read default values for template parameters and return it + Example: + If the template contains the following parameters defined + Parameters: + Param1: + Type: String + Default: default_value1 + Param2: + Type: String + Default: default_value2 + + then, this method will grab default value for Param1 and return the following result: + { + Param1: "default_value1", + Param2: "default_value2" + } + :param dict sam_template: SAM template + :return dict: Default values for parameters + """ + + default_values = {} + + parameter_definition = sam_template.get("Parameters", None) + if not parameter_definition or not isinstance(parameter_definition, dict): + LOG.debug("No Parameters detected in the template") + return default_values + + for param_name, value in parameter_definition.items(): + if isinstance(value, dict) and "Default" in value: + default_values[param_name] = value["Default"] + + LOG.debug("Collected default values for parameters: %s", default_values) + return default_values diff --git a/samcli/commands/local/lib/sam_function_provider.py b/samcli/commands/local/lib/sam_function_provider.py index 395c8961f2..c9f2cc1667 100644 --- a/samcli/commands/local/lib/sam_function_provider.py +++ b/samcli/commands/local/lib/sam_function_provider.py @@ -23,7 +23,7 @@ class SamFunctionProvider(FunctionProvider): _LAMBDA_FUNCTION = "AWS::Lambda::Function" _DEFAULT_CODEURI = "." - def __init__(self, template_dict): + def __init__(self, template_dict, parameter_overrides=None): """ Initialize the class with SAM template data. The SAM template passed to this provider is assumed to be valid, normalized and a dictionary. It should be normalized by running all pre-processing @@ -35,9 +35,11 @@ def __init__(self, template_dict): You need to explicitly update the class with new template, if necessary. :param dict template_dict: SAM Template as a dictionary + :param dict parameter_overrides: Optional dictionary of values for SAM template parameters that might want + to get substituted within the template """ - self.template_dict = SamBaseProvider.get_template(template_dict) + self.template_dict = SamBaseProvider.get_template(template_dict, parameter_overrides) self.resources = self.template_dict.get("Resources", {}) LOG.debug("%d resources found in the template", len(self.resources)) diff --git a/samcli/commands/local/start_api/cli.py b/samcli/commands/local/start_api/cli.py index f30177a584..c5144a3dac 100644 --- a/samcli/commands/local/start_api/cli.py +++ b/samcli/commands/local/start_api/cli.py @@ -38,7 +38,7 @@ help="Any static assets (e.g. CSS/Javascript/HTML) files located in this directory " "will be presented at /") @invoke_common_options -@cli_framework_options +@cli_framework_options # pylint: disable=R0914 @pass_context def cli(ctx, # start-api Specific Options @@ -46,16 +46,18 @@ def cli(ctx, # Common Options for Lambda Invoke template, env_vars, debug_port, debug_args, debugger_path, docker_volume_basedir, - docker_network, log_file, skip_pull_image, profile, region + docker_network, log_file, skip_pull_image, profile, region, parameter_overrides ): # All logic must be implemented in the ``do_cli`` method. This helps with easy unit testing do_cli(ctx, host, port, static_dir, template, env_vars, debug_port, debug_args, debugger_path, - docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region) # pragma: no cover + docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region, + parameter_overrides) # pragma: no cover def do_cli(ctx, host, port, static_dir, template, env_vars, debug_port, debug_args, # pylint: disable=R0914 - debugger_path, docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region): + debugger_path, docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region, + parameter_overrides): """ Implementation of the ``cli`` method, just separated out for unit testing purposes """ @@ -77,7 +79,8 @@ def do_cli(ctx, host, port, static_dir, template, env_vars, debug_port, debug_ar debug_port=debug_port, debug_args=debug_args, debugger_path=debugger_path, - aws_region=region) as invoke_context: + aws_region=region, + parameter_overrides=parameter_overrides) as invoke_context: service = LocalApiService(lambda_invoke_context=invoke_context, port=port, diff --git a/samcli/commands/local/start_lambda/cli.py b/samcli/commands/local/start_lambda/cli.py index 770b4113ca..b13d930c8d 100644 --- a/samcli/commands/local/start_lambda/cli.py +++ b/samcli/commands/local/start_lambda/cli.py @@ -59,16 +59,17 @@ def cli(ctx, # Common Options for Lambda Invoke template, env_vars, debug_port, debug_args, debugger_path, docker_volume_basedir, - docker_network, log_file, skip_pull_image, profile, region + docker_network, log_file, skip_pull_image, profile, region, parameter_overrides ): # All logic must be implemented in the ``do_cli`` method. This helps with easy unit testing do_cli(ctx, host, port, template, env_vars, debug_port, debug_args, debugger_path, docker_volume_basedir, - docker_network, log_file, skip_pull_image, profile, region) # pragma: no cover + docker_network, log_file, skip_pull_image, profile, region, parameter_overrides) # pragma: no cover def do_cli(ctx, host, port, template, env_vars, debug_port, debug_args, # pylint: disable=R0914 - debugger_path, docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region): + debugger_path, docker_volume_basedir, docker_network, log_file, skip_pull_image, profile, region, + parameter_overrides): """ Implementation of the ``cli`` method, just separated out for unit testing purposes """ @@ -90,7 +91,8 @@ def do_cli(ctx, host, port, template, env_vars, debug_port, debug_args, # pylin debug_port=debug_port, debug_args=debug_args, debugger_path=debugger_path, - aws_region=region) as invoke_context: + aws_region=region, + parameter_overrides=parameter_overrides) as invoke_context: service = LocalLambdaService(lambda_invoke_context=invoke_context, port=port, diff --git a/tests/integration/local/invoke/invoke_integ_base.py b/tests/integration/local/invoke/invoke_integ_base.py index a591835648..814562e1d3 100644 --- a/tests/integration/local/invoke/invoke_integ_base.py +++ b/tests/integration/local/invoke/invoke_integ_base.py @@ -28,7 +28,8 @@ def base_command(cls): return command - def get_command_list(self, function_to_invoke, template_path=None, event_path=None, env_var_path=None): + def get_command_list(self, function_to_invoke, template_path=None, event_path=None, env_var_path=None, + parameter_overrides=None, region=None): command_list = [self.cmd, "local", "invoke", function_to_invoke] if template_path: @@ -40,4 +41,13 @@ def get_command_list(self, function_to_invoke, template_path=None, event_path=No if env_var_path: command_list = command_list + ["-n", env_var_path] + if parameter_overrides: + arg_value = " ".join([ + "ParameterKey={},ParameterValue={}".format(key, value) for key, value in parameter_overrides.items() + ]) + command_list = command_list + ["--parameter-overrides", arg_value] + + if region: + command_list = command_list + ["--region", region] + return command_list diff --git a/tests/integration/local/invoke/test_integrations_cli.py b/tests/integration/local/invoke/test_integrations_cli.py index 39d089fbd4..27606225c1 100644 --- a/tests/integration/local/invoke/test_integrations_cli.py +++ b/tests/integration/local/invoke/test_integrations_cli.py @@ -1,3 +1,6 @@ +import json + +from nose_parameterized import parameterized from subprocess import Popen, PIPE from timeit import default_timer as timer @@ -36,8 +39,12 @@ def test_invoke_of_lambda_function(self): process_stdout = b"".join(process.stdout.readlines()).strip() self.assertEquals(process_stdout.decode('utf-8'), '"Hello world"') - def test_invoke_with_timeout_set(self): - command_list = self.get_command_list("TimeoutFunction", + @parameterized.expand([ + ("TimeoutFunction"), + ("TimeoutFunctionWithParameter"), + ]) + def test_invoke_with_timeout_set(self, function_name): + command_list = self.get_command_list(function_name, template_path=self.template_path, event_path=self.event_path) @@ -116,3 +123,44 @@ def test_invoke_raises_exception_with_noargs_and_event(self): process_stderr = b"".join(process.stderr.readlines()).strip() error_output = process_stderr.decode('utf-8') self.assertIn("no_event and event cannot be used together. Please provide only one.", error_output) + + def test_invoke_with_env_using_parameters(self): + command_list = self.get_command_list("EchoEnvWithParameters", + template_path=self.template_path, + event_path=self.event_path, + parameter_overrides={ + "MyRuntimeVersion": "v0", + "DefaultTimeout": "100" + }) + + process = Popen(command_list, stdout=PIPE) + process.wait() + process_stdout = b"".join(process.stdout.readlines()).strip() + environ = json.loads(process_stdout.decode('utf-8')) + + self.assertEquals(environ["Region"], "us-east-1") + self.assertEquals(environ["AccountId"], "123456789012") + self.assertEquals(environ["Partition"], "aws") + self.assertEquals(environ["StackName"], "local") + self.assertEquals(environ["StackId"], "arn:aws:cloudformation:us-east-1:123456789012:stack/" + "local/51af3dc0-da77-11e4-872e-1234567db123",) + + self.assertEquals(environ["URLSuffix"], "localhost") + self.assertEquals(environ["Timeout"], "100") + self.assertEquals(environ["MyRuntimeVersion"], "v0") + + def test_invoke_with_env_using_parameters_with_custom_region(self): + custom_region = "my-custom-region" + + command_list = self.get_command_list("EchoEnvWithParameters", + template_path=self.template_path, + event_path=self.event_path, + region=custom_region + ) + + process = Popen(command_list, stdout=PIPE) + process.wait() + process_stdout = b"".join(process.stdout.readlines()).strip() + environ = json.loads(process_stdout.decode('utf-8')) + + self.assertEquals(environ["Region"], custom_region) diff --git a/tests/integration/testdata/invoke/main.py b/tests/integration/testdata/invoke/main.py index f0d0262966..68d432f510 100644 --- a/tests/integration/testdata/invoke/main.py +++ b/tests/integration/testdata/invoke/main.py @@ -19,10 +19,14 @@ def sleep_handler(event, context): return "Slept for 10s" -def env_var_echo_hanler(event, context): +def custom_env_var_echo_hanler(event, context): return os.environ.get("CustomEnvVar") +def env_var_echo_hanler(event, context): + return dict(os.environ) + + def write_to_stderr(event, context): sys.stderr.write("Docker Lambda is writing to stderr") diff --git a/tests/integration/testdata/invoke/template.yml b/tests/integration/testdata/invoke/template.yml index added8ab7c..46a0c972e5 100644 --- a/tests/integration/testdata/invoke/template.yml +++ b/tests/integration/testdata/invoke/template.yml @@ -1,6 +1,15 @@ AWSTemplateFormatVersion : '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: A hello world application. + +Parameters: + DefaultTimeout: + Default: 5 + Type: Number + + MyRuntimeVersion: + Type: String + Resources: HelloWorldServerlessFunction: Type: AWS::Serverless::Function @@ -36,7 +45,7 @@ Resources: EchoCustomEnvVarFunction: Type: AWS::Serverless::Function Properties: - Handler: main.env_var_echo_hanler + Handler: main.custom_env_var_echo_hanler Runtime: python3.6 CodeUri: . Environment: @@ -70,3 +79,30 @@ Resources: Handler: main.raise_exception Runtime: python3.6 CodeUri: . + + TimeoutFunctionWithParameter: + Type: AWS::Serverless::Function + Properties: + Handler: main.sleep_handler + Runtime: python3.6 + CodeUri: . + Timeout: + Ref: DefaultTimeout + + EchoEnvWithParameters: + Type: AWS::Serverless::Function + Properties: + Handler: main.env_var_echo_hanler + Runtime: python3.6 + CodeUri: . + Environment: + Variables: + Region: !Ref "AWS::Region" + AccountId: !Ref "AWS::AccountId" + Partition: !Ref "AWS::Partition" + StackName: !Ref "AWS::StackName" + StackId: !Ref "AWS::StackId" + URLSuffix: !Ref "AWS::URLSuffix" + Timeout: !Ref DefaultTimeout + MyRuntimeVersion: !Ref MyRuntimeVersion + diff --git a/tests/unit/cli/test_types.py b/tests/unit/cli/test_types.py new file mode 100644 index 0000000000..966534b828 --- /dev/null +++ b/tests/unit/cli/test_types.py @@ -0,0 +1,97 @@ + +from unittest import TestCase +from mock import Mock, ANY +from nose_parameterized import parameterized + +from samcli.cli.types import CfnParameterOverridesType + + +class TestCfnParameterOverridesType(TestCase): + + def setUp(self): + self.param_type = CfnParameterOverridesType() + + @parameterized.expand([ + ("some string"), + + # Key must not contain spaces + ('ParameterKey="Ke y",ParameterValue=Value'), + + # No value + ('ParameterKey=Key,ParameterValue='), + + # No key + ('ParameterKey=,ParameterValue=Value'), + + # Case sensitive + ('parameterkey=Key,ParameterValue=Value'), + + # No space after comma + ('ParameterKey=Key, ParameterValue=Value'), + + # Bad separator + ('ParameterKey:Key,ParameterValue:Value') + ]) + def test_must_fail_on_invalid_format(self, input): + self.param_type.fail = Mock() + self.param_type.convert(input, "param", "ctx") + + self.param_type.fail.assert_called_with(ANY, "param", "ctx") + + @parameterized.expand([ + ( + "ParameterKey=KeyPairName,ParameterValue=MyKey ParameterKey=InstanceType,ParameterValue=t1.micro", + {"KeyPairName": "MyKey", "InstanceType": "t1.micro"}, + + ), + ( + 'ParameterKey="Key",ParameterValue=Val\\ ue', + {"Key": "Val ue"}, + ), + ( + 'ParameterKey="Key",ParameterValue="Val\\"ue"', + {"Key": 'Val"ue'}, + ), + ( + 'ParameterKey=Key,ParameterValue=Value', + {"Key": 'Value'}, + ), + ( + 'ParameterKey=Key,ParameterValue=""', + {"Key": ''}, + ), + ( + # Trailing and leading whitespaces + ' ParameterKey=Key,ParameterValue=Value ParameterKey=Key2,ParameterValue=Value2 ', + {"Key": 'Value', 'Key2': 'Value2'}, + ), + ( + # Quotes at the end + 'ParameterKey=Key,ParameterValue=Value\\"', + {"Key": 'Value"'}, + ), + ( + # Quotes at the start + 'ParameterKey=Key,ParameterValue=\\"Value', + {"Key": '"Value'}, + ), + ( + # Value is spacial characters + 'ParameterKey=Key,ParameterValue==-_)(*&^%$#@!`~:;,. ParameterKey=Key2,ParameterValue=Value2', + {"Key": "=-_)(*&^%$#@!`~:;,.", "Key2": 'Value2'}, + ), + ( + 'ParameterKey=Key1230,ParameterValue="{\\"a\\":\\"b\\"}"', + {"Key1230": '{"a":"b"}'}, + ), + + ( + # Must ignore empty inputs + "", + {} + ) + + ]) + def test_successful_parsing(self, input, expected): + result = self.param_type.convert(input, None, None) + self.assertEquals(result, expected, msg="Failed with Input = " + input) diff --git a/tests/unit/commands/local/cli_common/test_invoke_context.py b/tests/unit/commands/local/cli_common/test_invoke_context.py index 253cbda615..ba596377f3 100644 --- a/tests/unit/commands/local/cli_common/test_invoke_context.py +++ b/tests/unit/commands/local/cli_common/test_invoke_context.py @@ -40,7 +40,8 @@ def test_must_read_from_necessary_files(self, SamFunctionProviderMock): debug_port=1111, debugger_path="path-to-debugger", debug_args='args', - aws_region="region") + aws_region="region", + parameter_overrides={}) template_dict = "template_dict" invoke_context._get_template_data = Mock() @@ -71,7 +72,7 @@ def test_must_read_from_necessary_files(self, SamFunctionProviderMock): self.assertEquals(invoke_context._debug_context, debug_context_mock) invoke_context._get_template_data.assert_called_with(template_file) - SamFunctionProviderMock.assert_called_with(template_dict) + SamFunctionProviderMock.assert_called_with(template_dict, {"AWS::Region": "region"}) invoke_context._get_env_vars_value.assert_called_with(env_vars_file) invoke_context._setup_log_file.assert_called_with(log_file) invoke_context._get_debug_context.assert_called_once_with(1111, "args", "path-to-debugger") diff --git a/tests/unit/commands/local/invoke/test_cli.py b/tests/unit/commands/local/invoke/test_cli.py index b8bf4f1b81..7b3a907b3c 100644 --- a/tests/unit/commands/local/invoke/test_cli.py +++ b/tests/unit/commands/local/invoke/test_cli.py @@ -31,6 +31,7 @@ def setUp(self): self.profile = "profile" self.no_event = False self.region = "region" + self.parameter_overrides = {} @patch("samcli.commands.local.invoke.cli.InvokeContext") @patch("samcli.commands.local.invoke.cli._get_event") @@ -56,7 +57,8 @@ def test_cli_must_setup_context_and_invoke(self, get_event_mock, InvokeContextMo log_file=self.log_file, skip_pull_image=self.skip_pull_image, profile=self.profile, - region=self.region) + region=self.region, + parameter_overrides=self.parameter_overrides) InvokeContextMock.assert_called_with(template_file=self.template, function_identifier=self.function_id, @@ -69,7 +71,8 @@ def test_cli_must_setup_context_and_invoke(self, get_event_mock, InvokeContextMo debug_port=self.debug_port, debug_args=self.debug_args, debugger_path=self.debugger_path, - aws_region=self.region) + aws_region=self.region, + parameter_overrides=self.parameter_overrides) context_mock.local_lambda_runner.invoke.assert_called_with(context_mock.function_name, event=event_data, @@ -98,7 +101,8 @@ def test_cli_must_invoke_with_no_event(self, get_event_mock, InvokeContextMock): log_file=self.log_file, skip_pull_image=self.skip_pull_image, profile=self.profile, - region=self.region) + region=self.region, + parameter_overrides=self.parameter_overrides) InvokeContextMock.assert_called_with(template_file=self.template, function_identifier=self.function_id, @@ -111,7 +115,8 @@ def test_cli_must_invoke_with_no_event(self, get_event_mock, InvokeContextMock): debug_port=self.debug_port, debug_args=self.debug_args, debugger_path=self.debugger_path, - aws_region=self.region) + aws_region=self.region, + parameter_overrides=self.parameter_overrides) context_mock.local_lambda_runner.invoke.assert_called_with(context_mock.function_name, event="{}", @@ -140,7 +145,8 @@ def test_must_raise_user_exception_on_no_event_and_event(self, get_event_mock, I log_file=self.log_file, skip_pull_image=self.skip_pull_image, profile=self.profile, - region=self.region) + region=self.region, + parameter_overrides=self.parameter_overrides) msg = str(ex_ctx.exception) self.assertEquals(msg, "no_event and event cannot be used together. Please provide only one.") @@ -173,7 +179,8 @@ def test_must_raise_user_exception_on_function_not_found(self, get_event_mock, I log_file=self.log_file, skip_pull_image=self.skip_pull_image, profile=self.profile, - region=self.region) + region=self.region, + parameter_overrides=self.parameter_overrides) msg = str(ex_ctx.exception) self.assertEquals(msg, "Function {} not found in template".format(self.function_id)) @@ -202,7 +209,8 @@ def test_must_raise_user_exception_on_invalid_sam_template(self, get_event_mock, log_file=self.log_file, skip_pull_image=self.skip_pull_image, profile=self.profile, - region=self.region) + region=self.region, + parameter_overrides=self.parameter_overrides) msg = str(ex_ctx.exception) self.assertEquals(msg, "bad template") diff --git a/tests/unit/commands/local/lib/test_local_api_service.py b/tests/unit/commands/local/lib/test_local_api_service.py index d018ad0525..dae11f6ddc 100644 --- a/tests/unit/commands/local/lib/test_local_api_service.py +++ b/tests/unit/commands/local/lib/test_local_api_service.py @@ -58,7 +58,9 @@ def test_must_start_service(self, local_service.start() # Make sure the right methods are called - SamApiProviderMock.assert_called_with(self.template, cwd=self.cwd) + SamApiProviderMock.assert_called_with(self.template, + cwd=self.cwd, + parameter_overrides=self.lambda_invoke_context_mock.parameter_overrides) make_routing_list_mock.assert_called_with(self.api_provider_mock) log_routes_mock.assert_called_with(self.api_provider_mock, self.host, self.port) diff --git a/tests/unit/commands/local/lib/test_sam_base_provider.py b/tests/unit/commands/local/lib/test_sam_base_provider.py new file mode 100644 index 0000000000..001a217058 --- /dev/null +++ b/tests/unit/commands/local/lib/test_sam_base_provider.py @@ -0,0 +1,193 @@ + +from unittest import TestCase +from mock import Mock, patch +from nose_parameterized import parameterized + +from samcli.commands.local.lib.sam_base_provider import SamBaseProvider + + +class TestSamBaseProvider_resolve_parameters(TestCase): + + @parameterized.expand([ + ("AWS::AccountId", "123456789012"), + ("AWS::Partition", "aws"), + ("AWS::Region", "us-east-1"), + ("AWS::StackName", "local"), + ("AWS::StackId", "arn:aws:cloudformation:us-east-1:123456789012:stack/" + "local/51af3dc0-da77-11e4-872e-1234567db123"), + ("AWS::URLSuffix", "localhost"), + ]) + def test_with_pseudo_parameters(self, parameter, expected_value): + + template_dict = { + "Key": { + "Ref": parameter + } + } + + expected_template = { + "Key": expected_value + } + + result = SamBaseProvider._resolve_parameters(template_dict, {}) + self.assertEquals(result, expected_template) + + def test_override_pseudo_parameters(self): + template = { + "Key": { + "Ref": "AWS::Region" + } + } + + override = { + "AWS::Region": "someregion" + } + + expected_template = { + "Key": "someregion" + } + + self.assertEquals(SamBaseProvider._resolve_parameters(template, override), + expected_template) + + def test_parameter_with_defaults(self): + override = {} # No overrides + + template = { + "Parameters": { + "Key1": { + "Default": "Value1" + }, + "Key2": { + "Default": "Value2" + }, + "NoDefaultKey3": {} # No Default + }, + + "Resources": { + "R1": {"Ref": "Key1"}, + "R2": {"Ref": "Key2"}, + "R3": {"Ref": "NoDefaultKey3"} + } + } + + expected_template = { + "Parameters": { + "Key1": { + "Default": "Value1" + }, + "Key2": { + "Default": "Value2" + }, + "NoDefaultKey3": {} # No Default + }, + + "Resources": { + "R1": "Value1", + "R2": "Value2", + "R3": {"Ref": "NoDefaultKey3"} # No default value. so no subsitution + } + } + + self.assertEquals(SamBaseProvider._resolve_parameters(template, override), + expected_template) + + def test_override_parameters(self): + template = { + "Parameters": { + "Key1": { + "Default": "Value1" + }, + "Key2": { + "Default": "Value2" + }, + "NoDefaultKey3": {}, + + "NoOverrideKey4": {} # No override Value provided + }, + + "Resources": { + "R1": {"Ref": "Key1"}, + "R2": {"Ref": "Key2"}, + "R3": {"Ref": "NoDefaultKey3"}, + "R4": {"Ref": "NoOverrideKey4"} + } + } + + override = { + "Key1": "OverrideValue1", + "Key2": "OverrideValue2", + "NoDefaultKey3": "OverrideValue3" + } + + expected_template = { + "Parameters": { + "Key1": { + "Default": "Value1" + }, + "Key2": { + "Default": "Value2" + }, + "NoDefaultKey3": {}, + "NoOverrideKey4": {} # No override Value provided + }, + + "Resources": { + "R1": "OverrideValue1", + "R2": "OverrideValue2", + "R3": "OverrideValue3", + "R4": {"Ref": "NoOverrideKey4"} + } + } + + self.assertEquals(SamBaseProvider._resolve_parameters(template, override), + expected_template) + + def test_must_skip_non_ref_intrinsics(self): + template = { + "Key1": {"Fn::Sub": ["${AWS::Region}"]}, # Sub is not implemented + "Key2": {"Ref": "MyParam"} + } + + override = {"MyParam": "MyValue"} + + expected_template = { + "Key1": {"Fn::Sub": ["${AWS::Region}"]}, + "Key2": "MyValue" + } + + self.assertEquals(SamBaseProvider._resolve_parameters(template, override), + expected_template) + + def test_must_skip_empty_overrides(self): + template = {"Key": {"Ref": "Param"}} + override = None + expected_template = {"Key": {"Ref": "Param"}} + + self.assertEquals(SamBaseProvider._resolve_parameters(template, override), + expected_template) + + def test_must_skip_empty_template(self): + template = {} + override = None + expected_template = {} + + self.assertEquals(SamBaseProvider._resolve_parameters(template, override), + expected_template) + + +class TestSamBaseProvider_get_template(TestCase): + + @patch("samcli.commands.local.lib.sam_base_provider.SamTranslatorWrapper") + @patch.object(SamBaseProvider, "_resolve_parameters") + def test_must_run_translator_plugins(self, resolve_params_mock, SamTranslatorWrapperMock): + translator_instance = SamTranslatorWrapperMock.return_value = Mock() + + template = {"Key": "Value"} + overrides = {'some': 'value'} + + SamBaseProvider.get_template(template, overrides) + + SamTranslatorWrapperMock.assert_called_once_with(template) + translator_instance.run_plugins.assert_called_once() + resolve_params_mock.assert_called_once() diff --git a/tests/unit/commands/local/lib/test_sam_function_provider.py b/tests/unit/commands/local/lib/test_sam_function_provider.py index ec50a18de8..3c16573bd1 100644 --- a/tests/unit/commands/local/lib/test_sam_function_provider.py +++ b/tests/unit/commands/local/lib/test_sam_function_provider.py @@ -67,7 +67,8 @@ class TestSamFunctionProviderEndToEnd(TestCase): EXPECTED_FUNCTIONS = ["SamFunc1", "SamFunc2", "SamFunc3", "LambdaFunc1"] def setUp(self): - self.provider = SamFunctionProvider(self.TEMPLATE) + self.parameter_overrides = {} + self.provider = SamFunctionProvider(self.TEMPLATE, parameter_overrides=self.parameter_overrides) @parameterized.expand([ ("SamFunc1", Function( @@ -126,6 +127,9 @@ def test_get_all_must_return_all_functions(self): class TestSamFunctionProvider_init(TestCase): + def setUp(self): + self.parameter_overrides = {} + @patch.object(SamFunctionProvider, "_extract_functions") @patch("samcli.commands.local.lib.sam_function_provider.SamBaseProvider") def test_must_extract_functions(self, SamBaseProviderMock, extract_mock): @@ -134,10 +138,10 @@ def test_must_extract_functions(self, SamBaseProviderMock, extract_mock): template = {"Resources": {"a": "b"}} SamBaseProviderMock.get_template.return_value = template - provider = SamFunctionProvider(template) + provider = SamFunctionProvider(template, parameter_overrides=self.parameter_overrides) extract_mock.assert_called_with({"a": "b"}) - SamBaseProviderMock.get_template.assert_called_with(template) + SamBaseProviderMock.get_template.assert_called_with(template, self.parameter_overrides) self.assertEquals(provider.functions, extract_result) @patch.object(SamFunctionProvider, "_extract_functions") @@ -148,7 +152,7 @@ def test_must_default_to_empty_resources(self, SamBaseProviderMock, extract_mock template = {"a": "b"} # Template does *not* have 'Resources' key SamBaseProviderMock.get_template.return_value = template - provider = SamFunctionProvider(template) + provider = SamFunctionProvider(template, parameter_overrides=self.parameter_overrides) extract_mock.assert_called_with({}) # Empty Resources value must be passed self.assertEquals(provider.functions, extract_result) diff --git a/tests/unit/commands/local/start_api/test_cli.py b/tests/unit/commands/local/start_api/test_cli.py index c4ef3a8665..eaaacd0e28 100644 --- a/tests/unit/commands/local/start_api/test_cli.py +++ b/tests/unit/commands/local/start_api/test_cli.py @@ -25,6 +25,7 @@ def setUp(self): self.skip_pull_image = True self.profile = "profile" self.region = "region" + self.parameter_overrides = {} self.host = "host" self.port = 123 @@ -54,7 +55,8 @@ def test_cli_must_setup_context_and_start_service(self, local_api_service_mock, debug_port=self.debug_port, debug_args=self.debug_args, debugger_path=self.debugger_path, - aws_region=self.region) + aws_region=self.region, + parameter_overrides=self.parameter_overrides) local_api_service_mock.assert_called_with(lambda_invoke_context=context_mock, port=self.port, @@ -109,4 +111,5 @@ def call_cli(self): log_file=self.log_file, skip_pull_image=self.skip_pull_image, profile=self.profile, - region=self.region) + region=self.region, + parameter_overrides=self.parameter_overrides) diff --git a/tests/unit/commands/local/start_lambda/test_cli.py b/tests/unit/commands/local/start_lambda/test_cli.py index 1e2747482b..9cfba83d9e 100644 --- a/tests/unit/commands/local/start_lambda/test_cli.py +++ b/tests/unit/commands/local/start_lambda/test_cli.py @@ -20,6 +20,7 @@ def setUp(self): self.skip_pull_image = True self.profile = "profile" self.region = "region" + self.parameter_overrides = {} self.host = "host" self.port = 123 @@ -48,7 +49,8 @@ def test_cli_must_setup_context_and_start_service(self, local_lambda_service_moc debug_port=self.debug_port, debug_args=self.debug_args, debugger_path=self.debugger_path, - aws_region=self.region) + aws_region=self.region, + parameter_overrides=self.parameter_overrides) local_lambda_service_mock.assert_called_with(lambda_invoke_context=context_mock, port=self.port, @@ -81,4 +83,5 @@ def call_cli(self): log_file=self.log_file, skip_pull_image=self.skip_pull_image, profile=self.profile, - region=self.region) + region=self.region, + parameter_overrides=self.parameter_overrides)