summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoromnom62 <omnom62@outlook.com>2026-04-15 07:24:24 +1000
committeromnom62 <omnom62@outlook.com>2026-04-15 07:24:24 +1000
commitc3671c4e3e2ad7efa119982f2ee41334fcacd073 (patch)
tree0084638d1b9f71b6be671324779238439f9da5d6
parentc9e5c5f6d91bae587a6ce8b7f055b0d068129fe2 (diff)
downloadrest.vyos-c3671c4e3e2ad7efa119982f2ee41334fcacd073.tar.gz
rest.vyos-c3671c4e3e2ad7efa119982f2ee41334fcacd073.zip
Rest API module
-rw-r--r--README.md29
-rw-r--r--docs/vyos.rest.vyos_banner_module.rst56
-rw-r--r--meta/runtime.yml2
-rw-r--r--plugins/module_utils/vyos.py183
-rw-r--r--plugins/modules/vyos_banner.py310
-rw-r--r--plugins/modules/vyos_hostname.py186
-rw-r--r--plugins/modules/vyos_ntp_global.py47
-rw-r--r--plugins/modules/vyos_snmp_server.py1098
8 files changed, 1619 insertions, 292 deletions
diff --git a/README.md b/README.md
index 1d76b68..537d81e 100644
--- a/README.md
+++ b/README.md
@@ -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;">{&#x27;banner&#x27;: &#x27;pre-login&#x27;, &#x27;text&#x27;: &#x27;New banner text&#x27;}</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;">{&#x27;banner&#x27;: &#x27;pre-login&#x27;, &#x27;text&#x27;: &#x27;Old banner text&#x27;}</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;">[{&#x27;op&#x27;: &#x27;set&#x27;, &#x27;path&#x27;: [&#x27;system&#x27;, &#x27;login&#x27;, &#x27;banner&#x27;, &#x27;pre-login&#x27;], &#x27;value&#x27;: &#x27;New banner text&#x27;}]</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;">{&#x27;banner&#x27;: &#x27;pre-login&#x27;, &#x27;text&#x27;: &#x27;Current banner text&#x27;}</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()