summaryrefslogtreecommitdiff
path: root/plugins/module_utils
diff options
context:
space:
mode:
authoromnom62 <omnom62@outlook.com>2026-07-10 16:50:38 +1000
committeromnom62 <omnom62@outlook.com>2026-07-10 16:50:38 +1000
commit1e190aa2d3e8fcafdee25c3ee137fe03eccf6b7c (patch)
tree028454cc712e3be6e93df054d561667112d9c38a /plugins/module_utils
parentee7f171c6af8eb5ac37d34d1f58196fb3cb89cf4 (diff)
downloadrest.vyos-1e190aa2d3e8fcafdee25c3ee137fe03eccf6b7c.tar.gz
rest.vyos-1e190aa2d3e8fcafdee25c3ee137fe03eccf6b7c.zip
T8323: vyos_system module
Diffstat (limited to 'plugins/module_utils')
-rw-r--r--plugins/module_utils/vyos.py219
1 files changed, 113 insertions, 106 deletions
diff --git a/plugins/module_utils/vyos.py b/plugins/module_utils/vyos.py
index fedcc13..a0a36c6 100644
--- a/plugins/module_utils/vyos.py
+++ b/plugins/module_utils/vyos.py
@@ -5,15 +5,6 @@ Provides ``get_config()``, ``apply_commands()``, and ``save_config()``
on top of ``VyOSRestClient``, so resource modules can work with simple
``("set", path)`` / ``("delete", path)`` command tuples rather than
calling the REST client directly.
-
-Version-adaptive paths
-----------------------
-Some VyOS config paths changed between minor versions (e.g. the NTP
-``allow-client address`` node was removed in 1.5+). ``apply_commands``
-handles this transparently: if a ``set`` command is rejected by the device
-it retries with the last path segment removed (one level up), covering
-the most common schema simplifications. A ``delete`` that fails is
-treated as a no-op (already absent).
"""
from __future__ import absolute_import, division, print_function
@@ -28,7 +19,7 @@ from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import (
# ---------------------------------------------------------------------------
-# Dynamic config utilities
+# Legacy dynamic config utilities (used by Wave 1-3 modules)
# ---------------------------------------------------------------------------
@@ -106,30 +97,127 @@ def diff_configs(want, have, base_path, delete_missing=False):
return cmds
-class VyOSModule:
- """Thin wrapper around VyOSRestClient for resource modules.
+# ---------------------------------------------------------------------------
+# Generic dict diff engine (used by Wave 4+ modules)
+#
+# Design principles:
+# - want uses snake_case (from YAML/argspec)
+# - have uses kebab-case (from device API)
+# - Conversion between - and _ happens here, once, in the core
+# - Modules only need _BASE path — no key mapping anywhere
+# - want is the reference dataset — drives all operations
+# ---------------------------------------------------------------------------
+
+
+def owned_config(have, argspec):
+ """Filter raw device config to only keys owned by this module.
+
+ Ownership is declared by the module's argspec — the single source
+ of truth for what this module manages. Keys in have not present in
+ argspec (after normalization) are excluded from before/after output.
Args:
- module: AnsibleModule instance.
+ have (dict): Raw device config (kebab-case keys).
+ argspec (dict): Module argument_spec dict.
+
+ Returns:
+ dict: Filtered have with only module-owned keys.
"""
+ owned = set(argspec.keys()) - {"state"}
+ return {k: v for k, v in have.items() if k.replace("-", "_") in owned}
+
+
+def dict_op(want, have, base_path, op="set"):
+ """Generic dict diff engine for VyOS REST API.
+
+ Compares want (snake_case, from YAML) against have (kebab-case, from
+ device) and generates API command tuples. All key normalization between
+ snake_case and kebab-case happens here — modules never need to convert.
+
+ Set operations on the two datasets:
+ op="set" want - have present: apply what is missing
+ op="delete" want ∩ have absent: remove what exists
+ op="purge" have - want replaced: remove what is extra
+
+ Args:
+ want (dict): Desired config, snake_case keys (from YAML/argspec).
+ have (dict): Current config, kebab-case keys (raw from device API).
+ base_path (list): Base API path — the only module-specific knowledge.
+ op (str): "set", "delete", or "purge".
+
+ Returns:
+ list: Tuples of ("set", path) or ("delete", path).
+ """
+ cmds = []
+
+ # Index have by normalized key for O(1) lookup.
+ # Preserves original kebab-case key for use in API paths.
+ have_idx = {k.replace("-", "_"): (k, v) for k, v in (have or {}).items()}
+
+ if op == "purge":
+ # have - want: delete have keys not present in want
+ # Scoped naturally by _BASE — only this subtree is in have
+ want_keys = {k.replace("-", "_") for k in (want or {})}
+ for norm_k, (orig_k, have_v) in have_idx.items():
+ if norm_k not in want_keys:
+ cmds.append(("delete", base_path + [orig_k]))
+ return cmds
+
+ for key, want_val in (want or {}).items():
+ if want_val is None:
+ continue
+
+ # Normalize want key for lookup, get original device key for path
+ norm_key = key.replace("-", "_")
+ orig_key, have_val = have_idx.get(norm_key, (key.replace("_", "-"), None))
+ path = base_path + [orig_key]
+
+ if isinstance(want_val, dict):
+ if not want_val:
+ # Presence node
+ if op == "set" and not have_val:
+ cmds.append(("set", path))
+ elif op == "delete" and have_val is not None:
+ cmds.append(("delete", path))
+ else:
+ # Recurse — have_val passed raw, conversion happens recursively
+ cmds += dict_op(want_val, have_val or {}, path, op)
+
+ elif isinstance(want_val, list):
+ have_set = set(str(i) for i in (have_val or []))
+ if op == "set":
+ # want - have: add missing items
+ for item in want_val:
+ if str(item) not in have_set:
+ cmds.append(("set", path + [str(item)]))
+ elif op == "delete":
+ # want ∩ have: remove items that exist
+ for item in want_val:
+ if str(item) in have_set:
+ cmds.append(("delete", path + [str(item)]))
+
+ else:
+ # Scalar leaf
+ have_str = str(have_val) if have_val is not None else ""
+ if op == "set" and str(want_val) != have_str:
+ cmds.append(("set", path + [str(want_val)]))
+ elif op == "delete" and have_val is not None:
+ cmds.append(("delete", path))
+
+ return cmds
+
+
+class VyOSModule:
+ """Thin wrapper around VyOSRestClient for resource modules."""
def __init__(self, module):
self._module = module
self._client = VyOSRestClient(module)
- # ------------------------------------------------------------------
- # Read
- # ------------------------------------------------------------------
-
def get_config(self, path=None):
"""Retrieve the configuration subtree at *path*.
- Args:
- path (list, optional): Config path tokens. ``[]`` or ``None``
- returns the entire running configuration.
-
- Returns:
- dict: Configuration subtree, or ``{}`` if the path doesn't exist.
+ Returns raw device dict with kebab-case keys.
"""
try:
result = self._client.retrieve_show_config(path or [])
@@ -137,63 +225,6 @@ class VyOSModule:
except VyOSRestError:
return {}
- # ------------------------------------------------------------------
- # Write
- # ------------------------------------------------------------------
-
- # def apply_commands(self, commands):
- # """Execute a list of ``(op, path)`` command tuples against the device.
-
- # Args:
- # commands (list): Each item is a 2-tuple ``(op, path)`` where:
- # - *op* is ``"set"`` or ``"delete"``
- # - *path* is a list of config path tokens
-
- # Optionally a 3-tuple ``(op, path, value)`` for leaf nodes
- # that carry a value.
-
- # Returns:
- # list: Results from each command (dicts with ``op`` and ``path``).
-
- # Raises:
- # VyOSRestError: If a command fails and cannot be recovered.
-
- # Notes:
- # **Version-adaptive set:** if a ``set`` is rejected by the device,
- # the method retries once with the last path segment removed.
- # This handles schema simplifications between VyOS releases
- # (e.g. ``allow-client address <prefix>`` -> ``allow-client <prefix>``
- # in VyOS 1.5+).
-
- # **Idempotent delete:** ``delete`` failures are silently ignored
- # (the path is already absent).
- # """
- # results = []
-
- # for cmd in commands:
- # # Support two command formats:
- # # Tuple: ("set", ["path", "tokens"]) or ("set", ["path"], "value")
- # # Dict: {"op": "set", "path": ["path", "tokens"]}
- # if isinstance(cmd, dict):
- # op = cmd["op"]
- # path = list(cmd["path"])
- # value = cmd.get("value")
- # else:
- # op = cmd[0]
- # path = list(cmd[1])
- # value = cmd[2] if len(cmd) > 2 else None
-
- # if op == "set":
- # results.append(self._apply_set(path, value))
- # elif op == "delete":
- # results.append(self._apply_delete(path))
- # else:
- # self._module.fail_json(
- # msg="Unknown command op '{op}' in apply_commands".format(op=op),
- # )
-
- # return results
-
def apply_commands(self, commands):
if not commands:
return []
@@ -217,10 +248,6 @@ class VyOSModule:
self._client.configure_set(path, value)
return {"op": "set", "path": path, "status": "ok"}
except VyOSRestError as exc:
- # Retry without the second-to-last segment (version-adaptive).
- # Example: ["service","ntp","allow-client","address","10.0.0.0/24"]
- # -> ["service","ntp","allow-client","10.0.0.0/24"]
- # Only attempt if path is long enough to have an intermediate node.
if len(path) >= 3:
short_path = path[:-2] + [path[-1]]
try:
@@ -241,35 +268,15 @@ class VyOSModule:
return {"op": "delete", "path": path, "status": "noop"}
def show(self, path):
- """Run an operational show command via the /show endpoint.
-
- Args:
- path (list): Show command path tokens, e.g. ``["version"]``
- or ``["ip", "route"]``.
-
- Returns:
- str: Text output from the show command, or empty string on failure.
- """
+ """Run an operational show command via the /show endpoint."""
try:
result = self._client.show(path)
return result.get("data") or ""
except VyOSRestError:
return ""
- # ------------------------------------------------------------------
- # Persist
- # ------------------------------------------------------------------
-
def save_config(self, file_path=None):
- """Save the running configuration to disk.
-
- Args:
- file_path (str, optional): Destination path on the device.
- Defaults to ``/config/config.boot``.
-
- Returns:
- bool: ``True`` if saved successfully, ``False`` otherwise.
- """
+ """Save the running configuration to disk."""
try:
self._client.config_file_save(file_path)
return True