diff --git a/ebcli/controllers/deploy.py b/ebcli/controllers/deploy.py index 1b2ed2419..156dcc5df 100644 --- a/ebcli/controllers/deploy.py +++ b/ebcli/controllers/deploy.py @@ -10,18 +10,20 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. - -from os import path, chdir, getcwd +from os import path, chdir, getcwd, makedirs +import zipfile +import datetime +from typing import Optional from cement.utils.misc import minimal_logger from ebcli.core import io, hooks, fileoperations from ebcli.core.abstractcontroller import AbstractBaseController -from ebcli.lib import elasticbeanstalk, utils -from ebcli.objects.exceptions import InvalidOptionsError +from ebcli.lib import elasticbeanstalk +from ebcli.objects.environment import Environment +from ebcli.objects.exceptions import InvalidOptionsError, NotInitializedError from ebcli.operations import commonops, deployops, composeops, statusops -from ebcli.resources.strings import strings, flag_text, alerts -from ebcli.resources.statics import platform_branch_lifecycle_states +from ebcli.resources.strings import strings, flag_text LOG = minimal_logger(__name__) @@ -47,14 +49,16 @@ class Meta(AbstractBaseController.Meta): (['--source'], dict(help=flag_text['deploy.source'])), (['-p', '--process'], dict( action='store_true', help=flag_text['deploy.process'])), - ] + (['--archive'], dict(help=flag_text['deploy.archive'])),] + usage = AbstractBaseController.Meta.usage.replace('{cmd}', label) def do_command(self): - self.timeout = self.app.pargs.timeout self.nohang = self.app.pargs.nohang + self.timeout = self.app.pargs.timeout if self.nohang: self.timeout = 0 + if self.app.pargs.modules: self.multiple_app_deploy() return @@ -62,9 +66,28 @@ def do_command(self): self.message = self.app.pargs.message self.staged = self.app.pargs.staged self.source = self.app.pargs.source - self.app_name = self.get_app_name() - self.env_name = self.get_env_name() + self.archive = self.app.pargs.archive + if self.archive and not self.app.pargs.region: + raise InvalidOptionsError(strings['deploy.archivewithoutregion']) + if self.source and self.archive: + raise InvalidOptionsError(strings['deploy.archivewithsource']) + + self.env_name = self.app.pargs.environment_name + if self.archive and not self.env_name: + raise InvalidOptionsError(strings['deploy.archivewithoutenvname']) + elif not self.archive: + self.env_name = self.env_name or self.get_env_name() + + if self.archive: + environment = elasticbeanstalk.get_environment(env_name=self.env_name) + self.app_name = environment.app_name + else: + self.app_name = self.get_app_name() + self.version = self.app.pargs.version + if self.version and self.archive: + raise InvalidOptionsError(strings['deploy.archivewithversion']) + self.label = self.app.pargs.label self.process = self.app.pargs.process group_name = self.app.pargs.env_group_suffix @@ -76,9 +99,15 @@ def do_command(self): process_app_versions = fileoperations.env_yaml_exists() or self.process + source_bundle_zip = None + if self.archive: + source_bundle_zip = get_or_create_source_bundle(archive=self.archive, label=self.label) + self.label = self.label or source_bundle_zip.split(path.sep)[-1] + deployops.deploy(self.app_name, self.env_name, self.version, self.label, self.message, group_name=group_name, process_app_versions=process_app_versions, - staged=self.staged, timeout=self.timeout, source=self.source) + staged=self.staged, timeout=self.timeout, source=self.source, + source_bundle=source_bundle_zip) def multiple_app_deploy(self): missing_env_yaml = [] @@ -181,3 +210,25 @@ def compose_deploy(self): def _check_env_lifecycle_state(env_name): env = elasticbeanstalk.get_environment(env_name=env_name) statusops.alert_environment_status(env) + + +def get_or_create_source_bundle(archive: str, label: str=None) -> Optional[str]: + if archive and zipfile.is_zipfile(archive): + source_bundle_zip = archive + elif archive and path.isdir(archive): + upload_target_dir = archive + utcnow = str(datetime.datetime.now(datetime.UTC).timestamp()) + migrations_path = path.join(path.expanduser('~'), '.ebartifacts') + if label: + zip_file_name = f"{label}.zip" + else: + zip_file_name = f"archives-{utcnow}.zip" + source_bundle_zip = path.join(migrations_path, 'archives', zip_file_name) + makedirs(path.join(migrations_path, 'archives'), exist_ok=True) + fileoperations.zip_up_folder( + upload_target_dir, + source_bundle_zip + ) + else: + raise InvalidOptionsError(strings['deploy.archive_must_be_dir_or_zip']) + return source_bundle_zip diff --git a/ebcli/lib/s3.py b/ebcli/lib/s3.py index 931a2cf8f..a8e5def30 100644 --- a/ebcli/lib/s3.py +++ b/ebcli/lib/s3.py @@ -115,10 +115,11 @@ def delete_objects(bucket, keys): return result -def upload_workspace_version(bucket, key, file_path, workspace_type='Application'): +def upload_workspace_version(bucket, key, file_path, workspace_type='Application', relative_to_project_root=True): cwd = os.getcwd() try: - fileoperations.ProjectRoot.traverse() + if relative_to_project_root: + fileoperations.ProjectRoot.traverse() size = os.path.getsize(file_path) except OSError as err: if err.errno == 2: @@ -144,8 +145,8 @@ def upload_workspace_version(bucket, key, file_path, workspace_type='Application return result -def upload_application_version(bucket, key, file_path): - upload_workspace_version(bucket, key, file_path, 'Application') +def upload_application_version(bucket, key, file_path, relative_to_project_root=True): + upload_workspace_version(bucket, key, file_path, 'Application', relative_to_project_root=relative_to_project_root) def upload_platform_version(bucket, key, file_path): diff --git a/ebcli/operations/commonops.py b/ebcli/operations/commonops.py index 987dc655c..40bcabf40 100644 --- a/ebcli/operations/commonops.py +++ b/ebcli/operations/commonops.py @@ -15,6 +15,7 @@ import time from datetime import datetime, timedelta import platform +import zipfile from ebcli.core.fileoperations import _marker @@ -486,8 +487,9 @@ def create_dummy_app_version(app_name): None, None, warning=False) -def create_app_version(app_name, process=False, label=None, message=None, staged=False, build_config=None): +def create_app_version(app_name, process=False, label=None, message=None, staged=False, build_config=None, source_bundle=None): cwd = os.getcwd() + fileoperations.ProjectRoot.traverse() try: if heuristics.directory_is_empty(): @@ -514,7 +516,6 @@ def create_app_version(app_name, process=False, label=None, message=None, staged if len(description) > 200: description = description[:195] + '...' - artifact = fileoperations.get_config_setting('deploy', 'artifact') if artifact: file_name, file_extension = os.path.splitext(artifact) @@ -525,16 +526,41 @@ def create_app_version(app_name, process=False, label=None, message=None, staged else: s3_bucket, s3_key = get_app_version_s3_location(app_name, version_label) + file_name, file_path = None, None if s3_bucket is None and s3_key is None: - file_name, file_path = _zip_up_project( - version_label, source_control, staged=staged) - else: - file_name = None - file_path = None + if not source_bundle: + file_name, file_path = _zip_up_project( + version_label, source_control, staged=staged) + elif zipfile.is_zipfile(source_bundle): + file_name, file_path = label, source_bundle + + return handle_upload_target(app_name, + s3_bucket, + s3_key, + file_name, + file_path, + version_label, + description, + process, + build_config, + ) + +def handle_upload_target( + app_name, + s3_bucket, + s3_key, + file_name, + file_path, + version_label, + description, + process, + build_config, + relative_to_project_root=True +): bucket = elasticbeanstalk.get_storage_location() if s3_bucket is None else s3_bucket - key = app_name + '/' + file_name if s3_key is None else s3_key + key = app_name + '/' + file_name if s3_key is None else s3_key try: s3.get_object_info(bucket, key) io.log_info('S3 Object already exists. Skipping upload.') @@ -544,12 +570,17 @@ def create_app_version(app_name, process=False, label=None, message=None, staged ' Try uploading the Application Version again.') io.log_info('Uploading archive to s3 location: ' + key) - s3.upload_application_version(bucket, key, file_path) + if relative_to_project_root: + s3.upload_application_version(bucket, key, file_path) + else: + s3.upload_application_version(bucket, key, file_path, relative_to_project_root=False) - fileoperations.delete_app_versions() + if not relative_to_project_root: + fileoperations.delete_app_versions() io.log_info('Creating AppVersion ' + version_label) return _create_application_version(app_name, version_label, description, - bucket, key, process, build_config=build_config) + bucket, key, process, build_config=build_config, + relative_to_project_root=relative_to_project_root) def create_codecommit_app_version(app_name, process=False, label=None, message=None, build_config=None): @@ -672,7 +703,7 @@ def create_app_version_from_source( def _create_application_version(app_name, version_label, description, bucket, key, process=False, warning=True, repository=None, commit_id=None, - build_config=None): + build_config=None, relative_to_project_root=True): """ A wrapper around elasticbeanstalk.create_application_version that handles certain error cases: @@ -680,7 +711,7 @@ def _create_application_version(app_name, version_label, description, * version already exists * validates BuildSpec files for CodeBuild """ - if build_config is not None: + if relative_to_project_root and build_config is not None: buildspecops.validate_build_config(build_config) while True: try: @@ -725,6 +756,15 @@ def _zip_up_project(version_label, source_control, staged=False): return file_name, file_path +def _zip_up_project_at_location(version_label, upload_target_dir, zip_output_path): + file_name = version_label + '.zip' + fileoperations.zip_up_folder( + upload_target_dir, + zip_output_path, + ) + return file_name, zip_output_path + + def update_environment(env_name, changes, nohang, remove=None, template=None, timeout=None, template_body=None, solution_stack_name=None, platform_arn=None): diff --git a/ebcli/operations/deployops.py b/ebcli/operations/deployops.py index afcbe2fa7..fb548d624 100644 --- a/ebcli/operations/deployops.py +++ b/ebcli/operations/deployops.py @@ -21,13 +21,28 @@ def deploy(app_name, env_name, version, label, message, group_name=None, - process_app_versions=False, staged=False, timeout=5, source=None): + process_app_versions=False, staged=False, timeout=5, source=None, + source_bundle=None): region_name = aws.get_region_name() - build_config = None - if fileoperations.build_spec_exists() and version is None: - build_config = fileoperations.get_build_configuration() - LOG.debug("Retrieved build configuration from buildspec: {0}".format(build_config.__str__())) + if source_bundle: + file_name, file_path = label, source_bundle + version = commonops.handle_upload_target( + app_name, + None, + None, + file_name, + file_path, + label, + message, + process_app_versions, + build_config, + relative_to_project_root=False + ) + else: + if fileoperations.build_spec_exists() and version is None: + build_config = fileoperations.get_build_configuration() + LOG.debug("Retrieved build configuration from buildspec: {0}".format(build_config.__str__())) io.log_info('Deploying code to ' + env_name + " in region " + region_name) @@ -59,7 +74,7 @@ def deploy(app_name, env_name, version, label, message, group_name=None, build_config=build_config ) - if build_config is not None: + if not source_bundle and build_config is not None: buildspecops.stream_build_configuration_app_version_creation( app_name, app_version_label, build_config) elif process_app_versions is True: diff --git a/ebcli/resources/strings.py b/ebcli/resources/strings.py index 3f63df81e..6f9694869 100644 --- a/ebcli/resources/strings.py +++ b/ebcli/resources/strings.py @@ -260,8 +260,7 @@ 'Tags may only contain letters, numbers, and the following ' 'symbols: / _ . : + = - @', 'tags.max': 'Elastic Beanstalk supports a maximum of 50 tags.', - 'deploy.invalidoptions': 'You cannot use the "--version" option with either the "--message" ' - 'or "--label" option.', + 'init.getvarsfromoldeb': 'You previous used an earlier version of eb. Getting options from ' '.elasticbeanstalk/config.\n' 'Credentials will now be stored in ~/.aws/config', @@ -324,12 +323,22 @@ 'region.china.credentials': 'To use the China (Beijing) region, account credentials unique to the ' 'China (Beijing) region must be used.', + 'deploy.notadirectory': 'The directory {module} does not exist.', 'deploy.modulemissingenvyaml': 'All specified modules require an env.yaml file.\n' 'The following modules are missing this file: {modules}', 'deploy.noenvname': 'No environment name was specified in env.yaml for module {module}. Unable to deploy.', + 'deploy.invalidoptions': 'You cannot use the "--version" option with either the "--message" ' + 'or "--label" option.', + 'deploy.archivewithoutregion': 'You cannot use the "--archive" option without the "--region" option.', + 'deploy.archivewithoutenvname': 'You cannot use the "--archive" option without the environment name.', + 'deploy.archivewithversion': 'You cannot use the "--archive" option with the "--version" option for environment updates. ' + 'These are mutually exclusive methods for specifying application code.', + 'deploy.archivewithsource': 'You cannot use the "--archive" option with the "--source" option for environment updates. ' + 'These are mutually exclusive methods for specifying application code.', + 'deploy.archive_must_be_dir_or_zip': 'The "--archive" option requires a directory or ZIP file as an argument.', 'compose.noenvyaml': 'The module {module} does not contain an env.yaml file. This module will be skipped.', 'compose.novalidmodules': 'No valid modules were found. No environments will be created.', @@ -814,6 +823,7 @@ 'deploy.group_suffix': 'group suffix', 'deploy.source': 'source of code to deploy directly; example source_location/repo/branch', 'deploy.process': 'enable preprocessing of the application version', + 'deploy.archive': 'directory or ZIP file containing application version source code', 'platformevents.version': 'version to retrieve events for', 'events.follow': 'wait and continue to print events as they come', diff --git a/tests/unit/controllers/test_deploy.py b/tests/unit/controllers/test_deploy.py index 8f0d711c5..19f0b328e 100644 --- a/tests/unit/controllers/test_deploy.py +++ b/tests/unit/controllers/test_deploy.py @@ -12,6 +12,8 @@ # language governing permissions and limitations under the License. import os import shutil +import zipfile +from unittest.mock import MagicMock import mock import unittest @@ -21,6 +23,7 @@ from ebcli.core import fileoperations from ebcli.objects.environment import Environment from ebcli.objects.platform import PlatformVersion +from ebcli.objects.exceptions import InvalidOptionsError, NotInitializedError class TestDeploy(unittest.TestCase): @@ -142,7 +145,8 @@ def test_deploy( process_app_versions=False, source=None, staged=False, - timeout=None + timeout=None, + source_bundle=None ) @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') @@ -174,7 +178,8 @@ def test_deploy__nohang_sets_timeout_to_zero( process_app_versions=False, source=None, staged=False, - timeout=0 + timeout=0, + source_bundle=None ) @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') @@ -204,9 +209,10 @@ def test_deploy__with_version_label( None, group_name=None, process_app_versions=False, - source=None, staged=False, - timeout=None + timeout=None, + source=None, + source_bundle=None ) @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') @@ -242,9 +248,10 @@ def test_deploy__with_label_and_message( 'This is my message', group_name=None, process_app_versions=False, - source=None, staged=False, - timeout=None + timeout=None, + source=None, + source_bundle=None ) @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') @@ -283,7 +290,8 @@ def test_deploy__process_app_version_because_env_yaml_exists( process_app_versions=True, source=None, staged=False, - timeout=None + timeout=None, + source_bundle=None ) @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') @@ -320,9 +328,10 @@ def test_deploy__process_app_version_because_process_flag_is_specified( 'This is my message', group_name=None, process_app_versions=True, - source=None, staged=False, - timeout=None + timeout=None, + source=None, + source_bundle=None ) @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') @@ -359,7 +368,8 @@ def test_deploy__pass_group_name( process_app_versions=False, source=None, staged=False, - timeout=None + timeout=None, + source_bundle=None ) @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') @@ -394,9 +404,10 @@ def test_deploy__specify_codecommit_source( None, group_name=None, process_app_versions=False, - source='codecommit/my-repository/my-branch', staged=False, - timeout=None + timeout=None, + source='codecommit/my-repository/my-branch', + source_bundle=None ) @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') @@ -431,9 +442,10 @@ def test_deploy__specify_codecommit_source_with_forward_slash_in_branch_name( None, group_name=None, process_app_versions=False, - source='codecommit/my-repository/my-branch/feature', staged=False, - timeout=None + timeout=None, + source='codecommit/my-repository/my-branch/feature', + source_bundle=None ) @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') @@ -471,11 +483,81 @@ def test_deploy__indicate_staged_changes_must_be_used( process_app_versions=True, source=None, staged=True, - timeout=None + timeout=None, + source_bundle=None ) + @mock.patch('ebcli.controllers.deploy.elasticbeanstalk.get_environment') + @mock.patch('ebcli.controllers.deploy._check_env_lifecycle_state') + @mock.patch('ebcli.controllers.deploy.deployops.deploy') + @mock.patch('ebcli.controllers.deploy.get_or_create_source_bundle') + def test_deploy_with_archive( + self, + get_source_bundle_mock, + deploy_mock, + _check_env_lifecycle_state_mock, + get_environment_mock, + ): + get_source_bundle_mock.return_value = os.path.join('path', 'to', 'generated', 'archive.zip') + environment_mock = MagicMock() + environment_mock.app_name = "my-application" + get_environment_mock.return_value = environment_mock + + app = EB( + argv=[ + 'deploy', + 'environment-1', + '--archive', 'my-source-directory', + '--region', 'us-east-1' + ] + ) + app.setup() + app.run() + + _check_env_lifecycle_state_mock.assert_called_once_with('environment-1') + get_source_bundle_mock.assert_called_once_with(archive='my-source-directory', label=None) + deploy_mock.assert_called_with( + 'my-application', + 'environment-1', + None, + 'archive.zip', + None, + group_name=None, + process_app_versions=False, + source=None, + staged=False, + timeout=None, + source_bundle=os.path.join('path', 'to', 'generated', 'archive.zip') + ) + + def test_deploy_with_archive__fails_without_environment_name(self): + app = EB( + argv=[ + 'deploy', + '--archive', 'my-source-directory', + '--region', 'us-east-1' + ] + ) + app.setup() + try: + app.run() + except InvalidOptionsError: + pass + + def test_deploy_with_archive__fails_without_region_name(self): + app = EB( + argv=[ + 'deploy', + 'environment-1', + '--archive', 'my-source-directory', + ] + ) + app.setup() + try: + app.run() + except InvalidOptionsError: + pass -class TestMultipleAppDeploy(unittest.TestCase): platform = PlatformVersion( 'arn:aws:elasticbeanstalk:us-west-2::platform/PHP 7.1 running on 64bit Amazon Linux/2.6.5' ) @@ -491,6 +573,198 @@ def tearDown(self): os.chdir(self.root_dir) shutil.rmtree('testDir') + +class TestGetOrCreateSourceBundle(unittest.TestCase): + platform = PlatformVersion( + 'arn:aws:elasticbeanstalk:us-west-2::platform/PHP 7.1 running on 64bit Amazon Linux/2.6.5' + ) + + def setUp(self): + self.root_dir = os.getcwd() + if not os.path.exists('testDir'): + os.mkdir('testDir') + + os.chdir('testDir') + + # Create test files and directories + if not os.path.exists('source_dir'): + os.mkdir('source_dir') + + with open(os.path.join('source_dir', 'test_file.txt'), 'w') as f: + f.write('test content') + + # Create a test zip file + self.test_zip_path = os.path.join(os.getcwd(), 'test_archive.zip') + with zipfile.ZipFile(self.test_zip_path, 'w') as test_zip: + test_zip.writestr('test_file.txt', 'test content') + + def tearDown(self): + os.chdir(self.root_dir) + shutil.rmtree('testDir') + + def create_config_file_in(self, path): + original_dir = os.getcwd() + os.chdir(path) + fileoperations.create_config_file( + 'my-application', + 'us-west-2', + self.platform.name + ) + os.chdir(original_dir) + + @mock.patch('ebcli.controllers.deploy.zipfile.is_zipfile') + def test_get_or_create_source_bundle_with_zip_file(self, is_zipfile_mock): + # Setup + is_zipfile_mock.return_value = True + archive_path = 'test_archive.zip' + + # Execute + result = deploy.get_or_create_source_bundle(archive=archive_path) + + # Verify + is_zipfile_mock.assert_called_once_with(archive_path) + self.assertEqual(archive_path, result) + + @mock.patch('ebcli.controllers.deploy.zipfile.is_zipfile') + @mock.patch('ebcli.controllers.deploy.path.isdir') + def test_get_or_create_source_bundle_with_invalid_input(self, isdir_mock, is_zipfile_mock): + # Setup + is_zipfile_mock.return_value = False + isdir_mock.return_value = False + archive_path = 'non_existent_path' + + # Execute and verify + with self.assertRaises(InvalidOptionsError) as context: + deploy.get_or_create_source_bundle(archive=archive_path) + + self.assertIn( + 'The "--archive" option requires a directory or ZIP file as an argument.', + str(context.exception) + ) + is_zipfile_mock.assert_called_once_with(archive_path) + isdir_mock.assert_called_once_with(archive_path) + + @mock.patch('ebcli.controllers.deploy.zipfile.is_zipfile') + @mock.patch('ebcli.controllers.deploy.path.isdir') + @mock.patch('ebcli.controllers.deploy.datetime') + @mock.patch('ebcli.controllers.deploy.fileoperations.zip_up_folder') + @mock.patch('ebcli.controllers.deploy.makedirs') + @mock.patch('ebcli.controllers.deploy.path.expanduser') + def test_get_or_create_source_bundle_with_directory( + self, + expanduser_mock, + makedirs_mock, + zip_up_folder_mock, + datetime_mock, + isdir_mock, + is_zipfile_mock + ): + # Setup + is_zipfile_mock.return_value = False + isdir_mock.return_value = True + expanduser_mock.return_value = '/home/user' + + # Mock datetime to return a fixed timestamp + datetime_mock.datetime.now.return_value.timestamp.return_value = '1712175524.123456' + datetime_mock.UTC = mock.MagicMock() + + archive_path = 'source_dir' + expected_zip_path = os.path.join( + '/home/user', + '.ebartifacts', + 'archives', + 'archives-1712175524.123456.zip' + ) + + # Execute + result = deploy.get_or_create_source_bundle(archive=archive_path) + + # Verify + is_zipfile_mock.assert_called_once_with(archive_path) + isdir_mock.assert_called_once_with(archive_path) + makedirs_mock.assert_called_once_with( + os.path.join('/home/user', '.ebartifacts', 'archives'), + exist_ok=True + ) + zip_up_folder_mock.assert_called_once_with(archive_path, expected_zip_path) + self.assertEqual(expected_zip_path, result) + + @mock.patch('ebcli.controllers.deploy.zipfile.is_zipfile') + @mock.patch('ebcli.controllers.deploy.path.isdir') + @mock.patch('ebcli.controllers.deploy.datetime') + @mock.patch('ebcli.controllers.deploy.fileoperations.zip_up_folder') + @mock.patch('ebcli.controllers.deploy.makedirs') + @mock.patch('ebcli.controllers.deploy.path.expanduser') + def test_get_or_create_source_bundle_with_directory_and_label( + self, + expanduser_mock, + makedirs_mock, + zip_up_folder_mock, + datetime_mock, + isdir_mock, + is_zipfile_mock + ): + # Setup + is_zipfile_mock.return_value = False + isdir_mock.return_value = True + expanduser_mock.return_value = '/home/user' + + archive_path = 'source_dir' + label = 'custom-label' + expected_zip_path = os.path.join( + '/home/user', + '.ebartifacts', + 'archives', + 'custom-label.zip' + ) + + # Execute + result = deploy.get_or_create_source_bundle(archive=archive_path, label=label) + + # Verify + is_zipfile_mock.assert_called_once_with(archive_path) + isdir_mock.assert_called_once_with(archive_path) + makedirs_mock.assert_called_once_with( + os.path.join('/home/user', '.ebartifacts', 'archives'), + exist_ok=True + ) + zip_up_folder_mock.assert_called_once_with(archive_path, expected_zip_path) + self.assertEqual(expected_zip_path, result) + + @mock.patch('ebcli.controllers.deploy.zipfile.is_zipfile') + @mock.patch('ebcli.controllers.deploy.path.isdir') + @mock.patch('ebcli.controllers.deploy.datetime') + @mock.patch('ebcli.controllers.deploy.fileoperations.zip_up_folder') + @mock.patch('ebcli.controllers.deploy.makedirs') + def test_get_source_bundle_from_archive_creates_directories( + self, + makedirs_mock, + zip_up_folder_mock, + datetime_mock, + isdir_mock, + is_zipfile_mock + ): + # Setup + is_zipfile_mock.return_value = False + isdir_mock.return_value = True + + # Mock datetime to return a fixed timestamp + mock_datetime = mock.MagicMock() + mock_datetime.now.return_value.timestamp.return_value = '1712175524.123456' + datetime_mock.now.return_value = mock_datetime + datetime_mock.UTC = mock.MagicMock() + + archive_path = 'source_dir' + + # Execute + deploy.get_or_create_source_bundle(archive=archive_path) + + # Verify + makedirs_mock.assert_called_once_with( + os.path.join(os.path.expanduser('~'), '.ebartifacts', 'archives'), + exist_ok=True + ) + @mock.patch('ebcli.controllers.deploy.io.log_error') def test_multiple_modules__none_of_the_specified_modules_actually_exists( self, diff --git a/tests/unit/integration/test_deploy.py b/tests/unit/integration/test_deploy.py new file mode 100644 index 000000000..d40221c9b --- /dev/null +++ b/tests/unit/integration/test_deploy.py @@ -0,0 +1,848 @@ +# Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import os +import shutil +import subprocess +import sys +import unittest + +import mock + +from ebcli.core.ebcore import EB +from ebcli.core import fileoperations +from ebcli.operations import commonops +from ebcli.objects.exceptions import InvalidOptionsError, NotFoundError + +from .. import mock_responses + + +class TestDeploy(unittest.TestCase): + def setUp(self): + self.root_dir = os.getcwd() + self._delete_testDir_if_exists() + os.mkdir("testDir") + os.chdir("testDir") + + # Create a basic .elasticbeanstalk/config.yml file + fileoperations.create_config_file("my-application", "us-west-2", "php-7.2") + commonops.set_environment_for_current_branch("environment-1") + + # Create a sample application file + with open("app.py", "w") as f: + f.write('print("Hello, Elastic Beanstalk!")') + + def tearDown(self): + os.chdir(self.root_dir) + self._delete_testDir_if_exists() + + def _delete_testDir_if_exists(self): + if os.path.exists("testDir"): + if sys.platform == "win32": + subprocess.run(['rd', '/s', '/q', 'testDir'], shell=True) + else: + shutil.rmtree("testDir") + + def setup_git_repo(self): + """Set up a basic Git repository for testing - only call this in tests that need Git""" + os.system("git init") + os.system('git config user.email "test@example.com"') + os.system('git config user.name "Test User"') + + # Create initial branch (will be 'main' or 'master' depending on git version) + branch_name = self._get_default_branch_name() + + # Make sure the branch name in .elasticbeanstalk/config.yml matches the git branch + self._update_config_with_branch_name(branch_name) + + # Add and commit a file + os.system("git add app.py") + os.system('git commit -m "Initial commit"') + + def _get_default_branch_name(self): + """Get the default branch name from the git repository""" + # Execute git command to get the current branch name + import subprocess + + try: + result = subprocess.check_output( + ["git", "branch", "--show-current"], + stderr=subprocess.STDOUT, + universal_newlines=True, + ).strip() + if result: + return result + # Older git versions might not support --show-current + result = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + stderr=subprocess.STDOUT, + universal_newlines=True, + ).strip() + return result + except (subprocess.CalledProcessError, OSError): + # Default to 'main' if we can't determine the branch name + return "main" + + def _update_config_with_branch_name(self, branch_name): + fileoperations.write_config_setting( + "branch-defaults", branch_name, {"environment": "environment-1"} + ) + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.application_version_exists" + ) + @mock.patch("ebcli.operations.commonops.elasticbeanstalk.get_storage_location") + @mock.patch("ebcli.operations.commonops.s3.get_object_info") + @mock.patch("ebcli.operations.commonops.s3.upload_application_version") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.create_application_version" + ) + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + def test_deploy_basic( + self, + _check_env_lifecycle_state_mock, + create_application_version_mock, + upload_application_version_mock, + get_object_info_mock, + get_storage_location_mock, + application_version_exists_mock, + wait_for_success_events_mock, + update_env_mock, + ): + """Test basic deployment with no additional options""" + # Set up mocks + update_env_mock.return_value = "request-id" + application_version_exists_mock.return_value = None + get_storage_location_mock.return_value = "my-s3-bucket-location" + get_object_info_mock.side_effect = NotFoundError + + # Run command + app = EB(argv=["deploy"]) + app.setup() + app.run() + + # Verify + update_env_call_args = update_env_mock.call_args[0] + assert update_env_call_args[0] == "environment-1" + # app-250412_014106958315 or something like that + assert update_env_call_args[1].startswith("app-") + assert update_env_call_args[2] is None + upload_application_version_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + os.path.join( + os.getcwd(), + ".elasticbeanstalk", + "app_versions", + f"{update_env_call_args[1]}.zip", + ), + ) + get_object_info_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + ) + get_storage_location_mock.assert_called_once_with() + create_application_version_mock.assert_called_once_with( + "my-application", + update_env_call_args[1], + "EB-CLI deploy", + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + False, + None, + None, + None, + ) + wait_for_success_events_mock.assert_called_once_with( + "request-id", + timeout_in_minutes=None, + can_abort=True, + env_name="environment-1", + ) + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.application_version_exists" + ) + @mock.patch("ebcli.operations.commonops.elasticbeanstalk.get_storage_location") + @mock.patch("ebcli.operations.commonops.s3.get_object_info") + @mock.patch("ebcli.operations.commonops.s3.upload_application_version") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.create_application_version" + ) + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + def test_deploy_combined_options( + self, + _check_env_lifecycle_state_mock, + create_application_version_mock, + upload_application_version_mock, + get_object_info_mock, + get_storage_location_mock, + application_version_exists_mock, + wait_for_success_events_mock, + update_env_mock, + ): + """Test deploying with multiple options combined (environment, label, message, nohang)""" + # Set up mocks + update_env_mock.return_value = "request-id" + application_version_exists_mock.return_value = None + get_storage_location_mock.return_value = "my-s3-bucket-location" + get_object_info_mock.side_effect = NotFoundError + + # Run command with all options combined + app = EB( + argv=[ + "deploy", + "environment-2", + "--label", + "custom-label", + "--message", + "Test deployment message", + "--nohang", + ] + ) + app.setup() + app.run() + + # Verify + # Should use the specified environment + update_env_mock.assert_called_once_with("environment-2", "custom-label", None) + + # Should use the custom label + upload_application_version_mock.assert_called_once_with( + "my-s3-bucket-location", + "my-application/custom-label.zip", + os.path.join( + os.getcwd(), ".elasticbeanstalk", "app_versions", "custom-label.zip" + ), + ) + + get_object_info_mock.assert_called_once_with( + "my-s3-bucket-location", + "my-application/custom-label.zip", + ) + + get_storage_location_mock.assert_called_once_with() + + # Should use the custom message + create_application_version_mock.assert_called_once_with( + "my-application", + "custom-label", + "Test deployment message", + "my-s3-bucket-location", + "my-application/custom-label.zip", + False, + None, + None, + None, + ) + + # Should use nohang (timeout=0) + wait_for_success_events_mock.assert_called_once_with( + "request-id", timeout_in_minutes=0, can_abort=True, env_name="environment-2" + ) + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.application_version_exists" + ) + @mock.patch("ebcli.operations.commonops.elasticbeanstalk.get_storage_location") + @mock.patch("ebcli.operations.commonops.s3.get_object_info") + @mock.patch("ebcli.operations.commonops.s3.upload_application_version") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.create_application_version" + ) + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + def test_deploy_with_label( + self, + _check_env_lifecycle_state_mock, + create_application_version_mock, + upload_application_version_mock, + get_object_info_mock, + get_storage_location_mock, + application_version_exists_mock, + wait_for_success_events_mock, + update_env_mock, + ): + """Test deploying with a custom version label""" + # Set up mocks + update_env_mock.return_value = "request-id" + application_version_exists_mock.return_value = None + get_storage_location_mock.return_value = "my-s3-bucket-location" + get_object_info_mock.side_effect = NotFoundError + + # Run command + app = EB(argv=["deploy", "--label", "custom-label"]) + app.setup() + app.run() + + # Verify + update_env_mock.assert_called_once_with("environment-1", "custom-label", None) + upload_application_version_mock.assert_called_once_with( + "my-s3-bucket-location", + "my-application/custom-label.zip", + os.path.join( + os.getcwd(), ".elasticbeanstalk", "app_versions", "custom-label.zip" + ), + ) + get_object_info_mock.assert_called_once_with( + "my-s3-bucket-location", + "my-application/custom-label.zip", + ) + get_storage_location_mock.assert_called_once_with() + create_application_version_mock.assert_called_once_with( + "my-application", + "custom-label", + "EB-CLI deploy", + "my-s3-bucket-location", + "my-application/custom-label.zip", + False, + None, + None, + None, + ) + wait_for_success_events_mock.assert_called_once_with( + "request-id", + timeout_in_minutes=None, + can_abort=True, + env_name="environment-1", + ) + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.application_version_exists" + ) + @mock.patch("ebcli.operations.commonops.elasticbeanstalk.get_storage_location") + @mock.patch("ebcli.operations.commonops.s3.get_object_info") + @mock.patch("ebcli.operations.commonops.s3.upload_application_version") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.create_application_version" + ) + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + def test_deploy_with_message( + self, + _check_env_lifecycle_state_mock, + create_application_version_mock, + upload_application_version_mock, + get_object_info_mock, + get_storage_location_mock, + application_version_exists_mock, + wait_for_success_events_mock, + update_env_mock, + ): + """Test deploying with a custom version message""" + # Set up mocks + update_env_mock.return_value = "request-id" + application_version_exists_mock.return_value = None + get_storage_location_mock.return_value = "my-s3-bucket-location" + get_object_info_mock.side_effect = NotFoundError + + # Run command + app = EB(argv=["deploy", "--message", "Test deployment message"]) + app.setup() + app.run() + + # Verify + update_env_call_args = update_env_mock.call_args[0] + assert update_env_call_args[0] == "environment-1" + assert update_env_call_args[1].startswith("app-") + assert update_env_call_args[2] is None + + upload_application_version_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + os.path.join( + os.getcwd(), + ".elasticbeanstalk", + "app_versions", + f"{update_env_call_args[1]}.zip", + ), + ) + get_object_info_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + ) + get_storage_location_mock.assert_called_once_with() + create_application_version_mock.assert_called_once_with( + "my-application", + update_env_call_args[1], + "Test deployment message", + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + False, + None, + None, + None, + ) + wait_for_success_events_mock.assert_called_once_with( + "request-id", + timeout_in_minutes=None, + can_abort=True, + env_name="environment-1", + ) + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.application_version_exists" + ) + @mock.patch("ebcli.operations.commonops.elasticbeanstalk.get_storage_location") + @mock.patch("ebcli.operations.commonops.s3.get_object_info") + @mock.patch("ebcli.operations.commonops.s3.upload_application_version") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.create_application_version" + ) + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + def test_deploy_with_nohang( + self, + _check_env_lifecycle_state_mock, + create_application_version_mock, + upload_application_version_mock, + get_object_info_mock, + get_storage_location_mock, + application_version_exists_mock, + wait_for_success_events_mock, + update_env_mock, + ): + """Test deploying with the nohang option""" + # Set up mocks + update_env_mock.return_value = "request-id" + application_version_exists_mock.return_value = None + get_storage_location_mock.return_value = "my-s3-bucket-location" + get_object_info_mock.side_effect = NotFoundError + + # Run command + app = EB(argv=["deploy", "--nohang"]) + app.setup() + app.run() + + # Verify + update_env_call_args = update_env_mock.call_args[0] + assert update_env_call_args[0] == "environment-1" + assert update_env_call_args[1].startswith("app-") + assert update_env_call_args[2] is None + + upload_application_version_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + os.path.join( + os.getcwd(), + ".elasticbeanstalk", + "app_versions", + f"{update_env_call_args[1]}.zip", + ), + ) + get_object_info_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + ) + get_storage_location_mock.assert_called_once_with() + create_application_version_mock.assert_called_once() + + # Should not wait for success events (timeout=0) + wait_for_success_events_mock.assert_called_once_with( + "request-id", timeout_in_minutes=0, can_abort=True, env_name="environment-1" + ) + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.application_version_exists" + ) + @mock.patch("ebcli.operations.commonops.elasticbeanstalk.get_storage_location") + @mock.patch("ebcli.operations.commonops.s3.get_object_info") + @mock.patch("ebcli.operations.commonops.s3.upload_application_version") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.create_application_version" + ) + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + def test_deploy_with_timeout( + self, + _check_env_lifecycle_state_mock, + create_application_version_mock, + upload_application_version_mock, + get_object_info_mock, + get_storage_location_mock, + application_version_exists_mock, + wait_for_success_events_mock, + update_env_mock, + ): + """Test deploying with a custom timeout""" + # Set up mocks + update_env_mock.return_value = "request-id" + application_version_exists_mock.return_value = None + get_storage_location_mock.return_value = "my-s3-bucket-location" + get_object_info_mock.side_effect = NotFoundError + + # Run command + app = EB(argv=["deploy", "--timeout", "10"]) + app.setup() + app.run() + + # Verify + update_env_call_args = update_env_mock.call_args[0] + assert update_env_call_args[0] == "environment-1" + assert update_env_call_args[1].startswith("app-") + assert update_env_call_args[2] is None + + upload_application_version_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + os.path.join( + os.getcwd(), + ".elasticbeanstalk", + "app_versions", + f"{update_env_call_args[1]}.zip", + ), + ) + get_object_info_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + ) + get_storage_location_mock.assert_called_once_with() + create_application_version_mock.assert_called_once() + wait_for_success_events_mock.assert_called_once_with( + "request-id", + timeout_in_minutes=10, + can_abort=True, + env_name="environment-1", + ) + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.application_version_exists" + ) + @mock.patch("ebcli.operations.commonops.elasticbeanstalk.get_storage_location") + @mock.patch("ebcli.operations.commonops.s3.get_object_info") + @mock.patch("ebcli.operations.commonops.s3.upload_application_version") + @mock.patch( + "ebcli.operations.commonops.elasticbeanstalk.create_application_version" + ) + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + @mock.patch("ebcli.operations.gitops.git_management_enabled") + @mock.patch("ebcli.operations.commonops.wait_for_processed_app_versions") + def test_deploy_with_staged( + self, + wait_for_processed_app_versions_mock, + git_management_enabled_mock, + _check_env_lifecycle_state_mock, + create_application_version_mock, + upload_application_version_mock, + get_object_info_mock, + get_storage_location_mock, + application_version_exists_mock, + wait_for_success_events_mock, + update_env_mock, + ): + """Test deploying with the staged option""" + # Set up Git repository for this test - required for --staged option + self.setup_git_repo() + + # Make a change to stage + with open("app.py", "a") as f: + f.write('\nprint("Staged change")') + os.system("git add app.py") + + # Set up mocks + git_management_enabled_mock.return_value = ( + False # Ensure we don't use CodeCommit + ) + update_env_mock.return_value = "request-id" + application_version_exists_mock.return_value = None + get_storage_location_mock.return_value = "my-s3-bucket-location" + get_object_info_mock.side_effect = NotFoundError + + # Run command + app = EB(argv=["deploy", "--staged"]) + app.setup() + app.run() + + # Verify + update_env_call_args = update_env_mock.call_args[0] + assert update_env_call_args[0] == "environment-1" + assert update_env_call_args[1].startswith("app-") + assert ( + "-stage-" in update_env_call_args[1] + ) # Staged version should have -stage- in the name + assert update_env_call_args[2] is None + + upload_application_version_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + os.path.join( + os.getcwd(), + ".elasticbeanstalk", + "app_versions", + f"{update_env_call_args[1]}.zip", + ), + ) + get_object_info_mock.assert_called_once_with( + "my-s3-bucket-location", + f"my-application/{update_env_call_args[1]}.zip", + ) + get_storage_location_mock.assert_called_once_with() + create_application_version_mock.assert_called_once() + wait_for_success_events_mock.assert_called_once_with( + "request-id", + timeout_in_minutes=None, + can_abort=True, + env_name="environment-1", + ) + + @mock.patch("ebcli.operations.deployops.commonops.create_app_version_from_source") + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + @mock.patch("ebcli.operations.deployops.commonops.wait_for_processed_app_versions") + def test_deploy_with_source( + self, + wait_for_processed_app_versions_mock, + _check_env_lifecycle_state_mock, + wait_for_success_events_mock, + update_env_mock, + create_app_version_from_source_mock, + ): + """Test deploying with a specific source""" + # Set up mocks + create_app_version_from_source_mock.return_value = "version-label" + update_env_mock.return_value = "request-id" + + # Run command + app = EB(argv=["deploy", "--source", "codecommit/my-repo/my-branch"]) + app.setup() + app.run() + + # Verify + create_app_version_from_source_mock.assert_called_once_with( + "my-application", + "codecommit/my-repo/my-branch", + process=False, + label=None, + message=None, + build_config=None, + ) + update_env_mock.assert_called_once_with("environment-1", "version-label", None) + wait_for_success_events_mock.assert_called_once_with( + "request-id", + timeout_in_minutes=None, + can_abort=True, + env_name="environment-1", + ) + wait_for_processed_app_versions_mock.assert_called_once_with( + "my-application", + ["version-label"], + timeout=5, + ) + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.create_app_version") + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch("ebcli.operations.commonops.wait_for_processed_app_versions") + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + def test_deploy_with_process( + self, + _check_env_lifecycle_state_mock, + wait_for_processed_app_versions_mock, + wait_for_success_events_mock, + create_app_version_mock, + update_env_mock, + ): + """Test deploying with the process option""" + # Set up mocks + create_app_version_mock.return_value = "version-label" + update_env_mock.return_value = "request-id" + wait_for_processed_app_versions_mock.return_value = True + + # Run command + app = EB(argv=["deploy", "--process"]) + app.setup() + app.run() + + # Verify + create_app_version_mock.assert_called_once() + wait_for_processed_app_versions_mock.assert_called_once_with( + "my-application", ["version-label"], timeout=5 + ) + update_env_mock.assert_called_once_with("environment-1", "version-label", None) + # Should process app versions + self.assertTrue(app.pargs.process) + + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + def test_deploy_with_version_and_label(self, _check_env_lifecycle_state_mock): + """Test deploying with incompatible options (version and label)""" + # Run command and expect an exception + app = EB( + argv=["deploy", "--version", "existing-version", "--label", "new-label"] + ) + app.setup() + + with self.assertRaises(InvalidOptionsError): + app.run() + + @mock.patch("ebcli.operations.deployops.fileoperations.build_spec_exists") + @mock.patch("ebcli.operations.deployops.fileoperations.get_build_configuration") + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.create_app_version") + @mock.patch( + "ebcli.operations.buildspecops.stream_build_configuration_app_version_creation" + ) + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + def test_deploy_with_buildspec( + self, + _check_env_lifecycle_state_mock, + wait_for_success_events_mock, + stream_build_mock, + create_app_version_mock, + update_env_mock, + get_build_config_mock, + build_spec_exists_mock, + ): + """Test deploying with a buildspec.yml file""" + # Set up mocks + build_spec_exists_mock.return_value = True + build_config = mock.MagicMock() + get_build_config_mock.return_value = build_config + create_app_version_mock.return_value = "version-label" + update_env_mock.return_value = "request-id" + + # Run command + app = EB(argv=["deploy"]) + app.setup() + app.run() + + # Verify + build_spec_exists_mock.assert_called_once() + get_build_config_mock.assert_called_once() + create_app_version_mock.assert_called_once_with( + "my-application", + process=False, + label=None, + message=None, + staged=False, + build_config=build_config, + ) + stream_build_mock.assert_called_once_with( + "my-application", "version-label", build_config + ) + update_env_mock.assert_called_once_with("environment-1", "version-label", None) + wait_for_success_events_mock.assert_called_once() + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.create_codecommit_app_version") + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + @mock.patch("ebcli.operations.gitops.git_management_enabled") + @mock.patch("ebcli.operations.commonops.wait_for_processed_app_versions") + def test_deploy_with_codecommit( + self, + wait_for_processed_app_versions_mock, + git_management_enabled_mock, + _check_env_lifecycle_state_mock, + wait_for_success_events_mock, + create_codecommit_app_version_mock, + update_env_mock, + ): + """Test deploying with CodeCommit integration""" + # Set up Git repository for this test + self.setup_git_repo() + + # Set up mocks + git_management_enabled_mock.return_value = True # Enable CodeCommit + create_codecommit_app_version_mock.return_value = "version-label" + update_env_mock.return_value = "request-id" + wait_for_processed_app_versions_mock.return_value = True + + # Run command + app = EB(argv=["deploy"]) + app.setup() + app.run() + + # Verify + create_codecommit_app_version_mock.assert_called_once_with( + "my-application", process=False, label=None, message=None, build_config=None + ) + update_env_mock.assert_called_once_with("environment-1", "version-label", None) + wait_for_success_events_mock.assert_called_once_with( + "request-id", + timeout_in_minutes=None, + can_abort=True, + env_name="environment-1", + ) + wait_for_processed_app_versions_mock.assert_called_once_with( + "my-application", ["version-label"], timeout=5 + ) + + @mock.patch( + "ebcli.operations.deployops.elasticbeanstalk.update_env_application_version" + ) + @mock.patch("ebcli.operations.commonops.create_app_version") + @mock.patch("ebcli.operations.commonops.wait_for_success_events") + @mock.patch("ebcli.controllers.deploy._check_env_lifecycle_state") + @mock.patch("ebcli.core.fileoperations.env_yaml_exists") + @mock.patch("ebcli.operations.commonops.wait_for_processed_app_versions") + def test_deploy_with_env_yaml( + self, + wait_for_processed_app_versions_mock, + env_yaml_exists_mock, + _check_env_lifecycle_state_mock, + wait_for_success_events_mock, + create_app_version_mock, + update_env_mock, + ): + """Test deploying with env.yaml file present""" + # Set up mocks + env_yaml_exists_mock.return_value = True + create_app_version_mock.return_value = "version-label" + update_env_mock.return_value = "request-id" + wait_for_processed_app_versions_mock.return_value = True + + # Run command + app = EB(argv=["deploy"]) + app.setup() + app.run() + + # Verify + create_app_version_mock.assert_called_once() + wait_for_processed_app_versions_mock.assert_called_once_with( + "my-application", ["version-label"], timeout=5 + ) + update_env_mock.assert_called_once_with("environment-1", "version-label", None) + wait_for_success_events_mock.assert_called_once() diff --git a/tests/unit/lib/test_s3.py b/tests/unit/lib/test_s3.py index 334ba5790..7263cac7c 100644 --- a/tests/unit/lib/test_s3.py +++ b/tests/unit/lib/test_s3.py @@ -332,7 +332,7 @@ def test_upload_application_version( ): s3.upload_application_version('bucket', 'key', 'file/path.py') - upload_workspace_version_mock.assert_called_once_with('bucket', 'key', 'file/path.py', 'Application') + upload_workspace_version_mock.assert_called_once_with('bucket', 'key', 'file/path.py', 'Application', relative_to_project_root=True) @mock.patch('ebcli.lib.s3.upload_workspace_version') def test_upload_platform_version( diff --git a/tests/unit/lib/test_s3_upload_workspace.py b/tests/unit/lib/test_s3_upload_workspace.py new file mode 100644 index 000000000..35e2935b0 --- /dev/null +++ b/tests/unit/lib/test_s3_upload_workspace.py @@ -0,0 +1,93 @@ +import os +import shutil +import unittest +import mock + +from ebcli.core import fileoperations +from ebcli.lib import s3 + + +class TestUploadWorkspaceVersion(unittest.TestCase): + def setUp(self): + self.root_dir = os.getcwd() + if os.path.exists('testDir'): + shutil.rmtree('testDir') + os.mkdir('testDir') + os.chdir('testDir') + + def tearDown(self): + os.chdir(self.root_dir) + shutil.rmtree('testDir') + + @mock.patch('ebcli.lib.s3.aws.make_api_call') + @mock.patch('ebcli.lib.s3.os.path.getsize') + @mock.patch('ebcli.lib.s3.simple_upload') + def test_upload_workspace_version_with_relative_to_project_root_false( + self, + mock_simple_upload, + mock_getsize, + mock_make_api_call + ): + # Setup + fileoperations.create_config_file( + 'my-application', + 'us-west-2', + 'php-7.1' + ) + mock_getsize.return_value = 7340031 # Small enough for simple upload + mock_simple_upload.return_value = 'upload_result' + + # Create a test file + with open('test_file.txt', 'w') as f: + f.write('test content') + + # Call the function with relative_to_project_root=False + result = s3.upload_workspace_version( + 'bucket', + 'key', + 'test_file.txt', + workspace_type='Application', + relative_to_project_root=False + ) + + # Verify + self.assertEqual('upload_result', result) + self.assertEqual(os.getcwd(), os.path.join(self.root_dir, 'testDir')) # Should not change directory + mock_simple_upload.assert_called_once_with('bucket', 'key', 'test_file.txt') + + @mock.patch('ebcli.lib.s3.aws.make_api_call') + @mock.patch('ebcli.lib.s3.os.path.getsize') + @mock.patch('ebcli.lib.s3.simple_upload') + @mock.patch('ebcli.lib.s3.fileoperations.ProjectRoot.traverse') + def test_upload_workspace_version_with_relative_to_project_root_true( + self, + mock_traverse, + mock_simple_upload, + mock_getsize, + mock_make_api_call + ): + # Setup + fileoperations.create_config_file( + 'my-application', + 'us-west-2', + 'php-7.1' + ) + mock_getsize.return_value = 7340031 # Small enough for simple upload + mock_simple_upload.return_value = 'upload_result' + + # Create a test file + with open('test_file.txt', 'w') as f: + f.write('test content') + + # Call the function with default relative_to_project_root=True + result = s3.upload_workspace_version( + 'bucket', + 'key', + 'test_file.txt', + workspace_type='Application' + ) + + # Verify + self.assertEqual('upload_result', result) + mock_traverse.assert_called() + mock_simple_upload.assert_called_once_with('bucket', 'key', 'test_file.txt') diff --git a/tests/unit/operations/test_commonops.py b/tests/unit/operations/test_commonops.py index 2b19a9f62..6b441143c 100644 --- a/tests/unit/operations/test_commonops.py +++ b/tests/unit/operations/test_commonops.py @@ -15,6 +15,8 @@ from datetime import datetime, timedelta import os import shutil +import sys +import subprocess from dateutil import tz import mock @@ -68,8 +70,8 @@ class TestCommonOperations(unittest.TestCase): def setUp(self): self.root = os.getcwd() - if not os.path.exists('testDir'): - os.makedirs('testDir') + self._delete_testDir_if_exists() + os.makedirs('testDir') os.chdir('testDir') if not os.path.exists(fileoperations.beanstalk_directory): @@ -87,7 +89,14 @@ def setUp(self): def tearDown(self): os.chdir(self.root) - shutil.rmtree('testDir') + self._delete_testDir_if_exists() + + def _delete_testDir_if_exists(self): + if os.path.exists("testDir"): + if sys.platform == "win32": + subprocess.run(['rd', '/s', '/q', 'testDir'], shell=True) + else: + shutil.rmtree("testDir") def test_is_success_event(self): self.assertTrue(commonops._is_success_event('Environment health has been set to GREEN')) @@ -1077,7 +1086,8 @@ def test_create_app_version__app_version_already_exists_in_s3( 's3-bucket', 's3-key', False, - build_config=None + build_config=None, + relative_to_project_root=True, ) @mock.patch('ebcli.operations.commonops.fileoperations.ProjectRoot.traverse') @@ -1138,7 +1148,8 @@ def test_create_app_version__app_version_does_not_exist( 's3-bucket', 'my-application/version-label', False, - build_config=None + build_config=None, + relative_to_project_root=True, ) @mock.patch('ebcli.operations.commonops.fileoperations.ProjectRoot.traverse') @@ -1194,7 +1205,8 @@ def test_create_app_version__deploy_artifact__app_version_does_not_exist__upload 's3-bucket', 'my-application/version-label.zip', False, - build_config=None + build_config=None, + relative_to_project_root=True, ) @mock.patch('ebcli.operations.commonops.fileoperations.ProjectRoot.traverse') @@ -1254,7 +1266,8 @@ def test_create_app_version__app_version_does_not_exist__upload_to_s3_fails( 's3-bucket', 'my-application/version-label', False, - build_config=None + build_config=None, + relative_to_project_root=True, ) @mock.patch('ebcli.operations.commonops.fileoperations.ProjectRoot.traverse') @@ -1295,7 +1308,7 @@ def test_create_app_version__other_arguments_passed_in( label='my-version-label', message='message ' * 50, process=True, - build_config=build_config_mock + build_config=build_config_mock, ) ) @@ -1308,7 +1321,8 @@ def test_create_app_version__other_arguments_passed_in( 's3-bucket', 's3-key', True, - build_config=build_config_mock + build_config=build_config_mock, + relative_to_project_root=True, ) @mock.patch('ebcli.operations.commonops.fileoperations.ProjectRoot.traverse') diff --git a/tests/unit/operations/test_deployops.py b/tests/unit/operations/test_deployops.py index 058a3cd9c..48ee7491c 100644 --- a/tests/unit/operations/test_deployops.py +++ b/tests/unit/operations/test_deployops.py @@ -174,3 +174,125 @@ def test_plain_deploy_with_codebuild_buildspec(self, mock_fileops, mock_aws, moc env_name='ebcli-env', timeout_in_minutes=5 ) + + @mock.patch('ebcli.operations.deployops.elasticbeanstalk') + @mock.patch('ebcli.operations.deployops.commonops') + @mock.patch('ebcli.operations.deployops.gitops') + @mock.patch('ebcli.operations.deployops.aws') + @mock.patch('ebcli.operations.deployops.fileoperations') + @mock.patch('ebcli.operations.deployops.io') + def test_deploy_with_zip_file(self, mock_io, mock_fileops, mock_aws, mock_gitops, mock_commonops, mock_beanstalk): + # Setup + mock_aws.get_region_name.return_value = 'us-east-1' + mock_fileops.build_spec_exists.return_value = False + mock_gitops.git_management_enabled.return_value = False + mock_commonops.handle_upload_target.return_value = self.app_version_name + mock_beanstalk.update_env_application_version.return_value = self.request_id + source_bundle_path = '/path/to/application.zip' + + # Call the function with source_bundle parameter + deployops.deploy(self.app_name, self.env_name, None, None, self.description, source_bundle=source_bundle_path) + + # Verify the correct calls were made + mock_commonops.handle_upload_target.assert_called_with( + self.app_name, + None, + None, + None, + source_bundle_path, + None, + self.description, + False, + None, + relative_to_project_root=False + ) + mock_beanstalk.update_env_application_version.assert_called_with(self.env_name, self.app_version_name, None) + mock_commonops.wait_for_success_events.assert_called_with( + self.request_id, + can_abort=True, + env_name='ebcli-env', + timeout_in_minutes=5 + ) + + @mock.patch('ebcli.operations.deployops.elasticbeanstalk') + @mock.patch('ebcli.operations.deployops.commonops') + @mock.patch('ebcli.operations.deployops.aws') + @mock.patch('ebcli.operations.deployops.fileoperations') + @mock.patch('ebcli.operations.deployops.io') + def test_deploy_with_source_bundle(self, mock_io, mock_fileops, mock_aws, mock_commonops, mock_beanstalk): + # Setup + mock_aws.get_region_name.return_value = 'us-east-1' + mock_fileops.build_spec_exists.return_value = False + mock_commonops.handle_upload_target.return_value = self.app_version_name + mock_beanstalk.update_env_application_version.return_value = self.request_id + source_bundle_path = '/path/to/application.zip' + label = 'my-label' + + # Call the function with source_bundle parameter + deployops.deploy(self.app_name, self.env_name, None, label, self.description, source_bundle=source_bundle_path) + + # Verify the correct calls were made + mock_commonops.handle_upload_target.assert_called_with( + self.app_name, + None, + None, + label, + source_bundle_path, + label, + self.description, + False, + None, + relative_to_project_root=False + ) + mock_beanstalk.update_env_application_version.assert_called_with(self.env_name, self.app_version_name, None) + mock_commonops.wait_for_success_events.assert_called_with( + self.request_id, + can_abort=True, + env_name='ebcli-env', + timeout_in_minutes=5 + ) + + @mock.patch('ebcli.operations.deployops.elasticbeanstalk') + @mock.patch('ebcli.operations.deployops.commonops') + @mock.patch('ebcli.operations.deployops.aws') + @mock.patch('ebcli.operations.deployops.fileoperations') + @mock.patch('ebcli.operations.deployops.buildspecops') + @mock.patch('ebcli.operations.deployops.io') + def test_deploy_with_source_bundle_and_build_config( + self, + mock_io, + mock_buildspecops, + mock_fileops, + mock_aws, + mock_commonops, + mock_beanstalk + ): + # Setup + mock_aws.get_region_name.return_value = 'us-east-1' + mock_fileops.build_spec_exists.return_value = True + build_config = self.build_config + mock_fileops.get_build_configuration.return_value = build_config + mock_commonops.handle_upload_target.return_value = self.app_version_name + mock_beanstalk.update_env_application_version.return_value = self.request_id + source_bundle_path = '/path/to/application.zip' + label = 'my-label' + + # Call the function with source_bundle parameter + deployops.deploy(self.app_name, self.env_name, None, label, self.description, source_bundle=source_bundle_path) + + # Verify the correct calls were made + mock_commonops.handle_upload_target.assert_called_with( + self.app_name, + None, + None, + label, + source_bundle_path, + label, + self.description, + False, + None, + relative_to_project_root=False + ) + mock_beanstalk.update_env_application_version.assert_called_with(self.env_name, self.app_version_name, None) + # Verify buildspecops.stream_build_configuration_app_version_creation is NOT called with source_bundle + mock_buildspecops.stream_build_configuration_app_version_creation.assert_not_called() diff --git a/tests/unit/operations/test_deployops_source_bundle.py b/tests/unit/operations/test_deployops_source_bundle.py new file mode 100644 index 000000000..8e4609446 --- /dev/null +++ b/tests/unit/operations/test_deployops_source_bundle.py @@ -0,0 +1,134 @@ +import unittest +import mock + +from ebcli.objects.buildconfiguration import BuildConfiguration +from ebcli.operations import deployops + + +class TestDeployWithSourceBundle(unittest.TestCase): + app_name = 'ebcli-app' + app_version_name = 'ebcli-app-version' + env_name = 'ebcli-env' + description = 'ebcli testing app' + s3_bucket = 'app_bucket' + s3_key = 'app_bucket_key' + request_id = 'foo-foo-foo-foo' + + image = 'aws/codebuild/eb-java-8-amazonlinux-64:2.1.3' + compute_type = 'BUILD_GENERAL1_SMALL' + service_role = 'eb-test' + timeout = 60 + build_config = BuildConfiguration(image=image, compute_type=compute_type, + service_role=service_role, timeout=timeout) + + @mock.patch('ebcli.operations.deployops.elasticbeanstalk') + @mock.patch('ebcli.operations.deployops.commonops') + @mock.patch('ebcli.operations.deployops.aws') + @mock.patch('ebcli.operations.deployops.fileoperations') + @mock.patch('ebcli.operations.deployops.io') + def test_deploy_with_source_bundle( + self, + mock_io, + mock_fileops, + mock_aws, + mock_commonops, + mock_beanstalk + ): + # Setup + mock_aws.get_region_name.return_value = 'us-east-1' + mock_fileops.build_spec_exists.return_value = False + mock_commonops.handle_upload_target.return_value = self.app_version_name + mock_beanstalk.update_env_application_version.return_value = self.request_id + source_bundle_path = '/path/to/application.zip' + label = 'my-label' + + # Call the function with source_bundle parameter + deployops.deploy( + self.app_name, + self.env_name, + None, + label, + self.description, + source_bundle=source_bundle_path + ) + + # Verify the correct calls were made + mock_commonops.handle_upload_target.assert_called_with( + self.app_name, + None, + None, + label, + source_bundle_path, + label, + self.description, + False, + None, + relative_to_project_root=False + ) + mock_beanstalk.update_env_application_version.assert_called_with( + self.env_name, + self.app_version_name, + None + ) + mock_commonops.wait_for_success_events.assert_called_with( + self.request_id, + can_abort=True, + env_name='ebcli-env', + timeout_in_minutes=5 + ) + + @mock.patch('ebcli.operations.deployops.elasticbeanstalk') + @mock.patch('ebcli.operations.deployops.commonops') + @mock.patch('ebcli.operations.deployops.aws') + @mock.patch('ebcli.operations.deployops.fileoperations') + @mock.patch('ebcli.operations.deployops.buildspecops') + @mock.patch('ebcli.operations.deployops.io') + def test_deploy_with_source_bundle_and_build_config( + self, + mock_io, + mock_buildspecops, + mock_fileops, + mock_aws, + mock_commonops, + mock_beanstalk + ): + # Setup + mock_aws.get_region_name.return_value = 'us-east-1' + mock_fileops.build_spec_exists.return_value = True + build_config = self.build_config + mock_fileops.get_build_configuration.return_value = build_config + mock_commonops.handle_upload_target.return_value = self.app_version_name + mock_beanstalk.update_env_application_version.return_value = self.request_id + source_bundle_path = '/path/to/application.zip' + label = 'my-label' + + # Call the function with source_bundle parameter + deployops.deploy( + self.app_name, + self.env_name, + None, + label, + self.description, + source_bundle=source_bundle_path + ) + + # Verify the correct calls were made + mock_commonops.handle_upload_target.assert_called_with( + self.app_name, + None, + None, + label, + source_bundle_path, + label, + self.description, + False, + None, + relative_to_project_root=False + ) + mock_beanstalk.update_env_application_version.assert_called_with( + self.env_name, + self.app_version_name, + None + ) + # Verify buildspecops.stream_build_configuration_app_version_creation is NOT called with source_bundle + mock_buildspecops.stream_build_configuration_app_version_creation.assert_not_called()