diff --git a/changelogs/fragments/562-nmcli-fix-idempotency.yaml b/changelogs/fragments/562-nmcli-fix-idempotency.yaml new file mode 100644 index 0000000000..ad112a18dd --- /dev/null +++ b/changelogs/fragments/562-nmcli-fix-idempotency.yaml @@ -0,0 +1,2 @@ +bugfixes: + - nmcli - fix idempotetency when modifying an existing connection (https://github.com/ansible-collections/community.general/issues/481). \ No newline at end of file diff --git a/plugins/modules/net_tools/nmcli.py b/plugins/modules/net_tools/nmcli.py index 4b11b9b591..41e113ecc0 100644 --- a/plugins/modules/net_tools/nmcli.py +++ b/plugins/modules/net_tools/nmcli.py @@ -568,6 +568,11 @@ except (ImportError, ValueError): from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils._text import to_native, to_text +import re + + +class NmcliModuleError(Exception): + pass class Nmcli(object): @@ -670,6 +675,20 @@ class Nmcli(object): self.nmcli_bin = self.module.get_bin_path('nmcli', True) self.dhcp_client_id = module.params['dhcp_client_id'] + if self.ip4: + self.ipv4_method = 'manual' + else: + # supported values for 'ipv4.method': [auto, link-local, manual, shared, disabled] + # TODO: add a new module parameter to specify a non 'manual' value + self.ipv4_method = None + + if self.ip6: + self.ipv6_method = 'manual' + else: + # supported values for 'ipv6.method': [ignore, auto, dhcp, link-local, manual, shared] + # TODO: add a new module parameter to specify a non 'manual' value + self.ipv6_method = None + def execute_command(self, cmd, use_unsafe_shell=False, data=None): if isinstance(cmd, list): cmd = [to_text(item) for item in cmd] @@ -816,9 +835,11 @@ class Nmcli(object): def modify_connection_team(self): cmd = [self.nmcli_bin, 'con', 'mod', self.conn_name] options = { + 'ipv4.method': self.ipv4_method, 'ipv4.address': self.ip4, 'ipv4.gateway': self.gw4, 'ipv4.dns': self.dns4, + 'ipv6.method': self.ipv6_method, 'ipv6.address': self.ip6, 'ipv6.gateway': self.gw6, 'ipv6.dns': self.dns6, @@ -898,9 +919,11 @@ class Nmcli(object): # format for modifying bond interface options = { + 'ipv4.method': self.ipv4_method, 'ipv4.address': self.ip4, 'ipv4.gateway': self.gw4, 'ipv4.dns': self.dns4, + 'ipv6.method': self.ipv6_method, 'ipv6.address': self.ip6, 'ipv6.gateway': self.gw6, 'ipv6.dns': self.dns6, @@ -988,9 +1011,11 @@ class Nmcli(object): # - nmcli: conn_name=my-eth1 ifname=eth1 type=ethernet ip4=192.0.2.100/24 gw4=192.0.2.1 state=present # nmcli con mod con-name my-eth1 ifname eth1 type ethernet ipv4.address 192.0.2.100/24 ipv4.gateway 192.0.2.1 options = { + 'ipv4.method': self.ipv4_method, 'ipv4.address': self.ip4, 'ipv4.gateway': self.gw4, 'ipv4.dns': self.dns4, + 'ipv6.method': self.ipv6_method, 'ipv6.address': self.ip6, 'ipv6.gateway': self.gw6, 'ipv6.dns': self.dns6, @@ -1054,8 +1079,10 @@ class Nmcli(object): cmd = [self.nmcli_bin, 'con', 'mod', self.conn_name] options = { + 'ipv4.method': self.ipv4_method, 'ipv4.address': self.ip4, 'ipv4.gateway': self.gw4, + 'ipv6.method': self.ipv6_method, 'ipv6.address': self.ip6, 'ipv6.gateway': self.gw6, 'autoconnect': self.bool_to_string(self.autoconnect), @@ -1166,9 +1193,11 @@ class Nmcli(object): params = {'vlan.parent': self.vlandev, 'vlan.id': self.vlanid, + 'ipv4.method': self.ipv4_method, 'ipv4.address': self.ip4 or '', 'ipv4.gateway': self.gw4 or '', 'ipv4.dns': self.dns4 or '', + 'ipv6.method': self.ipv6_method, 'ipv6.address': self.ip6 or '', 'ipv6.gateway': self.gw6 or '', 'ipv6.dns': self.dns6 or '', @@ -1420,6 +1449,257 @@ class Nmcli(object): self.module.fail_json(msg="Type of device or network connection is required " "while performing 'modify' operation. Please specify 'type' as an argument.") + def show_connection(self): + cmd = [self.nmcli_bin, 'con', 'show'] + if self.conn_name is not None: + cmd.append(self.conn_name) + elif self.ifname is not None: + cmd.append(self.ifname) + else: + if self.type == 'vlan': + cmd.append('vlan%s' % to_text(self.vlanid)) + elif self.type == 'vxlan': + cmd.append('vxlan%s' % to_text(self.vxlan_id)) + elif self.type == 'ipip': + cmd.append('ipip%s' % self.ip_tunnel_dev) + elif self.type == 'sit': + cmd.append('sit%s' % self.ip_tunnel_dev) + else: + raise NmcliModuleError( + "Invalid connection identifier for nmcli") + + (rc, out, err) = self.execute_command(cmd) + + if rc != 0: + raise NmcliModuleError(err) + + p_enum_value = re.compile(r'^([-]?\d+) \((\w+)\)$') + + conn_info = dict() + for line in out.splitlines(): + pair = line.split(':', 1) + key = pair[0].strip() + if key and len(pair) > 1: + raw_value = pair[1].lstrip() + if raw_value == '--': + conn_info[key] = None + elif key == 'bond.options': + # Aliases such as 'miimon', 'downdelay' are equivalent to the +bond.options 'option=value' syntax. + opts = raw_value.split(',') + for opt in opts: + alias_pair = opt.split('=', 1) + if len(alias_pair) > 1: + alias_key = alias_pair[0] + alias_value = alias_pair[1] + conn_info[alias_key] = alias_value + elif key in ['ipv4.dns', 'ipv4.dns-search', 'ipv6.dns', 'ipv6.dns-search']: + values = raw_value.split(',') + conn_info[key] = values + else: + m_enum = p_enum_value.match(raw_value) + if m_enum is not None: + value = m_enum.group(1) + else: + value = raw_value + conn_info[key] = value + + return conn_info + + def _compare_conn_params(self, conn_info, options): + # See nmcli(1) for details + param_alias = { + 'type': 'connection.type', + 'con-name': 'connection.id', + 'autoconnect': 'connection.autoconnect', + 'ifname': 'connection.interface-name', + 'master': 'connection.master', + 'slave-type': 'connection.slave-type', + } + + changed = False + diff_before = dict() + diff_after = dict() + + for key, value in options.items(): + if not value: + continue + + # TODO: retain typed list arguments in Nmcli object instead of encoded strings + if key in ['ipv4.dns', 'ipv4.dns-search', 'ipv6.dns', 'ipv6.dns-search']: + list_values = value.split() + value = list_values + + if key in conn_info: + current_value = conn_info[key] + elif key in param_alias: + real_key = param_alias[key] + if real_key in conn_info: + current_value = conn_info[real_key] + else: + # alias parameter does not exist + current_value = None + else: + # parameter does not exist + current_value = None + + if isinstance(current_value, list) and isinstance(value, list): + # compare values between two lists + if sorted(current_value) != sorted(value): + changed = True + else: + if current_value != to_text(value): + changed = True + + diff_before[key] = current_value + diff_after[key] = value + + diff = { + 'before': diff_before, + 'after': diff_after, + } + return (changed, diff) + + def is_connection_changed(self): + conn_info = self.show_connection() + changed = False + + if self.type == 'team': + changed, diff = self._compare_conn_params(conn_info, { + 'ipv4.method': self.ipv4_method, + 'ipv4.addresses': self.ip4, + 'ipv4.gateway': self.gw4, + 'ipv4.dns': self.dns4, + 'ipv6.method': self.ipv6_method, + 'ipv6.addresses': self.ip6, + 'ipv6.gateway': self.gw6, + 'ipv6.dns': self.dns6, + 'autoconnect': self.bool_to_string(self.autoconnect), + 'ipv4.dns-search': self.dns4_search, + 'ipv6.dns-search': self.dns6_search, + 'ipv4.dhcp-client-id': self.dhcp_client_id, + }) + elif self.type == 'team-slave': + changed, diff = self._compare_conn_params(conn_info, { + 'connection.master': self.master, + '802-3-ethernet.mtu': to_text(self.mtu) + }) + elif self.type == 'bond': + changed, diff = self._compare_conn_params(conn_info, { + 'ipv4.method': self.ipv4_method, + 'ipv4.addresses': self.ip4, + 'ipv4.gateway': self.gw4, + 'ipv4.dns': self.dns4, + 'ipv6.method': self.ipv6_method, + 'ipv6.addresses': self.ip6, + 'ipv6.gateway': self.gw6, + 'ipv6.dns': self.dns6, + 'autoconnect': self.bool_to_string(self.autoconnect), + 'ipv4.dns-search': self.dns4_search, + 'ipv6.dns-search': self.dns6_search, + 'miimon': self.miimon, + 'downdelay': self.downdelay, + 'updelay': self.updelay, + 'arp-interval': self.arp_interval, + 'arp-ip-target': self.arp_ip_target, + 'ipv4.dhcp-client-id': self.dhcp_client_id, + }) + elif self.type == 'bond-slave': + changed, diff = self._compare_conn_params(conn_info, { + 'connection.master': self.master, + }) + elif self.type == 'ethernet': + changed, diff = self._compare_conn_params(conn_info, { + 'ipv4.method': self.ipv4_method, + 'ipv4.addresses': self.ip4, + 'ipv4.gateway': self.gw4, + 'ipv4.dns': self.dns4, + 'ipv6.method': self.ipv6_method, + 'ipv6.addresses': self.ip6, + 'ipv6.gateway': self.gw6, + 'ipv6.dns': self.dns6, + 'autoconnect': self.bool_to_string(self.autoconnect), + 'ipv4.dns-search': self.dns4_search, + 'ipv6.dns-search': self.dns6_search, + '802-3-ethernet.mtu': self.mtu, + 'ipv4.dhcp-client-id': self.dhcp_client_id, + }) + elif self.type == 'bridge': + changed, diff = self._compare_conn_params(conn_info, { + 'ipv4.method': self.ipv4_method, + 'ipv4.addresses': self.ip4, + 'ipv4.gateway': self.gw4, + 'ipv6.method': self.ipv6_method, + 'ipv6.addresses': self.ip6, + 'ipv6.gateway': self.gw6, + 'autoconnect': self.bool_to_string(self.autoconnect), + 'bridge.ageing-time': self.ageingtime, + 'bridge.forward-delay': self.forwarddelay, + 'bridge.hello-time': self.hellotime, + 'bridge.mac-address': self.mac, + 'bridge.max-age': self.maxage, + 'bridge.priority': self.priority, + 'bridge.stp': self.bool_to_string(self.stp) + }) + elif self.type == 'bridge-slave': + changed, diff = self._compare_conn_params(conn_info, { + 'master': self.master, + 'bridge-port.path-cost': self.path_cost, + 'bridge-port.hairpin-mode': self.bool_to_string(self.hairpin), + 'bridge-port.priority': self.slavepriority, + }) + elif self.type == 'vlan': + changed, diff = self._compare_conn_params(conn_info, { + 'vlan.parent': self.vlandev, + 'vlan.id': self.vlanid, + 'ipv4.method': self.ipv4_method, + 'ipv4.addresses': self.ip4 or '', + 'ipv4.gateway': self.gw4 or '', + 'ipv4.dns': self.dns4 or '', + 'ipv6.method': self.ipv6_method, + 'ipv6.addresses': self.ip6 or '', + 'ipv6.gateway': self.gw6 or '', + 'ipv6.dns': self.dns6 or '', + 'autoconnect': self.bool_to_string(self.autoconnect), + }) + elif self.type == 'vxlan': + changed, diff = self._compare_conn_params(conn_info, { + 'vxlan.id': self.vxlan_id, + 'vxlan.local': self.vxlan_local, + 'vxlan.remote': self.vxlan_remote, + 'autoconnect': self.bool_to_string(self.autoconnect), + }) + elif self.type == 'ipip': + changed, diff = self._compare_conn_params(conn_info, { + 'ip-tunnel.local': self.ip_tunnel_local, + 'ip-tunnel.remote': self.ip_tunnel_remote, + 'autoconnect': self.bool_to_string(self.autoconnect), + }) + elif self.type == 'sit': + changed, diff = self._compare_conn_params(conn_info, { + 'ip-tunnel.local': self.ip_tunnel_local, + 'ip-tunnel.remote': self.ip_tunnel_remote, + 'autoconnect': self.bool_to_string(self.autoconnect), + }) + elif self.type == 'generic': + changed, diff = self._compare_conn_params(conn_info, { + 'ipv4.method': self.ipv4_method, + 'ipv4.addresses': self.ip4, + 'ipv4.gateway': self.gw4, + 'ipv4.dns': self.dns4, + 'ipv6.method': self.ipv6_method, + 'ipv6.addresses': self.ip6, + 'ipv6.gateway': self.gw6, + 'ipv6.dns': self.dns6, + 'autoconnect': self.bool_to_string(self.autoconnect), + 'ipv4.dns-search': self.dns4_search, + 'ipv6.dns-search': self.dns6_search, + 'ipv4.dhcp-client-id': self.dhcp_client_id, + }) + else: + raise NmcliModuleError("Unknown type of device: %s" % self.type) + + return changed, diff + def main(): # Parsing argument file @@ -1502,30 +1782,42 @@ def main(): if nmcli.ifname is None: nmcli.module.fail_json(msg="Please specify an interface name for the connection when type is %s" % nmcli.type) - if nmcli.state == 'absent': - if nmcli.connection_exists(): - if module.check_mode: - module.exit_json(changed=True) - (rc, out, err) = nmcli.down_connection() - (rc, out, err) = nmcli.remove_connection() - if rc != 0: - module.fail_json(name=('No Connection named %s exists' % nmcli.conn_name), msg=err, rc=rc) + try: + if nmcli.state == 'absent': + if nmcli.connection_exists(): + if module.check_mode: + module.exit_json(changed=True) + (rc, out, err) = nmcli.down_connection() + (rc, out, err) = nmcli.remove_connection() + if rc != 0: + module.fail_json(name=('No Connection named %s exists' % nmcli.conn_name), msg=err, rc=rc) - elif nmcli.state == 'present': - if nmcli.connection_exists(): - # modify connection (note: this function is check mode aware) - # result['Connection']=('Connection %s of Type %s is not being added' % (nmcli.conn_name, nmcli.type)) - result['Exists'] = 'Connections do exist so we are modifying them' - if module.check_mode: - module.exit_json(changed=True) - (rc, out, err) = nmcli.modify_connection() - if not nmcli.connection_exists(): - result['Connection'] = ('Connection %s of Type %s is being added' % (nmcli.conn_name, nmcli.type)) - if module.check_mode: - module.exit_json(changed=True) - (rc, out, err) = nmcli.create_connection() - if rc is not None and rc != 0: - module.fail_json(name=nmcli.conn_name, msg=err, rc=rc) + elif nmcli.state == 'present': + if nmcli.connection_exists(): + changed, diff = nmcli.is_connection_changed() + if module._diff: + result['diff'] = diff + + if changed: + # modify connection (note: this function is check mode aware) + # result['Connection']=('Connection %s of Type %s is not being added' % (nmcli.conn_name, nmcli.type)) + result['Exists'] = 'Connections do exist so we are modifying them' + if module.check_mode: + module.exit_json(changed=True, **result) + (rc, out, err) = nmcli.modify_connection() + else: + result['Exists'] = 'Connections already exist and no changes made' + if module.check_mode: + module.exit_json(changed=False, **result) + if not nmcli.connection_exists(): + result['Connection'] = ('Connection %s of Type %s is being added' % (nmcli.conn_name, nmcli.type)) + if module.check_mode: + module.exit_json(changed=True, **result) + (rc, out, err) = nmcli.create_connection() + if rc is not None and rc != 0: + module.fail_json(name=nmcli.conn_name, msg=err, rc=rc) + except NmcliModuleError as e: + module.fail_json(name=nmcli.conn_name, msg=str(e)) if rc is None: result['changed'] = False diff --git a/tests/unit/plugins/modules/net_tools/test_nmcli.py b/tests/unit/plugins/modules/net_tools/test_nmcli.py index c25610dfca..e5f8824191 100644 --- a/tests/unit/plugins/modules/net_tools/test_nmcli.py +++ b/tests/unit/plugins/modules/net_tools/test_nmcli.py @@ -81,19 +81,29 @@ TESTCASE_GENERIC = [ 'type': 'generic', 'conn_name': 'non_existent_nw_device', 'ifname': 'generic_non_existant', - 'ip4': '10.10.10.10', + 'ip4': '10.10.10.10/24', 'gw4': '10.10.10.1', 'state': 'present', '_ansible_check_mode': False, }, ] +TESTCASE_GENERIC_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: generic_non_existant +connection.autoconnect: yes +ipv4.method: manual +ipv4.addresses: 10.10.10.10/24 +ipv4.gateway: 10.10.10.1 +ipv6.method: auto +""" + TESTCASE_GENERIC_DNS4_SEARCH = [ { 'type': 'generic', 'conn_name': 'non_existent_nw_device', 'ifname': 'generic_non_existant', - 'ip4': '10.10.10.10', + 'ip4': '10.10.10.10/24', 'gw4': '10.10.10.1', 'state': 'present', 'dns4_search': 'search.redhat.com', @@ -102,13 +112,25 @@ TESTCASE_GENERIC_DNS4_SEARCH = [ } ] +TESTCASE_GENERIC_DNS4_SEARCH_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: generic_non_existant +connection.autoconnect: yes +ipv4.method: manual +ipv4.addresses: 10.10.10.10/24 +ipv4.gateway: 10.10.10.1 +ipv4.dns-search: search.redhat.com +ipv6.dns-search: search6.redhat.com +ipv6.method: auto +""" + TESTCASE_BOND = [ { 'type': 'bond', 'conn_name': 'non_existent_nw_device', 'ifname': 'bond_non_existant', 'mode': 'active-backup', - 'ip4': '10.10.10.10', + 'ip4': '10.10.10.10/24', 'gw4': '10.10.10.1', 'state': 'present', 'primary': 'non_existent_primary', @@ -116,12 +138,23 @@ TESTCASE_BOND = [ } ] +TESTCASE_BOND_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: bond_non_existant +connection.autoconnect: yes +ipv4.method: manual +ipv4.addresses: 10.10.10.10/24 +ipv4.gateway: 10.10.10.1 +ipv6.method: auto +bond.options: mode=active-backup,primary=non_existent_primary +""" + TESTCASE_BRIDGE = [ { 'type': 'bridge', 'conn_name': 'non_existent_nw_device', 'ifname': 'br0_non_existant', - 'ip4': '10.10.10.10', + 'ip4': '10.10.10.10/24', 'gw4': '10.10.10.1', 'maxage': 100, 'stp': True, @@ -130,6 +163,22 @@ TESTCASE_BRIDGE = [ } ] +TESTCASE_BRIDGE_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: br0_non_existant +connection.autoconnect: yes +ipv4.method: manual +ipv4.addresses: 10.10.10.10/24 +ipv4.gateway: 10.10.10.1 +ipv6.method: auto +bridge.stp: yes +bridge.max-age: 100 +bridge.ageing-time: 300 +bridge.hello-time: 2 +bridge.priority: 128 +bridge.forward-delay: 15 +""" + TESTCASE_BRIDGE_SLAVE = [ { 'type': 'bridge-slave', @@ -141,12 +190,21 @@ TESTCASE_BRIDGE_SLAVE = [ } ] +TESTCASE_BRIDGE_SLAVE_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: br0_non_existant +connection.autoconnect: yes +bridge-port.path-cost: 100 +bridge-port.hairpin-mode: yes +bridge-port.priority: 32 +""" + TESTCASE_VLAN = [ { 'type': 'vlan', 'conn_name': 'non_existent_nw_device', 'ifname': 'vlan_not_exists', - 'ip4': '10.10.10.10', + 'ip4': '10.10.10.10/24', 'gw4': '10.10.10.1', 'vlanid': 10, 'state': 'present', @@ -154,6 +212,17 @@ TESTCASE_VLAN = [ } ] +TESTCASE_VLAN_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: vlan_not_exists +connection.autoconnect: yes +ipv4.method: manual +ipv4.addresses: 10.10.10.10/24 +ipv4.gateway: 10.10.10.1 +ipv6.method: auto +vlan.id: 10 +""" + TESTCASE_VXLAN = [ { 'type': 'vxlan', @@ -167,6 +236,15 @@ TESTCASE_VXLAN = [ } ] +TESTCASE_VXLAN_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: existent_nw_device +connection.autoconnect: yes +vxlan.id: 11 +vxlan.local: 192.168.225.5 +vxlan.remote: 192.168.225.6 +""" + TESTCASE_IPIP = [ { 'type': 'ipip', @@ -180,6 +258,16 @@ TESTCASE_IPIP = [ } ] +TESTCASE_IPIP_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: ipip-existent_nw_device +connection.autoconnect: yes +ip-tunnel.mode: ipip +ip-tunnel.parent: non_existent_ipip_device +ip-tunnel.local: 192.168.225.5 +ip-tunnel.remote: 192.168.225.6 +""" + TESTCASE_SIT = [ { 'type': 'sit', @@ -193,19 +281,60 @@ TESTCASE_SIT = [ } ] +TESTCASE_SIT_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: sit-existent_nw_device +connection.autoconnect: yes +ip-tunnel.mode: sit +ip-tunnel.parent: non_existent_sit_device +ip-tunnel.local: 192.168.225.5 +ip-tunnel.remote: 192.168.225.6 +""" + TESTCASE_ETHERNET_DHCP = [ { 'type': 'ethernet', 'conn_name': 'non_existent_nw_device', 'ifname': 'ethernet_non_existant', - 'ip4': '10.10.10.10', - 'gw4': '10.10.10.1', + 'dhcp_client_id': '00:11:22:AA:BB:CC:DD', 'state': 'present', '_ansible_check_mode': False, - 'dhcp_client_id': '00:11:22:AA:BB:CC:DD', } ] +TESTCASE_ETHERNET_DHCP_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: ethernet_non_existant +connection.autoconnect: yes +ipv4.method: auto +ipv4.dhcp-client-id: 00:11:22:AA:BB:CC:DD +ipv6.method: auto +""" + +TESTCASE_ETHERNET_STATIC = [ + { + 'type': 'ethernet', + 'conn_name': 'non_existent_nw_device', + 'ifname': 'ethernet_non_existant', + 'ip4': '10.10.10.10/24', + 'gw4': '10.10.10.1', + 'dns4': ['1.1.1.1', '8.8.8.8'], + 'state': 'present', + '_ansible_check_mode': False, + } +] + +TESTCASE_ETHERNET_STATIC_SHOW_OUTPUT = """\ +connection.id: non_existent_nw_device +connection.interface-name: ethernet_non_existant +connection.autoconnect: yes +ipv4.method: manual +ipv4.addresses: 10.10.10.10/24 +ipv4.gateway: 10.10.10.1 +ipv4.dns: 1.1.1.1,8.8.8.8 +ipv6.method: auto +""" + def mocker_set(mocker, connection_exists=False): """ @@ -224,15 +353,7 @@ def mocker_set(mocker, connection_exists=False): def mocked_generic_connection_create(mocker): mocker_set(mocker) command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') - command_result.return_value = {"rc": 100, "out": "aaa", "err": "none"} - return command_result - - -@pytest.fixture -def mocked_generic_connection_modify(mocker): - mocker_set(mocker, connection_exists=True) - command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') - command_result.return_value = {"rc": 100, "out": "aaa", "err": "none"} + command_result.return_value = (0, "", "") return command_result @@ -242,8 +363,127 @@ def mocked_connection_exists(mocker): return connection +@pytest.fixture +def mocked_generic_connection_modify(mocker): + mocker_set(mocker, connection_exists=True) + connection_changed = mocker.patch.object( + nmcli.Nmcli, 'is_connection_changed') + connection_changed.return_value = (True, dict()) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, "", "") + return command_result + + +@pytest.fixture +def mocked_generic_connection_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_GENERIC_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_generic_connection_dns_search_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = ( + 0, TESTCASE_GENERIC_DNS4_SEARCH_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_bond_connection_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_BOND_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_bridge_connection_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_BRIDGE_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_bridge_slave_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_BRIDGE_SLAVE_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_vlan_connection_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_VLAN_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_vxlan_connection_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_VXLAN_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_ipip_connection_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_IPIP_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_sit_connection_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_SIT_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_ethernet_connection_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_ETHERNET_DHCP, "") + return command_result + + +@pytest.fixture +def mocked_ethernet_connection_dhcp_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_ETHERNET_DHCP_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_ethernet_connection_static_unchanged(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.return_value = (0, TESTCASE_ETHERNET_STATIC_SHOW_OUTPUT, "") + return command_result + + +@pytest.fixture +def mocked_ethernet_connection_dhcp_to_static(mocker): + mocker_set(mocker, connection_exists=True) + command_result = mocker.patch.object(nmcli.Nmcli, 'execute_command') + command_result.side_effect = [ + (0, TESTCASE_ETHERNET_DHCP_SHOW_OUTPUT, ""), + (0, "", ""), + ] + return command_result + + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_BOND, indirect=['patch_ansible_module']) -def test_bond_connection_create(mocked_generic_connection_create): +def test_bond_connection_create(mocked_generic_connection_create, capfd): """ Test : Bond connection created """ @@ -267,9 +507,28 @@ def test_bond_connection_create(mocked_generic_connection_create): for param in ['gw4', 'primary', 'autoconnect', 'mode', 'active-backup', 'ip4']: assert param in args[0] + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_BOND, indirect=['patch_ansible_module']) +def test_bond_connection_unchanged(mocked_bond_connection_unchanged, capfd): + """ + Test : Bond connection 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_GENERIC, indirect=['patch_ansible_module']) -def test_generic_connection_create(mocked_generic_connection_create): +def test_generic_connection_create(mocked_generic_connection_create, capfd): """ Test : Generic connection created """ @@ -291,9 +550,14 @@ def test_generic_connection_create(mocked_generic_connection_create): for param in ['autoconnect', 'gw4', 'ip4']: assert param in args[0] + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_GENERIC, indirect=['patch_ansible_module']) -def test_generic_connection_modify(mocked_generic_connection_modify): +def test_generic_connection_modify(mocked_generic_connection_modify, capfd): """ Test : Generic connection modify """ @@ -312,9 +576,28 @@ def test_generic_connection_modify(mocked_generic_connection_modify): for param in ['ipv4.gateway', 'ipv4.address']: assert param in args[0] + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_GENERIC, indirect=['patch_ansible_module']) +def test_generic_connection_unchanged(mocked_generic_connection_unchanged, capfd): + """ + Test : Generic connection 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_GENERIC_DNS4_SEARCH, indirect=['patch_ansible_module']) -def test_generic_connection_create_dns_search(mocked_generic_connection_create): +def test_generic_connection_create_dns_search(mocked_generic_connection_create, capfd): """ Test : Generic connection created with dns search """ @@ -328,9 +611,14 @@ def test_generic_connection_create_dns_search(mocked_generic_connection_create): assert 'ipv4.dns-search' in args[0] assert 'ipv6.dns-search' in args[0] + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_GENERIC_DNS4_SEARCH, indirect=['patch_ansible_module']) -def test_generic_connection_modify_dns_search(mocked_generic_connection_create): +def test_generic_connection_modify_dns_search(mocked_generic_connection_create, capfd): """ Test : Generic connection modified with dns search """ @@ -344,6 +632,25 @@ def test_generic_connection_modify_dns_search(mocked_generic_connection_create): assert 'ipv4.dns-search' in args[0] assert 'ipv6.dns-search' in args[0] + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_GENERIC_DNS4_SEARCH, indirect=['patch_ansible_module']) +def test_generic_connection_dns_search_unchanged(mocked_generic_connection_dns_search_unchanged, capfd): + """ + Test : Generic connection with dns search 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_CONNECTION, indirect=['patch_ansible_module']) def test_dns4_none(mocked_connection_exists, capfd): @@ -355,11 +662,12 @@ def test_dns4_none(mocked_connection_exists, capfd): out, err = capfd.readouterr() results = json.loads(out) + assert not results.get('failed') assert results['changed'] @pytest.mark.parametrize('patch_ansible_module', TESTCASE_BRIDGE, indirect=['patch_ansible_module']) -def test_create_bridge(mocked_generic_connection_create): +def test_create_bridge(mocked_generic_connection_create, capfd): """ Test if Bridge created """ @@ -378,12 +686,17 @@ def test_create_bridge(mocked_generic_connection_create): assert args[0][5] == 'con-name' assert args[0][6] == 'non_existent_nw_device' - for param in ['ip4', '10.10.10.10', 'gw4', '10.10.10.1', 'bridge.max-age', '100', 'bridge.stp', 'yes']: + for param in ['ip4', '10.10.10.10/24', 'gw4', '10.10.10.1', 'bridge.max-age', '100', 'bridge.stp', 'yes']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_BRIDGE, indirect=['patch_ansible_module']) -def test_mod_bridge(mocked_generic_connection_modify): +def test_mod_bridge(mocked_generic_connection_modify, capfd): """ Test if Bridge modified """ @@ -399,12 +712,31 @@ def test_mod_bridge(mocked_generic_connection_modify): assert args[0][1] == 'con' assert args[0][2] == 'mod' assert args[0][3] == 'non_existent_nw_device' - for param in ['ipv4.address', '10.10.10.10', 'ipv4.gateway', '10.10.10.1', 'bridge.max-age', '100', 'bridge.stp', 'yes']: + for param in ['ipv4.address', '10.10.10.10/24', 'ipv4.gateway', '10.10.10.1', 'bridge.max-age', '100', 'bridge.stp', 'yes']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_BRIDGE, indirect=['patch_ansible_module']) +def test_bridge_connection_unchanged(mocked_bridge_connection_unchanged, capfd): + """ + Test : Bridge connection 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_BRIDGE_SLAVE, indirect=['patch_ansible_module']) -def test_create_bridge_slave(mocked_generic_connection_create): +def test_create_bridge_slave(mocked_generic_connection_create, capfd): """ Test if Bridge_slave created """ @@ -427,9 +759,14 @@ def test_create_bridge_slave(mocked_generic_connection_create): for param in ['bridge-port.path-cost', '100']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_BRIDGE_SLAVE, indirect=['patch_ansible_module']) -def test_mod_bridge_slave(mocked_generic_connection_modify): +def test_mod_bridge_slave(mocked_generic_connection_modify, capfd): """ Test if Bridge_slave modified """ @@ -449,9 +786,28 @@ def test_mod_bridge_slave(mocked_generic_connection_modify): for param in ['bridge-port.path-cost', '100']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_BRIDGE_SLAVE, indirect=['patch_ansible_module']) +def test_bridge_slave_unchanged(mocked_bridge_slave_unchanged, capfd): + """ + Test : Bridge-slave connection 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_VLAN, indirect=['patch_ansible_module']) -def test_create_vlan_con(mocked_generic_connection_create): +def test_create_vlan_con(mocked_generic_connection_create, capfd): """ Test if VLAN created """ @@ -471,12 +827,17 @@ def test_create_vlan_con(mocked_generic_connection_create): assert args[0][5] == 'con-name' assert args[0][6] == 'non_existent_nw_device' - for param in ['ip4', '10.10.10.10', 'gw4', '10.10.10.1', 'id', '10']: + for param in ['ip4', '10.10.10.10/24', 'gw4', '10.10.10.1', 'id', '10']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_VLAN, indirect=['patch_ansible_module']) -def test_mod_vlan_conn(mocked_generic_connection_modify): +def test_mod_vlan_conn(mocked_generic_connection_modify, capfd): """ Test if VLAN modified """ @@ -493,12 +854,31 @@ def test_mod_vlan_conn(mocked_generic_connection_modify): assert args[0][2] == 'mod' assert args[0][3] == 'non_existent_nw_device' - for param in ['ipv4.address', '10.10.10.10', 'ipv4.gateway', '10.10.10.1', 'vlan.id', '10']: + for param in ['ipv4.address', '10.10.10.10/24', 'ipv4.gateway', '10.10.10.1', 'vlan.id', '10']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_VLAN, indirect=['patch_ansible_module']) +def test_vlan_connection_unchanged(mocked_vlan_connection_unchanged, capfd): + """ + Test : VLAN connection 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_VXLAN, indirect=['patch_ansible_module']) -def test_create_vxlan(mocked_generic_connection_create): +def test_create_vxlan(mocked_generic_connection_create, capfd): """ Test if vxlan created """ @@ -521,9 +901,14 @@ def test_create_vxlan(mocked_generic_connection_create): for param in ['vxlan.local', '192.168.225.5', 'vxlan.remote', '192.168.225.6', 'vxlan.id', '11']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_VXLAN, indirect=['patch_ansible_module']) -def test_vxlan_mod(mocked_generic_connection_modify): +def test_vxlan_mod(mocked_generic_connection_modify, capfd): """ Test if vxlan modified """ @@ -542,9 +927,28 @@ def test_vxlan_mod(mocked_generic_connection_modify): for param in ['vxlan.local', '192.168.225.5', 'vxlan.remote', '192.168.225.6', 'vxlan.id', '11']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_VXLAN, indirect=['patch_ansible_module']) +def test_vxlan_connection_unchanged(mocked_vxlan_connection_unchanged, capfd): + """ + Test : VxLAN connection 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_IPIP, indirect=['patch_ansible_module']) -def test_create_ipip(mocked_generic_connection_create): +def test_create_ipip(mocked_generic_connection_create, capfd): """ Test if ipip created """ @@ -572,9 +976,14 @@ def test_create_ipip(mocked_generic_connection_create): for param in ['ip-tunnel.local', '192.168.225.5', 'ip-tunnel.remote', '192.168.225.6']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_IPIP, indirect=['patch_ansible_module']) -def test_ipip_mod(mocked_generic_connection_modify): +def test_ipip_mod(mocked_generic_connection_modify, capfd): """ Test if ipip modified """ @@ -593,9 +1002,28 @@ def test_ipip_mod(mocked_generic_connection_modify): for param in ['ip-tunnel.local', '192.168.225.5', 'ip-tunnel.remote', '192.168.225.6']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_IPIP, indirect=['patch_ansible_module']) +def test_ipip_connection_unchanged(mocked_ipip_connection_unchanged, capfd): + """ + Test : IPIP connection 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_SIT, indirect=['patch_ansible_module']) -def test_create_sit(mocked_generic_connection_create): +def test_create_sit(mocked_generic_connection_create, capfd): """ Test if sit created """ @@ -623,9 +1051,14 @@ def test_create_sit(mocked_generic_connection_create): for param in ['ip-tunnel.local', '192.168.225.5', 'ip-tunnel.remote', '192.168.225.6']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_SIT, indirect=['patch_ansible_module']) -def test_sit_mod(mocked_generic_connection_modify): +def test_sit_mod(mocked_generic_connection_modify, capfd): """ Test if sit modified """ @@ -644,9 +1077,28 @@ def test_sit_mod(mocked_generic_connection_modify): for param in ['ip-tunnel.local', '192.168.225.5', 'ip-tunnel.remote', '192.168.225.6']: assert param in map(to_text, args[0]) + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_SIT, indirect=['patch_ansible_module']) +def test_sit_connection_unchanged(mocked_sit_connection_unchanged, capfd): + """ + Test : SIT connection 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_DHCP, indirect=['patch_ansible_module']) -def test_eth_dhcp_client_id_con_create(mocked_generic_connection_create): +def test_eth_dhcp_client_id_con_create(mocked_generic_connection_create, capfd): """ Test : Ethernet connection created with DHCP_CLIENT_ID """ @@ -658,3 +1110,103 @@ def test_eth_dhcp_client_id_con_create(mocked_generic_connection_create): args, kwargs = arg_list[0] assert 'ipv4.dhcp-client-id' in args[0] + + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_ETHERNET_DHCP, indirect=['patch_ansible_module']) +def test_ethernet_connection_dhcp_unchanged(mocked_ethernet_connection_dhcp_unchanged, capfd): + """ + Test : Ethernet connection with DHCP_CLIENT_ID 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, indirect=['patch_ansible_module']) +def test_modify_ethernet_dhcp_to_static(mocked_ethernet_connection_dhcp_to_static, capfd): + """ + Test : Modify ethernet connection from DHCP to static + """ + with pytest.raises(SystemExit): + nmcli.main() + + assert nmcli.Nmcli.execute_command.call_count == 2 + arg_list = nmcli.Nmcli.execute_command.call_args_list + args, kwargs = arg_list[1] + + assert args[0][0] == '/usr/bin/nmcli' + assert args[0][1] == 'con' + assert args[0][2] == 'mod' + assert args[0][3] == 'non_existent_nw_device' + + for param in ['ipv4.method', 'ipv4.gateway', 'ipv4.address']: + assert param in args[0] + + 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, indirect=['patch_ansible_module']) +def test_create_ethernet_static(mocked_generic_connection_create, capfd): + """ + Test : Create ethernet connection with static IP configuration + """ + + with pytest.raises(SystemExit): + nmcli.main() + + assert nmcli.Nmcli.execute_command.call_count == 3 + arg_list = nmcli.Nmcli.execute_command.call_args_list + add_args, add_kw = arg_list[0] + mod_args, mod_kw = arg_list[1] + + 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' + assert add_args[0][7] == 'ifname' + assert add_args[0][8] == 'ethernet_non_existant' + + for param in ['ip4', '10.10.10.10/24', 'gw4', '10.10.10.1']: + assert param in map(to_text, add_args[0]) + + assert mod_args[0][0] == '/usr/bin/nmcli' + assert mod_args[0][1] == 'con' + assert mod_args[0][2] == 'mod' + assert mod_args[0][3] == 'non_existent_nw_device' + + for param in ['ipv4.dns', '1.1.1.1 8.8.8.8']: + assert param in map(to_text, mod_args[0]) + + 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, indirect=['patch_ansible_module']) +def test_ethernet_connection_static_unchanged(mocked_ethernet_connection_static_unchanged, capfd): + """ + Test : Ethernet connection with static IP 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']