""" VyOSModule — high-level wrapper used by vyos.rest resource modules. 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. """ from __future__ import absolute_import, division, print_function __metaclass__ = type from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( VyOSRestClient, VyOSRestError, ) # --------------------------------------------------------------------------- # 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 not have_val: 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: # - 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: 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): # Device may return a single value as a string instead of a list if isinstance(have_val, str): have_val = [have_val] 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) def get_config(self, path=None): """Retrieve the configuration subtree at *path*. Returns raw device dict with kebab-case keys. """ try: result = self._client.retrieve_show_config(path or []) return result.get("data") or {} except VyOSRestError: return {} def apply_commands(self, commands): if not commands: return [] payload = [] for cmd in commands: if isinstance(cmd, dict): op, path = cmd["op"], list(cmd["path"]) else: op, path = cmd[0], list(cmd[1]) payload.append({"op": op, "path": path}) try: return self._client.configure_batch(payload) except VyOSRestError as exc: self._module.fail_json( msg="apply_commands failed: {e}".format(e=str(exc)), ) def _apply_set(self, path, value=None): """Set a config path, retrying with a shortened path on failure.""" try: self._client.configure_set(path, value) return {"op": "set", "path": path, "status": "ok"} except VyOSRestError as exc: if len(path) >= 3: short_path = path[:-2] + [path[-1]] try: self._client.configure_set(short_path, value) return {"op": "set", "path": short_path, "status": "ok-adapted"} except VyOSRestError: pass raise VyOSRestError( "set {p} failed: {e}".format(p=" ".join(path), e=str(exc)), ) def _apply_delete(self, path): """Delete a config path; silently ignore if already absent.""" try: self._client.configure_delete(path) return {"op": "delete", "path": path, "status": "ok"} except VyOSRestError: return {"op": "delete", "path": path, "status": "noop"} def show(self, path): """Run an operational show command via the /show endpoint.""" try: result = self._client.show(path) return result.get("data") or "" except VyOSRestError: return "" def save_config(self, file_path=None): """Save the running configuration to disk.""" try: self._client.config_file_save(file_path) return True except VyOSRestError: return False