diff --git a/ebcli/controllers/deploy.py b/ebcli/controllers/deploy.py index 1b2ed2419..cab1a9fda 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,7 +49,8 @@ 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): @@ -62,9 +65,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 +98,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 +209,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.archivemustbedirirzip']) + 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..f56f3600e 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..beb514c4a 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.archivemustbedirirzip': '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..b6e56d882 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 = '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='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/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..3103f0b87 100644 --- a/tests/unit/operations/test_commonops.py +++ b/tests/unit/operations/test_commonops.py @@ -1077,7 +1077,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 +1139,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 +1196,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 +1257,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 +1299,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 +1312,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()