diff options
| author | omnom62 <omnom62@outlook.com> | 2026-05-06 08:08:27 +1000 |
|---|---|---|
| committer | omnom62 <omnom62@outlook.com> | 2026-05-06 08:08:27 +1000 |
| commit | 9a3fa5777e7203d46faf1185cfb56c4fc121d885 (patch) | |
| tree | 80988400ba3582cfc97ff6445fb8f97c724d3fc6 /plugins/module_utils | |
| parent | c2493986714604aa992bea28628b74587b3b51bd (diff) | |
| download | rest.vyos-9a3fa5777e7203d46faf1185cfb56c4fc121d885.tar.gz rest.vyos-9a3fa5777e7203d46faf1185cfb56c4fc121d885.zip | |
More modules
Diffstat (limited to 'plugins/module_utils')
| -rw-r--r-- | plugins/module_utils/utils.py | 64 | ||||
| -rw-r--r-- | plugins/module_utils/vyos.py | 231 | ||||
| -rw-r--r-- | plugins/module_utils/vyos_rest.py | 265 |
3 files changed, 452 insertions, 108 deletions
diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index f8a738f..b54bd95 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -1,40 +1,58 @@ +""" +Shared utility functions for the vyos.rest collection. +""" + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + + def normalize_to_list(value): - """ - Normalize VyOS REST return values to list. + """Coerce any VyOS config value into a flat Python list. - Handles: - dict -> keys - list -> same - str -> [value] - None -> [] - """ - if isinstance(value, dict): - return list(value.keys()) + VyOS returns config data in inconsistent shapes depending on how many + values are present: + - ``None`` -> no config exists -> ``[]`` + - ``{}`` -> empty dict (node exists) -> ``[]`` + - ``"10.0.0.1"`` -> single string value -> ``["10.0.0.1"]`` + - ``["a", "b"]`` -> already a list -> ``["a", "b"]`` + - ``{"a": {}, "b": {}}`` -> dict of leaf nodes -> ``["a", "b"]`` - if isinstance(value, list): - return value + Args: + value: Raw value from a VyOS ``showConfig`` response. + Returns: + list: Flat list of string values. + """ + if value is None: + return [] + if isinstance(value, list): + return [str(v) for v in value] if isinstance(value, str): return [value] - - return [] + if isinstance(value, dict): + # Keys are the values; dict values are sub-config (ignored here) + return [str(k) for k in value.keys()] + # Fallback: try to convert + return [str(value)] def normalize_to_dict(value): - """ - Normalize VyOS REST return values to dict. + """Coerce a VyOS multi-value node into a dict keyed by value. - list -> {item: {}} - str -> {value: {}} - dict -> same + Args: + value: Raw value from showConfig (None, str, list, or dict). + + Returns: + dict: ``{value: sub_config}`` pairs. """ + if not value: + return {} if isinstance(value, dict): return value - if isinstance(value, list): - return {v: {} for v in value} - + return {str(v): {} for v in value} if isinstance(value, str): return {value: {}} - return {} diff --git a/plugins/module_utils/vyos.py b/plugins/module_utils/vyos.py index 469fbd3..87fc37b 100644 --- a/plugins/module_utils/vyos.py +++ b/plugins/module_utils/vyos.py @@ -1,104 +1,165 @@ -from ansible.module_utils.connection import Connection +""" +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. + +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 + + +__metaclass__ = type + +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VyOSRestClient, + VyOSRestError, +) class VyOSModule: + """Thin wrapper around VyOSRestClient for resource modules. + + Args: + module: AnsibleModule instance. + """ def __init__(self, module): - self.module = module - - if not module._socket_path: - module.fail_json(msg="Connection must be httpapi") - - self.connection = Connection(module._socket_path) - self.existing = {} - - # ---------------------------------------------------------- - # Low-level transport - # ---------------------------------------------------------- - - def _send(self, path, op=None, path_list=None, commands=None): - payload = {} - if op: - payload["op"] = op - if path_list: - payload["path"] = path_list - if commands: - payload["commands"] = commands - - response = self.connection.send_request( - path=path, - method="POST", - data=payload, - ) - - if not response: - self.module.fail_json(msg="Empty response from device") - - if not response.get("success"): - error = response.get("error", "") - # VyOS returns an error when path has no config — treat as empty - if "empty" in error.lower() or "path is empty" in error.lower(): - return {} - self.module.fail_json( - msg="VyOS API error: {0}".format(error), - response=response, - ) + self._module = module + self._client = VyOSRestClient(module) - return response.get("data") or {} + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ - # ---------------------------------------------------------- - # Config retrieval - # ---------------------------------------------------------- + def get_config(self, path=None): + """Retrieve the configuration subtree at *path*. - def get_config(self, path_list): - """ - Fetch config at path_list via showConfig. - Returns empty dict when path has no configuration. + 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. """ - result = self._send( - path="/retrieve", - op="showConfig", - path_list=path_list, - ) - self.existing = result - return result - - # ---------------------------------------------------------- - # Config application - # ---------------------------------------------------------- + try: + result = self._client.retrieve_show_config(path or []) + return result.get("data") or {} + 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). """ - Send a list of commands to /configure. - Accepts tuples (op, path) or dicts {"op": ..., "path": ...}. - Returns the raw API response dict. - Raises via fail_json on API error. - Does NOT call exit_json — the caller controls module exit. - """ - if not commands: - return {} + results = [] - payload_commands = [] for cmd in commands: - if isinstance(cmd, tuple): - op, path = cmd - payload_commands.append({"op": op, "path": path}) - elif isinstance(cmd, dict): - payload_commands.append(cmd) + # 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="Invalid command format: {0}".format(cmd), + self._module.fail_json( + msg="Unknown command op '{op}' in apply_commands".format(op=op), ) - return self._send( - path="/configure", - commands=payload_commands, - ) + return results + + 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: + # 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: + 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 save_config(self): - """ - Persist running config to disk. - Returns True on success, fails via fail_json on error. + 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"} + + # ------------------------------------------------------------------ + # 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. """ - self._send(path="/config-file", op="save") - return True + try: + self._client.config_file_save(file_path) + return True + except VyOSRestError: + return False diff --git a/plugins/module_utils/vyos_rest.py b/plugins/module_utils/vyos_rest.py new file mode 100644 index 0000000..95b3aac --- /dev/null +++ b/plugins/module_utils/vyos_rest.py @@ -0,0 +1,265 @@ +""" +VyOS REST API client utility — supports both connection modes. + +**httpapi mode (recommended)**:: + + # inventory + vyos15x ansible_host=192.168.122.194 + ansible_network_os=vyos.rest.vyos + ansible_connection=ansible.netcommon.httpapi + ansible_httpapi_use_ssl=true + ansible_httpapi_port=443 + ansible_httpapi_validate_certs=false + ansible_httpapi_api_key=vyos123 + + # playbook — no connection params needed on any task + - vyos.rest.vyos_banner: + banner: pre-login + text: "Authorised use only." + state: present + +**local mode (alternative, no inventory network_os required)**:: + + - vyos.rest.vyos_banner: + hostname: 192.168.122.194 + api_key: vyos123 + verify_ssl: false + banner: pre-login + text: "Authorised use only." + state: present + +Both modes call the same VyOS endpoints. The httpapi mode routes calls +through the persistent connection daemon (better for large inventories); +the local mode opens a new HTTPS connection per task. +""" + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import json +import os + + +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode # Python 2 + +try: + import urllib3 + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +except ImportError: + pass + +from ansible.module_utils._text import to_text +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.connection import Connection, ConnectionError +from ansible.module_utils.urls import open_url + + +class VyOSRestError(Exception): + """Raised when the VyOS REST API returns an error or is unreachable.""" + + pass + + +class VyOSRestClient: + """Unified VyOS REST API client. + + Automatically selects transport: + * **httpapi** when module._socket_path is set (ansible_connection=httpapi). + * **local HTTP** falls back to direct open_url calls otherwise. + """ + + def __init__(self, module): + self.module = module + socket_path = getattr(module, "_socket_path", None) or os.environ.get("ANSIBLE_SOCKET") + if socket_path and os.path.exists(socket_path): + self._mode = "httpapi" + self._conn = Connection(socket_path) + else: + self._mode = "local" + self._init_local() + + def _init_local(self): + p = self.module.params + host = p.get("hostname") or "" + if not host: + self.module.fail_json( + msg=( + "No device address available. Either:\n" + " * Set ansible_connection=ansible.netcommon.httpapi with " + "ansible_network_os=vyos.rest.vyos in inventory (recommended), or\n" + " * Set the 'hostname' task parameter, or\n" + " * Export VYOS_HOST=<address>" + ), + ) + self.hostname = host + self.port = p.get("port") or 443 + self.api_key = p.get("api_key") or "" + self.timeout = p.get("timeout") or 30 + self.verify_ssl = bool(p.get("verify_ssl")) + self.base_url = "https://{host}:{port}".format(host=self.hostname, port=self.port) + + def _post(self, endpoint, payload): + if self._mode == "httpapi": + return self._post_httpapi(endpoint, payload) + return self._post_local(endpoint, payload) + + def _post_httpapi(self, endpoint, payload): + try: + result = self._conn.send_request(endpoint=endpoint, **payload) + return result + except ConnectionError as exc: + raise VyOSRestError(str(exc)) + except Exception as exc: + raise VyOSRestError( + "httpapi send_request to {ep} failed: {e}".format(ep=endpoint, e=str(exc)), + ) + + def _post_local(self, endpoint, payload): + url = "{base}{ep}".format(base=self.base_url, ep=endpoint) + body = json.dumps(payload) + form = urlencode({"data": body, "key": self.api_key}) + headers = {"Content-Type": "application/x-www-form-urlencoded"} + try: + response = open_url( + url, + data=form, + headers=headers, + method="POST", + timeout=self.timeout, + validate_certs=self.verify_ssl, + ) + raw = to_text(response.read()) + except Exception as exc: + raise VyOSRestError("HTTP POST to {url} failed: {e}".format(url=url, e=str(exc))) + try: + result = json.loads(raw) + except ValueError: + raise VyOSRestError("Invalid JSON from {url}: {raw}".format(url=url, raw=raw)) + if not result.get("success"): + raise VyOSRestError( + "VyOS API error at {url}: {err}".format( + url=url, + err=result.get("error", "unknown"), + ), + ) + return result + + def _get(self, endpoint, params=None): + if self._mode == "httpapi": + try: + return self._conn.get_info() + except Exception as exc: + raise VyOSRestError("httpapi get_info failed: {e}".format(e=str(exc))) + url = "{base}{ep}".format(base=self.base_url, ep=endpoint) + if params: + qs = "&".join("{k}={v}".format(k=k, v=v) for k, v in params.items()) + url = "{url}?{qs}".format(url=url, qs=qs) + try: + response = open_url( + url, + method="GET", + timeout=self.timeout, + validate_certs=self.verify_ssl, + ) + return json.loads(to_text(response.read())) + except Exception as exc: + raise VyOSRestError("HTTP GET to {url} failed: {e}".format(url=url, e=str(exc))) + + # High-level helpers — identical interface regardless of transport + def configure_set(self, path, value=None): + payload = {"op": "set", "path": path} + if value is not None: + payload["value"] = value + return self._post("/configure", payload) + + def configure_delete(self, path): + return self._post("/configure", {"op": "delete", "path": path}) + + def configure_comment(self, path, comment): + return self._post("/configure", {"op": "comment", "path": path, "value": comment}) + + def retrieve_show_config(self, path=None): + return self._post("/retrieve", {"op": "showConfig", "path": path or []}) + + def retrieve_return_values(self, path): + return self._post("/retrieve", {"op": "returnValues", "path": path}) + + def retrieve_return_value(self, path): + return self._post("/retrieve", {"op": "returnValue", "path": path}) + + def retrieve_exists(self, path): + result = self._post("/retrieve", {"op": "exists", "path": path}) + return bool(result.get("data")) + + def show(self, path): + return self._post("/show", {"op": "show", "path": path}) + + def generate(self, path): + return self._post("/generate", {"op": "generate", "path": path}) + + def reset(self, path): + return self._post("/reset", {"op": "reset", "path": path}) + + def reboot(self, at="now"): + return self._post("/reboot", {"op": "reboot", "path": [at]}) + + def poweroff(self, at="now"): + return self._post("/poweroff", {"op": "poweroff", "path": [at]}) + + def image_add(self, url): + return self._post("/image", {"op": "add", "url": url}) + + def image_delete(self, name): + return self._post("/image", {"op": "delete", "name": name}) + + def config_file_save(self, path=None): + payload = {"op": "save"} + if path: + payload["file"] = path + return self._post("/config-file", payload) + + def config_file_load(self, path): + return self._post("/config-file", {"op": "load", "file": path}) + + def info(self, version=None, hostname=None): + params = {} + if version is not None: + params["version"] = str(version).lower() + if hostname is not None: + params["hostname"] = str(hostname).lower() + return self._get("/info", params or None) + + +# Shared argument spec — only used in local mode; ignored when httpapi is active +VYOS_REST_CONNECTION_ARGSPEC = dict( + hostname=dict( + type="str", + required=False, + default=None, + fallback=(env_fallback, ["VYOS_HOST"]), + ), + port=dict( + type="int", + default=443, + fallback=(env_fallback, ["VYOS_PORT"]), + ), + api_key=dict( + type="str", + required=False, + default=None, + no_log=True, + fallback=(env_fallback, ["VYOS_API_KEY"]), + ), + timeout=dict(type="int", default=30), + verify_ssl=dict( + type="bool", + default=False, + fallback=(env_fallback, ["VYOS_VERIFY_SSL"]), + ), +) |
