diff --git a/samcli/commands/deploy/command.py b/samcli/commands/deploy/command.py index 5b7744b89d..e1e3ae0452 100644 --- a/samcli/commands/deploy/command.py +++ b/samcli/commands/deploy/command.py @@ -26,6 +26,7 @@ from samcli.lib.utils import osutils from samcli.lib.bootstrap.bootstrap import manage_stack from samcli.lib.utils.version_checker import check_newer_version +from samcli.lib.bootstrap.companion_stack.companion_stack_manager import sync_ecr_stack SHORT_HELP = "Deploy an AWS SAM application." @@ -158,6 +159,14 @@ "If you do not provide a --s3-bucket value, the managed bucket will be used. " "Do not use --s3-guided parameter with this option.", ) +@click.option( + "--resolve-image-repos", + required=False, + is_flag=True, + help="Automatically create and delete ECR repositories for image-based functions in non-guided deployments. " + "A companion stack containing ECR repos for each function will be deployed along with the template stack. " + "Automatically created image repositories will be deleted if the corresponding functions are removed.", +) @metadata_override_option @notification_arns_override_option @tags_override_option @@ -196,6 +205,7 @@ def cli( confirm_changeset, signing_profiles, resolve_s3, + resolve_image_repos, config_file, config_env, ): @@ -230,6 +240,7 @@ def cli( resolve_s3, config_file, config_env, + resolve_image_repos, ) # pragma: no cover @@ -260,6 +271,7 @@ def do_cli( resolve_s3, config_file, config_env, + resolve_image_repos, ): """ Implementation of the ``cli`` method @@ -289,13 +301,21 @@ def do_cli( config_file=config_file, ) guided_context.run() - elif resolve_s3 and bool(s3_bucket): - raise DeployResolveS3AndS3SetError() - elif resolve_s3: - s3_bucket = manage_stack(profile=profile, region=region) - click.echo(f"\n\t\tManaged S3 bucket: {s3_bucket}") - click.echo("\t\tA different default S3 bucket can be set in samconfig.toml") - click.echo("\t\tOr by specifying --s3-bucket explicitly.") + else: + if resolve_s3: + if bool(s3_bucket): + raise DeployResolveS3AndS3SetError() + s3_bucket = manage_stack(profile=profile, region=region) + click.echo(f"\n\t\tManaged S3 bucket: {s3_bucket}") + click.echo("\t\tA different default S3 bucket can be set in samconfig.toml") + click.echo("\t\tOr by specifying --s3-bucket explicitly.") + + # TODO Refactor resolve-s3 and resolve-image-repos into one place + # after we figure out how to enable resolve-images-repos in package + if resolve_image_repos: + image_repositories = sync_ecr_stack( + template_file, stack_name, region, s3_bucket, s3_prefix, image_repositories + ) with osutils.tempfile_platform_independent() as output_template_file: diff --git a/samcli/commands/deploy/guided_config.py b/samcli/commands/deploy/guided_config.py index eef259af9c..a236c18808 100644 --- a/samcli/commands/deploy/guided_config.py +++ b/samcli/commands/deploy/guided_config.py @@ -102,7 +102,10 @@ def _save_parameter_overrides(self, cmd_names, config_env, parameter_overrides, samconfig.put(cmd_names, self.section, "parameter_overrides", " ".join(_params), env=config_env) def _save_image_repositories(self, cmd_names, config_env, samconfig, image_repositories): - if image_repositories: + # Check for None only as empty dict should be saved to config + # This can happen in an edge case where all companion stack repos are deleted and + # the config needs to be updated. + if image_repositories is not None: _image_repositories = [f"{key}={value}" for key, value in image_repositories.items()] samconfig.put(cmd_names, self.section, "image_repositories", _image_repositories, env=config_env) diff --git a/samcli/commands/deploy/guided_context.py b/samcli/commands/deploy/guided_context.py index 10fd3b6da8..f878c011f9 100644 --- a/samcli/commands/deploy/guided_context.py +++ b/samcli/commands/deploy/guided_context.py @@ -3,7 +3,7 @@ """ import logging -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional import click from click import confirm @@ -13,8 +13,6 @@ from samcli.commands._utils.options import _space_separated_list_func_type from samcli.commands._utils.template import ( get_template_parameters, - get_template_artifacts_format, - get_template_function_resource_ids, ) from samcli.commands.deploy.auth_utils import auth_per_resource from samcli.commands.deploy.code_signer_utils import ( @@ -31,12 +29,13 @@ from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable from samcli.lib.package.ecr_utils import is_ecr_url from samcli.lib.package.image_utils import tag_translation, NonLocalImageException, NoImageFoundException -from samcli.lib.providers.provider import Stack -from samcli.lib.providers.sam_function_provider import SamFunctionProvider +from samcli.lib.providers.provider import Function, Stack from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider from samcli.lib.utils.colors import Colored from samcli.lib.utils.defaults import get_default_aws_region from samcli.lib.utils.packagetype import IMAGE +from samcli.lib.providers.sam_function_provider import SamFunctionProvider +from samcli.lib.bootstrap.companion_stack.companion_stack_manager import CompanionStackManager LOG = logging.getLogger(__name__) @@ -137,7 +136,6 @@ def guided_prompts(self, parameter_override_keys): parameter_overrides=sanitize_parameter_overrides(input_parameter_overrides), global_parameter_overrides=global_parameter_overrides, ) - image_repositories = self.prompt_image_repository(stacks) click.secho("\t#Shows you resources changes to be deployed and require a 'Y' to initiate deploy") confirm_changeset = confirm( @@ -173,9 +171,14 @@ def guided_prompts(self, parameter_override_keys): type=click.STRING, ) + click.echo("\n\tLooking for resources needed for deployment:") s3_bucket = manage_stack(profile=self.profile, region=region) - click.echo(f"\n\t\tManaged S3 bucket: {s3_bucket}") - click.echo("\t\tA different default S3 bucket can be set in samconfig.toml") + click.echo(f"\t Managed S3 bucket: {s3_bucket}") + click.echo("\t A different default S3 bucket can be set in samconfig.toml") + + image_repositories = self.prompt_image_repository( + stack_name, stacks, self.image_repositories, region, s3_bucket, self.s3_prefix + ) self.guided_stack_name = stack_name self.guided_s3_bucket = s3_bucket @@ -289,51 +292,244 @@ def prompt_parameters( _prompted_param_overrides[parameter_key] = {"Value": parameter, "Hidden": False} return _prompted_param_overrides - def prompt_image_repository(self, stacks: List[Stack]): + def prompt_image_repository( + self, + stack_name, + stacks: List[Stack], + image_repositories: Optional[Dict[str, str]], + region: str, + s3_bucket: str, + s3_prefix: str, + ) -> Dict[str, str]: """ Prompt for the image repository to push the images. For each image function found in build artifacts, it will prompt for an image repository. Parameters ---------- + stack_name : List[Stack] + Name of the stack to be deployed. + stacks : List[Stack] List of stacks to look for image functions. + image_repositories: Dict[str, str] + Dictionary with function logical ID as key and image repo URI as value. + + region: str + Region for the image repos. + + s3_bucket: str + s3 bucket URI to be used for uploading companion stack template + + s3_prefix: str + s3 prefix to be used for uploading companion stack template + Returns ------- - Dict + Dict[str, str] A dictionary contains image function logical ID as key, image repository as value. """ - image_repositories = {} - artifacts_format = get_template_artifacts_format(template_file=self.template_file) - if IMAGE in artifacts_format: - self.function_provider = SamFunctionProvider(stacks, ignore_code_extraction_warnings=True) - function_resources = get_template_function_resource_ids(template_file=self.template_file, artifact=IMAGE) - for resource_id in function_resources: - image_repositories[resource_id] = prompt( - f"\t{self.start_bold}Image Repository for {resource_id}{self.end_bold}", - default=self.image_repositories.get(resource_id, "") - if isinstance(self.image_repositories, dict) - else "" or self.image_repository, - ) - if resource_id not in image_repositories or not is_ecr_url(str(image_repositories[resource_id])): - raise GuidedDeployFailedError( - f"Invalid Image Repository ECR URI: {image_repositories.get(resource_id)}" - ) - for resource_id, function_prop in self.function_provider.functions.items(): - if function_prop.packagetype == IMAGE: - image = function_prop.imageuri - try: - tag = tag_translation(image) - except NonLocalImageException: - pass - except NoImageFoundException as ex: - raise GuidedDeployFailedError("No images found to deploy, try running sam build") from ex - else: - click.secho(f"\t {image} to be pushed to {image_repositories.get(resource_id)}:{tag}") - click.secho(nl=True) - - return image_repositories + updated_repositories = image_repositories.copy() if image_repositories is not None else {} + self.function_provider = SamFunctionProvider(stacks, ignore_code_extraction_warnings=True) + manager = CompanionStackManager(stack_name, region, s3_bucket, s3_prefix) + + function_logical_ids = [ + function.full_path for function in self.function_provider.get_all() if function.packagetype == IMAGE + ] + + functions_without_repo = [ + function_logical_id + for function_logical_id in function_logical_ids + if function_logical_id not in updated_repositories + ] + + manager.set_functions(function_logical_ids, updated_repositories) + + create_all_repos = self.prompt_create_all_repos( + function_logical_ids, functions_without_repo, updated_repositories + ) + if create_all_repos: + updated_repositories.update(manager.get_repository_mapping()) + else: + updated_repositories = self.prompt_specify_repos(functions_without_repo, updated_repositories) + manager.set_functions(function_logical_ids, updated_repositories) + + updated_repositories = self.prompt_delete_unreferenced_repos( + [manager.get_repo_uri(repo) for repo in manager.get_unreferenced_repos()], updated_repositories + ) + GuidedContext.verify_images_exist_locally(self.function_provider.functions) + + manager.sync_repos() + return updated_repositories + + def prompt_specify_repos( + self, + functions_without_repos: List[str], + image_repositories: Dict[str, str], + ) -> Dict[str, str]: + """ + Show prompts for each function that isn't associated with a image repo + + Parameters + ---------- + functions_without_repos: List[str] + List of functions without associating repos + + image_repositories: Dict[str, str] + Current image repo dictionary with function logical ID as key and image repo URI as value. + + Returns + ------- + Dict[str, str] + Updated image repo dictionary with values(image repo URIs) filled by user input + """ + updated_repositories = image_repositories.copy() + for function_logical_id in functions_without_repos: + image_uri = prompt( + f"\t {self.start_bold}ECR repository for {function_logical_id}{self.end_bold}", + type=click.STRING, + ) + if not is_ecr_url(image_uri): + raise GuidedDeployFailedError(f"Invalid Image Repository ECR URI: {image_uri}") + + updated_repositories[function_logical_id] = image_uri + + return updated_repositories + + def prompt_create_all_repos( + self, functions: List[str], functions_without_repo: List[str], existing_mapping: Dict[str, str] + ) -> bool: + """ + Prompt whether to create all repos + + Parameters + ---------- + functions: List[str] + List of function logical IDs that are image based + + functions_without_repo: List[str] + List of function logical IDs that do not have an ECR image repo specified + + existing_mapping: Dict[str, str] + Current image repo dictionary with function logical ID as key and image repo URI as value. + This dict will be shown in the terminal. + + Returns + ------- + Boolean + Returns False if there is no missing function or denied by prompt + """ + if not functions: + return False + + # Case for when all functions do not have mapped repo + if functions == functions_without_repo: + click.echo("\t Image repositories: Not found.") + click.echo( + "\t #Managed repositories will be deleted when " + "their functions are removed from the template and deployed" + ) + return confirm( + f"\t {self.start_bold}Create managed ECR repositories for all functions?{self.end_bold}", default=True + ) + + functions_with_repo_count = len(functions) - len(functions_without_repo) + click.echo( + "\t Image repositories: " + f"Found ({functions_with_repo_count} of {len(functions)})" + " #Different image repositories can be set in samconfig.toml" + ) + for function_logical_id, repo_uri in existing_mapping.items(): + click.echo(f"\t {function_logical_id}: {repo_uri}") + + # Case for all functions do have mapped repo + if not functions_without_repo: + return False + + click.echo( + "\t #Managed repositories will be deleted when their functions are " + "removed from the template and deployed" + ) + return ( + confirm( + f"\t {self.start_bold}Create managed ECR repositories for the " + f"{len(functions_without_repo)} functions without?{self.end_bold}", + default=True, + ) + if functions_without_repo + else True + ) + + def prompt_delete_unreferenced_repos( + self, unreferenced_repo_uris: List[str], image_repositories: Dict[str, str] + ) -> Dict[str, str]: + """ + Prompt user for deleting unreferenced companion stack image repos. + Throws GuidedDeployFailedError if delete repos has been denied by the user. + This function does not actually remove the functions from the stack. + + Parameters + ---------- + + unreferenced_repo_uris: List[str] + List of unreferenced image repos that need to be deleted. + image_repositories: Dict[str, str] + Dictionary of image repo URIs with key as function logical ID and value as image repo URI + + Returns + ------- + Dict[str, str] + Copy of image_repositories that have unreferenced image repos removed + """ + output_image_repositories = image_repositories.copy() + + if not unreferenced_repo_uris: + return output_image_repositories + + click.echo("\t Checking for unreferenced ECR repositories to clean-up: " f"{len(unreferenced_repo_uris)} found") + for repo_uri in unreferenced_repo_uris: + click.echo(f"\t {repo_uri}") + delete_repos = confirm( + f"\t {self.start_bold}Delete the unreferenced repositories listed above when deploying?{self.end_bold}", + default=False, + ) + if not delete_repos: + click.echo("\t Deployment aborted!") + click.echo( + "\t #The deployment was aborted to prevent " + "unreferenced managed ECR repositories from being deleted.\n" + "\t #You may remove repositories from the SAMCLI " + "managed stack to retain them and resolve this unreferenced check." + ) + raise GuidedDeployFailedError("Unreferenced Auto Created ECR Repos Must Be Deleted.") + + for function_logical_id, repo_uri in image_repositories.items(): + if repo_uri in unreferenced_repo_uris: + del output_image_repositories[function_logical_id] + break + return output_image_repositories + + @staticmethod + def verify_images_exist_locally(functions: Dict[str, Function]) -> None: + """ + Verify all images associated with deploying functions exist locally. + + Parameters + ---------- + functions: Dict[str, Function] + Dictionary of functions in the stack to be deployed with key as their logical ID. + """ + for _, function_prop in functions.items(): + if function_prop.packagetype != IMAGE: + continue + image = function_prop.imageuri + try: + tag_translation(image) + except NonLocalImageException: + LOG.debug("Image URI is not pointing to local. Skipping verification.") + except NoImageFoundException as ex: + raise GuidedDeployFailedError("No images found to deploy, try running sam build") from ex def run(self): diff --git a/samcli/lib/bootstrap/bootstrap.py b/samcli/lib/bootstrap/bootstrap.py index a9a590dc7f..ae6cc74ebf 100644 --- a/samcli/lib/bootstrap/bootstrap.py +++ b/samcli/lib/bootstrap/bootstrap.py @@ -51,54 +51,47 @@ def get_current_account_id(profile: Optional[str] = None): def _get_stack_template(): gc = GlobalConfig() - info = {"version": __version__, "installationId": gc.installation_id if gc.installation_id else "unknown"} - - template = """ - AWSTemplateFormatVersion : '2010-09-09' - Transform: AWS::Serverless-2016-10-31 - Description: Managed Stack for AWS SAM CLI - - Metadata: - SamCliInfo: {info} - - Resources: - SamCliSourceBucket: - Type: AWS::S3::Bucket - Properties: - VersioningConfiguration: - Status: Enabled - Tags: - - Key: ManagedStackSource - Value: AwsSamCli - - SamCliSourceBucketBucketPolicy: - Type: AWS::S3::BucketPolicy - Properties: - Bucket: !Ref SamCliSourceBucket - PolicyDocument: - Statement: - - - Action: - - "s3:GetObject" - Effect: "Allow" - Resource: - Fn::Join: - - "" - - - - "arn:" - - !Ref AWS::Partition - - ":s3:::" - - !Ref SamCliSourceBucket - - "/*" - Principal: - Service: serverlessrepo.amazonaws.com - Condition: - StringEquals: - aws:SourceAccount: !Ref AWS::AccountId - - Outputs: - SourceBucket: - Value: !Ref SamCliSourceBucket - """ - - return template.format(info=json.dumps(info)) + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "Managed Stack for AWS SAM CLI", + "Metadata": { + "SamCliInfo": { + "version": __version__, + "installationId": gc.installation_id if gc.installation_id else "unknown", + } + }, + "Resources": { + "SamCliSourceBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "VersioningConfiguration": {"Status": "Enabled"}, + "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], + }, + }, + "SamCliSourceBucketBucketPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": "!Ref SamCliSourceBucket", + "PolicyDocument": { + "Statement": [ + { + "Action": ["s3:GetObject"], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + ["arn:", "!Ref AWS::Partition", ":s3:::", "!Ref SamCliSourceBucket", "/*"], + ] + }, + "Principal": {"Service": "serverlessrepo.amazonaws.com"}, + "Condition": {"StringEquals": {"aws:SourceAccount": "!Ref AWS::AccountId"}}, + } + ] + }, + }, + }, + }, + "Outputs": {"SourceBucket": {"Value": "!Ref SamCliSourceBucket"}}, + } + return json.dumps(template) diff --git a/samcli/lib/bootstrap/companion_stack/__init__.py b/samcli/lib/bootstrap/companion_stack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/samcli/lib/bootstrap/companion_stack/companion_stack_builder.py b/samcli/lib/bootstrap/companion_stack/companion_stack_builder.py new file mode 100644 index 0000000000..85280c2513 --- /dev/null +++ b/samcli/lib/bootstrap/companion_stack/companion_stack_builder.py @@ -0,0 +1,130 @@ +""" + Companion stack template builder +""" +import json + +from typing import Dict + +from samcli.lib.bootstrap.companion_stack.data_types import CompanionStack, ECRRepo +from samcli import __version__ as VERSION + + +class CompanionStackBuilder: + """ + CFN template builder for the companion stack + """ + + _parent_stack_name: str + _companion_stack: CompanionStack + _repo_mapping: Dict[str, ECRRepo] + + def __init__(self, companion_stack: CompanionStack) -> None: + self._companion_stack = companion_stack + self._repo_mapping: Dict[str, ECRRepo] = dict() + + def add_function(self, function_logical_id: str) -> None: + """ + Add an ECR repo associated with the function to the companion stack template + """ + self._repo_mapping[function_logical_id] = ECRRepo(self._companion_stack, function_logical_id) + + def clear_functions(self) -> None: + """ + Remove all functions that need ECR repos + """ + self._repo_mapping = dict() + + def build(self) -> str: + """ + Build companion stack CFN template with current functions + Returns + ------- + str + CFN template for companions stack + """ + template_dict = self._build_template_dict() + for _, ecr_repo in self._repo_mapping.items(): + template_dict["Resources"][ecr_repo.logical_id] = self._build_repo_dict(ecr_repo) + template_dict["Outputs"][ecr_repo.output_logical_id] = CompanionStackBuilder._build_output_dict(ecr_repo) + + return json.dumps(template_dict) + + def _build_template_dict(self) -> Dict: + """ + Build Companion stack template dictionary with Resources and Outputs not filled + Returns + ------- + dict + Companion stack template dictionary + """ + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "AWS SAM CLI Managed ECR Repo Stack", + "Metadata": {"SamCliInfo": VERSION, "CompanionStackname": self._companion_stack.stack_name}, + "Resources": {}, + "Outputs": {}, + } + return template + + def _build_repo_dict(self, repo: ECRRepo) -> Dict: + """ + Build a single ECR repo resource dictionary + + Parameters + ---------- + repo + ECR repo that will be turned into CFN resource + + Returns + ------- + dict + ECR repo resource dictionary + """ + return { + "Type": "AWS::ECR::Repository", + "Properties": { + "RepositoryName": repo.physical_id, + "Tags": [ + {"Key": "ManagedStackSource", "Value": "AwsSamCli"}, + {"Key": "AwsSamCliCompanionStack", "Value": self._companion_stack.stack_name}, + ], + "RepositoryPolicyText": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowLambdaSLR", + "Effect": "Allow", + "Principal": {"Service": ["lambda.amazonaws.com"]}, + "Action": ["ecr:GetDownloadUrlForLayer", "ecr:GetRepositoryPolicy", "ecr:BatchGetImage"], + } + ], + }, + }, + } + + @staticmethod + def _build_output_dict(repo: ECRRepo) -> Dict: + """ + Build a single ECR repo output resource dictionary + + Parameters + ---------- + repo + ECR repo that will be turned into CFN output resource + + Returns + ------- + dict + ECR repo output resource dictionary + """ + return { + "Value": f"!Sub ${{AWS::AccountId}}.dkr.ecr.${{AWS::Region}}.${{AWS::URLSuffix}}/${{{repo.logical_id}}}" + } + + @property + def repo_mapping(self) -> Dict[str, ECRRepo]: + """ + Repo mapping dictionary with key as function logical ID and value as ECRRepo object + """ + return self._repo_mapping diff --git a/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py b/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py new file mode 100644 index 0000000000..c6d1baa70c --- /dev/null +++ b/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py @@ -0,0 +1,319 @@ +""" + Companion stack manager +""" +import logging +from typing import List, Dict, Optional +import typing + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError, NoRegionError, NoCredentialsError + +from samcli.commands.exceptions import CredentialsError, RegionError +from samcli.lib.bootstrap.companion_stack.companion_stack_builder import CompanionStackBuilder +from samcli.lib.bootstrap.companion_stack.data_types import CompanionStack, ECRRepo +from samcli.lib.package.artifact_exporter import mktempfile +from samcli.lib.package.s3_uploader import S3Uploader +from samcli.lib.utils.packagetype import IMAGE +from samcli.lib.providers.sam_function_provider import SamFunctionProvider +from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider + +# pylint: disable=E0401 +if typing.TYPE_CHECKING: # pragma: no cover + from mypy_boto3_cloudformation.client import CloudFormationClient + from mypy_boto3_s3.client import S3Client + +LOG = logging.getLogger(__name__) + + +class CompanionStackManager: + """ + Manager class for a companion stack + Used to create/update the remote stack + """ + + _companion_stack: CompanionStack + _builder: CompanionStackBuilder + _boto_config: Config + _update_stack_waiter_config: Dict[str, int] + _delete_stack_waiter_config: Dict[str, int] + _s3_bucket: str + _s3_prefix: str + _cfn_client: "CloudFormationClient" + _s3_client: "S3Client" + + def __init__(self, stack_name, region, s3_bucket, s3_prefix): + self._companion_stack = CompanionStack(stack_name) + self._builder = CompanionStackBuilder(self._companion_stack) + self._boto_config = Config(region_name=region if region else None) + self._update_stack_waiter_config = {"Delay": 5, "MaxAttempts": 240} + self._delete_stack_waiter_config = {"Delay": 5, "MaxAttempts": 120} + self._s3_bucket = s3_bucket + self._s3_prefix = s3_prefix + try: + self._cfn_client = boto3.client("cloudformation", config=self._boto_config) + self._ecr_client = boto3.client("ecr", config=self._boto_config) + self._s3_client = boto3.client("s3", config=self._boto_config) + self._account_id = boto3.client("sts").get_caller_identity().get("Account") + self._region_name = self._cfn_client.meta.region_name + except NoCredentialsError as ex: + raise CredentialsError( + "Error Setting Up Managed Stack Client: Unable to resolve " + "credentials for the AWS SDK for Python client. " + "Please see their documentation for options to pass in credentials: " + "https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html" + ) from ex + except NoRegionError as ex: + raise RegionError( + "Error Setting Up Managed Stack Client: Unable to resolve a region. " + "Please provide a region via the --region parameter or by the AWS_REGION environment variable." + ) from ex + + def set_functions( + self, function_logical_ids: List[str], image_repositories: Optional[Dict[str, str]] = None + ) -> None: + """ + Sets functions that need to have ECR repos created + + Parameters + ---------- + function_logical_ids: List[str] + Function logical IDs that need to have ECR repos created + image_repositories: Optional[Dict[str, str]] + Optional image repository mapping. Functions with non-auto-ecr URIs + will be ignored. + """ + self._builder.clear_functions() + if image_repositories is None: + image_repositories = dict() + for function_logical_id in function_logical_ids: + if function_logical_id not in image_repositories or self.is_repo_uri( + image_repositories.get(function_logical_id), function_logical_id + ): + self._builder.add_function(function_logical_id) + + def update_companion_stack(self) -> None: + """ + Blocking call to create or update the companion stack based on current functions + Companion stack template will be updated to the s3 bucket first before deployment + """ + if not self._builder.repo_mapping: + return + + stack_name = self._companion_stack.stack_name + template = self._builder.build() + + with mktempfile() as temporary_file: + temporary_file.write(template) + temporary_file.flush() + + s3_uploader = S3Uploader( + self._s3_client, bucket_name=self._s3_bucket, prefix=self._s3_prefix, no_progressbar=True + ) + # TemplateUrl property requires S3 URL to be in path-style format + parts = S3Uploader.parse_s3_url( + s3_uploader.upload_with_dedup(temporary_file.name, "template"), version_property="Version" + ) + + template_url = s3_uploader.to_path_style_s3_url(parts["Key"], parts.get("Version", None)) + + exists = self.does_companion_stack_exist() + if exists: + self._cfn_client.update_stack( + StackName=stack_name, TemplateURL=template_url, Capabilities=["CAPABILITY_AUTO_EXPAND"] + ) + waiter = self._cfn_client.get_waiter("stack_update_complete") + else: + self._cfn_client.create_stack( + StackName=stack_name, TemplateURL=template_url, Capabilities=["CAPABILITY_AUTO_EXPAND"] + ) + waiter = self._cfn_client.get_waiter("stack_create_complete") + + waiter.wait(StackName=stack_name, WaiterConfig=self._update_stack_waiter_config) # type: ignore + + def _delete_companion_stack(self): + """ + Blocking call to delete the companion stack + """ + stack_name = self._companion_stack.stack_name + waiter = self._cfn_client.get_waiter("stack_delete_complete") + self._cfn_client.delete_stack(StackName=stack_name) + waiter.wait(StackName=stack_name, WaiterConfig=self._delete_stack_waiter_config) + + def list_deployed_repos(self) -> List[ECRRepo]: + """ + List deployed ECR repos for this companion stack + Not using create_change_set as it is slow. + + Returns + ------- + List[ECRRepo] + List of ECR repos deployed for this companion stack + Returns empty list if companion stack does not exist + """ + if not self.does_companion_stack_exist(): + return [] + repos: List[ECRRepo] = list() + stack = boto3.resource("cloudformation", config=self._boto_config).Stack(self._companion_stack.stack_name) + for resource in stack.resource_summaries.all(): + if resource.resource_type == "AWS::ECR::Repository": + repos.append( + ECRRepo(logical_id=resource.logical_resource_id, physical_id=resource.physical_resource_id) + ) + return repos + + def get_unreferenced_repos(self) -> List[ECRRepo]: + """ + List deployed ECR repos that is not referenced by current list of functions + + Returns + ------- + List[ECRRepo] + List of deployed ECR repos that is not referenced by current list of functions + Returns empty list if companion stack does not exist + """ + if not self.does_companion_stack_exist(): + return [] + deployed_repos: List[ECRRepo] = self.list_deployed_repos() + current_mapping = self._builder.repo_mapping + + unreferenced_repos: List[ECRRepo] = list() + for deployed_repo in deployed_repos: + for _, current_repo in current_mapping.items(): + if current_repo.logical_id == deployed_repo.logical_id: + break + else: + unreferenced_repos.append(deployed_repo) + return unreferenced_repos + + def delete_unreferenced_repos(self) -> None: + """ + Blocking call to delete all deployed ECR repos that are unreferenced by a function + If repo does not exist, this will simply skip it. + """ + repos = self.get_unreferenced_repos() + for repo in repos: + try: + self._ecr_client.delete_repository(repositoryName=repo.physical_id, force=True) + except self._ecr_client.exceptions.RepositoryNotFoundException: + LOG.debug("Image repo [%s] not found in companion stack. Skipping deletion.", repo.physical_id) + + def sync_repos(self) -> None: + """ + Blocking call to sync companion stack with the following actions + Creates the stack if it does not exist, and updates it if it does. + Deletes unreferenced repos if they exist. + Deletes companion stack if there isn't any repo left. + """ + has_repo = bool(self.get_repository_mapping()) + if self.does_companion_stack_exist(): + self.delete_unreferenced_repos() + if has_repo: + self.update_companion_stack() + else: + self._delete_companion_stack() + elif has_repo: + self.update_companion_stack() + + def does_companion_stack_exist(self) -> bool: + """ + Does companion stack exist + + Returns + ------- + bool + Returns True if companion stack exists + """ + try: + self._cfn_client.describe_stacks(StackName=self._companion_stack.stack_name) + return True + except ClientError as e: + error_message = e.response.get("Error", {}).get("Message") + if error_message == f"Stack with id {self._companion_stack.stack_name} does not exist": + return False + raise e + + def get_repository_mapping(self) -> Dict[str, str]: + """ + Get current function to repo mapping + + Returns + ------- + Dict[str, str] + Dictionary with key as function logical ID and value as ECR repo URI. + """ + return dict((k, self.get_repo_uri(v)) for (k, v) in self._builder.repo_mapping.items()) + + def get_repo_uri(self, repo: ECRRepo) -> str: + """ + Get repo URI for a ECR repo + + Parameters + ---------- + repo: ECRRepo + + Returns + ------- + str + ECR repo URI based on account ID and region. + """ + return repo.get_repo_uri(self._account_id, self._region_name) + + def is_repo_uri(self, repo_uri: Optional[str], function_logical_id: str) -> bool: + """ + Check whether repo URI is a companion stack repo + + Parameters + ---------- + repo_uri: str + Repo URI to be checked. + + function_logical_id: str + Function logical ID associated with the image repo. + + Returns + ------- + bool + Returns True if repo_uri is a companion stack repo. + """ + return repo_uri == self.get_repo_uri(ECRRepo(self._companion_stack, function_logical_id)) + + +def sync_ecr_stack( + template_file: str, stack_name: str, region: str, s3_bucket: str, s3_prefix: str, image_repositories: Dict[str, str] +) -> Dict[str, str]: + """Blocking call to sync local functions with ECR Companion Stack + + Parameters + ---------- + template_file : str + Template file path. + stack_name : str + Stack name + region : str + AWS region + s3_bucket : str + S3 bucket + s3_prefix : str + S3 prefix for the bucket + image_repositories : Dict[str, str] + Mapping between function logical ID and ECR URI + + Returns + ------- + Dict[str, str] + Updated mapping of image_repositories. Auto ECR URIs are added + for Functions without a repo specified. + """ + image_repositories = image_repositories.copy() if image_repositories else {} + manager = CompanionStackManager(stack_name, region, s3_bucket, s3_prefix) + + stacks = SamLocalStackProvider.get_stacks(template_file)[0] + function_provider = SamFunctionProvider(stacks, ignore_code_extraction_warnings=True) + function_logical_ids = [ + function.full_path for function in function_provider.get_all() if function.packagetype == IMAGE + ] + manager.set_functions(function_logical_ids, image_repositories) + image_repositories.update(manager.get_repository_mapping()) + manager.sync_repos() + return image_repositories diff --git a/samcli/lib/bootstrap/companion_stack/data_types.py b/samcli/lib/bootstrap/companion_stack/data_types.py new file mode 100644 index 0000000000..f617246a39 --- /dev/null +++ b/samcli/lib/bootstrap/companion_stack/data_types.py @@ -0,0 +1,137 @@ +""" + Date type classes for companion stacks +""" +import re +from typing import Optional +from samcli.lib.utils.hash import str_checksum + + +class CompanionStack: + """ + Abstraction class for the companion stack + Companion stack name will be generated by this class. + """ + + _parent_stack_name: str + _escaped_parent_stack_name: str + _parent_stack_hash: str + _stack_name: str + + def __init__(self, parent_stack_name: str) -> None: + self._parent_stack_name = parent_stack_name + self._escaped_parent_stack_name = re.sub(r"[^a-z0-9]", "", self._parent_stack_name.lower()) + self._parent_stack_hash = str_checksum(self._parent_stack_name) + # There is max 128 characters limit on the length of stack name. + # Using MD5 to avoid collision after truncating + # 104 + 1 + 8 + 15 = 128 max char + self._stack_name = f"{self._parent_stack_name[:104]}-{self._parent_stack_hash[:8]}-CompanionStack" + + @property + def parent_stack_name(self) -> str: + """ + Parent stack name + """ + return self._parent_stack_name + + @property + def escaped_parent_stack_name(self) -> str: + """ + Parent stack name with only alphanumeric characters + """ + return self._escaped_parent_stack_name + + @property + def parent_stack_hash(self) -> str: + """ + MD5 hash of parent stack name + """ + return self._parent_stack_hash + + @property + def stack_name(self) -> str: + """ + Companion stack stack name + """ + return self._stack_name + + +class ECRRepo: + """ + Abstraction class for ECR repos in companion stacks + Logical ID, Physical ID, and Repo URI will be generated with this class. + """ + + _function_logical_id: Optional[str] + _escaped_function_logical_id: Optional[str] + _function_md5: Optional[str] + _companion_stack: Optional[CompanionStack] + _logical_id: Optional[str] + _physical_id: Optional[str] + _output_logical_id: Optional[str] + + def __init__( + self, + companion_stack: Optional[CompanionStack] = None, + function_logical_id: Optional[str] = None, + logical_id: Optional[str] = None, + physical_id: Optional[str] = None, + output_logical_id: Optional[str] = None, + ): + """ + Must be specified either with + companion_stack and function_logical_id + or + logical_id, physical_id, and output_logical_id + """ + self._function_logical_id = function_logical_id + self._escaped_function_logical_id = ( + re.sub(r"[^a-z0-9]", "", self._function_logical_id.lower()) + if self._function_logical_id is not None + else None + ) + self._function_md5 = str_checksum(self._function_logical_id) if self._function_logical_id is not None else None + self._companion_stack = companion_stack + + self._logical_id = logical_id + self._physical_id = physical_id + self._output_logical_id = output_logical_id + + @property + def logical_id(self) -> Optional[str]: + if self._logical_id is None and self._function_logical_id and self._function_md5: + # MD5 is used to avoid two having the same escaped name with different Lambda Functions + # For example: Helloworld and HELLO-WORLD + # 52 + 8 + 4 = 64 max char + self._logical_id = self._function_logical_id[:52] + self._function_md5[:8] + "Repo" + return self._logical_id + + @property + def physical_id(self) -> Optional[str]: + if ( + self._physical_id is None + and self._companion_stack + and self._function_md5 + and self._escaped_function_logical_id + ): + # The physical ID is constructed with escaped_stack_name + stack_md5[:8] as prefix/path and + # followed by escaped_lambda_logical_id + function_md5[:8] + "repo" to show + # the linkage between the function and the repo + # 128 + 8 + 1 + 64 + 8 + 4 = 213 max char + self._physical_id = ( + self._companion_stack.escaped_parent_stack_name + + self._companion_stack.parent_stack_hash[:8] + + "/" + + self._escaped_function_logical_id + + self._function_md5[:8] + + "repo" + ) + return self._physical_id + + @property + def output_logical_id(self) -> Optional[str]: + if self._output_logical_id is None and self._function_logical_id and self._function_md5: + self._output_logical_id = self._function_logical_id[:52] + self._function_md5[:8] + "Out" + return self._output_logical_id + + def get_repo_uri(self, account_id, region) -> str: + return f"{account_id}.dkr.ecr.{region}.amazonaws.com/{self.physical_id}" diff --git a/samcli/lib/cli_validation/image_repository_validation.py b/samcli/lib/cli_validation/image_repository_validation.py index 329e855019..e529ea63e2 100644 --- a/samcli/lib/cli_validation/image_repository_validation.py +++ b/samcli/lib/cli_validation/image_repository_validation.py @@ -12,7 +12,7 @@ def image_repository_validation(func): """ Wrapper Validation function that will run last after the all cli parmaters have been loaded - to check for conditions surrounding `--image-repository` and `--image-repositories`. The + to check for conditions surrounding `--image-repository`, `--image-repositories`, and `--resolve-image-repos`. The reason they are done last instead of in callback functions, is because the options depend on each other, and this breaks cyclic dependencies. @@ -25,11 +25,12 @@ def wrapped(*args, **kwargs): guided = ctx.params.get("guided", False) or ctx.params.get("g", False) image_repository = ctx.params.get("image_repository", False) image_repositories = ctx.params.get("image_repositories", False) or {} + resolve_image_repos = ctx.params.get("resolve_image_repos", False) template_file = ( ctx.params.get("t", False) or ctx.params.get("template_file", False) or ctx.params.get("template", False) ) - # Check if `--image-repository` or `--image-repositories` are required by + # Check if `--image-repository`, `--image-repositories`, or `--resolve-image-repos` are required by # looking for resources that have an IMAGE based packagetype. required = any( @@ -41,20 +42,26 @@ def wrapped(*args, **kwargs): validators = [ Validator( - validation_function=lambda: image_repository and image_repositories, + validation_function=lambda: bool(image_repository) + + bool(image_repositories) + + bool(resolve_image_repos) + > 1, exception=click.BadOptionUsage( option_name="--image-repositories", ctx=ctx, - message="Both '--image-repositories' and '--image-repository' cannot be provided. " - "Do you have both specified in the command or in a configuration file?", + message="Only one of the following can be provided: '--image-repositories', " + "'--image-repository', or '--resolve-image-repos'. " + "Do you have multiple specified in the command or in a configuration file?", ), ), Validator( - validation_function=lambda: not guided and not (image_repository or image_repositories) and required, + validation_function=lambda: not guided + and not (image_repository or image_repositories or resolve_image_repos) + and required, exception=click.BadOptionUsage( option_name="--image-repositories", ctx=ctx, - message="Missing option '--image-repository' or '--image-repositories'", + message="Missing option '--image-repository', '--image-repositories', or '--resolve-image-repos'", ), ), Validator( @@ -62,11 +69,13 @@ def wrapped(*args, **kwargs): and ( set(image_repositories.keys()) != set(get_template_function_resource_ids(template_file, IMAGE)) and image_repositories + and not resolve_image_repos ), exception=click.BadOptionUsage( option_name="--image-repositories", ctx=ctx, - message="Incomplete list of function logical ids specified for '--image-repositories'", + message="Incomplete list of function logical ids specified for '--image-repositories'. " + "You can also add --resolve-image-repos to automatically create missing repositories.", ), ), ] diff --git a/samcli/lib/package/stream_cursor_utils.py b/samcli/lib/package/stream_cursor_utils.py index 908293c317..c8a037f4ab 100644 --- a/samcli/lib/package/stream_cursor_utils.py +++ b/samcli/lib/package/stream_cursor_utils.py @@ -1,11 +1,20 @@ """ Stream cursor utilities for moving cursor in the terminal. """ +import os +import platform # NOTE: ANSI escape codes. # NOTE: Still needs investigation on non terminal environments. ESC = "\u001B[" +# Enables ANSI escape codes on Windows +if platform.system().lower() == "windows": + try: + os.system("color") + except Exception: + pass + def cursor_up(count=1): return ESC + str(count) + "A" diff --git a/samcli/lib/utils/managed_cloudformation_stack.py b/samcli/lib/utils/managed_cloudformation_stack.py index 29d148a7d9..aaac18c016 100644 --- a/samcli/lib/utils/managed_cloudformation_stack.py +++ b/samcli/lib/utils/managed_cloudformation_stack.py @@ -97,12 +97,11 @@ def _create_or_get_stack( ds_resp = cloudformation_client.describe_stacks(StackName=stack_name) stacks = ds_resp["Stacks"] stack = stacks[0] - click.echo("\n\tLooking for resources needed for deployment: Found!") _check_sanity_of_stack(stack) stack_outputs = cast(List[Dict[str, str]], stack["Outputs"]) return StackOutput(stack_outputs) except ClientError: - click.echo("\n\tLooking for resources needed for deployment: Not found.") + LOG.debug("Managed S3 stack [%s] not found. Creating a new one.", stack_name) try: stack = _create_stack( diff --git a/tests/integration/deploy/deploy_integ_base.py b/tests/integration/deploy/deploy_integ_base.py index 72ecb0145c..870ee7203a 100644 --- a/tests/integration/deploy/deploy_integ_base.py +++ b/tests/integration/deploy/deploy_integ_base.py @@ -46,6 +46,7 @@ def get_deploy_command_list( resolve_s3=False, config_file=None, signing_profiles=None, + resolve_image_repos=False, ): command_list = [self.base_command(), "deploy"] @@ -103,6 +104,8 @@ def get_deploy_command_list( command_list = command_list + ["--config-file", str(config_file)] if signing_profiles: command_list = command_list + ["--signing-profiles", str(signing_profiles)] + if resolve_image_repos: + command_list = command_list + ["--resolve-image-repos"] return command_list diff --git a/tests/integration/deploy/test_deploy_command.py b/tests/integration/deploy/test_deploy_command.py index 381755806e..aece4f47b2 100644 --- a/tests/integration/deploy/test_deploy_command.py +++ b/tests/integration/deploy/test_deploy_command.py @@ -1,4 +1,5 @@ import os +from samcli.lib.bootstrap.companion_stack.data_types import CompanionStack import shutil import tempfile import time @@ -7,6 +8,7 @@ from unittest import skipIf import boto3 +from botocore.exceptions import ClientError import docker from botocore.config import Config from parameterized import parameterized @@ -47,7 +49,8 @@ def setUpClass(cls): DeployIntegBase.setUpClass() def setUp(self): - self.cf_client = boto3.client("cloudformation") + self.cfn_client = boto3.client("cloudformation") + self.ecr_client = boto3.client("ecr") self.sns_arn = os.environ.get("AWS_SNS") self.stacks = [] time.sleep(CFN_SLEEP) @@ -60,10 +63,12 @@ def tearDown(self): stack_name = stack["name"] if stack_name != SAM_CLI_STACK_NAME: region = stack.get("region") - cf_client = ( - self.cf_client if not region else boto3.client("cloudformation", config=Config(region_name=region)) + cfn_client = ( + self.cfn_client if not region else boto3.client("cloudformation", config=Config(region_name=region)) ) - cf_client.delete_stack(StackName=stack_name) + ecr_client = self.ecr_client if not region else boto3.client("ecr", config=Config(region_name=region)) + self._delete_companion_stack(cfn_client, ecr_client, self._stack_name_to_companion_stack(stack_name)) + cfn_client.delete_stack(StackName=stack_name) super().tearDown() @parameterized.expand(["aws-serverless-function.yaml"]) @@ -195,6 +200,33 @@ def test_no_package_and_deploy_with_s3_bucket_all_args_image_repositories(self, deploy_process_execute = run_command(deploy_command_list) self.assertEqual(deploy_process_execute.process.returncode, 0) + @parameterized.expand(["aws-serverless-function-image.yaml"]) + def test_no_package_and_deploy_with_s3_bucket_all_args_resolve_image_repos(self, template_file): + template_path = self.test_data_path.joinpath(template_file) + + stack_name = self._method_to_stack_name(self.id()) + self.stacks.append({"name": stack_name}) + + # Package and Deploy in one go without confirming change set. + deploy_command_list = self.get_deploy_command_list( + template_file=template_path, + stack_name=stack_name, + capabilities="CAPABILITY_IAM", + s3_prefix="integ_deploy", + s3_bucket=self.s3_bucket.name, + force_upload=True, + notification_arns=self.sns_arn, + parameter_overrides="Parameter=Clarity", + kms_key_id=self.kms_key, + no_execute_changeset=False, + tags="integ=true clarity=yes foo_bar=baz", + confirm_changeset=False, + resolve_image_repos=True, + ) + + deploy_process_execute = run_command(deploy_command_list) + self.assertEqual(deploy_process_execute.process.returncode, 0) + @parameterized.expand(["aws-serverless-function.yaml"]) def test_no_package_and_deploy_with_s3_bucket_and_no_confirm_changeset(self, template_file): template_path = self.test_data_path.joinpath(template_file) @@ -564,7 +596,7 @@ def test_deploy_guided_zip(self, template_file): os.remove(self.test_data_path.joinpath(DEFAULT_CONFIG_FILE_NAME)) @parameterized.expand(["aws-serverless-function-image.yaml"]) - def test_deploy_guided_image(self, template_file): + def test_deploy_guided_image_auto(self, template_file): template_path = self.test_data_path.joinpath(template_file) stack_name = self._method_to_stack_name(self.id()) @@ -574,7 +606,7 @@ def test_deploy_guided_image(self, template_file): deploy_command_list = self.get_deploy_command_list(template_file=template_path, guided=True) deploy_process_execute = run_command_with_input( - deploy_command_list, f"{stack_name}\n\n{self.ecr_repo_name}\n\n\ny\n\n\n\n\n\n".encode() + deploy_command_list, f"{stack_name}\n\n\n\ny\n\n\ny\n\n\n\n".encode() ) # Deploy should succeed with a managed stack @@ -583,6 +615,34 @@ def test_deploy_guided_image(self, template_file): # Remove samconfig.toml os.remove(self.test_data_path.joinpath(DEFAULT_CONFIG_FILE_NAME)) + @parameterized.expand(["aws-serverless-function-image.yaml"]) + def test_deploy_guided_image_specify(self, template_file): + template_path = self.test_data_path.joinpath(template_file) + + stack_name = self._method_to_stack_name(self.id()) + self.stacks.append({"name": stack_name}) + + # Package and Deploy in one go without confirming change set. + deploy_command_list = self.get_deploy_command_list(template_file=template_path, guided=True) + + deploy_process_execute = run_command_with_input( + deploy_command_list, f"{stack_name}\n\n\n\ny\n\n\n\nn\n{self.ecr_repo_name}\n\n\n\n".encode() + ) + + # Deploy should succeed with a managed stack + self.assertEqual(deploy_process_execute.process.returncode, 0) + # Verify companion stack does not exist + try: + self.cfn_client.describe_stacks(StackName=self._stack_name_to_companion_stack(stack_name)) + except ClientError: + pass + else: + self.fail("Companion stack was created. This should not happen with specifying image repos.") + + self.stacks.append(SAM_CLI_STACK_NAME) + # Remove samconfig.toml + os.remove(self.test_data_path.joinpath(DEFAULT_CONFIG_FILE_NAME)) + @parameterized.expand(["aws-serverless-function.yaml"]) def test_deploy_guided_set_parameter(self, template_file): template_path = self.test_data_path.joinpath(template_file) @@ -873,3 +933,24 @@ 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}" + + def _stack_name_to_companion_stack(self, stack_name): + return CompanionStack(stack_name).stack_name + + def _delete_companion_stack(self, cfn_client, ecr_client, companion_stack_name): + repos = list() + try: + cfn_client.describe_stacks(StackName=companion_stack_name) + except ClientError: + return + stack = boto3.resource("cloudformation").Stack(companion_stack_name) + resources = stack.resource_summaries.all() + for resource in resources: + if resource.resource_type == "AWS::ECR::Repository": + repos.append(resource.physical_resource_id) + for repo in repos: + try: + ecr_client.delete_repository(repositoryName=repo, force=True) + except ecr_client.exceptions.RepositoryNotFoundException: + pass + cfn_client.delete_stack(StackName=companion_stack_name) diff --git a/tests/regression/deploy/regression_deploy_base.py b/tests/regression/deploy/regression_deploy_base.py index 9c482d7a3c..2154ad6910 100644 --- a/tests/regression/deploy/regression_deploy_base.py +++ b/tests/regression/deploy/regression_deploy_base.py @@ -42,6 +42,7 @@ def get_deploy_command_list( tags=None, profile=None, region=None, + resolve_image_repos=False, ): command_list = self.base_command(base=base) @@ -79,6 +80,8 @@ def get_deploy_command_list( command_list = command_list + ["--region", str(region)] if profile: command_list = command_list + ["--profile", str(profile)] + if resolve_image_repos: + command_list = command_list + ["--resolve-image-repos"] return command_list diff --git a/tests/unit/commands/deploy/test_command.py b/tests/unit/commands/deploy/test_command.py index 6781972a58..46ac917e06 100644 --- a/tests/unit/commands/deploy/test_command.py +++ b/tests/unit/commands/deploy/test_command.py @@ -47,8 +47,20 @@ def setUp(self): self.config_env = "mock-default-env" self.config_file = "mock-default-filename" self.signing_profiles = None + self.resolve_image_repos = False MOCK_SAM_CONFIG.reset_mock() + self.companion_stack_manager_patch = patch("samcli.commands.deploy.guided_context.CompanionStackManager") + self.companion_stack_manager_mock = self.companion_stack_manager_patch.start() + self.companion_stack_manager_mock.return_value.set_functions.return_value = None + self.companion_stack_manager_mock.return_value.get_repository_mapping.return_value = { + "HelloWorldFunction": self.image_repository + } + self.companion_stack_manager_mock.return_value.get_unreferenced_repos.return_value = [] + + def tearDown(self): + self.companion_stack_manager_patch.stop() + @patch("samcli.commands.package.command.click") @patch("samcli.commands.package.package_context.PackageContext") @patch("samcli.commands.deploy.command.click") @@ -85,6 +97,7 @@ def test_all_args(self, mock_deploy_context, mock_deploy_click, mock_package_con resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, + resolve_image_repos=self.resolve_image_repos, ) mock_deploy_context.assert_called_with( @@ -121,7 +134,6 @@ def test_all_args(self, mock_deploy_context, mock_deploy_click, mock_package_con @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_parameters") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @patch.object(GuidedConfig, "get_config_ctx", MagicMock(return_value=(None, get_mock_sam_config()))) @@ -133,7 +145,6 @@ def test_all_args_guided_no_to_authorization_confirmation_prompt( mock_prompt, mock_signer_config_per_function, mock_sam_function_provider, - mock_get_template_artifacts_format, mock_get_buildable_stacks, mock_get_template_parameters, mockauth_per_resource, @@ -144,8 +155,7 @@ def test_all_args_guided_no_to_authorization_confirmation_prompt( mock_package_click, ): mock_get_buildable_stacks.return_value = (Mock(), []) - mock_sam_function_provider.return_value = {} - mock_get_template_artifacts_format.return_value = [ZIP] + mock_sam_function_provider.return_value.functions = {} context_mock = Mock() mockauth_per_resource.return_value = [("HelloWorldResource1", False), ("HelloWorldResource2", False)] mock_deploy_context.return_value.__enter__.return_value = context_mock @@ -197,6 +207,7 @@ def test_all_args_guided_no_to_authorization_confirmation_prompt( resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, + resolve_image_repos=self.resolve_image_repos, ) @patch("samcli.commands.package.command.click") @@ -207,8 +218,6 @@ def test_all_args_guided_no_to_authorization_confirmation_prompt( @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.get_template_parameters") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") - @patch("samcli.commands.deploy.guided_context.get_template_function_resource_ids") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @patch.object(GuidedConfig, "get_config_ctx", MagicMock(return_value=(None, get_mock_sam_config()))) @@ -222,8 +231,6 @@ def test_all_args_guided( mock_prompt, mock_signer_config_per_function, mock_sam_function_provider, - mock_get_template_function_resource_ids, - mock_get_template_artifacts_format, mock_get_buildable_stacks, mock_get_template_parameters, mockauth_per_resource, @@ -235,22 +242,21 @@ def test_all_args_guided( ): mock_get_buildable_stacks.return_value = (Mock(), []) mock_tag_translation.return_value = "helloworld-123456-v1" - mock_get_template_function_resource_ids.return_value = ["HelloWorldFunction"] context_mock = Mock() - mock_sam_function_provider.return_value = MagicMock( - functions={"HelloWorldFunction": MagicMock(packagetype=IMAGE, imageuri="helloworld:v1")} - ) - mock_get_template_artifacts_format.return_value = [IMAGE] + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = "helloworld:v1" + function_mock.full_path = "HelloWorldFunction" + mock_sam_function_provider.return_value.get_all.return_value = [function_mock] mockauth_per_resource.return_value = [("HelloWorldResource", False)] mock_deploy_context.return_value.__enter__.return_value = context_mock - mock_confirm.side_effect = [True, False, True, True] + mock_confirm.side_effect = [True, False, True, True, True, True] mock_prompt.side_effect = [ "sam-app", "us-east-1", "guidedParameter", "secure", - "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", ("CAPABILITY_IAM",), "testconfig.toml", "test-env", @@ -293,6 +299,7 @@ def test_all_args_guided( resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, + resolve_image_repos=self.resolve_image_repos, ) mock_deploy_context.assert_called_with( @@ -347,8 +354,6 @@ def test_all_args_guided( @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.deploy.guided_context.get_template_parameters") - @patch("samcli.commands.deploy.guided_context.get_template_function_resource_ids") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @patch.object( @@ -366,8 +371,6 @@ def test_all_args_guided_no_save_echo_param_to_config( mock_prompt, mock_signer_config_per_function, mock_sam_function_provider, - mock_get_template_artifacts_format, - mock_get_template_function_resource_ids, mock_get_template_parameters, mock_get_buildable_stacks, mockauth_per_resource, @@ -379,13 +382,13 @@ def test_all_args_guided_no_save_echo_param_to_config( ): mock_get_buildable_stacks.return_value = (Mock(), []) mock_tag_translation.return_value = "helloworld-123456-v1" - mock_get_template_function_resource_ids.return_value = ["HelloWorldFunction"] context_mock = Mock() - mock_sam_function_provider.return_value = MagicMock( - functions={"HelloWorldFunction": MagicMock(packagetype=IMAGE, imageuri="helloworld:v1")} - ) - mock_get_template_artifacts_format.return_value = [IMAGE] + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = "helloworld:v1" + function_mock.full_path = "HelloWorldFunction" + mock_sam_function_provider.return_value.get_all.return_value = [function_mock] mockauth_per_resource.return_value = [("HelloWorldResource", False)] mock_get_template_parameters.return_value = { "Myparameter": {"Type": "String"}, @@ -399,12 +402,11 @@ def test_all_args_guided_no_save_echo_param_to_config( "guidedParameter", "guided parameter with spaces", "secure", - "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", ("CAPABILITY_IAM",), "testconfig.toml", "test-env", ] - mock_confirm.side_effect = [True, False, True, True] + mock_confirm.side_effect = [True, False, True, True, True, True] mock_managed_stack.return_value = "managed-s3-bucket" mock_signer_config_per_function.return_value = ({}, {}) @@ -436,6 +438,7 @@ def test_all_args_guided_no_save_echo_param_to_config( resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, + resolve_image_repos=self.resolve_image_repos, ) mock_deploy_context.assert_called_with( @@ -505,8 +508,6 @@ def test_all_args_guided_no_save_echo_param_to_config( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.get_template_parameters") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") - @patch("samcli.commands.deploy.guided_context.get_template_function_resource_ids") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch.object( GuidedConfig, @@ -526,8 +527,6 @@ def test_all_args_guided_no_params_save_config( mock_confirm, mock_prompt, mock_sam_function_provider, - mock_get_template_function_resource_ids, - mock_get_template_artifacts_format, mock_signer_config_per_function, mock_get_template_parameters, mock_managed_stack, @@ -540,13 +539,13 @@ def test_all_args_guided_no_params_save_config( ): mock_get_buildable_stacks.return_value = (Mock(), []) mock_tag_translation.return_value = "helloworld-123456-v1" - mock_get_template_function_resource_ids.return_value = ["HelloWorldFunction"] context_mock = Mock() - mock_sam_function_provider.return_value = MagicMock( - functions={"HelloWorldFunction": MagicMock(packagetype=IMAGE, imageuri="helloworld:v1")} - ) - mock_get_template_artifacts_format.return_value = [IMAGE] + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = "helloworld:v1" + function_mock.full_path = "HelloWorldFunction" + mock_sam_function_provider.return_value.get_all.return_value = [function_mock] mockauth_per_resource.return_value = [("HelloWorldResource", False)] mock_get_template_parameters.return_value = {} @@ -554,12 +553,11 @@ def test_all_args_guided_no_params_save_config( mock_prompt.side_effect = [ "sam-app", "us-east-1", - "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", ("CAPABILITY_IAM",), "testconfig.toml", "test-env", ] - mock_confirm.side_effect = [True, False, True, True] + mock_confirm.side_effect = [True, False, True, True, True, True] mock_get_cmd_names.return_value = ["deploy"] mock_managed_stack.return_value = "managed-s3-bucket" mock_signer_config_per_function.return_value = ({}, {}) @@ -591,6 +589,7 @@ def test_all_args_guided_no_params_save_config( config_env=self.config_env, config_file=self.config_file, signing_profiles=self.signing_profiles, + resolve_image_repos=self.resolve_image_repos, ) mock_deploy_context.assert_called_with( @@ -649,8 +648,6 @@ def test_all_args_guided_no_params_save_config( @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.deploy.guided_context.get_template_parameters") - @patch("samcli.commands.deploy.guided_context.get_template_function_resource_ids") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @patch.object(GuidedConfig, "get_config_ctx", MagicMock(return_value=(None, get_mock_sam_config()))) @@ -664,8 +661,6 @@ def test_all_args_guided_no_params_no_save_config( mock_prompt, mock_signer_config_per_function, mock_sam_function_provider, - mock_get_template_artifacts_format, - mock_get_template_function_resource_ids, mock_get_template_parameters, mock_get_buildable_stacks, mockauth_per_resource, @@ -677,23 +672,22 @@ def test_all_args_guided_no_params_no_save_config( ): mock_get_buildable_stacks.return_value = (Mock(), []) mock_tag_translation.return_value = "helloworld-123456-v1" - mock_get_template_function_resource_ids.return_value = ["HelloWorldFunction"] context_mock = Mock() - mock_sam_function_provider.return_value = MagicMock( - functions={"HelloWorldFunction": MagicMock(packagetype=IMAGE, imageuri="helloworld:v1")} - ) - mock_get_template_artifacts_format.return_value = [IMAGE] + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = "helloworld:v1" + function_mock.full_path = "HelloWorldFunction" + mock_sam_function_provider.return_value.get_all.return_value = [function_mock] mockauth_per_resource.return_value = [("HelloWorldResource", False)] mock_get_template_parameters.return_value = {} mock_deploy_context.return_value.__enter__.return_value = context_mock mock_prompt.side_effect = [ "sam-app", "us-east-1", - "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1", ("CAPABILITY_IAM",), ] - mock_confirm.side_effect = [True, False, True, False] + mock_confirm.side_effect = [True, False, True, False, True, True] mock_managed_stack.return_value = "managed-s3-bucket" mock_signer_config_per_function.return_value = ({}, {}) @@ -727,6 +721,7 @@ def test_all_args_guided_no_params_no_save_config( config_file=self.config_file, config_env=self.config_env, signing_profiles=self.signing_profiles, + resolve_image_repos=self.resolve_image_repos, ) mock_deploy_context.assert_called_with( @@ -796,6 +791,7 @@ def test_all_args_resolve_s3( config_file=self.config_file, config_env=self.config_env, signing_profiles=self.signing_profiles, + resolve_image_repos=self.resolve_image_repos, ) mock_deploy_context.assert_called_with( @@ -853,4 +849,80 @@ def test_resolve_s3_and_s3_bucket_both_set(self): config_file=self.config_file, config_env=self.config_env, signing_profiles=self.signing_profiles, + resolve_image_repos=self.resolve_image_repos, ) + + @patch("samcli.commands.package.command.click") + @patch("samcli.commands.package.package_context.PackageContext") + @patch("samcli.commands.deploy.command.click") + @patch("samcli.commands.deploy.deploy_context.DeployContext") + @patch("samcli.commands.deploy.command.manage_stack") + @patch("samcli.commands.deploy.command.sync_ecr_stack") + def test_all_args_resolve_image_repos( + self, + mock_sync_ecr_stack, + mock_manage_stack, + mock_deploy_context, + mock_deploy_click, + mock_package_context, + mock_package_click, + ): + context_mock = Mock() + mock_deploy_context.return_value.__enter__.return_value = context_mock + mock_sync_ecr_stack.return_value = {"HelloWorldFunction1": self.image_repository} + + do_cli( + template_file=self.template_file, + stack_name=self.stack_name, + s3_bucket=self.s3_bucket, + image_repository=None, + image_repositories=None, + force_upload=self.force_upload, + no_progressbar=self.no_progressbar, + s3_prefix=self.s3_prefix, + kms_key_id=self.kms_key_id, + parameter_overrides=self.parameter_overrides, + capabilities=self.capabilities, + no_execute_changeset=self.no_execute_changeset, + role_arn=self.role_arn, + notification_arns=self.notification_arns, + fail_on_empty_changeset=self.fail_on_empty_changset, + tags=self.tags, + region=self.region, + profile=self.profile, + use_json=self.use_json, + metadata=self.metadata, + guided=self.guided, + confirm_changeset=self.confirm_changeset, + resolve_s3=False, + config_file=self.config_file, + config_env=self.config_env, + signing_profiles=self.signing_profiles, + resolve_image_repos=True, + ) + + mock_deploy_context.assert_called_with( + template_file=ANY, + stack_name=self.stack_name, + s3_bucket=self.s3_bucket, + force_upload=self.force_upload, + image_repository=None, + image_repositories={"HelloWorldFunction1": self.image_repository}, + no_progressbar=self.no_progressbar, + s3_prefix=self.s3_prefix, + kms_key_id=self.kms_key_id, + parameter_overrides=self.parameter_overrides, + capabilities=self.capabilities, + no_execute_changeset=self.no_execute_changeset, + role_arn=self.role_arn, + notification_arns=self.notification_arns, + fail_on_empty_changeset=self.fail_on_empty_changset, + tags=self.tags, + region=self.region, + profile=self.profile, + confirm_changeset=self.confirm_changeset, + signing_profiles=self.signing_profiles, + ) + + context_mock.run.assert_called_with() + self.assertEqual(context_mock.run.call_count, 1) diff --git a/tests/unit/commands/deploy/test_guided_context.py b/tests/unit/commands/deploy/test_guided_context.py index 7b31ff60eb..d76012b47b 100644 --- a/tests/unit/commands/deploy/test_guided_context.py +++ b/tests/unit/commands/deploy/test_guided_context.py @@ -11,6 +11,7 @@ class TestGuidedContext(TestCase): def setUp(self): + self.image_repository = "123456789012.dkr.ecr.us-east-1.amazonaws.com/test1" self.gc = GuidedContext( template_file="template", stack_name="test", @@ -19,36 +20,59 @@ def setUp(self): confirm_changeset=True, region="region", image_repository=None, - image_repositories={"HelloWorldFunction": "image-repo"}, + image_repositories={"RandomFunction": "image-repo"}, ) + self.unreferenced_repo_mock = MagicMock() + + self.companion_stack_manager_patch = patch("samcli.commands.deploy.guided_context.CompanionStackManager") + self.companion_stack_manager_mock = self.companion_stack_manager_patch.start() + self.companion_stack_manager_mock.return_value.set_functions.return_value = None + self.companion_stack_manager_mock.return_value.get_repository_mapping.return_value = { + "HelloWorldFunction": self.image_repository + } + self.companion_stack_manager_mock.return_value.get_unreferenced_repos.return_value = [ + self.unreferenced_repo_mock + ] + self.companion_stack_manager_mock.return_value.get_repo_uri = ( + lambda repo: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test2" + if repo == self.unreferenced_repo_mock + else None + ) + + self.verify_image_patch = patch( + "samcli.commands.deploy.guided_context.GuidedContext.verify_images_exist_locally" + ) + self.verify_image_mock = self.verify_image_patch.start() + + def tearDown(self): + self.companion_stack_manager_patch.stop() + self.verify_image_patch.stop() + @patch("samcli.commands.deploy.guided_context.prompt") @patch("samcli.commands.deploy.guided_context.confirm") @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_defaults_non_public_resources_zips( self, patched_signer_config_per_function, patched_sam_function_provider, - patched_get_template_artifacts_format, patched_get_buildable_stacks, - patchedauth_per_resource, + patched_auth_per_resource, patched_manage_stack, patched_confirm, patched_prompt, ): - patched_sam_function_provider.return_value = {} - patched_get_template_artifacts_format.return_value = [ZIP] + patched_sam_function_provider.return_value.functions = {} patched_get_buildable_stacks.return_value = (Mock(), []) # Series of inputs to confirmations so that full range of questions are asked. - patchedauth_per_resource.return_value = [ + patched_auth_per_resource.return_value = [ ("HelloWorldFunction", True), ] - patched_confirm.side_effect = [True, False, "", True] + patched_confirm.side_effect = [True, False, "", True, True, True] patched_manage_stack.return_value = "managed_s3_stack" patched_signer_config_per_function.return_value = ({}, {}) self.gc.guided_prompts(parameter_override_keys=None) @@ -57,6 +81,10 @@ def test_guided_prompts_check_defaults_non_public_resources_zips( call(f"\t{self.gc.start_bold}Confirm changes before deploy{self.gc.end_bold}", default=True), call(f"\t{self.gc.start_bold}Allow SAM CLI IAM role creation{self.gc.end_bold}", default=True), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) @@ -76,14 +104,12 @@ def test_guided_prompts_check_defaults_non_public_resources_zips( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_defaults_public_resources_zips( self, patched_signer_config_per_function, patched_sam_function_provider, - patched_get_template_artifacts_format, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, @@ -91,12 +117,11 @@ def test_guided_prompts_check_defaults_public_resources_zips( patched_prompt, ): patched_signer_config_per_function.return_value = (None, None) - patched_sam_function_provider.return_value = {} - patched_get_template_artifacts_format.return_value = [ZIP] + patched_sam_function_provider.return_value.functions = {} patched_get_buildable_stacks.return_value = (Mock(), []) # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] - patched_confirm.side_effect = [True, False, True, False, ""] + patched_confirm.side_effect = [True, False, True, False, True, True] patched_manage_stack.return_value = "managed_s3_stack" self.gc.guided_prompts(parameter_override_keys=None) # Now to check for all the defaults on confirmations. @@ -108,6 +133,10 @@ def test_guided_prompts_check_defaults_public_resources_zips( default=False, ), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) @@ -124,8 +153,6 @@ def test_guided_prompts_check_defaults_public_resources_zips( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_function_resource_ids") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.click.secho") @patch("samcli.commands.deploy.guided_context.tag_translation") @@ -136,8 +163,6 @@ def test_guided_prompts_check_defaults_public_resources_images( patched_tag_translation, patched_click_secho, patched_sam_function_provider, - patched_get_template_artifacts_format, - mock_get_template_function_resource_ids, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, @@ -145,23 +170,22 @@ def test_guided_prompts_check_defaults_public_resources_images( patched_prompt, ): - mock_get_template_function_resource_ids.return_value = ["HelloWorldFunction"] patched_signer_config_per_function.return_value = (None, None) patched_tag_translation.return_value = "helloworld-123456-v1" - patched_sam_function_provider.return_value = MagicMock( - functions={"HelloWorldFunction": MagicMock(packagetype=IMAGE, imageuri="helloworld:v1")} - ) - patched_get_template_artifacts_format.return_value = [IMAGE] + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = "helloworld:v1" + function_mock.full_path = "HelloWorldFunction" + patched_sam_function_provider.return_value.get_all.return_value = [function_mock] patched_get_buildable_stacks.return_value = (Mock(), []) patched_prompt.side_effect = [ "sam-app", "region", - "123456789012.dkr.ecr.region.amazonaws.com/myrepo", "CAPABILITY_IAM", ] # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] - patched_confirm.side_effect = [True, False, True, False, ""] + patched_confirm.side_effect = [True, False, True, False, True, True] patched_manage_stack.return_value = "managed_s3_stack" self.gc.guided_prompts(parameter_override_keys=None) # Now to check for all the defaults on confirmations. @@ -173,6 +197,14 @@ def test_guided_prompts_check_defaults_public_resources_images( default=False, ), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Create managed ECR repositories for all functions?{self.gc.end_bold}", + default=True, + ), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) @@ -180,10 +212,6 @@ def test_guided_prompts_check_defaults_public_resources_images( expected_prompt_calls = [ call(f"\t{self.gc.start_bold}Stack Name{self.gc.end_bold}", default="test", type=click.STRING), call(f"\t{self.gc.start_bold}AWS Region{self.gc.end_bold}", default="region", type=click.STRING), - call( - f"\t{self.gc.start_bold}Image Repository for HelloWorldFunction{self.gc.end_bold}", - default="image-repo", - ), call(f"\t{self.gc.start_bold}Capabilities{self.gc.end_bold}", default=["CAPABILITY_IAM"], type=ANY), ] self.assertEqual(expected_prompt_calls, patched_prompt.call_args_list) @@ -191,10 +219,77 @@ def test_guided_prompts_check_defaults_public_resources_images( print(expected_prompt_calls) print(patched_prompt.call_args_list) expected_click_secho_calls = [ + call("\t#Shows you resources changes to be deployed and require a 'Y' to initiate deploy"), + call("\t#SAM needs permission to be able to create roles to connect to the resources in your template"), + ] + self.assertEqual(expected_click_secho_calls, patched_click_secho.call_args_list) + + @patch("samcli.commands.deploy.guided_context.prompt") + @patch("samcli.commands.deploy.guided_context.confirm") + @patch("samcli.commands.deploy.guided_context.manage_stack") + @patch("samcli.commands.deploy.guided_context.auth_per_resource") + @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") + @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") + @patch("samcli.commands.deploy.guided_context.click.secho") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") + def test_guided_prompts_check_defaults_public_resources_images_ecr_url( + self, + patched_signer_config_per_function, + patched_click_secho, + patched_sam_function_provider, + patched_get_buildable_stacks, + patchedauth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, + ): + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = "helloworld:v1" + function_mock.full_path = "HelloWorldFunction" + patched_sam_function_provider.return_value.get_all.return_value = [function_mock] + patched_get_buildable_stacks.return_value = (Mock(), []) + patched_prompt.side_effect = [ + "sam-app", + "region", + "CAPABILITY_IAM", + "abc", + ] + # Series of inputs to confirmations so that full range of questions are asked. + patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] + patched_confirm.side_effect = [True, False, True, False, True, True] + patched_manage_stack.return_value = "managed_s3_stack" + patched_signer_config_per_function.return_value = ({}, {}) + self.gc.guided_prompts(parameter_override_keys=None) + # Now to check for all the defaults on confirmations. + expected_confirmation_calls = [ + call(f"\t{self.gc.start_bold}Confirm changes before deploy{self.gc.end_bold}", default=True), + call(f"\t{self.gc.start_bold}Allow SAM CLI IAM role creation{self.gc.end_bold}", default=True), call( - f"\t helloworld:v1 to be pushed to 123456789012.dkr.ecr.region.amazonaws.com/myrepo:helloworld-123456-v1" + f"\t{self.gc.start_bold}HelloWorldFunction may not have authorization defined, Is this okay?{self.gc.end_bold}", + default=False, ), - call(nl=True), + call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Create managed ECR repositories for all functions?{self.gc.end_bold}", + default=True, + ), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), + ] + self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) + + # Now to check for all the defaults on prompts. + expected_prompt_calls = [ + call(f"\t{self.gc.start_bold}Stack Name{self.gc.end_bold}", default="test", type=click.STRING), + call(f"\t{self.gc.start_bold}AWS Region{self.gc.end_bold}", default="region", type=click.STRING), + call(f"\t{self.gc.start_bold}Capabilities{self.gc.end_bold}", default=["CAPABILITY_IAM"], type=ANY), + ] + self.assertEqual(expected_prompt_calls, patched_prompt.call_args_list) + # Now to check click secho outputs and no references to images pushed. + expected_click_secho_calls = [ call("\t#Shows you resources changes to be deployed and require a 'Y' to initiate deploy"), call("\t#SAM needs permission to be able to create roles to connect to the resources in your template"), ] @@ -205,46 +300,81 @@ def test_guided_prompts_check_defaults_public_resources_images( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") - @patch("samcli.commands.deploy.guided_context.get_template_function_resource_ids") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.click.secho") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") - def test_guided_prompts_check_defaults_public_resources_images_ecr_url( + def test_guided_prompts_images_illegal_image_uri( self, patched_signer_config_per_function, patched_click_secho, patched_sam_function_provider, - mock_get_template_function_resource_ids, - patched_get_template_artifacts_format, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt, ): - mock_get_template_function_resource_ids.return_value = ["HelloWorldFunction"] + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = None + function_mock.full_path = "HelloWorldFunction" + patched_sam_function_provider.return_value.get_all.return_value = [function_mock] + patched_get_buildable_stacks.return_value = (Mock(), []) + patched_prompt.side_effect = [ + "sam-app", + "region", + "CAPABILITY_IAM", + "illegaluri", + ] + # Series of inputs to confirmations so that full range of questions are asked. + patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] + patched_confirm.side_effect = [True, False, True, False, False, True] + patched_manage_stack.return_value = "managed_s3_stack" + patched_signer_config_per_function.return_value = ({}, {}) + with self.assertRaises(GuidedDeployFailedError): + self.gc.guided_prompts(parameter_override_keys=None) - patched_sam_function_provider.return_value = MagicMock( - functions={ - "HelloWorldFunction": MagicMock( - packagetype=IMAGE, imageuri="123456789012.dkr.ecr.region.amazonaws.com/myrepo" - ) - } - ) - patched_get_template_artifacts_format.return_value = [IMAGE] + @patch("samcli.commands.deploy.guided_context.prompt") + @patch("samcli.commands.deploy.guided_context.confirm") + @patch("samcli.commands.deploy.guided_context.manage_stack") + @patch("samcli.commands.deploy.guided_context.auth_per_resource") + @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") + @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") + @patch("samcli.commands.deploy.guided_context.click.secho") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") + def test_guided_prompts_images_missing_repo( + self, + patched_signer_config_per_function, + patched_click_secho, + patched_sam_function_provider, + patched_get_buildable_stacks, + patchedauth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, + ): + # Set ImageUri to be None, the sam app was never built. + function_mock_1 = MagicMock() + function_mock_1.packagetype = IMAGE + function_mock_1.imageuri = None + function_mock_1.full_path = "HelloWorldFunction" + function_mock_2 = MagicMock() + function_mock_2.packagetype = IMAGE + function_mock_2.imageuri = None + function_mock_2.full_path = "RandomFunction" + patched_sam_function_provider.return_value.get_all.return_value = [function_mock_1, function_mock_2] patched_get_buildable_stacks.return_value = (Mock(), []) patched_prompt.side_effect = [ "sam-app", "region", - "123456789012.dkr.ecr.region.amazonaws.com/myrepo", "CAPABILITY_IAM", ] # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] - patched_confirm.side_effect = [True, False, True, False, ""] + patched_confirm.side_effect = [True, False, True, False, True, True] patched_manage_stack.return_value = "managed_s3_stack" patched_signer_config_per_function.return_value = ({}, {}) + self.gc.guided_prompts(parameter_override_keys=None) # Now to check for all the defaults on confirmations. expected_confirmation_calls = [ @@ -255,6 +385,14 @@ def test_guided_prompts_check_defaults_public_resources_images_ecr_url( default=False, ), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Create managed ECR repositories for the 1 functions without?{self.gc.end_bold}", + default=True, + ), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) @@ -262,16 +400,88 @@ def test_guided_prompts_check_defaults_public_resources_images_ecr_url( expected_prompt_calls = [ call(f"\t{self.gc.start_bold}Stack Name{self.gc.end_bold}", default="test", type=click.STRING), call(f"\t{self.gc.start_bold}AWS Region{self.gc.end_bold}", default="region", type=click.STRING), + call(f"\t{self.gc.start_bold}Capabilities{self.gc.end_bold}", default=["CAPABILITY_IAM"], type=ANY), + ] + self.assertEqual(expected_prompt_calls, patched_prompt.call_args_list) + # Now to check click secho outputs and no references to images pushed. + expected_click_secho_calls = [ + call("\t#Shows you resources changes to be deployed and require a 'Y' to initiate deploy"), + call("\t#SAM needs permission to be able to create roles to connect to the resources in your template"), + ] + self.assertEqual(expected_click_secho_calls, patched_click_secho.call_args_list) + + @patch("samcli.commands.deploy.guided_context.prompt") + @patch("samcli.commands.deploy.guided_context.confirm") + @patch("samcli.commands.deploy.guided_context.manage_stack") + @patch("samcli.commands.deploy.guided_context.auth_per_resource") + @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") + @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") + @patch("samcli.commands.deploy.guided_context.click.secho") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") + def test_guided_prompts_images_no_repo( + self, + patched_signer_config_per_function, + patched_click_secho, + patched_sam_function_provider, + patched_get_buildable_stacks, + patchedauth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, + ): + # Set ImageUri to be None, the sam app was never built. + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = None + function_mock.full_path = "HelloWorldFunction" + patched_sam_function_provider.return_value.get_all.return_value = [function_mock] + patched_get_buildable_stacks.return_value = (Mock(), []) + patched_prompt.side_effect = [ + "sam-app", + "region", + "CAPABILITY_IAM", + "123456789012.dkr.ecr.region.amazonaws.com/myrepo", + ] + # Series of inputs to confirmations so that full range of questions are asked. + patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] + patched_confirm.side_effect = [True, False, True, False, False, True] + patched_manage_stack.return_value = "managed_s3_stack" + patched_signer_config_per_function.return_value = ({}, {}) + + self.gc.guided_prompts(parameter_override_keys=None) + # Now to check for all the defaults on confirmations. + expected_confirmation_calls = [ + call(f"\t{self.gc.start_bold}Confirm changes before deploy{self.gc.end_bold}", default=True), + call(f"\t{self.gc.start_bold}Allow SAM CLI IAM role creation{self.gc.end_bold}", default=True), call( - f"\t{self.gc.start_bold}Image Repository for HelloWorldFunction{self.gc.end_bold}", - default="image-repo", + f"\t{self.gc.start_bold}HelloWorldFunction may not have authorization defined, Is this okay?{self.gc.end_bold}", + default=False, ), + call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Create managed ECR repositories for all functions?{self.gc.end_bold}", + default=True, + ), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), + ] + self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) + + # Now to check for all the defaults on prompts. + expected_prompt_calls = [ + call(f"\t{self.gc.start_bold}Stack Name{self.gc.end_bold}", default="test", type=click.STRING), + call(f"\t{self.gc.start_bold}AWS Region{self.gc.end_bold}", default="region", type=click.STRING), call(f"\t{self.gc.start_bold}Capabilities{self.gc.end_bold}", default=["CAPABILITY_IAM"], type=ANY), + call( + f"\t {self.gc.start_bold}ECR repository for HelloWorldFunction{self.gc.end_bold}", + type=click.STRING, + ), ] self.assertEqual(expected_prompt_calls, patched_prompt.call_args_list) # Now to check click secho outputs and no references to images pushed. expected_click_secho_calls = [ - call(nl=True), call("\t#Shows you resources changes to be deployed and require a 'Y' to initiate deploy"), call("\t#SAM needs permission to be able to create roles to connect to the resources in your template"), ] @@ -282,41 +492,35 @@ def test_guided_prompts_check_defaults_public_resources_images_ecr_url( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") - @patch("samcli.commands.deploy.guided_context.get_template_function_resource_ids") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.click.secho") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") - def test_guided_prompts_images_no_image_uri( + def test_guided_prompts_images_deny_deletion( self, patched_signer_config_per_function, patched_click_secho, patched_sam_function_provider, - mock_get_template_function_resource_ids, - patched_get_template_artifacts_format, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt, ): - mock_get_template_function_resource_ids.return_value = ["HelloWorldFunction"] - # Set ImageUri to be None, the sam app was never built. - patched_sam_function_provider.return_value = MagicMock( - functions={"HelloWorldFunction": MagicMock(packagetype=IMAGE, imageuri=None)} - ) - patched_get_template_artifacts_format.return_value = [IMAGE] + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = None + function_mock.full_path = "HelloWorldFunction" + patched_sam_function_provider.return_value.get_all.return_value = [function_mock] patched_get_buildable_stacks.return_value = (Mock(), []) patched_prompt.side_effect = [ "sam-app", "region", - "123456789012.dkr.ecr.region.amazonaws.com/myrepo", "CAPABILITY_IAM", ] # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] - patched_confirm.side_effect = [True, False, True, False, ""] + patched_confirm.side_effect = [True, False, True, False, True, False] patched_manage_stack.return_value = "managed_s3_stack" patched_signer_config_per_function.return_value = ({}, {}) with self.assertRaises(GuidedDeployFailedError): @@ -327,8 +531,6 @@ def test_guided_prompts_images_no_image_uri( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") - @patch("samcli.commands.deploy.guided_context.get_template_function_resource_ids") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.click.secho") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") @@ -337,30 +539,28 @@ def test_guided_prompts_images_blank_image_repository( patched_signer_config_per_function, patched_click_secho, patched_sam_function_provider, - mock_get_template_function_resource_ids, - patched_get_template_artifacts_format, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt, ): - mock_get_template_function_resource_ids.return_value = ["HelloWorldFunction"] - - patched_sam_function_provider.return_value = MagicMock( - functions={"HelloWorldFunction": MagicMock(packagetype=IMAGE, imageuri="mysamapp:v1")} - ) - patched_get_template_artifacts_format.return_value = [IMAGE] + function_mock = MagicMock() + function_mock.packagetype = IMAGE + function_mock.imageuri = None + function_mock.full_path = "HelloWorldFunction" + patched_sam_function_provider.return_value.get_all.return_value = [function_mock] patched_get_buildable_stacks.return_value = (Mock(), []) # set Image repository to be blank. patched_prompt.side_effect = [ "sam-app", "region", "", + "", ] # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] - patched_confirm.side_effect = [True, False, True, False, ""] + patched_confirm.side_effect = [True, False, True, False, False, True] patched_manage_stack.return_value = "managed_s3_stack" patched_signer_config_per_function.return_value = ({}, {}) with self.assertRaises(GuidedDeployFailedError): @@ -385,7 +585,6 @@ def test_guided_prompts_images_blank_image_repository( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_with_given_capabilities( @@ -393,7 +592,6 @@ def test_guided_prompts_with_given_capabilities( given_capabilities, patched_signer_config_per_function, patched_sam_function_provider, - patched_get_template_artifacts_format, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, @@ -404,13 +602,17 @@ def test_guided_prompts_with_given_capabilities( patched_get_buildable_stacks.return_value = (Mock(), []) self.gc.capabilities = given_capabilities # Series of inputs to confirmations so that full range of questions are asked. - patched_confirm.side_effect = [True, False, "", True] + patched_confirm.side_effect = [True, False, "", True, True, True] self.gc.guided_prompts(parameter_override_keys=None) # Now to check for all the defaults on confirmations. expected_confirmation_calls = [ call(f"\t{self.gc.start_bold}Confirm changes before deploy{self.gc.end_bold}", default=True), call(f"\t{self.gc.start_bold}Allow SAM CLI IAM role creation{self.gc.end_bold}", default=True), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) @@ -428,27 +630,24 @@ def test_guided_prompts_with_given_capabilities( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_configuration_file_prompt_calls( self, patched_signer_config_per_function, patched_sam_function_provider, - patched_get_template_artifacts_format, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt, ): - patched_sam_function_provider.return_value = {} - patched_get_template_artifacts_format.return_value = [ZIP] + patched_sam_function_provider.return_value.functions = {} patched_get_buildable_stacks.return_value = (Mock(), []) patched_signer_config_per_function.return_value = ({}, {}) # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] - patched_confirm.side_effect = [True, False, True, True, ""] + patched_confirm.side_effect = [True, False, True, True, True, True] patched_manage_stack.return_value = "managed_s3_stack" self.gc.guided_prompts(parameter_override_keys=None) # Now to check for all the defaults on confirmations. @@ -460,6 +659,10 @@ def test_guided_prompts_check_configuration_file_prompt_calls( default=False, ), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) @@ -485,26 +688,23 @@ def test_guided_prompts_check_configuration_file_prompt_calls( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_parameter_from_template( self, patched_signer_config_per_function, patched_sam_function_provider, - patched_get_template_artifacts_format, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt, ): - patched_sam_function_provider.return_value = {} - patched_get_template_artifacts_format.return_value = [ZIP] + patched_sam_function_provider.return_value.functions = {} patched_get_buildable_stacks.return_value = (Mock(), []) # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] - patched_confirm.side_effect = [True, False, True, False, ""] + patched_confirm.side_effect = [True, False, True, False, True, True] patched_manage_stack.return_value = "managed_s3_stack" patched_signer_config_per_function.return_value = ({}, {}) parameter_override_from_template = {"MyTestKey": {"Default": "MyTemplateDefaultVal"}} @@ -519,6 +719,10 @@ def test_guided_prompts_check_parameter_from_template( default=False, ), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) @@ -539,26 +743,23 @@ def test_guided_prompts_check_parameter_from_template( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_parameter_from_cmd_or_config( self, patched_signer_config_per_function, patched_sam_function_provider, - patched_get_template_artifacts_format, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, patched_confirm, patched_prompt, ): - patched_sam_function_provider.return_value = {} - patched_get_template_artifacts_format.return_value = [ZIP] + patched_sam_function_provider.return_value.functions = {} patched_get_buildable_stacks.return_value = (Mock(), []) # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] - patched_confirm.side_effect = [True, False, True, False, ""] + patched_confirm.side_effect = [True, False, True, False, True, True] patched_signer_config_per_function.return_value = ({}, {}) patched_manage_stack.return_value = "managed_s3_stack" parameter_override_from_template = {"MyTestKey": {"Default": "MyTemplateDefaultVal"}} @@ -573,6 +774,10 @@ def test_guided_prompts_check_parameter_from_cmd_or_config( default=False, ), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) @@ -604,14 +809,12 @@ def test_guided_prompts_check_parameter_from_cmd_or_config( @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") def test_guided_prompts_with_code_signing( self, given_sign_packages_flag, given_code_signing_configs, patched_sam_function_provider, - patched_get_template_artifacts_format, patched_signer_config_per_function, patched_get_buildable_stacks, patchedauth_per_resource, @@ -622,12 +825,11 @@ def test_guided_prompts_with_code_signing( ): # given_sign_packages_flag = True # given_code_signing_configs = ({"MyFunction1"}, {"MyLayer1": {"MyFunction1"}, "MyLayer2": {"MyFunction1"}}) - patched_sam_function_provider.return_value = {} - patched_get_template_artifacts_format.return_value = [ZIP] + patched_sam_function_provider.return_value.functions = {} patched_signer_config_per_function.return_value = given_code_signing_configs patched_get_buildable_stacks.return_value = (Mock(), []) # Series of inputs to confirmations so that full range of questions are asked. - patched_confirm.side_effect = [True, False, given_sign_packages_flag, "", True] + patched_confirm.side_effect = [True, False, given_sign_packages_flag, "", True, True, True] self.gc.guided_prompts(parameter_override_keys=None) # Now to check for all the defaults on confirmations. expected_confirmation_calls = [ @@ -638,6 +840,10 @@ def test_guided_prompts_with_code_signing( default=True, ), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) @@ -672,14 +878,12 @@ def test_guided_prompts_with_code_signing( @patch("samcli.commands.deploy.guided_context.manage_stack") @patch("samcli.commands.deploy.guided_context.auth_per_resource") @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") - @patch("samcli.commands.deploy.guided_context.get_template_artifacts_format") @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") @patch("samcli.commands.deploy.guided_context.signer_config_per_function") def test_guided_prompts_check_default_config_region( self, patched_signer_config_per_function, patched_sam_function_provider, - patched_get_template_artifacts_format, patched_get_buildable_stacks, patchedauth_per_resource, patched_manage_stack, @@ -687,12 +891,11 @@ def test_guided_prompts_check_default_config_region( patched_prompt, patched_get_default_aws_region, ): - patched_sam_function_provider.return_value = {} - patched_get_template_artifacts_format.return_value = [ZIP] + patched_sam_function_provider.return_value.functions = {} patched_get_buildable_stacks.return_value = (Mock(), []) # Series of inputs to confirmations so that full range of questions are asked. patchedauth_per_resource.return_value = [("HelloWorldFunction", False)] - patched_confirm.side_effect = [True, False, True, True, ""] + patched_confirm.side_effect = [True, False, True, True, True, True] patched_signer_config_per_function.return_value = ({}, {}) patched_manage_stack.return_value = "managed_s3_stack" patched_get_default_aws_region.return_value = "default_config_region" @@ -708,6 +911,10 @@ def test_guided_prompts_check_default_config_region( default=False, ), call(f"\t{self.gc.start_bold}Save arguments to configuration file{self.gc.end_bold}", default=True), + call( + f"\t {self.gc.start_bold}Delete the unreferenced repositories listed above when deploying?{self.gc.end_bold}", + default=False, + ), ] self.assertEqual(expected_confirmation_calls, patched_confirm.call_args_list) diff --git a/tests/unit/commands/samconfig/test_samconfig.py b/tests/unit/commands/samconfig/test_samconfig.py index b6ba2b9891..1d943c169c 100644 --- a/tests/unit/commands/samconfig/test_samconfig.py +++ b/tests/unit/commands/samconfig/test_samconfig.py @@ -604,6 +604,7 @@ def test_deploy(self, do_cli_mock, get_template_artifacts_format_mock): False, "samconfig.toml", "default", + False, ) @patch("samcli.commands.deploy.command.do_cli") @@ -712,6 +713,7 @@ def test_deploy_different_parameter_override_format(self, do_cli_mock, get_templ False, "samconfig.toml", "default", + False, ) @patch("samcli.commands.logs.command.do_cli") diff --git a/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_builder.py b/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_builder.py new file mode 100644 index 0000000000..f395ebee95 --- /dev/null +++ b/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_builder.py @@ -0,0 +1,93 @@ +from samcli.lib.bootstrap.companion_stack.companion_stack_builder import CompanionStackBuilder +from unittest import TestCase +from unittest.mock import Mock, patch + + +class TestCompanionStackBuilder(TestCase): + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_builder.ECRRepo") + def test_building_single_function(self, ecr_repo_mock): + companion_stack_name = "CompanionStackA" + function_a = "FunctionA" + + repo_logical_id = "RepoLogicalIDA" + repo_physical_id = "RepoPhysicalIDA" + repo_output_id = "RepoOutputIDA" + + ecr_repo_instance = ecr_repo_mock.return_value + ecr_repo_instance.logical_id = repo_logical_id + ecr_repo_instance.physical_id = repo_physical_id + ecr_repo_instance.output_logical_id = repo_output_id + + companion_stack = Mock() + companion_stack.stack_name = companion_stack_name + builder = CompanionStackBuilder(companion_stack) + + builder.add_function(function_a) + template = builder.build() + self.assertIn(f'"{repo_logical_id}":', template) + self.assertIn(f'"RepositoryName": "{repo_physical_id}"', template) + self.assertIn(f'"{repo_output_id}":', template) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_builder.ECRRepo") + def test_building_multiple_functions(self, ecr_repo_mock): + companion_stack_name = "CompanionStackA" + function_prefix = "Function" + function_names = ["A", "B", "C", "D", "E", "F"] + + repo_logical_id_prefix = "RepoLogicalID" + repo_physical_id_prefix = "RepoPhysicalID" + repo_output_id_prefix = "RepoOutputID" + + ecr_repo_instances = list() + for function_name in function_names: + ecr_repo_instance = Mock() + ecr_repo_instance.logical_id = repo_logical_id_prefix + function_name + ecr_repo_instance.physical_id = repo_physical_id_prefix + function_name + ecr_repo_instance.output_logical_id = repo_output_id_prefix + function_name + ecr_repo_instances.append(ecr_repo_instance) + + ecr_repo_mock.side_effect = ecr_repo_instances + + companion_stack = Mock() + companion_stack.stack_name = companion_stack_name + builder = CompanionStackBuilder(companion_stack) + + for function_name in function_names: + builder.add_function(function_prefix + function_name) + template = builder.build() + for function_name in function_names: + self.assertIn(f'"{repo_logical_id_prefix + function_name}":', template) + self.assertIn(f'"RepositoryName": "{repo_physical_id_prefix + function_name}"', template) + self.assertIn(f'"{repo_output_id_prefix + function_name}":', template) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_builder.ECRRepo") + def test_mapping_multiple_functions(self, ecr_repo_mock): + companion_stack_name = "CompanionStackA" + function_prefix = "Function" + function_names = ["A", "B", "C", "D", "E", "F"] + + repo_logical_id_prefix = "RepoLogicalID" + repo_physical_id_prefix = "RepoPhysicalID" + repo_output_id_prefix = "RepoOutputID" + + ecr_repo_instances = list() + for function_name in function_names: + ecr_repo_instance = Mock() + ecr_repo_instance.logical_id = repo_logical_id_prefix + function_name + ecr_repo_instance.physical_id = repo_physical_id_prefix + function_name + ecr_repo_instance.output_logical_id = repo_output_id_prefix + function_name + ecr_repo_instances.append(ecr_repo_instance) + + ecr_repo_mock.side_effect = ecr_repo_instances + + companion_stack = Mock() + companion_stack.stack_name = companion_stack_name + builder = CompanionStackBuilder(companion_stack) + + for function_name in function_names: + builder.add_function(function_prefix + function_name) + for function_name in function_names: + self.assertIn( + (function_prefix + function_name, ecr_repo_instances[function_names.index(function_name)]), + builder.repo_mapping.items(), + ) diff --git a/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_manager.py b/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_manager.py new file mode 100644 index 0000000000..f40fb36bd8 --- /dev/null +++ b/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_manager.py @@ -0,0 +1,279 @@ +from botocore.exceptions import ClientError +from samcli.lib.bootstrap.companion_stack.companion_stack_manager import CompanionStackManager, sync_ecr_stack +from unittest import TestCase +from unittest.mock import ANY, MagicMock, Mock, patch + + +class TestCompanionStackManager(TestCase): + def setUp(self): + self.stack_name = "StackA" + self.companion_stack_name = "CompanionStackA" + + self.boto3_client_patch = patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.boto3.client") + self.boto3_client_mock = self.boto3_client_patch.start() + + self.companion_stack_patch = patch( + "samcli.lib.bootstrap.companion_stack.companion_stack_manager.CompanionStack" + ) + self.companion_stack_mock = self.companion_stack_patch.start() + + self.companion_stack_builder_patch = patch( + "samcli.lib.bootstrap.companion_stack.companion_stack_manager.CompanionStackBuilder" + ) + self.companion_stack_builder_mock = self.companion_stack_builder_patch.start() + + self.cfn_client = Mock() + self.ecr_client = Mock() + self.s3_client = Mock() + self.sts_client = Mock() + + self.companion_stack_mock.return_value.stack_name = self.companion_stack_name + self.boto3_client_mock.side_effect = [self.cfn_client, self.ecr_client, self.s3_client, self.sts_client] + self.manager = CompanionStackManager(self.stack_name, "region", "s3_bucket", "s3_prefix") + + def tearDown(self): + self.boto3_client_patch.stop() + self.companion_stack_patch.stop() + self.companion_stack_builder_patch.stop() + + def test_set_functions(self): + function_a = "FunctionA" + function_b = "FunctionB" + + self.manager.set_functions([function_a, function_b]) + + self.companion_stack_builder_mock.return_value.clear_functions.assert_called_once() + self.companion_stack_builder_mock.return_value.add_function.assert_any_call(function_a) + self.companion_stack_builder_mock.return_value.add_function.assert_any_call(function_b) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.mktempfile") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.S3Uploader") + def test_create_companion_stack( + self, + s3_uploader_mock, + mktempfile_mock, + ): + cfn_waiter = Mock() + self.cfn_client.get_waiter.return_value = cfn_waiter + + self.manager.does_companion_stack_exist = lambda: False + + self.manager.update_companion_stack() + + self.companion_stack_builder_mock.return_value.build.assert_called_once() + s3_uploader_mock.return_value.upload_with_dedup.assert_called_once() + self.cfn_client.create_stack.assert_called_once_with( + StackName=self.companion_stack_name, TemplateURL=ANY, Capabilities=ANY + ) + self.cfn_client.get_waiter.assert_called_once_with("stack_create_complete") + cfn_waiter.wait.assert_called_once_with(StackName=self.companion_stack_name, WaiterConfig=ANY) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.mktempfile") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.S3Uploader") + def test_update_companion_stack( + self, + s3_uploader_mock, + mktempfile_mock, + ): + cfn_waiter = Mock() + self.cfn_client.get_waiter.return_value = cfn_waiter + + self.manager.does_companion_stack_exist = lambda: True + + self.manager.update_companion_stack() + + self.companion_stack_builder_mock.return_value.build.assert_called_once() + s3_uploader_mock.return_value.upload_with_dedup.assert_called_once() + self.cfn_client.update_stack.assert_called_once_with( + StackName=self.companion_stack_name, TemplateURL=ANY, Capabilities=ANY + ) + self.cfn_client.get_waiter.assert_called_once_with("stack_update_complete") + cfn_waiter.wait.assert_called_once_with(StackName=self.companion_stack_name, WaiterConfig=ANY) + + def test_delete_companion_stack(self): + cfn_waiter = Mock() + self.cfn_client.get_waiter.return_value = cfn_waiter + + self.manager._delete_companion_stack() + + self.cfn_client.delete_stack.assert_called_once_with(StackName=self.companion_stack_name) + self.cfn_client.get_waiter.assert_called_once_with("stack_delete_complete") + cfn_waiter.wait.assert_called_once_with(StackName=self.companion_stack_name, WaiterConfig=ANY) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.ECRRepo") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.boto3.resource") + def test_list_deployed_repos(self, boto3_resource_mock, ecr_repo_mock): + repo_a = "ECRRepoA" + repo_b = "ECRRepoB" + + resource_a = Mock() + resource_a.resource_type = "AWS::ECR::Repository" + resource_a.logical_resource_id = repo_a + resource_b = Mock() + resource_b.resource_type = "AWS::ECR::Repository" + resource_b.logical_resource_id = repo_b + resource_c = Mock() + resource_c.resource_type = "RandomResource" + resources = [resource_a, resource_b, resource_c] + boto3_resource_mock.return_value.Stack.return_value.resource_summaries.all.return_value = resources + + self.manager.does_companion_stack_exist = lambda: True + + repos = self.manager.list_deployed_repos() + self.assertTrue(len(repos) == 2) + ecr_repo_mock.assert_any_call(logical_id=repo_a, physical_id=ANY) + ecr_repo_mock.assert_any_call(logical_id=repo_b, physical_id=ANY) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.ECRRepo") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.boto3.resource") + def test_list_deployed_repos_does_not_exist(self, boto3_resource_mock, ecr_repo_mock): + repo_a = "ECRRepoA" + repo_b = "ECRRepoB" + + resource_a = Mock() + resource_a.resource_type = "AWS::ECR::Repository" + resource_a.logical_resource_id = repo_a + resource_b = Mock() + resource_b.resource_type = "AWS::ECR::Repository" + resource_b.logical_resource_id = repo_b + resource_c = Mock() + resource_c.resource_type = "RandomResource" + resources = [resource_a, resource_b, resource_c] + boto3_resource_mock.return_value.Stack.return_value.resource_summaries.all.return_value = resources + + self.manager.does_companion_stack_exist = lambda: False + + repos = self.manager.list_deployed_repos() + self.assertEqual(repos, []) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.ECRRepo") + def test_get_unreferenced_repos(self, ecr_repo_mock): + repo_a_id = "ECRRepoA" + repo_b_id = "ECRRepoB" + + current_repo_a = Mock() + current_repo_a.logical_id = repo_a_id + current_repos = {"FunctionA": current_repo_a} + + repo_a = Mock() + repo_a.logical_id = repo_a_id + repo_b = Mock() + repo_b.logical_id = repo_b_id + deployed_repos = [repo_a, repo_b] + + self.manager.does_companion_stack_exist = lambda: True + self.manager.list_deployed_repos = lambda: deployed_repos + self.companion_stack_builder_mock.return_value.repo_mapping = current_repos + + unreferenced_repos = self.manager.get_unreferenced_repos() + self.assertEqual(len(unreferenced_repos), 1) + self.assertEqual(unreferenced_repos[0].logical_id, repo_b_id) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.ECRRepo") + def test_get_unreferenced_repos_does_not_exist(self, ecr_repo_mock): + repo_a_id = "ECRRepoA" + repo_b_id = "ECRRepoB" + + current_repo_a = Mock() + current_repo_a.logical_id = repo_a_id + current_repos = {"FunctionA": current_repo_a} + + repo_a = Mock() + repo_a.logical_id = repo_a_id + repo_b = Mock() + repo_b.logical_id = repo_b_id + deployed_repos = [repo_a, repo_b] + + self.manager.does_companion_stack_exist = lambda: False + self.manager.list_deployed_repos = lambda: deployed_repos + self.companion_stack_builder_mock.return_value.repo_mapping = current_repos + + unreferenced_repos = self.manager.get_unreferenced_repos() + self.assertEqual(unreferenced_repos, []) + + def test_delete_unreferenced_repos(self): + repo_a_id = "ECRRepoA" + repo_b_id = "ECRRepoB" + + repo_a = Mock() + repo_a.physical_id = repo_a_id + repo_b = Mock() + repo_b.physical_id = repo_b_id + unreferenced_repos = [repo_a, repo_b] + + self.manager.get_unreferenced_repos = lambda: unreferenced_repos + + self.manager.delete_unreferenced_repos() + + self.ecr_client.delete_repository.assert_any_call(repositoryName=repo_a_id, force=True) + self.ecr_client.delete_repository.assert_any_call(repositoryName=repo_b_id, force=True) + + def test_sync_repos_exists(self): + self.manager.does_companion_stack_exist = lambda: True + self.manager.get_repository_mapping = lambda: {"a": ""} + self.manager.delete_unreferenced_repos = Mock() + self.manager.update_companion_stack = Mock() + self.manager._delete_companion_stack = Mock() + + self.manager.sync_repos() + self.manager.delete_unreferenced_repos.assert_called_once() + self.manager._delete_companion_stack.assert_not_called() + self.manager.update_companion_stack.assert_called_once() + + def test_sync_repos_exists_with_no_repo(self): + self.manager.does_companion_stack_exist = lambda: True + self.manager.get_repository_mapping = lambda: {} + self.manager.delete_unreferenced_repos = Mock() + self.manager.update_companion_stack = Mock() + self.manager._delete_companion_stack = Mock() + + self.manager.sync_repos() + self.manager.delete_unreferenced_repos.assert_called_once() + self.manager._delete_companion_stack.assert_called_once() + self.manager.update_companion_stack.assert_not_called() + + def test_sync_repos_does_not_exist(self): + self.manager.does_companion_stack_exist = lambda: False + self.manager.get_repository_mapping = lambda: {"a": ""} + self.manager.delete_unreferenced_repos = Mock() + self.manager.update_companion_stack = Mock() + self.manager._delete_companion_stack = Mock() + + self.manager.sync_repos() + self.manager.delete_unreferenced_repos.assert_not_called() + self.manager._delete_companion_stack.assert_not_called() + self.manager.update_companion_stack.assert_called_once() + + def test_does_companion_stack_exist_true(self): + self.cfn_client.describe_stacks.return_value = {"a": "a"} + self.assertTrue(self.manager.does_companion_stack_exist()) + + def test_does_companion_stack_exist_false(self): + error = ClientError({}, Mock()) + error_message = f"Stack with id {self.companion_stack_name} does not exist" + error.response = {"Error": {"Message": error_message}} + self.cfn_client.describe_stacks.side_effect = error + self.assertFalse(self.manager.does_companion_stack_exist()) + + def test_does_companion_stack_exist_error(self): + error = ClientError({}, Mock()) + self.cfn_client.describe_stacks.side_effect = error + with self.assertRaises(ClientError): + self.assertFalse(self.manager.does_companion_stack_exist()) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.CompanionStackManager") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.SamLocalStackProvider") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.SamFunctionProvider") + def test_sync_ecr_stack(self, function_provider_mock, stack_provider_mock, manager_mock): + image_repositories = {"Function1": "uri1"} + stacks = MagicMock() + stack_provider_mock.get_stacks.return_value = (stacks, None) + manager_mock.return_value.get_repository_mapping.return_value = {"Function2": "uri2"} + + result = sync_ecr_stack("template.yaml", "stack-name", "region", "s3-bucket", "s3-prefix", image_repositories) + + manager_mock.assert_called_once_with("stack-name", "region", "s3-bucket", "s3-prefix") + function_provider_mock.assert_called_once_with(stacks, ignore_code_extraction_warnings=True) + manager_mock.return_value.sync_repos.assert_called_once_with() + + self.assertEqual(result, {"Function1": "uri1", "Function2": "uri2"}) diff --git a/tests/unit/lib/bootstrap/companion_stack/test_data_types.py b/tests/unit/lib/bootstrap/companion_stack/test_data_types.py new file mode 100644 index 0000000000..dd91dc0f7b --- /dev/null +++ b/tests/unit/lib/bootstrap/companion_stack/test_data_types.py @@ -0,0 +1,94 @@ +from samcli.lib.bootstrap.companion_stack.data_types import CompanionStack, ECRRepo +from samcli.lib.bootstrap.companion_stack.companion_stack_builder import CompanionStackBuilder +from unittest import TestCase +from unittest.mock import Mock, patch + + +class TestCheckSumConsistency(TestCase): + """This test case is used for surfacing breaking changes companion stacks + that can be caused by str_checksum. + If the behavior of str_checksum is changed, please verify the side effects + that can be caused on companion stacks. + """ + + def test_check_sum_consistency(self): + companion_stack = CompanionStack("Parent-Stack") + self.assertEqual(companion_stack.stack_name, "Parent-Stack-8ab67daa-CompanionStack") + + +class TestCompanionStack(TestCase): + def setUp(self): + self.check_sum = "checksum" + self.parent_stack_name = "Parent-Stack" + self.check_sum_patch = patch("samcli.lib.bootstrap.companion_stack.data_types.str_checksum") + self.check_sum_mock = self.check_sum_patch.start() + self.check_sum_mock.return_value = self.check_sum + self.companion_stack = CompanionStack(self.parent_stack_name) + + def tearDown(self): + self.check_sum_patch.stop() + + def test_parent_stack_name(self): + self.assertEqual(self.companion_stack.parent_stack_name, self.parent_stack_name) + + def test_escaped_parent_stack_name(self): + self.assertEqual(self.companion_stack.escaped_parent_stack_name, "parentstack") + + def test_parent_stack_hash(self): + self.assertEqual(self.companion_stack.parent_stack_hash, "checksum") + + def test_stack_name(self): + self.assertEqual(self.companion_stack.stack_name, "Parent-Stack-checksum-CompanionStack") + + def test_stack_name_cutoff(self): + self.parent_stack_name = "A" * 128 + self.companion_stack = CompanionStack(self.parent_stack_name) + self.assertEqual(self.companion_stack.stack_name, "A" * 104 + "-checksum-CompanionStack") + + +class TestECRRepo(TestCase): + def setUp(self): + self.check_sum = "qwertyuiop" + self.parent_stack_name = "Parent-Stack" + self.function_id = "FunctionA" + + self.check_sum_patch = patch("samcli.lib.bootstrap.companion_stack.data_types.str_checksum") + self.check_sum_mock = self.check_sum_patch.start() + self.check_sum_mock.return_value = self.check_sum + + self.companion_stack_mock = Mock() + self.companion_stack_mock.escaped_parent_stack_name = "parentstackname" + self.companion_stack_mock.parent_stack_hash = "abcdefghijklmn" + self.ecr_repo = ECRRepo(companion_stack=self.companion_stack_mock, function_logical_id=self.function_id) + + def tearDown(self): + self.check_sum_patch.stop() + + def test_logical_id(self): + self.assertEqual(self.ecr_repo.logical_id, "FunctionAqwertyuiRepo") + + def test_physical_id(self): + self.assertEqual(self.ecr_repo.physical_id, "parentstacknameabcdefgh/functionaqwertyuirepo") + + def test_output_logical_id(self): + self.assertEqual(self.ecr_repo.output_logical_id, "FunctionAqwertyuiOut") + + def test_get_repo_uri(self): + self.assertEqual( + self.ecr_repo.get_repo_uri("12345", "us-west-2"), + "12345.dkr.ecr.us-west-2.amazonaws.com/parentstacknameabcdefgh/functionaqwertyuirepo", + ) + + def test_physical_id_cutoff(self): + self.companion_stack_mock.escaped_parent_stack_name = "s" * 128 + self.companion_stack_mock.parent_stack_hash = "abcdefghijklmn" + + self.function_id = "F" * 64 + self.ecr_repo = ECRRepo(companion_stack=self.companion_stack_mock, function_logical_id=self.function_id) + + self.assertEqual(self.ecr_repo.physical_id, "s" * 128 + "abcdefgh/" + "f" * 64 + "qwertyuirepo") + + def test_logical_id_cutoff(self): + self.function_id = "F" * 64 + self.ecr_repo = ECRRepo(companion_stack=self.companion_stack_mock, function_logical_id=self.function_id) + self.assertEqual(self.ecr_repo.logical_id, "F" * 52 + "qwertyuiRepo") diff --git a/tests/unit/lib/cli_validation/test_image_repository_validation.py b/tests/unit/lib/cli_validation/test_image_repository_validation.py index 9773cbc9d0..acbbd18343 100644 --- a/tests/unit/lib/cli_validation/test_image_repository_validation.py +++ b/tests/unit/lib/cli_validation/test_image_repository_validation.py @@ -90,7 +90,10 @@ def test_image_repository_validation_failure_IMAGE_image_repositories_and_image_ with self.assertRaises(click.BadOptionUsage) as ex: self.foobar() - self.assertIn("'--image-repositories' and '--image-repository' cannot be provided", ex.exception.message) + self.assertIn( + "Only one of the following can be provided: '--image-repositories', '--image-repository', or '--resolve-image-repos'. ", + ex.exception.message, + ) @patch("samcli.lib.cli_validation.image_repository_validation.click") @patch("samcli.lib.cli_validation.image_repository_validation.get_template_function_resource_ids") @@ -131,7 +134,10 @@ def test_image_repository_validation_failure_IMAGE_missing_image_repositories( with self.assertRaises(click.BadOptionUsage) as ex: self.foobar() - self.assertIn("Missing option '--image-repository' or '--image-repositories'", ex.exception.message) + self.assertIn( + "Missing option '--image-repository', '--image-repositories', or '--resolve-image-repos'", + ex.exception.message, + ) @patch("samcli.lib.cli_validation.image_repository_validation.click") @patch("samcli.lib.cli_validation.image_repository_validation.get_template_function_resource_ids")