Repository: ansible-collections/community.general

Issue:
proxmox_kvm: Create VM without providing vmid not idempotent
### Summary

When I try to run playbook that uses `community.general.proxmox_kvm` module to create a new VM without specifying its `vmid` and with the same `name` more than one time, new one with different `vmid` is always created.

Module behaviour changed with version `7.1.0` due to issue #6155.

Possible solution is to change the following:
https://github.com/ansible-collections/community.general/blob/2e0079cb3e683915d7421461839bd653f85334a4/plugins/modules/proxmox_kvm.py#L1265-L1273

to something like that:

```python
   if not vmid:
        if state == 'present' and not update and not clone and not delete and not revert and not migrate:
            existing_vmid = proxmox.get_vmid(name, ignore_missing=True)
            if existing_vmid:
                vmid = existing_vmid
            else:
                try:
                    vmid = proxmox.get_nextvmid()
                except Exception:
                    module.fail_json(msg="Can't get the next vmid for VM {0} automatically. Ensure your cluster state is good".format(name))
        else:
            clone_target = clone or name
            vmid = proxmox.get_vmid(clone_target, ignore_missing=True)
```

### Issue Type

Bug Report

### Component Name

proxmox_kvm

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.15.0]
...
    python version = 3.10.6 (main, May 29 2023, 11:10:38) [GCC 11.3.0]
...
```


### Community.general Version

```console (paste below)
$ ansible-galaxy collection list community.general
Collection        Version
----------------- -------
community.general 7.1.0
```


### Configuration

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


### OS / Environment

```console
$ lsb_release -a
Distributor ID:	Debian
Description:	Debian GNU/Linux 11 (bullseye)
Release:	11
Codename:	bullseye
```

```console
$ pip3 list | grep -iE "proxmoxer|requests"
proxmoxer          2.0.1
requests           2.31.0
```

### Steps to Reproduce

Run following playbook at least to times. With each run new VM named `test-vm` with different `vmid` is created.

In this test case fist playbook run creates a VM named `test-vm` with `vmid: 100`.

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
- hosts: localhost
  gather_facts: false
  vars_files:
    - api_credentials.yml
  tasks:
    - name: Deploy VM
      community.general.proxmox_kvm:
        api_host: "{{ api_host }}"
        api_user: "{{ api_user }}"
        api_token_id: "{{ api_token_id }}"
        api_token_secret: "{{ api_token_secret }}"
        node: pve
        name: test-vm
        state: present
```


### Expected Results

After discovering that VM with name `test-vm` already exists and no `vmid` was provided module shouldn't make any changes (`changed: false`, unless `update: true` or related flags are set) and return info about previously created one (as in `7.0.0`)

First run:
```console
$ ansible-playbook -v test.yml
changed: [127.0.0.1] => {
    "changed": true,
    "devices": {},
    "mac": {},
    "msg": "VM test-vm with vmid 100 deployed",
    "vmid": 100
}
```

Second run:
```console
$ ansible-playbook -v test.yml
ok: [127.0.0.1] => {
    "changed": false,
    "msg": "VM with name <test-vm> already exists",
    "vmid": 100
}
```

### Actual Results

Multiple VM instances with different `vmid` and the same `name` are created every playbook run.

First run:
```console
$ ansible-playbook -v test.yml
changed: [127.0.0.1] => {
    "changed": true,
    "devices": {},
    "mac": {},
    "msg": "VM test-vm with vmid 100 deployed",
    "vmid": 100
}
```
Second run:
```console (paste below)
$ ansible-playbook -v test.yml
changed: [127.0.0.1] => {
    "changed": true,
    "devices": {},
    "mac": {},
    "msg": "VM test-vm with vmid 101 deployed",
    "vmid": 101
}
```


### Code of Conduct

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


Discussion:
Files identified in the description:
* [`plugins/modules/proxmox_kvm.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/proxmox_kvm.py)

If these files are incorrect, 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/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @Ajpantuso @Thulium-Drake @UnderGreen @helldorado @joshainglis @karmab @tleguern
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@wozniaka I think the previous behavior was wrong. Proxmox API allows you to create/have multiple VMs with the same name. Previously the module didn't let that, but we shouldn't restrict something which is a feature by design in API.
@felixfontein or @russoz would like to hear your thoughts about that.
@UnderGreen I can understand that but can't module support this scenario by explicitly providing different pairs of `name` and `vmid` and keep the previous one if no `vmid` is provided? Module supports creating VM with `name` only, so i think in that case it could also be treated in unique manner.

However, I must admit that, in this case, getting `vmid` (as they would be needed to be defined explicitly) for mass created VM with the same `name` would be rather hard just by using `community.general.proxmox*` modules, as they're now. On the other side it's quite simple to get `vmid` of already deployed VM and provide playbook rather then task level idempotence.

Maybe module can provide some sort of switch between two use cases?

I hate to add more params to the module as it already contains about twenty of them, but we need some sort of `unique` param for the case if only a `name` is provided.
In any case notice of the changes for the user experience should be added to the module docs.

Resolving patch:
diff --git a/plugins/modules/proxmox_kvm.py b/plugins/modules/proxmox_kvm.py
--- a/plugins/modules/proxmox_kvm.py
+++ b/plugins/modules/proxmox_kvm.py
@@ -287,8 +287,9 @@
     type: int
   name:
     description:
-      - Specifies the VM name. Only used on the configuration web interface.
+      - Specifies the VM name. Name could be non-unique across the cluster.
       - Required only for O(state=present).
+      - With O(state=present) if O(vmid) not provided and VM with name exists in the cluster then no changes will be made.
     type: str
   nameservers:
     description:
@@ -1289,10 +1290,14 @@ def main():
     # the cloned vm name or retrieve the next free VM id from ProxmoxAPI.
     if not vmid:
         if state == 'present' and not update and not clone and not delete and not revert and not migrate:
-            try:
-                vmid = proxmox.get_nextvmid()
-            except Exception:
-                module.fail_json(msg="Can't get the next vmid for VM {0} automatically. Ensure your cluster state is good".format(name))
+            existing_vmid = proxmox.get_vmid(name, ignore_missing=True)
+            if existing_vmid:
+                vmid = existing_vmid
+            else:
+                try:
+                    vmid = proxmox.get_nextvmid()
+                except Exception:
+                    module.fail_json(msg="Can't get the next vmid for VM {0} automatically. Ensure your cluster state is good".format(name))
         else:
             clone_target = clone or name
             vmid = proxmox.get_vmid(clone_target, ignore_missing=True)


Test patch:
diff --git a/tests/unit/plugins/modules/test_proxmox_kvm.py b/tests/unit/plugins/modules/test_proxmox_kvm.py
--- a/tests/unit/plugins/modules/test_proxmox_kvm.py
+++ b/tests/unit/plugins/modules/test_proxmox_kvm.py
@@ -12,14 +12,17 @@
 
 import pytest
 
-proxmoxer = pytest.importorskip('proxmoxer')
+proxmoxer = pytest.importorskip("proxmoxer")
 mandatory_py_version = pytest.mark.skipif(
     sys.version_info < (2, 7),
-    reason='The proxmoxer dependency requires python2.7 or higher'
+    reason="The proxmoxer dependency requires python2.7 or higher",
 )
 
 from ansible_collections.community.general.plugins.modules import proxmox_kvm
-from ansible_collections.community.general.tests.unit.compat.mock import patch
+from ansible_collections.community.general.tests.unit.compat.mock import (
+    patch,
+    DEFAULT,
+)
 from ansible_collections.community.general.tests.unit.plugins.modules.utils import (
     AnsibleExitJson,
     AnsibleFailJson,
@@ -36,10 +39,19 @@ def setUp(self):
         self.module = proxmox_kvm
         self.connect_mock = patch(
             "ansible_collections.community.general.plugins.module_utils.proxmox.ProxmoxAnsible._connect"
-        )
-        self.connect_mock.start()
+        ).start()
+        self.get_node_mock = patch.object(
+            proxmox_utils.ProxmoxAnsible, "get_node"
+        ).start()
+        self.get_vm_mock = patch.object(proxmox_utils.ProxmoxAnsible, "get_vm").start()
+        self.create_vm_mock = patch.object(
+            proxmox_kvm.ProxmoxKvmAnsible, "create_vm"
+        ).start()
 
     def tearDown(self):
+        self.create_vm_mock.stop()
+        self.get_vm_mock.stop()
+        self.get_node_mock.stop()
         self.connect_mock.stop()
         super(TestProxmoxKvmModule, self).tearDown()
 
@@ -58,45 +70,99 @@ def test_module_exits_unchaged_when_provided_vmid_exists(self):
                 "node": "pve",
             }
         )
-        with patch.object(proxmox_utils.ProxmoxAnsible, "get_vm") as get_vm_mock:
-            get_vm_mock.return_value = [{"vmid": "100"}]
+        self.get_vm_mock.return_value = [{"vmid": "100"}]
+        with pytest.raises(AnsibleExitJson) as exc_info:
+            self.module.main()
+
+        assert self.get_vm_mock.call_count == 1
+        result = exc_info.value.args[0]
+        assert result["changed"] is False
+        assert result["msg"] == "VM with vmid <100> already exists"
+
+    def test_vm_created_when_vmid_not_exist_but_name_already_exist(self):
+        set_module_args(
+            {
+                "api_host": "host",
+                "api_user": "user",
+                "api_password": "password",
+                "vmid": "100",
+                "name": "existing.vm.local",
+                "node": "pve",
+            }
+        )
+        self.get_vm_mock.return_value = None
+        with pytest.raises(AnsibleExitJson) as exc_info:
+            self.module.main()
+
+        assert self.get_vm_mock.call_count == 1
+        assert self.get_node_mock.call_count == 1
+        result = exc_info.value.args[0]
+        assert result["changed"] is True
+        assert result["msg"] == "VM existing.vm.local with vmid 100 deployed"
+
+    def test_vm_not_created_when_name_already_exist_and_vmid_not_set(self):
+        set_module_args(
+            {
+                "api_host": "host",
+                "api_user": "user",
+                "api_password": "password",
+                "name": "existing.vm.local",
+                "node": "pve",
+            }
+        )
+        with patch.object(proxmox_utils.ProxmoxAnsible, "get_vmid") as get_vmid_mock:
+            get_vmid_mock.return_value = {
+                "vmid": 100,
+                "name": "existing.vm.local",
+            }
             with pytest.raises(AnsibleExitJson) as exc_info:
                 self.module.main()
 
-            assert get_vm_mock.call_count == 1
-            result = exc_info.value.args[0]
-            assert result["changed"] is False
-            assert result["msg"] == "VM with vmid <100> already exists"
+        assert get_vmid_mock.call_count == 1
+        result = exc_info.value.args[0]
+        assert result["changed"] is False
 
-    @patch.object(proxmox_kvm.ProxmoxKvmAnsible, "create_vm")
-    def test_vm_created_when_vmid_not_exist_but_name_already_exist(self, create_vm_mock):
+    def test_vm_created_when_name_doesnt_exist_and_vmid_not_set(self):
         set_module_args(
             {
                 "api_host": "host",
                 "api_user": "user",
                 "api_password": "password",
-                "vmid": "100",
                 "name": "existing.vm.local",
                 "node": "pve",
             }
         )
-        with patch.object(proxmox_utils.ProxmoxAnsible, "get_vm") as get_vm_mock:
-            with patch.object(proxmox_utils.ProxmoxAnsible, "get_node") as get_node_mock:
-                get_vm_mock.return_value = None
-                get_node_mock.return_value = {"node": "pve", "status": "online"}
-                with pytest.raises(AnsibleExitJson) as exc_info:
-                    self.module.main()
-
-            assert get_vm_mock.call_count == 1
-            assert get_node_mock.call_count == 1
-            result = exc_info.value.args[0]
-            assert result["changed"] is True
-            assert result["msg"] == "VM existing.vm.local with vmid 100 deployed"
+        self.get_vm_mock.return_value = None
+        with patch.multiple(
+            proxmox_utils.ProxmoxAnsible, get_vmid=DEFAULT, get_nextvmid=DEFAULT
+        ) as utils_mock:
+            utils_mock["get_vmid"].return_value = None
+            utils_mock["get_nextvmid"].return_value = 101
+            with pytest.raises(AnsibleExitJson) as exc_info:
+                self.module.main()
+
+        assert utils_mock["get_vmid"].call_count == 1
+        assert utils_mock["get_nextvmid"].call_count == 1
+        result = exc_info.value.args[0]
+        assert result["changed"] is True
+        assert result["msg"] == "VM existing.vm.local with vmid 101 deployed"
 
     def test_parse_mac(self):
-        assert proxmox_kvm.parse_mac("virtio=00:11:22:AA:BB:CC,bridge=vmbr0,firewall=1") == "00:11:22:AA:BB:CC"
+        assert (
+            proxmox_kvm.parse_mac("virtio=00:11:22:AA:BB:CC,bridge=vmbr0,firewall=1")
+            == "00:11:22:AA:BB:CC"
+        )
 
     def test_parse_dev(self):
-        assert proxmox_kvm.parse_dev("local-lvm:vm-1000-disk-0,format=qcow2") == "local-lvm:vm-1000-disk-0"
-        assert proxmox_kvm.parse_dev("local-lvm:vm-101-disk-1,size=8G") == "local-lvm:vm-101-disk-1"
-        assert proxmox_kvm.parse_dev("local-zfs:vm-1001-disk-0") == "local-zfs:vm-1001-disk-0"
+        assert (
+            proxmox_kvm.parse_dev("local-lvm:vm-1000-disk-0,format=qcow2")
+            == "local-lvm:vm-1000-disk-0"
+        )
+        assert (
+            proxmox_kvm.parse_dev("local-lvm:vm-101-disk-1,size=8G")
+            == "local-lvm:vm-101-disk-1"
+        )
+        assert (
+            proxmox_kvm.parse_dev("local-zfs:vm-1001-disk-0")
+            == "local-zfs:vm-1001-disk-0"
+        )