Repository: ansible-collections/community.vmware

Issue:
vmware_tag_manager always returns `changed: True` when in `set` mode.
##### SUMMARY
vmware_tag_manager always returns `changed: True` when in `set` mode.

##### ISSUE TYPE
- Bug Report

##### COMPONENT NAME
`plugins/modules/vmware_tag_manager.py`

##### ANSIBLE VERSION
<!--- Paste verbatim output from "ansible --version" between quotes -->
```paste below
ansible [core 2.12.2]
  config file = /mnt/d/Workspace/infrastructure/configuration/ansible.cfg
  configured module search path = ['/mnt/d/Workspace/infrastructure/configuration/library']
  ansible python module location = /mnt/d/Workspace/infrastructure/configuration/.env/lib/python3.9/site-packages/ansible
  ansible collection location = /usr/share/ansible/collections
  executable location = /mnt/d/Workspace/infrastructure/configuration/.env/bin/ansible
  python version = 3.9.7 (default, Sep 10 2021, 14:59:43) [GCC 11.2.0]
  jinja version = 3.0.3
  libyaml = True
```

##### COLLECTION VERSION
<!--- Paste verbatim output from "ansible-galaxy collection list <namespace>.<collection>"  between the quotes
for example: ansible-galaxy collection list community.general
-->
```paste below

# /mnt/d/Workspace/infrastructure/configuration/.env/lib/python3.9/site-packages/ansible_collections
Collection       Version
---------------- -------
community.vmware 1.17.1
```

##### CONFIGURATION
<!--- Paste verbatim output from "ansible-config dump --only-changed" between quotes -->
```paste below
ANSIBLE_PIPELINING(/mnt/d/Workspace/infrastructure/configuration/ansible.cfg) = True
DEFAULT_FORCE_HANDLERS(/mnt/d/Workspace/infrastructure/configuration/ansible.cfg) = True
DEFAULT_GATHERING(/mnt/d/Workspace/infrastructure/configuration/ansible.cfg) = smart
DEFAULT_PRIVATE_ROLE_VARS(/mnt/d/Workspace/infrastructure/configuration/ansible.cfg) = True
DISPLAY_SKIPPED_HOSTS(/mnt/d/Workspace/infrastructure/configuration/ansible.cfg) = False
```

##### OS / ENVIRONMENT
Targeting vSphere 6.5.0.38000


##### STEPS TO REPRODUCE

```yaml
- name: Set VM tags
  delegate_to: localhost
  community.vmware.vmware_tag_manager:
    hostname: "{{ vsphere_server }}"
    username: "{{ vsphere_user }}"
    password: "{{ vsphere_password }}"

    object_type: VirtualMachine
    moid: "{{ vm_info.instance.moid }}"
    tag_names: "{{ autovm_tags }}"
    state: set
```

##### EXPECTED RESULTS
The task should be `changed` only if the set of tags was modified by the plugin.

##### ACTUAL RESULTS
The task is always `changed` if it contains at least one tag.

Additional note: on this line, `changed` is set to True unconditionally when in `set` mode.
https://github.com/ansible-collections/community.vmware/blob/main/plugins/modules/vmware_tag_manager.py#L349




Discussion:
Yes, it looks like the module always removes all tags and then adds them again one by one when specifying  `set`:

https://github.com/ansible-collections/community.vmware/blob/fac9919fc3af36996173413c0c25bb816459f12a/plugins/modules/vmware_tag_manager.py#L339-L349

Resolving patch:
diff --git a/plugins/modules/vmware_tag_manager.py b/plugins/modules/vmware_tag_manager.py
--- a/plugins/modules/vmware_tag_manager.py
+++ b/plugins/modules/vmware_tag_manager.py
@@ -283,6 +283,7 @@ def ensure_state(self):
             changed=False,
             tag_status=dict(),
         )
+        tag_objs = []
         changed = False
         action = self.params.get('state')
         try:
@@ -297,7 +298,6 @@ def ensure_state(self):
         results['tag_status']['desired_tags'] = self.tag_names
 
         # Check if category and tag combination exists as per user request
-        removed_tags_for_set = False
         for tag in self.tag_names:
             category_obj, category_name, tag_name = None, None, None
             if isinstance(tag, dict):
@@ -327,7 +327,20 @@ def ensure_state(self):
             if not tag_obj:
                 self.module.fail_json(msg="Unable to find the tag %s" % tag_name)
 
-            if action in ('add', 'present'):
+            tag_objs.append(tag_obj)
+
+        if action in ('add', 'present'):
+            for tag_obj in tag_objs:
+                if tag_obj not in available_tag_obj:
+                    # Tag is not already applied
+                    try:
+                        self.tag_association_svc.attach(tag_id=tag_obj.id, object_id=self.dynamic_managed_object)
+                        changed = True
+                    except Error as error:
+                        self.module.fail_json(msg="%s" % self.get_error_message(error))
+
+        elif action == 'set':
+            for tag_obj in tag_objs:
                 if tag_obj not in available_tag_obj:
                     # Tag is not already applied
                     try:
@@ -336,19 +349,17 @@ def ensure_state(self):
                     except Error as error:
                         self.module.fail_json(msg="%s" % self.get_error_message(error))
 
-            elif action == 'set':
-                # Remove all tags first
-                try:
-                    if not removed_tags_for_set:
-                        for av_tag in available_tag_obj:
-                            self.tag_association_svc.detach(tag_id=av_tag.id, object_id=self.dynamic_managed_object)
-                        removed_tags_for_set = True
-                    self.tag_association_svc.attach(tag_id=tag_obj.id, object_id=self.dynamic_managed_object)
-                    changed = True
-                except Error as error:
-                    self.module.fail_json(msg="%s" % self.get_error_message(error))
-
-            elif action in ('remove', 'absent'):
+            for av_tag in available_tag_obj:
+                if av_tag not in tag_objs:
+                    # Tag not in the defined list
+                    try:
+                        self.tag_association_svc.detach(tag_id=av_tag.id, object_id=self.dynamic_managed_object)
+                        changed = True
+                    except Error as error:
+                        self.module.fail_json(msg="%s" % self.get_error_message(error))
+
+        elif action in ('remove', 'absent'):
+            for tag_obj in tag_objs:
                 if tag_obj in available_tag_obj:
                     try:
                         self.tag_association_svc.detach(tag_id=tag_obj.id, object_id=self.dynamic_managed_object)


Test patch:
diff --git a/tests/integration/targets/vmware_tag_manager/tasks/cleanup.yml b/tests/integration/targets/vmware_tag_manager/tasks/cleanup.yml
--- a/tests/integration/targets/vmware_tag_manager/tasks/cleanup.yml
+++ b/tests/integration/targets/vmware_tag_manager/tasks/cleanup.yml
@@ -1,5 +1,5 @@
 - name: Delete Tags
-  vmware_tag:
+  community.vmware.vmware_tag:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'
@@ -10,7 +10,7 @@
   register: delete_tag
 
 - name: Delete Categories
-  vmware_category:
+  community.vmware.vmware_category:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'
diff --git a/tests/integration/targets/vmware_tag_manager/tasks/main.yml b/tests/integration/targets/vmware_tag_manager/tasks/main.yml
--- a/tests/integration/targets/vmware_tag_manager/tasks/main.yml
+++ b/tests/integration/targets/vmware_tag_manager/tasks/main.yml
@@ -15,7 +15,7 @@
 
 - block:
   - name: Create first category
-    vmware_category:
+    community.vmware.vmware_category:
       hostname: '{{ vcenter_hostname }}'
       username: '{{ vcenter_username }}'
       password: '{{ vcenter_password }}'
diff --git a/tests/integration/targets/vmware_tag_manager/tasks/tag_manager_dict.yml b/tests/integration/targets/vmware_tag_manager/tasks/tag_manager_dict.yml
--- a/tests/integration/targets/vmware_tag_manager/tasks/tag_manager_dict.yml
+++ b/tests/integration/targets/vmware_tag_manager/tasks/tag_manager_dict.yml
@@ -5,7 +5,7 @@
 # Testcase for https://github.com/ansible/ansible/issues/65765
 
 - name: Create tag with colon in name
-  vmware_tag:
+  community.vmware.vmware_tag:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'
@@ -21,7 +21,7 @@
       - tag_one_create.changed
 
 - name: Get VM Facts
-  vmware_vm_info:
+  community.vmware.vmware_vm_info:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'
@@ -33,7 +33,7 @@
     vm_moid: "{{ vm_info['virtual_machines'][0]['moid'] }}"
 
 - name: Assign tag to given virtual machine
-  vmware_tag_manager:
+  community.vmware.vmware_tag_manager:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'
@@ -47,7 +47,7 @@
   register: vm_tag_info
 
 - name: Assign tag to rw_datastore
-  vmware_tag_manager:
+  community.vmware.vmware_tag_manager:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'
@@ -67,7 +67,7 @@
       - datastore_tag_info.changed
 
 - name: Remove tag to given virtual machine
-  vmware_tag_manager:
+  community.vmware.vmware_tag_manager:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'
@@ -80,8 +80,61 @@
     state: remove
   register: vm_tag_info
 
+- name: Test idempotency for state set
+  block:
+    - name: Set the tags on a VM to a given list
+      community.vmware.vmware_tag_manager:
+        hostname: '{{ vcenter_hostname }}'
+        username: '{{ vcenter_username }}'
+        password: '{{ vcenter_password }}'
+        validate_certs: false
+        tag_names:
+          - category: "{{ cat_one }}"
+            tag: "{{ tag_one }}"
+        object_name: "{{ vm_name }}"
+        object_type: VirtualMachine
+        state: set
+      register: vm_tag_info
+
+    - name: Check the module assigned the tags
+      assert:
+        that:
+          - vm_tag_info.changed
+
+    - name: Set the tags on a VM to a given list again
+      community.vmware.vmware_tag_manager:
+        hostname: '{{ vcenter_hostname }}'
+        username: '{{ vcenter_username }}'
+        password: '{{ vcenter_password }}'
+        validate_certs: false
+        tag_names:
+          - category: "{{ cat_one }}"
+            tag: "{{ tag_one }}"
+        object_name: "{{ vm_name }}"
+        object_type: VirtualMachine
+        state: set
+      register: vm_tag_info
+
+    - name: Check idempotency
+      assert:
+        that:
+          - not vm_tag_info.changed
+
+    - name: Remove tag from given virtual machine
+      community.vmware.vmware_tag_manager:
+        hostname: '{{ vcenter_hostname }}'
+        username: '{{ vcenter_username }}'
+        password: '{{ vcenter_password }}'
+        validate_certs: false
+        tag_names:
+          - category: "{{ cat_one }}"
+            tag: "{{ tag_one }}"
+        object_name: "{{ vm_name }}"
+        object_type: VirtualMachine
+        state: remove
+
 - name: Assign tag to given virtual machine using moid
-  vmware_tag_manager:
+  community.vmware.vmware_tag_manager:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'
@@ -100,7 +153,7 @@
       - vm_tag_info.changed
 
 - name: Remove tag to datastore
-  vmware_tag_manager:
+  community.vmware.vmware_tag_manager:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'
@@ -114,7 +167,7 @@
   register: datastore_tag_info
 
 - name: Remove tag to given virtual machine
-  vmware_tag_manager:
+  community.vmware.vmware_tag_manager:
     hostname: '{{ vcenter_hostname }}'
     username: '{{ vcenter_username }}'
     password: '{{ vcenter_password }}'