Repository: ansible-collections/amazon.aws

Issue:
amazon.aws.cloudformation module update when failure
### Summary

Hello,

Using the module docs.ansible.com/ansible/latest/collections/amazon/aws/cloudformation_module.html and the parameter "disable_rollback: true" will create a resource and wont delete the stack if it fails. 
But if you fix the error in the code and try to run it again you will get the message:

`TASK [create a cloudformation stack] *******************************************
An exception occurred during task execution. To see the full traceback, use -vvv. The error was: botocore.exceptions.ClientError: An error occurred (ValidationError) when calling the UpdateStack operation: This stack is currently in a non-terminal [CREATE_FAILED] state. To update the stack from this state, please use the disable-rollback parameter with update-stack API. To rollback to the last known good state, use the rollback-stack API
fatal: [localhost]: FAILED! => {"boto3_version": "1.26.131", "botocore_version": "1.29.131", "changed": false, "error": {"code": "ValidationError", "message": "This stack is currently in a non-terminal [CREATE_FAILED] state. To update the stack from this state, please use the disable-rollback parameter with update-stack API. To rollback to the last known good state, use the rollback-stack API", "type": "Sender"}, "msg": "Failed to update stack dev-acap: An error occurred (ValidationError) when calling the UpdateStack operation: This stack is currently in a non-terminal [CREATE_FAILED] state. To update the stack from this state, please use the disable-rollback parameter with update-stack API. To rollback to the last known good state, use the rollback-stack API", "response_metadata": {"http_headers": {"connection": "keep-alive", "content-length": "492", "content-type": "text/xml", "date": "Wed, 12 Jul 2023 12:47:22 GMT", "x-amzn-requestid": "ef72523f-f5d3-4d35-a14d-4e4881deef35"}, "http_status_code": 400, "request_id": "ef72523f-f5d3-4d35-a14d-4e4881deef35", "retry_attempts": 0}}`

 I checked CloudTrail for the API calls “CreateStack” and “UpdateStack”. I could see that the CreateStack was using the "disableRollback":true parameter but the UpdateStack API call was not. "requestParameters":null.

This means that Ansible is only specifying the disable rollback parameter on create and not on update. This is why it is unable to update the stack in the non-terminal [CREATE_FAILED] state. I tried to use the "on_create_failure : DO_NOTHING" parameter as well but that would not let it update it, it has to be created with the disable rollback parameter. 


The only way to fix this is to manually execute the aws cli update-stack disable-rollback command. 

The module needs to take care of this use case 

### Issue Type

Feature Idea

### Component Name

cloudformation

### Additional Information

If this feature is added to the module, thee user will no longer need to find workarounds( like interrogate the CF stack, get the state, execute api calls ,etc )

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/cloudformation.py`](https://github.com/['ansible-collections/amazon.aws', 'ansible-collections/community.aws', 'ansible-collections/community.vmware']/blob/main/plugins/modules/cloudformation.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @jillr @jsmartin @s-hertel @tremble
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/modules/cloudformation.py b/plugins/modules/cloudformation.py
--- a/plugins/modules/cloudformation.py
+++ b/plugins/modules/cloudformation.py
@@ -384,7 +384,7 @@ def create_stack(module, stack_params, cfn, events_limit):
             msg="Either 'template', 'template_body' or 'template_url' is required when the stack does not exist."
         )
 
-    # 'DisableRollback', 'TimeoutInMinutes', 'EnableTerminationProtection' and
+    # 'TimeoutInMinutes', 'EnableTerminationProtection' and
     # 'OnFailure' only apply on creation, not update.
     if module.params.get("on_create_failure") is not None:
         stack_params["OnFailure"] = module.params["on_create_failure"]
@@ -483,6 +483,8 @@ def update_stack(module, stack_params, cfn, events_limit):
     if module.params["stack_policy_on_update_body"] is not None:
         stack_params["StackPolicyDuringUpdateBody"] = module.params["stack_policy_on_update_body"]
 
+    stack_params["DisableRollback"] = module.params["disable_rollback"]
+
     # if the state is present and the stack already exists, we try to update it.
     # AWS will tell us if the stack template and parameters are the same and
     # don't need to be updated.


Test patch:
diff --git a/tests/integration/targets/cloudformation/defaults/main.yml b/tests/integration/targets/cloudformation/defaults/main.yml
--- a/tests/integration/targets/cloudformation/defaults/main.yml
+++ b/tests/integration/targets/cloudformation/defaults/main.yml
@@ -1,4 +1,6 @@
 stack_name: "{{ resource_prefix }}"
+stack_name_disable_rollback_true: "{{ resource_prefix }}-drb-true"
+stack_name_disable_rollback_false: "{{ resource_prefix }}-drb-false"
 
 availability_zone: '{{ ec2_availability_zone_names[0] }}'
 
diff --git a/tests/integration/targets/cloudformation/tasks/main.yml b/tests/integration/targets/cloudformation/tasks/main.yml
--- a/tests/integration/targets/cloudformation/tasks/main.yml
+++ b/tests/integration/targets/cloudformation/tasks/main.yml
@@ -25,6 +25,10 @@
         az: "{{ availability_zone }}"
       register: testing_subnet
 
+    # ==== Cloudformation tests with disable_rollback ====================
+
+    - import_tasks: test_disable_rollback.yml
+
     # ==== Cloudformation tests ===============================================
 
     # 1. Basic stack creation (check mode, actual run and idempotency)
diff --git a/tests/integration/targets/cloudformation/tasks/test_disable_rollback.yml b/tests/integration/targets/cloudformation/tasks/test_disable_rollback.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/cloudformation/tasks/test_disable_rollback.yml
@@ -0,0 +1,218 @@
+---
+- name: Run cloudformation tests for `disable_rollback` parameter
+  block:
+
+    # disable rollback to true
+    - name: create a cloudformation stack (disable_rollback=true) (check mode)
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name_disable_rollback_true }}"
+        state: present
+        disable_rollback: true
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}"
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+      check_mode: yes
+
+    - name: check task return attributes
+      assert:
+        that:
+          - cf_stack.changed
+          - "'msg' in cf_stack and 'New stack would be created' in cf_stack.msg"
+
+    - name: create a cloudformation stack (disable_rollback=true)
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name_disable_rollback_true }}"
+        state: present
+        disable_rollback: true
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}"
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+
+    - name: get stack details
+      cloudformation_info:
+        stack_name: "{{ stack_name_disable_rollback_true }}"
+      register: stack_info
+
+    - name: assert stack info
+      assert:
+        that:
+          - "'cloudformation' in stack_info"
+          - stack_info.cloudformation | length == 1
+          - stack_info.cloudformation[stack_name_disable_rollback_true].stack_description.disable_rollback == true
+
+    # disable rollback to false
+    - name: create a cloudformation stack (disable_rollback=false) (check mode)
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name_disable_rollback_false }}"
+        state: present
+        disable_rollback: false
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}"
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+      check_mode: yes
+
+    - name: check task return attributes
+      assert:
+        that:
+          - cf_stack.changed
+          - "'msg' in cf_stack and 'New stack would be created' in cf_stack.msg"
+
+    - name: create a cloudformation stack (disable_rollback=false)
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name_disable_rollback_false }}"
+        state: present
+        disable_rollback: false
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}"
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+
+    - name: get stack details
+      cloudformation_info:
+        stack_name: "{{ stack_name_disable_rollback_false }}"
+      register: stack_info
+
+    - name: assert stack info
+      assert:
+        that:
+          - "'cloudformation' in stack_info"
+          - "stack_info.cloudformation | length == 1"
+          - "stack_info.cloudformation[stack_name_disable_rollback_false].stack_description.disable_rollback == false"
+
+    # disable rollback not set
+    - name: create a cloudformation stack (disable_rollback not set) (check mode)
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name }}"
+        state: present
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}"
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+      check_mode: yes
+
+    - name: check task return attributes
+      assert:
+        that:
+          - cf_stack.changed
+          - "'msg' in cf_stack and 'New stack would be created' in cf_stack.msg"
+
+    - name: create a cloudformation stack (disable_rollback not set)
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name }}"
+        state: present
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}"
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+
+    - name: get stack details
+      cloudformation_info:
+        stack_name: "{{ stack_name }}"
+      register: stack_info
+
+    - name: assert stack info
+      assert:
+        that:
+          - "'cloudformation' in stack_info"
+          - "stack_info.cloudformation | length == 1"
+          - "stack_info.cloudformation[stack_name].stack_description.disable_rollback == false"
+
+    # =============================================================================================
+    # Test Scenario
+    # 1. create a cloudformation stack
+    # 2. try update, FAILED by providing wrong ami id (disable_rollback=true, do not delete failed stack)
+    # 3. Fix the ami id, retry update, fails as disable_rollback=False
+    # 4. Try (3) with disable_rollback=true, update completes
+    # =============================================================================================
+
+    - name: Create a cloudformation stack
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name }}-failtest"
+        state: present
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        disable_rollback: false
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}"
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+      ignore_errors: true
+
+    - name:  Update the cloudformation stack with wrong ami (fails, does not delete failed as disable_rollback=true)
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name }}-failtest"
+        state: present
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        disable_rollback: true
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}1" # wrong ami provided
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+      ignore_errors: true
+
+    # update stack by correcting AMI ID
+    - name: Fix the AMI ID and retry updating the cloudformation stack (fails with disable_rollback=false)
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name }}-failtest"
+        state: present
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        disable_rollback: false
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}"
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+      ignore_errors: true
+
+    - name: Fix the AMI ID and retry updating the cloudformation stack (passes with disable_rollback=true)
+      amazon.aws.cloudformation:
+        stack_name: "{{ stack_name }}-failtest"
+        state: present
+        template_body: "{{ lookup('file','cf_template.json') }}"
+        disable_rollback: true
+        template_parameters:
+          InstanceType: "t3.nano"
+          ImageId: "{{ ec2_ami_id }}"
+          SubnetId: "{{ testing_subnet.subnet.id }}"
+      register: cf_stack
+
+    - name: get stack details
+      cloudformation_info:
+        stack_name: "{{ stack_name }}-failtest"
+      register: stack_info
+
+    - name: Assert that update was successful
+      assert:
+        that:
+          - cf_stack.changed
+          - cf_stack.output == "Stack UPDATE complete"
+          - stack_info.cloudformation["{{ stack_name }}-failtest"].stack_description.stack_status == "UPDATE_COMPLETE"
+
+  always:
+
+    - name: delete stack
+      cloudformation:
+        stack_name: "{{ item }}"
+        state: absent
+      ignore_errors: true
+      with_items:
+        - "{{ stack_name_disable_rollback_true }}"
+        - "{{ stack_name_disable_rollback_false }}"
+        - "{{ stack_name }}-failtest"
+        - "{{ stack_name }}"