mirror of
https://github.com/ansible-collections/community.routeros.git
synced 2025-07-12 19:24:34 +02:00
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:
parent
4d8ebaeb8d
commit
cf6c79e1b3
6 changed files with 137 additions and 4 deletions
3
changelogs/fragments/213-read-write-only.yml
Normal file
3
changelogs/fragments/213-read-write-only.yml
Normal 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)."
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -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 = [
|
||||||
('', [], ''),
|
('', [], ''),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue