Repository: ansible-collections/amazon.aws

Issue:
rds_cluster: Add support for more states (stopped, started, restarted)
### Summary

I am trying to implement a playbook to stop and start an application environment to save money.
The stack includes RDS Aurora clusters and the AWS collection only supports present and absent states.

AWS added support to interrupt RDS clusters temporarily with some caveats:
- https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_StopInstance.html
- https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-cluster-stop-start.html

It would be great to have support for these additional states for us and the community. Automated solution exists (example: https://aws.amazon.com/blogs/database/save-costs-by-automating-the-start-and-stop-of-amazon-rds-instances-with-aws-lambda-and-amazon-eventbridge/) but I would like to provide the flexibility to our development team to do it on demand, not on schedule.

### Issue Type

Feature Idea

### Component Name

rds_cluster

### Additional Information

```yaml (paste below)
- name: Get database cluster information
  amazon.aws.rds_cluster_info:
    cluster_name: "{{ rds_cluster_name }}"
    region: "{{ aws_region }}"
  register: cluster_info

- name: Stop database cluster (AWS restarts it automatically after 7 days)
  amazon.aws.rds_cluster:
    db_cluster_identifier: "{{ cluster_info.clusters[0].db_cluster_identifier }}"
    state: stopped
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:

Resolving patch:
diff --git a/plugins/module_utils/rds.py b/plugins/module_utils/rds.py
--- a/plugins/module_utils/rds.py
+++ b/plugins/module_utils/rds.py
@@ -35,6 +35,8 @@
     "remove_tags_from_resource",
     "list_tags_for_resource",
     "promote_read_replica_db_cluster",
+    "stop_db_cluster",
+    "start_db_cluster",
 ]
 instance_method_names = [
     "create_db_instance",
diff --git a/plugins/modules/rds_cluster.py b/plugins/modules/rds_cluster.py
--- a/plugins/modules/rds_cluster.py
+++ b/plugins/modules/rds_cluster.py
@@ -24,8 +24,11 @@
 options:
   # General module options
     state:
-        description: Whether the snapshot should exist or not.
-        choices: ['present', 'absent']
+        description:
+          - Whether the snapshot should exist or not.
+          - C(started) and C(stopped) can only be used with aurora clusters
+          - Support for C(started) and C(stopped) was added in release 6.3.0.
+        choices: ['present', 'absent', 'started', 'stopped']
         default: 'present'
         type: str
     creation_source:
@@ -689,7 +692,6 @@
       sample: sg-12345678
 """
 
-
 try:
     import botocore
 except ImportError:
@@ -921,6 +923,14 @@ def get_rds_method_attribute_name(cluster):
         if cluster and cluster["Status"] not in ["deleting", "deleted"]:
             method_name = "delete_db_cluster"
             method_options_name = "get_delete_options"
+    elif state == "started":
+        if cluster and cluster["Status"] not in ["starting", "started", "available"]:
+            method_name = "start_db_cluster"
+            method_options_name = "get_modify_options"
+    elif state == "stopped":
+        if cluster and cluster["Status"] not in ["stopping", "stopped"]:
+            method_name = "stop_db_cluster"
+            method_options_name = "get_modify_options"
     else:
         if cluster:
             method_name = "modify_db_cluster"
@@ -1030,6 +1040,16 @@ def changing_cluster_options(modify_params, current_cluster):
         if apply_immediately is not None:
             changing_params["ApplyImmediately"] = apply_immediately
 
+    if module.params["state"] == "started":
+        if current_cluster["Engine"] in ["mysql", "postgres"]:
+            module.fail_json("Only aurora clusters can use the state started")
+        changing_params["DBClusterIdentifier"] = db_cluster_id
+
+    if module.params["state"] == "stopped":
+        if current_cluster["Engine"] in ["mysql", "postgres"]:
+            module.fail_json("Only aurora clusters can use the state stopped")
+        changing_params["DBClusterIdentifier"] = db_cluster_id
+
     return changing_params
 
 
@@ -1087,7 +1107,7 @@ def main():
     global client
 
     arg_spec = dict(
-        state=dict(choices=["present", "absent"], default="present"),
+        state=dict(choices=["present", "absent", "started", "stopped"], default="present"),
         creation_source=dict(type="str", choices=["snapshot", "s3", "cluster"]),
         force_update_password=dict(type="bool", default=False),
         promote=dict(type="bool", default=False),


Test patch:
diff --git a/tests/integration/targets/rds_cluster_states/aliases b/tests/integration/targets/rds_cluster_states/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/rds_cluster_states/aliases
@@ -0,0 +1,4 @@
+time=30m
+cloud/aws
+rds_cluster
+rds_cluster_info
\ No newline at end of file
diff --git a/tests/integration/targets/rds_cluster_states/defaults/main.yml b/tests/integration/targets/rds_cluster_states/defaults/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/rds_cluster_states/defaults/main.yml
@@ -0,0 +1,11 @@
+cluster_id: ansible-test-cluster-{{ tiny_prefix }}
+username: testrdsusername
+password: test-rds_password
+engine: aurora
+db_cluster_instance_class: db.r5.large
+
+mysql_cluster_id: ansible-test-mysql-cluster-{{ tiny_prefix }}
+mysql_engine: mysql
+mysql_allocated_storage: 100
+mysql_iops: 1000
+mysql_db_cluster_instance_class: db.m5d.large
\ No newline at end of file
diff --git a/tests/integration/targets/rds_cluster_states/tasks/main.yml b/tests/integration/targets/rds_cluster_states/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/rds_cluster_states/tasks/main.yml
@@ -0,0 +1,240 @@
+- module_defaults:
+    group/aws:
+      region: '{{ aws_region }}'
+      aws_access_key: '{{ aws_access_key }}'
+      aws_secret_key: '{{ aws_secret_key }}'
+      security_token: '{{ security_token | default(omit) }}'
+  block:
+    # ------------------------------------------------------------------------------------------
+    # Create DB cluster
+    - name: Ensure the resource doesn't exist
+      rds_cluster:
+        id: '{{ cluster_id }}'
+        state: absent
+        engine: '{{ engine}}'
+        username: '{{ username }}'
+        password: '{{ password }}'
+        skip_final_snapshot: true
+      register: _result_delete_db_cluster
+
+    - assert:
+        that:
+        - not _result_delete_db_cluster.changed
+      ignore_errors: yes
+
+    - name: Create an Aurora-PostgreSQL DB cluster
+      rds_cluster:
+        id: '{{ cluster_id }}'
+        state: present
+        engine: aurora-postgresql
+        engine_mode: provisioned
+        username: '{{ username }}'
+        password: '{{ password }}'
+      register: _result_create_source_db_cluster
+
+    - assert:
+        that:
+        - _result_create_source_db_cluster.changed
+        - "'allocated_storage' in _result_create_source_db_cluster"
+        - _result_create_source_db_cluster.allocated_storage == 1
+        - "'cluster_create_time' in _result_create_source_db_cluster"
+        - _result_create_source_db_cluster.copy_tags_to_snapshot == false
+        - "'db_cluster_arn' in _result_create_source_db_cluster"
+        - _result_create_source_db_cluster.db_cluster_identifier == '{{ cluster_id }}'
+        - "'db_cluster_parameter_group' in _result_create_source_db_cluster"
+        - "'db_cluster_resource_id' in _result_create_source_db_cluster"
+        - "'endpoint' in _result_create_source_db_cluster"
+        - "'engine' in _result_create_source_db_cluster"
+        - _result_create_source_db_cluster.engine == "aurora-postgresql"
+        - "'engine_mode' in _result_create_source_db_cluster"
+        - _result_create_source_db_cluster.engine_mode == "provisioned"
+        - "'engine_version' in _result_create_source_db_cluster"
+        - "'master_username' in _result_create_source_db_cluster"
+        - _result_create_source_db_cluster.master_username == "{{ username }}"
+        - "'port' in _result_create_source_db_cluster"
+        - "'status' in _result_create_source_db_cluster"
+        - _result_create_source_db_cluster.status == "available"
+        - "'tags' in _result_create_source_db_cluster"
+        - "'vpc_security_groups' in _result_create_source_db_cluster"
+
+    # ------------------------------------------------------------------------------------------
+    # Test stopping db clusters
+    - name: Stop db clusters - checkmode
+      amazon.aws.rds_cluster:
+        cluster_id: '{{ cluster_id }}'
+        state: stopped
+      register: check_stopped_cluster
+      check_mode: yes
+
+    - assert:
+        that:
+        - check_stopped_cluster.changed
+
+    - name: Stop db clusters
+      amazon.aws.rds_cluster:
+        cluster_id: '{{ cluster_id }}'
+        state: stopped
+      register: stopped_cluster
+
+    - assert:
+        that:
+        - stopped_cluster.changed
+    
+    - name: Wait until db clusters state is stopped
+      amazon.aws.rds_cluster_info:
+        cluster_id: '{{ cluster_id }}'
+      register: stopped_cluster_info
+      retries: 30
+      delay: 60
+      until: stopped_cluster_info.clusters[0].status == "stopped"
+
+    - name: Stop db clusters (idempotence) - checkmode
+      amazon.aws.rds_cluster:
+        cluster_id: '{{ cluster_id }}'
+        state: stopped
+      register: check_stopped_cluster_idem
+      check_mode: yes
+
+    - assert:
+        that:
+        - not check_stopped_cluster_idem.changed
+
+    - name: Stop db clusters (idempotence)
+      amazon.aws.rds_cluster:
+        cluster_id: '{{ cluster_id }}'
+        state: stopped
+      register: stopped_cluster_idem
+
+    - assert:
+        that:
+        - not stopped_cluster_idem.changed
+
+    # ------------------------------------------------------------------------------------------
+    # Test starting DB clusters
+    - name: Start db clusters - checkmode
+      amazon.aws.rds_cluster:
+        cluster_id: '{{ cluster_id }}'
+        state: started
+      register: check_started_cluster
+      check_mode: yes
+
+    - assert:
+        that:
+        - check_started_cluster.changed
+    
+    - name: Start db clusters
+      amazon.aws.rds_cluster:
+        cluster_id: '{{ cluster_id }}'
+        state: started
+      register: started_cluster
+
+    - assert:
+        that:
+        - started_cluster.changed
+    
+    - name: Start db clusters (idempotence) - checkmode
+      amazon.aws.rds_cluster:
+        cluster_id: '{{ cluster_id }}'
+        state: started
+      register: check_started_cluster
+      check_mode: yes
+
+    - assert:
+        that:
+        - not check_started_cluster.changed
+
+    - name: Start db clusters
+      amazon.aws.rds_cluster:
+        cluster_id: '{{ cluster_id }}'
+        state: started
+      register: started_cluster
+
+    - assert:
+        that:
+        - not started_cluster.changed
+
+    # ------------------------------------------------------------------------------------------
+    # Give errors for MySql DB cluster
+    - name: Ensure the resource doesn't exist
+      rds_cluster:
+        id: '{{ mysql_cluster_id }}'
+        state: absent
+        engine: '{{ mysql_engine }}'
+        username: '{{ username }}'
+        password: '{{ password }}'
+        skip_final_snapshot: true
+      register: _result_delete_mysql_db_cluster
+  
+    - assert:
+        that:
+        - not _result_delete_mysql_db_cluster.changed
+      ignore_errors: yes
+    
+    - name: Create an MySql DB cluster
+      rds_cluster:
+        id: '{{ mysql_cluster_id }}'
+        state: present
+        engine: '{{ mysql_engine }}'
+        engine_mode: provisioned
+        allocated_storage: '{{ mysql_allocated_storage }}'
+        iops: '{{ mysql_iops }}'
+        db_cluster_instance_class: '{{ mysql_db_cluster_instance_class }}'
+        username: '{{ username }}'
+        password: '{{ password }}'
+      ignore_errors: yes
+      register: mysql_cluster
+
+    - assert:
+        that:
+        - mysql_cluster.changed
+        - "'allocated_storage' in mysql_cluster"
+        - mysql_cluster.allocated_storage == 100
+        - "'cluster_create_time' in mysql_cluster"
+        - mysql_cluster.copy_tags_to_snapshot == false
+        - "'db_cluster_arn' in mysql_cluster"
+        - mysql_cluster.db_cluster_identifier == "{{ mysql_cluster_id }}"
+        - "'db_cluster_parameter_group' in mysql_cluster"
+        - "'db_cluster_resource_id' in mysql_cluster"
+        - "'endpoint' in mysql_cluster"
+        - "'engine' in mysql_cluster"
+        - mysql_cluster.engine == "{{ mysql_engine }}"
+        - "'engine_mode' in mysql_cluster"
+        - mysql_cluster.engine_mode == "provisioned"
+        - "'engine_version' in mysql_cluster"
+        - "'master_username' in mysql_cluster"
+        - mysql_cluster.master_username == "{{ username }}"
+        - "'port' in mysql_cluster"
+        - "'status' in mysql_cluster"
+        - mysql_cluster.status == "available"
+        - "'tags' in mysql_cluster"
+        - "'vpc_security_groups' in mysql_cluster"
+      
+
+    - name: Stop MySql DB cluster
+      amazon.aws.rds_cluster:
+        cluster_id: '{{ mysql_cluster_id }}'
+        state: stopped
+      register: mysql_cluster
+      ignore_errors: true
+
+    - assert:
+        that:
+        - mysql_cluster is failed
+        - mysql_cluster.msg == "Only aurora clusters can use the state stopped"
+
+  always:
+    # ------------------------------------------------------------------------------------------
+    # Cleanup starts here
+    - name: Delete MySql db cluster without creating a final snapshot
+      rds_cluster:
+        state: absent
+        cluster_id: '{{ mysql_cluster_id }}'
+        skip_final_snapshot: true
+      ignore_errors: true
+
+    - name: Delete Aurora-PostgreSql db cluster without creating a final snapshot
+      rds_cluster:
+        state: absent
+        cluster_id: '{{ cluster_id }}'
+        skip_final_snapshot: true
+      ignore_errors: true
\ No newline at end of file
diff --git a/tests/unit/module_utils/test_rds.py b/tests/unit/module_utils/test_rds.py
--- a/tests/unit/module_utils/test_rds.py
+++ b/tests/unit/module_utils/test_rds.py
@@ -137,6 +137,36 @@ def test__wait_for_cluster_snapshot_status_failed(input, expected):
                 )
             ),
         ),
+        (
+            "start_db_cluster",
+            {
+                "new_db_cluster_identifier": "test",
+            },
+            *expected(
+                rds.Boto3ClientMethod(
+                    name="start_db_cluster",
+                    waiter="cluster_available",
+                    operation_description="start DB cluster",
+                    resource="cluster",
+                    retry_codes=["InvalidDBClusterState"],
+                )
+            ),
+        ),
+        (
+            "stop_db_cluster",
+            {
+                "new_db_cluster_identifier": "test",
+            },
+            *expected(
+                rds.Boto3ClientMethod(
+                    name="stop_db_cluster",
+                    waiter="cluster_available",
+                    operation_description="stop DB cluster",
+                    resource="cluster",
+                    retry_codes=["InvalidDBClusterState"],
+                )
+            ),
+        ),
         (
             "restore_db_cluster_from_snapshot",
             {