diff options
| author | omnom62 <omnom62@outlook.com> | 2026-04-15 07:24:24 +1000 |
|---|---|---|
| committer | omnom62 <omnom62@outlook.com> | 2026-04-15 07:24:24 +1000 |
| commit | c3671c4e3e2ad7efa119982f2ee41334fcacd073 (patch) | |
| tree | 0084638d1b9f71b6be671324779238439f9da5d6 | |
| parent | c9e5c5f6d91bae587a6ce8b7f055b0d068129fe2 (diff) | |
| download | rest.vyos-c3671c4e3e2ad7efa119982f2ee41334fcacd073.tar.gz rest.vyos-c3671c4e3e2ad7efa119982f2ee41334fcacd073.zip | |
Rest API module
| -rw-r--r-- | README.md | 29 | ||||
| -rw-r--r-- | docs/vyos.rest.vyos_banner_module.rst | 56 | ||||
| -rw-r--r-- | meta/runtime.yml | 2 | ||||
| -rw-r--r-- | plugins/module_utils/vyos.py | 183 | ||||
| -rw-r--r-- | plugins/modules/vyos_banner.py | 310 | ||||
| -rw-r--r-- | plugins/modules/vyos_hostname.py | 186 | ||||
| -rw-r--r-- | plugins/modules/vyos_ntp_global.py | 47 | ||||
| -rw-r--r-- | plugins/modules/vyos_snmp_server.py | 1098 |
8 files changed, 1619 insertions, 292 deletions
@@ -2,12 +2,27 @@ Ansible collection for managing VyOS via REST API. -## Plugins - -### Httpapi Plugins -<!--start: httpapi --> -<!--end: httpapi --> +<!--start collection content--> +### Httpapi plugins +Name | Description +--- | --- +[vyos.rest.vyos](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_httpapi.rst)|VyOS REST API ### Modules -<!--start: modules --> -<!--end: modules --> +Name | Description +--- | --- +[vyos.rest.vyos_banner](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_banner_module.rst)|Manage login banners on VyOS devices using REST API +[vyos.rest.vyos_hostname](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_hostname_module.rst)|Manage the hostname of a VyOS device using REST API +[vyos.rest.vyos_snmp_server](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_snmp_server_module.rst)|Manage SNMP server configuration on VyOS devices using REST API + +<!--end collection content--> + +<!--start requires_ansible--> +## Ansible version compatibility + +This collection has been tested against the following Ansible versions: **>=2.16.0**. + +Plugins and modules within a collection may be tested with only specific Ansible versions. +A collection may contain metadata that identifies these versions. +PEP440 is the schema used to describe the versions of Ansible. +<!--end requires_ansible--> diff --git a/docs/vyos.rest.vyos_banner_module.rst b/docs/vyos.rest.vyos_banner_module.rst index f09f98b..7e7b285 100644 --- a/docs/vyos.rest.vyos_banner_module.rst +++ b/docs/vyos.rest.vyos_banner_module.rst @@ -19,8 +19,9 @@ Synopsis -------- - Configure pre-login and post-login banners on VyOS devices via REST API. - Supports idempotent configuration using structured data. -- Multiline banner text is supported. +- Multiline banner text is supported via YAML block scalars. - Uses REST API (``connection=httpapi``) instead of CLI. +- VyOS stores multiline banners as a single string with literal ``\\n`` separators. @@ -86,7 +87,8 @@ Parameters <td> </td> <td> - <div>Banner text (supports multiline string).</div> + <div>Banner text. Supports multiline strings via YAML block scalar (|).</div> + <div>Internally, real newlines are converted to literal <code>\\n</code> as VyOS expects.</div> </td> </tr> @@ -122,6 +124,8 @@ Notes .. note:: - This module requires ``ansible_connection=httpapi``. - Banner text comparison is whitespace-normalized for idempotency. + - VyOS stores banner text as a leaf string value with literal ``\\n`` for newlines. + - For ``merged``, ``replaced``, and ``overridden`` states, the behaviour is identical for this single-leaf resource — the banner value is set to the desired text. @@ -130,21 +134,28 @@ Examples .. code-block:: yaml - - name: Configure pre-login banner + - name: Configure single-line pre-login banner vyos.rest.vyos_banner: config: banner: pre-login - text: | - Unauthorized access is prohibited - Disconnect immediately + text: "Unauthorized access is prohibited" state: merged - - name: Replace post-login banner + - name: Configure multiline post-login banner vyos.rest.vyos_banner: config: banner: post-login text: | Welcome to VyOS + Authorized users only + Disconnect if you are not authorized + state: merged + + - name: Replace post-login banner + vyos.rest.vyos_banner: + config: + banner: post-login + text: "Welcome to VyOS" state: replaced - name: Remove pre-login banner @@ -184,8 +195,10 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late </td> <td>when changed</td> <td> - <div>Configuration after changes.</div> + <div>Configuration after changes (text uses real newlines).</div> <br/> + <div style="font-size: smaller"><b>Sample:</b></div> + <div style="font-size: smaller; color: blue; word-wrap: break-word; word-break: break-all;">{'banner': 'pre-login', 'text': 'New banner text'}</div> </td> </tr> <tr> @@ -199,8 +212,10 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late </td> <td>always</td> <td> - <div>Configuration before changes.</div> + <div>Configuration before changes (text uses real newlines).</div> <br/> + <div style="font-size: smaller"><b>Sample:</b></div> + <div style="font-size: smaller; color: blue; word-wrap: break-word; word-break: break-all;">{'banner': 'pre-login', 'text': 'Old banner text'}</div> </td> </tr> <tr> @@ -214,8 +229,10 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late </td> <td>when changes are required</td> <td> - <div>List of commands sent to the device.</div> + <div>List of API command dicts sent to the device.</div> <br/> + <div style="font-size: smaller"><b>Sample:</b></div> + <div style="font-size: smaller; color: blue; word-wrap: break-word; word-break: break-all;">[{'op': 'set', 'path': ['system', 'login', 'banner', 'pre-login'], 'value': 'New banner text'}]</div> </td> </tr> <tr> @@ -229,8 +246,10 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late </td> <td>when state is gathered</td> <td> - <div>Current device configuration.</div> + <div>Current device configuration (text uses real newlines).</div> <br/> + <div style="font-size: smaller"><b>Sample:</b></div> + <div style="font-size: smaller; color: blue; word-wrap: break-word; word-break: break-all;">{'banner': 'pre-login', 'text': 'Current banner text'}</div> </td> </tr> <tr> @@ -248,6 +267,21 @@ Common return values are documented `here <https://docs.ansible.com/ansible/late <br/> </td> </tr> + <tr> + <td colspan="1"> + <div class="ansibleOptionAnchor" id="return-"></div> + <b>saved</b> + <a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a> + <div style="font-size: small"> + <span style="color: purple">dictionary</span> + </div> + </td> + <td>when changes are applied</td> + <td> + <div>Result of save_config call after applying changes.</div> + <br/> + </td> + </tr> </table> <br/><br/> diff --git a/meta/runtime.yml b/meta/runtime.yml index 35e8e92..ceb0d37 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -1,4 +1,4 @@ -# requires_ansible: ">=2.16.0" +requires_ansible: ">=2.16.0" # plugin_routing: diff --git a/plugins/module_utils/vyos.py b/plugins/module_utils/vyos.py index 525ed0c..469fbd3 100644 --- a/plugins/module_utils/vyos.py +++ b/plugins/module_utils/vyos.py @@ -4,142 +4,80 @@ from ansible.module_utils.connection import Connection 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): + # ---------------------------------------------------------- + # 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, - ) + 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) + 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, + ) - return response.get("data", {}) + return response.get("data") or {} - # - # Fetch config - # - def get_config(self, path_list): + # ---------------------------------------------------------- + # Config retrieval + # ---------------------------------------------------------- - self.existing = self._send( + def get_config(self, path_list): + """ + Fetch config at path_list via showConfig. + Returns empty dict when path has no configuration. + """ + result = self._send( path="/retrieve", op="showConfig", path_list=path_list, ) + self.existing = result + return result - 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) + # ---------------------------------------------------------- + # Config application + # ---------------------------------------------------------- - 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): - + """ + 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: - self.module.exit_json( - changed=False, - commands=[], - msg="Already configured", - ) + return {} - # convert tuples to dicts for API payload_commands = [] for cmd in commands: if isinstance(cmd, tuple): @@ -148,42 +86,19 @@ class VyOSModule: elif isinstance(cmd, dict): payload_commands.append(cmd) else: - self.module.fail_json(msg=f"Invalid command type: {cmd}") + self.module.fail_json( + msg="Invalid command format: {0}".format(cmd), + ) - response = self._send( + return self._send( path="/configure", commands=payload_commands, ) - self.save_config() - - 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, - ) - def save_config(self): - - response = self._send( # noqa: F841 - path="/config-file", - op="save", - ) - + """ + Persist running config to disk. + Returns True on success, fails via fail_json on error. + """ + self._send(path="/config-file", op="save") return True diff --git a/plugins/modules/vyos_banner.py b/plugins/modules/vyos_banner.py index 0c35f78..4824629 100644 --- a/plugins/modules/vyos_banner.py +++ b/plugins/modules/vyos_banner.py @@ -11,8 +11,9 @@ short_description: Manage login banners on VyOS devices using REST API description: - Configure pre-login and post-login banners on VyOS devices via REST API. - Supports idempotent configuration using structured data. - - Multiline banner text is supported. + - Multiline banner text is supported via YAML block scalars. - Uses REST API (C(connection=httpapi)) instead of CLI. + - VyOS stores multiline banners as a single string with literal C(\\n) separators. version_added: "1.0.0" author: - Your Name (@yourhandle) @@ -34,7 +35,8 @@ options: - post-login text: description: - - Banner text (supports multiline string). + - Banner text. Supports multiline strings via YAML block scalar (|). + - Internally, real newlines are converted to literal C(\\n) as VyOS expects. type: str state: @@ -52,24 +54,34 @@ options: notes: - This module requires C(ansible_connection=httpapi). - Banner text comparison is whitespace-normalized for idempotency. + - VyOS stores banner text as a leaf string value with literal C(\\n) for newlines. + - For C(merged), C(replaced), and C(overridden) states, the behaviour is identical + for this single-leaf resource — the banner value is set to the desired text. """ EXAMPLES = r""" -- name: Configure pre-login banner +- name: Configure single-line pre-login banner vyos.rest.vyos_banner: config: banner: pre-login - text: | - Unauthorized access is prohibited - Disconnect immediately + text: "Unauthorized access is prohibited" state: merged -- name: Replace post-login banner +- name: Configure multiline post-login banner vyos.rest.vyos_banner: config: banner: post-login text: | Welcome to VyOS + Authorized users only + Disconnect if you are not authorized + state: merged + +- name: Replace post-login banner + vyos.rest.vyos_banner: + config: + banner: post-login + text: "Welcome to VyOS" state: replaced - name: Remove pre-login banner @@ -87,44 +99,128 @@ EXAMPLES = r""" RETURN = r""" before: - description: Configuration before changes. + description: Configuration before changes (text uses real newlines). returned: always type: dict + sample: + banner: pre-login + text: "Old banner text" after: - description: Configuration after changes. + description: Configuration after changes (text uses real newlines). returned: when changed type: dict + sample: + banner: pre-login + text: "New banner text" commands: - description: List of commands sent to the device. + description: List of API command dicts sent to the device. returned: when changes are required type: list + sample: + - op: set + path: ["system", "login", "banner", "pre-login"] + value: "New banner text" gathered: - description: Current device configuration. + description: Current device configuration (text uses real newlines). returned: when state is gathered type: dict + sample: + banner: pre-login + text: "Current banner text" response: description: Raw response from VyOS REST API. returned: when changes are applied type: dict + +saved: + description: Result of save_config call after applying changes. + returned: when changes are applied + type: dict """ # ------------------------------------------------------------ -# Helpers +# Text conversion helpers +# +# Internal canonical form: real Python newlines (\n), stripped. +# +# VyOS wire format: literal backslash-n (\\n) sequences within +# a single quoted string, e.g. 'line one\nline two\nline three'. +# +# Rule: convert FROM wire format on read, TO wire format on write, +# always compare in canonical (internal) form. # ------------------------------------------------------------ def normalize_text(text): + """ + Normalize text to canonical internal form. + + - Converts to str if needed + - Strips each line + - Removes leading/trailing blank lines + - Joins with real newlines + - Returns None if result is empty + """ if text is None: return None - return text.strip() + lines = [line.strip() for line in text.strip().splitlines()] + # Trim leading blank lines + while lines and not lines[0]: + lines.pop(0) + # Trim trailing blank lines + while lines and not lines[-1]: + lines.pop() + return "\n".join(lines) if lines else None + + +def text_from_api_value(raw): + """ + Convert VyOS API/device value to canonical internal form. + + VyOS returns multiline banners with literal \\n sequences: + 'line one\\nline two\\nline three' + + Convert those to real newlines first, then normalize. + """ + if raw is None: + return None + return normalize_text(raw.replace("\\n", "\n")) + + +def text_to_api_value(text): + """ + Convert canonical internal form to VyOS wire format. + + Real newlines become literal \\n sequences as VyOS expects. + Returns None if input normalizes to None. + """ + normalized = normalize_text(text) + if normalized is None: + return None + return normalized.replace("\n", "\\n") + + +# ------------------------------------------------------------ +# Device interaction +# ------------------------------------------------------------ def get_running_config(vyos, banner): + """ + Fetch current banner configuration from the device. + + The VyOS showConfig API returns banner text as a plain string value: + {"pre-login": "test1"} + {"post-login": "line one\\nline two\\nline three"} + + Returns: + {"banner": <str>, "text": <normalized str or None>} + """ try: raw = vyos.get_config(["system", "login", "banner"]) except Exception as e: @@ -137,137 +233,80 @@ def get_running_config(vyos, banner): val = raw.get(banner) - if not val: + if val is None: return {"banner": banner, "text": None} if isinstance(val, str): - return { - "banner": banner, - "text": val.strip(), - } - - if isinstance(val, list): - lines = [line.strip() for line in val if line.strip()] - return { - "banner": banner, - "text": "\n".join(lines) if lines else None, - } - - return {"banner": banner, "text": None} + # Convert from wire format (literal \n) to internal form (real newlines) + return {"banner": banner, "text": text_from_api_value(val)} + + # Unexpected type — surface clearly rather than silently swallowing + raise ValueError( + "Unexpected banner value type '{t}' returned by API (value={v!r}). " + "Expected a plain string. Please file a bug.".format( + t=type(val).__name__, + v=val, + ), + ) def build_commands(want, have, state): """ - Build list of VyOS REST API commands to apply `want` configuration - based on current `have` configuration and desired `state`. - Handles: - - missing paths - - idempotency - - multiline banners - - merged, replaced, overridden, deleted - """ - commands = [] + Build list of VyOS REST API command dicts. - banner = want["banner"] - want_text = want.get("text") - have_text = have.get("text") + VyOS banner API payload structure: + SET: {"op": "set", "path": ["system","login","banner","<type>"], "value": "<text>"} + DELETE: {"op": "delete", "path": ["system","login","banner","<type>"]} - base_path = ["system", "login", "banner", banner] + The banner text is always a leaf VALUE on the path, never a path segment. + + All text comparison is done in canonical internal form (real newlines). + Conversion to wire format (literal \\n) happens only when building the + final API command value. + """ + banner = want["banner"] - want_lines = want_text.splitlines() if want_text else [] - have_lines = have_text.splitlines() if have_text else [] + # Canonical internal form for comparison + want_text = normalize_text(want.get("text")) + have_text = have.get("text") # already in canonical form from get_running_config + base_path = ["system", "login", "banner", banner] banner_exists = have_text is not None + # -------------------------------------------------------- - # deleted + # deleted: remove the banner node if it exists # -------------------------------------------------------- if state == "deleted": - if banner_exists or have_text is not None: - commands.append( - { - "op": "delete", - "path": base_path, - }, - ) - return commands + if banner_exists: + return [{"op": "delete", "path": base_path}] + return [] # -------------------------------------------------------- - # merged + # merged / replaced / overridden + # + # For this single leaf-value resource all three states are + # semantically equivalent: ensure the desired value is set. + # Idempotency: skip if the normalized value already matches. # -------------------------------------------------------- - - if state == "merged": - - # normalize both - want_lines = want_text.splitlines() if want_text else [] - have_lines = have_text.splitlines() if have_text else [] - - # if identical → idempotent - if want_lines == have_lines: + if state in ("merged", "replaced", "overridden"): + if want_text is None: + # No text provided with a non-delete state — nothing to do return [] - # if device empty → just push all - if not have_lines: - return [{"op": "set", "path": base_path + [line]} for line in want_lines] - - # append missing lines only - commands = [] - for line in want_lines: - if line not in have_lines: - commands.append( - { - "op": "set", - "path": base_path + [line], - }, - ) - - return commands - - # if state == "merged": - # # create parent path if missing + if want_text == have_text: + # Already correct — idempotent, no change needed + return [] - # for line in want_lines: - # if line not in have_lines: - # commands.append({ - # "op": "set", - # "path": base_path + [line] - # }) + # Convert to wire format only at the point of building the payload + return [ + { + "op": "set", + "path": base_path, + "value": text_to_api_value(want_text), + }, + ] - # return commands - - # -------------------------------------------------------- - # replaced / overridden - # -------------------------------------------------------- - if state in ["replaced", "overridden"]: - # if existing lines differ from desired lines, delete first - if banner_exists and want_lines != have_lines: - commands.append( - { - "op": "delete", - "path": base_path, - }, - ) - # create parent path again before setting lines - if want_lines: - commands.append( - { - "op": "set", - "path": base_path, - "value": "", - }, - ) - - # set all desired lines - for line in want_lines: - commands.append( - { - "op": "set", - "path": base_path + [line], - }, - ) - - return commands - - return commands + return [] # ------------------------------------------------------------ @@ -311,23 +350,19 @@ def main(): state = module.params["state"] config = module.params.get("config") or {} - banner = config.get("banner") if not banner: module.fail_json(msg="banner is required in config") + # Fetch current device state (text in canonical internal form) have = get_running_config(vyos, banner) # -------------------------------------------------------- - # gathered + # gathered: return current state, no changes # -------------------------------------------------------- - if state == "gathered": - module.exit_json( - changed=False, - gathered=have, - ) + module.exit_json(changed=False, gathered=have) want = { "banner": banner, @@ -336,25 +371,40 @@ def main(): commands = build_commands(want, have, state) + # -------------------------------------------------------- + # check mode: report what would change, make no API calls + # -------------------------------------------------------- if module.check_mode: module.exit_json( changed=bool(commands), commands=commands, + before=have, ) - if commands: + # -------------------------------------------------------- + # apply changes + # -------------------------------------------------------- + if commands: response = vyos.apply_commands(commands) saved = vyos.save_config() module.exit_json( changed=True, before=have, + # Report after-state in canonical form (real newlines, human-readable) after=want, + # after={ + # "banner": banner, + # "text": normalize_text(want["text"]), + # }, commands=commands, saved=saved, response=response, ) + # -------------------------------------------------------- + # no changes needed + # -------------------------------------------------------- module.exit_json( changed=False, before=have, diff --git a/plugins/modules/vyos_hostname.py b/plugins/modules/vyos_hostname.py index 9d70649..89f5cec 100644 --- a/plugins/modules/vyos_hostname.py +++ b/plugins/modules/vyos_hostname.py @@ -4,6 +4,192 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule +DOCUMENTATION = r""" +--- +module: vyos_hostname +short_description: Manage the hostname of a VyOS device using REST API +description: + - Configures the system hostname on VyOS network devices via the REST API. + - Supports idempotent configuration — no change is made if the desired + hostname already matches the running configuration. + - Uses REST API (C(connection=httpapi)) instead of CLI. +version_added: "1.0.0" +author: + - Your Name (@yourhandle) + +options: + config: + description: + - Hostname configuration dictionary. + type: dict + suboptions: + hostname: + description: + - The hostname to set on the VyOS device. + - Must be a valid RFC 1123 hostname. + type: str + + state: + description: + - The desired state of the hostname configuration. + - C(merged), C(replaced), and C(overridden) are identical for this + single-value resource — all three ensure the hostname is set to + the value specified in C(config). + - C(deleted) removes the configured hostname, reverting to the + device default. + - C(gathered) retrieves the current hostname from the device and + returns it as structured data in the C(gathered) key. No changes + are made to the device. + type: str + default: merged + choices: + - merged + - replaced + - overridden + - deleted + - gathered + +notes: + - Tested against VyOS 1.3 (equuleus) and 1.4 (sagitta). + - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin. + - The C(ansible_network_os) inventory variable must be set to C(vyos.rest.vyos). +""" + +EXAMPLES = r""" +# Before state: +# ------------- +# vyos@router:~$ show configuration commands | grep host-name +# set system host-name 'vyostest' + +- name: Set hostname using merged state + vyos.rest.vyos_hostname: + config: + hostname: vyos + state: merged + +# After state: +# ------------ +# set system host-name 'vyos' + +# ------------------------------------------------------------------------ + +# Before state: +# ------------- +# set system host-name 'vyos' + +- name: Override hostname (identical behaviour to merged for this resource) + vyos.rest.vyos_hostname: + config: + hostname: vyosTest + state: overridden + +# After state: +# ------------ +# set system host-name 'vyosTest' + +# ------------------------------------------------------------------------ + +# Before state: +# ------------- +# set system host-name 'vyos' + +- name: Replace hostname + vyos.rest.vyos_hostname: + config: + hostname: vyosRouter + state: replaced + +# After state: +# ------------ +# set system host-name 'vyosRouter' + +# ------------------------------------------------------------------------ + +# Before state: +# ------------- +# set system host-name 'vyos' + +- name: Delete configured hostname + vyos.rest.vyos_hostname: + state: deleted + +# After state: +# ------------ +# (no host-name entry — device reverts to default) + +# ------------------------------------------------------------------------ + +- name: Gather current hostname from device + vyos.rest.vyos_hostname: + state: gathered + +# Module result: +# -------------- +# "gathered": { +# "hostname": "vyos" +# } + +# ------------------------------------------------------------------------ + +- name: Idempotency — no change when hostname already matches + vyos.rest.vyos_hostname: + config: + hostname: vyos + state: merged + +# Module result (hostname already 'vyos'): +# ---------------------------------------- +# "changed": false +""" + +RETURN = r""" +before: + description: Hostname configuration on the device before this module ran. + returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted) + type: dict + sample: + hostname: vyostest + +after: + description: Hostname configuration on the device after this module ran. + returned: when changed + type: dict + sample: + hostname: vyos + +commands: + description: List of API command dicts sent to the device. + returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted) + type: list + sample: + - op: set + path: ["system", "host-name", "vyos"] + +gathered: + description: > + Current hostname configuration retrieved from the device as structured + data. Returned only when I(state) is C(gathered). + returned: when I(state) is C(gathered) + type: dict + sample: + hostname: vyos + +response: + description: Raw response returned by the VyOS REST API. + returned: when changes are applied + type: dict + sample: + success: true + data: null + error: null + +saved: + description: Result of the save_config call issued after applying changes. + returned: when changes are applied + type: dict +""" + + def get_running_config(vyos): raw = vyos.get_config(["system", "host-name"]) diff --git a/plugins/modules/vyos_ntp_global.py b/plugins/modules/vyos_ntp_global.py index d970142..bcfdd3b 100644 --- a/plugins/modules/vyos_ntp_global.py +++ b/plugins/modules/vyos_ntp_global.py @@ -362,23 +362,52 @@ def main(): # diff engine # -------------------------------------------------------- - commands = build_commands(desired, existing, state) + # commands = build_commands(desired, existing, state) - result["before"] = existing + # result["before"] = existing - result["commands"] = commands + # result["commands"] = commands - if commands: + # if commands: + + # result["changed"] = True + + # if not module.check_mode: - result["changed"] = True + # vyos.apply_commands(commands) - if not module.check_mode: + # result["after"] = desired - vyos.apply_commands(commands) + # module.exit_json(**result) - result["after"] = desired + commands = build_commands(desired, existing, state) + + if module.check_mode: + module.exit_json( + changed=bool(commands), + commands=commands, + before=existing, + ) - module.exit_json(**result) + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + + module.exit_json( + changed=True, + before=existing, + after=desired, + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json( + changed=False, + before=existing, + after=existing, + commands=[], + ) if __name__ == "__main__": diff --git a/plugins/modules/vyos_snmp_server.py b/plugins/modules/vyos_snmp_server.py new file mode 100644 index 0000000..a092432 --- /dev/null +++ b/plugins/modules/vyos_snmp_server.py @@ -0,0 +1,1098 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright 2024 Red Hat +# GNU General Public License v3.0+ +# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_snmp_server +short_description: Manage SNMP server configuration on VyOS devices using REST API +description: + - Manages SNMP server configuration on VyOS devices via the REST API. + - Supports communities, listen addresses, contact/location/description scalar + fields, trap target, and SNMPv3 (engine ID, groups, users, views). + - Uses REST API (C(connection=httpapi)) instead of CLI. +version_added: "1.0.0" +author: your_name (@yourhandle) + +options: + config: + description: SNMP server configuration. + type: dict + suboptions: + communities: + description: SNMP community configuration. + type: list + elements: dict + suboptions: + name: + description: Community name. + type: str + required: true + clients: + description: IP addresses of SNMP clients allowed to contact the system. + type: list + elements: str + networks: + description: Subnets of SNMP clients allowed to contact the system. + type: list + elements: str + authorization_type: + description: Authorization type. + type: str + choices: ['ro', 'rw'] + contact: + description: Contact person for the system. + type: str + description: + description: System description. + type: str + location: + description: System location. + type: str + smux_peer: + description: Register a subtree for SMUX-based processing. + type: str + trap_source: + description: SNMP trap source address. + type: str + listen_addresses: + description: IP addresses to listen for incoming SNMP requests. + type: list + elements: dict + suboptions: + address: + description: IP address. + type: str + required: true + port: + description: UDP port (default 161). + type: int + trap_target: + description: SNMP trap target. + type: dict + suboptions: + address: + description: IP address of trap target. + type: str + community: + description: Community string for traps. + type: str + port: + description: Destination port for trap notifications. + type: int + snmp_v3: + description: SNMPv3 configuration. + type: dict + suboptions: + engine_id: + description: EngineID as a hex string. + type: str + groups: + description: SNMPv3 groups. + type: list + elements: dict + suboptions: + group: + description: Group name. + type: str + required: true + mode: + description: Access mode. + type: str + choices: ['ro', 'rw'] + seclevel: + description: Security level. + type: str + choices: ['auth', 'priv'] + view: + description: View name for this group. + type: str + users: + description: SNMPv3 users. + type: list + elements: dict + suboptions: + user: + description: Username. + type: str + required: true + authentication: + description: Authentication configuration. + type: dict + suboptions: + type: + description: Authentication protocol. + type: str + choices: ['md5', 'sha'] + encrypted_key: + description: Encrypted authentication password. + type: str + plaintext_key: + description: Plaintext authentication password (will be encrypted on device). + type: str + no_log: true + privacy: + description: Privacy configuration. + type: dict + suboptions: + type: + description: Privacy protocol. + type: str + choices: ['des', 'aes'] + encrypted_key: + description: Encrypted privacy password. + type: str + plaintext_key: + description: Plaintext privacy password (will be encrypted on device). + type: str + no_log: true + group: + description: Group membership for this user. + type: str + mode: + description: Access mode. + type: str + choices: ['ro', 'rw'] + tsm_key: + description: TSM certificate fingerprint or filename. + type: str + trap_targets: + description: SNMPv3 trap/inform targets. + type: list + elements: dict + suboptions: + address: + description: IP/IPv6 address of trap target. + type: str + port: + description: TCP/UDP port for traps/informs. + type: int + protocol: + description: Notification protocol. + type: str + choices: ['tcp', 'udp'] + type: + description: Notification type. + type: str + choices: ['inform', 'trap'] + authentication: + description: Authentication for this trap target. + type: dict + suboptions: + type: + description: Authentication protocol. + type: str + choices: ['md5', 'sha'] + encrypted_key: + description: Encrypted authentication password. + type: str + plaintext_key: + description: Plaintext authentication password (will be encrypted on device). + type: str + no_log: true + privacy: + description: Privacy for this trap target. + type: dict + suboptions: + type: + description: Privacy protocol. + type: str + choices: ['des', 'aes'] + encrypted_key: + description: Encrypted privacy password. + type: str + plaintext_key: + description: Plaintext privacy password (will be encrypted on device). + type: str + no_log: true + views: + description: SNMPv3 views. + type: list + elements: dict + suboptions: + view: + description: View name. + type: str + required: true + oid: + description: OID for this view. + type: str + exclude: + description: Excluded OID subtree. + type: str + mask: + description: Bit-mask for OID subidentifiers. + type: str + + state: + description: + - Desired state of SNMP configuration. + - C(merged) adds or updates provided config without removing existing entries. + - C(replaced) and C(overridden) perform a full replacement — entries not in + the task config are removed. + - C(deleted) removes all SNMP configuration with a single delete command. + - C(gathered) returns current device config as structured data, no changes made. + type: str + default: merged + choices: + - merged + - replaced + - overridden + - deleted + - gathered + +notes: + - Tested against VyOS 1.5q2. +""" + +EXAMPLES = r""" +- name: Merge SNMP configuration + vyos.rest.vyos_snmp_server: + config: + communities: + - name: switches + authorization_type: rw + - name: bridges + clients: + - 1.1.1.1 + - 12.1.1.10 + contact: admin2@ex.com + listen_addresses: + - address: 20.1.1.1 + - address: 100.1.2.1 + port: 33 + snmp_v3: + users: + - user: admin_user + authentication: + plaintext_key: abc1234567 + type: sha + privacy: + plaintext_key: abc1234567 + type: aes + state: merged + +# ------------------------------------------------------------------------ + +- name: Replace SNMP configuration + vyos.rest.vyos_snmp_server: + config: + communities: + - name: bridges + networks: + - 1.1.1.0/24 + - 12.1.1.0/24 + location: "RDU, NC" + listen_addresses: + - address: 100.1.2.1 + port: 33 + snmp_v3: + groups: + - group: default + view: default + users: + - user: admin_user + authentication: + encrypted_key: 33f8bfd6b69ee03a184818a4daea503c9e579633 + type: sha + privacy: + encrypted_key: 33f8bfd6b69ee03a184818a4daea503c9e579633 + type: aes + group: default + views: + - view: default + oid: "1" + state: replaced + +# ------------------------------------------------------------------------ + +- name: Delete all SNMP configuration + vyos.rest.vyos_snmp_server: + state: deleted + +# ------------------------------------------------------------------------ + +- name: Gather current SNMP configuration + vyos.rest.vyos_snmp_server: + state: gathered +""" + +RETURN = r""" +before: + description: SNMP configuration before this module ran. + returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted) + type: dict + +after: + description: SNMP configuration after this module ran. + returned: when changed + type: dict + +commands: + description: List of API command dicts sent to the device. + returned: always + type: list + +gathered: + description: Current SNMP configuration as structured data. + returned: when I(state) is C(gathered) + type: dict + +response: + description: Raw API response. + returned: when changes are applied + type: dict + +saved: + description: Result of save_config after applying changes. + returned: when changes are applied + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +# ------------------------------------------------------------ +# Constants +# ------------------------------------------------------------ + +SNMP_BASE = ["service", "snmp"] + +# Scalar fields that map directly: argspec_key → api_key +SCALAR_FIELDS = { + "contact": "contact", + "description": "description", + "location": "location", + "smux_peer": "smux-peer", + "trap_source": "trap-source", +} + + +# ------------------------------------------------------------ +# Generic helpers +# ------------------------------------------------------------ + + +def to_list(value): + """Coerce None / str / list → list.""" + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return list(value.keys()) + return [str(value)] + + +# ------------------------------------------------------------ +# Parsing: API response → Ansible argspec dict +# ------------------------------------------------------------ + + +def _parse_communities(raw): + """ + API: {"bridges": {"client": [...], "network": [...]}, "switches": {"authorization": "rw"}} + → [{"name": "bridges", "clients": [...], ...}, ...] + """ + if not raw or not isinstance(raw, dict): + return [] + result = [] + for name, data in sorted(raw.items()): + entry = {"name": name} + if not isinstance(data, dict): + result.append(entry) + continue + if "authorization" in data: + entry["authorization_type"] = data["authorization"] + if "client" in data: + entry["clients"] = sorted(to_list(data["client"])) + if "network" in data: + entry["networks"] = sorted(to_list(data["network"])) + result.append(entry) + return result + + +def _parse_listen_addresses(raw): + """ + API: {"198.51.100.10": {"port": "33"}, "203.0.113.65": {}} + → [{"address": "198.51.100.10", "port": 33}, {"address": "203.0.113.65"}] + """ + if not raw or not isinstance(raw, dict): + return [] + result = [] + for addr, data in sorted(raw.items()): + entry = {"address": addr} + if isinstance(data, dict) and "port" in data: + entry["port"] = int(data["port"]) + result.append(entry) + return result + + +def _parse_trap_target(raw): + """ + API: {"address": "x.x.x.x"} or plain string address, optional port/community. + """ + if not raw: + return None + if isinstance(raw, str): + return {"address": raw} + entry = {} + if "address" in raw: + entry["address"] = raw["address"] + if "community" in raw: + entry["community"] = raw["community"] + if "port" in raw: + entry["port"] = int(raw["port"]) + return entry if entry else None + + +def _parse_v3_auth_privacy(raw, key): + """ + Parse auth or privacy block. + API key is 'auth', argspec key is 'authentication'. + API key is 'privacy', argspec key is 'privacy'. + Returns dict with type and encrypted_key (never plaintext — device never returns it). + """ + block = raw.get(key) if isinstance(raw, dict) else None + if not block: + return None + result = {} + if "type" in block: + result["type"] = block["type"] + # API uses 'encrypted-password'; map to argspec 'encrypted_key' + if "encrypted-password" in block: + result["encrypted_key"] = block["encrypted-password"] + if "plaintext-key" in block: + result["plaintext_key"] = block["plaintext-key"] + return result if result else None + + +def _parse_v3_users(raw): + """ + API: {"adminuser": {"auth": {...}, "group": "testgroup", "privacy": {...}}} + → [{"user": "adminuser", "authentication": {...}, "group": "testgroup", "privacy": {...}}] + """ + if not raw or not isinstance(raw, dict): + return [] + result = [] + for username, data in sorted(raw.items()): + entry = {"user": username} + auth = _parse_v3_auth_privacy(data, "auth") + if auth: + entry["authentication"] = auth + priv = _parse_v3_auth_privacy(data, "privacy") + if priv: + entry["privacy"] = priv + if isinstance(data, dict): + if "group" in data: + entry["group"] = data["group"] + if "mode" in data: + entry["mode"] = data["mode"] + if "tsm-key" in data: + entry["tsm_key"] = data["tsm-key"] + result.append(entry) + return result + + +def _parse_v3_groups(raw): + """ + API: {"testgroup": {"mode": "ro", "view": "default"}} + → [{"group": "testgroup", "mode": "ro", "view": "default"}] + """ + if not raw or not isinstance(raw, dict): + return [] + result = [] + for name, data in sorted(raw.items()): + entry = {"group": name} + if isinstance(data, dict): + for key in ("mode", "seclevel", "view"): + if key in data: + entry[key] = data[key] + result.append(entry) + return result + + +def _parse_v3_views(raw): + """ + API: {"default": {"oid": {"1": {}}}} + → [{"view": "default", "oid": "1"}] + + OID is stored as a dict key with an empty dict value. + """ + if not raw or not isinstance(raw, dict): + return [] + result = [] + for name, data in sorted(raw.items()): + entry = {"view": name} + if isinstance(data, dict) and "oid" in data: + oid_data = data["oid"] + if isinstance(oid_data, dict) and oid_data: + # Extract the first (and usually only) OID key + entry["oid"] = str(list(oid_data.keys())[0]) + elif isinstance(oid_data, str): + entry["oid"] = oid_data + if isinstance(data, dict): + if "exclude" in data: + entry["exclude"] = data["exclude"] + if "mask" in data: + entry["mask"] = data["mask"] + result.append(entry) + return result + + +def _parse_v3_trap_targets(raw): + """ + API: {"x.x.x.x": {"auth": {...}, "privacy": {...}, "port": "162", ...}} + → [{"address": "x.x.x.x", ...}] + """ + if not raw or not isinstance(raw, dict): + return [] + result = [] + for addr, data in sorted(raw.items()): + entry = {"address": addr} + if isinstance(data, dict): + if "port" in data: + entry["port"] = int(data["port"]) + if "protocol" in data: + entry["protocol"] = data["protocol"] + if "type" in data: + entry["type"] = data["type"] + auth = _parse_v3_auth_privacy(data, "auth") + if auth: + entry["authentication"] = auth + priv = _parse_v3_auth_privacy(data, "privacy") + if priv: + entry["privacy"] = priv + result.append(entry) + return result + + +def parse_snmp_config(raw): + """ + Convert full API SNMP response dict → Ansible argspec dict. + """ + if not raw or not isinstance(raw, dict): + return {} + + result = {} + + # Scalar fields + for argspec_key, api_key in SCALAR_FIELDS.items(): + if api_key in raw: + result[argspec_key] = raw[api_key] + + # Communities + communities = _parse_communities(raw.get("community")) + if communities: + result["communities"] = communities + + # Listen addresses + listen = _parse_listen_addresses(raw.get("listen-address")) + if listen: + result["listen_addresses"] = listen + + # Trap target + trap = _parse_trap_target(raw.get("trap-target")) + if trap: + result["trap_target"] = trap + + # SNMPv3 + v3_raw = raw.get("v3") + if v3_raw and isinstance(v3_raw, dict): + v3 = {} + if "engineid" in v3_raw: + v3["engine_id"] = v3_raw["engineid"] + groups = _parse_v3_groups(v3_raw.get("group")) + if groups: + v3["groups"] = groups + users = _parse_v3_users(v3_raw.get("user")) + if users: + v3["users"] = users + views = _parse_v3_views(v3_raw.get("view")) + if views: + v3["views"] = views + trap_targets = _parse_v3_trap_targets(v3_raw.get("trap-target")) + if trap_targets: + v3["trap_targets"] = trap_targets + if v3: + result["snmp_v3"] = v3 + + return result + + +def get_running_config(vyos): + """Fetch and parse current SNMP config from device.""" + try: + raw = vyos.get_config(SNMP_BASE) + except Exception as e: + if "Configuration under specified path is empty" in str(e): + return {} + raise + return parse_snmp_config(raw) + + +# ------------------------------------------------------------ +# Command builders: Ansible argspec dict → API command list +# ------------------------------------------------------------ + + +def _cmd(op, path): + return {"op": op, "path": path} + + +def _set(path): + return _cmd("set", path) + + +def _delete(path): + return _cmd("delete", path) + + +def _build_scalar_commands(want, have, state): + """Build commands for simple string scalar fields.""" + cmds = [] + for argspec_key, api_key in SCALAR_FIELDS.items(): + want_val = want.get(argspec_key) + have_val = have.get(argspec_key) + path = SNMP_BASE + [api_key] + if state in ("merged", "replaced", "overridden"): + if want_val and want_val != have_val: + cmds.append(_set(path + [want_val])) + if state in ("replaced", "overridden"): + if have_val and want_val != have_val: + cmds.append(_delete(path)) + return cmds + + +def _build_community_commands(want_list, have_list, state): + """Build commands for SNMP communities.""" + cmds = [] + want_map = {c["name"]: c for c in (want_list or [])} + have_map = {c["name"]: c for c in (have_list or [])} + + if state in ("replaced", "overridden"): + # Delete communities not in want + for name in have_map: + if name not in want_map: + cmds.append(_delete(SNMP_BASE + ["community", name])) + + for name, want_comm in want_map.items(): + have_comm = have_map.get(name, {}) + base = SNMP_BASE + ["community", name] + + if state in ("merged", "replaced", "overridden"): + # authorization_type + want_auth = want_comm.get("authorization_type") + have_auth = have_comm.get("authorization_type") + if want_auth and want_auth != have_auth: + cmds.append(_set(base + ["authorization", want_auth])) + if state in ("replaced", "overridden") and have_auth and want_auth != have_auth: + cmds.append(_delete(base + ["authorization"])) + + # clients + want_clients = set(want_comm.get("clients") or []) + have_clients = set(have_comm.get("clients") or []) + for c in want_clients - have_clients: + cmds.append(_set(base + ["client", c])) + if state in ("replaced", "overridden"): + for c in have_clients - want_clients: + cmds.append(_delete(base + ["client", c])) + + # networks + want_nets = set(want_comm.get("networks") or []) + have_nets = set(have_comm.get("networks") or []) + for n in want_nets - have_nets: + cmds.append(_set(base + ["network", n])) + if state in ("replaced", "overridden"): + for n in have_nets - want_nets: + cmds.append(_delete(base + ["network", n])) + + return cmds + + +def _build_listen_address_commands(want_list, have_list, state): + """Build commands for listen-address entries.""" + cmds = [] + want_map = {e["address"]: e for e in (want_list or [])} + have_map = {e["address"]: e for e in (have_list or [])} + base = SNMP_BASE + ["listen-address"] + + if state in ("replaced", "overridden"): + for addr in have_map: + if addr not in want_map: + cmds.append(_delete(base + [addr])) + + for addr, want_entry in want_map.items(): + have_entry = have_map.get(addr, {}) + want_port = want_entry.get("port") + have_port = have_entry.get("port") + + if addr not in have_map: + # New address + if want_port: + cmds.append(_set(base + [addr, "port", str(want_port)])) + else: + cmds.append(_set(base + [addr])) + elif want_port != have_port: + # Address exists but port changed — delete and re-set + cmds.append(_delete(base + [addr])) + if want_port: + cmds.append(_set(base + [addr, "port", str(want_port)])) + else: + cmds.append(_set(base + [addr])) + + return cmds + + +def _build_trap_target_commands(want, have, state): + """Build commands for the v2 trap-target scalar dict.""" + cmds = [] + base = SNMP_BASE + ["trap-target"] + + if state in ("merged", "replaced", "overridden"): + if want: + want_addr = want.get("address") + have_addr = have.get("address") if have else None + if want_addr and want_addr != have_addr: + cmds.append(_set(base + [want_addr])) + if want.get("community"): + cmds.append(_set(base + [want_addr, "community", want["community"]])) + if want.get("port"): + cmds.append(_set(base + [want_addr, "port", str(want["port"])])) + + if state in ("replaced", "overridden"): + if have and (not want or have.get("address") != (want or {}).get("address")): + cmds.append(_delete(base)) + + return cmds + + +def _build_v3_auth_privacy_commands(base, want_block, have_block, api_key): + """ + Build set commands for a v3 auth or privacy block. + api_key: 'auth' or 'privacy' + """ + cmds = [] + if not want_block: + return cmds + block_base = base + [api_key] + have_block = have_block or {} + + if want_block.get("type") and want_block["type"] != have_block.get("type"): + cmds.append(_set(block_base + ["type", want_block["type"]])) + if want_block.get("encrypted_key") and want_block["encrypted_key"] != have_block.get( + "encrypted_key", + ): + cmds.append(_set(block_base + ["encrypted-password", want_block["encrypted_key"]])) + if want_block.get("plaintext_key"): + # Always set plaintext — we cannot compare it to the stored encrypted value + cmds.append(_set(block_base + ["plaintext-key", want_block["plaintext_key"]])) + + return cmds + + +def _build_v3_user_commands(want_list, have_list, state): + """Build commands for SNMPv3 users.""" + cmds = [] + want_map = {u["user"]: u for u in (want_list or [])} + have_map = {u["user"]: u for u in (have_list or [])} + base = SNMP_BASE + ["v3", "user"] + + if state in ("replaced", "overridden"): + for username in have_map: + if username not in want_map: + cmds.append(_delete(base + [username])) + + for username, want_user in want_map.items(): + have_user = have_map.get(username, {}) + user_base = base + [username] + + cmds += _build_v3_auth_privacy_commands( + user_base, + want_user.get("authentication"), + have_user.get("authentication"), + "auth", + ) + cmds += _build_v3_auth_privacy_commands( + user_base, + want_user.get("privacy"), + have_user.get("privacy"), + "privacy", + ) + + if want_user.get("group") and want_user["group"] != have_user.get("group"): + cmds.append(_set(user_base + ["group", want_user["group"]])) + if want_user.get("mode") and want_user["mode"] != have_user.get("mode"): + cmds.append(_set(user_base + ["mode", want_user["mode"]])) + if want_user.get("tsm_key") and want_user["tsm_key"] != have_user.get("tsm_key"): + cmds.append(_set(user_base + ["tsm-key", want_user["tsm_key"]])) + + return cmds + + +def _build_v3_group_commands(want_list, have_list, state): + """Build commands for SNMPv3 groups.""" + cmds = [] + want_map = {g["group"]: g for g in (want_list or [])} + have_map = {g["group"]: g for g in (have_list or [])} + base = SNMP_BASE + ["v3", "group"] + + if state in ("replaced", "overridden"): + for name in have_map: + if name not in want_map: + cmds.append(_delete(base + [name])) + + for name, want_group in want_map.items(): + have_group = have_map.get(name, {}) + group_base = base + [name] + + for key, api_key in [("mode", "mode"), ("seclevel", "seclevel"), ("view", "view")]: + want_val = want_group.get(key) + have_val = have_group.get(key) + if want_val and want_val != have_val: + cmds.append(_set(group_base + [api_key, want_val])) + if state in ("replaced", "overridden") and have_val and want_val != have_val: + cmds.append(_delete(group_base + [api_key])) + + return cmds + + +def _build_v3_view_commands(want_list, have_list, state): + """Build commands for SNMPv3 views.""" + cmds = [] + want_map = {v["view"]: v for v in (want_list or [])} + have_map = {v["view"]: v for v in (have_list or [])} + base = SNMP_BASE + ["v3", "view"] + + if state in ("replaced", "overridden"): + for name in have_map: + if name not in want_map: + cmds.append(_delete(base + [name])) + + for name, want_view in want_map.items(): + have_view = have_map.get(name, {}) + view_base = base + [name] + + want_oid = str(want_view["oid"]) if want_view.get("oid") else None + have_oid = str(have_view.get("oid")) if have_view.get("oid") else None + + if want_oid and want_oid != have_oid: + cmds.append(_set(view_base + ["oid", want_oid])) + if state in ("replaced", "overridden") and have_oid and want_oid != have_oid: + cmds.append(_delete(view_base + ["oid", have_oid])) + + for key in ("exclude", "mask"): + want_val = want_view.get(key) + have_val = have_view.get(key) + if want_val and want_val != have_val: + cmds.append(_set(view_base + [key, want_val])) + + return cmds + + +def _build_v3_commands(want_v3, have_v3, state): + """Build all SNMPv3 commands.""" + cmds = [] + want_v3 = want_v3 or {} + have_v3 = have_v3 or {} + + # engine_id (maps to API key 'engineid') + want_eid = want_v3.get("engine_id") + have_eid = have_v3.get("engine_id") + if want_eid and want_eid != have_eid: + cmds.append(_set(SNMP_BASE + ["v3", "engineid", want_eid])) + if state in ("replaced", "overridden") and have_eid and want_eid != have_eid: + cmds.append(_delete(SNMP_BASE + ["v3", "engineid"])) + + cmds += _build_v3_group_commands(want_v3.get("groups"), have_v3.get("groups"), state) + cmds += _build_v3_user_commands(want_v3.get("users"), have_v3.get("users"), state) + cmds += _build_v3_view_commands(want_v3.get("views"), have_v3.get("views"), state) + + return cmds + + +def build_commands(want, have, state): + """ + Build the full list of API command dicts to move from have → want. + For deleted state, a single delete of the entire SNMP subtree is issued. + """ + if state == "deleted": + if have: + return [_delete(SNMP_BASE)] + return [] + + cmds = [] + cmds += _build_scalar_commands(want, have, state) + cmds += _build_community_commands(want.get("communities"), have.get("communities"), state) + cmds += _build_listen_address_commands( + want.get("listen_addresses"), + have.get("listen_addresses"), + state, + ) + cmds += _build_trap_target_commands(want.get("trap_target"), have.get("trap_target"), state) + cmds += _build_v3_commands(want.get("snmp_v3"), have.get("snmp_v3"), state) + return cmds + + +# ------------------------------------------------------------ +# Argument spec +# ------------------------------------------------------------ + + +def _auth_privacy_spec(): + return dict( + type=dict(type="str"), + encrypted_key=dict(type="str", no_log=False), + plaintext_key=dict(type="str", no_log=True), + ) + + +ARGUMENT_SPEC = dict( + config=dict( + type="dict", + options=dict( + communities=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + clients=dict(type="list", elements="str"), + networks=dict(type="list", elements="str"), + authorization_type=dict(type="str", choices=["ro", "rw"]), + ), + ), + contact=dict(type="str"), + description=dict(type="str"), + location=dict(type="str"), + smux_peer=dict(type="str"), + trap_source=dict(type="str"), + listen_addresses=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str", required=True), + port=dict(type="int"), + ), + ), + trap_target=dict( + type="dict", + options=dict( + address=dict(type="str"), + community=dict(type="str"), + port=dict(type="int"), + ), + ), + snmp_v3=dict( + type="dict", + options=dict( + engine_id=dict(type="str"), + groups=dict( + type="list", + elements="dict", + options=dict( + group=dict(type="str", required=True), + mode=dict(type="str", choices=["ro", "rw"]), + seclevel=dict(type="str", choices=["auth", "priv"]), + view=dict(type="str"), + ), + ), + users=dict( + type="list", + elements="dict", + options=dict( + user=dict(type="str", required=True), + authentication=dict(type="dict", options=_auth_privacy_spec()), + privacy=dict(type="dict", options=_auth_privacy_spec()), + group=dict(type="str"), + mode=dict(type="str", choices=["ro", "rw"]), + tsm_key=dict(type="str"), + ), + ), + trap_targets=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str"), + port=dict(type="int"), + protocol=dict(type="str", choices=["tcp", "udp"]), + type=dict(type="str", choices=["inform", "trap"]), + authentication=dict(type="dict", options=_auth_privacy_spec()), + privacy=dict(type="dict", options=_auth_privacy_spec()), + ), + ), + views=dict( + type="list", + elements="dict", + options=dict( + view=dict(type="str", required=True), + oid=dict(type="str"), + exclude=dict(type="str"), + mask=dict(type="str"), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), +) + + +# ------------------------------------------------------------ +# Main +# ------------------------------------------------------------ + + +def main(): + module = AnsibleModule( + argument_spec=ARGUMENT_SPEC, + supports_check_mode=True, + ) + + vyos = VyOSModule(module) + state = module.params["state"] + config = module.params.get("config") or {} + + have = get_running_config(vyos) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + want = config # already in argspec shape from AnsibleModule + + commands = build_commands(want, have, state) + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + module.exit_json( + changed=True, + before=have, + after=want, + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() |
