Repository: ansible-collections/community.general

Issue:
Add ability in nmcli module to bind vlan interface to a bridge interface.
<!--- Verify first that your feature was not already discussed on GitHub -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY
<!--- Describe the new feature/improvement briefly below -->

In the `nmcli` module, add a `master` and `slave-type` parameter for vlan interface types to allow them to be enslaved onto bridge interfaces.

##### ISSUE TYPE
- Feature Idea

##### COMPONENT NAME
<!--- Write the short name of the module, plugin, task or feature below, use your best guess if unsure -->
nmcli

##### ADDITIONAL INFORMATION
<!--- Describe how the feature would be used, why it is needed and what it would solve -->

I'm currently trying to setup a bunch of different network interfaces for an Openstack deployment in my home lab. Per the [Openstack documentation](https://docs.openstack.org/openstack-ansible/latest/user/network-arch/example.html#single-interface-or-bond), I'm need to create three bridge interfaces that are all on separate vlans. I'm trying to use the `nmcli` module to do this, however it seems currently impossible given the list of possible options in the [documentation](https://docs.ansible.com/ansible/latest/modules/nmcli_module.html). 

From what I can see based on [this snippet](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/7/html/networking_guide/sec-vlan_on_bond_and_bridge_using_the_networkmanager_command_line_tool_nmcli) of RedHat documentation, what I'd like is for the following playbook:

<!--- Paste example playbooks or commands between quotes below -->
```yaml
-name: Add br-mgmt interface
  nmcli:
    state: present
    conn_name: br-mgmt
    ifname: br-mgmt
    type: bridge
- name: Create vlan 100 interface and bind to br-mgmt
  nmcli:
    state: present
    conn_name: em1.100
    ifname: em1.100
    type: vlan
    vlanid: 100
    vlandev: em1
    master: br-mgmt
    slave-type: bridge
```

To execute something like the following:

```
nmcli connection add type bridge con-name br-mgmt ifname br-mgmt
nmcli connection add type vlan con-name em1.100 ifname em1.100 dev em1 id 100 master br-mgmt slave-type bridge
```

It's the functionality of appending the `master br-mgmt slave-type bridge` to the command that would need to be implemented in the `nmcli` module.

<!--- HINT: You can also paste gist.github.com links for larger files -->


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

If these files are inaccurate, 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/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @alcamie101 @nerzhul
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Agree with the need for this feature
We are currently bumping into this issue and could really use it. Is there any updates on this PR.
The lack of this feature prevents me from using this module.
I hope to see it land.
Thank you for the work
Files identified in the description:
* [`plugins/modules/nmcli.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/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 --->
I meet this problem again and again. So I have deeped into issue and understang some things.
Module takes one parameter that defines type of interface - `type`. Around this param it have constructing anothers, including `slave-type`. `master` is passed from module invocation if `type` one of: `bond-slave`, `bridge-slave`, `team-slave`, `wifi`.
I have look in documentation of [nmcli](https://people.freedesktop.org/~lkundrak/nm-docs/nmcli.html) to understand what is `bridge-slave` and another slaves and what purpose of this thing:

> This option also accepts values of bond-slave, team-slave and bridge-slave. They create ethernet connection profiles. Their use is discouraged in favor of using a specific type with master option.

In context of this topic I think I understand why it not recommended... Apparently it is some presets for fast create of connection or something.

I think the one **good way** to resolve issue is deprecate all `*-slave` options of `type` and add new module parameter `slave-type`. In pair with `master` it will allow to do anything.

While deprecation coming `bridge-slave` may be processed as follows:
If user passes both `vlanid` and `vlandev` with type=`bridge-slave` it forces type to `vlan`, slave-type to `bridge` and master passes as is from module arguments.

Strictly speaking for same reasons it unable to create another non-ethernet connection types attached to bridges or bond. For example  it unable to create bond-interface attached to bridge. Some exotic configurations as bond from vlans or bridges is impossible too, although techically useful of these solutions is doubt :)))

Resolving patch:
diff --git a/plugins/modules/nmcli.py b/plugins/modules/nmcli.py
--- a/plugins/modules/nmcli.py
+++ b/plugins/modules/nmcli.py
@@ -66,6 +66,8 @@
             - Type C(macvlan) is added in community.general 6.6.0.
             - Type C(wireguard) is added in community.general 4.3.0.
             - Type C(vpn) is added in community.general 5.1.0.
+            - Using C(bond-slave), C(bridge-slave) or C(team-slave) implies C(ethernet) connection type with corresponding I(slave_type) option.
+            - If you want to control non-ethernet connection attached to C(bond), C(bridge) or C(team) consider using C(slave_type) option.
         type: str
         choices: [ bond, bond-slave, bridge, bridge-slave, dummy, ethernet, generic, gre, infiniband, ipip, macvlan, sit, team, team-slave, vlan, vxlan,
             wifi, gsm, wireguard, vpn ]
@@ -81,9 +83,16 @@
         type: str
         choices: [ datagram, connected ]
         version_added: 5.8.0
+    slave_type:
+        description:
+            - Type of the device of this slave's master connection (for example C(bond)).
+        type: str
+        choices: [ 'bond', 'bridge', 'team' ]
+        version_added: 7.0.0
     master:
         description:
             - Master <master (ifname, or connection UUID or conn_name) of bridge, team, bond master connection profile.
+            - Mandatory if I(slave_type) is defined.
         type: str
     ip4:
         description:
@@ -1429,6 +1438,39 @@
     autoconnect: false
     state: present
 
+## Creating bond attached to bridge example
+- name: Create bond attached to bridge
+  community.general.nmcli:
+    type: bond
+    conn_name: bond0
+    slave_type: bridge
+    master: br0
+    state: present
+
+- name: Create master bridge
+  community.general.nmcli:
+    type: bridge
+    conn_name: br0
+    method4: disabled
+    method6: disabled
+    state: present
+
+## Creating vlan connection attached to bridge
+- name: Create master bridge
+  community.general.nmcli:
+    type: bridge
+    conn_name: br0
+    state: present
+
+- name: Create VLAN 5
+  community.general.nmcli:
+    type: vlan
+    conn_name: eth0.5
+    slave_type: bridge
+    master: br0
+    vlandev: eth0
+    vlanid: 5
+    state: present
 '''
 
 RETURN = r"""#
@@ -1475,6 +1517,7 @@ def __init__(self, module):
         self.ignore_unsupported_suboptions = module.params['ignore_unsupported_suboptions']
         self.autoconnect = module.params['autoconnect']
         self.conn_name = module.params['conn_name']
+        self.slave_type = module.params['slave_type']
         self.master = module.params['master']
         self.ifname = module.params['ifname']
         self.type = module.params['type']
@@ -1570,6 +1613,14 @@ def __init__(self, module):
 
         self.edit_commands = []
 
+        self.extra_options_validation()
+
+    def extra_options_validation(self):
+        """ Additional validation of options set passed to module that cannot be implemented in module's argspecs. """
+        if self.type not in ("bridge-slave", "team-slave", "bond-slave"):
+            if self.master is None and self.slave_type is not None:
+                self.module.fail_json(msg="'master' option is required when 'slave_type' is specified.")
+
     def execute_command(self, cmd, use_unsafe_shell=False, data=None):
         if isinstance(cmd, list):
             cmd = [to_text(item) for item in cmd]
@@ -1634,6 +1685,7 @@ def connection_options(self, detect_change=False):
         if self.slave_conn_type:
             options.update({
                 'connection.master': self.master,
+                'connection.slave-type': self.slave_type,
             })
 
         # Options specific to a connection type.
@@ -1649,9 +1701,17 @@ def connection_options(self, detect_change=False):
                 'xmit_hash_policy': self.xmit_hash_policy,
             })
         elif self.type == 'bond-slave':
-            options.update({
-                'connection.slave-type': 'bond',
-            })
+            if self.slave_type and self.slave_type != 'bond':
+                self.module.fail_json(msg="Connection type '%s' cannot be combined with '%s' slave-type. "
+                                          "Allowed slave-type for '%s' is 'bond'."
+                                          % (self.type, self.slave_type, self.type)
+                                      )
+            if not self.slave_type:
+                self.module.warn("Connection 'slave-type' property automatically set to 'bond' "
+                                 "because of using 'bond-slave' connection type.")
+                options.update({
+                    'connection.slave-type': 'bond',
+                })
         elif self.type == 'bridge':
             options.update({
                 'bridge.ageing-time': self.ageingtime,
@@ -1675,16 +1735,36 @@ def connection_options(self, detect_change=False):
                     'team.runner-fast-rate': self.runner_fast_rate,
                 })
         elif self.type == 'bridge-slave':
+            if self.slave_type and self.slave_type != 'bridge':
+                self.module.fail_json(msg="Connection type '%s' cannot be combined with '%s' slave-type. "
+                                          "Allowed slave-type for '%s' is 'bridge'."
+                                          % (self.type, self.slave_type, self.type)
+                                      )
+            if not self.slave_type:
+                self.module.warn("Connection 'slave-type' property automatically set to 'bridge' "
+                                 "because of using 'bridge-slave' connection type.")
+                options.update({'connection.slave-type': 'bridge'})
+            self.module.warn(
+                "Connection type as 'bridge-slave' implies 'ethernet' connection with 'bridge' slave-type. "
+                "Consider using slave_type='bridge' with necessary type."
+            )
             options.update({
-                'connection.slave-type': 'bridge',
                 'bridge-port.path-cost': self.path_cost,
                 'bridge-port.hairpin-mode': self.hairpin,
                 'bridge-port.priority': self.slavepriority,
             })
         elif self.type == 'team-slave':
-            options.update({
-                'connection.slave-type': 'team',
-            })
+            if self.slave_type and self.slave_type != 'team':
+                self.module.fail_json(msg="Connection type '%s' cannot be combined with '%s' slave-type. "
+                                          "Allowed slave-type for '%s' is 'team'."
+                                          % (self.type, self.slave_type, self.type)
+                                      )
+            if not self.slave_type:
+                self.module.warn("Connection 'slave-type' property automatically set to 'team' "
+                                 "because of using 'team-slave' connection type.")
+                options.update({
+                    'connection.slave-type': 'team',
+                })
         elif self.tunnel_conn_type:
             options.update({
                 'ip-tunnel.local': self.ip_tunnel_local,
@@ -1869,6 +1949,12 @@ def ip6_privacy_to_num(privacy):
     @property
     def slave_conn_type(self):
         return self.type in (
+            'ethernet',
+            'bridge',
+            'bond',
+            'vlan',
+            'team',
+            'wifi',
             'bond-slave',
             'bridge-slave',
             'team-slave',
@@ -2260,6 +2346,7 @@ def main():
             state=dict(type='str', required=True, choices=['absent', 'present']),
             conn_name=dict(type='str', required=True),
             master=dict(type='str'),
+            slave_type=dict(type='str', choices=['bond', 'bridge', 'team']),
             ifname=dict(type='str'),
             type=dict(type='str',
                       choices=[
@@ -2416,7 +2503,7 @@ def main():
         if nmcli.runner_fast_rate is not None and nmcli.runner != "lacp":
             nmcli.module.fail_json(msg="runner-fast-rate is only allowed for runner lacp")
     # team-slave checks
-    if nmcli.type == 'team-slave':
+    if nmcli.type == 'team-slave' or nmcli.slave_type == 'team':
         if nmcli.master is None:
             nmcli.module.fail_json(msg="Please specify a name for the master when type is %s" % nmcli.type)
         if nmcli.ifname is None:


Test patch:
diff --git a/tests/unit/plugins/modules/test_nmcli.py b/tests/unit/plugins/modules/test_nmcli.py
--- a/tests/unit/plugins/modules/test_nmcli.py
+++ b/tests/unit/plugins/modules/test_nmcli.py
@@ -4031,6 +4031,7 @@ def test_bond_connection_unchanged(mocked_generic_connection_diff_check, capfd):
             state=dict(type='str', required=True, choices=['absent', 'present']),
             conn_name=dict(type='str', required=True),
             master=dict(type='str'),
+            slave_type=dict(type=str, choices=['bond', 'bridge', 'team']),
             ifname=dict(type='str'),
             type=dict(type='str',
                       choices=[
@@ -4258,3 +4259,294 @@ def test_macvlan_mod(mocked_generic_connection_modify, capfd):
     results = json.loads(out)
     assert not results.get('failed')
     assert results['changed']
+
+
+TESTCASE_SLAVE_TYPE_BRIDGE_CONNECTION = [
+    {
+        'type': 'ethernet',
+        'conn_name': 'fake_conn',
+        'ifname': 'fake_eth0',
+        'state': 'present',
+        'slave_type': 'bridge',
+        'master': 'fake_br0',
+        '_ansible_check_mode': False,
+    }
+]
+
+
+TESTCASE_SLAVE_TYPE_BRIDGE_CONNECTION_SHOW_OUTPUT = """\
+connection.id:                          fake_conn
+connection.type:                        802-3-ethernet
+connection.interface-name:              fake_eth0
+connection.autoconnect:                 yes
+connection.master:                      --
+connection.slave-type:                  --
+802-3-ethernet.mtu:                     auto
+"""
+
+
+TESTCASE_SLAVE_TYPE_BRIDGE_CONNECTION_UNCHANGED_SHOW_OUTPUT = """\
+connection.id:                          fake_conn
+connection.type:                        802-3-ethernet
+connection.interface-name:              fake_eth0
+connection.autoconnect:                 yes
+connection.master:                      fake_br0
+connection.slave-type:                  bridge
+802-3-ethernet.mtu:                     auto
+"""
+
+
+@pytest.fixture
+def mocked_slave_type_bridge_create(mocker):
+    mocker_set(mocker,
+               execute_return=None,
+               execute_side_effect=(
+                   (0, TESTCASE_SLAVE_TYPE_BRIDGE_CONNECTION_SHOW_OUTPUT, ""),
+                   (0, "", ""),
+               ))
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SLAVE_TYPE_BRIDGE_CONNECTION, indirect=['patch_ansible_module'])
+def test_create_slave_type_bridge(mocked_slave_type_bridge_create, capfd):
+    """
+    Test : slave for bridge created
+    """
+
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    assert nmcli.Nmcli.execute_command.call_count == 1
+    arg_list = nmcli.Nmcli.execute_command.call_args_list
+    args, kwargs = arg_list[0]
+
+    assert args[0][0] == '/usr/bin/nmcli'
+    assert args[0][1] == 'con'
+    assert args[0][2] == 'add'
+    assert args[0][3] == 'type'
+    assert args[0][4] == 'ethernet'
+    assert args[0][5] == 'con-name'
+    assert args[0][6] == 'fake_conn'
+    con_master_index = args[0].index('connection.master')
+    slave_type_index = args[0].index('connection.slave-type')
+    assert args[0][con_master_index + 1] == 'fake_br0'
+    assert args[0][slave_type_index + 1] == 'bridge'
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert results['changed']
+
+
+@pytest.fixture
+def mocked_create_slave_type_bridge_unchanged(mocker):
+    mocker_set(mocker,
+               connection_exists=True,
+               execute_return=(0, TESTCASE_SLAVE_TYPE_BRIDGE_CONNECTION_UNCHANGED_SHOW_OUTPUT, ""))
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SLAVE_TYPE_BRIDGE_CONNECTION, indirect=['patch_ansible_module'])
+def test_slave_type_bridge_unchanged(mocked_create_slave_type_bridge_unchanged, capfd):
+    """
+    Test : Existent slave for bridge unchanged
+    """
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert not results['changed']
+
+
+TESTCASE_SLAVE_TYPE_BOND_CONNECTION = [
+    {
+        'type': 'ethernet',
+        'conn_name': 'fake_conn',
+        'ifname': 'fake_eth0',
+        'state': 'present',
+        'slave_type': 'bond',
+        'master': 'fake_bond0',
+        '_ansible_check_mode': False,
+    }
+]
+
+
+TESTCASE_SLAVE_TYPE_BOND_CONNECTION_SHOW_OUTPUT = """\
+connection.id:                          fake_conn
+connection.type:                        802-3-ethernet
+connection.interface-name:              fake_eth0
+connection.autoconnect:                 yes
+connection.master:                      --
+connection.slave-type:                  --
+802-3-ethernet.mtu:                     auto
+"""
+
+
+TESTCASE_SLAVE_TYPE_BOND_CONNECTION_UNCHANGED_SHOW_OUTPUT = """\
+connection.id:                          fake_conn
+connection.type:                        802-3-ethernet
+connection.interface-name:              fake_eth0
+connection.autoconnect:                 yes
+connection.master:                      fake_bond0
+connection.slave-type:                  bond
+802-3-ethernet.mtu:                     auto
+"""
+
+
+@pytest.fixture
+def mocked_slave_type_bond_create(mocker):
+    mocker_set(mocker,
+               execute_return=None,
+               execute_side_effect=(
+                   (0, TESTCASE_SLAVE_TYPE_BOND_CONNECTION_SHOW_OUTPUT, ""),
+                   (0, "", ""),
+               ))
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SLAVE_TYPE_BOND_CONNECTION, indirect=['patch_ansible_module'])
+def test_create_slave_type_bond(mocked_slave_type_bond_create, capfd):
+    """
+    Test : slave for bond created
+    """
+
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    assert nmcli.Nmcli.execute_command.call_count == 1
+    arg_list = nmcli.Nmcli.execute_command.call_args_list
+    args, kwargs = arg_list[0]
+
+    assert args[0][0] == '/usr/bin/nmcli'
+    assert args[0][1] == 'con'
+    assert args[0][2] == 'add'
+    assert args[0][3] == 'type'
+    assert args[0][4] == 'ethernet'
+    assert args[0][5] == 'con-name'
+    assert args[0][6] == 'fake_conn'
+    con_master_index = args[0].index('connection.master')
+    slave_type_index = args[0].index('connection.slave-type')
+    assert args[0][con_master_index + 1] == 'fake_bond0'
+    assert args[0][slave_type_index + 1] == 'bond'
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert results['changed']
+
+
+@pytest.fixture
+def mocked_create_slave_type_bond_unchanged(mocker):
+    mocker_set(mocker,
+               connection_exists=True,
+               execute_return=(0, TESTCASE_SLAVE_TYPE_BOND_CONNECTION_UNCHANGED_SHOW_OUTPUT, ""))
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SLAVE_TYPE_BOND_CONNECTION, indirect=['patch_ansible_module'])
+def test_slave_type_bond_unchanged(mocked_create_slave_type_bond_unchanged, capfd):
+    """
+    Test : Existent slave for bridge unchanged
+    """
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert not results['changed']
+
+
+TESTCASE_SLAVE_TYPE_TEAM_CONNECTION = [
+    {
+        'type': 'ethernet',
+        'conn_name': 'fake_conn',
+        'ifname': 'fake_eth0',
+        'state': 'present',
+        'slave_type': 'team',
+        'master': 'fake_team0',
+        '_ansible_check_mode': False,
+    }
+]
+
+
+TESTCASE_SLAVE_TYPE_TEAM_CONNECTION_SHOW_OUTPUT = """\
+connection.id:                          fake_conn
+connection.type:                        802-3-ethernet
+connection.interface-name:              fake_eth0
+connection.autoconnect:                 yes
+connection.master:                      --
+connection.slave-type:                  --
+802-3-ethernet.mtu:                     auto
+"""
+
+
+TESTCASE_SLAVE_TYPE_TEAM_CONNECTION_UNCHANGED_SHOW_OUTPUT = """\
+connection.id:                          fake_conn
+connection.type:                        802-3-ethernet
+connection.interface-name:              fake_eth0
+connection.autoconnect:                 yes
+connection.master:                      fake_team0
+connection.slave-type:                  team
+802-3-ethernet.mtu:                     auto
+"""
+
+
+@pytest.fixture
+def mocked_slave_type_team_create(mocker):
+    mocker_set(mocker,
+               execute_return=None,
+               execute_side_effect=(
+                   (0, TESTCASE_SLAVE_TYPE_TEAM_CONNECTION_SHOW_OUTPUT, ""),
+                   (0, "", ""),
+               ))
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SLAVE_TYPE_TEAM_CONNECTION, indirect=['patch_ansible_module'])
+def test_create_slave_type_team(mocked_slave_type_team_create, capfd):
+    """
+    Test : slave for bond created
+    """
+
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    assert nmcli.Nmcli.execute_command.call_count == 1
+    arg_list = nmcli.Nmcli.execute_command.call_args_list
+    args, kwargs = arg_list[0]
+
+    assert args[0][0] == '/usr/bin/nmcli'
+    assert args[0][1] == 'con'
+    assert args[0][2] == 'add'
+    assert args[0][3] == 'type'
+    assert args[0][4] == 'ethernet'
+    assert args[0][5] == 'con-name'
+    assert args[0][6] == 'fake_conn'
+    con_master_index = args[0].index('connection.master')
+    slave_type_index = args[0].index('connection.slave-type')
+    assert args[0][con_master_index + 1] == 'fake_team0'
+    assert args[0][slave_type_index + 1] == 'team'
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert results['changed']
+
+
+@pytest.fixture
+def mocked_create_slave_type_team_unchanged(mocker):
+    mocker_set(mocker,
+               connection_exists=True,
+               execute_return=(0, TESTCASE_SLAVE_TYPE_TEAM_CONNECTION_UNCHANGED_SHOW_OUTPUT, ""))
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SLAVE_TYPE_TEAM_CONNECTION, indirect=['patch_ansible_module'])
+def test_slave_type_team_unchanged(mocked_create_slave_type_team_unchanged, capfd):
+    """
+    Test : Existent slave for bridge unchanged
+    """
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert not results['changed']