diff --git a/plugins/doc_fragments/api.py b/plugins/doc_fragments/api.py new file mode 100644 index 0000000..f436d64 --- /dev/null +++ b/plugins/doc_fragments/api.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Nikolay Dachev +# GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + DOCUMENTATION = r''' +options: + hostname: + description: + - RouterOS hostname API. + required: true + type: str + username: + description: + - RouterOS login user. + required: true + type: str + password: + description: + - RouterOS user password. + required: true + type: str + tls: + description: + - If is set TLS will be used for RouterOS API connection. + required: false + type: bool + default: false + aliases: + - ssl + port: + description: + - RouterOS api port. If I(tls) is set, port will apply to TLS/SSL connection. + - Defaults are C(8728) for the HTTP API, and C(8729) for the HTTPS API. + type: int + validate_certs: + description: + - Set to C(false) to skip validation of TLS certificates. + - See also I(validate_cert_hostname). Only used when I(tls=true). + - B(Note:) instead of simply deactivating certificate validations to "make things work", + please consider creating your own CA certificate and using it to sign certificates used + for your router. You can tell the module about your CA certificate with the I(ca_path) + option. + type: bool + default: true + version_added: 1.2.0 + validate_cert_hostname: + description: + - Set to C(true) to validate hostnames in certificates. + - See also I(validate_certs). Only used when I(tls=true) and I(validate_certs=true). + type: bool + default: false + version_added: 1.2.0 + ca_path: + description: + - PEM formatted file that contains a CA certificate to be used for certificate validation. + - See also I(validate_cert_hostname). Only used when I(tls=true) and I(validate_certs=true). + type: path + version_added: 1.2.0 +requirements: + - librouteros + - Python >= 3.6 (for librouteros) +seealso: + - ref: ansible_collections.community.routeros.docsite.api-guide + description: How to connect to RouterOS devices with the RouterOS API +''' diff --git a/plugins/module_utils/api.py b/plugins/module_utils/api.py new file mode 100644 index 0000000..f8b839f --- /dev/null +++ b/plugins/module_utils/api.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2022, Felix Fontein (@felixfontein) +# Copyright: (c) 2020, Nikolay Dachev +# 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 + + +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils.common.text.converters import to_native + +import re +import ssl +import traceback + +LIB_IMP_ERR = None +try: + from librouteros import connect + from librouteros.exceptions import LibRouterosError + HAS_LIB = True +except Exception as e: + HAS_LIB = False + LIB_IMP_ERR = traceback.format_exc() + + +def check_has_library(module): + if not HAS_LIB: + module.fail_json( + msg=missing_required_lib('librouteros'), + exception=LIB_IMP_ERR, + ) + + +def api_argument_spec(): + return dict( + username=dict(type='str', required=True), + password=dict(type='str', required=True, no_log=True), + hostname=dict(type='str', required=True), + port=dict(type='int'), + tls=dict(type='bool', default=False, aliases=['ssl']), + validate_certs=dict(type='bool', default=True), + validate_cert_hostname=dict(type='bool', default=False), + ca_path=dict(type='path'), + ) + + +def _ros_api_connect(module, username, password, host, port, use_tls, validate_certs, validate_cert_hostname, ca_path): + '''Connect to RouterOS API.''' + if not port: + if use_tls: + port = 8729 + else: + port = 8728 + try: + if use_tls: + ctx = ssl.create_default_context(cafile=ca_path) + wrap_context = ctx.wrap_socket + if not validate_certs: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + elif not validate_cert_hostname: + ctx.check_hostname = False + else: + # Since librouteros doesn't pass server_hostname, + # we have to do this ourselves: + def wrap_context(*args, **kwargs): + kwargs.pop('server_hostname', None) + return ctx.wrap_socket(*args, server_hostname=host, **kwargs) + api = connect( + username=username, + password=password, + host=host, + ssl_wrapper=wrap_context, + port=port, + ) + else: + api = connect( + username=username, + password=password, + host=host, + port=port, + ) + except Exception as e: + connection = { + 'username': username, + 'hostname': host, + 'port': port, + 'ssl': use_tls, + 'status': 'Error while connecting: %s' % to_native(e), + } + module.fail_json(msg=connection['status'], connection=connection) + return api + + +def create_api(module): + return _ros_api_connect( + module, + module.params['username'], + module.params['password'], + module.params['hostname'], + module.params['port'], + module.params['tls'], + module.params['validate_certs'], + module.params['validate_cert_hostname'], + module.params['ca_path'], + ) diff --git a/plugins/modules/api.py b/plugins/modules/api.py index e4e716c..dc58ca5 100644 --- a/plugins/modules/api.py +++ b/plugins/modules/api.py @@ -13,43 +13,14 @@ module: api author: "Nikolay Dachev (@NikolayDachev)" short_description: Ansible module for RouterOS API description: - - Ansible module for RouterOS API with python librouteros. - - This module can add, remove, update, query and execute arbitrary command in routeros via API. + - Ansible module for RouterOS API with the Python C(librouteros) library. + - This module can add, remove, update, query and execute arbitrary command in RouterOS via API. notes: - I(add), I(remove), I(update), I(cmd) and I(query) are mutually exclusive. - I(check_mode) is not supported. -requirements: - - librouteros - - Python >= 3.6 (for librouteros) +extends_documentation_fragment: + - community.routeros.api options: - hostname: - description: - - RouterOS hostname API. - required: true - type: str - username: - description: - - RouterOS login user. - required: true - type: str - password: - description: - - RouterOS user password. - required: true - type: str - tls: - description: - - If is set TLS will be used for RouterOS API connection. - required: false - type: bool - default: false - aliases: - - ssl - port: - description: - - RouterOS api port. If I(tls) is set, port will apply to TLS/SSL connection. - - Defaults are C(8728) for the HTTP API, and C(8729) for the HTTPS API. - type: int path: description: - Main path for all other arguments. @@ -95,33 +66,7 @@ options: - Example path C(system script) and cmd C(run .id=*03) is equivalent in RouterOS CLI C(/system script run number=0). - Example path C(ip address) and cmd C(print) is equivalent in RouterOS CLI C(/ip address print). type: str - validate_certs: - description: - - Set to C(false) to skip validation of TLS certificates. - - See also I(validate_cert_hostname). Only used when I(tls=true). - - B(Note:) instead of simply deactivating certificate validations to "make things work", - please consider creating your own CA certificate and using it to sign certificates used - for your router. You can tell the module about your CA certificate with the I(ca_path) - option. - type: bool - default: true - version_added: 1.2.0 - validate_cert_hostname: - description: - - Set to C(true) to validate hostnames in certificates. - - See also I(validate_certs). Only used when I(tls=true) and I(validate_certs=true). - type: bool - default: false - version_added: 1.2.0 - ca_path: - description: - - PEM formatted file that contains a CA certificate to be used for certificate validation. - - See also I(validate_cert_hostname). Only used when I(tls=true) and I(validate_certs=true). - type: path - version_added: 1.2.0 seealso: - - ref: ansible_collections.community.routeros.docsite.api-guide - description: How to connect to RouterOS devices with the RouterOS API - ref: ansible_collections.community.routeros.docsite.quoting description: How to quote and unquote commands and arguments ''' @@ -275,58 +220,44 @@ from ansible_collections.community.routeros.plugins.module_utils.quoting import split_routeros_command, ) +from ansible_collections.community.routeros.plugins.module_utils.api import ( + api_argument_spec, + check_has_library, + create_api, +) + import re import ssl import traceback -LIB_IMP_ERR = None try: - from librouteros import connect from librouteros.exceptions import LibRouterosError from librouteros.query import Key - HAS_LIB = True -except Exception as e: - HAS_LIB = False - LIB_IMP_ERR = traceback.format_exc() +except Exception: + # Handled in api module_utils + pass class ROS_api_module: def __init__(self): module_args = dict( - username=dict(type='str', required=True), - password=dict(type='str', required=True, no_log=True), - hostname=dict(type='str', required=True), - port=dict(type='int'), - tls=dict(type='bool', default=False, aliases=['ssl']), path=dict(type='str', required=True), add=dict(type='str'), remove=dict(type='str'), update=dict(type='str'), cmd=dict(type='str'), query=dict(type='str'), - validate_certs=dict(type='bool', default=True), - validate_cert_hostname=dict(type='bool', default=False), - ca_path=dict(type='path'), ) + module_args.update(api_argument_spec()) self.module = AnsibleModule(argument_spec=module_args, supports_check_mode=False, mutually_exclusive=(('add', 'remove', 'update', 'cmd', 'query'),),) - if not HAS_LIB: - self.module.fail_json(msg=missing_required_lib("librouteros"), - exception=LIB_IMP_ERR) + check_has_library(self.module) - self.api = self.ros_api_connect(self.module.params['username'], - self.module.params['password'], - self.module.params['hostname'], - self.module.params['port'], - self.module.params['tls'], - self.module.params['validate_certs'], - self.module.params['validate_cert_hostname'], - self.module.params['ca_path'], - ) + self.api = create_api(self.module) self.path = self.module.params['path'].split() self.add = self.module.params['add'] @@ -497,49 +428,6 @@ class ROS_api_module: self.result['message'].append("%s" % e) self.return_result(False, False) - def ros_api_connect(self, username, password, host, port, use_tls, validate_certs, validate_cert_hostname, ca_path): - # connect to routeros api - conn_status = {"connection": {"username": username, - "hostname": host, - "port": port, - "ssl": use_tls, - "status": "Connected"}} - try: - if use_tls: - if not port: - port = 8729 - conn_status["connection"]["port"] = port - ctx = ssl.create_default_context(cafile=ca_path) - wrap_context = ctx.wrap_socket - if not validate_certs: - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - elif not validate_cert_hostname: - ctx.check_hostname = False - else: - # Since librouteros doesn't pass server_hostname, - # we have to do this ourselves: - def wrap_context(*args, **kwargs): - kwargs.pop('server_hostname', None) - return ctx.wrap_socket(*args, server_hostname=host, **kwargs) - api = connect(username=username, - password=password, - host=host, - ssl_wrapper=wrap_context, - port=port) - else: - if not port: - port = 8728 - conn_status["connection"]["port"] = port - api = connect(username=username, - password=password, - host=host, - port=port) - except Exception as e: - conn_status["connection"]["status"] = "error: %s" % e - self.module.fail_json(msg=to_native([conn_status])) - return api - def main(): diff --git a/plugins/modules/api_facts.py b/plugins/modules/api_facts.py new file mode 100644 index 0000000..b5d0e17 --- /dev/null +++ b/plugins/modules/api_facts.py @@ -0,0 +1,483 @@ +#!/usr/bin/python + +# Copyright: (c) 2022, Felix Fontein +# Copyright: (c) 2020, Nikolay Dachev +# Copyright: (c) 2018, Egor Zaitsev (@heuels) +# 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 + +DOCUMENTATION = ''' +--- +module: api_facts +author: + - "Egor Zaitsev (@heuels)" + - "Nikolay Dachev (@NikolayDachev)" + - "Felix Fontein (@felixfontein)" +version_added: 2.1.0 +short_description: Collect facts from remote devices running MikroTik RouterOS using the API +description: + - Collects a base set of device facts from a remote device that + is running RouterOS. This module prepends all of the + base network fact keys with C(ansible_net_). The facts + module will always collect a base set of facts from the device + and can enable or disable collection of additional facts. + - As opposed to the M(community.routeros.facts) module, it uses the + RouterOS API, similar to the M(community.routeros.api) module. +extends_documentation_fragment: + - community.routeros.api +options: + gather_subset: + description: + - When supplied, this argument will restrict the facts collected + to a given subset. Possible values for this argument include + C(all), C(hardware), C(interfaces), and C(routing). + - Can specify a list of values to include a larger subset. + Values can also be used with an initial C(!) to specify that a + specific subset should not be collected. + required: false + default: 'all' + type: list + elements: str +seealso: + - module: community.routeros.facts + - module: community.routeros.api +''' + +EXAMPLES = """ +- name: Collect all facts from the device + community.routeros.api_facts: + hostname: 192.168.88.1 + username: admin + password: password + gather_subset: all + +- name: Do not collect hardware facts + community.routeros.api_facts: + hostname: 192.168.88.1 + username: admin + password: password + gather_subset: + - "!hardware" +""" + +RETURN = """ +ansible_facts: + description: "Dictionary of IP geolocation facts for a host's IP address." + returned: always + type: dict + contains: + ansible_net_gather_subset: + description: The list of fact subsets collected from the device. + returned: always + type: list + + # default + ansible_net_model: + description: The model name returned from the device. + returned: I(gather_subset) contains C(default) + type: str + ansible_net_serialnum: + description: The serial number of the remote device. + returned: I(gather_subset) contains C(default) + type: str + ansible_net_version: + description: The operating system version running on the remote device. + returned: I(gather_subset) contains C(default) + type: str + ansible_net_hostname: + description: The configured hostname of the device. + returned: I(gather_subset) contains C(default) + type: str + ansible_net_arch: + description: The CPU architecture of the device. + returned: I(gather_subset) contains C(default) + type: str + ansible_net_uptime: + description: The uptime of the device. + returned: I(gather_subset) contains C(default) + type: str + ansible_net_cpu_load: + description: Current CPU load. + returned: I(gather_subset) contains C(default) + type: str + + # hardware + ansible_net_spacefree_mb: + description: The available disk space on the remote device in MiB. + returned: I(gather_subset) contains C(hardware) + type: dict + ansible_net_spacetotal_mb: + description: The total disk space on the remote device in MiB. + returned: I(gather_subset) contains C(hardware) + type: dict + ansible_net_memfree_mb: + description: The available free memory on the remote device in MiB. + returned: I(gather_subset) contains C(hardware) + type: int + ansible_net_memtotal_mb: + description: The total memory on the remote device in MiB. + returned: I(gather_subset) contains C(hardware) + type: int + + # interfaces + ansible_net_all_ipv4_addresses: + description: All IPv4 addresses configured on the device. + returned: I(gather_subset) contains C(interfaces) + type: list + ansible_net_all_ipv6_addresses: + description: All IPv6 addresses configured on the device. + returned: I(gather_subset) contains C(interfaces) + type: list + ansible_net_interfaces: + description: A hash of all interfaces running on the system. + returned: I(gather_subset) contains C(interfaces) + type: dict + ansible_net_neighbors: + description: The list of neighbors from the remote device. + returned: I(gather_subset) contains C(interfaces) + type: dict + + # routing + ansible_net_bgp_peer: + description: A dictionary with BGP peer information. + returned: I(gather_subset) contains C(routing) + type: dict + ansible_net_bgp_vpnv4_route: + description: A dictionary with BGP vpnv4 route information. + returned: I(gather_subset) contains C(routing) + type: dict + ansible_net_bgp_instance: + description: A dictionary with BGP instance information. + returned: I(gather_subset) contains C(routing) + type: dict + ansible_net_route: + description: A dictionary for routes in all routing tables. + returned: I(gather_subset) contains C(routing) + type: dict + ansible_net_ospf_instance: + description: A dictionary with OSPF instances. + returned: I(gather_subset) contains C(routing) + type: dict + ansible_net_ospf_neighbor: + description: A dictionary with OSPF neighbors. + returned: I(gather_subset) contains C(routing) + type: dict +""" +import re + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import iteritems +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.routeros.plugins.module_utils.api import ( + api_argument_spec, + check_has_library, + create_api, +) + +try: + from librouteros.exceptions import LibRouterosError +except Exception: + # Handled in api module_utils + pass + + +class FactsBase(object): + + COMMANDS = [] + + def __init__(self, module, api): + self.module = module + self.api = api + self.facts = {} + self.responses = None + + def populate(self): + self.responses = [] + for path in self.COMMANDS: + self.responses.append(self.query_path(path)) + + def query_path(self, path): + api_path = self.api.path() + for part in path: + api_path = api_path.join(part) + try: + return list(api_path) + except LibRouterosError as e: + self.module.warn('Error while querying path {path}: {error}'.format( + path=' '.join(path), + error=to_native(e), + )) + return [] + + +class Default(FactsBase): + + COMMANDS = [ + ['system', 'identity'], + ['system', 'resource'], + ['system', 'routerboard'], + ] + + def populate(self): + super(Default, self).populate() + data = self.responses[0] + if data: + self.facts['hostname'] = data[0].get('name') + data = self.responses[1] + if data: + self.facts['version'] = data[0].get('version') + self.facts['arch'] = data[0].get('architecture-name') + self.facts['uptime'] = data[0].get('uptime') + self.facts['cpu_load'] = data[0].get('cpu-load') + data = self.responses[2] + if data: + self.facts['model'] = data[0].get('model') + self.facts['serialnum'] = data[0].get('serial-number') + + +class Hardware(FactsBase): + + COMMANDS = [ + ['system', 'resource'], + ] + + def populate(self): + super(Hardware, self).populate() + data = self.responses[0] + if data: + self.parse_filesystem_info(data[0]) + self.parse_memory_info(data[0]) + + def parse_filesystem_info(self, data): + self.facts['spacefree_mb'] = self.to_megabytes(data.get('free-hdd-space')) + self.facts['spacetotal_mb'] = self.to_megabytes(data.get('total-hdd-space')) + + def parse_memory_info(self, data): + self.facts['memfree_mb'] = self.to_megabytes(data.get('free-memory')) + self.facts['memtotal_mb'] = self.to_megabytes(data.get('total-memory')) + + def to_megabytes(self, value): + if value is None: + return None + return float(value) / 1024 / 1024 + + +class Interfaces(FactsBase): + + COMMANDS = [ + ['interface'], + ['ip', 'address'], + ['ipv6', 'address'], + ['ip', 'neighbor'], + ] + + def populate(self): + super(Interfaces, self).populate() + + self.facts['interfaces'] = {} + self.facts['all_ipv4_addresses'] = [] + self.facts['all_ipv6_addresses'] = [] + self.facts['neighbors'] = [] + + data = self.responses[0] + if data: + interfaces = self.parse_interfaces(data) + self.populate_interfaces(interfaces) + + data = self.responses[1] + if data: + data = self.parse_detail(data) + self.populate_addresses(data, 'ipv4') + + data = self.responses[2] + if data: + data = self.parse_detail(data) + self.populate_addresses(data, 'ipv6') + + data = self.responses[3] + if data: + self.facts['neighbors'] = list(self.parse_detail(data)) + + def populate_interfaces(self, data): + for key, value in iteritems(data): + self.facts['interfaces'][key] = value + + def populate_addresses(self, data, family): + for value in data: + key = value['interface'] + if family not in self.facts['interfaces'][key]: + self.facts['interfaces'][key][family] = [] + addr, subnet = value['address'].split('/') + subnet = subnet.strip() + # Try to convert subnet to an integer + try: + subnet = int(subnet) + except Exception: + pass + ip = dict(address=addr.strip(), subnet=subnet) + self.add_ip_address(addr.strip(), family) + self.facts['interfaces'][key][family].append(ip) + + def add_ip_address(self, address, family): + if family == 'ipv4': + self.facts['all_ipv4_addresses'].append(address) + else: + self.facts['all_ipv6_addresses'].append(address) + + def parse_interfaces(self, data): + facts = {} + for entry in data: + if 'name' not in entry: + continue + entry.pop('.id', None) + facts[entry['name']] = entry + return facts + + def parse_detail(self, data): + for entry in data: + if 'interface' not in entry: + continue + entry.pop('.id', None) + yield entry + + +class Routing(FactsBase): + + COMMANDS = [ + ['routing', 'bgp', 'peer'], + ['routing', 'bgp', 'vpnv4-route'], + ['routing', 'bgp', 'instance'], + ['ip', 'route'], + ['routing', 'ospf', 'instance'], + ['routing', 'ospf', 'neighbor'], + ] + + def populate(self): + super(Routing, self).populate() + self.facts['bgp_peer'] = {} + self.facts['bgp_vpnv4_route'] = {} + self.facts['bgp_instance'] = {} + self.facts['route'] = {} + self.facts['ospf_instance'] = {} + self.facts['ospf_neighbor'] = {} + data = self.responses[0] + if data: + peer = self.parse(data, 'name') + self.populate_result('bgp_peer', peer) + data = self.responses[1] + if data: + vpnv4 = self.parse(data, 'interface') + self.populate_result('bgp_vpnv4_route', vpnv4) + data = self.responses[2] + if data: + instance = self.parse(data, 'name') + self.populate_result('bgp_instance', instance) + data = self.responses[3] + if data: + route = self.parse(data, 'routing-mark', fallback='main') + self.populate_result('route', route) + data = self.responses[4] + if data: + instance = self.parse(data, 'name') + self.populate_result('ospf_instance', instance) + data = self.responses[5] + if data: + instance = self.parse(data, 'instance') + self.populate_result('ospf_neighbor', instance) + + def parse(self, data, key, fallback=None): + facts = {} + for line in data: + name = line.get(key) or fallback + line.pop('.id', None) + facts[name] = line + return facts + + def populate_result(self, name, data): + for key, value in iteritems(data): + self.facts[name][key] = value + + +FACT_SUBSETS = dict( + default=Default, + hardware=Hardware, + interfaces=Interfaces, + routing=Routing, +) + +VALID_SUBSETS = frozenset(FACT_SUBSETS.keys()) + +warnings = [] + + +def main(): + argument_spec = dict( + gather_subset=dict( + default=['all'], + type='list', + elements='str', + ) + ) + argument_spec.update(api_argument_spec()) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + check_has_library(module) + api = create_api(module) + + gather_subset = module.params['gather_subset'] + + runable_subsets = set() + exclude_subsets = set() + + for subset in gather_subset: + if subset == 'all': + runable_subsets.update(VALID_SUBSETS) + continue + + if subset.startswith('!'): + subset = subset[1:] + if subset == 'all': + exclude_subsets.update(VALID_SUBSETS) + continue + exclude = True + else: + exclude = False + + if subset not in VALID_SUBSETS: + module.fail_json(msg='Bad subset: %s' % subset) + + if exclude: + exclude_subsets.add(subset) + else: + runable_subsets.add(subset) + + if not runable_subsets: + runable_subsets.update(VALID_SUBSETS) + + runable_subsets.difference_update(exclude_subsets) + runable_subsets.add('default') + + facts = {} + facts['gather_subset'] = sorted(runable_subsets) + + instances = [] + for key in runable_subsets: + instances.append(FACT_SUBSETS[key](module, api)) + + for inst in instances: + inst.populate() + facts.update(inst.facts) + + ansible_facts = {} + for key, value in iteritems(facts): + key = 'ansible_net_%s' % key + ansible_facts[key] = value + + module.exit_json(ansible_facts=ansible_facts, warnings=warnings) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/facts.py b/plugins/modules/facts.py index d5589c3..3b423ca 100644 --- a/plugins/modules/facts.py +++ b/plugins/modules/facts.py @@ -21,10 +21,10 @@ options: description: - When supplied, this argument will restrict the facts collected to a given subset. Possible values for this argument include - C(all), C(hardware), C(config), and C(interfaces). Can specify a list of - values to include a larger subset. Values can also be used - with an initial C(!) to specify that a specific subset should - not be collected. + C(all), C(hardware), C(config), C(interfaces), and C(routing). + - Can specify a list of values to include a larger subset. + Values can also be used with an initial C(!) to specify that a + specific subset should not be collected. required: false default: '!config' type: list @@ -52,67 +52,67 @@ EXAMPLES = """ RETURN = """ ansible_facts: - description: "Dictionary of ip geolocation facts for a host's IP address" + description: "Dictionary of IP geolocation facts for a host's IP address." returned: always type: dict contains: ansible_net_gather_subset: - description: The list of fact subsets collected from the device + description: The list of fact subsets collected from the device. returned: always type: list # default ansible_net_model: - description: The model name returned from the device - returned: always + description: The model name returned from the device. + returned: I(gather_subset) contains C(default) type: str ansible_net_serialnum: - description: The serial number of the remote device - returned: always + description: The serial number of the remote device. + returned: I(gather_subset) contains C(default) type: str ansible_net_version: - description: The operating system version running on the remote device - returned: always + description: The operating system version running on the remote device. + returned: I(gather_subset) contains C(default) type: str ansible_net_hostname: - description: The configured hostname of the device - returned: always + description: The configured hostname of the device. + returned: I(gather_subset) contains C(default) type: str ansible_net_arch: - description: The CPU architecture of the device - returned: always + description: The CPU architecture of the device. + returned: I(gather_subset) contains C(default) type: str ansible_net_uptime: - description: The uptime of the device - returned: always + description: The uptime of the device. + returned: I(gather_subset) contains C(default) type: str ansible_net_cpu_load: - description: Current CPU load - returned: always + description: Current CPU load. + returned: I(gather_subset) contains C(default) type: str # hardware ansible_net_spacefree_mb: - description: The available disk space on the remote device in MiB - returned: when hardware is configured + description: The available disk space on the remote device in MiB. + returned: I(gather_subset) contains C(hardware) type: dict ansible_net_spacetotal_mb: - description: The total disk space on the remote device in MiB - returned: when hardware is configured + description: The total disk space on the remote device in MiB. + returned: I(gather_subset) contains C(hardware) type: dict ansible_net_memfree_mb: - description: The available free memory on the remote device in MiB - returned: when hardware is configured + description: The available free memory on the remote device in MiB. + returned: I(gather_subset) contains C(hardware) type: int ansible_net_memtotal_mb: - description: The total memory on the remote device in MiB - returned: when hardware is configured + description: The total memory on the remote device in MiB. + returned: I(gather_subset) contains C(hardware) type: int # config ansible_net_config: - description: The current active config from the device - returned: when config is configured + description: The current active config from the device. + returned: I(gather_subset) contains C(config) type: str ansible_net_config_nonverbose: @@ -121,52 +121,52 @@ ansible_facts: - This value is idempotent in the sense that if the facts module is run twice and the device's config was not changed between the runs, the value is identical. This is achieved by running C(/export) and stripping the timestamp from the comment in the first line. - returned: when config is configured + returned: I(gather_subset) contains C(config) type: str version_added: 1.2.0 # interfaces ansible_net_all_ipv4_addresses: - description: All IPv4 addresses configured on the device - returned: when interfaces is configured + description: All IPv4 addresses configured on the device. + returned: I(gather_subset) contains C(interfaces) type: list ansible_net_all_ipv6_addresses: - description: All IPv6 addresses configured on the device - returned: when interfaces is configured + description: All IPv6 addresses configured on the device. + returned: I(gather_subset) contains C(interfaces) type: list ansible_net_interfaces: - description: A hash of all interfaces running on the system - returned: when interfaces is configured + description: A hash of all interfaces running on the system. + returned: I(gather_subset) contains C(interfaces) type: dict ansible_net_neighbors: - description: The list of neighbors from the remote device - returned: when interfaces is configured + description: The list of neighbors from the remote device. + returned: I(gather_subset) contains C(interfaces) type: dict # routing ansible_net_bgp_peer: - description: The dict bgp peer - returned: peer information + description: A dictionary with BGP peer information. + returned: I(gather_subset) contains C(routing) type: dict ansible_net_bgp_vpnv4_route: - description: The dict bgp vpnv4 route - returned: vpnv4 route information + description: A dictionary with BGP vpnv4 route information. + returned: I(gather_subset) contains C(routing) type: dict ansible_net_bgp_instance: - description: The dict bgp instance - returned: bgp instance information + description: A dictionary with BGP instance information. + returned: I(gather_subset) contains C(routing) type: dict ansible_net_route: - description: The dict routes in all routing table - returned: routes information in all routing table + description: A dictionary for routes in all routing tables. + returned: I(gather_subset) contains C(routing) type: dict ansible_net_ospf_instance: - description: The dict ospf instance - returned: ospf instance information + description: A dictionary with OSPF instances. + returned: I(gather_subset) contains C(routing) type: dict ansible_net_ospf_neighbor: - description: The dict ospf neighbor - returned: ospf neighbor information + description: A dictionary with OSPF neighbors. + returned: I(gather_subset) contains C(routing) type: dict """ import re diff --git a/tests/ee/roles/smoke/tasks/main.yml b/tests/ee/roles/smoke/tasks/main.yml index 484b9ce..33adbff 100644 --- a/tests/ee/roles/smoke/tasks/main.yml +++ b/tests/ee/roles/smoke/tasks/main.yml @@ -12,7 +12,7 @@ assert: that: - result is failed - - "'error: [Errno 111] Connection refused' in result.msg" + - "'Error while connecting: [Errno 111] Connection refused' == result.msg" - name: Run command module community.routeros.command: diff --git a/tests/unit/plugins/modules/fake_api.py b/tests/unit/plugins/modules/fake_api.py new file mode 100644 index 0000000..03cb6d2 --- /dev/null +++ b/tests/unit/plugins/modules/fake_api.py @@ -0,0 +1,115 @@ +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class FakeLibRouterosError(Exception): + def __init__(self, message): + self.message = message + super(FakeLibRouterosError, self).__init__(self.message) + + +class TrapError(FakeLibRouterosError): + def __init__(self, message="failure: already have interface with such name"): + super(TrapError, self).__init__(message) + + +# fixtures +class fake_ros_api(object): + def __init__(self, api, path): + pass + + def path(self, api, path): + fake_bridge = [{".id": "*DC", "name": "b2", "mtu": "auto", "actual-mtu": 1500, + "l2mtu": 65535, "arp": "enabled", "arp-timeout": "auto", + "mac-address": "3A:C1:90:D6:E8:44", "protocol-mode": "rstp", + "fast-forward": "true", "igmp-snooping": "false", + "auto-mac": "true", "ageing-time": "5m", "priority": + "0x8000", "max-message-age": "20s", "forward-delay": "15s", + "transmit-hold-count": 6, "vlan-filtering": "false", + "dhcp-snooping": "false", "running": "true", "disabled": "false"}] + return fake_bridge + + def arbitrary(self, api, path): + def retr(self, *args, **kwargs): + if 'name' not in kwargs.keys(): + raise TrapError(message="no such command") + dummy_test_string = '/interface/bridge add name=unit_test_brige_arbitrary' + result = "/%s/%s add name=%s" % (path[0], path[1], kwargs['name']) + return [result] + return retr + + def add(self, name): + if name == "unit_test_brige_exist": + raise TrapError + return '*A1' + + def remove(self, id): + if id != "*A1": + raise TrapError(message="no such item (4)") + return '*A1' + + def update(self, **kwargs): + if kwargs['.id'] != "*A1" or 'name' not in kwargs.keys(): + raise TrapError(message="no such item (4)") + return ["updated: {'.id': '%s' % kwargs['.id'], 'name': '%s' % kwargs['name']}"] + + def select(self, *args): + dummy_bridge = [{".id": "*A1", "name": "dummy_bridge_A1"}, + {".id": "*A2", "name": "dummy_bridge_A2"}, + {".id": "*A3", "name": "dummy_bridge_A3"}] + + result = [] + for dummy in dummy_bridge: + found = {} + for search in args: + if search in dummy.keys(): + found[search] = dummy[search] + else: + continue + if len(found.keys()) == 2: + result.append(found) + + if result: + return result + else: + return ["no results for 'interface bridge 'query' %s" % ' '.join(args)] + + def select_where(self, api, path): + api_path = Where() + return api_path + + +class Where(object): + def __init__(self): + pass + + def select(self, *args): + return self + + def where(self, *args): + return ["*A1"] + + +class Key(object): + def __init__(self, name): + self.name = name + self.str_return() + + def str_return(self): + return str(self.name) diff --git a/tests/unit/plugins/modules/test_api.py b/tests/unit/plugins/modules/test_api.py index cabb918..98dcd8c 100644 --- a/tests/unit/plugins/modules/test_api.py +++ b/tests/unit/plugins/modules/test_api.py @@ -21,107 +21,11 @@ import json import pytest from ansible_collections.community.routeros.tests.unit.compat.mock import patch, MagicMock +from ansible_collections.community.routeros.tests.unit.plugins.modules.fake_api import FakeLibRouterosError, Key, fake_ros_api from ansible_collections.community.routeros.tests.unit.plugins.modules.utils import set_module_args, AnsibleExitJson, AnsibleFailJson, ModuleTestCase from ansible_collections.community.routeros.plugins.modules import api -class FakeLibRouterosError(Exception): - def __init__(self, message): - self.message = message - super(FakeLibRouterosError, self).__init__(self.message) - - -class TrapError(FakeLibRouterosError): - def __init__(self, message="failure: already have interface with such name"): - super(TrapError, self).__init__(message) - - -# fixtures -class fake_ros_api(object): - def __init__(self, api, path): - pass - - def path(self, api, path): - fake_bridge = [{".id": "*DC", "name": "b2", "mtu": "auto", "actual-mtu": 1500, - "l2mtu": 65535, "arp": "enabled", "arp-timeout": "auto", - "mac-address": "3A:C1:90:D6:E8:44", "protocol-mode": "rstp", - "fast-forward": "true", "igmp-snooping": "false", - "auto-mac": "true", "ageing-time": "5m", "priority": - "0x8000", "max-message-age": "20s", "forward-delay": "15s", - "transmit-hold-count": 6, "vlan-filtering": "false", - "dhcp-snooping": "false", "running": "true", "disabled": "false"}] - return fake_bridge - - def arbitrary(self, api, path): - def retr(self, *args, **kwargs): - if 'name' not in kwargs.keys(): - raise TrapError(message="no such command") - dummy_test_string = '/interface/bridge add name=unit_test_brige_arbitrary' - result = "/%s/%s add name=%s" % (path[0], path[1], kwargs['name']) - return [result] - return retr - - def add(self, name): - if name == "unit_test_brige_exist": - raise TrapError - return '*A1' - - def remove(self, id): - if id != "*A1": - raise TrapError(message="no such item (4)") - return '*A1' - - def update(self, **kwargs): - if kwargs['.id'] != "*A1" or 'name' not in kwargs.keys(): - raise TrapError(message="no such item (4)") - return ["updated: {'.id': '%s' % kwargs['.id'], 'name': '%s' % kwargs['name']}"] - - def select(self, *args): - dummy_bridge = [{".id": "*A1", "name": "dummy_bridge_A1"}, - {".id": "*A2", "name": "dummy_bridge_A2"}, - {".id": "*A3", "name": "dummy_bridge_A3"}] - - result = [] - for dummy in dummy_bridge: - found = {} - for search in args: - if search in dummy.keys(): - found[search] = dummy[search] - else: - continue - if len(found.keys()) == 2: - result.append(found) - - if result: - return result - else: - return ["no results for 'interface bridge 'query' %s" % ' '.join(args)] - - def select_where(self, api, path): - api_path = Where() - return api_path - - -class Where(object): - def __init__(self): - pass - - def select(self, *args): - return self - - def where(self, *args): - return ["*A1"] - - -class Key(object): - def __init__(self, name): - self.name = name - self.str_return() - - def str_return(self): - return str(self.name) - - class TestRouterosApiModule(ModuleTestCase): def setUp(self): @@ -130,12 +34,17 @@ class TestRouterosApiModule(ModuleTestCase): self.module = api self.module.LibRouterosError = FakeLibRouterosError self.module.connect = MagicMock(new=fake_ros_api) + self.patch_create_api = patch('ansible_collections.community.routeros.plugins.modules.api.create_api', MagicMock(new=fake_ros_api)) + self.patch_create_api.start() self.module.Key = MagicMock(new=Key) self.config_module_args = {"username": "admin", "password": "pаss", "hostname": "127.0.0.1", "path": "interface bridge"} + def tearDown(self): + self.patch_create_api.stop() + def test_module_fail_when_required_args_missing(self): with self.assertRaises(AnsibleFailJson) as exc: set_module_args({}) diff --git a/tests/unit/plugins/modules/test_api_facts.py b/tests/unit/plugins/modules/test_api_facts.py new file mode 100644 index 0000000..8c3eeac --- /dev/null +++ b/tests/unit/plugins/modules/test_api_facts.py @@ -0,0 +1,766 @@ +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import json +import pytest + +from ansible_collections.community.routeros.tests.unit.compat.mock import patch, MagicMock +from ansible_collections.community.routeros.tests.unit.plugins.modules.fake_api import FakeLibRouterosError, Key, fake_ros_api +from ansible_collections.community.routeros.tests.unit.plugins.modules.utils import set_module_args, AnsibleExitJson, AnsibleFailJson, ModuleTestCase +from ansible_collections.community.routeros.plugins.modules import api_facts + + +API_RESPONSES = { + ('interface', ): [ + { + '.id': '*1', + 'name': 'first-ether', + 'default-name': 'ether1', + 'type': 'ether', + 'mtu': 1500, + 'actual-mtu': 1500, + 'l2mtu': 1598, + 'max-l2mtu': 4074, + 'mac-address': '00:11:22:33:44:55', + 'last-link-up-time': 'apr/22/2022 07:54:55', + 'link-downs': 0, + 'rx-byte': 1234, + 'tx-byte': 1234, + 'rx-packet': 1234, + 'tx-packet': 1234, + 'rx-drop': 1234, + 'tx-drop': 1234, + 'tx-queue-drop': 1234, + 'rx-error': 1234, + 'tx-error': 1234, + 'fp-rx-byte': 1234, + 'fp-tx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-packet': 1234, + 'running': True, + 'disabled': False, + }, + { + '.id': '*2', + 'name': 'second-ether', + 'default-name': 'ether2', + 'type': 'ether', + 'mtu': 1500, + 'actual-mtu': 1500, + 'l2mtu': 1598, + 'max-l2mtu': 4074, + 'mac-address': '00:11:22:33:44:66', + 'last-link-up-time': 'apr/22/2022 07:54:55', + 'link-downs': 0, + 'rx-byte': 1234, + 'tx-byte': 1234, + 'rx-packet': 1234, + 'tx-packet': 1234, + 'rx-drop': 1234, + 'tx-drop': 1234, + 'tx-queue-drop': 1234, + 'rx-error': 1234, + 'tx-error': 1234, + 'fp-rx-byte': 1234, + 'fp-tx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-packet': 1234, + 'running': True, + 'slave': True, + 'disabled': False, + }, + { + '.id': '*3', + 'name': 'third-ether', + 'default-name': 'ether3', + 'type': 'ether', + 'mtu': 1500, + 'actual-mtu': 1500, + 'l2mtu': 1598, + 'max-l2mtu': 4074, + 'mac-address': '00:11:22:33:44:77', + 'last-link-up-time': 'apr/22/2022 07:54:55', + 'link-downs': 0, + 'rx-byte': 1234, + 'tx-byte': 1234, + 'rx-packet': 1234, + 'tx-packet': 1234, + 'rx-drop': 1234, + 'tx-drop': 1234, + 'tx-queue-drop': 1234, + 'rx-error': 1234, + 'tx-error': 1234, + 'fp-rx-byte': 1234, + 'fp-tx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-packet': 1234, + 'running': True, + 'slave': True, + 'disabled': False, + }, + { + '.id': '*4', + 'name': 'fourth-ether', + 'default-name': 'ether4', + 'type': 'ether', + 'mtu': 1500, + 'actual-mtu': 1500, + 'l2mtu': 1598, + 'max-l2mtu': 4074, + 'mac-address': '00:11:22:33:44:88', + 'last-link-down-time': 'apr/23/2022 08:22:50', + 'last-link-up-time': 'apr/23/2022 08:22:52', + 'link-downs': 2, + 'rx-byte': 1234, + 'tx-byte': 1234, + 'rx-packet': 1234, + 'tx-packet': 1234, + 'rx-drop': 1234, + 'tx-drop': 1234, + 'tx-queue-drop': 1234, + 'rx-error': 1234, + 'tx-error': 1234, + 'fp-rx-byte': 1234, + 'fp-tx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-packet': 1234, + 'running': True, + 'disabled': False, + }, + { + '.id': '*5', + 'name': 'fifth-ether', + 'default-name': 'ether5', + 'type': 'ether', + 'mtu': 1500, + 'actual-mtu': 1500, + 'l2mtu': 1598, + 'max-l2mtu': 4074, + 'mac-address': '00:11:22:33:44:99', + 'last-link-down-time': 'may/02/2022 18:12:32', + 'last-link-up-time': 'may/02/2022 18:08:01', + 'link-downs': 14, + 'rx-byte': 1234, + 'tx-byte': 1234, + 'rx-packet': 1234, + 'tx-packet': 1234, + 'rx-drop': 1234, + 'tx-drop': 1234, + 'tx-queue-drop': 1234, + 'rx-error': 1234, + 'tx-error': 1234, + 'fp-rx-byte': 1234, + 'fp-tx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-packet': 1234, + 'running': False, + 'slave': True, + 'disabled': False, + }, + { + '.id': '*7', + 'name': 'my-bridge', + 'type': 'bridge', + 'mtu': 'auto', + 'actual-mtu': 1500, + 'l2mtu': 1598, + 'mac-address': '00:11:22:33:44:66', + 'last-link-up-time': 'apr/22/2022 07:54:48', + 'link-downs': 0, + 'rx-byte': 1234, + 'tx-byte': 1234, + 'rx-packet': 1234, + 'tx-packet': 1234, + 'rx-drop': 1234, + 'tx-drop': 1234, + 'tx-queue-drop': 1234, + 'rx-error': 1234, + 'tx-error': 1234, + 'fp-rx-byte': 1234, + 'fp-tx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-packet': 1234, + 'running': True, + 'disabled': False, + }, + ], + ('ip', 'address', ): [ + { + '.id': '*1', + 'address': '192.168.1.1/24', + 'network': '192.168.1.0', + 'interface': 'my-bridge', + 'actual-interface': 'my-bridge', + 'invalid': False, + 'dynamic': False, + 'disabled': False, + 'comment': 'Wohnung', + }, + { + '.id': '*5', + 'address': '192.168.2.1/24', + 'network': '192.168.2.0', + 'interface': 'fourth-ether', + 'actual-interface': 'fourth-ether', + 'invalid': False, + 'dynamic': False, + 'disabled': False, + 'comment': 'VoIP', + }, + { + '.id': '*6', + 'address': '1.2.3.4/21', + 'network': '84.73.216.0', + 'interface': 'first-ether', + 'actual-interface': 'first-ether', + 'invalid': False, + 'dynamic': True, + 'disabled': False, + }, + ], + ('ipv6', 'address', ): [ + { + '.id': '*1', + 'address': 'fe80::1:2:3/64', + 'from-pool': '', + 'interface': 'my-bridge', + 'actual-interface': 'my-bridge', + 'eui-64': False, + 'advertise': False, + 'no-dad': False, + 'invalid': False, + 'dynamic': True, + 'link-local': True, + 'disabled': False, + }, + { + '.id': '*2', + 'address': 'fe80::1:2:4/64', + 'from-pool': '', + 'interface': 'fourth-ether', + 'actual-interface': 'fourth-ether', + 'eui-64': False, + 'advertise': False, + 'no-dad': False, + 'invalid': False, + 'dynamic': True, + 'link-local': True, + 'disabled': False, + }, + { + '.id': '*3', + 'address': 'fe80::1:2:5/64', + 'from-pool': '', + 'interface': 'first-ether', + 'actual-interface': 'first-ether', + 'eui-64': False, + 'advertise': False, + 'no-dad': False, + 'invalid': False, + 'dynamic': True, + 'link-local': True, + 'disabled': False, + }, + ], + ('ip', 'neighbor', ): [], + ('system', 'identity', ): [ + { + 'name': 'MikroTik', + }, + ], + ('system', 'resource', ): [ + { + 'uptime': '2w3d4h5m6s', + 'version': '6.49.6 (stable)', + 'build-time': 'Apr/07/2022 17:53:31', + 'free-memory': 12345678, + 'total-memory': 23456789, + 'cpu': 'MIPS 24Kc V7.4', + 'cpu-count': 1, + 'cpu-frequency': 400, + 'cpu-load': 48, + 'free-hdd-space': 123456789, + 'total-hdd-space': 234567890, + 'write-sect-since-reboot': 1234, + 'write-sect-total': 12345, + 'bad-blocks': 0, + 'architecture-name': 'mipsbe', + 'board-name': 'RB750GL', + 'platform': 'MikroTik', + }, + ], + ('system', 'routerboard', ): [ + { + 'routerboard': True, + 'model': '750GL', + 'serial-number': '0123456789AB', + 'firmware-type': 'ar7240', + 'factory-firmware': '3.09', + 'current-firmware': '6.49.6', + 'upgrade-firmware': '6.49.6', + }, + ], + ('routing', 'bgp', 'peer', ): [], + ('routing', 'bgp', 'vpnv4-route', ): [], + ('routing', 'bgp', 'instance', ): [ + { + '.id': '*0', + 'name': 'default', + 'as': 65530, + 'router-id': '0.0.0.0', + 'redistribute-connected': False, + 'redistribute-static': False, + 'redistribute-rip': False, + 'redistribute-ospf': False, + 'redistribute-other-bgp': False, + 'out-filter': '', + 'client-to-client-reflection': True, + 'ignore-as-path-len': False, + 'routing-table': '', + 'default': True, + 'disabled': False, + }, + ], + ('ip', 'route', ): [ + { + '.id': '*30000001', + 'dst-address': '0.0.0.0/0', + 'gateway': '1.2.3.0', + 'gateway-status': '1.2.3.0 reachable via first-ether', + 'distance': 1, + 'scope': 30, + 'target-scope': 10, + 'vrf-interface': 'first-ether', + 'active': True, + 'dynamic': True, + 'static': True, + 'disabled': False, + }, + { + '.id': '*40162F13', + 'dst-address': '84.73.216.0/21', + 'pref-src': '1.2.3.4', + 'gateway': 'first-ether', + 'gateway-status': 'first-ether reachable', + 'distance': 0, + 'scope': 10, + 'active': True, + 'dynamic': True, + 'connect': True, + 'disabled': False, + }, + { + '.id': '*4016AA23', + 'dst-address': '192.168.2.0/24', + 'pref-src': '192.168.2.1', + 'gateway': 'fourth-ether', + 'gateway-status': 'fourth-ether reachable', + 'distance': 0, + 'scope': 10, + 'active': True, + 'dynamic': True, + 'connect': True, + 'disabled': False, + }, + { + '.id': '*40168E05', + 'dst-address': '192.168.1.0/24', + 'pref-src': '192.168.1.1', + 'gateway': 'my-bridge', + 'gateway-status': 'my-bridge reachable', + 'distance': 0, + 'scope': 10, + 'active': True, + 'dynamic': True, + 'connect': True, + 'disabled': False, + }, + ], + ('routing', 'ospf', 'instance', ): [ + { + '.id': '*0', + 'name': 'default', + 'router-id': '0.0.0.0', + 'distribute-default': 'never', + 'redistribute-connected': False, + 'redistribute-static': False, + 'redistribute-rip': False, + 'redistribute-bgp': False, + 'redistribute-other-ospf': False, + 'metric-default': 1, + 'metric-connected': 20, + 'metric-static': 20, + 'metric-rip': 20, + 'metric-bgp': 'auto', + 'metric-other-ospf': 'auto', + 'in-filter': 'ospf-in', + 'out-filter': 'ospf-out', + 'state': 'down', + 'default': True, + 'disabled': False, + }, + ], + ('routing', 'ospf', 'neighbor', ): [], +} + + +class TestRouterosApiFactsModule(ModuleTestCase): + + def setUp(self): + super(TestRouterosApiFactsModule, self).setUp() + librouteros = pytest.importorskip('librouteros') + self.module = api_facts + self.module.LibRouterosError = FakeLibRouterosError + self.module.connect = MagicMock(new=fake_ros_api) + self.patch_create_api = patch('ansible_collections.community.routeros.plugins.modules.api_facts.create_api', MagicMock(new=fake_ros_api)) + self.patch_create_api.start() + self.patch_query_path = patch('ansible_collections.community.routeros.plugins.modules.api_facts.FactsBase.query_path', self.query_path) + self.patch_query_path.start() + self.module.Key = MagicMock(new=Key) + self.config_module_args = { + 'username': 'admin', + 'password': 'pаss', + 'hostname': '127.0.0.1', + } + + def tearDown(self): + self.patch_query_path.stop() + self.patch_create_api.stop() + + def query_path(self, path): + response = API_RESPONSES.get(tuple(path)) + if response is None: + raise Exception('Unexpected command: %s' % repr(path)) + return response + + def test_module_fail_when_required_args_missing(self): + with self.assertRaises(AnsibleFailJson) as exc: + set_module_args({}) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + + def test_module_fail_when_invalid_gather_subset(self): + with self.assertRaises(AnsibleFailJson) as exc: + module_args = self.config_module_args.copy() + module_args['gather_subset'] = ['!foobar'] + set_module_args(module_args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['failed'], True) + self.assertEqual(result['msg'], 'Bad subset: foobar') + + def test_full_run(self): + with self.assertRaises(AnsibleExitJson) as exc: + set_module_args(self.config_module_args.copy()) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], False) + self.assertEqual(result['ansible_facts']['ansible_net_all_ipv4_addresses'], [ + '192.168.1.1', + '192.168.2.1', + '1.2.3.4', + ]) + self.assertEqual(result['ansible_facts']['ansible_net_all_ipv6_addresses'], [ + 'fe80::1:2:3', + 'fe80::1:2:4', + 'fe80::1:2:5', + ]) + self.assertEqual(result['ansible_facts']['ansible_net_arch'], 'mipsbe') + self.assertEqual(result['ansible_facts']['ansible_net_bgp_instance'], { + 'default': { + 'as': 65530, + 'client-to-client-reflection': True, + 'default': True, + 'disabled': False, + 'ignore-as-path-len': False, + 'name': 'default', + 'out-filter': '', + 'redistribute-connected': False, + 'redistribute-ospf': False, + 'redistribute-other-bgp': False, + 'redistribute-rip': False, + 'redistribute-static': False, + 'router-id': '0.0.0.0', + 'routing-table': '' + }, + }) + self.assertEqual(result['ansible_facts']['ansible_net_bgp_peer'], {}) + self.assertEqual(result['ansible_facts']['ansible_net_bgp_vpnv4_route'], {}) + self.assertEqual(result['ansible_facts']['ansible_net_cpu_load'], 48) + self.assertEqual(result['ansible_facts']['ansible_net_gather_subset'], [ + 'default', + 'hardware', + 'interfaces', + 'routing', + ]) + self.assertEqual(result['ansible_facts']['ansible_net_hostname'], 'MikroTik') + self.assertEqual(result['ansible_facts']['ansible_net_interfaces'], { + 'my-bridge': { + 'actual-mtu': 1500, + 'disabled': False, + 'fp-rx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-byte': 1234, + 'fp-tx-packet': 1234, + 'ipv4': [ + { + 'address': '192.168.1.1', + 'subnet': 24 + } + ], + 'ipv6': [ + { + 'address': 'fe80::1:2:3', + 'subnet': 64 + } + ], + 'l2mtu': 1598, + 'last-link-up-time': 'apr/22/2022 07:54:48', + 'link-downs': 0, + 'mac-address': '00:11:22:33:44:66', + 'mtu': 'auto', + 'name': 'my-bridge', + 'running': True, + 'rx-byte': 1234, + 'rx-drop': 1234, + 'rx-error': 1234, + 'rx-packet': 1234, + 'tx-byte': 1234, + 'tx-drop': 1234, + 'tx-error': 1234, + 'tx-packet': 1234, + 'tx-queue-drop': 1234, + 'type': 'bridge' + }, + 'first-ether': { + 'actual-mtu': 1500, + 'default-name': 'ether1', + 'disabled': False, + 'fp-rx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-byte': 1234, + 'fp-tx-packet': 1234, + 'ipv4': [ + { + 'address': '1.2.3.4', + 'subnet': 21 + } + ], + 'ipv6': [ + { + 'address': 'fe80::1:2:5', + 'subnet': 64 + } + ], + 'l2mtu': 1598, + 'last-link-up-time': 'apr/22/2022 07:54:55', + 'link-downs': 0, + 'mac-address': '00:11:22:33:44:55', + 'max-l2mtu': 4074, + 'mtu': 1500, + 'name': 'first-ether', + 'running': True, + 'rx-byte': 1234, + 'rx-drop': 1234, + 'rx-error': 1234, + 'rx-packet': 1234, + 'tx-byte': 1234, + 'tx-drop': 1234, + 'tx-error': 1234, + 'tx-packet': 1234, + 'tx-queue-drop': 1234, + 'type': 'ether' + }, + 'second-ether': { + 'actual-mtu': 1500, + 'default-name': 'ether2', + 'disabled': False, + 'fp-rx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-byte': 1234, + 'fp-tx-packet': 1234, + 'l2mtu': 1598, + 'last-link-up-time': 'apr/22/2022 07:54:55', + 'link-downs': 0, + 'mac-address': '00:11:22:33:44:66', + 'max-l2mtu': 4074, + 'mtu': 1500, + 'name': 'second-ether', + 'running': True, + 'rx-byte': 1234, + 'rx-drop': 1234, + 'rx-error': 1234, + 'rx-packet': 1234, + 'slave': True, + 'tx-byte': 1234, + 'tx-drop': 1234, + 'tx-error': 1234, + 'tx-packet': 1234, + 'tx-queue-drop': 1234, + 'type': 'ether' + }, + 'third-ether': { + 'actual-mtu': 1500, + 'default-name': 'ether3', + 'disabled': False, + 'fp-rx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-byte': 1234, + 'fp-tx-packet': 1234, + 'l2mtu': 1598, + 'last-link-up-time': 'apr/22/2022 07:54:55', + 'link-downs': 0, + 'mac-address': '00:11:22:33:44:77', + 'max-l2mtu': 4074, + 'mtu': 1500, + 'name': 'third-ether', + 'running': True, + 'rx-byte': 1234, + 'rx-drop': 1234, + 'rx-error': 1234, + 'rx-packet': 1234, + 'slave': True, + 'tx-byte': 1234, + 'tx-drop': 1234, + 'tx-error': 1234, + 'tx-packet': 1234, + 'tx-queue-drop': 1234, + 'type': 'ether' + }, + 'fourth-ether': { + 'actual-mtu': 1500, + 'default-name': 'ether4', + 'disabled': False, + 'fp-rx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-byte': 1234, + 'fp-tx-packet': 1234, + 'ipv4': [ + { + 'address': '192.168.2.1', + 'subnet': 24 + } + ], + 'ipv6': [ + { + 'address': 'fe80::1:2:4', + 'subnet': 64 + } + ], + 'l2mtu': 1598, + 'last-link-down-time': 'apr/23/2022 08:22:50', + 'last-link-up-time': 'apr/23/2022 08:22:52', + 'link-downs': 2, + 'mac-address': '00:11:22:33:44:88', + 'max-l2mtu': 4074, + 'mtu': 1500, + 'name': 'fourth-ether', + 'running': True, + 'rx-byte': 1234, + 'rx-drop': 1234, + 'rx-error': 1234, + 'rx-packet': 1234, + 'tx-byte': 1234, + 'tx-drop': 1234, + 'tx-error': 1234, + 'tx-packet': 1234, + 'tx-queue-drop': 1234, + 'type': 'ether' + }, + 'fifth-ether': { + 'actual-mtu': 1500, + 'default-name': 'ether5', + 'disabled': False, + 'fp-rx-byte': 1234, + 'fp-rx-packet': 1234, + 'fp-tx-byte': 1234, + 'fp-tx-packet': 1234, + 'l2mtu': 1598, + 'last-link-down-time': 'may/02/2022 18:12:32', + 'last-link-up-time': 'may/02/2022 18:08:01', + 'link-downs': 14, + 'mac-address': '00:11:22:33:44:99', + 'max-l2mtu': 4074, + 'mtu': 1500, + 'name': 'fifth-ether', + 'running': False, + 'rx-byte': 1234, + 'rx-drop': 1234, + 'rx-error': 1234, + 'rx-packet': 1234, + 'slave': True, + 'tx-byte': 1234, + 'tx-drop': 1234, + 'tx-error': 1234, + 'tx-packet': 1234, + 'tx-queue-drop': 1234, + 'type': 'ether' + } + }) + self.assertEqual(result['ansible_facts']['ansible_net_memfree_mb'], 12345678 / 1048576.0) + self.assertEqual(result['ansible_facts']['ansible_net_memtotal_mb'], 23456789 / 1048576.0) + self.assertEqual(result['ansible_facts']['ansible_net_model'], '750GL') + self.assertEqual(result['ansible_facts']['ansible_net_neighbors'], []) + self.assertEqual(result['ansible_facts']['ansible_net_ospf_instance'], { + 'default': { + 'default': True, + 'disabled': False, + 'distribute-default': 'never', + 'in-filter': 'ospf-in', + 'metric-bgp': 'auto', + 'metric-connected': 20, + 'metric-default': 1, + 'metric-other-ospf': 'auto', + 'metric-rip': 20, + 'metric-static': 20, + 'name': 'default', + 'out-filter': 'ospf-out', + 'redistribute-bgp': False, + 'redistribute-connected': False, + 'redistribute-other-ospf': False, + 'redistribute-rip': False, + 'redistribute-static': False, + 'router-id': '0.0.0.0', + 'state': 'down' + } + }) + self.assertEqual(result['ansible_facts']['ansible_net_ospf_neighbor'], {}) + self.assertEqual(result['ansible_facts']['ansible_net_route'], { + 'main': { + 'active': True, + 'connect': True, + 'disabled': False, + 'distance': 0, + 'dst-address': '192.168.1.0/24', + 'dynamic': True, + 'gateway': 'my-bridge', + 'gateway-status': 'my-bridge reachable', + 'pref-src': '192.168.1.1', + 'scope': 10 + } + }) + self.assertEqual(result['ansible_facts']['ansible_net_serialnum'], '0123456789AB') + self.assertEqual(result['ansible_facts']['ansible_net_spacefree_mb'], 123456789 / 1048576.0) + self.assertEqual(result['ansible_facts']['ansible_net_spacetotal_mb'], 234567890 / 1048576.0) + self.assertEqual(result['ansible_facts']['ansible_net_uptime'], '2w3d4h5m6s') + self.assertEqual(result['ansible_facts']['ansible_net_version'], '6.49.6 (stable)')