summaryrefslogtreecommitdiffstats
path: root/ansible_collections/community/routeros/plugins/modules
diff options
context:
space:
mode:
Diffstat (limited to 'ansible_collections/community/routeros/plugins/modules')
-rw-r--r--ansible_collections/community/routeros/plugins/modules/api.py577
-rw-r--r--ansible_collections/community/routeros/plugins/modules/api_facts.py495
-rw-r--r--ansible_collections/community/routeros/plugins/modules/api_find_and_modify.py327
-rw-r--r--ansible_collections/community/routeros/plugins/modules/api_info.py366
-rw-r--r--ansible_collections/community/routeros/plugins/modules/api_modify.py1030
-rw-r--r--ansible_collections/community/routeros/plugins/modules/command.py210
-rw-r--r--ansible_collections/community/routeros/plugins/modules/facts.py663
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()