summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md5
-rw-r--r--changelogs/fragments/t8989_module_utils_vyos.yml3
-rw-r--r--plugins/module_utils/vyos.py84
-rw-r--r--tests/unit/test_module_utils_vyos.py55
4 files changed, 63 insertions, 84 deletions
diff --git a/README.md b/README.md
index 50d4aad..5d33329 100644
--- a/README.md
+++ b/README.md
@@ -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()