From d96411269b7bb362683d490e45b382297282f595 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 19 May 2022 14:53:07 -0700 Subject: [PATCH 01/72] Added the base commands of sam list and their corresponding help messages --- samcli/cli/command.py | 1 + samcli/commands/list/__init__.py | 0 samcli/commands/list/list.py | 22 ++++++++++ samcli/commands/list/resources/__init__.py | 0 samcli/commands/list/resources/cli.py | 42 ++++++++++++++++++ .../commands/list/stack_outputs/__init__.py | 0 samcli/commands/list/stack_outputs/cli.py | 38 ++++++++++++++++ .../list/testable_resources/__init__.py | 0 .../commands/list/testable_resources/cli.py | 43 +++++++++++++++++++ 9 files changed, 146 insertions(+) create mode 100644 samcli/commands/list/__init__.py create mode 100644 samcli/commands/list/list.py create mode 100644 samcli/commands/list/resources/__init__.py create mode 100644 samcli/commands/list/resources/cli.py create mode 100644 samcli/commands/list/stack_outputs/__init__.py create mode 100644 samcli/commands/list/stack_outputs/cli.py create mode 100644 samcli/commands/list/testable_resources/__init__.py create mode 100644 samcli/commands/list/testable_resources/cli.py diff --git a/samcli/cli/command.py b/samcli/cli/command.py index c0465db511e..efac2241c7f 100644 --- a/samcli/cli/command.py +++ b/samcli/cli/command.py @@ -25,6 +25,7 @@ "samcli.commands.traces", "samcli.commands.sync", "samcli.commands.pipeline.pipeline", + "samcli.commands.list.list", # We intentionally do not expose the `bootstrap` command for now. We might open it up later # "samcli.commands.bootstrap", ] diff --git a/samcli/commands/list/__init__.py b/samcli/commands/list/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/commands/list/list.py b/samcli/commands/list/list.py new file mode 100644 index 00000000000..d8c1683376a --- /dev/null +++ b/samcli/commands/list/list.py @@ -0,0 +1,22 @@ +""" +Command group for "list" suite for commands. +""" + +import click + +from .resources.cli import cli as resources_cli +from .stack_outputs.cli import cli as stack_outputs_cli +from .testable_resources.cli import cli as testable_resources_cli + + +@click.group() +def cli(): + """ + Get local and deployed state of serverless application. + """ + + +# Add individual commands under this group +cli.add_command(resources_cli) +cli.add_command(stack_outputs_cli) +cli.add_command(testable_resources_cli) diff --git a/samcli/commands/list/resources/__init__.py b/samcli/commands/list/resources/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py new file mode 100644 index 00000000000..dad4cf71519 --- /dev/null +++ b/samcli/commands/list/resources/cli.py @@ -0,0 +1,42 @@ +""" +Sets up the cli for resources +""" + +import click + +from samcli.cli.main import pass_context +from samcli.commands.local.generate_event.event_generation import GenerateEventCommand + +HELP_TEXT = """ +Get a list of resources that will be deployed to CloudFormation.\n +\b +If a stack name is provided, the corresponding physical IDs of each +resource will be mapped to the logical ID of each resource. +""" + +# @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) + + +@click.command(name="resources", help=HELP_TEXT) +@click.option( + "--stack-name", + help=( + "Name of corresponding deployed stack.(Not including" + "a stack name will only show local resources defined" + "in the template.)" + ), + type=click.STRING, +) +@click.option( + "--output", + help=( + "Output the results from the command in a given" + "output format (json, yaml, table or text)." + ), + type=click.STRING, +) +@pass_context +def cli(self, stack_name, output): + """ + Generate an event for one of the services listed below: + """ diff --git a/samcli/commands/list/stack_outputs/__init__.py b/samcli/commands/list/stack_outputs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/commands/list/stack_outputs/cli.py b/samcli/commands/list/stack_outputs/cli.py new file mode 100644 index 00000000000..b3b3ea189ba --- /dev/null +++ b/samcli/commands/list/stack_outputs/cli.py @@ -0,0 +1,38 @@ +""" +Sets up the cli for stack-outputs +""" + +import click + +from samcli.cli.main import pass_context +from samcli.commands.local.generate_event.event_generation import GenerateEventCommand + +HELP_TEXT = """ +Get the stack outputs as defined in the SAM/CloudFormation template. +""" + +# @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) + + +@click.command(name="stack-outputs", help=HELP_TEXT) +@click.option( + "--stack-name", + help=( + "Name of corresponding deployed stack." + ), + required=True, + type=click.STRING, +) +@click.option( + "--output", + help=( + "Output the results from the command in a given" + "output format (json, yaml, table or text)." + ), + type=click.STRING, +) +@pass_context +def cli(self, stack_name, output): + """ + Generate an event for one of the services listed below: + """ \ No newline at end of file diff --git a/samcli/commands/list/testable_resources/__init__.py b/samcli/commands/list/testable_resources/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py new file mode 100644 index 00000000000..2dfd4262c0f --- /dev/null +++ b/samcli/commands/list/testable_resources/cli.py @@ -0,0 +1,43 @@ +""" +Sets up the cli for resources +""" + +import click + +from samcli.cli.main import pass_context +from samcli.commands.local.generate_event.event_generation import GenerateEventCommand + +HELP_TEXT = """ +Get a summary of the testable resources in the stack. \n +\b +This command will show both the cloud and local endpoints that can +be used with sam local and sam sync. Currently the testable resources +are lambda functions and API Gateway API resources. +""" + +# @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) + + +@click.command(name="testable-resources", help=HELP_TEXT) +@click.option( + "--stack-name", + help=( + "Name of corresponding deployed stack.(Not including" + "a stack name will only show local resources defined" + "in the template.)" + ), + type=click.STRING, +) +@click.option( + "--output", + help=( + "Output the results from the command in a given" + "output format (json, yaml, table or text)." + ), + type=click.STRING, +) +@pass_context +def cli(self, stack_name, output): + """ + Generate an event for one of the services listed below: + """ From 5cc31a5e7ece28cb2b053a5974b16cccbb154fc1 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Tue, 24 May 2022 10:38:43 -0700 Subject: [PATCH 02/72] Added no-args handling to base commands and added files for integration test suite --- samcli/commands/list/resources/cli.py | 2 +- samcli/commands/list/stack_outputs/cli.py | 2 +- samcli/commands/list/testable_resources/cli.py | 2 +- tests/integration/list/__init__.py | 0 tests/integration/list/resources/__init__.py | 0 tests/integration/list/resources/resources_integ_base.py | 9 +++++++++ 6 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 tests/integration/list/__init__.py create mode 100644 tests/integration/list/resources/__init__.py create mode 100644 tests/integration/list/resources/resources_integ_base.py diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index dad4cf71519..9e71ee39a9e 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -17,7 +17,7 @@ # @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) -@click.command(name="resources", help=HELP_TEXT) +@click.command(name="resources", no_args_is_help=True, help=HELP_TEXT) @click.option( "--stack-name", help=( diff --git a/samcli/commands/list/stack_outputs/cli.py b/samcli/commands/list/stack_outputs/cli.py index b3b3ea189ba..31bb6ef27b3 100644 --- a/samcli/commands/list/stack_outputs/cli.py +++ b/samcli/commands/list/stack_outputs/cli.py @@ -14,7 +14,7 @@ # @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) -@click.command(name="stack-outputs", help=HELP_TEXT) +@click.command(name="stack-outputs", no_args_is_help=True, help=HELP_TEXT) @click.option( "--stack-name", help=( diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py index 2dfd4262c0f..7cabdf93ac4 100644 --- a/samcli/commands/list/testable_resources/cli.py +++ b/samcli/commands/list/testable_resources/cli.py @@ -18,7 +18,7 @@ # @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) -@click.command(name="testable-resources", help=HELP_TEXT) +@click.command(name="testable-resources", no_args_is_help=True, help=HELP_TEXT) @click.option( "--stack-name", help=( diff --git a/tests/integration/list/__init__.py b/tests/integration/list/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/list/resources/__init__.py b/tests/integration/list/resources/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/list/resources/resources_integ_base.py b/tests/integration/list/resources/resources_integ_base.py new file mode 100644 index 00000000000..f65403b0942 --- /dev/null +++ b/tests/integration/list/resources/resources_integ_base.py @@ -0,0 +1,9 @@ +import os +from typing import Optional +from unittest import TestCase, skipIf +from pathlib import Path +from subprocess import Popen, PIPE, TimeoutExpired + +class ResourcesIntegBase(TestCase): + @classmethod + def setUpClass(cls): From 70b3ba8c43fa262e12d5d9a88fef03ab69a74a3d Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 25 May 2022 12:04:50 -0700 Subject: [PATCH 03/72] Made additions to resources integration tests --- .../list/resources/resources_integ_base.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/integration/list/resources/resources_integ_base.py b/tests/integration/list/resources/resources_integ_base.py index f65403b0942..b98315649e4 100644 --- a/tests/integration/list/resources/resources_integ_base.py +++ b/tests/integration/list/resources/resources_integ_base.py @@ -7,3 +7,26 @@ class ResourcesIntegBase(TestCase): @classmethod def setUpClass(cls): + cls.cmd = cls.base_command() + cls.resources_test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "delete") + + def setUp(self): + super().setUp() + + def tearDown(self): + super().tearDown() + + def base_command(self): + command = "sam" + if os.getenv("SAM_CLI_DEV"): + command = "samdev" + + return command + + def get_resources_command_list(self, stack_name=None, output=None): + command_list = [self.base_command(), "list", "resources"] + if stack_name: + command_list += ["--stack-name", str(stack_name)] + + if output: + command_list += ["--output", str(output)] From fc81cebf064c499529726a0c91cff28ca785e300 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 25 May 2022 12:45:02 -0700 Subject: [PATCH 04/72] Made additions to the sam list integration test suite --- .../list/resources/resources_integ_base.py | 7 +++- .../list/resources/test_resources_command.py | 30 +++++++++++++++ .../list/stack_outputs/__init__.py | 0 .../stack_outputs/stack_outputs_integ_base.py | 37 +++++++++++++++++++ .../list/testable_resources/__init__.py | 0 .../testable_resources_integ_base.py | 37 +++++++++++++++++++ 6 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 tests/integration/list/resources/test_resources_command.py create mode 100644 tests/integration/list/stack_outputs/__init__.py create mode 100644 tests/integration/list/stack_outputs/stack_outputs_integ_base.py create mode 100644 tests/integration/list/testable_resources/__init__.py create mode 100644 tests/integration/list/testable_resources/testable_resources_integ_base.py diff --git a/tests/integration/list/resources/resources_integ_base.py b/tests/integration/list/resources/resources_integ_base.py index b98315649e4..899cc919a06 100644 --- a/tests/integration/list/resources/resources_integ_base.py +++ b/tests/integration/list/resources/resources_integ_base.py @@ -4,11 +4,12 @@ from pathlib import Path from subprocess import Popen, PIPE, TimeoutExpired + class ResourcesIntegBase(TestCase): @classmethod def setUpClass(cls): cls.cmd = cls.base_command() - cls.resources_test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "delete") + cls.resources_test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "list") def setUp(self): super().setUp() @@ -30,3 +31,7 @@ def get_resources_command_list(self, stack_name=None, output=None): if output: command_list += ["--output", str(output)] + + return command_list + + diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py new file mode 100644 index 00000000000..20a1d205e8d --- /dev/null +++ b/tests/integration/list/resources/test_resources_command.py @@ -0,0 +1,30 @@ +import logging +import os +import random +import shutil +import sys +from pathlib import Path +from typing import Set +from unittest import skipIf + +import jmespath +import docker +import pytest +from parameterized import parameterized, parameterized_class + +from samcli.lib.utils import osutils +from samcli.yamlhelper import yaml_parse +from tests.testing_utils import ( + IS_WINDOWS, + RUNNING_ON_CI, + RUNNING_TEST_FOR_MASTER_ON_CI, + RUN_BY_CANARY, + CI_OVERRIDE, + run_command, + SKIP_DOCKER_TESTS, + SKIP_DOCKER_BUILD, + SKIP_DOCKER_MESSAGE, +) +from .resources_integ_base import ResourcesIntegBase + +class TestResources(ResourcesIntegBase): diff --git a/tests/integration/list/stack_outputs/__init__.py b/tests/integration/list/stack_outputs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/list/stack_outputs/stack_outputs_integ_base.py b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py new file mode 100644 index 00000000000..3f4f632da70 --- /dev/null +++ b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py @@ -0,0 +1,37 @@ +import os +from typing import Optional +from unittest import TestCase, skipIf +from pathlib import Path +from subprocess import Popen, PIPE, TimeoutExpired + + +class StackOutputsIntegBase(TestCase): + @classmethod + def setUpClass(cls): + cls.cmd = cls.base_command() + cls.stack_outputs_test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "list") + + def setUp(self): + super().setUp() + + def tearDown(self): + super().tearDown() + + def base_command(self): + command = "sam" + if os.getenv("SAM_CLI_DEV"): + command = "samdev" + + return command + + def get_stack_outputs_command_list(self, stack_name=None, output=None): + command_list = [self.base_command(), "list", "stack-outputs"] + if stack_name: + command_list += ["--stack-name", str(stack_name)] + + if output: + command_list += ["--output", str(output)] + + return command_list + + diff --git a/tests/integration/list/testable_resources/__init__.py b/tests/integration/list/testable_resources/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/list/testable_resources/testable_resources_integ_base.py b/tests/integration/list/testable_resources/testable_resources_integ_base.py new file mode 100644 index 00000000000..e82343bfa6e --- /dev/null +++ b/tests/integration/list/testable_resources/testable_resources_integ_base.py @@ -0,0 +1,37 @@ +import os +from typing import Optional +from unittest import TestCase, skipIf +from pathlib import Path +from subprocess import Popen, PIPE, TimeoutExpired + + +class TestableResourcesIntegBase(TestCase): + @classmethod + def setUpClass(cls): + cls.cmd = cls.base_command() + cls.testable_resources_test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "list") + + def setUp(self): + super().setUp() + + def tearDown(self): + super().tearDown() + + def base_command(self): + command = "sam" + if os.getenv("SAM_CLI_DEV"): + command = "samdev" + + return command + + def get_testable_resources_command_list(self, stack_name=None, output=None): + command_list = [self.base_command(), "list", "testable-resources"] + if stack_name: + command_list += ["--stack-name", str(stack_name)] + + if output: + command_list += ["--output", str(output)] + + return command_list + + From 97ec32d27fb6176b03956e1203d9c768a877e6af Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 25 May 2022 16:52:25 -0700 Subject: [PATCH 05/72] Added to sam list integration and unit tests --- samcli/commands/list/resources/cli.py | 6 ++ .../list/resources/test_resources_command.py | 1 + tests/unit/commands/list/__init__.py | 0 .../unit/commands/list/resources/__init__.py | 0 .../unit/commands/list/resources/test_cli.py | 64 +++++++++++++++++++ .../commands/list/resources/test_resources.py | 12 ++++ 6 files changed, 83 insertions(+) create mode 100644 tests/unit/commands/list/__init__.py create mode 100644 tests/unit/commands/list/resources/__init__.py create mode 100644 tests/unit/commands/list/resources/test_cli.py create mode 100644 tests/unit/commands/list/resources/test_resources.py diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index 9e71ee39a9e..279d79d8fb8 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -40,3 +40,9 @@ def cli(self, stack_name, output): """ Generate an event for one of the services listed below: """ + + do_cli(self, stack_name, output) + + +def do_cli(self, stack_name, output): + pass diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 20a1d205e8d..2bc715a4abe 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -28,3 +28,4 @@ from .resources_integ_base import ResourcesIntegBase class TestResources(ResourcesIntegBase): + def test_ \ No newline at end of file diff --git a/tests/unit/commands/list/__init__.py b/tests/unit/commands/list/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/commands/list/resources/__init__.py b/tests/unit/commands/list/resources/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/commands/list/resources/test_cli.py b/tests/unit/commands/list/resources/test_cli.py new file mode 100644 index 00000000000..2f5f88c126a --- /dev/null +++ b/tests/unit/commands/list/resources/test_cli.py @@ -0,0 +1,64 @@ +from unittest import TestCase +from unittest.mock import patch, Mock +from parameterized import parameterized, param + + +class TestCli(TestCase): + def test_cli_base_command(self, get_event_mock): + event_data = "data" + get_event_mock.return_value = event_data + + ctx_mock = Mock() + ctx_mock.region = self.region_name + ctx_mock.profile = self.profile + + # Mock the __enter__ method to return a object inside a context manager + context_mock = Mock() + InvokeContextMock.return_value.__enter__.return_value = context_mock + + invoke_cli( + ctx=ctx_mock, + function_identifier=self.function_id, + template=self.template, + event=self.eventfile, + no_event=self.no_event, + env_vars=self.env_vars, + debug_port=self.debug_ports, + debug_args=self.debug_args, + debugger_path=self.debugger_path, + container_env_vars=self.container_env_vars, + docker_volume_basedir=self.docker_volume_basedir, + docker_network=self.docker_network, + log_file=self.log_file, + skip_pull_image=self.skip_pull_image, + parameter_overrides=self.parameter_overrides, + layer_cache_basedir=self.layer_cache_basedir, + force_image_build=self.force_image_build, + shutdown=self.shutdown, + container_host=self.container_host, + container_host_interface=self.container_host_interface, + invoke_image=self.invoke_image, + ) + + InvokeContextMock.assert_called_with( + template_file=self.template, + function_identifier=self.function_id, + env_vars_file=self.env_vars, + docker_volume_basedir=self.docker_volume_basedir, + docker_network=self.docker_network, + log_file=self.log_file, + skip_pull_image=self.skip_pull_image, + debug_ports=self.debug_ports, + debug_args=self.debug_args, + debugger_path=self.debugger_path, + container_env_vars_file=self.container_env_vars, + parameter_overrides=self.parameter_overrides, + layer_cache_basedir=self.layer_cache_basedir, + force_image_build=self.force_image_build, + shutdown=self.shutdown, + aws_region=self.region_name, + aws_profile=self.profile, + container_host=self.container_host, + container_host_interface=self.container_host_interface, + invoke_images={None: "amazon/aws-sam-cli-emulation-image-python3.6"}, + ) diff --git a/tests/unit/commands/list/resources/test_resources.py b/tests/unit/commands/list/resources/test_resources.py new file mode 100644 index 00000000000..c8e8253a0e4 --- /dev/null +++ b/tests/unit/commands/list/resources/test_resources.py @@ -0,0 +1,12 @@ +from unittest import TestCase +from unittest.mock import patch, Mock +from parameterized import parameterized, param +from unittest.mock import patch, call, MagicMock + +import click + +#from samcli.commands.list.resources.cli import DeleteContext +#from samcli.lib.package.artifact_exporter import Template +#from samcli.cli.cli_config_file import TomlProvider + +#class TestResources \ No newline at end of file From 3e200f2d2b266c15724037c861625727f0008972 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 26 May 2022 16:03:18 -0700 Subject: [PATCH 06/72] Added integration tests to test help messages of sam list commands --- samcli/commands/list/resources/cli.py | 16 +++----- samcli/commands/list/stack_outputs/cli.py | 16 +++----- .../commands/list/testable_resources/cli.py | 14 +++---- .../list/resources/resources_integ_base.py | 21 +++++++--- .../list/resources/test_resources_command.py | 40 ++++++------------- .../stack_outputs/stack_outputs_integ_base.py | 20 +++++++--- .../test_stack_outputs_command.py | 14 +++++++ .../test_testable_resources_command.py | 15 +++++++ .../testable_resources_integ_base.py | 20 +++++++--- 9 files changed, 100 insertions(+), 76 deletions(-) create mode 100644 tests/integration/list/stack_outputs/test_stack_outputs_command.py create mode 100644 tests/integration/list/testable_resources/test_testable_resources_command.py diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index 279d79d8fb8..624f4879d4a 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -5,11 +5,10 @@ import click from samcli.cli.main import pass_context -from samcli.commands.local.generate_event.event_generation import GenerateEventCommand HELP_TEXT = """ Get a list of resources that will be deployed to CloudFormation.\n -\b + If a stack name is provided, the corresponding physical IDs of each resource will be mapped to the logical ID of each resource. """ @@ -17,7 +16,7 @@ # @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) -@click.command(name="resources", no_args_is_help=True, help=HELP_TEXT) +@click.command(name="resources", help=HELP_TEXT) @click.option( "--stack-name", help=( @@ -29,11 +28,8 @@ ) @click.option( "--output", - help=( - "Output the results from the command in a given" - "output format (json, yaml, table or text)." - ), - type=click.STRING, + help=("Output the results from the command in a given" "output format (json, yaml, table or text)."), + type=click.Choice(["json", "yaml", "table", "text"], case_sensitive=False), ) @pass_context def cli(self, stack_name, output): @@ -41,8 +37,8 @@ def cli(self, stack_name, output): Generate an event for one of the services listed below: """ - do_cli(self, stack_name, output) + do_cli(stack_name, output) -def do_cli(self, stack_name, output): +def do_cli(stack_name, output): pass diff --git a/samcli/commands/list/stack_outputs/cli.py b/samcli/commands/list/stack_outputs/cli.py index 31bb6ef27b3..1f19c164822 100644 --- a/samcli/commands/list/stack_outputs/cli.py +++ b/samcli/commands/list/stack_outputs/cli.py @@ -5,7 +5,6 @@ import click from samcli.cli.main import pass_context -from samcli.commands.local.generate_event.event_generation import GenerateEventCommand HELP_TEXT = """ Get the stack outputs as defined in the SAM/CloudFormation template. @@ -14,25 +13,20 @@ # @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) -@click.command(name="stack-outputs", no_args_is_help=True, help=HELP_TEXT) +@click.command(name="stack-outputs", help=HELP_TEXT) @click.option( "--stack-name", - help=( - "Name of corresponding deployed stack." - ), + help=("Name of corresponding deployed stack."), required=True, type=click.STRING, ) @click.option( "--output", - help=( - "Output the results from the command in a given" - "output format (json, yaml, table or text)." - ), - type=click.STRING, + help=("Output the results from the command in a given" "output format (json, yaml, table or text)."), + type=click.Choice(["json", "yaml", "table", "text"], case_sensitive=False), ) @pass_context def cli(self, stack_name, output): """ Generate an event for one of the services listed below: - """ \ No newline at end of file + """ diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py index 7cabdf93ac4..c13c2965548 100644 --- a/samcli/commands/list/testable_resources/cli.py +++ b/samcli/commands/list/testable_resources/cli.py @@ -5,11 +5,10 @@ import click from samcli.cli.main import pass_context -from samcli.commands.local.generate_event.event_generation import GenerateEventCommand HELP_TEXT = """ -Get a summary of the testable resources in the stack. \n -\b +Get a summary of the testable resources in the stack.\n + This command will show both the cloud and local endpoints that can be used with sam local and sam sync. Currently the testable resources are lambda functions and API Gateway API resources. @@ -18,7 +17,7 @@ # @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) -@click.command(name="testable-resources", no_args_is_help=True, help=HELP_TEXT) +@click.command(name="testable-resources", help=HELP_TEXT) @click.option( "--stack-name", help=( @@ -30,11 +29,8 @@ ) @click.option( "--output", - help=( - "Output the results from the command in a given" - "output format (json, yaml, table or text)." - ), - type=click.STRING, + help=("Output the results from the command in a given" "output format (json, yaml, table or text)."), + type=click.Choice(["json", "yaml", "table", "text"], case_sensitive=False), ) @pass_context def cli(self, stack_name, output): diff --git a/tests/integration/list/resources/resources_integ_base.py b/tests/integration/list/resources/resources_integ_base.py index 899cc919a06..7f27343d95c 100644 --- a/tests/integration/list/resources/resources_integ_base.py +++ b/tests/integration/list/resources/resources_integ_base.py @@ -1,8 +1,10 @@ import os -from typing import Optional -from unittest import TestCase, skipIf +from unittest import TestCase from pathlib import Path -from subprocess import Popen, PIPE, TimeoutExpired +import uuid +import shutil +import tempfile + class ResourcesIntegBase(TestCase): @@ -13,10 +15,16 @@ def setUpClass(cls): def setUp(self): super().setUp() + self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) + shutil.rmtree(self.scratch_dir, ignore_errors=True) + os.mkdir(self.scratch_dir) + + self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) def tearDown(self): super().tearDown() + @classmethod def base_command(self): command = "sam" if os.getenv("SAM_CLI_DEV"): @@ -24,7 +32,7 @@ def base_command(self): return command - def get_resources_command_list(self, stack_name=None, output=None): + def get_resources_command_list(self, stack_name=None, output=None, help=False): command_list = [self.base_command(), "list", "resources"] if stack_name: command_list += ["--stack-name", str(stack_name)] @@ -32,6 +40,7 @@ def get_resources_command_list(self, stack_name=None, output=None): if output: command_list += ["--output", str(output)] - return command_list - + if help: + command_list += ["--help"] + return command_list diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 2bc715a4abe..852aac00c00 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -1,31 +1,15 @@ -import logging -import os -import random -import shutil -import sys -from pathlib import Path -from typing import Set -from unittest import skipIf - -import jmespath -import docker -import pytest -from parameterized import parameterized, parameterized_class - -from samcli.lib.utils import osutils -from samcli.yamlhelper import yaml_parse -from tests.testing_utils import ( - IS_WINDOWS, - RUNNING_ON_CI, - RUNNING_TEST_FOR_MASTER_ON_CI, - RUN_BY_CANARY, - CI_OVERRIDE, - run_command, - SKIP_DOCKER_TESTS, - SKIP_DOCKER_BUILD, - SKIP_DOCKER_MESSAGE, -) from .resources_integ_base import ResourcesIntegBase +from samcli.commands.list.resources.cli import HELP_TEXT +from tests.testing_utils import run_command +import re + class TestResources(ResourcesIntegBase): - def test_ \ No newline at end of file + def test_resources_help_message(self): + cmdlist = self.get_resources_command_list(help=True) + command_result = run_command(cmdlist, cwd=self.working_dir) + from_command = "".join(re.split(" *", str(command_result.stdout).replace("\\n", ""))) + from_help = "".join(re.split("\n*| *", HELP_TEXT)) + self.assertTrue(from_help in from_command, "Resources help text should have been printed") + + diff --git a/tests/integration/list/stack_outputs/stack_outputs_integ_base.py b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py index 3f4f632da70..cef45fcaf5b 100644 --- a/tests/integration/list/stack_outputs/stack_outputs_integ_base.py +++ b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py @@ -1,8 +1,9 @@ import os -from typing import Optional -from unittest import TestCase, skipIf +from unittest import TestCase from pathlib import Path -from subprocess import Popen, PIPE, TimeoutExpired +import uuid +import shutil +import tempfile class StackOutputsIntegBase(TestCase): @@ -13,10 +14,16 @@ def setUpClass(cls): def setUp(self): super().setUp() + self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) + shutil.rmtree(self.scratch_dir, ignore_errors=True) + os.mkdir(self.scratch_dir) + + self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) def tearDown(self): super().tearDown() + @classmethod def base_command(self): command = "sam" if os.getenv("SAM_CLI_DEV"): @@ -24,7 +31,7 @@ def base_command(self): return command - def get_stack_outputs_command_list(self, stack_name=None, output=None): + def get_stack_outputs_command_list(self, stack_name=None, output=None, help=False): command_list = [self.base_command(), "list", "stack-outputs"] if stack_name: command_list += ["--stack-name", str(stack_name)] @@ -32,6 +39,7 @@ def get_stack_outputs_command_list(self, stack_name=None, output=None): if output: command_list += ["--output", str(output)] - return command_list - + if help: + command_list += ["--help"] + return command_list diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py new file mode 100644 index 00000000000..3c017db8be6 --- /dev/null +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -0,0 +1,14 @@ +from .stack_outputs_integ_base import StackOutputsIntegBase +from samcli.commands.list.stack_outputs.cli import HELP_TEXT +from tests.testing_utils import run_command +import re + +class TestStackOutputs(StackOutputsIntegBase): + def test_stack_outputs_help_message(self): + cmdlist = self.get_stack_outputs_command_list(help=True) + command_result = run_command(cmdlist, cwd=self.working_dir) + from_command = "".join(re.split(" *", str(command_result.stdout).replace("\\n", ""))) + from_help = "".join(re.split("\n*| *", HELP_TEXT)) + self.assertTrue(from_help in from_command, "Stack-outputs help text should have been printed") + + diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py new file mode 100644 index 00000000000..d7fd0de011c --- /dev/null +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -0,0 +1,15 @@ +from .testable_resources_integ_base import TestableResourcesIntegBase +from samcli.commands.list.testable_resources.cli import HELP_TEXT +from tests.testing_utils import run_command +import re + +class TestTestableResources(TestableResourcesIntegBase): + def test_testable_resources_help_message(self): + cmdlist = self.get_testable_resources_command_list(help=True) + command_result = run_command(cmdlist, cwd=self.working_dir) + from_command = "".join(re.split(" *", str(command_result.stdout).replace("\\n", ""))) + from_help = "".join(re.split("\n*| *", HELP_TEXT)) + + self.assertTrue(from_help in from_command, "Testable-resources help text should have been printed") + + diff --git a/tests/integration/list/testable_resources/testable_resources_integ_base.py b/tests/integration/list/testable_resources/testable_resources_integ_base.py index e82343bfa6e..998afba169c 100644 --- a/tests/integration/list/testable_resources/testable_resources_integ_base.py +++ b/tests/integration/list/testable_resources/testable_resources_integ_base.py @@ -1,8 +1,9 @@ import os -from typing import Optional -from unittest import TestCase, skipIf +from unittest import TestCase from pathlib import Path -from subprocess import Popen, PIPE, TimeoutExpired +import uuid +import shutil +import tempfile class TestableResourcesIntegBase(TestCase): @@ -13,10 +14,16 @@ def setUpClass(cls): def setUp(self): super().setUp() + self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) + shutil.rmtree(self.scratch_dir, ignore_errors=True) + os.mkdir(self.scratch_dir) + + self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) def tearDown(self): super().tearDown() + @classmethod def base_command(self): command = "sam" if os.getenv("SAM_CLI_DEV"): @@ -24,7 +31,7 @@ def base_command(self): return command - def get_testable_resources_command_list(self, stack_name=None, output=None): + def get_testable_resources_command_list(self, stack_name=None, output=None, help=False): command_list = [self.base_command(), "list", "testable-resources"] if stack_name: command_list += ["--stack-name", str(stack_name)] @@ -32,6 +39,7 @@ def get_testable_resources_command_list(self, stack_name=None, output=None): if output: command_list += ["--output", str(output)] - return command_list - + if help: + command_list += ["--help"] + return command_list From bcdf40bc0877570245ff8ebe9a2e616e866c674f Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 26 May 2022 16:05:44 -0700 Subject: [PATCH 07/72] Reformatted files --- tests/integration/list/resources/resources_integ_base.py | 1 - tests/integration/list/resources/test_resources_command.py | 2 -- .../list/stack_outputs/test_stack_outputs_command.py | 3 +-- .../list/testable_resources/test_testable_resources_command.py | 3 +-- 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/integration/list/resources/resources_integ_base.py b/tests/integration/list/resources/resources_integ_base.py index 7f27343d95c..ee1e558cb5d 100644 --- a/tests/integration/list/resources/resources_integ_base.py +++ b/tests/integration/list/resources/resources_integ_base.py @@ -6,7 +6,6 @@ import tempfile - class ResourcesIntegBase(TestCase): @classmethod def setUpClass(cls): diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 852aac00c00..f994a35d5bd 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -11,5 +11,3 @@ def test_resources_help_message(self): from_command = "".join(re.split(" *", str(command_result.stdout).replace("\\n", ""))) from_help = "".join(re.split("\n*| *", HELP_TEXT)) self.assertTrue(from_help in from_command, "Resources help text should have been printed") - - diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 3c017db8be6..256b67af4ac 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -3,6 +3,7 @@ from tests.testing_utils import run_command import re + class TestStackOutputs(StackOutputsIntegBase): def test_stack_outputs_help_message(self): cmdlist = self.get_stack_outputs_command_list(help=True) @@ -10,5 +11,3 @@ def test_stack_outputs_help_message(self): from_command = "".join(re.split(" *", str(command_result.stdout).replace("\\n", ""))) from_help = "".join(re.split("\n*| *", HELP_TEXT)) self.assertTrue(from_help in from_command, "Stack-outputs help text should have been printed") - - diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py index d7fd0de011c..d6f4c5f5666 100644 --- a/tests/integration/list/testable_resources/test_testable_resources_command.py +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -3,6 +3,7 @@ from tests.testing_utils import run_command import re + class TestTestableResources(TestableResourcesIntegBase): def test_testable_resources_help_message(self): cmdlist = self.get_testable_resources_command_list(help=True) @@ -11,5 +12,3 @@ def test_testable_resources_help_message(self): from_help = "".join(re.split("\n*| *", HELP_TEXT)) self.assertTrue(from_help in from_command, "Testable-resources help text should have been printed") - - From 93f55cda27d36e24df8c5e7f43d0c43a11637564 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 26 May 2022 16:13:25 -0700 Subject: [PATCH 08/72] Cleaned up unfinished tests --- .../unit/commands/list/resources/test_cli.py | 66 ++----------------- .../commands/list/resources/test_resources.py | 14 +--- 2 files changed, 9 insertions(+), 71 deletions(-) diff --git a/tests/unit/commands/list/resources/test_cli.py b/tests/unit/commands/list/resources/test_cli.py index 2f5f88c126a..784f46b3139 100644 --- a/tests/unit/commands/list/resources/test_cli.py +++ b/tests/unit/commands/list/resources/test_cli.py @@ -1,64 +1,12 @@ from unittest import TestCase -from unittest.mock import patch, Mock -from parameterized import parameterized, param +from unittest.mock import patch class TestCli(TestCase): - def test_cli_base_command(self, get_event_mock): - event_data = "data" - get_event_mock.return_value = event_data + def setUp(self): + self.stack_name = "stack-name" + self.output = "json" - ctx_mock = Mock() - ctx_mock.region = self.region_name - ctx_mock.profile = self.profile - - # Mock the __enter__ method to return a object inside a context manager - context_mock = Mock() - InvokeContextMock.return_value.__enter__.return_value = context_mock - - invoke_cli( - ctx=ctx_mock, - function_identifier=self.function_id, - template=self.template, - event=self.eventfile, - no_event=self.no_event, - env_vars=self.env_vars, - debug_port=self.debug_ports, - debug_args=self.debug_args, - debugger_path=self.debugger_path, - container_env_vars=self.container_env_vars, - docker_volume_basedir=self.docker_volume_basedir, - docker_network=self.docker_network, - log_file=self.log_file, - skip_pull_image=self.skip_pull_image, - parameter_overrides=self.parameter_overrides, - layer_cache_basedir=self.layer_cache_basedir, - force_image_build=self.force_image_build, - shutdown=self.shutdown, - container_host=self.container_host, - container_host_interface=self.container_host_interface, - invoke_image=self.invoke_image, - ) - - InvokeContextMock.assert_called_with( - template_file=self.template, - function_identifier=self.function_id, - env_vars_file=self.env_vars, - docker_volume_basedir=self.docker_volume_basedir, - docker_network=self.docker_network, - log_file=self.log_file, - skip_pull_image=self.skip_pull_image, - debug_ports=self.debug_ports, - debug_args=self.debug_args, - debugger_path=self.debugger_path, - container_env_vars_file=self.container_env_vars, - parameter_overrides=self.parameter_overrides, - layer_cache_basedir=self.layer_cache_basedir, - force_image_build=self.force_image_build, - shutdown=self.shutdown, - aws_region=self.region_name, - aws_profile=self.profile, - container_host=self.container_host, - container_host_interface=self.container_host_interface, - invoke_images={None: "amazon/aws-sam-cli-emulation-image-python3.6"}, - ) + @patch("samcli.commands.list.resources.cli.click") + def test_cli_base_command(self, mock_resources_context): + pass diff --git a/tests/unit/commands/list/resources/test_resources.py b/tests/unit/commands/list/resources/test_resources.py index c8e8253a0e4..d6bc49d0cf7 100644 --- a/tests/unit/commands/list/resources/test_resources.py +++ b/tests/unit/commands/list/resources/test_resources.py @@ -1,12 +1,2 @@ -from unittest import TestCase -from unittest.mock import patch, Mock -from parameterized import parameterized, param -from unittest.mock import patch, call, MagicMock - -import click - -#from samcli.commands.list.resources.cli import DeleteContext -#from samcli.lib.package.artifact_exporter import Template -#from samcli.cli.cli_config_file import TomlProvider - -#class TestResources \ No newline at end of file +class TestResources: + pass From 60915f110390265169884a24e7ea33b4a7f8a42e Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 26 May 2022 19:09:30 -0700 Subject: [PATCH 09/72] adding check to see what the appveyor test will produce. Trying to resolve test failure --- tests/integration/list/resources/test_resources_command.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index f994a35d5bd..95770c8788d 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -10,4 +10,7 @@ def test_resources_help_message(self): command_result = run_command(cmdlist, cwd=self.working_dir) from_command = "".join(re.split(" *", str(command_result.stdout).replace("\\n", ""))) from_help = "".join(re.split("\n*| *", HELP_TEXT)) + print() + print(from_command) + print(from_help) self.assertTrue(from_help in from_command, "Resources help text should have been printed") From a5418f697ffe49f3976768935a1c0508bda8d9d2 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 26 May 2022 19:32:56 -0700 Subject: [PATCH 10/72] Fixed test to check help messages --- samcli/commands/list/resources/cli.py | 1 - samcli/commands/list/testable_resources/cli.py | 1 - tests/integration/list/resources/test_resources_command.py | 7 ++----- .../list/stack_outputs/test_stack_outputs_command.py | 4 ++-- .../testable_resources/test_testable_resources_command.py | 5 ++--- 5 files changed, 6 insertions(+), 12 deletions(-) diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index 624f4879d4a..bdfcf4628d5 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -8,7 +8,6 @@ HELP_TEXT = """ Get a list of resources that will be deployed to CloudFormation.\n - If a stack name is provided, the corresponding physical IDs of each resource will be mapped to the logical ID of each resource. """ diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py index c13c2965548..d54e2a1cda0 100644 --- a/samcli/commands/list/testable_resources/cli.py +++ b/samcli/commands/list/testable_resources/cli.py @@ -8,7 +8,6 @@ HELP_TEXT = """ Get a summary of the testable resources in the stack.\n - This command will show both the cloud and local endpoints that can be used with sam local and sam sync. Currently the testable resources are lambda functions and API Gateway API resources. diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 95770c8788d..f67518ec574 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -8,9 +8,6 @@ class TestResources(ResourcesIntegBase): def test_resources_help_message(self): cmdlist = self.get_resources_command_list(help=True) command_result = run_command(cmdlist, cwd=self.working_dir) - from_command = "".join(re.split(" *", str(command_result.stdout).replace("\\n", ""))) - from_help = "".join(re.split("\n*| *", HELP_TEXT)) - print() - print(from_command) - print(from_help) + from_command = "".join(re.split("\n| *", command_result.stdout.decode())) + from_help = "".join(re.split("\n| *", HELP_TEXT)) self.assertTrue(from_help in from_command, "Resources help text should have been printed") diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 256b67af4ac..7c5c6b6af27 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -8,6 +8,6 @@ class TestStackOutputs(StackOutputsIntegBase): def test_stack_outputs_help_message(self): cmdlist = self.get_stack_outputs_command_list(help=True) command_result = run_command(cmdlist, cwd=self.working_dir) - from_command = "".join(re.split(" *", str(command_result.stdout).replace("\\n", ""))) - from_help = "".join(re.split("\n*| *", HELP_TEXT)) + from_command = "".join(re.split("\n| *", command_result.stdout.decode())) + from_help = "".join(re.split("\n| *", HELP_TEXT)) self.assertTrue(from_help in from_command, "Stack-outputs help text should have been printed") diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py index d6f4c5f5666..40519b650b3 100644 --- a/tests/integration/list/testable_resources/test_testable_resources_command.py +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -8,7 +8,6 @@ class TestTestableResources(TestableResourcesIntegBase): def test_testable_resources_help_message(self): cmdlist = self.get_testable_resources_command_list(help=True) command_result = run_command(cmdlist, cwd=self.working_dir) - from_command = "".join(re.split(" *", str(command_result.stdout).replace("\\n", ""))) - from_help = "".join(re.split("\n*| *", HELP_TEXT)) - + from_command = "".join(re.split("\n| *", command_result.stdout.decode())) + from_help = "".join(re.split("\n| *", HELP_TEXT)) self.assertTrue(from_help in from_command, "Testable-resources help text should have been printed") From a3d150a74e846c57014f1d3241cbfcb0b62b1250 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 26 May 2022 19:49:00 -0700 Subject: [PATCH 11/72] modified pattern matching for eliminating newlines when matching help message --- tests/integration/list/resources/test_resources_command.py | 4 ++-- .../list/stack_outputs/test_stack_outputs_command.py | 4 ++-- .../testable_resources/test_testable_resources_command.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index f67518ec574..7a3e6c9df4f 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -8,6 +8,6 @@ class TestResources(ResourcesIntegBase): def test_resources_help_message(self): cmdlist = self.get_resources_command_list(help=True) command_result = run_command(cmdlist, cwd=self.working_dir) - from_command = "".join(re.split("\n| *", command_result.stdout.decode())) - from_help = "".join(re.split("\n| *", HELP_TEXT)) + from_command = "".join(re.split("\n*| *", command_result.stdout.decode())) + from_help = "".join(re.split("\n*| *", HELP_TEXT)) self.assertTrue(from_help in from_command, "Resources help text should have been printed") diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 7c5c6b6af27..550f4094cde 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -8,6 +8,6 @@ class TestStackOutputs(StackOutputsIntegBase): def test_stack_outputs_help_message(self): cmdlist = self.get_stack_outputs_command_list(help=True) command_result = run_command(cmdlist, cwd=self.working_dir) - from_command = "".join(re.split("\n| *", command_result.stdout.decode())) - from_help = "".join(re.split("\n| *", HELP_TEXT)) + from_command = "".join(re.split("\n*| *", command_result.stdout.decode())) + from_help = "".join(re.split("\n*| *", HELP_TEXT)) self.assertTrue(from_help in from_command, "Stack-outputs help text should have been printed") diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py index 40519b650b3..03307b0df7d 100644 --- a/tests/integration/list/testable_resources/test_testable_resources_command.py +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -8,6 +8,6 @@ class TestTestableResources(TestableResourcesIntegBase): def test_testable_resources_help_message(self): cmdlist = self.get_testable_resources_command_list(help=True) command_result = run_command(cmdlist, cwd=self.working_dir) - from_command = "".join(re.split("\n| *", command_result.stdout.decode())) - from_help = "".join(re.split("\n| *", HELP_TEXT)) + from_command = "".join(re.split("\n*| *", command_result.stdout.decode())) + from_help = "".join(re.split("\n*| *", HELP_TEXT)) self.assertTrue(from_help in from_command, "Testable-resources help text should have been printed") From 5dff9e2c901a68ca27473614a1814a9034b2baae Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 26 May 2022 20:00:31 -0700 Subject: [PATCH 12/72] Changed the way whitespaces are handled in matching help messages --- tests/integration/list/resources/test_resources_command.py | 4 ++-- .../list/stack_outputs/test_stack_outputs_command.py | 4 ++-- .../testable_resources/test_testable_resources_command.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 7a3e6c9df4f..ec973ca157f 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -8,6 +8,6 @@ class TestResources(ResourcesIntegBase): def test_resources_help_message(self): cmdlist = self.get_resources_command_list(help=True) command_result = run_command(cmdlist, cwd=self.working_dir) - from_command = "".join(re.split("\n*| *", command_result.stdout.decode())) - from_help = "".join(re.split("\n*| *", HELP_TEXT)) + from_command = "".join(command_result.stdout.decode().split()) + from_help = "".join(HELP_TEXT.split()) self.assertTrue(from_help in from_command, "Resources help text should have been printed") diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 550f4094cde..7d8fde000c1 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -8,6 +8,6 @@ class TestStackOutputs(StackOutputsIntegBase): def test_stack_outputs_help_message(self): cmdlist = self.get_stack_outputs_command_list(help=True) command_result = run_command(cmdlist, cwd=self.working_dir) - from_command = "".join(re.split("\n*| *", command_result.stdout.decode())) - from_help = "".join(re.split("\n*| *", HELP_TEXT)) + from_command = "".join(command_result.stdout.decode().split()) + from_help = "".join(HELP_TEXT.split()) self.assertTrue(from_help in from_command, "Stack-outputs help text should have been printed") diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py index 03307b0df7d..4bfcaf42d16 100644 --- a/tests/integration/list/testable_resources/test_testable_resources_command.py +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -8,6 +8,6 @@ class TestTestableResources(TestableResourcesIntegBase): def test_testable_resources_help_message(self): cmdlist = self.get_testable_resources_command_list(help=True) command_result = run_command(cmdlist, cwd=self.working_dir) - from_command = "".join(re.split("\n*| *", command_result.stdout.decode())) - from_help = "".join(re.split("\n*| *", HELP_TEXT)) + from_command = "".join(command_result.stdout.decode().split()) + from_help = "".join(HELP_TEXT.split()) self.assertTrue(from_help in from_command, "Testable-resources help text should have been printed") From bafaea290987f8ce8a0bc4cc6d7759102e7a20f0 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Fri, 27 May 2022 15:16:14 -0700 Subject: [PATCH 13/72] Addressed PR comments, moved items into common classes --- samcli/commands/list/cli_common/__init__.py | 0 samcli/commands/list/cli_common/options.py | 33 ++++++++++++++++++ samcli/commands/list/resources/cli.py | 26 +++++--------- samcli/commands/list/stack_outputs/cli.py | 19 ++++++----- .../commands/list/testable_resources/cli.py | 31 ++++++++--------- tests/integration/list/list_integ_base.py | 34 +++++++++++++++++++ .../list/resources/resources_integ_base.py | 19 ++--------- .../list/resources/test_resources_command.py | 2 +- .../stack_outputs/stack_outputs_integ_base.py | 19 ++--------- .../test_stack_outputs_command.py | 2 +- .../test_testable_resources_command.py | 2 +- .../testable_resources_integ_base.py | 19 ++--------- .../commands/list/resources/test_resources.py | 2 -- 13 files changed, 113 insertions(+), 95 deletions(-) create mode 100644 samcli/commands/list/cli_common/__init__.py create mode 100644 samcli/commands/list/cli_common/options.py create mode 100644 tests/integration/list/list_integ_base.py delete mode 100644 tests/unit/commands/list/resources/test_resources.py diff --git a/samcli/commands/list/cli_common/__init__.py b/samcli/commands/list/cli_common/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/commands/list/cli_common/options.py b/samcli/commands/list/cli_common/options.py new file mode 100644 index 00000000000..c2d15e24806 --- /dev/null +++ b/samcli/commands/list/cli_common/options.py @@ -0,0 +1,33 @@ +""" +Common CLI options shared by various commands +""" + +import click + + +def stack_name_click_option(): + return click.option( + "--stack-name", + help=( + "Name of corresponding deployed stack.(Not including" + "a stack name will only show local resources defined" + "in the template.)" + ), + type=click.STRING, + ) + + +def stack_name_option(f): + return stack_name_click_option()(f) + + +def output_click_option(): + return click.option( + "--output", + help="Output the results from the command in a given" "output format (json, yaml, table or text).", + type=click.Choice(["json", "table"], case_sensitive=False), + ) + + +def output_option(f): + return output_click_option()(f) diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index bdfcf4628d5..98f94fd8d31 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -4,7 +4,9 @@ import click -from samcli.cli.main import pass_context +from samcli.commands.list.cli_common.options import stack_name_option, output_option +from samcli.cli.main import pass_context, common_options, aws_creds_options, print_cmdline_args + HELP_TEXT = """ Get a list of resources that will be deployed to CloudFormation.\n @@ -12,25 +14,15 @@ resource will be mapped to the logical ID of each resource. """ -# @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) - @click.command(name="resources", help=HELP_TEXT) -@click.option( - "--stack-name", - help=( - "Name of corresponding deployed stack.(Not including" - "a stack name will only show local resources defined" - "in the template.)" - ), - type=click.STRING, -) -@click.option( - "--output", - help=("Output the results from the command in a given" "output format (json, yaml, table or text)."), - type=click.Choice(["json", "yaml", "table", "text"], case_sensitive=False), -) +@stack_name_option +@output_option +@output_option +@aws_creds_options +@common_options @pass_context +@print_cmdline_args def cli(self, stack_name, output): """ Generate an event for one of the services listed below: diff --git a/samcli/commands/list/stack_outputs/cli.py b/samcli/commands/list/stack_outputs/cli.py index 1f19c164822..a48a274c712 100644 --- a/samcli/commands/list/stack_outputs/cli.py +++ b/samcli/commands/list/stack_outputs/cli.py @@ -4,14 +4,13 @@ import click -from samcli.cli.main import pass_context +from samcli.commands.list.cli_common.options import output_option +from samcli.cli.main import pass_context, common_options, aws_creds_options, print_cmdline_args HELP_TEXT = """ Get the stack outputs as defined in the SAM/CloudFormation template. """ -# @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) - @click.command(name="stack-outputs", help=HELP_TEXT) @click.option( @@ -20,13 +19,17 @@ required=True, type=click.STRING, ) -@click.option( - "--output", - help=("Output the results from the command in a given" "output format (json, yaml, table or text)."), - type=click.Choice(["json", "yaml", "table", "text"], case_sensitive=False), -) +@output_option +@aws_creds_options +@common_options @pass_context +@print_cmdline_args def cli(self, stack_name, output): """ Generate an event for one of the services listed below: """ + do_cli(stack_name, output) + + +def do_cli(stack_name, output): + pass diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py index d54e2a1cda0..b38546a93d3 100644 --- a/samcli/commands/list/testable_resources/cli.py +++ b/samcli/commands/list/testable_resources/cli.py @@ -4,7 +4,9 @@ import click -from samcli.cli.main import pass_context +from samcli.commands.list.cli_common.options import stack_name_option, output_option +from samcli.cli.main import pass_context, common_options, aws_creds_options, print_cmdline_args + HELP_TEXT = """ Get a summary of the testable resources in the stack.\n @@ -13,26 +15,21 @@ are lambda functions and API Gateway API resources. """ -# @click.command(name="resources", cls=GenerateEventCommand, help=HELP_TEXT) - @click.command(name="testable-resources", help=HELP_TEXT) -@click.option( - "--stack-name", - help=( - "Name of corresponding deployed stack.(Not including" - "a stack name will only show local resources defined" - "in the template.)" - ), - type=click.STRING, -) -@click.option( - "--output", - help=("Output the results from the command in a given" "output format (json, yaml, table or text)."), - type=click.Choice(["json", "yaml", "table", "text"], case_sensitive=False), -) +@stack_name_option +@output_option +@output_option +@aws_creds_options +@common_options @pass_context +@print_cmdline_args def cli(self, stack_name, output): """ Generate an event for one of the services listed below: """ + do_cli(stack_name, output) + + +def do_cli(stack_name, output): + pass diff --git a/tests/integration/list/list_integ_base.py b/tests/integration/list/list_integ_base.py new file mode 100644 index 00000000000..1dd2a842445 --- /dev/null +++ b/tests/integration/list/list_integ_base.py @@ -0,0 +1,34 @@ +import os +from unittest import TestCase +from pathlib import Path +import uuid +import shutil +import tempfile + + +class ListIntegBase(TestCase): + @classmethod + def setUpClass(cls): + cls.cmd = cls.base_command() + cls.test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "list") + + def setUp(self): + super().setUp() + self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) + shutil.rmtree(self.scratch_dir, ignore_errors=True) + os.mkdir(self.scratch_dir) + + self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) + + def tearDown(self): + super().tearDown() + self.working_dir and shutil.rmtree(self.working_dir, ignore_errors=True) + self.scratch_dir and shutil.rmtree(self.scratch_dir, ignore_errors=True) + + @classmethod + def base_command(cls): + command = "sam" + if os.getenv("SAM_CLI_DEV"): + command = "samdev" + + return command diff --git a/tests/integration/list/resources/resources_integ_base.py b/tests/integration/list/resources/resources_integ_base.py index ee1e558cb5d..209ea9db0c7 100644 --- a/tests/integration/list/resources/resources_integ_base.py +++ b/tests/integration/list/resources/resources_integ_base.py @@ -4,33 +4,20 @@ import uuid import shutil import tempfile +from tests.integration.list.list_integ_base import ListIntegBase -class ResourcesIntegBase(TestCase): +class ResourcesIntegBase(ListIntegBase, TestCase): @classmethod def setUpClass(cls): - cls.cmd = cls.base_command() - cls.resources_test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "list") + super().setUpClass() def setUp(self): super().setUp() - self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) - shutil.rmtree(self.scratch_dir, ignore_errors=True) - os.mkdir(self.scratch_dir) - - self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) def tearDown(self): super().tearDown() - @classmethod - def base_command(self): - command = "sam" - if os.getenv("SAM_CLI_DEV"): - command = "samdev" - - return command - def get_resources_command_list(self, stack_name=None, output=None, help=False): command_list = [self.base_command(), "list", "resources"] if stack_name: diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index ec973ca157f..58693e5ddb8 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -10,4 +10,4 @@ def test_resources_help_message(self): command_result = run_command(cmdlist, cwd=self.working_dir) from_command = "".join(command_result.stdout.decode().split()) from_help = "".join(HELP_TEXT.split()) - self.assertTrue(from_help in from_command, "Resources help text should have been printed") + self.assertIn(from_help, from_command, "Resources help text should have been printed") diff --git a/tests/integration/list/stack_outputs/stack_outputs_integ_base.py b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py index cef45fcaf5b..e1fcb6d94f1 100644 --- a/tests/integration/list/stack_outputs/stack_outputs_integ_base.py +++ b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py @@ -4,33 +4,20 @@ import uuid import shutil import tempfile +from tests.integration.list.list_integ_base import ListIntegBase -class StackOutputsIntegBase(TestCase): +class StackOutputsIntegBase(ListIntegBase, TestCase): @classmethod def setUpClass(cls): - cls.cmd = cls.base_command() - cls.stack_outputs_test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "list") + super().setUpClass() def setUp(self): super().setUp() - self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) - shutil.rmtree(self.scratch_dir, ignore_errors=True) - os.mkdir(self.scratch_dir) - - self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) def tearDown(self): super().tearDown() - @classmethod - def base_command(self): - command = "sam" - if os.getenv("SAM_CLI_DEV"): - command = "samdev" - - return command - def get_stack_outputs_command_list(self, stack_name=None, output=None, help=False): command_list = [self.base_command(), "list", "stack-outputs"] if stack_name: diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 7d8fde000c1..408e855353d 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -10,4 +10,4 @@ def test_stack_outputs_help_message(self): command_result = run_command(cmdlist, cwd=self.working_dir) from_command = "".join(command_result.stdout.decode().split()) from_help = "".join(HELP_TEXT.split()) - self.assertTrue(from_help in from_command, "Stack-outputs help text should have been printed") + self.assertIn(from_help, from_command, "Stack-outputs help text should have been printed") diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py index 4bfcaf42d16..92e98471cf1 100644 --- a/tests/integration/list/testable_resources/test_testable_resources_command.py +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -10,4 +10,4 @@ def test_testable_resources_help_message(self): command_result = run_command(cmdlist, cwd=self.working_dir) from_command = "".join(command_result.stdout.decode().split()) from_help = "".join(HELP_TEXT.split()) - self.assertTrue(from_help in from_command, "Testable-resources help text should have been printed") + self.assertIn(from_help, from_command, "Testable-resources help text should have been printed") diff --git a/tests/integration/list/testable_resources/testable_resources_integ_base.py b/tests/integration/list/testable_resources/testable_resources_integ_base.py index 998afba169c..7fe67ac93a9 100644 --- a/tests/integration/list/testable_resources/testable_resources_integ_base.py +++ b/tests/integration/list/testable_resources/testable_resources_integ_base.py @@ -4,33 +4,20 @@ import uuid import shutil import tempfile +from tests.integration.list.list_integ_base import ListIntegBase -class TestableResourcesIntegBase(TestCase): +class TestableResourcesIntegBase(ListIntegBase, TestCase): @classmethod def setUpClass(cls): - cls.cmd = cls.base_command() - cls.testable_resources_test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "list") + super().setUpClass() def setUp(self): super().setUp() - self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) - shutil.rmtree(self.scratch_dir, ignore_errors=True) - os.mkdir(self.scratch_dir) - - self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) def tearDown(self): super().tearDown() - @classmethod - def base_command(self): - command = "sam" - if os.getenv("SAM_CLI_DEV"): - command = "samdev" - - return command - def get_testable_resources_command_list(self, stack_name=None, output=None, help=False): command_list = [self.base_command(), "list", "testable-resources"] if stack_name: diff --git a/tests/unit/commands/list/resources/test_resources.py b/tests/unit/commands/list/resources/test_resources.py deleted file mode 100644 index d6bc49d0cf7..00000000000 --- a/tests/unit/commands/list/resources/test_resources.py +++ /dev/null @@ -1,2 +0,0 @@ -class TestResources: - pass From 8e084ff998c38f9e2c8c900fa4c0ac60903c4f6a Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 30 May 2022 17:55:55 -0700 Subject: [PATCH 14/72] Made modifications based on comments, removed relative import paths, added method annotations, fixed text formatting --- samcli/commands/list/cli_common/options.py | 11 +++++++---- samcli/commands/list/list.py | 6 +++--- samcli/commands/list/resources/cli.py | 5 ++++- samcli/commands/list/stack_outputs/cli.py | 9 +++++++-- samcli/commands/list/testable_resources/cli.py | 5 ++++- tests/integration/list/list_integ_base.py | 7 ++----- .../list/resources/resources_integ_base.py | 12 +----------- .../list/resources/test_resources_command.py | 3 +-- .../list/stack_outputs/stack_outputs_integ_base.py | 12 +----------- .../list/stack_outputs/test_stack_outputs_command.py | 3 +-- .../test_testable_resources_command.py | 3 +-- .../testable_resources_integ_base.py | 12 +----------- 12 files changed, 33 insertions(+), 55 deletions(-) diff --git a/samcli/commands/list/cli_common/options.py b/samcli/commands/list/cli_common/options.py index c2d15e24806..240335b2932 100644 --- a/samcli/commands/list/cli_common/options.py +++ b/samcli/commands/list/cli_common/options.py @@ -9,9 +9,9 @@ def stack_name_click_option(): return click.option( "--stack-name", help=( - "Name of corresponding deployed stack.(Not including" - "a stack name will only show local resources defined" - "in the template.)" + "Name of corresponding deployed stack.(Not including " + "a stack name will only show local resources defined " + "in the template.) " ), type=click.STRING, ) @@ -24,7 +24,10 @@ def stack_name_option(f): def output_click_option(): return click.option( "--output", - help="Output the results from the command in a given" "output format (json, yaml, table or text).", + help=( + "Output the results from the command in a given " + "output format (json, yaml, table or text). " + ), type=click.Choice(["json", "table"], case_sensitive=False), ) diff --git a/samcli/commands/list/list.py b/samcli/commands/list/list.py index d8c1683376a..c4ce0446907 100644 --- a/samcli/commands/list/list.py +++ b/samcli/commands/list/list.py @@ -4,9 +4,9 @@ import click -from .resources.cli import cli as resources_cli -from .stack_outputs.cli import cli as stack_outputs_cli -from .testable_resources.cli import cli as testable_resources_cli +from samcli.commands.list.resources.cli import cli as resources_cli +from samcli.commands.list.stack_outputs.cli import cli as stack_outputs_cli +from samcli.commands.list.testable_resources.cli import cli as testable_resources_cli @click.group() diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index 98f94fd8d31..f4b7b7fc5c8 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -6,6 +6,8 @@ from samcli.commands.list.cli_common.options import stack_name_option, output_option from samcli.cli.main import pass_context, common_options, aws_creds_options, print_cmdline_args +from samcli.lib.utils.version_checker import check_newer_version +from samcli.lib.telemetry.metric import track_command HELP_TEXT = """ @@ -18,10 +20,11 @@ @click.command(name="resources", help=HELP_TEXT) @stack_name_option @output_option -@output_option @aws_creds_options @common_options @pass_context +@track_command +@check_newer_version @print_cmdline_args def cli(self, stack_name, output): """ diff --git a/samcli/commands/list/stack_outputs/cli.py b/samcli/commands/list/stack_outputs/cli.py index a48a274c712..e90dc940124 100644 --- a/samcli/commands/list/stack_outputs/cli.py +++ b/samcli/commands/list/stack_outputs/cli.py @@ -6,6 +6,9 @@ from samcli.commands.list.cli_common.options import output_option from samcli.cli.main import pass_context, common_options, aws_creds_options, print_cmdline_args +from samcli.lib.utils.version_checker import check_newer_version +from samcli.lib.telemetry.metric import track_command + HELP_TEXT = """ Get the stack outputs as defined in the SAM/CloudFormation template. @@ -15,7 +18,7 @@ @click.command(name="stack-outputs", help=HELP_TEXT) @click.option( "--stack-name", - help=("Name of corresponding deployed stack."), + help="Name of corresponding deployed stack. ", required=True, type=click.STRING, ) @@ -23,8 +26,10 @@ @aws_creds_options @common_options @pass_context +@track_command +@check_newer_version @print_cmdline_args -def cli(self, stack_name, output): +def cli(self, stack_name, output="json"): """ Generate an event for one of the services listed below: """ diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py index b38546a93d3..0950fa8f5a4 100644 --- a/samcli/commands/list/testable_resources/cli.py +++ b/samcli/commands/list/testable_resources/cli.py @@ -6,6 +6,8 @@ from samcli.commands.list.cli_common.options import stack_name_option, output_option from samcli.cli.main import pass_context, common_options, aws_creds_options, print_cmdline_args +from samcli.lib.utils.version_checker import check_newer_version +from samcli.lib.telemetry.metric import track_command HELP_TEXT = """ @@ -19,10 +21,11 @@ @click.command(name="testable-resources", help=HELP_TEXT) @stack_name_option @output_option -@output_option @aws_creds_options @common_options @pass_context +@track_command +@check_newer_version @print_cmdline_args def cli(self, stack_name, output): """ diff --git a/tests/integration/list/list_integ_base.py b/tests/integration/list/list_integ_base.py index 1dd2a842445..94ccce7cf5a 100644 --- a/tests/integration/list/list_integ_base.py +++ b/tests/integration/list/list_integ_base.py @@ -4,6 +4,7 @@ import uuid import shutil import tempfile +from tests.testing_utils import get_sam_command class ListIntegBase(TestCase): @@ -27,8 +28,4 @@ def tearDown(self): @classmethod def base_command(cls): - command = "sam" - if os.getenv("SAM_CLI_DEV"): - command = "samdev" - - return command + return get_sam_command() diff --git a/tests/integration/list/resources/resources_integ_base.py b/tests/integration/list/resources/resources_integ_base.py index 209ea9db0c7..f444694bd5e 100644 --- a/tests/integration/list/resources/resources_integ_base.py +++ b/tests/integration/list/resources/resources_integ_base.py @@ -7,17 +7,7 @@ from tests.integration.list.list_integ_base import ListIntegBase -class ResourcesIntegBase(ListIntegBase, TestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - def setUp(self): - super().setUp() - - def tearDown(self): - super().tearDown() - +class ResourcesIntegBase(ListIntegBase): def get_resources_command_list(self, stack_name=None, output=None, help=False): command_list = [self.base_command(), "list", "resources"] if stack_name: diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 58693e5ddb8..39d5825a46d 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -1,7 +1,6 @@ -from .resources_integ_base import ResourcesIntegBase +from tests.integration.list.resources.resources_integ_base import ResourcesIntegBase from samcli.commands.list.resources.cli import HELP_TEXT from tests.testing_utils import run_command -import re class TestResources(ResourcesIntegBase): diff --git a/tests/integration/list/stack_outputs/stack_outputs_integ_base.py b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py index e1fcb6d94f1..2a71285cc8c 100644 --- a/tests/integration/list/stack_outputs/stack_outputs_integ_base.py +++ b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py @@ -7,17 +7,7 @@ from tests.integration.list.list_integ_base import ListIntegBase -class StackOutputsIntegBase(ListIntegBase, TestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - def setUp(self): - super().setUp() - - def tearDown(self): - super().tearDown() - +class StackOutputsIntegBase(ListIntegBase): def get_stack_outputs_command_list(self, stack_name=None, output=None, help=False): command_list = [self.base_command(), "list", "stack-outputs"] if stack_name: diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 408e855353d..597c7b2f3c6 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -1,7 +1,6 @@ -from .stack_outputs_integ_base import StackOutputsIntegBase +from tests.integration.list.stack_outputs.stack_outputs_integ_base import StackOutputsIntegBase from samcli.commands.list.stack_outputs.cli import HELP_TEXT from tests.testing_utils import run_command -import re class TestStackOutputs(StackOutputsIntegBase): diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py index 92e98471cf1..44a8d84a10f 100644 --- a/tests/integration/list/testable_resources/test_testable_resources_command.py +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -1,7 +1,6 @@ -from .testable_resources_integ_base import TestableResourcesIntegBase +from tests.integration.list.testable_resources.testable_resources_integ_base import TestableResourcesIntegBase from samcli.commands.list.testable_resources.cli import HELP_TEXT from tests.testing_utils import run_command -import re class TestTestableResources(TestableResourcesIntegBase): diff --git a/tests/integration/list/testable_resources/testable_resources_integ_base.py b/tests/integration/list/testable_resources/testable_resources_integ_base.py index 7fe67ac93a9..6be824282f0 100644 --- a/tests/integration/list/testable_resources/testable_resources_integ_base.py +++ b/tests/integration/list/testable_resources/testable_resources_integ_base.py @@ -7,17 +7,7 @@ from tests.integration.list.list_integ_base import ListIntegBase -class TestableResourcesIntegBase(ListIntegBase, TestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - def setUp(self): - super().setUp() - - def tearDown(self): - super().tearDown() - +class TestableResourcesIntegBase(ListIntegBase): def get_testable_resources_command_list(self, stack_name=None, output=None, help=False): command_list = [self.base_command(), "list", "testable-resources"] if stack_name: From 719cfd5e0251b33b890865b28401ff055939a8ec Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 30 May 2022 18:10:14 -0700 Subject: [PATCH 15/72] Reformatted files --- samcli/commands/list/cli_common/options.py | 5 +---- samcli/commands/list/stack_outputs/cli.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/samcli/commands/list/cli_common/options.py b/samcli/commands/list/cli_common/options.py index 240335b2932..27f0a7b885b 100644 --- a/samcli/commands/list/cli_common/options.py +++ b/samcli/commands/list/cli_common/options.py @@ -24,10 +24,7 @@ def stack_name_option(f): def output_click_option(): return click.option( "--output", - help=( - "Output the results from the command in a given " - "output format (json, yaml, table or text). " - ), + help=("Output the results from the command in a given " "output format (json, yaml, table or text). "), type=click.Choice(["json", "table"], case_sensitive=False), ) diff --git a/samcli/commands/list/stack_outputs/cli.py b/samcli/commands/list/stack_outputs/cli.py index e90dc940124..a65210c9071 100644 --- a/samcli/commands/list/stack_outputs/cli.py +++ b/samcli/commands/list/stack_outputs/cli.py @@ -29,7 +29,7 @@ @track_command @check_newer_version @print_cmdline_args -def cli(self, stack_name, output="json"): +def cli(self, stack_name, output): """ Generate an event for one of the services listed below: """ From c7499e4a9786bbfed665e908f7a21bd2bb1f4aa9 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 6 Jun 2022 09:56:21 -0700 Subject: [PATCH 16/72] removed folder deletion --- tests/integration/list/list_integ_base.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/integration/list/list_integ_base.py b/tests/integration/list/list_integ_base.py index 94ccce7cf5a..e11e9e66a33 100644 --- a/tests/integration/list/list_integ_base.py +++ b/tests/integration/list/list_integ_base.py @@ -16,9 +16,7 @@ def setUpClass(cls): def setUp(self): super().setUp() self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) - shutil.rmtree(self.scratch_dir, ignore_errors=True) os.mkdir(self.scratch_dir) - self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) def tearDown(self): From ee2015abbe4d93c9f198773a45712262fb311653 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 6 Jun 2022 10:39:37 -0700 Subject: [PATCH 17/72] removed uneccessary folder creation and deletion --- tests/integration/list/list_integ_base.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/integration/list/list_integ_base.py b/tests/integration/list/list_integ_base.py index e11e9e66a33..02639601523 100644 --- a/tests/integration/list/list_integ_base.py +++ b/tests/integration/list/list_integ_base.py @@ -11,18 +11,6 @@ class ListIntegBase(TestCase): @classmethod def setUpClass(cls): cls.cmd = cls.base_command() - cls.test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "list") - - def setUp(self): - super().setUp() - self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) - os.mkdir(self.scratch_dir) - self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) - - def tearDown(self): - super().tearDown() - self.working_dir and shutil.rmtree(self.working_dir, ignore_errors=True) - self.scratch_dir and shutil.rmtree(self.scratch_dir, ignore_errors=True) @classmethod def base_command(cls): From 2c62cd94ef30c687999e3f202acfb136e5f650ad Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 6 Jun 2022 10:58:56 -0700 Subject: [PATCH 18/72] fixed errors with cwd of integration tests --- tests/integration/list/resources/test_resources_command.py | 2 +- .../list/stack_outputs/test_stack_outputs_command.py | 2 +- .../list/testable_resources/test_testable_resources_command.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 39d5825a46d..1bc18725e35 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -6,7 +6,7 @@ class TestResources(ResourcesIntegBase): def test_resources_help_message(self): cmdlist = self.get_resources_command_list(help=True) - command_result = run_command(cmdlist, cwd=self.working_dir) + command_result = run_command(cmdlist) from_command = "".join(command_result.stdout.decode().split()) from_help = "".join(HELP_TEXT.split()) self.assertIn(from_help, from_command, "Resources help text should have been printed") diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 597c7b2f3c6..e46df4d818e 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -6,7 +6,7 @@ class TestStackOutputs(StackOutputsIntegBase): def test_stack_outputs_help_message(self): cmdlist = self.get_stack_outputs_command_list(help=True) - command_result = run_command(cmdlist, cwd=self.working_dir) + command_result = run_command(cmdlist) from_command = "".join(command_result.stdout.decode().split()) from_help = "".join(HELP_TEXT.split()) self.assertIn(from_help, from_command, "Stack-outputs help text should have been printed") diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py index 44a8d84a10f..a1a13e5c336 100644 --- a/tests/integration/list/testable_resources/test_testable_resources_command.py +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -6,7 +6,7 @@ class TestTestableResources(TestableResourcesIntegBase): def test_testable_resources_help_message(self): cmdlist = self.get_testable_resources_command_list(help=True) - command_result = run_command(cmdlist, cwd=self.working_dir) + command_result = run_command(cmdlist) from_command = "".join(command_result.stdout.decode().split()) from_help = "".join(HELP_TEXT.split()) self.assertIn(from_help, from_command, "Testable-resources help text should have been printed") From de4439ba9644a4556a78fbbc35833d67b7892b3f Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Tue, 7 Jun 2022 18:21:42 -0700 Subject: [PATCH 19/72] Added implementation and tests for the stack-outputs command --- samcli/commands/list/exceptions.py | 16 ++ samcli/commands/list/resources/cli.py | 6 +- samcli/commands/list/stack_outputs/cli.py | 11 +- .../stack_outputs/stack_outputs_context.py | 103 +++++++++++ .../commands/list/testable_resources/cli.py | 6 +- samcli/lib/list/__init__.py | 0 samcli/lib/list/list_utils.py | 0 tests/integration/list/list_integ_base.py | 13 ++ .../list/resources/resources_integ_base.py | 8 +- .../stack_outputs/stack_outputs_integ_base.py | 8 +- .../test_stack_outputs_command.py | 113 +++++++++++- .../testable_resources_integ_base.py | 8 +- .../testdata/list/hello_world/__init__.py | 0 .../testdata/list/hello_world/app.py | 42 +++++ .../list/hello_world/requirements.txt | 1 + .../list/test_stack_creation_template.yaml | 39 ++++ .../list/test_stack_no_outputs_template.yaml | 26 +++ .../commands/list/stack_outputs/__init__.py | 0 .../commands/list/stack_outputs/test_cli.py | 33 ++++ .../test_stack_outputs_context.py | 170 ++++++++++++++++++ 20 files changed, 588 insertions(+), 15 deletions(-) create mode 100644 samcli/commands/list/exceptions.py create mode 100644 samcli/commands/list/stack_outputs/stack_outputs_context.py create mode 100644 samcli/lib/list/__init__.py create mode 100644 samcli/lib/list/list_utils.py create mode 100644 tests/integration/testdata/list/hello_world/__init__.py create mode 100644 tests/integration/testdata/list/hello_world/app.py create mode 100644 tests/integration/testdata/list/hello_world/requirements.txt create mode 100644 tests/integration/testdata/list/test_stack_creation_template.yaml create mode 100644 tests/integration/testdata/list/test_stack_no_outputs_template.yaml create mode 100644 tests/unit/commands/list/stack_outputs/__init__.py create mode 100644 tests/unit/commands/list/stack_outputs/test_cli.py create mode 100644 tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py diff --git a/samcli/commands/list/exceptions.py b/samcli/commands/list/exceptions.py new file mode 100644 index 00000000000..0ca1fa06f79 --- /dev/null +++ b/samcli/commands/list/exceptions.py @@ -0,0 +1,16 @@ +""" +Exceptions for SAM list +""" + + +from samcli.commands.exceptions import UserException + + +class NoRegionError(UserException): + def __init__(self, stack_name, msg): + self.stack_name = stack_name + self.msg = msg + + message_fmt = "Error with {stack_name}, {msg}" + + super().__init__(message=message_fmt.format(stack_name=self.stack_name, msg=msg)) diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index f4b7b7fc5c8..23c69c73c3a 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -31,8 +31,8 @@ def cli(self, stack_name, output): Generate an event for one of the services listed below: """ - do_cli(stack_name, output) + # do_cli(stack_name, output) -def do_cli(stack_name, output): - pass +# def do_cli(stack_name, output): +# pass diff --git a/samcli/commands/list/stack_outputs/cli.py b/samcli/commands/list/stack_outputs/cli.py index a65210c9071..7c26ab32799 100644 --- a/samcli/commands/list/stack_outputs/cli.py +++ b/samcli/commands/list/stack_outputs/cli.py @@ -33,8 +33,13 @@ def cli(self, stack_name, output): """ Generate an event for one of the services listed below: """ - do_cli(stack_name, output) + do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile) -def do_cli(stack_name, output): - pass +def do_cli(stack_name, output, region, profile): + from samcli.commands.list.stack_outputs.stack_outputs_context import StackOutputsContext + + with StackOutputsContext( + stack_name=stack_name, output=output, region=region, profile=profile + ) as stack_output_context: + stack_output_context.run() diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py new file mode 100644 index 00000000000..d69d9489c58 --- /dev/null +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -0,0 +1,103 @@ +""" +Display the Outputs of a SAM stack +""" +import logging + +import json +import boto3 + +import click +from botocore.exceptions import ClientError, BotoCoreError + +from samcli.lib.utils.boto_utils import get_boto_config_with_user_agent +from samcli.cli.context import Context +from samcli.commands.list.exceptions import NoRegionError + + +LOG = logging.getLogger(__name__) + + +class StackOutputsContext: + def __init__(self, stack_name, output, region, profile): + self.stack_name = stack_name + self.output = output + self.region = region + self.profile = profile + self.cloudformation_client = None + + def __enter__(self): + self.init_clients() + return self + + def __exit__(self, *args): + pass + + def get_stack_info(self): + return self.cloudformation_client.describe_stacks(StackName=self.stack_name) + + def stack_exists(self, stack_name): + input_stack_does_not_exist_in_region = ( + f"Error: The input stack {self.stack_name} does" f" not exist on Cloudformation in the region {self.region}" + ) + outputs_do_not_exist_in_stack = ( + f"Error: Outputs do not exist for the input stack {self.stack_name}" + f" on Cloudformation in the region {self.region}" + ) + try: + response = self.get_stack_info() + if not response["Stacks"]: + return False, input_stack_does_not_exist_in_region + if "Outputs" not in response["Stacks"][0]: + return False, outputs_do_not_exist_in_stack + return True, None + + except ClientError as e: + if "Stack with id {0} does not exist".format(stack_name) in str(e): + LOG.debug("Stack with id %s does not exist", stack_name) + return False, input_stack_does_not_exist_in_region + LOG.error("ClientError Exception : %s", str(e)) + return False, "Error: " + str(e) + except BotoCoreError as e: + # If there are credentials, environment errors, + # catch that and throw a delete failed error. + + LOG.error("Botocore Exception : %s", str(e)) + return False, "Error: " + str(e) + + def init_clients(self): + """ + Initialize the clients being used by sam list. + """ + if not self.region: + session = boto3.Session() + region = session.region_name + if region: + self.region = region + else: + raise NoRegionError(stack_name=self.stack_name, msg="no region specified/found") + + if self.profile: + Context.get_current_context().profile = self.profile + if self.region: + Context.get_current_context().region = self.region + + boto_config = get_boto_config_with_user_agent() + self.cloudformation_client = boto3.client( + "cloudformation", region_name=self.region if self.region else None, config=boto_config + ) + + def run(self): + self.init_clients() + exists = self.stack_exists(self.stack_name) + if exists: + if exists[0]: + response = self.get_stack_info() + click.echo(json.dumps(response["Stacks"][0]["Outputs"], indent=2)) + else: + LOG.debug("Input stack does not exists on Cloudformation") + click.echo(exists[1]) + else: + click.echo( + f"Error: The input stack {self.stack_name} does" + f" not exist on Cloudformation in the region {self.region}" + ) diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py index 0950fa8f5a4..8d771cb34a7 100644 --- a/samcli/commands/list/testable_resources/cli.py +++ b/samcli/commands/list/testable_resources/cli.py @@ -31,8 +31,8 @@ def cli(self, stack_name, output): """ Generate an event for one of the services listed below: """ - do_cli(stack_name, output) + # do_cli(stack_name, output) -def do_cli(stack_name, output): - pass +# def do_cli(stack_name, output): +# pass diff --git a/samcli/lib/list/__init__.py b/samcli/lib/list/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/lib/list/list_utils.py b/samcli/lib/list/list_utils.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/list/list_integ_base.py b/tests/integration/list/list_integ_base.py index 02639601523..922f8e803a0 100644 --- a/tests/integration/list/list_integ_base.py +++ b/tests/integration/list/list_integ_base.py @@ -11,6 +11,19 @@ class ListIntegBase(TestCase): @classmethod def setUpClass(cls): cls.cmd = cls.base_command() + cls.list_test_data_path = Path(__file__).resolve().parents[1].joinpath("testdata", "list") + + def setUp(self): + super().setUp() + self.scratch_dir = str(Path(__file__).resolve().parent.joinpath(str(uuid.uuid4()).replace("-", "")[:10])) + shutil.rmtree(self.scratch_dir, ignore_errors=True) + os.mkdir(self.scratch_dir) + self.working_dir = tempfile.mkdtemp(dir=self.scratch_dir) + + def tearDown(self): + super().tearDown() + self.working_dir and shutil.rmtree(self.working_dir, ignore_errors=True) + self.scratch_dir and shutil.rmtree(self.scratch_dir, ignore_errors=True) @classmethod def base_command(cls): diff --git a/tests/integration/list/resources/resources_integ_base.py b/tests/integration/list/resources/resources_integ_base.py index f444694bd5e..a167947f473 100644 --- a/tests/integration/list/resources/resources_integ_base.py +++ b/tests/integration/list/resources/resources_integ_base.py @@ -8,7 +8,7 @@ class ResourcesIntegBase(ListIntegBase): - def get_resources_command_list(self, stack_name=None, output=None, help=False): + def get_resources_command_list(self, stack_name=None, output=None, region=None, profile=None, help=False): command_list = [self.base_command(), "list", "resources"] if stack_name: command_list += ["--stack-name", str(stack_name)] @@ -16,6 +16,12 @@ def get_resources_command_list(self, stack_name=None, output=None, help=False): if output: command_list += ["--output", str(output)] + if region: + command_list += ["--region", str(region)] + + if profile: + command_list += ["--profile", str(profile)] + if help: command_list += ["--help"] diff --git a/tests/integration/list/stack_outputs/stack_outputs_integ_base.py b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py index 2a71285cc8c..557bc933bb2 100644 --- a/tests/integration/list/stack_outputs/stack_outputs_integ_base.py +++ b/tests/integration/list/stack_outputs/stack_outputs_integ_base.py @@ -8,7 +8,7 @@ class StackOutputsIntegBase(ListIntegBase): - def get_stack_outputs_command_list(self, stack_name=None, output=None, help=False): + def get_stack_outputs_command_list(self, stack_name=None, output=None, region=None, profile=None, help=False): command_list = [self.base_command(), "list", "stack-outputs"] if stack_name: command_list += ["--stack-name", str(stack_name)] @@ -16,6 +16,12 @@ def get_stack_outputs_command_list(self, stack_name=None, output=None, help=Fals if output: command_list += ["--output", str(output)] + if region: + command_list += ["--region", str(region)] + + if profile: + command_list += ["--profile", str(profile)] + if help: command_list += ["--help"] diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index e46df4d818e..2fb46abdccb 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -1,12 +1,119 @@ +import os +import time +import boto3 +import re + + +from tests.integration.deploy.deploy_integ_base import DeployIntegBase from tests.integration.list.stack_outputs.stack_outputs_integ_base import StackOutputsIntegBase from samcli.commands.list.stack_outputs.cli import HELP_TEXT -from tests.testing_utils import run_command +from tests.testing_utils import run_command, run_command_with_input + +CFN_SLEEP = 3 +CFN_PYTHON_VERSION_SUFFIX = os.environ.get("PYTHON_VERSION", "0.0.0").replace(".", "-") + + +class TestStackOutputs(DeployIntegBase, StackOutputsIntegBase): + @classmethod + def setUpClass(cls): + DeployIntegBase.setUpClass() + StackOutputsIntegBase.setUpClass() + def setUp(self): + + self.cf_client = boto3.client("cloudformation", region_name=boto3.Session().region_name) + time.sleep(CFN_SLEEP) + super().setUp() -class TestStackOutputs(StackOutputsIntegBase): def test_stack_outputs_help_message(self): cmdlist = self.get_stack_outputs_command_list(help=True) - command_result = run_command(cmdlist) + command_result = run_command(cmdlist, cwd=self.working_dir) from_command = "".join(command_result.stdout.decode().split()) from_help = "".join(HELP_TEXT.split()) self.assertIn(from_help, from_command, "Stack-outputs help text should have been printed") + + def test_stack_output_exists(self): + template_path = self.list_test_data_path.joinpath("test_stack_creation_template.yaml") + stack_name = self._method_to_stack_name(self.id()) + config_file_name = stack_name + ".toml" + region = boto3.Session().region_name + deploy_command_list = self.get_deploy_command_list( + template_file=template_path, + guided=True, + config_file=config_file_name, + region=region, + confirm_changeset=True, + disable_rollback=True, + ) + deploy_process_execute = run_command_with_input( + deploy_command_list, "{}\n{}\nY\nY\nY\nY\nY\n\n\nY\n".format(stack_name, region).encode() + ) + cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region) + command_result = run_command(cmdlist, cwd=self.working_dir) + self.assertTrue( + re.match( + """^\[ + { + "OutputKey": "HelloWorldFunctionIamRole", + "OutputValue": "arn:aws:iam::............:role/test-stack-output-exists-0-HelloWorldFunctionRole\-............", + "Description": "Implicit IAM Role created for Hello World function" + }, + { + "OutputKey": "HelloWorldApi", + "OutputValue": "https://...........execute\-api.us\-east\-1.amazonaws.com/Prod/hello/", + "Description": "API Gateway endpoint URL for Prod stage for Hello World function" + }, + { + "OutputKey": "HelloWorldFunction", + "OutputValue": "arn:aws:lambda:us\-east\-1:............:function:test-stack-output-exists-0-0-0\-HelloWorldFunction\-............", + "Description": "Hello World Lambda Function ARN" + } +\] +""", + command_result.stdout.decode(), + ) + ) + + def test_stack_no_outputs_exist(self): + template_path = self.list_test_data_path.joinpath("test_stack_no_outputs_template.yaml") + stack_name = self._method_to_stack_name(self.id()) + config_file_name = stack_name + ".toml" + region = boto3.Session().region_name + deploy_command_list = self.get_deploy_command_list( + template_file=template_path, + guided=True, + config_file=config_file_name, + region=region, + confirm_changeset=True, + disable_rollback=True, + ) + deploy_process_execute = run_command_with_input( + deploy_command_list, "{}\n{}\nY\nY\nY\nY\nY\n\n\nY\n".format(stack_name, region).encode() + ) + cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region) + command_result = run_command(cmdlist, cwd=self.working_dir) + expected_output = ( + f"Error: Outputs do not exist for the input stack {stack_name}" f" on Cloudformation in the region {region}" + ) + self.assertIn( + expected_output, command_result.stdout.decode(), "Should have raised error that outputs do not exist" + ) + + def test_stack_does_not_exist(self): + template_path = self.list_test_data_path.joinpath("test_stack_no_outputs_template.yaml") + stack_name = self._method_to_stack_name(self.id()) + config_file_name = stack_name + ".toml" + region = boto3.Session().region_name + cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region) + command_result = run_command(cmdlist, cwd=self.working_dir) + expected_output = ( + f"Error: The input stack {stack_name} does" f" not exist on Cloudformation in the region {region}" + ) + self.assertIn( + expected_output, command_result.stdout.decode(), "Should have raised error that outputs do not exist" + ) + + def _method_to_stack_name(self, method_name): + """Method expects method name which can be a full path. Eg: test.integration.test_deploy_command.method_name""" + method_name = method_name.split(".")[-1] + return f"{method_name.replace('_', '-')}-{CFN_PYTHON_VERSION_SUFFIX}" diff --git a/tests/integration/list/testable_resources/testable_resources_integ_base.py b/tests/integration/list/testable_resources/testable_resources_integ_base.py index 6be824282f0..cccde077476 100644 --- a/tests/integration/list/testable_resources/testable_resources_integ_base.py +++ b/tests/integration/list/testable_resources/testable_resources_integ_base.py @@ -8,7 +8,7 @@ class TestableResourcesIntegBase(ListIntegBase): - def get_testable_resources_command_list(self, stack_name=None, output=None, help=False): + def get_testable_resources_command_list(self, stack_name=None, output=None, region=None, profile=None, help=False): command_list = [self.base_command(), "list", "testable-resources"] if stack_name: command_list += ["--stack-name", str(stack_name)] @@ -16,6 +16,12 @@ def get_testable_resources_command_list(self, stack_name=None, output=None, help if output: command_list += ["--output", str(output)] + if region: + command_list += ["--region", str(region)] + + if profile: + command_list += ["--profile", str(profile)] + if help: command_list += ["--help"] diff --git a/tests/integration/testdata/list/hello_world/__init__.py b/tests/integration/testdata/list/hello_world/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/testdata/list/hello_world/app.py b/tests/integration/testdata/list/hello_world/app.py new file mode 100644 index 00000000000..5c98a3f615b --- /dev/null +++ b/tests/integration/testdata/list/hello_world/app.py @@ -0,0 +1,42 @@ +import json + +# import requests + + +def lambda_handler(event, context): + """Sample pure Lambda function + + Parameters + ---------- + event: dict, required + API Gateway Lambda Proxy Input Format + + Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format + + context: object, required + Lambda Context runtime methods and attributes + + Context doc: https://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html + + Returns + ------ + API Gateway Lambda Proxy Output Format: dict + + Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + """ + + # try: + # ip = requests.get("http://checkip.amazonaws.com/") + # except requests.RequestException as e: + # # Send some context about this error to Lambda Logs + # print(e) + + # raise e + personId = event['queryStringParameters']['personId'] + return { + "statusCode": 200, + "body": json.dumps({ + "personId": personId + " from Lambda", + # "location": ip.text.replace("\n", "") + }), + } diff --git a/tests/integration/testdata/list/hello_world/requirements.txt b/tests/integration/testdata/list/hello_world/requirements.txt new file mode 100644 index 00000000000..663bd1f6a2a --- /dev/null +++ b/tests/integration/testdata/list/hello_world/requirements.txt @@ -0,0 +1 @@ +requests \ No newline at end of file diff --git a/tests/integration/testdata/list/test_stack_creation_template.yaml b/tests/integration/testdata/list/test_stack_creation_template.yaml new file mode 100644 index 00000000000..5a2efcdd337 --- /dev/null +++ b/tests/integration/testdata/list/test_stack_creation_template.yaml @@ -0,0 +1,39 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: "Test stack for testing sam list" + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 3 + Tracing: Active + +Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction + Properties: + CodeUri: hello_world/ + Handler: app.lambda_handler + Runtime: python3.8 + Architectures: + - x86_64 + Events: + HelloWorld: + Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api + Properties: + Path: /hello + Method: get + +Outputs: + # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function + # Find out more about other implicit resources you can reference within SAM + # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api + HelloWorldApi: + Description: "API Gateway endpoint URL for Prod stage for Hello World function" + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/" + HelloWorldFunction: + Description: "Hello World Lambda Function ARN" + Value: !GetAtt HelloWorldFunction.Arn + HelloWorldFunctionIamRole: + Description: "Implicit IAM Role created for Hello World function" + Value: !GetAtt HelloWorldFunctionRole.Arn diff --git a/tests/integration/testdata/list/test_stack_no_outputs_template.yaml b/tests/integration/testdata/list/test_stack_no_outputs_template.yaml new file mode 100644 index 00000000000..1f64430eba5 --- /dev/null +++ b/tests/integration/testdata/list/test_stack_no_outputs_template.yaml @@ -0,0 +1,26 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: "Test stack for testing sam list" + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 3 + Tracing: Active + +Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction + Properties: + CodeUri: hello_world/ + Handler: app.lambda_handler + Runtime: python3.8 + Architectures: + - x86_64 + Events: + HelloWorld: + Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api + Properties: + Path: /hello + Method: get + diff --git a/tests/unit/commands/list/stack_outputs/__init__.py b/tests/unit/commands/list/stack_outputs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/commands/list/stack_outputs/test_cli.py b/tests/unit/commands/list/stack_outputs/test_cli.py new file mode 100644 index 00000000000..5c7e8569288 --- /dev/null +++ b/tests/unit/commands/list/stack_outputs/test_cli.py @@ -0,0 +1,33 @@ +from unittest import TestCase +from unittest.mock import Mock, patch +from samcli.commands.list.stack_outputs.cli import do_cli + + +class TestCli(TestCase): + def setUp(self): + self.stack_name = "stack-name" + self.output = "json" + self.region = None + self.profile = None + + @patch("samcli.commands.list.stack_outputs.cli.click") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.StackOutputsContext") + def test_cli_base_command(self, mock_stack_outputs_context, mock_stack_outputs_click): + context_mock = Mock() + mock_stack_outputs_context.return_value.__enter__.return_value = context_mock + do_cli( + stack_name=self.stack_name, + output=self.output, + region=self.region, + profile=self.profile, + ) + + mock_stack_outputs_context.assert_called_with( + stack_name=self.stack_name, + output=self.output, + region=self.region, + profile=self.profile, + ) + + context_mock.run.assert_called_with() + self.assertEqual(context_mock.run.call_count, 1) diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py new file mode 100644 index 00000000000..5c917e2e3f2 --- /dev/null +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -0,0 +1,170 @@ +from unittest import TestCase, mock +from unittest.mock import patch, call, MagicMock +from botocore.exceptions import ClientError, BotoCoreError, WaiterError, EndpointConnectionError +import boto3 + +import click + +from samcli.commands.list.stack_outputs.stack_outputs_context import StackOutputsContext +from samcli.commands.list.exceptions import NoRegionError + + +class TestStackOutputsContext(TestCase): + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch.object( + StackOutputsContext, + "stack_exists", + MagicMock( + return_value=( + False, + f"Error: The input stack test does" f" not exist on Cloudformation in the region us-east-1", + ) + ), + ) + def test_stack_outputs_stack_does_not_exist(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile="test" + ) as stack_output_context: + stack_output_context.run() + + expected_click_echo_calls = [ + call(f"Error: The input stack test does" + f" not exist on Cloudformation in the region us-east-1"), + ] + self.assertEqual( + expected_click_echo_calls, + patched_click_echo.call_args_list, + "The input stack should not exist in the given region", + ) + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch.object( + StackOutputsContext, + "get_stack_info", + MagicMock( + return_value=( + { + "Stacks": [ + {"Outputs": [{"OutputKey": "HelloWorldTest", "OutputValue": "TestVal", "Description": "Test"}]} + ] + } + ) + ), + ) + def test_stack_outputs_stack_exists(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile="test" + ) as stack_output_context: + stack_output_context.run() + expected_click_echo_calls = [ + call( + '[\n {\n "OutputKey": "HelloWorldTest",\n "OutputValue": "TestVal",\n "Description": "Test"\n }\n]' + ) + ] + self.assertEqual( + expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" + ) + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch.object( + StackOutputsContext, + "get_stack_info", + MagicMock(return_value=({"Stacks": []})), + ) + def test_no_stack_object_in_response(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile="test" + ) as stack_output_context: + stack_output_context.run() + expected_click_echo_calls = [ + call("Error: The input stack test does not exist on Cloudformation in the region us-east-1") + ] + self.assertEqual( + expected_click_echo_calls, + patched_click_echo.call_args_list, + "Input stack should not exist in the given region", + ) + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch.object( + StackOutputsContext, + "get_stack_info", + MagicMock(return_value=({"Stacks": [{}]})), + ) + def test_no_output_object_in_response(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile="test" + ) as stack_output_context: + stack_output_context.run() + expected_click_echo_calls = [ + call("Error: Outputs do not exist for the input stack test on Cloudformation in the region us-east-1") + ] + self.assertEqual( + expected_click_echo_calls, patched_click_echo.call_args_list, "Outputs should not exist for this stack" + ) + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch.object( + StackOutputsContext, + "get_stack_info", + MagicMock( + side_effect=ClientError( + {"Error": {"Code": "ValidationError", "Message": "Stack with id test does not exist"}}, "DescribeStacks" + ) + ), + ) + def test_clienterror_stack_does_not_exist_in_region(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile="test" + ) as stack_output_context: + stack_output_context.run() + + expected_click_echo_calls = [ + call(f"Error: The input stack test does" + f" not exist on Cloudformation in the region us-east-1"), + ] + self.assertEqual( + expected_click_echo_calls, patched_click_echo.call_args_list, "The input stack should not exists" + ) + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch.object( + StackOutputsContext, + "get_stack_info", + MagicMock(side_effect=EndpointConnectionError(endpoint_url="https://cloudformation.test.amazonaws.com/")), + ) + def test_botocoreerror_invalid_region(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile="test" + ) as stack_output_context: + # patched_click_echo.raiseError.side_effect = Mock(side_effect=Exception('Test')) + stack_output_context.run() + + expected_click_echo_calls = [ + call('Error: Could not connect to the endpoint URL: "https://cloudformation.test.amazonaws.com/"'), + ] + self.assertEqual( + expected_click_echo_calls, patched_click_echo.call_args_list, "Should raise endpoint connection error" + ) + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch("boto3.Session.region_name", None) + def test_init_clients_no_region(self, patched_click_get_current_context, patched_click_echo): + with self.assertRaises(NoRegionError): + with StackOutputsContext( + stack_name="test", output="json", region=None, profile="test" + ) as stack_output_context: + stack_output_context.init_clients() + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch("boto3.Session.region_name", "us-east-1") + def test_init_clients_has_region(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext(stack_name="test", output="json", region=None, profile="test") as stack_output_context: + stack_output_context.init_clients() + self.assertTrue(stack_output_context.region) From 08bf419fad211efb8a07f651f7cec2043cb43afc Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 8 Jun 2022 12:05:18 -0700 Subject: [PATCH 20/72] Added test skips for integration tests, added unit tests, removed redundant init_client call --- samcli/commands/list/resources/cli.py | 4 +- samcli/commands/list/stack_outputs/cli.py | 1 - .../stack_outputs/stack_outputs_context.py | 3 - .../commands/list/testable_resources/cli.py | 4 +- .../test_stack_outputs_command.py | 10 ++- .../unit/commands/list/resources/test_cli.py | 15 +++- .../test_stack_outputs_context.py | 75 ++++++++++++++++++- .../list/testable_resources/__init__.py | 0 .../list/testable_resources/test_cli.py | 21 ++++++ 9 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 tests/unit/commands/list/testable_resources/__init__.py create mode 100644 tests/unit/commands/list/testable_resources/test_cli.py diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index f4b7b7fc5c8..27b2ba7cabd 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -31,8 +31,8 @@ def cli(self, stack_name, output): Generate an event for one of the services listed below: """ - do_cli(stack_name, output) + do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile) -def do_cli(stack_name, output): +def do_cli(stack_name, output, region, profile): pass diff --git a/samcli/commands/list/stack_outputs/cli.py b/samcli/commands/list/stack_outputs/cli.py index 72cc7e04893..7c26ab32799 100644 --- a/samcli/commands/list/stack_outputs/cli.py +++ b/samcli/commands/list/stack_outputs/cli.py @@ -43,4 +43,3 @@ def do_cli(stack_name, output, region, profile): stack_name=stack_name, output=output, region=region, profile=profile ) as stack_output_context: stack_output_context.run() - diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py index d69d9489c58..21915439b9a 100644 --- a/samcli/commands/list/stack_outputs/stack_outputs_context.py +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -2,10 +2,8 @@ Display the Outputs of a SAM stack """ import logging - import json import boto3 - import click from botocore.exceptions import ClientError, BotoCoreError @@ -87,7 +85,6 @@ def init_clients(self): ) def run(self): - self.init_clients() exists = self.stack_exists(self.stack_name) if exists: if exists[0]: diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py index 0950fa8f5a4..a59e2c98728 100644 --- a/samcli/commands/list/testable_resources/cli.py +++ b/samcli/commands/list/testable_resources/cli.py @@ -31,8 +31,8 @@ def cli(self, stack_name, output): """ Generate an event for one of the services listed below: """ - do_cli(stack_name, output) + do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile) -def do_cli(stack_name, output): +def do_cli(stack_name, output, region, profile): pass diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 3c66afc0b87..43d7b52cc1d 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -2,13 +2,15 @@ import time import boto3 import re - +from unittest import skipIf from tests.integration.deploy.deploy_integ_base import DeployIntegBase from tests.integration.list.stack_outputs.stack_outputs_integ_base import StackOutputsIntegBase from samcli.commands.list.stack_outputs.cli import HELP_TEXT +from tests.testing_utils import RUNNING_ON_CI, RUNNING_TEST_FOR_MASTER_ON_CI, RUN_BY_CANARY from tests.testing_utils import run_command, run_command_with_input +SKIP_STACK_OUTPUTS_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY CFN_SLEEP = 3 CFN_PYTHON_VERSION_SUFFIX = os.environ.get("PYTHON_VERSION", "0.0.0").replace(".", "-") @@ -21,7 +23,7 @@ def setUpClass(cls): def setUp(self): - self.cf_client = boto3.client("cloudformation", region_name=boto3.Session().region_name) + self.cf_client = boto3.client("cloudformation") time.sleep(CFN_SLEEP) super().setUp() @@ -32,6 +34,7 @@ def test_stack_outputs_help_message(self): from_help = "".join(HELP_TEXT.split()) self.assertIn(from_help, from_command, "Stack-outputs help text should have been printed") + @skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") def test_stack_output_exists(self): template_path = self.list_test_data_path.joinpath("test_stack_creation_template.yaml") stack_name = self._method_to_stack_name(self.id()) @@ -74,6 +77,7 @@ def test_stack_output_exists(self): ) ) + @skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") def test_stack_no_outputs_exist(self): template_path = self.list_test_data_path.joinpath("test_stack_no_outputs_template.yaml") stack_name = self._method_to_stack_name(self.id()) @@ -99,6 +103,7 @@ def test_stack_no_outputs_exist(self): expected_output, command_result.stdout.decode(), "Should have raised error that outputs do not exist" ) + @skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") def test_stack_does_not_exist(self): template_path = self.list_test_data_path.joinpath("test_stack_no_outputs_template.yaml") stack_name = self._method_to_stack_name(self.id()) @@ -117,4 +122,3 @@ def _method_to_stack_name(self, method_name): """Method expects method name which can be a full path. Eg: test.integration.test_deploy_command.method_name""" method_name = method_name.split(".")[-1] return f"{method_name.replace('_', '-')}-{CFN_PYTHON_VERSION_SUFFIX}" - diff --git a/tests/unit/commands/list/resources/test_cli.py b/tests/unit/commands/list/resources/test_cli.py index 784f46b3139..cd09bde6f43 100644 --- a/tests/unit/commands/list/resources/test_cli.py +++ b/tests/unit/commands/list/resources/test_cli.py @@ -1,12 +1,21 @@ from unittest import TestCase -from unittest.mock import patch +from unittest.mock import Mock, patch +from samcli.commands.list.resources.cli import do_cli class TestCli(TestCase): def setUp(self): self.stack_name = "stack-name" self.output = "json" + self.region = None + self.profile = None @patch("samcli.commands.list.resources.cli.click") - def test_cli_base_command(self, mock_resources_context): - pass + def test_cli_base_command(self, mock_resources_click): + context_mock = Mock() + do_cli( + stack_name=self.stack_name, + output=self.output, + region=self.region, + profile=self.profile, + ) diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index 5c917e2e3f2..0a0e76ab973 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -2,7 +2,7 @@ from unittest.mock import patch, call, MagicMock from botocore.exceptions import ClientError, BotoCoreError, WaiterError, EndpointConnectionError import boto3 - +import os import click from samcli.commands.list.stack_outputs.stack_outputs_context import StackOutputsContext @@ -168,3 +168,76 @@ def test_init_clients_has_region(self, patched_click_get_current_context, patche with StackOutputsContext(stack_name="test", output="json", region=None, profile="test") as stack_output_context: stack_output_context.init_clients() self.assertTrue(stack_output_context.region) + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch.object( + StackOutputsContext, + "stack_exists", + MagicMock(return_value=(None)), + ) + def test_stack_exists_returns_none(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile="test" + ) as stack_output_context: + stack_output_context.run() + expected_click_echo_calls = [ + call( + f"Error: The input stack {stack_output_context.stack_name} does" + f" not exist on Cloudformation in the region {stack_output_context.region}" + ) + ] + self.assertEqual( + expected_click_echo_calls, patched_click_echo.call_args_list, "stack_exists should have returned None" + ) + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch.object( + StackOutputsContext, + "get_stack_info", + MagicMock( + return_value=( + { + "Stacks": [ + {"Outputs": [{"OutputKey": "HelloWorldTest", "OutputValue": "TestVal", "Description": "Test"}]} + ] + } + ) + ), + ) + @patch.object( + StackOutputsContext, + "stack_exists", + MagicMock(return_value=(True, None)), + ) + def test_stack_outputs_stack_exists_returns_true(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile="test" + ) as stack_output_context: + stack_output_context.run() + expected_click_echo_calls = [ + call( + '[\n {\n "OutputKey": "HelloWorldTest",\n "OutputValue": "TestVal",\n "Description": "Test"\n }\n]' + ) + ] + self.assertEqual( + expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" + ) + + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch.object( + StackOutputsContext, + "stack_exists", + MagicMock(return_value=(False, "Error message")), + ) + def test_stack_outputs_stack_exists_returns_false(self, patched_click_get_current_context, patched_click_echo): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile="test" + ) as stack_output_context: + stack_output_context.run() + expected_click_echo_calls = [call("Error message")] + self.assertEqual( + expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" + ) diff --git a/tests/unit/commands/list/testable_resources/__init__.py b/tests/unit/commands/list/testable_resources/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/commands/list/testable_resources/test_cli.py b/tests/unit/commands/list/testable_resources/test_cli.py new file mode 100644 index 00000000000..83b441b6217 --- /dev/null +++ b/tests/unit/commands/list/testable_resources/test_cli.py @@ -0,0 +1,21 @@ +from unittest import TestCase +from unittest.mock import Mock, patch +from samcli.commands.list.testable_resources.cli import do_cli + + +class TestCli(TestCase): + def setUp(self): + self.stack_name = "stack-name" + self.output = "json" + self.region = None + self.profile = None + + @patch("samcli.commands.list.testable_resources.cli.click") + def test_cli_base_command(self, mock_testable_resources_click): + context_mock = Mock() + do_cli( + stack_name=self.stack_name, + output=self.output, + region=self.region, + profile=self.profile, + ) From 1358e61516294fa7db6ed9a583290323f1dab4ce Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 8 Jun 2022 13:44:03 -0700 Subject: [PATCH 21/72] commit to retrigger appveyor tests --- .../integration/list/stack_outputs/test_stack_outputs_command.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 43d7b52cc1d..1aba7de8643 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -13,6 +13,7 @@ SKIP_STACK_OUTPUTS_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY CFN_SLEEP = 3 CFN_PYTHON_VERSION_SUFFIX = os.environ.get("PYTHON_VERSION", "0.0.0").replace(".", "-") +# commit to re-trigger appveyor tests class TestStackOutputs(DeployIntegBase, StackOutputsIntegBase): From cf5c1915b0cce971e1c9a9b917efabab6a90d0ca Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 8 Jun 2022 15:32:38 -0700 Subject: [PATCH 22/72] Commmit to trigger appveyor --- .../list/stack_outputs/test_stack_outputs_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 1aba7de8643..4e7d862c6ca 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -13,7 +13,7 @@ SKIP_STACK_OUTPUTS_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY CFN_SLEEP = 3 CFN_PYTHON_VERSION_SUFFIX = os.environ.get("PYTHON_VERSION", "0.0.0").replace(".", "-") -# commit to re-trigger appveyor tests +# commit to re-trigger appveyor tests class TestStackOutputs(DeployIntegBase, StackOutputsIntegBase): From 3aa02da6752859e15f3f70f9936ffd1d045141fd Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 9 Jun 2022 16:27:18 -0700 Subject: [PATCH 23/72] Modified client source, made fixes based on comments --- samcli/commands/list/exceptions.py | 14 +++- samcli/commands/list/resources/cli.py | 6 +- samcli/commands/list/stack_outputs/cli.py | 5 +- .../stack_outputs/stack_outputs_context.py | 83 ++++++++++--------- .../commands/list/testable_resources/cli.py | 6 +- samcli/lib/list/__init__.py | 0 samcli/lib/list/list_utils.py | 0 .../test_stack_outputs_command.py | 3 +- .../test_stack_outputs_context.py | 82 ++++++------------ 9 files changed, 94 insertions(+), 105 deletions(-) delete mode 100644 samcli/lib/list/__init__.py delete mode 100644 samcli/lib/list/list_utils.py diff --git a/samcli/commands/list/exceptions.py b/samcli/commands/list/exceptions.py index 0ca1fa06f79..ee6d61a9f66 100644 --- a/samcli/commands/list/exceptions.py +++ b/samcli/commands/list/exceptions.py @@ -6,11 +6,21 @@ from samcli.commands.exceptions import UserException -class NoRegionError(UserException): +class StackOutputsError(UserException): def __init__(self, stack_name, msg): self.stack_name = stack_name self.msg = msg - message_fmt = "Error with {stack_name}, {msg}" + message_fmt = "{msg}" + + super().__init__(message=message_fmt.format(stack_name=self.stack_name, msg=msg)) + + +class NoOutputsForStackError(UserException): + def __init__(self, stack_name, msg): + self.stack_name = stack_name + self.msg = msg + + message_fmt = f"Outputs do not exist for the input stack {stack_name} on Cloudformation in the region {msg}" super().__init__(message=message_fmt.format(stack_name=self.stack_name, msg=msg)) diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index 27b2ba7cabd..cc8c7de2ced 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -28,11 +28,13 @@ @print_cmdline_args def cli(self, stack_name, output): """ - Generate an event for one of the services listed below: + `sam list resources` command entry point """ do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile) def do_cli(stack_name, output, region, profile): - pass + """ + Implementation of the ``cli`` method + """ diff --git a/samcli/commands/list/stack_outputs/cli.py b/samcli/commands/list/stack_outputs/cli.py index 7c26ab32799..2486363aab8 100644 --- a/samcli/commands/list/stack_outputs/cli.py +++ b/samcli/commands/list/stack_outputs/cli.py @@ -31,12 +31,15 @@ @print_cmdline_args def cli(self, stack_name, output): """ - Generate an event for one of the services listed below: + `sam list stack-outputs` command entry point """ do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile) def do_cli(stack_name, output, region, profile): + """ + Implementation of the ``cli`` method + """ from samcli.commands.list.stack_outputs.stack_outputs_context import StackOutputsContext with StackOutputsContext( diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py index 21915439b9a..b5fc49a556f 100644 --- a/samcli/commands/list/stack_outputs/stack_outputs_context.py +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -7,16 +7,15 @@ import click from botocore.exceptions import ClientError, BotoCoreError -from samcli.lib.utils.boto_utils import get_boto_config_with_user_agent -from samcli.cli.context import Context -from samcli.commands.list.exceptions import NoRegionError - +from samcli.commands.exceptions import RegionError +from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError +from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config LOG = logging.getLogger(__name__) class StackOutputsContext: - def __init__(self, stack_name, output, region, profile): + def __init__(self, stack_name: str, output: str, region: str, profile: str): self.stack_name = stack_name self.output = output self.region = region @@ -31,38 +30,51 @@ def __exit__(self, *args): pass def get_stack_info(self): - return self.cloudformation_client.describe_stacks(StackName=self.stack_name) - - def stack_exists(self, stack_name): - input_stack_does_not_exist_in_region = ( - f"Error: The input stack {self.stack_name} does" f" not exist on Cloudformation in the region {self.region}" - ) - outputs_do_not_exist_in_stack = ( - f"Error: Outputs do not exist for the input stack {self.stack_name}" - f" on Cloudformation in the region {self.region}" - ) + """ + Returns the stack information for the stack + + Returns + ------- + A dictionary containing the stack's information + """ + cfn_client = self.cloudformation_client + return cfn_client.describe_stacks(StackName=self.stack_name) + + def stack_exists(self, stack_name: str) -> bool: + """ + Returns whether a stack exists in the region and is valid, and raises exceptions accordingly + + Parameters + ---------- + stack_name: str + Name of the stack that is deployed to CFN + + Returns + ------- + A boolean value of whether the stack exists in the region + """ try: response = self.get_stack_info() if not response["Stacks"]: - return False, input_stack_does_not_exist_in_region + return False if "Outputs" not in response["Stacks"][0]: - return False, outputs_do_not_exist_in_stack - return True, None + raise NoOutputsForStackError(stack_name=self.stack_name, msg=self.region) + return True except ClientError as e: if "Stack with id {0} does not exist".format(stack_name) in str(e): LOG.debug("Stack with id %s does not exist", stack_name) - return False, input_stack_does_not_exist_in_region + return False LOG.error("ClientError Exception : %s", str(e)) - return False, "Error: " + str(e) + raise StackOutputsError(stack_name=self.stack_name, msg=str(e)) from e except BotoCoreError as e: # If there are credentials, environment errors, # catch that and throw a delete failed error. LOG.error("Botocore Exception : %s", str(e)) - return False, "Error: " + str(e) + raise StackOutputsError(stack_name=self.stack_name, msg=str(e)) from e - def init_clients(self): + def init_clients(self) -> None: """ Initialize the clients being used by sam list. """ @@ -72,28 +84,21 @@ def init_clients(self): if region: self.region = region else: - raise NoRegionError(stack_name=self.stack_name, msg="no region specified/found") + raise RegionError(message="no region specified/found") - if self.profile: - Context.get_current_context().profile = self.profile - if self.region: - Context.get_current_context().region = self.region + client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) + self.cloudformation_client = client_provider("cloudformation") - boto_config = get_boto_config_with_user_agent() - self.cloudformation_client = boto3.client( - "cloudformation", region_name=self.region if self.region else None, config=boto_config - ) - - def run(self): + def run(self) -> None: + """ + Get the stack outputs for a stack + """ exists = self.stack_exists(self.stack_name) if exists: - if exists[0]: - response = self.get_stack_info() - click.echo(json.dumps(response["Stacks"][0]["Outputs"], indent=2)) - else: - LOG.debug("Input stack does not exists on Cloudformation") - click.echo(exists[1]) + response = self.get_stack_info() + click.echo(json.dumps(response["Stacks"][0]["Outputs"], indent=2)) else: + LOG.debug("Input stack does not exists on Cloudformation") click.echo( f"Error: The input stack {self.stack_name} does" f" not exist on Cloudformation in the region {self.region}" diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py index a59e2c98728..255d6c17e40 100644 --- a/samcli/commands/list/testable_resources/cli.py +++ b/samcli/commands/list/testable_resources/cli.py @@ -29,10 +29,12 @@ @print_cmdline_args def cli(self, stack_name, output): """ - Generate an event for one of the services listed below: + `sam list testable-resources` command entry point """ do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile) def do_cli(stack_name, output, region, profile): - pass + """ + Implementation of the ``cli`` method + """ diff --git a/samcli/lib/list/__init__.py b/samcli/lib/list/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/samcli/lib/list/list_utils.py b/samcli/lib/list/list_utils.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 4e7d862c6ca..9ee7ee522dd 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -13,7 +13,6 @@ SKIP_STACK_OUTPUTS_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY CFN_SLEEP = 3 CFN_PYTHON_VERSION_SUFFIX = os.environ.get("PYTHON_VERSION", "0.0.0").replace(".", "-") -# commit to re-trigger appveyor tests class TestStackOutputs(DeployIntegBase, StackOutputsIntegBase): @@ -101,7 +100,7 @@ def test_stack_no_outputs_exist(self): f"Error: Outputs do not exist for the input stack {stack_name}" f" on Cloudformation in the region {region}" ) self.assertIn( - expected_output, command_result.stdout.decode(), "Should have raised error that outputs do not exist" + expected_output, command_result.stderr.decode(), "Should have raised error that outputs do not exist" ) @skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index 0a0e76ab973..8fdd4e85b9b 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -6,7 +6,8 @@ import click from samcli.commands.list.stack_outputs.stack_outputs_context import StackOutputsContext -from samcli.commands.list.exceptions import NoRegionError +from samcli.commands.exceptions import RegionError +from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError class TestStackOutputsContext(TestCase): @@ -15,16 +16,11 @@ class TestStackOutputsContext(TestCase): @patch.object( StackOutputsContext, "stack_exists", - MagicMock( - return_value=( - False, - f"Error: The input stack test does" f" not exist on Cloudformation in the region us-east-1", - ) - ), + MagicMock(return_value=False), ) def test_stack_outputs_stack_does_not_exist(self, patched_click_get_current_context, patched_click_echo): with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile="test" + stack_name="test", output="json", region="us-east-1", profile=None ) as stack_output_context: stack_output_context.run() @@ -54,7 +50,7 @@ def test_stack_outputs_stack_does_not_exist(self, patched_click_get_current_cont ) def test_stack_outputs_stack_exists(self, patched_click_get_current_context, patched_click_echo): with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile="test" + stack_name="test", output="json", region="us-east-1", profile=None ) as stack_output_context: stack_output_context.run() expected_click_echo_calls = [ @@ -75,7 +71,7 @@ def test_stack_outputs_stack_exists(self, patched_click_get_current_context, pat ) def test_no_stack_object_in_response(self, patched_click_get_current_context, patched_click_echo): with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile="test" + stack_name="test", output="json", region="us-east-1", profile=None ) as stack_output_context: stack_output_context.run() expected_click_echo_calls = [ @@ -95,16 +91,11 @@ def test_no_stack_object_in_response(self, patched_click_get_current_context, pa MagicMock(return_value=({"Stacks": [{}]})), ) def test_no_output_object_in_response(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile="test" - ) as stack_output_context: - stack_output_context.run() - expected_click_echo_calls = [ - call("Error: Outputs do not exist for the input stack test on Cloudformation in the region us-east-1") - ] - self.assertEqual( - expected_click_echo_calls, patched_click_echo.call_args_list, "Outputs should not exist for this stack" - ) + with self.assertRaises(NoOutputsForStackError): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile=None + ) as stack_output_context: + stack_output_context.run() @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") @@ -119,7 +110,7 @@ def test_no_output_object_in_response(self, patched_click_get_current_context, p ) def test_clienterror_stack_does_not_exist_in_region(self, patched_click_get_current_context, patched_click_echo): with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile="test" + stack_name="test", output="json", region="us-east-1", profile=None ) as stack_output_context: stack_output_context.run() @@ -138,26 +129,20 @@ def test_clienterror_stack_does_not_exist_in_region(self, patched_click_get_curr MagicMock(side_effect=EndpointConnectionError(endpoint_url="https://cloudformation.test.amazonaws.com/")), ) def test_botocoreerror_invalid_region(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile="test" - ) as stack_output_context: - # patched_click_echo.raiseError.side_effect = Mock(side_effect=Exception('Test')) - stack_output_context.run() - - expected_click_echo_calls = [ - call('Error: Could not connect to the endpoint URL: "https://cloudformation.test.amazonaws.com/"'), - ] - self.assertEqual( - expected_click_echo_calls, patched_click_echo.call_args_list, "Should raise endpoint connection error" - ) + with self.assertRaises(StackOutputsError): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile=None + ) as stack_output_context: + # patched_click_echo.raiseError.side_effect = Mock(side_effect=Exception('Test')) + stack_output_context.run() @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") @patch("boto3.Session.region_name", None) def test_init_clients_no_region(self, patched_click_get_current_context, patched_click_echo): - with self.assertRaises(NoRegionError): + with self.assertRaises(RegionError): with StackOutputsContext( - stack_name="test", output="json", region=None, profile="test" + stack_name="test", output="json", region=None, profile=None ) as stack_output_context: stack_output_context.init_clients() @@ -165,7 +150,7 @@ def test_init_clients_no_region(self, patched_click_get_current_context, patched @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") @patch("boto3.Session.region_name", "us-east-1") def test_init_clients_has_region(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext(stack_name="test", output="json", region=None, profile="test") as stack_output_context: + with StackOutputsContext(stack_name="test", output="json", region=None, profile=None) as stack_output_context: stack_output_context.init_clients() self.assertTrue(stack_output_context.region) @@ -174,11 +159,11 @@ def test_init_clients_has_region(self, patched_click_get_current_context, patche @patch.object( StackOutputsContext, "stack_exists", - MagicMock(return_value=(None)), + MagicMock(return_value=None), ) def test_stack_exists_returns_none(self, patched_click_get_current_context, patched_click_echo): with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile="test" + stack_name="test", output="json", region="us-east-1", profile=None ) as stack_output_context: stack_output_context.run() expected_click_echo_calls = [ @@ -209,11 +194,11 @@ def test_stack_exists_returns_none(self, patched_click_get_current_context, patc @patch.object( StackOutputsContext, "stack_exists", - MagicMock(return_value=(True, None)), + MagicMock(return_value=True), ) def test_stack_outputs_stack_exists_returns_true(self, patched_click_get_current_context, patched_click_echo): with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile="test" + stack_name="test", output="json", region="us-east-1", profile=None ) as stack_output_context: stack_output_context.run() expected_click_echo_calls = [ @@ -224,20 +209,3 @@ def test_stack_outputs_stack_exists_returns_true(self, patched_click_get_current self.assertEqual( expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" ) - - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch.object( - StackOutputsContext, - "stack_exists", - MagicMock(return_value=(False, "Error message")), - ) - def test_stack_outputs_stack_exists_returns_false(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile="test" - ) as stack_output_context: - stack_output_context.run() - expected_click_echo_calls = [call("Error message")] - self.assertEqual( - expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" - ) From 5bc42473097f2b98288446ec94a4e53fe994d3a1 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Tue, 14 Jun 2022 16:27:51 -0700 Subject: [PATCH 24/72] Made fixes based on comments --- samcli/commands/list/exceptions.py | 23 ++-- .../stack_outputs/stack_outputs_context.py | 57 ++++----- .../test_stack_outputs_command.py | 10 +- .../testdata/list/hello_world/app.py | 4 +- .../test_stack_outputs_context.py | 113 ++---------------- 5 files changed, 57 insertions(+), 150 deletions(-) diff --git a/samcli/commands/list/exceptions.py b/samcli/commands/list/exceptions.py index ee6d61a9f66..e9cc93915a6 100644 --- a/samcli/commands/list/exceptions.py +++ b/samcli/commands/list/exceptions.py @@ -7,20 +7,29 @@ class StackOutputsError(UserException): - def __init__(self, stack_name, msg): - self.stack_name = stack_name + def __init__(self, msg): self.msg = msg message_fmt = "{msg}" - super().__init__(message=message_fmt.format(stack_name=self.stack_name, msg=msg)) + super().__init__(message=message_fmt.format(msg=msg)) class NoOutputsForStackError(UserException): - def __init__(self, stack_name, msg): + def __init__(self, stack_name, region): self.stack_name = stack_name - self.msg = msg + self.region = region + + message_fmt = f"Outputs do not exist for the input stack {stack_name} on Cloudformation in the region {region}" + + super().__init__(message=message_fmt.format(stack_name=self.stack_name, region=self.region)) + + +class StackDoesNotExistInRegionError(UserException): + def __init__(self, stack_name, region): + self.stack_name = stack_name + self.region = region - message_fmt = f"Outputs do not exist for the input stack {stack_name} on Cloudformation in the region {msg}" + message_fmt = f"The input stack {stack_name} does" f" not exist on Cloudformation in the region {region}" - super().__init__(message=message_fmt.format(stack_name=self.stack_name, msg=msg)) + super().__init__(message=message_fmt.format(stack_name=self.stack_name, region=self.region)) diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py index b5fc49a556f..f2919021727 100644 --- a/samcli/commands/list/stack_outputs/stack_outputs_context.py +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -3,19 +3,21 @@ """ import logging import json +from typing import Optional import boto3 import click from botocore.exceptions import ClientError, BotoCoreError from samcli.commands.exceptions import RegionError -from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError +from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError, StackDoesNotExistInRegionError from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config + LOG = logging.getLogger(__name__) class StackOutputsContext: - def __init__(self, stack_name: str, output: str, region: str, profile: str): + def __init__(self, stack_name: str, output: str, region: Optional[str], profile: Optional[str]): self.stack_name = stack_name self.output = output self.region = region @@ -31,7 +33,7 @@ def __exit__(self, *args): def get_stack_info(self): """ - Returns the stack information for the stack + Returns the stack information for the stack passed in from the command line Returns ------- @@ -40,39 +42,32 @@ def get_stack_info(self): cfn_client = self.cloudformation_client return cfn_client.describe_stacks(StackName=self.stack_name) - def stack_exists(self, stack_name: str) -> bool: + def stack_exists(self): """ - Returns whether a stack exists in the region and is valid, and raises exceptions accordingly - - Parameters - ---------- - stack_name: str - Name of the stack that is deployed to CFN + Returns the stack output information for the stack and raises exceptions accordingly Returns ------- - A boolean value of whether the stack exists in the region + A dictionary containing the stack's information """ + try: response = self.get_stack_info() if not response["Stacks"]: - return False + raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) if "Outputs" not in response["Stacks"][0]: - raise NoOutputsForStackError(stack_name=self.stack_name, msg=self.region) - return True + raise NoOutputsForStackError(stack_name=self.stack_name, region=self.region) + return response except ClientError as e: - if "Stack with id {0} does not exist".format(stack_name) in str(e): - LOG.debug("Stack with id %s does not exist", stack_name) - return False + if "Stack with id {0} does not exist".format(self.stack_name) in str(e): + LOG.debug("Stack with id %s does not exist", self.stack_name) + raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) from e LOG.error("ClientError Exception : %s", str(e)) - raise StackOutputsError(stack_name=self.stack_name, msg=str(e)) from e + raise StackOutputsError(msg=str(e)) from e except BotoCoreError as e: - # If there are credentials, environment errors, - # catch that and throw a delete failed error. - LOG.error("Botocore Exception : %s", str(e)) - raise StackOutputsError(stack_name=self.stack_name, msg=str(e)) from e + raise StackOutputsError(msg=str(e)) from e def init_clients(self) -> None: """ @@ -84,7 +79,10 @@ def init_clients(self) -> None: if region: self.region = region else: - raise RegionError(message="no region specified/found") + raise RegionError( + message="No region was specified/found. " + "Please provide a region via the --region parameter or by the AWS_REGION environment variable." + ) client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) self.cloudformation_client = client_provider("cloudformation") @@ -93,13 +91,6 @@ def run(self) -> None: """ Get the stack outputs for a stack """ - exists = self.stack_exists(self.stack_name) - if exists: - response = self.get_stack_info() - click.echo(json.dumps(response["Stacks"][0]["Outputs"], indent=2)) - else: - LOG.debug("Input stack does not exists on Cloudformation") - click.echo( - f"Error: The input stack {self.stack_name} does" - f" not exist on Cloudformation in the region {self.region}" - ) + + response = self.stack_exists() + click.echo(json.dumps(response["Stacks"][0]["Outputs"], indent=2)) diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 9ee7ee522dd..86d5ab4bcf9 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -15,6 +15,7 @@ CFN_PYTHON_VERSION_SUFFIX = os.environ.get("PYTHON_VERSION", "0.0.0").replace(".", "-") +@skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") class TestStackOutputs(DeployIntegBase, StackOutputsIntegBase): @classmethod def setUpClass(cls): @@ -34,7 +35,6 @@ def test_stack_outputs_help_message(self): from_help = "".join(HELP_TEXT.split()) self.assertIn(from_help, from_command, "Stack-outputs help text should have been printed") - @skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") def test_stack_output_exists(self): template_path = self.list_test_data_path.joinpath("test_stack_creation_template.yaml") stack_name = self._method_to_stack_name(self.id()) @@ -63,12 +63,12 @@ def test_stack_output_exists(self): }, { "OutputKey": "HelloWorldApi", - "OutputValue": "https://...........execute\-api.us\-east\-1.amazonaws.com/Prod/hello/", + "OutputValue": "https://...........execute.*.amazonaws.com/Prod/hello/", "Description": "API Gateway endpoint URL for Prod stage for Hello World function" }, { "OutputKey": "HelloWorldFunction", - "OutputValue": "arn:aws:lambda:us\-east\-1:............:function:test-stack-output-exists-0-0-0\-HelloWorldFunction\-............", + "OutputValue": "arn:aws:lambda:.*:............:function:test-stack-output-exists-0-0-0\-HelloWorldFunction\-............", "Description": "Hello World Lambda Function ARN" } \] @@ -77,7 +77,6 @@ def test_stack_output_exists(self): ) ) - @skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") def test_stack_no_outputs_exist(self): template_path = self.list_test_data_path.joinpath("test_stack_no_outputs_template.yaml") stack_name = self._method_to_stack_name(self.id()) @@ -103,7 +102,6 @@ def test_stack_no_outputs_exist(self): expected_output, command_result.stderr.decode(), "Should have raised error that outputs do not exist" ) - @skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") def test_stack_does_not_exist(self): template_path = self.list_test_data_path.joinpath("test_stack_no_outputs_template.yaml") stack_name = self._method_to_stack_name(self.id()) @@ -115,7 +113,7 @@ def test_stack_does_not_exist(self): f"Error: The input stack {stack_name} does" f" not exist on Cloudformation in the region {region}" ) self.assertIn( - expected_output, command_result.stdout.decode(), "Should have raised error that outputs do not exist" + expected_output, command_result.stderr.decode(), "Should have raised error that outputs do not exist" ) def _method_to_stack_name(self, method_name): diff --git a/tests/integration/testdata/list/hello_world/app.py b/tests/integration/testdata/list/hello_world/app.py index 5c98a3f615b..093062037aa 100644 --- a/tests/integration/testdata/list/hello_world/app.py +++ b/tests/integration/testdata/list/hello_world/app.py @@ -32,11 +32,11 @@ def lambda_handler(event, context): # print(e) # raise e - personId = event['queryStringParameters']['personId'] + return { "statusCode": 200, "body": json.dumps({ - "personId": personId + " from Lambda", + "message": "hello world", # "location": ip.text.replace("\n", "") }), } diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index 8fdd4e85b9b..fcbb45c763e 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -7,32 +7,10 @@ from samcli.commands.list.stack_outputs.stack_outputs_context import StackOutputsContext from samcli.commands.exceptions import RegionError -from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError +from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError, StackDoesNotExistInRegionError class TestStackOutputsContext(TestCase): - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch.object( - StackOutputsContext, - "stack_exists", - MagicMock(return_value=False), - ) - def test_stack_outputs_stack_does_not_exist(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile=None - ) as stack_output_context: - stack_output_context.run() - - expected_click_echo_calls = [ - call(f"Error: The input stack test does" + f" not exist on Cloudformation in the region us-east-1"), - ] - self.assertEqual( - expected_click_echo_calls, - patched_click_echo.call_args_list, - "The input stack should not exist in the given region", - ) - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") @patch.object( @@ -70,18 +48,11 @@ def test_stack_outputs_stack_exists(self, patched_click_get_current_context, pat MagicMock(return_value=({"Stacks": []})), ) def test_no_stack_object_in_response(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile=None - ) as stack_output_context: - stack_output_context.run() - expected_click_echo_calls = [ - call("Error: The input stack test does not exist on Cloudformation in the region us-east-1") - ] - self.assertEqual( - expected_click_echo_calls, - patched_click_echo.call_args_list, - "Input stack should not exist in the given region", - ) + with self.assertRaises(StackDoesNotExistInRegionError): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile=None + ) as stack_output_context: + stack_output_context.run() @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") @@ -109,17 +80,11 @@ def test_no_output_object_in_response(self, patched_click_get_current_context, p ), ) def test_clienterror_stack_does_not_exist_in_region(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile=None - ) as stack_output_context: - stack_output_context.run() - - expected_click_echo_calls = [ - call(f"Error: The input stack test does" + f" not exist on Cloudformation in the region us-east-1"), - ] - self.assertEqual( - expected_click_echo_calls, patched_click_echo.call_args_list, "The input stack should not exists" - ) + with self.assertRaises(StackDoesNotExistInRegionError): + with StackOutputsContext( + stack_name="test", output="json", region="us-east-1", profile=None + ) as stack_output_context: + stack_output_context.run() @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") @@ -153,59 +118,3 @@ def test_init_clients_has_region(self, patched_click_get_current_context, patche with StackOutputsContext(stack_name="test", output="json", region=None, profile=None) as stack_output_context: stack_output_context.init_clients() self.assertTrue(stack_output_context.region) - - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch.object( - StackOutputsContext, - "stack_exists", - MagicMock(return_value=None), - ) - def test_stack_exists_returns_none(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile=None - ) as stack_output_context: - stack_output_context.run() - expected_click_echo_calls = [ - call( - f"Error: The input stack {stack_output_context.stack_name} does" - f" not exist on Cloudformation in the region {stack_output_context.region}" - ) - ] - self.assertEqual( - expected_click_echo_calls, patched_click_echo.call_args_list, "stack_exists should have returned None" - ) - - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch.object( - StackOutputsContext, - "get_stack_info", - MagicMock( - return_value=( - { - "Stacks": [ - {"Outputs": [{"OutputKey": "HelloWorldTest", "OutputValue": "TestVal", "Description": "Test"}]} - ] - } - ) - ), - ) - @patch.object( - StackOutputsContext, - "stack_exists", - MagicMock(return_value=True), - ) - def test_stack_outputs_stack_exists_returns_true(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext( - stack_name="test", output="json", region="us-east-1", profile=None - ) as stack_output_context: - stack_output_context.run() - expected_click_echo_calls = [ - call( - '[\n {\n "OutputKey": "HelloWorldTest",\n "OutputValue": "TestVal",\n "Description": "Test"\n }\n]' - ) - ] - self.assertEqual( - expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" - ) From ae4e2f006cc612d934d699a457c8ede1cc174b3d Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 15 Jun 2022 23:55:22 -0700 Subject: [PATCH 25/72] Combined get_stack_info and stack_exists, and modified unit tests --- .../stack_outputs/stack_outputs_context.py | 15 +--- .../test_stack_outputs_context.py | 76 ++++++++----------- 2 files changed, 35 insertions(+), 56 deletions(-) diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py index f2919021727..9e38f8daa19 100644 --- a/samcli/commands/list/stack_outputs/stack_outputs_context.py +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -32,17 +32,6 @@ def __exit__(self, *args): pass def get_stack_info(self): - """ - Returns the stack information for the stack passed in from the command line - - Returns - ------- - A dictionary containing the stack's information - """ - cfn_client = self.cloudformation_client - return cfn_client.describe_stacks(StackName=self.stack_name) - - def stack_exists(self): """ Returns the stack output information for the stack and raises exceptions accordingly @@ -52,7 +41,7 @@ def stack_exists(self): """ try: - response = self.get_stack_info() + response = self.cloudformation_client.describe_stacks(StackName=self.stack_name) if not response["Stacks"]: raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) if "Outputs" not in response["Stacks"][0]: @@ -92,5 +81,5 @@ def run(self) -> None: Get the stack outputs for a stack """ - response = self.stack_exists() + response = self.get_stack_info() click.echo(json.dumps(response["Stacks"][0]["Outputs"], indent=2)) diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index fcbb45c763e..a664afd09c8 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -1,5 +1,5 @@ from unittest import TestCase, mock -from unittest.mock import patch, call, MagicMock +from unittest.mock import patch, call, MagicMock, Mock from botocore.exceptions import ClientError, BotoCoreError, WaiterError, EndpointConnectionError import boto3 import os @@ -13,23 +13,17 @@ class TestStackOutputsContext(TestCase): @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch.object( - StackOutputsContext, - "get_stack_info", - MagicMock( - return_value=( - { - "Stacks": [ - {"Outputs": [{"OutputKey": "HelloWorldTest", "OutputValue": "TestVal", "Description": "Test"}]} - ] - } - ) - ), - ) - def test_stack_outputs_stack_exists(self, patched_click_get_current_context, patched_click_echo): + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + def test_stack_outputs_stack_exists( + self, mock_client_provider, patched_click_get_current_context, patched_click_echo + ): + mock_client_provider.return_value.return_value.describe_stacks.return_value = { + "Stacks": [{"Outputs": [{"OutputKey": "HelloWorldTest", "OutputValue": "TestVal", "Description": "Test"}]}] + } with StackOutputsContext( stack_name="test", output="json", region="us-east-1", profile=None ) as stack_output_context: + stack_output_context.run() expected_click_echo_calls = [ call( @@ -42,12 +36,11 @@ def test_stack_outputs_stack_exists(self, patched_click_get_current_context, pat @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch.object( - StackOutputsContext, - "get_stack_info", - MagicMock(return_value=({"Stacks": []})), - ) - def test_no_stack_object_in_response(self, patched_click_get_current_context, patched_click_echo): + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + def test_no_stack_object_in_response( + self, mock_client_provider, patched_click_get_current_context, patched_click_echo + ): + mock_client_provider.return_value.return_value.describe_stacks.return_value = {"Stacks": []} with self.assertRaises(StackDoesNotExistInRegionError): with StackOutputsContext( stack_name="test", output="json", region="us-east-1", profile=None @@ -56,12 +49,11 @@ def test_no_stack_object_in_response(self, patched_click_get_current_context, pa @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch.object( - StackOutputsContext, - "get_stack_info", - MagicMock(return_value=({"Stacks": [{}]})), - ) - def test_no_output_object_in_response(self, patched_click_get_current_context, patched_click_echo): + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + def test_no_output_object_in_response( + self, mock_client_provider, patched_click_get_current_context, patched_click_echo + ): + mock_client_provider.return_value.return_value.describe_stacks.return_value = {"Stacks": [{}]} with self.assertRaises(NoOutputsForStackError): with StackOutputsContext( stack_name="test", output="json", region="us-east-1", profile=None @@ -70,16 +62,13 @@ def test_no_output_object_in_response(self, patched_click_get_current_context, p @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch.object( - StackOutputsContext, - "get_stack_info", - MagicMock( - side_effect=ClientError( - {"Error": {"Code": "ValidationError", "Message": "Stack with id test does not exist"}}, "DescribeStacks" - ) - ), - ) - def test_clienterror_stack_does_not_exist_in_region(self, patched_click_get_current_context, patched_click_echo): + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + def test_clienterror_stack_does_not_exist_in_region( + self, mock_client_provider, patched_click_get_current_context, patched_click_echo + ): + mock_client_provider.return_value.return_value.describe_stacks.side_effect = ClientError( + {"Error": {"Code": "ValidationError", "Message": "Stack with id test does not exist"}}, "DescribeStacks" + ) with self.assertRaises(StackDoesNotExistInRegionError): with StackOutputsContext( stack_name="test", output="json", region="us-east-1", profile=None @@ -88,12 +77,13 @@ def test_clienterror_stack_does_not_exist_in_region(self, patched_click_get_curr @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch.object( - StackOutputsContext, - "get_stack_info", - MagicMock(side_effect=EndpointConnectionError(endpoint_url="https://cloudformation.test.amazonaws.com/")), - ) - def test_botocoreerror_invalid_region(self, patched_click_get_current_context, patched_click_echo): + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + def test_botocoreerror_invalid_region( + self, mock_client_provider, patched_click_get_current_context, patched_click_echo + ): + mock_client_provider.return_value.return_value.describe_stacks.side_effect = EndpointConnectionError( + endpoint_url="https://cloudformation.test.amazonaws.com/" + ) with self.assertRaises(StackOutputsError): with StackOutputsContext( stack_name="test", output="json", region="us-east-1", profile=None From 38fc5f0383bd8f1d2a58f2644cf3222edbf4518b Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 16 Jun 2022 10:22:13 -0700 Subject: [PATCH 26/72] Empty-Commit From 86166a7bd76e8122de85277d0579eb5297740374 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 16 Jun 2022 10:32:06 -0700 Subject: [PATCH 27/72] Empty-Commit From 3c8f6a130b1ccd6cb7f22343c84dc22b9f489a65 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 16 Jun 2022 10:45:25 -0700 Subject: [PATCH 28/72] Empty-Commit From 3bf1d8999531d44a255e777c2bfa0f3c1209b683 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 16 Jun 2022 14:34:35 -0700 Subject: [PATCH 29/72] fixed tests based on comments --- .../test_stack_outputs_command.py | 43 +++++++++---------- .../test_stack_outputs_context.py | 1 - 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 86d5ab4bcf9..8778e33af5a 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -8,7 +8,7 @@ from tests.integration.list.stack_outputs.stack_outputs_integ_base import StackOutputsIntegBase from samcli.commands.list.stack_outputs.cli import HELP_TEXT from tests.testing_utils import RUNNING_ON_CI, RUNNING_TEST_FOR_MASTER_ON_CI, RUN_BY_CANARY -from tests.testing_utils import run_command, run_command_with_input +from tests.testing_utils import run_command, run_command_with_input, method_to_stack_name SKIP_STACK_OUTPUTS_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY CFN_SLEEP = 3 @@ -37,7 +37,7 @@ def test_stack_outputs_help_message(self): def test_stack_output_exists(self): template_path = self.list_test_data_path.joinpath("test_stack_creation_template.yaml") - stack_name = self._method_to_stack_name(self.id()) + stack_name = method_to_stack_name(self.id()) config_file_name = stack_name + ".toml" region = boto3.Session().region_name deploy_command_list = self.get_deploy_command_list( @@ -54,32 +54,33 @@ def test_stack_output_exists(self): cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region) command_result = run_command(cmdlist, cwd=self.working_dir) self.assertTrue( - re.match( - """^\[ - { + re.search( + """{ "OutputKey": "HelloWorldFunctionIamRole", - "OutputValue": "arn:aws:iam::............:role/test-stack-output-exists-0-HelloWorldFunctionRole\-............", + "OutputValue": "arn:aws:iam::.*:role/.*-HelloWorldFunctionRole\-.*", "Description": "Implicit IAM Role created for Hello World function" - }, - { + }""", command_result.stdout.decode()) + ) + self.assertTrue( + re.search( + """{ "OutputKey": "HelloWorldApi", - "OutputValue": "https://...........execute.*.amazonaws.com/Prod/hello/", + "OutputValue": "https://.*execute.*.amazonaws.com/Prod/hello/", "Description": "API Gateway endpoint URL for Prod stage for Hello World function" - }, - { + }""", command_result.stdout.decode()) + ) + self.assertTrue( + re.search( + """{ "OutputKey": "HelloWorldFunction", - "OutputValue": "arn:aws:lambda:.*:............:function:test-stack-output-exists-0-0-0\-HelloWorldFunction\-............", + "OutputValue": "arn:aws:lambda:.*:.*:function:.*-HelloWorldFunction\-.*", "Description": "Hello World Lambda Function ARN" - } -\] -""", - command_result.stdout.decode(), - ) + }""", command_result.stdout.decode()) ) def test_stack_no_outputs_exist(self): template_path = self.list_test_data_path.joinpath("test_stack_no_outputs_template.yaml") - stack_name = self._method_to_stack_name(self.id()) + stack_name = method_to_stack_name(self.id()) config_file_name = stack_name + ".toml" region = boto3.Session().region_name deploy_command_list = self.get_deploy_command_list( @@ -104,7 +105,7 @@ def test_stack_no_outputs_exist(self): def test_stack_does_not_exist(self): template_path = self.list_test_data_path.joinpath("test_stack_no_outputs_template.yaml") - stack_name = self._method_to_stack_name(self.id()) + stack_name = method_to_stack_name(self.id()) config_file_name = stack_name + ".toml" region = boto3.Session().region_name cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region) @@ -116,7 +117,3 @@ def test_stack_does_not_exist(self): expected_output, command_result.stderr.decode(), "Should have raised error that outputs do not exist" ) - def _method_to_stack_name(self, method_name): - """Method expects method name which can be a full path. Eg: test.integration.test_deploy_command.method_name""" - method_name = method_name.split(".")[-1] - return f"{method_name.replace('_', '-')}-{CFN_PYTHON_VERSION_SUFFIX}" diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index a664afd09c8..896f7ed5246 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -88,7 +88,6 @@ def test_botocoreerror_invalid_region( with StackOutputsContext( stack_name="test", output="json", region="us-east-1", profile=None ) as stack_output_context: - # patched_click_echo.raiseError.side_effect = Mock(side_effect=Exception('Test')) stack_output_context.run() @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") From 0b5e40759e669a6c9975e1fd78cc552adfe88a25 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 16 Jun 2022 15:15:07 -0700 Subject: [PATCH 30/72] reformatted file --- .../test_stack_outputs_command.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 8778e33af5a..0b9f1c79ca7 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -55,27 +55,33 @@ def test_stack_output_exists(self): command_result = run_command(cmdlist, cwd=self.working_dir) self.assertTrue( re.search( - """{ + """{ "OutputKey": "HelloWorldFunctionIamRole", "OutputValue": "arn:aws:iam::.*:role/.*-HelloWorldFunctionRole\-.*", "Description": "Implicit IAM Role created for Hello World function" - }""", command_result.stdout.decode()) + }""", + command_result.stdout.decode(), + ) ) self.assertTrue( re.search( - """{ + """{ "OutputKey": "HelloWorldApi", "OutputValue": "https://.*execute.*.amazonaws.com/Prod/hello/", "Description": "API Gateway endpoint URL for Prod stage for Hello World function" - }""", command_result.stdout.decode()) + }""", + command_result.stdout.decode(), + ) ) self.assertTrue( re.search( - """{ + """{ "OutputKey": "HelloWorldFunction", "OutputValue": "arn:aws:lambda:.*:.*:function:.*-HelloWorldFunction\-.*", "Description": "Hello World Lambda Function ARN" - }""", command_result.stdout.decode()) + }""", + command_result.stdout.decode(), + ) ) def test_stack_no_outputs_exist(self): @@ -116,4 +122,3 @@ def test_stack_does_not_exist(self): self.assertIn( expected_output, command_result.stderr.decode(), "Should have raised error that outputs do not exist" ) - From bf8e4f33360315be5c561006e3d8823b6e79e861 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Fri, 17 Jun 2022 11:51:44 -0700 Subject: [PATCH 31/72] Refactored stack outputs command to the producer mapper consumer design pattern --- .../stack_outputs/stack_outputs_context.py | 63 +----------- samcli/lib/list/__init__.py | 0 samcli/lib/list/consumer.py | 13 +++ samcli/lib/list/data_to_json_mapper.py | 12 +++ samcli/lib/list/json_consumer.py | 10 ++ samcli/lib/list/mapper.py | 13 +++ samcli/lib/list/mapper_consumer_container.py | 9 ++ samcli/lib/list/mapper_consumer_factory.py | 16 ++++ .../list/mapper_consumer_factory_interface.py | 10 ++ samcli/lib/list/producer.py | 16 ++++ samcli/lib/list/stack_outputs/__init__.py | 0 .../lib/list/stack_outputs/stack_outputs.py | 16 ++++ .../stack_outputs/stack_outputs_producer.py | 95 +++++++++++++++++++ .../test_stack_outputs_command.py | 30 +++--- .../test_stack_outputs_context.py | 55 ++++++----- 15 files changed, 257 insertions(+), 101 deletions(-) create mode 100644 samcli/lib/list/__init__.py create mode 100644 samcli/lib/list/consumer.py create mode 100644 samcli/lib/list/data_to_json_mapper.py create mode 100644 samcli/lib/list/json_consumer.py create mode 100644 samcli/lib/list/mapper.py create mode 100644 samcli/lib/list/mapper_consumer_container.py create mode 100644 samcli/lib/list/mapper_consumer_factory.py create mode 100644 samcli/lib/list/mapper_consumer_factory_interface.py create mode 100644 samcli/lib/list/producer.py create mode 100644 samcli/lib/list/stack_outputs/__init__.py create mode 100644 samcli/lib/list/stack_outputs/stack_outputs.py create mode 100644 samcli/lib/list/stack_outputs/stack_outputs_producer.py diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py index 9e38f8daa19..ee59eed8b45 100644 --- a/samcli/commands/list/stack_outputs/stack_outputs_context.py +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -2,16 +2,8 @@ Display the Outputs of a SAM stack """ import logging -import json from typing import Optional -import boto3 -import click -from botocore.exceptions import ClientError, BotoCoreError - -from samcli.commands.exceptions import RegionError -from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError, StackDoesNotExistInRegionError -from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config - +from samcli.lib.list.stack_outputs.stack_outputs_producer import StackOutputsProducer LOG = logging.getLogger(__name__) @@ -25,61 +17,16 @@ def __init__(self, stack_name: str, output: str, region: Optional[str], profile: self.cloudformation_client = None def __enter__(self): - self.init_clients() return self def __exit__(self, *args): pass - def get_stack_info(self): - """ - Returns the stack output information for the stack and raises exceptions accordingly - - Returns - ------- - A dictionary containing the stack's information - """ - - try: - response = self.cloudformation_client.describe_stacks(StackName=self.stack_name) - if not response["Stacks"]: - raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) - if "Outputs" not in response["Stacks"][0]: - raise NoOutputsForStackError(stack_name=self.stack_name, region=self.region) - return response - - except ClientError as e: - if "Stack with id {0} does not exist".format(self.stack_name) in str(e): - LOG.debug("Stack with id %s does not exist", self.stack_name) - raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) from e - LOG.error("ClientError Exception : %s", str(e)) - raise StackOutputsError(msg=str(e)) from e - except BotoCoreError as e: - LOG.error("Botocore Exception : %s", str(e)) - raise StackOutputsError(msg=str(e)) from e - - def init_clients(self) -> None: - """ - Initialize the clients being used by sam list. - """ - if not self.region: - session = boto3.Session() - region = session.region_name - if region: - self.region = region - else: - raise RegionError( - message="No region was specified/found. " - "Please provide a region via the --region parameter or by the AWS_REGION environment variable." - ) - - client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) - self.cloudformation_client = client_provider("cloudformation") - def run(self) -> None: """ Get the stack outputs for a stack """ - - response = self.get_stack_info() - click.echo(json.dumps(response["Stacks"][0]["Outputs"], indent=2)) + with StackOutputsProducer( + stack_name=self.stack_name, output=self.output, region=self.region, profile=self.profile + ) as producer: + producer.produce() diff --git a/samcli/lib/list/__init__.py b/samcli/lib/list/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/lib/list/consumer.py b/samcli/lib/list/consumer.py new file mode 100644 index 00000000000..eec5b4ada05 --- /dev/null +++ b/samcli/lib/list/consumer.py @@ -0,0 +1,13 @@ +""" +Interface for Consumers +""" +import abc +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class Consumer(Generic[T]): + @abc.abstractmethod + def consume(self, data: T): + pass diff --git a/samcli/lib/list/data_to_json_mapper.py b/samcli/lib/list/data_to_json_mapper.py new file mode 100644 index 00000000000..71146f01d83 --- /dev/null +++ b/samcli/lib/list/data_to_json_mapper.py @@ -0,0 +1,12 @@ +""" +Implementation of the data to json mapper +""" +from typing import Dict +import json +from samcli.lib.list.mapper import Mapper + + +class DataToJsonMapper(Mapper): + def map(self, data: Dict[str, str]) -> str: + output = json.dumps(data, indent=2) + return output diff --git a/samcli/lib/list/json_consumer.py b/samcli/lib/list/json_consumer.py new file mode 100644 index 00000000000..f9871746637 --- /dev/null +++ b/samcli/lib/list/json_consumer.py @@ -0,0 +1,10 @@ +""" +The json consumer for 'sam list' +""" +import click +from samcli.lib.list.consumer import Consumer + + +class JsonConsumer(Consumer): + def consume(self, data: str) -> None: + click.echo(data) diff --git a/samcli/lib/list/mapper.py b/samcli/lib/list/mapper.py new file mode 100644 index 00000000000..7168dd356a6 --- /dev/null +++ b/samcli/lib/list/mapper.py @@ -0,0 +1,13 @@ +""" +Interface for Mappers +""" +import abc +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class Mapper(Generic[T]): + @abc.abstractmethod + def map(self, data: T): + pass diff --git a/samcli/lib/list/mapper_consumer_container.py b/samcli/lib/list/mapper_consumer_container.py new file mode 100644 index 00000000000..5fa8ea38be9 --- /dev/null +++ b/samcli/lib/list/mapper_consumer_container.py @@ -0,0 +1,9 @@ +""" +Container for a mapper and a consumer +""" + + +class MapperConsumerContainer: + def __init__(self, mapper, consumer): + self.mapper = mapper + self.consumer = consumer diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py new file mode 100644 index 00000000000..2ad23c28bd0 --- /dev/null +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -0,0 +1,16 @@ +""" +The factory for returning the appropriate mapper and consumer +""" +from samcli.lib.list.mapper_consumer_factory_interface import MapperConsumerFactoryInterface +from samcli.lib.list.data_to_json_mapper import DataToJsonMapper +from samcli.lib.list.json_consumer import JsonConsumer +from samcli.lib.list.mapper_consumer_container import MapperConsumerContainer + + +class MapperConsumerFactory(MapperConsumerFactoryInterface): + def create(self, producer, output): + # Will add conditions here to return different sorts of containers later on + new_data_to_json_mapper = DataToJsonMapper() + new_json_consumer = JsonConsumer() + new_container = MapperConsumerContainer(mapper=new_data_to_json_mapper, consumer=new_json_consumer) + return new_container diff --git a/samcli/lib/list/mapper_consumer_factory_interface.py b/samcli/lib/list/mapper_consumer_factory_interface.py new file mode 100644 index 00000000000..116152c4c8e --- /dev/null +++ b/samcli/lib/list/mapper_consumer_factory_interface.py @@ -0,0 +1,10 @@ +""" +Interface for MapperConsumerFactory +""" +import abc + + +class MapperConsumerFactoryInterface: + @abc.abstractmethod + def create(self, producer, output): + pass diff --git a/samcli/lib/list/producer.py b/samcli/lib/list/producer.py new file mode 100644 index 00000000000..bf34a7ee57b --- /dev/null +++ b/samcli/lib/list/producer.py @@ -0,0 +1,16 @@ +""" +Interface for Producers +""" +import abc + +from samcli.lib.list.consumer import Consumer +from samcli.lib.list.mapper import Mapper + + +class Producer: + mapper: Mapper + consumer: Consumer + + @abc.abstractmethod + def produce(self): + pass diff --git a/samcli/lib/list/stack_outputs/__init__.py b/samcli/lib/list/stack_outputs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/lib/list/stack_outputs/stack_outputs.py b/samcli/lib/list/stack_outputs/stack_outputs.py new file mode 100644 index 00000000000..2844456acd7 --- /dev/null +++ b/samcli/lib/list/stack_outputs/stack_outputs.py @@ -0,0 +1,16 @@ +""" +The container for stack outputs +""" +from dataclasses import dataclass + + +@dataclass +class StackOutputs: + OutputKey: str + OutputValue: str + Description: str + + def __init__(self, OutputKey, OutputValue, Description): + self.OutputKey = OutputKey + self.OutputValue = OutputValue + self.Description = Description diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py new file mode 100644 index 00000000000..dbfe4f999f3 --- /dev/null +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -0,0 +1,95 @@ +""" +The producer for the 'sam list stack-outputs' command +""" +import dataclasses +import logging +import boto3 +from botocore.exceptions import ClientError, BotoCoreError +from samcli.commands.exceptions import RegionError +from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError, StackDoesNotExistInRegionError + +from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config +from samcli.lib.list.producer import Producer +from samcli.lib.list.stack_outputs.stack_outputs import StackOutputs +from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory + +LOG = logging.getLogger(__name__) + + +class StackOutputsProducer(Producer): + def __init__(self, stack_name, output, region, profile): + self.stack_name = stack_name + self.output = output + self.region = region + self.profile = profile + self.cloudformation_client = None + self.mapper = None + self.consumer = None + self.factory = None + + def __enter__(self): + self.init_clients() + self.factory = MapperConsumerFactory() + return self + + def __exit__(self, *args): + pass + + def get_stack_info(self): + """ + Returns the stack output information for the stack and raises exceptions accordingly + + Returns + ------- + A dictionary containing the stack's information + """ + + try: + response = self.cloudformation_client.describe_stacks(StackName=self.stack_name) + if not response["Stacks"]: + raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) + if "Outputs" not in response["Stacks"][0]: + raise NoOutputsForStackError(stack_name=self.stack_name, region=self.region) + return response + + except ClientError as e: + if "Stack with id {0} does not exist".format(self.stack_name) in str(e): + LOG.debug("Stack with id %s does not exist", self.stack_name) + raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) from e + LOG.error("ClientError Exception : %s", str(e)) + raise StackOutputsError(msg=str(e)) from e + except BotoCoreError as e: + LOG.error("Botocore Exception : %s", str(e)) + raise StackOutputsError(msg=str(e)) from e + + def init_clients(self) -> None: + """ + Initialize the clients being used by sam list. + """ + if not self.region: + session = boto3.Session() + region = session.region_name + if region: + self.region = region + else: + raise RegionError( + message="No region was specified/found. " + "Please provide a region via the --region parameter or by the AWS_REGION environment variable." + ) + + client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) + self.cloudformation_client = client_provider("cloudformation") + + def produce(self): + new_container = self.factory.create(producer="stackoutputsproducer", output=self.output) + self.mapper = new_container.mapper + self.consumer = new_container.consumer + response = self.get_stack_info() + for stack_output in response["Stacks"][0]["Outputs"]: + stack_output_data = StackOutputs( + OutputKey=stack_output["OutputKey"], + OutputValue=stack_output["OutputValue"], + Description=stack_output["Description"], + ) + mapped_output = self.mapper.map(dataclasses.asdict(stack_output_data)) + self.consumer.consume(mapped_output) diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 0b9f1c79ca7..4043a590f49 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -51,35 +51,35 @@ def test_stack_output_exists(self): deploy_process_execute = run_command_with_input( deploy_command_list, "{}\n{}\nY\nY\nY\nY\nY\n\n\nY\n".format(stack_name, region).encode() ) - cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region) + cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region, output="json") command_result = run_command(cmdlist, cwd=self.working_dir) self.assertTrue( re.search( """{ - "OutputKey": "HelloWorldFunctionIamRole", - "OutputValue": "arn:aws:iam::.*:role/.*-HelloWorldFunctionRole\-.*", - "Description": "Implicit IAM Role created for Hello World function" - }""", + "OutputKey": "HelloWorldFunctionIamRole", + "OutputValue": "arn:aws:iam::.*:role/.*-HelloWorldFunctionRole\-.*", + "Description": "Implicit IAM Role created for Hello World function" +}""", command_result.stdout.decode(), ) ) self.assertTrue( re.search( """{ - "OutputKey": "HelloWorldApi", - "OutputValue": "https://.*execute.*.amazonaws.com/Prod/hello/", - "Description": "API Gateway endpoint URL for Prod stage for Hello World function" - }""", + "OutputKey": "HelloWorldApi", + "OutputValue": "https://.*execute.*.amazonaws.com/Prod/hello/", + "Description": "API Gateway endpoint URL for Prod stage for Hello World function" +}""", command_result.stdout.decode(), ) ) self.assertTrue( re.search( """{ - "OutputKey": "HelloWorldFunction", - "OutputValue": "arn:aws:lambda:.*:.*:function:.*-HelloWorldFunction\-.*", - "Description": "Hello World Lambda Function ARN" - }""", + "OutputKey": "HelloWorldFunction", + "OutputValue": "arn:aws:lambda:.*:.*:function:.*-HelloWorldFunction\-.*", + "Description": "Hello World Lambda Function ARN" +}""", command_result.stdout.decode(), ) ) @@ -100,7 +100,7 @@ def test_stack_no_outputs_exist(self): deploy_process_execute = run_command_with_input( deploy_command_list, "{}\n{}\nY\nY\nY\nY\nY\n\n\nY\n".format(stack_name, region).encode() ) - cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region) + cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region, output="json") command_result = run_command(cmdlist, cwd=self.working_dir) expected_output = ( f"Error: Outputs do not exist for the input stack {stack_name}" f" on Cloudformation in the region {region}" @@ -114,7 +114,7 @@ def test_stack_does_not_exist(self): stack_name = method_to_stack_name(self.id()) config_file_name = stack_name + ".toml" region = boto3.Session().region_name - cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region) + cmdlist = self.get_stack_outputs_command_list(stack_name=stack_name, region=region, output="json") command_result = run_command(cmdlist, cwd=self.working_dir) expected_output = ( f"Error: The input stack {stack_name} does" f" not exist on Cloudformation in the region {region}" diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index 896f7ed5246..da5bd415330 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -6,14 +6,15 @@ import click from samcli.commands.list.stack_outputs.stack_outputs_context import StackOutputsContext +from samcli.lib.list.stack_outputs.stack_outputs_producer import StackOutputsProducer from samcli.commands.exceptions import RegionError from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError, StackDoesNotExistInRegionError class TestStackOutputsContext(TestCase): - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.lib.list.json_consumer.click.echo") + @patch("samcli.lib.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") def test_stack_outputs_stack_exists( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -26,17 +27,15 @@ def test_stack_outputs_stack_exists( stack_output_context.run() expected_click_echo_calls = [ - call( - '[\n {\n "OutputKey": "HelloWorldTest",\n "OutputValue": "TestVal",\n "Description": "Test"\n }\n]' - ) + call('{\n "OutputKey": "HelloWorldTest",\n "OutputValue": "TestVal",\n "Description": "Test"\n}') ] self.assertEqual( expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" ) - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.lib.list.json_consumer.click.echo") + @patch("samcli.lib.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") def test_no_stack_object_in_response( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -47,9 +46,9 @@ def test_no_stack_object_in_response( ) as stack_output_context: stack_output_context.run() - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.lib.list.json_consumer.click.echo") + @patch("samcli.lib.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") def test_no_output_object_in_response( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -60,9 +59,9 @@ def test_no_output_object_in_response( ) as stack_output_context: stack_output_context.run() - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.lib.list.json_consumer.click.echo") + @patch("samcli.lib.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") def test_clienterror_stack_does_not_exist_in_region( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -75,9 +74,9 @@ def test_clienterror_stack_does_not_exist_in_region( ) as stack_output_context: stack_output_context.run() - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.lib.list.json_consumer.click.echo") + @patch("samcli.lib.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") def test_botocoreerror_invalid_region( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -90,20 +89,20 @@ def test_botocoreerror_invalid_region( ) as stack_output_context: stack_output_context.run() - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch("samcli.lib.list.json_consumer.click.echo") + @patch("samcli.lib.list.json_consumer.click.get_current_context") @patch("boto3.Session.region_name", None) def test_init_clients_no_region(self, patched_click_get_current_context, patched_click_echo): with self.assertRaises(RegionError): - with StackOutputsContext( + with StackOutputsProducer( stack_name="test", output="json", region=None, profile=None - ) as stack_output_context: - stack_output_context.init_clients() + ) as stack_output_producer: + stack_output_producer.init_clients() - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.echo") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.click.get_current_context") + @patch("samcli.lib.list.json_consumer.click.echo") + @patch("samcli.lib.list.json_consumer.click.get_current_context") @patch("boto3.Session.region_name", "us-east-1") def test_init_clients_has_region(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsContext(stack_name="test", output="json", region=None, profile=None) as stack_output_context: - stack_output_context.init_clients() - self.assertTrue(stack_output_context.region) + with StackOutputsProducer(stack_name="test", output="json", region=None, profile=None) as stack_output_producer: + stack_output_producer.init_clients() + self.assertEqual(stack_output_producer.region, "us-east-1") From 435abd7058f55a1fd17bdbe238bf9034f769f816 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Fri, 17 Jun 2022 12:20:13 -0700 Subject: [PATCH 32/72] Fixed formatting --- .../commands/list/stack_outputs/stack_outputs_context.py | 1 - .../list/stack_outputs/test_stack_outputs_command.py | 9 ++++++--- .../list/stack_outputs/test_stack_outputs_context.py | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py index 1722dfdec97..ee59eed8b45 100644 --- a/samcli/commands/list/stack_outputs/stack_outputs_context.py +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -30,4 +30,3 @@ def run(self) -> None: stack_name=self.stack_name, output=self.output, region=self.region, profile=self.profile ) as producer: producer.produce() - diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 17e6c630211..4043a590f49 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -59,7 +59,8 @@ def test_stack_output_exists(self): "OutputKey": "HelloWorldFunctionIamRole", "OutputValue": "arn:aws:iam::.*:role/.*-HelloWorldFunctionRole\-.*", "Description": "Implicit IAM Role created for Hello World function" -}""", command_result.stdout.decode(), +}""", + command_result.stdout.decode(), ) ) self.assertTrue( @@ -68,7 +69,8 @@ def test_stack_output_exists(self): "OutputKey": "HelloWorldApi", "OutputValue": "https://.*execute.*.amazonaws.com/Prod/hello/", "Description": "API Gateway endpoint URL for Prod stage for Hello World function" -}""", command_result.stdout.decode(), +}""", + command_result.stdout.decode(), ) ) self.assertTrue( @@ -77,7 +79,8 @@ def test_stack_output_exists(self): "OutputKey": "HelloWorldFunction", "OutputValue": "arn:aws:lambda:.*:.*:function:.*-HelloWorldFunction\-.*", "Description": "Hello World Lambda Function ARN" -}""", command_result.stdout.decode(), +}""", + command_result.stdout.decode(), ) ) diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index 2f3d41aca76..da5bd415330 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -106,4 +106,3 @@ def test_init_clients_has_region(self, patched_click_get_current_context, patche with StackOutputsProducer(stack_name="test", output="json", region=None, profile=None) as stack_output_producer: stack_output_producer.init_clients() self.assertEqual(stack_output_producer.region, "us-east-1") - From 7d0c72140530b74bf192073440728e9e9a2c68a0 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Tue, 21 Jun 2022 14:02:31 -0700 Subject: [PATCH 33/72] Moved interfaces, made changes based on comments --- samcli/commands/list/exceptions.py | 2 +- .../{lib => commands}/list/json_consumer.py | 4 +- .../stack_outputs/stack_outputs_context.py | 34 +++++++- samcli/lib/list/consumer.py | 13 --- samcli/lib/list/data_to_json_mapper.py | 2 +- samcli/lib/list/list_interfaces.py | 80 +++++++++++++++++++ samcli/lib/list/mapper.py | 13 --- samcli/lib/list/mapper_consumer_factory.py | 12 +-- .../list/mapper_consumer_factory_interface.py | 10 --- samcli/lib/list/producer.py | 16 ---- .../stack_outputs/stack_outputs_producer.py | 43 ++-------- .../test_stack_outputs_context.py | 61 +++++++------- 12 files changed, 160 insertions(+), 130 deletions(-) rename samcli/{lib => commands}/list/json_consumer.py (52%) delete mode 100644 samcli/lib/list/consumer.py create mode 100644 samcli/lib/list/list_interfaces.py delete mode 100644 samcli/lib/list/mapper.py delete mode 100644 samcli/lib/list/mapper_consumer_factory_interface.py delete mode 100644 samcli/lib/list/producer.py diff --git a/samcli/commands/list/exceptions.py b/samcli/commands/list/exceptions.py index e9cc93915a6..0d643c5ee2b 100644 --- a/samcli/commands/list/exceptions.py +++ b/samcli/commands/list/exceptions.py @@ -6,7 +6,7 @@ from samcli.commands.exceptions import UserException -class StackOutputsError(UserException): +class SamListError(UserException): def __init__(self, msg): self.msg = msg diff --git a/samcli/lib/list/json_consumer.py b/samcli/commands/list/json_consumer.py similarity index 52% rename from samcli/lib/list/json_consumer.py rename to samcli/commands/list/json_consumer.py index f9871746637..2fd304bd873 100644 --- a/samcli/lib/list/json_consumer.py +++ b/samcli/commands/list/json_consumer.py @@ -2,9 +2,9 @@ The json consumer for 'sam list' """ import click -from samcli.lib.list.consumer import Consumer +from samcli.lib.list.list_interfaces import ListInfoPullerConsumer -class JsonConsumer(Consumer): +class JsonConsumer(ListInfoPullerConsumer): def consume(self, data: str) -> None: click.echo(data) diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py index ee59eed8b45..a19e437876b 100644 --- a/samcli/commands/list/stack_outputs/stack_outputs_context.py +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -3,7 +3,10 @@ """ import logging from typing import Optional +import boto3 from samcli.lib.list.stack_outputs.stack_outputs_producer import StackOutputsProducer +from samcli.commands.exceptions import RegionError +from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config LOG = logging.getLogger(__name__) @@ -17,16 +20,39 @@ def __init__(self, stack_name: str, output: str, region: Optional[str], profile: self.cloudformation_client = None def __enter__(self): + self.init_clients() return self def __exit__(self, *args): pass + def init_clients(self) -> None: + """ + Initialize the clients being used by sam list. + """ + if not self.region: + session = boto3.Session() + region = session.region_name + if region: + self.region = region + else: + raise RegionError( + message="No region was specified/found. " + "Please provide a region via the --region parameter or by the AWS_REGION environment variable." + ) + + client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) + self.cloudformation_client = client_provider("cloudformation") + def run(self) -> None: """ Get the stack outputs for a stack """ - with StackOutputsProducer( - stack_name=self.stack_name, output=self.output, region=self.region, profile=self.profile - ) as producer: - producer.produce() + producer = StackOutputsProducer( + stack_name=self.stack_name, + output=self.output, + region=self.region, + profile=self.profile, + cloudformation_client=self.cloudformation_client, + ) + producer.produce() diff --git a/samcli/lib/list/consumer.py b/samcli/lib/list/consumer.py deleted file mode 100644 index eec5b4ada05..00000000000 --- a/samcli/lib/list/consumer.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Interface for Consumers -""" -import abc -from typing import Generic, TypeVar - -T = TypeVar("T") - - -class Consumer(Generic[T]): - @abc.abstractmethod - def consume(self, data: T): - pass diff --git a/samcli/lib/list/data_to_json_mapper.py b/samcli/lib/list/data_to_json_mapper.py index 71146f01d83..920c82f582f 100644 --- a/samcli/lib/list/data_to_json_mapper.py +++ b/samcli/lib/list/data_to_json_mapper.py @@ -3,7 +3,7 @@ """ from typing import Dict import json -from samcli.lib.list.mapper import Mapper +from samcli.lib.list.list_interfaces import Mapper class DataToJsonMapper(Mapper): diff --git a/samcli/lib/list/list_interfaces.py b/samcli/lib/list/list_interfaces.py new file mode 100644 index 00000000000..a775586ebfd --- /dev/null +++ b/samcli/lib/list/list_interfaces.py @@ -0,0 +1,80 @@ +""" +Interface for MapperConsumerFactory, Producer, Mapper, ListInfoPullerConsumer +""" +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +InputType = TypeVar("InputType") +OutputType = TypeVar("OutputType") + + +class ListInfoPullerConsumer(ABC, Generic[InputType]): + """ + Interface definition to consume and display data + """ + + @abstractmethod + def consume(self, data: InputType): + """ + Parameters + ---------- + data: TypeVar + Data for the consumer to print + """ + + +class Mapper(ABC, Generic[InputType, OutputType]): + """ + Interface definition to map data to json or table + """ + + @abstractmethod + def map(self, data: InputType) -> OutputType: + """ + Parameters + ---------- + data: TypeVar + Data for the mapper to map + + Returns + ------- + Any + Mapped output given the data + """ + + +class Producer(ABC): + """ + Interface definition to produce data for the mappers and consumers + """ + + mapper: Mapper + consumer: ListInfoPullerConsumer + + @abstractmethod + def produce(self): + """ + Produces the data for the mappers and consumers + """ + + +class MapperConsumerFactoryInterface(ABC): + """ + Interface definition to create mapper-consumer factories + """ + + @abstractmethod + def create(self, producer, output): + """ + Parameters + ---------- + producer: str + A string indicating which producer is calling the function + output: str + A string indicating the output type + + Returns + ------- + MapperConsumerContainer + A container that contains a mapper and a consumer + """ diff --git a/samcli/lib/list/mapper.py b/samcli/lib/list/mapper.py deleted file mode 100644 index 7168dd356a6..00000000000 --- a/samcli/lib/list/mapper.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Interface for Mappers -""" -import abc -from typing import Generic, TypeVar - -T = TypeVar("T") - - -class Mapper(Generic[T]): - @abc.abstractmethod - def map(self, data: T): - pass diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py index 2ad23c28bd0..5fa56f42bd2 100644 --- a/samcli/lib/list/mapper_consumer_factory.py +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -1,16 +1,16 @@ """ The factory for returning the appropriate mapper and consumer """ -from samcli.lib.list.mapper_consumer_factory_interface import MapperConsumerFactoryInterface +from samcli.lib.list.list_interfaces import MapperConsumerFactoryInterface from samcli.lib.list.data_to_json_mapper import DataToJsonMapper -from samcli.lib.list.json_consumer import JsonConsumer +from samcli.commands.list.json_consumer import JsonConsumer from samcli.lib.list.mapper_consumer_container import MapperConsumerContainer class MapperConsumerFactory(MapperConsumerFactoryInterface): def create(self, producer, output): # Will add conditions here to return different sorts of containers later on - new_data_to_json_mapper = DataToJsonMapper() - new_json_consumer = JsonConsumer() - new_container = MapperConsumerContainer(mapper=new_data_to_json_mapper, consumer=new_json_consumer) - return new_container + data_to_json_mapper = DataToJsonMapper() + json_consumer = JsonConsumer() + container = MapperConsumerContainer(mapper=data_to_json_mapper, consumer=json_consumer) + return container diff --git a/samcli/lib/list/mapper_consumer_factory_interface.py b/samcli/lib/list/mapper_consumer_factory_interface.py deleted file mode 100644 index 116152c4c8e..00000000000 --- a/samcli/lib/list/mapper_consumer_factory_interface.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Interface for MapperConsumerFactory -""" -import abc - - -class MapperConsumerFactoryInterface: - @abc.abstractmethod - def create(self, producer, output): - pass diff --git a/samcli/lib/list/producer.py b/samcli/lib/list/producer.py deleted file mode 100644 index bf34a7ee57b..00000000000 --- a/samcli/lib/list/producer.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Interface for Producers -""" -import abc - -from samcli.lib.list.consumer import Consumer -from samcli.lib.list.mapper import Mapper - - -class Producer: - mapper: Mapper - consumer: Consumer - - @abc.abstractmethod - def produce(self): - pass diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index dbfe4f999f3..635d1076771 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -3,13 +3,11 @@ """ import dataclasses import logging -import boto3 + from botocore.exceptions import ClientError, BotoCoreError -from samcli.commands.exceptions import RegionError -from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError, StackDoesNotExistInRegionError +from samcli.commands.list.exceptions import SamListError, NoOutputsForStackError, StackDoesNotExistInRegionError -from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config -from samcli.lib.list.producer import Producer +from samcli.lib.list.list_interfaces import Producer from samcli.lib.list.stack_outputs.stack_outputs import StackOutputs from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory @@ -17,24 +15,16 @@ class StackOutputsProducer(Producer): - def __init__(self, stack_name, output, region, profile): + def __init__(self, stack_name, output, region, profile, cloudformation_client): self.stack_name = stack_name self.output = output self.region = region self.profile = profile - self.cloudformation_client = None + self.cloudformation_client = cloudformation_client self.mapper = None self.consumer = None self.factory = None - def __enter__(self): - self.init_clients() - self.factory = MapperConsumerFactory() - return self - - def __exit__(self, *args): - pass - def get_stack_info(self): """ Returns the stack output information for the stack and raises exceptions accordingly @@ -57,30 +47,13 @@ def get_stack_info(self): LOG.debug("Stack with id %s does not exist", self.stack_name) raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) from e LOG.error("ClientError Exception : %s", str(e)) - raise StackOutputsError(msg=str(e)) from e + raise SamListError(msg=str(e)) from e except BotoCoreError as e: LOG.error("Botocore Exception : %s", str(e)) - raise StackOutputsError(msg=str(e)) from e - - def init_clients(self) -> None: - """ - Initialize the clients being used by sam list. - """ - if not self.region: - session = boto3.Session() - region = session.region_name - if region: - self.region = region - else: - raise RegionError( - message="No region was specified/found. " - "Please provide a region via the --region parameter or by the AWS_REGION environment variable." - ) - - client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) - self.cloudformation_client = client_provider("cloudformation") + raise SamListError(msg=str(e)) from e def produce(self): + self.factory = MapperConsumerFactory() new_container = self.factory.create(producer="stackoutputsproducer", output=self.output) self.mapper = new_container.mapper self.consumer = new_container.consumer diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index da5bd415330..ef388dfebba 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -1,20 +1,18 @@ from unittest import TestCase, mock from unittest.mock import patch, call, MagicMock, Mock from botocore.exceptions import ClientError, BotoCoreError, WaiterError, EndpointConnectionError -import boto3 -import os import click from samcli.commands.list.stack_outputs.stack_outputs_context import StackOutputsContext from samcli.lib.list.stack_outputs.stack_outputs_producer import StackOutputsProducer from samcli.commands.exceptions import RegionError -from samcli.commands.list.exceptions import StackOutputsError, NoOutputsForStackError, StackDoesNotExistInRegionError +from samcli.commands.list.exceptions import SamListError, NoOutputsForStackError, StackDoesNotExistInRegionError class TestStackOutputsContext(TestCase): - @patch("samcli.lib.list.json_consumer.click.echo") - @patch("samcli.lib.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") def test_stack_outputs_stack_exists( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -33,9 +31,9 @@ def test_stack_outputs_stack_exists( expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" ) - @patch("samcli.lib.list.json_consumer.click.echo") - @patch("samcli.lib.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") def test_no_stack_object_in_response( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -46,9 +44,9 @@ def test_no_stack_object_in_response( ) as stack_output_context: stack_output_context.run() - @patch("samcli.lib.list.json_consumer.click.echo") - @patch("samcli.lib.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") def test_no_output_object_in_response( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -59,9 +57,9 @@ def test_no_output_object_in_response( ) as stack_output_context: stack_output_context.run() - @patch("samcli.lib.list.json_consumer.click.echo") - @patch("samcli.lib.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") def test_clienterror_stack_does_not_exist_in_region( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -74,35 +72,40 @@ def test_clienterror_stack_does_not_exist_in_region( ) as stack_output_context: stack_output_context.run() - @patch("samcli.lib.list.json_consumer.click.echo") - @patch("samcli.lib.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.stack_outputs.stack_outputs_producer.get_boto_client_provider_with_config") + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") def test_botocoreerror_invalid_region( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): mock_client_provider.return_value.return_value.describe_stacks.side_effect = EndpointConnectionError( endpoint_url="https://cloudformation.test.amazonaws.com/" ) - with self.assertRaises(StackOutputsError): + with self.assertRaises(SamListError): with StackOutputsContext( stack_name="test", output="json", region="us-east-1", profile=None ) as stack_output_context: stack_output_context.run() - @patch("samcli.lib.list.json_consumer.click.echo") - @patch("samcli.lib.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") @patch("boto3.Session.region_name", None) def test_init_clients_no_region(self, patched_click_get_current_context, patched_click_echo): with self.assertRaises(RegionError): - with StackOutputsProducer( + with StackOutputsContext( stack_name="test", output="json", region=None, profile=None - ) as stack_output_producer: - stack_output_producer.init_clients() + ) as stack_output_context: + stack_output_context.init_clients() - @patch("samcli.lib.list.json_consumer.click.echo") - @patch("samcli.lib.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") @patch("boto3.Session.region_name", "us-east-1") def test_init_clients_has_region(self, patched_click_get_current_context, patched_click_echo): - with StackOutputsProducer(stack_name="test", output="json", region=None, profile=None) as stack_output_producer: - stack_output_producer.init_clients() - self.assertEqual(stack_output_producer.region, "us-east-1") + with StackOutputsContext( + stack_name="test", + output="json", + region=None, + profile=None, + ) as stack_output_context: + stack_output_context.init_clients() + self.assertEqual(stack_output_context.region, "us-east-1") From 68d5a025ee557b1fa3c2375f16d673126c1e378f Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 23 Jun 2022 15:07:36 -0700 Subject: [PATCH 34/72] Made fixes based on comments --- samcli/commands/list/exceptions.py | 4 ++++ samcli/commands/list/json_consumer.py | 4 ++++ .../stack_outputs/stack_outputs_context.py | 8 ++++++- samcli/lib/list/list_interfaces.py | 7 +++++++ samcli/lib/list/mapper_consumer_factory.py | 2 +- .../lib/list/stack_outputs/stack_outputs.py | 5 ----- .../stack_outputs/stack_outputs_producer.py | 21 +++++++------------ 7 files changed, 30 insertions(+), 21 deletions(-) diff --git a/samcli/commands/list/exceptions.py b/samcli/commands/list/exceptions.py index 0d643c5ee2b..b306f86e21a 100644 --- a/samcli/commands/list/exceptions.py +++ b/samcli/commands/list/exceptions.py @@ -7,6 +7,10 @@ class SamListError(UserException): + """ + Base exception for the 'sam list' command + """ + def __init__(self, msg): self.msg = msg diff --git a/samcli/commands/list/json_consumer.py b/samcli/commands/list/json_consumer.py index 2fd304bd873..b197096c3eb 100644 --- a/samcli/commands/list/json_consumer.py +++ b/samcli/commands/list/json_consumer.py @@ -6,5 +6,9 @@ class JsonConsumer(ListInfoPullerConsumer): + """ + Consumes string data and outputs it in json format + """ + def consume(self, data: str) -> None: click.echo(data) diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py index a19e437876b..9742aa5d338 100644 --- a/samcli/commands/list/stack_outputs/stack_outputs_context.py +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -7,6 +7,8 @@ from samcli.lib.list.stack_outputs.stack_outputs_producer import StackOutputsProducer from samcli.commands.exceptions import RegionError from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config +from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory +from samcli.lib.list.list_interfaces import ProducersEnum LOG = logging.getLogger(__name__) @@ -48,11 +50,15 @@ def run(self) -> None: """ Get the stack outputs for a stack """ + factory = MapperConsumerFactory() + container = factory.create(producer=ProducersEnum.STACK_OUTPUTS_PRODUCER, output=self.output) + producer = StackOutputsProducer( stack_name=self.stack_name, output=self.output, region=self.region, - profile=self.profile, cloudformation_client=self.cloudformation_client, + mapper=container.mapper, + consumer=container.consumer, ) producer.produce() diff --git a/samcli/lib/list/list_interfaces.py b/samcli/lib/list/list_interfaces.py index a775586ebfd..bf17aa3077e 100644 --- a/samcli/lib/list/list_interfaces.py +++ b/samcli/lib/list/list_interfaces.py @@ -3,6 +3,7 @@ """ from abc import ABC, abstractmethod from typing import Generic, TypeVar +from enum import Enum InputType = TypeVar("InputType") OutputType = TypeVar("OutputType") @@ -78,3 +79,9 @@ def create(self, producer, output): MapperConsumerContainer A container that contains a mapper and a consumer """ + + +class ProducersEnum(Enum): + STACK_OUTPUTS_PRODUCER = 1 + RESOURCES_PRODUCER = 2 + TESTABLE_RESOURCES_PRODUCER = 3 diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py index 5fa56f42bd2..570d98b9e5e 100644 --- a/samcli/lib/list/mapper_consumer_factory.py +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -12,5 +12,5 @@ def create(self, producer, output): # Will add conditions here to return different sorts of containers later on data_to_json_mapper = DataToJsonMapper() json_consumer = JsonConsumer() - container = MapperConsumerContainer(mapper=data_to_json_mapper, consumer=json_consumer) + container = MapperConsumerContainer(data_to_json_mapper, json_consumer) return container diff --git a/samcli/lib/list/stack_outputs/stack_outputs.py b/samcli/lib/list/stack_outputs/stack_outputs.py index 2844456acd7..292da23b481 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs.py +++ b/samcli/lib/list/stack_outputs/stack_outputs.py @@ -9,8 +9,3 @@ class StackOutputs: OutputKey: str OutputValue: str Description: str - - def __init__(self, OutputKey, OutputValue, Description): - self.OutputKey = OutputKey - self.OutputValue = OutputValue - self.Description = Description diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index 635d1076771..9554aa8c837 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -9,21 +9,18 @@ from samcli.lib.list.list_interfaces import Producer from samcli.lib.list.stack_outputs.stack_outputs import StackOutputs -from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory LOG = logging.getLogger(__name__) class StackOutputsProducer(Producer): - def __init__(self, stack_name, output, region, profile, cloudformation_client): + def __init__(self, stack_name, output, region, cloudformation_client, mapper, consumer): self.stack_name = stack_name self.output = output self.region = region - self.profile = profile self.cloudformation_client = cloudformation_client - self.mapper = None - self.consumer = None - self.factory = None + self.mapper = mapper + self.consumer = consumer def get_stack_info(self): """ @@ -36,11 +33,11 @@ def get_stack_info(self): try: response = self.cloudformation_client.describe_stacks(StackName=self.stack_name) - if not response["Stacks"]: + if not response.get("Stacks", []): raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) - if "Outputs" not in response["Stacks"][0]: + if len(response.get("Stacks", [])) > 0 and "Outputs" not in response.get("Stacks", [])[0]: raise NoOutputsForStackError(stack_name=self.stack_name, region=self.region) - return response + return response["Stacks"][0]["Outputs"] except ClientError as e: if "Stack with id {0} does not exist".format(self.stack_name) in str(e): @@ -53,12 +50,8 @@ def get_stack_info(self): raise SamListError(msg=str(e)) from e def produce(self): - self.factory = MapperConsumerFactory() - new_container = self.factory.create(producer="stackoutputsproducer", output=self.output) - self.mapper = new_container.mapper - self.consumer = new_container.consumer response = self.get_stack_info() - for stack_output in response["Stacks"][0]["Outputs"]: + for stack_output in response: stack_output_data = StackOutputs( OutputKey=stack_output["OutputKey"], OutputValue=stack_output["OutputValue"], From 57c199eeed812d025eaef270576a90fdd5148d05 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 27 Jun 2022 14:17:47 -0700 Subject: [PATCH 35/72] Made fixes based on comments --- samcli/commands/list/json_consumer.py | 2 +- samcli/lib/list/mapper_consumer_container.py | 8 +++++--- samcli/lib/list/mapper_consumer_factory.py | 4 ++-- .../list/stack_outputs/test_stack_outputs_context.py | 8 +++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/samcli/commands/list/json_consumer.py b/samcli/commands/list/json_consumer.py index b197096c3eb..7fc3c61b5cb 100644 --- a/samcli/commands/list/json_consumer.py +++ b/samcli/commands/list/json_consumer.py @@ -5,7 +5,7 @@ from samcli.lib.list.list_interfaces import ListInfoPullerConsumer -class JsonConsumer(ListInfoPullerConsumer): +class StringConsumerJsonOutput(ListInfoPullerConsumer): """ Consumes string data and outputs it in json format """ diff --git a/samcli/lib/list/mapper_consumer_container.py b/samcli/lib/list/mapper_consumer_container.py index 5fa8ea38be9..6be090c9cde 100644 --- a/samcli/lib/list/mapper_consumer_container.py +++ b/samcli/lib/list/mapper_consumer_container.py @@ -1,9 +1,11 @@ """ Container for a mapper and a consumer """ +from dataclasses import dataclass +from samcli.lib.list.list_interfaces import ListInfoPullerConsumer, Mapper +@dataclass class MapperConsumerContainer: - def __init__(self, mapper, consumer): - self.mapper = mapper - self.consumer = consumer + mapper: Mapper + consumer: ListInfoPullerConsumer diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py index 570d98b9e5e..97fbfaa0d89 100644 --- a/samcli/lib/list/mapper_consumer_factory.py +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -3,7 +3,7 @@ """ from samcli.lib.list.list_interfaces import MapperConsumerFactoryInterface from samcli.lib.list.data_to_json_mapper import DataToJsonMapper -from samcli.commands.list.json_consumer import JsonConsumer +from samcli.commands.list.json_consumer import StringConsumerJsonOutput from samcli.lib.list.mapper_consumer_container import MapperConsumerContainer @@ -11,6 +11,6 @@ class MapperConsumerFactory(MapperConsumerFactoryInterface): def create(self, producer, output): # Will add conditions here to return different sorts of containers later on data_to_json_mapper = DataToJsonMapper() - json_consumer = JsonConsumer() + json_consumer = StringConsumerJsonOutput() container = MapperConsumerContainer(data_to_json_mapper, json_consumer) return container diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index ef388dfebba..62e7ac85f4e 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -1,10 +1,8 @@ -from unittest import TestCase, mock -from unittest.mock import patch, call, MagicMock, Mock -from botocore.exceptions import ClientError, BotoCoreError, WaiterError, EndpointConnectionError -import click +from unittest import TestCase +from unittest.mock import patch, call +from botocore.exceptions import ClientError, EndpointConnectionError from samcli.commands.list.stack_outputs.stack_outputs_context import StackOutputsContext -from samcli.lib.list.stack_outputs.stack_outputs_producer import StackOutputsProducer from samcli.commands.exceptions import RegionError from samcli.commands.list.exceptions import SamListError, NoOutputsForStackError, StackDoesNotExistInRegionError From 657f6cd0654a4372cb1d010ae57108796c7fcf77 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 27 Jun 2022 14:24:52 -0700 Subject: [PATCH 36/72] Empty commit From 5bebdbc490a98e97886c31616207aff4bc773323 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 29 Jun 2022 12:03:44 -0700 Subject: [PATCH 37/72] Made changes based on comments, added new exceptions --- samcli/commands/list/exceptions.py | 12 ++++++++++++ samcli/lib/list/mapper_consumer_factory.py | 3 ++- .../lib/list/stack_outputs/stack_outputs_producer.py | 11 ++++++----- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/samcli/commands/list/exceptions.py b/samcli/commands/list/exceptions.py index b306f86e21a..16c9db4ea1c 100644 --- a/samcli/commands/list/exceptions.py +++ b/samcli/commands/list/exceptions.py @@ -19,6 +19,18 @@ def __init__(self, msg): super().__init__(message=message_fmt.format(msg=msg)) +class SamListUnknownClientError(SamListError): + """ + Used when boto3 API call raises an unexpected ClientError + """ + + +class SamListUnknownBotoCoreError(SamListError): + """ + Used when boto3 API call raises an unexpected BotoCoreError + """ + + class NoOutputsForStackError(UserException): def __init__(self, stack_name, region): self.stack_name = stack_name diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py index 97fbfaa0d89..4bfa137d6e6 100644 --- a/samcli/lib/list/mapper_consumer_factory.py +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -5,10 +5,11 @@ from samcli.lib.list.data_to_json_mapper import DataToJsonMapper from samcli.commands.list.json_consumer import StringConsumerJsonOutput from samcli.lib.list.mapper_consumer_container import MapperConsumerContainer +from samcli.lib.list.list_interfaces import ProducersEnum class MapperConsumerFactory(MapperConsumerFactoryInterface): - def create(self, producer, output): + def create(self, producer: ProducersEnum, output: str) -> MapperConsumerContainer: # Will add conditions here to return different sorts of containers later on data_to_json_mapper = DataToJsonMapper() json_consumer = StringConsumerJsonOutput() diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index 9554aa8c837..7af4a3fb6f0 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -5,10 +5,11 @@ import logging from botocore.exceptions import ClientError, BotoCoreError -from samcli.commands.list.exceptions import SamListError, NoOutputsForStackError, StackDoesNotExistInRegionError +from samcli.commands.list.exceptions import SamListUnknownClientError, SamListUnknownBotoCoreError, NoOutputsForStackError, StackDoesNotExistInRegionError from samcli.lib.list.list_interfaces import Producer from samcli.lib.list.stack_outputs.stack_outputs import StackOutputs +from samcli.lib.utils.boto_utils import get_client_error_code LOG = logging.getLogger(__name__) @@ -22,7 +23,7 @@ def __init__(self, stack_name, output, region, cloudformation_client, mapper, co self.mapper = mapper self.consumer = consumer - def get_stack_info(self): + def get_stack_info(self) -> dict: """ Returns the stack output information for the stack and raises exceptions accordingly @@ -40,14 +41,14 @@ def get_stack_info(self): return response["Stacks"][0]["Outputs"] except ClientError as e: - if "Stack with id {0} does not exist".format(self.stack_name) in str(e): + if get_client_error_code(e) == "ValidationError": LOG.debug("Stack with id %s does not exist", self.stack_name) raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) from e LOG.error("ClientError Exception : %s", str(e)) - raise SamListError(msg=str(e)) from e + raise SamListUnknownClientError(msg=str(e)) from e except BotoCoreError as e: LOG.error("Botocore Exception : %s", str(e)) - raise SamListError(msg=str(e)) from e + raise SamListUnknownBotoCoreError(msg=str(e)) from e def produce(self): response = self.get_stack_info() From eb6639d077e5dee04f3b60a353fbc618ed891df8 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 29 Jun 2022 12:08:53 -0700 Subject: [PATCH 38/72] Fixed format --- samcli/lib/list/stack_outputs/stack_outputs_producer.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index 7af4a3fb6f0..a9376db5490 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -5,7 +5,12 @@ import logging from botocore.exceptions import ClientError, BotoCoreError -from samcli.commands.list.exceptions import SamListUnknownClientError, SamListUnknownBotoCoreError, NoOutputsForStackError, StackDoesNotExistInRegionError +from samcli.commands.list.exceptions import ( + SamListUnknownClientError, + SamListUnknownBotoCoreError, + NoOutputsForStackError, + StackDoesNotExistInRegionError, +) from samcli.lib.list.list_interfaces import Producer from samcli.lib.list.stack_outputs.stack_outputs import StackOutputs From b0882037eda04acc925b705346870bd154316dfb Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 29 Jun 2022 14:58:30 -0700 Subject: [PATCH 39/72] Fixed return type declaration --- samcli/lib/list/stack_outputs/stack_outputs_producer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index a9376db5490..6a2e8c89d54 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -1,6 +1,7 @@ """ The producer for the 'sam list stack-outputs' command """ +from typing import Any, Optional, Dict import dataclasses import logging @@ -28,7 +29,7 @@ def __init__(self, stack_name, output, region, cloudformation_client, mapper, co self.mapper = mapper self.consumer = consumer - def get_stack_info(self) -> dict: + def get_stack_info(self) -> Optional[Dict[Any, Any]]: """ Returns the stack output information for the stack and raises exceptions accordingly From 53674283aed56357f628bf1f3df50f6f5e0aa2ae Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 29 Jun 2022 15:19:22 -0700 Subject: [PATCH 40/72] Fixed return type declaration --- samcli/lib/list/stack_outputs/stack_outputs_producer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index 6a2e8c89d54..8a63db376b1 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -1,7 +1,7 @@ """ The producer for the 'sam list stack-outputs' command """ -from typing import Any, Optional, Dict +from typing import Any, Optional import dataclasses import logging @@ -29,7 +29,7 @@ def __init__(self, stack_name, output, region, cloudformation_client, mapper, co self.mapper = mapper self.consumer = consumer - def get_stack_info(self) -> Optional[Dict[Any, Any]]: + def get_stack_info(self) -> Optional[Any]: """ Returns the stack output information for the stack and raises exceptions accordingly From 523915d1c8c2dedf36f80b82499ad448f85f7f68 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 29 Jun 2022 15:25:34 -0700 Subject: [PATCH 41/72] Changed return type to list --- samcli/lib/list/stack_outputs/stack_outputs_producer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index 8a63db376b1..c7e3b044e70 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -1,7 +1,7 @@ """ The producer for the 'sam list stack-outputs' command """ -from typing import Any, Optional +from typing import Optional, List import dataclasses import logging @@ -29,7 +29,7 @@ def __init__(self, stack_name, output, region, cloudformation_client, mapper, co self.mapper = mapper self.consumer = consumer - def get_stack_info(self) -> Optional[Any]: + def get_stack_info(self) -> Optional[List]: """ Returns the stack output information for the stack and raises exceptions accordingly @@ -58,6 +58,7 @@ def get_stack_info(self) -> Optional[Any]: def produce(self): response = self.get_stack_info() + print(response) for stack_output in response: stack_output_data = StackOutputs( OutputKey=stack_output["OutputKey"], From 84382cf2bab69d01a48d7fe8766e7fef25cf21ff Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 29 Jun 2022 16:11:31 -0700 Subject: [PATCH 42/72] Fixed error --- samcli/lib/list/stack_outputs/stack_outputs_producer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index c7e3b044e70..ce350d4c8f9 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -1,7 +1,7 @@ """ The producer for the 'sam list stack-outputs' command """ -from typing import Optional, List +from typing import Optional, Any import dataclasses import logging @@ -29,7 +29,7 @@ def __init__(self, stack_name, output, region, cloudformation_client, mapper, co self.mapper = mapper self.consumer = consumer - def get_stack_info(self) -> Optional[List]: + def get_stack_info(self) -> Optional[Any]: """ Returns the stack output information for the stack and raises exceptions accordingly From c549ce6bfe20d33f7205fd713a54483de28dadd8 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 29 Jun 2022 17:37:55 -0700 Subject: [PATCH 43/72] Implementation of the local transform and resource collection --- samcli/commands/list/exceptions.py | 6 + samcli/commands/list/resources/cli.py | 16 +- .../list/resources/resources_context.py | 74 +++++ samcli/commands/local/invoke/cli.py | 2 +- samcli/commands/local/start_api/cli.py | 2 +- samcli/commands/local/start_lambda/cli.py | 2 +- samcli/commands/validate/validate.py | 29 +- samcli/lib/list/resources/__init__.py | 0 .../resources/resource_mapping_producer.py | 80 ++++++ samcli/lib/list/resources/resources_def.py | 10 + .../stack_outputs/stack_outputs_producer.py | 1 - samcli/lib/providers/cfn_base_api_provider.py | 2 +- samcli/lib/providers/sam_api_provider.py | 2 +- samcli/lib/providers/sam_stack_provider.py | 9 +- samcli/lib/samlib/wrapper.py | 2 +- samcli/lib/translate/__init__.py | 0 .../lib => lib/translate}/exceptions.py | 0 .../translate}/sam_template_validator.py | 33 ++- samcli/lib/translate/translate_utils.py | 31 +++ .../lib/test_sam_template_validator.py | 4 +- .../list/resources/resources_integ_base.py | 7 +- .../list/resources/test_resources_command.py | 84 ++++++ .../test_resources_invalid_sam_template.yaml | 42 +++ .../unit/commands/list/resources/test_cli.py | 17 +- .../list/resources/test_resources_context.py | 256 ++++++++++++++++++ tests/unit/commands/local/invoke/test_cli.py | 2 +- .../local/lib/test_sam_api_provider.py | 2 +- .../unit/commands/local/start_api/test_cli.py | 2 +- .../commands/local/start_lambda/test_cli.py | 2 +- .../lib/test_sam_template_validator.py | 16 +- tests/unit/commands/validate/test_cli.py | 19 +- .../iac/cfn/test_cfn_iac_implementation.py | 5 +- 32 files changed, 693 insertions(+), 66 deletions(-) create mode 100644 samcli/commands/list/resources/resources_context.py create mode 100644 samcli/lib/list/resources/__init__.py create mode 100644 samcli/lib/list/resources/resource_mapping_producer.py create mode 100644 samcli/lib/list/resources/resources_def.py create mode 100644 samcli/lib/translate/__init__.py rename samcli/{commands/validate/lib => lib/translate}/exceptions.py (100%) rename samcli/{commands/validate/lib => lib/translate}/sam_template_validator.py (86%) create mode 100644 samcli/lib/translate/translate_utils.py create mode 100644 tests/integration/testdata/list/test_resources_invalid_sam_template.yaml create mode 100644 tests/unit/commands/list/resources/test_resources_context.py diff --git a/samcli/commands/list/exceptions.py b/samcli/commands/list/exceptions.py index 16c9db4ea1c..9269249d0ee 100644 --- a/samcli/commands/list/exceptions.py +++ b/samcli/commands/list/exceptions.py @@ -31,6 +31,12 @@ class SamListUnknownBotoCoreError(SamListError): """ +class SamListLocalResourcesNotFoundError(SamListError): + """ + Used when unable to retrieve local resources after performing a transform + """ + + class NoOutputsForStackError(UserException): def __init__(self, stack_name, region): self.stack_name = stack_name diff --git a/samcli/commands/list/resources/cli.py b/samcli/commands/list/resources/cli.py index cc8c7de2ced..725a5c56161 100644 --- a/samcli/commands/list/resources/cli.py +++ b/samcli/commands/list/resources/cli.py @@ -8,6 +8,8 @@ from samcli.cli.main import pass_context, common_options, aws_creds_options, print_cmdline_args from samcli.lib.utils.version_checker import check_newer_version from samcli.lib.telemetry.metric import track_command +from samcli.commands._utils.options import template_option_without_build +from samcli.cli.cli_config_file import configuration_option, TomlProvider HELP_TEXT = """ @@ -18,23 +20,31 @@ @click.command(name="resources", help=HELP_TEXT) +@configuration_option(provider=TomlProvider(section="parameters")) @stack_name_option @output_option +@template_option_without_build @aws_creds_options @common_options @pass_context @track_command @check_newer_version @print_cmdline_args -def cli(self, stack_name, output): +def cli(self, stack_name, output, template_file, config_file, config_env): """ `sam list resources` command entry point """ - do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile) + do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile, template_file=template_file) -def do_cli(stack_name, output, region, profile): +def do_cli(stack_name, output, region, profile, template_file): """ Implementation of the ``cli`` method """ + from samcli.commands.list.resources.resources_context import ResourcesContext + + with ResourcesContext( + stack_name=stack_name, output=output, region=region, profile=profile, template_file=template_file + ) as resources_context: + resources_context.run() diff --git a/samcli/commands/list/resources/resources_context.py b/samcli/commands/list/resources/resources_context.py new file mode 100644 index 00000000000..1333f9906de --- /dev/null +++ b/samcli/commands/list/resources/resources_context.py @@ -0,0 +1,74 @@ +""" +Display the Resources of a SAM stack +""" +import logging +from typing import Optional +import boto3 + +from samcli.commands.exceptions import RegionError +from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config + +from samcli.lib.list.resources.resource_mapping_producer import ResourceMappingProducer +from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory +from samcli.lib.list.list_interfaces import ProducersEnum + + +LOG = logging.getLogger(__name__) + + +class ResourcesContext: + def __init__( + self, stack_name: str, output: str, region: Optional[str], profile: Optional[str], template_file: Optional[str] + ): + self.stack_name = stack_name + self.output = output + self.region = region + self.profile = profile + self.template_file = template_file + self.cloudformation_client = None + self.iam_client = None + + def __enter__(self): + self.init_clients() + return self + + def __exit__(self, *args): + pass + + def init_clients(self) -> None: + """ + Initialize the clients being used by sam list. + """ + if not self.region: + session = boto3.Session() + region = session.region_name + if region: + self.region = region + else: + raise RegionError( + message="No region was specified/found. " + "Please provide a region via the --region parameter or by the AWS_REGION environment variable." + ) + + client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) + self.cloudformation_client = client_provider("cloudformation") + self.iam_client = client_provider("iam") + + def run(self) -> None: + """ + Get the resources for a stack + """ + factory = MapperConsumerFactory() + container = factory.create(producer=ProducersEnum.RESOURCES_PRODUCER, output=self.output) + resource_producer = ResourceMappingProducer( + stack_name=self.stack_name, + output=self.output, + region=self.region, + profile=self.profile, + template_file=self.template_file, + cloudformation_client=self.cloudformation_client, + iam_client=self.iam_client, + mapper=container.mapper, + consumer=container.consumer, + ) + resource_producer.produce() diff --git a/samcli/commands/local/invoke/cli.py b/samcli/commands/local/invoke/cli.py index eb97e606fcc..7ea75653133 100644 --- a/samcli/commands/local/invoke/cli.py +++ b/samcli/commands/local/invoke/cli.py @@ -138,7 +138,7 @@ def do_cli( # pylint: disable=R0914 from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.local.cli_common.invoke_context import InvokeContext from samcli.local.lambdafn.exceptions import FunctionNotFound - from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException + from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError, NoPrivilegeException from samcli.local.docker.manager import DockerImagePullFailedException from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/samcli/commands/local/start_api/cli.py b/samcli/commands/local/start_api/cli.py index 44475ea2031..eca5920be56 100644 --- a/samcli/commands/local/start_api/cli.py +++ b/samcli/commands/local/start_api/cli.py @@ -152,7 +152,7 @@ def do_cli( # pylint: disable=R0914 from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.exceptions import UserException from samcli.commands.local.lib.local_api_service import LocalApiService - from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException + from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/samcli/commands/local/start_lambda/cli.py b/samcli/commands/local/start_lambda/cli.py index 730c4626751..2c2afe20a3a 100644 --- a/samcli/commands/local/start_lambda/cli.py +++ b/samcli/commands/local/start_lambda/cli.py @@ -160,7 +160,7 @@ def do_cli( # pylint: disable=R0914 from samcli.commands.local.cli_common.user_exceptions import UserException from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.local.lib.local_lambda_service import LocalLambdaService - from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException + from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/samcli/commands/validate/validate.py b/samcli/commands/validate/validate.py index 88cdba024ec..e8fc7bac3fe 100644 --- a/samcli/commands/validate/validate.py +++ b/samcli/commands/validate/validate.py @@ -1,8 +1,6 @@ """ CLI Command for Validating a SAM Template """ -import os - import boto3 from botocore.exceptions import NoCredentialsError import click @@ -15,6 +13,7 @@ from samcli.lib.telemetry.metric import track_command from samcli.cli.cli_config_file import configuration_option, TomlProvider from samcli.lib.utils.version_checker import check_newer_version +from samcli.lib.translate.translate_utils import _read_sam_file @click.command("validate", short_help="Validate an AWS SAM template.") @@ -47,8 +46,8 @@ def do_cli(ctx, template): from samcli.commands.exceptions import UserException from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException - from .lib.exceptions import InvalidSamDocumentException - from .lib.sam_template_validator import SamTemplateValidator + from samcli.lib.translate.exceptions import InvalidSamDocumentException + from samcli.lib.translate.sam_template_validator import SamTemplateValidator sam_template = _read_sam_file(template) @@ -73,25 +72,3 @@ def do_cli(ctx, template): ) from e click.secho("{} is a valid SAM Template".format(template), fg="green") - - -def _read_sam_file(template): - """ - Reads the file (json and yaml supported) provided and returns the dictionary representation of the file. - - :param str template: Path to the template file - :return dict: Dictionary representing the SAM Template - :raises: SamTemplateNotFoundException when the template file does not exist - """ - - from samcli.commands.local.cli_common.user_exceptions import SamTemplateNotFoundException - from samcli.yamlhelper import yaml_parse - - if not os.path.exists(template): - click.secho("SAM Template Not Found", bg="red") - raise SamTemplateNotFoundException("Template at {} is not found".format(template)) - - with click.open_file(template, "r", encoding="utf-8") as sam_template: - sam_template = yaml_parse(sam_template.read()) - - return sam_template diff --git a/samcli/lib/list/resources/__init__.py b/samcli/lib/list/resources/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py new file mode 100644 index 00000000000..fc994091c42 --- /dev/null +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -0,0 +1,80 @@ +""" +in progress +""" +from typing import Optional, Any +import dataclasses +import logging +import yaml +import click + +from botocore.exceptions import ClientError, NoCredentialsError +from samcli.commands.list.exceptions import SamListLocalResourcesNotFoundError, SamListUnknownClientError + +from samcli.lib.list.list_interfaces import Producer +from samcli.lib.list.resources.resources_def import ResourcesDef +from samcli.lib.translate.sam_template_validator import SamTemplateValidator +from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider +from samtranslator.translator.managed_policy_translator import ManagedPolicyLoader +from samcli.lib.translate.translate_utils import _read_sam_file +from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException +from samtranslator.translator.arn_generator import NoRegionFound +from samcli.commands.exceptions import UserException + +LOG = logging.getLogger(__name__) + + +class ResourceMappingProducer(Producer): + def __init__( + self, stack_name, output, region, profile, template_file, cloudformation_client, iam_client, mapper, consumer + ): + self.stack_name = stack_name + self.output = output + self.region = region + self.profile = profile + self.template_file = template_file + self.cloudformation_client = cloudformation_client + self.iam_client = iam_client + self.mapper = mapper + self.consumer = consumer + + def get_translated_dict(self, template_file_dict) -> Optional[Any]: + try: + validator = SamTemplateValidator( + template_file_dict, ManagedPolicyLoader(self.iam_client), profile=self.profile, region=self.region + ) + translated_dict = yaml.load(validator.get_translated_template(), Loader=yaml.FullLoader) + return translated_dict + except InvalidSamDocumentException as e: + click.echo("Template provided was invalid SAM Template.") + raise InvalidSamTemplateException(str(e)) from e + except NoRegionFound as no_region_found_e: + raise UserException( + "AWS Region was not found. Please configure your region through a profile or --region option", + wrapped_from=no_region_found_e.__class__.__name__, + ) from no_region_found_e + except NoCredentialsError as e: + raise UserException( + "AWS Credentials are required. Please configure your credentials.", wrapped_from=e.__class__.__name__ + ) from e + except ClientError as e: + LOG.error("ClientError Exception : %s", str(e)) + raise SamListUnknownClientError(msg=str(e)) from e + + def produce(self): + sam_template = _read_sam_file(self.template_file) + + translated_dict = self.get_translated_dict(template_file_dict=sam_template) + + stacks, stack_paths = SamLocalStackProvider.get_stacks(template_file="", template_dict_format=translated_dict) + + if not stacks or len(stacks) < 1 or not stacks[0].resources: + raise SamListLocalResourcesNotFoundError(msg="No local resources found.") + resources_dict = {} + for local_resource in stacks[0].resources: + resources_dict[local_resource] = "-" + + for logical_id, physical_id in resources_dict.items(): + resource_data = ResourcesDef(LogicalResourceId=logical_id, PhysicalResourceId=physical_id) + mapped_output = self.mapper.map(dataclasses.asdict(resource_data)) + self.consumer.consume(mapped_output) diff --git a/samcli/lib/list/resources/resources_def.py b/samcli/lib/list/resources/resources_def.py new file mode 100644 index 00000000000..1bfb19a8818 --- /dev/null +++ b/samcli/lib/list/resources/resources_def.py @@ -0,0 +1,10 @@ +""" +in progress +""" +from dataclasses import dataclass + + +@dataclass +class ResourcesDef: + LogicalResourceId: str + PhysicalResourceId: str diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index ce350d4c8f9..888df6975ca 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -58,7 +58,6 @@ def get_stack_info(self) -> Optional[Any]: def produce(self): response = self.get_stack_info() - print(response) for stack_output in response: stack_output_data = StackOutputs( OutputKey=stack_output["OutputKey"], diff --git a/samcli/lib/providers/cfn_base_api_provider.py b/samcli/lib/providers/cfn_base_api_provider.py index e9ba77ddc3d..026cdcca05e 100644 --- a/samcli/lib/providers/cfn_base_api_provider.py +++ b/samcli/lib/providers/cfn_base_api_provider.py @@ -16,7 +16,7 @@ CORS_MAX_AGE_HEADER, ) from samcli.local.apigw.local_apigw_service import Route -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.translate.exceptions import InvalidSamDocumentException LOG = logging.getLogger(__name__) diff --git a/samcli/lib/providers/sam_api_provider.py b/samcli/lib/providers/sam_api_provider.py index 5493baedb23..f3b44a9711e 100644 --- a/samcli/lib/providers/sam_api_provider.py +++ b/samcli/lib/providers/sam_api_provider.py @@ -5,7 +5,7 @@ from samcli.lib.providers.api_collector import ApiCollector from samcli.lib.providers.cfn_base_api_provider import CfnBaseApiProvider -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.lib.providers.provider import Stack from samcli.lib.utils.colors import Colored from samcli.local.apigw.local_apigw_service import Route diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index 8425296c34b..5100c72aeb7 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -198,6 +198,7 @@ def get_stacks( parameter_overrides: Optional[Dict] = None, global_parameter_overrides: Optional[Dict] = None, metadata: Optional[Dict] = None, + template_dict_format: Optional[Dict] = None, ) -> Tuple[List[Stack], List[str]]: """ Recursively extract stacks from a template file. @@ -218,6 +219,8 @@ def get_stacks( that might want to get substituted within the template and its child templates metadata: Optional[Dict] Optional dictionary of nested stack resource metadata values. + template_dict_format: Optional[Dict] + Optional dictionary representing the sam template file to be used instead of the template file Returns ------- @@ -226,7 +229,11 @@ def get_stacks( remote_stack_full_paths : List[str] The list of full paths of detected remote stacks """ - template_dict = get_template_data(template_file) + template_dict: dict + if not template_dict_format: + template_dict = get_template_data(template_file) + else: + template_dict = template_dict_format stacks = [ Stack( stack_path, diff --git a/samcli/lib/samlib/wrapper.py b/samcli/lib/samlib/wrapper.py index 08a52a75239..f0c4536d30e 100644 --- a/samcli/lib/samlib/wrapper.py +++ b/samcli/lib/samlib/wrapper.py @@ -24,7 +24,7 @@ from samtranslator.translator.translator import prepare_plugins from samtranslator.validator.validator import SamTemplateValidator -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.translate.exceptions import InvalidSamDocumentException from .local_uri_plugin import SupportLocalUriPlugin diff --git a/samcli/lib/translate/__init__.py b/samcli/lib/translate/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/commands/validate/lib/exceptions.py b/samcli/lib/translate/exceptions.py similarity index 100% rename from samcli/commands/validate/lib/exceptions.py rename to samcli/lib/translate/exceptions.py diff --git a/samcli/commands/validate/lib/sam_template_validator.py b/samcli/lib/translate/sam_template_validator.py similarity index 86% rename from samcli/commands/validate/lib/sam_template_validator.py rename to samcli/lib/translate/sam_template_validator.py index 3cb863f6348..2cc3b459933 100644 --- a/samcli/commands/validate/lib/sam_template_validator.py +++ b/samcli/lib/translate/sam_template_validator.py @@ -13,7 +13,7 @@ from samcli.lib.utils.packagetype import ZIP, IMAGE from samcli.lib.utils.resources import AWS_SERVERLESS_FUNCTION from samcli.yamlhelper import yaml_dump -from .exceptions import InvalidSamDocumentException +from samcli.lib.translate.exceptions import InvalidSamDocumentException LOG = logging.getLogger(__name__) @@ -45,6 +45,37 @@ def __init__(self, sam_template, managed_policy_loader, profile=None, region=Non self.sam_parser = parser.Parser() self.boto3_session = Session(profile_name=profile, region_name=region) + def get_translated_template(self): + """ + Runs the SAM Translator to determine if the template provided is valid and then + returns the translated template if it is + + Returns + ------- + dict + A dictionary representing the translated template file + """ + managed_policy_map = self.managed_policy_loader.load() + + sam_translator = Translator( + managed_policy_map=managed_policy_map, + sam_parser=self.sam_parser, + plugins=[], + boto_session=self.boto3_session, + ) + + self._replace_local_codeuri() + self._replace_local_image() + + try: + template = sam_translator.translate(sam_template=self.sam_template, parameter_values={}) + LOG.debug("Translated template is:\n%s", yaml_dump(template)) + return yaml_dump(template) + except InvalidDocumentException as e: + raise InvalidSamDocumentException( + functools.reduce(lambda message, error: message + " " + str(error), e.causes, str(e)) + ) from e + def is_valid(self): """ Runs the SAM Translator to determine if the template provided is valid. This is similar to running a diff --git a/samcli/lib/translate/translate_utils.py b/samcli/lib/translate/translate_utils.py new file mode 100644 index 00000000000..d6e3bac067b --- /dev/null +++ b/samcli/lib/translate/translate_utils.py @@ -0,0 +1,31 @@ +""" +Library for Validating Sam Templates +""" +import os +import logging +import click + + +LOG = logging.getLogger(__name__) + + +def _read_sam_file(template): + """ + Reads the file (json and yaml supported) provided and returns the dictionary representation of the file. + + :param str template: Path to the template file + :return dict: Dictionary representing the SAM Template + :raises: SamTemplateNotFoundException when the template file does not exist + """ + + from samcli.commands.local.cli_common.user_exceptions import SamTemplateNotFoundException + from samcli.yamlhelper import yaml_parse + + if not os.path.exists(template): + click.secho("SAM Template Not Found", bg="red") + raise SamTemplateNotFoundException("Template at {} is not found".format(template)) + + with click.open_file(template, "r", encoding="utf-8") as sam_template: + sam_template = yaml_parse(sam_template.read()) + + return sam_template diff --git a/tests/functional/commands/validate/lib/test_sam_template_validator.py b/tests/functional/commands/validate/lib/test_sam_template_validator.py index 8d79b836ea5..4a6069c2079 100644 --- a/tests/functional/commands/validate/lib/test_sam_template_validator.py +++ b/tests/functional/commands/validate/lib/test_sam_template_validator.py @@ -5,8 +5,8 @@ import samcli.yamlhelper as yamlhelper -from samcli.commands.validate.lib.sam_template_validator import SamTemplateValidator -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.translate.sam_template_validator import SamTemplateValidator +from samcli.lib.translate.exceptions import InvalidSamDocumentException # Out of TestValidate's scope because https://stackoverflow.com/a/47224266 TEMPLATE_DIR = "tests/functional/commands/validate/lib/models" diff --git a/tests/integration/list/resources/resources_integ_base.py b/tests/integration/list/resources/resources_integ_base.py index f6c840e9051..deccf660e20 100644 --- a/tests/integration/list/resources/resources_integ_base.py +++ b/tests/integration/list/resources/resources_integ_base.py @@ -2,7 +2,9 @@ class ResourcesIntegBase(ListIntegBase): - def get_resources_command_list(self, stack_name=None, output=None, region=None, profile=None, help=False): + def get_resources_command_list( + self, stack_name=None, output=None, region=None, profile=None, template_file=None, help=False + ): command_list = [self.base_command(), "list", "resources"] if stack_name: command_list += ["--stack-name", str(stack_name)] @@ -16,6 +18,9 @@ def get_resources_command_list(self, stack_name=None, output=None, region=None, if profile: command_list += ["--profile", str(profile)] + if template_file: + command_list += ["--template-file", str(template_file)] + if help: command_list += ["--help"] diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 1bc18725e35..4ffc1b81d3a 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -1,12 +1,96 @@ +import os +import time +import boto3 +import re +from unittest import skipIf +from tests.integration.deploy.deploy_integ_base import DeployIntegBase from tests.integration.list.resources.resources_integ_base import ResourcesIntegBase from samcli.commands.list.resources.cli import HELP_TEXT from tests.testing_utils import run_command +from tests.testing_utils import RUNNING_ON_CI, RUNNING_TEST_FOR_MASTER_ON_CI, RUN_BY_CANARY +from tests.testing_utils import run_command, run_command_with_input, method_to_stack_name +SKIP_STACK_OUTPUTS_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY +CFN_SLEEP = 3 +CFN_PYTHON_VERSION_SUFFIX = os.environ.get("PYTHON_VERSION", "0.0.0").replace(".", "-") + +@skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") class TestResources(ResourcesIntegBase): + @classmethod + def setUpClass(cls): + DeployIntegBase.setUpClass() + ResourcesIntegBase.setUpClass() + + def setUp(self): + self.cf_client = boto3.client("cloudformation") + time.sleep(CFN_SLEEP) + super().setUp() + def test_resources_help_message(self): cmdlist = self.get_resources_command_list(help=True) command_result = run_command(cmdlist) from_command = "".join(command_result.stdout.decode().split()) from_help = "".join(HELP_TEXT.split()) self.assertIn(from_help, from_command, "Resources help text should have been printed") + + def test_successful_transform(self): + template_path = self.list_test_data_path.joinpath("test_stack_creation_template.yaml") + region = boto3.Session().region_name + cmdlist = self.get_resources_command_list( + stack_name=None, region=region, output="json", template_file=template_path + ) + command_result = run_command(cmdlist, cwd=self.working_dir) + self.assertIn( + """{ + "LogicalResourceId": "HelloWorldFunction", + "PhysicalResourceId": "-" +}""", + command_result.stdout.decode(), + ) + self.assertIn( + """{ + "LogicalResourceId": "HelloWorldFunctionRole", + "PhysicalResourceId": "-" +}""", + command_result.stdout.decode(), + ) + self.assertIn( + """{ + "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd", + "PhysicalResourceId": "-" +}""", + command_result.stdout.decode(), + ) + self.assertIn( + """{ + "LogicalResourceId": "ServerlessRestApi", + "PhysicalResourceId": "-" +}""", + command_result.stdout.decode(), + ) + self.assertIn( + """{ + "LogicalResourceId": "ServerlessRestApiProdStage", + "PhysicalResourceId": "-" +}""", + command_result.stdout.decode(), + ) + self.assertTrue( + re.search( + """{ + "LogicalResourceId": "ServerlessRestApiDeployment.*", + "PhysicalResourceId": "-" +}""", + command_result.stdout.decode(), + ) + ) + + def test_invalid_template_file(self): + template_path = self.list_test_data_path.joinpath("test_resources_invalid_sam_template.yaml") + region = boto3.Session().region_name + cmdlist = self.get_resources_command_list( + stack_name=None, region=region, output="json", template_file=template_path + ) + command_result = run_command(cmdlist, cwd=self.working_dir) + self.assertIn("Template provided was invalid SAM Template.", command_result.stdout.decode()) diff --git a/tests/integration/testdata/list/test_resources_invalid_sam_template.yaml b/tests/integration/testdata/list/test_resources_invalid_sam_template.yaml new file mode 100644 index 00000000000..66a58420126 --- /dev/null +++ b/tests/integration/testdata/list/test_resources_invalid_sam_template.yaml @@ -0,0 +1,42 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + sam-app-hello + + Sample SAM Template for sam-app-hello + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 3 + Tracing: Active + +ResourcesMissing: + HelloWorldFunction: + Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction + Properties: + CodeUri: hello_world/ + Handler: app.lambda_handler + Runtime: python3.8 + Architectures: + - x86_64 + Events: + HelloWorld: + Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api + Properties: + Path: /hello + Method: get + +Outputs: + # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function + # Find out more about other implicit resources you can reference within SAM + # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api + HelloWorldApi: + Description: "API Gateway endpoint URL for Prod stage for Hello World function" + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/" + HelloWorldFunction: + Description: "Hello World Lambda Function ARN" + Value: !GetAtt HelloWorldFunction.Arn + HelloWorldFunctionIamRole: + Description: "Implicit IAM Role created for Hello World function" + Value: !GetAtt HelloWorldFunctionRole.Arn \ No newline at end of file diff --git a/tests/unit/commands/list/resources/test_cli.py b/tests/unit/commands/list/resources/test_cli.py index cd09bde6f43..3070468b461 100644 --- a/tests/unit/commands/list/resources/test_cli.py +++ b/tests/unit/commands/list/resources/test_cli.py @@ -9,13 +9,28 @@ def setUp(self): self.output = "json" self.region = None self.profile = None + self.template_file = None @patch("samcli.commands.list.resources.cli.click") - def test_cli_base_command(self, mock_resources_click): + @patch("samcli.commands.list.resources.resources_context.ResourcesContext") + def test_cli_base_command(self, mock_resources_context, mock_resources_click): context_mock = Mock() + mock_resources_context.return_value.__enter__.return_value = context_mock do_cli( stack_name=self.stack_name, output=self.output, region=self.region, profile=self.profile, + template_file=self.template_file, ) + + mock_resources_context.assert_called_with( + stack_name=self.stack_name, + output=self.output, + region=self.region, + profile=self.profile, + template_file=self.template_file, + ) + + context_mock.run.assert_called_with() + self.assertEqual(context_mock.run.call_count, 1) diff --git a/tests/unit/commands/list/resources/test_resources_context.py b/tests/unit/commands/list/resources/test_resources_context.py new file mode 100644 index 00000000000..ed22b7d84f1 --- /dev/null +++ b/tests/unit/commands/list/resources/test_resources_context.py @@ -0,0 +1,256 @@ +from unittest import TestCase +from unittest.mock import patch, call, Mock + +from samcli.commands.list.resources.resources_context import ResourcesContext +from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException +from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.exceptions import RegionError +from samcli.commands.list.exceptions import SamListError +from samtranslator.public.exceptions import InvalidDocumentException +from samcli.lib.translate.sam_template_validator import SamTemplateValidator + + +class TestResourcesContext(TestCase): + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer._read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") + def test_resources_local_only_no_stack_name( + self, mock_get_translated_dict, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo + ): + mock_get_translated_dict.return_value = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", + "Resources": { + "HelloWorldFunction": { + "Properties": { + "Architectures": ["x86_64"], + "Code": {"S3Bucket": "bucket", "S3Key": "value"}, + "Handler": "app.lambda_handler", + "Role": {"Fn::GetAtt": ["HelloWorldFunctionRole", "Arn"]}, + "Runtime": "python3.8", + "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], + "Timeout": 3, + "TracingConfig": {"Mode": "Active"}, + }, + "Type": "AWS::Lambda::Function", + }, + "HelloWorldFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": ["sts:AssumeRole"], + "Effect": "Allow", + "Principal": {"Service": ["lambda.amazonaws.com"]}, + } + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess", + ], + "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], + }, + "Type": "AWS::IAM::Role", + }, + "HelloWorldFunctionHelloWorldPermissionProd": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": {"Ref": "HelloWorldFunction"}, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/GET/hello", + {"__ApiId__": {"Ref": "ServerlessRestApi"}, "__Stage__": "*"}, + ] + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ServerlessRestApi": { + "Properties": { + "Body": { + "info": {"version": "1.0", "title": {"Ref": "AWS::StackName"}}, + "paths": { + "/hello": { + "get": { + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations" + }, + }, + "responses": {}, + } + } + }, + "swagger": "2.0", + } + }, + "Type": "AWS::ApiGateway::RestApi", + }, + "ServerlessRestApiDeploymentf5716dc08b": { + "Properties": { + "Description": "RestApi deployment id: f5716dc08b0d213bd0f2dfb686579c351b09ae49", + "RestApiId": {"Ref": "ServerlessRestApi"}, + "StageName": "Stage", + }, + "Type": "AWS::ApiGateway::Deployment", + }, + "ServerlessRestApiProdStage": { + "Properties": { + "DeploymentId": {"Ref": "ServerlessRestApiDeploymentf5716dc08b"}, + "RestApiId": {"Ref": "ServerlessRestApi"}, + "StageName": "Prod", + }, + "Type": "AWS::ApiGateway::Stage", + }, + }, + } + + mock_sam_file_reader.return_value = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", + "Globals": {"Function": {"Tracing": "Active", "Timeout": 3}}, + "Resources": { + "HelloWorldFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "hello_world/", + "Handler": "app.lambda_handler", + "Architectures": ["x86_64"], + "Runtime": "python3.8", + "Events": {"HelloWorld": {"Type": "Api", "Properties": {"Path": "/hello", "Method": "get"}}}, + }, + } + }, + } + with ResourcesContext( + stack_name=None, output="json", region="us-east-1", profile=None, template_file=None + ) as resources_context: + resources_context.run() + expected_output = [ + call('{\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n}'), + call('{\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n}'), + call( + '{\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n}' + ), + call('{\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n}'), + call( + '{\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n}' + ), + call('{\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n}'), + ] + self.assertEqual(expected_output, patched_click_echo.call_args_list) + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer._read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template") + def test_get_translate_dict_clienterror( + self, mock_get_translated_template, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo + ): + mock_sam_file_reader.return_value = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", + "Globals": {"Function": {"Tracing": "Active", "Timeout": 3}}, + "Resources": { + "HelloWorldFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "hello_world/", + "Handler": "app.lambda_handler", + "Architectures": ["x86_64"], + "Runtime": "python3.8", + "Events": {"HelloWorld": {"Type": "Api", "Properties": {"Path": "/hello", "Method": "get"}}}, + }, + } + }, + } + mock_get_translated_template.side_effect = InvalidSamDocumentException() + with self.assertRaises(InvalidSamTemplateException): + with ResourcesContext( + stack_name=None, output="json", region="us-east-1", profile=None, template_file=None + ) as resources_context: + resources_context.run() + self.assertEqual(patched_click_echo.call_args_list, "Template provided was invalid SAM Template.") + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("boto3.Session.region_name", None) + def test_init_clients_no_region(self, patched_click_get_current_context, patched_click_echo): + with self.assertRaises(RegionError): + with ResourcesContext( + stack_name="test", output="json", region=None, profile=None, template_file=None + ) as resources_context: + resources_context.init_clients() + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("boto3.Session.region_name", "us-east-1") + def test_init_clients_no_input_region_get_region_from_session( + self, patched_click_get_current_context, patched_click_echo + ): + with ResourcesContext( + stack_name="test", output="json", region=None, profile=None, template_file=None + ) as resources_context: + resources_context.init_clients() + self.assertEqual(resources_context.region, "us-east-1") + + @patch("samcli.lib.translate.sam_template_validator.Session") + @patch("samcli.lib.translate.sam_template_validator.Translator") + @patch("samcli.lib.translate.sam_template_validator.parser") + def test_get_translated_template_raises_exception(self, sam_parser, sam_translator, boto_session_patch): + managed_policy_mock = Mock() + managed_policy_mock.load.return_value = {"policy": "SomePolicy"} + template = {"a": "b"} + + parser = Mock() + sam_parser.Parser.return_value = parser + + boto_session_mock = Mock() + boto_session_patch.return_value = boto_session_mock + + translate_mock = Mock() + translate_mock.translate.side_effect = InvalidDocumentException([Exception("message")]) + sam_translator.return_value = translate_mock + + validator = SamTemplateValidator(template, managed_policy_mock) + + with self.assertRaises(InvalidSamDocumentException): + validator.get_translated_template() + + sam_translator.assert_called_once_with( + managed_policy_map={"policy": "SomePolicy"}, sam_parser=parser, plugins=[], boto_session=boto_session_mock + ) + + boto_session_patch.assert_called_once_with(profile_name=None, region_name=None) + translate_mock.translate.assert_called_once_with(sam_template=template, parameter_values={}) + sam_parser.Parser.assert_called_once() + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer._read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") + @patch("samcli.lib.list.resources.resource_mapping_producer.SamLocalStackProvider.get_stacks") + def test_resources_get_stacks_returns_empty( + self, + mock_get_stacks, + mock_get_translated_dict, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, + ): + mock_get_translated_dict.return_value = {} + mock_sam_file_reader.return_value = {} + mock_get_stacks.return_value = ([], []) + with self.assertRaises(SamListError): + with ResourcesContext( + stack_name=None, output="json", region="us-east-1", profile=None, template_file=None + ) as resources_context: + resources_context.run() diff --git a/tests/unit/commands/local/invoke/test_cli.py b/tests/unit/commands/local/invoke/test_cli.py index 4ec958b6103..73c23ba745c 100644 --- a/tests/unit/commands/local/invoke/test_cli.py +++ b/tests/unit/commands/local/invoke/test_cli.py @@ -9,7 +9,7 @@ from samcli.local.docker.exceptions import ContainerNotStartableException from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.lib.providers.exceptions import InvalidLayerReference -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.commands.exceptions import UserException from samcli.commands.local.invoke.cli import do_cli as invoke_cli, _get_event as invoke_cli_get_event from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError, InvalidIntermediateImageError diff --git a/tests/unit/commands/local/lib/test_sam_api_provider.py b/tests/unit/commands/local/lib/test_sam_api_provider.py index 705e7bf876d..1307ce29163 100644 --- a/tests/unit/commands/local/lib/test_sam_api_provider.py +++ b/tests/unit/commands/local/lib/test_sam_api_provider.py @@ -6,7 +6,7 @@ from unittest.mock import patch, Mock from parameterized import parameterized -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.lib.providers.api_provider import ApiProvider from samcli.lib.providers.provider import Cors, Stack from samcli.local.apigw.local_apigw_service import Route diff --git a/tests/unit/commands/local/start_api/test_cli.py b/tests/unit/commands/local/start_api/test_cli.py index 8c746f042ff..c40f2d84640 100644 --- a/tests/unit/commands/local/start_api/test_cli.py +++ b/tests/unit/commands/local/start_api/test_cli.py @@ -11,7 +11,7 @@ from samcli.commands.local.lib.exceptions import NoApisDefined, InvalidIntermediateImageError from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.exceptions import UserException -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError from samcli.local.docker.exceptions import ContainerNotStartableException from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/tests/unit/commands/local/start_lambda/test_cli.py b/tests/unit/commands/local/start_lambda/test_cli.py index 10013bfe973..a22331dfd0b 100644 --- a/tests/unit/commands/local/start_lambda/test_cli.py +++ b/tests/unit/commands/local/start_lambda/test_cli.py @@ -6,7 +6,7 @@ from samcli.commands.local.start_lambda.cli import do_cli as start_lambda_cli from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.local.cli_common.user_exceptions import UserException -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.local.docker.exceptions import ContainerNotStartableException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError, InvalidIntermediateImageError from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/tests/unit/commands/validate/lib/test_sam_template_validator.py b/tests/unit/commands/validate/lib/test_sam_template_validator.py index a269278b935..c1dd79062aa 100644 --- a/tests/unit/commands/validate/lib/test_sam_template_validator.py +++ b/tests/unit/commands/validate/lib/test_sam_template_validator.py @@ -4,14 +4,14 @@ from samcli.lib.utils.packagetype import IMAGE from samtranslator.public.exceptions import InvalidDocumentException -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException -from samcli.commands.validate.lib.sam_template_validator import SamTemplateValidator +from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.lib.translate.sam_template_validator import SamTemplateValidator class TestSamTemplateValidator(TestCase): - @patch("samcli.commands.validate.lib.sam_template_validator.Session") - @patch("samcli.commands.validate.lib.sam_template_validator.Translator") - @patch("samcli.commands.validate.lib.sam_template_validator.parser") + @patch("samcli.lib.translate.sam_template_validator.Session") + @patch("samcli.lib.translate.sam_template_validator.Translator") + @patch("samcli.lib.translate.sam_template_validator.parser") def test_is_valid_returns_true(self, sam_parser, sam_translator, boto_session_patch): managed_policy_mock = Mock() managed_policy_mock.load.return_value = {"policy": "SomePolicy"} @@ -39,9 +39,9 @@ def test_is_valid_returns_true(self, sam_parser, sam_translator, boto_session_pa translate_mock.translate.assert_called_once_with(sam_template=template, parameter_values={}) sam_parser.Parser.assert_called_once() - @patch("samcli.commands.validate.lib.sam_template_validator.Session") - @patch("samcli.commands.validate.lib.sam_template_validator.Translator") - @patch("samcli.commands.validate.lib.sam_template_validator.parser") + @patch("samcli.lib.translate.sam_template_validator.Session") + @patch("samcli.lib.translate.sam_template_validator.Translator") + @patch("samcli.lib.translate.sam_template_validator.parser") def test_is_valid_raises_exception(self, sam_parser, sam_translator, boto_session_patch): managed_policy_mock = Mock() managed_policy_mock.load.return_value = {"policy": "SomePolicy"} diff --git a/tests/unit/commands/validate/test_cli.py b/tests/unit/commands/validate/test_cli.py index d354c693270..0606658a35e 100644 --- a/tests/unit/commands/validate/test_cli.py +++ b/tests/unit/commands/validate/test_cli.py @@ -6,15 +6,16 @@ from samcli.commands.exceptions import UserException from samcli.commands.local.cli_common.user_exceptions import SamTemplateNotFoundException, InvalidSamTemplateException -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException -from samcli.commands.validate.validate import do_cli, _read_sam_file +from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.validate import do_cli +from samcli.lib.translate.translate_utils import _read_sam_file ctx_mock = namedtuple("ctx", ["profile", "region"]) class TestValidateCli(TestCase): - @patch("samcli.commands.validate.validate.click") - @patch("samcli.commands.validate.validate.os.path.exists") + @patch("samcli.lib.translate.translate_utils.click") + @patch("samcli.lib.translate.translate_utils.os.path.exists") def test_file_not_found(self, path_exists_patch, click_patch): template_path = "path_to_template" @@ -24,8 +25,8 @@ def test_file_not_found(self, path_exists_patch, click_patch): _read_sam_file(template_path) @patch("samcli.yamlhelper.yaml_parse") - @patch("samcli.commands.validate.validate.click") - @patch("samcli.commands.validate.validate.os.path.exists") + @patch("samcli.lib.translate.translate_utils.click") + @patch("samcli.lib.translate.translate_utils.os.path.exists") def test_file_parsed(self, path_exists_patch, click_patch, yaml_parse_patch): template_path = "path_to_template" @@ -37,7 +38,7 @@ def test_file_parsed(self, path_exists_patch, click_patch, yaml_parse_patch): self.assertEqual(actual_template, {"a": "b"}) - @patch("samcli.commands.validate.lib.sam_template_validator.SamTemplateValidator") + @patch("samcli.lib.translate.sam_template_validator.SamTemplateValidator") @patch("samcli.commands.validate.validate.click") @patch("samcli.commands.validate.validate._read_sam_file") def test_template_fails_validation(self, read_sam_file_patch, click_patch, template_valiadator): @@ -51,7 +52,7 @@ def test_template_fails_validation(self, read_sam_file_patch, click_patch, templ with self.assertRaises(InvalidSamTemplateException): do_cli(ctx=ctx_mock(profile="profile", region="region"), template=template_path) - @patch("samcli.commands.validate.lib.sam_template_validator.SamTemplateValidator") + @patch("samcli.lib.translate.sam_template_validator.SamTemplateValidator") @patch("samcli.commands.validate.validate.click") @patch("samcli.commands.validate.validate._read_sam_file") def test_no_credentials_provided(self, read_sam_file_patch, click_patch, template_valiadator): @@ -65,7 +66,7 @@ def test_no_credentials_provided(self, read_sam_file_patch, click_patch, templat with self.assertRaises(UserException): do_cli(ctx=ctx_mock(profile="profile", region="region"), template=template_path) - @patch("samcli.commands.validate.lib.sam_template_validator.SamTemplateValidator") + @patch("samcli.lib.translate.sam_template_validator.SamTemplateValidator") @patch("samcli.commands.validate.validate.click") @patch("samcli.commands.validate.validate._read_sam_file") def test_template_passes_validation(self, read_sam_file_patch, click_patch, template_valiadator): diff --git a/tests/unit/lib/iac/cfn/test_cfn_iac_implementation.py b/tests/unit/lib/iac/cfn/test_cfn_iac_implementation.py index fc5aaa625a3..ab6f78b3fed 100644 --- a/tests/unit/lib/iac/cfn/test_cfn_iac_implementation.py +++ b/tests/unit/lib/iac/cfn/test_cfn_iac_implementation.py @@ -1,9 +1,8 @@ -import copy import os from unittest import TestCase -from unittest.mock import patch, Mock, ANY +from unittest.mock import patch, Mock -from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.lib.iac.cfn.cfn_iac import CfnIacImplementation from samcli.lib.iac.plugins_interfaces import ( SamCliContext, From a820f04444c83f6dfc66d313c522dc1ad45bde1f Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 29 Jun 2022 17:40:11 -0700 Subject: [PATCH 44/72] Empty-Commit From defa233b48f99238c05f366ef10ace75f23545f4 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 29 Jun 2022 17:44:21 -0700 Subject: [PATCH 45/72] Added section to avoid unused variable --- samcli/lib/list/resources/resource_mapping_producer.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index fc994091c42..193677c410b 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -8,17 +8,17 @@ import click from botocore.exceptions import ClientError, NoCredentialsError -from samcli.commands.list.exceptions import SamListLocalResourcesNotFoundError, SamListUnknownClientError +from samtranslator.translator.managed_policy_translator import ManagedPolicyLoader +from samtranslator.translator.arn_generator import NoRegionFound +from samcli.commands.list.exceptions import SamListLocalResourcesNotFoundError, SamListUnknownClientError from samcli.lib.list.list_interfaces import Producer from samcli.lib.list.resources.resources_def import ResourcesDef from samcli.lib.translate.sam_template_validator import SamTemplateValidator from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider -from samtranslator.translator.managed_policy_translator import ManagedPolicyLoader from samcli.lib.translate.translate_utils import _read_sam_file from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException -from samtranslator.translator.arn_generator import NoRegionFound from samcli.commands.exceptions import UserException LOG = logging.getLogger(__name__) @@ -67,7 +67,8 @@ def produce(self): translated_dict = self.get_translated_dict(template_file_dict=sam_template) stacks, stack_paths = SamLocalStackProvider.get_stacks(template_file="", template_dict_format=translated_dict) - + if stack_paths: + pass if not stacks or len(stacks) < 1 or not stacks[0].resources: raise SamListLocalResourcesNotFoundError(msg="No local resources found.") resources_dict = {} From e98ac151375dcc9b05f28628eddbca54d9a57da4 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 30 Jun 2022 10:57:52 -0700 Subject: [PATCH 46/72] Refactored common code --- .../list/cli_common/list_common_context.py | 33 +++++++++++++++++++ .../list/resources/resources_context.py | 26 +++------------ .../stack_outputs/stack_outputs_context.py | 25 ++------------ .../test_stack_outputs_context.py | 10 +++--- 4 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 samcli/commands/list/cli_common/list_common_context.py diff --git a/samcli/commands/list/cli_common/list_common_context.py b/samcli/commands/list/cli_common/list_common_context.py new file mode 100644 index 00000000000..ddfcab405d1 --- /dev/null +++ b/samcli/commands/list/cli_common/list_common_context.py @@ -0,0 +1,33 @@ +""" +Common context class to inherit from for sam list sub-commands +""" +import boto3 +from samcli.commands.exceptions import RegionError +from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config + + +class ListContext: + def __init__(self): + self.cloudformation_client = None + self.client_provider = None + self.region = None + self.profile = None + + def init_clients(self) -> None: + """ + Initialize the clients being used by sam list. + """ + if not self.region: + session = boto3.Session() + region = session.region_name + if region: + self.region = region + else: + raise RegionError( + message="No region was specified/found. " + "Please provide a region via the --region parameter or by the AWS_REGION environment variable." + ) + + client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) + self.client_provider = client_provider + self.cloudformation_client = client_provider("cloudformation") diff --git a/samcli/commands/list/resources/resources_context.py b/samcli/commands/list/resources/resources_context.py index 1333f9906de..caabf73fa42 100644 --- a/samcli/commands/list/resources/resources_context.py +++ b/samcli/commands/list/resources/resources_context.py @@ -3,29 +3,25 @@ """ import logging from typing import Optional -import boto3 - -from samcli.commands.exceptions import RegionError -from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config +from samcli.commands.list.cli_common.list_common_context import ListContext from samcli.lib.list.resources.resource_mapping_producer import ResourceMappingProducer from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory from samcli.lib.list.list_interfaces import ProducersEnum - LOG = logging.getLogger(__name__) -class ResourcesContext: +class ResourcesContext(ListContext): def __init__( self, stack_name: str, output: str, region: Optional[str], profile: Optional[str], template_file: Optional[str] ): + super().__init__() self.stack_name = stack_name self.output = output self.region = region self.profile = profile self.template_file = template_file - self.cloudformation_client = None self.iam_client = None def __enter__(self): @@ -39,20 +35,8 @@ def init_clients(self) -> None: """ Initialize the clients being used by sam list. """ - if not self.region: - session = boto3.Session() - region = session.region_name - if region: - self.region = region - else: - raise RegionError( - message="No region was specified/found. " - "Please provide a region via the --region parameter or by the AWS_REGION environment variable." - ) - - client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) - self.cloudformation_client = client_provider("cloudformation") - self.iam_client = client_provider("iam") + super().init_clients() + self.iam_client = self.client_provider("iam") def run(self) -> None: """ diff --git a/samcli/commands/list/stack_outputs/stack_outputs_context.py b/samcli/commands/list/stack_outputs/stack_outputs_context.py index 9742aa5d338..927b8731a80 100644 --- a/samcli/commands/list/stack_outputs/stack_outputs_context.py +++ b/samcli/commands/list/stack_outputs/stack_outputs_context.py @@ -3,18 +3,17 @@ """ import logging from typing import Optional -import boto3 from samcli.lib.list.stack_outputs.stack_outputs_producer import StackOutputsProducer -from samcli.commands.exceptions import RegionError -from samcli.lib.utils.boto_utils import get_boto_client_provider_with_config +from samcli.commands.list.cli_common.list_common_context import ListContext from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory from samcli.lib.list.list_interfaces import ProducersEnum LOG = logging.getLogger(__name__) -class StackOutputsContext: +class StackOutputsContext(ListContext): def __init__(self, stack_name: str, output: str, region: Optional[str], profile: Optional[str]): + super().__init__() self.stack_name = stack_name self.output = output self.region = region @@ -28,24 +27,6 @@ def __enter__(self): def __exit__(self, *args): pass - def init_clients(self) -> None: - """ - Initialize the clients being used by sam list. - """ - if not self.region: - session = boto3.Session() - region = session.region_name - if region: - self.region = region - else: - raise RegionError( - message="No region was specified/found. " - "Please provide a region via the --region parameter or by the AWS_REGION environment variable." - ) - - client_provider = get_boto_client_provider_with_config(region=self.region, profile=self.profile) - self.cloudformation_client = client_provider("cloudformation") - def run(self) -> None: """ Get the stack outputs for a stack diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index 62e7ac85f4e..06021b7328e 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -10,7 +10,7 @@ class TestStackOutputsContext(TestCase): @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") def test_stack_outputs_stack_exists( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -31,7 +31,7 @@ def test_stack_outputs_stack_exists( @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") def test_no_stack_object_in_response( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -44,7 +44,7 @@ def test_no_stack_object_in_response( @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") def test_no_output_object_in_response( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -57,7 +57,7 @@ def test_no_output_object_in_response( @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") def test_clienterror_stack_does_not_exist_in_region( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): @@ -72,7 +72,7 @@ def test_clienterror_stack_does_not_exist_in_region( @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.commands.list.stack_outputs.stack_outputs_context.get_boto_client_provider_with_config") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") def test_botocoreerror_invalid_region( self, mock_client_provider, patched_click_get_current_context, patched_click_echo ): From 562d2ad91b6a9b89e55fb70bf19a459cd572ccc4 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 30 Jun 2022 12:13:43 -0700 Subject: [PATCH 47/72] Added tests, modified PR --- .../resources/resource_mapping_producer.py | 7 +++--- .../list/resources/test_resources_context.py | 23 ++++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index 193677c410b..14c93753c4e 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -66,13 +66,12 @@ def produce(self): translated_dict = self.get_translated_dict(template_file_dict=sam_template) - stacks, stack_paths = SamLocalStackProvider.get_stacks(template_file="", template_dict_format=translated_dict) - if stack_paths: - pass - if not stacks or len(stacks) < 1 or not stacks[0].resources: + stacks, _ = SamLocalStackProvider.get_stacks(template_file="", template_dict_format=translated_dict) + if not stacks or not stacks[0].resources: raise SamListLocalResourcesNotFoundError(msg="No local resources found.") resources_dict = {} for local_resource in stacks[0].resources: + # Set the PhysicalID to "-" if there is no corresponding PhysicalID resources_dict[local_resource] = "-" for logical_id, physical_id in resources_dict.items(): diff --git a/tests/unit/commands/list/resources/test_resources_context.py b/tests/unit/commands/list/resources/test_resources_context.py index ed22b7d84f1..ca2096b1a7c 100644 --- a/tests/unit/commands/list/resources/test_resources_context.py +++ b/tests/unit/commands/list/resources/test_resources_context.py @@ -1,11 +1,12 @@ from unittest import TestCase from unittest.mock import patch, call, Mock +from botocore.exceptions import ClientError from samcli.commands.list.resources.resources_context import ResourcesContext from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.commands.exceptions import RegionError -from samcli.commands.list.exceptions import SamListError +from samcli.commands.list.exceptions import SamListError, SamListLocalResourcesNotFoundError, SamListUnknownClientError from samtranslator.public.exceptions import InvalidDocumentException from samcli.lib.translate.sam_template_validator import SamTemplateValidator @@ -151,7 +152,23 @@ def test_resources_local_only_no_stack_name( @patch("samcli.commands.list.json_consumer.click.get_current_context") @patch("samcli.lib.list.resources.resource_mapping_producer._read_sam_file") @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template") - def test_get_translate_dict_clienterror( + def test_clienterror_exception( + self, mock_get_translated_template, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo + ): + mock_get_translated_template.side_effect = ClientError( + {"Error": {"Code": "ExpiredToken", "Message": "The security token included in the request is expired"}}, "DescribeStacks" + ) + with self.assertRaises(SamListUnknownClientError): + with ResourcesContext( + stack_name=None, output="json", region="us-east-1", profile=None, template_file=None + ) as resources_context: + resources_context.run() + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer._read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template") + def test_get_translate_dict_invalid_template_error( self, mock_get_translated_template, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo ): mock_sam_file_reader.return_value = { @@ -249,7 +266,7 @@ def test_resources_get_stacks_returns_empty( mock_get_translated_dict.return_value = {} mock_sam_file_reader.return_value = {} mock_get_stacks.return_value = ([], []) - with self.assertRaises(SamListError): + with self.assertRaises(SamListLocalResourcesNotFoundError): with ResourcesContext( stack_name=None, output="json", region="us-east-1", profile=None, template_file=None ) as resources_context: From c5d91ebe40508d0f3c8bf97a8b6eeb3cee97d4b9 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 30 Jun 2022 17:37:01 -0700 Subject: [PATCH 48/72] Fixed formatting --- tests/unit/commands/list/resources/test_resources_context.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/commands/list/resources/test_resources_context.py b/tests/unit/commands/list/resources/test_resources_context.py index ca2096b1a7c..7ed17d4c4cb 100644 --- a/tests/unit/commands/list/resources/test_resources_context.py +++ b/tests/unit/commands/list/resources/test_resources_context.py @@ -156,7 +156,8 @@ def test_clienterror_exception( self, mock_get_translated_template, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo ): mock_get_translated_template.side_effect = ClientError( - {"Error": {"Code": "ExpiredToken", "Message": "The security token included in the request is expired"}}, "DescribeStacks" + {"Error": {"Code": "ExpiredToken", "Message": "The security token included in the request is expired"}}, + "DescribeStacks", ) with self.assertRaises(SamListUnknownClientError): with ResourcesContext( From 7fd31798eeb88fcfe78b255e7148235004321408 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 6 Jul 2022 18:55:49 -0700 Subject: [PATCH 49/72] Made fixes based on PR comments --- samcli/commands/translate/__init__.py | 0 .../translate/translate_utils.py | 4 +- samcli/commands/validate/validate.py | 6 +-- .../resources/resource_mapping_producer.py | 33 +++++++++------- samcli/lib/list/resources/resources_def.py | 2 +- samcli/lib/providers/exceptions.py | 9 +++++ samcli/lib/providers/sam_stack_provider.py | 20 +++++----- .../lib/translate/sam_template_validator.py | 34 +---------------- .../lib/test_sam_template_validator.py | 16 ++++---- .../list/resources/test_resources_context.py | 32 ++++++++++------ .../lib/test_sam_template_validator.py | 4 +- tests/unit/commands/validate/test_cli.py | 38 +++++++++---------- 12 files changed, 96 insertions(+), 102 deletions(-) create mode 100644 samcli/commands/translate/__init__.py rename samcli/{lib => commands}/translate/translate_utils.py (92%) diff --git a/samcli/commands/translate/__init__.py b/samcli/commands/translate/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/lib/translate/translate_utils.py b/samcli/commands/translate/translate_utils.py similarity index 92% rename from samcli/lib/translate/translate_utils.py rename to samcli/commands/translate/translate_utils.py index d6e3bac067b..7cd47e0a061 100644 --- a/samcli/lib/translate/translate_utils.py +++ b/samcli/commands/translate/translate_utils.py @@ -1,5 +1,5 @@ """ -Library for Validating Sam Templates +Utils for performing Sam translates """ import os import logging @@ -9,7 +9,7 @@ LOG = logging.getLogger(__name__) -def _read_sam_file(template): +def read_sam_file(template): """ Reads the file (json and yaml supported) provided and returns the dictionary representation of the file. diff --git a/samcli/commands/validate/validate.py b/samcli/commands/validate/validate.py index e8fc7bac3fe..b98dc66baed 100644 --- a/samcli/commands/validate/validate.py +++ b/samcli/commands/validate/validate.py @@ -13,7 +13,7 @@ from samcli.lib.telemetry.metric import track_command from samcli.cli.cli_config_file import configuration_option, TomlProvider from samcli.lib.utils.version_checker import check_newer_version -from samcli.lib.translate.translate_utils import _read_sam_file +from samcli.commands.translate.translate_utils import read_sam_file @click.command("validate", short_help="Validate an AWS SAM template.") @@ -49,7 +49,7 @@ def do_cli(ctx, template): from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.lib.translate.sam_template_validator import SamTemplateValidator - sam_template = _read_sam_file(template) + sam_template = read_sam_file(template) iam_client = boto3.client("iam") validator = SamTemplateValidator( @@ -57,7 +57,7 @@ def do_cli(ctx, template): ) try: - validator.is_valid() + validator.get_translated_template_if_valid() except InvalidSamDocumentException as e: click.secho("Template provided at '{}' was invalid SAM Template.".format(template), bg="red") raise InvalidSamTemplateException(str(e)) from e diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index 14c93753c4e..2a3d4a8d6e7 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -1,22 +1,21 @@ """ -in progress +The producer for the 'sam list resources' command """ from typing import Optional, Any import dataclasses import logging import yaml -import click from botocore.exceptions import ClientError, NoCredentialsError from samtranslator.translator.managed_policy_translator import ManagedPolicyLoader from samtranslator.translator.arn_generator import NoRegionFound from samcli.commands.list.exceptions import SamListLocalResourcesNotFoundError, SamListUnknownClientError -from samcli.lib.list.list_interfaces import Producer +from samcli.lib.list.list_interfaces import Producer, Mapper, ListInfoPullerConsumer from samcli.lib.list.resources.resources_def import ResourcesDef from samcli.lib.translate.sam_template_validator import SamTemplateValidator from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider -from samcli.lib.translate.translate_utils import _read_sam_file +from samcli.commands.translate.translate_utils import read_sam_file from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException from samcli.commands.exceptions import UserException @@ -26,7 +25,16 @@ class ResourceMappingProducer(Producer): def __init__( - self, stack_name, output, region, profile, template_file, cloudformation_client, iam_client, mapper, consumer + self, + stack_name, + output, + region, + profile, + template_file, + cloudformation_client, + iam_client, + mapper, + consumer, ): self.stack_name = stack_name self.output = output @@ -38,15 +46,14 @@ def __init__( self.mapper = mapper self.consumer = consumer - def get_translated_dict(self, template_file_dict) -> Optional[Any]: + def get_translated_dict(self, template_file_dict: dict) -> Optional[Any]: try: validator = SamTemplateValidator( template_file_dict, ManagedPolicyLoader(self.iam_client), profile=self.profile, region=self.region ) - translated_dict = yaml.load(validator.get_translated_template(), Loader=yaml.FullLoader) + translated_dict = yaml.load(validator.get_translated_template_if_valid(), Loader=yaml.FullLoader) return translated_dict except InvalidSamDocumentException as e: - click.echo("Template provided was invalid SAM Template.") raise InvalidSamTemplateException(str(e)) from e except NoRegionFound as no_region_found_e: raise UserException( @@ -62,19 +69,19 @@ def get_translated_dict(self, template_file_dict) -> Optional[Any]: raise SamListUnknownClientError(msg=str(e)) from e def produce(self): - sam_template = _read_sam_file(self.template_file) + sam_template = read_sam_file(self.template_file) translated_dict = self.get_translated_dict(template_file_dict=sam_template) - stacks, _ = SamLocalStackProvider.get_stacks(template_file="", template_dict_format=translated_dict) + stacks, _ = SamLocalStackProvider.get_stacks(template_file="", template_dict=translated_dict) if not stacks or not stacks[0].resources: raise SamListLocalResourcesNotFoundError(msg="No local resources found.") resources_dict = {} for local_resource in stacks[0].resources: # Set the PhysicalID to "-" if there is no corresponding PhysicalID resources_dict[local_resource] = "-" - - for logical_id, physical_id in resources_dict.items(): - resource_data = ResourcesDef(LogicalResourceId=logical_id, PhysicalResourceId=physical_id) + resource_data = ResourcesDef( + LogicalResourceId=local_resource, PhysicalResourceId=resources_dict[local_resource] + ) mapped_output = self.mapper.map(dataclasses.asdict(resource_data)) self.consumer.consume(mapped_output) diff --git a/samcli/lib/list/resources/resources_def.py b/samcli/lib/list/resources/resources_def.py index 1bfb19a8818..37bebecbba3 100644 --- a/samcli/lib/list/resources/resources_def.py +++ b/samcli/lib/list/resources/resources_def.py @@ -1,5 +1,5 @@ """ -in progress +The container for Resources """ from dataclasses import dataclass diff --git a/samcli/lib/providers/exceptions.py b/samcli/lib/providers/exceptions.py index ff462b1d013..5f59bc39f8f 100644 --- a/samcli/lib/providers/exceptions.py +++ b/samcli/lib/providers/exceptions.py @@ -84,3 +84,12 @@ def resource_identifier(self) -> "ResourceIdentifier": @property def property_name(self) -> str: return self._property_name + + +class MissingTemplateFile(Exception): + """ + Raised when a required template file is missing + """ + + def __init__(self) -> None: + super().__init__("A template file is required but missing.") diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index 5100c72aeb7..3e606f4063e 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -7,7 +7,7 @@ from urllib.parse import unquote, urlparse from samcli.commands._utils.template import get_template_data -from samcli.lib.providers.exceptions import RemoteStackLocationNotSupported +from samcli.lib.providers.exceptions import RemoteStackLocationNotSupported, MissingTemplateFile from samcli.lib.providers.provider import Stack, get_full_path from samcli.lib.providers.sam_base_provider import SamBaseProvider from samcli.lib.utils.resources import AWS_CLOUDFORMATION_STACK, AWS_SERVERLESS_APPLICATION @@ -192,13 +192,13 @@ def _convert_cfn_stack_resource( @staticmethod def get_stacks( - template_file: str, + template_file: Optional[str], stack_path: str = "", name: str = "", parameter_overrides: Optional[Dict] = None, global_parameter_overrides: Optional[Dict] = None, metadata: Optional[Dict] = None, - template_dict_format: Optional[Dict] = None, + template_dict: Optional[Dict] = None, ) -> Tuple[List[Stack], List[str]]: """ Recursively extract stacks from a template file. @@ -206,7 +206,7 @@ def get_stacks( Parameters ---------- template_file: str - the file path of the template to extract stacks from + the file path of the template to extract stacks from. Only one of either template_dict or template_file is required stack_path: str the stack path of the parent stack, for root stack, it is "" name: str @@ -219,8 +219,8 @@ def get_stacks( that might want to get substituted within the template and its child templates metadata: Optional[Dict] Optional dictionary of nested stack resource metadata values. - template_dict_format: Optional[Dict] - Optional dictionary representing the sam template file to be used instead of the template file + template_dict: Optional[Dict] + dictionary representing the sam template. Only one of either template_dict or template_file is required Returns ------- @@ -229,11 +229,11 @@ def get_stacks( remote_stack_full_paths : List[str] The list of full paths of detected remote stacks """ - template_dict: dict - if not template_dict_format: + if not template_dict: + if not template_file: + raise MissingTemplateFile() template_dict = get_template_data(template_file) - else: - template_dict = template_dict_format + stacks = [ Stack( stack_path, diff --git a/samcli/lib/translate/sam_template_validator.py b/samcli/lib/translate/sam_template_validator.py index 2cc3b459933..def2aa310a6 100644 --- a/samcli/lib/translate/sam_template_validator.py +++ b/samcli/lib/translate/sam_template_validator.py @@ -45,38 +45,7 @@ def __init__(self, sam_template, managed_policy_loader, profile=None, region=Non self.sam_parser = parser.Parser() self.boto3_session = Session(profile_name=profile, region_name=region) - def get_translated_template(self): - """ - Runs the SAM Translator to determine if the template provided is valid and then - returns the translated template if it is - - Returns - ------- - dict - A dictionary representing the translated template file - """ - managed_policy_map = self.managed_policy_loader.load() - - sam_translator = Translator( - managed_policy_map=managed_policy_map, - sam_parser=self.sam_parser, - plugins=[], - boto_session=self.boto3_session, - ) - - self._replace_local_codeuri() - self._replace_local_image() - - try: - template = sam_translator.translate(sam_template=self.sam_template, parameter_values={}) - LOG.debug("Translated template is:\n%s", yaml_dump(template)) - return yaml_dump(template) - except InvalidDocumentException as e: - raise InvalidSamDocumentException( - functools.reduce(lambda message, error: message + " " + str(error), e.causes, str(e)) - ) from e - - def is_valid(self): + def get_translated_template_if_valid(self): """ Runs the SAM Translator to determine if the template provided is valid. This is similar to running a ChangeSet in CloudFormation for a SAM Template @@ -101,6 +70,7 @@ def is_valid(self): try: template = sam_translator.translate(sam_template=self.sam_template, parameter_values={}) LOG.debug("Translated template is:\n%s", yaml_dump(template)) + return yaml_dump(template) except InvalidDocumentException as e: raise InvalidSamDocumentException( functools.reduce(lambda message, error: message + " " + str(error), e.causes, str(e)) diff --git a/tests/functional/commands/validate/lib/test_sam_template_validator.py b/tests/functional/commands/validate/lib/test_sam_template_validator.py index 4a6069c2079..25f35f0e84f 100644 --- a/tests/functional/commands/validate/lib/test_sam_template_validator.py +++ b/tests/functional/commands/validate/lib/test_sam_template_validator.py @@ -39,7 +39,7 @@ def test_valid_template(self): validator = SamTemplateValidator(template, managed_policy_mock, region="us-east-1") # Should not throw an exception - validator.is_valid() + validator.get_translated_template_if_valid() def test_invalid_template(self): template = { @@ -59,7 +59,7 @@ def test_invalid_template(self): validator = SamTemplateValidator(template, managed_policy_mock, region="us-east-1") with self.assertRaises(InvalidSamDocumentException): - validator.is_valid() + validator.get_translated_template_if_valid() def test_valid_template_with_local_code_for_function(self): template = { @@ -79,7 +79,7 @@ def test_valid_template_with_local_code_for_function(self): validator = SamTemplateValidator(template, managed_policy_mock, region="us-east-1") # Should not throw an exception - validator.is_valid() + validator.get_translated_template_if_valid() def test_valid_template_with_local_code_for_layer_version(self): template = { @@ -96,7 +96,7 @@ def test_valid_template_with_local_code_for_layer_version(self): validator = SamTemplateValidator(template, managed_policy_mock, region="us-east-1") # Should not throw an exception - validator.is_valid() + validator.get_translated_template_if_valid() def test_valid_template_with_local_code_for_api(self): template = { @@ -116,7 +116,7 @@ def test_valid_template_with_local_code_for_api(self): validator = SamTemplateValidator(template, managed_policy_mock, region="us-east-1") # Should not throw an exception - validator.is_valid() + validator.get_translated_template_if_valid() def test_valid_template_with_DefinitionBody_for_api(self): template = { @@ -136,7 +136,7 @@ def test_valid_template_with_DefinitionBody_for_api(self): validator = SamTemplateValidator(template, managed_policy_mock, region="us-east-1") # Should not throw an exception - validator.is_valid() + validator.get_translated_template_if_valid() def test_valid_template_with_s3_object_passed(self): template = { @@ -168,7 +168,7 @@ def test_valid_template_with_s3_object_passed(self): validator = SamTemplateValidator(template, managed_policy_mock, region="us-east-1") # Should not throw an exception - validator.is_valid() + validator.get_translated_template_if_valid() # validate the CodeUri was not changed self.assertEqual( @@ -190,4 +190,4 @@ def test_valid_api_request_model_template(self, template_path): validator = SamTemplateValidator(template, managed_policy_mock, region="us-east-1") # Should not throw an exception - validator.is_valid() + validator.get_translated_template_if_valid() diff --git a/tests/unit/commands/list/resources/test_resources_context.py b/tests/unit/commands/list/resources/test_resources_context.py index 7ed17d4c4cb..67c60d0c4c9 100644 --- a/tests/unit/commands/list/resources/test_resources_context.py +++ b/tests/unit/commands/list/resources/test_resources_context.py @@ -14,7 +14,7 @@ class TestResourcesContext(TestCase): @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.resources.resource_mapping_producer._read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.read_sam_file") @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") def test_resources_local_only_no_stack_name( self, mock_get_translated_dict, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo @@ -150,12 +150,16 @@ def test_resources_local_only_no_stack_name( @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.resources.resource_mapping_producer._read_sam_file") - @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template") + @patch("samcli.lib.list.resources.resource_mapping_producer.read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template_if_valid") def test_clienterror_exception( - self, mock_get_translated_template, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo + self, + mock_get_translated_template_if_valid, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, ): - mock_get_translated_template.side_effect = ClientError( + mock_get_translated_template_if_valid.side_effect = ClientError( {"Error": {"Code": "ExpiredToken", "Message": "The security token included in the request is expired"}}, "DescribeStacks", ) @@ -167,10 +171,14 @@ def test_clienterror_exception( @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.resources.resource_mapping_producer._read_sam_file") - @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template") + @patch("samcli.lib.list.resources.resource_mapping_producer.read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template_if_valid") def test_get_translate_dict_invalid_template_error( - self, mock_get_translated_template, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo + self, + mock_get_translated_template_if_valid, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, ): mock_sam_file_reader.return_value = { "AWSTemplateFormatVersion": "2010-09-09", @@ -190,7 +198,7 @@ def test_get_translate_dict_invalid_template_error( } }, } - mock_get_translated_template.side_effect = InvalidSamDocumentException() + mock_get_translated_template_if_valid.side_effect = InvalidSamDocumentException() with self.assertRaises(InvalidSamTemplateException): with ResourcesContext( stack_name=None, output="json", region="us-east-1", profile=None, template_file=None @@ -223,7 +231,7 @@ def test_init_clients_no_input_region_get_region_from_session( @patch("samcli.lib.translate.sam_template_validator.Session") @patch("samcli.lib.translate.sam_template_validator.Translator") @patch("samcli.lib.translate.sam_template_validator.parser") - def test_get_translated_template_raises_exception(self, sam_parser, sam_translator, boto_session_patch): + def test_get_translated_template_if_valid_raises_exception(self, sam_parser, sam_translator, boto_session_patch): managed_policy_mock = Mock() managed_policy_mock.load.return_value = {"policy": "SomePolicy"} template = {"a": "b"} @@ -241,7 +249,7 @@ def test_get_translated_template_raises_exception(self, sam_parser, sam_translat validator = SamTemplateValidator(template, managed_policy_mock) with self.assertRaises(InvalidSamDocumentException): - validator.get_translated_template() + validator.get_translated_template_if_valid() sam_translator.assert_called_once_with( managed_policy_map={"policy": "SomePolicy"}, sam_parser=parser, plugins=[], boto_session=boto_session_mock @@ -253,7 +261,7 @@ def test_get_translated_template_raises_exception(self, sam_parser, sam_translat @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.resources.resource_mapping_producer._read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.read_sam_file") @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") @patch("samcli.lib.list.resources.resource_mapping_producer.SamLocalStackProvider.get_stacks") def test_resources_get_stacks_returns_empty( diff --git a/tests/unit/commands/validate/lib/test_sam_template_validator.py b/tests/unit/commands/validate/lib/test_sam_template_validator.py index c1dd79062aa..3008419e8b3 100644 --- a/tests/unit/commands/validate/lib/test_sam_template_validator.py +++ b/tests/unit/commands/validate/lib/test_sam_template_validator.py @@ -30,7 +30,7 @@ def test_is_valid_returns_true(self, sam_parser, sam_translator, boto_session_pa validator = SamTemplateValidator(template, managed_policy_mock, profile="profile", region="region") # Should not throw an Exception - validator.is_valid() + validator.get_translated_template_if_valid() boto_session_patch.assert_called_once_with(profile_name="profile", region_name="region") sam_translator.assert_called_once_with( @@ -60,7 +60,7 @@ def test_is_valid_raises_exception(self, sam_parser, sam_translator, boto_sessio validator = SamTemplateValidator(template, managed_policy_mock) with self.assertRaises(InvalidSamDocumentException): - validator.is_valid() + validator.get_translated_template_if_valid() sam_translator.assert_called_once_with( managed_policy_map={"policy": "SomePolicy"}, sam_parser=parser, plugins=[], boto_session=boto_session_mock diff --git a/tests/unit/commands/validate/test_cli.py b/tests/unit/commands/validate/test_cli.py index 0606658a35e..97baa4b8ce7 100644 --- a/tests/unit/commands/validate/test_cli.py +++ b/tests/unit/commands/validate/test_cli.py @@ -8,25 +8,25 @@ from samcli.commands.local.cli_common.user_exceptions import SamTemplateNotFoundException, InvalidSamTemplateException from samcli.lib.translate.exceptions import InvalidSamDocumentException from samcli.commands.validate.validate import do_cli -from samcli.lib.translate.translate_utils import _read_sam_file +from samcli.commands.translate.translate_utils import read_sam_file ctx_mock = namedtuple("ctx", ["profile", "region"]) class TestValidateCli(TestCase): - @patch("samcli.lib.translate.translate_utils.click") - @patch("samcli.lib.translate.translate_utils.os.path.exists") + @patch("samcli.commands.translate.translate_utils.click") + @patch("samcli.commands.translate.translate_utils.os.path.exists") def test_file_not_found(self, path_exists_patch, click_patch): template_path = "path_to_template" path_exists_patch.return_value = False with self.assertRaises(SamTemplateNotFoundException): - _read_sam_file(template_path) + read_sam_file(template_path) @patch("samcli.yamlhelper.yaml_parse") - @patch("samcli.lib.translate.translate_utils.click") - @patch("samcli.lib.translate.translate_utils.os.path.exists") + @patch("samcli.commands.translate.translate_utils.click") + @patch("samcli.commands.translate.translate_utils.os.path.exists") def test_file_parsed(self, path_exists_patch, click_patch, yaml_parse_patch): template_path = "path_to_template" @@ -34,47 +34,47 @@ def test_file_parsed(self, path_exists_patch, click_patch, yaml_parse_patch): yaml_parse_patch.return_value = {"a": "b"} - actual_template = _read_sam_file(template_path) + actual_template = read_sam_file(template_path) self.assertEqual(actual_template, {"a": "b"}) @patch("samcli.lib.translate.sam_template_validator.SamTemplateValidator") @patch("samcli.commands.validate.validate.click") - @patch("samcli.commands.validate.validate._read_sam_file") + @patch("samcli.commands.validate.validate.read_sam_file") def test_template_fails_validation(self, read_sam_file_patch, click_patch, template_valiadator): template_path = "path_to_template" read_sam_file_patch.return_value = {"a": "b"} - is_valid_mock = Mock() - is_valid_mock.is_valid.side_effect = InvalidSamDocumentException - template_valiadator.return_value = is_valid_mock + get_translated_template_if_valid_mock = Mock() + get_translated_template_if_valid_mock.get_translated_template_if_valid.side_effect = InvalidSamDocumentException + template_valiadator.return_value = get_translated_template_if_valid_mock with self.assertRaises(InvalidSamTemplateException): do_cli(ctx=ctx_mock(profile="profile", region="region"), template=template_path) @patch("samcli.lib.translate.sam_template_validator.SamTemplateValidator") @patch("samcli.commands.validate.validate.click") - @patch("samcli.commands.validate.validate._read_sam_file") + @patch("samcli.commands.validate.validate.read_sam_file") def test_no_credentials_provided(self, read_sam_file_patch, click_patch, template_valiadator): template_path = "path_to_template" read_sam_file_patch.return_value = {"a": "b"} - is_valid_mock = Mock() - is_valid_mock.is_valid.side_effect = NoCredentialsError - template_valiadator.return_value = is_valid_mock + get_translated_template_if_valid_mock = Mock() + get_translated_template_if_valid_mock.get_translated_template_if_valid.side_effect = NoCredentialsError + template_valiadator.return_value = get_translated_template_if_valid_mock with self.assertRaises(UserException): do_cli(ctx=ctx_mock(profile="profile", region="region"), template=template_path) @patch("samcli.lib.translate.sam_template_validator.SamTemplateValidator") @patch("samcli.commands.validate.validate.click") - @patch("samcli.commands.validate.validate._read_sam_file") + @patch("samcli.commands.validate.validate.read_sam_file") def test_template_passes_validation(self, read_sam_file_patch, click_patch, template_valiadator): template_path = "path_to_template" read_sam_file_patch.return_value = {"a": "b"} - is_valid_mock = Mock() - is_valid_mock.is_valid.return_value = True - template_valiadator.return_value = is_valid_mock + get_translated_template_if_valid_mock = Mock() + get_translated_template_if_valid_mock.get_translated_template_if_valid.return_value = True + template_valiadator.return_value = get_translated_template_if_valid_mock do_cli(ctx=ctx_mock(profile="profile", region="region"), template=template_path) From 8d9a7984e21886448bedb1b73f0ff1b883785a40 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 6 Jul 2022 19:04:47 -0700 Subject: [PATCH 50/72] Fixed formatting --- samcli/lib/list/resources/resource_mapping_producer.py | 2 +- samcli/lib/providers/sam_stack_provider.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index 2a3d4a8d6e7..e672c5b776a 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -11,7 +11,7 @@ from samtranslator.translator.arn_generator import NoRegionFound from samcli.commands.list.exceptions import SamListLocalResourcesNotFoundError, SamListUnknownClientError -from samcli.lib.list.list_interfaces import Producer, Mapper, ListInfoPullerConsumer +from samcli.lib.list.list_interfaces import Producer from samcli.lib.list.resources.resources_def import ResourcesDef from samcli.lib.translate.sam_template_validator import SamTemplateValidator from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index 3e606f4063e..6868a4c0795 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -206,7 +206,8 @@ def get_stacks( Parameters ---------- template_file: str - the file path of the template to extract stacks from. Only one of either template_dict or template_file is required + the file path of the template to extract stacks from. Only one of either template_dict or template_file + is required stack_path: str the stack path of the parent stack, for root stack, it is "" name: str From 91a06a3472ed6a10daeafacd0d7e70176bf375c0 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 7 Jul 2022 10:31:17 -0700 Subject: [PATCH 51/72] Fixed typing errors --- samcli/lib/providers/exceptions.py | 6 +++--- samcli/lib/providers/provider.py | 4 ++-- samcli/lib/providers/sam_stack_provider.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/samcli/lib/providers/exceptions.py b/samcli/lib/providers/exceptions.py index 5f59bc39f8f..b6477673bb5 100644 --- a/samcli/lib/providers/exceptions.py +++ b/samcli/lib/providers/exceptions.py @@ -3,7 +3,7 @@ """ from typing import TYPE_CHECKING - +from samcli.commands.exceptions import UserException if TYPE_CHECKING: # pragma: no cover from samcli.lib.providers.provider import ResourceIdentifier @@ -86,10 +86,10 @@ def property_name(self) -> str: return self._property_name -class MissingTemplateFile(Exception): +class MissingTemplateFile(UserException): """ Raised when a required template file is missing """ def __init__(self) -> None: - super().__init__("A template file is required but missing.") + super().__init__(message="A template file or a template dict is required but both are missing.") diff --git a/samcli/lib/providers/provider.py b/samcli/lib/providers/provider.py index 3c07a082cb0..ebfe4be6075 100644 --- a/samcli/lib/providers/provider.py +++ b/samcli/lib/providers/provider.py @@ -508,9 +508,9 @@ def __init__( self, parent_stack_path: str, name: str, - location: str, + location: Optional[str], parameters: Optional[Dict], - template_dict: Dict, + template_dict: Optional[Dict], metadata: Optional[Dict] = None, ): self.parent_stack_path = parent_stack_path diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index 6868a4c0795..7448879df22 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -24,9 +24,9 @@ class SamLocalStackProvider(SamBaseProvider): def __init__( self, - template_file: str, + template_file: Optional[str], stack_path: str, - template_dict: Dict, + template_dict: Optional[Dict], parameter_overrides: Optional[Dict] = None, global_parameter_overrides: Optional[Dict] = None, ): @@ -192,7 +192,7 @@ def _convert_cfn_stack_resource( @staticmethod def get_stacks( - template_file: Optional[str], + template_file: Optional[str] = None, stack_path: str = "", name: str = "", parameter_overrides: Optional[Dict] = None, From 3479342aa98648303bc7a800ecf56133780c0c34 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 7 Jul 2022 10:55:12 -0700 Subject: [PATCH 52/72] Reverted typing --- samcli/lib/providers/provider.py | 4 ++-- samcli/lib/providers/sam_stack_provider.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/samcli/lib/providers/provider.py b/samcli/lib/providers/provider.py index ebfe4be6075..3c07a082cb0 100644 --- a/samcli/lib/providers/provider.py +++ b/samcli/lib/providers/provider.py @@ -508,9 +508,9 @@ def __init__( self, parent_stack_path: str, name: str, - location: Optional[str], + location: str, parameters: Optional[Dict], - template_dict: Optional[Dict], + template_dict: Dict, metadata: Optional[Dict] = None, ): self.parent_stack_path = parent_stack_path diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index 7448879df22..8565db5757c 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -24,9 +24,9 @@ class SamLocalStackProvider(SamBaseProvider): def __init__( self, - template_file: Optional[str], + template_file: str, stack_path: str, - template_dict: Optional[Dict], + template_dict: Dict, parameter_overrides: Optional[Dict] = None, global_parameter_overrides: Optional[Dict] = None, ): @@ -230,6 +230,7 @@ def get_stacks( remote_stack_full_paths : List[str] The list of full paths of detected remote stacks """ + str(template_file) if not template_dict: if not template_file: raise MissingTemplateFile() From da1eda754ef5ebd63710af116e27c7c8519df46b Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 7 Jul 2022 11:35:39 -0700 Subject: [PATCH 53/72] Fixed error with typing --- samcli/lib/providers/sam_stack_provider.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index 8565db5757c..8f2872c1b00 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -230,11 +230,13 @@ def get_stacks( remote_stack_full_paths : List[str] The list of full paths of detected remote stacks """ - str(template_file) - if not template_dict: - if not template_file: - raise MissingTemplateFile() + + if template_file: template_dict = get_template_data(template_file) + elif template_dict: + template_file = "" + else: + raise MissingTemplateFile() stacks = [ Stack( From 4cd1506b2e319e78cbb6e48522153fc4038a2af1 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 7 Jul 2022 14:29:03 -0700 Subject: [PATCH 54/72] Made changes to handling optional params --- samcli/lib/providers/sam_stack_provider.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index 8f2872c1b00..3173f30ed36 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -237,6 +237,8 @@ def get_stacks( template_file = "" else: raise MissingTemplateFile() + template_file = "" + template_dict = {} stacks = [ Stack( From 0851eb342ac83da37feb987cfe3ea4325b7bbce6 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 7 Jul 2022 14:43:11 -0700 Subject: [PATCH 55/72] Fixes to typing errors --- .../lib/list/resources/resource_mapping_producer.py | 2 +- samcli/lib/providers/sam_stack_provider.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index e672c5b776a..726d0fece6d 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -73,7 +73,7 @@ def produce(self): translated_dict = self.get_translated_dict(template_file_dict=sam_template) - stacks, _ = SamLocalStackProvider.get_stacks(template_file="", template_dict=translated_dict) + stacks, _ = SamLocalStackProvider.get_stacks(template_file="", template_dictionary=translated_dict) if not stacks or not stacks[0].resources: raise SamListLocalResourcesNotFoundError(msg="No local resources found.") resources_dict = {} diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index 3173f30ed36..8df5f530be6 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -198,7 +198,7 @@ def get_stacks( parameter_overrides: Optional[Dict] = None, global_parameter_overrides: Optional[Dict] = None, metadata: Optional[Dict] = None, - template_dict: Optional[Dict] = None, + template_dictionary: Optional[Dict] = None, ) -> Tuple[List[Stack], List[str]]: """ Recursively extract stacks from a template file. @@ -220,7 +220,7 @@ def get_stacks( that might want to get substituted within the template and its child templates metadata: Optional[Dict] Optional dictionary of nested stack resource metadata values. - template_dict: Optional[Dict] + template_dictionary: Optional[Dict] dictionary representing the sam template. Only one of either template_dict or template_file is required Returns @@ -230,15 +230,14 @@ def get_stacks( remote_stack_full_paths : List[str] The list of full paths of detected remote stacks """ - + template_dict: dict if template_file: template_dict = get_template_data(template_file) - elif template_dict: + elif template_dictionary: template_file = "" + template_dict = template_dictionary else: raise MissingTemplateFile() - template_file = "" - template_dict = {} stacks = [ Stack( From 9de6afa3990b88c4b1d38ab6ee46e4af5210fbe9 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Sun, 10 Jul 2022 22:48:13 -0700 Subject: [PATCH 56/72] Made edits based on comments --- .../list/resources/resources_context.py | 1 - samcli/commands/local/invoke/cli.py | 2 +- samcli/commands/local/start_api/cli.py | 2 +- samcli/commands/local/start_lambda/cli.py | 2 +- .../validate/lib}/exceptions.py | 0 samcli/commands/validate/validate.py | 28 +++++++++++++-- samcli/lib/list/mapper_consumer_factory.py | 13 +++++-- .../resources/resource_mapping_producer.py | 30 ++++++++++++---- samcli/lib/providers/cfn_base_api_provider.py | 2 +- samcli/lib/providers/exceptions.py | 10 ------ samcli/lib/providers/sam_api_provider.py | 2 +- samcli/lib/providers/sam_stack_provider.py | 7 ++-- samcli/lib/samlib/wrapper.py | 2 +- .../lib/translate/sam_template_validator.py | 2 +- .../lib/test_sam_template_validator.py | 2 +- .../list/resources/test_resources_context.py | 12 +++---- tests/unit/commands/local/invoke/test_cli.py | 2 +- .../local/lib/test_sam_api_provider.py | 2 +- .../unit/commands/local/start_api/test_cli.py | 2 +- .../commands/local/start_lambda/test_cli.py | 2 +- .../lib/test_sam_template_validator.py | 2 +- tests/unit/commands/validate/test_cli.py | 35 +++++++++---------- .../iac/cfn/test_cfn_iac_implementation.py | 2 +- 23 files changed, 100 insertions(+), 64 deletions(-) rename samcli/{lib/translate => commands/validate/lib}/exceptions.py (100%) diff --git a/samcli/commands/list/resources/resources_context.py b/samcli/commands/list/resources/resources_context.py index caabf73fa42..de61b8167cc 100644 --- a/samcli/commands/list/resources/resources_context.py +++ b/samcli/commands/list/resources/resources_context.py @@ -46,7 +46,6 @@ def run(self) -> None: container = factory.create(producer=ProducersEnum.RESOURCES_PRODUCER, output=self.output) resource_producer = ResourceMappingProducer( stack_name=self.stack_name, - output=self.output, region=self.region, profile=self.profile, template_file=self.template_file, diff --git a/samcli/commands/local/invoke/cli.py b/samcli/commands/local/invoke/cli.py index 7ea75653133..eb97e606fcc 100644 --- a/samcli/commands/local/invoke/cli.py +++ b/samcli/commands/local/invoke/cli.py @@ -138,7 +138,7 @@ def do_cli( # pylint: disable=R0914 from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.local.cli_common.invoke_context import InvokeContext from samcli.local.lambdafn.exceptions import FunctionNotFound - from samcli.lib.translate.exceptions import InvalidSamDocumentException + from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError, NoPrivilegeException from samcli.local.docker.manager import DockerImagePullFailedException from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/samcli/commands/local/start_api/cli.py b/samcli/commands/local/start_api/cli.py index eca5920be56..44475ea2031 100644 --- a/samcli/commands/local/start_api/cli.py +++ b/samcli/commands/local/start_api/cli.py @@ -152,7 +152,7 @@ def do_cli( # pylint: disable=R0914 from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.exceptions import UserException from samcli.commands.local.lib.local_api_service import LocalApiService - from samcli.lib.translate.exceptions import InvalidSamDocumentException + from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/samcli/commands/local/start_lambda/cli.py b/samcli/commands/local/start_lambda/cli.py index 2c2afe20a3a..730c4626751 100644 --- a/samcli/commands/local/start_lambda/cli.py +++ b/samcli/commands/local/start_lambda/cli.py @@ -160,7 +160,7 @@ def do_cli( # pylint: disable=R0914 from samcli.commands.local.cli_common.user_exceptions import UserException from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.local.lib.local_lambda_service import LocalLambdaService - from samcli.lib.translate.exceptions import InvalidSamDocumentException + from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/samcli/lib/translate/exceptions.py b/samcli/commands/validate/lib/exceptions.py similarity index 100% rename from samcli/lib/translate/exceptions.py rename to samcli/commands/validate/lib/exceptions.py diff --git a/samcli/commands/validate/validate.py b/samcli/commands/validate/validate.py index b98dc66baed..f3aab72b43c 100644 --- a/samcli/commands/validate/validate.py +++ b/samcli/commands/validate/validate.py @@ -1,6 +1,7 @@ """ CLI Command for Validating a SAM Template """ +import os import boto3 from botocore.exceptions import NoCredentialsError import click @@ -13,7 +14,6 @@ from samcli.lib.telemetry.metric import track_command from samcli.cli.cli_config_file import configuration_option, TomlProvider from samcli.lib.utils.version_checker import check_newer_version -from samcli.commands.translate.translate_utils import read_sam_file @click.command("validate", short_help="Validate an AWS SAM template.") @@ -46,10 +46,10 @@ def do_cli(ctx, template): from samcli.commands.exceptions import UserException from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException - from samcli.lib.translate.exceptions import InvalidSamDocumentException + from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.lib.translate.sam_template_validator import SamTemplateValidator - sam_template = read_sam_file(template) + sam_template = _read_sam_file(template) iam_client = boto3.client("iam") validator = SamTemplateValidator( @@ -72,3 +72,25 @@ def do_cli(ctx, template): ) from e click.secho("{} is a valid SAM Template".format(template), fg="green") + + +def _read_sam_file(template): + """ + Reads the file (json and yaml supported) provided and returns the dictionary representation of the file. + + :param str template: Path to the template file + :return dict: Dictionary representing the SAM Template + :raises: SamTemplateNotFoundException when the template file does not exist + """ + + from samcli.commands.local.cli_common.user_exceptions import SamTemplateNotFoundException + from samcli.yamlhelper import yaml_parse + + if not os.path.exists(template): + click.secho("SAM Template Not Found", bg="red") + raise SamTemplateNotFoundException("Template at {} is not found".format(template)) + + with click.open_file(template, "r", encoding="utf-8") as sam_template: + sam_template = yaml_parse(sam_template.read()) + + return sam_template diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py index 4bfa137d6e6..f40858a26f8 100644 --- a/samcli/lib/list/mapper_consumer_factory.py +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -4,14 +4,21 @@ from samcli.lib.list.list_interfaces import MapperConsumerFactoryInterface from samcli.lib.list.data_to_json_mapper import DataToJsonMapper from samcli.commands.list.json_consumer import StringConsumerJsonOutput +from samcli.commands.list.table_consumer import StringConsumerTableOutput from samcli.lib.list.mapper_consumer_container import MapperConsumerContainer from samcli.lib.list.list_interfaces import ProducersEnum +from samcli.lib.list.stack_outputs.stack_output_to_table_mapper import StackOutputToTableMapper class MapperConsumerFactory(MapperConsumerFactoryInterface): def create(self, producer: ProducersEnum, output: str) -> MapperConsumerContainer: # Will add conditions here to return different sorts of containers later on - data_to_json_mapper = DataToJsonMapper() - json_consumer = StringConsumerJsonOutput() - container = MapperConsumerContainer(data_to_json_mapper, json_consumer) + if output == "json": + data_to_json_mapper = DataToJsonMapper() + json_consumer = StringConsumerJsonOutput() + container = MapperConsumerContainer(data_to_json_mapper, json_consumer) + return container + stack_outputs_mapper = StackOutputToTableMapper() + table_consumer = StringConsumerTableOutput() + container = MapperConsumerContainer(stack_outputs_mapper, table_consumer) return container diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index 726d0fece6d..b6c22468a4c 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -1,7 +1,7 @@ """ The producer for the 'sam list resources' command """ -from typing import Optional, Any +from typing import Optional, Any, Dict import dataclasses import logging import yaml @@ -15,10 +15,10 @@ from samcli.lib.list.resources.resources_def import ResourcesDef from samcli.lib.translate.sam_template_validator import SamTemplateValidator from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider -from samcli.commands.translate.translate_utils import read_sam_file -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException from samcli.commands.exceptions import UserException +from samcli.commands._utils.template import get_template_data LOG = logging.getLogger(__name__) @@ -27,7 +27,6 @@ class ResourceMappingProducer(Producer): def __init__( self, stack_name, - output, region, profile, template_file, @@ -37,7 +36,6 @@ def __init__( consumer, ): self.stack_name = stack_name - self.output = output self.region = region self.profile = profile self.template_file = template_file @@ -46,8 +44,23 @@ def __init__( self.mapper = mapper self.consumer = consumer - def get_translated_dict(self, template_file_dict: dict) -> Optional[Any]: + def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Optional[Any]: + """ + Performs a sam translate on a template and returns the translated template in the form of a dictionary or + raises exceptions accordingly + + Parameters + ---------- + template_file_dict: Dict[Any, Any] + The template in dictionary format to be translated + + Returns + ------- + response: Dict[Any, Any] + The dictionary representing the translated template + """ try: + # Note to check if IAM can be mocked to get around doing a translate without it validator = SamTemplateValidator( template_file_dict, ManagedPolicyLoader(self.iam_client), profile=self.profile, region=self.region ) @@ -69,7 +82,10 @@ def get_translated_dict(self, template_file_dict: dict) -> Optional[Any]: raise SamListUnknownClientError(msg=str(e)) from e def produce(self): - sam_template = read_sam_file(self.template_file) + """ + Produces the resource data to be printed + """ + sam_template = get_template_data(self.template_file) translated_dict = self.get_translated_dict(template_file_dict=sam_template) diff --git a/samcli/lib/providers/cfn_base_api_provider.py b/samcli/lib/providers/cfn_base_api_provider.py index 026cdcca05e..e9ba77ddc3d 100644 --- a/samcli/lib/providers/cfn_base_api_provider.py +++ b/samcli/lib/providers/cfn_base_api_provider.py @@ -16,7 +16,7 @@ CORS_MAX_AGE_HEADER, ) from samcli.local.apigw.local_apigw_service import Route -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException LOG = logging.getLogger(__name__) diff --git a/samcli/lib/providers/exceptions.py b/samcli/lib/providers/exceptions.py index b6477673bb5..1370bf9af8d 100644 --- a/samcli/lib/providers/exceptions.py +++ b/samcli/lib/providers/exceptions.py @@ -3,7 +3,6 @@ """ from typing import TYPE_CHECKING -from samcli.commands.exceptions import UserException if TYPE_CHECKING: # pragma: no cover from samcli.lib.providers.provider import ResourceIdentifier @@ -84,12 +83,3 @@ def resource_identifier(self) -> "ResourceIdentifier": @property def property_name(self) -> str: return self._property_name - - -class MissingTemplateFile(UserException): - """ - Raised when a required template file is missing - """ - - def __init__(self) -> None: - super().__init__(message="A template file or a template dict is required but both are missing.") diff --git a/samcli/lib/providers/sam_api_provider.py b/samcli/lib/providers/sam_api_provider.py index f3b44a9711e..5493baedb23 100644 --- a/samcli/lib/providers/sam_api_provider.py +++ b/samcli/lib/providers/sam_api_provider.py @@ -5,7 +5,7 @@ from samcli.lib.providers.api_collector import ApiCollector from samcli.lib.providers.cfn_base_api_provider import CfnBaseApiProvider -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.lib.providers.provider import Stack from samcli.lib.utils.colors import Colored from samcli.local.apigw.local_apigw_service import Route diff --git a/samcli/lib/providers/sam_stack_provider.py b/samcli/lib/providers/sam_stack_provider.py index 8df5f530be6..c6adf8476a2 100644 --- a/samcli/lib/providers/sam_stack_provider.py +++ b/samcli/lib/providers/sam_stack_provider.py @@ -7,10 +7,11 @@ from urllib.parse import unquote, urlparse from samcli.commands._utils.template import get_template_data -from samcli.lib.providers.exceptions import RemoteStackLocationNotSupported, MissingTemplateFile +from samcli.lib.providers.exceptions import RemoteStackLocationNotSupported from samcli.lib.providers.provider import Stack, get_full_path from samcli.lib.providers.sam_base_provider import SamBaseProvider from samcli.lib.utils.resources import AWS_CLOUDFORMATION_STACK, AWS_SERVERLESS_APPLICATION +from samcli.commands._utils.template import TemplateNotFoundException LOG = logging.getLogger(__name__) @@ -237,7 +238,9 @@ def get_stacks( template_file = "" template_dict = template_dictionary else: - raise MissingTemplateFile() + raise TemplateNotFoundException( + message="A template file or a template dict is required but both are missing." + ) stacks = [ Stack( diff --git a/samcli/lib/samlib/wrapper.py b/samcli/lib/samlib/wrapper.py index f0c4536d30e..08a52a75239 100644 --- a/samcli/lib/samlib/wrapper.py +++ b/samcli/lib/samlib/wrapper.py @@ -24,7 +24,7 @@ from samtranslator.translator.translator import prepare_plugins from samtranslator.validator.validator import SamTemplateValidator -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from .local_uri_plugin import SupportLocalUriPlugin diff --git a/samcli/lib/translate/sam_template_validator.py b/samcli/lib/translate/sam_template_validator.py index def2aa310a6..b7ee8b285dc 100644 --- a/samcli/lib/translate/sam_template_validator.py +++ b/samcli/lib/translate/sam_template_validator.py @@ -13,7 +13,7 @@ from samcli.lib.utils.packagetype import ZIP, IMAGE from samcli.lib.utils.resources import AWS_SERVERLESS_FUNCTION from samcli.yamlhelper import yaml_dump -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException LOG = logging.getLogger(__name__) diff --git a/tests/functional/commands/validate/lib/test_sam_template_validator.py b/tests/functional/commands/validate/lib/test_sam_template_validator.py index 25f35f0e84f..659663b7e60 100644 --- a/tests/functional/commands/validate/lib/test_sam_template_validator.py +++ b/tests/functional/commands/validate/lib/test_sam_template_validator.py @@ -6,7 +6,7 @@ import samcli.yamlhelper as yamlhelper from samcli.lib.translate.sam_template_validator import SamTemplateValidator -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException # Out of TestValidate's scope because https://stackoverflow.com/a/47224266 TEMPLATE_DIR = "tests/functional/commands/validate/lib/models" diff --git a/tests/unit/commands/list/resources/test_resources_context.py b/tests/unit/commands/list/resources/test_resources_context.py index 67c60d0c4c9..af021063c54 100644 --- a/tests/unit/commands/list/resources/test_resources_context.py +++ b/tests/unit/commands/list/resources/test_resources_context.py @@ -4,9 +4,9 @@ from samcli.commands.list.resources.resources_context import ResourcesContext from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.commands.exceptions import RegionError -from samcli.commands.list.exceptions import SamListError, SamListLocalResourcesNotFoundError, SamListUnknownClientError +from samcli.commands.list.exceptions import SamListLocalResourcesNotFoundError, SamListUnknownClientError from samtranslator.public.exceptions import InvalidDocumentException from samcli.lib.translate.sam_template_validator import SamTemplateValidator @@ -14,7 +14,7 @@ class TestResourcesContext(TestCase): @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.resources.resource_mapping_producer.read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") def test_resources_local_only_no_stack_name( self, mock_get_translated_dict, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo @@ -150,7 +150,7 @@ def test_resources_local_only_no_stack_name( @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.resources.resource_mapping_producer.read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template_if_valid") def test_clienterror_exception( self, @@ -171,7 +171,7 @@ def test_clienterror_exception( @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.resources.resource_mapping_producer.read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template_if_valid") def test_get_translate_dict_invalid_template_error( self, @@ -261,7 +261,7 @@ def test_get_translated_template_if_valid_raises_exception(self, sam_parser, sam @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.lib.list.resources.resource_mapping_producer.read_sam_file") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") @patch("samcli.lib.list.resources.resource_mapping_producer.SamLocalStackProvider.get_stacks") def test_resources_get_stacks_returns_empty( diff --git a/tests/unit/commands/local/invoke/test_cli.py b/tests/unit/commands/local/invoke/test_cli.py index 73c23ba745c..4ec958b6103 100644 --- a/tests/unit/commands/local/invoke/test_cli.py +++ b/tests/unit/commands/local/invoke/test_cli.py @@ -9,7 +9,7 @@ from samcli.local.docker.exceptions import ContainerNotStartableException from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.lib.providers.exceptions import InvalidLayerReference -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.commands.exceptions import UserException from samcli.commands.local.invoke.cli import do_cli as invoke_cli, _get_event as invoke_cli_get_event from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError, InvalidIntermediateImageError diff --git a/tests/unit/commands/local/lib/test_sam_api_provider.py b/tests/unit/commands/local/lib/test_sam_api_provider.py index 1307ce29163..705e7bf876d 100644 --- a/tests/unit/commands/local/lib/test_sam_api_provider.py +++ b/tests/unit/commands/local/lib/test_sam_api_provider.py @@ -6,7 +6,7 @@ from unittest.mock import patch, Mock from parameterized import parameterized -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.lib.providers.api_provider import ApiProvider from samcli.lib.providers.provider import Cors, Stack from samcli.local.apigw.local_apigw_service import Route diff --git a/tests/unit/commands/local/start_api/test_cli.py b/tests/unit/commands/local/start_api/test_cli.py index c40f2d84640..8c746f042ff 100644 --- a/tests/unit/commands/local/start_api/test_cli.py +++ b/tests/unit/commands/local/start_api/test_cli.py @@ -11,7 +11,7 @@ from samcli.commands.local.lib.exceptions import NoApisDefined, InvalidIntermediateImageError from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.exceptions import UserException -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError from samcli.local.docker.exceptions import ContainerNotStartableException from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/tests/unit/commands/local/start_lambda/test_cli.py b/tests/unit/commands/local/start_lambda/test_cli.py index a22331dfd0b..10013bfe973 100644 --- a/tests/unit/commands/local/start_lambda/test_cli.py +++ b/tests/unit/commands/local/start_lambda/test_cli.py @@ -6,7 +6,7 @@ from samcli.commands.local.start_lambda.cli import do_cli as start_lambda_cli from samcli.lib.providers.exceptions import InvalidLayerReference from samcli.commands.local.cli_common.user_exceptions import UserException -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.local.docker.exceptions import ContainerNotStartableException from samcli.commands.local.lib.exceptions import OverridesNotWellDefinedError, InvalidIntermediateImageError from samcli.local.docker.lambda_debug_settings import DebuggingNotSupported diff --git a/tests/unit/commands/validate/lib/test_sam_template_validator.py b/tests/unit/commands/validate/lib/test_sam_template_validator.py index 3008419e8b3..305830587c9 100644 --- a/tests/unit/commands/validate/lib/test_sam_template_validator.py +++ b/tests/unit/commands/validate/lib/test_sam_template_validator.py @@ -4,7 +4,7 @@ from samcli.lib.utils.packagetype import IMAGE from samtranslator.public.exceptions import InvalidDocumentException -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.lib.translate.sam_template_validator import SamTemplateValidator diff --git a/tests/unit/commands/validate/test_cli.py b/tests/unit/commands/validate/test_cli.py index 97baa4b8ce7..7d35745339f 100644 --- a/tests/unit/commands/validate/test_cli.py +++ b/tests/unit/commands/validate/test_cli.py @@ -6,27 +6,26 @@ from samcli.commands.exceptions import UserException from samcli.commands.local.cli_common.user_exceptions import SamTemplateNotFoundException, InvalidSamTemplateException -from samcli.lib.translate.exceptions import InvalidSamDocumentException -from samcli.commands.validate.validate import do_cli -from samcli.commands.translate.translate_utils import read_sam_file +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.commands.validate.validate import do_cli, _read_sam_file ctx_mock = namedtuple("ctx", ["profile", "region"]) class TestValidateCli(TestCase): - @patch("samcli.commands.translate.translate_utils.click") - @patch("samcli.commands.translate.translate_utils.os.path.exists") + @patch("samcli.commands.validate.validate.click") + @patch("samcli.commands.validate.validate.os.path.exists") def test_file_not_found(self, path_exists_patch, click_patch): template_path = "path_to_template" path_exists_patch.return_value = False with self.assertRaises(SamTemplateNotFoundException): - read_sam_file(template_path) + _read_sam_file(template_path) @patch("samcli.yamlhelper.yaml_parse") - @patch("samcli.commands.translate.translate_utils.click") - @patch("samcli.commands.translate.translate_utils.os.path.exists") + @patch("samcli.commands.validate.validate.click") + @patch("samcli.commands.validate.validate.os.path.exists") def test_file_parsed(self, path_exists_patch, click_patch, yaml_parse_patch): template_path = "path_to_template" @@ -34,16 +33,16 @@ def test_file_parsed(self, path_exists_patch, click_patch, yaml_parse_patch): yaml_parse_patch.return_value = {"a": "b"} - actual_template = read_sam_file(template_path) + actual_template = _read_sam_file(template_path) self.assertEqual(actual_template, {"a": "b"}) @patch("samcli.lib.translate.sam_template_validator.SamTemplateValidator") @patch("samcli.commands.validate.validate.click") - @patch("samcli.commands.validate.validate.read_sam_file") - def test_template_fails_validation(self, read_sam_file_patch, click_patch, template_valiadator): + @patch("samcli.commands.validate.validate._read_sam_file") + def test_template_fails_validation(self, _read_sam_file_patch, click_patch, template_valiadator): template_path = "path_to_template" - read_sam_file_patch.return_value = {"a": "b"} + _read_sam_file_patch.return_value = {"a": "b"} get_translated_template_if_valid_mock = Mock() get_translated_template_if_valid_mock.get_translated_template_if_valid.side_effect = InvalidSamDocumentException @@ -54,10 +53,10 @@ def test_template_fails_validation(self, read_sam_file_patch, click_patch, templ @patch("samcli.lib.translate.sam_template_validator.SamTemplateValidator") @patch("samcli.commands.validate.validate.click") - @patch("samcli.commands.validate.validate.read_sam_file") - def test_no_credentials_provided(self, read_sam_file_patch, click_patch, template_valiadator): + @patch("samcli.commands.validate.validate._read_sam_file") + def test_no_credentials_provided(self, _read_sam_file_patch, click_patch, template_valiadator): template_path = "path_to_template" - read_sam_file_patch.return_value = {"a": "b"} + _read_sam_file_patch.return_value = {"a": "b"} get_translated_template_if_valid_mock = Mock() get_translated_template_if_valid_mock.get_translated_template_if_valid.side_effect = NoCredentialsError @@ -68,10 +67,10 @@ def test_no_credentials_provided(self, read_sam_file_patch, click_patch, templat @patch("samcli.lib.translate.sam_template_validator.SamTemplateValidator") @patch("samcli.commands.validate.validate.click") - @patch("samcli.commands.validate.validate.read_sam_file") - def test_template_passes_validation(self, read_sam_file_patch, click_patch, template_valiadator): + @patch("samcli.commands.validate.validate._read_sam_file") + def test_template_passes_validation(self, _read_sam_file_patch, click_patch, template_valiadator): template_path = "path_to_template" - read_sam_file_patch.return_value = {"a": "b"} + _read_sam_file_patch.return_value = {"a": "b"} get_translated_template_if_valid_mock = Mock() get_translated_template_if_valid_mock.get_translated_template_if_valid.return_value = True diff --git a/tests/unit/lib/iac/cfn/test_cfn_iac_implementation.py b/tests/unit/lib/iac/cfn/test_cfn_iac_implementation.py index ab6f78b3fed..995bc318840 100644 --- a/tests/unit/lib/iac/cfn/test_cfn_iac_implementation.py +++ b/tests/unit/lib/iac/cfn/test_cfn_iac_implementation.py @@ -2,7 +2,7 @@ from unittest import TestCase from unittest.mock import patch, Mock -from samcli.lib.translate.exceptions import InvalidSamDocumentException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException from samcli.lib.iac.cfn.cfn_iac import CfnIacImplementation from samcli.lib.iac.plugins_interfaces import ( SamCliContext, From 26fb5f096f46b33169f764a552ed920e0c9b830c Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 11 Jul 2022 09:25:51 -0700 Subject: [PATCH 57/72] Fixed error --- samcli/lib/list/mapper_consumer_factory.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py index f40858a26f8..4bfa137d6e6 100644 --- a/samcli/lib/list/mapper_consumer_factory.py +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -4,21 +4,14 @@ from samcli.lib.list.list_interfaces import MapperConsumerFactoryInterface from samcli.lib.list.data_to_json_mapper import DataToJsonMapper from samcli.commands.list.json_consumer import StringConsumerJsonOutput -from samcli.commands.list.table_consumer import StringConsumerTableOutput from samcli.lib.list.mapper_consumer_container import MapperConsumerContainer from samcli.lib.list.list_interfaces import ProducersEnum -from samcli.lib.list.stack_outputs.stack_output_to_table_mapper import StackOutputToTableMapper class MapperConsumerFactory(MapperConsumerFactoryInterface): def create(self, producer: ProducersEnum, output: str) -> MapperConsumerContainer: # Will add conditions here to return different sorts of containers later on - if output == "json": - data_to_json_mapper = DataToJsonMapper() - json_consumer = StringConsumerJsonOutput() - container = MapperConsumerContainer(data_to_json_mapper, json_consumer) - return container - stack_outputs_mapper = StackOutputToTableMapper() - table_consumer = StringConsumerTableOutput() - container = MapperConsumerContainer(stack_outputs_mapper, table_consumer) + data_to_json_mapper = DataToJsonMapper() + json_consumer = StringConsumerJsonOutput() + container = MapperConsumerContainer(data_to_json_mapper, json_consumer) return container From 13c7edb6696569653f2cbec25ef12b1fe0922eef Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 11 Jul 2022 10:36:07 -0700 Subject: [PATCH 58/72] Changed return type --- samcli/lib/list/resources/resource_mapping_producer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index b6c22468a4c..de014a75ffc 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -1,7 +1,7 @@ """ The producer for the 'sam list resources' command """ -from typing import Optional, Any, Dict +from typing import Any, Dict import dataclasses import logging import yaml @@ -44,7 +44,7 @@ def __init__( self.mapper = mapper self.consumer = consumer - def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Optional[Any]: + def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Dict[Any, Any]: """ Performs a sam translate on a template and returns the translated template in the form of a dictionary or raises exceptions accordingly From e7c17d29f318aff9b330f84d0a2051d240f63d00 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 11 Jul 2022 10:59:12 -0700 Subject: [PATCH 59/72] Reverted return type due to make pr error --- samcli/lib/list/resources/resource_mapping_producer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index de014a75ffc..7c766123de8 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -44,7 +44,7 @@ def __init__( self.mapper = mapper self.consumer = consumer - def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Dict[Any, Any]: + def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Any: """ Performs a sam translate on a template and returns the translated template in the form of a dictionary or raises exceptions accordingly From 9ab5674e2aa5fc02a976407555239e983778bd5c Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Mon, 11 Jul 2022 11:02:03 -0700 Subject: [PATCH 60/72] Added change to fix make pr error --- samcli/lib/list/resources/resource_mapping_producer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index 7c766123de8..d749cd33ae9 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -44,7 +44,7 @@ def __init__( self.mapper = mapper self.consumer = consumer - def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Any: + def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Dict[Any, Any]: """ Performs a sam translate on a template and returns the translated template in the form of a dictionary or raises exceptions accordingly @@ -64,6 +64,7 @@ def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Any: validator = SamTemplateValidator( template_file_dict, ManagedPolicyLoader(self.iam_client), profile=self.profile, region=self.region ) + translated_dict: dict translated_dict = yaml.load(validator.get_translated_template_if_valid(), Loader=yaml.FullLoader) return translated_dict except InvalidSamDocumentException as e: From c105195aedd6d93c7b26f7836820f22e22a6aa4e Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Tue, 12 Jul 2022 11:11:19 -0700 Subject: [PATCH 61/72] Removed translate_utils.py file --- samcli/commands/translate/__init__.py | 0 samcli/commands/translate/translate_utils.py | 31 -------------------- 2 files changed, 31 deletions(-) delete mode 100644 samcli/commands/translate/__init__.py delete mode 100644 samcli/commands/translate/translate_utils.py diff --git a/samcli/commands/translate/__init__.py b/samcli/commands/translate/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/samcli/commands/translate/translate_utils.py b/samcli/commands/translate/translate_utils.py deleted file mode 100644 index 7cd47e0a061..00000000000 --- a/samcli/commands/translate/translate_utils.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Utils for performing Sam translates -""" -import os -import logging -import click - - -LOG = logging.getLogger(__name__) - - -def read_sam_file(template): - """ - Reads the file (json and yaml supported) provided and returns the dictionary representation of the file. - - :param str template: Path to the template file - :return dict: Dictionary representing the SAM Template - :raises: SamTemplateNotFoundException when the template file does not exist - """ - - from samcli.commands.local.cli_common.user_exceptions import SamTemplateNotFoundException - from samcli.yamlhelper import yaml_parse - - if not os.path.exists(template): - click.secho("SAM Template Not Found", bg="red") - raise SamTemplateNotFoundException("Template at {} is not found".format(template)) - - with click.open_file(template, "r", encoding="utf-8") as sam_template: - sam_template = yaml_parse(sam_template.read()) - - return sam_template From 502e463f959583feae2ba74a1f2a5c37f5bfd24a Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 13 Jul 2022 22:23:37 -0700 Subject: [PATCH 62/72] Added cloud resources to sam list resources output --- samcli/commands/list/cli_common/options.py | 2 +- .../resources/resource_mapping_producer.py | 66 +- .../list/resources/test_resources_command.py | 135 +++- .../list/resources/test_resources_context.py | 661 +++++++++++++----- 4 files changed, 656 insertions(+), 208 deletions(-) diff --git a/samcli/commands/list/cli_common/options.py b/samcli/commands/list/cli_common/options.py index 27f0a7b885b..ecaadc97087 100644 --- a/samcli/commands/list/cli_common/options.py +++ b/samcli/commands/list/cli_common/options.py @@ -24,7 +24,7 @@ def stack_name_option(f): def output_click_option(): return click.option( "--output", - help=("Output the results from the command in a given " "output format (json, yaml, table or text). "), + help="Output the results from the command in a given " "output format (json or table). ", type=click.Choice(["json", "table"], case_sensitive=False), ) diff --git a/samcli/lib/list/resources/resource_mapping_producer.py b/samcli/lib/list/resources/resource_mapping_producer.py index d749cd33ae9..88bb8f12a0b 100644 --- a/samcli/lib/list/resources/resource_mapping_producer.py +++ b/samcli/lib/list/resources/resource_mapping_producer.py @@ -6,11 +6,16 @@ import logging import yaml -from botocore.exceptions import ClientError, NoCredentialsError +from botocore.exceptions import ClientError, NoCredentialsError, BotoCoreError from samtranslator.translator.managed_policy_translator import ManagedPolicyLoader from samtranslator.translator.arn_generator import NoRegionFound -from samcli.commands.list.exceptions import SamListLocalResourcesNotFoundError, SamListUnknownClientError +from samcli.commands.list.exceptions import ( + SamListLocalResourcesNotFoundError, + SamListUnknownClientError, + StackDoesNotExistInRegionError, + SamListUnknownBotoCoreError, +) from samcli.lib.list.list_interfaces import Producer from samcli.lib.list.resources.resources_def import ResourcesDef from samcli.lib.translate.sam_template_validator import SamTemplateValidator @@ -19,6 +24,8 @@ from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException from samcli.commands.exceptions import UserException from samcli.commands._utils.template import get_template_data +from samcli.lib.utils.boto_utils import get_client_error_code + LOG = logging.getLogger(__name__) @@ -44,6 +51,30 @@ def __init__( self.mapper = mapper self.consumer = consumer + def get_resources_info(self): + """ + Returns the stack resources information for the stack and raises exceptions accordingly + + Returns + ------- + A dictionary containing information about the stack's resources + """ + + try: + response = self.cloudformation_client.describe_stack_resources(StackName=self.stack_name) + if "StackResources" not in response: + return {"StackResources": []} + return response + except ClientError as e: + if get_client_error_code(e) == "ValidationError": + LOG.debug("Stack with id %s does not exist", self.stack_name) + raise StackDoesNotExistInRegionError(stack_name=self.stack_name, region=self.region) from e + LOG.error("ClientError Exception : %s", str(e)) + raise SamListUnknownClientError(msg=str(e)) from e + except BotoCoreError as e: + LOG.error("Botocore Exception : %s", str(e)) + raise SamListUnknownBotoCoreError(msg=str(e)) from e + def get_translated_dict(self, template_file_dict: Dict[Any, Any]) -> Dict[Any, Any]: """ Performs a sam translate on a template and returns the translated template in the form of a dictionary or @@ -93,12 +124,25 @@ def produce(self): stacks, _ = SamLocalStackProvider.get_stacks(template_file="", template_dictionary=translated_dict) if not stacks or not stacks[0].resources: raise SamListLocalResourcesNotFoundError(msg="No local resources found.") - resources_dict = {} - for local_resource in stacks[0].resources: - # Set the PhysicalID to "-" if there is no corresponding PhysicalID - resources_dict[local_resource] = "-" - resource_data = ResourcesDef( - LogicalResourceId=local_resource, PhysicalResourceId=resources_dict[local_resource] - ) - mapped_output = self.mapper.map(dataclasses.asdict(resource_data)) - self.consumer.consume(mapped_output) + seen_resources = set() + resources_list = [] + if self.stack_name: + response = self.get_resources_info() + for deployed_resource in response["StackResources"]: + resource_data = ResourcesDef( + LogicalResourceId=deployed_resource["LogicalResourceId"], + PhysicalResourceId=deployed_resource["PhysicalResourceId"], + ) + resources_list.append(dataclasses.asdict(resource_data)) + seen_resources.add(deployed_resource["LogicalResourceId"]) + for local_resource in stacks[0].resources: + if local_resource not in seen_resources: + resource_data = ResourcesDef(LogicalResourceId=local_resource, PhysicalResourceId="-") + resources_list.append(dataclasses.asdict(resource_data)) + else: + for local_resource in stacks[0].resources: + # Set the PhysicalID to "-" if there is no corresponding PhysicalID + resource_data = ResourcesDef(LogicalResourceId=local_resource, PhysicalResourceId="-") + resources_list.append(dataclasses.asdict(resource_data)) + mapped_output = self.mapper.map(resources_list) + self.consumer.consume(mapped_output) diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 4ffc1b81d3a..f36ded6b194 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -6,7 +6,6 @@ from tests.integration.deploy.deploy_integ_base import DeployIntegBase from tests.integration.list.resources.resources_integ_base import ResourcesIntegBase from samcli.commands.list.resources.cli import HELP_TEXT -from tests.testing_utils import run_command from tests.testing_utils import RUNNING_ON_CI, RUNNING_TEST_FOR_MASTER_ON_CI, RUN_BY_CANARY from tests.testing_utils import run_command, run_command_with_input, method_to_stack_name @@ -16,7 +15,7 @@ @skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") -class TestResources(ResourcesIntegBase): +class TestResources(DeployIntegBase, ResourcesIntegBase): @classmethod def setUpClass(cls): DeployIntegBase.setUpClass() @@ -43,45 +42,45 @@ def test_successful_transform(self): command_result = run_command(cmdlist, cwd=self.working_dir) self.assertIn( """{ - "LogicalResourceId": "HelloWorldFunction", - "PhysicalResourceId": "-" -}""", + "LogicalResourceId": "HelloWorldFunction", + "PhysicalResourceId": "-" + }""", command_result.stdout.decode(), ) self.assertIn( """{ - "LogicalResourceId": "HelloWorldFunctionRole", - "PhysicalResourceId": "-" -}""", + "LogicalResourceId": "HelloWorldFunctionRole", + "PhysicalResourceId": "-" + }""", command_result.stdout.decode(), ) self.assertIn( """{ - "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd", - "PhysicalResourceId": "-" -}""", + "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd", + "PhysicalResourceId": "-" + }""", command_result.stdout.decode(), ) self.assertIn( """{ - "LogicalResourceId": "ServerlessRestApi", - "PhysicalResourceId": "-" -}""", + "LogicalResourceId": "ServerlessRestApi", + "PhysicalResourceId": "-" + }""", command_result.stdout.decode(), ) self.assertIn( """{ - "LogicalResourceId": "ServerlessRestApiProdStage", - "PhysicalResourceId": "-" -}""", + "LogicalResourceId": "ServerlessRestApiProdStage", + "PhysicalResourceId": "-" + }""", command_result.stdout.decode(), ) self.assertTrue( re.search( """{ - "LogicalResourceId": "ServerlessRestApiDeployment.*", - "PhysicalResourceId": "-" -}""", + "LogicalResourceId": "ServerlessRestApiDeployment.*", + "PhysicalResourceId": "-" + }""", command_result.stdout.decode(), ) ) @@ -93,4 +92,98 @@ def test_invalid_template_file(self): stack_name=None, region=region, output="json", template_file=template_path ) command_result = run_command(cmdlist, cwd=self.working_dir) - self.assertIn("Template provided was invalid SAM Template.", command_result.stdout.decode()) + self.assertIn( + "Error: [InvalidTemplateException(\"'Resources' section is required\")] 'Resources' section is required", + command_result.stderr.decode(), + ) + + def test_success_with_stack_name(self): + template_path = self.list_test_data_path.joinpath("test_stack_creation_template.yaml") + stack_name = method_to_stack_name(self.id()) + config_file_name = stack_name + ".toml" + region = boto3.Session().region_name + deploy_command_list = self.get_deploy_command_list( + template_file=template_path, + guided=True, + config_file=config_file_name, + region=region, + confirm_changeset=True, + disable_rollback=True, + ) + deploy_process_execute = run_command_with_input( + deploy_command_list, "{}\n{}\nY\nY\nY\nY\nY\n\n\nY\n".format(stack_name, region).encode() + ) + cmdlist = self.get_resources_command_list( + stack_name=stack_name, region=region, output="json", template_file=template_path + ) + command_result = run_command(cmdlist, cwd=self.working_dir) + self.assertTrue( + re.search( + """{ + "LogicalResourceId": "HelloWorldFunction", + "PhysicalResourceId": ".*HelloWorldFunction.*" + }""", + command_result.stdout.decode(), + ) + ) + self.assertTrue( + re.search( + """{ + "LogicalResourceId": "HelloWorldFunctionRole", + "PhysicalResourceId": ".*HelloWorldFunctionRole.*" + }""", + command_result.stdout.decode(), + ) + ) + self.assertTrue( + re.search( + """{ + "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd", + "PhysicalResourceId": ".*HelloWorldFunctionHelloWorldPermissionProd.*" + }""", + command_result.stdout.decode(), + ) + ) + self.assertTrue( + re.search( + """{ + "LogicalResourceId": "ServerlessRestApi", + "PhysicalResourceId": ".*" + }""", + command_result.stdout.decode(), + ) + ) + self.assertTrue( + re.search( + """{ + "LogicalResourceId": "ServerlessRestApiProdStage", + "PhysicalResourceId": ".*" + }""", + command_result.stdout.decode(), + ) + ) + self.assertTrue( + re.search( + """{ + "LogicalResourceId": "ServerlessRestApiDeployment.*", + "PhysicalResourceId": ".*" + }""", + command_result.stdout.decode(), + ) + ) + + def test_stack_does_not_exist(self): + template_path = self.list_test_data_path.joinpath("test_stack_creation_template.yaml") + stack_name = method_to_stack_name(self.id()) + config_file_name = stack_name + ".toml" + region = boto3.Session().region_name + cmdlist = self.get_resources_command_list( + stack_name=stack_name, region=region, output="json", template_file=template_path + ) + command_result = run_command(cmdlist, cwd=self.working_dir) + expected_output = ( + f"Error: The input stack {stack_name} does" f" not exist on Cloudformation in the region {region}" + ) + self.assertIn( + expected_output, command_result.stderr.decode(), "Should have raised error that outputs do not exist" + ) diff --git a/tests/unit/commands/list/resources/test_resources_context.py b/tests/unit/commands/list/resources/test_resources_context.py index af021063c54..dfc75d28ef7 100644 --- a/tests/unit/commands/list/resources/test_resources_context.py +++ b/tests/unit/commands/list/resources/test_resources_context.py @@ -1,17 +1,157 @@ from unittest import TestCase from unittest.mock import patch, call, Mock -from botocore.exceptions import ClientError +from botocore.exceptions import ClientError, EndpointConnectionError, NoCredentialsError, BotoCoreError +from samtranslator.translator.arn_generator import NoRegionFound from samcli.commands.list.resources.resources_context import ResourcesContext from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException -from samcli.commands.exceptions import RegionError -from samcli.commands.list.exceptions import SamListLocalResourcesNotFoundError, SamListUnknownClientError +from samcli.commands.exceptions import RegionError, UserException +from samcli.commands.list.exceptions import ( + SamListLocalResourcesNotFoundError, + SamListUnknownClientError, + StackDoesNotExistInRegionError, + SamListUnknownBotoCoreError, +) from samtranslator.public.exceptions import InvalidDocumentException from samcli.lib.translate.sam_template_validator import SamTemplateValidator +from samcli.lib.list.resources.resource_mapping_producer import ResourceMappingProducer +from samcli.lib.list.data_to_json_mapper import DataToJsonMapper +from samcli.commands.list.json_consumer import StringConsumerJsonOutput + + +TRANSLATED_DICT_RETURN = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", + "Resources": { + "HelloWorldFunction": { + "Properties": { + "Architectures": ["x86_64"], + "Code": {"S3Bucket": "bucket", "S3Key": "value"}, + "Handler": "app.lambda_handler", + "Role": {"Fn::GetAtt": ["HelloWorldFunctionRole", "Arn"]}, + "Runtime": "python3.8", + "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], + "Timeout": 3, + "TracingConfig": {"Mode": "Active"}, + }, + "Type": "AWS::Lambda::Function", + }, + "HelloWorldFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": ["sts:AssumeRole"], + "Effect": "Allow", + "Principal": {"Service": ["lambda.amazonaws.com"]}, + } + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess", + ], + "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], + }, + "Type": "AWS::IAM::Role", + }, + "HelloWorldFunctionHelloWorldPermissionProd": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": {"Ref": "HelloWorldFunction"}, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/GET/hello", + {"__ApiId__": {"Ref": "ServerlessRestApi"}, "__Stage__": "*"}, + ] + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ServerlessRestApi": { + "Properties": { + "Body": { + "info": {"version": "1.0", "title": {"Ref": "AWS::StackName"}}, + "paths": { + "/hello": { + "get": { + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations" + }, + }, + "responses": {}, + } + } + }, + "swagger": "2.0", + } + }, + "Type": "AWS::ApiGateway::RestApi", + }, + "ServerlessRestApiDeploymentf5716dc08b": { + "Properties": { + "Description": "RestApi deployment id: f5716dc08b0d213bd0f2dfb686579c351b09ae49", + "RestApiId": {"Ref": "ServerlessRestApi"}, + "StageName": "Stage", + }, + "Type": "AWS::ApiGateway::Deployment", + }, + "ServerlessRestApiProdStage": { + "Properties": { + "DeploymentId": {"Ref": "ServerlessRestApiDeploymentf5716dc08b"}, + "RestApiId": {"Ref": "ServerlessRestApi"}, + "StageName": "Prod", + }, + "Type": "AWS::ApiGateway::Stage", + }, + }, +} + +SAM_FILE_READER_RETURN = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", + "Globals": {"Function": {"Tracing": "Active", "Timeout": 3}}, + "Resources": { + "HelloWorldFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "hello_world/", + "Handler": "app.lambda_handler", + "Architectures": ["x86_64"], + "Runtime": "python3.8", + "Events": {"HelloWorld": {"Type": "Api", "Properties": {"Path": "/hello", "Method": "get"}}}, + }, + } + }, +} class TestResourcesContext(TestCase): + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.resources.resources_context.ResourceMappingProducer.produce") + def test_resources_context_run_local_only_no_stack_name( + self, mock_produce, patched_click_get_current_context, patched_click_echo + ): + mock_produce.return_value = '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }\n]' + + with ResourcesContext( + stack_name=None, output="json", region="us-east-1", profile=None, template_file=None + ) as resources_context: + resources_context.run() + expected_output = '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }\n]' + + self.assertEqual(expected_output, mock_produce.return_value) + + +class TestResourceMappingProducerProduce(TestCase): @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") @@ -19,99 +159,144 @@ class TestResourcesContext(TestCase): def test_resources_local_only_no_stack_name( self, mock_get_translated_dict, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo ): - mock_get_translated_dict.return_value = { - "AWSTemplateFormatVersion": "2010-09-09", - "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", - "Resources": { - "HelloWorldFunction": { - "Properties": { - "Architectures": ["x86_64"], - "Code": {"S3Bucket": "bucket", "S3Key": "value"}, - "Handler": "app.lambda_handler", - "Role": {"Fn::GetAtt": ["HelloWorldFunctionRole", "Arn"]}, - "Runtime": "python3.8", - "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], - "Timeout": 3, - "TracingConfig": {"Mode": "Active"}, - }, - "Type": "AWS::Lambda::Function", - }, - "HelloWorldFunctionRole": { - "Properties": { - "AssumeRolePolicyDocument": { - "Statement": [ - { - "Action": ["sts:AssumeRole"], - "Effect": "Allow", - "Principal": {"Service": ["lambda.amazonaws.com"]}, - } - ], - "Version": "2012-10-17", - }, - "ManagedPolicyArns": [ - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - "arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess", - ], - "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], - }, - "Type": "AWS::IAM::Role", - }, - "HelloWorldFunctionHelloWorldPermissionProd": { - "Properties": { - "Action": "lambda:InvokeFunction", - "FunctionName": {"Ref": "HelloWorldFunction"}, - "Principal": "apigateway.amazonaws.com", - "SourceArn": { - "Fn::Sub": [ - "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/GET/hello", - {"__ApiId__": {"Ref": "ServerlessRestApi"}, "__Stage__": "*"}, - ] - }, - }, - "Type": "AWS::Lambda::Permission", - }, - "ServerlessRestApi": { - "Properties": { - "Body": { - "info": {"version": "1.0", "title": {"Ref": "AWS::StackName"}}, - "paths": { - "/hello": { - "get": { - "x-amazon-apigateway-integration": { - "httpMethod": "POST", - "type": "aws_proxy", - "uri": { - "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations" - }, - }, - "responses": {}, - } - } - }, - "swagger": "2.0", - } - }, - "Type": "AWS::ApiGateway::RestApi", - }, - "ServerlessRestApiDeploymentf5716dc08b": { - "Properties": { - "Description": "RestApi deployment id: f5716dc08b0d213bd0f2dfb686579c351b09ae49", - "RestApiId": {"Ref": "ServerlessRestApi"}, - "StageName": "Stage", - }, - "Type": "AWS::ApiGateway::Deployment", - }, - "ServerlessRestApiProdStage": { - "Properties": { - "DeploymentId": {"Ref": "ServerlessRestApiDeploymentf5716dc08b"}, - "RestApiId": {"Ref": "ServerlessRestApi"}, - "StageName": "Prod", - }, - "Type": "AWS::ApiGateway::Stage", + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN + + mock_sam_file_reader.return_value = SAM_FILE_READER_RETURN + resource_producer = ResourceMappingProducer( + stack_name=None, + region=None, + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + mapper=DataToJsonMapper(), + consumer=StringConsumerJsonOutput(), + ) + resource_producer.produce() + expected_output = [ + call( + '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }\n]' + ) + ] + self.assertEqual(expected_output, patched_click_echo.call_args_list) + + @patch("samcli.lib.translate.sam_template_validator.Session") + @patch("samcli.lib.translate.sam_template_validator.Translator") + @patch("samcli.lib.translate.sam_template_validator.parser") + def test_get_translated_template_if_valid_raises_exception(self, sam_parser, sam_translator, boto_session_patch): + managed_policy_mock = Mock() + managed_policy_mock.load.return_value = {"policy": "SomePolicy"} + template = {"a": "b"} + + parser = Mock() + sam_parser.Parser.return_value = parser + + boto_session_mock = Mock() + boto_session_patch.return_value = boto_session_mock + + translate_mock = Mock() + translate_mock.translate.side_effect = InvalidDocumentException([Exception("message")]) + sam_translator.return_value = translate_mock + + validator = SamTemplateValidator(template, managed_policy_mock) + + with self.assertRaises(InvalidSamDocumentException): + validator.get_translated_template_if_valid() + + sam_translator.assert_called_once_with( + managed_policy_map={"policy": "SomePolicy"}, sam_parser=parser, plugins=[], boto_session=boto_session_mock + ) + + boto_session_patch.assert_called_once_with(profile_name=None, region_name=None) + translate_mock.translate.assert_called_once_with(sam_template=template, parameter_values={}) + sam_parser.Parser.assert_called_once() + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") + @patch("samcli.lib.list.resources.resource_mapping_producer.SamLocalStackProvider.get_stacks") + def test_resources_get_stacks_returns_empty( + self, + mock_get_stacks, + mock_get_translated_dict, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, + ): + mock_get_translated_dict.return_value = {} + mock_sam_file_reader.return_value = {} + mock_get_stacks.return_value = ([], []) + with self.assertRaises(SamListLocalResourcesNotFoundError): + resource_producer = ResourceMappingProducer( + stack_name=None, + region=None, + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + mapper=DataToJsonMapper(), + consumer=StringConsumerJsonOutput(), + ) + resource_producer.produce() + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_resources_info") + def test_resources_success_with_stack_name( + self, + mock_get_resources_info, + mock_get_translated_dict, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, + ): + mock_get_resources_info.return_value = { + "StackResources": [ + {"LogicalResourceId": "HelloWorldFunction", "PhysicalResourceId": "physical_resource_1"}, + {"LogicalResourceId": "HelloWorldFunctionRole", "PhysicalResourceId": "physical_resource_2"}, + { + "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd", + "PhysicalResourceId": "physical_resource_3", }, - }, + ] } + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN + mock_sam_file_reader.return_value = SAM_FILE_READER_RETURN + resource_producer = ResourceMappingProducer( + stack_name="test-stack", + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + mapper=DataToJsonMapper(), + consumer=StringConsumerJsonOutput(), + ) + resource_producer.produce() + expected_output = [ + call( + '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "physical_resource_1"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "physical_resource_2"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "physical_resource_3"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }\n]' + ) + ] + self.assertEqual(expected_output, patched_click_echo.call_args_list) + + +class TestGetTranslatedDict(TestCase): + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") + @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template_if_valid") + def test_get_translate_dict_invalid_template_error( + self, + mock_get_translated_template_if_valid, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, + ): mock_sam_file_reader.return_value = { "AWSTemplateFormatVersion": "2010-09-09", "Transform": "AWS::Serverless-2016-10-31", @@ -130,29 +315,25 @@ def test_resources_local_only_no_stack_name( } }, } - with ResourcesContext( - stack_name=None, output="json", region="us-east-1", profile=None, template_file=None - ) as resources_context: - resources_context.run() - expected_output = [ - call('{\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n}'), - call('{\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n}'), - call( - '{\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n}' - ), - call('{\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n}'), - call( - '{\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n}' - ), - call('{\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n}'), - ] - self.assertEqual(expected_output, patched_click_echo.call_args_list) + mock_get_translated_template_if_valid.side_effect = InvalidSamDocumentException() + with self.assertRaises(InvalidSamTemplateException): + resource_producer = ResourceMappingProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + mapper=None, + consumer=None, + ) + resource_producer.get_translated_dict(mock_sam_file_reader.return_value) @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template_if_valid") - def test_clienterror_exception( + def test_get_translated_dict_clienterror_exception( self, mock_get_translated_template_if_valid, mock_sam_file_reader, @@ -164,48 +345,70 @@ def test_clienterror_exception( "DescribeStacks", ) with self.assertRaises(SamListUnknownClientError): - with ResourcesContext( - stack_name=None, output="json", region="us-east-1", profile=None, template_file=None - ) as resources_context: - resources_context.run() + resource_producer = ResourceMappingProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + mapper=None, + consumer=None, + ) + resource_producer.get_translated_dict(mock_sam_file_reader.return_value) @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template_if_valid") - def test_get_translate_dict_invalid_template_error( + def test_get_translated_dict_no_credentials_exception( self, mock_get_translated_template_if_valid, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo, ): - mock_sam_file_reader.return_value = { - "AWSTemplateFormatVersion": "2010-09-09", - "Transform": "AWS::Serverless-2016-10-31", - "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", - "Globals": {"Function": {"Tracing": "Active", "Timeout": 3}}, - "Resources": { - "HelloWorldFunction": { - "Type": "AWS::Serverless::Function", - "Properties": { - "CodeUri": "hello_world/", - "Handler": "app.lambda_handler", - "Architectures": ["x86_64"], - "Runtime": "python3.8", - "Events": {"HelloWorld": {"Type": "Api", "Properties": {"Path": "/hello", "Method": "get"}}}, - }, - } - }, - } - mock_get_translated_template_if_valid.side_effect = InvalidSamDocumentException() - with self.assertRaises(InvalidSamTemplateException): - with ResourcesContext( - stack_name=None, output="json", region="us-east-1", profile=None, template_file=None - ) as resources_context: - resources_context.run() - self.assertEqual(patched_click_echo.call_args_list, "Template provided was invalid SAM Template.") + mock_get_translated_template_if_valid.side_effect = NoCredentialsError() + with self.assertRaises(UserException): + resource_producer = ResourceMappingProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + mapper=None, + consumer=None, + ) + resource_producer.get_translated_dict(mock_sam_file_reader.return_value) + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") + @patch("samcli.lib.list.resources.resource_mapping_producer.SamTemplateValidator.get_translated_template_if_valid") + def test_get_translated_dict_no_region_found_exception( + self, + mock_get_translated_template_if_valid, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, + ): + mock_get_translated_template_if_valid.side_effect = NoRegionFound() + with self.assertRaises(UserException): + resource_producer = ResourceMappingProducer( + stack_name=None, + region=None, + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + mapper=None, + consumer=None, + ) + resource_producer.get_translated_dict(mock_sam_file_reader.return_value) + +class TestResourcesInitClients(TestCase): @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") @patch("boto3.Session.region_name", None) @@ -228,55 +431,163 @@ def test_init_clients_no_input_region_get_region_from_session( resources_context.init_clients() self.assertEqual(resources_context.region, "us-east-1") - @patch("samcli.lib.translate.sam_template_validator.Session") - @patch("samcli.lib.translate.sam_template_validator.Translator") - @patch("samcli.lib.translate.sam_template_validator.parser") - def test_get_translated_template_if_valid_raises_exception(self, sam_parser, sam_translator, boto_session_patch): - managed_policy_mock = Mock() - managed_policy_mock.load.return_value = {"policy": "SomePolicy"} - template = {"a": "b"} - - parser = Mock() - sam_parser.Parser.return_value = parser - boto_session_mock = Mock() - boto_session_patch.return_value = boto_session_mock +class TestGetResourcesInfo(TestCase): + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_clienterror_stack_does_not_exist_in_region( + self, + mock_client_provider, + mock_get_translated_dict, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.describe_stack_resources.side_effect = ClientError( + {"Error": {"Code": "ValidationError", "Message": "Stack with id test does not exist"}}, "DescribeStacks" + ) + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN - translate_mock = Mock() - translate_mock.translate.side_effect = InvalidDocumentException([Exception("message")]) - sam_translator.return_value = translate_mock + mock_sam_file_reader.return_value = SAM_FILE_READER_RETURN + with self.assertRaises(StackDoesNotExistInRegionError): + resource_producer = ResourceMappingProducer( + stack_name="test-stack", + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=mock_client_provider.return_value.return_value, + iam_client=None, + mapper=None, + consumer=None, + ) + resource_producer.get_resources_info() - validator = SamTemplateValidator(template, managed_policy_mock) + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_botocoreerror_invalid_region( + self, + mock_client_provider, + mock_get_translated_dict, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.describe_stack_resources.side_effect = EndpointConnectionError( + endpoint_url="https://cloudformation.test.amazonaws.com/" + ) + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN - with self.assertRaises(InvalidSamDocumentException): - validator.get_translated_template_if_valid() + mock_sam_file_reader.return_value = SAM_FILE_READER_RETURN + with self.assertRaises(SamListUnknownBotoCoreError): + resource_producer = ResourceMappingProducer( + stack_name="test-stack", + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=mock_client_provider.return_value.return_value, + iam_client=None, + mapper=None, + consumer=None, + ) + resource_producer.get_resources_info() - sam_translator.assert_called_once_with( - managed_policy_map={"policy": "SomePolicy"}, sam_parser=parser, plugins=[], boto_session=boto_session_mock + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_clienterror_token_error( + self, + mock_client_provider, + mock_get_translated_dict, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.describe_stack_resources.side_effect = ClientError( + {"Error": {"Code": "ExpiredToken", "Message": "The security token included in the request is expired"}}, + "DescribeStacks", ) + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN - boto_session_patch.assert_called_once_with(profile_name=None, region_name=None) - translate_mock.translate.assert_called_once_with(sam_template=template, parameter_values={}) - sam_parser.Parser.assert_called_once() + mock_sam_file_reader.return_value = SAM_FILE_READER_RETURN + with self.assertRaises(SamListUnknownClientError): + resource_producer = ResourceMappingProducer( + stack_name="test-stack", + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=mock_client_provider.return_value.return_value, + iam_client=None, + mapper=None, + consumer=None, + ) + resource_producer.get_resources_info() @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") - @patch("samcli.lib.list.resources.resource_mapping_producer.SamLocalStackProvider.get_stacks") - def test_resources_get_stacks_returns_empty( + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_stack_resource_not_in_response( self, - mock_get_stacks, + mock_client_provider, mock_get_translated_dict, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo, ): - mock_get_translated_dict.return_value = {} - mock_sam_file_reader.return_value = {} - mock_get_stacks.return_value = ([], []) - with self.assertRaises(SamListLocalResourcesNotFoundError): - with ResourcesContext( - stack_name=None, output="json", region="us-east-1", profile=None, template_file=None - ) as resources_context: - resources_context.run() + mock_client_provider.return_value.return_value.describe_stack_resources.return_value = {} + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN + + mock_sam_file_reader.return_value = SAM_FILE_READER_RETURN + resource_producer = ResourceMappingProducer( + stack_name="test-stack", + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=mock_client_provider.return_value.return_value, + iam_client=None, + mapper=None, + consumer=None, + ) + response = resource_producer.get_resources_info() + self.assertEqual(response, {"StackResources": []}) + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_stack_resource_in_response( + self, + mock_client_provider, + mock_get_translated_dict, + mock_sam_file_reader, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.describe_stack_resources.return_value = { + "StackResources": [{"StackName": "sam-app-hello"}] + } + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN + + mock_sam_file_reader.return_value = SAM_FILE_READER_RETURN + resource_producer = ResourceMappingProducer( + stack_name="test-stack", + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=mock_client_provider.return_value.return_value, + iam_client=None, + mapper=None, + consumer=None, + ) + response = resource_producer.get_resources_info() + self.assertEqual(response, {"StackResources": [{"StackName": "sam-app-hello"}]}) From a6e4f1add7fc75afad2fe331b6091d693b8ee659 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 14 Jul 2022 12:21:31 -0700 Subject: [PATCH 63/72] Empty commit From 0ce203cfd4fe32d0f8b414437337a8a9779bdd37 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 14 Jul 2022 12:31:33 -0700 Subject: [PATCH 64/72] modified test format --- .../list/resources/test_resources_command.py | 60 ++++--------------- 1 file changed, 12 insertions(+), 48 deletions(-) diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index f36ded6b194..75b4ac23a10 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -41,46 +41,28 @@ def test_successful_transform(self): ) command_result = run_command(cmdlist, cwd=self.working_dir) self.assertIn( - """{ - "LogicalResourceId": "HelloWorldFunction", - "PhysicalResourceId": "-" - }""", + """{\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n }""", command_result.stdout.decode(), ) self.assertIn( - """{ - "LogicalResourceId": "HelloWorldFunctionRole", - "PhysicalResourceId": "-" - }""", + """{\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n }""", command_result.stdout.decode(), ) self.assertIn( - """{ - "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd", - "PhysicalResourceId": "-" - }""", + """{\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n }""", command_result.stdout.decode(), ) self.assertIn( - """{ - "LogicalResourceId": "ServerlessRestApi", - "PhysicalResourceId": "-" - }""", + """{\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n }""", command_result.stdout.decode(), ) self.assertIn( - """{ - "LogicalResourceId": "ServerlessRestApiProdStage", - "PhysicalResourceId": "-" - }""", + """{\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }""", command_result.stdout.decode(), ) self.assertTrue( re.search( - """{ - "LogicalResourceId": "ServerlessRestApiDeployment.*", - "PhysicalResourceId": "-" - }""", + """{\n "LogicalResourceId": "ServerlessRestApiDeployment.*",\n "PhysicalResourceId": "-"\n }""", command_result.stdout.decode(), ) ) @@ -119,55 +101,37 @@ def test_success_with_stack_name(self): command_result = run_command(cmdlist, cwd=self.working_dir) self.assertTrue( re.search( - """{ - "LogicalResourceId": "HelloWorldFunction", - "PhysicalResourceId": ".*HelloWorldFunction.*" - }""", + """{\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": ".*HelloWorldFunction.*"\n }""", command_result.stdout.decode(), ) ) self.assertTrue( re.search( - """{ - "LogicalResourceId": "HelloWorldFunctionRole", - "PhysicalResourceId": ".*HelloWorldFunctionRole.*" - }""", + """{\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": ".*HelloWorldFunctionRole.*"\n }""", command_result.stdout.decode(), ) ) self.assertTrue( re.search( - """{ - "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd", - "PhysicalResourceId": ".*HelloWorldFunctionHelloWorldPermissionProd.*" - }""", + """{\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": ".*HelloWorldFunctionHelloWorldPermissionProd.*"\n }""", command_result.stdout.decode(), ) ) self.assertTrue( re.search( - """{ - "LogicalResourceId": "ServerlessRestApi", - "PhysicalResourceId": ".*" - }""", + """{\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": ".*"\n }""", command_result.stdout.decode(), ) ) self.assertTrue( re.search( - """{ - "LogicalResourceId": "ServerlessRestApiProdStage", - "PhysicalResourceId": ".*" - }""", + """{\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": ".*"\n }""", command_result.stdout.decode(), ) ) self.assertTrue( re.search( - """{ - "LogicalResourceId": "ServerlessRestApiDeployment.*", - "PhysicalResourceId": ".*" - }""", + """{\n "LogicalResourceId": "ServerlessRestApiDeployment.*",\n "PhysicalResourceId": ".*"\n }""", command_result.stdout.decode(), ) ) From 955c500b5fc5aac522765a323ec9ea2c9edc281f Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 20 Jul 2022 16:42:09 -0700 Subject: [PATCH 65/72] Modified tests --- .../list/resources/test_resources_command.py | 49 ++++++------------- .../list/resources/test_resources_context.py | 18 ++++--- 2 files changed, 26 insertions(+), 41 deletions(-) diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 75b4ac23a10..57ac364704d 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -99,42 +99,21 @@ def test_success_with_stack_name(self): stack_name=stack_name, region=region, output="json", template_file=template_path ) command_result = run_command(cmdlist, cwd=self.working_dir) - self.assertTrue( - re.search( - """{\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": ".*HelloWorldFunction.*"\n }""", - command_result.stdout.decode(), - ) - ) - self.assertTrue( - re.search( - """{\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": ".*HelloWorldFunctionRole.*"\n }""", - command_result.stdout.decode(), + expression_list = [ + """{\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": ".*HelloWorldFunction.*"\n }""", + """{\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": ".*HelloWorldFunctionRole.*"\n }""", + """{\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": ".*HelloWorldFunctionHelloWorldPermissionProd.*"\n }""", + """{\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": ".*"\n }""", + """{\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": ".*"\n }""", + """{\n "LogicalResourceId": "ServerlessRestApiDeployment.*",\n "PhysicalResourceId": ".*"\n }""", + ] + for expression in expression_list: + self.assertTrue( + re.search( + expression, + command_result.stdout.decode(), + ) ) - ) - self.assertTrue( - re.search( - """{\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": ".*HelloWorldFunctionHelloWorldPermissionProd.*"\n }""", - command_result.stdout.decode(), - ) - ) - self.assertTrue( - re.search( - """{\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": ".*"\n }""", - command_result.stdout.decode(), - ) - ) - self.assertTrue( - re.search( - """{\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": ".*"\n }""", - command_result.stdout.decode(), - ) - ) - self.assertTrue( - re.search( - """{\n "LogicalResourceId": "ServerlessRestApiDeployment.*",\n "PhysicalResourceId": ".*"\n }""", - command_result.stdout.decode(), - ) - ) def test_stack_does_not_exist(self): template_path = self.list_test_data_path.joinpath("test_stack_creation_template.yaml") diff --git a/tests/unit/commands/list/resources/test_resources_context.py b/tests/unit/commands/list/resources/test_resources_context.py index dfc75d28ef7..ce5ca3f9e2e 100644 --- a/tests/unit/commands/list/resources/test_resources_context.py +++ b/tests/unit/commands/list/resources/test_resources_context.py @@ -136,19 +136,25 @@ class TestResourcesContext(TestCase): @patch("samcli.commands.list.json_consumer.click.echo") @patch("samcli.commands.list.json_consumer.click.get_current_context") - @patch("samcli.commands.list.resources.resources_context.ResourceMappingProducer.produce") + @patch("samcli.lib.list.resources.resource_mapping_producer.get_template_data") + @patch("samcli.lib.list.resources.resource_mapping_producer.ResourceMappingProducer.get_translated_dict") def test_resources_context_run_local_only_no_stack_name( - self, mock_produce, patched_click_get_current_context, patched_click_echo + self, mock_get_translated_dict, mock_sam_file_reader, patched_click_get_current_context, patched_click_echo ): - mock_produce.return_value = '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }\n]' + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN + mock_sam_file_reader.return_value = SAM_FILE_READER_RETURN with ResourcesContext( stack_name=None, output="json", region="us-east-1", profile=None, template_file=None ) as resources_context: resources_context.run() - expected_output = '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }\n]' - - self.assertEqual(expected_output, mock_produce.return_value) + expected_output = [ + call( + '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }\n]' + ) + ] + print(patched_click_echo.call_args_list) + self.assertEqual(expected_output, patched_click_echo.call_args_list) class TestResourceMappingProducerProduce(TestCase): From e9c63581c74a6f5148d3964e3af04af046b4c78f Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 21 Jul 2022 12:29:44 -0700 Subject: [PATCH 66/72] Modified test --- .../list/resources/test_resources_command.py | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 57ac364704d..63f019213b1 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -40,26 +40,18 @@ def test_successful_transform(self): stack_name=None, region=region, output="json", template_file=template_path ) command_result = run_command(cmdlist, cwd=self.working_dir) - self.assertIn( + expression_list = [ """{\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n }""", - command_result.stdout.decode(), - ) - self.assertIn( """{\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n }""", - command_result.stdout.decode(), - ) - self.assertIn( """{\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n }""", - command_result.stdout.decode(), - ) - self.assertIn( """{\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n }""", - command_result.stdout.decode(), - ) - self.assertIn( """{\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }""", - command_result.stdout.decode(), - ) + ] + for expression in expression_list: + self.assertIn( + expression, + command_result.stdout.decode(), + ) self.assertTrue( re.search( """{\n "LogicalResourceId": "ServerlessRestApiDeployment.*",\n "PhysicalResourceId": "-"\n }""", From ae421a81f59e14793eca843b0493d85aece8e928 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Tue, 26 Jul 2022 11:10:12 -0700 Subject: [PATCH 67/72] Adding the sam list testable resources command, tests, and table output format support for all sam list commands --- samcli/commands/list/cli_common/options.py | 1 + samcli/commands/list/table_consumer.py | 14 + .../commands/list/testable_resources/cli.py | 16 +- .../testable_resources_context.py | 66 ++ samcli/lib/list/mapper_consumer_factory.py | 24 +- .../resources/resources_to_table_mapper.py | 15 + .../stack_output_to_table_mapper.py | 16 + .../stack_outputs/stack_outputs_producer.py | 6 +- .../lib/list/testable_resources/__init__.py | 0 .../testable_resources/testable_res_def.py | 13 + .../testable_resources_producer.py | 367 ++++++ .../testable_resources_to_table_mapper.py | 30 + .../list/resources/test_resources_command.py | 4 +- .../test_testable_resources_command.py | 125 +- .../testable_resources_integ_base.py | 7 +- .../test_testable_resources_template.yaml | 44 + .../list/resources/test_resources_context.py | 1 - .../test_stack_outputs_context.py | 5 +- tests/unit/commands/list/test_list_mappers.py | 72 ++ .../list/testable_resources/test_cli.py | 17 +- .../test_testable_resources_context.py | 1017 +++++++++++++++++ 21 files changed, 1844 insertions(+), 16 deletions(-) create mode 100644 samcli/commands/list/table_consumer.py create mode 100644 samcli/commands/list/testable_resources/testable_resources_context.py create mode 100644 samcli/lib/list/resources/resources_to_table_mapper.py create mode 100644 samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py create mode 100644 samcli/lib/list/testable_resources/__init__.py create mode 100644 samcli/lib/list/testable_resources/testable_res_def.py create mode 100644 samcli/lib/list/testable_resources/testable_resources_producer.py create mode 100644 samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py create mode 100644 tests/integration/testdata/list/test_testable_resources_template.yaml create mode 100644 tests/unit/commands/list/test_list_mappers.py create mode 100644 tests/unit/commands/list/testable_resources/test_testable_resources_context.py diff --git a/samcli/commands/list/cli_common/options.py b/samcli/commands/list/cli_common/options.py index ecaadc97087..b214ec9da7e 100644 --- a/samcli/commands/list/cli_common/options.py +++ b/samcli/commands/list/cli_common/options.py @@ -24,6 +24,7 @@ def stack_name_option(f): def output_click_option(): return click.option( "--output", + default="table", help="Output the results from the command in a given " "output format (json or table). ", type=click.Choice(["json", "table"], case_sensitive=False), ) diff --git a/samcli/commands/list/table_consumer.py b/samcli/commands/list/table_consumer.py new file mode 100644 index 00000000000..e4a75de4869 --- /dev/null +++ b/samcli/commands/list/table_consumer.py @@ -0,0 +1,14 @@ +""" +The table consumer for 'sam list' +""" +from samcli.lib.list.list_interfaces import ListInfoPullerConsumer +from samcli.views.concrete_views.rich_table import RichTable + + +class StringConsumerTableOutput(ListInfoPullerConsumer): + """ + Consumes a Rich table and outputs it in table format + """ + + def consume(self, data: RichTable) -> None: + data.print() diff --git a/samcli/commands/list/testable_resources/cli.py b/samcli/commands/list/testable_resources/cli.py index 255d6c17e40..f00bdaf5bc1 100644 --- a/samcli/commands/list/testable_resources/cli.py +++ b/samcli/commands/list/testable_resources/cli.py @@ -8,6 +8,8 @@ from samcli.cli.main import pass_context, common_options, aws_creds_options, print_cmdline_args from samcli.lib.utils.version_checker import check_newer_version from samcli.lib.telemetry.metric import track_command +from samcli.commands._utils.options import template_option_without_build +from samcli.cli.cli_config_file import configuration_option, TomlProvider HELP_TEXT = """ @@ -19,22 +21,30 @@ @click.command(name="testable-resources", help=HELP_TEXT) +@configuration_option(provider=TomlProvider(section="parameters")) @stack_name_option @output_option +@template_option_without_build @aws_creds_options @common_options @pass_context @track_command @check_newer_version @print_cmdline_args -def cli(self, stack_name, output): +def cli(self, stack_name, output, template_file, config_file, config_env): """ `sam list testable-resources` command entry point """ - do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile) + do_cli(stack_name=stack_name, output=output, region=self.region, profile=self.profile, template_file=template_file) -def do_cli(stack_name, output, region, profile): +def do_cli(stack_name, output, region, profile, template_file): """ Implementation of the ``cli`` method """ + from samcli.commands.list.testable_resources.testable_resources_context import TestableResourcesContext + + with TestableResourcesContext( + stack_name=stack_name, output=output, region=region, profile=profile, template_file=template_file + ) as testable_resources_context: + testable_resources_context.run() diff --git a/samcli/commands/list/testable_resources/testable_resources_context.py b/samcli/commands/list/testable_resources/testable_resources_context.py new file mode 100644 index 00000000000..41fa400edc4 --- /dev/null +++ b/samcli/commands/list/testable_resources/testable_resources_context.py @@ -0,0 +1,66 @@ +""" +Display of the Testable Resources of a SAM stack +""" +import logging +from typing import Optional + +from samcli.commands.list.cli_common.list_common_context import ListContext +from samcli.lib.list.testable_resources.testable_resources_producer import TestableResourcesProducer +from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory +from samcli.lib.list.list_interfaces import ProducersEnum + +LOG = logging.getLogger(__name__) + + +class TestableResourcesContext(ListContext): + def __init__( + self, stack_name: str, output: str, region: Optional[str], profile: Optional[str], template_file: Optional[str] + ): + super().__init__() + self.stack_name = stack_name + self.output = output + self.region = region + self.profile = profile + self.template_file = template_file + self.iam_client = None + self.cloudcontrol_client = None + self.apigateway_client = None + self.apigatewayv2_client = None + + def __enter__(self): + self.init_clients() + return self + + def __exit__(self, *args): + pass + + def init_clients(self) -> None: + """ + Initialize the clients being used by sam list. + """ + super().init_clients() + self.iam_client = self.client_provider("iam") + self.cloudcontrol_client = self.client_provider("cloudcontrol") + self.apigateway_client = self.client_provider("apigateway") + self.apigatewayv2_client = self.client_provider("apigatewayv2") + + def run(self) -> None: + """ + Get the resources for a stack + """ + factory = MapperConsumerFactory() + container = factory.create(producer=ProducersEnum.TESTABLE_RESOURCES_PRODUCER, output=self.output) + testable_resource_producer = TestableResourcesProducer( + stack_name=self.stack_name, + region=self.region, + profile=self.profile, + template_file=self.template_file, + cloudformation_client=self.cloudformation_client, + iam_client=self.iam_client, + cloudcontrol_client=self.cloudcontrol_client, + apigateway_client=self.apigateway_client, + apigatewayv2_client=self.apigatewayv2_client, + mapper=container.mapper, + consumer=container.consumer, + ) + testable_resource_producer.produce() diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py index 4bfa137d6e6..9e9c090e233 100644 --- a/samcli/lib/list/mapper_consumer_factory.py +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -4,14 +4,30 @@ from samcli.lib.list.list_interfaces import MapperConsumerFactoryInterface from samcli.lib.list.data_to_json_mapper import DataToJsonMapper from samcli.commands.list.json_consumer import StringConsumerJsonOutput +from samcli.commands.list.table_consumer import StringConsumerTableOutput from samcli.lib.list.mapper_consumer_container import MapperConsumerContainer -from samcli.lib.list.list_interfaces import ProducersEnum +from samcli.lib.list.stack_outputs.stack_output_to_table_mapper import StackOutputToTableMapper +from samcli.lib.list.resources.resources_to_table_mapper import ResourcesToTableMapper +from samcli.lib.list.testable_resources.testable_resources_to_table_mapper import TestableResourcesToTableMapper +from samcli.lib.list.list_interfaces import ProducersEnum, Mapper class MapperConsumerFactory(MapperConsumerFactoryInterface): def create(self, producer: ProducersEnum, output: str) -> MapperConsumerContainer: # Will add conditions here to return different sorts of containers later on - data_to_json_mapper = DataToJsonMapper() - json_consumer = StringConsumerJsonOutput() - container = MapperConsumerContainer(data_to_json_mapper, json_consumer) + if output == "json": + data_to_json_mapper = DataToJsonMapper() + json_consumer = StringConsumerJsonOutput() + container = MapperConsumerContainer(data_to_json_mapper, json_consumer) + return container + table_mapper: Mapper + table_consumer = StringConsumerTableOutput() + if producer == ProducersEnum.STACK_OUTPUTS_PRODUCER: + table_mapper = StackOutputToTableMapper() + # add conditional for when adding in the testable resources table + elif producer == ProducersEnum.RESOURCES_PRODUCER: + table_mapper = ResourcesToTableMapper() + else: + table_mapper = TestableResourcesToTableMapper() + container = MapperConsumerContainer(table_mapper, table_consumer) return container diff --git a/samcli/lib/list/resources/resources_to_table_mapper.py b/samcli/lib/list/resources/resources_to_table_mapper.py new file mode 100644 index 00000000000..3a423d2a161 --- /dev/null +++ b/samcli/lib/list/resources/resources_to_table_mapper.py @@ -0,0 +1,15 @@ +""" +Implementation of the resources to table mapper +""" +from samcli.lib.list.list_interfaces import Mapper +from samcli.views.concrete_views.rich_table import RichTable + + +class ResourcesToTableMapper(Mapper): + def map(self, data: list) -> RichTable: + output = RichTable(title="Resources", table_options={"show_lines": True}) + output.add_column("Logical ID", {"justify": "center", "no_wrap": True}) + output.add_column("Physical ID", {"justify": "center", "no_wrap": True}) + for resource in data: + output.add_row([resource["LogicalResourceId"], resource["PhysicalResourceId"]]) + return output diff --git a/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py b/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py new file mode 100644 index 00000000000..20f3a8f9716 --- /dev/null +++ b/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py @@ -0,0 +1,16 @@ +""" +Implementation of the stack output to table mapper +""" +from samcli.lib.list.list_interfaces import Mapper +from samcli.views.concrete_views.rich_table import RichTable + + +class StackOutputToTableMapper(Mapper): + def map(self, data: list) -> RichTable: + output = RichTable(title="Stack Outputs", table_options={"show_lines": True}) + output.add_column("OutputKey", {"justify": "center", "no_wrap": True}) + output.add_column("OutputValue", {"justify": "center", "no_wrap": True}) + output.add_column("Description", {"justify": "center", "no_wrap": True}) + for stack_output in data: + output.add_row([stack_output["OutputKey"], stack_output["OutputValue"], stack_output["Description"]]) + return output diff --git a/samcli/lib/list/stack_outputs/stack_outputs_producer.py b/samcli/lib/list/stack_outputs/stack_outputs_producer.py index 888df6975ca..a97b35856e3 100644 --- a/samcli/lib/list/stack_outputs/stack_outputs_producer.py +++ b/samcli/lib/list/stack_outputs/stack_outputs_producer.py @@ -58,11 +58,13 @@ def get_stack_info(self) -> Optional[Any]: def produce(self): response = self.get_stack_info() + output_list = [] for stack_output in response: stack_output_data = StackOutputs( OutputKey=stack_output["OutputKey"], OutputValue=stack_output["OutputValue"], Description=stack_output["Description"], ) - mapped_output = self.mapper.map(dataclasses.asdict(stack_output_data)) - self.consumer.consume(mapped_output) + output_list.append(dataclasses.asdict(stack_output_data)) + mapped_output = self.mapper.map(output_list) + self.consumer.consume(data=mapped_output) diff --git a/samcli/lib/list/testable_resources/__init__.py b/samcli/lib/list/testable_resources/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/samcli/lib/list/testable_resources/testable_res_def.py b/samcli/lib/list/testable_resources/testable_res_def.py new file mode 100644 index 00000000000..3fddb86804f --- /dev/null +++ b/samcli/lib/list/testable_resources/testable_res_def.py @@ -0,0 +1,13 @@ +""" +The container for Testable Resources +""" +from typing import Any +from dataclasses import dataclass + + +@dataclass +class TestableResDef: + LogicalResourceId: str + PhysicalResourceId: str + CloudEndpointOrFURL: Any + Methods: Any diff --git a/samcli/lib/list/testable_resources/testable_resources_producer.py b/samcli/lib/list/testable_resources/testable_resources_producer.py new file mode 100644 index 00000000000..f880fc36a1f --- /dev/null +++ b/samcli/lib/list/testable_resources/testable_resources_producer.py @@ -0,0 +1,367 @@ +""" +The producer for the 'sam list testable-resources' command +""" +import dataclasses +import logging +from typing import Dict, List, Any +from enum import Enum +import json +from botocore.exceptions import ClientError, BotoCoreError +from samcli.commands.list.exceptions import ( + SamListUnknownBotoCoreError, + SamListLocalResourcesNotFoundError, + SamListUnknownClientError, +) +from samcli.lib.list.list_interfaces import Producer +from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider +from samcli.lib.providers.provider import Stack +from samcli.commands._utils.template import get_template_data +from samcli.lib.list.testable_resources.testable_res_def import TestableResDef +from samcli.lib.list.resources.resource_mapping_producer import ResourceMappingProducer +from samcli.lib.utils.boto_utils import get_client_error_code + +LOG = logging.getLogger(__name__) + + +class APIGatewayEnum(Enum): + API_GATEWAY = 1 + API_GATEWAY_V2 = 2 + + +class TestableResourcesProducer(ResourceMappingProducer, Producer): + def __init__( + self, + stack_name, + region, + profile, + template_file, + cloudformation_client, + iam_client, + cloudcontrol_client, + apigateway_client, + apigatewayv2_client, + mapper, + consumer, + ): + super().__init__( + stack_name, region, profile, template_file, cloudformation_client, iam_client, mapper, consumer + ) + self.stack_name = stack_name + self.region = region + self.profile = profile + self.template_file = template_file + self.cloudformation_client = cloudformation_client + self.iam_client = iam_client + self.cloudcontrol_client = cloudcontrol_client + self.apigateway_client = apigateway_client + self.apigatewayv2_client = apigatewayv2_client + self.mapper = mapper + self.consumer = consumer + + def get_function_url(self, identifier: str) -> Any: + """ + Gets the function url of a Lambda Function + + Parameters + ---------- + identifier: str + The identifier or physical ID + + Returns + ------- + furl: str + The function url in the form of a string + """ + try: + response = self.cloudcontrol_client.get_resource(TypeName="AWS::Lambda::Url", Identifier=identifier) + if not response.get("ResourceDescription", {}).get("Properties", {}): + return "-" + response_dict = json.loads(response.get("ResourceDescription", {}).get("Properties", {})) + furl = response_dict.get("FunctionUrl", "-") + return furl + except ClientError as e: + if get_client_error_code(e) == "ResourceNotFoundException": + return "-" + LOG.error("ClientError Exception : %s", str(e)) + raise SamListUnknownClientError(msg=str(e)) from e + + def get_stage_list(self, api_id: str, api_type: APIGatewayEnum) -> List[Any]: + """ + Gets a list of stages for a given api of type AWS::ApiGateway::RestApi or AWS::ApiGatewayV2::Api + + Parameters + ---------- + api_id: str + The api id or rest api id of the api + api_type: APIGatewayEnum + The type of api, AWS::ApiGateway::RestApi or AWS::ApiGatewayV2::Api + + Returns + ------- + response_list: List[Any] + A list of stages for the api + """ + response_list: List[Any] + try: + response_list = [] + response: dict + search_key: str + stage_name_key: str + if api_type == APIGatewayEnum.API_GATEWAY: + response = self.apigateway_client.get_stages(restApiId=api_id) + search_key = "item" + stage_name_key = "stageName" + else: + response = self.apigatewayv2_client.get_stages(ApiId=api_id) + search_key = "Items" + stage_name_key = "StageName" + if not response.get(search_key, []): + return response_list + for item in response.get(search_key, []): + if item.get(stage_name_key, None): + response_list.append(item.get(stage_name_key, "")) + return response_list + except ClientError as e: + if get_client_error_code(e) == "NotFoundException": + return [] + LOG.error("ClientError Exception : %s", str(e)) + raise SamListUnknownClientError(msg=str(e)) from e + except BotoCoreError as e: + LOG.error("Botocore Exception : %s", str(e)) + raise SamListUnknownBotoCoreError(msg=str(e)) from e + + def build_api_gw_endpoints(self, physical_id: str, stages: list) -> list: + api_list = [] + for stage in stages: + + api_list.append(f"https://{physical_id}.execute-api.{self.region}.amazonaws.com/{stage}") + return api_list + + def produce(self): + """ + The producer function for the testable resources command + """ + sam_template = get_template_data(self.template_file) + + translated_dict = self.get_translated_dict(template_file_dict=sam_template) + stacks, _ = SamLocalStackProvider.get_stacks(template_file="", template_dictionary=translated_dict) + validate_stack(stacks) + seen_testable_resources = set() + testable_resources_list = [] + testable_resource_types = {"AWS::Lambda::Function", "AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"} + if self.stack_name: + response = self.get_resources_info() + response_domain_dict = get_response_domain_dict(response) + custom_domain_substitute_dict = get_custom_domain_substitute_list(response, stacks, response_domain_dict) + + for deployed_resource in response["StackResources"]: + if deployed_resource["ResourceType"] in testable_resource_types: + endpoint_function_url = "-" + paths_and_methods = "-" + if deployed_resource["ResourceType"] == "AWS::Lambda::Function": + endpoint_function_url = self.get_function_url(deployed_resource["PhysicalResourceId"]) + + elif deployed_resource["ResourceType"] in ("AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"): + stages = self.get_stage_list( + deployed_resource["PhysicalResourceId"], + get_api_type_enum(deployed_resource["ResourceType"]), + ) + if deployed_resource["LogicalResourceId"] in custom_domain_substitute_dict: + endpoint_function_url = custom_domain_substitute_dict[ + deployed_resource["LogicalResourceId"] + ] + else: + endpoint_function_url = self.build_api_gw_endpoints( + deployed_resource["PhysicalResourceId"], stages + ) + paths_and_methods = get_methods_and_paths(deployed_resource["LogicalResourceId"], stacks[0]) + + testable_resource_data = TestableResDef( + LogicalResourceId=deployed_resource["LogicalResourceId"], + PhysicalResourceId=deployed_resource["PhysicalResourceId"], + CloudEndpointOrFURL=endpoint_function_url, + Methods=paths_and_methods, + ) + testable_resources_list.append(dataclasses.asdict(testable_resource_data)) + seen_testable_resources.add(deployed_resource["LogicalResourceId"]) + for local_resource in stacks[0].resources: + local_resource_type = stacks[0].resources[local_resource]["Type"] + paths_and_methods = "-" + if local_resource_type in testable_resource_types and local_resource not in seen_testable_resources: + if local_resource_type in ("AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"): + paths_and_methods = get_methods_and_paths(local_resource, stacks[0]) + testable_resource_data = TestableResDef( + LogicalResourceId=local_resource, + PhysicalResourceId="-", + CloudEndpointOrFURL="-", + Methods=paths_and_methods, + ) + testable_resources_list.append(dataclasses.asdict(testable_resource_data)) + else: + testable_resources_list = get_local_testable_resources(stacks, testable_resource_types) + mapped_output = self.mapper.map(testable_resources_list) + self.consumer.consume(mapped_output) + + +def validate_stack(stacks: list): + """ + Checks if the stack non-empty and contains stack resources and raises exceptions accordingly + + Parameters + ---------- + stacks: list + A list containing the stack + """ + if not stacks or not stacks[0].resources: + raise SamListLocalResourcesNotFoundError(msg="No local resources found.") + + +def get_local_testable_resources(stacks: list, testable_resource_types: set) -> list: + """ + Gets a list of local testable resources based on the local stack + + Parameters + ---------- + stacks: list + A list containing the stack + testable_resource_types: set + A set of resources types that should be displayed by testable resources + + Returns + ------- + testable_resources_list: list + A list containing the testable resources and their information + """ + testable_resources_list = [] + paths_and_methods: Any + for local_resource in stacks[0].resources: + local_resource_type = stacks[0].resources[local_resource]["Type"] + if local_resource_type in testable_resource_types: + paths_and_methods = "-" + if local_resource_type in ("AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"): + paths_and_methods = get_methods_and_paths(local_resource, stacks[0]) + # Set the PhysicalID to "-" if there is no corresponding PhysicalID + testable_resource_data = TestableResDef( + LogicalResourceId=local_resource, + PhysicalResourceId="-", + CloudEndpointOrFURL="-", + Methods=paths_and_methods, + ) + testable_resources_list.append(dataclasses.asdict(testable_resource_data)) + return testable_resources_list + + +def get_api_type_enum(resource_type: str) -> APIGatewayEnum: + """ + Gets the APIGatewayEnum associated with the input resource type + + Parameters + ---------- + resource_type: str + The type of the resource + + Returns + ------- + The APIGatewayEnum associated with the input resource type + """ + if resource_type == "AWS::ApiGatewayV2::Api": + return APIGatewayEnum.API_GATEWAY_V2 + return APIGatewayEnum.API_GATEWAY + + +def get_custom_domain_substitute_list( + response: Dict[Any, Any], stacks: list, response_domain_dict: Dict[str, str] +) -> Dict[Any, Any]: + """ + Gets a dictionary containing the custom domain lists that map back to the original api + + Parameters + ---------- + response: Dict[Any, Any] + The response containing the cloud stack resources information + stacks: list + A list containing the local stack + response_domain_dict: Dict + A dictionary containing the custom domains + Returns + ------- + custom_domain_substitute_dict: Dict[Any, Any] + A dict containing the custom domain lists mapped to the original apis + """ + custom_domain_substitute_dict = {} + for resource in response["StackResources"]: + if resource["ResourceType"] == "AWS::ApiGateway::BasePathMapping": + local_mapping = stacks[0].resources[resource["LogicalResourceId"]]["Properties"] + rest_api_id = local_mapping["RestApiId"] + domain_id = local_mapping["DomainName"] + if domain_id in response_domain_dict: + if rest_api_id not in custom_domain_substitute_dict: + custom_domain_substitute_dict[rest_api_id] = [response_domain_dict[domain_id]] + else: + custom_domain_substitute_dict[rest_api_id].append(response_domain_dict[domain_id]) + elif resource["ResourceType"] == "AWS::ApiGatewayV2::ApiMapping": + local_mapping = stacks[0].resources[resource["LogicalResourceId"]]["Properties"] + rest_api_id = local_mapping["ApiId"] + domain_id = local_mapping["DomainName"] + if domain_id in response_domain_dict: + if rest_api_id not in custom_domain_substitute_dict: + custom_domain_substitute_dict[rest_api_id] = [response_domain_dict[domain_id]] + else: + custom_domain_substitute_dict[rest_api_id].append(response_domain_dict[domain_id]) + return custom_domain_substitute_dict + + +def get_response_domain_dict(response: Dict[Any, Any]) -> Dict[str, str]: + """ + Gets a dictionary containing the custom domains + + Parameters + ---------- + response: Dict[Any, Any] + The response containing the cloud stack resources information + + Returns + ------- + response_domain_dict: Dict[str, str] + A dict containing the custom domains + """ + response_domain_dict = {} + for resource in response["StackResources"]: + if ( + resource["ResourceType"] == "AWS::ApiGateway::DomainName" + or resource["ResourceType"] == "AWS::ApiGatewayV2::DomainName" + ): + response_domain_dict[resource["LogicalResourceId"]] = "https://" + resource["PhysicalResourceId"] + return response_domain_dict + + +def get_methods_and_paths(logical_id: str, stack: Stack) -> list: + """ + Gets the methods and paths for apis based on the stack and the logical ID + + Parameters + ---------- + logical_id: str + The logical ID of the api + stack: Stack + The stack to retrieve the methods and paths from + + Returns + ------- + method_paths_list: list + A list containing the methods and paths of the api + """ + method_paths_list: List[Any] + method_paths_list = [] + if not stack.resources: + raise SamListLocalResourcesNotFoundError(msg="No local resources found.") + if not stack.resources.get(logical_id, {}).get("Properties", {}).get("Body", {}).get("paths", {}): + return method_paths_list + paths_dict = stack.resources.get(logical_id, {}).get("Properties", {}).get("Body", {}).get("paths", {}) + for path in paths_dict: + method_list = [] + for method in paths_dict[path]: + method_list.append(method) + path_item = path + f"{method_list}" + method_paths_list.append(path_item) + return method_paths_list diff --git a/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py b/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py new file mode 100644 index 00000000000..d108c93628e --- /dev/null +++ b/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py @@ -0,0 +1,30 @@ +""" +Implementation of the testable resources to table mapper +""" +from samcli.lib.list.list_interfaces import Mapper +from samcli.views.concrete_views.rich_table import RichTable + + +class TestableResourcesToTableMapper(Mapper): + def map(self, data: list) -> RichTable: + output = RichTable(title="Testable Resources", table_options={"show_lines": True}) + output.add_column("Resource ID", {"justify": "center", "no_wrap": True}) + output.add_column("Physical ID", {"justify": "center", "no_wrap": True}) + output.add_column("Cloud Endpoint/FURL", {"justify": "center", "no_wrap": True}) + output.add_column("Methods", {"justify": "center", "no_wrap": True}) + for testable_resource in data: + cloud_endpoint_furl_string = testable_resource["CloudEndpointOrFURL"] + methods_string = "-" + if isinstance(testable_resource["CloudEndpointOrFURL"], list): + cloud_endpoint_furl_string = "\n".join(testable_resource["CloudEndpointOrFURL"]) + if isinstance(testable_resource["Methods"], list) and testable_resource["Methods"]: + methods_string = "; ".join(testable_resource["Methods"]) + output.add_row( + [ + testable_resource["LogicalResourceId"], + testable_resource["PhysicalResourceId"], + cloud_endpoint_furl_string, + methods_string, + ] + ) + return output diff --git a/tests/integration/list/resources/test_resources_command.py b/tests/integration/list/resources/test_resources_command.py index 63f019213b1..df7eeef09a5 100644 --- a/tests/integration/list/resources/test_resources_command.py +++ b/tests/integration/list/resources/test_resources_command.py @@ -9,12 +9,12 @@ from tests.testing_utils import RUNNING_ON_CI, RUNNING_TEST_FOR_MASTER_ON_CI, RUN_BY_CANARY from tests.testing_utils import run_command, run_command_with_input, method_to_stack_name -SKIP_STACK_OUTPUTS_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY +SKIP_RESOURCES_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY CFN_SLEEP = 3 CFN_PYTHON_VERSION_SUFFIX = os.environ.get("PYTHON_VERSION", "0.0.0").replace(".", "-") -@skipIf(SKIP_STACK_OUTPUTS_TESTS, "Skip stack-outputs tests in CI/CD only") +@skipIf(SKIP_RESOURCES_TESTS, "Skip resources tests in CI/CD only") class TestResources(DeployIntegBase, ResourcesIntegBase): @classmethod def setUpClass(cls): diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py index a1a13e5c336..5d2218229f4 100644 --- a/tests/integration/list/testable_resources/test_testable_resources_command.py +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -1,12 +1,135 @@ +import os +import time +import boto3 +import re +from unittest import skipIf +from tests.integration.deploy.deploy_integ_base import DeployIntegBase from tests.integration.list.testable_resources.testable_resources_integ_base import TestableResourcesIntegBase from samcli.commands.list.testable_resources.cli import HELP_TEXT from tests.testing_utils import run_command +from tests.testing_utils import RUNNING_ON_CI, RUNNING_TEST_FOR_MASTER_ON_CI, RUN_BY_CANARY +from tests.testing_utils import run_command, run_command_with_input, method_to_stack_name +SKIP_TESTABLE_RESOURCES_TESTS = RUNNING_ON_CI and RUNNING_TEST_FOR_MASTER_ON_CI and not RUN_BY_CANARY +CFN_SLEEP = 3 +CFN_PYTHON_VERSION_SUFFIX = os.environ.get("PYTHON_VERSION", "0.0.0").replace(".", "-") + + +@skipIf(SKIP_TESTABLE_RESOURCES_TESTS, "Skip testable resources tests in CI/CD only") +class TestTestableResources(DeployIntegBase, TestableResourcesIntegBase): + @classmethod + def setUpClass(cls): + DeployIntegBase.setUpClass() + TestableResourcesIntegBase.setUpClass() + + def setUp(self): + self.cf_client = boto3.client("cloudformation") + time.sleep(CFN_SLEEP) + super().setUp() -class TestTestableResources(TestableResourcesIntegBase): def test_testable_resources_help_message(self): cmdlist = self.get_testable_resources_command_list(help=True) command_result = run_command(cmdlist) from_command = "".join(command_result.stdout.decode().split()) from_help = "".join(HELP_TEXT.split()) self.assertIn(from_help, from_command, "Testable-resources help text should have been printed") + + def test_no_stack_name(self): + template_path = self.list_test_data_path.joinpath("test_testable_resources_template.yaml") + region = boto3.Session().region_name + cmdlist = self.get_testable_resources_command_list( + stack_name=None, region=region, output="json", template_file=template_path + ) + command_result = run_command(cmdlist, cwd=self.working_dir) + expected_output = [ + """{ + "LogicalResourceId": "HelloWorldFunction", + "PhysicalResourceId": "-", + "CloudEndpointOrFURL": "-", + "Methods": "-" + }""", + """{ + "LogicalResourceId": "TestAPI", + "PhysicalResourceId": "-", + "CloudEndpointOrFURL": "-", + "Methods": [] + }""", + """{ + "LogicalResourceId": "ServerlessRestApi", + "PhysicalResourceId": "-", + "CloudEndpointOrFURL": "-", + "Methods": [ + "/hello2['get']", + "/hello['get']" + ] + }""", + ] + for expression in expected_output: + self.assertIn(expression, command_result.stdout.decode()) + + def test_has_stack_name(self): + template_path = self.list_test_data_path.joinpath("test_testable_resources_template.yaml") + stack_name = method_to_stack_name(self.id()) + config_file_name = stack_name + ".toml" + region = boto3.Session().region_name + deploy_command_list = self.get_deploy_command_list( + template_file=template_path, + guided=True, + config_file=config_file_name, + region=region, + confirm_changeset=True, + disable_rollback=True, + ) + deploy_process_execute = run_command_with_input( + deploy_command_list, "{}\n{}\nY\nY\nY\nY\nY\nY\n\n\nY\n".format(stack_name, region).encode() + ) + cmdlist = self.get_testable_resources_command_list( + stack_name=stack_name, region=region, output="json", template_file=template_path + ) + command_result = run_command(cmdlist, cwd=self.working_dir) + expected_output = [ + """{ + "LogicalResourceId": "HelloWorldFunction", + "PhysicalResourceId": "test-has-stack-name.*", + "CloudEndpointOrFURL": "https://.*.lambda-url..*.on.aws/", + "Methods": "-" + }""", + """ { + "LogicalResourceId": "ServerlessRestApi", + "PhysicalResourceId": ".*", + "CloudEndpointOrFURL": .* + "https://.*.execute-api..*.amazonaws.com/Prod", + "https://.*.execute-api..*.amazonaws.com/Stage" + .*, + "Methods": .* + "/hello2.'get'.", + "/hello.'get'." + . + }""", + """ { + "LogicalResourceId": "TestAPI", + "PhysicalResourceId": ".*", + "CloudEndpointOrFURL": . + "https://.*.execute-api..*.amazonaws.com/Test2" + ., + "Methods": .. + }""", + ] + for expression in expected_output: + self.assertTrue(re.search(expression, command_result.stdout.decode())) + + def test_stack_does_not_exist(self): + template_path = self.list_test_data_path.joinpath("test_testable_resources_template.yaml") + stack_name = method_to_stack_name(self.id()) + config_file_name = stack_name + ".toml" + region = boto3.Session().region_name + cmdlist = self.get_testable_resources_command_list( + stack_name=stack_name, region=region, output="json", template_file=template_path + ) + command_result = run_command(cmdlist, cwd=self.working_dir) + expected_output = ( + f"Error: The input stack {stack_name} does" f" not exist on Cloudformation in the region {region}" + ) + self.assertIn( + expected_output, command_result.stderr.decode(), "Should have raised error that outputs do not exist" + ) diff --git a/tests/integration/list/testable_resources/testable_resources_integ_base.py b/tests/integration/list/testable_resources/testable_resources_integ_base.py index 2a050a31e2e..0b814841c3a 100644 --- a/tests/integration/list/testable_resources/testable_resources_integ_base.py +++ b/tests/integration/list/testable_resources/testable_resources_integ_base.py @@ -2,7 +2,9 @@ class TestableResourcesIntegBase(ListIntegBase): - def get_testable_resources_command_list(self, stack_name=None, output=None, region=None, profile=None, help=False): + def get_testable_resources_command_list( + self, stack_name=None, output=None, region=None, profile=None, template_file=None, help=False + ): command_list = [self.base_command(), "list", "testable-resources"] if stack_name: command_list += ["--stack-name", str(stack_name)] @@ -16,6 +18,9 @@ def get_testable_resources_command_list(self, stack_name=None, output=None, regi if profile: command_list += ["--profile", str(profile)] + if template_file: + command_list += ["--template-file", str(template_file)] + if help: command_list += ["--help"] diff --git a/tests/integration/testdata/list/test_testable_resources_template.yaml b/tests/integration/testdata/list/test_testable_resources_template.yaml new file mode 100644 index 00000000000..7d8a81fec70 --- /dev/null +++ b/tests/integration/testdata/list/test_testable_resources_template.yaml @@ -0,0 +1,44 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + sam-app-hello + + Sample SAM Template for sam-app-hello + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 3 + Tracing: Active + +Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction + Properties: + CodeUri: hello_world/ + Handler: app.lambda_handler + Runtime: python3.8 + FunctionUrlConfig: + AuthType: AWS_IAM + Architectures: + - x86_64 + Events: + HelloWorld: + Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api + Properties: + Path: /hello + Method: get + HelloWorld2: + Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api + Properties: + Path: /hello2 + Method: get + TestAPI: + Type: AWS::Serverless::HttpApi + Properties: + Description: "Test resources" + StageName: Test2 + + + + diff --git a/tests/unit/commands/list/resources/test_resources_context.py b/tests/unit/commands/list/resources/test_resources_context.py index ce5ca3f9e2e..320d6936e66 100644 --- a/tests/unit/commands/list/resources/test_resources_context.py +++ b/tests/unit/commands/list/resources/test_resources_context.py @@ -153,7 +153,6 @@ def test_resources_context_run_local_only_no_stack_name( '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionRole",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "HelloWorldFunctionHelloWorldPermissionProd",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiDeploymentf5716dc08b",\n "PhysicalResourceId": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApiProdStage",\n "PhysicalResourceId": "-"\n }\n]' ) ] - print(patched_click_echo.call_args_list) self.assertEqual(expected_output, patched_click_echo.call_args_list) diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index 06021b7328e..1286d4367b8 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -23,8 +23,11 @@ def test_stack_outputs_stack_exists( stack_output_context.run() expected_click_echo_calls = [ - call('{\n "OutputKey": "HelloWorldTest",\n "OutputValue": "TestVal",\n "Description": "Test"\n}') + call( + '[\n {\n "OutputKey": "HelloWorldTest",\n "OutputValue": "TestVal",\n "Description": "Test"\n }\n]' + ) ] + print(patched_click_echo.call_args_list) self.assertEqual( expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" ) diff --git a/tests/unit/commands/list/test_list_mappers.py b/tests/unit/commands/list/test_list_mappers.py new file mode 100644 index 00000000000..e4a024d1e5d --- /dev/null +++ b/tests/unit/commands/list/test_list_mappers.py @@ -0,0 +1,72 @@ +from unittest import TestCase +from samcli.lib.list.resources.resources_to_table_mapper import ResourcesToTableMapper +from samcli.lib.list.stack_outputs.stack_output_to_table_mapper import StackOutputToTableMapper +from samcli.lib.list.data_to_json_mapper import DataToJsonMapper +from samcli.commands.list.json_consumer import StringConsumerJsonOutput +from samcli.commands.list.table_consumer import StringConsumerTableOutput +from samcli.lib.list.testable_resources.testable_resources_to_table_mapper import TestableResourcesToTableMapper +from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory +from samcli.lib.list.list_interfaces import ProducersEnum + + +class TestStackOutputsToTableMapper(TestCase): + def test_map(self): + data = [{"OutputKey": "outputkey1", "OutputValue": "outputvalue1", "Description": "sample description"}] + stack_outputs_to_table_mapper = StackOutputToTableMapper() + output = stack_outputs_to_table_mapper.map(data) + self.assertEqual(output._title, "Stack Outputs") + + +class TestResourcesToTableMapper(TestCase): + def test_map(self): + data = [{"LogicalResourceId": "LID_1", "PhysicalResourceId": "PID_1"}] + resources_to_table_mapper = ResourcesToTableMapper() + output = resources_to_table_mapper.map(data) + self.assertEqual(output._title, "Resources") + + +class TestTestableResourcesToTableMapper(TestCase): + def test_map(self): + data = [ + { + "LogicalResourceId": "LID_1", + "PhysicalResourceId": "PID_1", + "CloudEndpointOrFURL": "test.url", + "Methods": "-", + }, + { + "LogicalResourceId": "LID_1", + "PhysicalResourceId": "PID_1", + "CloudEndpointOrFURL": ["api.url1", "api.url2"], + "Methods": ["/hello2['get, put']", "/hello['get']"], + }, + ] + testable_resources_to_table_mapper = TestableResourcesToTableMapper() + output = testable_resources_to_table_mapper.map(data) + self.assertEqual(output._title, "Testable Resources") + + +class TestMapperConsumerFactory(TestCase): + def test_create_json_output(self): + factory = MapperConsumerFactory() + container = factory.create(ProducersEnum.STACK_OUTPUTS_PRODUCER, "json") + self.assertIsInstance(container.mapper, DataToJsonMapper) + self.assertIsInstance(container.consumer, StringConsumerJsonOutput) + + def test_create_stack_outputs_table_output(self): + factory = MapperConsumerFactory() + container = factory.create(ProducersEnum.STACK_OUTPUTS_PRODUCER, "table") + self.assertIsInstance(container.mapper, StackOutputToTableMapper) + self.assertIsInstance(container.consumer, StringConsumerTableOutput) + + def test_create_resources_table_output(self): + factory = MapperConsumerFactory() + container = factory.create(ProducersEnum.RESOURCES_PRODUCER, "table") + self.assertIsInstance(container.mapper, ResourcesToTableMapper) + self.assertIsInstance(container.consumer, StringConsumerTableOutput) + + def test_create_testable_resources_table_output(self): + factory = MapperConsumerFactory() + container = factory.create(ProducersEnum.TESTABLE_RESOURCES_PRODUCER, "table") + self.assertIsInstance(container.mapper, TestableResourcesToTableMapper) + self.assertIsInstance(container.consumer, StringConsumerTableOutput) diff --git a/tests/unit/commands/list/testable_resources/test_cli.py b/tests/unit/commands/list/testable_resources/test_cli.py index 83b441b6217..b55734a0774 100644 --- a/tests/unit/commands/list/testable_resources/test_cli.py +++ b/tests/unit/commands/list/testable_resources/test_cli.py @@ -9,13 +9,28 @@ def setUp(self): self.output = "json" self.region = None self.profile = None + self.template_file = None @patch("samcli.commands.list.testable_resources.cli.click") - def test_cli_base_command(self, mock_testable_resources_click): + @patch("samcli.commands.list.testable_resources.testable_resources_context.TestableResourcesContext") + def test_cli_base_command(self, mock_testable_resources_context, mock_testable_resources_click): context_mock = Mock() + mock_testable_resources_context.return_value.__enter__.return_value = context_mock do_cli( stack_name=self.stack_name, output=self.output, region=self.region, profile=self.profile, + template_file=self.template_file, ) + + mock_testable_resources_context.assert_called_with( + stack_name=self.stack_name, + output=self.output, + region=self.region, + profile=self.profile, + template_file=self.template_file, + ) + + context_mock.run.assert_called_with() + self.assertEqual(context_mock.run.call_count, 1) diff --git a/tests/unit/commands/list/testable_resources/test_testable_resources_context.py b/tests/unit/commands/list/testable_resources/test_testable_resources_context.py new file mode 100644 index 00000000000..27ba7ce76f6 --- /dev/null +++ b/tests/unit/commands/list/testable_resources/test_testable_resources_context.py @@ -0,0 +1,1017 @@ +from unittest import TestCase +from unittest.mock import patch, call, Mock +from botocore.exceptions import ClientError, EndpointConnectionError, NoCredentialsError, BotoCoreError +from samtranslator.translator.arn_generator import NoRegionFound + +from samcli.commands.list.testable_resources.testable_resources_context import TestableResourcesContext +from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException +from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException +from samcli.commands.exceptions import RegionError, UserException +from samcli.commands.list.exceptions import ( + SamListLocalResourcesNotFoundError, + SamListUnknownClientError, + StackDoesNotExistInRegionError, + SamListUnknownBotoCoreError, +) +from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider +from samtranslator.public.exceptions import InvalidDocumentException +from samcli.lib.translate.sam_template_validator import SamTemplateValidator +from samcli.lib.list.testable_resources.testable_resources_producer import TestableResourcesProducer, APIGatewayEnum +from samcli.lib.list.data_to_json_mapper import DataToJsonMapper +from samcli.commands.list.json_consumer import StringConsumerJsonOutput +from samcli.lib.providers.provider import Stack +from samcli.lib.list.data_to_json_mapper import DataToJsonMapper +from samcli.commands.list.json_consumer import StringConsumerJsonOutput +from samcli.commands.list.table_consumer import StringConsumerTableOutput +from samcli.lib.list.testable_resources.testable_resources_to_table_mapper import TestableResourcesToTableMapper + + +TRANSLATED_DICT_RETURN = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", + "Resources": { + "HelloWorldFunction": { + "Properties": { + "Architectures": ["x86_64"], + "Code": {"S3Bucket": "bucket", "S3Key": "value"}, + "Handler": "app.lambda_handler", + "Role": {"Fn::GetAtt": ["HelloWorldFunctionRole", "Arn"]}, + "Runtime": "python3.8", + "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], + "Timeout": 3, + "TracingConfig": {"Mode": "Active"}, + }, + "Type": "AWS::Lambda::Function", + }, + "HelloWorldFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": ["sts:AssumeRole"], + "Effect": "Allow", + "Principal": {"Service": ["lambda.amazonaws.com"]}, + } + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess", + ], + "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], + }, + "Type": "AWS::IAM::Role", + }, + "HelloWorldFunctionHelloWorldPermissionProd": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": {"Ref": "HelloWorldFunction"}, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/GET/hello", + {"__ApiId__": {"Ref": "ServerlessRestApi"}, "__Stage__": "*"}, + ] + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ServerlessRestApi": { + "Properties": { + "Body": { + "info": {"version": "1.0", "title": {"Ref": "AWS::StackName"}}, + "paths": { + "/hello": { + "get": { + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations" + }, + }, + "responses": {}, + } + } + }, + "swagger": "2.0", + } + }, + "Type": "AWS::ApiGateway::RestApi", + }, + "ServerlessRestApiDeploymentf5716dc08b": { + "Properties": { + "Description": "RestApi deployment id: f5716dc08b0d213bd0f2dfb686579c351b09ae49", + "RestApiId": {"Ref": "ServerlessRestApi"}, + "StageName": "Stage", + }, + "Type": "AWS::ApiGateway::Deployment", + }, + "ServerlessRestApiProdStage": { + "Properties": { + "DeploymentId": {"Ref": "ServerlessRestApiDeploymentf5716dc08b"}, + "RestApiId": {"Ref": "ServerlessRestApi"}, + "StageName": "Prod", + }, + "Type": "AWS::ApiGateway::Stage", + }, + }, +} + +TRANSLATED_DICT_RETURN_WITH_APIS = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", + "Resources": { + "customDomainCert": { + "Type": "AWS::CertificateManager::Certificate", + "Properties": {"DomainName": "api7.zhandr.people.aws.dev", "ValidationMethod": "DNS"}, + }, + "BPMapping1": { + "Type": "AWS::ApiGateway::BasePathMapping", + "Properties": {"DomainName": "apigw_dm_mapping_LID", "RestApiId": "test_apigw_restapi", "Stage": "String"}, + }, + "HelloWorldFunction": { + "Properties": { + "Architectures": ["x86_64"], + "Code": {"S3Bucket": "bucket", "S3Key": "value"}, + "Handler": "app.lambda_handler", + "Role": {"Fn::GetAtt": ["HelloWorldFunctionRole", "Arn"]}, + "Runtime": "python3.8", + "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], + "Timeout": 3, + "TracingConfig": {"Mode": "Active"}, + }, + "Type": "AWS::Lambda::Function", + }, + "HelloWorldFunctionUrl": { + "Properties": {"AuthType": "AWS_IAM", "TargetFunctionArn": {"Ref": "HelloWorldFunction"}}, + "Type": "AWS::Lambda::Url", + }, + "HelloWorldFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": ["sts:AssumeRole"], + "Effect": "Allow", + "Principal": {"Service": ["lambda.amazonaws.com"]}, + } + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "arn:aws:iam::aws:policy/AWSXrayWriteOnlyAccess", + ], + "Tags": [{"Key": "lambda:createdBy", "Value": "SAM"}], + }, + "Type": "AWS::IAM::Role", + }, + "HelloWorldFunctionHelloWorldPermissionProd": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": {"Ref": "HelloWorldFunction"}, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/GET/hello", + {"__ApiId__": {"Ref": "ServerlessRestApi"}, "__Stage__": "*"}, + ] + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "HelloWorldFunctionHelloWorld2PermissionProd": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": {"Ref": "HelloWorldFunction"}, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/GET, PUT/hello2", + {"__ApiId__": {"Ref": "ServerlessRestApi"}, "__Stage__": "*"}, + ] + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "TestResource2": { + "Properties": { + "Body": { + "info": {"version": "1.0", "description": "Test resources", "title": {"Ref": "AWS::StackName"}}, + "paths": {}, + "openapi": "3.0.1", + "tags": [{"name": "httpapi:createdBy", "x-amazon-apigateway-tag-value": "SAM"}], + } + }, + "Type": "AWS::ApiGatewayV2::Api", + }, + "TestResource5": { + "Properties": { + "Body": { + "info": {"version": "1.0", "description": "Test resources", "title": {"Ref": "AWS::StackName"}}, + "paths": {}, + "openapi": "3.0.1", + "tags": [{"name": "httpapi:createdBy", "x-amazon-apigateway-tag-value": "SAM"}], + } + }, + "Type": "AWS::ApiGatewayV2::Api", + }, + "ApiGatewayDomainNameV28437445d28": { + "Properties": { + "DomainName": "api7.zhandr.people.aws.dev", + "DomainNameConfigurations": [ + {"CertificateArn": {"Ref": "customDomainCert"}, "EndpointType": "REGIONAL"} + ], + "Tags": {"httpapi:createdBy": "SAM"}, + }, + "Type": "AWS::ApiGatewayV2::DomainName", + }, + "TestResource2ApiMapping": { + "Properties": { + "ApiId": {"Ref": "TestResource2"}, + "DomainName": {"Ref": "ApiGatewayDomainNameV28437445d28"}, + "Stage": {"Ref": "TestResource2Test2Stage"}, + }, + "Type": "AWS::ApiGatewayV2::ApiMapping", + }, + "TestResource2Test2Stage": { + "Properties": { + "ApiId": {"Ref": "TestResource2"}, + "AutoDeploy": True, + "StageName": "Test2", + "Tags": {"httpapi:createdBy": "SAM"}, + }, + "Type": "AWS::ApiGatewayV2::Stage", + }, + "TestResource4": { + "Properties": { + "Body": { + "info": {"version": "1.0", "description": "Test resources", "title": {"Ref": "AWS::StackName"}}, + "paths": {}, + "openapi": "3.0.1", + "tags": [{"name": "httpapi:createdBy", "x-amazon-apigateway-tag-value": "SAM"}], + } + }, + "Type": "AWS::ApiGatewayV2::Api", + }, + "TestResource4Test2Stage": { + "Properties": { + "ApiId": {"Ref": "TestResource4"}, + "AutoDeploy": True, + "StageName": "Test2", + "Tags": {"httpapi:createdBy": "SAM"}, + }, + "Type": "AWS::ApiGatewayV2::Stage", + }, + "ServerlessRestApi": { + "Properties": { + "Body": { + "info": {"version": "1.0", "title": {"Ref": "AWS::StackName"}}, + "paths": { + "/hello2": { + "get, put": { + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations" + }, + }, + "responses": {}, + } + }, + "/hello": { + "get": { + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HelloWorldFunction.Arn}/invocations" + }, + }, + "responses": {}, + } + }, + }, + "swagger": "2.0", + } + }, + "Type": "AWS::ApiGateway::RestApi", + }, + "ServerlessRestApiDeployment88d73b1fc4": { + "Properties": { + "Description": "RestApi deployment id: 88d73b1fc436b53afc5f54ce63096d44e97b741b", + "RestApiId": {"Ref": "ServerlessRestApi"}, + "StageName": "Stage", + }, + "Type": "AWS::ApiGateway::Deployment", + }, + "ServerlessRestApiProdStage": { + "Properties": { + "DeploymentId": {"Ref": "ServerlessRestApiDeployment88d73b1fc4"}, + "RestApiId": {"Ref": "ServerlessRestApi"}, + "StageName": "Prod", + }, + "Type": "AWS::ApiGateway::Stage", + }, + }, +} + +SAM_APP_HELLO_RETURN_RESPONSE = { + "StackResources": [ + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "ApiGatewayDomainName1", + "PhysicalResourceId": "test.custom.domain1", + "ResourceType": "AWS::ApiGatewayV2::DomainName", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "HelloWorldFunction", + "PhysicalResourceId": "sam-app-hello6-HelloWorldFunction-testID", + "ResourceType": "AWS::Lambda::Function", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "HelloWorldFunctionUrl", + "PhysicalResourceId": "arn:aws:lambda:us-east-1:function:sam-app-hello6-HelloWorldFunction-testID", + "ResourceType": "AWS::Lambda::Url", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "ServerlessRestApi", + "PhysicalResourceId": "jwompba769", + "ResourceType": "AWS::ApiGateway::RestApi", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "ServerlessRestApiDeployment78c5316093", + "PhysicalResourceId": "lulx9h", + "ResourceType": "AWS::ApiGateway::Deployment", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "ServerlessRestApiProdStage", + "PhysicalResourceId": "Prod", + "ResourceType": "AWS::ApiGateway::Stage", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "TestResource2", + "PhysicalResourceId": "erj31jdyw5", + "ResourceType": "AWS::ApiGatewayV2::Api", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "TestResource2ApiMapping", + "PhysicalResourceId": "rut5pp", + "ResourceType": "AWS::ApiGatewayV2::ApiMapping", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "TestResource2Test2Stage", + "PhysicalResourceId": "Test2", + "ResourceType": "AWS::ApiGatewayV2::Stage", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "TestResource4", + "PhysicalResourceId": "5u9ekr1d32", + "ResourceType": "AWS::ApiGatewayV2::Api", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "TestResource4Test2Stage", + "PhysicalResourceId": "Test2", + "ResourceType": "AWS::ApiGatewayV2::Stage", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "customDomainCert", + "PhysicalResourceId": "arn:aws:acm:us-east-1:certificate", + "ResourceType": "AWS::CertificateManager::Certificate", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "test_apigw_restapi", + "PhysicalResourceId": "testPID", + "ResourceType": "AWS::ApiGateway::RestApi", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": {"StackResourceDriftStatus": "NOT_CHECKED"}, + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "apigw_dm_mapping_LID", + "PhysicalResourceId": "test.custom.bpmapping.domain", + "ResourceType": "AWS::ApiGateway::DomainName", + }, + { + "StackName": "sam-app-hello6", + "LogicalResourceId": "BPMapping1", + "PhysicalResourceId": "bp_mapping_PID", + "ResourceType": "AWS::ApiGateway::BasePathMapping", + }, + ], + "ResponseMetadata": { + "RequestId": "b15914d5-009b-46ce-aab8-458efc09f34d", + "HTTPStatusCode": 200, + "HTTPHeaders": { + "x-amzn-requestid": "b15914d5-009b-46ce-aab8-458efc09f34d", + "content-type": "text/xml", + "content-length": "10370", + "date": "Mon, 25 Jul 2022 20:27:05 GMT", + }, + "RetryAttempts": 0, + }, +} + +SAM_FILE_READER_RETURN = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "sam-app-hello\nSample SAM Template for sam-app-hello\n", + "Globals": {"Function": {"Tracing": "Active", "Timeout": 3}}, + "Resources": { + "HelloWorldFunction": { + "Type": "AWS::Serverless::Function", + "Properties": { + "CodeUri": "hello_world/", + "Handler": "app.lambda_handler", + "Architectures": ["x86_64"], + "Runtime": "python3.8", + "Events": {"HelloWorld": {"Type": "Api", "Properties": {"Path": "/hello", "Method": "get"}}}, + }, + } + }, +} + + +class TestTestableResourcesInitClients(TestCase): + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("boto3.Session.region_name", None) + def test_init_clients_no_region(self, patched_click_get_current_context, patched_click_echo): + with self.assertRaises(RegionError): + with TestableResourcesContext( + stack_name="test", output="json", region=None, profile=None, template_file=None + ) as testable_resources_context: + testable_resources_context.init_clients() + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("boto3.Session.region_name", "us-east-1") + def test_init_clients_no_input_region_get_region_from_session( + self, patched_click_get_current_context, patched_click_echo + ): + with TestableResourcesContext( + stack_name="test", output="json", region=None, profile=None, template_file=None + ) as testable_resources_context: + testable_resources_context.init_clients() + self.assertEqual(testable_resources_context.region, "us-east-1") + + +class TestGetFunctionUrl(TestCase): + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_clienterror_resource_not_found( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_resource.side_effect = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "The resource you requested does not exist"}}, + "GetResources", + ) + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=mock_client_provider.return_value.return_value, + apigateway_client=None, + apigatewayv2_client=None, + mapper=None, + consumer=None, + ) + response = testable_resource_producer.get_function_url("testID") + self.assertEqual(response, "-") + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_clienterror_others( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_resource.side_effect = ClientError( + {"Error": {"Code": "ExpiredToken", "Message": "The security token included in the request is expired"}}, + "DescribeStacks", + ) + with self.assertRaises(SamListUnknownClientError): + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=mock_client_provider.return_value.return_value, + apigateway_client=None, + apigatewayv2_client=None, + mapper=None, + consumer=None, + ) + testable_resource_producer.get_function_url("testID") + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_properties_not_in_response( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_resource.return_value = {} + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=mock_client_provider.return_value.return_value, + apigateway_client=None, + apigatewayv2_client=None, + mapper=None, + consumer=None, + ) + response = testable_resource_producer.get_function_url("testID") + self.assertEqual(response, "-") + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_properties_in_response( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_resource.return_value = { + "TypeName": "AWS::Lambda::Url", + "ResourceDescription": { + "Identifier": "testid", + "Properties": '{"FunctionArn":"arn:aws:lambda:sam-app-hello-HelloWorldFunction","FunctionUrl":"https://test.lambda-url.us-east-1.on.aws/","AuthType":"AWS_IAM"}', + }, + "ResponseMetadata": { + "RequestId": "testID", + "HTTPStatusCode": 200, + "HTTPHeaders": { + "x-amzn-requestid": "testID", + "date": "testDate", + "content-type": "application/x-amz-json-1.0", + "content-length": "408", + }, + "RetryAttempts": 0, + }, + } + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=mock_client_provider.return_value.return_value, + apigateway_client=None, + apigatewayv2_client=None, + mapper=None, + consumer=None, + ) + response = testable_resource_producer.get_function_url("testID") + self.assertEqual(response, "https://test.lambda-url.us-east-1.on.aws/") + + +class TestGetStages(TestCase): + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_apigw_v2_stages( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_stages.return_value = { + "ResponseMetadata": { + "RequestId": "testid", + "HTTPStatusCode": 200, + "HTTPHeaders": { + "date": "Mon, 18 Jul 2022 20:59:15 GMT", + "content-type": "application/json", + "content-length": "762", + "connection": "keep-alive", + "x-amzn-requestid": "testid", + "access-control-allow-origin": "*", + "x-amz-apigw-id": "testid", + "access-control-expose-headers": "x-amzn-RequestId,x-amzn-ErrorType,x-amzn-ErrorMessage,Date", + "x-amzn-trace-id": "Root=testid", + }, + "RetryAttempts": 0, + }, + "Items": [ + { + "AutoDeploy": True, + "DefaultRouteSettings": {"DetailedMetricsEnabled": False}, + "RouteSettings": {}, + "StageName": "$default", + "StageVariables": {}, + } + ], + } + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=None, + apigatewayv2_client=mock_client_provider.return_value.return_value, + mapper=None, + consumer=None, + ) + response = testable_resource_producer.get_stage_list("testID", APIGatewayEnum.API_GATEWAY_V2) + self.assertEqual(response, ["$default"]) + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_apigw_stages( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_stages.return_value = { + "ResponseMetadata": { + "RequestId": "testID", + "HTTPStatusCode": 200, + "HTTPHeaders": { + "date": "Mon, 18 Jul 2022 21:15:06 GMT", + "content-type": "application/json", + "content-length": "679", + "connection": "keep-alive", + "x-amzn-requestid": "testID", + "x-amz-apigw-id": "testID", + }, + "RetryAttempts": 0, + }, + "item": [ + { + "deploymentId": "t50nmu", + "stageName": "Prod", + "cacheClusterEnabled": False, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": False, + "tags": {"aws:cloudformation:logical-id": "testID", "aws:cloudformation:stack-name": "testStack"}, + }, + { + "deploymentId": "t50nmu", + "stageName": "Stage", + "cacheClusterEnabled": False, + "cacheClusterStatus": "NOT_AVAILABLE", + "methodSettings": {}, + "tracingEnabled": False, + }, + ], + } + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=mock_client_provider.return_value.return_value, + apigatewayv2_client=None, + mapper=None, + consumer=None, + ) + response = testable_resource_producer.get_stage_list("testID", APIGatewayEnum.API_GATEWAY) + self.assertEqual(response, ["Prod", "Stage"]) + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_apigw_stages_empty_return( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_stages.return_value = { + "ResponseMetadata": { + "RequestId": "testID", + "HTTPStatusCode": 200, + "HTTPHeaders": { + "date": "Mon, 18 Jul 2022 21:15:06 GMT", + "content-type": "application/json", + "content-length": "679", + "connection": "keep-alive", + "x-amzn-requestid": "testID", + "x-amz-apigw-id": "testID", + }, + "RetryAttempts": 0, + }, + } + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=mock_client_provider.return_value.return_value, + apigatewayv2_client=None, + mapper=None, + consumer=None, + ) + response = testable_resource_producer.get_stage_list("testID", APIGatewayEnum.API_GATEWAY) + self.assertEqual(response, []) + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_get_stage_list_unknown_clienterror( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_stages.side_effect = ClientError( + {"Error": {"Code": "ExpiredToken", "Message": "The security token included in the request is expired"}}, + "DescribeStacks", + ) + with self.assertRaises(SamListUnknownClientError): + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=mock_client_provider.return_value.return_value, + apigatewayv2_client=mock_client_provider.return_value.return_value, + mapper=None, + consumer=None, + ) + testable_resource_producer.get_stage_list("testID", APIGatewayEnum.API_GATEWAY) + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_get_stage_list_not_found_exception_clienterror( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_stages.side_effect = ClientError( + {"Error": {"Code": "NotFoundException", "Message": ""}}, + "DescribeStacks", + ) + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=mock_client_provider.return_value.return_value, + apigatewayv2_client=mock_client_provider.return_value.return_value, + mapper=None, + consumer=None, + ) + response = testable_resource_producer.get_stage_list("testID", APIGatewayEnum.API_GATEWAY) + self.assertEqual(response, []) + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.commands.list.cli_common.list_common_context.get_boto_client_provider_with_config") + def test_get_stage_list_unknown_botocore_error( + self, + mock_client_provider, + patched_click_get_current_context, + patched_click_echo, + ): + mock_client_provider.return_value.return_value.get_stages.side_effect = EndpointConnectionError( + endpoint_url="https://cloudformation.test.amazonaws.com/" + ) + with self.assertRaises(SamListUnknownBotoCoreError): + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=mock_client_provider.return_value.return_value, + apigatewayv2_client=mock_client_provider.return_value.return_value, + mapper=None, + consumer=None, + ) + testable_resource_producer.get_stage_list("testID", APIGatewayEnum.API_GATEWAY) + + +class TestBuildAPIGWEndpoints(TestCase): + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + def test_build_api_gw_endpoints( + self, + patched_click_get_current_context, + patched_click_echo, + ): + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=None, + apigatewayv2_client=None, + mapper=None, + consumer=None, + ) + repsonse1 = testable_resource_producer.build_api_gw_endpoints("testID", []) + self.assertEqual(repsonse1, []) + repsonse2 = testable_resource_producer.build_api_gw_endpoints("testID", ["Prod"]) + self.assertEqual(repsonse2, ["https://testID.execute-api.us-east-1.amazonaws.com/Prod"]) + + +class TestTestableResourcesProducerProduce(TestCase): + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.testable_resources.testable_resources_producer.SamLocalStackProvider.get_stacks") + @patch("samcli.lib.list.testable_resources.testable_resources_producer.get_template_data") + @patch( + "samcli.lib.list.testable_resources.testable_resources_producer.TestableResourcesProducer.get_translated_dict" + ) + def test_produce_resources_not_found_error( + self, + mock_get_translated_dict, + mock_get_template_data, + mock_get_stacks, + patched_click_get_current_context, + patched_click_echo, + ): + mock_get_template_data.return_value = {} + mock_get_translated_dict.return_value = {} + mock_get_stacks.return_value = ([], []) + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=None, + apigatewayv2_client=None, + mapper=None, + consumer=None, + ) + with self.assertRaises(SamListLocalResourcesNotFoundError): + testable_resource_producer.produce() + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.testable_resources.testable_resources_producer.get_template_data") + @patch( + "samcli.lib.list.testable_resources.testable_resources_producer.TestableResourcesProducer.get_translated_dict" + ) + def test_produce_no_stack_name_json( + self, + mock_get_translated_dict, + mock_get_template_data, + patched_click_get_current_context, + patched_click_echo, + ): + mock_get_template_data.return_value = {} + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN_WITH_APIS + + stacks = SamLocalStackProvider.get_stacks( + template_file="", template_dictionary=mock_get_translated_dict.return_value + ) + testable_resource_producer = TestableResourcesProducer( + stack_name=None, + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=None, + apigatewayv2_client=None, + mapper=DataToJsonMapper(), + consumer=StringConsumerJsonOutput(), + ) + testable_resource_producer.produce() + expected_output = [ + call( + '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": "-"\n },\n {\n "LogicalResourceId": "TestResource2",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource5",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource4",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": []\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": [\n "/hello2[\'get, put\']",\n "/hello[\'get\']"\n ]\n }\n]' + ) + ] + self.assertEqual(patched_click_echo.call_args_list, expected_output) + + @patch("samcli.commands.list.json_consumer.click.echo") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + @patch("samcli.lib.list.testable_resources.testable_resources_producer.get_template_data") + @patch( + "samcli.lib.list.testable_resources.testable_resources_producer.TestableResourcesProducer.get_translated_dict" + ) + @patch( + "samcli.lib.list.testable_resources.testable_resources_producer.TestableResourcesProducer.get_resources_info" + ) + @patch("samcli.lib.list.testable_resources.testable_resources_producer.TestableResourcesProducer.get_function_url") + @patch("samcli.lib.list.testable_resources.testable_resources_producer.TestableResourcesProducer.get_stage_list") + def test_produce_has_stack_name_( + self, + mock_get_stages_list, + mock_get_function_url, + mock_get_resources_info, + mock_get_translated_dict, + mock_get_template_data, + patched_click_get_current_context, + patched_click_echo, + ): + mock_get_stages_list.return_value = ["testStage"] + mock_get_function_url.return_value = "test.function.url" + mock_get_resources_info.return_value = SAM_APP_HELLO_RETURN_RESPONSE + mock_get_template_data.return_value = {} + mock_get_translated_dict.return_value = TRANSLATED_DICT_RETURN_WITH_APIS + + stacks = SamLocalStackProvider.get_stacks( + template_file="", template_dictionary=mock_get_translated_dict.return_value + ) + testable_resource_producer = TestableResourcesProducer( + stack_name="sam-app-hello6", + region="us-east-1", + profile=None, + template_file=None, + cloudformation_client=None, + iam_client=None, + cloudcontrol_client=None, + apigateway_client=None, + apigatewayv2_client=None, + mapper=DataToJsonMapper(), + consumer=StringConsumerJsonOutput(), + ) + testable_resource_producer.produce() + expected_output = [ + call( + '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "sam-app-hello6-HelloWorldFunction-testID",\n "CloudEndpointOrFURL": "test.function.url",\n "Methods": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "jwompba769",\n "CloudEndpointOrFURL": [\n "https://jwompba769.execute-api.us-east-1.amazonaws.com/testStage"\n ],\n "Methods": [\n "/hello2[\'get, put\']",\n "/hello[\'get\']"\n ]\n },\n {\n "LogicalResourceId": "TestResource2",\n "PhysicalResourceId": "erj31jdyw5",\n "CloudEndpointOrFURL": [\n "https://erj31jdyw5.execute-api.us-east-1.amazonaws.com/testStage"\n ],\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource4",\n "PhysicalResourceId": "5u9ekr1d32",\n "CloudEndpointOrFURL": [\n "https://5u9ekr1d32.execute-api.us-east-1.amazonaws.com/testStage"\n ],\n "Methods": []\n },\n {\n "LogicalResourceId": "test_apigw_restapi",\n "PhysicalResourceId": "testPID",\n "CloudEndpointOrFURL": [\n "https://test.custom.bpmapping.domain"\n ],\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource5",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": []\n }\n]' + ) + ] + self.assertEqual(patched_click_echo.call_args_list, expected_output) From 040e7dfeb253dd31d53f14180d2d19e001a97331 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 3 Aug 2022 18:43:15 -0700 Subject: [PATCH 68/72] Changed table and made changes based on pr comments --- samcli/commands/list/table_consumer.py | 24 +- samcli/lib/list/mapper_consumer_factory.py | 4 +- .../resources/resources_to_table_mapper.py | 29 ++- .../stack_output_to_table_mapper.py | 28 ++- .../testable_resources/testable_res_def.py | 2 +- .../testable_resources_producer.py | 227 +++++++++++------- .../testable_resources_to_table_mapper.py | 51 ++-- .../test_stack_outputs_context.py | 1 - tests/unit/commands/list/test_list_mappers.py | 45 +++- .../test_testable_resources_context.py | 4 +- 10 files changed, 283 insertions(+), 132 deletions(-) diff --git a/samcli/commands/list/table_consumer.py b/samcli/commands/list/table_consumer.py index e4a75de4869..d00a6a35d83 100644 --- a/samcli/commands/list/table_consumer.py +++ b/samcli/commands/list/table_consumer.py @@ -2,13 +2,29 @@ The table consumer for 'sam list' """ from samcli.lib.list.list_interfaces import ListInfoPullerConsumer -from samcli.views.concrete_views.rich_table import RichTable +from samcli.commands._utils.table_print import pprint_column_names, pprint_columns class StringConsumerTableOutput(ListInfoPullerConsumer): """ - Consumes a Rich table and outputs it in table format + Outputs data in table format """ - def consume(self, data: RichTable) -> None: - data.print() + def consume(self, data: dict) -> None: + @pprint_column_names( + format_string=data["format_string"], + format_kwargs=data["format_args"], + table_header=data["table_name"], + ) + def print_table(**kwargs): + for entry in data["data"]: + pprint_columns( + columns=entry, + width=kwargs["width"], + margin=kwargs["margin"], + format_string=data["format_string"], + format_args=kwargs["format_args"], + columns_dict=data["format_args"].copy(), + ) + + print_table() diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py index 9e9c090e233..8cc4a520ef1 100644 --- a/samcli/lib/list/mapper_consumer_factory.py +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -14,7 +14,6 @@ class MapperConsumerFactory(MapperConsumerFactoryInterface): def create(self, producer: ProducersEnum, output: str) -> MapperConsumerContainer: - # Will add conditions here to return different sorts of containers later on if output == "json": data_to_json_mapper = DataToJsonMapper() json_consumer = StringConsumerJsonOutput() @@ -24,10 +23,9 @@ def create(self, producer: ProducersEnum, output: str) -> MapperConsumerContaine table_consumer = StringConsumerTableOutput() if producer == ProducersEnum.STACK_OUTPUTS_PRODUCER: table_mapper = StackOutputToTableMapper() - # add conditional for when adding in the testable resources table elif producer == ProducersEnum.RESOURCES_PRODUCER: table_mapper = ResourcesToTableMapper() - else: + elif producer == ProducersEnum.TESTABLE_RESOURCES_PRODUCER: table_mapper = TestableResourcesToTableMapper() container = MapperConsumerContainer(table_mapper, table_consumer) return container diff --git a/samcli/lib/list/resources/resources_to_table_mapper.py b/samcli/lib/list/resources/resources_to_table_mapper.py index 3a423d2a161..970ea19e3f4 100644 --- a/samcli/lib/list/resources/resources_to_table_mapper.py +++ b/samcli/lib/list/resources/resources_to_table_mapper.py @@ -1,15 +1,30 @@ """ Implementation of the resources to table mapper """ +from typing import Dict, Any +from collections import OrderedDict from samcli.lib.list.list_interfaces import Mapper -from samcli.views.concrete_views.rich_table import RichTable class ResourcesToTableMapper(Mapper): - def map(self, data: list) -> RichTable: - output = RichTable(title="Resources", table_options={"show_lines": True}) - output.add_column("Logical ID", {"justify": "center", "no_wrap": True}) - output.add_column("Physical ID", {"justify": "center", "no_wrap": True}) + def map(self, data: list) -> Dict[Any, Any]: + entry_list = [] for resource in data: - output.add_row([resource["LogicalResourceId"], resource["PhysicalResourceId"]]) - return output + entry_list.append( + [ + resource.get("LogicalResourceId", "-"), + resource.get("PhysicalResourceId", "-"), + ] + ) + table_data = { + "format_string": "{Logical ID:<{0}} {Physical ID:<{1}}", + "format_args": OrderedDict( + { + "Logical ID": "Logical ID", + "Physical ID": "Physical ID", + } + ), + "table_name": "Resources", + "data": entry_list, + } + return table_data diff --git a/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py b/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py index 20f3a8f9716..35fcd62467d 100644 --- a/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py +++ b/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py @@ -1,16 +1,28 @@ """ Implementation of the stack output to table mapper """ +from typing import Dict, Any +from collections import OrderedDict from samcli.lib.list.list_interfaces import Mapper -from samcli.views.concrete_views.rich_table import RichTable class StackOutputToTableMapper(Mapper): - def map(self, data: list) -> RichTable: - output = RichTable(title="Stack Outputs", table_options={"show_lines": True}) - output.add_column("OutputKey", {"justify": "center", "no_wrap": True}) - output.add_column("OutputValue", {"justify": "center", "no_wrap": True}) - output.add_column("Description", {"justify": "center", "no_wrap": True}) + def map(self, data: list) -> Dict[Any, Any]: + entry_list = [] for stack_output in data: - output.add_row([stack_output["OutputKey"], stack_output["OutputValue"], stack_output["Description"]]) - return output + entry_list.append( + [ + stack_output.get("OutputKey", "-"), + stack_output.get("OutputValue", "-"), + stack_output.get("Description", "-"), + ] + ) + table_data = { + "format_string": "{OutputKey:<{0}} {OutputValue:<{1}} {Description:<{2}}", + "format_args": OrderedDict( + {"OutputKey": "OutputKey", "OutputValue": "OutputValue", "Description": "Description"} + ), + "table_name": "Stack Outputs", + "data": entry_list, + } + return table_data diff --git a/samcli/lib/list/testable_resources/testable_res_def.py b/samcli/lib/list/testable_resources/testable_res_def.py index 3fddb86804f..ee0f670d7ed 100644 --- a/samcli/lib/list/testable_resources/testable_res_def.py +++ b/samcli/lib/list/testable_resources/testable_res_def.py @@ -9,5 +9,5 @@ class TestableResDef: LogicalResourceId: str PhysicalResourceId: str - CloudEndpointOrFURL: Any + CloudEndpointOrFunctionURL: Any Methods: Any diff --git a/samcli/lib/list/testable_resources/testable_resources_producer.py b/samcli/lib/list/testable_resources/testable_resources_producer.py index f880fc36a1f..cdfa081a577 100644 --- a/samcli/lib/list/testable_resources/testable_resources_producer.py +++ b/samcli/lib/list/testable_resources/testable_resources_producer.py @@ -21,6 +21,19 @@ from samcli.lib.utils.boto_utils import get_client_error_code LOG = logging.getLogger(__name__) +TESTABLE_RESOURCE_TYPES = {"AWS::Lambda::Function", "AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"} +RESOURCE_DESCRIPTION = "ResourceDescription" +PROPERTIES = "Properties" +FUNCTION_URL = "FunctionUrl" +STACK_RESOURCES = "StackResources" +RESOURCE_TYPE = "ResourceType" +PHYSICAL_RESOURCE_ID = "PhysicalResourceId" +LOGICAL_RESOURCE_ID = "LogicalResourceId" +REST_API_ID = "RestApiId" +API_ID = "ApiId" +DOMAIN_NAME = "DomainName" +BODY = "Body" +PATHS = "paths" class APIGatewayEnum(Enum): @@ -74,10 +87,10 @@ def get_function_url(self, identifier: str) -> Any: """ try: response = self.cloudcontrol_client.get_resource(TypeName="AWS::Lambda::Url", Identifier=identifier) - if not response.get("ResourceDescription", {}).get("Properties", {}): + if not response.get(RESOURCE_DESCRIPTION, {}).get(PROPERTIES, {}): return "-" - response_dict = json.loads(response.get("ResourceDescription", {}).get("Properties", {})) - furl = response_dict.get("FunctionUrl", "-") + response_dict = json.loads(response.get(RESOURCE_DESCRIPTION, {}).get(PROPERTIES, {})) + furl = response_dict.get(FUNCTION_URL, "-") return furl except ClientError as e: if get_client_error_code(e) == "ResourceNotFoundException": @@ -111,7 +124,7 @@ def get_stage_list(self, api_id: str, api_type: APIGatewayEnum) -> List[Any]: response = self.apigateway_client.get_stages(restApiId=api_id) search_key = "item" stage_name_key = "stageName" - else: + elif api_type == APIGatewayEnum.API_GATEWAY_V2: response = self.apigatewayv2_client.get_stages(ApiId=api_id) search_key = "Items" stage_name_key = "StageName" @@ -131,12 +144,99 @@ def get_stage_list(self, api_id: str, api_type: APIGatewayEnum) -> List[Any]: raise SamListUnknownBotoCoreError(msg=str(e)) from e def build_api_gw_endpoints(self, physical_id: str, stages: list) -> list: + """ + Builds the default api gateway endpoints + + Parameters + ---------- + physical_id: str + The physical ID of the api resource + stages: list + A list of stages for the api resource + + Returns + ------- + api_list: List[Any] + The list of default api gateway endpoints + """ api_list = [] for stage in stages: api_list.append(f"https://{physical_id}.execute-api.{self.region}.amazonaws.com/{stage}") return api_list + def get_cloud_testable_resources(self, stacks: list) -> list: + """ + Gets a list of cloud testable resources + + Parameters + ---------- + stacks: list + A list containing the local stack + + Returns + ------- + testable_resources_list: List[Any] + A list of cloud testable resources + """ + testable_resources_list = [] + local_stack = stacks[0] + local_stack_resources = local_stack.resources + seen_testable_resources = set() + response = self.get_resources_info() + response_domain_dict = get_response_domain_dict(response) + custom_domain_substitute_dict = get_custom_domain_substitute_list(response, stacks, response_domain_dict) + + for deployed_resource in response.get(STACK_RESOURCES, {}): + if deployed_resource.get(RESOURCE_TYPE, "") in TESTABLE_RESOURCE_TYPES: + endpoint_function_url: Any + paths_and_methods: Any + endpoint_function_url = "-" + paths_and_methods = "-" + if deployed_resource.get(RESOURCE_TYPE, "") == "AWS::Lambda::Function": + endpoint_function_url = self.get_function_url(deployed_resource.get(PHYSICAL_RESOURCE_ID, "")) + + elif deployed_resource.get(RESOURCE_TYPE, "") in ("AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"): + stages = self.get_stage_list( + deployed_resource.get(PHYSICAL_RESOURCE_ID, ""), + get_api_type_enum(deployed_resource.get(RESOURCE_TYPE, "")), + ) + if deployed_resource.get(LOGICAL_RESOURCE_ID, "") in custom_domain_substitute_dict: + endpoint_function_url = custom_domain_substitute_dict.get( + deployed_resource.get(LOGICAL_RESOURCE_ID, ""), "-" + ) + else: + endpoint_function_url = self.build_api_gw_endpoints( + deployed_resource.get(PHYSICAL_RESOURCE_ID, ""), stages + ) + paths_and_methods = get_methods_and_paths( + deployed_resource.get(LOGICAL_RESOURCE_ID, ""), local_stack + ) + + testable_resource_data = TestableResDef( + LogicalResourceId=deployed_resource.get(LOGICAL_RESOURCE_ID, "-"), + PhysicalResourceId=deployed_resource.get(PHYSICAL_RESOURCE_ID, "-"), + CloudEndpointOrFunctionURL=endpoint_function_url, + Methods=paths_and_methods, + ) + testable_resources_list.append(dataclasses.asdict(testable_resource_data)) + seen_testable_resources.add(deployed_resource.get(LOGICAL_RESOURCE_ID, "")) + for local_resource in local_stack_resources: + local_resource_type = local_stack_resources.get(local_resource, {}).get("Type", "") + paths_and_methods = "-" + if local_resource_type in TESTABLE_RESOURCE_TYPES and local_resource not in seen_testable_resources: + if local_resource_type in ("AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"): + paths_and_methods = get_methods_and_paths(local_resource, local_stack) + testable_resource_data = TestableResDef( + LogicalResourceId=local_resource, + PhysicalResourceId="-", + CloudEndpointOrFunctionURL="-", + Methods=paths_and_methods, + ) + testable_resources_list.append(dataclasses.asdict(testable_resource_data)) + + return testable_resources_list + def produce(self): """ The producer function for the testable resources command @@ -146,59 +246,13 @@ def produce(self): translated_dict = self.get_translated_dict(template_file_dict=sam_template) stacks, _ = SamLocalStackProvider.get_stacks(template_file="", template_dictionary=translated_dict) validate_stack(stacks) - seen_testable_resources = set() - testable_resources_list = [] - testable_resource_types = {"AWS::Lambda::Function", "AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"} + + testable_resources_list: list + if self.stack_name: - response = self.get_resources_info() - response_domain_dict = get_response_domain_dict(response) - custom_domain_substitute_dict = get_custom_domain_substitute_list(response, stacks, response_domain_dict) - - for deployed_resource in response["StackResources"]: - if deployed_resource["ResourceType"] in testable_resource_types: - endpoint_function_url = "-" - paths_and_methods = "-" - if deployed_resource["ResourceType"] == "AWS::Lambda::Function": - endpoint_function_url = self.get_function_url(deployed_resource["PhysicalResourceId"]) - - elif deployed_resource["ResourceType"] in ("AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"): - stages = self.get_stage_list( - deployed_resource["PhysicalResourceId"], - get_api_type_enum(deployed_resource["ResourceType"]), - ) - if deployed_resource["LogicalResourceId"] in custom_domain_substitute_dict: - endpoint_function_url = custom_domain_substitute_dict[ - deployed_resource["LogicalResourceId"] - ] - else: - endpoint_function_url = self.build_api_gw_endpoints( - deployed_resource["PhysicalResourceId"], stages - ) - paths_and_methods = get_methods_and_paths(deployed_resource["LogicalResourceId"], stacks[0]) - - testable_resource_data = TestableResDef( - LogicalResourceId=deployed_resource["LogicalResourceId"], - PhysicalResourceId=deployed_resource["PhysicalResourceId"], - CloudEndpointOrFURL=endpoint_function_url, - Methods=paths_and_methods, - ) - testable_resources_list.append(dataclasses.asdict(testable_resource_data)) - seen_testable_resources.add(deployed_resource["LogicalResourceId"]) - for local_resource in stacks[0].resources: - local_resource_type = stacks[0].resources[local_resource]["Type"] - paths_and_methods = "-" - if local_resource_type in testable_resource_types and local_resource not in seen_testable_resources: - if local_resource_type in ("AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"): - paths_and_methods = get_methods_and_paths(local_resource, stacks[0]) - testable_resource_data = TestableResDef( - LogicalResourceId=local_resource, - PhysicalResourceId="-", - CloudEndpointOrFURL="-", - Methods=paths_and_methods, - ) - testable_resources_list.append(dataclasses.asdict(testable_resource_data)) + testable_resources_list = self.get_cloud_testable_resources(stacks) else: - testable_resources_list = get_local_testable_resources(stacks, testable_resource_types) + testable_resources_list = get_local_testable_resources(stacks) mapped_output = self.mapper.map(testable_resources_list) self.consumer.consume(mapped_output) @@ -212,11 +266,12 @@ def validate_stack(stacks: list): stacks: list A list containing the stack """ - if not stacks or not stacks[0].resources: + + if not stacks or not hasattr(stacks[0], "resources") or not stacks[0].resources: raise SamListLocalResourcesNotFoundError(msg="No local resources found.") -def get_local_testable_resources(stacks: list, testable_resource_types: set) -> list: +def get_local_testable_resources(stacks: list) -> list: """ Gets a list of local testable resources based on the local stack @@ -224,8 +279,6 @@ def get_local_testable_resources(stacks: list, testable_resource_types: set) -> ---------- stacks: list A list containing the stack - testable_resource_types: set - A set of resources types that should be displayed by testable resources Returns ------- @@ -234,17 +287,19 @@ def get_local_testable_resources(stacks: list, testable_resource_types: set) -> """ testable_resources_list = [] paths_and_methods: Any - for local_resource in stacks[0].resources: - local_resource_type = stacks[0].resources[local_resource]["Type"] - if local_resource_type in testable_resource_types: + local_stack = stacks[0] + local_stack_resources = local_stack.resources + for local_resource in local_stack_resources: + local_resource_type = local_stack_resources.get(local_resource, {}).get("Type", "") + if local_resource_type in TESTABLE_RESOURCE_TYPES: paths_and_methods = "-" if local_resource_type in ("AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"): - paths_and_methods = get_methods_and_paths(local_resource, stacks[0]) + paths_and_methods = get_methods_and_paths(local_resource, local_stack) # Set the PhysicalID to "-" if there is no corresponding PhysicalID testable_resource_data = TestableResDef( LogicalResourceId=local_resource, PhysicalResourceId="-", - CloudEndpointOrFURL="-", + CloudEndpointOrFunctionURL="-", Methods=paths_and_methods, ) testable_resources_list.append(dataclasses.asdict(testable_resource_data)) @@ -289,25 +344,27 @@ def get_custom_domain_substitute_list( A dict containing the custom domain lists mapped to the original apis """ custom_domain_substitute_dict = {} - for resource in response["StackResources"]: - if resource["ResourceType"] == "AWS::ApiGateway::BasePathMapping": - local_mapping = stacks[0].resources[resource["LogicalResourceId"]]["Properties"] - rest_api_id = local_mapping["RestApiId"] - domain_id = local_mapping["DomainName"] + local_stack = stacks[0] + local_stack_resources = local_stack.resources + for resource in response.get(STACK_RESOURCES, {}): + if resource.get(RESOURCE_TYPE, "") == "AWS::ApiGateway::BasePathMapping": + local_mapping = local_stack_resources.get(resource.get(LOGICAL_RESOURCE_ID, ""), {}).get(PROPERTIES, {}) + rest_api_id = local_mapping.get(REST_API_ID, "") + domain_id = local_mapping.get(DOMAIN_NAME, "") if domain_id in response_domain_dict: if rest_api_id not in custom_domain_substitute_dict: - custom_domain_substitute_dict[rest_api_id] = [response_domain_dict[domain_id]] + custom_domain_substitute_dict[rest_api_id] = [response_domain_dict.get(domain_id, None)] else: - custom_domain_substitute_dict[rest_api_id].append(response_domain_dict[domain_id]) - elif resource["ResourceType"] == "AWS::ApiGatewayV2::ApiMapping": - local_mapping = stacks[0].resources[resource["LogicalResourceId"]]["Properties"] - rest_api_id = local_mapping["ApiId"] - domain_id = local_mapping["DomainName"] + custom_domain_substitute_dict[rest_api_id].append(response_domain_dict.get(domain_id, None)) + elif resource.get(RESOURCE_TYPE, "") == "AWS::ApiGatewayV2::ApiMapping": + local_mapping = local_stack_resources.get(resource.get(LOGICAL_RESOURCE_ID, ""), {}).get(PROPERTIES, {}) + rest_api_id = local_mapping.get(API_ID, "") + domain_id = local_mapping.get(DOMAIN_NAME, "") if domain_id in response_domain_dict: if rest_api_id not in custom_domain_substitute_dict: - custom_domain_substitute_dict[rest_api_id] = [response_domain_dict[domain_id]] + custom_domain_substitute_dict[rest_api_id] = [response_domain_dict.get(domain_id, None)] else: - custom_domain_substitute_dict[rest_api_id].append(response_domain_dict[domain_id]) + custom_domain_substitute_dict[rest_api_id].append(response_domain_dict.get(domain_id, None)) return custom_domain_substitute_dict @@ -326,12 +383,14 @@ def get_response_domain_dict(response: Dict[Any, Any]) -> Dict[str, str]: A dict containing the custom domains """ response_domain_dict = {} - for resource in response["StackResources"]: + for resource in response.get(STACK_RESOURCES, {}): if ( - resource["ResourceType"] == "AWS::ApiGateway::DomainName" - or resource["ResourceType"] == "AWS::ApiGatewayV2::DomainName" + resource.get(RESOURCE_TYPE, "") == "AWS::ApiGateway::DomainName" + or resource.get(RESOURCE_TYPE, "") == "AWS::ApiGatewayV2::DomainName" ): - response_domain_dict[resource["LogicalResourceId"]] = "https://" + resource["PhysicalResourceId"] + response_domain_dict[ + resource.get(LOGICAL_RESOURCE_ID, "") + ] = f'https://{resource.get(PHYSICAL_RESOURCE_ID, "")}' return response_domain_dict @@ -355,12 +414,12 @@ def get_methods_and_paths(logical_id: str, stack: Stack) -> list: method_paths_list = [] if not stack.resources: raise SamListLocalResourcesNotFoundError(msg="No local resources found.") - if not stack.resources.get(logical_id, {}).get("Properties", {}).get("Body", {}).get("paths", {}): + if not stack.resources.get(logical_id, {}).get(PROPERTIES, {}).get(BODY, {}).get(PATHS, {}): return method_paths_list - paths_dict = stack.resources.get(logical_id, {}).get("Properties", {}).get("Body", {}).get("paths", {}) + paths_dict = stack.resources.get(logical_id, {}).get(PROPERTIES, {}).get(BODY, {}).get(PATHS, {}) for path in paths_dict: method_list = [] - for method in paths_dict[path]: + for method in paths_dict.get(path, ""): method_list.append(method) path_item = path + f"{method_list}" method_paths_list.append(path_item) diff --git a/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py b/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py index d108c93628e..54cea0257ac 100644 --- a/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py +++ b/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py @@ -1,30 +1,49 @@ """ Implementation of the testable resources to table mapper """ +from typing import Dict, Any +from collections import OrderedDict from samcli.lib.list.list_interfaces import Mapper -from samcli.views.concrete_views.rich_table import RichTable class TestableResourcesToTableMapper(Mapper): - def map(self, data: list) -> RichTable: - output = RichTable(title="Testable Resources", table_options={"show_lines": True}) - output.add_column("Resource ID", {"justify": "center", "no_wrap": True}) - output.add_column("Physical ID", {"justify": "center", "no_wrap": True}) - output.add_column("Cloud Endpoint/FURL", {"justify": "center", "no_wrap": True}) - output.add_column("Methods", {"justify": "center", "no_wrap": True}) + def map(self, data: list) -> Dict[Any, Any]: + entry_list = [] for testable_resource in data: - cloud_endpoint_furl_string = testable_resource["CloudEndpointOrFURL"] + cloud_endpoint_furl_string = testable_resource.get("CloudEndpointOrFunctionURL", "-") methods_string = "-" - if isinstance(testable_resource["CloudEndpointOrFURL"], list): - cloud_endpoint_furl_string = "\n".join(testable_resource["CloudEndpointOrFURL"]) - if isinstance(testable_resource["Methods"], list) and testable_resource["Methods"]: - methods_string = "; ".join(testable_resource["Methods"]) - output.add_row( + cloud_endpoint_furl_multi_list = [] + if isinstance(testable_resource.get("CloudEndpointOrFunctionURL", "-"), list) and testable_resource.get( + "CloudEndpointOrFunctionURL", [] + ): + cloud_endpoint_furl_string = testable_resource.get("CloudEndpointOrFunctionURL", ["-"])[0] + if len(testable_resource.get("CloudEndpointOrFunctionURL", [])) > 1: + cloud_endpoint_furl_multi_list = testable_resource.get("CloudEndpointOrFunctionURL", ["", ""])[1:] + if isinstance(testable_resource.get("Methods", "-"), list) and testable_resource.get("Methods", []): + methods_string = "; ".join(testable_resource.get("Methods", [])) + + entry_list.append( [ - testable_resource["LogicalResourceId"], - testable_resource["PhysicalResourceId"], + testable_resource.get("LogicalResourceId", "-"), + testable_resource.get("PhysicalResourceId", "-"), cloud_endpoint_furl_string, methods_string, ] ) - return output + if cloud_endpoint_furl_multi_list: + for url in cloud_endpoint_furl_multi_list: + entry_list.append(["", "", url, ""]) + table_data = { + "format_string": "{Resource ID:<{0}} {Physical ID:<{1}} {Cloud Endpoint/FURL:<{2}} {Methods:<{3}}", + "format_args": OrderedDict( + { + "Resource ID": "Resource ID", + "Physical ID": "Physical ID", + "Cloud Endpoint/FURL": "Cloud Endpoint/FURL", + "Methods": "Methods", + } + ), + "table_name": "Testable Resources", + "data": entry_list, + } + return table_data diff --git a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py index 1286d4367b8..721e1d9eeb1 100644 --- a/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py +++ b/tests/unit/commands/list/stack_outputs/test_stack_outputs_context.py @@ -27,7 +27,6 @@ def test_stack_outputs_stack_exists( '[\n {\n "OutputKey": "HelloWorldTest",\n "OutputValue": "TestVal",\n "Description": "Test"\n }\n]' ) ] - print(patched_click_echo.call_args_list) self.assertEqual( expected_click_echo_calls, patched_click_echo.call_args_list, "Stack and stack outputs should exist" ) diff --git a/tests/unit/commands/list/test_list_mappers.py b/tests/unit/commands/list/test_list_mappers.py index e4a024d1e5d..8d0550573f2 100644 --- a/tests/unit/commands/list/test_list_mappers.py +++ b/tests/unit/commands/list/test_list_mappers.py @@ -1,12 +1,14 @@ from unittest import TestCase +from unittest.mock import patch, call +from collections import OrderedDict from samcli.lib.list.resources.resources_to_table_mapper import ResourcesToTableMapper from samcli.lib.list.stack_outputs.stack_output_to_table_mapper import StackOutputToTableMapper from samcli.lib.list.data_to_json_mapper import DataToJsonMapper from samcli.commands.list.json_consumer import StringConsumerJsonOutput -from samcli.commands.list.table_consumer import StringConsumerTableOutput from samcli.lib.list.testable_resources.testable_resources_to_table_mapper import TestableResourcesToTableMapper from samcli.lib.list.mapper_consumer_factory import MapperConsumerFactory from samcli.lib.list.list_interfaces import ProducersEnum +from samcli.commands.list.table_consumer import StringConsumerTableOutput class TestStackOutputsToTableMapper(TestCase): @@ -14,7 +16,7 @@ def test_map(self): data = [{"OutputKey": "outputkey1", "OutputValue": "outputvalue1", "Description": "sample description"}] stack_outputs_to_table_mapper = StackOutputToTableMapper() output = stack_outputs_to_table_mapper.map(data) - self.assertEqual(output._title, "Stack Outputs") + self.assertEqual(output.get("table_name", ""), "Stack Outputs") class TestResourcesToTableMapper(TestCase): @@ -22,7 +24,7 @@ def test_map(self): data = [{"LogicalResourceId": "LID_1", "PhysicalResourceId": "PID_1"}] resources_to_table_mapper = ResourcesToTableMapper() output = resources_to_table_mapper.map(data) - self.assertEqual(output._title, "Resources") + self.assertEqual(output.get("table_name", ""), "Resources") class TestTestableResourcesToTableMapper(TestCase): @@ -31,19 +33,31 @@ def test_map(self): { "LogicalResourceId": "LID_1", "PhysicalResourceId": "PID_1", - "CloudEndpointOrFURL": "test.url", + "CloudEndpointOrFunctionURL": "test.url", + "Methods": "-", + }, + { + "LogicalResourceId": "LID_1", + "PhysicalResourceId": "PID_1", + "CloudEndpointOrFunctionURL": "-", "Methods": "-", }, { "LogicalResourceId": "LID_1", "PhysicalResourceId": "PID_1", - "CloudEndpointOrFURL": ["api.url1", "api.url2"], + "CloudEndpointOrFunctionURL": ["api.url1"], + "Methods": "-", + }, + { + "LogicalResourceId": "LID_1", + "PhysicalResourceId": "PID_1", + "CloudEndpointOrFunctionURL": ["api.url1", "api.url2", "api.url3"], "Methods": ["/hello2['get, put']", "/hello['get']"], }, ] testable_resources_to_table_mapper = TestableResourcesToTableMapper() output = testable_resources_to_table_mapper.map(data) - self.assertEqual(output._title, "Testable Resources") + self.assertEqual(output.get("table_name", ""), "Testable Resources") class TestMapperConsumerFactory(TestCase): @@ -70,3 +84,22 @@ def test_create_testable_resources_table_output(self): container = factory.create(ProducersEnum.TESTABLE_RESOURCES_PRODUCER, "table") self.assertIsInstance(container.mapper, TestableResourcesToTableMapper) self.assertIsInstance(container.consumer, StringConsumerTableOutput) + + +class TestTableConsumer(TestCase): + @patch("samcli.commands.list.json_consumer.click.secho") + @patch("samcli.commands.list.json_consumer.click.get_current_context") + def test_consume(self, patched_click_get_current_context, patched_click_echo): + consumer = StringConsumerTableOutput() + data = { + "format_string": "{OutputKey:<{0}} {OutputValue:<{1}} {Description:<{2}}", + "format_args": OrderedDict( + {"OutputKey": "OutputKey", "OutputValue": "OutputValue", "Description": "Description"} + ), + "table_name": "Stack Outputs", + "data": [], + } + consumer.consume(data) + print(patched_click_echo.call_args_list) + self.assertTrue(patched_click_echo.call_args_list) + self.assertEqual(call("Stack Outputs"), patched_click_echo.call_args_list[0]) diff --git a/tests/unit/commands/list/testable_resources/test_testable_resources_context.py b/tests/unit/commands/list/testable_resources/test_testable_resources_context.py index 27ba7ce76f6..aa6a503a1b1 100644 --- a/tests/unit/commands/list/testable_resources/test_testable_resources_context.py +++ b/tests/unit/commands/list/testable_resources/test_testable_resources_context.py @@ -960,7 +960,7 @@ def test_produce_no_stack_name_json( testable_resource_producer.produce() expected_output = [ call( - '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": "-"\n },\n {\n "LogicalResourceId": "TestResource2",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource5",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource4",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": []\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": [\n "/hello2[\'get, put\']",\n "/hello[\'get\']"\n ]\n }\n]' + '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFunctionURL": "-",\n "Methods": "-"\n },\n {\n "LogicalResourceId": "TestResource2",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFunctionURL": "-",\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource5",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFunctionURL": "-",\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource4",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFunctionURL": "-",\n "Methods": []\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFunctionURL": "-",\n "Methods": [\n "/hello2[\'get, put\']",\n "/hello[\'get\']"\n ]\n }\n]' ) ] self.assertEqual(patched_click_echo.call_args_list, expected_output) @@ -1011,7 +1011,7 @@ def test_produce_has_stack_name_( testable_resource_producer.produce() expected_output = [ call( - '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "sam-app-hello6-HelloWorldFunction-testID",\n "CloudEndpointOrFURL": "test.function.url",\n "Methods": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "jwompba769",\n "CloudEndpointOrFURL": [\n "https://jwompba769.execute-api.us-east-1.amazonaws.com/testStage"\n ],\n "Methods": [\n "/hello2[\'get, put\']",\n "/hello[\'get\']"\n ]\n },\n {\n "LogicalResourceId": "TestResource2",\n "PhysicalResourceId": "erj31jdyw5",\n "CloudEndpointOrFURL": [\n "https://erj31jdyw5.execute-api.us-east-1.amazonaws.com/testStage"\n ],\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource4",\n "PhysicalResourceId": "5u9ekr1d32",\n "CloudEndpointOrFURL": [\n "https://5u9ekr1d32.execute-api.us-east-1.amazonaws.com/testStage"\n ],\n "Methods": []\n },\n {\n "LogicalResourceId": "test_apigw_restapi",\n "PhysicalResourceId": "testPID",\n "CloudEndpointOrFURL": [\n "https://test.custom.bpmapping.domain"\n ],\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource5",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFURL": "-",\n "Methods": []\n }\n]' + '[\n {\n "LogicalResourceId": "HelloWorldFunction",\n "PhysicalResourceId": "sam-app-hello6-HelloWorldFunction-testID",\n "CloudEndpointOrFunctionURL": "test.function.url",\n "Methods": "-"\n },\n {\n "LogicalResourceId": "ServerlessRestApi",\n "PhysicalResourceId": "jwompba769",\n "CloudEndpointOrFunctionURL": [\n "https://jwompba769.execute-api.us-east-1.amazonaws.com/testStage"\n ],\n "Methods": [\n "/hello2[\'get, put\']",\n "/hello[\'get\']"\n ]\n },\n {\n "LogicalResourceId": "TestResource2",\n "PhysicalResourceId": "erj31jdyw5",\n "CloudEndpointOrFunctionURL": [\n "https://erj31jdyw5.execute-api.us-east-1.amazonaws.com/testStage"\n ],\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource4",\n "PhysicalResourceId": "5u9ekr1d32",\n "CloudEndpointOrFunctionURL": [\n "https://5u9ekr1d32.execute-api.us-east-1.amazonaws.com/testStage"\n ],\n "Methods": []\n },\n {\n "LogicalResourceId": "test_apigw_restapi",\n "PhysicalResourceId": "testPID",\n "CloudEndpointOrFunctionURL": [\n "https://test.custom.bpmapping.domain"\n ],\n "Methods": []\n },\n {\n "LogicalResourceId": "TestResource5",\n "PhysicalResourceId": "-",\n "CloudEndpointOrFunctionURL": "-",\n "Methods": []\n }\n]' ) ] self.assertEqual(patched_click_echo.call_args_list, expected_output) From 12273f2a91846d13b1ad9ea5041fc650a2415748 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Thu, 4 Aug 2022 16:02:19 -0700 Subject: [PATCH 69/72] Fixed integration test expected outputs --- .../test_testable_resources_command.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/list/testable_resources/test_testable_resources_command.py b/tests/integration/list/testable_resources/test_testable_resources_command.py index 5d2218229f4..aef9240839f 100644 --- a/tests/integration/list/testable_resources/test_testable_resources_command.py +++ b/tests/integration/list/testable_resources/test_testable_resources_command.py @@ -45,19 +45,19 @@ def test_no_stack_name(self): """{ "LogicalResourceId": "HelloWorldFunction", "PhysicalResourceId": "-", - "CloudEndpointOrFURL": "-", + "CloudEndpointOrFunctionURL": "-", "Methods": "-" }""", """{ "LogicalResourceId": "TestAPI", "PhysicalResourceId": "-", - "CloudEndpointOrFURL": "-", + "CloudEndpointOrFunctionURL": "-", "Methods": [] }""", """{ "LogicalResourceId": "ServerlessRestApi", "PhysicalResourceId": "-", - "CloudEndpointOrFURL": "-", + "CloudEndpointOrFunctionURL": "-", "Methods": [ "/hello2['get']", "/hello['get']" @@ -91,13 +91,13 @@ def test_has_stack_name(self): """{ "LogicalResourceId": "HelloWorldFunction", "PhysicalResourceId": "test-has-stack-name.*", - "CloudEndpointOrFURL": "https://.*.lambda-url..*.on.aws/", + "CloudEndpointOrFunctionURL": "https://.*.lambda-url..*.on.aws/", "Methods": "-" }""", """ { "LogicalResourceId": "ServerlessRestApi", "PhysicalResourceId": ".*", - "CloudEndpointOrFURL": .* + "CloudEndpointOrFunctionURL": .* "https://.*.execute-api..*.amazonaws.com/Prod", "https://.*.execute-api..*.amazonaws.com/Stage" .*, @@ -109,7 +109,7 @@ def test_has_stack_name(self): """ { "LogicalResourceId": "TestAPI", "PhysicalResourceId": ".*", - "CloudEndpointOrFURL": . + "CloudEndpointOrFunctionURL": . "https://.*.execute-api..*.amazonaws.com/Test2" ., "Methods": .. From 99ad6420c3ac0807827f9ca7137f8a32aac5422e Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Tue, 9 Aug 2022 10:09:39 -0700 Subject: [PATCH 70/72] Fixed table heading --- .../testable_resources/testable_resources_to_table_mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py b/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py index 54cea0257ac..5225d062725 100644 --- a/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py +++ b/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py @@ -39,7 +39,7 @@ def map(self, data: list) -> Dict[Any, Any]: { "Resource ID": "Resource ID", "Physical ID": "Physical ID", - "Cloud Endpoint/FURL": "Cloud Endpoint/FURL", + "Cloud Endpoint/FURL": "Cloud Endpoint/Function URL", "Methods": "Methods", } ), From db4a2799444dabe6ab038d8df0ab79f85f43b697 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 10 Aug 2022 11:58:02 -0700 Subject: [PATCH 71/72] Added docstring and re-arranged the testable resources producer to reduce if-elses within a single function --- samcli/commands/list/table_consumer.py | 18 ++++- .../testable_resources_context.py | 18 +++++ samcli/lib/list/mapper_consumer_factory.py | 20 ++++++ .../resources/resources_to_table_mapper.py | 18 +++++ .../stack_output_to_table_mapper.py | 18 +++++ .../testable_resources/testable_res_def.py | 4 ++ .../testable_resources_producer.py | 70 ++++++++++++++++--- .../testable_resources_to_table_mapper.py | 18 +++++ .../test_stack_outputs_command.py | 28 ++++---- 9 files changed, 184 insertions(+), 28 deletions(-) diff --git a/samcli/commands/list/table_consumer.py b/samcli/commands/list/table_consumer.py index d00a6a35d83..cbbdd52f971 100644 --- a/samcli/commands/list/table_consumer.py +++ b/samcli/commands/list/table_consumer.py @@ -1,6 +1,7 @@ """ The table consumer for 'sam list' """ +from typing import Dict, Any from samcli.lib.list.list_interfaces import ListInfoPullerConsumer from samcli.commands._utils.table_print import pprint_column_names, pprint_columns @@ -10,13 +11,24 @@ class StringConsumerTableOutput(ListInfoPullerConsumer): Outputs data in table format """ - def consume(self, data: dict) -> None: + def consume(self, data: Dict[Any, Any]) -> None: + """ + Outputs the data in a table format + Parameters + ---------- + data: Dict[Any, Any] + The data to be outputted + """ + @pprint_column_names( format_string=data["format_string"], format_kwargs=data["format_args"], table_header=data["table_name"], ) - def print_table(**kwargs): + def print_table_rows(**kwargs): + """ + Prints the rows of the table based on the data provided + """ for entry in data["data"]: pprint_columns( columns=entry, @@ -27,4 +39,4 @@ def print_table(**kwargs): columns_dict=data["format_args"].copy(), ) - print_table() + print_table_rows() diff --git a/samcli/commands/list/testable_resources/testable_resources_context.py b/samcli/commands/list/testable_resources/testable_resources_context.py index 41fa400edc4..df2ca7b8e6f 100644 --- a/samcli/commands/list/testable_resources/testable_resources_context.py +++ b/samcli/commands/list/testable_resources/testable_resources_context.py @@ -13,9 +13,27 @@ class TestableResourcesContext(ListContext): + """ + Context class for testable resources + """ + def __init__( self, stack_name: str, output: str, region: Optional[str], profile: Optional[str], template_file: Optional[str] ): + """ + Parameters + ---------- + stack_name: str + The name of the stack + output: str + The format of the output, either json or table + region: Optional[str] + The region of the stack + profile: Optional[str] + Optional profile to be used + template_file: Optional[str] + The location of the template file. If one is not specified, the default will be "template.yaml" in the CWD + """ super().__init__() self.stack_name = stack_name self.output = output diff --git a/samcli/lib/list/mapper_consumer_factory.py b/samcli/lib/list/mapper_consumer_factory.py index 8cc4a520ef1..565a023a3f7 100644 --- a/samcli/lib/list/mapper_consumer_factory.py +++ b/samcli/lib/list/mapper_consumer_factory.py @@ -13,7 +13,27 @@ class MapperConsumerFactory(MapperConsumerFactoryInterface): + """ + Factory class to create factory objects that map a given producer and output format to a mapper and a consumer + """ + def create(self, producer: ProducersEnum, output: str) -> MapperConsumerContainer: + """ + Creates a MapperConsumerContainer that contains the resulting mapper and consumer given + the producer and output format + + Parameters + ---------- + producer: ProducersEnum + An enum representing the producers (stack-outputs, resources, or testable-resources producer) + output: str + The output format, either json or table + + Returns + ------- + container: MapperConsumerContainer + A MapperConsumerContainer containing the resulting mapper and consumer to be used by the producer + """ if output == "json": data_to_json_mapper = DataToJsonMapper() json_consumer = StringConsumerJsonOutput() diff --git a/samcli/lib/list/resources/resources_to_table_mapper.py b/samcli/lib/list/resources/resources_to_table_mapper.py index 970ea19e3f4..4cc209809b9 100644 --- a/samcli/lib/list/resources/resources_to_table_mapper.py +++ b/samcli/lib/list/resources/resources_to_table_mapper.py @@ -7,7 +7,25 @@ class ResourcesToTableMapper(Mapper): + """ + Mapper class for mapping resources data for table output + """ + def map(self, data: list) -> Dict[Any, Any]: + """ + Maps data to the format needed for consumption by the table consumer + + Parameters + ---------- + data: list + List of dictionaries containing the entries of the resources data + + Returns + ------- + table_data: Dict[Any, Any] + Dictionary containing the information and data needed for the table + consumer to output the data in table format + """ entry_list = [] for resource in data: entry_list.append( diff --git a/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py b/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py index 35fcd62467d..3e0cc846f65 100644 --- a/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py +++ b/samcli/lib/list/stack_outputs/stack_output_to_table_mapper.py @@ -7,7 +7,25 @@ class StackOutputToTableMapper(Mapper): + """ + Mapper class for mapping stack-outputs data for table output + """ + def map(self, data: list) -> Dict[Any, Any]: + """ + Maps data to the format needed for consumption by the table consumer + + Parameters + ---------- + data: list + List of dictionaries containing the entries of the stack outputs data + + Returns + ------- + table_data: Dict[Any, Any] + Dictionary containing the information and data needed for the table consumer + to output the data in table format + """ entry_list = [] for stack_output in data: entry_list.append( diff --git a/samcli/lib/list/testable_resources/testable_res_def.py b/samcli/lib/list/testable_resources/testable_res_def.py index ee0f670d7ed..0d89e632e9d 100644 --- a/samcli/lib/list/testable_resources/testable_res_def.py +++ b/samcli/lib/list/testable_resources/testable_res_def.py @@ -7,6 +7,10 @@ @dataclass class TestableResDef: + """ + Dataclass for containing entries of testable resources data + """ + LogicalResourceId: str PhysicalResourceId: str CloudEndpointOrFunctionURL: Any diff --git a/samcli/lib/list/testable_resources/testable_resources_producer.py b/samcli/lib/list/testable_resources/testable_resources_producer.py index cdfa081a577..ab82f931eaf 100644 --- a/samcli/lib/list/testable_resources/testable_resources_producer.py +++ b/samcli/lib/list/testable_resources/testable_resources_producer.py @@ -56,6 +56,32 @@ def __init__( mapper, consumer, ): + """ + Parameters + ---------- + stack_name: str + The name of the stack + region: Optional[str] + The region of the stack + profile: Optional[str] + Optional profile to be used + template_file: Optional[str] + The location of the template file. If one is not specified, the default will be "template.yaml" in the CWD + cloudformation_client: CloudFormation + The CloudFormation client + iam_client: IAM + The IAM client + cloudcontrol_client: CloudControl + The CloudControl client + apigateway_client: APIGateway + The APIGateway client + apigatewayv2_client: APIGatewayV2 + The APIGatewayV2 client + mapper: Mapper + The mapper used to map data to the format needed for the consumer provided + consumer: ListInfoPullerConsumer + The consumer used to output the data + """ super().__init__( stack_name, region, profile, template_file, cloudformation_client, iam_client, mapper, consumer ) @@ -165,6 +191,37 @@ def build_api_gw_endpoints(self, physical_id: str, stages: list) -> list: api_list.append(f"https://{physical_id}.execute-api.{self.region}.amazonaws.com/{stage}") return api_list + def get_api_gateway_endpoint( + self, deployed_resource: Dict[Any, Any], custom_domain_substitute_dict: Dict[Any, Any] + ) -> Any: + """ + Gets the API gateway endpoints for APIGateway and APIGatewayV2 APIs + + Parameters + ---------- + deployed_resource: Dict[Any, Any] + Dictionary containing the resource info of the deployed API + custom_domain_substitute_dict: Dict[Any, Any] + Dictionary containing the mappings of the custom domains for APIs + + Returns + ------- + endpoint: Any + The endpoint(s) of the current API resource + """ + endpoint: Any + stages = self.get_stage_list( + deployed_resource.get(PHYSICAL_RESOURCE_ID, ""), + get_api_type_enum(deployed_resource.get(RESOURCE_TYPE, "")), + ) + if deployed_resource.get(LOGICAL_RESOURCE_ID, "") in custom_domain_substitute_dict: + endpoint = custom_domain_substitute_dict.get( + deployed_resource.get(LOGICAL_RESOURCE_ID, ""), "-" + ) + else: + endpoint = self.build_api_gw_endpoints(deployed_resource.get(PHYSICAL_RESOURCE_ID, ""), stages) + return endpoint + def get_cloud_testable_resources(self, stacks: list) -> list: """ Gets a list of cloud testable resources @@ -197,18 +254,9 @@ def get_cloud_testable_resources(self, stacks: list) -> list: endpoint_function_url = self.get_function_url(deployed_resource.get(PHYSICAL_RESOURCE_ID, "")) elif deployed_resource.get(RESOURCE_TYPE, "") in ("AWS::ApiGateway::RestApi", "AWS::ApiGatewayV2::Api"): - stages = self.get_stage_list( - deployed_resource.get(PHYSICAL_RESOURCE_ID, ""), - get_api_type_enum(deployed_resource.get(RESOURCE_TYPE, "")), + endpoint_function_url = self.get_api_gateway_endpoint( + deployed_resource, custom_domain_substitute_dict ) - if deployed_resource.get(LOGICAL_RESOURCE_ID, "") in custom_domain_substitute_dict: - endpoint_function_url = custom_domain_substitute_dict.get( - deployed_resource.get(LOGICAL_RESOURCE_ID, ""), "-" - ) - else: - endpoint_function_url = self.build_api_gw_endpoints( - deployed_resource.get(PHYSICAL_RESOURCE_ID, ""), stages - ) paths_and_methods = get_methods_and_paths( deployed_resource.get(LOGICAL_RESOURCE_ID, ""), local_stack ) diff --git a/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py b/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py index 5225d062725..e55ce950a51 100644 --- a/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py +++ b/samcli/lib/list/testable_resources/testable_resources_to_table_mapper.py @@ -7,7 +7,25 @@ class TestableResourcesToTableMapper(Mapper): + """ + Mapper class for mapping testable-resources data for table output + """ + def map(self, data: list) -> Dict[Any, Any]: + """ + Maps data to the format needed for consumption by the table consumer + + Parameters + ---------- + data: list + List of dictionaries containing the entries of the testable resources data + + Returns + ------- + table_data: Dict[Any, Any] + Dictionary containing the information and data needed for the table consumer + to output the data in table format + """ entry_list = [] for testable_resource in data: cloud_endpoint_furl_string = testable_resource.get("CloudEndpointOrFunctionURL", "-") diff --git a/tests/integration/list/stack_outputs/test_stack_outputs_command.py b/tests/integration/list/stack_outputs/test_stack_outputs_command.py index 4043a590f49..54016784d3d 100644 --- a/tests/integration/list/stack_outputs/test_stack_outputs_command.py +++ b/tests/integration/list/stack_outputs/test_stack_outputs_command.py @@ -56,30 +56,30 @@ def test_stack_output_exists(self): self.assertTrue( re.search( """{ - "OutputKey": "HelloWorldFunctionIamRole", - "OutputValue": "arn:aws:iam::.*:role/.*-HelloWorldFunctionRole\-.*", - "Description": "Implicit IAM Role created for Hello World function" -}""", + "OutputKey": "HelloWorldFunctionIamRole", + "OutputValue": "arn:aws:iam::.*:role/.*-HelloWorldFunctionRole\-.*", + "Description": "Implicit IAM Role created for Hello World function" + }""", command_result.stdout.decode(), ) ) self.assertTrue( re.search( - """{ - "OutputKey": "HelloWorldApi", - "OutputValue": "https://.*execute.*.amazonaws.com/Prod/hello/", - "Description": "API Gateway endpoint URL for Prod stage for Hello World function" -}""", + """ { + "OutputKey": "HelloWorldApi", + "OutputValue": "https://.*execute.*.amazonaws.com/Prod/hello/", + "Description": "API Gateway endpoint URL for Prod stage for Hello World function" + }""", command_result.stdout.decode(), ) ) self.assertTrue( re.search( - """{ - "OutputKey": "HelloWorldFunction", - "OutputValue": "arn:aws:lambda:.*:.*:function:.*-HelloWorldFunction\-.*", - "Description": "Hello World Lambda Function ARN" -}""", + """ { + "OutputKey": "HelloWorldFunction", + "OutputValue": "arn:aws:lambda:.*:.*:function:.*-HelloWorldFunction\-.*", + "Description": "Hello World Lambda Function ARN" + }""", command_result.stdout.decode(), ) ) From 95d1758212e90e393fde4d9af107bc621a8c4fc3 Mon Sep 17 00:00:00 2001 From: Andrew Zhan Date: Wed, 10 Aug 2022 14:27:35 -0700 Subject: [PATCH 72/72] Fixed format --- .../list/testable_resources/testable_resources_producer.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/samcli/lib/list/testable_resources/testable_resources_producer.py b/samcli/lib/list/testable_resources/testable_resources_producer.py index ab82f931eaf..be2a6441d48 100644 --- a/samcli/lib/list/testable_resources/testable_resources_producer.py +++ b/samcli/lib/list/testable_resources/testable_resources_producer.py @@ -215,9 +215,7 @@ def get_api_gateway_endpoint( get_api_type_enum(deployed_resource.get(RESOURCE_TYPE, "")), ) if deployed_resource.get(LOGICAL_RESOURCE_ID, "") in custom_domain_substitute_dict: - endpoint = custom_domain_substitute_dict.get( - deployed_resource.get(LOGICAL_RESOURCE_ID, ""), "-" - ) + endpoint = custom_domain_substitute_dict.get(deployed_resource.get(LOGICAL_RESOURCE_ID, ""), "-") else: endpoint = self.build_api_gw_endpoints(deployed_resource.get(PHYSICAL_RESOURCE_ID, ""), stages) return endpoint