diff --git a/samcli/commands/pipeline/bootstrap/cli.py b/samcli/commands/pipeline/bootstrap/cli.py index b8f1996683..2df23c5a3f 100644 --- a/samcli/commands/pipeline/bootstrap/cli.py +++ b/samcli/commands/pipeline/bootstrap/cli.py @@ -17,14 +17,12 @@ from samcli.lib.utils.version_checker import check_newer_version from .guided_context import GuidedContext -SHORT_HELP = "Sets up infrastructure resources for AWS SAM CI/CD pipelines." - -HELP_TEXT = """Sets up the following infrastructure resources for AWS SAM CI/CD pipelines: -\n\t - Pipeline IAM user with access key ID and secret access key credentials to be shared with the CI/CD system -\n\t - Pipeline execution IAM role assumed by the pipeline user to obtain access to the AWS account -\n\t - CloudFormation execution IAM role assumed by CloudFormation to deploy the AWS SAM application -\n\t - Artifacts S3 bucket to hold the AWS SAM build artifacts -\n\t - Optionally, an ECR image repository to hold container image Lambda deployment packages +SHORT_HELP = "Generates the necessary AWS resources to connect your CI/CD system." + +HELP_TEXT = """ +SAM Pipeline Bootstrap generates the necessary AWS resources to connect your +CI/CD system. This step must be completed for each pipeline stage prior to +running sam pipeline init """ PIPELINE_CONFIG_DIR = os.path.join(".aws-sam", "pipeline") @@ -46,27 +44,24 @@ ) @click.option( "--pipeline-user", - help="The ARN of the IAM user having its access key ID and secret access key shared with the CI/CD system. " - "It is used to grant this IAM user the permissions to access the corresponding AWS account. " - "If not provided, the command will create one along with access key ID and secret access key credentials.", + help="An IAM user generated or referenced by sam pipeline bootstrap in order to " + "allow the connected CI/CD system to connect to the SAM CLI.", required=False, ) @click.option( "--pipeline-execution-role", - help="The ARN of an IAM role to be assumed by the pipeline user to operate on this environment. " - "Provide it only if you want to user your own role, otherwise, the command will create one", + help="Execution role that the CI/CD system assumes in order to make changes to resources on your behalf.", required=False, ) @click.option( "--cloudformation-execution-role", - help="The ARN of an IAM role to be assumed by the CloudFormation service while deploying the application's stack. " - "Provide it only if you want to user your own role, otherwise, the command will create one.", + help="Execution role that CloudFormation assumes in order to make changes to resources on your behalf", required=False, ) @click.option( - "--artifacts-bucket", - help="The ARN of an S3 bucket to hold the AWS SAM build artifacts. " - "Provide it only if you want to user your own S3 bucket, otherwise, the command will create one.", + "--bucket", + help="The name of the S3 bucket where this command uploads your CloudFormation template. This is required for" + "deployments of templates sized greater than 51,200 bytes.", required=False, ) @click.option( @@ -78,9 +73,7 @@ ) @click.option( "--image-repository", - help="The ARN of an ECR image repository to hold the containers images of Lambda functions of Image package type. " - "If provided, the --create-image-repository argument is ignored. If not provided and --create-image-repository is " - "set to true, the command will create one.", + help="ECR repo uri where this command uploads the image artifacts that are referenced in your template.", required=False, ) @click.option( @@ -107,7 +100,7 @@ def cli( pipeline_user: Optional[str], pipeline_execution_role: Optional[str], cloudformation_execution_role: Optional[str], - artifacts_bucket: Optional[str], + bucket: Optional[str], create_image_repository: bool, image_repository: Optional[str], pipeline_ip_range: Optional[str], @@ -126,7 +119,7 @@ def cli( pipeline_user_arn=pipeline_user, pipeline_execution_role_arn=pipeline_execution_role, cloudformation_execution_role_arn=cloudformation_execution_role, - artifacts_bucket_arn=artifacts_bucket, + artifacts_bucket_arn=bucket, create_image_repository=create_image_repository, image_repository_arn=image_repository, pipeline_ip_range=pipeline_ip_range, @@ -151,6 +144,7 @@ def do_cli( confirm_changeset: bool, config_file: Optional[str], config_env: Optional[str], + standalone: bool = True, ) -> None: """ implementation of `sam pipeline bootstrap` command @@ -159,7 +153,21 @@ def do_cli( pipeline_user_arn = _load_saved_pipeline_user_arn() if interactive: + if standalone: + click.echo( + dedent( + """\ + + sam pipeline bootstrap generates the necessary AWS resources to connect a stage in + your CI/CD system. We will ask for [1] stage definition, [2] account details, and + [3] references to existing resources in order to bootstrap these pipeline + resources. You can also add optional security parameters. + """ + ), + ) + guided_context = GuidedContext( + profile=profile, environment_name=environment_name, pipeline_user_arn=pipeline_user_arn, pipeline_execution_role_arn=pipeline_execution_role_arn, @@ -180,6 +188,7 @@ def do_cli( create_image_repository = guided_context.create_image_repository image_repository_arn = guided_context.image_repository_arn region = guided_context.region + profile = guided_context.profile if not environment_name: raise click.UsageError("Missing required parameter '--environment'") @@ -210,8 +219,8 @@ def do_cli( dedent( f"""\ View the definition in {os.path.join(PIPELINE_CONFIG_DIR, PIPELINE_CONFIG_FILENAME)}, - run {Colored().bold("sam pipeline bootstrap")} to generate another set of resources, or proceed to - {Colored().bold("sam pipeline init")} to create your pipeline configuration file. + run sam pipeline bootstrap to generate another set of resources, or proceed to + sam pipeline init to create your pipeline configuration file. """ ) ) diff --git a/samcli/commands/pipeline/bootstrap/guided_context.py b/samcli/commands/pipeline/bootstrap/guided_context.py index d7f21044b2..5695520438 100644 --- a/samcli/commands/pipeline/bootstrap/guided_context.py +++ b/samcli/commands/pipeline/bootstrap/guided_context.py @@ -2,22 +2,27 @@ An interactive flow that prompt the user for required information to bootstrap the AWS account of an environment with the required infrastructure """ +import os import sys from textwrap import dedent from typing import Optional, List, Tuple, Callable import click +from botocore.credentials import EnvProvider +from samcli.commands.exceptions import CredentialsError from samcli.commands.pipeline.external_links import CONFIG_AWS_CRED_DOC_URL from samcli.lib.bootstrap.bootstrap import get_current_account_id from samcli.lib.utils.colors import Colored from samcli.lib.utils.defaults import get_default_aws_region +from samcli.lib.utils.profile import list_available_profiles class GuidedContext: def __init__( self, + profile: Optional[str] = None, environment_name: Optional[str] = None, pipeline_user_arn: Optional[str] = None, pipeline_execution_role_arn: Optional[str] = None, @@ -28,6 +33,7 @@ def __init__( pipeline_ip_range: Optional[str] = None, region: Optional[str] = None, ) -> None: + self.profile = profile self.environment_name = environment_name self.pipeline_user_arn = pipeline_user_arn self.pipeline_execution_role_arn = pipeline_execution_role_arn @@ -39,10 +45,47 @@ def __init__( self.region = region self.color = Colored() + def _prompt_account_id(self) -> None: + profiles = list_available_profiles() + click.echo("The following AWS credential sources are available to use:") + click.echo( + dedent( + f"""\ + To know more about configuration AWS credentials, visit the link below: + {CONFIG_AWS_CRED_DOC_URL}\ + """ + ) + ) + if os.getenv(EnvProvider.ACCESS_KEY) and os.getenv(EnvProvider.SECRET_KEY): + click.echo(f" e. Environment variables: {EnvProvider.ACCESS_KEY} and {EnvProvider.SECRET_KEY}") + for i, profile in enumerate(profiles): + click.echo(f" {i + 1}. {profile} (named profile)") + click.echo(" q. Quit and configure AWS credential myself") + answer = click.prompt( + "Select an account source to associate with this stage", + show_choices=False, + show_default=False, + type=click.Choice([str(i + 1) for i in range(len(profiles))] + ["q", "e"]), + ) + if answer == "q": + sys.exit(0) + elif answer == "e": + # by default, env variable has higher precedence + # https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html#envvars-list + self.profile = None + else: + self.profile = profiles[int(answer) - 1] + + try: + account_id = get_current_account_id(self.profile) + click.echo(self.color.green(f"Associated account {account_id} with stage {self.environment_name}.")) + except CredentialsError as ex: + click.echo(self.color.red(ex.message)) + self._prompt_account_id() + def _prompt_stage_name(self) -> None: click.echo( - "Enter a name for the stage you want to bootstrap. This will be referenced later " - "when generating a Pipeline Config File with Pipeline Init." + "Enter a name for this stage. This will be referenced later when you use the sam pipeline init command:" ) self.environment_name = click.prompt( "Stage name", @@ -52,7 +95,7 @@ def _prompt_stage_name(self) -> None: def _prompt_region_name(self) -> None: self.region = click.prompt( - "Enter the region you want these resources to create", + "Enter the region in which you want these resources to be created", type=click.STRING, default=get_default_aws_region(), ) @@ -92,7 +135,7 @@ def _prompt_image_repository(self) -> None: if click.confirm("Does your application contain any IMAGE type Lambda functions?"): self.image_repository_arn = click.prompt( "Please enter the ECR image repository ARN(s) for your Image type function(s)." - "If you do not yet have a repostiory, we will create one for you", + "If you do not yet have a repository, we will create one for you", default="", type=click.STRING, ) @@ -110,36 +153,37 @@ def _prompt_ip_range(self) -> None: def _get_user_inputs(self) -> List[Tuple[str, Callable[[], None]]]: return [ + (f"Account: {get_current_account_id(self.profile)}", self._prompt_account_id), (f"Stage name: {self.environment_name}", self._prompt_stage_name), (f"Region: {self.region}", self._prompt_region_name), ( f"Pipeline user ARN: {self.pipeline_user_arn}" if self.pipeline_user_arn - else "Pipeline user: to be created", + else "Pipeline user: [to be created]", self._prompt_pipeline_user, ), ( f"Pipeline execution role ARN: {self.pipeline_execution_role_arn}" if self.pipeline_execution_role_arn - else "Pipeline execution role: to be created", + else "Pipeline execution role: [to be created]", self._prompt_pipeline_execution_role, ), ( f"CloudFormation execution role ARN: {self.cloudformation_execution_role_arn}" if self.cloudformation_execution_role_arn - else "CloudFormation execution role: to be created", + else "CloudFormation execution role: [to be created]", self._prompt_cloudformation_execution_role, ), ( f"Artifacts bucket ARN: {self.artifacts_bucket_arn}" if self.artifacts_bucket_arn - else "Artifacts bucket: to be created", + else "Artifacts bucket: [to be created]", self._prompt_artifacts_bucket, ), ( f"ECR image repository ARN: {self.image_repository_arn}" if self.image_repository_arn - else f"ECR image repository: {'to be created' if self.create_image_repository else 'skipped'}", + else f"ECR image repository: [{'to be created' if self.create_image_repository else 'skipped'}]", self._prompt_image_repository, ), ( @@ -156,71 +200,66 @@ def run(self) -> None: # pylint: disable=too-many-branches for the pipeline to work. Users can provide all, none or some resources' ARNs and leave the remaining empty and it will be created by the bootstrap command """ - click.secho( - dedent( - f"""\ - {Colored().bold("sam pipeline bootstrap")} generates the necessary AWS resources to connect your - CI/CD system. We will ask for [1] account details, [2] stage definition, - and [3] references to existing resources in order to bootstrap these pipeline - resources. You can also add optional security parameters. - """ - ), - fg="cyan", - ) - - account_id = get_current_account_id() - click.secho("[1] Account details", bold=True) - if click.confirm(f"You are bootstrapping resources in account {account_id}. Do you want to switch accounts?"): - click.echo(f"Please refer to this page about configuring credentials: {CONFIG_AWS_CRED_DOC_URL}.") - sys.exit(0) - - click.secho("[2] Stage definition", bold=True) + click.secho(self.color.bold("[1] Stage definition")) if self.environment_name: click.echo(f"Stage name: {self.environment_name}") else: self._prompt_stage_name() + click.echo() + + click.secho(self.color.bold("[2] Account details")) + self._prompt_account_id() + click.echo() if not self.region: self._prompt_region_name() - click.secho("[3] Reference existing resources", bold=True) if self.pipeline_user_arn: click.echo(f"Pipeline IAM user ARN: {self.pipeline_user_arn}") else: self._prompt_pipeline_user() + click.echo() + + click.secho(self.color.bold("[3] Reference application build resources")) if self.pipeline_execution_role_arn: click.echo(f"Pipeline execution role ARN: {self.pipeline_execution_role_arn}") else: self._prompt_pipeline_execution_role() + click.echo() if self.cloudformation_execution_role_arn: click.echo(f"CloudFormation execution role ARN: {self.cloudformation_execution_role_arn}") else: self._prompt_cloudformation_execution_role() + click.echo() if self.artifacts_bucket_arn: click.echo(f"Artifacts bucket ARN: {self.cloudformation_execution_role_arn}") else: self._prompt_artifacts_bucket() + click.echo() if self.image_repository_arn: click.echo(f"ECR image repository ARN: {self.image_repository_arn}") else: self._prompt_image_repository() + click.echo() - click.secho("[4] Security definition - OPTIONAL", bold=True) + click.secho(self.color.bold("[4] Security definition - OPTIONAL")) if self.pipeline_ip_range: click.echo(f"Pipeline IP address range: {self.pipeline_ip_range}") else: self._prompt_ip_range() + click.echo() # Ask customers to confirm the inputs + click.secho(self.color.bold("[5] Summary")) while True: inputs = self._get_user_inputs() - click.secho(self.color.cyan("Below is the summary of the answers:")) + click.secho("Below is the summary of the answers:") for i, (text, _) in enumerate(inputs): - click.secho(self.color.cyan(f" {i + 1}. {text}")) + click.secho(f" {i + 1}. {text}") edit_input = click.prompt( text="Press enter to confirm the values above, or select an item to edit the value", default="0", @@ -230,5 +269,6 @@ def run(self) -> None: # pylint: disable=too-many-branches ) if int(edit_input): inputs[int(edit_input) - 1][1]() + click.echo() else: break diff --git a/samcli/commands/pipeline/init/cli.py b/samcli/commands/pipeline/init/cli.py index 0a82f86dea..1c1ffc7bb7 100644 --- a/samcli/commands/pipeline/init/cli.py +++ b/samcli/commands/pipeline/init/cli.py @@ -7,38 +7,45 @@ from samcli.cli.cli_config_file import configuration_option, TomlProvider from samcli.cli.main import pass_context, common_options as cli_framework_options -from samcli.commands.pipeline.init.interactive_init_flow import do_interactive +from samcli.commands.pipeline.init.interactive_init_flow import InteractiveInitFlow from samcli.lib.telemetry.metric import track_command SHORT_HELP = "Generates CI/CD pipeline configuration files." HELP_TEXT = """ -Generates CI/CD pipeline configuration files for a chosen CI/CD system such as Jenkins, -GitLab CI/CD or GitHub Actions +sam pipeline init generates a pipeline config file that you can use to connect your +AWS account(s) to your CI/CD system. Before using sam pipeline init, you must +bootstrap the necessary resources for each stage in your pipeline. You can do this +by running sam pipeline init --bootstrap to be guided through the setup and config +file generation process, or refer to resources you have previously created with the +sam pipeline bootstrap command. """ @click.command("init", help=HELP_TEXT, short_help=SHORT_HELP) @configuration_option(provider=TomlProvider(section="parameters")) +@click.option( + "--bootstrap", + is_flag=True, + default=False, + help="Allow bootstrapping resources.", +) @cli_framework_options @pass_context @track_command # pylint: disable=R0914 -def cli( - ctx: Any, - config_env: Optional[str], - config_file: Optional[str], -) -> None: +def cli(ctx: Any, config_env: Optional[str], config_file: Optional[str], bootstrap: bool) -> None: """ `sam pipeline init` command entry point """ # Currently we support interactive mode only, i.e. the user doesn't provide the required arguments during the call # so we call do_cli without any arguments. This will change after supporting the non interactive mode. - do_cli() + do_cli(bootstrap) -def do_cli() -> None: +def do_cli(bootstrap: bool) -> None: """ implementation of `sam pipeline init` command """ # TODO non-interactive mode - do_interactive() + init_flow = InteractiveInitFlow(bootstrap) + init_flow.do_interactive() diff --git a/samcli/commands/pipeline/init/interactive_init_flow.py b/samcli/commands/pipeline/init/interactive_init_flow.py index c084a65ed8..607f5d5b75 100644 --- a/samcli/commands/pipeline/init/interactive_init_flow.py +++ b/samcli/commands/pipeline/init/interactive_init_flow.py @@ -5,7 +5,8 @@ import logging import os from pathlib import Path -from typing import Dict, List +from textwrap import dedent +from typing import Dict, List, Tuple import click @@ -20,7 +21,7 @@ from samcli.lib.utils.colors import Colored from samcli.lib.utils.git_repo import GitRepo, CloneRepoException from .pipeline_templates_manifest import Provider, PipelineTemplateMetadata, PipelineTemplatesManifest -from ..bootstrap.cli import PIPELINE_CONFIG_DIR, PIPELINE_CONFIG_FILENAME +from ..bootstrap.cli import do_cli as do_bootstrap, PIPELINE_CONFIG_DIR, PIPELINE_CONFIG_FILENAME LOG = logging.getLogger(__name__) shared_path: Path = global_cfg.config_dir @@ -31,65 +32,184 @@ CUSTOM_PIPELINE_TEMPLATE_SOURCE = "Custom Pipeline Template Location" -def do_interactive() -> None: - """ - An interactive flow that prompts the user for pipeline template (cookiecutter template) location, downloads it, - runs its specific questionnaire then generates the pipeline config file based on the template and user's responses - """ - pipeline_template_source_question = Choice( - key="pipeline-template-source", - text="Which pipeline template source would you like to use?", - options=[SAM_PIPELINE_TEMPLATE_SOURCE, CUSTOM_PIPELINE_TEMPLATE_SOURCE], - ) - source = pipeline_template_source_question.ask() - if source == CUSTOM_PIPELINE_TEMPLATE_SOURCE: - generated_files = _generate_from_custom_location() - else: - generated_files = _generate_from_app_pipeline_templates() - click.secho(Colored().green("Successfully created the pipeline configuration file(s):")) - for file in generated_files: - click.secho(Colored().green(f"\t- {file}")) - - -def _generate_from_app_pipeline_templates() -> List[str]: - """ - Prompts the user to choose a pipeline template from SAM predefined set of pipeline templates hosted in the git - repository: aws/aws-sam-cli-pipeline-init-templates.git - downloads locally, then generates the pipeline config file from the selected pipeline template. - Finally, return the list of generated files. - """ - pipeline_templates_local_dir: Path = _clone_app_pipeline_templates() - pipeline_templates_manifest: PipelineTemplatesManifest = _read_app_pipeline_templates_manifest( - pipeline_templates_local_dir - ) - # The manifest contains multiple pipeline-templates so select one - selected_pipeline_template_metadata: PipelineTemplateMetadata = _prompt_pipeline_template( - pipeline_templates_manifest - ) - selected_pipeline_template_dir: Path = pipeline_templates_local_dir.joinpath( - selected_pipeline_template_metadata.location - ) - return _generate_from_pipeline_template(selected_pipeline_template_dir) - - -def _generate_from_custom_location() -> List[str]: - """ - Prompts the user for a custom pipeline template location, downloads locally, then generates the pipeline config file - and return the list of generated files - """ - pipeline_template_git_location: str = click.prompt("Template Git location") - if os.path.exists(pipeline_template_git_location): - return _generate_from_pipeline_template(Path(pipeline_template_git_location)) - - with osutils.mkdir_temp(ignore_errors=True) as tempdir: - tempdir_path = Path(tempdir) - pipeline_template_local_dir: Path = _clone_pipeline_templates( - pipeline_template_git_location, tempdir_path, CUSTOM_PIPELINE_TEMPLATE_REPO_LOCAL_NAME +class InteractiveInitFlow: + def __init__(self, allow_bootstrap: bool): + self.allow_bootstrap = allow_bootstrap + self.color = Colored() + + def do_interactive(self) -> None: + """ + An interactive flow that prompts the user for pipeline template (cookiecutter template) location, downloads it, + runs its specific questionnaire then generates the pipeline config file + based on the template and user's responses + """ + click.echo( + dedent( + """\ + + sam pipeline init generates a pipeline config file that you can use to connect your + AWS account(s) to your CI/CD system. We will guide you through the process to + bootstrap resources for each stage, then walk through the details necessary for + creating the pipeline config file. + + Please ensure you are in the root folder of your SAM application before you begin. + """ + ) ) - return _generate_from_pipeline_template(pipeline_template_local_dir) - -def _load_pipeline_bootstrap_context() -> Dict: + click.echo("Select a pipeline structure template to get started:") + pipeline_template_source_question = Choice( + key="pipeline-template-source", + text="Select template", + options=[SAM_PIPELINE_TEMPLATE_SOURCE, CUSTOM_PIPELINE_TEMPLATE_SOURCE], + is_required=True, + ) + source = pipeline_template_source_question.ask() + if source == CUSTOM_PIPELINE_TEMPLATE_SOURCE: + generated_files = self._generate_from_custom_location() + else: + generated_files = self._generate_from_app_pipeline_templates() + click.secho(Colored().green("Successfully created the pipeline configuration file(s):")) + for file in generated_files: + click.secho(Colored().green(f"\t- {file}")) + + def _generate_from_app_pipeline_templates( + self, + ) -> List[str]: + """ + Prompts the user to choose a pipeline template from SAM predefined set of pipeline templates hosted in the git + repository: aws/aws-sam-cli-pipeline-init-templates.git + downloads locally, then generates the pipeline config file from the selected pipeline template. + Finally, return the list of generated files. + """ + pipeline_templates_local_dir: Path = _clone_app_pipeline_templates() + pipeline_templates_manifest: PipelineTemplatesManifest = _read_app_pipeline_templates_manifest( + pipeline_templates_local_dir + ) + # The manifest contains multiple pipeline-templates so select one + selected_pipeline_template_metadata: PipelineTemplateMetadata = _prompt_pipeline_template( + pipeline_templates_manifest + ) + selected_pipeline_template_dir: Path = pipeline_templates_local_dir.joinpath( + selected_pipeline_template_metadata.location + ) + return self._generate_from_pipeline_template(selected_pipeline_template_dir) + + def _generate_from_custom_location( + self, + ) -> List[str]: + """ + Prompts the user for a custom pipeline template location, downloads locally, + then generates the pipeline config file and return the list of generated files + """ + pipeline_template_git_location: str = click.prompt("Template Git location") + if os.path.exists(pipeline_template_git_location): + return self._generate_from_pipeline_template(Path(pipeline_template_git_location)) + + with osutils.mkdir_temp(ignore_errors=True) as tempdir: + tempdir_path = Path(tempdir) + pipeline_template_local_dir: Path = _clone_pipeline_templates( + pipeline_template_git_location, tempdir_path, CUSTOM_PIPELINE_TEMPLATE_REPO_LOCAL_NAME + ) + return self._generate_from_pipeline_template(pipeline_template_local_dir) + + def _prompt_run_bootstrap_within_pipeline_init(self, env_names: List[str], required_env_number: int) -> bool: + """ + Prompt bootstrap if `--bootstrap` flag is provided. Return True if bootstrap process is executed. + """ + if not env_names: + click.echo(Colored().yellow("No bootstrapped resources were detected.")) + else: + click.echo( + Colored().yellow( + f"Only {len(env_names)} bootstrapped stage(s) were detected, " + f"fewer than what the template requires: {required_env_number}." + ) + ) + click.echo() + + if self.allow_bootstrap: + if click.confirm( + "Do you want to go through stage setup process now? If you choose no, " + "you can still reference other bootstrapped resources." + ): + click.secho( + dedent( + """\ + + For each stage, we will ask for [1] stage definition, [2] account details, and [3] + reference application build resources in order to bootstrap these pipeline + resources. You can also add optional security parameters. + + We recommend using an individual AWS account profiles for each stage in your + pipeline. You can set these profiles up using [little bit of info on how to do + this/docs]. + """ + ) + ) + + click.echo(Colored().bold(f"\nStage {len(env_names) + 1} Setup\n")) + do_bootstrap( + region=None, + profile=None, + interactive=True, + environment_name=None, + pipeline_user_arn=None, + pipeline_execution_role_arn=None, + cloudformation_execution_role_arn=None, + artifacts_bucket_arn=None, + create_image_repository=False, + image_repository_arn=None, + pipeline_ip_range=None, + confirm_changeset=True, + config_file=None, + config_env=None, + standalone=False, + ) + return True + else: + click.echo( + Colored().yellow( + dedent( + f"""\ + If you want to setup stages before proceed, please quit the process using Ctrl+C. + Then you can either run {Colored().bold('sam pipeline bootstrap')} to setup a stage + or re-run this command with option {Colored().bold('--bootstrap')} to enable stage setup. + """ + ) + ) + ) + return False + + def _generate_from_pipeline_template(self, pipeline_template_dir: Path) -> List[str]: + """ + Generates a pipeline config file from a given pipeline template local location + and return the list of generated files. + """ + pipeline_template: Template = _initialize_pipeline_template(pipeline_template_dir) + required_env_number = 2 # TODO: read from template + click.echo(f"You are using the {required_env_number}-stage pipeline template.") + _draw_stage_diagram(required_env_number) + while True: + click.echo("Checking for bootstrapped resources...") + env_names, bootstrap_context = _load_pipeline_bootstrap_resources() + if len(env_names) < required_env_number and self._prompt_run_bootstrap_within_pipeline_init( + env_names, required_env_number + ): + # the customers just went through the bootstrap process, + # refresh the pipeline bootstrap resources and see whether bootstrap is still needed + continue + break + + context: Dict = pipeline_template.run_interactive_flows(bootstrap_context) + with osutils.mkdir_temp() as generate_dir: + LOG.debug("Generating pipeline files into %s", generate_dir) + context["outputDir"] = "." # prevent cookiecutter from generating a sub-folder + pipeline_template.generate_project(context, generate_dir) + return _copy_dir_contents_to_cwd_fail_on_exist(generate_dir) + + +def _load_pipeline_bootstrap_resources() -> Tuple[List[str], Dict[str, str]]: bootstrap_command_names = ["pipeline", "bootstrap"] section = "parameters" context: Dict = {} @@ -97,7 +217,7 @@ def _load_pipeline_bootstrap_context() -> Dict: config = SamConfig(PIPELINE_CONFIG_DIR, PIPELINE_CONFIG_FILENAME) if not config.exists(): context[str(["environment_names_message"])] = "" - return context + return [], context # config.get_env_names() will return the list of # bootstrapped env names and "default" which is used to store shared values @@ -115,22 +235,7 @@ def _load_pipeline_bootstrap_context() -> Dict: ) context[str(["environment_names_message"])] = environment_names_message - return context - - -def _generate_from_pipeline_template(pipeline_template_dir: Path) -> List[str]: - """ - Generates a pipeline config file from a given pipeline template local location - and return the list of generated files. - """ - pipeline_template: Template = _initialize_pipeline_template(pipeline_template_dir) - bootstrap_context: Dict = _load_pipeline_bootstrap_context() - context: Dict = pipeline_template.run_interactive_flows(bootstrap_context) - with osutils.mkdir_temp() as generate_dir: - LOG.debug("Generating pipeline files into %s", generate_dir) - context["outputDir"] = "." # prevent cookiecutter from generating a sub-folder - pipeline_template.generate_project(context, generate_dir) - return _copy_dir_contents_to_cwd_fail_on_exist(generate_dir) + return env_names, context def _copy_dir_contents_to_cwd_fail_on_exist(source_dir: str) -> List[str]: @@ -243,9 +348,7 @@ def _prompt_cicd_provider(available_providers: List[Provider]) -> Provider: return available_providers[0] question_to_choose_provider = Choice( - key="provider", - text="CI/CD system", - options=[p.display_name for p in available_providers], + key="provider", text="CI/CD system", options=[p.display_name for p in available_providers], is_required=True ) chosen_provider_display_name = question_to_choose_provider.ask() return next(p for p in available_providers if p.display_name == chosen_provider_display_name) @@ -310,3 +413,19 @@ def _get_pipeline_template_interactive_flow(pipeline_template_dir: Path) -> Inte """ flow_definition_path: Path = pipeline_template_dir.joinpath("questions.json") return InteractiveFlowCreator.create_flow(str(flow_definition_path)) + + +def _lines_for_stage(stage_index: int) -> List[str]: + return [ + " _________ ", + "| |", + f"| Stage {stage_index} |", + "|_________|", + ] + + +def _draw_stage_diagram(number_of_stages: int) -> None: + delimiters = [" ", " ", "->", " "] + stage_lines = [_lines_for_stage(i + 1) for i in range(number_of_stages)] + for i, delimiter in enumerate(delimiters): + click.echo(delimiter.join([stage_lines[stage_i][i] for stage_i in range(number_of_stages)])) diff --git a/samcli/lib/bootstrap/bootstrap.py b/samcli/lib/bootstrap/bootstrap.py index 9424d9e918..7535287253 100644 --- a/samcli/lib/bootstrap/bootstrap.py +++ b/samcli/lib/bootstrap/bootstrap.py @@ -4,7 +4,10 @@ import json import logging +from typing import Optional + import boto3 +from botocore.exceptions import ClientError from samcli import __version__ from samcli.cli.global_config import GlobalConfig @@ -31,10 +34,16 @@ def manage_stack(profile, region): return bucket_name -def get_current_account_id(): +def get_current_account_id(profile: Optional[str] = None): """Returns account ID based on used AWS credentials.""" - sts_client = boto3.client("sts") - caller_identity = sts_client.get_caller_identity() + session = boto3.Session(profile_name=profile) # type: ignore + sts_client = session.client("sts") + try: + caller_identity = sts_client.get_caller_identity() + except ClientError as ex: + if ex.response["Error"]["Code"] == "InvalidClientTokenId": + raise CredentialsError("Cannot identify account due to invalid configured credentials.") from ex + raise CredentialsError("Cannot identify account based on configured credentials.") from ex if "Account" not in caller_identity: raise CredentialsError("Cannot identify account based on configured credentials.") return caller_identity["Account"] diff --git a/samcli/lib/cookiecutter/interactive_flow.py b/samcli/lib/cookiecutter/interactive_flow.py index 996ac89ce3..95ce846dc0 100644 --- a/samcli/lib/cookiecutter/interactive_flow.py +++ b/samcli/lib/cookiecutter/interactive_flow.py @@ -1,7 +1,10 @@ """A flow of questions to be asked to the user in an interactive way.""" -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, List, Tuple + +import click from .question import Question +from ..utils.colors import Colored class InteractiveFlow: @@ -20,6 +23,7 @@ def __init__(self, questions: Dict[str, Question], first_question_key: str): self._questions: Dict[str, Question] = questions self._first_question_key: str = first_question_key self._current_question: Optional[Question] = None + self._color = Colored() def advance_to_next_question(self, current_answer: Optional[Any] = None) -> Optional[Question]: """ @@ -61,9 +65,25 @@ def run( associated to the key of the corresponding question """ context = context.copy() + answers: List[Tuple[str, Any]] = [] + question = self.advance_to_next_question() while question: answer = question.ask(context=context) context[question.key] = answer + answers.append((question.key, answer)) question = self.advance_to_next_question(answer) + + # print summary + click.echo(self._color.bold("SUMMARY")) + click.echo("We will generate a pipeline config file based on the following information:") + + for question_key, answer in answers: + if answer is None: + # ignore unanswered questions + continue + + question = self._questions[question_key] + click.echo(f"\t{question.text}: {self._color.underline(str(answer))}") + return context diff --git a/samcli/lib/cookiecutter/question.py b/samcli/lib/cookiecutter/question.py index fbec641388..3b3f483f67 100644 --- a/samcli/lib/cookiecutter/question.py +++ b/samcli/lib/cookiecutter/question.py @@ -237,6 +237,7 @@ def ask(self, context: Optional[Dict] = None) -> str: default=resolved_default_answer, show_choices=False, type=click.Choice(choices), + show_default=resolved_default_answer is not None, ) return self._options[int(choice) - 1] diff --git a/samcli/lib/utils/profile.py b/samcli/lib/utils/profile.py new file mode 100644 index 0000000000..47d0242eee --- /dev/null +++ b/samcli/lib/utils/profile.py @@ -0,0 +1,10 @@ +""" +Module for aws profile related helpers +""" +from typing import List, cast + +from botocore.session import Session + + +def list_available_profiles() -> List[str]: + return cast(List[str], Session().available_profiles) diff --git a/tests/unit/commands/pipeline/bootstrap/test_guided_context.py b/tests/unit/commands/pipeline/bootstrap/test_guided_context.py index 2d40e5454c..e1b8825107 100644 --- a/tests/unit/commands/pipeline/bootstrap/test_guided_context.py +++ b/tests/unit/commands/pipeline/bootstrap/test_guided_context.py @@ -1,5 +1,7 @@ from unittest import TestCase -from unittest.mock import patch, Mock +from unittest.mock import patch, Mock, ANY + +from parameterized import parameterized from samcli.commands.pipeline.bootstrap.guided_context import GuidedContext @@ -17,7 +19,10 @@ class TestGuidedContext(TestCase): @patch("samcli.commands.pipeline.bootstrap.guided_context.get_current_account_id") @patch("samcli.commands.pipeline.bootstrap.guided_context.click") - def test_guided_context_will_not_prompt_for_fields_that_are_already_provided(self, click_mock, account_id_mock): + @patch("samcli.commands.pipeline.bootstrap.guided_context.GuidedContext._prompt_account_id") + def test_guided_context_will_not_prompt_for_fields_that_are_already_provided( + self, prompt_account_id_mock, click_mock, account_id_mock + ): account_id_mock.return_value = "1234567890" click_mock.confirm.return_value = False click_mock.prompt = Mock(return_value="0") @@ -33,12 +38,18 @@ def test_guided_context_will_not_prompt_for_fields_that_are_already_provided(sel region=ANY_REGION, ) gc.run() - # there should only one prompt to ask what values customers want to change + # there should only two prompt to ask + # 1. which account to use (mocked in _prompt_account_id(), not contributing to count) + # 2. what values customers want to change + prompt_account_id_mock.assert_called_once() click_mock.prompt.assert_called_once() @patch("samcli.commands.pipeline.bootstrap.guided_context.get_current_account_id") @patch("samcli.commands.pipeline.bootstrap.guided_context.click") - def test_guided_context_will_prompt_for_fields_that_are_not_provided(self, click_mock, account_id_mock): + @patch("samcli.commands.pipeline.bootstrap.guided_context.GuidedContext._prompt_account_id") + def test_guided_context_will_prompt_for_fields_that_are_not_provided( + self, prompt_account_id_mock, click_mock, account_id_mock + ): account_id_mock.return_value = "1234567890" click_mock.confirm.return_value = False click_mock.prompt = Mock(return_value="0") @@ -46,6 +57,7 @@ def test_guided_context_will_prompt_for_fields_that_are_not_provided(self, click image_repository_arn=ANY_IMAGE_REPOSITORY_ARN # Exclude ECR repo, it has its own detailed test below ) gc.run() + prompt_account_id_mock.assert_called_once() self.assertTrue(self.did_prompt_text_like("Stage Name", click_mock.prompt)) self.assertTrue(self.did_prompt_text_like("Pipeline IAM user", click_mock.prompt)) self.assertTrue(self.did_prompt_text_like("Pipeline execution role", click_mock.prompt)) @@ -56,8 +68,9 @@ def test_guided_context_will_prompt_for_fields_that_are_not_provided(self, click @patch("samcli.commands.pipeline.bootstrap.guided_context.get_current_account_id") @patch("samcli.commands.pipeline.bootstrap.guided_context.click") + @patch("samcli.commands.pipeline.bootstrap.guided_context.GuidedContext._prompt_account_id") def test_guided_context_will_not_prompt_for_not_provided_image_repository_if_no_image_repository_is_required( - self, click_mock, account_id_mock + self, prompt_account_id_mock, click_mock, account_id_mock ): account_id_mock.return_value = "1234567890" # ECR Image Repository choices: @@ -75,22 +88,22 @@ def test_guided_context_will_not_prompt_for_not_provided_image_repository_if_no_ self.assertIsNone(gc_without_ecr_info.image_repository_arn) - click_mock.confirm.side_effect = [False, False] # the user chose to not CREATE an ECR Image repository - click_mock.prompt.return_value = "0" + click_mock.confirm.return_value = False # the user chose to not CREATE an ECR Image repository + click_mock.prompt.side_effect = [None, "0"] gc_without_ecr_info.run() self.assertIsNone(gc_without_ecr_info.image_repository_arn) self.assertFalse(gc_without_ecr_info.create_image_repository) self.assertFalse(self.did_prompt_text_like("Please enter the ECR image repository", click_mock.prompt)) - click_mock.confirm.side_effect = [False, True] # the user chose to CREATE an ECR Image repository - click_mock.prompt.side_effect = [None, "0"] + click_mock.confirm.return_value = True # the user chose to CREATE an ECR Image repository + click_mock.prompt.side_effect = [None, None, "0"] gc_without_ecr_info.run() self.assertIsNone(gc_without_ecr_info.image_repository_arn) self.assertTrue(gc_without_ecr_info.create_image_repository) self.assertTrue(self.did_prompt_text_like("Please enter the ECR image repository", click_mock.prompt)) - click_mock.confirm.side_effect = [False, True] # the user already has a repo - click_mock.prompt.side_effect = [ANY_IMAGE_REPOSITORY_ARN, "0"] + click_mock.confirm.return_value = True # the user already has a repo + click_mock.prompt.side_effect = [None, ANY_IMAGE_REPOSITORY_ARN, "0"] gc_without_ecr_info.run() self.assertFalse(gc_without_ecr_info.create_image_repository) self.assertTrue( @@ -110,3 +123,113 @@ def did_prompt_text_like(txt, click_prompt_mock): if txt in text: return True return False + + +class TestGuidedContext_prompt_account_id(TestCase): + @patch("samcli.commands.pipeline.bootstrap.guided_context.get_current_account_id") + @patch("samcli.commands.pipeline.bootstrap.guided_context.click") + @patch("samcli.commands.pipeline.bootstrap.guided_context.os.getenv") + @patch("samcli.commands.pipeline.bootstrap.guided_context.list_available_profiles") + def test_prompt_account_id_can_display_profiles_and_environment( + self, list_available_profiles_mock, getenv_mock, click_mock, get_current_account_id_mock + ): + getenv_mock.return_value = "not None" + list_available_profiles_mock.return_value = ["profile1", "profile2"] + click_mock.prompt.return_value = "e" # select environment variable + get_current_account_id_mock.return_value = "account_id" + + guided_context_mock = Mock() + GuidedContext._prompt_account_id(guided_context_mock) + + click_mock.prompt.assert_called_once_with( + ANY, show_choices=False, show_default=False, type=click_mock.Choice(["1", "2", "q", "e"]) + ) + + @patch("samcli.commands.pipeline.bootstrap.guided_context.get_current_account_id") + @patch("samcli.commands.pipeline.bootstrap.guided_context.click") + @patch("samcli.commands.pipeline.bootstrap.guided_context.os.getenv") + @patch("samcli.commands.pipeline.bootstrap.guided_context.list_available_profiles") + def test_prompt_account_id_wont_show_environment_option_when_it_doesnt_exist( + self, list_available_profiles_mock, getenv_mock, click_mock, get_current_account_id_mock + ): + getenv_mock.return_value = None + list_available_profiles_mock.return_value = ["profile1", "profile2"] + click_mock.prompt.return_value = "e" # select environment variable + get_current_account_id_mock.return_value = "account_id" + + guided_context_mock = Mock() + GuidedContext._prompt_account_id(guided_context_mock) + + click_mock.prompt.assert_called_once_with( + ANY, show_choices=False, show_default=False, type=click_mock.Choice(["1", "2", "q"]) + ) + + @patch("samcli.commands.pipeline.bootstrap.guided_context.get_current_account_id") + @patch("samcli.commands.pipeline.bootstrap.guided_context.click") + @patch("samcli.commands.pipeline.bootstrap.guided_context.os.getenv") + @patch("samcli.commands.pipeline.bootstrap.guided_context.list_available_profiles") + def test_prompt_account_id_select_environment_unset_self_profile( + self, list_available_profiles_mock, getenv_mock, click_mock, get_current_account_id_mock + ): + getenv_mock.return_value = "not None" + list_available_profiles_mock.return_value = ["profile1", "profile2"] + click_mock.prompt.return_value = "e" # select environment variable + get_current_account_id_mock.return_value = "account_id" + + guided_context_mock = Mock() + GuidedContext._prompt_account_id(guided_context_mock) + + self.assertEquals(None, guided_context_mock.profile) + + @parameterized.expand( + [ + ( + "1", + "profile1", + ), + ( + "2", + "profile2", + ), + ] + ) + @patch("samcli.commands.pipeline.bootstrap.guided_context.get_current_account_id") + @patch("samcli.commands.pipeline.bootstrap.guided_context.click") + @patch("samcli.commands.pipeline.bootstrap.guided_context.os.getenv") + @patch("samcli.commands.pipeline.bootstrap.guided_context.list_available_profiles") + def test_prompt_account_id_select_profile_set_profile_to_its_name( + self, + profile_selection, + expected_profile, + list_available_profiles_mock, + getenv_mock, + click_mock, + get_current_account_id_mock, + ): + getenv_mock.return_value = "not None" + list_available_profiles_mock.return_value = ["profile1", "profile2"] + click_mock.prompt.return_value = profile_selection + get_current_account_id_mock.return_value = "account_id" + + guided_context_mock = Mock() + GuidedContext._prompt_account_id(guided_context_mock) + + self.assertEquals(expected_profile, guided_context_mock.profile) + + @patch("samcli.commands.pipeline.bootstrap.guided_context.sys.exit") + @patch("samcli.commands.pipeline.bootstrap.guided_context.get_current_account_id") + @patch("samcli.commands.pipeline.bootstrap.guided_context.click") + @patch("samcli.commands.pipeline.bootstrap.guided_context.os.getenv") + @patch("samcli.commands.pipeline.bootstrap.guided_context.list_available_profiles") + def test_prompt_account_id_select_quit( + self, list_available_profiles_mock, getenv_mock, click_mock, get_current_account_id_mock, exit_mock + ): + getenv_mock.return_value = "not None" + list_available_profiles_mock.return_value = ["profile1", "profile2"] + click_mock.prompt.return_value = "q" # quit + get_current_account_id_mock.return_value = "account_id" + + guided_context_mock = Mock() + GuidedContext._prompt_account_id(guided_context_mock) + + exit_mock.assert_called_once_with(0) diff --git a/tests/unit/commands/pipeline/init/test_cli.py b/tests/unit/commands/pipeline/init/test_cli.py index 4b9f4b6349..2e7cd0699b 100644 --- a/tests/unit/commands/pipeline/init/test_cli.py +++ b/tests/unit/commands/pipeline/init/test_cli.py @@ -14,9 +14,9 @@ def test_cli_default_flow(self, do_cli_mock): runner.invoke(init_cmd) # Currently we support the interactive mode only, i.e. we don't accept any command arguments, # instead we ask the user about the required arguments in an interactive way - do_cli_mock.assert_called_once_with() # Called without arguments + do_cli_mock.assert_called_once_with(False) # Called without arguments - @patch("samcli.commands.pipeline.init.cli.do_interactive") + @patch("samcli.commands.pipeline.init.cli.InteractiveInitFlow.do_interactive") def test_do_cli(self, do_interactive_mock): - init_cli() + init_cli(False) do_interactive_mock.assert_called_once_with() # Called without arguments diff --git a/tests/unit/commands/pipeline/init/test_initeractive_init_flow.py b/tests/unit/commands/pipeline/init/test_initeractive_init_flow.py index 446cbdc6cf..0f4b4a71dc 100644 --- a/tests/unit/commands/pipeline/init/test_initeractive_init_flow.py +++ b/tests/unit/commands/pipeline/init/test_initeractive_init_flow.py @@ -1,9 +1,12 @@ from unittest import TestCase -from unittest.mock import patch, Mock, ANY +from unittest.mock import patch, Mock, ANY, call import os from pathlib import Path + +from parameterized import parameterized + from samcli.commands.pipeline.init.interactive_init_flow import ( - do_interactive, + InteractiveInitFlow, PipelineTemplateCloneException, APP_PIPELINE_TEMPLATES_REPO_LOCAL_NAME, shared_path, @@ -19,7 +22,7 @@ class TestInteractiveInitFlow(TestCase): @patch("samcli.commands.pipeline.init.interactive_init_flow._read_app_pipeline_templates_manifest") @patch("samcli.commands.pipeline.init.interactive_init_flow._prompt_pipeline_template") - @patch("samcli.commands.pipeline.init.interactive_init_flow._generate_from_pipeline_template") + @patch("samcli.commands.pipeline.init.interactive_init_flow.InteractiveInitFlow._generate_from_pipeline_template") @patch("samcli.commands.pipeline.init.interactive_init_flow.shared_path") @patch("samcli.commands.pipeline.init.interactive_init_flow.GitRepo.clone") @patch("samcli.lib.cookiecutter.question.click") @@ -44,7 +47,7 @@ def test_app_pipeline_templates_clone_fail_when_an_old_clone_exists( click_mock.prompt.return_value = "1" # App pipeline templates # trigger - do_interactive() + InteractiveInitFlow(allow_bootstrap=False).do_interactive() # verify clone_mock.assert_called_once_with( @@ -68,7 +71,7 @@ def test_app_pipeline_templates_clone_fail_when_no_old_clone_exist(self, click_m # trigger with self.assertRaises(PipelineTemplateCloneException): - do_interactive() + InteractiveInitFlow(allow_bootstrap=False).do_interactive() @patch("samcli.commands.pipeline.init.interactive_init_flow.GitRepo.clone") @patch("samcli.commands.pipeline.init.interactive_init_flow.click") @@ -83,7 +86,7 @@ def test_custom_pipeline_template_clone_fail(self, question_click_mock, init_cli # trigger with self.assertRaises(PipelineTemplateCloneException): - do_interactive() + InteractiveInitFlow(allow_bootstrap=False).do_interactive() @patch("samcli.commands.pipeline.init.interactive_init_flow._read_app_pipeline_templates_manifest") @patch("samcli.commands.pipeline.init.interactive_init_flow.GitRepo.clone") @@ -99,7 +102,7 @@ def test_app_pipeline_templates_with_invalid_manifest( # trigger with self.assertRaises(AppPipelineTemplateManifestException): - do_interactive() + InteractiveInitFlow(allow_bootstrap=False).do_interactive() @patch("samcli.commands.pipeline.init.interactive_init_flow.SamConfig") @patch("samcli.commands.pipeline.init.interactive_init_flow.osutils") @@ -157,7 +160,7 @@ def test_generate_pipeline_configuration_file_from_app_pipeline_template_happy_c ] # trigger - do_interactive() + InteractiveInitFlow(allow_bootstrap=False).do_interactive() # verify osutils_mock.mkdir_temp.assert_called() # cookiecutter project is generated to temp @@ -213,11 +216,11 @@ def test_generate_pipeline_configuration_file_when_pipeline_template_missing_que # trigger with self.assertRaises(QuestionsNotFoundException): - do_interactive() + InteractiveInitFlow(allow_bootstrap=False).do_interactive() @patch("samcli.commands.pipeline.init.interactive_init_flow.os") @patch("samcli.commands.pipeline.init.interactive_init_flow.osutils") - @patch("samcli.commands.pipeline.init.interactive_init_flow._generate_from_pipeline_template") + @patch("samcli.commands.pipeline.init.interactive_init_flow.InteractiveInitFlow._generate_from_pipeline_template") @patch("samcli.commands.pipeline.init.interactive_init_flow.GitRepo.clone") @patch("samcli.commands.pipeline.init.interactive_init_flow.click") @patch("samcli.lib.cookiecutter.question.click") @@ -236,7 +239,7 @@ def test_generate_pipeline_configuration_file_from_custom_local_existing_path_wi questions_click_mock.prompt.return_value = "2" # Custom pipeline templates init_click_mock.prompt.return_value = local_pipeline_templates_path # git repo path # trigger - do_interactive() + InteractiveInitFlow(allow_bootstrap=False).do_interactive() # verify osutils_mock.mkdir_temp.assert_not_called() @@ -277,7 +280,7 @@ def test_generate_pipeline_configuration_file_from_custom_remote_pipeline_templa init_click_mock.prompt.return_value = "https://github.com/any-custom-pipeline-template-repo.git" # trigger - do_interactive() + InteractiveInitFlow(allow_bootstrap=False).do_interactive() # verify # Custom templates are cloned to temp; cookiecutter project is generated to temp @@ -330,3 +333,147 @@ def test_prompt_provider_pipeline_template_will_not_prompt_if_the_list_of_templa chosen_template = _prompt_provider_pipeline_template(templates) click_mock.prompt.assert_called_once() self.assertEqual(chosen_template, template2) + + +class TestInteractiveInitFlowWithBootstrap(TestCase): + @patch("samcli.commands.pipeline.init.interactive_init_flow.SamConfig") + @patch("samcli.commands.pipeline.init.interactive_init_flow.osutils") + @patch("samcli.lib.cookiecutter.template.cookiecutter") + @patch("samcli.commands.pipeline.init.interactive_init_flow.InteractiveFlowCreator.create_flow") + @patch( + "samcli.commands.pipeline.init.interactive_init_flow.InteractiveInitFlow._prompt_run_bootstrap_within_pipeline_init" + ) + @patch("samcli.commands.pipeline.init.interactive_init_flow.PipelineTemplatesManifest") + @patch("samcli.commands.pipeline.init.interactive_init_flow.GitRepo.clone") + @patch("samcli.commands.pipeline.init.interactive_init_flow._copy_dir_contents_to_cwd_fail_on_exist") + @patch("samcli.lib.cookiecutter.question.click") + def test_with_bootstrap_but_answer_no( + self, + click_mock, + _copy_dir_contents_to_cwd_fail_on_exist_mock, + clone_mock, + PipelineTemplatesManifest_mock, + _prompt_run_bootstrap_within_pipeline_init_mock, + create_interactive_flow_mock, + cookiecutter_mock, + osutils_mock, + samconfig_mock, + ): + # setup + any_app_pipeline_templates_path = Path( + os.path.normpath(shared_path.joinpath(APP_PIPELINE_TEMPLATES_REPO_LOCAL_NAME)) + ) + clone_mock.return_value = any_app_pipeline_templates_path + jenkins_template_location = "some/location" + jenkins_template_mock = Mock( + display_name="Jenkins pipeline template", location=jenkins_template_location, provider="jenkins" + ) + pipeline_templates_manifest_mock = Mock( + providers=[ + Mock(id="gitlab", display_name="Gitlab"), + Mock(id="jenkins", display_name="Jenkins"), + ], + templates=[jenkins_template_mock], + ) + PipelineTemplatesManifest_mock.return_value = pipeline_templates_manifest_mock + cookiecutter_output_dir_mock = "/tmp/any/dir2" + osutils_mock.mkdir_temp.return_value.__enter__ = Mock(return_value=cookiecutter_output_dir_mock) + interactive_flow_mock = Mock() + create_interactive_flow_mock.return_value = interactive_flow_mock + cookiecutter_context_mock = {"key": "value"} + interactive_flow_mock.run.return_value = cookiecutter_context_mock + config_file = Mock() + samconfig_mock.return_value = config_file + config_file.exists.return_value = True + config_file.get_env_names.return_value = ["testing"] + config_file.get_all.return_value = {"pipeline_execution_role": "arn:aws:iam::123456789012:role/execution-role"} + + click_mock.prompt.side_effect = [ + "1", # App pipeline templates + "2", + # choose "Jenkins" when prompt for CI/CD system. (See pipeline_templates_manifest_mock, Jenkins is the 2nd provider) + "1", # choose "Jenkins pipeline template" when prompt for pipeline template + ] + + _prompt_run_bootstrap_within_pipeline_init_mock.return_value = False # not to bootstrap + + # trigger + InteractiveInitFlow(allow_bootstrap=True).do_interactive() + + # verify + _prompt_run_bootstrap_within_pipeline_init_mock.assert_called_once_with(["testing"], 2) + + @parameterized.expand( + [ + ([["testing"], ["testing", "prod"]], [call(["testing"], 2)]), + ([[], ["testing"], ["testing", "prod"]], [call([], 2), call(["testing"], 2)]), + ] + ) + @patch("samcli.commands.pipeline.init.interactive_init_flow.SamConfig") + @patch("samcli.commands.pipeline.init.interactive_init_flow.osutils") + @patch("samcli.lib.cookiecutter.template.cookiecutter") + @patch("samcli.commands.pipeline.init.interactive_init_flow.InteractiveFlowCreator.create_flow") + @patch( + "samcli.commands.pipeline.init.interactive_init_flow.InteractiveInitFlow._prompt_run_bootstrap_within_pipeline_init" + ) + @patch("samcli.commands.pipeline.init.interactive_init_flow.PipelineTemplatesManifest") + @patch("samcli.commands.pipeline.init.interactive_init_flow.GitRepo.clone") + @patch("samcli.commands.pipeline.init.interactive_init_flow._copy_dir_contents_to_cwd_fail_on_exist") + @patch("samcli.lib.cookiecutter.question.click") + def test_with_bootstrap_answer_yes( + self, + get_env_name_side_effects, + _prompt_run_bootstrap_expected_calls, + click_mock, + _copy_dir_contents_to_cwd_fail_on_exist_mock, + clone_mock, + PipelineTemplatesManifest_mock, + _prompt_run_bootstrap_within_pipeline_init_mock, + create_interactive_flow_mock, + cookiecutter_mock, + osutils_mock, + samconfig_mock, + ): + # setup + any_app_pipeline_templates_path = Path( + os.path.normpath(shared_path.joinpath(APP_PIPELINE_TEMPLATES_REPO_LOCAL_NAME)) + ) + clone_mock.return_value = any_app_pipeline_templates_path + jenkins_template_location = "some/location" + jenkins_template_mock = Mock( + display_name="Jenkins pipeline template", location=jenkins_template_location, provider="jenkins" + ) + pipeline_templates_manifest_mock = Mock( + providers=[ + Mock(id="gitlab", display_name="Gitlab"), + Mock(id="jenkins", display_name="Jenkins"), + ], + templates=[jenkins_template_mock], + ) + PipelineTemplatesManifest_mock.return_value = pipeline_templates_manifest_mock + cookiecutter_output_dir_mock = "/tmp/any/dir2" + osutils_mock.mkdir_temp.return_value.__enter__ = Mock(return_value=cookiecutter_output_dir_mock) + interactive_flow_mock = Mock() + create_interactive_flow_mock.return_value = interactive_flow_mock + cookiecutter_context_mock = {"key": "value"} + interactive_flow_mock.run.return_value = cookiecutter_context_mock + config_file = Mock() + samconfig_mock.return_value = config_file + config_file.exists.return_value = True + config_file.get_env_names.side_effect = get_env_name_side_effects + config_file.get_all.return_value = {"pipeline_execution_role": "arn:aws:iam::123456789012:role/execution-role"} + + click_mock.prompt.side_effect = [ + "1", # App pipeline templates + "2", + # choose "Jenkins" when prompt for CI/CD system. (See pipeline_templates_manifest_mock, Jenkins is the 2nd provider) + "1", # choose "Jenkins pipeline template" when prompt for pipeline template + ] + + _prompt_run_bootstrap_within_pipeline_init_mock.return_value = True # to bootstrap + + # trigger + InteractiveInitFlow(allow_bootstrap=True).do_interactive() + + # verify + _prompt_run_bootstrap_within_pipeline_init_mock.assert_has_calls(_prompt_run_bootstrap_expected_calls) diff --git a/tests/unit/lib/bootstrap/test_bootstrap.py b/tests/unit/lib/bootstrap/test_bootstrap.py index 8737946625..e62ad26a5c 100644 --- a/tests/unit/lib/bootstrap/test_bootstrap.py +++ b/tests/unit/lib/bootstrap/test_bootstrap.py @@ -28,16 +28,18 @@ def test_manage_stack_happy_case(self, manage_cfn_stack_mock): @patch("samcli.lib.bootstrap.bootstrap.boto3") def test_get_current_account_id(self, boto3_mock): + session_mock = boto3_mock.Session.return_value = MagicMock() sts_mock = MagicMock() sts_mock.get_caller_identity.return_value = {"Account": 1234567890} - boto3_mock.client.return_value = sts_mock + session_mock.client.return_value = sts_mock account_id = get_current_account_id() self.assertEqual(account_id, 1234567890) @patch("samcli.lib.bootstrap.bootstrap.boto3") def test_get_current_account_id_missing_id(self, boto3_mock): + session_mock = boto3_mock.Session.return_value = MagicMock() sts_mock = MagicMock() sts_mock.get_caller_identity.return_value = {} - boto3_mock.client.return_value = sts_mock + session_mock.client.return_value = sts_mock with self.assertRaises(CredentialsError): get_current_account_id() diff --git a/tests/unit/lib/cookiecutter/test_question.py b/tests/unit/lib/cookiecutter/test_question.py index c46a37fa43..e61dee88f2 100644 --- a/tests/unit/lib/cookiecutter/test_question.py +++ b/tests/unit/lib/cookiecutter/test_question.py @@ -188,7 +188,11 @@ def test_ask(self, mock_click, mock_choice): answer = self.question.ask({}) self.assertEqual(answer, TestQuestion._ANY_OPTIONS[1]) # we deduct one from user's choice (base 1 vs base 0) mock_click.prompt.assert_called_once_with( - text="Choice", default=self.question.default_answer, show_choices=False, type=ANY + text="Choice", + default=self.question.default_answer, + show_choices=False, + type=ANY, + show_default=self.question.default_answer is not None, ) mock_choice.assert_called_once_with(["1", "2", "3"])