From f797b4a231a44823e31a3f2ec257578e590f7de6 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sat, 13 Aug 2022 10:55:37 +0200 Subject: [PATCH] Support absent values. Support absent value 'all' for 'server' in /ip dhcp-server lease. (#107) --- .../fragments/107-api-path-ip-dhcp-lease.yml | 2 + plugins/module_utils/_api_data.py | 7 +- plugins/modules/api_info.py | 6 +- plugins/modules/api_modify.py | 21 +++- tests/unit/plugins/modules/fake_api.py | 5 + tests/unit/plugins/modules/test_api_info.py | 89 ++++++++++++++ tests/unit/plugins/modules/test_api_modify.py | 112 ++++++++++++++++++ 7 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 changelogs/fragments/107-api-path-ip-dhcp-lease.yml diff --git a/changelogs/fragments/107-api-path-ip-dhcp-lease.yml b/changelogs/fragments/107-api-path-ip-dhcp-lease.yml new file mode 100644 index 0000000..b421dc1 --- /dev/null +++ b/changelogs/fragments/107-api-path-ip-dhcp-lease.yml @@ -0,0 +1,2 @@ +bugfixes: + - "api_modify, api_info - make API path ``ip dhcp-server lease`` support ``server=all`` (https://github.com/ansible-collections/community.routeros/issues/104, https://github.com/ansible-collections/community.routeros/pull/107)." diff --git a/plugins/module_utils/_api_data.py b/plugins/module_utils/_api_data.py index 5d2958f..e7cdce6 100644 --- a/plugins/module_utils/_api_data.py +++ b/plugins/module_utils/_api_data.py @@ -46,18 +46,21 @@ class APIData(object): class KeyInfo(object): - def __init__(self, _dummy=None, can_disable=False, remove_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): if _dummy is not None: raise ValueError('KeyInfo() does not have positional arguments') if sum([required, default is not None, automatically_computed_from is not None, can_disable]) > 1: raise ValueError('required, default, automatically_computed_from, and can_disable are mutually exclusive') if not can_disable and remove_value is not None: 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]): + raise ValueError('absent_value can not be combined with default, automatically_computed_from, can_disable=True, or absent_value') self.can_disable = can_disable self.remove_value = remove_value self.automatically_computed_from = automatically_computed_from self.default = default self.required = required + self.absent_value = absent_value def split_path(path): @@ -755,7 +758,7 @@ PATHS = { 'disabled': KeyInfo(default=False), 'insert-queue-before': KeyInfo(can_disable=True), 'mac-address': KeyInfo(can_disable=True, remove_value=''), - 'server': KeyInfo(), + 'server': KeyInfo(absent_value='all'), }, ), ('ip', 'dhcp-server', 'network'): APIData( diff --git a/plugins/modules/api_info.py b/plugins/modules/api_info.py index 2a15bc6..75e1a00 100644 --- a/plugins/modules/api_info.py +++ b/plugins/modules/api_info.py @@ -275,10 +275,12 @@ def main(): if handle_disabled == 'exclamation': k = '!%s' % k entry[k] = None - if hide_defaults: - for k, field_info in path_info.fields.items(): + 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) diff --git a/plugins/modules/api_modify.py b/plugins/modules/api_modify.py index fe7a13e..fdf682e 100644 --- a/plugins/modules/api_modify.py +++ b/plugins/modules/api_modify.py @@ -446,6 +446,15 @@ def remove_dynamic(entries): 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 sync_list(module, api, path, path_info): handle_absent_entries = module.params['handle_absent_entries'] handle_entries_content = module.params['handle_entries_content'] @@ -476,7 +485,7 @@ def sync_list(module, api, path, path_info): api_path = compose_api_path(api, path) - old_data = list(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): @@ -588,7 +597,7 @@ def sync_list(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(list(api_path)) + new_data = remove_dynamic(get_api_data(api_path, path_info)) # Remove 'irrelevant' data for entry in old_data: @@ -656,7 +665,7 @@ def sync_with_primary_keys(module, api, path, path_info): api_path = compose_api_path(api, path) - old_data = list(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 = {} @@ -782,7 +791,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(list(api_path)) + new_data = remove_dynamic(get_api_data(api_path, path_info)) # Remove 'irrelevant' data for entry in old_data: @@ -818,7 +827,7 @@ def sync_single_value(module, api, path, path_info): api_path = compose_api_path(api, path) - old_data = list(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( @@ -839,7 +848,7 @@ def sync_single_value(module, api, path, path_info): except (LibRouterosError, UnicodeEncodeError) as e: module.fail_json(msg='Error while modifying: {error}'.format(error=to_native(e))) # Retrieve latest version - new_data = list(api_path) + new_data = get_api_data(api_path, path_info) if len(new_data) == 1: updated_entry = new_data[0] diff --git a/tests/unit/plugins/modules/fake_api.py b/tests/unit/plugins/modules/fake_api.py index 0a3f608..b17c088 100644 --- a/tests/unit/plugins/modules/fake_api.py +++ b/tests/unit/plugins/modules/fake_api.py @@ -128,6 +128,8 @@ def _normalize_entry(entry, path_info): if ('!%s' % key) in entry: entry.pop(key, None) del entry['!%s' % key] + if data.absent_value is not None and key in entry and entry[key] == data.absent_value: + del entry[key] def massage_expected_result_data(values, path, keep_all=False, remove_dynamic=False): @@ -142,6 +144,9 @@ def massage_expected_result_data(values, path, keep_all=False, remove_dynamic=Fa if key == '.id' or key in path_info.fields: continue del entry[key] + for key, data in path_info.fields.items(): + if data.absent_value is not None and key not in entry: + entry[key] = data.absent_value return values diff --git a/tests/unit/plugins/modules/test_api_info.py b/tests/unit/plugins/modules/test_api_info.py index 2195d20..e9e0696 100644 --- a/tests/unit/plugins/modules/test_api_info.py +++ b/tests/unit/plugins/modules/test_api_info.py @@ -406,3 +406,92 @@ class TestRouterosApiInfoModule(ModuleTestCase): 'dynamic': True, }, ]) + + @patch('ansible_collections.community.routeros.plugins.modules.api_info.compose_api_path') + def test_absent(self, mock_compose_api_path): + mock_compose_api_path.return_value = [ + { + '.id': '*1', + 'address': '192.168.88.2', + 'mac-address': '11:22:33:44:55:66', + 'client-id': 'ff:1:2:3:4:5:6:7:8:9:a:b:c:d:e:f:0:1:2', + 'address-lists': '', + 'server': 'main', + 'dhcp-option': '', + 'status': 'waiting', + 'last-seen': 'never', + 'radius': False, + 'dynamic': False, + 'blocked': False, + 'disabled': False, + 'comment': 'foo', + }, + { + '.id': '*2', + 'address': '192.168.88.3', + 'mac-address': '11:22:33:44:55:77', + 'client-id': '1:2:3:4:5:6:7', + 'address-lists': '', + 'server': 'main', + 'dhcp-option': '', + 'status': 'bound', + 'expires-after': '3d7m8s', + 'last-seen': '1m52s', + 'active-address': '192.168.88.14', + 'active-mac-address': '11:22:33:44:55:76', + 'active-client-id': '1:2:3:4:5:6:7', + 'active-server': 'main', + 'host-name': 'bar', + 'radius': False, + 'dynamic': False, + 'blocked': False, + 'disabled': False, + }, + { + '.id': '*3', + 'address': '0.0.0.1', + 'mac-address': '00:00:00:00:00:01', + 'address-lists': '', + 'dhcp-option': '', + 'status': 'waiting', + 'last-seen': 'never', + 'radius': False, + 'dynamic': False, + 'blocked': False, + 'disabled': False, + }, + ] + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dhcp-server lease', + 'handle_disabled': 'omit', + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], False) + self.assertEqual(result['result'], [ + { + '.id': '*1', + 'address': '192.168.88.2', + 'mac-address': '11:22:33:44:55:66', + 'client-id': 'ff:1:2:3:4:5:6:7:8:9:a:b:c:d:e:f:0:1:2', + 'server': 'main', + 'comment': 'foo', + }, + { + '.id': '*2', + 'address': '192.168.88.3', + 'mac-address': '11:22:33:44:55:77', + 'client-id': '1:2:3:4:5:6:7', + 'server': 'main', + }, + { + '.id': '*3', + 'address': '0.0.0.1', + 'mac-address': '00:00:00:00:00:01', + 'server': 'all', + }, + ]) diff --git a/tests/unit/plugins/modules/test_api_modify.py b/tests/unit/plugins/modules/test_api_modify.py index 5c5d4ee..2d70c43 100644 --- a/tests/unit/plugins/modules/test_api_modify.py +++ b/tests/unit/plugins/modules/test_api_modify.py @@ -93,6 +93,74 @@ START_IP_ADDRESS = [ START_IP_ADDRESS_OLD_DATA = massage_expected_result_data(START_IP_ADDRESS, ('ip', 'address')) +START_IP_DHCP_SEVER_LEASE = [ + { + '.id': '*1', + 'address': '192.168.88.2', + 'mac-address': '11:22:33:44:55:66', + 'client-id': 'ff:1:2:3:4:5:6:7:8:9:a:b:c:d:e:f:0:1:2', + 'address-lists': '', + 'server': 'main', + 'dhcp-option': '', + 'status': 'waiting', + 'last-seen': 'never', + 'radius': False, + 'dynamic': False, + 'blocked': False, + 'disabled': False, + 'comment': 'foo', + }, + { + '.id': '*2', + 'address': '192.168.88.3', + 'mac-address': '11:22:33:44:55:77', + 'client-id': '1:2:3:4:5:6:7', + 'address-lists': '', + 'server': 'main', + 'dhcp-option': '', + 'status': 'bound', + 'expires-after': '3d7m8s', + 'last-seen': '1m52s', + 'active-address': '192.168.88.14', + 'active-mac-address': '11:22:33:44:55:76', + 'active-client-id': '1:2:3:4:5:6:7', + 'active-server': 'main', + 'host-name': 'bar', + 'radius': False, + 'dynamic': False, + 'blocked': False, + 'disabled': False, + }, + { + '.id': '*3', + 'address': '0.0.0.1', + 'mac-address': '00:00:00:00:00:01', + 'address-lists': '', + 'dhcp-option': '', + 'status': 'waiting', + 'last-seen': 'never', + 'radius': False, + 'dynamic': False, + 'blocked': False, + 'disabled': False, + }, + { + '.id': '*4', + 'address': '0.0.0.2', + 'mac-address': '00:00:00:00:00:02', + 'address-lists': '', + 'dhcp-option': '', + 'status': 'waiting', + 'last-seen': 'never', + 'radius': False, + 'dynamic': False, + 'blocked': False, + 'disabled': False, + }, +] + +START_IP_DHCP_SEVER_LEASE_OLD_DATA = massage_expected_result_data(START_IP_DHCP_SEVER_LEASE, ('ip', 'dhcp-server', 'lease')) + class TestRouterosApiModifyModule(ModuleTestCase): @@ -1538,3 +1606,47 @@ class TestRouterosApiModifyModule(ModuleTestCase): 'disabled': False, }, ]) + + @patch('ansible_collections.community.routeros.plugins.modules.api_modify.compose_api_path', + new=create_fake_path(('ip', 'dhcp-server', 'lease'), START_IP_DHCP_SEVER_LEASE, read_only=True)) + def test_absent_value(self): + with self.assertRaises(AnsibleExitJson) as exc: + args = self.config_module_args.copy() + args.update({ + 'path': 'ip dhcp-server lease', + 'data': [ + { + 'address': '192.168.88.2', + 'mac-address': '11:22:33:44:55:66', + 'client-id': 'ff:1:2:3:4:5:6:7:8:9:a:b:c:d:e:f:0:1:2', + 'server': 'main', + 'comment': 'foo', + }, + { + 'address': '192.168.88.3', + 'mac-address': '11:22:33:44:55:77', + 'client-id': '1:2:3:4:5:6:7', + 'server': 'main', + }, + { + 'address': '0.0.0.1', + 'mac-address': '00:00:00:00:00:01', + 'server': 'all', + }, + { + 'address': '0.0.0.2', + 'mac-address': '00:00:00:00:00:02', + 'server': 'all', + }, + ], + 'handle_absent_entries': 'remove', + 'handle_entries_content': 'remove', + 'ensure_order': True, + }) + set_module_args(args) + self.module.main() + + result = exc.exception.args[0] + self.assertEqual(result['changed'], False) + self.assertEqual(result['old_data'], START_IP_DHCP_SEVER_LEASE_OLD_DATA) + self.assertEqual(result['new_data'], START_IP_DHCP_SEVER_LEASE_OLD_DATA)