Implement basic support for read-only and write-only keys in api_info and api_modify (#213)

* Implement basic support for read-only and write-only keys in api_info and api_modify.

* Do not show write-only fields as 'disabled'.
This commit is contained in:
Felix Fontein 2023-09-17 14:34:09 +02:00 committed by GitHub
parent 4d8ebaeb8d
commit cf6c79e1b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 137 additions and 4 deletions

View file

@ -0,0 +1,3 @@
minor_changes:
- "api_info - add new ``include_read_only`` option to select behavior for read-only values. By default these are not returned (https://github.com/ansible-collections/community.routeros/pull/213)."
- "api_modify - add new ``handle_read_only`` and ``handle_write_only`` options to handle the module's behavior for read-only and write-only fields (https://github.com/ansible-collections/community.routeros/pull/213)."

View file

@ -169,7 +169,16 @@ class VersionedAPIData(object):
class KeyInfo(object): class KeyInfo(object):
def __init__(self, _dummy=None, can_disable=False, remove_value=None, absent_value=None, default=None, required=False, automatically_computed_from=None): def __init__(self,
_dummy=None,
can_disable=False,
remove_value=None,
absent_value=None,
default=None,
required=False,
automatically_computed_from=None,
read_only=False,
write_only=False):
if _dummy is not None: if _dummy is not None:
raise ValueError('KeyInfo() does not have positional arguments') raise ValueError('KeyInfo() does not have positional arguments')
if sum([required, default is not None or can_disable, automatically_computed_from is not None]) > 1: if sum([required, default is not None or can_disable, automatically_computed_from is not None]) > 1:
@ -180,12 +189,16 @@ class KeyInfo(object):
raise ValueError('remove_value can only be specified if can_disable=True') raise ValueError('remove_value can only be specified if can_disable=True')
if absent_value is not None and any([default is not None, automatically_computed_from is not None, can_disable]): if absent_value is not None and any([default is not None, automatically_computed_from is not None, can_disable]):
raise ValueError('absent_value can not be combined with default, automatically_computed_from, can_disable=True, or absent_value') raise ValueError('absent_value can not be combined with default, automatically_computed_from, can_disable=True, or absent_value')
if read_only and write_only:
raise ValueError('read_only and write_only cannot be used at the same time')
self.can_disable = can_disable self.can_disable = can_disable
self.remove_value = remove_value self.remove_value = remove_value
self.automatically_computed_from = automatically_computed_from self.automatically_computed_from = automatically_computed_from
self.default = default self.default = default
self.required = required self.required = required
self.absent_value = absent_value self.absent_value = absent_value
self.read_only = read_only
self.write_only = write_only
def split_path(path): def split_path(path):

View file

@ -235,6 +235,13 @@ options:
type: bool type: bool
default: false default: false
version_added: 2.4.0 version_added: 2.4.0
include_read_only:
description:
- Whether to include read-only fields.
- By default, they are not returned.
type: bool
default: false
version_added: 2.10.0
seealso: seealso:
- module: community.routeros.api - module: community.routeros.api
- module: community.routeros.api_facts - module: community.routeros.api_facts
@ -314,6 +321,7 @@ def main():
hide_defaults=dict(type='bool', default=True), hide_defaults=dict(type='bool', default=True),
include_dynamic=dict(type='bool', default=False), include_dynamic=dict(type='bool', default=False),
include_builtin=dict(type='bool', default=False), include_builtin=dict(type='bool', default=False),
include_read_only=dict(type='bool', default=False),
) )
module_args.update(api_argument_spec()) module_args.update(api_argument_spec())
@ -339,6 +347,7 @@ def main():
hide_defaults = module.params['hide_defaults'] hide_defaults = module.params['hide_defaults']
include_dynamic = module.params['include_dynamic'] include_dynamic = module.params['include_dynamic']
include_builtin = module.params['include_builtin'] include_builtin = module.params['include_builtin']
include_read_only = module.params['include_read_only']
try: try:
api_path = compose_api_path(api, path) api_path = compose_api_path(api, path)
@ -362,7 +371,10 @@ def main():
if k not in path_info.fields: if k not in path_info.fields:
entry.pop(k) entry.pop(k)
if handle_disabled != 'omit': if handle_disabled != 'omit':
for k in path_info.fields: for k, field_info in path_info.fields.items():
if field_info.write_only:
entry.pop(k, None)
continue
if k not in entry: if k not in entry:
if handle_disabled == 'exclamation': if handle_disabled == 'exclamation':
k = '!%s' % k k = '!%s' % k
@ -373,6 +385,8 @@ def main():
entry.pop(k) entry.pop(k)
if field_info.absent_value and k not in entry: if field_info.absent_value and k not in entry:
entry[k] = field_info.absent_value entry[k] = field_info.absent_value
if not include_read_only and k in entry and field_info.read_only:
entry.pop(k)
result.append(entry) result.append(entry)
module.exit_json(result=result) module.exit_json(result=result)

View file

@ -24,6 +24,10 @@ description:
- B(Note) that this module is still heavily in development, and only supports B(some) paths. - 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 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/). L(create an issue in the community.routeros Issue Tracker,https://github.com/ansible-collections/community.routeros/issues/).
notes:
- If write-only fields are present in the path, the module is B(not idempotent) in a strict sense,
since it is not able to verify the current value of these fields. The behavior the module should
assume can be controlled with the O(handle_write_only) option.
requirements: requirements:
- Needs L(ordereddict,https://pypi.org/project/ordereddict) for Python 2.6 - Needs L(ordereddict,https://pypi.org/project/ordereddict) for Python 2.6
extends_documentation_fragment: extends_documentation_fragment:
@ -234,12 +238,42 @@ options:
- If V(remove), they are removed. If at least one cannot be removed, the module will fail. - If V(remove), they are removed. If at least one cannot be removed, the module will fail.
- If V(remove_as_much_as_possible), all that can be removed will be removed. The ones that - If V(remove_as_much_as_possible), all that can be removed will be removed. The ones that
cannot be removed will be kept. cannot be removed will be kept.
- Note that V(remove) and V(remove_as_much_as_possible) do not apply to write-only fields.
type: str type: str
choices: choices:
- ignore - ignore
- remove - remove
- remove_as_much_as_possible - remove_as_much_as_possible
default: ignore default: ignore
handle_read_only:
description:
- How to handle values passed in for read-only fields.
- If V(ignore), they are not passed to the API.
- If V(validate), the values are not passed for creation, and for updating they are compared to the value returned for the object.
If they differ, the module fails.
- If V(error), the module will fail if read-only fields are provided.
type: str
choices:
- ignore
- validate
- error
default: error
version_added: 2.10.0
handle_write_only:
description:
- How to handle values passed in for write-only fields.
- If V(create_only), they are passed on creation, and ignored for updating.
- If V(always_update), they are always passed to the API. This means that if such a value is present,
the module will always result in C(changed) since there is no way to validate whether the value
actually changed.
- If V(error), the module will fail if write-only fields are provided.
type: str
choices:
- create_only
- always_update
- error
default: create_only
version_added: 2.10.0
seealso: seealso:
- module: community.routeros.api - module: community.routeros.api
- module: community.routeros.api_facts - module: community.routeros.api_facts
@ -386,6 +420,18 @@ def find_modifications(old_entry, new_entry, path_info, module, for_text='', ret
continue continue
if k not in old_entry and path_info.fields[k].default == v and not path_info.fields[k].can_disable: if k not in old_entry and path_info.fields[k].default == v and not path_info.fields[k].can_disable:
continue continue
key_info = path_info.fields[k]
if key_info.read_only:
# handle_read_only must be 'validate'
if old_entry.get(k) != v:
module.fail_json(
msg='Read-only key "{key}" has value "{old_value}", but should have new value "{new_value}"{for_text}.'.format(
key=k, old_value=old_entry.get(k), new_value=v, for_text=for_text))
continue
if key_info.write_only:
if module.params['handle_write_only'] == 'create_only':
# do not update this value
continue
if k not in old_entry or old_entry[k] != v: if k not in old_entry or old_entry[k] != v:
modifications[k] = v modifications[k] = v
updated_entry[k] = v updated_entry[k] = v
@ -454,6 +500,18 @@ def essentially_same_weight(old_entry, new_entry, path_info, module):
return weight return weight
def remove_read_only(entry, path_info):
to_remove = []
for real_k, v in entry.items():
k = real_k
if k.startswith('!'):
k = k[1:]
if path_info.fields[k].read_only:
to_remove.append(real_k)
for k in to_remove:
entry.pop(k)
def format_pk(primary_keys, values): def format_pk(primary_keys, values):
return ', '.join('{pk}="{value}"'.format(pk=pk, value=value) for pk, value in zip(primary_keys, values)) return ', '.join('{pk}="{value}"'.format(pk=pk, value=value) for pk, value in zip(primary_keys, values))
@ -461,6 +519,7 @@ def format_pk(primary_keys, values):
def polish_entry(entry, path_info, module, for_text): def polish_entry(entry, path_info, module, for_text):
if '.id' in entry: if '.id' in entry:
entry.pop('.id') entry.pop('.id')
to_remove = []
for key, value in entry.items(): for key, value in entry.items():
real_key = key real_key = key
disabled_key = False disabled_key = False
@ -480,6 +539,16 @@ def polish_entry(entry, path_info, module, for_text):
elif value is None: elif value is None:
if not key_info.can_disable: 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)) module.fail_json(msg='Key "{key}" must not be disabled (value null/~/None){for_text}.'.format(key=key, for_text=for_text))
if key_info.read_only:
if module.params['handle_read_only'] == 'error':
module.fail_json(msg='Key "{key}" is read-only{for_text}, and handle_read_only=error.'.format(key=key, for_text=for_text))
if module.params['handle_read_only'] == 'ignore':
to_remove.append(real_key)
if key_info.write_only:
if module.params['handle_write_only'] == 'error':
module.fail_json(msg='Key "{key}" is write-only{for_text}, and handle_write_only=error.'.format(key=key, for_text=for_text))
for key in to_remove:
entry.pop(key)
for key, field_info in path_info.fields.items(): for key, field_info in path_info.fields.items():
if field_info.required and key not in entry: 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)) module.fail_json(msg='Key "{key}" must be present{for_text}.'.format(key=key, for_text=for_text))
@ -635,6 +704,7 @@ def sync_list(module, api, path, path_info):
new_data.append((old_index, updated_entry)) new_data.append((old_index, updated_entry))
new_entry['.id'] = old_entry['.id'] new_entry['.id'] = old_entry['.id']
else: else:
remove_read_only(new_entry, path_info)
create_list.append(new_entry) create_list.append(new_entry)
if handle_absent_entries == 'remove': if handle_absent_entries == 'remove':
@ -827,6 +897,7 @@ def sync_with_primary_keys(module, api, path, path_info):
for primary_key in primary_keys for primary_key in primary_keys
]), ]),
)) ))
remove_read_only(new_entry, path_info)
create_list.append(new_entry) create_list.append(new_entry)
new_entry = new_entry.copy() new_entry = new_entry.copy()
for key in list(new_entry): for key in list(new_entry):
@ -1028,6 +1099,8 @@ def main():
handle_absent_entries=dict(type='str', choices=['ignore', 'remove'], default='ignore'), 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'), handle_entries_content=dict(type='str', choices=['ignore', 'remove', 'remove_as_much_as_possible'], default='ignore'),
ensure_order=dict(type='bool', default=False), ensure_order=dict(type='bool', default=False),
handle_read_only=dict(type='str', default='error', choices=['ignore', 'validate', 'error']),
handle_write_only=dict(type='str', default='create_only', choices=['create_only', 'always_update', 'error']),
) )
module_args.update(api_argument_spec()) module_args.update(api_argument_spec())

View file

@ -99,6 +99,10 @@ def test_key_info_errors():
KeyInfo(remove_value='') KeyInfo(remove_value='')
assert exc.value.args[0] == 'remove_value can only be specified if can_disable=True' assert exc.value.args[0] == 'remove_value can only be specified if can_disable=True'
with pytest.raises(ValueError) as exc:
KeyInfo(read_only=True, write_only=True)
assert exc.value.args[0] == 'read_only and write_only cannot be used at the same time'
SPLITTED_PATHS = [ SPLITTED_PATHS = [
('', [], ''), ('', [], ''),

View file

@ -169,8 +169,16 @@ class Path(object):
self._new_id_counter = 0 self._new_id_counter = 0
self._read_only = read_only self._read_only = read_only
def _sanitize(self, entry):
entry = entry.copy()
for field, field_info in self._path_info.fields.items():
if field in entry:
if field_info.write_only:
del entry[field]
return entry
def __iter__(self): def __iter__(self):
return [entry.copy() for entry in self._values].__iter__() return [self._sanitize(entry) for entry in self._values].__iter__()
def _find_id(self, id, required=False): def _find_id(self, id, required=False):
for index, entry in enumerate(self._values): for index, entry in enumerate(self._values):
@ -194,7 +202,15 @@ class Path(object):
entry = { entry = {
'.id': id, '.id': id,
} }
entry.update(kwargs) for field, value in kwargs.items():
if field.startswith('!'):
field = field[1:]
if field not in self._path_info.fields:
raise ValueError('Trying to set unknown field "{field}"'.format(field=field))
field_info = self._path_info.fields[field]
if field_info.read_only:
raise ValueError('Trying to set read-only field "{field}"'.format(field=field))
entry[field] = value
_normalize_entry(entry, self._path_info, on_create=True) _normalize_entry(entry, self._path_info, on_create=True)
self._values.append(entry) self._values.append(entry)
return id return id
@ -223,6 +239,16 @@ class Path(object):
entry = self._values[index] entry = self._values[index]
if entry.get('dynamic', False) or entry.get('builtin', False): if entry.get('dynamic', False) or entry.get('builtin', False):
raise Exception('Trying to update a dynamic or builtin entry') raise Exception('Trying to update a dynamic or builtin entry')
for field in kwargs:
if field == '.id':
continue
if field.startswith('!'):
field = field[1:]
if field not in self._path_info.fields:
raise ValueError('Trying to update unknown field "{field}"'.format(field=field))
field_info = self._path_info.fields[field]
if field_info.read_only:
raise ValueError('Trying to update read-only field "{field}"'.format(field=field))
entry.update(kwargs) entry.update(kwargs)
_normalize_entry(entry, self._path_info) _normalize_entry(entry, self._path_info)