diff --git a/samcli/commands/pipeline/init/interactive_init_flow.py b/samcli/commands/pipeline/init/interactive_init_flow.py index 2091d87dbc..35d8476b5a 100644 --- a/samcli/commands/pipeline/init/interactive_init_flow.py +++ b/samcli/commands/pipeline/init/interactive_init_flow.py @@ -17,6 +17,7 @@ from samcli.lib.cookiecutter.question import Choice from samcli.lib.cookiecutter.template import Template from samcli.lib.utils import osutils +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 @@ -42,17 +43,20 @@ def do_interactive() -> None: ) source = pipeline_template_source_question.ask() if source == CUSTOM_PIPELINE_TEMPLATE_SOURCE: - _generate_from_custom_location() + generated_files = _generate_from_custom_location() else: - _generate_from_app_pipeline_templates() - click.echo("Successfully created the pipeline configuration file(s)") + 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() -> None: +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( @@ -65,23 +69,24 @@ def _generate_from_app_pipeline_templates() -> None: selected_pipeline_template_dir: Path = pipeline_templates_local_dir.joinpath( selected_pipeline_template_metadata.location ) - _generate_from_pipeline_template(selected_pipeline_template_dir) + return _generate_from_pipeline_template(selected_pipeline_template_dir) -def _generate_from_custom_location() -> None: +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): - _generate_from_pipeline_template(Path(pipeline_template_git_location)) - else: - 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 - ) - _generate_from_pipeline_template(pipeline_template_local_dir) + 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 + ) + return _generate_from_pipeline_template(pipeline_template_local_dir) def _load_pipeline_bootstrap_context() -> Dict: @@ -100,14 +105,34 @@ def _load_pipeline_bootstrap_context() -> Dict: return context -def _generate_from_pipeline_template(pipeline_template_dir: Path) -> None: +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) - pipeline_template.generate_project(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 _copy_dir_contents_to_cwd_fail_on_exist(source_dir: str) -> List[str]: + copied_file_paths: List[str] = [] + for root, _, files in os.walk(source_dir): + for filename in files: + file_path = Path(root, filename) + target_file_path = Path(".").joinpath(file_path.relative_to(source_dir)) + LOG.debug("Verify %s does not exist", target_file_path) + if target_file_path.exists(): + raise PipelineFileAlreadyExistsError(target_file_path) + copied_file_paths.append(str(target_file_path)) + LOG.debug("Copy contents of %s to cwd", source_dir) + osutils.copytree(source_dir, ".") + return copied_file_paths def _clone_app_pipeline_templates() -> Path: @@ -267,3 +292,10 @@ 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)) + + +class PipelineFileAlreadyExistsError(Exception): + def __init__(self, file_path: os.PathLike) -> None: + Exception.__init__( + self, f'Pipeline file "{file_path}" already exists in project root directory, please remove it first.' + ) diff --git a/samcli/lib/cookiecutter/template.py b/samcli/lib/cookiecutter/template.py index ed73ce8cac..678303dd08 100644 --- a/samcli/lib/cookiecutter/template.py +++ b/samcli/lib/cookiecutter/template.py @@ -121,7 +121,7 @@ def run_interactive_flows(self, context: Optional[Dict] = None) -> Dict: except Exception as e: raise UserException(str(e), wrapped_from=e.__class__.__name__) from e - def generate_project(self, context: Dict) -> None: + def generate_project(self, context: Dict, output_dir: str) -> None: """ Generates a project based on this cookiecutter template and the given context. The context is first processed and manipulated by series of preprocessors(if any) then the project is generated and finally @@ -131,6 +131,8 @@ def generate_project(self, context: Dict) -> None: ---------- context: Dict the cookiecutter context to fulfill the values of cookiecutter.json keys + output_dir: str + the directory where project will be generated in Raise: ------ @@ -146,7 +148,13 @@ def generate_project(self, context: Dict) -> None: try: LOG.debug("Baking a new template with cookiecutter with all parameters") - cookiecutter(template=self._location, output_dir=".", no_input=True, extra_context=context) + cookiecutter( + template=self._location, + output_dir=output_dir, + no_input=True, + extra_context=context, + overwrite_if_exists=True, + ) except RepositoryNotFound as e: # cookiecutter.json is not found in the template. Let's just clone it directly without # using cookiecutter and call it done. 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 34bf4a22d0..735d8bcff5 100644 --- a/tests/unit/commands/pipeline/init/test_initeractive_init_flow.py +++ b/tests/unit/commands/pipeline/init/test_initeractive_init_flow.py @@ -100,18 +100,22 @@ def test_app_pipeline_templates_with_invalid_manifest( do_interactive() @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.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_generate_pipeline_configuration_file_from_app_pipeline_template_happy_case( self, click_mock, + _copy_dir_contents_to_cwd_fail_on_exist_mock, clone_mock, PipelineTemplatesManifest_mock, create_interactive_flow_mock, cookiecutter_mock, + osutils_mock, samconfig_mock, ): # setup @@ -131,9 +135,11 @@ def test_generate_pipeline_configuration_file_from_app_pipeline_template_happy_c 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 = Mock() + cookiecutter_context_mock = {"key": "value"} interactive_flow_mock.run.return_value = cookiecutter_context_mock config_file = Mock() samconfig_mock.return_value = config_file @@ -152,6 +158,7 @@ def test_generate_pipeline_configuration_file_from_app_pipeline_template_happy_c do_interactive() # verify + osutils_mock.mkdir_temp.assert_called() # cookiecutter project is generated to temp expected_cookicutter_template_location = any_app_pipeline_templates_path.joinpath(jenkins_template_location) clone_mock.assert_called_once_with(shared_path, APP_PIPELINE_TEMPLATES_REPO_LOCAL_NAME, replace_existing=True) PipelineTemplatesManifest_mock.assert_called_once() @@ -166,9 +173,10 @@ def test_generate_pipeline_configuration_file_from_app_pipeline_template_happy_c ) cookiecutter_mock.assert_called_once_with( template=str(expected_cookicutter_template_location), - output_dir=".", + output_dir=cookiecutter_output_dir_mock, no_input=True, extra_context=cookiecutter_context_mock, + overwrite_if_exists=True, ) @patch("samcli.commands.pipeline.init.interactive_init_flow._read_app_pipeline_templates_manifest") @@ -236,10 +244,12 @@ def test_generate_pipeline_configuration_file_from_custom_local_existing_path_wi @patch("samcli.commands.pipeline.init.interactive_init_flow.InteractiveFlowCreator.create_flow") @patch("samcli.commands.pipeline.init.interactive_init_flow.GitRepo.clone") @patch("samcli.commands.pipeline.init.interactive_init_flow.click") + @patch("samcli.commands.pipeline.init.interactive_init_flow._copy_dir_contents_to_cwd_fail_on_exist") @patch("samcli.lib.cookiecutter.question.click") def test_generate_pipeline_configuration_file_from_custom_remote_pipeline_template_happy_case( self, questions_click_mock, + _copy_dir_contents_to_cwd_fail_on_exist_mock, init_click_mock, clone_mock, create_interactive_flow_mock, @@ -248,14 +258,16 @@ def test_generate_pipeline_configuration_file_from_custom_remote_pipeline_templa ): # setup any_temp_dir = "/tmp/any/dir" - osutils_mock.mkdir_temp.return_value.__enter__ = Mock(return_value=any_temp_dir) + cookiecutter_output_dir_mock = "/tmp/any/dir2" + osutils_mock.mkdir_temp.return_value.__enter__ = Mock(side_effect=[any_temp_dir, cookiecutter_output_dir_mock]) osutils_mock.mkdir_temp.return_value.__exit__ = Mock() any_custom_pipeline_templates_path = Path(os.path.join(any_temp_dir, CUSTOM_PIPELINE_TEMPLATE_REPO_LOCAL_NAME)) clone_mock.return_value = any_custom_pipeline_templates_path interactive_flow_mock = Mock() create_interactive_flow_mock.return_value = interactive_flow_mock - cookiecutter_context_mock = Mock() + cookiecutter_context_mock = {"key": "value"} interactive_flow_mock.run.return_value = cookiecutter_context_mock + _copy_dir_contents_to_cwd_fail_on_exist_mock.return_value = ["file1"] questions_click_mock.prompt.return_value = "2" # Custom pipeline templates init_click_mock.prompt.return_value = "https://github.com/any-custom-pipeline-template-repo.git" @@ -264,7 +276,8 @@ def test_generate_pipeline_configuration_file_from_custom_remote_pipeline_templa do_interactive() # verify - osutils_mock.mkdir_temp.assert_called_once() # Custom templates are cloned to temp + # Custom templates are cloned to temp; cookiecutter project is generated to temp + osutils_mock.mkdir_temp.assert_called() clone_mock.assert_called_once_with( Path(any_temp_dir), CUSTOM_PIPELINE_TEMPLATE_REPO_LOCAL_NAME, replace_existing=True ) @@ -274,7 +287,8 @@ def test_generate_pipeline_configuration_file_from_custom_remote_pipeline_templa interactive_flow_mock.run.assert_called_once() cookiecutter_mock.assert_called_once_with( template=str(any_custom_pipeline_templates_path), - output_dir=".", + output_dir=cookiecutter_output_dir_mock, no_input=True, extra_context=cookiecutter_context_mock, + overwrite_if_exists=True, ) diff --git a/tests/unit/lib/cookiecutter/test_template.py b/tests/unit/lib/cookiecutter/test_template.py index edb7412f59..318939f46b 100644 --- a/tests/unit/lib/cookiecutter/test_template.py +++ b/tests/unit/lib/cookiecutter/test_template.py @@ -114,11 +114,16 @@ def test_generate_project(self, mock_preprocessor, mock_postprocessor, mock_inte postprocessors=[mock_postprocessor], ) mock_preprocessor.run.return_value = self._ANY_PROCESSOR_CONTEXT - t.generate_project(context=self._ANY_INTERACTIVE_FLOW_CONTEXT) + output_dir = Mock() + t.generate_project(context=self._ANY_INTERACTIVE_FLOW_CONTEXT, output_dir=output_dir) mock_interactive_flow.run.assert_not_called() mock_preprocessor.run.assert_called_once_with(self._ANY_INTERACTIVE_FLOW_CONTEXT) mock_cookiecutter.assert_called_with( - template=self._ANY_LOCATION, output_dir=".", no_input=True, extra_context=self._ANY_PROCESSOR_CONTEXT + template=self._ANY_LOCATION, + output_dir=output_dir, + no_input=True, + extra_context=self._ANY_PROCESSOR_CONTEXT, + overwrite_if_exists=True, ) mock_postprocessor.run.assert_called_once_with(self._ANY_PROCESSOR_CONTEXT) @@ -127,7 +132,7 @@ def test_generate_project_preprocessors_exceptions(self, mock_preprocessor): t = Template(location=self._ANY_LOCATION, preprocessors=[mock_preprocessor]) with self.assertRaises(PreprocessingError): mock_preprocessor.run.side_effect = Exception("something went wrong") - t.generate_project({}) + t.generate_project({}, Mock()) @patch("samcli.lib.cookiecutter.template.cookiecutter") @patch("samcli.lib.cookiecutter.processor") @@ -135,7 +140,7 @@ def test_generate_project_postprocessors_exceptions(self, mock_postprocessor, mo t = Template(location=self._ANY_LOCATION, postprocessors=[mock_postprocessor]) with self.assertRaises(PostprocessingError): mock_postprocessor.run.side_effect = Exception("something went wrong") - t.generate_project({}) + t.generate_project({}, Mock()) @patch("samcli.lib.cookiecutter.template.generate_non_cookiecutter_project") @patch("samcli.lib.cookiecutter.template.cookiecutter") @@ -143,13 +148,13 @@ def test_generate_project_cookiecutter_exceptions(self, mock_cookiecutter, mock_ t = Template(location=self._ANY_LOCATION) with self.assertRaises(InvalidLocationError): mock_cookiecutter.side_effect = UnknownRepoType() - t.generate_project({}) + t.generate_project({}, Mock()) mock_cookiecutter.reset_mock() with self.assertRaises(GenerateProjectFailedError): mock_cookiecutter.side_effect = Exception("something went wrong") - t.generate_project({}) + t.generate_project({}, Mock()) mock_cookiecutter.reset_mock() # if the provided template is not a cookiecutter template, we generate a non cookiecutter template mock_cookiecutter.side_effect = RepositoryNotFound() - t.generate_project({}) + t.generate_project({}, Mock()) mock_generate_non_cookiecutter_project.assert_called_once()