summaryrefslogtreecommitdiff
path: root/plugins/module_utils
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/module_utils')
-rw-r--r--plugins/module_utils/utils.py64
-rw-r--r--plugins/module_utils/vyos.py231
-rw-r--r--plugins/module_utils/vyos_rest.py265
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"]),
+ ),
+)