diff --git a/awscli/customizations/cloudformation/deploy.py b/awscli/customizations/cloudformation/deploy.py index 4ebef072df56..aa71c2f512a9 100644 --- a/awscli/customizations/cloudformation/deploy.py +++ b/awscli/customizations/cloudformation/deploy.py @@ -215,7 +215,37 @@ class DeployCommand(BasicCommand): 'Causes the CLI to return an exit code of 0 if there are no ' 'changes to be made to the stack.' ) - } + }, + { + 'name': 'tags', + 'action': 'store', + 'required': False, + 'default': [], + 'schema': { + "type": "array", + "items": { + "type": "object", + "properties": { + "Key": { + "description": "The tag key.", + "type": "string", + "required": True + }, + "Value": { + "description": "The tag value.", + "type": "string", + "required": True + } + } + } + }, + 'help_text': ( + 'Key-value pairs to associate with the stack,' + ' that is created or updated by the changeset.' + ' AWS CloudFormation also propagates these tags' + ' to resources in the stack.' + ) + }, ] def _run_main(self, parsed_args, parsed_globals): @@ -267,22 +297,25 @@ def _run_main(self, parsed_args, parsed_globals): return self.deploy(deployer, stack_name, template_str, parameters, parsed_args.capabilities, parsed_args.execute_changeset, parsed_args.role_arn, - parsed_args.notification_arns, - s3_uploader, + parsed_args.notification_arns, s3_uploader, + parsed_args.tags, parsed_args.fail_on_empty_changeset) def deploy(self, deployer, stack_name, template_str, parameters, capabilities, execute_changeset, role_arn, - notification_arns, s3_uploader, fail_on_empty_changeset=True): + notification_arns, s3_uploader, tags, + fail_on_empty_changeset=True): try: result = deployer.create_and_wait_for_changeset( - stack_name=stack_name, - cfn_template=template_str, - parameter_values=parameters, - capabilities=capabilities, - role_arn=role_arn, - notification_arns=notification_arns, - s3_uploader=s3_uploader) + stack_name=stack_name, + cfn_template=template_str, + parameter_values=parameters, + capabilities=capabilities, + role_arn=role_arn, + notification_arns=notification_arns, + s3_uploader=s3_uploader, + tags=tags + ) except exceptions.ChangeEmptyError as ex: if fail_on_empty_changeset: raise diff --git a/awscli/customizations/cloudformation/deployer.py b/awscli/customizations/cloudformation/deployer.py index 76e41a5fe30a..6569a66a7b9e 100644 --- a/awscli/customizations/cloudformation/deployer.py +++ b/awscli/customizations/cloudformation/deployer.py @@ -73,7 +73,7 @@ def has_stack(self, stack_name): def create_changeset(self, stack_name, cfn_template, parameter_values, capabilities, role_arn, - notification_arns, s3_uploader): + notification_arns, s3_uploader, tags): """ Call Cloudformation to create a changeset and wait for it to complete @@ -81,6 +81,7 @@ def create_changeset(self, stack_name, cfn_template, :param cfn_template: CloudFormation template string :param parameter_values: Template parameters object :param capabilities: Array of capabilities passed to CloudFormation + :param tags: Array of tags passed to CloudFormation :return: """ @@ -107,6 +108,7 @@ def create_changeset(self, stack_name, cfn_template, 'Parameters': parameter_values, 'Capabilities': capabilities, 'Description': description, + 'Tags': tags, } # If an S3 uploader is available, use TemplateURL to deploy rather than @@ -208,12 +210,11 @@ def wait_for_execute(self, stack_name, changeset_type): def create_and_wait_for_changeset(self, stack_name, cfn_template, parameter_values, capabilities, role_arn, - notification_arns, s3_uploader): + notification_arns, s3_uploader, tags): result = self.create_changeset( stack_name, cfn_template, parameter_values, capabilities, - role_arn, notification_arns, s3_uploader) - + role_arn, notification_arns, s3_uploader, tags) self.wait_for_changeset(result.changeset_id, stack_name) return result diff --git a/awscli/examples/cloudformation/deploy.rst b/awscli/examples/cloudformation/deploy.rst index 3cd0f6165f9b..8bb83db45167 100644 --- a/awscli/examples/cloudformation/deploy.rst +++ b/awscli/examples/cloudformation/deploy.rst @@ -2,5 +2,5 @@ Following command deploys template named ``template.json`` to a stack named ``my-new-stack``:: - aws cloudformation deploy --template-file /path_to_template/template.json --stack-name my-new-stack --parameter-overrides Key1=Value1 Key2=Value2 + aws cloudformation deploy --template-file /path_to_template/template.json --stack-name my-new-stack --parameter-overrides Key1=Value1 Key2=Value2 --tags Key=key1,Value=value1,Key=key2,Value=Value2 diff --git a/tests/unit/customizations/cloudformation/test_deploy.py b/tests/unit/customizations/cloudformation/test_deploy.py index aaf4ea6087dd..4a4693200c8f 100644 --- a/tests/unit/customizations/cloudformation/test_deploy.py +++ b/tests/unit/customizations/cloudformation/test_deploy.py @@ -61,7 +61,8 @@ def setUp(self): s3_bucket=None, s3_prefix="some prefix", kms_key_id="some kms key id", - force_upload=True) + force_upload=True, + tags=[{"Key": "key1", "Value": "val1"}]) self.parsed_globals = FakeArgs(region="us-east-1", endpoint_url=None, verify_ssl=None) self.deploy_command = DeployCommand(self.session) @@ -107,17 +108,18 @@ def test_command_invoked(self, mock_yaml_parse): open_mock.assert_called_once_with(file_path, "r") self.deploy_command.deploy.assert_called_once_with( - mock.ANY, - self.parsed_args.stack_name, - mock.ANY, - fake_parameters, - None, - not self.parsed_args.no_execute_changeset, - None, - [], - None, - True) - + mock.ANY, + 'some_stack_name', + mock.ANY, + fake_parameters, + None, + not self.parsed_args.no_execute_changeset, + None, + [], + None, + mock.ANY, + True + ) self.deploy_command.parse_parameter_arg.assert_called_once_with( self.parsed_args.parameter_overrides) @@ -158,7 +160,7 @@ def test_s3_upload_required_but_missing_bucket(self, mock_getsize, mock_yaml_par @patch('awscli.customizations.cloudformation.deploy.os.path.getsize') @patch('awscli.customizations.cloudformation.deploy.DeployCommand.deploy') @patch('awscli.customizations.cloudformation.deploy.S3Uploader') - def test_s3_uploader_is_configured_properly(self, s3UploaderMock, + def test_s3_uploader_is_configured_properly(self, s3UploaderMock, deploy_method_mock, mock_getsize, mock_yaml_parse, mock_isfile): """ Tests that large templates are detected prior to deployment @@ -183,19 +185,21 @@ def test_s3_uploader_is_configured_properly(self, s3UploaderMock, parsed_globals=self.parsed_globals) self.deploy_command.deploy.assert_called_once_with( - mock.ANY, - self.parsed_args.stack_name, - mock.ANY, - mock.ANY, - None, - not self.parsed_args.no_execute_changeset, - None, - [], - s3UploaderObject, - True) - - s3UploaderMock.assert_called_once_with(mock.ANY, - bucket_name, + mock.ANY, + self.parsed_args.stack_name, + mock.ANY, + mock.ANY, + None, + not self.parsed_args.no_execute_changeset, + None, + [], + s3UploaderObject, + [{'Key': 'key1', 'Value': 'val1'}], + True + ) + + s3UploaderMock.assert_called_once_with(mock.ANY, + bucket_name, mock.ANY, self.parsed_args.s3_prefix, self.parsed_args.kms_key_id, @@ -216,6 +220,7 @@ def test_deploy_success(self): role_arn = "arn:aws:iam::1234567890:role" notification_arns = ["arn:aws:sns:region:1234567890:notify"] s3_uploader = None + tags = [{"Key":"key1", "Value": "val1"}] # Set the mock to return this fake changeset_id self.deployer.create_and_wait_for_changeset.return_value = ChangeSetResult(changeset_id, changeset_type) @@ -228,7 +233,8 @@ def test_deploy_success(self): execute_changeset, role_arn, notification_arns, - s3_uploader) + s3_uploader, + tags) self.assertEqual(rc, 0) @@ -238,7 +244,8 @@ def test_deploy_success(self): capabilities=capabilities, role_arn=role_arn, notification_arns=notification_arns, - s3_uploader=s3_uploader) + s3_uploader=s3_uploader, + tags=tags) # since execute_changeset is set to True, deploy() will execute changeset self.deployer.execute_changeset.assert_called_once_with(changeset_id, stack_name) @@ -255,6 +262,7 @@ def test_deploy_no_execute(self): role_arn = "arn:aws:iam::1234567890:role" notification_arns = ["arn:aws:sns:region:1234567890:notify"] s3_uploader = None + tags = [{"Key":"key1", "Value": "val1"}] self.deployer.create_and_wait_for_changeset.return_value = ChangeSetResult(changeset_id, "CREATE") @@ -266,7 +274,8 @@ def test_deploy_no_execute(self): execute_changeset, role_arn, notification_arns, - s3_uploader) + s3_uploader, + tags) self.assertEqual(rc, 0) self.deployer.create_and_wait_for_changeset.assert_called_once_with(stack_name=stack_name, @@ -275,7 +284,8 @@ def test_deploy_no_execute(self): capabilities=capabilities, role_arn=role_arn, notification_arns=notification_arns, - s3_uploader=s3_uploader) + s3_uploader=s3_uploader, + tags=tags) # since execute_changeset is set to True, deploy() will execute changeset self.deployer.execute_changeset.assert_not_called() @@ -291,6 +301,7 @@ def test_deploy_raise_exception(self): role_arn = "arn:aws:iam::1234567890:role" notification_arns = ["arn:aws:sns:region:1234567890:notify"] s3_uploader = None + tags = [{"Key":"key1", "Value": "val1"}] self.deployer.wait_for_execute.side_effect = RuntimeError("Some error") with self.assertRaises(RuntimeError): @@ -302,7 +313,8 @@ def test_deploy_raise_exception(self): execute_changeset, role_arn, notification_arns, - s3_uploader) + s3_uploader, + tags) def test_deploy_raises_exception_on_empty_changeset(self): stack_name = "stack_name" @@ -312,6 +324,7 @@ def test_deploy_raises_exception_on_empty_changeset(self): execute_changeset = True role_arn = "arn:aws:iam::1234567890:role" notification_arns = ["arn:aws:sns:region:1234567890:notify"] + tags = [] empty_changeset = exceptions.ChangeEmptyError(stack_name=stack_name) changeset_func = self.deployer.create_and_wait_for_changeset @@ -320,7 +333,7 @@ def test_deploy_raises_exception_on_empty_changeset(self): self.deploy_command.deploy( self.deployer, stack_name, template, parameters, capabilities, execute_changeset, role_arn, notification_arns, - s3_uploader=None) + None, tags) def test_deploy_does_not_raise_exception_on_empty_changeset(self): stack_name = "stack_name" @@ -337,7 +350,7 @@ def test_deploy_does_not_raise_exception_on_empty_changeset(self): self.deploy_command.deploy( self.deployer, stack_name, template, parameters, capabilities, execute_changeset, role_arn, notification_arns, - s3_uploader=None, + s3_uploader=None, tags=[], fail_on_empty_changeset=False ) diff --git a/tests/unit/customizations/cloudformation/test_deployer.py b/tests/unit/customizations/cloudformation/test_deployer.py index 93087680e407..73d2f8ce37f6 100644 --- a/tests/unit/customizations/cloudformation/test_deployer.py +++ b/tests/unit/customizations/cloudformation/test_deployer.py @@ -105,6 +105,8 @@ def test_create_changeset_success(self): notification_arns = ["arn:aws:sns:region:1234567890:notify"] s3_uploader = None + tags = [{"Key":"key1", "Value": "val1"}] + # Case 1: Stack DOES NOT exist self.deployer.has_stack = Mock() self.deployer.has_stack.return_value = False @@ -118,7 +120,8 @@ def test_create_changeset_success(self): "Capabilities": capabilities, "Description": botocore.stub.ANY, "RoleARN": role_arn, - "NotificationARNs": notification_arns + "NotificationARNs": notification_arns, + "Tags": tags } response = { @@ -130,7 +133,7 @@ def test_create_changeset_success(self): with self.stub_client: result = self.deployer.create_changeset( stack_name, template, parameters, capabilities, role_arn, - notification_arns, s3_uploader) + notification_arns, s3_uploader, tags) self.assertEquals(response["Id"], result.changeset_id) self.assertEquals("CREATE", result.changeset_type) @@ -143,7 +146,7 @@ def test_create_changeset_success(self): with self.stub_client: result = self.deployer.create_changeset( stack_name, template, parameters, capabilities, role_arn, - notification_arns, s3_uploader) + notification_arns, s3_uploader, tags) self.assertEquals(response["Id"], result.changeset_id) self.assertEquals("UPDATE", result.changeset_type) @@ -187,6 +190,7 @@ def upload_with_dedup(filename,extension): "Capabilities": capabilities, "Description": botocore.stub.ANY, "RoleARN": role_arn, + "Tags": [], "NotificationARNs": notification_arns } @@ -198,8 +202,8 @@ def upload_with_dedup(filename,extension): expected_params) with self.stub_client: result = self.deployer.create_changeset( - stack_name, template, parameters, capabilities, role_arn, - notification_arns, s3_uploader) + stack_name, template, parameters, capabilities, role_arn, + notification_arns, s3_uploader, []) self.assertEquals(response["Id"], result.changeset_id) self.assertEquals("CREATE", result.changeset_type) @@ -212,7 +216,7 @@ def upload_with_dedup(filename,extension): with self.stub_client: result = self.deployer.create_changeset( stack_name, template, parameters, capabilities, role_arn, - notification_arns, s3_uploader) + notification_arns, s3_uploader, []) self.assertEquals(response["Id"], result.changeset_id) self.assertEquals("UPDATE", result.changeset_type) @@ -225,6 +229,7 @@ def test_create_changeset_exception(self): role_arn = "arn:aws:iam::1234567890:role" notification_arns = ["arn:aws:sns:region:1234567890:notify"] s3_uploader = None + tags = [{"Key":"key1", "Value": "val1"}] self.deployer.has_stack = Mock() self.deployer.has_stack.return_value = False @@ -234,7 +239,7 @@ def test_create_changeset_exception(self): with self.stub_client: with self.assertRaises(botocore.exceptions.ClientError): self.deployer.create_changeset(stack_name, template, parameters, - capabilities, role_arn, notification_arns, None) + capabilities, role_arn, notification_arns, None, tags) def test_execute_changeset(self): stack_name = "stack_name" @@ -270,6 +275,7 @@ def test_create_and_wait_for_changeset_successful(self): role_arn = "arn:aws:iam::1234567890:role" notification_arns = ["arn:aws:sns:region:1234567890:notify"] s3_uploader = None + tags = [{"Key":"key1", "Value": "val1"}] self.deployer.create_changeset = Mock() self.deployer.create_changeset.return_value = ChangeSetResult(changeset_id, changeset_type) @@ -278,7 +284,7 @@ def test_create_and_wait_for_changeset_successful(self): result = self.deployer.create_and_wait_for_changeset( stack_name, template, parameters, capabilities, role_arn, - notification_arns, s3_uploader) + notification_arns, s3_uploader, tags) self.assertEquals(result.changeset_id, changeset_id) self.assertEquals(result.changeset_type, changeset_type) @@ -293,6 +299,7 @@ def test_create_and_wait_for_changeset_error_waiting_for_changeset(self): role_arn = "arn:aws:iam::1234567890:role" notification_arns = ["arn:aws:sns:region:1234567890:notify"] s3_uploader = None + tags = [{"Key":"key1", "Value": "val1"}] self.deployer.create_changeset = Mock() self.deployer.create_changeset.return_value = ChangeSetResult(changeset_id, changeset_type) @@ -303,7 +310,7 @@ def test_create_and_wait_for_changeset_error_waiting_for_changeset(self): with self.assertRaises(RuntimeError): result = self.deployer.create_and_wait_for_changeset( stack_name, template, parameters, capabilities, role_arn, - notification_arns, s3_uploader) + notification_arns, s3_uploader, tags) def test_wait_for_changeset_no_changes(self): stack_name = "stack_name"