diff options
Diffstat (limited to 'ansible_collections/community/routeros/plugins/modules')
7 files changed, 3668 insertions, 0 deletions
diff --git a/ansible_collections/community/routeros/plugins/modules/api.py b/ansible_collections/community/routeros/plugins/modules/api.py new file mode 100644 index 000000000..f9c619fc1 --- /dev/null +++ b/ansible_collections/community/routeros/plugins/modules/api.py @@ -0,0 +1,577 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2020, Nikolay Dachev <nikolay@dachev.info> +# GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: api +author: "Nikolay Dachev (@NikolayDachev)" +short_description: Ansible module for RouterOS API +description: + - 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. + - Use the M(community.routeros.api_modify) and M(community.routeros.api_find_and_modify) modules + for more specific modifications, and the M(community.routeros.api_info) module for a more controlled + way of returning all entries for a path. +extends_documentation_fragment: + - community.routeros.api + - community.routeros.attributes + - community.routeros.attributes.actiongroup_api +attributes: + check_mode: + support: none + diff_mode: + support: none + platform: + support: full + platforms: RouterOS + action_group: + version_added: 2.1.0 +options: + path: + description: + - Main path for all other arguments. + - If other arguments are not set, api will return all items in selected path. + - Example C(ip address). Equivalent of RouterOS CLI C(/ip address print). + required: true + type: str + add: + description: + - Will add selected arguments in selected path to RouterOS config. + - Example C(address=1.1.1.1/32 interface=ether1). + - Equivalent in RouterOS CLI C(/ip address add address=1.1.1.1/32 interface=ether1). + type: str + remove: + description: + - Remove config/value from RouterOS by '.id'. + - Example C(*03) will remove config/value with C(id=*03) in selected path. + - Equivalent in RouterOS CLI C(/ip address remove numbers=1). + - Note C(number) in RouterOS CLI is different from C(.id). + type: str + update: + description: + - Update config/value in RouterOS by '.id' in selected path. + - Example C(.id=*03 address=1.1.1.3/32) and path C(ip address) will replace existing ip address with C(.id=*03). + - Equivalent in RouterOS CLI C(/ip address set address=1.1.1.3/32 numbers=1). + - Note C(number) in RouterOS CLI is different from C(.id). + type: str + query: + description: + - Query given path for selected query attributes from RouterOS aip. + - WHERE is key word which extend query. WHERE format is key operator value - with spaces. + - WHERE valid operators are C(==) or C(eq), C(!=) or C(not), C(>) or C(more), C(<) or C(less). + - Example path C(ip address) and query C(.id address) will return only C(.id) and C(address) for all items in C(ip address) path. + - Example path C(ip address) and query C(.id address WHERE address == 1.1.1.3/32). + will return only C(.id) and C(address) for items in C(ip address) path, where address is eq to 1.1.1.3/32. + - Example path C(interface) and query C(mtu name WHERE mut > 1400) will + return only interfaces C(mtu,name) where mtu is bigger than 1400. + - Equivalent in RouterOS CLI C(/interface print where mtu > 1400). + type: str + extended_query: + description: + - Extended query given path for selected query attributes from RouterOS API. + - Extended query allow conjunctive input. If there is no matching entry, an empty list will be returned. + type: dict + suboptions: + attributes: + description: + - The list of attributes to return. + - Every attribute used in a I(where) clause need to be listed here. + type: list + elements: str + required: true + where: + description: + - Allows to restrict the objects returned. + - The conditions here must all match. An I(or) condition needs at least one of its conditions to match. + type: list + elements: dict + suboptions: + attribute: + description: + - The attribute to match. Must be part of I(attributes). + - Either I(or) or all of I(attribute), I(is), and I(value) have to be specified. + type: str + is: + description: + - The operator to use for matching. + - For equality use C(==) or C(eq). For less use C(<) or C(less). For more use C(>) or C(more). + - Use C(in) to check whether the value is part of a list. In that case, I(value) must be a list. + - Either I(or) or all of I(attribute), I(is), and I(value) have to be specified. + type: str + choices: ["==", "!=", ">", "<", "in", "eq", "not", "more", "less"] + value: + description: + - The value to compare to. Must be a list for I(is=in). + - Either I(or) or all of I(attribute), I(is), and I(value) have to be specified. + type: raw + or: + description: + - A list of conditions so that at least one of them has to match. + - Either I(or) or all of I(attribute), I(is), and I(value) have to be specified. + type: list + elements: dict + suboptions: + attribute: + description: + - The attribute to match. Must be part of I(attributes). + type: str + required: true + is: + description: + - The operator to use for matching. + - For equality use C(==) or C(eq). For less use C(<) or C(less). For more use C(>) or C(more). + - Use C(in) to check whether the value is part of a list. In that case, I(value) must be a list. + type: str + choices: ["==", "!=", ">", "<", "in", "eq", "not", "more", "less"] + required: true + value: + description: + - The value to compare to. Must be a list for I(is=in). + type: raw + required: true + cmd: + description: + - Execute any/arbitrary command in selected path, after the command we can add C(.id). + - 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 +seealso: + - ref: ansible_collections.community.routeros.docsite.quoting + description: How to quote and unquote commands and arguments + - module: community.routeros.api_facts + - module: community.routeros.api_find_and_modify + - module: community.routeros.api_info + - module: community.routeros.api_modify +''' + +EXAMPLES = ''' +- name: Get example - ip address print + community.routeros.api: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: "ip address" + register: ipaddrd_printout + +- name: Dump "Get example" output + ansible.builtin.debug: + msg: '{{ ipaddrd_printout }}' + +- name: Add example - ip address + community.routeros.api: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: "ip address" + add: "address=192.168.255.10/24 interface=ether2" + +- name: Query example - ".id, address" in "ip address WHERE address == 192.168.255.10/24" + community.routeros.api: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: "ip address" + query: ".id address WHERE address == {{ ip2 }}" + register: queryout + +- name: Dump "Query example" output + ansible.builtin.debug: + msg: '{{ queryout }}' + +- name: Extended query example - ".id,address,network" where address is not 192.168.255.10/24 or is 10.20.36.20/24 + community.routeros.api: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: "ip address" + extended_query: + attributes: + - network + - address + - .id + where: + - attribute: "network" + is: "==" + value: "192.168.255.0" + - or: + - attribute: "address" + is: "!=" + value: "192.168.255.10/24" + - attribute: "address" + is: "eq" + value: "10.20.36.20/24" + - attribute: "network" + is: "in" + value: + - "10.20.36.0" + - "192.168.255.0" + register: extended_queryout + +- name: Dump "Extended query example" output + ansible.builtin.debug: + msg: '{{ extended_queryout }}' + +- name: Update example - ether2 ip addres with ".id = *14" + community.routeros.api: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: "ip address" + update: >- + .id=*14 + address=192.168.255.20/24 + comment={{ 'Update 192.168.255.10/24 to 192.168.255.20/24 on ether2' | community.routeros.quote_argument_value }} + +- name: Remove example - ether2 ip 192.168.255.20/24 with ".id = *14" + community.routeros.api: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: "ip address" + remove: "*14" + +- name: Arbitrary command example "/system identity print" + community.routeros.api: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: "system identity" + cmd: "print" + register: arbitraryout + +- name: Dump "Arbitrary command example" output + ansible.builtin.debug: + msg: '{{ arbitraryout }}' +''' + +RETURN = ''' +--- +message: + description: All outputs are in list with dictionary elements returned from RouterOS api. + sample: + - address: 1.2.3.4 + - address: 2.3.4.5 + type: list + returned: always +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.routeros.plugins.module_utils.quoting import ( + ParseError, + convert_list_to_dictionary, + parse_argument_value, + split_routeros_command, +) + +from ansible_collections.community.routeros.plugins.module_utils.api import ( + api_argument_spec, + check_has_library, + create_api, +) + +import re + +try: + from librouteros.exceptions import LibRouterosError + from librouteros.query import Key, Or +except Exception: + # Handled in api module_utils + pass + + +class ROS_api_module: + def __init__(self): + module_args = dict( + 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'), + extended_query=dict(type='dict', options=dict( + attributes=dict(type='list', elements='str', required=True), + where=dict( + type='list', + elements='dict', + options={ + 'attribute': dict(type='str'), + 'is': dict(type='str', choices=["==", "!=", ">", "<", "in", "eq", "not", "more", "less"]), + 'value': dict(type='raw'), + 'or': dict(type='list', elements='dict', options={ + 'attribute': dict(type='str', required=True), + 'is': dict(type='str', choices=["==", "!=", ">", "<", "in", "eq", "not", "more", "less"], required=True), + 'value': dict(type='raw', required=True), + }), + }, + required_together=[('attribute', 'is', 'value')], + mutually_exclusive=[('attribute', 'or')], + required_one_of=[('attribute', 'or')], + ), + )), + ) + module_args.update(api_argument_spec()) + + self.module = AnsibleModule(argument_spec=module_args, + supports_check_mode=False, + mutually_exclusive=(('add', 'remove', 'update', + 'cmd', 'query', 'extended_query'),),) + + check_has_library(self.module) + + self.api = create_api(self.module) + + self.path = self.module.params['path'].split() + self.add = self.module.params['add'] + self.remove = self.module.params['remove'] + self.update = self.module.params['update'] + self.arbitrary = self.module.params['cmd'] + + self.where = None + self.query = self.module.params['query'] + self.extended_query = self.module.params['extended_query'] + + self.result = dict( + message=[]) + + # create api base path + self.api_path = self.api_add_path(self.api, self.path) + + # api calls + try: + if self.add: + self.api_add() + elif self.remove: + self.api_remove() + elif self.update: + self.api_update() + elif self.query: + self.check_query() + self.api_query() + elif self.extended_query: + self.check_extended_query() + self.api_extended_query() + elif self.arbitrary: + self.api_arbitrary() + else: + self.api_get_all() + except UnicodeEncodeError as exc: + self.module.fail_json(msg='Error while encoding text: {error}'.format(error=exc)) + + def check_query(self): + where_index = self.query.find(' WHERE ') + if where_index < 0: + self.query = self.split_params(self.query) + else: + where = self.query[where_index + len(' WHERE '):] + self.query = self.split_params(self.query[:where_index]) + # where must be of the format '<attribute> <operator> <value>' + m = re.match(r'^\s*([^ ]+)\s+([^ ]+)\s+(.*)$', where) + if not m: + self.errors("invalid syntax for 'WHERE %s'" % where) + try: + self.where = [ + m.group(1), # attribute + m.group(2), # operator + parse_argument_value(m.group(3).rstrip())[0], # value + ] + except ParseError as exc: + self.errors("invalid syntax for 'WHERE %s': %s" % (where, exc)) + try: + idx = self.query.index('WHERE') + self.where = self.query[idx + 1:] + self.query = self.query[:idx] + except ValueError: + # Raised when WHERE has not been found + pass + + def check_extended_query_syntax(self, test_atr, or_msg=''): + if test_atr['is'] == "in" and not isinstance(test_atr['value'], list): + self.errors("invalid syntax 'extended_query':'where':%s%s 'value' must be a type list" % (or_msg, test_atr)) + + def check_extended_query(self): + if self.extended_query["where"]: + for i in self.extended_query['where']: + if i["or"] is not None: + if len(i['or']) < 2: + self.errors("invalid syntax 'extended_query':'where':'or':%s 'or' requires minimum two items" % i["or"]) + for orv in i['or']: + self.check_extended_query_syntax(orv, ":'or':") + else: + self.check_extended_query_syntax(i) + + def list_to_dic(self, ldict): + return convert_list_to_dictionary(ldict, skip_empty_values=True, require_assignment=True) + + def split_params(self, params): + if not isinstance(params, str): + raise AssertionError('Parameters can only be a string, received %s' % type(params)) + try: + return split_routeros_command(params) + except ParseError as e: + self.module.fail_json(msg=to_native(e)) + + def api_add_path(self, api, path): + api_path = api.path() + for p in path: + api_path = api_path.join(p) + return api_path + + def api_get_all(self): + try: + for i in self.api_path: + self.result['message'].append(i) + self.return_result(False, True) + except LibRouterosError as e: + self.errors(e) + + def api_add(self): + param = self.list_to_dic(self.split_params(self.add)) + try: + self.result['message'].append("added: .id= %s" + % self.api_path.add(**param)) + self.return_result(True) + except LibRouterosError as e: + self.errors(e) + + def api_remove(self): + try: + self.api_path.remove(self.remove) + self.result['message'].append("removed: .id= %s" % self.remove) + self.return_result(True) + except LibRouterosError as e: + self.errors(e) + + def api_update(self): + param = self.list_to_dic(self.split_params(self.update)) + if '.id' not in param.keys(): + self.errors("missing '.id' for %s" % param) + try: + self.api_path.update(**param) + self.result['message'].append("updated: %s" % param) + self.return_result(True) + except LibRouterosError as e: + self.errors(e) + + def api_query(self): + keys = {} + for k in self.query: + if 'id' in k and k != ".id": + self.errors("'%s' must be '.id'" % k) + keys[k] = Key(k) + try: + if self.where: + if self.where[1] in ('==', 'eq'): + select = self.api_path.select(*keys).where(keys[self.where[0]] == self.where[2]) + elif self.where[1] in ('!=', 'not'): + select = self.api_path.select(*keys).where(keys[self.where[0]] != self.where[2]) + elif self.where[1] in ('>', 'more'): + select = self.api_path.select(*keys).where(keys[self.where[0]] > self.where[2]) + elif self.where[1] in ('<', 'less'): + select = self.api_path.select(*keys).where(keys[self.where[0]] < self.where[2]) + else: + self.errors("'%s' is not operator for 'where'" + % self.where[1]) + else: + select = self.api_path.select(*keys) + for row in select: + self.result['message'].append(row) + if len(self.result['message']) < 1: + msg = "no results for '%s 'query' %s" % (' '.join(self.path), + ' '.join(self.query)) + if self.where: + msg = msg + ' WHERE %s' % ' '.join(self.where) + self.result['message'].append(msg) + self.return_result(False) + except LibRouterosError as e: + self.errors(e) + + def build_api_extended_query(self, item): + if item['attribute'] not in self.extended_query['attributes']: + self.errors("'%s' attribute is not in attributes: %s" + % (item, self.extended_query['attributes'])) + if item['is'] in ('eq', '=='): + return self.query_keys[item['attribute']] == item['value'] + elif item['is'] in ('not', '!='): + return self.query_keys[item['attribute']] != item['value'] + elif item['is'] in ('less', '<'): + return self.query_keys[item['attribute']] < item['value'] + elif item['is'] in ('more', '>'): + return self.query_keys[item['attribute']] > item['value'] + elif item['is'] == 'in': + return self.query_keys[item['attribute']].In(*item['value']) + else: + self.errors("'%s' is not operator for 'is'" % item['is']) + + def api_extended_query(self): + self.query_keys = {} + for k in self.extended_query['attributes']: + if k == 'id': + self.errors("'extended_query':'attributes':'%s' must be '.id'" % k) + self.query_keys[k] = Key(k) + try: + if self.extended_query['where']: + where_args = [] + for i in self.extended_query['where']: + if i['or']: + where_or_args = [] + for ior in i['or']: + where_or_args.append(self.build_api_extended_query(ior)) + where_args.append(Or(*where_or_args)) + else: + where_args.append(self.build_api_extended_query(i)) + select = self.api_path.select(*self.query_keys).where(*where_args) + else: + select = self.api_path.select(*self.extended_query['attributes']) + for row in select: + self.result['message'].append(row) + self.return_result(False) + except LibRouterosError as e: + self.errors(e) + + def api_arbitrary(self): + param = {} + self.arbitrary = self.split_params(self.arbitrary) + arb_cmd = self.arbitrary[0] + if len(self.arbitrary) > 1: + param = self.list_to_dic(self.arbitrary[1:]) + try: + arbitrary_result = self.api_path(arb_cmd, **param) + for i in arbitrary_result: + self.result['message'].append(i) + self.return_result(False) + except LibRouterosError as e: + self.errors(e) + + def return_result(self, ch_status=False, status=True): + if not status: + self.module.fail_json(msg=self.result['message']) + else: + self.module.exit_json(changed=ch_status, + msg=self.result['message']) + + def errors(self, e): + if e.__class__.__name__ == 'TrapError': + self.result['message'].append("%s" % e) + self.return_result(False, False) + self.result['message'].append("%s" % e) + self.return_result(False, False) + + +def main(): + + ROS_api_module() + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/routeros/plugins/modules/api_facts.py b/ansible_collections/community/routeros/plugins/modules/api_facts.py new file mode 100644 index 000000000..f29723667 --- /dev/null +++ b/ansible_collections/community/routeros/plugins/modules/api_facts.py @@ -0,0 +1,495 @@ +#!/usr/bin/python + +# Copyright (c) 2022, Felix Fontein <felix@fontein.de> +# Copyright (c) 2020, Nikolay Dachev <nikolay@dachev.info> +# Copyright (c) 2018, Egor Zaitsev (@heuels) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +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_<fact>). 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 + - community.routeros.attributes + - community.routeros.attributes.actiongroup_api + - community.routeros.attributes.facts + - community.routeros.attributes.facts_module +attributes: + platform: + support: full + platforms: RouterOS +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 + - module: community.routeros.api_find_and_modify + - module: community.routeros.api_info + - module: community.routeros.api_modify +''' + +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 +""" + +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/ansible_collections/community/routeros/plugins/modules/api_find_and_modify.py b/ansible_collections/community/routeros/plugins/modules/api_find_and_modify.py new file mode 100644 index 000000000..0be3f7039 --- /dev/null +++ b/ansible_collections/community/routeros/plugins/modules/api_find_and_modify.py @@ -0,0 +1,327 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ https://www.gnu.org/licenses/gpl-3.0.txt +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: api_find_and_modify +author: + - "Felix Fontein (@felixfontein)" +short_description: Find and modify information using the API +version_added: 2.1.0 +description: + - Allows to find entries for a path by conditions and modify the values of these entries. + - Use the M(community.routeros.api_find_and_modify) module to set all entries of a path to specific values, + or change multiple entries in different ways in one step. +notes: + - "If you want to change values based on their old values (like change all comments 'foo' to 'bar') and make sure that + there are at least N such values, you can use I(require_matches_min=N) together with I(allow_no_matches=true). + This will make the module fail if there are less than N such entries, but not if there is no match. The latter case + is needed for idempotency of the task: once the values have been changed, there should be no further match." +extends_documentation_fragment: + - community.routeros.api + - community.routeros.attributes + - community.routeros.attributes.actiongroup_api +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + support: full + platforms: RouterOS +options: + path: + description: + - Path to query. + - An example value is C(ip address). This is equivalent to running C(/ip address) in the RouterOS CLI. + required: true + type: str + find: + description: + - Fields to search for. + - The module will only consider entries in the given I(path) that match all fields provided here. + - Use YAML C(~), or prepend keys with C(!), to specify an unset value. + - Note that if the dictionary specified here is empty, every entry in the path will be matched. + required: true + type: dict + values: + description: + - On all entries matching the conditions in I(find), set the keys of this option to the values specified here. + - Use YAML C(~), or prepend keys with C(!), to specify to unset a value. + required: true + type: dict + require_matches_min: + description: + - Make sure that there are no less matches than this number. + - If there are less matches, fail instead of modifying anything. + type: int + default: 0 + require_matches_max: + description: + - Make sure that there are no more matches than this number. + - If there are more matches, fail instead of modifying anything. + - If not specified, there is no upper limit. + type: int + allow_no_matches: + description: + - Whether to allow that no match is found. + - If not specified, this value is induced from whether I(require_matches_min) is 0 or larger. + type: bool +seealso: + - module: community.routeros.api + - module: community.routeros.api_facts + - module: community.routeros.api_modify + - module: community.routeros.api_info +''' + +EXAMPLES = ''' +--- +- name: Rename bridge from 'bridge' to 'my-bridge' + community.routeros.api_find_and_modify: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: interface bridge + find: + name: bridge + values: + name: my-bridge + +- name: Change IP address to 192.168.1.1 for interface bridge - assuming there is only one + community.routeros.api_find_and_modify: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: ip address + find: + interface: bridge + values: + address: "192.168.1.1/24" + # If there are zero entries, or more than one: fail! We expected that + # exactly one is configured. + require_matches_min: 1 + require_matches_max: 1 +''' + +RETURN = ''' +--- +old_data: + description: + - A list of all elements for the current path before a change was made. + sample: + - '.id': '*1' + actual-interface: bridge + address: "192.168.88.1/24" + comment: defconf + disabled: false + dynamic: false + interface: bridge + invalid: false + network: 192.168.88.0 + type: list + elements: dict + returned: success +new_data: + description: + - A list of all elements for the current path after a change was made. + sample: + - '.id': '*1' + actual-interface: bridge + address: "192.168.1.1/24" + comment: awesome + disabled: false + dynamic: false + interface: bridge + invalid: false + network: 192.168.1.0 + type: list + elements: dict + returned: success +match_count: + description: + - The number of entries that matched the criteria in I(find). + sample: 1 + type: int + returned: success +modify__count: + description: + - The number of entries that were modified. + sample: 1 + type: int + returned: success +''' + +from ansible.module_utils.basic import AnsibleModule +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, +) + +from ansible_collections.community.routeros.plugins.module_utils._api_data import ( + split_path, +) + +try: + from librouteros.exceptions import LibRouterosError +except Exception: + # Handled in api module_utils + pass + + +def compose_api_path(api, path): + api_path = api.path() + for p in path: + api_path = api_path.join(p) + return api_path + + +DISABLED_MEANS_EMPTY_STRING = ('comment', ) + + +def main(): + module_args = dict( + path=dict(type='str', required=True), + find=dict(type='dict', required=True), + values=dict(type='dict', required=True), + require_matches_min=dict(type='int', default=0), + require_matches_max=dict(type='int'), + allow_no_matches=dict(type='bool'), + ) + module_args.update(api_argument_spec()) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + if module.params['allow_no_matches'] is None: + module.params['allow_no_matches'] = module.params['require_matches_min'] <= 0 + + find = module.params['find'] + for key, value in sorted(find.items()): + if key.startswith('!'): + key = key[1:] + if value not in (None, ''): + module.fail_json(msg='The value for "!{key}" in `find` must not be non-trivial!'.format(key=key)) + if key in find: + module.fail_json(msg='`find` must not contain both "{key}" and "!{key}"!'.format(key=key)) + values = module.params['values'] + for key, value in sorted(values.items()): + if key.startswith('!'): + key = key[1:] + if value not in (None, ''): + module.fail_json(msg='The value for "!{key}" in `values` must not be non-trivial!'.format(key=key)) + if key in values: + module.fail_json(msg='`values` must not contain both "{key}" and "!{key}"!'.format(key=key)) + + check_has_library(module) + api = create_api(module) + + path = split_path(module.params['path']) + + api_path = compose_api_path(api, path) + + old_data = list(api_path) + new_data = [entry.copy() for entry in old_data] + + # Find matching entries + matching_entries = [] + for index, entry in enumerate(new_data): + matches = True + for key, value in find.items(): + if key.startswith('!'): + # Allow to specify keys that should not be present by prepending '!' + key = key[1:] + value = None + current_value = entry.get(key) + if key in DISABLED_MEANS_EMPTY_STRING and value == '' and current_value is None: + current_value = value + if current_value != value: + matches = False + break + if matches: + matching_entries.append((index, entry)) + + # Check whether the correct amount of entries was found + if matching_entries: + if len(matching_entries) < module.params['require_matches_min']: + module.fail_json(msg='Found %d entries, but expected at least %d' % (len(matching_entries), module.params['require_matches_min'])) + if module.params['require_matches_max'] is not None and len(matching_entries) > module.params['require_matches_max']: + module.fail_json(msg='Found %d entries, but expected at most %d' % (len(matching_entries), module.params['require_matches_max'])) + elif not module.params['allow_no_matches']: + module.fail_json(msg='Found no entries, but allow_no_matches=false') + + # Identify entries to update + modifications = [] + for index, entry in matching_entries: + modification = {} + for key, value in values.items(): + if key.startswith('!'): + # Allow to specify keys to remove by prepending '!' + key = key[1:] + value = None + current_value = entry.get(key) + if key in DISABLED_MEANS_EMPTY_STRING and value == '' and current_value is None: + current_value = value + if current_value != value: + if value is None: + disable_key = '!%s' % key + if key in DISABLED_MEANS_EMPTY_STRING: + disable_key = key + modification[disable_key] = '' + entry.pop(key, None) + else: + modification[key] = value + entry[key] = value + if modification: + if '.id' in entry: + modification['.id'] = entry['.id'] + modifications.append(modification) + + # Apply changes + if not module.check_mode and modifications: + for modification in modifications: + try: + api_path.update(**modification) + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json( + msg='Error while modifying for .id={id}: {error}'.format( + id=modification['.id'], + error=to_native(e), + ) + ) + new_data = list(api_path) + + # Produce return value + more = {} + if module._diff: + # Only include the matching values + more['diff'] = { + 'before': { + 'values': [old_data[index] for index, entry in matching_entries], + }, + 'after': { + 'values': [entry for index, entry in matching_entries], + }, + } + module.exit_json( + changed=bool(modifications), + old_data=old_data, + new_data=new_data, + match_count=len(matching_entries), + modify_count=len(modifications), + **more + ) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/routeros/plugins/modules/api_info.py b/ansible_collections/community/routeros/plugins/modules/api_info.py new file mode 100644 index 000000000..50228c063 --- /dev/null +++ b/ansible_collections/community/routeros/plugins/modules/api_info.py @@ -0,0 +1,366 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, Felix Fontein (@felixfontein) <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: api_info +author: + - "Felix Fontein (@felixfontein)" +short_description: Retrieve information from API +version_added: 2.2.0 +description: + - Allows to retrieve information for a path using the API. + - This can be used to backup a path to restore it with the M(community.routeros.api_modify) module. + - Entries are normalized, dynamic and builtin entries are not returned. Use the I(handle_disabled) and + I(hide_defaults) options to control normalization, the I(include_dynamic) and I(include_builtin) options to also return + dynamic resp. builtin entries, and use I(unfiltered) to return all fields including counters. + - B(Note) that this module is still heavily in development, and only supports B(some) paths. + If you want to support new paths, or think you found problems with existing paths, please first + L(create an issue in the community.routeros Issue Tracker,https://github.com/ansible-collections/community.routeros/issues/). +extends_documentation_fragment: + - community.routeros.api + - community.routeros.attributes + - community.routeros.attributes.actiongroup_api + - community.routeros.attributes.info_module +attributes: + platform: + support: full + platforms: RouterOS +options: + path: + description: + - Path to query. + - An example value is C(ip address). This is equivalent to running C(/ip address print) in the RouterOS CLI. + required: true + type: str + choices: + # BEGIN PATH LIST + - caps-man aaa + - caps-man access-list + - caps-man configuration + - caps-man datapath + - caps-man manager + - caps-man provisioning + - caps-man security + - certificate settings + - interface bonding + - interface bridge + - interface bridge mlag + - interface bridge port + - interface bridge port-controller + - interface bridge port-extender + - interface bridge settings + - interface bridge vlan + - interface detect-internet + - interface eoip + - interface ethernet + - interface ethernet poe + - interface ethernet switch + - interface ethernet switch port + - interface gre + - interface gre6 + - interface l2tp-server server + - interface list + - interface list member + - interface ovpn-server server + - interface pppoe-client + - interface pptp-server server + - interface sstp-server server + - interface vlan + - interface vrrp + - interface wireguard + - interface wireguard peers + - interface wireless align + - interface wireless cap + - interface wireless sniffer + - interface wireless snooper + - ip accounting + - ip accounting web-access + - ip address + - ip arp + - ip cloud + - ip cloud advanced + - ip dhcp-client + - ip dhcp-client option + - ip dhcp-server + - ip dhcp-server config + - ip dhcp-server lease + - ip dhcp-server network + - ip dns + - ip dns static + - ip firewall address-list + - ip firewall connection tracking + - ip firewall filter + - ip firewall layer7-protocol + - ip firewall mangle + - ip firewall nat + - ip firewall raw + - ip firewall service-port + - ip hotspot service-port + - ip ipsec identity + - ip ipsec peer + - ip ipsec policy + - ip ipsec profile + - ip ipsec proposal + - ip ipsec settings + - ip neighbor discovery-settings + - ip pool + - ip proxy + - ip route + - ip route vrf + - ip service + - ip settings + - ip smb + - ip socks + - ip ssh + - ip tftp settings + - ip traffic-flow + - ip traffic-flow ipfix + - ip upnp + - ipv6 address + - ipv6 dhcp-client + - ipv6 dhcp-server + - ipv6 dhcp-server option + - ipv6 firewall address-list + - ipv6 firewall filter + - ipv6 firewall mangle + - ipv6 firewall raw + - ipv6 nd + - ipv6 nd prefix default + - ipv6 route + - ipv6 settings + - mpls + - mpls ldp + - port firmware + - ppp aaa + - queue interface + - queue tree + - radius incoming + - routing bgp instance + - routing mme + - routing ospf area + - routing ospf area range + - routing ospf instance + - routing ospf interface-template + - routing pimsm instance + - routing pimsm interface-template + - routing rip + - routing ripng + - snmp + - snmp community + - system clock + - system clock manual + - system identity + - system leds settings + - system logging + - system logging action + - system note + - system ntp client + - system ntp client servers + - system ntp server + - system package update + - system routerboard settings + - system scheduler + - system script + - system upgrade mirror + - system ups + - system watchdog + - tool bandwidth-server + - tool e-mail + - tool graphing + - tool mac-server + - tool mac-server mac-winbox + - tool mac-server ping + - tool romon + - tool sms + - tool sniffer + - tool traffic-generator + - user aaa + - user group + # END PATH LIST + unfiltered: + description: + - Whether to output all fields, and not just the ones supported as input for M(community.routeros.api_modify). + - Unfiltered output can contain counters and other state information. + type: bool + default: false + handle_disabled: + description: + - How to handle unset values. + - C(exclamation) prepends the keys with C(!) in the output with value C(null). + - C(null-value) uses the regular key with value C(null). + - C(omit) omits these values from the result. + type: str + choices: + - exclamation + - null-value + - omit + default: exclamation + hide_defaults: + description: + - Whether to hide default values. + type: bool + default: true + include_dynamic: + description: + - Whether to include dynamic values. + - By default, they are not returned, and the C(dynamic) keys are omitted. + - If set to C(true), they are returned as well, and the C(dynamic) keys are returned as well. + type: bool + default: false + include_builtin: + description: + - Whether to include builtin values. + - By default, they are not returned, and the C(builtin) keys are omitted. + - If set to C(true), they are returned as well, and the C(builtin) keys are returned as well. + type: bool + default: false + version_added: 2.4.0 +seealso: + - module: community.routeros.api + - module: community.routeros.api_facts + - module: community.routeros.api_find_and_modify + - module: community.routeros.api_modify +''' + +EXAMPLES = ''' +--- +- name: Get IP addresses + community.routeros.api_info: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: ip address + register: ip_addresses + +- name: Print data for IP addresses + ansible.builtin.debug: + var: ip_addresses.result +''' + +RETURN = ''' +--- +result: + description: A list of all elements for the current path. + sample: + - '.id': '*1' + actual-interface: bridge + address: "192.168.88.1/24" + comment: defconf + disabled: false + dynamic: false + interface: bridge + invalid: false + network: 192.168.88.0 + type: list + elements: dict + returned: always +''' + +from ansible.module_utils.basic import AnsibleModule +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, +) + +from ansible_collections.community.routeros.plugins.module_utils._api_data import ( + PATHS, + join_path, + split_path, +) + +try: + from librouteros.exceptions import LibRouterosError +except Exception: + # Handled in api module_utils + pass + + +def compose_api_path(api, path): + api_path = api.path() + for p in path: + api_path = api_path.join(p) + return api_path + + +def main(): + module_args = dict( + path=dict(type='str', required=True, choices=sorted([join_path(path) for path in PATHS if PATHS[path].fully_understood])), + unfiltered=dict(type='bool', default=False), + handle_disabled=dict(type='str', choices=['exclamation', 'null-value', 'omit'], default='exclamation'), + hide_defaults=dict(type='bool', default=True), + include_dynamic=dict(type='bool', default=False), + include_builtin=dict(type='bool', default=False), + ) + module_args.update(api_argument_spec()) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + + check_has_library(module) + api = create_api(module) + + path = split_path(module.params['path']) + path_info = PATHS.get(tuple(path)) + if path_info is None: + module.fail_json(msg='Path /{path} is not yet supported'.format(path='/'.join(path))) + + handle_disabled = module.params['handle_disabled'] + hide_defaults = module.params['hide_defaults'] + include_dynamic = module.params['include_dynamic'] + include_builtin = module.params['include_builtin'] + try: + api_path = compose_api_path(api, path) + + result = [] + unfiltered = module.params['unfiltered'] + for entry in api_path: + if not include_dynamic: + if entry.get('dynamic', False): + continue + if not include_builtin: + if entry.get('builtin', False): + continue + if not unfiltered: + for k in list(entry): + if k == '.id': + continue + if k == 'dynamic' and include_dynamic: + continue + if k == 'builtin' and include_builtin: + continue + if k not in path_info.fields: + entry.pop(k) + if handle_disabled != 'omit': + for k in path_info.fields: + if k not in entry: + if handle_disabled == 'exclamation': + k = '!%s' % k + entry[k] = None + for k, field_info in path_info.fields.items(): + if hide_defaults: + if field_info.default is not None and entry.get(k) == field_info.default: + entry.pop(k) + if field_info.absent_value and k not in entry: + entry[k] = field_info.absent_value + result.append(entry) + + module.exit_json(result=result) + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json(msg=to_native(e)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/routeros/plugins/modules/api_modify.py b/ansible_collections/community/routeros/plugins/modules/api_modify.py new file mode 100644 index 000000000..5d410e9fb --- /dev/null +++ b/ansible_collections/community/routeros/plugins/modules/api_modify.py @@ -0,0 +1,1030 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2022, Felix Fontein (@felixfontein) <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: api_modify +author: + - "Felix Fontein (@felixfontein)" +short_description: Modify data at paths with API +version_added: 2.2.0 +description: + - Allows to modify information for a path using the API. + - Use the M(community.routeros.api_find_and_modify) module to modify one or multiple entries in a controlled way + depending on some search conditions. + - To make a backup of a path that can be restored with this module, use the M(community.routeros.api_info) module. + - The module ignores dynamic and builtin entries. + - B(Note) that this module is still heavily in development, and only supports B(some) paths. + If you want to support new paths, or think you found problems with existing paths, please first + L(create an issue in the community.routeros Issue Tracker,https://github.com/ansible-collections/community.routeros/issues/). +requirements: + - Needs L(ordereddict,https://pypi.org/project/ordereddict) for Python 2.6 +extends_documentation_fragment: + - community.routeros.api + - community.routeros.attributes + - community.routeros.attributes.actiongroup_api +attributes: + check_mode: + support: full + diff_mode: + support: full + platform: + support: full + platforms: RouterOS +options: + path: + description: + - Path to query. + - An example value is C(ip address). This is equivalent to running modification commands in C(/ip address) in the RouterOS CLI. + required: true + type: str + choices: + # BEGIN PATH LIST + - caps-man aaa + - caps-man access-list + - caps-man configuration + - caps-man datapath + - caps-man manager + - caps-man provisioning + - caps-man security + - certificate settings + - interface bonding + - interface bridge + - interface bridge mlag + - interface bridge port + - interface bridge port-controller + - interface bridge port-extender + - interface bridge settings + - interface bridge vlan + - interface detect-internet + - interface eoip + - interface ethernet + - interface ethernet poe + - interface ethernet switch + - interface ethernet switch port + - interface gre + - interface gre6 + - interface l2tp-server server + - interface list + - interface list member + - interface ovpn-server server + - interface pppoe-client + - interface pptp-server server + - interface sstp-server server + - interface vlan + - interface vrrp + - interface wireguard + - interface wireguard peers + - interface wireless align + - interface wireless cap + - interface wireless sniffer + - interface wireless snooper + - ip accounting + - ip accounting web-access + - ip address + - ip arp + - ip cloud + - ip cloud advanced + - ip dhcp-client + - ip dhcp-client option + - ip dhcp-server + - ip dhcp-server config + - ip dhcp-server lease + - ip dhcp-server network + - ip dns + - ip dns static + - ip firewall address-list + - ip firewall connection tracking + - ip firewall filter + - ip firewall layer7-protocol + - ip firewall mangle + - ip firewall nat + - ip firewall raw + - ip firewall service-port + - ip hotspot service-port + - ip ipsec identity + - ip ipsec peer + - ip ipsec policy + - ip ipsec profile + - ip ipsec proposal + - ip ipsec settings + - ip neighbor discovery-settings + - ip pool + - ip proxy + - ip route + - ip route vrf + - ip service + - ip settings + - ip smb + - ip socks + - ip ssh + - ip tftp settings + - ip traffic-flow + - ip traffic-flow ipfix + - ip upnp + - ipv6 address + - ipv6 dhcp-client + - ipv6 dhcp-server + - ipv6 dhcp-server option + - ipv6 firewall address-list + - ipv6 firewall filter + - ipv6 firewall mangle + - ipv6 firewall raw + - ipv6 nd + - ipv6 nd prefix default + - ipv6 route + - ipv6 settings + - mpls + - mpls ldp + - port firmware + - ppp aaa + - queue interface + - queue tree + - radius incoming + - routing bgp instance + - routing mme + - routing ospf area + - routing ospf area range + - routing ospf instance + - routing ospf interface-template + - routing pimsm instance + - routing pimsm interface-template + - routing rip + - routing ripng + - snmp + - snmp community + - system clock + - system clock manual + - system identity + - system leds settings + - system logging + - system logging action + - system note + - system ntp client + - system ntp client servers + - system ntp server + - system package update + - system routerboard settings + - system scheduler + - system script + - system upgrade mirror + - system ups + - system watchdog + - tool bandwidth-server + - tool e-mail + - tool graphing + - tool mac-server + - tool mac-server mac-winbox + - tool mac-server ping + - tool romon + - tool sms + - tool sniffer + - tool traffic-generator + - user aaa + - user group + # END PATH LIST + data: + description: + - Data to ensure that is present for this path. + - Fields not provided will not be modified. + - If C(.id) appears in an entry, it will be ignored. + required: true + type: list + elements: dict + ensure_order: + description: + - Whether to ensure the same order of the config as present in I(data). + - Requires I(handle_absent_entries=remove). + type: bool + default: false + handle_absent_entries: + description: + - How to handle entries that are present in the current config, but not in I(data). + - C(ignore) ignores them. + - C(remove) removes them. + type: str + choices: + - ignore + - remove + default: ignore + handle_entries_content: + description: + - For a single entry in I(data), this describes how to handle fields that are not mentioned + in that entry, but appear in the actual config. + - If C(ignore), they are not modified. + - If C(remove), they are removed. If at least one cannot be removed, the module will fail. + - If C(remove_as_much_as_possible), all that can be removed will be removed. The ones that + cannot be removed will be kept. + type: str + choices: + - ignore + - remove + - remove_as_much_as_possible + default: ignore +seealso: + - module: community.routeros.api + - module: community.routeros.api_facts + - module: community.routeros.api_find_and_modify + - module: community.routeros.api_info +''' + +EXAMPLES = ''' +--- +- name: Setup DHCP server networks + # Ensures that we have exactly two DHCP server networks (in the specified order) + community.routeros.api_modify: + path: ip dhcp-server network + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + ensure_order: true + data: + - address: 192.168.88.0/24 + comment: admin network + dns-server: 192.168.88.1 + gateway: 192.168.88.1 + - address: 192.168.1.0/24 + comment: customer network 1 + dns-server: 192.168.1.1 + gateway: 192.168.1.1 + netmask: 24 + +- name: Adjust NAT + community.routeros.api_modify: + hostname: "{{ hostname }}" + password: "{{ password }}" + username: "{{ username }}" + path: ip firewall nat + data: + - action: masquerade + chain: srcnat + comment: NAT to WAN + out-interface-list: WAN + # Three ways to unset values: + # - nothing after `:` + # - "empty" value (null/~/None) + # - prepend '!' + out-interface: + to-addresses: ~ + '!to-ports': +''' + +RETURN = ''' +--- +old_data: + description: + - A list of all elements for the current path before a change was made. + sample: + - '.id': '*1' + actual-interface: bridge + address: "192.168.88.1/24" + comment: defconf + disabled: false + dynamic: false + interface: bridge + invalid: false + network: 192.168.88.0 + type: list + elements: dict + returned: always +new_data: + description: + - A list of all elements for the current path after a change was made. + sample: + - '.id': '*1' + actual-interface: bridge + address: "192.168.1.1/24" + comment: awesome + disabled: false + dynamic: false + interface: bridge + invalid: false + network: 192.168.1.0 + type: list + elements: dict + returned: always +''' + +from collections import defaultdict + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +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, +) + +from ansible_collections.community.routeros.plugins.module_utils._api_data import ( + PATHS, + join_path, + split_path, +) + +HAS_ORDEREDDICT = True +try: + from collections import OrderedDict +except ImportError: + try: + from ordereddict import OrderedDict + except ImportError: + HAS_ORDEREDDICT = False + OrderedDict = dict + +try: + from librouteros.exceptions import LibRouterosError +except Exception: + # Handled in api module_utils + pass + + +def compose_api_path(api, path): + api_path = api.path() + for p in path: + api_path = api_path.join(p) + return api_path + + +def find_modifications(old_entry, new_entry, path_info, module, for_text='', return_none_instead_of_fail=False): + modifications = OrderedDict() + updated_entry = old_entry.copy() + for k, v in new_entry.items(): + if k == '.id': + continue + disabled_k = None + if k.startswith('!'): + disabled_k = k[1:] + elif v is None or v == path_info.fields[k].remove_value: + disabled_k = k + if disabled_k is not None: + if disabled_k in old_entry: + if path_info.fields[disabled_k].remove_value is not None: + modifications[disabled_k] = path_info.fields[disabled_k].remove_value + else: + modifications['!%s' % disabled_k] = '' + del updated_entry[disabled_k] + continue + if k not in old_entry and path_info.fields[k].default == v and not path_info.fields[k].can_disable: + continue + if k not in old_entry or old_entry[k] != v: + modifications[k] = v + updated_entry[k] = v + handle_entries_content = module.params['handle_entries_content'] + if handle_entries_content != 'ignore': + for k in old_entry: + if k == '.id' or k in new_entry or ('!%s' % k) in new_entry or k not in path_info.fields: + continue + field_info = path_info.fields[k] + if field_info.default is not None and field_info.default == old_entry[k]: + continue + if field_info.remove_value is not None and field_info.remove_value == old_entry[k]: + continue + if field_info.can_disable: + if field_info.default is not None: + modifications[k] = field_info.default + elif field_info.remove_value is not None: + modifications[k] = field_info.remove_value + else: + modifications['!%s' % k] = '' + del updated_entry[k] + elif field_info.default is not None: + modifications[k] = field_info.default + updated_entry[k] = field_info.default + elif handle_entries_content == 'remove': + if return_none_instead_of_fail: + return None, None + module.fail_json(msg='Key "{key}" cannot be removed{for_text}.'.format(key=k, for_text=for_text)) + for k in path_info.fields: + field_info = path_info.fields[k] + if k not in old_entry and k not in new_entry and field_info.can_disable and field_info.default is not None: + modifications[k] = field_info.default + updated_entry[k] = field_info.default + return modifications, updated_entry + + +def essentially_same_weight(old_entry, new_entry, path_info, module): + for k, v in new_entry.items(): + if k == '.id': + continue + disabled_k = None + if k.startswith('!'): + disabled_k = k[1:] + elif v is None or v == path_info.fields[k].remove_value: + disabled_k = k + if disabled_k is not None: + if disabled_k in old_entry: + return None + continue + if k not in old_entry and path_info.fields[k].default == v: + continue + if k not in old_entry or old_entry[k] != v: + return None + handle_entries_content = module.params['handle_entries_content'] + weight = 0 + for k in old_entry: + if k == '.id' or k in new_entry or ('!%s' % k) in new_entry or k not in path_info.fields: + continue + field_info = path_info.fields[k] + if field_info.default is not None and field_info.default == old_entry[k]: + continue + if handle_entries_content != 'ignore': + return None + else: + weight += 1 + return weight + + +def format_pk(primary_keys, values): + return ', '.join('{pk}="{value}"'.format(pk=pk, value=value) for pk, value in zip(primary_keys, values)) + + +def polish_entry(entry, path_info, module, for_text): + if '.id' in entry: + entry.pop('.id') + for key, value in entry.items(): + real_key = key + disabled_key = False + if key.startswith('!'): + disabled_key = True + key = key[1:] + if key in entry: + module.fail_json(msg='Not both "{key}" and "!{key}" must appear{for_text}.'.format(key=key, for_text=for_text)) + key_info = path_info.fields.get(key) + if key_info is None: + module.fail_json(msg='Unknown key "{key}"{for_text}.'.format(key=real_key, for_text=for_text)) + if disabled_key: + if not key_info.can_disable: + module.fail_json(msg='Key "!{key}" must not be disabled (leading "!"){for_text}.'.format(key=key, for_text=for_text)) + if value not in (None, '', key_info.remove_value): + module.fail_json(msg='Disabled key "!{key}" must not have a value{for_text}.'.format(key=key, for_text=for_text)) + elif value is None: + if not key_info.can_disable: + module.fail_json(msg='Key "{key}" must not be disabled (value null/~/None){for_text}.'.format(key=key, for_text=for_text)) + for key, field_info in path_info.fields.items(): + if field_info.required and key not in entry: + module.fail_json(msg='Key "{key}" must be present{for_text}.'.format(key=key, for_text=for_text)) + for require_list in path_info.required_one_of: + found_req_keys = [rk for rk in require_list if rk in entry] + if len(require_list) > 0 and not found_req_keys: + module.fail_json( + msg='Every element in data must contain one of {required_keys}. For example, the element{for_text} does not provide it.'.format( + required_keys=', '.join(['"{k}"'.format(k=k) for k in require_list]), + for_text=for_text, + ) + ) + for exclusive_list in path_info.mutually_exclusive: + found_ex_keys = [ek for ek in exclusive_list if ek in entry] + if len(found_ex_keys) > 1: + module.fail_json( + msg='Keys {exclusive_keys} cannot be used at the same time{for_text}.'.format( + exclusive_keys=', '.join(['"{k}"'.format(k=k) for k in found_ex_keys]), + for_text=for_text, + ) + ) + + +def remove_irrelevant_data(entry, path_info): + for k, v in list(entry.items()): + if k == '.id': + continue + if k not in path_info.fields or v is None: + del entry[k] + + +def match_entries(new_entries, old_entries, path_info, module): + matching_old_entries = [None for entry in new_entries] + old_entries = list(old_entries) + matches = [] + handle_absent_entries = module.params['handle_absent_entries'] + if handle_absent_entries == 'remove': + for new_index, (unused, new_entry) in enumerate(new_entries): + for old_index, (unused, old_entry) in enumerate(old_entries): + modifications, unused = find_modifications(old_entry, new_entry, path_info, module, return_none_instead_of_fail=True) + if modifications is not None: + matches.append((new_index, old_index, len(modifications))) + else: + for new_index, (unused, new_entry) in enumerate(new_entries): + for old_index, (unused, old_entry) in enumerate(old_entries): + weight = essentially_same_weight(old_entry, new_entry, path_info, module) + if weight is not None: + matches.append((new_index, old_index, weight)) + matches.sort(key=lambda entry: entry[2]) + for new_index, old_index, rating in matches: + if matching_old_entries[new_index] is not None or old_entries[old_index] is None: + continue + matching_old_entries[new_index], old_entries[old_index] = old_entries[old_index], None + unmatched_old_entries = [index_entry for index_entry in old_entries if index_entry is not None] + return matching_old_entries, unmatched_old_entries + + +def remove_dynamic(entries): + result = [] + for entry in entries: + if entry.get('dynamic', False) or entry.get('builtin', False): + continue + result.append(entry) + return result + + +def get_api_data(api_path, path_info): + entries = list(api_path) + for entry in entries: + for k, field_info in path_info.fields.items(): + if field_info.absent_value is not None and k not in entry: + entry[k] = field_info.absent_value + return entries + + +def prepare_for_add(entry, path_info): + new_entry = {} + for k, v in entry.items(): + if k.startswith('!'): + real_k = k[1:] + remove_value = path_info.fields[real_k].remove_value + if remove_value is not None: + k = real_k + v = remove_value + else: + if v is None: + v = path_info.fields[k].remove_value + new_entry[k] = v + return new_entry + + +def sync_list(module, api, path, path_info): + handle_absent_entries = module.params['handle_absent_entries'] + handle_entries_content = module.params['handle_entries_content'] + if handle_absent_entries == 'remove': + if handle_entries_content == 'ignore': + module.fail_json('For this path, handle_absent_entries=remove cannot be combined with handle_entries_content=ignore') + + stratify_keys = path_info.stratify_keys or () + + data = module.params['data'] + stratified_data = defaultdict(list) + for index, entry in enumerate(data): + for stratify_key in stratify_keys: + if stratify_key not in entry: + module.fail_json( + msg='Every element in data must contain "{stratify_key}". For example, the element at index #{index} does not provide it.'.format( + stratify_key=stratify_key, + index=index + 1, + ) + ) + sks = tuple(entry[stratify_key] for stratify_key in stratify_keys) + polish_entry( + entry, path_info, module, + ' at index {index}'.format(index=index + 1), + ) + stratified_data[sks].append((index, entry)) + stratified_data = dict(stratified_data) + + api_path = compose_api_path(api, path) + + old_data = get_api_data(api_path, path_info) + old_data = remove_dynamic(old_data) + stratified_old_data = defaultdict(list) + for index, entry in enumerate(old_data): + sks = tuple(entry[stratify_key] for stratify_key in stratify_keys) + stratified_old_data[sks].append((index, entry)) + stratified_old_data = dict(stratified_old_data) + + create_list = [] + modify_list = [] + remove_list = [] + + new_data = [] + for key, indexed_entries in stratified_data.items(): + old_entries = stratified_old_data.pop(key, []) + + # Try to match indexed_entries with old_entries + matching_old_entries, unmatched_old_entries = match_entries(indexed_entries, old_entries, path_info, module) + + # Update existing entries + for (index, new_entry), potential_old_entry in zip(indexed_entries, matching_old_entries): + if potential_old_entry is not None: + old_index, old_entry = potential_old_entry + modifications, updated_entry = find_modifications( + old_entry, new_entry, path_info, module, + ' at index {index}'.format(index=index + 1), + ) + # Add to modification list if there are changes + if modifications: + modifications['.id'] = old_entry['.id'] + modify_list.append(modifications) + new_data.append((old_index, updated_entry)) + new_entry['.id'] = old_entry['.id'] + else: + create_list.append(new_entry) + + if handle_absent_entries == 'remove': + remove_list.extend(entry['.id'] for index, entry in unmatched_old_entries) + else: + new_data.extend(unmatched_old_entries) + + for key, entries in stratified_old_data.items(): + if handle_absent_entries == 'remove': + remove_list.extend(entry['.id'] for index, entry in entries) + else: + new_data.extend(entries) + + new_data = [entry for index, entry in sorted(new_data, key=lambda entry: entry[0])] + new_data.extend(create_list) + + reorder_list = [] + if module.params['ensure_order']: + for index, entry in enumerate(data): + if '.id' in entry: + def match(current_entry): + return current_entry['.id'] == entry['.id'] + + else: + def match(current_entry): + return current_entry is entry + + current_index = next(current_index + index for current_index, current_entry in enumerate(new_data[index:]) if match(current_entry)) + if current_index != index: + reorder_list.append((index, new_data[current_index], new_data[index])) + new_data.insert(index, new_data.pop(current_index)) + + if not module.check_mode: + if remove_list: + try: + api_path.remove(*remove_list) + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json( + msg='Error while removing {remove_list}: {error}'.format( + remove_list=', '.join(['ID {id}'.format(id=id) for id in remove_list]), + error=to_native(e), + ) + ) + for modifications in modify_list: + try: + api_path.update(**modifications) + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json( + msg='Error while modifying for ID {id}: {error}'.format( + id=modifications['.id'], + error=to_native(e), + ) + ) + for entry in create_list: + try: + entry['.id'] = api_path.add(**prepare_for_add(entry, path_info)) + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json( + msg='Error while creating entry: {error}'.format( + error=to_native(e), + ) + ) + for new_index, new_entry, old_entry in reorder_list: + try: + for res in api_path('move', numbers=new_entry['.id'], destination=old_entry['.id']): + pass + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json( + msg='Error while moving entry ID {element_id} to position #{new_index} ID ({new_id}): {error}'.format( + element_id=new_entry['.id'], + new_index=new_index, + new_id=old_entry['.id'], + error=to_native(e), + ) + ) + + # For sake of completeness, retrieve the full new data: + if modify_list or create_list or reorder_list: + new_data = remove_dynamic(get_api_data(api_path, path_info)) + + # Remove 'irrelevant' data + for entry in old_data: + remove_irrelevant_data(entry, path_info) + for entry in new_data: + remove_irrelevant_data(entry, path_info) + + # Produce return value + more = {} + if module._diff: + more['diff'] = { + 'before': { + 'data': old_data, + }, + 'after': { + 'data': new_data, + }, + } + module.exit_json( + changed=bool(create_list or modify_list or remove_list or reorder_list), + old_data=old_data, + new_data=new_data, + **more + ) + + +def sync_with_primary_keys(module, api, path, path_info): + primary_keys = path_info.primary_keys + + if path_info.fixed_entries: + if module.params['ensure_order']: + module.fail_json(msg='ensure_order=true cannot be used with this path') + if module.params['handle_absent_entries'] == 'remove': + module.fail_json(msg='handle_absent_entries=remove cannot be used with this path') + + data = module.params['data'] + new_data_by_key = OrderedDict() + for index, entry in enumerate(data): + for primary_key in primary_keys: + if primary_key not in entry: + module.fail_json( + msg='Every element in data must contain "{primary_key}". For example, the element at index #{index} does not provide it.'.format( + primary_key=primary_key, + index=index + 1, + ) + ) + pks = tuple(entry[primary_key] for primary_key in primary_keys) + if pks in new_data_by_key: + module.fail_json( + msg='Every element in data must contain a unique value for {primary_keys}. The value {value} appears at least twice.'.format( + primary_keys=','.join(primary_keys), + value=','.join(['"{0}"'.format(pk) for pk in pks]), + ) + ) + polish_entry( + entry, path_info, module, + ' for {values}'.format( + values=', '.join([ + '{primary_key}="{value}"'.format(primary_key=primary_key, value=value) + for primary_key, value in zip(primary_keys, pks) + ]) + ), + ) + new_data_by_key[pks] = entry + + api_path = compose_api_path(api, path) + + old_data = get_api_data(api_path, path_info) + old_data = remove_dynamic(old_data) + old_data_by_key = OrderedDict() + id_by_key = {} + for entry in old_data: + pks = tuple(entry[primary_key] for primary_key in primary_keys) + old_data_by_key[pks] = entry + id_by_key[pks] = entry['.id'] + new_data = [] + + create_list = [] + modify_list = [] + remove_list = [] + remove_keys = [] + handle_absent_entries = module.params['handle_absent_entries'] + for key, old_entry in old_data_by_key.items(): + new_entry = new_data_by_key.pop(key, None) + if new_entry is None: + if handle_absent_entries == 'remove': + remove_list.append(old_entry['.id']) + remove_keys.append(key) + else: + new_data.append(old_entry) + else: + modifications, updated_entry = find_modifications( + old_entry, new_entry, path_info, module, + ' for {values}'.format( + values=', '.join([ + '{primary_key}="{value}"'.format(primary_key=primary_key, value=value) + for primary_key, value in zip(primary_keys, key) + ]) + ) + ) + new_data.append(updated_entry) + # Add to modification list if there are changes + if modifications: + modifications['.id'] = old_entry['.id'] + modify_list.append((key, modifications)) + for new_entry in new_data_by_key.values(): + if path_info.fixed_entries: + module.fail_json(msg='Cannot add new entry {values} to this path'.format( + values=', '.join([ + '{primary_key}="{value}"'.format(primary_key=primary_key, value=new_entry[primary_key]) + for primary_key in primary_keys + ]), + )) + create_list.append(new_entry) + new_entry = new_entry.copy() + for key in list(new_entry): + if key.startswith('!'): + new_entry.pop(key) + new_data.append(new_entry) + + reorder_list = [] + if module.params['ensure_order']: + index_by_key = dict() + for index, entry in enumerate(new_data): + index_by_key[tuple(entry[primary_key] for primary_key in primary_keys)] = index + for index, source_entry in enumerate(data): + source_pks = tuple(source_entry[primary_key] for primary_key in primary_keys) + source_index = index_by_key.pop(source_pks) + if index == source_index: + continue + entry = new_data[index] + pks = tuple(entry[primary_key] for primary_key in primary_keys) + reorder_list.append((source_pks, index, pks)) + for k, v in index_by_key.items(): + if v >= index and v < source_index: + index_by_key[k] = v + 1 + new_data.insert(index, new_data.pop(source_index)) + + if not module.check_mode: + if remove_list: + try: + api_path.remove(*remove_list) + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json( + msg='Error while removing {remove_list}: {error}'.format( + remove_list=', '.join([ + '{identifier} (ID {id})'.format(identifier=format_pk(primary_keys, key), id=id) + for id, key in zip(remove_list, remove_keys) + ]), + error=to_native(e), + ) + ) + for key, modifications in modify_list: + try: + api_path.update(**modifications) + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json( + msg='Error while modifying for {identifier} (ID {id}): {error}'.format( + identifier=format_pk(primary_keys, key), + id=modifications['.id'], + error=to_native(e), + ) + ) + for entry in create_list: + try: + entry['.id'] = api_path.add(**prepare_for_add(entry, path_info)) + # Store ID for primary keys + pks = tuple(entry[primary_key] for primary_key in primary_keys) + id_by_key[pks] = entry['.id'] + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json( + msg='Error while creating entry for {identifier}: {error}'.format( + identifier=format_pk(primary_keys, [entry[pk] for pk in primary_keys]), + error=to_native(e), + ) + ) + for element_pks, new_index, new_pks in reorder_list: + try: + element_id = id_by_key[element_pks] + new_id = id_by_key[new_pks] + for res in api_path('move', numbers=element_id, destination=new_id): + pass + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json( + msg='Error while moving entry ID {element_id} to position of ID {new_id}: {error}'.format( + element_id=element_id, + new_id=new_id, + error=to_native(e), + ) + ) + + # For sake of completeness, retrieve the full new data: + if modify_list or create_list or reorder_list: + new_data = remove_dynamic(get_api_data(api_path, path_info)) + + # Remove 'irrelevant' data + for entry in old_data: + remove_irrelevant_data(entry, path_info) + for entry in new_data: + remove_irrelevant_data(entry, path_info) + + # Produce return value + more = {} + if module._diff: + more['diff'] = { + 'before': { + 'data': old_data, + }, + 'after': { + 'data': new_data, + }, + } + module.exit_json( + changed=bool(create_list or modify_list or remove_list or reorder_list), + old_data=old_data, + new_data=new_data, + **more + ) + + +def sync_single_value(module, api, path, path_info): + data = module.params['data'] + if len(data) != 1: + module.fail_json(msg='Data must be a list with exactly one element.') + new_entry = data[0] + polish_entry(new_entry, path_info, module, '') + + api_path = compose_api_path(api, path) + + old_data = get_api_data(api_path, path_info) + if len(old_data) != 1: + module.fail_json( + msg='Internal error: retrieving /{path} resulted in {count} elements. Expected exactly 1.'.format( + path=join_path(path), + count=len(old_data) + ) + ) + old_entry = old_data[0] + + # Determine modifications + modifications, updated_entry = find_modifications(old_entry, new_entry, path_info, module, '') + # Do modifications + if modifications: + if not module.check_mode: + # Actually do modification + try: + api_path.update(**modifications) + except (LibRouterosError, UnicodeEncodeError) as e: + module.fail_json(msg='Error while modifying: {error}'.format(error=to_native(e))) + # Retrieve latest version + new_data = get_api_data(api_path, path_info) + if len(new_data) == 1: + updated_entry = new_data[0] + + # Remove 'irrelevant' data + remove_irrelevant_data(old_entry, path_info) + remove_irrelevant_data(updated_entry, path_info) + + # Produce return value + more = {} + if module._diff: + more['diff'] = { + 'before': old_entry, + 'after': updated_entry, + } + module.exit_json( + changed=bool(modifications), + old_data=[old_entry], + new_data=[updated_entry], + **more + ) + + +def get_backend(path_info): + if path_info is None: + return None + if not path_info.fully_understood: + return None + + if path_info.primary_keys: + return sync_with_primary_keys + + if path_info.single_value: + return sync_single_value + + if not path_info.has_identifier: + return sync_list + + return None + + +def main(): + path_choices = sorted([join_path(path) for path, path_info in PATHS.items() if get_backend(path_info) is not None]) + module_args = dict( + path=dict(type='str', required=True, choices=path_choices), + data=dict(type='list', elements='dict', required=True), + handle_absent_entries=dict(type='str', choices=['ignore', 'remove'], default='ignore'), + handle_entries_content=dict(type='str', choices=['ignore', 'remove', 'remove_as_much_as_possible'], default='ignore'), + ensure_order=dict(type='bool', default=False), + ) + module_args.update(api_argument_spec()) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + if module.params['ensure_order'] and module.params['handle_absent_entries'] == 'ignore': + module.fail_json(msg='ensure_order=true requires handle_absent_entries=remove') + + if not HAS_ORDEREDDICT: + # This should never happen for Python 2.7+ + module.fail_json(msg=missing_required_lib('ordereddict')) + + check_has_library(module) + api = create_api(module) + + path = split_path(module.params['path']) + path_info = PATHS.get(tuple(path)) + backend = get_backend(path_info) + if path_info is None or backend is None: + module.fail_json(msg='Path /{path} is not yet supported'.format(path='/'.join(path))) + + backend(module, api, path, path_info) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/routeros/plugins/modules/command.py b/ansible_collections/community/routeros/plugins/modules/command.py new file mode 100644 index 000000000..84426025c --- /dev/null +++ b/ansible_collections/community/routeros/plugins/modules/command.py @@ -0,0 +1,210 @@ +#!/usr/bin/python + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: command +author: "Egor Zaitsev (@heuels)" +short_description: Run commands on remote devices running MikroTik RouterOS +description: + - Sends arbitrary commands to an RouterOS node and returns the results + read from the device. This module includes an + argument that will cause the module to wait for a specific condition + before returning or timing out if the condition is not met. + - The module always indicates a (changed) status. You can use + R(the changed_when task property,override_the_changed_result) to determine + whether a command task actually resulted in a change or not. +notes: + - The module declares that it B(supports check mode). This is a bug and will + be changed in community.routeros 3.0.0. +extends_documentation_fragment: + - community.routeros.attributes +attributes: + check_mode: + support: partial + details: + - The module claims to support check mode, but it simply always executes the command. + diff_mode: + support: none + platform: + support: full + platforms: RouterOS +options: + commands: + description: + - List of commands to send to the remote RouterOS device over the + configured provider. The resulting output from the command + is returned. If the I(wait_for) argument is provided, the + module is not returned until the condition is satisfied or + the number of retries has expired. + required: true + type: list + elements: str + wait_for: + description: + - List of conditions to evaluate against the output of the + command. The task will wait for each condition to be true + before moving forward. If the conditional is not true + within the configured number of retries, the task fails. + See examples. + type: list + elements: str + match: + description: + - The I(match) argument is used in conjunction with the + I(wait_for) argument to specify the match policy. Valid + values are C(all) or C(any). If the value is set to C(all) + then all conditionals in the wait_for must be satisfied. If + the value is set to C(any) then only one of the values must be + satisfied. + default: all + choices: ['any', 'all'] + type: str + retries: + description: + - Specifies the number of retries a command should by tried + before it is considered failed. The command is run on the + target device every retry and evaluated against the + I(wait_for) conditions. + default: 10 + type: int + interval: + description: + - Configures the interval in seconds to wait between retries + of the command. If the command does not pass the specified + conditions, the interval indicates how long to wait before + trying the command again. + default: 1 + type: int +seealso: + - ref: ansible_collections.community.routeros.docsite.ssh-guide + description: How to connect to RouterOS devices with SSH + - ref: ansible_collections.community.routeros.docsite.quoting + description: How to quote and unquote commands and arguments +''' + +EXAMPLES = """ +- name: Run command on remote devices + community.routeros.command: + commands: /system routerboard print + +- name: Run command and check to see if output contains routeros + community.routeros.command: + commands: /system resource print + wait_for: result[0] contains MikroTik + +- name: Run multiple commands on remote nodes + community.routeros.command: + commands: + - /system routerboard print + - /system identity print + +- name: Run multiple commands and evaluate the output + community.routeros.command: + commands: + - /system routerboard print + - /interface ethernet print + wait_for: + - result[0] contains x86 + - result[1] contains ether1 +""" + +RETURN = """ +stdout: + description: The set of responses from the commands + returned: always apart from low level errors (such as action plugin) + type: list + sample: ['...', '...'] +stdout_lines: + description: The value of stdout split into a list + returned: always apart from low level errors (such as action plugin) + type: list + sample: [['...', '...'], ['...'], ['...']] +failed_conditions: + description: The list of conditionals that have failed + returned: failed + type: list + sample: ['...', '...'] +""" + +import time + +from ansible_collections.community.routeros.plugins.module_utils.routeros import run_commands +from ansible_collections.community.routeros.plugins.module_utils.routeros import routeros_argument_spec +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.parsing import Conditional +from ansible.module_utils.six import string_types + + +def to_lines(stdout): + for item in stdout: + if isinstance(item, string_types): + item = str(item).split('\n') + yield item + + +def main(): + """main entry point for module execution + """ + argument_spec = dict( + commands=dict(type='list', elements='str', required=True), + + wait_for=dict(type='list', elements='str'), + match=dict(type='str', default='all', choices=['all', 'any']), + + retries=dict(default=10, type='int'), + interval=dict(default=1, type='int') + ) + + argument_spec.update(routeros_argument_spec) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + + result = {'changed': False} + + wait_for = module.params['wait_for'] or list() + conditionals = [Conditional(c) for c in wait_for] + + retries = module.params['retries'] + interval = module.params['interval'] + match = module.params['match'] + + while retries > 0: + responses = run_commands(module, module.params['commands']) + + for item in list(conditionals): + if item(responses): + if match == 'any': + conditionals = list() + break + conditionals.remove(item) + + if not conditionals: + break + + time.sleep(interval) + retries -= 1 + + if conditionals: + failed_conditions = [item.raw for item in conditionals] + msg = 'One or more conditional statements have not been satisfied' + module.fail_json(msg=msg, failed_conditions=failed_conditions) + + result.update({ + 'changed': True, + 'stdout': responses, + 'stdout_lines': list(to_lines(responses)) + }) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/routeros/plugins/modules/facts.py b/ansible_collections/community/routeros/plugins/modules/facts.py new file mode 100644 index 000000000..85c0b37c4 --- /dev/null +++ b/ansible_collections/community/routeros/plugins/modules/facts.py @@ -0,0 +1,663 @@ +#!/usr/bin/python + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: facts +author: "Egor Zaitsev (@heuels)" +short_description: Collect facts from remote devices running MikroTik RouterOS +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_<fact>). The facts + module will always collect a base set of facts from the device + and can enable or disable collection of additional facts. +extends_documentation_fragment: + - community.routeros.attributes + - community.routeros.attributes.facts + - community.routeros.attributes.facts_module +attributes: + platform: + support: full + platforms: RouterOS +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(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 + elements: str +seealso: + - ref: ansible_collections.community.routeros.docsite.ssh-guide + description: How to connect to RouterOS devices with SSH +''' + +EXAMPLES = """ +- name: Collect all facts from the device + community.routeros.facts: + gather_subset: all + +- name: Collect only the config and default facts + community.routeros.facts: + gather_subset: + - config + +- name: Do not collect hardware facts + community.routeros.facts: + 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 + + # config + ansible_net_config: + description: The current active config from the device. + returned: I(gather_subset) contains C(config) + type: str + + ansible_net_config_nonverbose: + description: + - The current active config from the device in minimal form. + - 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: 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: 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_collections.community.routeros.plugins.module_utils.routeros import run_commands +from ansible_collections.community.routeros.plugins.module_utils.routeros import routeros_argument_spec +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import iteritems + + +class FactsBase(object): + + COMMANDS = list() + + def __init__(self, module): + self.module = module + self.facts = dict() + self.responses = None + + def populate(self): + self.responses = run_commands(self.module, commands=self.COMMANDS, check_rc=False) + + def run(self, cmd): + return run_commands(self.module, commands=cmd, check_rc=False) + + +class Default(FactsBase): + + COMMANDS = [ + '/system identity print without-paging', + '/system resource print without-paging', + '/system routerboard print without-paging' + ] + + def populate(self): + super(Default, self).populate() + data = self.responses[0] + if data: + self.facts['hostname'] = self.parse_hostname(data) + data = self.responses[1] + if data: + self.facts['version'] = self.parse_version(data) + self.facts['arch'] = self.parse_arch(data) + self.facts['uptime'] = self.parse_uptime(data) + self.facts['cpu_load'] = self.parse_cpu_load(data) + data = self.responses[2] + if data: + self.facts['model'] = self.parse_model(data) + self.facts['serialnum'] = self.parse_serialnum(data) + + def parse_hostname(self, data): + match = re.search(r'name:\s(.*)\s*$', data, re.M) + if match: + return match.group(1) + + def parse_version(self, data): + match = re.search(r'version:\s(.*)\s*$', data, re.M) + if match: + return match.group(1) + + def parse_model(self, data): + match = re.search(r'model:\s(.*)\s*$', data, re.M) + if match: + return match.group(1) + + def parse_arch(self, data): + match = re.search(r'architecture-name:\s(.*)\s*$', data, re.M) + if match: + return match.group(1) + + def parse_uptime(self, data): + match = re.search(r'uptime:\s(.*)\s*$', data, re.M) + if match: + return match.group(1) + + def parse_cpu_load(self, data): + match = re.search(r'cpu-load:\s(.*)\s*$', data, re.M) + if match: + return match.group(1) + + def parse_serialnum(self, data): + match = re.search(r'serial-number:\s(.*)\s*$', data, re.M) + if match: + return match.group(1) + + +class Hardware(FactsBase): + + COMMANDS = [ + '/system resource print without-paging' + ] + + def populate(self): + super(Hardware, self).populate() + data = self.responses[0] + if data: + self.parse_filesystem_info(data) + self.parse_memory_info(data) + + def parse_filesystem_info(self, data): + match = re.search(r'free-hdd-space:\s(.*)([KMG]iB)', data, re.M) + if match: + self.facts['spacefree_mb'] = self.to_megabytes(match) + match = re.search(r'total-hdd-space:\s(.*)([KMG]iB)', data, re.M) + if match: + self.facts['spacetotal_mb'] = self.to_megabytes(match) + + def parse_memory_info(self, data): + match = re.search(r'free-memory:\s(\d+\.?\d*)([KMG]iB)', data, re.M) + if match: + self.facts['memfree_mb'] = self.to_megabytes(match) + match = re.search(r'total-memory:\s(\d+\.?\d*)([KMG]iB)', data, re.M) + if match: + self.facts['memtotal_mb'] = self.to_megabytes(match) + + def to_megabytes(self, data): + if data.group(2) == 'KiB': + return float(data.group(1)) / 1024 + elif data.group(2) == 'MiB': + return float(data.group(1)) + elif data.group(2) == 'GiB': + return float(data.group(1)) * 1024 + else: + return None + + +class Config(FactsBase): + + COMMANDS = [ + '/export verbose', + '/export', + ] + + RM_DATE_RE = re.compile(r'^# [a-z0-9/][a-z0-9/]* [0-9:]* by RouterOS') + + def populate(self): + super(Config, self).populate() + + data = self.responses[0] + if data: + self.facts['config'] = data + + data = self.responses[1] + if data: + # remove datetime + data = re.sub(self.RM_DATE_RE, r'# RouterOS', data) + self.facts['config_nonverbose'] = data + + +class Interfaces(FactsBase): + + COMMANDS = [ + '/interface print detail without-paging', + '/ip address print detail without-paging', + '/ipv6 address print detail without-paging', + '/ip neighbor print detail without-paging' + ] + + DETAIL_RE = re.compile(r'([\w\d\-]+)=\"?(\w{3}/\d{2}/\d{4}\s\d{2}:\d{2}:\d{2}|[\w\d\-\.:/]+)') + WRAPPED_LINE_RE = re.compile(r'^\s+(?!\d)') + + def populate(self): + super(Interfaces, self).populate() + + self.facts['interfaces'] = dict() + self.facts['all_ipv4_addresses'] = list() + self.facts['all_ipv6_addresses'] = list() + self.facts['neighbors'] = list() + + 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] = list() + addr, subnet = value['address'].split("/") + ip = dict(address=addr.strip(), subnet=subnet.strip()) + 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 preprocess(self, data): + preprocessed = list() + for line in data.split('\n'): + if len(line) == 0 or line[:5] == 'Flags': + continue + elif not preprocessed or not re.match(self.WRAPPED_LINE_RE, line): + preprocessed.append(line) + else: + preprocessed[-1] += line + return preprocessed + + def parse_interfaces(self, data): + facts = dict() + data = self.preprocess(data) + for line in data: + parsed = dict(re.findall(self.DETAIL_RE, line)) + if "name" not in parsed: + continue + facts[parsed["name"]] = dict(re.findall(self.DETAIL_RE, line)) + return facts + + def parse_detail(self, data): + data = self.preprocess(data) + for line in data: + parsed = dict(re.findall(self.DETAIL_RE, line)) + if "interface" not in parsed: + continue + yield parsed + + +class Routing(FactsBase): + + COMMANDS = [ + '/routing bgp peer print detail without-paging', + '/routing bgp vpnv4-route print detail without-paging', + '/routing bgp instance print detail without-paging', + '/ip route print detail without-paging', + '/routing ospf instance print detail without-paging', + '/routing ospf neighbor print detail without-paging' + ] + + DETAIL_RE = re.compile(r'([\w\d\-]+)=\"?(\w{3}/\d{2}/\d{4}\s\d{2}:\d{2}:\d{2}|[\w\d\-\.:/]+)') + WRAPPED_LINE_RE = re.compile(r'^\s+(?!\d)') + + def populate(self): + super(Routing, self).populate() + self.facts['bgp_peer'] = dict() + self.facts['bgp_vpnv4_route'] = dict() + self.facts['bgp_instance'] = dict() + self.facts['route'] = dict() + self.facts['ospf_instance'] = dict() + self.facts['ospf_neighbor'] = dict() + data = self.responses[0] + if data: + peer = self.parse_bgp_peer(data) + self.populate_bgp_peer(peer) + data = self.responses[1] + if data: + vpnv4 = self.parse_vpnv4_route(data) + self.populate_vpnv4_route(vpnv4) + data = self.responses[2] + if data: + instance = self.parse_instance(data) + self.populate_bgp_instance(instance) + data = self.responses[3] + if data: + route = self.parse_route(data) + self.populate_route(route) + data = self.responses[4] + if data: + instance = self.parse_instance(data) + self.populate_ospf_instance(instance) + data = self.responses[5] + if data: + instance = self.parse_ospf_neighbor(data) + self.populate_ospf_neighbor(instance) + + def preprocess(self, data): + preprocessed = list() + for line in data.split('\n'): + if len(line) == 0 or line[:5] == 'Flags': + continue + elif not preprocessed or not re.match(self.WRAPPED_LINE_RE, line): + preprocessed.append(line) + else: + preprocessed[-1] += line + return preprocessed + + def parse_name(self, data): + match = re.search(r'name=.(\S+\b)', data, re.M) + if match: + return match.group(1) + + def parse_interface(self, data): + match = re.search(r'interface=([\w\d\-]+)', data, re.M) + if match: + return match.group(1) + + def parse_instance_name(self, data): + match = re.search(r'instance=([\w\d\-]+)', data, re.M) + if match: + return match.group(1) + + def parse_routing_mark(self, data): + match = re.search(r'routing-mark=([\w\d\-]+)', data, re.M) + if match: + return match.group(1) + else: + match = 'main' + return match + + def parse_bgp_peer(self, data): + facts = dict() + data = self.preprocess(data) + for line in data: + name = self.parse_name(line) + facts[name] = dict() + for (key, value) in re.findall(self.DETAIL_RE, line): + facts[name][key] = value + return facts + + def parse_instance(self, data): + facts = dict() + data = self.preprocess(data) + for line in data: + name = self.parse_name(line) + facts[name] = dict() + for (key, value) in re.findall(self.DETAIL_RE, line): + facts[name][key] = value + return facts + + def parse_vpnv4_route(self, data): + facts = dict() + data = self.preprocess(data) + for line in data: + name = self.parse_interface(line) + facts[name] = dict() + for (key, value) in re.findall(self.DETAIL_RE, line): + facts[name][key] = value + return facts + + def parse_route(self, data): + facts = dict() + data = self.preprocess(data) + for line in data: + name = self.parse_routing_mark(line) + facts[name] = dict() + for (key, value) in re.findall(self.DETAIL_RE, line): + facts[name][key] = value + return facts + + def parse_ospf_instance(self, data): + facts = dict() + data = self.preprocess(data) + for line in data: + name = self.parse_name(line) + facts[name] = dict() + for (key, value) in re.findall(self.DETAIL_RE, line): + facts[name][key] = value + return facts + + def parse_ospf_neighbor(self, data): + facts = dict() + data = self.preprocess(data) + for line in data: + name = self.parse_instance_name(line) + facts[name] = dict() + for (key, value) in re.findall(self.DETAIL_RE, line): + facts[name][key] = value + return facts + + def populate_bgp_peer(self, data): + for key, value in iteritems(data): + self.facts['bgp_peer'][key] = value + + def populate_vpnv4_route(self, data): + for key, value in iteritems(data): + self.facts['bgp_vpnv4_route'][key] = value + + def populate_bgp_instance(self, data): + for key, value in iteritems(data): + self.facts['bgp_instance'][key] = value + + def populate_route(self, data): + for key, value in iteritems(data): + self.facts['route'][key] = value + + def populate_ospf_instance(self, data): + for key, value in iteritems(data): + self.facts['ospf_instance'][key] = value + + def populate_ospf_neighbor(self, data): + for key, value in iteritems(data): + self.facts['ospf_neighbor'][key] = value + + +FACT_SUBSETS = dict( + default=Default, + hardware=Hardware, + interfaces=Interfaces, + config=Config, + routing=Routing, +) + +VALID_SUBSETS = frozenset(FACT_SUBSETS.keys()) + +warnings = list() + + +def main(): + """main entry point for module execution + """ + argument_spec = dict( + gather_subset=dict(default=['!config'], type='list', elements='str') + ) + + argument_spec.update(routeros_argument_spec) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + + 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 = dict() + facts['gather_subset'] = list(runable_subsets) + + instances = list() + for key in runable_subsets: + instances.append(FACT_SUBSETS[key](module)) + + for inst in instances: + inst.populate() + facts.update(inst.facts) + + ansible_facts = dict() + 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() |