Repository: ansible-collections/amazon.aws

Issue:
Idempotence fails with backup_plan on botocore >= 1.31.36
### Summary

The `backup_plan` module is not idempotent on botocore >= 1.31.36. This is because timezone customization support was added for rules (https://github.com/boto/boto3/blob/86426e872033d2d03e083fa53848e53d099ebcde/CHANGELOG.rst#L1112). The `ScheduleExpressionTimezone` field is always returned when describing a backup plan and defaults to `Etc/UTC`.

### Issue Type

Bug Report

### Component Name

backup_plan

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.17.0.dev0] (milestone 5812cabaf5) last updated 2023/11/29 15:10:13 (GMT -400)
  config file = None
  configured module search path = ['/home/mgraves/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/mgraves/git/ansible/lib/ansible
  ansible collection location = /home/mgraves/git:/home/mgraves/.ansible/collections
  executable location = /home/mgraves/git/ansible/bin/ansible
  python version = 3.10.2 (main, Feb 22 2022, 13:57:33) [GCC 10.2.1 20210110] (/home/mgraves/git/ansible/venv-3.10/bin/python)
  jinja version = 3.1.2
  libyaml = True
```


### Collection Versions

```console (paste below)
$ ansible-galaxy collection list
amazon.aws                8.0.0-dev0
```


### AWS SDK versions

```console (paste below)
$ pip show boto boto3 botocore
Name: boto3
Version: 1.28.35
Summary: The AWS SDK for Python
Home-page: https://github.com/boto/boto3
Author: Amazon Web Services
Author-email: 
License: Apache License 2.0
Location: /home/mgraves/git/ansible/venv-3.10/lib/python3.10/site-packages
Requires: botocore, jmespath, s3transfer
Required-by: 
---
Name: botocore
Version: 1.31.36
Summary: Low-level, data-driven core of boto 3.
Home-page: https://github.com/boto/botocore
Author: Amazon Web Services
Author-email: 
License: Apache License 2.0
Location: /home/mgraves/git/ansible/venv-3.10/lib/python3.10/site-packages
Requires: jmespath, python-dateutil, urllib3
Required-by: boto3, s3transfer
```


### Configuration

_No response_

### OS / Environment

_No response_

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
- hosts: localhost
  gather_facts: false
  vars:
    backup_vault_name: "mgraves-test-backup-vault"
    backup_plan_name: "mgraves-test-backup-plan"
  tasks:
    - name: Create a backup vault for the plan to target
      amazon.aws.backup_vault:
        backup_vault_name: "{{ backup_vault_name }}"

    - name: Create a backup plan
      amazon.aws.backup_plan:
        backup_plan_name: "{{ backup_plan_name }}"
        rules:
          - rule_name: daily
            target_backup_vault_name: "{{ backup_vault_name }}"
        tags:
          Environment: Test

    - name: Create the same backup plan
      amazon.aws.backup_plan:
        backup_plan_name: "{{ backup_plan_name }}"
        rules:
          - rule_name: daily
            target_backup_vault_name: "{{ backup_vault_name }}"
        tags:
          Environment: Test
```


### Expected Results

The second task creating a backup plan should not show changed.

### Actual Results

The second task creating a backup plan shows that it changed.

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:

Resolving patch:
diff --git a/plugins/modules/backup_plan.py b/plugins/modules/backup_plan.py
--- a/plugins/modules/backup_plan.py
+++ b/plugins/modules/backup_plan.py
@@ -125,6 +125,15 @@
           - AWS default if not supplied is false.
         type: bool
         default: false
+      schedule_expression_timezone:
+        description:
+          - This is the timezone in which the schedule expression is set.
+          - By default, ScheduleExpressions are in UTC. You can modify this to a specified timezone.
+          - This option requires botocore >= 1.31.36.
+        type: str
+        default: "Etc/UTC"
+        required: false
+        version_added: 7.3.0
   advanced_backup_settings:
     description:
       - Specifies a list of advanced backup settings for each resource type.
@@ -233,6 +242,70 @@
       returned: always
       type: list
       elements: dict
+      contains:
+        rule_name:
+          description: A display name for a backup rule.
+          returned: always
+          type: str
+          sample: "daily"
+        target_backup_vault_name:
+          description: The name of a logical container where backups are stored.
+          returned: always
+          type: str
+          sample: 09da67966fd5-backup-vault"
+        schedule_expression:
+          description: A cron expression in UTC specifying when Backup initiates a backup job.
+          returned: always
+          type: str
+          sample: "cron(0 5 ? * * *)"
+        start_window_minutes:
+          description:
+            - A value in minutes after a backup is scheduled before a job will be canceled if it
+              doesn't start successfully.
+          type: int
+          sample: 480
+        completion_window_minutes:
+          description:
+            - A value in minutes after a backup job is successfully started before it must be
+              completed or it will be canceled by Backup.
+          type: int
+          sample: 10080
+        lifecycle:
+          description:
+            - The lifecycle defines when a protected resource is transitioned to cold storage and when
+              it expires.
+          type: dict
+          sample: {}
+        recovery_point_tags:
+          description:
+            - An array of key-value pair strings that are assigned to resources that are associated with
+              this rule when restored from backup.
+          type: dict
+          sample: {}
+        rule_id:
+          description:
+            - Uniquely identifies a rule that is used to schedule the backup of a selection of resources.
+          type: str
+          returned: always
+          sample: "973621ef-d863-41ef-b5c3-9e943a64ad0c"
+        copy_actions:
+          description: An array of CopyAction objects, which contains the details of the copy operation.
+          type: list
+          returned: always
+          sample: []
+        enable_continous_backup:
+          description: Specifies whether Backup creates continuous backups.
+          type: bool
+          returned: always
+          sample: false
+        schedule_expression_timezone:
+          description:
+            - This is the timezone in which the schedule expression is set.
+            - This information is returned for botocore versions >= 1.31.36.
+          type: str
+          returned: when botocore >= 1.31.36
+          sample: "Etc/UTC"
+          version_added: 7.3.0
     advanced_backup_settings:
       description: Advanced backup settings of the backup plan.
       returned: when configured
@@ -281,6 +354,7 @@
             schedule_expression=dict(type="str", default="cron(0 5 ? * * *)"),
             start_window_minutes=dict(type="int", default=480),
             completion_window_minutes=dict(type="int", default=10080),
+            schedule_expression_timezone=dict(type="str", default="Etc/UTC"),
             lifecycle=dict(
                 type="dict",
                 options=dict(
@@ -542,6 +616,7 @@ def main():
     client = module.client("backup")
     state = module.params["state"]
     plan_name = module.params["backup_plan_name"]
+
     plan = {
         "backup_plan_name": module.params["backup_plan_name"],
         "rules": [scrub_none_parameters(rule) for rule in module.params["rules"] or []],
@@ -549,6 +624,15 @@ def main():
             scrub_none_parameters(setting) for setting in module.params["advanced_backup_settings"] or []
         ],
     }
+
+    if module.params["rules"]:
+        for each in plan["rules"]:
+            if not module.botocore_at_least("1.31.36"):
+                module.warn(
+                    "schedule_expression_timezone requires botocore >= 1.31.36. schedule_expression_timezone will be ignored."
+                )
+                each.pop("schedule_expression_timezone")
+
     tags = module.params["tags"]
 
     # Get existing backup plan details and ID if present


Test patch:
diff --git a/tests/integration/targets/backup_plan/meta/main.yml b/tests/integration/targets/backup_plan/meta/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/backup_plan/meta/main.yml
@@ -0,0 +1,5 @@
+---
+dependencies:
+  - role: setup_botocore_pip
+    vars:
+      botocore_version: "1.31.36"
diff --git a/tests/integration/targets/backup_plan/tasks/main.yml b/tests/integration/targets/backup_plan/tasks/main.yml
--- a/tests/integration/targets/backup_plan/tasks/main.yml
+++ b/tests/integration/targets/backup_plan/tasks/main.yml
@@ -305,6 +305,45 @@
           - backup_plan_delete_result.backup_plan_arn == backup_plan_info.backup_plans[0].backup_plan_arn
           - backup_plan_delete_result.deletion_date is defined
 
+    - name: Create a backup plan using specific botocore version
+      amazon.aws.backup_plan:
+        backup_plan_name: "{{ backup_plan_name }}"
+        rules:
+          - rule_name: daily
+            target_backup_vault_name: "{{ backup_vault_name }}"
+        tags:
+          Environment: Test
+      register: backup_plan_create_result
+      vars:
+        ansible_python_interpreter: "{{ botocore_virtualenv_interpreter }}"
+
+    - name: Verify backup plan create result
+      ansible.builtin.assert:
+        that:
+          - backup_plan_create_result.exists is true
+          - backup_plan_create_result.changed is true
+          - backup_plan_create_result.backup_plan_name == backup_plan_name
+          - backup_plan_create_result.backup_plan.rules != []
+          - "backup_plan_create_result.backup_plan.rules | selectattr('schedule_expression_timezone', 'match', 'Etc/UTC') | list"
+
+    - name: Create a backup plan using specific botocore version (idempotency)
+      amazon.aws.backup_plan:
+        backup_plan_name: "{{ backup_plan_name }}"
+        rules:
+          - rule_name: daily
+            target_backup_vault_name: "{{ backup_vault_name }}"
+        tags:
+          Environment: Test
+      register: backup_plan_create_result
+      vars:
+        ansible_python_interpreter: "{{ botocore_virtualenv_interpreter }}"
+
+    - name: Verify backup plan create result
+      ansible.builtin.assert:
+        that:
+          - backup_plan_create_result.exists is true
+          - backup_plan_create_result.changed is false
+
   always:
     - name: Delete AWS Backup plan created during this test
       amazon.aws.backup_plan: