Repository: ansible/ansible

Issue:
[DI_MISSING] Add missing declarative module net_l3_interface for supported platforms
<!---
Verify first that your issue/request is not already reported on GitHub.
Also test if the latest release, and master branch are affected too.
-->

##### ISSUE TYPE
<!--- Pick one below and delete the rest: -->
 - Feature Idea

##### COMPONENT NAME
<!--- Name of the module/plugin/task/feature -->

- [x] nxos/nxos_l3_interface https://github.com/ansible/ansible/issues/31888 @trishnaguha
- [x] ios/ios_l3_interface https://github.com/ansible/ansible/pull/34201 @ganeshrn
- ~[ ] iosxr/iosxr_l3_interface~
- [x] eos/eos_l3_interface https://github.com/ansible/ansible/pull/34224 @ganeshrn

##### ANSIBLE VERSION
<!--- Paste verbatim output from "ansible --version" between quotes below -->
```
2.5
```


Discussion:
cc @Qalthos @ganeshrn @gundalow @privateip @rcarrillocruz
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
cc @kedarX
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
cc @CloudEngine @CloudEngine-Ansible @CumulusNetworks @Etienne-Carriere @GGabriele @QijunPan @abirami-n @alexanderturner @amitsi @amuraleedhar @bjolivot @caphrim007 @chiradeep @cmoberg @cnasten @dagwieers @dgarros @dhivyap @djh00t @dkasberg @dominis @ericchou @ericchou1 @ericsysmin @evgenyfedoruk @giorgos-nikolopoulos @grastogi23 @isharacomix @ivanbojer @jayakody @jedelman8 @jeremyschulman @jmcgill298 @jmighion @jrrivers @jtschichold @khaltore @lpenz @mharista @mhite @mikewiebe @mischapeters @mryanlam @ogenstad @paulquack @pdellaert @pekdon @perzizzle @qalthos @rahushen @rnh556 @schunduri @sebasdoes @skg-net @stygstra @tedelhourani @userlerueda @vinayvenkat @vuile @wojtek0806 @xen0l
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
cc @atabachnik @samerd
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Target Dev complete date: 2017-12-22

Resolving patch:
diff --git a/lib/ansible/module_utils/network/ios/ios.py b/lib/ansible/module_utils/network/ios/ios.py
--- a/lib/ansible/module_utils/network/ios/ios.py
+++ b/lib/ansible/module_utils/network/ios/ios.py
@@ -123,7 +123,7 @@ def run_commands(module, commands, check_rc=True):
 
 
 def load_config(module, commands):
-
+    response = []
     rc, out, err = exec_command(module, 'configure terminal')
     if rc != 0:
         module.fail_json(msg='unable to enter configuration mode', err=to_text(out, errors='surrogate_then_replace'))
@@ -135,5 +135,8 @@ def load_config(module, commands):
         if rc != 0:
             exec_command(module, 'end')
             module.fail_json(msg=to_text(err, errors='surrogate_then_replace'), command=command, rc=rc)
+        response.append({command: out})
 
     exec_command(module, 'end')
+
+    return response
diff --git a/lib/ansible/modules/network/ios/ios_l3_interface.py b/lib/ansible/modules/network/ios/ios_l3_interface.py
new file mode 100644
--- /dev/null
+++ b/lib/ansible/modules/network/ios/ios_l3_interface.py
@@ -0,0 +1,317 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# (c) 2017, Ansible by Red Hat, inc
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+
+ANSIBLE_METADATA = {'metadata_version': '1.1',
+                    'status': ['preview'],
+                    'supported_by': 'network'}
+
+DOCUMENTATION = """
+---
+module: ios_l3_interface
+version_added: "2.5"
+author: "Ganesh Nalawade (@ganeshrn)"
+short_description: Manage L3 interfaces on IOS network devices.
+description:
+  - This module provides declarative management of L3 interfaces
+    on IOS network devices.
+notes:
+  - Tested against IOS 15.2
+options:
+  name:
+    description:
+      - Name of the L3 interface to be configured eg. GigabitEthernet0/2
+  ipv4:
+    description:
+      - IPv4 address to be set for the L3 interface mentioned in I(name) option.
+        The address format is <ipv4 address>/<mask>, the mask is number
+        in range 0-32 eg. 192.168.0.1/24
+  ipv6:
+    description:
+      - IPv6 address to be set for the L3 interface mentioned in I(name) option.
+        The address format is <ipv6 address>/<mask>, the mask is number
+        in range 0-128 eg. fd5d:12c9:2201:1::1/64
+  aggregate:
+    description:
+      - List of L3 interfaces definitions. Each of the entry in aggregate list should
+        define name of interface C(name) and a optional C(ipv4) or C(ipv6) address.
+  state:
+    description:
+      - State of the L3 interface configuration. It indicates if the configuration should
+        be present or absent on remote device.
+    default: present
+    choices: ['present', 'absent']
+"""
+
+EXAMPLES = """
+- name: Remove GigabitEthernet0/3 IPv4 and IPv6 address
+  ios_l3_interface:
+    name: GigabitEthernet0/3
+    state: absent
+
+- name: Set GigabitEthernet0/3 IPv4 address
+  ios_l3_interface:
+    name: GigabitEthernet0/3
+    ipv4: 192.168.0.1/24
+
+- name: Set GigabitEthernet0/3 IPv6 address
+  ios_l3_interface:
+    name: GigabitEthernet0/3
+    ipv6: "fd5d:12c9:2201:1::1/64"
+
+- name: Set GigabitEthernet0/3 in dhcp
+  ios_l3_interface:
+    name: GigabitEthernet0/3
+    ipv4: dhcp
+    ipv6: dhcp
+
+- name: Set IP addresses on aggregate
+  ios_l3_interface:
+    aggregate:
+      - { name: GigabitEthernet0/3, ipv4: 192.168.2.10/24 }
+      - { name: GigabitEthernet0/3, ipv4: 192.168.3.10/24, ipv6: "fd5d:12c9:2201:1::1/64" }
+
+- name: Remove IP addresses on aggregate
+  ios_l3_interface:
+    aggregate:
+      - { name: GigabitEthernet0/3, ipv4: 192.168.2.10/24 }
+      - { name: GigabitEthernet0/3, ipv4: 192.168.3.10/24, ipv6: "fd5d:12c9:2201:1::1/64" }
+    state: absent
+"""
+
+RETURN = """
+commands:
+  description: The list of configuration mode commands to send to the device
+  returned: always, except for the platforms that use Netconf transport to manage the device.
+  type: list
+  sample:
+    - interface GigabitEthernet0/2
+    - ip address 192.168.0.1 255.255.255.0
+    - ipv6 address fd5d:12c9:2201:1::1/64
+"""
+import re
+
+from copy import deepcopy
+
+from ansible.module_utils._text import to_text
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils.six import iteritems
+from ansible.module_utils.network.ios.ios import get_config, load_config
+from ansible.module_utils.network.ios.ios import ios_argument_spec
+from ansible.module_utils.network.common.config import NetworkConfig
+from ansible.module_utils.network.common.utils import conditional, remove_default_spec
+from ansible.module_utils.network.common.utils import is_netmask, is_masklen, to_subnet, to_netmask, to_masklen
+
+
+def validate_ipv4(value, module):
+    if value:
+        address = value.split('/')
+        if len(address) != 2:
+            module.fail_json(msg='address format is <ipv4 address>/<mask>, got invalid format %s' % value)
+
+        if not is_masklen(address[1]):
+            module.fail_json(msg='invalid value for mask: %s, mask should be in range 0-32' % address[1])
+
+
+def validate_ipv6(value, module):
+    if value:
+        address = value.split('/')
+        if len(address) != 2:
+            module.fail_json(msg='address format is <ipv6 address>/<mask>, got invalid format %s' % value)
+        else:
+            if not 0 <= int(address[1]) <= 128:
+                module.fail_json(msg='invalid value for mask: %s, mask should be in range 0-128' % address[1])
+
+
+def validate_param_values(module, obj, param=None):
+    if param is None:
+        param = module.params
+    for key in obj:
+        # validate the param value (if validator func exists)
+        validator = globals().get('validate_%s' % key)
+        if callable(validator):
+            validator(param.get(key), module)
+
+
+def parse_config_argument(configobj, name, arg=None):
+    cfg = configobj['interface %s' % name]
+    cfg = '\n'.join(cfg.children)
+    match = re.search(r'%s (.+)$' % arg, cfg, re.M)
+    if match:
+        return match.group(1).strip()
+
+
+def search_obj_in_list(name, lst):
+    for o in lst:
+        if o['name'] == name:
+            return o
+
+    return None
+
+
+def map_obj_to_commands(updates, module):
+    commands = list()
+    want, have = updates
+    for w in want:
+        name = w['name']
+        ipv4 = w['ipv4']
+        ipv6 = w['ipv6']
+        state = w['state']
+
+        interface = 'interface ' + name
+        commands.append(interface)
+
+        obj_in_have = search_obj_in_list(name, have)
+        if state == 'absent' and obj_in_have:
+            if obj_in_have['ipv4']:
+                if ipv4:
+                    address = ipv4.split('/')
+                    if len(address) == 2:
+                        ipv4 = '{0} {1}'.format(address[0], to_netmask(address[1]))
+                    commands.append('no ip address {}'.format(ipv4))
+                else:
+                    commands.append('no ip address')
+            if obj_in_have['ipv6']:
+                if ipv6:
+                    commands.append('no ipv6 address {}'.format(ipv6))
+                else:
+                    commands.append('no ipv6 address')
+
+        elif state == 'present':
+            if ipv4:
+                if obj_in_have is None or ipv4 != obj_in_have['ipv4']:
+                    address = ipv4.split('/')
+                    if len(address) == 2:
+                        ipv4 = '{0} {1}'.format(address[0], to_netmask(address[1]))
+                    commands.append('ip address {}'.format(ipv4))
+
+            if ipv6:
+                if obj_in_have is None or obj_in_have['ipv6'] is None or ipv6.lower() != obj_in_have['ipv6'].lower():
+                    commands.append('ipv6 address {}'.format(ipv6))
+
+        if commands[-1] == interface:
+            commands.pop(-1)
+
+    return commands
+
+
+def map_config_to_obj(module):
+    config = get_config(module, flags=['| section interface'])
+    configobj = NetworkConfig(indent=1, contents=config)
+
+    match = re.findall(r'^interface (\S+)', config, re.M)
+    if not match:
+        return list()
+
+    instances = list()
+
+    for item in set(match):
+        ipv4 = parse_config_argument(configobj, item, 'ip address')
+        if ipv4:
+            # eg. 192.168.2.10 255.255.255.0 -> 192.168.2.10/24
+            address = ipv4.strip().split(' ')
+            if len(address) == 2 and is_netmask(address[1]):
+                ipv4 = '{0}/{1}'.format(address[0], to_text(to_masklen(address[1])))
+
+        obj = {
+            'name': item,
+            'ipv4': ipv4,
+            'ipv6': parse_config_argument(configobj, item, 'ipv6 address'),
+            'state': 'present'
+        }
+        instances.append(obj)
+
+    return instances
+
+
+def map_params_to_obj(module):
+    obj = []
+
+    aggregate = module.params.get('aggregate')
+    if aggregate:
+        for item in aggregate:
+            for key in item:
+                if item.get(key) is None:
+                    item[key] = module.params[key]
+
+            validate_param_values(module, item, item)
+            obj.append(item.copy())
+    else:
+        obj.append({
+            'name': module.params['name'],
+            'ipv4': module.params['ipv4'],
+            'ipv6': module.params['ipv6'],
+            'state': module.params['state']
+        })
+
+        validate_param_values(module, obj)
+
+    return obj
+
+
+def main():
+    """ main entry point for module execution
+    """
+    element_spec = dict(
+        name=dict(),
+        ipv4=dict(),
+        ipv6=dict(),
+        state=dict(default='present',
+                   choices=['present', 'absent'])
+    )
+
+    aggregate_spec = deepcopy(element_spec)
+    aggregate_spec['name'] = dict(required=True)
+
+    # remove default in aggregate spec, to handle common arguments
+    remove_default_spec(aggregate_spec)
+
+    argument_spec = dict(
+        aggregate=dict(type='list', elements='dict', options=aggregate_spec),
+    )
+
+    argument_spec.update(element_spec)
+    argument_spec.update(ios_argument_spec)
+
+    required_one_of = [['name', 'aggregate']]
+    mutually_exclusive = [['name', 'aggregate']]
+    module = AnsibleModule(argument_spec=argument_spec,
+                           required_one_of=required_one_of,
+                           mutually_exclusive=mutually_exclusive,
+                           supports_check_mode=True)
+
+    warnings = list()
+
+    result = {'changed': False}
+
+    if warnings:
+        result['warnings'] = warnings
+
+    want = map_params_to_obj(module)
+    have = map_config_to_obj(module)
+
+    commands = map_obj_to_commands((want, have), module)
+    result['commands'] = commands
+
+    if commands:
+        if not module.check_mode:
+            response = load_config(module, commands)
+            for item in response:
+                for cmd, rsp in iteritems(item):
+                    for err in ('Error:', 'overlaps with', 'Bad mask'):
+                        if err in rsp:
+                            module.fail_json(msg="command '%s' failed with error '%s'." % (cmd, rsp))
+
+        result['changed'] = True
+
+    module.exit_json(**result)
+
+
+if __name__ == '__main__':
+    main()


Test patch:
diff --git a/test/integration/ios.yaml b/test/integration/ios.yaml
--- a/test/integration/ios.yaml
+++ b/test/integration/ios.yaml
@@ -103,6 +103,15 @@
             failed_modules: "{{ failed_modules }} + [ 'ios_ping' ]"
             test_failed: true
 
+    - block:
+      - include_role:
+          name: ios_l3_interface
+        when: "limit_to in ['*', 'ios_l3_interface']"
+      rescue:
+        - set_fact:
+            failed_modules: "{{ failed_modules }} + [ 'ios_l3_interface' ]"
+            test_failed: true
+
 #    - block:
 #      - include_role:
 #          name: ios_vlan
diff --git a/test/integration/targets/ios_l3_interface/defaults/main.yaml b/test/integration/targets/ios_l3_interface/defaults/main.yaml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/ios_l3_interface/defaults/main.yaml
@@ -0,0 +1,2 @@
+---
+testcase: "*"
diff --git a/test/integration/targets/ios_l3_interface/meta/main.yaml b/test/integration/targets/ios_l3_interface/meta/main.yaml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/ios_l3_interface/meta/main.yaml
@@ -0,0 +1,2 @@
+dependencies:
+  - prepare_ios_tests
diff --git a/test/integration/targets/ios_l3_interface/tasks/cli.yaml b/test/integration/targets/ios_l3_interface/tasks/cli.yaml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/ios_l3_interface/tasks/cli.yaml
@@ -0,0 +1,22 @@
+---
+- name: collect all cli test cases
+  find:
+    paths: "{{ role_path }}/tests/cli"
+    patterns: "{{ testcase }}.yaml"
+  register: test_cases
+  delegate_to: localhost
+
+- name: set test_items
+  set_fact: test_items="{{ test_cases.files | map(attribute='path') | list }}"
+
+- name: run test cases (connection=network_cli)
+  include: "{{ test_case_to_run }} ansible_connection=network_cli ansible_become=yes"
+  with_items: "{{ test_items }}"
+  loop_control:
+    loop_var: test_case_to_run
+
+- name: run test case (connection=local)
+  include: "{{ test_case_to_run }} ansible_connection=local"
+  with_first_found: "{{ test_items }}"
+  loop_control:
+    loop_var: test_case_to_run
diff --git a/test/integration/targets/ios_l3_interface/tasks/main.yaml b/test/integration/targets/ios_l3_interface/tasks/main.yaml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/ios_l3_interface/tasks/main.yaml
@@ -0,0 +1,2 @@
+---
+- { include: cli.yaml, tags: ['cli'] }
diff --git a/test/integration/targets/ios_l3_interface/tests/cli/basic.yaml b/test/integration/targets/ios_l3_interface/tests/cli/basic.yaml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/ios_l3_interface/tests/cli/basic.yaml
@@ -0,0 +1,261 @@
+---
+- debug: msg="START ios_l3_interface cli/basic.yaml on connection={{ ansible_connection }}"
+
+- name: Run show version
+  ios_command:
+    commands: show version
+    authorize: yes
+  register: show_version_result
+
+- block:
+    - name: Set test interface to GigabitEthernet0/1 as we are on Cisco IOS
+      set_fact: test_interface=GigabitEthernet0/1
+    - name: Set test interface 2 to GigabitEthernet0/2 as we are on Cisco IOS
+      set_fact: test_interface2=GigabitEthernet0/2
+  when: "'Cisco IOS' in show_version_result.stdout[0]"
+
+- block:
+    - name: Set test interface to GigabitEthernet2 as we are on Cisco IOS-XE
+      set_fact: test_interface=GigabitEthernet2
+    - name: Disable autonegotiation on GigabitEthernet2
+      ios_config:
+        lines:
+          - no negotiation auto
+        parents: int GigabitEthernet2
+        authorize: yes
+
+    - name: Set test interface 2 to GigabitEthernet3 as we are on Cisco IOS-XE
+      set_fact: test_interface2=GigabitEthernet3
+    - name: Disable autonegotiation on GigabitEthernet3
+      ios_config:
+        lines:
+          - no negotiation auto
+        parents: int GigabitEthernet3
+        authorize: yes
+  when: "'Cisco IOS-XE' in show_version_result.stdout[0]"
+
+- name: Delete interface ipv4 and ipv6 address(setup)
+  ios_l3_interface:
+    name: "{{ test_interface }}"
+    state: absent
+    authorize: yes
+  register: result
+
+- name: Configure interface ipv4 address
+  ios_l3_interface:
+    name: "{{ test_interface }}"
+    ipv4: 192.168.0.1/24
+    state: present
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == true'
+      - '"interface {{ test_interface }}" in result.commands'
+      - '"ip address 192.168.0.1 255.255.255.0" in result.commands'
+
+- name: Configure interface ipv4 address (idempotent)
+  ios_l3_interface:
+    name: "{{ test_interface }}"
+    ipv4: 192.168.0.1/24
+    state: present
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == false'
+
+- name: Assign same ipv4 address to other interface (fail)
+  ios_l3_interface:
+    name: "{{ test_interface2 }}"
+    ipv4: 192.168.0.1/24
+    state: present
+    authorize: yes
+  ignore_errors: yes
+  register: result
+
+- assert:
+    that:
+      - "result.failed == true"
+      - "result.msg is defined"
+
+- name: Change interface ipv4 address
+  ios_l3_interface:
+    name: "{{ test_interface }}"
+    ipv4: dhcp
+    state: present
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == true'
+      - '"interface {{ test_interface }}" in result.commands'
+      - '"ip address dhcp" in result.commands'
+
+- name: Configure interface ipv6 address
+  ios_l3_interface:
+    name: "{{ test_interface }}"
+    ipv6: fd5d:12c9:2201:1::1/64
+    state: present
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == true'
+      - '"interface {{ test_interface }}" in result.commands'
+      - '"ipv6 address fd5d:12c9:2201:1::1/64" in result.commands'
+
+- name: Configure interface ipv6 address (idempotent)
+  ios_l3_interface:
+    name: "{{ test_interface }}"
+    ipv6: fd5d:12c9:2201:1::1/64
+    state: present
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == false'
+
+- name: Assign same ipv6 address to other interface (fail)
+  ios_l3_interface:
+    name: "{{ test_interface2 }}"
+    ipv6: fd5d:12c9:2201:1::1/64
+    state: present
+    authorize: yes
+  ignore_errors: yes
+  register: result
+
+- assert:
+    that:
+      - "result.failed == true"
+      - "result.msg is defined"
+
+- name: Change interface ipv6 address
+  ios_l3_interface:
+    name: "{{ test_interface }}"
+    ipv6: dhcp
+    state: present
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == true'
+      - '"interface {{ test_interface }}" in result.commands'
+      - '"ipv6 address dhcp" in result.commands'
+
+- name: Delete interface ipv4 and ipv6 address
+  ios_l3_interface:
+    name: "{{ test_interface }}"
+    state: absent
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == true'
+      - '"interface {{ test_interface }}" in result.commands'
+      - '"no ip address" in result.commands'
+      - '"no ipv6 address" in result.commands'
+
+- name: Delete interface ipv4 and ipv6 address (idempotent)
+  ios_l3_interface:
+    name: "{{ test_interface }}"
+    state: absent
+    authorize: yes
+  register: result
+
+- name: Delete second interface ipv4 and ipv6 address (setup)
+  ios_l3_interface:
+    name: "{{ test_interface2 }}"
+    state: absent
+    authorize: yes
+  register: result
+
+- name: Configure ipv4 and ipv6 address using aggregate
+  ios_l3_interface:
+    aggregate:
+    - { name: "{{ test_interface }}", ipv4: 192.161.0.1/24, ipv6: "fd5d:12c9:2201:2::2/64" }
+    - { name: "{{ test_interface2 }}", ipv4: 192.162.0.2/16, ipv6: "fd5e:12c9:2201:3::3/32" }
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == true'
+      - '"interface {{ test_interface }}" in result.commands'
+      - '"ip address 192.161.0.1 255.255.255.0" in result.commands'
+      - '"ipv6 address fd5d:12c9:2201:2::2/64" in result.commands'
+      - '"interface {{ test_interface2 }}" in result.commands'
+      - '"ip address 192.162.0.2 255.255.0.0" in result.commands'
+      - '"ipv6 address fd5e:12c9:2201:3::3/32" in result.commands'
+
+- name:  Configure ipv4 and ipv6 address using aggregate (idempotent)
+  ios_l3_interface:
+    aggregate:
+    - { name: "{{ test_interface }}", ipv4: 192.161.0.1/24, ipv6: "fd5d:12c9:2201:2::2/64" }
+    - { name: "{{ test_interface2 }}", ipv4: 192.162.0.2/16, ipv6: "fd5e:12c9:2201:3::3/32" }
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == false'
+
+- name:  Change ipv4 and ipv6 address using aggregate
+  ios_l3_interface:
+    aggregate:
+    - { name: "{{ test_interface }}", ipv4: 193.167.1.1/8, ipv6: "fd5a:12c9:2201:4::4/32" }
+    - { name: "{{ test_interface2 }}", ipv4: 192.169.2.2/24, ipv6: "fd5b:12c9:2201:5::5/90" }
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == true'
+      - '"interface {{ test_interface }}" in result.commands'
+      - '"ip address 193.167.1.1 255.0.0.0" in result.commands'
+      - '"ipv6 address fd5a:12c9:2201:4::4/32" in result.commands'
+      - '"interface {{ test_interface2 }}" in result.commands'
+      - '"ip address 192.169.2.2 255.255.255.0" in result.commands'
+      - '"ipv6 address fd5b:12c9:2201:5::5/90" in result.commands'
+
+
+- name:  Delete ipv4 and ipv6 address using aggregate
+  ios_l3_interface:
+    aggregate:
+    - { name: "{{ test_interface }}" }
+    - { name: "{{ test_interface2 }}" }
+    state: absent
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == true'
+      - '"interface {{ test_interface }}" in result.commands'
+      - '"no ip address" in result.commands'
+      - '"no ipv6 address" in result.commands'
+      - '"interface {{ test_interface2 }}" in result.commands'
+      - '"no ip address" in result.commands'
+      - '"no ipv6 address" in result.commands'
+
+- name:  Delete ipv4 and ipv6 address using aggregate (idempotent)
+  ios_l3_interface:
+    aggregate:
+    - { name: "{{ test_interface }}" }
+    - { name: "{{ test_interface2 }}" }
+    state: absent
+    authorize: yes
+  register: result
+
+- assert:
+    that:
+      - 'result.changed == false'
+
+- debug: msg="END ios_l3_interface cli/basic.yaml on connection={{ ansible_connection }}"