Repository: ansible-collections/amazon.aws

Issue:
backup_selection conditions update incorrectly reports changed
### Summary

When adding the `conditions` option to an idempotent backup selection update, the selection does not change but the result returns `"changed": true`. This happens because a user can pass one or more of the [four possible `conditions` suboptions](https://github.com/ansible-collections/amazon.aws/blob/aae17c7cfdf1215a9a63b7722f0a93cefdc0a54d/plugins/modules/backup_selection.py#L72), and any suboptions not passed will simply not exist in the module data (rather than being present with an empty or `None` value). 

Later, when we [compare the provided `conditions` with the conditions value from the current AWS backup selection](https://github.com/ansible-collections/amazon.aws/blob/aae17c7cfdf1215a9a63b7722f0a93cefdc0a54d/plugins/modules/backup_selection.py#L206), they are never equal because the boto3 client's `get_backup_selection()` returns _all_ condition suboptions instead of just the ones that have values. Any that do not have values are returned as empty lists, which are not present in the data provided by the user.

Ultimately this still results in the backup selection update being correct, it's just the returned `changed` value that's wrong.

### Issue Type

Bug Report

### Component Name

backup_selection

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.15.0]
  config file = /Users/hebailey/.ansible.cfg
  configured module search path = ['/Users/hebailey/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/hebailey/.pyenv/versions/3.11.3/envs/ansible-core-2.15/lib/python3.11/site-packages/ansible
  ansible collection location = /Users/hebailey:/Users/hebailey
  executable location = /Users/hebailey/.pyenv/versions/ansible-core-2.15/bin/ansible
  python version = 3.11.3 (main, May 15 2023, 16:53:55) [Clang 14.0.3 (clang-1403.0.22.14.1)] (/Users/hebailey/.pyenv/versions/3.11.3/envs/ansible-core-2.15/bin/python3.11)
  jinja version = 3.1.2
  libyaml = True
```


### Collection Versions

```console (paste below)
$ ansible-galaxy collection list
Collection       Version   
---------------- ----------
.mypy_cache.3.11 *         
amazon.aws       7.0.0-dev0
cloud.aws_ops    1.0.3     
cloud.terraform  1.1.0     
community.aws    6.0.0-dev0
```


### AWS SDK versions

```console (paste below)
$ pip show boto boto3 botocore
Name: boto3
Version: 1.26.151
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: /Users/hebailey/.pyenv/versions/3.11.3/envs/ansible-core-2.15/lib/python3.11/site-packages
Requires: botocore, jmespath, s3transfer
Required-by: 
---
Name: botocore
Version: 1.29.151
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: /Users/hebailey/.pyenv/versions/3.11.3/envs/ansible-core-2.15/lib/python3.11/site-packages
Requires: jmespath, python-dateutil, urllib3
Required-by: boto3, s3transfer
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed

```


### OS / Environment

_No response_

### Steps to Reproduce

This is modified from the existing integration test to include `conditions`.
<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
    - name: Create an AWS Backup selection
      amazon.aws.backup_selection:
        selection_name: "{{ backup_selection_name }}"
        backup_plan_name: "{{ backup_plan_name }}"
        iam_role_arn: "{{ iam_role.iam_role.arn }}"
        list_of_tags:
          - condition_type: "STRINGEQUALS"
            condition_key: "backup"
            condition_value: "daily"
      register: _create_result_backup_selection

# Skipping several tests irrelevant to this issue

    - name: Modify an AWS Backup selection
      amazon.aws.backup_selection:
        selection_name: "{{ backup_selection_name }}"
        backup_plan_name: "{{ backup_plan_name }}"
        iam_role_arn: "{{ iam_role.iam_role.arn }}"
        list_of_tags:
          - condition_type: "STRINGEQUALS"
            condition_key: "backup"
            condition_value: "weekly"
        conditions:
          string_not_equals:
            - condition_key: "aws:ResourceTag/env"
              condition_value: "test"
      register: _modify_result_backup_selection

    - name: Verify modify result
      ansible.builtin.assert:
        that:
          - _modify_result_backup_selection.changed

   - name: Modify an AWS Backup selection (idempotency)
      amazon.aws.backup_selection:
        selection_name: "{{ backup_selection_name }}"
        backup_plan_name: "{{ backup_plan_name }}"
        iam_role_arn: "{{ iam_role.iam_role.arn }}"
        list_of_tags:
          - condition_type: "STRINGEQUALS"
            condition_key: "backup"
            condition_value: "weekly"
        conditions:
          string_not_equals:
            - condition_key: "aws:ResourceTag/env"
              condition_value: "test"
      register: _modify_result_backup_selection

    - name: Verify modify (idempotency) result
      ansible.builtin.assert:
        that:
          - not _modify_result_backup_selection.changed
```

### Expected Results

```
TASK [backup_selection : Create an AWS Backup selection] ***********************
changed: [testhost]

TASK [backup_selection : Modify an AWS Backup selection] ***********************
changed: [testhost]

TASK [backup_selection : Verify modify result] ****************************************
ok: [testhost] => {
    "changed": false,
    "msg": "All assertions passed"
}

TASK [backup_selection : Modify an AWS Backup selection (idempotency)] *********
changed: [testhost]

TASK [backup_selection : Verify modify (idempotency) result] ****************************************
ok: [testhost] => {
    "changed": false,
    "msg": "All assertions passed"
}
```

### Actual Results

```console (paste below)
TASK [backup_selection : Create an AWS Backup selection] ***********************
changed: [testhost]

TASK [backup_selection : Modify an AWS Backup selection] ***********************
changed: [testhost]

TASK [backup_selection : Verify modify result] ****************************************
ok: [testhost] => {
    "changed": false,
    "msg": "All assertions passed"
}

TASK [backup_selection : Modify an AWS Backup selection (idempotency)] *********
changed: [testhost]

TASK [backup_selection : Verify modify (idempotent) result] ****************************************
fatal: [testhost]: FAILED! => {
    "assertion": "not _modify_result_backup_selection.changed",
    "changed": false,
    "evaluated_to": false,
    "msg": "Assertion failed"
}
```


### Code of Conduct

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


Discussion:
I intend to submit a PR for this but because describing the issue is kind of involved, I wanted to document it first rather than trying to write all the details up in the PR summary.

Resolving patch:
diff --git a/plugins/module_utils/backup.py b/plugins/module_utils/backup.py
--- a/plugins/module_utils/backup.py
+++ b/plugins/module_utils/backup.py
@@ -144,7 +144,7 @@ def get_selection_details(module, client, plan_name: str, selection_name: Union[
                 if selection["SelectionName"] == selection_name:
                     selection_id = selection["SelectionId"]
                     result.append(_get_backup_selection(client, module, plan_id, selection_id))
-                break
+                    break
     else:
         for selection in selection_list:
             selection_id = selection["SelectionId"]
diff --git a/plugins/modules/backup_selection.py b/plugins/modules/backup_selection.py
--- a/plugins/modules/backup_selection.py
+++ b/plugins/modules/backup_selection.py
@@ -43,13 +43,14 @@
     description:
       - A list of conditions that you define to assign resources to your backup plans using tags.
       - Condition operators are case sensitive.
+      - When you specify more than one condition in I(list_of_tags), you assign all resources that match AT LEAST ONE condition (using OR logic).
     type: list
     elements: dict
     suboptions:
         condition_type:
             description:
             - An operation applied to a key-value pair used to assign resources to your backup plan.
-            - Condition only supports C(StringEquals).
+            - Condition only supports C(string_equals).
             type: str
         condition_key:
             description:
@@ -69,8 +70,71 @@
   conditions:
     description:
       - A list of conditions (expressed as a dict) that you define to assign resources to your backup plans using tags.
-      - I(conditions) supports C(StringEquals), C(StringLike), C(StringNotEquals), and C(StringNotLike). I(list_of_tags) only supports C(StringEquals).
+      - When you specify more than one condition in I(conditions), you only assign the resources that match ALL conditions (using AND logic).
+      - I(conditions) supports C(string_equals), C(string_like), C(string_not_equals), and C(string_not_like). I(list_of_tags) only supports C(string_equals).
     type: dict
+    suboptions:
+      string_equals:
+        description:
+          - Filters the values of your tagged resources for only those resources that you tagged with the same value.
+        type: list
+        default: []
+        elements: dict
+        suboptions:
+          condition_key:
+            description:
+              - The key in a key-value pair.
+              - I(condition_key) in the I(conditions) option must use the AWS resource tag prefix, e.g. 'aws:ResourceTag/key-name'
+            type: str
+          condition_value:
+            description: The value in a key-value pair.
+            type: str
+      string_like:
+        description:
+          - Filters the values of your tagged resources for matching tag values with the use of a wildcard character (*) anywhere in the string.
+            For example, "prod*" or "*rod*" matches the tag value "production".
+        type: list
+        default: []
+        elements: dict
+        suboptions:
+          condition_key:
+            description:
+              - The key in a key-value pair.
+              - I(condition_key) in the I(conditions) option must use the AWS resource tag prefix, e.g. 'aws:ResourceTag/key-name'
+            type: str
+          condition_value:
+            description: The value in a key-value pair.
+            type: str
+      string_not_equals:
+        description:
+          - Filters the values of your tagged resources for only those resources that you tagged that do not have the same value.
+        type: list
+        default: []
+        elements: dict
+        suboptions:
+          condition_key:
+            description:
+              - The key in a key-value pair.
+              - I(condition_key) in the I(conditions) option must use the AWS resource tag prefix, e.g. 'aws:ResourceTag/key-name'
+            type: str
+          condition_value:
+            description: The value in a key-value pair.
+            type: str
+      string_not_like:
+        description:
+          - Filters the values of your tagged resources for non-matching tag values with the use of a wildcard character (*) anywhere in the string.
+        type: list
+        default: []
+        elements: dict
+        suboptions:
+          condition_key:
+            description:
+              - The key in a key-value pair.
+              - I(condition_key) in the I(conditions) option must use the AWS resource tag prefix, e.g. 'aws:ResourceTag/key-name'
+            type: str
+          condition_value:
+            description: The value in a key-value pair.
+            type: str
   state:
     description:
       - Create, delete a backup selection.
@@ -220,7 +284,47 @@ def main():
         backup_plan_name=dict(type="str", required=True, aliases=["plan_name"]),
         iam_role_arn=dict(type="str"),
         resources=dict(type="list", elements="str"),
-        conditions=dict(type="dict"),
+        conditions=dict(
+            type="dict",
+            options=dict(
+                string_equals=dict(
+                    type="list",
+                    default=[],
+                    elements="dict",
+                    options=dict(
+                        condition_key=dict(type="str", no_log=False),
+                        condition_value=dict(type="str"),
+                    ),
+                ),
+                string_like=dict(
+                    type="list",
+                    default=[],
+                    elements="dict",
+                    options=dict(
+                        condition_key=dict(type="str", no_log=False),
+                        condition_value=dict(type="str"),
+                    ),
+                ),
+                string_not_equals=dict(
+                    type="list",
+                    default=[],
+                    elements="dict",
+                    options=dict(
+                        condition_key=dict(type="str", no_log=False),
+                        condition_value=dict(type="str"),
+                    ),
+                ),
+                string_not_like=dict(
+                    type="list",
+                    default=[],
+                    elements="dict",
+                    options=dict(
+                        condition_key=dict(type="str", no_log=False),
+                        condition_value=dict(type="str"),
+                    ),
+                ),
+            ),
+        ),
         not_resources=dict(type="list", elements="str"),
         list_of_tags=dict(
             type="list",
@@ -271,8 +375,7 @@ def main():
 
         if current_selection:
             results["exists"] = True
-            update_needed |= check_for_update(current_selection, backup_selection_data, iam_role_arn)
-
+            update_needed = check_for_update(current_selection, backup_selection_data, iam_role_arn)
             if update_needed:
                 if module.check_mode:
                     results["changed"] = True


Test patch:
diff --git a/tests/integration/targets/backup_selection/tasks/main.yml b/tests/integration/targets/backup_selection/tasks/main.yml
--- a/tests/integration/targets/backup_selection/tasks/main.yml
+++ b/tests/integration/targets/backup_selection/tasks/main.yml
@@ -18,60 +18,36 @@
         wait: true
       register: iam_role
 
-    # Wait for the role to be created
-    - pause:
-        seconds: 5
+    - name: Wait for the role to be created
+      ansible.builtin.pause:
+        seconds: 8
 
     - name: Create an AWS Backup vault for the plan to target
       amazon.aws.backup_vault:
         backup_vault_name: "{{ backup_vault_name }}"
-      register: _resutl_create_backup_vault
+      register: _result_create_backup_vault
 
     - name: Verify result
       ansible.builtin.assert:
         that:
-          - _resutl_create_backup_vault.changed
-
-    # - name: Create an AWS Backup plan
-    #   amazon.aws.backup_plan:
-    #     backup_plan_name: "{{ backup_plan_name }}"
-    #     rules:
-    #       - RuleName: DailyBackups
-    #         TargetBackupVaultName: "{{ backup_vault_name }}"
-    #         ScheduleExpression: "cron(0 5 ? * * *)"
-    #         StartWindowMinutes: 60
-    #         CompletionWindowMinutes: 1440
-    #     tags:
-    #       environment: test
-    #   register: _resutl_create_backup_plan
-
-    # - name: Verify result
-    #   ansible.builtin.assert:
-    #     that:
-    #       - _resutl_create_backup_plan.changed
-
-    # - name: Get detailed information about the AWS Backup plan
-    #   amazon.aws.backup_plan_info:
-    #     backup_plan_names:
-    #       - "{{ backup_plan_name }}"
-    #   register: _result_backup_plan_info
-
-    # - name: Verify result
-    #   ansible.builtin.assert:
-    #     that:
-    #       - _result_backup_plan_info.backup_plans | length == 1
-
-    - name: Create an AWS Backup plan
-      command: aws backup create-backup-plan --backup-plan "{\"BackupPlanName\":\"{{ backup_plan_name }}\",\"Rules\":[{\"RuleName\":\"DailyBackups\",\"ScheduleExpression\":\"cron(0 5 ? * * *)\",\"StartWindowMinutes\":60,\"TargetBackupVaultName\":\"{{ backup_vault_name }}\",\"CompletionWindowMinutes\":1440,\"Lifecycle\":{\"DeleteAfterDays\":35}}]}"
-      environment:
-        AWS_ACCESS_KEY_ID: "{{ aws_access_key }}"
-        AWS_SECRET_ACCESS_KEY: "{{ aws_secret_key }}"
-        AWS_SESSION_TOKEN: "{{ security_token | default('') }}"
-        AWS_DEFAULT_REGION: "{{ aws_region }}"
+          - _result_create_backup_vault.changed
+
+    - name: Create an AWS Backup plan for the selection to target
+      amazon.aws.backup_plan:
+        backup_plan_name: "{{ backup_plan_name }}"
+        rules:
+          - rule_name: DailyBackups
+            target_backup_vault_name: "{{ backup_vault_name }}"
+            schedule_expression: "cron(0 5 ? * * *)"
+            start_window_minutes: 60
+            completion_window_minutes: 1440
+        tags:
+          environment: test
       register: _result_create_backup_plan
 
-    - set_fact:
-        backup_plan_id: "{{ (_result_create_backup_plan.stdout | from_json).BackupPlanId }}"
+    - name: Set backup plan ID
+      ansible.builtin.set_fact:
+        backup_plan_id: "{{ _result_create_backup_plan.backup_plan_id }}"
 
     - name: Create an AWS Backup selection (check_mode)
       amazon.aws.backup_selection:
@@ -82,6 +58,10 @@
           - condition_type: "STRINGEQUALS"
             condition_key: "backup"
             condition_value: "daily"
+        conditions:
+          string_like:
+            - condition_key: "aws:ResourceTag/environment"
+              condition_value: "prod*"
       check_mode: true
       register: _create_result_backup_selection
 
@@ -99,6 +79,10 @@
           - condition_type: "STRINGEQUALS"
             condition_key: "backup"
             condition_value: "daily"
+        conditions:
+          string_like:
+            - condition_key: "aws:ResourceTag/environment"
+              condition_value: "prod*"
       register: _create_result_backup_selection
 
     - name: Verify result
@@ -118,6 +102,10 @@
           - condition_type: "STRINGEQUALS"
             condition_key: "backup"
             condition_value: "daily"
+        conditions:
+          string_like:
+            - condition_key: "aws:ResourceTag/environment"
+              condition_value: "prod*"
       register: _create_result_backup_selection
 
     - name: Verify result
@@ -155,6 +143,10 @@
           - condition_type: "STRINGEQUALS"
             condition_key: "backup"
             condition_value: "weekly"
+        conditions:
+          string_not_equals:
+            - condition_key: "aws:ResourceTag/environment"
+              condition_value: "dev"
       check_mode: true
       register: _modify_result_backup_selection
 
@@ -172,6 +164,10 @@
           - condition_type: "STRINGEQUALS"
             condition_key: "backup"
             condition_value: "weekly"
+        conditions:
+          string_not_equals:
+            - condition_key: "aws:ResourceTag/environment"
+              condition_value: "dev"
       register: _modify_result_backup_selection
 
     - name: Verify result
@@ -191,6 +187,10 @@
           - condition_type: "STRINGEQUALS"
             condition_key: "backup"
             condition_value: "weekly"
+        conditions:
+          string_not_equals:
+            - condition_key: "aws:ResourceTag/environment"
+              condition_value: "dev"
       register: _modify_result_backup_selection
 
     - name: Verify result
@@ -260,19 +260,10 @@
         state: absent
       ignore_errors: true
 
-    # - name: Delete AWS Backup plan created during this test
-    #   amazon.aws.backup_plan:
-    #     backup_plan_name: "{{ backup_plan_name }}"
-    #     state: absent
-    #   ignore_errors: true
-
     - name: Delete AWS Backup plan created during this test
-      command: aws backup delete-backup-plan --backup-plan-id "{{ backup_plan_id }}"
-      environment:
-        AWS_ACCESS_KEY_ID: "{{ aws_access_key }}"
-        AWS_SECRET_ACCESS_KEY: "{{ aws_secret_key }}"
-        AWS_SESSION_TOKEN: "{{ security_token | default('') }}"
-        AWS_DEFAULT_REGION: "{{ aws_region }}"
+      amazon.aws.backup_plan:
+        backup_plan_name: "{{ backup_plan_name }}"
+        state: absent
       ignore_errors: true
 
     - name: Delete AWS Backup vault created during this test
@@ -280,3 +271,9 @@
         backup_vault_name: "{{ backup_vault_name }}"
         state: absent
       ignore_errors: true
+
+    - name: Delete IAM role created during this test
+      community.aws.iam_role:
+        name: "{{ backup_iam_role_name }}"
+        state: absent
+      ignore_errors: true