diff options
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | changelogs/fragments/t8989_module_utils_vyos.yml | 3 | ||||
| -rw-r--r-- | plugins/module_utils/vyos.py | 84 | ||||
| -rw-r--r-- | tests/unit/test_module_utils_vyos.py | 55 |
4 files changed, 63 insertions, 84 deletions
@@ -92,8 +92,7 @@ Name | Description [vyos.rest.vyos_l3_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_l3_interfaces_module.rst)|Manage L3 interface configuration on VyOS devices via REST API. [vyos.rest.vyos_lag_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lag_interfaces_module.rst)|Manage LAG interface configuration on VyOS devices via REST API. [vyos.rest.vyos_lldp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lldp_global_module.rst)|Manage LLDP global configuration on VyOS via REST API. -[vyos.rest.vyos_lldp_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lldp_int -erfaces_module.rst)|Manage LLDP interface configuration on VyOS devices via REST API. +[vyos.rest.vyos_lldp_interfaces](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lldp_interfaces_module.rst)|Manage LLDP interface configuration on VyOS devices via REST API. [vyos.rest.vyos_logging_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_logging_global_module.rst)|Manage syslog configuration on VyOS devices using REST API [vyos.rest.vyos_nat](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_nat_module.rst)|Manage NAT configuration on VyOS devices using REST API [vyos.rest.vyos_ntp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ntp_global_module.rst)|Manage NTP configuration on VyOS devices using REST API @@ -134,5 +133,3 @@ automation. | Connection plugin | `ansible.netcommon.network_cli` | `ansible.netcommon.httpapi` | | VyOS requirement | Any | VyOS 1.3+ with REST API enabled | | Atomic commits | Per-command | Batch (single commit) | - - diff --git a/changelogs/fragments/t8989_module_utils_vyos.yml b/changelogs/fragments/t8989_module_utils_vyos.yml new file mode 100644 index 0000000..759fb7e --- /dev/null +++ b/changelogs/fragments/t8989_module_utils_vyos.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - module_utils/vyos.py - Fix cast_by_spec to handle int-typed leaves returned as collapsed lists (including empty list -> None). diff --git a/plugins/module_utils/vyos.py b/plugins/module_utils/vyos.py index a37a0c2..92549d6 100644 --- a/plugins/module_utils/vyos.py +++ b/plugins/module_utils/vyos.py @@ -19,85 +19,6 @@ from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( # --------------------------------------------------------------------------- -# Legacy dynamic config utilities (used by Wave 1-3 modules) -# --------------------------------------------------------------------------- - - -def _kebab_to_snake(s): - """Convert kebab-case string to snake_case.""" - return s.replace("-", "_") - - -def _snake_to_kebab(s): - """Convert snake_case string to kebab-case.""" - return s.replace("_", "-") - - -def normalize(raw): - """Recursively normalize an API response dict to snake_case keys.""" - if isinstance(raw, dict): - return {_kebab_to_snake(k): normalize(v) for k, v in raw.items()} - if isinstance(raw, list): - return [normalize(v) for v in raw] - return raw - - -def denormalize_path(path): - """Convert a snake_case path list to kebab-case for the API.""" - return [_snake_to_kebab(p) for p in path] - - -def _diff_value(want_val, have_val, path, cmds, delete_missing): - if isinstance(want_val, dict): - if not want_val: - if have_val is None: - cmds.append(("set", denormalize_path(path))) - else: - have_dict = have_val if isinstance(have_val, dict) else {} - _diff_dict(want_val, have_dict, path, cmds, delete_missing) - elif isinstance(want_val, list): - have_set = set(have_val) if isinstance(have_val, list) else set() - for item in want_val: - if item not in have_set: - cmds.append(("set", denormalize_path(path + [str(item)]))) - if delete_missing: - want_set = set(str(i) for i in want_val) - for item in have_val or []: - if str(item) not in want_set: - cmds.append(("delete", denormalize_path(path + [str(item)]))) - else: - if want_val != have_val: - cmds.append(("set", denormalize_path(path + [str(want_val)]))) - - -def _diff_dict(want, have, path, cmds, delete_missing): - for key, want_val in want.items(): - _diff_value(want_val, have.get(key), path + [key], cmds, delete_missing) - if delete_missing: - for key in have: - if key not in want: - cmds.append(("delete", denormalize_path(path + [key]))) - - -def diff_configs(want, have, base_path, delete_missing=False): - """Diff two normalized config dicts and return API command tuples. - - Args: - want (dict): Desired configuration (snake_case keys). - have (dict): Current configuration (snake_case keys). - base_path (list): Base API path for commands. - delete_missing (bool): Generate delete commands for keys in - ``have`` absent from ``want``. - - Returns: - list: Tuples of ``("set", path)`` or ``("delete", path)``. - """ - cmds = [] - _diff_dict(want, have, base_path, cmds, delete_missing) - return cmds - - -# --------------------------------------------------------------------------- # Generic dict diff engine (used by Wave 4+ modules) # # Design principles: @@ -229,7 +150,10 @@ def cast_by_spec(entry, options): continue spec_type = spec.get("type") if spec_type == "int": - entry[key] = int(entry[key]) + val = entry[key] + if isinstance(val, list) and len(val) <= 1: + val = val[0] if val else None + entry[key] = int(val) if val is not None else None elif spec_type == "dict": cast_by_spec(entry[key], spec.get("options")) elif spec_type == "list": diff --git a/tests/unit/test_module_utils_vyos.py b/tests/unit/test_module_utils_vyos.py new file mode 100644 index 0000000..920e9a5 --- /dev/null +++ b/tests/unit/test_module_utils_vyos.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +"""Unit tests for the shared functions in plugins/module_utils/vyos.py. + +Kept separate from any single module's test file since these tests +shared, cross-module infrastructure (cast_by_spec, dict_op, autoclean, +from_device) rather than any one module's own behavior -- a fix here +should be verifiable, and shippable, independently of any module that +happens to use it. +""" +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from ansible_collections.vyos.rest.plugins.module_utils.vyos import cast_by_spec + + +class TestCastBySpecIntCollapse(unittest.TestCase): + """Regression test for a confirmed defensive gap: cast_by_spec's + own docstring claims to handle VyOS's single-value collapse, and + the list branch already does, but the int branch previously called + int() directly with no such guard -- a genuinely collapsed list + for an int-typed leaf would have raised TypeError rather than + being handled. No module's own fields are known to hit this case + in live device output today (confirmed for vyos_static_routes: + distance/admin_distance are always plain scalars) -- this hardens + shared infrastructure against a case that could arise for a future + module's fields, matching what the docstring already promises. + """ + + def test_collapsed_single_value_list_for_int_field(self): + entry = {"distance": ["200"]} + cast_by_spec(entry, {"distance": {"type": "int"}}) + self.assertEqual(entry["distance"], 200) + + def test_plain_scalar_still_works(self): + entry = {"distance": "200"} + cast_by_spec(entry, {"distance": {"type": "int"}}) + self.assertEqual(entry["distance"], 200) + + def test_empty_list_becomes_none(self): + entry = {"distance": []} + cast_by_spec(entry, {"distance": {"type": "int"}}) + self.assertIsNone(entry["distance"]) + + def test_none_value_untouched(self): + entry = {"distance": None} + cast_by_spec(entry, {"distance": {"type": "int"}}) + self.assertIsNone(entry["distance"]) + + +if __name__ == "__main__": + unittest.main() |
