diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43a4ed6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv +**/.venv +__pycache__ \ No newline at end of file diff --git a/utilities/resource_sync/README.md b/utilities/resource_sync/README.md index 86c3ece..8012c33 100644 --- a/utilities/resource_sync/README.md +++ b/utilities/resource_sync/README.md @@ -21,8 +21,8 @@ You can run this utility in any location where you have Python and the following $ sync.py [-h] [--targets TARGETS] [--src-job-names SRC_JOB_NAMES] [--src-database-names SRC_DATABASE_NAMES] [--src-table-names SRC_TABLE_NAMES] [--src-profile SRC_PROFILE] [--src-region SRC_REGION] [--src-s3-endpoint-url SRC_S3_ENDPOINT_URL] [--src-sts-endpoint-url SRC_STS_ENDPOINT_URL] [--src-glue-endpoint-url SRC_GLUE_ENDPOINT_URL] [--dst-profile DST_PROFILE] [--dst-region DST_REGION] [--dst-s3-endpoint-url DST_S3_ENDPOINT_URL] [--dst-sts-endpoint-url DST_STS_ENDPOINT_URL] [--dst-glue-endpoint-url DST_GLUE_ENDPOINT_URL] - [--sts-role-arn STS_ROLE_ARN] [--skip-no-dag-jobs SKIP_NO_DAG_JOBS] [--overwrite-jobs OVERWRITE_JOBS] [--overwrite-databases OVERWRITE_DATABASES] [--overwrite-tables OVERWRITE_TABLES] - [--copy-job-script COPY_JOB_SCRIPT] [--config-path CONFIG_PATH] [--skip-errors] [--dryrun] [--skip-prompt] [-v] + [--src-role-arn SRC_ROLE_ARN] [--dst-role-arn DST_ROLE_ARN] [--skip-no-dag-jobs SKIP_NO_DAG_JOBS] [--overwrite-jobs OVERWRITE_JOBS] [--overwrite-databases OVERWRITE_DATABASES] + [--overwrite-tables OVERWRITE_TABLES] [--copy-job-script COPY_JOB_SCRIPT] [--config-path CONFIG_PATH] [--skip-errors] [--dryrun] [--skip-prompt] [-v] optional arguments: -h, --help show this help message and exit @@ -53,8 +53,10 @@ optional arguments: Destination endpoint URL for AWS STS. --dst-glue-endpoint-url DST_GLUE_ENDPOINT_URL Destination endpoint URL for AWS Glue. - --sts-role-arn STS_ROLE_ARN - IAM role arn to be assumed to access destination account resources. + --src-role-arn SRC_ROLE_ARN + IAM role ARN to be assumed to access source account resources. + --dst-role-arn DST_ROLE_ARN + IAM role ARN to be assumed to access destination account resources. --skip-no-dag-jobs SKIP_NO_DAG_JOBS Skip Glue jobs which do not have DAG. (possible values: [true, false]. default: true) --overwrite-jobs OVERWRITE_JOBS @@ -94,12 +96,12 @@ There are two options to configure credentials for accessing resources in target #### Profile -When you provide `--dst-profile`, this utility uses AWS Named profile set in the option to access resources in the destination account. +When you provide `--src-profile` or `--dst-profile`, this utility uses AWS Named profile set in the option to access resources in the respective account. #### STS AssumeRole -When you provide `--sts-role-arn`, this utility assumes the role and use the role to access resources in the destination account. -You need to create IAM role in the destination account, and configure the trust relationship for the role to allow `AssumeRole` calls from the source account. +When you provide `--src-role-arn` or `--dst-role-arn`, this utility assumes the role and uses it to access resources in the respective account. +You need to create IAM roles in the source and/or destination accounts, and configure the trust relationship for the roles to allow `AssumeRole` calls from the account running the script. ## Examples diff --git a/utilities/resource_sync/requirements.txt b/utilities/resource_sync/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/utilities/resource_sync/sync.py b/utilities/resource_sync/sync.py index a981267..6f84c80 100644 --- a/utilities/resource_sync/sync.py +++ b/utilities/resource_sync/sync.py @@ -13,108 +13,152 @@ import sys import logging -# Configure credentials and required parameters -parser = argparse.ArgumentParser() -parser.add_argument('--targets', dest='targets', type=str, default="job", - help='The comma separated list of targets [job, catalog]. (possible values: [job, catalog]. default: job)') -parser.add_argument('--src-job-names', dest='src_job_names', type=str, - help='The comma separated list of the names of AWS Glue jobs which are going to be copied from source AWS account. If it is not set, all the Glue jobs in the source account will be copied to the destination account.') -parser.add_argument('--src-database-names', dest='src_database_names', type=str, - help='The comma separated list of the names of AWS Glue databases which are going to be copied from source AWS account. If it is not set, all the Glue databases in the source account will be copied to the destination account.') -parser.add_argument('--src-table-names', dest='src_table_names', type=str, - help='The comma separated list of the names of AWS Glue tables which are going to be copied from source AWS account. If it is not set, all the Glue tables in the specified databases will be copied to the destination account.') -parser.add_argument('--src-profile', dest='src_profile', type=str, - help='AWS named profile name for source AWS account.') -parser.add_argument('--src-region', dest='src_region', - type=str, help='Source region name.') -parser.add_argument('--src-s3-endpoint-url', dest='src_s3_endpoint_url', - type=str, help='Source endpoint URL for Amazon S3.') -parser.add_argument('--src-sts-endpoint-url', dest='src_sts_endpoint_url', - type=str, help='Source endpoint URL for AWS STS.') -parser.add_argument('--src-glue-endpoint-url', dest='src_glue_endpoint_url', - type=str, help='Source endpoint URL for AWS Glue.') -parser.add_argument('--dst-profile', dest='dst_profile', type=str, - help='AWS named profile name for destination AWS account.') -parser.add_argument('--dst-region', dest='dst_region', - type=str, help='Destination region name.') -parser.add_argument('--dst-s3-endpoint-url', dest='dst_s3_endpoint_url', - type=str, help='Destination endpoint URL for Amazon S3.') -parser.add_argument('--dst-sts-endpoint-url', dest='dst_sts_endpoint_url', - type=str, help='Destination endpoint URL for AWS STS.') -parser.add_argument('--dst-glue-endpoint-url', dest='dst_glue_endpoint_url', - type=str, help='Destination endpoint URL for AWS Glue.') -parser.add_argument('--sts-role-arn', dest='sts_role_arn', - type=str, help='IAM role arn to be assumed to access destination account resources.') -parser.add_argument('--skip-no-dag-jobs', dest='skip_no_dag_jobs', type=strtobool, default=True, - help='Skip Glue jobs which do not have DAG. (possible values: [true, false]. default: true)') -parser.add_argument('--overwrite-jobs', dest='overwrite_jobs', type=strtobool, default=True, - help='Overwrite Glue jobs when the jobs already exist. (possible values: [true, false]. default: true)') -parser.add_argument('--overwrite-databases', dest='overwrite_databases', type=strtobool, default=True, - help='Overwrite Glue databases when the tables already exist. (possible values: [true, false]. default: true)') -parser.add_argument('--overwrite-tables', dest='overwrite_tables', type=strtobool, default=True, - help='Overwrite Glue tables when the tables already exist. (possible values: [true, false]. default: true)') -parser.add_argument('--copy-job-script', dest='copy_job_script', type=strtobool, default=True, - help='Copy Glue job script from the source account to the destination account. (possible values: [true, false]. default: true)') -parser.add_argument('--config-path', dest='config_path', type=str, - help='The config file path to provide parameter mapping. You can set S3 path or local file path.') -parser.add_argument('--skip-errors', dest='skip_errors', action="store_true", help='(Optional) Skip errors and continue execution. (default: false)') -parser.add_argument('--dryrun', dest='dryrun', action="store_true", help='(Optional) Display the operations that would be performed using the specified command without actually running them (default: false)') -parser.add_argument('--skip-prompt', dest='skip_prompt', action="store_true", help='(Optional) Skip prompt (default: false)') -parser.add_argument('-v', '--verbose', dest='verbose', action="store_true", help='(Optional) Display verbose logging (default: false)') -args, unknown = parser.parse_known_args() +# Global variables +args = None +logger = None +src_session = None +src_glue = None +src_s3 = None +dst_session = None +dst_glue = None +dst_s3 = None +dst_s3_client = None +do_update = None logger = logging.getLogger() -logger_handler = logging.StreamHandler(sys.stdout) -logger.addHandler(logger_handler) -if args.verbose: - logger.setLevel(logging.DEBUG) -else: - logger.setLevel(logging.INFO) -for libname in ["boto3", "botocore", "urllib3", "s3transfer"]: - logging.getLogger(libname).setLevel(logging.WARNING) - -logger.debug(f"Python version: {sys.version}") -logger.debug(f"Version info: {sys.version_info}") -logger.debug(f"boto3 version: {boto3.__version__}") -logger.debug(f"botocore version: {botocore.__version__}") - -src_session_args = {} -if args.src_profile is not None: - src_session_args['profile_name'] = args.src_profile - logger.info(f"Source: boto3 Session uses {args.src_profile} profile based on the argument.") -if args.src_region is not None: - src_session_args['region_name'] = args.src_region - logger.info(f"Source: boto3 Session uses {args.src_region} region based on the argument.") - -dst_session_args = {} -if args.dst_profile is not None: - dst_session_args['profile_name'] = args.dst_profile - logger.info(f"Destination: boto3 Session uses {args.dst_profile} profile based on the argument.") -if args.dst_region is not None: - dst_session_args['region_name'] = args.dst_region - logger.info(f"Destination: boto3 Session uses {args.dst_region} region based on the argument.") - -src_session = boto3.Session(**src_session_args) -src_glue = src_session.client('glue', endpoint_url=args.src_glue_endpoint_url) -src_s3 = src_session.resource('s3', endpoint_url=args.src_s3_endpoint_url) - -if args.sts_role_arn is not None: - src_sts = src_session.client('sts', endpoint_url=args.src_sts_endpoint_url) - res = src_sts.assume_role(RoleArn=args.sts_role_arn, RoleSessionName='glue-job-sync') - dst_session_args['aws_access_key_id'] = res['Credentials']['AccessKeyId'] - dst_session_args['aws_secret_access_key'] = res['Credentials']['SecretAccessKey'] - dst_session_args['aws_session_token'] = res['Credentials']['SessionToken'] - -if args.dst_profile is None and args.sts_role_arn is None: - logger.error("You need to set --dst-profile or --sts-role-arn to create resources in the destination account.") - sys.exit(1) - -dst_session = boto3.Session(**dst_session_args) -dst_glue = dst_session.client('glue', endpoint_url=args.dst_glue_endpoint_url) -dst_s3 = dst_session.resource('s3', endpoint_url=args.dst_s3_endpoint_url) -dst_s3_client = dst_session.client('s3', endpoint_url=args.dst_s3_endpoint_url) - -do_update = not args.dryrun + + +def parse_arguments(): + # Configure credentials and required parameters + parser = argparse.ArgumentParser() + parser.add_argument('--targets', dest='targets', type=str, default="job", + help='The comma separated list of targets [job, catalog]. (possible values: [job, catalog]. default: job)') + parser.add_argument('--src-job-names', dest='src_job_names', type=str, + help='The comma separated list of the names of AWS Glue jobs which are going to be copied from source AWS account. If it is not set, all the Glue jobs in the source account will be copied to the destination account.') + parser.add_argument('--src-database-names', dest='src_database_names', type=str, + help='The comma separated list of the names of AWS Glue databases which are going to be copied from source AWS account. If it is not set, all the Glue databases in the source account will be copied to the destination account.') + parser.add_argument('--src-table-names', dest='src_table_names', type=str, + help='The comma separated list of the names of AWS Glue tables which are going to be copied from source AWS account. If it is not set, all the Glue tables in the specified databases will be copied to the destination account.') + parser.add_argument('--src-profile', dest='src_profile', type=str, + help='AWS named profile name for source AWS account.') + parser.add_argument('--src-region', dest='src_region', + type=str, help='Source region name.') + parser.add_argument('--src-s3-endpoint-url', dest='src_s3_endpoint_url', + type=str, help='Source endpoint URL for Amazon S3.') + parser.add_argument('--src-sts-endpoint-url', dest='src_sts_endpoint_url', + type=str, help='Source endpoint URL for AWS STS.') + parser.add_argument('--src-glue-endpoint-url', dest='src_glue_endpoint_url', + type=str, help='Source endpoint URL for AWS Glue.') + parser.add_argument('--dst-profile', dest='dst_profile', type=str, + help='AWS named profile name for destination AWS account.') + parser.add_argument('--dst-region', dest='dst_region', + type=str, help='Destination region name.') + parser.add_argument('--dst-s3-endpoint-url', dest='dst_s3_endpoint_url', + type=str, help='Destination endpoint URL for Amazon S3.') + parser.add_argument('--dst-sts-endpoint-url', dest='dst_sts_endpoint_url', + type=str, help='Destination endpoint URL for AWS STS.') + parser.add_argument('--dst-glue-endpoint-url', dest='dst_glue_endpoint_url', + type=str, help='Destination endpoint URL for AWS Glue.') + parser.add_argument('--src-role-arn', dest='src_role_arn', type=str, + help='IAM role ARN to be assumed to access source account resources.') + parser.add_argument('--dst-role-arn', dest='dst_role_arn', type=str, + help='IAM role ARN to be assumed to access destination account resources.') + parser.add_argument('--skip-no-dag-jobs', dest='skip_no_dag_jobs', type=strtobool, default=True, + help='Skip Glue jobs which do not have DAG. (possible values: [true, false]. default: true)') + parser.add_argument('--overwrite-jobs', dest='overwrite_jobs', type=strtobool, default=True, + help='Overwrite Glue jobs when the jobs already exist. (possible values: [true, false]. default: true)') + parser.add_argument('--overwrite-databases', dest='overwrite_databases', type=strtobool, default=True, + help='Overwrite Glue databases when the tables already exist. (possible values: [true, false]. default: true)') + parser.add_argument('--overwrite-tables', dest='overwrite_tables', type=strtobool, default=True, + help='Overwrite Glue tables when the tables already exist. (possible values: [true, false]. default: true)') + parser.add_argument('--copy-job-script', dest='copy_job_script', type=strtobool, default=True, + help='Copy Glue job script from the source account to the destination account. (possible values: [true, false]. default: true)') + parser.add_argument('--config-path', dest='config_path', type=str, + help='The config file path to provide parameter mapping. You can set S3 path or local file path.') + parser.add_argument('--skip-errors', dest='skip_errors', action="store_true", help='(Optional) Skip errors and continue execution. (default: false)') + parser.add_argument('--dryrun', dest='dryrun', action="store_true", help='(Optional) Display the operations that would be performed using the specified command without actually running them (default: false)') + parser.add_argument('--skip-prompt', dest='skip_prompt', action="store_true", help='(Optional) Skip prompt (default: false)') + parser.add_argument('-v', '--verbose', dest='verbose', action="store_true", help='(Optional) Display verbose logging (default: false)') + return parser.parse_args() + +def setup_logging(verbose): + logger_handler = logging.StreamHandler(sys.stdout) + logger.addHandler(logger_handler) + + if verbose: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + + for libname in ["boto3", "botocore", "urllib3", "s3transfer"]: + logging.getLogger(libname).setLevel(logging.WARNING) + + logger.debug(f"Python version: {sys.version}") + logger.debug(f"Version info: {sys.version_info}") + logger.debug(f"boto3 version: {boto3.__version__}") + logger.debug(f"botocore version: {botocore.__version__}") + + +def initialize(): + global args, logger, src_session, src_glue, src_s3, dst_session, dst_glue, dst_s3, dst_s3_client, do_update + + args = parse_arguments() + setup_logging(args.verbose) + + src_session_args = {} + if args.src_profile is not None: + src_session_args['profile_name'] = args.src_profile + logger.info(f"Source: boto3 Session uses {args.src_profile} profile based on the argument.") + if args.src_region is not None: + src_session_args['region_name'] = args.src_region + logger.info(f"Source: boto3 Session uses {args.src_region} region based on the argument.") + + src_session = boto3.Session(**src_session_args) + + if args.src_role_arn is not None: + src_sts = src_session.client('sts', endpoint_url=args.src_sts_endpoint_url) + src_assumed_role = src_sts.assume_role(RoleArn=args.src_role_arn, RoleSessionName='glue-job-sync-source') + src_session = boto3.Session( + aws_access_key_id=src_assumed_role['Credentials']['AccessKeyId'], + aws_secret_access_key=src_assumed_role['Credentials']['SecretAccessKey'], + aws_session_token=src_assumed_role['Credentials']['SessionToken'], + region_name=args.src_region + ) + logger.info(f"Assumed role in source account: {args.src_role_arn}") + + src_glue = src_session.client('glue', endpoint_url=args.src_glue_endpoint_url) + src_s3 = src_session.resource('s3', endpoint_url=args.src_s3_endpoint_url) + + dst_session_args = {} + if args.dst_profile is not None: + dst_session_args['profile_name'] = args.dst_profile + logger.info(f"Destination: boto3 Session uses {args.dst_profile} profile based on the argument.") + if args.dst_region is not None: + dst_session_args['region_name'] = args.dst_region + logger.info(f"Destination: boto3 Session uses {args.dst_region} region based on the argument.") + + dst_session = boto3.Session(**dst_session_args) + + if args.dst_role_arn is not None: + dst_sts = dst_session.client('sts', endpoint_url=args.dst_sts_endpoint_url) + dst_assumed_role = dst_sts.assume_role(RoleArn=args.dst_role_arn, RoleSessionName='glue-job-sync-destination') + dst_session = boto3.Session( + aws_access_key_id=dst_assumed_role['Credentials']['AccessKeyId'], + aws_secret_access_key=dst_assumed_role['Credentials']['SecretAccessKey'], + aws_session_token=dst_assumed_role['Credentials']['SessionToken'], + region_name=args.dst_region + ) + logger.info(f"Assumed role in destination account: {args.dst_role_arn}") + + if args.dst_profile is None and args.dst_role_arn is None: + logger.error("You need to set --dst-profile or --dst-role-arn to create resources in the destination account.") + sys.exit(1) + + dst_glue = dst_session.client('glue', endpoint_url=args.dst_glue_endpoint_url) + dst_s3 = dst_session.resource('s3', endpoint_url=args.dst_s3_endpoint_url) + dst_s3_client = dst_session.client('s3', endpoint_url=args.dst_s3_endpoint_url) + + do_update = not args.dryrun def prompt(message): @@ -264,13 +308,8 @@ def copy_job_script(src_s3path, dst_s3path): def synchronize_job(job_name, mapping): - """Function to synchronize an AWS Glue job. + """Function to synchronize an AWS Glue job.""" - Args: - job_name: The name of AWS Glue job which is going to be synchronized. - mapping: Mapping configuration to replace the job parameter. - - """ logger.debug(f"Synchronizing job '{job_name}'") # Get DAG per job in the source account res = src_glue.get_job(JobName=job_name) @@ -319,6 +358,7 @@ def synchronize_job(job_name, mapping): dst_glue.update_job(**job_update) logger.info(f"The job '{job_name}' has been overwritten.") except dst_glue.exceptions.EntityNotFoundException: + logger.debug(f"Job '{job_name}' does not exist in the destination account. Creating it.") logger.debug(f"Creating job '{job_name}' with configuration: '{json.dumps(job, indent=4, default=str)}'") if do_update: dst_glue.create_job(**job) @@ -596,6 +636,8 @@ def synchronize_database(database, mapping): def main(): + initialize() + if args.config_path: logger.debug(f"Loading Mapping config file: {args.config_path}") mapping = load_mapping_config_file(args.config_path) diff --git a/utilities/resource_sync/test_sync.py b/utilities/resource_sync/test_sync.py new file mode 100644 index 0000000..3584e6a --- /dev/null +++ b/utilities/resource_sync/test_sync.py @@ -0,0 +1,156 @@ +# Copyright 2019-2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +import unittest +from unittest.mock import patch, MagicMock +import boto3 +from moto import mock_aws +import sync + + +class TestSync(unittest.TestCase): + + def setUp(self): + self.mock_aws = mock_aws() + self.mock_aws.start() + + self.glue_client = boto3.client('glue', region_name='us-east-1') + self.s3_client = boto3.client('s3', region_name='us-east-1') + self.sts_client = boto3.client('sts', region_name='us-east-1') + + # Mock sys.exit to prevent actual exit during tests + self.exit_patcher = patch('sys.exit') + self.mock_exit = self.exit_patcher.start() + + # Mock args + self.args_patcher = patch('sync.args', MagicMock( + skip_no_dag_jobs=True, + overwrite_databases=True, + skip_errors=False, + copy_job_script=True, + dst_region='us-east-1', + targets='job', + src_job_names=None, + config_path=None + )) + self.mock_args = self.args_patcher.start() + + def tearDown(self): + self.mock_aws.stop() + self.exit_patcher.stop() + self.args_patcher.stop() + + def test_organize_job_param(self): + job = { + 'Name': 'TestJob', + 'AllocatedCapacity': 10, + 'CreatedOn': '2023-01-01', + 'Command': { + 'ScriptLocation': 's3://old-bucket/script.py' + } + } + mapping = { + 's3://old-bucket': 's3://new-bucket' + } + + result = sync.organize_job_param(job, mapping) + + self.assertNotIn('AllocatedCapacity', result) + self.assertNotIn('CreatedOn', result) + self.assertEqual(result['Command']['ScriptLocation'], 's3://new-bucket/script.py') + + def test_copy_job_script(self): + self.s3_client.create_bucket(Bucket='src-bucket') + self.s3_client.put_object(Bucket='src-bucket', Key='script.py', Body='print("Hello")') + + mock_dst_s3 = MagicMock() + mock_dst_s3.meta.client = self.s3_client + + with patch('sync.src_s3', boto3.resource('s3')), \ + patch('sync.dst_s3_client', self.s3_client), \ + patch('sync.dst_s3', mock_dst_s3), \ + patch('sync.args', MagicMock(dst_region='us-east-1')): + sync.copy_job_script('s3://src-bucket/script.py', 's3://dst-bucket/script.py') + + result = self.s3_client.get_object(Bucket='dst-bucket', Key='script.py') + self.assertEqual(result['Body'].read().decode('utf-8'), 'print("Hello")') + + def test_synchronize_job(self): + self.glue_client.create_job( + Name='TestJob', + Role='TestRole', + Command={'Name': 'glueetl', 'ScriptLocation': 's3://src-bucket/script.py'} + ) + + with patch('sync.src_glue', self.glue_client), \ + patch('sync.dst_glue', self.glue_client), \ + patch('sync.copy_job_script'): + sync.synchronize_job('TestJob', {}) + + job = self.glue_client.get_job(JobName='TestJob')['Job'] + self.assertEqual(job['Name'], 'TestJob') + + def test_synchronize_database(self): + self.glue_client.create_database(DatabaseInput={'Name': 'TestDB'}) + + with patch('sync.src_glue', self.glue_client), \ + patch('sync.dst_glue', self.glue_client): + sync.synchronize_database({'Name': 'TestDB'}, {}) + + db = self.glue_client.get_database(Name='TestDB')['Database'] + self.assertEqual(db['Name'], 'TestDB') + + def test_synchronize_table(self): + self.glue_client.create_database(DatabaseInput={'Name': 'TestDB'}) + self.glue_client.create_table( + DatabaseName='TestDB', + TableInput={ + 'Name': 'TestTable', + 'StorageDescriptor': { + 'Columns': [{'Name': 'col1', 'Type': 'string'}] + }, + 'TableType': 'EXTERNAL_TABLE' # Add this line + } + ) + + with patch('sync.src_glue', self.glue_client), \ + patch('sync.dst_glue', self.glue_client): + sync.synchronize_table({ + 'Name': 'TestTable', + 'DatabaseName': 'TestDB', + 'StorageDescriptor': { + 'Columns': [{'Name': 'col1', 'Type': 'string'}] + }, + 'TableType': 'EXTERNAL_TABLE' # Add this line + }, {}) + + table = self.glue_client.get_table(DatabaseName='TestDB', Name='TestTable')['Table'] + self.assertEqual(table['Name'], 'TestTable') + self.assertEqual(table['DatabaseName'], 'TestDB') + + def test_main_without_destination_profile(self): + with patch('sync.parse_arguments') as mock_parse_args, \ + patch('sync.boto3.Session') as mock_session, \ + patch('sync.boto3.client') as mock_client, \ + patch('sync.load_mapping_config_file', return_value={}), \ + patch('sync.initialize') as mock_initialize: + mock_parse_args.return_value = MagicMock( + dst_profile=None, + dst_role_arn=None, + config_path=None, + targets='job', + src_job_names=None + ) + mock_session.return_value.get_credentials.return_value = None + + # Simulate the error condition + mock_initialize.side_effect = SystemExit(1) + + with self.assertRaises(SystemExit) as cm: + sync.main() + + self.assertEqual(cm.exception.code, 1) + mock_client.assert_not_called() + +if __name__ == '__main__': + unittest.main()