Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

api_modify/api_info: add restrict option #305

Merged
merged 8 commits into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelogs/fragments/305-api-restrict.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
minor_changes:
- "api_info - allow to restrict the output by limiting fields to specific values with the new ``restrict`` option (https://github.com/ansible-collections/community.routeros/pull/305)."
- "api_modify - allow to restrict what is updated by limiting fields to specific values with the new ``restrict`` option (https://github.com/ansible-collections/community.routeros/pull/305)."
32 changes: 32 additions & 0 deletions plugins/doc_fragments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,35 @@ class ModuleDocFragment(object):
- ref: ansible_collections.community.routeros.docsite.api-guide
description: How to connect to RouterOS devices with the RouterOS API
'''

RESTRICT = r'''
options:
restrict:
type: list
elements: dict
suboptions:
field:
description:
- The field whose values to restrict.
required: true
type: str
values:
description:
- The values of the field to limit to.
- >-
Note that the types of the values are important. If you provide a string V("0"),
and librouteros converts the value returned by the API to the integer V(0),
then this will not match. If you are not sure, better include both variants:
both the string and the integer.
- Use V(none) for disabled values.
- Either O(restrict[].values) or O(restrict[].regex), but not both, must be specified.
type: list
elements: raw
regex:
description:
- A regular expression matching values of the field to limit to.
- Note that all values will be converted to strings before matching.
- It is not possible to match disabled values with regular expressions.
felixfontein marked this conversation as resolved.
Show resolved Hide resolved
- Either O(restrict[].values) or O(restrict[].regex), but not both, must be specified.
type: str
'''
89 changes: 89 additions & 0 deletions plugins/module_utils/_api_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2022, Felix Fontein (@felixfontein) <[email protected]>
# 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

# The data inside here is private to this collection. If you use this from outside the collection,
# you are on your own. There can be random changes to its format even in bugfix releases!

from __future__ import absolute_import, division, print_function
__metaclass__ = type

import re

from ansible.module_utils.common.text.converters import to_text


def validate_and_prepare_restrict(module, path_info):
restrict = module.params['restrict']
if restrict is None:
return None
restrict_data = []
for rule in restrict:
field = rule['field']
if field.startswith('!'):
module.fail_json(msg='restrict: the field name "{0}" must not start with "!"'.format(field))
f = path_info.fields.get(field)
if f is None:
module.fail_json(msg='restrict: the field "{0}" does not exist for this path'.format(field))

new_rule = dict(field=field)
if rule['values'] is not None:
new_rule['values'] = rule['values']
elif rule['regex'] is not None:
regex = rule['regex']
try:
new_rule['regex'] = re.compile(regex)
new_rule['regex_source'] = regex
except Exception as exc:
module.fail_json(msg='restrict: invalid regular expression "{0}": {1}'.format(regex, exc))
restrict_data.append(new_rule)
return restrict_data


def restrict_entry_accepted(entry, path_info, restrict_data):
if restrict_data is None:
return True
for rule in restrict_data:
# Obtain field and value
field = rule['field']
field_info = path_info.fields[field]
value = entry.get(field)
if value is None:
value = field_info.default
if field not in entry and field_info.absent_value:
value = field_info.absent_value

# Actual test
if 'values' in rule and value not in rule['values']:
return False
if 'regex' in rule:
if value is None:
# regex cannot match None
return False
value_str = to_text(value)
if isinstance(value, bool):
value_str = value_str.lower()
if rule['regex'].match(value_str):
return False
return True


def restrict_argument_spec():
return dict(
restrict=dict(
type='list',
elements='dict',
options=dict(
field=dict(type='str', required=True),
values=dict(type='list', elements='raw'),
regex=dict(type='str'),
),
mutually_exclusive=[
('values', 'regex'),
],
required_one_of=[
('values', 'regex'),
],
),
)
27 changes: 27 additions & 0 deletions plugins/modules/api_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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.api.restrict
- community.routeros.attributes
- community.routeros.attributes.actiongroup_api
- community.routeros.attributes.info_module
Expand Down Expand Up @@ -301,6 +302,10 @@
type: bool
default: false
version_added: 2.10.0
restrict:
description:
- Restrict output to entries matching the following criteria.
version_added: 2.18.0
seealso:
- module: community.routeros.api
- module: community.routeros.api_facts
Expand All @@ -318,6 +323,18 @@
path: ip address
register: ip_addresses

- name: Print data for IP addresses
ansible.builtin.debug:
var: ip_addresses.result

- 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
Expand Down Expand Up @@ -358,6 +375,12 @@
split_path,
)

from ansible_collections.community.routeros.plugins.module_utils._api_helper import (
restrict_argument_spec,
restrict_entry_accepted,
validate_and_prepare_restrict,
)

try:
from librouteros.exceptions import LibRouterosError
except Exception:
Expand All @@ -383,6 +406,7 @@ def main():
include_read_only=dict(type='bool', default=False),
)
module_args.update(api_argument_spec())
module_args.update(restrict_argument_spec())

module = AnsibleModule(
argument_spec=module_args,
Expand Down Expand Up @@ -411,6 +435,7 @@ def main():
include_dynamic = module.params['include_dynamic']
include_builtin = module.params['include_builtin']
include_read_only = module.params['include_read_only']
restrict_data = validate_and_prepare_restrict(module, path_info)
try:
api_path = compose_api_path(api, path)

Expand All @@ -423,6 +448,8 @@ def main():
if not include_builtin:
if entry.get('builtin', False):
continue
if not restrict_entry_accepted(entry, path_info, restrict_data):
continue
if not unfiltered:
for k in list(entry):
if k == '.id':
Expand Down
65 changes: 60 additions & 5 deletions plugins/modules/api_modify.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
- Needs L(ordereddict,https://pypi.org/project/ordereddict) for Python 2.6
extends_documentation_fragment:
- community.routeros.api
- community.routeros.api.restrict
- community.routeros.attributes
- community.routeros.attributes.actiongroup_api
attributes:
Expand Down Expand Up @@ -333,6 +334,15 @@
- error
default: create_only
version_added: 2.10.0
restrict:
description:
- Restrict operation to entries matching the following criteria.
- This can be useful together with O(handle_absent_entries=remove) to operate on a subset of
the values.
- For example, for O(path=ip firewall filter), you can set O(restrict[].field=chain) and
O(restrict[].values=input) to restrict operation to the input chain, and ignore the
forward and output chains.
version_added: 2.18.0
seealso:
- module: community.routeros.api
- module: community.routeros.api_facts
Expand Down Expand Up @@ -378,6 +388,23 @@
out-interface:
to-addresses: ~
'!to-ports':

- name: Block all incoming connections
community.routeros.api_modify:
hostname: "{{ hostname }}"
password: "{{ password }}"
username: "{{ username }}"
path: ip firewall filter
handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible
restrict:
# Do not touch any chain except the input chain
- field: chain
values:
- input
data:
- action: drop
chain: input
'''

RETURN = '''
Expand Down Expand Up @@ -434,6 +461,12 @@
split_path,
)

from ansible_collections.community.routeros.plugins.module_utils._api_helper import (
restrict_argument_spec,
restrict_entry_accepted,
validate_and_prepare_restrict,
)

HAS_ORDEREDDICT = True
try:
from collections import OrderedDict
Expand Down Expand Up @@ -699,18 +732,29 @@ def prepare_for_add(entry, path_info):
return new_entry


def sync_list(module, api, path, path_info):
def remove_rejected(data, path_info, restrict_data):
return [
entry for entry in data
if restrict_entry_accepted(entry, path_info, restrict_data)
]


def sync_list(module, api, path, path_info, restrict_data):
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')
module.fail_json(
msg='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):
if not restrict_entry_accepted(entry, path_info, restrict_data):
module.fail_json(msg='The element at index #{index} does not match `restrict`'.format(index=index + 1))
for stratify_key in stratify_keys:
if stratify_key not in entry:
module.fail_json(
Expand All @@ -731,6 +775,7 @@ def sync_list(module, api, path, path_info):

old_data = get_api_data(api_path, path_info)
old_data = remove_dynamic(old_data)
old_data = remove_rejected(old_data, path_info, restrict_data)
stratified_old_data = defaultdict(list)
for index, entry in enumerate(old_data):
sks = tuple(entry[stratify_key] for stratify_key in stratify_keys)
Expand Down Expand Up @@ -843,6 +888,7 @@ def match(current_entry):
# 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))
new_data = remove_rejected(new_data, path_info, restrict_data)

# Remove 'irrelevant' data
for entry in old_data:
Expand All @@ -869,7 +915,7 @@ def match(current_entry):
)


def sync_with_primary_keys(module, api, path, path_info):
def sync_with_primary_keys(module, api, path, path_info, restrict_data):
primary_keys = path_info.primary_keys

if path_info.fixed_entries:
Expand All @@ -881,6 +927,8 @@ def sync_with_primary_keys(module, api, path, path_info):
data = module.params['data']
new_data_by_key = OrderedDict()
for index, entry in enumerate(data):
if not restrict_entry_accepted(entry, path_info, restrict_data):
module.fail_json(msg='The element at index #{index} does not match `restrict`'.format(index=index + 1))
for primary_key in primary_keys:
if primary_key not in entry:
module.fail_json(
Expand Down Expand Up @@ -912,6 +960,7 @@ def sync_with_primary_keys(module, api, path, path_info):

old_data = get_api_data(api_path, path_info)
old_data = remove_dynamic(old_data)
old_data = remove_rejected(old_data, path_info, restrict_data)
old_data_by_key = OrderedDict()
id_by_key = {}
for entry in old_data:
Expand Down Expand Up @@ -1038,6 +1087,7 @@ def sync_with_primary_keys(module, api, path, path_info):
# 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))
new_data = remove_rejected(new_data, path_info, restrict_data)

# Remove 'irrelevant' data
for entry in old_data:
Expand All @@ -1064,7 +1114,9 @@ def sync_with_primary_keys(module, api, path, path_info):
)


def sync_single_value(module, api, path, path_info):
def sync_single_value(module, api, path, path_info, restrict_data):
if module.params['restrict'] is not None:
module.fail_json(msg='The restrict option cannot be used with this path, since there is precisely one entry.')
data = module.params['data']
if len(data) != 1:
module.fail_json(msg='Data must be a list with exactly one element.')
Expand Down Expand Up @@ -1162,6 +1214,7 @@ def main():
handle_write_only=dict(type='str', default='create_only', choices=['create_only', 'always_update', 'error']),
)
module_args.update(api_argument_spec())
module_args.update(restrict_argument_spec())

module = AnsibleModule(
argument_spec=module_args,
Expand Down Expand Up @@ -1193,7 +1246,9 @@ def main():
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)
restrict_data = validate_and_prepare_restrict(module, path_info)

backend(module, api, path, path_info, restrict_data)


if __name__ == '__main__':
Expand Down
Loading
Loading