diff options
| author | omnom62 <omnom62@outlook.com> | 2026-03-02 21:20:30 +1000 |
|---|---|---|
| committer | omnom62 <omnom62@outlook.com> | 2026-03-02 21:20:30 +1000 |
| commit | 7952a8645124ef160c2759a2dae9e9cb32eac1a9 (patch) | |
| tree | a01436ff4f4af14d7386715e9e7c613f05d5ac2c /plugins | |
| download | rest.vyos-7952a8645124ef160c2759a2dae9e9cb32eac1a9.tar.gz rest.vyos-7952a8645124ef160c2759a2dae9e9cb32eac1a9.zip | |
first commit
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/README.md | 31 | ||||
| -rw-r--r-- | plugins/httpapi/vyos.py | 65 | ||||
| -rw-r--r-- | plugins/module_utils/utils.py | 40 | ||||
| -rw-r--r-- | plugins/module_utils/vyos.py | 177 | ||||
| -rw-r--r-- | plugins/modules/vyos_ntp_global.py | 379 |
5 files changed, 692 insertions, 0 deletions
diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..615f703 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,31 @@ +# Collections Plugins Directory + +This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that +is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that +would contain module utils and modules respectively. + +Here is an example directory of the majority of plugins currently supported by Ansible: + +``` +└── plugins + ├── action + ├── become + ├── cache + ├── callback + ├── cliconf + ├── connection + ├── filter + ├── httpapi + ├── inventory + ├── lookup + ├── module_utils + ├── modules + ├── netconf + ├── shell + ├── strategy + ├── terminal + ├── test + └── vars +``` + +A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.18/plugins/plugins.html). diff --git a/plugins/httpapi/vyos.py b/plugins/httpapi/vyos.py new file mode 100644 index 0000000..049ef97 --- /dev/null +++ b/plugins/httpapi/vyos.py @@ -0,0 +1,65 @@ +from ansible.plugins.httpapi import HttpApiBase +from urllib.parse import urlencode +import json + + +DOCUMENTATION = r''' +--- +httpapi: vyos +short_description: VyOS REST API +description: + - HTTPAPI plugin for interacting with VyOS REST API. +options: + api_key: + description: VyOS API key + required: true + type: str + vars: + - name: ansible_httpapi_api_key + env: + - name: ANSIBLE_HTTPAPI_API_KEY +''' + + +class HttpApi(HttpApiBase): + + def set_options(self, task_keys=None, var_options=None, direct=None): + super().set_options(task_keys=task_keys, var_options=var_options, direct=direct) + + def send_request(self, path, data=None, method="POST", headers=None): + + api_key = self.get_option("api_key") + + form_data = {} + + if data is not None: + if not isinstance(data, str): + data = json.dumps(data) + form_data["data"] = data + + form_data["key"] = api_key + + body = urlencode(form_data).encode("utf-8") + + if headers is None: + headers = {} + + headers["Content-Type"] = "application/x-www-form-urlencoded" + + status, response = self.connection.send( + path, + body, + method=method, + headers=headers + ) + + if hasattr(response, "read"): + response = response.read() + + if isinstance(response, bytes): + response = response.decode("utf-8") + + if isinstance(response, str): + response = json.loads(response) + + return response
\ No newline at end of file diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py new file mode 100644 index 0000000..f256247 --- /dev/null +++ b/plugins/module_utils/utils.py @@ -0,0 +1,40 @@ +def normalize_to_list(value): + """ + Normalize VyOS REST return values to list. + + Handles: + dict -> keys + list -> same + str -> [value] + None -> [] + """ + if isinstance(value, dict): + return list(value.keys()) + + if isinstance(value, list): + return value + + if isinstance(value, str): + return [value] + + return [] + + +def normalize_to_dict(value): + """ + Normalize VyOS REST return values to dict. + + list -> {item: {}} + str -> {value: {}} + dict -> same + """ + if isinstance(value, dict): + return value + + if isinstance(value, list): + return {v: {} for v in value} + + if isinstance(value, str): + return {value: {}} + + return {}
\ No newline at end of file diff --git a/plugins/module_utils/vyos.py b/plugins/module_utils/vyos.py new file mode 100644 index 0000000..aa17a9f --- /dev/null +++ b/plugins/module_utils/vyos.py @@ -0,0 +1,177 @@ +from ansible.module_utils.connection import Connection +import json + + +class VyOSModule: + + 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 = {} + self.commands = [] + + # + # 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"): + self.module.fail_json(msg="VyOS error", response=response) + + return response.get("data", {}) + + # + # Fetch config + # + def get_config(self, path_list): + + self.existing = self._send( + path="/retrieve", + op="showConfig", + path_list=path_list + ) + + return self.existing + + # + # Add command + # + def add_command(self, cmd): + + self.commands.append(cmd) + + # + # Diff + # + def get_diff(self): + + diff = [] + + existing_servers = [] + + # + # Extract existing servers safely + # + if isinstance(self.existing, dict): + server_block = self.existing.get("server", {}) + if isinstance(server_block, dict): + existing_servers = list(server_block.keys()) + + # + # Compare commands vs existing state + # + for cmd in self.commands: + + op = cmd.get("op") + path = cmd.get("path", []) + + if not path: + continue + + server = path[-1] + + if op == "set": + if server not in existing_servers: + diff.append(cmd) + + elif op == "delete": + if server in existing_servers: + diff.append(cmd) + + return diff + # + # Apply config + # + def apply_config(self, diff): + + if not diff: + self.module.exit_json( + changed=False, + msg="Already configured" + ) + + response = self._send( + path="/configure", + commands=diff + ) + + self.module.exit_json( + changed=True, + commands=diff, + response=response + ) + + # + # Apply commands + # + def apply_commands(self, commands): + + if not commands: + self.module.exit_json( + changed=False, + commands=[], + msg="Already configured" + ) + + # convert tuples to dicts for API + 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) + else: + self.module.fail_json(msg=f"Invalid command type: {cmd}") + + response = self._send( + path="/configure", + commands=payload_commands + ) + + self.module.exit_json( + changed=True, + commands=commands, + response=response + ) + # + # Delete config + # + def delete_config(self, cmds): + + response = self._send( + path="/configure", + commands=cmds + ) + + self.module.exit_json( + changed=True, + commands=cmds, + response=response + )
\ No newline at end of file diff --git a/plugins/modules/vyos_ntp_global.py b/plugins/modules/vyos_ntp_global.py new file mode 100644 index 0000000..746838c --- /dev/null +++ b/plugins/modules/vyos_ntp_global.py @@ -0,0 +1,379 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule +from ansible_collections.vyos.rest.plugins.module_utils.utils import normalize_to_list + +# ------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------ + +def normalize_config(config): + + result = { + "allow_clients": sorted(config.get("allow_clients", [])), + "listen_addresses": sorted(config.get("listen_addresses", [])), + "servers": {} + } + + for s in config.get("servers", []): + + name = s["server"] + + result["servers"][name] = sorted(s.get("options", [])) + + return result + +def normalize_servers(value): + result = {} + + if isinstance(value, dict): + for server, data in value.items(): + + if isinstance(data, dict): + result[server] = sorted(list(data.keys())) + + elif isinstance(data, list): + result[server] = sorted(data) + + elif isinstance(data, str): + result[server] = [data] + + else: + result[server] = [] + + elif isinstance(value, list): + for server in value: + result[server] = [] + + elif isinstance(value, str): + result[value] = [] + + return result + + +def get_running_config(vyos): + + raw = vyos.get_config(["service", "ntp"]) + + result = { + "allow_clients": [], + "listen_addresses": [], + "servers": {} + } + + if not raw: + return result + + allow_raw = raw.get("allow-client", {}).get("address", []) + result["allow_clients"] = sorted(normalize_to_list(allow_raw)) + + listen_raw = raw.get("listen-address", []) + result["listen_addresses"] = sorted(normalize_to_list(listen_raw)) + + servers_raw = raw.get("server", {}) + result["servers"] = normalize_servers(servers_raw) + + return result + +def build_commands(desired, existing, state): + + cmds = [] + + # overridden = delete all then merged + if state == "overridden": + + if existing["allow_clients"]: + cmds.append(("delete", ["service", "ntp", "allow-clients"])) + + if existing["listen_addresses"]: + cmds.append(("delete", ["service", "ntp", "listen-address"])) + + if existing["servers"]: + cmds.append(("delete", ["service", "ntp", "server"])) + + state = "merged" + + cmds += diff_list( + "allow-client", "address", # singular + desired["allow_clients"], + existing["allow_clients"], + state + ) + + # listen_addresses + cmds += diff_list( + "listen-address", None, + desired["listen_addresses"], + existing["listen_addresses"], + state + ) + + # servers + cmds += diff_servers( + desired["servers"], + existing["servers"], + state + ) + + return cmds + + +def diff_list(node, subnode, desired, existing, state): + + cmds = [] + + desired = set(desired) + existing = set(existing) + + if state in ["merged", "replaced"]: + + for v in desired - existing: + + path = ["service", "ntp", node] + + if subnode: + path += [subnode, v] + else: + path += [v] + + cmds.append(("set", path)) + + if state in ["replaced", "deleted"]: + + for v in existing - desired: + + path = ["service", "ntp", node] + + if subnode: + path += [subnode, v] + else: + path += [v] + + cmds.append(("delete", path)) + + return cmds + + +def diff_servers(desired, existing, state): + + cmds = [] + + desired_set = set(desired.keys()) + existing_set = set(existing.keys()) + + # add/update + if state in ["merged", "replaced"]: + + for server in desired_set: + + desired_opts = set(desired[server]) + existing_opts = set(existing.get(server, [])) + + # add server + if server not in existing_set: + cmds.append(("set", ["service", "ntp", "server", server])) + + # add options + for opt in desired_opts - existing_opts: + + cmds.append( + ("set", ["service", "ntp", "server", server, opt]) + ) + + # remove options (replaced only) + if state == "replaced": + + for opt in existing_opts - desired_opts: + + cmds.append( + ("delete", ["service", "ntp", "server", server, opt]) + ) + + # delete + if state in ["replaced", "deleted"]: + + for server in existing_set - desired_set: + + cmds.append(("delete", ["service", "ntp", "server", server])) + + return cmds + + +def parse_running_config(text): + + result = { + "allow_clients": [], + "listen_addresses": [], + "servers": {} + } + + for line in text.splitlines(): + + parts = line.strip().split() + + if len(parts) < 4: + continue + + if parts[3] == "allow-clients": + result["allow_clients"].append(parts[-1]) + + elif parts[3] == "listen-address": + result["listen_addresses"].append(parts[-1]) + + elif parts[3] == "server": + + server = parts[4] + + if server not in result["servers"]: + result["servers"][server] = [] + + if len(parts) > 5: + result["servers"][server].append(parts[5]) + + return result + + +def render_commands(config): + + cmds = [] + + for c in config["allow_clients"]: + cmds.append(f"set service ntp allow-clients address {c}") + + for l in config["listen_addresses"]: + cmds.append(f"set service ntp listen-address {l}") + + for server, opts in config["servers"].items(): + + if not opts: + cmds.append(f"set service ntp server {server}") + + for opt in opts: + cmds.append(f"set service ntp server {server} {opt}") + + return cmds + +# ------------------------------------------------------------ +# Main +# ------------------------------------------------------------ + +def main(): + + argument_spec = dict( + + config=dict( + type="dict", + options=dict( + allow_clients=dict(type="list", elements="str"), + listen_addresses=dict(type="list", elements="str"), + servers=dict( + type="list", + elements="dict", + options=dict( + server=dict(type="str", required=True), + options=dict(type="list", elements="str") + ) + ) + ) + ), + + running_config=dict(type="str"), + + state=dict( + default="merged", + choices=[ + "merged", + "replaced", + "overridden", + "deleted", + "gathered", + "rendered", + "parsed" + ] + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or {} + + result = { + "changed": False + } + + # -------------------------------------------------------- + # parsed + # -------------------------------------------------------- + + if state == "parsed": + + parsed = parse_running_config(module.params["running_config"]) + + module.exit_json(parsed=parsed) + + # -------------------------------------------------------- + # rendered + # -------------------------------------------------------- + + desired = normalize_config(config) + + if state == "rendered": + + module.exit_json(rendered=render_commands(desired)) + + # -------------------------------------------------------- + # gathered + # -------------------------------------------------------- + + existing = get_running_config(vyos) + + if state == "gathered": + + module.exit_json(gathered=existing) + + # -------------------------------------------------------- + # deleted + # -------------------------------------------------------- + + if state == "deleted": + + desired = { + "allow_clients": [], + "listen_addresses": [], + "servers": {} + } + + # -------------------------------------------------------- + # diff engine + # -------------------------------------------------------- + + commands = build_commands(desired, existing, state) + + result["before"] = existing + + result["commands"] = commands + + if commands: + + result["changed"] = True + + if not module.check_mode: + + vyos.apply_commands(commands) + + result["after"] = desired + + module.exit_json(**result) + + +if __name__ == "__main__": + main()
\ No newline at end of file |
