From c50c19d5ba6a2b4ef1601d08fa4e566660577cf9 Mon Sep 17 00:00:00 2001 From: Mehmet Nuri Deveci <5735811+mndeveci@users.noreply.github.com> Date: Thu, 15 Jul 2021 15:15:54 -0700 Subject: [PATCH] feat: sam test command (lambda + sqs) (#364) * refactor for test command + some unit test fixes * remove some test command changes which accidentally went into refactor one * remove some test command changes which accidentally went into refactor one * sam test implementation and some groundwork * when neither payload nor payload_file is provided, it will use sys.stdin to read the payload * fix typo * use protocol instead of complex callable * use utf-8 for encoding * fix typo * use protocol instead of complex callable * address comments * address comments * feat(Accelerate): CI Fixes (#368) * Reverted pylint to 2.6.x * Disables PyLint for Generic * Updated boto3-stubs * Fixed Another PyLint Generic Issue * Fixed Test Typing Issues * Fixed Python3.7 and 3.8 re.escape Test Issue * Added ECR Login * fix unit tests * address comments * address comments * address comments * update log message * flip the flag back once we have new log group is created * feat: sam test lambda implementation (#366) * sam test lambda implementation * when neither payload nor payload_file is provided, it will use sys.stdin to read the payload * use utf-8 for encoding * fix typo * use protocol instead of complex callable * address comments & update with ability to pipe file input into test execution * feat: sam test sqs implementation (#367) * sam test sqs implementation * when neither payload nor payload_file is provided, it will use sys.stdin to read the payload * use utf-8 for encoding * fix typo * use protocol instead of complex callable * address comments & update with ability to pipe file input into test execution * address comments & update sqs test executor * added extra debug logging * address comments * address comments * make black Co-authored-by: Cosh_ --- samcli/cli/command.py | 1 + samcli/commands/_utils/resources.py | 1 + samcli/commands/deploy/deploy_context.py | 2 +- samcli/commands/logs/command.py | 15 +- samcli/commands/logs/logs_context.py | 34 +-- samcli/commands/logs/puller_factory.py | 31 +-- samcli/commands/package/package_context.py | 2 +- samcli/commands/test/__init__.py | 4 + samcli/commands/test/command.py | 135 +++++++++++ samcli/commands/traces/command.py | 2 +- .../cli_validation/payload_file_validation.py | 46 ++++ .../observability/cw_logs/cw_log_puller.py | 14 +- samcli/lib/sync/sync_flow_factory.py | 20 +- samcli/lib/test/__init__.py | 0 samcli/lib/test/lambda_test_executor.py | 68 ++++++ samcli/lib/test/sqs_test_executor.py | 55 +++++ samcli/lib/test/test_executor_factory.py | 83 +++++++ samcli/lib/test/test_executors.py | 215 ++++++++++++++++++ samcli/lib/utils/boto_utils.py | 84 +++++++ samcli/lib/utils/botoconfig.py | 17 -- samcli/lib/utils/cloudformation.py | 127 +++++++++++ tests/unit/commands/logs/test_command.py | 36 +-- tests/unit/commands/logs/test_logs_context.py | 124 ++++------ .../unit/commands/logs/test_puller_factory.py | 13 +- tests/unit/commands/sync/test_command.py | 6 + tests/unit/commands/test/__init__.py | 0 tests/unit/commands/test/test_command.py | 136 +++++++++++ tests/unit/commands/traces/test_command.py | 2 +- .../test_payload_file_validation.py | 49 ++++ .../cw_logs/test_cw_log_puller.py | 33 +++ tests/unit/lib/sync/test_sync_flow_factory.py | 40 +--- .../lib/test/test_lambda_test_executor.py | 68 ++++++ tests/unit/lib/test/test_sqs_test_executor.py | 65 ++++++ .../lib/test/test_test_executor_factory.py | 106 +++++++++ tests/unit/lib/test/test_test_executors.py | 165 ++++++++++++++ tests/unit/lib/utils/test_boto_utils.py | 75 ++++++ tests/unit/lib/utils/test_cloudformation.py | 119 ++++++++++ 37 files changed, 1768 insertions(+), 225 deletions(-) create mode 100644 samcli/commands/test/__init__.py create mode 100644 samcli/commands/test/command.py create mode 100644 samcli/lib/cli_validation/payload_file_validation.py create mode 100644 samcli/lib/test/__init__.py create mode 100644 samcli/lib/test/lambda_test_executor.py create mode 100644 samcli/lib/test/sqs_test_executor.py create mode 100644 samcli/lib/test/test_executor_factory.py create mode 100644 samcli/lib/test/test_executors.py create mode 100644 samcli/lib/utils/boto_utils.py delete mode 100644 samcli/lib/utils/botoconfig.py create mode 100644 samcli/lib/utils/cloudformation.py create mode 100644 tests/unit/commands/test/__init__.py create mode 100644 tests/unit/commands/test/test_command.py create mode 100644 tests/unit/lib/cli_validation/test_payload_file_validation.py create mode 100644 tests/unit/lib/test/test_lambda_test_executor.py create mode 100644 tests/unit/lib/test/test_sqs_test_executor.py create mode 100644 tests/unit/lib/test/test_test_executor_factory.py create mode 100644 tests/unit/lib/test/test_test_executors.py create mode 100644 tests/unit/lib/utils/test_boto_utils.py create mode 100644 tests/unit/lib/utils/test_cloudformation.py diff --git a/samcli/cli/command.py b/samcli/cli/command.py index 5792a7e7ba..cfa867df4f 100644 --- a/samcli/cli/command.py +++ b/samcli/cli/command.py @@ -23,6 +23,7 @@ "samcli.commands.publish", "samcli.commands.traces", "samcli.commands.sync", + "samcli.commands.test", # We intentionally do not expose the `bootstrap` command for now. We might open it up later # "samcli.commands.bootstrap", ] diff --git a/samcli/commands/_utils/resources.py b/samcli/commands/_utils/resources.py index b2fedf6d61..779ce41333 100644 --- a/samcli/commands/_utils/resources.py +++ b/samcli/commands/_utils/resources.py @@ -24,6 +24,7 @@ AWS_GLUE_JOB = "AWS::Glue::Job" AWS_SERVERLESS_STATEMACHINE = "AWS::Serverless::StateMachine" AWS_STEPFUNCTIONS_STATEMACHINE = "AWS::StepFunctions::StateMachine" +AWS_SQS_QUEUE = "AWS::SQS::Queue" METADATA_WITH_LOCAL_PATHS = {AWS_SERVERLESSREPO_APPLICATION: ["LicenseUrl", "ReadmeUrl"]} diff --git a/samcli/commands/deploy/deploy_context.py b/samcli/commands/deploy/deploy_context.py index e4db64b413..2a2f0f913e 100644 --- a/samcli/commands/deploy/deploy_context.py +++ b/samcli/commands/deploy/deploy_context.py @@ -32,7 +32,7 @@ from samcli.lib.deploy.deployer import Deployer from samcli.lib.package.s3_uploader import S3Uploader from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider -from samcli.lib.utils.botoconfig import get_boto_config_with_user_agent +from samcli.lib.utils.boto_utils import get_boto_config_with_user_agent from samcli.yamlhelper import yaml_parse LOG = logging.getLogger(__name__) diff --git a/samcli/commands/logs/command.py b/samcli/commands/logs/command.py index c8bfbc4a25..b899170675 100644 --- a/samcli/commands/logs/command.py +++ b/samcli/commands/logs/command.py @@ -114,29 +114,24 @@ def do_cli( """ Implementation of the ``cli`` method """ - import boto3 from datetime import datetime - from typing import Callable, Any from samcli.commands.logs.logs_context import parse_time, ResourcePhysicalIdResolver from samcli.commands.logs.puller_factory import generate_puller - from samcli.lib.utils.botoconfig import get_boto_config_with_user_agent + from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config, get_boto_resource_provider_with_config sanitized_start_time = parse_time(start_time, "start-time") sanitized_end_time = parse_time(end_time, "end-time") or datetime.utcnow() - boto_config = get_boto_config_with_user_agent(region_name=region) - logs_client_generator: Callable[[], Any] = lambda: boto3.session.Session().client("logs", config=boto_config) - cfn_resource = boto3.resource("cloudformation", config=boto_config) - xray_client = boto3.client("xray", config=boto_config) if include_tracing else None - resource_logical_id_resolver = ResourcePhysicalIdResolver(cfn_resource, stack_name, names) + boto_client_provider = get_boto_client_provider_with_config(region_name=region) + boto_resource_provider = get_boto_resource_provider_with_config(region_name=region) + resource_logical_id_resolver = ResourcePhysicalIdResolver(boto_resource_provider, stack_name, names) # only fetch all resources when no CloudWatch log group defined fetch_all_when_no_resource_name_given = not cw_log_groups puller = generate_puller( - logs_client_generator, - xray_client, + boto_client_provider, resource_logical_id_resolver.get_resource_information(fetch_all_when_no_resource_name_given), filter_pattern, cw_log_groups, diff --git a/samcli/commands/logs/logs_context.py b/samcli/commands/logs/logs_context.py index 9a15a56bac..077661998b 100644 --- a/samcli/commands/logs/logs_context.py +++ b/samcli/commands/logs/logs_context.py @@ -7,6 +7,8 @@ from samcli.commands._utils.resources import AWS_LAMBDA_FUNCTION, AWS_APIGATEWAY_RESTAPI, AWS_APIGATEWAY_HTTPAPI from samcli.commands.exceptions import UserException +from samcli.lib.utils.boto_utils import BotoProviderType +from samcli.lib.utils.cloudformation import get_resource_summaries from samcli.lib.utils.time import to_utc, parse_date LOG = logging.getLogger(__name__) @@ -60,12 +62,12 @@ class ResourcePhysicalIdResolver: def __init__( self, - cfn_resource: Any, + boto_resource_provider: BotoProviderType, stack_name: str, resource_names: Optional[List[str]] = None, supported_resource_types: Optional[Set[str]] = None, ): - self._cfn_resource = cfn_resource + self._boto_resource_provider = boto_resource_provider self._stack_name = stack_name if resource_names is None: resource_names = [] @@ -112,33 +114,17 @@ def _fetch_resources_from_stack(self, selected_resource_names: Optional[Set[str] """ results = [] LOG.debug("Getting logical id of the all resources for stack '%s'", self._stack_name) - stack_resources = self._get_stack_resources() + stack_resources = get_resource_summaries( + self._boto_resource_provider, self._stack_name, ResourcePhysicalIdResolver.DEFAULT_SUPPORTED_RESOURCES + ) if selected_resource_names is None: - selected_resource_names = {stack_resource.logical_id for stack_resource in stack_resources} + selected_resource_names = {stack_resource.logical_resource_id for stack_resource in stack_resources} for resource in stack_resources: # if resource name is not selected, continue - if resource.logical_id not in selected_resource_names: - LOG.debug("Resource (%s) is not selected with given input", resource.logical_id) - continue - # if resource type is not supported, continue - if not self.is_supported_resource(resource.resource_type): - LOG.debug( - "Resource (%s) with type (%s) is not supported, skipping", - resource.logical_id, - resource.resource_type, - ) + if resource.logical_resource_id not in selected_resource_names: + LOG.debug("Resource (%s) is not selected with given input", resource.logical_resource_id) continue results.append(resource) return results - - def _get_stack_resources(self) -> Any: - """ - Fetches all resource information for the given stack, response is type of StackResourceSummariesCollection - """ - cfn_stack = self._cfn_resource.Stack(self._stack_name) - return cfn_stack.resource_summaries.all() - - def is_supported_resource(self, resource_type: str) -> bool: - return resource_type in self._supported_resource_types diff --git a/samcli/commands/logs/puller_factory.py b/samcli/commands/logs/puller_factory.py index 1937fd3cd8..0621f44ed2 100644 --- a/samcli/commands/logs/puller_factory.py +++ b/samcli/commands/logs/puller_factory.py @@ -3,7 +3,7 @@ with its producers and consumers """ import logging -from typing import List, Optional, Callable, Any +from typing import List, Optional from samcli.commands.exceptions import UserException from samcli.commands.logs.console_consumers import CWConsoleEventConsumer @@ -24,6 +24,8 @@ ObservabilityEventConsumer, ObservabilityCombinedPuller, ) +from samcli.lib.utils.boto_utils import BotoProviderType +from samcli.lib.utils.cloudformation import CloudFormationResourceSummary from samcli.lib.utils.colors import Colored LOG = logging.getLogger(__name__) @@ -37,9 +39,8 @@ class NoPullerGeneratedException(UserException): def generate_puller( - logs_client_generator: Callable[[], Any], - xray_client: Any, - resource_information_list: List[Any], + boto_client_provider: BotoProviderType, + resource_information_list: List[CloudFormationResourceSummary], filter_pattern: Optional[str] = None, additional_cw_log_groups: Optional[List[str]] = None, output_dir: Optional[str] = None, @@ -51,12 +52,10 @@ def generate_puller( Parameters ---------- - logs_client_generator: Callable[[], CloudWatchLogsClient] - CloudWatchLogsClient generator, which will create a new instance of the client with a new session that could be + boto_client_provider: BotoProviderType + Boto3 client generator, which will create a new instance of the client with a new session that could be used within different threads/coroutines - xray_client: boto3.client - Boto3 xray client which will be used to fetch the debug traces - resource_information_list : List[ResourceInformation] + resource_information_list : List[CloudFormationResourceSummary] List of resource information, which keeps logical id, physical id and type of the resources filter_pattern : Optional[str] Optional filter pattern which will be used to filter incoming events @@ -82,16 +81,18 @@ def generate_puller( resource_information.resource_type, resource_information.physical_resource_id ) if not cw_log_group_name: - LOG.warning("Can't find CloudWatch LogGroup name for resource (%s)", resource_information.logical_id) + LOG.warning( + "Can't find CloudWatch LogGroup name for resource (%s)", resource_information.logical_resource_id + ) continue - consumer = generate_consumer(filter_pattern, output_dir, resource_information.logical_id) + consumer = generate_consumer(filter_pattern, output_dir, resource_information.logical_resource_id) pullers.append( CWLogPuller( - logs_client_generator(), + boto_client_provider("logs"), consumer, cw_log_group_name, - resource_information.logical_id, + resource_information.logical_resource_id, ) ) @@ -100,7 +101,7 @@ def generate_puller( consumer = generate_consumer(filter_pattern, output_dir) pullers.append( CWLogPuller( - logs_client_generator(), + boto_client_provider("logs"), consumer, cw_log_group, ) @@ -108,7 +109,7 @@ def generate_puller( # if tracing flag is set, add the xray traces puller to fetch debug traces if include_tracing: - trace_puller = generate_trace_puller(xray_client, output_dir) + trace_puller = generate_trace_puller(boto_client_provider("xray"), output_dir) pullers.append(trace_puller) # if no puller have been collected, raise an exception since there is nothing to pull diff --git a/samcli/commands/package/package_context.py b/samcli/commands/package/package_context.py index 0a26577333..8aaca8241c 100644 --- a/samcli/commands/package/package_context.py +++ b/samcli/commands/package/package_context.py @@ -30,7 +30,7 @@ from samcli.lib.package.code_signer import CodeSigner from samcli.lib.package.s3_uploader import S3Uploader from samcli.lib.package.uploaders import Uploaders -from samcli.lib.utils.botoconfig import get_boto_config_with_user_agent +from samcli.lib.utils.boto_utils import get_boto_config_with_user_agent from samcli.yamlhelper import yaml_dump LOG = logging.getLogger(__name__) diff --git a/samcli/commands/test/__init__.py b/samcli/commands/test/__init__.py new file mode 100644 index 0000000000..bd71ec629a --- /dev/null +++ b/samcli/commands/test/__init__.py @@ -0,0 +1,4 @@ +"""`sam test` command.""" + +# Expose the cli object here +from .command import cli # noqa diff --git a/samcli/commands/test/command.py b/samcli/commands/test/command.py new file mode 100644 index 0000000000..ec63acd32f --- /dev/null +++ b/samcli/commands/test/command.py @@ -0,0 +1,135 @@ +"""CLI command for "test" command.""" +import logging +import sys +from io import TextIOWrapper +from typing import cast + +import click + +from samcli.cli.cli_config_file import configuration_option, TomlProvider +from samcli.cli.context import Context +from samcli.cli.main import print_cmdline_args, pass_context, aws_creds_options, common_options +from samcli.lib.cli_validation.payload_file_validation import payload_and_payload_file_options_validation +from samcli.lib.telemetry.metric import track_command +from samcli.lib.utils.version_checker import check_newer_version + +LOG = logging.getLogger(__name__) + +HELP_TEXT = """ +Invoke or send test data to remote resources in your CFN stack +""" +SHORT_HELP = "Test a deployed resource" + + +@click.command("test", help=HELP_TEXT, short_help=SHORT_HELP) +@configuration_option(provider=TomlProvider(section="parameters")) +@click.option("--stack-name", required=True, help="Name of the stack to get the resource information from") +@click.option("--resource-id", required=True, help="Name of the resource that will be tested") +@click.option( + "--payload", + help="The payload that will be sent to the resource. The target parameter will depend on the resource type. " + "For instance: 'Payload' for Lambda, 'Entries' for SQS and 'Records' for Kinesis.", +) +@click.option( + "--payload-file", + type=click.File("r", encoding="utf-8"), + help="The file that contains the payload that will be sent to the resource", +) +@click.option( + "--tail", + is_flag=True, + help="Use this option to start tailing logs and XRay information for the given resource. " + "The execution will continue until it is explicitly interrupted with Ctrl + C", +) +@payload_and_payload_file_options_validation +@common_options +@aws_creds_options +@pass_context +@track_command +@check_newer_version +@print_cmdline_args +def cli( + ctx: Context, + stack_name: str, + resource_id: str, + payload: str, + payload_file: TextIOWrapper, + tail: bool, + config_file: str, + config_env: str, +) -> None: + """ + `sam test` command entry point + """ + + do_cli(stack_name, resource_id, payload, payload_file, tail, ctx.region, ctx.profile, config_file, config_env) + + +def do_cli( + stack_name: str, + resource_id: str, + payload: str, + payload_file: TextIOWrapper, + tail: bool, + region: str, + profile: str, + config_file: str, + config_env: str, +) -> None: + """ + Implementation of the ``cli`` method + """ + from samcli.lib.test.test_executor_factory import TestExecutorFactory + from samcli.lib.test.test_executors import TestExecutionInfo + from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config, get_boto_resource_provider_with_config + from samcli.lib.utils.cloudformation import get_resource_summary + from samcli.commands.logs.logs_context import ResourcePhysicalIdResolver + from samcli.commands.logs.puller_factory import generate_puller + + from datetime import datetime + + # create clients and required + boto_client_provider = get_boto_client_provider_with_config(region_name=region) + boto_resource_provider = get_boto_resource_provider_with_config(region_name=region) + + # get resource summary + resource_summary = get_resource_summary(boto_resource_provider, stack_name, resource_id) + if not resource_summary: + LOG.error("Can't find the resource %s in given stack %s", resource_id, stack_name) + return + + # generate executor with given resource + test_executor_factory = TestExecutorFactory(boto_client_provider) + test_executor = test_executor_factory.create_test_executor(resource_summary) + + if not test_executor: + LOG.error("Resource (%s) is not supported with 'sam test' command", resource_id) + return + + # set start_time for pulling logs later + start_time = datetime.utcnow() + + # if no payload nor payload_file argument is given, read from stdin + if not payload and not payload_file: + LOG.info("Neither --payload nor --payload-file option have been provided, reading from stdin") + payload_file = cast(TextIOWrapper, sys.stdin) + + test_exec_info = TestExecutionInfo(payload, payload_file) + + # run execution + test_result = test_executor.execute(test_exec_info) + + if test_result.is_succeeded(): + LOG.info("Test succeeded, result: %s", test_result.response) + + if tail: + LOG.debug("Tailing is enabled, generating puller instance to start tailing") + resource_logical_id_resolver = ResourcePhysicalIdResolver(boto_resource_provider, stack_name, []) + log_trace_puller = generate_puller( + boto_client_provider, resource_logical_id_resolver.get_resource_information(), include_tracing=True + ) + LOG.debug("Starting to pull logs and XRay traces, press Ctrl + C to stop it") + log_trace_puller.tail(start_time) + + else: + LOG.error("Test execution failed with following error", exc_info=test_result.exception) diff --git a/samcli/commands/traces/command.py b/samcli/commands/traces/command.py index cfe6c6502d..52a9132314 100644 --- a/samcli/commands/traces/command.py +++ b/samcli/commands/traces/command.py @@ -57,7 +57,7 @@ def do_cli(trace_ids, start_time, end_time, tailing, output_dir, region): import boto3 from samcli.commands.logs.logs_context import parse_time from samcli.commands.traces.traces_puller_factory import generate_trace_puller - from samcli.lib.utils.botoconfig import get_boto_config_with_user_agent + from samcli.lib.utils.boto_utils import get_boto_config_with_user_agent sanitized_start_time = parse_time(start_time, "start-time") sanitized_end_time = parse_time(end_time, "end-time") or datetime.utcnow() diff --git a/samcli/lib/cli_validation/payload_file_validation.py b/samcli/lib/cli_validation/payload_file_validation.py new file mode 100644 index 0000000000..d077b0d724 --- /dev/null +++ b/samcli/lib/cli_validation/payload_file_validation.py @@ -0,0 +1,46 @@ +""" +This file contains validation for --payload and --payload-file options +""" +from functools import wraps + +import click + +from samcli.commands._utils.option_validator import Validator + + +def payload_and_payload_file_options_validation(func): + """ + This function validates that both --payload and --payload-file should not be provided + + Parameters + ---------- + func : + Command that would be executed, in this case it is 'sam test' + + Returns + ------- + A wrapper function which will first validate options and will execute command if validation succeeds + """ + + @wraps(func) + def wrapped(*args, **kwargs): + ctx = click.get_current_context() + + payload = ctx.params.get("payload") + payload_file = ctx.params.get("payload_file") + + validator = Validator( + validation_function=lambda: payload and payload_file, + exception=click.BadOptionUsage( + option_name="--payload-file", + ctx=ctx, + message="Both '--payload-file' and '--payload' cannot be provided. " + "Please check that you don't have both specified in the command or in a configuration file", + ), + ) + + validator.validate() + + return func(*args, **kwargs) + + return wrapped diff --git a/samcli/lib/observability/cw_logs/cw_log_puller.py b/samcli/lib/observability/cw_logs/cw_log_puller.py index 92e85ec111..7eb7bd0d5c 100644 --- a/samcli/lib/observability/cw_logs/cw_log_puller.py +++ b/samcli/lib/observability/cw_logs/cw_log_puller.py @@ -53,6 +53,7 @@ def __init__( self._poll_interval = poll_interval self.latest_event_time = 0 self.had_data = False + self._invalid_log_group = False def tail(self, start_time: Optional[datetime] = None, filter_pattern: Optional[str] = None): if start_time: @@ -115,12 +116,15 @@ def load_time_period( LOG.debug("Fetching logs from CloudWatch with parameters %s", kwargs) try: result = self.logs_client.filter_log_events(**kwargs) + self._invalid_log_group = False except self.logs_client.exceptions.ResourceNotFoundException: - LOG.warning( - "The specified log group %s does not exist. Please make sure logging is enable and log group is " - "created", - self.cw_log_group, - ) + if not self._invalid_log_group: + LOG.warning( + "The specified log group %s does not exist. " + "Please make sure logging is enable and log group is created", + self.cw_log_group, + ) + self._invalid_log_group = True break # Several events will be returned. Consume one at a time diff --git a/samcli/lib/sync/sync_flow_factory.py b/samcli/lib/sync/sync_flow_factory.py index 9397949e3b..b9cc098dbc 100644 --- a/samcli/lib/sync/sync_flow_factory.py +++ b/samcli/lib/sync/sync_flow_factory.py @@ -2,9 +2,6 @@ import logging from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, cast -import boto3 -from botocore.config import Config - from samcli.lib.providers.provider import Stack, get_resource_by_id, ResourceIdentifier from samcli.lib.providers.sam_base_provider import SamBaseProvider from samcli.lib.providers.sam_api_provider import SamApiProvider @@ -19,6 +16,8 @@ from samcli.lib.sync.flows.image_function_sync_flow import ImageFunctionSyncFlow from samcli.lib.sync.flows.rest_api_sync_flow import RestApiSyncFlow from samcli.lib.sync.flows.http_api_sync_flow import HttpApiSyncFlow +from samcli.lib.utils.boto_utils import get_boto_resource_provider_with_config +from samcli.lib.utils.cloudformation import get_physical_id_mapping if TYPE_CHECKING: from samcli.commands.deploy.deploy_context import DeployContext @@ -34,7 +33,6 @@ class SyncFlowFactory(ResourceTypeBasedFactory[SyncFlow]): # pylint: disable=E1 _deploy_context: "DeployContext" _build_context: "BuildContext" - _boto_config: Config _physical_id_mapping: Dict[str, str] def __init__(self, build_context: "BuildContext", deploy_context: "DeployContext", stacks: List[Stack]) -> None: @@ -51,19 +49,17 @@ def __init__(self, build_context: "BuildContext", deploy_context: "DeployContext super().__init__(stacks) self._deploy_context = deploy_context self._build_context = build_context - - self._boto_config = Config(region_name=self._deploy_context.region if self._deploy_context.region else None) - self._physical_id_mapping = dict() def load_physical_id_mapping(self) -> None: """Load physical IDs of the stack resources from remote""" LOG.debug("Loading physical ID mapping") - self._physical_id_mapping.clear() - stack = boto3.resource("cloudformation", config=self._boto_config).Stack(self._deploy_context.stack_name) - resources = stack.resource_summaries.all() - for resource in resources: - self._physical_id_mapping[resource.logical_resource_id] = resource.physical_resource_id + self._physical_id_mapping = get_physical_id_mapping( + get_boto_resource_provider_with_config( + region_name=self._deploy_context.region if self._deploy_context.region else None + ), + self._deploy_context.stack_name, + ) def _create_lambda_flow( self, resource_identifier: ResourceIdentifier, resource: Dict[str, Any] diff --git a/samcli/lib/test/__init__.py b/samcli/lib/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/samcli/lib/test/lambda_test_executor.py b/samcli/lib/test/lambda_test_executor.py new file mode 100644 index 0000000000..995627d63b --- /dev/null +++ b/samcli/lib/test/lambda_test_executor.py @@ -0,0 +1,68 @@ +""" +Test executor implementation for Lambda +""" +import json +import logging +from json import JSONDecodeError +from typing import Any, cast + +from botocore.response import StreamingBody + +from samcli.lib.test.test_executors import BotoActionExecutor, TestRequestResponseMapper, TestExecutionInfo + +LOG = logging.getLogger(__name__) + + +class LambdaInvokeExecutor(BotoActionExecutor): + """ + Calls "invoke" method of "lambda" service with given input. + If a file location provided, the file handle will be passed as Payload object + """ + + _lambda_client: Any + _function_name: str + + def __init__(self, lambda_client: Any, function_name: str): + self._lambda_client = lambda_client + self._function_name = function_name + + def _execute_action(self, payload: str): + LOG.debug("Calling lambda_client.invoke with FunctionName:%s, Payload:%s", self._function_name, payload) + return self._lambda_client.invoke(FunctionName=self._function_name, Payload=payload) + + +class LambdaConvertToDefaultJSON(TestRequestResponseMapper): + """ + If a regular string is provided as payload, this class will convert it into a JSON object + """ + + def map(self, test_input: TestExecutionInfo) -> TestExecutionInfo: + if not test_input.is_file_provided(): + try: + _ = json.loads(cast(str, test_input.payload)) + except JSONDecodeError: + json_value = f'"{test_input.payload}"' + LOG.info( + "Auto converting value '%s' into JSON '%s'. " + "If you don't want auto-conversion, please provide a JSON string as payload", + test_input.payload, + json_value, + ) + test_input.payload = json_value + + return test_input + + +class LambdaResponseConverter(TestRequestResponseMapper): + """ + This class helps to convert response from lambda service. Normally lambda service + returns 'Payload' field as stream, this class converts that stream into string object + """ + + def map(self, test_input: TestExecutionInfo) -> TestExecutionInfo: + assert isinstance(test_input.response, dict), "Invalid response type received, expecting dict" + payload_field = test_input.response.get("Payload") + if payload_field: + test_input.response["Payload"] = cast(StreamingBody, payload_field).read().decode("utf-8") + + return test_input diff --git a/samcli/lib/test/sqs_test_executor.py b/samcli/lib/test/sqs_test_executor.py new file mode 100644 index 0000000000..2154e6339f --- /dev/null +++ b/samcli/lib/test/sqs_test_executor.py @@ -0,0 +1,55 @@ +""" +Test executor implementation for SQS +""" +import json +import logging +import uuid +from io import TextIOWrapper +from json import JSONDecodeError +from typing import Any + +from samcli.lib.test.test_executors import BotoActionExecutor, TestRequestResponseMapper, TestExecutionInfo + +LOG = logging.getLogger(__name__) + + +class SqsSendMessageExecutor(BotoActionExecutor): + """ + Calls "send_message_batch" method of "sqs" service with given input. + If a file location provided, the file contents will be read and parse as JSON. + """ + + _sqs_client: Any + _sqs_queue_url: str + + def __init__(self, sqs_client: Any, sqs_queue_url: str): + self._sqs_client = sqs_client + self._sqs_queue_url = sqs_queue_url + + def _execute_action(self, payload: str): + LOG.debug("Calling sqs_client.send_message_batch with QueueUrl:%s, Entries:%s", self._sqs_queue_url, payload) + return self._sqs_client.send_message_batch(QueueUrl=self._sqs_queue_url, Entries=payload) + + def _execute_action_file(self, payload_file: TextIOWrapper): + try: + entries = json.loads(payload_file.read()) + except JSONDecodeError as e: + LOG.error("Invalid file (%s) contents. File should contain valid JSON", str(payload_file)) + raise e + + LOG.debug("Calling sqs_client.send_message_batch with QueueUrl:%s, Entries:%s", self._sqs_queue_url, entries) + return self._sqs_client.send_message_batch(QueueUrl=self._sqs_queue_url, Entries=entries) + + +class SqsConvertToEntriesJsonObject(TestRequestResponseMapper): + """ + Creates 'Entries' parameter from given string by converting it to a JSON object and adding auto generated 'Id' field + """ + + def map(self, test_input: TestExecutionInfo) -> TestExecutionInfo: + if not test_input.is_file_provided(): + json_value = f'[{{ "MessageBody": "{test_input.payload}", "Id": "{str(uuid.uuid4())}" }}]' + LOG.info("Auto converting value '%s' into JSON '%s'. ", test_input.payload, json_value) + test_input.payload = json.loads(json_value) + + return test_input diff --git a/samcli/lib/test/test_executor_factory.py b/samcli/lib/test/test_executor_factory.py new file mode 100644 index 0000000000..ba3222e3b4 --- /dev/null +++ b/samcli/lib/test/test_executor_factory.py @@ -0,0 +1,83 @@ +""" +Test executor factory to instantiate test executor for given resource +""" +import logging +from typing import Dict, Callable, Any, Optional + +from samcli.commands._utils.resources import AWS_LAMBDA_FUNCTION, AWS_SQS_QUEUE +from samcli.lib.test.lambda_test_executor import ( + LambdaConvertToDefaultJSON, + LambdaResponseConverter, + LambdaInvokeExecutor, +) +from samcli.lib.test.sqs_test_executor import SqsSendMessageExecutor, SqsConvertToEntriesJsonObject +from samcli.lib.test.test_executors import TestExecutor, ResponseObjectToJsonStringMapper +from samcli.lib.utils.cloudformation import CloudFormationResourceSummary + +LOG = logging.getLogger(__name__) + + +class TestExecutorFactory: + """ + Factory implementation to instantiate different test executor for different resource types + """ + + def __init__(self, boto_client_provider: Callable[[str], Any]): + # defining _boto_client_provider causes issues see https://github.com/python/mypy/issues/708 + self._boto_client_provider = boto_client_provider + + def create_test_executor(self, cfn_resource_summary: CloudFormationResourceSummary) -> Optional[TestExecutor]: + """ + Creates test executor with given CloudFormationResourceSummary + + Parameters + ---------- + cfn_resource_summary : CloudFormationResourceSummary + Information about the resource, which TestExecutor will be created for + + Returns: + ------- + Optional[TestExecutor] + TestExecutor instance for the given CFN resource, None if the resource is not supported yet + + """ + test_executor = TestExecutorFactory.EXECUTOR_MAPPING.get(cfn_resource_summary.resource_type) + + if test_executor: + return test_executor(self, cfn_resource_summary) + + LOG.error( + "Can't find test executor instance for resource %s for type %s", + cfn_resource_summary.logical_resource_id, + cfn_resource_summary.resource_type, + ) + + return None + + def _create_lambda_test_executor(self, cfn_resource_summary: CloudFormationResourceSummary): + return TestExecutor( + request_mappers=[LambdaConvertToDefaultJSON()], + response_mappers=[LambdaResponseConverter(), ResponseObjectToJsonStringMapper()], + boto_action_executor=LambdaInvokeExecutor( + self._boto_client_provider("lambda"), + cfn_resource_summary.physical_resource_id, + ), + ) + + def _create_sqs_test_executor(self, cfn_resource_summary: CloudFormationResourceSummary): + return TestExecutor( + request_mappers=[ + SqsConvertToEntriesJsonObject(), + ], + response_mappers=[ResponseObjectToJsonStringMapper()], + boto_action_executor=SqsSendMessageExecutor( + self._boto_client_provider("sqs"), + cfn_resource_summary.physical_resource_id, + ), + ) + + # mapping definition for each supported resource type + EXECUTOR_MAPPING: Dict[str, Callable[["TestExecutorFactory", CloudFormationResourceSummary], TestExecutor]] = { + AWS_LAMBDA_FUNCTION: _create_lambda_test_executor, + AWS_SQS_QUEUE: _create_sqs_test_executor, + } diff --git a/samcli/lib/test/test_executors.py b/samcli/lib/test/test_executors.py new file mode 100644 index 0000000000..1334fd9b9f --- /dev/null +++ b/samcli/lib/test/test_executors.py @@ -0,0 +1,215 @@ +""" +Abstract class definitions and generic implementations for test execution +""" +import json +import logging +from abc import abstractmethod, ABC +from io import TextIOWrapper +from pathlib import Path +from typing import List, Optional, Union, Callable, cast, Any + +LOG = logging.getLogger(__name__) + + +class TestExecutionInfo: + """ + Keeps request and response information about test execution + + payload: payload string given by the customer + payload_file: if file is given, this points to its location + + response: response object returned from boto3 action + exception: if an exception is been thrown, it will be stored here + """ + + # Request related properties + payload: Optional[Union[str, List, dict]] + payload_file: Optional[TextIOWrapper] + + # Response related properties + response: Optional[Union[dict, str]] + exception: Optional[Exception] + + def __init__(self, payload: Optional[Union[str, List, dict]], payload_file: Optional[TextIOWrapper]): + self.payload = payload + self.payload_file = payload_file + self.response = None + self.exception = None + + def is_file_provided(self): + return self.payload_file + + @property + def payload_file_path(self) -> Optional[TextIOWrapper]: + return self.payload_file if self.is_file_provided() else None + + def is_succeeded(self): + return self.response + + +class TestRequestResponseMapper(ABC): + """ + Mapper definition which can be used map test requests or responses. + + For instance, if a string provided where JSON is required, a mapper can convert given string + into JSON object for request. + + Or for a response object, if it contains streaming results, a mapper can convert them back + to string to display on + """ + + @abstractmethod + def map(self, test_input: TestExecutionInfo) -> TestExecutionInfo: + raise NotImplementedError() + + +class ResponseObjectToJsonStringMapper(TestRequestResponseMapper): + def map(self, test_input: TestExecutionInfo) -> TestExecutionInfo: + LOG.debug("Converting response object into JSON") + test_input.response = json.dumps(test_input.response, indent=2) + return test_input + + +class BotoActionExecutor(ABC): + """ + Executes a specific boto3 service action and updates the response of the TestExecutionInfo object + If execution throws an exception, it updates the exception information as well + """ + + @abstractmethod + def _execute_action(self, payload: str) -> dict: + """ + Specific boto3 API call implementation. + + Parameters + ---------- + payload : str + Payload object that is been provided + + Returns + ------- + Response dictionary from the API call + + """ + raise NotImplementedError() + + def _execute_action_file(self, payload_file: TextIOWrapper) -> dict: + """ + Different implementation which is specific to a file path. Some boto3 APIs may accept a file path + rather than a string. This implementation targets these options to support different file types + other than just string. + + Default implementation reads the file contents as string and calls execute_action. + + Parameters + ---------- + payload_file : Path + Location of the payload file + + Returns + ------- + Response dictionary from the API call + """ + return self._execute_action(payload_file.read()) + + def execute(self, test_input: TestExecutionInfo) -> TestExecutionInfo: + """ + Executes boto3 API and updates response or exception object depending on the result + + Parameters + ---------- + test_input : TestExecutionInfo + TestExecutionInfo details which contains payload or payload file information + + Returns : TestExecutionInfo + ------- + Updates response or exception fields of given input and returns it + """ + action_executor: Callable[[Any], dict] + payload: Union[str, Path] + + # if a file pointed is provided for payload, use specific payload and its function here + if test_input.is_file_provided(): + action_executor = self._execute_action_file + payload = cast(Path, test_input.payload_file_path) + else: + action_executor = self._execute_action + payload = cast(str, test_input.payload) + + # execute boto3 API, and update result if it is successful, update exception otherwise + try: + action_response = action_executor(payload) + test_input.response = action_response + except Exception as e: + LOG.error("Failed while executing boto action", exc_info=e) + test_input.exception = e + + return test_input + + +class TestExecutor: + """ + Generic TestExecutor, which contains request mappers, response mappers and boto action executor. + + Input is been updated with the given list of request mappers. + Then updated input have been passed to boto action executor to actually call the API + Once the result is returned, if it is successful, response have been mapped with list of response mappers + """ + + _request_mappers: List[TestRequestResponseMapper] + _response_mappers: List[TestRequestResponseMapper] + _boto_action_executor: BotoActionExecutor + + def __init__( + self, + request_mappers: List[TestRequestResponseMapper], + response_mappers: List[TestRequestResponseMapper], + boto_action_executor: BotoActionExecutor, + ): + self._request_mappers = request_mappers + self._response_mappers = response_mappers + self._boto_action_executor = boto_action_executor + + def execute(self, test_input: TestExecutionInfo) -> TestExecutionInfo: + test_input = self._map_input(test_input) + test_output = self._boto_action_executor.execute(test_input) + + # call output mappers if the action is succeeded + if test_output.is_succeeded(): + return self._map_output(test_output) + + return test_output + + def _map_input(self, test_input: TestExecutionInfo) -> TestExecutionInfo: + """ + Maps the given input through the request mapper list. + + Parameters + ---------- + test_input : TestExecutionInfo + Given test execution info which contains the request information + + Returns : TestExecutionInfo + ------- + TestExecutionInfo which contains updated input payload + """ + for input_mapper in self._request_mappers: + test_input = input_mapper.map(test_input) + return test_input + + def _map_output(self, test_output: TestExecutionInfo) -> TestExecutionInfo: + """ + Maps the given response through the response mapper list. + + Parameters + ---------- + test_output : TestExecutionInfo + Given test execution info which contains the response information + + Returns : TestExecutionInfo + ------- + TestExecutionInfo which contains updated response + """ + for output_mapper in self._response_mappers: + test_output = output_mapper.map(test_output) + return test_output diff --git a/samcli/lib/utils/boto_utils.py b/samcli/lib/utils/boto_utils.py new file mode 100644 index 0000000000..11dd2ffa48 --- /dev/null +++ b/samcli/lib/utils/boto_utils.py @@ -0,0 +1,84 @@ +""" +This module contains utility functions for boto3 library +""" +from typing import Any +from typing_extensions import Protocol + +import boto3 +from botocore.config import Config + +from samcli import __version__ +from samcli.cli.global_config import GlobalConfig + + +def get_boto_config_with_user_agent(**kwargs) -> Config: + """ + Automatically add user agent string to boto configs. + + Parameters + ---------- + kwargs : + key=value params which will be added to the Config object + + Returns + ------- + Config + Returns config instance which contains given parameters in it + """ + gc = GlobalConfig() + return Config( + user_agent_extra=f"aws-sam-cli/{__version__}/{gc.installation_id}" + if gc.telemetry_enabled + else f"aws-sam-cli/{__version__}", + **kwargs, + ) + + +# Type definition of following boto providers, which is equal to Callable[[str], Any] +class BotoProviderType(Protocol): + def __call__(self, service_name: str) -> Any: + ... + + +def get_boto_client_provider_with_config(**kwargs) -> BotoProviderType: + """ + Returns a wrapper function for boto client with given configuration. It can be used like; + + client_provider = get_boto_client_wrapper_with_config(region_name=region) + lambda_client = client_provider("lambda") + + Parameters + ---------- + kwargs : + Key-value params that will be passed to get_boto_config_with_user_agent + + Returns + ------- + A callable function which will return a boto client + """ + # ignore typing because mypy tries to assert client_name with a valid service name + return lambda client_name: boto3.session.Session().client( + client_name, config=get_boto_config_with_user_agent(**kwargs) + ) + + +def get_boto_resource_provider_with_config(**kwargs) -> BotoProviderType: + """ + Returns a wrapper function for boto resource with given configuration. It can be used like; + + resource_provider = get_boto_resource_wrapper_with_config(region_name=region) + cloudformation_resource = resource_provider("cloudformation") + + Parameters + ---------- + kwargs : + Key-value params that will be passed to get_boto_config_with_user_agent + + Returns + ------- + A callable function which will return a boto resource + """ + # ignore typing because mypy tries to assert client_name with a valid service name + return lambda resource_name: boto3.session.Session().resource( + resource_name, config=get_boto_config_with_user_agent(**kwargs) + ) diff --git a/samcli/lib/utils/botoconfig.py b/samcli/lib/utils/botoconfig.py deleted file mode 100644 index 7a7bd6d792..0000000000 --- a/samcli/lib/utils/botoconfig.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Automatically add user agent string to boto configs. -""" -from botocore.config import Config - -from samcli import __version__ -from samcli.cli.global_config import GlobalConfig - - -def get_boto_config_with_user_agent(**kwargs): - gc = GlobalConfig() - return Config( - user_agent_extra=f"aws-sam-cli/{__version__}/{gc.installation_id}" - if gc.telemetry_enabled - else f"aws-sam-cli/{__version__}", - **kwargs, - ) diff --git a/samcli/lib/utils/cloudformation.py b/samcli/lib/utils/cloudformation.py new file mode 100644 index 0000000000..b6590550d4 --- /dev/null +++ b/samcli/lib/utils/cloudformation.py @@ -0,0 +1,127 @@ +""" +This utility file contains methods to read information from certain CFN stack +""" +import logging +from typing import List, Dict, NamedTuple, Set, Optional + +from botocore.exceptions import ClientError + +from samcli.lib.utils.boto_utils import BotoProviderType + +LOG = logging.getLogger(__name__) + + +class CloudFormationResourceSummary(NamedTuple): + """ + Keeps information about CFN resource + """ + + resource_type: str + logical_resource_id: str + physical_resource_id: str + + +def get_physical_id_mapping( + boto_resource_provider: BotoProviderType, stack_name: str, resource_types: Optional[Set[str]] = None +) -> Dict[str, str]: + """ + Uses get_resource_summaries method to gather resource summaries and creates a dictionary which contains + logical_id to physical_id mapping + + Parameters + ---------- + boto_resource_provider : BotoProviderType + A callable which will return boto3 resource + stack_name : str + Name of the stack which is deployed to CFN + resource_types : Optional[Set[str]] + List of resource types, which will filter the results + + Returns + ------- + Dictionary of string, string which will contain logical_id to physical_id mapping + + """ + resource_summaries = get_resource_summaries(boto_resource_provider, stack_name, resource_types) + + resource_physical_id_map: Dict[str, str] = {} + for resource_summary in resource_summaries: + resource_physical_id_map[resource_summary.logical_resource_id] = resource_summary.physical_resource_id + + return resource_physical_id_map + + +def get_resource_summaries( + boto_resource_provider: BotoProviderType, stack_name: str, resource_types: Optional[Set[str]] = None +) -> List[CloudFormationResourceSummary]: + """ + Collects information about CFN resources and return their summary as list + + Parameters + ---------- + boto_resource_provider : BotoProviderType + A callable which will return boto3 resource + stack_name : str + Name of the stack which is deployed to CFN + resource_types : Optional[Set[str]] + List of resource types, which will filter the results + + Returns + ------- + List of CloudFormationResourceSummary which contains information about resources in the given stack + + """ + LOG.debug("Fetching stack (%s) resources", stack_name) + cfn_resource_summaries = boto_resource_provider("cloudformation").Stack(stack_name).resource_summaries.all() + resource_summaries: List[CloudFormationResourceSummary] = [] + + for cfn_resource_summary in cfn_resource_summaries: + resource_summary = CloudFormationResourceSummary( + cfn_resource_summary.resource_type, + cfn_resource_summary.logical_resource_id, + cfn_resource_summary.physical_resource_id, + ) + if resource_types and resource_summary.resource_type not in resource_types: + LOG.debug( + "Skipping resource %s since its type %s is not supported. Supported types %s", + resource_summary.logical_resource_id, + resource_summary.resource_type, + resource_types, + ) + continue + + resource_summaries.append(resource_summary) + + return resource_summaries + + +def get_resource_summary(boto_resource_provider: BotoProviderType, stack_name: str, resource_logical_id: str): + """ + Returns resource summary of given single resource with its logical id + + Parameters + ---------- + boto_resource_provider : BotoProviderType + A callable which will return boto3 resource + stack_name : str + Name of the stack which is deployed to CFN + resource_logical_id : str + Logical ID of the resource that will be returned as resource summary + + Returns + ------- + CloudFormationResourceSummary of the resource which is identified by given logical id + """ + try: + cfn_resource_summary = boto_resource_provider("cloudformation").StackResource(stack_name, resource_logical_id) + + return CloudFormationResourceSummary( + cfn_resource_summary.resource_type, + cfn_resource_summary.logical_resource_id, + cfn_resource_summary.physical_resource_id, + ) + except ClientError as e: + LOG.error( + "Failed to pull resource (%s) information from stack (%s)", resource_logical_id, stack_name, exc_info=e + ) + return None diff --git a/tests/unit/commands/logs/test_command.py b/tests/unit/commands/logs/test_command.py index bfc81622d9..79b132e9a9 100644 --- a/tests/unit/commands/logs/test_command.py +++ b/tests/unit/commands/logs/test_command.py @@ -43,17 +43,17 @@ def setUp(self): ) @patch("samcli.commands.logs.puller_factory.generate_puller") @patch("samcli.commands.logs.logs_context.ResourcePhysicalIdResolver") - @patch("boto3.resource") @patch("samcli.commands.logs.logs_context.parse_time") - @patch("samcli.lib.utils.botoconfig.get_boto_config_with_user_agent") + @patch("samcli.lib.utils.boto_utils.get_boto_client_provider_with_config") + @patch("samcli.lib.utils.boto_utils.get_boto_resource_provider_with_config") def test_logs_command( self, tailing, include_tracing, cw_log_group, - patched_config_generator, + patched_boto_resource_provider, + patched_boto_client_provider, patched_parse_time, - patched_resource, patched_resource_physical_id_resolver, patched_generate_puller, ): @@ -61,9 +61,6 @@ def test_logs_command( mocked_end_time = Mock() patched_parse_time.side_effect = [mocked_start_time, mocked_end_time] - mocked_cfn_resource = Mock() - patched_resource.return_value = mocked_cfn_resource - mocked_resource_physical_id_resolver = Mock() mocked_resource_information = Mock() mocked_resource_physical_id_resolver.get_resource_information.return_value = mocked_resource_information @@ -72,8 +69,11 @@ def test_logs_command( mocked_puller = Mock() patched_generate_puller.return_value = mocked_puller - mocked_config = Mock() - patched_config_generator.return_value = mocked_config + mocked_client_provider = Mock() + patched_boto_client_provider.return_value = mocked_client_provider + + mocked_resource_provider = Mock() + patched_boto_resource_provider.return_value = mocked_resource_provider do_cli( self.function_name, @@ -95,23 +95,23 @@ def test_logs_command( ] ) - patched_config_generator.assert_called_with(region_name=self.region) - - patched_resource.assert_has_calls( - [ - call.resource("cloudformation", config=mocked_config), - ] - ) + patched_boto_client_provider.assert_called_with(region_name=self.region) + patched_boto_resource_provider.assert_called_with(region_name=self.region) patched_resource_physical_id_resolver.assert_called_with( - mocked_cfn_resource, self.stack_name, self.function_name + mocked_resource_provider, self.stack_name, self.function_name ) fetch_param = not bool(len(cw_log_group)) mocked_resource_physical_id_resolver.assert_has_calls([call.get_resource_information(fetch_param)]) patched_generate_puller.assert_called_with( - ANY, None, mocked_resource_information, self.filter_pattern, cw_log_group, self.output_dir, False + mocked_client_provider, + mocked_resource_information, + self.filter_pattern, + cw_log_group, + self.output_dir, + False, ) if tailing: diff --git a/tests/unit/commands/logs/test_logs_context.py b/tests/unit/commands/logs/test_logs_context.py index e8f93d49f9..050ae9ef91 100644 --- a/tests/unit/commands/logs/test_logs_context.py +++ b/tests/unit/commands/logs/test_logs_context.py @@ -3,6 +3,7 @@ from samcli.commands.exceptions import UserException from samcli.commands.logs.logs_context import parse_time, ResourcePhysicalIdResolver +from samcli.lib.utils.cloudformation import CloudFormationResourceSummary AWS_SOME_RESOURCE = "AWS::Some::Resource" AWS_LAMBDA_FUNCTION = "AWS::Lambda::Function" @@ -82,96 +83,47 @@ def test_get_no_resource_information(self): actual_return = resource_physical_id_resolver.get_resource_information(False) self.assertEqual(actual_return, []) - def test_default_supported_resource_lambda_function(self): + @patch("samcli.commands.logs.logs_context.get_resource_summaries") + def test_fetch_all_resources(self, patched_get_resources): resource_physical_id_resolver = ResourcePhysicalIdResolver(Mock(), "stack_name", []) - self.assertTrue(resource_physical_id_resolver.is_supported_resource(AWS_LAMBDA_FUNCTION)) - - def test_default_supported_resource_rest_api(self): - resource_physical_id_resolver = ResourcePhysicalIdResolver(Mock(), "stack_name", []) - self.assertTrue(resource_physical_id_resolver.is_supported_resource(AWS_APIGATEWAY_RESTAPI)) - - def test_default_supported_resource_http_api(self): - resource_physical_id_resolver = ResourcePhysicalIdResolver(Mock(), "stack_name", []) - self.assertTrue(resource_physical_id_resolver.is_supported_resource(AWS_APIGATEWAY_HTTPAPI)) - - def test_default_supported_resource_with_not_supported_resource(self): - resource_physical_id_resolver = ResourcePhysicalIdResolver(Mock(), "stack_name", []) - self.assertFalse(resource_physical_id_resolver.is_supported_resource(AWS_SOME_RESOURCE)) - - def test_custom_supported_resource(self): - supported_resource_types = {"Resource1", "Resource2"} - resource_physical_id_resolver = ResourcePhysicalIdResolver( - Mock(), "stack_name", [], supported_resource_types=supported_resource_types - ) - - self.assertTrue(resource_physical_id_resolver.is_supported_resource("Resource1")) - self.assertTrue(resource_physical_id_resolver.is_supported_resource("Resource2")) - self.assertFalse(resource_physical_id_resolver.is_supported_resource("Resource3")) - - def test_get_stack_resources(self): - mock_cfn_resource = Mock() - given_stack_mock = Mock() - mock_cfn_resource.Stack.return_value = given_stack_mock - given_stack_resource_array = [ - Mock(physical_id="physical_id_1", logical_id="logical_id_1", resource_type=AWS_LAMBDA_FUNCTION), - Mock(physical_id="physical_id_2", logical_id="logical_id_2", resource_type=AWS_LAMBDA_FUNCTION), - Mock(physical_id="physical_id_3", logical_id="logical_id_3", resource_type=AWS_SOME_RESOURCE), + mocked_return_value = [ + CloudFormationResourceSummary(AWS_LAMBDA_FUNCTION, "logical_id_1", "physical_id_1"), + CloudFormationResourceSummary(AWS_LAMBDA_FUNCTION, "logical_id_2", "physical_id_2"), + CloudFormationResourceSummary(AWS_APIGATEWAY_RESTAPI, "logical_id_3", "physical_id_3"), + CloudFormationResourceSummary(AWS_APIGATEWAY_HTTPAPI, "logical_id_4", "physical_id_4"), ] - given_stack_mock.resource_summaries.all.return_value = given_stack_resource_array + patched_get_resources.return_value = mocked_return_value - resource_physical_id_resolver = ResourcePhysicalIdResolver(mock_cfn_resource, "stack_name", []) + actual_result = resource_physical_id_resolver._fetch_resources_from_stack() + self.assertEqual(len(actual_result), 4) - actual_stack_resources = resource_physical_id_resolver._get_stack_resources() - self.assertEqual(len(actual_stack_resources), 3) - self.assertEqual(actual_stack_resources, given_stack_resource_array) + expected_results = [ + item + for item in mocked_return_value + if item.resource_type in ResourcePhysicalIdResolver.DEFAULT_SUPPORTED_RESOURCES + ] + self.assertEqual(expected_results, actual_result) - def test_fetch_all_resources(self): - resource_physical_id_resolver = ResourcePhysicalIdResolver(Mock(), "stack_name", []) - with mock.patch( - "samcli.commands.logs.logs_context.ResourcePhysicalIdResolver._get_stack_resources" - ) as mocked_get_resources: - mocked_return_value = [ - Mock(physical_id="physical_id_1", logical_id="logical_id_1", resource_type=AWS_LAMBDA_FUNCTION), - Mock(physical_id="physical_id_2", logical_id="logical_id_2", resource_type=AWS_LAMBDA_FUNCTION), - Mock(physical_id="physical_id_3", logical_id="logical_id_3", resource_type=AWS_SOME_RESOURCE), - Mock(physical_id="physical_id_4", logical_id="logical_id_4", resource_type=AWS_APIGATEWAY_RESTAPI), - Mock(physical_id="physical_id_5", logical_id="logical_id_5", resource_type=AWS_APIGATEWAY_HTTPAPI), - ] - mocked_get_resources.return_value = mocked_return_value - - actual_result = resource_physical_id_resolver._fetch_resources_from_stack() - self.assertEqual(len(actual_result), 4) - - expected_results = [ - item - for item in mocked_return_value - if item.resource_type in ResourcePhysicalIdResolver.DEFAULT_SUPPORTED_RESOURCES - ] - self.assertEqual(expected_results, actual_result) - - def test_fetch_given_resources(self): + @patch("samcli.commands.logs.logs_context.get_resource_summaries") + def test_fetch_given_resources(self, patched_get_resources): given_resources = ["logical_id_1", "logical_id_2", "logical_id_3", "logical_id_5", "logical_id_6"] resource_physical_id_resolver = ResourcePhysicalIdResolver(Mock(), "stack_name", given_resources) - with mock.patch( - "samcli.commands.logs.logs_context.ResourcePhysicalIdResolver._get_stack_resources" - ) as mocked_get_resources: - mocked_return_value = [ - Mock(physical_id="physical_id_1", logical_id="logical_id_1", resource_type=AWS_LAMBDA_FUNCTION), - Mock(physical_id="physical_id_2", logical_id="logical_id_2", resource_type=AWS_LAMBDA_FUNCTION), - Mock(physical_id="physical_id_3", logical_id="logical_id_3", resource_type=AWS_SOME_RESOURCE), - Mock(physical_id="physical_id_4", logical_id="logical_id_4", resource_type=AWS_LAMBDA_FUNCTION), - Mock(physical_id="physical_id_5", logical_id="logical_id_5", resource_type=AWS_APIGATEWAY_RESTAPI), - Mock(physical_id="physical_id_6", logical_id="logical_id_6", resource_type=AWS_APIGATEWAY_HTTPAPI), - ] - mocked_get_resources.return_value = mocked_return_value - - actual_result = resource_physical_id_resolver._fetch_resources_from_stack(set(given_resources)) - self.assertEqual(len(actual_result), 4) - - expected_results = [ - item - for item in mocked_return_value - if item.resource_type in ResourcePhysicalIdResolver.DEFAULT_SUPPORTED_RESOURCES - and item.logical_id in given_resources - ] - self.assertEqual(expected_results, actual_result) + mocked_return_value = [ + CloudFormationResourceSummary(AWS_LAMBDA_FUNCTION, "logical_id_1", "physical_id_1"), + CloudFormationResourceSummary(AWS_LAMBDA_FUNCTION, "logical_id_2", "physical_id_2"), + CloudFormationResourceSummary(AWS_LAMBDA_FUNCTION, "logical_id_3", "physical_id_3"), + CloudFormationResourceSummary(AWS_APIGATEWAY_RESTAPI, "logical_id_4", "physical_id_4"), + CloudFormationResourceSummary(AWS_APIGATEWAY_HTTPAPI, "logical_id_5", "physical_id_5"), + ] + patched_get_resources.return_value = mocked_return_value + + actual_result = resource_physical_id_resolver._fetch_resources_from_stack(set(given_resources)) + self.assertEqual(len(actual_result), 4) + + expected_results = [ + item + for item in mocked_return_value + if item.resource_type in ResourcePhysicalIdResolver.DEFAULT_SUPPORTED_RESOURCES + and item.logical_resource_id in given_resources + ] + self.assertEqual(expected_results, actual_result) diff --git a/tests/unit/commands/logs/test_puller_factory.py b/tests/unit/commands/logs/test_puller_factory.py index 9c2872d998..d0298f27a2 100644 --- a/tests/unit/commands/logs/test_puller_factory.py +++ b/tests/unit/commands/logs/test_puller_factory.py @@ -42,7 +42,9 @@ def test_generate_puller( ): mock_logs_client = Mock() mock_xray_client = Mock() - mock_logs_client_generator = lambda: mock_logs_client + + mock_client_provider = lambda client_name: mock_logs_client if client_name == "logs" else mock_xray_client + mock_resource_info_list = [ Mock(resource_type=AWS_LAMBDA_FUNCTION), Mock(resource_type=AWS_LAMBDA_FUNCTION), @@ -70,8 +72,7 @@ def test_generate_puller( patched_combined_puller.return_value = mocked_combined_puller puller = generate_puller( - mock_logs_client_generator, - mock_xray_client, + mock_client_provider, mock_resource_info_list, param_filter_pattern, param_cw_log_groups, @@ -105,7 +106,7 @@ def test_puller_with_invalid_resource_type(self): mock_resource_information.get_log_group_name.return_value = None with self.assertRaises(NoPullerGeneratedException): - generate_puller(mock_logs_client, None, [mock_resource_information]) + generate_puller(mock_logs_client, [mock_resource_information]) @patch("samcli.commands.logs.puller_factory.generate_console_consumer") @patch("samcli.commands.logs.puller_factory.CWLogPuller") @@ -114,7 +115,7 @@ def test_generate_puller_with_console_with_additional_cw_logs_groups( self, patched_combined_puller, patched_cw_log_puller, patched_console_consumer ): mock_logs_client = Mock() - mock_logs_client_generator = lambda: mock_logs_client + mock_logs_client_generator = lambda client: mock_logs_client mock_cw_log_groups = [Mock(), Mock(), Mock()] mocked_consumers = [Mock() for _ in mock_cw_log_groups] @@ -126,7 +127,7 @@ def test_generate_puller_with_console_with_additional_cw_logs_groups( mocked_combined_puller = Mock() patched_combined_puller.return_value = mocked_combined_puller - puller = generate_puller(mock_logs_client_generator, None, [], additional_cw_log_groups=mock_cw_log_groups) + puller = generate_puller(mock_logs_client_generator, [], additional_cw_log_groups=mock_cw_log_groups) self.assertEqual(puller, mocked_combined_puller) diff --git a/tests/unit/commands/sync/test_command.py b/tests/unit/commands/sync/test_command.py index 914f2e1b7d..f67931e2d6 100644 --- a/tests/unit/commands/sync/test_command.py +++ b/tests/unit/commands/sync/test_command.py @@ -51,11 +51,13 @@ def setUp(self): @patch("samcli.commands.deploy.command.click") @patch("samcli.commands.deploy.deploy_context.DeployContext") @patch("samcli.commands.build.command.os") + @patch("samcli.commands.sync.command.manage_stack") def test_infra_must_succeed_sync( self, infra, code, watch, + manage_stack_mock, os_mock, DeployContextMock, mock_deploy_click, @@ -165,11 +167,13 @@ def test_infra_must_succeed_sync( @patch("samcli.commands.deploy.command.click") @patch("samcli.commands.deploy.deploy_context.DeployContext") @patch("samcli.commands.build.command.os") + @patch("samcli.commands.sync.command.manage_stack") def test_watch_must_succeed_sync( self, infra, code, watch, + manage_stack_mock, os_mock, DeployContextMock, mock_deploy_click, @@ -279,11 +283,13 @@ def test_watch_must_succeed_sync( @patch("samcli.commands.deploy.command.click") @patch("samcli.commands.deploy.deploy_context.DeployContext") @patch("samcli.commands.build.command.os") + @patch("samcli.commands.sync.command.manage_stack") def test_code_must_succeed_sync( self, infra, code, watch, + manage_stack_mock, os_mock, DeployContextMock, mock_deploy_click, diff --git a/tests/unit/commands/test/__init__.py b/tests/unit/commands/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/commands/test/test_command.py b/tests/unit/commands/test/test_command.py new file mode 100644 index 0000000000..2a95bcfa48 --- /dev/null +++ b/tests/unit/commands/test/test_command.py @@ -0,0 +1,136 @@ +from unittest import TestCase +from unittest.mock import patch, Mock + +from parameterized import parameterized + +from samcli.commands.test.command import do_cli + + +class TestTestCliCommand(TestCase): + def setUp(self) -> None: + self.stack_name = "stack_name" + self.resource_id = "resource_id" + self.region = "region" + self.profile = "profile" + self.config_file = "config_file" + self.config_env = "config_env" + + @parameterized.expand( + [ + ("payload", None, False, None, Mock(), None), + ("payload", None, False, Mock(), None, None), + ("payload", None, False, Mock(), Mock(), Mock()), + ("payload", None, False, Mock(), Mock(), None), + ("payload", None, True, Mock(), Mock(), None), + (None, "payload_file", False, Mock(), Mock(), None), + (None, "payload_file", True, Mock(), Mock(), None), + (None, None, True, Mock(), Mock(), None), + ] + ) + @patch("samcli.commands.test.command.LOG") + @patch("samcli.commands.logs.puller_factory.generate_puller") + @patch("samcli.commands.logs.logs_context.ResourcePhysicalIdResolver") + @patch("samcli.lib.test.test_executor_factory.TestExecutorFactory") + @patch("samcli.lib.test.test_executors.TestExecutionInfo") + @patch("samcli.lib.utils.boto_utils.get_boto_client_provider_with_config") + @patch("samcli.lib.utils.boto_utils.get_boto_resource_provider_with_config") + @patch("samcli.lib.utils.cloudformation.get_resource_summary") + @patch("sys.stdin") + def test_test_command( + self, + payload, + payload_file, + tail, + given_resource_summary, + given_test_executor, + given_execution_exception, + patched_stdin, + patched_get_resource_summary, + patched_get_boto_resource_provider_with_config, + patched_get_boto_client_provider_with_config, + patched_test_execution_info, + patched_test_executor_factory, + patched_resource_physical_id_resolver, + patched_generate_puller, + patched_log, + ): + given_client_provider = Mock() + patched_get_boto_client_provider_with_config.return_value = given_client_provider + + given_resource_provider = Mock() + patched_get_boto_resource_provider_with_config.return_value = given_resource_provider + + patched_get_resource_summary.return_value = given_resource_summary + + given_test_executor_factory = Mock() + patched_test_executor_factory.return_value = given_test_executor_factory + + given_test_executor_factory.create_test_executor.return_value = given_test_executor + + given_test_execution_info = Mock() + patched_test_execution_info.return_value = given_test_execution_info + + given_test_result = Mock(exception=given_execution_exception) + given_test_result.is_succeeded.return_value = not bool(given_execution_exception) + if given_test_executor: + given_test_executor.execute.return_value = given_test_result + + given_resource_physical_id_resolver = Mock() + patched_resource_physical_id_resolver.return_value = given_resource_physical_id_resolver + + given_puller = Mock() + patched_generate_puller.return_value = given_puller + + do_cli( + self.stack_name, + self.resource_id, + payload, + payload_file, + tail, + self.region, + self.profile, + self.config_file, + self.config_env, + ) + + patched_get_boto_client_provider_with_config.assert_called_with(region_name=self.region) + patched_get_boto_resource_provider_with_config.assert_called_with(region_name=self.region) + + patched_get_resource_summary.assert_called_with(given_resource_provider, self.stack_name, self.resource_id) + + if not given_resource_summary: + # if resource not found it shouldn't go further + patched_test_executor_factory.assert_not_called() + return + + patched_test_executor_factory.assert_called_with(given_client_provider) + given_test_executor_factory.create_test_executor.assert_called_with(given_resource_summary) + + if not given_test_executor: + # if test executor not found it shouldn't go further + patched_test_execution_info.assert_not_called() + return + + # if both payload and payload_file is None, it should use sys.stdin + if not payload and not payload_file: + patched_test_execution_info.assert_called_with(payload, patched_stdin) + else: + patched_test_execution_info.assert_called_with(payload, payload_file) + + given_test_executor.execute.assert_called_with(given_test_execution_info) + + if given_execution_exception: + patched_log.error.assert_called_with( + "Test execution failed with following error", exc_info=given_execution_exception + ) + patched_log.info.assert_not_called() + + if tail: + patched_resource_physical_id_resolver.assert_called_with(given_resource_provider, self.stack_name, []) + patched_generate_puller.assert_called_with( + given_client_provider, + given_resource_physical_id_resolver.get_resource_information(), + include_tracing=True, + ) + + given_puller.tail.assert_called_once() diff --git a/tests/unit/commands/traces/test_command.py b/tests/unit/commands/traces/test_command.py index 5c1f3d696a..69d457eaa3 100644 --- a/tests/unit/commands/traces/test_command.py +++ b/tests/unit/commands/traces/test_command.py @@ -21,7 +21,7 @@ def setUp(self): ] ) @patch("samcli.commands.logs.logs_context.parse_time") - @patch("samcli.lib.utils.botoconfig.get_boto_config_with_user_agent") + @patch("samcli.lib.utils.boto_utils.get_boto_config_with_user_agent") @patch("boto3.client") @patch("samcli.commands.traces.traces_puller_factory.generate_trace_puller") def test_traces_command( diff --git a/tests/unit/lib/cli_validation/test_payload_file_validation.py b/tests/unit/lib/cli_validation/test_payload_file_validation.py new file mode 100644 index 0000000000..4d6f547bfb --- /dev/null +++ b/tests/unit/lib/cli_validation/test_payload_file_validation.py @@ -0,0 +1,49 @@ +from unittest import TestCase +from unittest.mock import Mock, patch + +from click import BadOptionUsage + +from samcli.lib.cli_validation.payload_file_validation import payload_and_payload_file_options_validation + + +class TestPayloadFileValidation(TestCase): + @patch("samcli.lib.cli_validation.payload_file_validation.click.get_current_context") + def test_only_payload_param(self, patched_click_context): + mock_func = Mock() + + mocked_context = Mock() + patched_click_context.return_value = mocked_context + + mocked_context.params.get.side_effect = lambda key: "payload" if key == "payload" else None + + payload_and_payload_file_options_validation(mock_func)() + + mock_func.assert_called_once() + + @patch("samcli.lib.cli_validation.payload_file_validation.click.get_current_context") + def test_only_payload_file_param(self, patched_click_context): + mock_func = Mock() + + mocked_context = Mock() + patched_click_context.return_value = mocked_context + + mocked_context.params.get.side_effect = lambda key: "payload_file" if key == "payload_file" else None + + payload_and_payload_file_options_validation(mock_func)() + + mock_func.assert_called_once() + + @patch("samcli.lib.cli_validation.payload_file_validation.click.get_current_context") + def test_both_params(self, patched_click_context): + mock_func = Mock() + mocked_context = Mock() + patched_click_context.return_value = mocked_context + + mocked_context.params.get.side_effect = lambda key: "payload_content" + + with self.assertRaises(BadOptionUsage) as ex: + payload_and_payload_file_options_validation(mock_func)() + + self.assertIn("Both '--payload-file' and '--payload' cannot be provided.", ex.exception.message) + + mock_func.assert_not_called() diff --git a/tests/unit/lib/observability/cw_logs/test_cw_log_puller.py b/tests/unit/lib/observability/cw_logs/test_cw_log_puller.py index a424362dbc..7e609e1b01 100644 --- a/tests/unit/lib/observability/cw_logs/test_cw_log_puller.py +++ b/tests/unit/lib/observability/cw_logs/test_cw_log_puller.py @@ -100,6 +100,39 @@ def test_must_fetch_logs_with_all_params(self): for event in self.expected_events: self.assertIn(event, call_args) + @patch("samcli.lib.observability.cw_logs.cw_log_puller.LOG") + def test_must_print_resource_not_found_only_once(self, patched_log): + pattern = "foobar" + start = datetime.utcnow() + end = datetime.utcnow() + + expected_params = { + "logGroupName": self.log_group_name, + "interleaved": True, + "startTime": to_timestamp(start), + "endTime": to_timestamp(end), + "filterPattern": pattern, + } + + self.client_stubber.add_client_error( + "filter_log_events", expected_params=expected_params, service_error_code="ResourceNotFoundException" + ) + self.client_stubber.add_client_error( + "filter_log_events", expected_params=expected_params, service_error_code="ResourceNotFoundException" + ) + self.client_stubber.add_response("filter_log_events", self.mock_api_response, expected_params) + + with self.client_stubber: + self.assertFalse(self.fetcher._invalid_log_group) + self.fetcher.load_time_period(start_time=start, end_time=end, filter_pattern=pattern) + self.assertTrue(self.fetcher._invalid_log_group) + self.fetcher.load_time_period(start_time=start, end_time=end, filter_pattern=pattern) + self.assertTrue(self.fetcher._invalid_log_group) + self.fetcher.load_time_period(start_time=start, end_time=end, filter_pattern=pattern) + self.assertFalse(self.fetcher._invalid_log_group) + + patched_log.warning.assert_called_once() + def test_must_paginate_using_next_token(self): """Make three API calls, first two returns a nextToken and last does not.""" token = "token" diff --git a/tests/unit/lib/sync/test_sync_flow_factory.py b/tests/unit/lib/sync/test_sync_flow_factory.py index 1b33b07223..8a44c15bf8 100644 --- a/tests/unit/lib/sync/test_sync_flow_factory.py +++ b/tests/unit/lib/sync/test_sync_flow_factory.py @@ -1,5 +1,5 @@ from unittest import TestCase -from unittest.mock import ANY, MagicMock, call, patch +from unittest.mock import MagicMock, patch from samcli.lib.sync.sync_flow_factory import SyncFlowFactory @@ -11,19 +11,10 @@ def create_factory(self): ) return factory - @patch("samcli.lib.sync.sync_flow_factory.boto3.resource") - @patch("samcli.lib.sync.sync_flow_factory.Config") - def test_load_physical_id_mapping(self, config_mock, resource_mock): - resource1 = MagicMock() - resource1.logical_resource_id = "Resource1" - resource1.physical_resource_id = "PhysicalResource1" - resource2 = MagicMock() - resource2.logical_resource_id = "Resource2" - resource2.physical_resource_id = "PhysicalResource2" - - stack_mock = MagicMock() - stack_mock.resource_summaries.all.return_value = [resource1, resource2] - resource_mock.return_value.Stack.return_value = stack_mock + @patch("samcli.lib.sync.sync_flow_factory.get_physical_id_mapping") + @patch("samcli.lib.sync.sync_flow_factory.get_boto_resource_provider_with_config") + def test_load_physical_id_mapping(self, get_boto_resource_provider_mock, get_physical_id_mapping_mock): + get_physical_id_mapping_mock.return_value = {"Resource1": "PhysicalResource1", "Resource2": "PhysicalResource2"} factory = self.create_factory() factory.load_physical_id_mapping() @@ -35,8 +26,7 @@ def test_load_physical_id_mapping(self, config_mock, resource_mock): @patch("samcli.lib.sync.sync_flow_factory.ImageFunctionSyncFlow") @patch("samcli.lib.sync.sync_flow_factory.ZipFunctionSyncFlow") - @patch("samcli.lib.sync.sync_flow_factory.Config") - def test_create_lambda_flow_zip(self, config_mock, zip_function_mock, image_function_mock): + def test_create_lambda_flow_zip(self, zip_function_mock, image_function_mock): factory = self.create_factory() resource = {"Properties": {"PackageType": "Zip"}} result = factory._create_lambda_flow("Function1", resource) @@ -44,46 +34,40 @@ def test_create_lambda_flow_zip(self, config_mock, zip_function_mock, image_func @patch("samcli.lib.sync.sync_flow_factory.ImageFunctionSyncFlow") @patch("samcli.lib.sync.sync_flow_factory.ZipFunctionSyncFlow") - @patch("samcli.lib.sync.sync_flow_factory.Config") - def test_create_lambda_flow_image(self, config_mock, zip_function_mock, image_function_mock): + def test_create_lambda_flow_image(self, zip_function_mock, image_function_mock): factory = self.create_factory() resource = {"Properties": {"PackageType": "Image"}} result = factory._create_lambda_flow("Function1", resource) self.assertEqual(result, image_function_mock.return_value) @patch("samcli.lib.sync.sync_flow_factory.LayerSyncFlow") - @patch("samcli.lib.sync.sync_flow_factory.Config") - def test_create_layer_flow(self, config_mock, layer_sync_mock): + def test_create_layer_flow(self, layer_sync_mock): factory = self.create_factory() result = factory._create_layer_flow("Layer1", {}) self.assertEqual(result, layer_sync_mock.return_value) @patch("samcli.lib.sync.sync_flow_factory.ImageFunctionSyncFlow") @patch("samcli.lib.sync.sync_flow_factory.ZipFunctionSyncFlow") - @patch("samcli.lib.sync.sync_flow_factory.Config") - def test_create_lambda_flow_other(self, config_mock, zip_function_mock, image_function_mock): + def test_create_lambda_flow_other(self, zip_function_mock, image_function_mock): factory = self.create_factory() resource = {"Properties": {"PackageType": "Other"}} result = factory._create_lambda_flow("Function1", resource) self.assertEqual(result, None) @patch("samcli.lib.sync.sync_flow_factory.RestApiSyncFlow") - @patch("samcli.lib.sync.sync_flow_factory.Config") - def test_create_rest_api_flow(self, config_mock, rest_api_sync_mock): + def test_create_rest_api_flow(self, rest_api_sync_mock): factory = self.create_factory() result = factory._create_rest_api_flow("API1", {}) self.assertEqual(result, rest_api_sync_mock.return_value) @patch("samcli.lib.sync.sync_flow_factory.HttpApiSyncFlow") - @patch("samcli.lib.sync.sync_flow_factory.Config") - def test_create_api_flow(self, config_mock, http_api_sync_mock): + def test_create_api_flow(self, http_api_sync_mock): factory = self.create_factory() result = factory._create_api_flow("API1", {}) self.assertEqual(result, http_api_sync_mock.return_value) @patch("samcli.lib.sync.sync_flow_factory.get_resource_by_id") - @patch("samcli.lib.sync.sync_flow_factory.Config") - def test_create_sync_flow(self, config_mock, get_resource_by_id_mock): + def test_create_sync_flow(self, get_resource_by_id_mock): factory = self.create_factory() sync_flow = MagicMock() diff --git a/tests/unit/lib/test/test_lambda_test_executor.py b/tests/unit/lib/test/test_lambda_test_executor.py new file mode 100644 index 0000000000..0e29d3148a --- /dev/null +++ b/tests/unit/lib/test/test_lambda_test_executor.py @@ -0,0 +1,68 @@ +from unittest import TestCase +from unittest.mock import Mock + +from samcli.lib.test.lambda_test_executor import ( + LambdaInvokeExecutor, + LambdaConvertToDefaultJSON, + LambdaResponseConverter, +) +from samcli.lib.test.test_executors import TestExecutionInfo + + +class TestLambdaInvokeExecutor(TestCase): + def setUp(self) -> None: + self.lambda_client = Mock() + self.function_name = Mock() + self.lambda_invoke_executor = LambdaInvokeExecutor(self.lambda_client, self.function_name) + + def test_execute_action(self): + given_payload = Mock() + given_result = Mock() + self.lambda_client.invoke.return_value = given_result + + result = self.lambda_invoke_executor._execute_action(given_payload) + + self.assertEqual(result, given_result) + self.lambda_client.invoke.assert_called_with(FunctionName=self.function_name, Payload=given_payload) + + +class TestLambdaConvertToDefaultJSON(TestCase): + def setUp(self) -> None: + self.lambda_convert_to_default_json = LambdaConvertToDefaultJSON() + + def test_conversion(self): + given_string = "Hello World" + test_execution_info = TestExecutionInfo(given_string, None) + + expected_string = '"Hello World"' + + result = self.lambda_convert_to_default_json.map(test_execution_info) + + self.assertEqual(result.payload, expected_string) + + def test_skip(self): + given_string = '{"body": "Hello World"}' + test_execution_info = TestExecutionInfo(given_string, None) + + result = self.lambda_convert_to_default_json.map(test_execution_info) + + self.assertEqual(result.payload, given_string) + + +class TestLambdaResponseConverter(TestCase): + def setUp(self) -> None: + self.lambda_response_converter = LambdaResponseConverter() + + def test_conversion(self): + given_streaming_body = Mock() + given_decoded_string = "decoded string" + given_streaming_body.read().decode.return_value = given_decoded_string + given_test_result = {"Payload": given_streaming_body} + test_execution_info = TestExecutionInfo(None, None) + test_execution_info.response = given_test_result + + expected_result = {"Payload": given_decoded_string} + + result = self.lambda_response_converter.map(test_execution_info) + + self.assertEqual(result.response, expected_result) diff --git a/tests/unit/lib/test/test_sqs_test_executor.py b/tests/unit/lib/test/test_sqs_test_executor.py new file mode 100644 index 0000000000..197546ec05 --- /dev/null +++ b/tests/unit/lib/test/test_sqs_test_executor.py @@ -0,0 +1,65 @@ +from json import JSONDecodeError +from unittest import TestCase +from unittest.mock import Mock, patch + +from samcli.lib.test.sqs_test_executor import SqsSendMessageExecutor, SqsConvertToEntriesJsonObject +from samcli.lib.test.test_executors import TestExecutionInfo + + +class TestSqsSendMessageExecutor(TestCase): + def setUp(self) -> None: + self.sqs_client = Mock() + self.sqs_queue_url = Mock() + self.sqs_send_message_executor = SqsSendMessageExecutor(self.sqs_client, self.sqs_queue_url) + + def test_execute_action(self): + given_payload = Mock() + given_result = Mock() + self.sqs_client.send_message_batch.return_value = given_result + + result = self.sqs_send_message_executor._execute_action(given_payload) + + self.assertEqual(result, given_result) + self.sqs_client.send_message_batch.assert_called_with(QueueUrl=self.sqs_queue_url, Entries=given_payload) + + @patch("samcli.lib.test.sqs_test_executor.json") + def test_execute_action_file(self, patched_json): + given_result = Mock() + self.sqs_client.send_message_batch.return_value = given_result + + given_file_contents = Mock() + given_payload_file = Mock() + given_payload_file.read.return_value = given_file_contents + + given_json_object = Mock() + patched_json.loads.return_value = given_json_object + + result = self.sqs_send_message_executor._execute_action_file(given_payload_file) + + self.assertEqual(result, given_result) + given_payload_file.read.assert_called_once() + patched_json.loads.assert_called_with(given_file_contents) + self.sqs_client.send_message_batch.assert_called_with(QueueUrl=self.sqs_queue_url, Entries=given_json_object) + + @patch("samcli.lib.test.sqs_test_executor.json") + def test_execute_action_file_exception(self, patched_json): + given_payload_file = Mock() + + patched_json.loads.side_effect = JSONDecodeError("msg", "doc", 1) + + with self.assertRaises(JSONDecodeError): + self.sqs_send_message_executor._execute_action_file(given_payload_file) + + +class TestSqsConvertToEntriesJsonObject(TestCase): + def setUp(self) -> None: + self.sqs_convert_to_json_object = SqsConvertToEntriesJsonObject() + + def test_conversion(self): + given_content = "Hello World" + test_execution_info = TestExecutionInfo(given_content, None) + + result = self.sqs_convert_to_json_object.map(test_execution_info) + + self.assertEqual(given_content, result.payload[0]["MessageBody"]) + self.assertTrue("Id" in result.payload[0]) diff --git a/tests/unit/lib/test/test_test_executor_factory.py b/tests/unit/lib/test/test_test_executor_factory.py new file mode 100644 index 0000000000..2c050d6203 --- /dev/null +++ b/tests/unit/lib/test/test_test_executor_factory.py @@ -0,0 +1,106 @@ +from unittest import TestCase +from unittest.mock import patch, Mock + +from samcli.lib.test.test_executor_factory import TestExecutorFactory + + +class TestTestExecutorFactory(TestCase): + def setUp(self) -> None: + self.boto_client_provider_mock = Mock() + self.test_executor_factory = TestExecutorFactory(self.boto_client_provider_mock) + + @patch("samcli.lib.test.test_executor_factory.TestExecutorFactory.EXECUTOR_MAPPING") + def test_create_test_executor(self, patched_executor_mapping): + given_executor_creator_method = Mock() + patched_executor_mapping.get.return_value = given_executor_creator_method + + given_executor = Mock() + given_executor_creator_method.return_value = given_executor + + given_cfn_resource_summary = Mock() + executor = self.test_executor_factory.create_test_executor(given_cfn_resource_summary) + + patched_executor_mapping.get.assert_called_with(given_cfn_resource_summary.resource_type) + given_executor_creator_method.assert_called_with(self.test_executor_factory, given_cfn_resource_summary) + self.assertEqual(executor, given_executor) + + def test_failed_create_test_executor(self): + given_cfn_resource_summary = Mock() + executor = self.test_executor_factory.create_test_executor(given_cfn_resource_summary) + self.assertIsNone(executor) + + @patch("samcli.lib.test.test_executor_factory.LambdaInvokeExecutor") + @patch("samcli.lib.test.test_executor_factory.LambdaConvertToDefaultJSON") + @patch("samcli.lib.test.test_executor_factory.LambdaResponseConverter") + @patch("samcli.lib.test.test_executor_factory.ResponseObjectToJsonStringMapper") + @patch("samcli.lib.test.test_executor_factory.TestExecutor") + def test_create_lambda_test_executor( + self, + patched_test_executor, + patched_object_to_json_converter, + patched_response_converter, + patched_convert_to_default_json, + patched_lambda_invoke_executor, + ): + given_physical_resource_id = "physical_resource_id" + given_cfn_resource_summary = Mock(physical_resource_id="physical_resource_id") + + given_lambda_client = Mock() + self.boto_client_provider_mock.return_value = given_lambda_client + + given_test_executor = Mock() + patched_test_executor.return_value = given_test_executor + + lambda_executor = self.test_executor_factory._create_lambda_test_executor(given_cfn_resource_summary) + + self.assertEqual(lambda_executor, given_test_executor) + + patched_convert_to_default_json.assert_called_once() + patched_response_converter.assert_called_once() + + self.boto_client_provider_mock.assert_called_with("lambda") + patched_lambda_invoke_executor.assert_called_with(given_lambda_client, given_physical_resource_id) + + patched_test_executor.assert_called_with( + request_mappers=[patched_convert_to_default_json()], + response_mappers=[patched_response_converter(), patched_object_to_json_converter()], + boto_action_executor=patched_lambda_invoke_executor(), + ) + + @patch("samcli.lib.test.test_executor_factory.SqsSendMessageExecutor") + @patch("samcli.lib.test.test_executor_factory.ResponseObjectToJsonStringMapper") + @patch("samcli.lib.test.test_executor_factory.SqsConvertToEntriesJsonObject") + @patch("samcli.lib.test.test_executor_factory.TestExecutor") + def test_create_sqs_test_executor( + self, + patched_test_executor, + patched_convert_to_json, + patched_convert_response_to_string, + patched_sqs_message_executor, + ): + given_physical_resource_id = "physical_resource_id" + given_cfn_resource_summary = Mock(physical_resource_id="physical_resource_id") + + given_sqs_client = Mock() + self.boto_client_provider_mock.return_value = given_sqs_client + + given_test_executor = Mock() + patched_test_executor.return_value = given_test_executor + + lambda_executor = self.test_executor_factory._create_sqs_test_executor(given_cfn_resource_summary) + + self.assertEqual(lambda_executor, given_test_executor) + + patched_convert_to_json.assert_called_once() + patched_convert_response_to_string.assert_called_once() + + self.boto_client_provider_mock.assert_called_with("sqs") + patched_sqs_message_executor.assert_called_with(given_sqs_client, given_physical_resource_id) + + patched_test_executor.assert_called_with( + request_mappers=[ + patched_convert_to_json(), + ], + response_mappers=[patched_convert_response_to_string()], + boto_action_executor=patched_sqs_message_executor(), + ) diff --git a/tests/unit/lib/test/test_test_executors.py b/tests/unit/lib/test/test_test_executors.py new file mode 100644 index 0000000000..0fdd090b37 --- /dev/null +++ b/tests/unit/lib/test/test_test_executors.py @@ -0,0 +1,165 @@ +import json +from pathlib import Path +from unittest import TestCase +from unittest.mock import Mock, patch + +from samcli.lib.test.test_executors import ( + TestExecutionInfo, + BotoActionExecutor, + TestExecutor, + ResponseObjectToJsonStringMapper, +) + + +class TestTestExecutionInfo(TestCase): + def test_execution_info_payload(self): + given_payload = Mock() + + test_execution_info = TestExecutionInfo(given_payload, None) + + self.assertEqual(given_payload, test_execution_info.payload) + self.assertFalse(test_execution_info.is_file_provided()) + self.assertIsNone(test_execution_info.payload_file_path) + + def test_execution_info_payload_file(self): + given_payload_file = Mock() + + test_execution_info = TestExecutionInfo(None, given_payload_file) + + self.assertIsNone(test_execution_info.payload) + self.assertTrue(test_execution_info.is_file_provided()) + + file_path = test_execution_info.payload_file_path + + self.assertEqual(file_path, given_payload_file) + + def test_execution_success(self): + given_response = Mock() + + test_execution_info = TestExecutionInfo(None, None) + test_execution_info.response = given_response + + self.assertTrue(test_execution_info.is_succeeded()) + self.assertEqual(test_execution_info.response, given_response) + + def test_execution_failed(self): + given_exception = Mock() + + test_execution_info = TestExecutionInfo(None, None) + test_execution_info.exception = given_exception + + self.assertFalse(test_execution_info.is_succeeded()) + self.assertEqual(test_execution_info.exception, given_exception) + + +class ExampleBotoActionExecutor(BotoActionExecutor): + def _execute_action(self, payload: str) -> dict: + return {} + + +class TestBotoActionExecutor(TestCase): + def setUp(self) -> None: + self.boto_action_executor = ExampleBotoActionExecutor() + + def test_execute_with_payload(self): + given_payload = Mock() + test_execution_info = TestExecutionInfo(given_payload, None) + + with patch.object(self.boto_action_executor, "_execute_action") as patched_execute_action, patch.object( + self.boto_action_executor, "_execute_action_file" + ) as patched_execute_action_file: + given_result = Mock() + patched_execute_action.return_value = given_result + + result = self.boto_action_executor.execute(test_execution_info) + + patched_execute_action.assert_called_with(given_payload) + patched_execute_action_file.assert_not_called() + + self.assertEqual(given_result, result.response) + + def test_execute_with_payload_file(self): + given_payload_file = Mock() + test_execution_info = TestExecutionInfo(None, given_payload_file) + + with patch.object(self.boto_action_executor, "_execute_action") as patched_execute_action, patch.object( + self.boto_action_executor, "_execute_action_file" + ) as patched_execute_action_file: + given_result = Mock() + patched_execute_action_file.return_value = given_result + + result = self.boto_action_executor.execute(test_execution_info) + + patched_execute_action_file.assert_called_with(given_payload_file) + patched_execute_action.assert_not_called() + + self.assertEqual(given_result, result.response) + + def test_execute_error(self): + given_payload = Mock() + test_execution_info = TestExecutionInfo(given_payload, None) + + with patch.object(self.boto_action_executor, "_execute_action") as patched_execute_action: + given_exception = ValueError() + patched_execute_action.side_effect = given_exception + + result = self.boto_action_executor.execute(test_execution_info) + + patched_execute_action.assert_called_with(given_payload) + + self.assertEqual(given_exception, result.exception) + + +class TestTestExecutor(TestCase): + def setUp(self) -> None: + self.mock_boto_action_executor = Mock() + self.mock_request_mappers = [Mock(), Mock(), Mock()] + self.mock_response_mappers = [Mock(), Mock(), Mock()] + + self.test_executor = TestExecutor( + self.mock_request_mappers, self.mock_response_mappers, self.mock_boto_action_executor + ) + + def test_execution(self): + given_payload = Mock() + test_execution_info = TestExecutionInfo(given_payload, None) + + result = self.test_executor.execute(test_execution_info) + + self.assertIsNotNone(result) + + for request_mapper in self.mock_request_mappers: + request_mapper.map.assert_called_once() + + for response_mapper in self.mock_response_mappers: + response_mapper.map.assert_called_once() + + def test_execution_failure(self): + given_payload = Mock() + test_execution_info = TestExecutionInfo(given_payload, None) + + given_result_execution_info = TestExecutionInfo(given_payload, None) + given_result_execution_info.exception = Mock() + self.mock_boto_action_executor.execute.return_value = given_result_execution_info + + result = self.test_executor.execute(test_execution_info) + + self.assertIsNotNone(result) + + for request_mapper in self.mock_request_mappers: + request_mapper.map.assert_called_once() + + for response_mapper in self.mock_response_mappers: + response_mapper.map.assert_not_called() + + +class TestResponseObjectToJsonStringMapper(TestCase): + def test_mapper(self): + given_object = [{"key": "value", "key2": 123}] + test_execution_info = TestExecutionInfo(None, None) + test_execution_info.response = given_object + + mapper = ResponseObjectToJsonStringMapper() + result = mapper.map(test_execution_info) + + self.assertEqual(result.response, json.dumps(given_object, indent=2)) diff --git a/tests/unit/lib/utils/test_boto_utils.py b/tests/unit/lib/utils/test_boto_utils.py new file mode 100644 index 0000000000..35d45b77f6 --- /dev/null +++ b/tests/unit/lib/utils/test_boto_utils.py @@ -0,0 +1,75 @@ +from unittest import TestCase +from unittest.mock import patch, Mock + +from parameterized import parameterized + +from samcli.lib.utils.boto_utils import ( + get_boto_config_with_user_agent, + get_boto_client_provider_with_config, + get_boto_resource_provider_with_config, +) + +TEST_VERSION = "1.0.0" + + +class TestBotoUtils(TestCase): + @parameterized.expand([(True,), (False,)]) + @patch("samcli.lib.utils.boto_utils.GlobalConfig") + @patch("samcli.lib.utils.boto_utils.__version__", TEST_VERSION) + def test_get_boto_config_with_user_agent( + self, + telemetry_enabled, + patched_global_config, + ): + given_global_config_instance = Mock() + patched_global_config.return_value = given_global_config_instance + + given_global_config_instance.telemetry_enabled = telemetry_enabled + given_region_name = "us-west-2" + + config = get_boto_config_with_user_agent(region_name=given_region_name) + + self.assertEqual(given_region_name, config.region_name) + + if telemetry_enabled: + self.assertEqual( + config.user_agent_extra, f"aws-sam-cli/{TEST_VERSION}/{given_global_config_instance.installation_id}" + ) + else: + self.assertEqual(config.user_agent_extra, f"aws-sam-cli/{TEST_VERSION}") + + @patch("samcli.lib.utils.boto_utils.get_boto_config_with_user_agent") + @patch("samcli.lib.utils.boto_utils.boto3") + def test_get_boto_client_provider_with_config(self, patched_boto3, patched_get_config): + given_config = Mock() + patched_get_config.return_value = given_config + + given_config_param = Mock() + client_generator = get_boto_client_provider_with_config(param=given_config_param) + + given_service_client = Mock() + patched_boto3.session.Session().client.return_value = given_service_client + + client = client_generator("service") + + self.assertEqual(client, given_service_client) + patched_get_config.assert_called_with(param=given_config_param) + patched_boto3.session.Session().client.assert_called_with("service", config=given_config) + + @patch("samcli.lib.utils.boto_utils.get_boto_config_with_user_agent") + @patch("samcli.lib.utils.boto_utils.boto3") + def test_get_boto_resource_provider_with_config(self, patched_boto3, patched_get_config): + given_config = Mock() + patched_get_config.return_value = given_config + + given_config_param = Mock() + client_generator = get_boto_resource_provider_with_config(param=given_config_param) + + given_service_client = Mock() + patched_boto3.session.Session().resource.return_value = given_service_client + + client = client_generator("service") + + self.assertEqual(client, given_service_client) + patched_get_config.assert_called_with(param=given_config_param) + patched_boto3.session.Session().resource.assert_called_with("service", config=given_config) diff --git a/tests/unit/lib/utils/test_cloudformation.py b/tests/unit/lib/utils/test_cloudformation.py new file mode 100644 index 0000000000..f925e295ba --- /dev/null +++ b/tests/unit/lib/utils/test_cloudformation.py @@ -0,0 +1,119 @@ +from unittest import TestCase +from unittest.mock import patch, Mock, ANY + +from botocore.exceptions import ClientError + +from samcli.lib.utils.cloudformation import ( + CloudFormationResourceSummary, + get_physical_id_mapping, + get_resource_summaries, + get_resource_summary, +) + + +class TestCloudFormationResourceSummary(TestCase): + def test_cfn_resource_summary(self): + given_type = "type" + given_logical_id = "logical_id" + given_physical_id = "physical_id" + + resource_summary = CloudFormationResourceSummary(given_type, given_logical_id, given_physical_id) + + self.assertEqual(given_type, resource_summary.resource_type) + self.assertEqual(given_logical_id, resource_summary.logical_resource_id) + self.assertEqual(given_physical_id, resource_summary.physical_resource_id) + + +class TestCloudformationUtils(TestCase): + @patch("samcli.lib.utils.cloudformation.get_resource_summaries") + def test_get_physical_id_mapping(self, patched_get_resource_summaries): + patched_get_resource_summaries.return_value = [ + CloudFormationResourceSummary("", "Logical1", "Physical1"), + CloudFormationResourceSummary("", "Logical2", "Physical2"), + CloudFormationResourceSummary("", "Logical3", "Physical3"), + ] + + given_resource_provider = Mock() + given_resource_types = Mock() + given_stack_name = "stack_name" + physical_id_mapping = get_physical_id_mapping(given_resource_provider, given_stack_name, given_resource_types) + + self.assertEqual( + physical_id_mapping, + { + "Logical1": "Physical1", + "Logical2": "Physical2", + "Logical3": "Physical3", + }, + ) + + patched_get_resource_summaries.assert_called_with( + given_resource_provider, given_stack_name, given_resource_types + ) + + def test_get_resource_summaries(self): + resource_provider_mock = Mock() + given_stack_name = "stack_name" + given_resource_types = {"ResourceType0"} + + given_stack_resource_array = [ + Mock( + physical_resource_id="physical_id_1", logical_resource_id="logical_id_1", resource_type="ResourceType0" + ), + Mock( + physical_resource_id="physical_id_2", logical_resource_id="logical_id_2", resource_type="ResourceType0" + ), + Mock( + physical_resource_id="physical_id_3", logical_resource_id="logical_id_3", resource_type="ResourceType1" + ), + ] + + resource_provider_mock(ANY).Stack(ANY).resource_summaries.all.return_value = given_stack_resource_array + + resource_summaries = get_resource_summaries(resource_provider_mock, given_stack_name, given_resource_types) + + self.assertEqual(len(resource_summaries), 2) + self.assertEqual( + resource_summaries, + [ + CloudFormationResourceSummary("ResourceType0", "logical_id_1", "physical_id_1"), + CloudFormationResourceSummary("ResourceType0", "logical_id_2", "physical_id_2"), + ], + ) + + resource_provider_mock.assert_called_with("cloudformation") + resource_provider_mock(ANY).Stack.assert_called_with(given_stack_name) + resource_provider_mock(ANY).Stack(ANY).resource_summaries.all.assert_called_once() + + def test_get_resource_summary(self): + resource_provider_mock = Mock() + given_stack_name = "stack_name" + given_resource_logical_id = "logical_id_1" + + given_resource_type = "ResourceType0" + given_physical_id = "physical_id_1" + resource_provider_mock(ANY).StackResource.return_value = Mock( + physical_resource_id=given_physical_id, + logical_resource_id=given_resource_logical_id, + resource_type=given_resource_type, + ) + + resource_summary = get_resource_summary(resource_provider_mock, given_stack_name, given_resource_logical_id) + + self.assertEqual(resource_summary.resource_type, given_resource_type) + self.assertEqual(resource_summary.logical_resource_id, given_resource_logical_id) + self.assertEqual(resource_summary.physical_resource_id, given_physical_id) + + resource_provider_mock.assert_called_with("cloudformation") + resource_provider_mock(ANY).StackResource.assert_called_with(given_stack_name, given_resource_logical_id) + + def test_get_resource_summary_fail(self): + resource_provider_mock = Mock() + given_stack_name = "stack_name" + given_resource_logical_id = "logical_id_1" + + resource_provider_mock(ANY).StackResource.side_effect = ClientError({}, "operation") + + resource_summary = get_resource_summary(resource_provider_mock, given_stack_name, given_resource_logical_id) + + self.assertIsNone(resource_summary)