Repository: ansible-collections/community.general

Issue:
nmcli returning "changed" with ipv6 addresses on task rerun
### Summary

community.general.nmcli returning "changed" when rerunning task with ipv6 address without network mask. This bug cannot be reproduced when network mask is added to ipv6 address. Documentation is saying that it's possible to use ipv6 address without mask.

### Issue Type

Bug Report

### Component Name

community.general.nmcli

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.11.6] 
  config file = /home/alex/ansible/ansible.cfg
  configured module search path = ['/home/alex/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/alex/.virtualenvs/nmcli_contributing/lib64/python3.10/site-packages/ansible
  ansible collection location = /home/alex/.ansible/collections:/usr/share/ansible/collections
  executable location = /home/alex/.virtualenvs/nmcli_contributing/bin/ansible
  python version = 3.10.0 (default, Oct  4 2021, 00:00:00) [GCC 11.2.1 20210728 (Red Hat 11.2.1-1)]
  jinja version = 3.0.2
  libyaml = True

```


### Community.general Version

```console (paste below)
Collection        Version
----------------- -------
community.general 4.1.0
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed
DEFAULT_BECOME(/home/alex/ansible/ansible.cfg) = False
DEFAULT_BECOME_METHOD(/home/alex/ansible/ansible.cfg) = sudo
DEFAULT_HOST_LIST(/home/alex/ansible/ansible.cfg) = ['/home/alex/ansible/hosts.ini']
DEFAULT_REMOTE_USER(/home/alex/ansible/ansible.cfg) = ansible
HOST_KEY_CHECKING(/home/alex/ansible/ansible.cfg) = False
```


### OS / Environment

Controller Node : Fedora 35
Target Node : RHEL 8.3

### Steps to Reproduce


```yaml 
---
- name: readding ip6 interface
  hosts: [redhat]
  become: true
  tasks:
    - name: clear dummy_con1
      nmcli:
        conn_name: dummy_con1
        ifname: dummy0
        state: absent
    
    - name: Adding new dummy connection with ip6 address
      nmcli:
        conn_name: dummy_con1
        ifname: dummy0
        ip6: 2001:db8::cafe
        state: present
        type: dummy

    - name: Adding same dummy connection with ip6 address
      nmcli:
        conn_name: dummy_con1
        ifname: dummy0
        ip6: 2001:db8::cafe
        state: present
        type: dummy
```


### Expected Results

```
ansible-playbook bad_playbook.yaml --diff

PLAY [readding ip6 interface] ****************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************************************************************************************************************************************
ok: [redhat]

TASK [clear dummy_con1] **********************************************************************************************************************************************************************************************************************
changed: [redhat]

TASK [Adding new dummy connection with ip6 address] ******************************************************************************************************************************************************************************************
changed: [redhat]

TASK [Adding same dummy connection with ip6 address] *****************************************************************************************************************************************************************************************
ok: [redhat]

PLAY RECAP ***********************************************************************************************************************************************************************************************************************************
redhat                     : ok=4    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 
```

### Actual Results

```console (paste below)
ansible-playbook bad_playbook.yaml --diff

PLAY [check dummy interface] *****************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************************************************************************************************************************************
ok: [redhat]

TASK [clear dummy_con1] **********************************************************************************************************************************************************************************************************************
changed: [redhat]

TASK [Adding new dummy connection with ip6 address] ******************************************************************************************************************************************************************************************
changed: [redhat]

TASK [Adding same dummy connection with ip6 address] *****************************************************************************************************************************************************************************************
--- before
+++ after
@@ -7,7 +7,7 @@
     "ipv4.may-fail": "yes",
     "ipv4.method": "disabled",
     "ipv4.never-default": "no",
-    "ipv6.addresses": "2001:db8::cafe/128",
+    "ipv6.addresses": "2001:db8::cafe",
     "ipv6.ignore-auto-dns": "no",
     "ipv6.ignore-auto-routes": "no",
     "ipv6.method": "manual"

changed: [redhat]

PLAY RECAP ***********************************************************************************************************************************************************************************************************************************
redhat                     : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```


### Code of Conduct

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


Discussion:
Files identified in the description:
* [`plugins/modules/net_tools/nmcli.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/net_tools/nmcli.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 @alcamie101
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
I can fix this, but I should know if it is better to change documentation or to add possibility of using ipv6 address without network mask.
I think it's best to fix this properly. My favourite fix would be to use `ipaddress.ip_network` to parse both networks and compare the resulting objects, but that would be a breaking change since the module does not depend on `ipaddress` so far. But at least this simple case (appending `/128` if no netmask is there) is easy to implement.
Ok. Maybe i should also add this behaviour to ipv4 address? It's just restricted by documentation to use ip4 parameter without network mask by now.

Resolving patch:
diff --git a/plugins/modules/net_tools/nmcli.py b/plugins/modules/net_tools/nmcli.py
--- a/plugins/modules/net_tools/nmcli.py
+++ b/plugins/modules/net_tools/nmcli.py
@@ -143,10 +143,11 @@
         version_added: 3.3.0
     ip6:
         description:
-            - The IPv6 address to this interface.
-            - Use the format C(abbe::cafe/128 or abbe::cafe).
+            - List of IPv6 addresses to this interface.
+            - Use the format C(abbe::cafe/128) or C(abbe::cafe).
             - If defined and I(method6) is not specified, automatically set C(ipv6.method) to C(manual).
-        type: str
+        type: list
+        elements: str
     gw6:
         description:
             - The IPv6 gateway for this interface.
@@ -1510,13 +1511,10 @@ def enforce_ipv4_cidr_notation(ip4_addresses):
         return [address if '/' in address else address + '/32' for address in ip4_addresses]
 
     @staticmethod
-    def enforce_ipv6_cidr_notation(ip6_address):
-        if ip6_address is None:
+    def enforce_ipv6_cidr_notation(ip6_addresses):
+        if ip6_addresses is None:
             return None
-        elif '/' in ip6_address:
-            return ip6_address
-        else:
-            return ip6_address + '/128'
+        return [address if '/' in address else address + '/128' for address in ip6_addresses]
 
     @staticmethod
     def bool_to_string(boolean):
@@ -1543,6 +1541,7 @@ def settings_type(setting):
                        '802-11-wireless.hidden'):
             return bool
         elif setting in ('ipv4.addresses',
+                         'ipv6.addresses',
                          'ipv4.dns',
                          'ipv4.dns-search',
                          'ipv4.routes',
@@ -1846,7 +1845,7 @@ def main():
             method4=dict(type='str', choices=['auto', 'link-local', 'manual', 'shared', 'disabled']),
             may_fail4=dict(type='bool', default=True),
             dhcp_client_id=dict(type='str'),
-            ip6=dict(type='str'),
+            ip6=dict(type='list', elements='str'),
             gw6=dict(type='str'),
             gw6_ignore_auto=dict(type='bool', default=False),
             dns6=dict(type='list', elements='str'),


Test patch:
diff --git a/tests/unit/plugins/modules/net_tools/test_nmcli.py b/tests/unit/plugins/modules/net_tools/test_nmcli.py
--- a/tests/unit/plugins/modules/net_tools/test_nmcli.py
+++ b/tests/unit/plugins/modules/net_tools/test_nmcli.py
@@ -601,6 +601,29 @@
     }
 ]
 
+TESTCASE_ETHERNET_STATIC_MULTIPLE_IP6_ADDRESSES = [
+    {
+        'type': 'ethernet',
+        'conn_name': 'non_existent_nw_device',
+        'ifname': 'ethernet_non_existant',
+        'ip6': ['2001:db8::cafe/128', '2002:db8::cafe/128'],
+        'gw6': '2001:db8::cafa',
+        'dns6': ['2001:4860:4860::8888', '2001:4860:4860::8844'],
+        'state': 'present',
+        '_ansible_check_mode': False,
+    },
+    {
+        'type': 'ethernet',
+        'conn_name': 'non_existent_nw_device',
+        'ifname': 'ethernet_non_existant',
+        'ip6': ['2001:db8::cafe', '2002:db8::cafe'],
+        'gw6': '2001:db8::cafa',
+        'dns6': ['2001:4860:4860::8888', '2001:4860:4860::8844'],
+        'state': 'present',
+        '_ansible_check_mode': False,
+    }
+]
+
 TESTCASE_ETHERNET_STATIC_MULTIPLE_IP4_ADDRESSES_SHOW_OUTPUT = """\
 connection.id:                          non_existent_nw_device
 connection.interface-name:              ethernet_non_existant
@@ -1050,6 +1073,13 @@ def mocked_ethernet_connection_static_ip6_privacy_and_addr_gen_mode_unchange(moc
                execute_return=(0, TESTCASE_ETHERNET_STATIC_IP6_PRIVACY_AND_ADDR_GEN_MODE_UNCHANGED_OUTPUT, ""))
 
 
+@pytest.fixture
+def mocked_ethernet_connection_static_multiple_ip6_addresses_unchanged(mocker):
+    mocker_set(mocker,
+               connection_exists=True,
+               execute_return=(0, TESTCASE_ETHERNET_STATIC_MULTIPLE_IP6_ADDRESSES_SHOW_OUTPUT, ""))
+
+
 @pytest.fixture
 def mocked_ethernet_connection_static_modify(mocker):
     mocker_set(mocker,
@@ -2650,6 +2680,46 @@ def test_create_ethernet_with_mulitple_ip4_addresses_static(mocked_generic_conne
     assert results['changed']
 
 
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_ETHERNET_STATIC_MULTIPLE_IP6_ADDRESSES, indirect=['patch_ansible_module'])
+def test_create_ethernet_with_mulitple_ip6_addresses_static(mocked_generic_connection_create, capfd):
+    """
+    Test : Create ethernet connection with multiple IPv6 addresses configuration
+    """
+
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    assert nmcli.Nmcli.execute_command.call_count == 2
+    arg_list = nmcli.Nmcli.execute_command.call_args_list
+    add_args, add_kw = arg_list[0]
+
+    assert add_args[0][0] == '/usr/bin/nmcli'
+    assert add_args[0][1] == 'con'
+    assert add_args[0][2] == 'add'
+    assert add_args[0][3] == 'type'
+    assert add_args[0][4] == 'ethernet'
+    assert add_args[0][5] == 'con-name'
+    assert add_args[0][6] == 'non_existent_nw_device'
+
+    add_args_text = list(map(to_text, add_args[0]))
+    for param in ['connection.interface-name', 'ethernet_non_existant',
+                  'ipv6.addresses', '2001:db8::cafe/128,2002:db8::cafe/128',
+                  'ipv6.gateway', '2001:db8::cafa',
+                  'ipv6.dns', '2001:4860:4860::8888,2001:4860:4860::8844']:
+        assert param in add_args_text
+
+    up_args, up_kw = arg_list[1]
+    assert up_args[0][0] == '/usr/bin/nmcli'
+    assert up_args[0][1] == 'con'
+    assert up_args[0][2] == 'up'
+    assert up_args[0][3] == 'non_existent_nw_device'
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert results['changed']
+
+
 @pytest.mark.parametrize('patch_ansible_module', TESTCASE_ETHERNET_STATIC_MULTIPLE_IP4_ADDRESSES, indirect=['patch_ansible_module'])
 def test_ethernet_connection_static_with_mulitple_ip4_addresses_unchanged(mocked_ethernet_connection_static_multiple_ip4_addresses_unchanged, capfd):
     """
@@ -2664,6 +2734,20 @@ def test_ethernet_connection_static_with_mulitple_ip4_addresses_unchanged(mocked
     assert not results['changed']
 
 
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_ETHERNET_STATIC_MULTIPLE_IP6_ADDRESSES, indirect=['patch_ansible_module'])
+def test_ethernet_connection_static_with_mulitple_ip6_addresses_unchanged(mocked_ethernet_connection_static_multiple_ip6_addresses_unchanged, capfd):
+    """
+    Test : Ethernet connection with multiple IPv6 addresses configuration unchanged
+    """
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert not results['changed']
+
+
 @pytest.mark.parametrize('patch_ansible_module', TESTCASE_ETHERNET_STATIC_MULTIPLE_IP4_ADDRESSES, indirect=['patch_ansible_module'])
 def test_add_second_ip4_address_to_ethernet_connection(mocked_ethernet_connection_static_modify, capfd):
     """