summaryrefslogtreecommitdiff
path: root/plugins/module_utils
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/module_utils')
-rw-r--r--plugins/module_utils/network/vyos/argspec/ha/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/argspec/ha/ha.py196
-rw-r--r--plugins/module_utils/network/vyos/config/ha/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/config/ha/ha.py705
-rw-r--r--plugins/module_utils/network/vyos/facts/facts.py4
-rw-r--r--plugins/module_utils/network/vyos/facts/ha/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/facts/ha/ha.py174
-rw-r--r--plugins/module_utils/network/vyos/rm_templates/ha.py1011
-rw-r--r--plugins/module_utils/network/vyos/utils/utils.py54
9 files changed, 2136 insertions, 8 deletions
diff --git a/plugins/module_utils/network/vyos/argspec/ha/__init__.py b/plugins/module_utils/network/vyos/argspec/ha/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/plugins/module_utils/network/vyos/argspec/ha/__init__.py
diff --git a/plugins/module_utils/network/vyos/argspec/ha/ha.py b/plugins/module_utils/network/vyos/argspec/ha/ha.py
new file mode 100644
index 00000000..4fe431cd
--- /dev/null
+++ b/plugins/module_utils/network/vyos/argspec/ha/ha.py
@@ -0,0 +1,196 @@
+# -*- 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
+
+#############################################
+# WARNING #
+#############################################
+#
+# This file is auto generated by the
+# cli_rm_builder.
+#
+# Manually editing this file is not advised.
+#
+# To update the argspec make the desired changes
+# in the module docstring and re-run
+# cli_rm_builder.
+#
+#############################################
+
+"""
+The arg spec for the vyos_ha module
+"""
+
+
+class HaArgs(object): # pylint: disable=R0903
+ """The arg spec for the vyos_ha module"""
+
+ argument_spec = {
+ "config": {
+ "type": "dict",
+ "required": False,
+ "options": {
+ "disable": {"type": "bool", "default": False},
+ "virtual_servers": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "name": {"type": "str", "required": True},
+ "address": {"type": "str"},
+ "algorithm": {"type": "str"},
+ "delay_loop": {"type": "int"},
+ "forward_method": {"type": "str", "choices": ["direct", "nat"]},
+ "fwmark": {"type": "int"},
+ "persistence_timeout": {"type": "int"},
+ "port": {"type": "int"},
+ "protocol": {"type": "str", "choices": ["tcp", "udp"]},
+ "real_server": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "address": {"type": "str", "required": True},
+ "port": {"type": "int"},
+ "connection_timeout": {"type": "int"},
+ "health_check_script": {"type": "str"},
+ },
+ },
+ },
+ },
+ "vrrp": {
+ "type": "dict",
+ "options": {
+ "global_parameters": {
+ "type": "dict",
+ "options": {
+ "garp": {
+ "type": "dict",
+ "options": {
+ "interval": {"type": "int"},
+ "master_delay": {"type": "int"},
+ "master_refresh": {"type": "int"},
+ "master_refresh_repeat": {"type": "int"},
+ "master_repeat": {"type": "int"},
+ },
+ },
+ "startup_delay": {"type": "int"},
+ "version": {"type": "str"},
+ },
+ },
+ "groups": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "name": {"type": "str", "required": True},
+ "address": {"type": "list", "elements": "str"},
+ "advertise_interval": {"type": "int"},
+ "authentication": {
+ "type": "dict",
+ "options": {
+ "password": {"type": "str", "no_log": True},
+ "type": {"type": "str"},
+ },
+ },
+ "description": {"type": "str"},
+ "disable": {"type": "bool", "default": False},
+ "excluded_address": {"type": "list", "elements": "str"},
+ "garp": {
+ "type": "dict",
+ "options": {
+ "interval": {"type": "int"},
+ "master_delay": {"type": "int"},
+ "master_refresh": {"type": "int"},
+ "master_refresh_repeat": {"type": "int"},
+ "master_repeat": {"type": "int"},
+ },
+ },
+ "health_check": {
+ "type": "dict",
+ "options": {
+ "failure_count": {"type": "int"},
+ "interval": {"type": "int"},
+ "ping": {"type": "str"},
+ "script": {"type": "str"},
+ },
+ },
+ "hello_source_address": {"type": "str"},
+ "interface": {"type": "str"},
+ "no_preempt": {"type": "bool", "default": False},
+ "peer_address": {"type": "str"},
+ "preempt_delay": {"type": "int"},
+ "priority": {"type": "int"},
+ "rfc3768_compatibility": {"type": "bool", "default": False},
+ "track": {
+ "type": "dict",
+ "options": {
+ "exclude_vrrp_interface": {"type": "bool"},
+ "interface": {"type": "list", "elements": "str"},
+ },
+ },
+ "transition_script": {
+ "type": "dict",
+ "options": {
+ "backup": {"type": "str"},
+ "fault": {"type": "str"},
+ "master": {"type": "str"},
+ "stop": {"type": "str"},
+ },
+ },
+ "vrid": {"type": "int", "required": False},
+ },
+ },
+ "snmp": {
+ "type": "str",
+ "choices": ["disabled", "enabled"],
+ },
+ "sync_groups": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "name": {"type": "str", "required": True},
+ "health_check": {
+ "type": "dict",
+ "options": {
+ "failure_count": {"type": "int"},
+ "interval": {"type": "int"},
+ "ping": {"type": "str"},
+ "script": {"type": "str"},
+ },
+ },
+ "member": {"type": "list", "elements": "str"},
+ "transition_script": {
+ "type": "dict",
+ "options": {
+ "backup": {"type": "str"},
+ "fault": {"type": "str"},
+ "master": {"type": "str"},
+ "stop": {"type": "str"},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ "state": {
+ "type": "str",
+ "choices": [
+ "deleted",
+ "merged",
+ "purged",
+ "replaced",
+ "gathered",
+ "rendered",
+ "parsed",
+ "overridden",
+ ],
+ "default": "merged",
+ },
+ "running_config": {"type": "str"},
+ } # pylint: disable=C0301
diff --git a/plugins/module_utils/network/vyos/config/ha/__init__.py b/plugins/module_utils/network/vyos/config/ha/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/plugins/module_utils/network/vyos/config/ha/__init__.py
diff --git a/plugins/module_utils/network/vyos/config/ha/ha.py b/plugins/module_utils/network/vyos/config/ha/ha.py
new file mode 100644
index 00000000..7fc96a35
--- /dev/null
+++ b/plugins/module_utils/network/vyos/config/ha/ha.py
@@ -0,0 +1,705 @@
+#
+# -*- coding: utf-8 -*-
+# Copyright 2021 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
+
+"""
+The vyos_ha config file.
+It is in this file where the current configuration (as dict)
+is compared to the provided configuration (as dict) and the command set
+necessary to bring the current configuration to its desired end-state is
+created.
+"""
+
+from copy import deepcopy
+
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.resource_module import (
+ ResourceModule,
+)
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import (
+ remove_empties,
+)
+
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.facts import Facts
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.rm_templates.ha import (
+ HaTemplate,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.utils.utils import combine
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.utils.version import (
+ LooseVersion,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.vyos import get_os_version
+
+
+class Ha(ResourceModule):
+ """
+ The vyos_ha config class
+ """
+
+ def __init__(self, module):
+ super(Ha, self).__init__(
+ empty_fact_val={},
+ facts_module=Facts(module),
+ module=module,
+ resource="ha",
+ tmplt=HaTemplate(),
+ )
+ self.parsers = [
+ "disable",
+ ]
+
+ self._validate_template()
+
+ def _validate_template(self):
+ version = get_os_version(self._module)
+ if LooseVersion(version) >= LooseVersion("1.4"):
+ self._tmplt = HaTemplate()
+ else:
+ self._module.fail_json(msg="High Availability is not supported in this version of VyOS")
+
+ def execute_module(self):
+ """Execute the module
+
+ :rtype: A dictionary
+ :returns: The result from module execution
+ """
+
+ if self.state not in ["parsed", "gathered", "purged"]:
+ self.generate_commands()
+ self.run_commands()
+
+ if self.state == "purged":
+ wantd = {"disable": False}
+ haved = deepcopy(self.have)
+ if wantd != haved:
+ self.commands = ["delete high-availability"]
+ self.run_commands()
+ if "before" in self.result:
+ self._normalize_lists(self.result["before"])
+ if "after" in self.result:
+ self._normalize_lists(self.result["after"])
+ if "parsed" in self.result:
+ self._normalize_lists(self.result["parsed"])
+ return self.result
+
+ def generate_commands(self):
+ """Generate configuration commands to send based on
+ want, have and desired state.
+ """
+ wantd = deepcopy(self.want)
+ haved = deepcopy(self.have)
+ for entry in wantd, haved:
+ self._list_to_named_dict(entry)
+ self._normalize_lists(entry)
+
+ if self.state in ["deleted"]:
+ wantd, haved, p = self._prune_stubs(self._module.params.get("config", {}), haved)
+
+ if self.state in ["overridden"]:
+ wo = deepcopy(wantd)
+ self._diff_w_h(wo, haved)
+
+ haved_disable = haved.get("disable")
+
+ for k1, v1 in wo.items():
+
+ if not isinstance(v1, dict):
+ continue
+
+ for name, obj in v1.items():
+ if isinstance(obj, dict) and not obj:
+ wi, hi, pi = self._prune_stubs({k1: {name: {}}}, haved)
+ haved = hi
+
+ for k2, v2 in v1.items():
+ if not isinstance(v2, dict):
+ continue
+
+ for name, obj in v2.items():
+ if isinstance(obj, dict) and not obj:
+ wi, hi, pi = self._prune_stubs({k1: {k2: {name: {}}}}, haved)
+ haved = hi
+
+ if haved_disable is not None:
+ haved["disable"] = haved_disable
+
+ keys = set(wantd) | set(haved)
+
+ for k in keys:
+
+ want = wantd.get(k, {})
+ have = haved.get(k, {})
+
+ if k == "vrrp":
+ if self.state in ["merged"]:
+ want = combine(have, want, recursive=True, list_merge="append_rp")
+ self._compare_vrrp(want, have)
+
+ if k == "virtual_servers":
+ if self.state in ["merged"]:
+ want = combine(have, want, recursive=True)
+ self._compare_vsrvs(want, have)
+
+ if self.state in ["deleted"] and k == "disable":
+ want = have
+ if self.state in ["overridden"] and k == "disable" and not want:
+ want = False
+ if self.state in ["rendered"]:
+ have = None
+
+ self.compare(
+ parsers=self.parsers,
+ want={k: want},
+ have={k: have},
+ )
+
+ self.commands = list(dict.fromkeys(self.commands))
+
+ def _compare_vsrvs(self, want, have):
+ """Compare virtual servers.
+
+ Pre-index both want and have by (name, attribute) signature so that
+ each lookup is O(1) instead of O(n). Groups that are identical
+ between want and have are skipped entirely via an equality
+ short-circuit before leaf decomposition.
+ """
+ vs_parsers = [
+ "virtual_servers.address",
+ "virtual_servers.algorithm",
+ "virtual_servers.delay_loop",
+ "virtual_servers.forward_method",
+ "virtual_servers.persistence_timeout",
+ "virtual_servers.fwmark",
+ "virtual_servers.port",
+ "virtual_servers.protocol",
+ "virtual_servers.real_server.port",
+ "virtual_servers.real_server.health_check_script",
+ "virtual_servers.real_server.connection_timeout",
+ ]
+
+ want_index = (
+ {vs["name"]: vs for vs in want.values() if isinstance(vs, dict) and vs.get("name")}
+ if isinstance(want, dict)
+ else {}
+ )
+ have_index = (
+ {vs["name"]: vs for vs in have.values() if isinstance(vs, dict) and vs.get("name")}
+ if isinstance(have, dict)
+ else {}
+ )
+
+ all_names = set(want_index) | set(have_index)
+
+ for name in all_names:
+ w = want_index.get(name, {})
+ h = have_index.get(name, {})
+
+ if w == h and self.state not in ["rendered"]:
+ continue
+
+ wlist = self._extract_named_leafs(w) if w else []
+ hlist = self._extract_named_leafs(h) if h else []
+
+ if self.state == "rendered":
+ hlist = []
+
+ def _vsrv_sig(item):
+ if not isinstance(item, dict):
+ return None
+ iname = item.get("name")
+ if not iname:
+ return None
+ if "real_server" in item:
+ rs = item["real_server"]
+ if not isinstance(rs, dict) or "address" not in rs:
+ return None
+ addr = rs["address"]
+ for k in rs:
+ if k != "address":
+ return ("real_server", iname, addr, k)
+ return ("real_server", iname, addr, None)
+ for k in item:
+ if k != "name":
+ return ("attr", iname, k)
+ return None
+
+ have_leaf_index = {}
+ for hdict in hlist:
+ sig = _vsrv_sig(hdict)
+ if sig is not None:
+ have_leaf_index[sig] = hdict
+
+ want_leaf_index = {}
+ for wdict in wlist:
+ sig = _vsrv_sig(wdict)
+ if sig is not None:
+ want_leaf_index[sig] = wdict
+
+ if self.state in ["replaced", "deleted"]:
+ for sig, hdict in have_leaf_index.items():
+ wdict = want_leaf_index.get(sig, {})
+ if self.state == "deleted" and wdict:
+ wdict = {}
+ elif not wdict:
+ hdict = {}
+ self.compare(
+ parsers=vs_parsers,
+ want={"virtual_servers": wdict},
+ have={"virtual_servers": hdict},
+ )
+
+ if self.state in ["merged", "replaced", "rendered", "overridden"]:
+ for sig, wdict in want_leaf_index.items():
+ hdict = have_leaf_index.get(sig, {})
+ self.compare(
+ parsers=vs_parsers,
+ want={"virtual_servers": wdict},
+ have={"virtual_servers": hdict},
+ )
+
+ def _compare_vrrp(self, want, have):
+ """Compare VRRP groups and sync-groups.
+
+ Pre-index groups by name so matching is O(1). Groups that are
+ identical between want and have are skipped via equality
+ short-circuit before any leaf decomposition occurs — this is the
+ dominant performance win for large idempotent runs.
+ """
+ vrrp_parsers = [
+ "vrrp.snmp",
+ "vrrp.global_parameters",
+ "vrrp.global_parameters.garp",
+ "vrrp.groups",
+ "vrrp.groups.disable",
+ "vrrp.groups.no_preempt",
+ "vrrp.groups.rfc3768_compatibility",
+ "vrrp.groups.address",
+ "vrrp.groups.excluded_address",
+ "vrrp.groups.garp",
+ "vrrp.groups.authentication",
+ "vrrp.groups.transition_script",
+ "vrrp.groups.health_check",
+ "vrrp.groups.track.interface",
+ "vrrp.groups.track.exclude_vrrp_interface",
+ "vrrp.sync_groups.member",
+ "vrrp.sync_groups.transition_script",
+ "vrrp.sync_groups.health_check",
+ ]
+
+ if (
+ have.get("snmp") == "enabled"
+ and want.get("snmp") != "enabled"
+ and self.state not in ["deleted", "overridden"]
+ and (self.state != "merged" or "snmp" in want)
+ ):
+ self.commands.append("delete high-availability vrrp snmp")
+
+ non_named = {k: v for k, v in (want or {}).items() if k not in ("groups", "sync_groups")}
+ non_named_have = {
+ k: v for k, v in (have or {}).items() if k not in ("groups", "sync_groups")
+ }
+
+ hlist_non = self._extract_leaf_items(non_named_have)
+ wlist_non = self._extract_leaf_items(non_named)
+
+ if self.state == "rendered":
+ hlist_non = []
+
+ have_non_index = {}
+ for hdict in hlist_non:
+ sig = self._vrrp_leaf_sig(hdict)
+ have_non_index[sig] = hdict
+
+ want_non_index = {}
+ for wdict in wlist_non:
+ sig = self._vrrp_leaf_sig(wdict)
+ want_non_index[sig] = wdict
+
+ if self.state in ["replaced", "deleted"]:
+ for sig, hdict in have_non_index.items():
+ wdict = want_non_index.get(sig, {})
+ if self.state == "deleted" and wdict:
+ wdict = {}
+ if self.state == "replaced" and wdict and wdict != hdict:
+ wdict = {}
+ elif not wdict:
+ hdict = {}
+ self.compare(parsers=vrrp_parsers, want={"vrrp": wdict}, have={"vrrp": hdict})
+
+ if self.state in ["merged", "replaced", "rendered", "overridden"]:
+ for sig, wdict in want_non_index.items():
+ hdict = have_non_index.get(sig, {})
+ self.compare(parsers=vrrp_parsers, want={"vrrp": wdict}, have={"vrrp": hdict})
+
+ for section in ("groups", "sync_groups"):
+ want_objs = (want or {}).get(section, {})
+ have_objs = (have or {}).get(section, {})
+
+ if not isinstance(want_objs, dict):
+ want_objs = {}
+ if not isinstance(have_objs, dict):
+ have_objs = {}
+
+ all_names = set(want_objs) | set(have_objs)
+
+ for name in all_names:
+ w = want_objs.get(name, {})
+ h = have_objs.get(name, {})
+
+ if w == h and self.state not in ["rendered"]:
+ continue
+
+ wlist = self._extract_leaf_items({section: {name: w}}) if w else []
+ hlist = self._extract_leaf_items({section: {name: h}}) if h else []
+
+ if self.state == "rendered":
+ hlist = []
+
+ have_leaf_index = {}
+ for hdict in hlist:
+ sig = self._vrrp_leaf_sig(hdict)
+ have_leaf_index[sig] = hdict
+
+ want_leaf_index = {}
+ for wdict in wlist:
+ sig = self._vrrp_leaf_sig(wdict)
+ want_leaf_index[sig] = wdict
+
+ if self.state in ["replaced", "deleted"]:
+ for sig, hdict in have_leaf_index.items():
+ wdict = want_leaf_index.get(sig, {})
+ if self.state == "deleted" and wdict:
+ wdict = {}
+ if self.state == "replaced" and wdict and wdict != hdict:
+ wdict = {}
+ elif not wdict:
+ hdict = {}
+ self.compare(
+ parsers=vrrp_parsers,
+ want={"vrrp": wdict},
+ have={"vrrp": hdict},
+ )
+
+ if self.state in ["merged", "replaced", "rendered", "overridden"]:
+ for sig, wdict in want_leaf_index.items():
+ hdict = have_leaf_index.get(sig, {})
+ self.compare(
+ parsers=vrrp_parsers,
+ want={"vrrp": wdict},
+ have={"vrrp": hdict},
+ )
+
+ def _vrrp_leaf_sig(self, item):
+ """Build a hashable signature for a VRRP leaf dict for O(1) indexing."""
+ if not isinstance(item, dict) or not item:
+ return ()
+
+ container = next(iter(item))
+ inner = item[container]
+
+ sig = [container]
+
+ if isinstance(inner, dict) and "name" in inner:
+ sig.append(("name", inner["name"]))
+
+ if isinstance(inner, dict):
+ for k, v in inner.items():
+ if k == "name":
+ continue
+ if not isinstance(v, dict):
+ sig.append(k)
+ break
+ sig.append(k)
+ for leaf in v:
+ sig.append(leaf)
+ break
+ break
+
+ return tuple(sig)
+
+ def _list_to_named_dict(self, data):
+ """Convert all named-object lists to name-keyed dicts in-place.
+
+ Replaces the three separate _vrrp_groups_list_to_dict,
+ _vrrp_sync_groups_list_to_dict, and _virtual_servers_list_to_dict
+ methods with a single helper. Also normalises real_server lists
+ inside virtual servers.
+ """
+ # VRRP groups and sync_groups
+ vrrp = data.get("vrrp", {})
+ for key in ("groups", "sync_groups"):
+ items = vrrp.get(key)
+ if isinstance(items, list):
+ vrrp[key] = {
+ item["name"]: item
+ for item in items
+ if isinstance(item, dict) and item.get("name")
+ }
+
+ # Virtual servers
+ vss = data.get("virtual_servers")
+ if isinstance(vss, list):
+ new_vss = {}
+ for vs in vss:
+ if not isinstance(vs, dict):
+ continue
+ name = vs.get("name")
+ if not name:
+ continue
+ rs = vs.get("real_server")
+ if isinstance(rs, list):
+ vs["real_server"] = {
+ item["address"]: item
+ for item in rs
+ if isinstance(item, dict) and item.get("address")
+ }
+ new_vss[name] = vs
+ data["virtual_servers"] = new_vss
+ elif isinstance(vss, dict):
+ for vs in vss.values():
+ if not isinstance(vs, dict):
+ continue
+ rs = vs.get("real_server")
+ if isinstance(rs, list):
+ vs["real_server"] = {
+ item["address"]: item
+ for item in rs
+ if isinstance(item, dict) and item.get("address")
+ }
+
+ return data
+
+ def _extract_leaf_items(self, data, path=None, parent_name=None):
+ path = path or []
+ results = []
+
+ if isinstance(data, dict):
+ current_name = data.get("name", parent_name)
+
+ for k, v in data.items():
+ if k == "name" or (k == "snmp" and v == "disabled"):
+ continue
+ results.extend(self._extract_leaf_items(v, path + [k], current_name))
+ return results
+
+ leaf_key = path[-1]
+ top_key = path[0]
+
+ if top_key in ["groups", "sync_groups"]:
+ subkeys = path[2:]
+ else:
+ subkeys = path[1:]
+
+ nested = {leaf_key: data}
+
+ for p in reversed(subkeys[:-1]):
+ nested = {p: nested}
+ if parent_name:
+ out = {top_key: {"name": parent_name}}
+ out[top_key].update(nested)
+ else:
+ out = {top_key: nested}
+
+ results.append(out)
+ return results
+
+ def _normalize_lists(self, node):
+ """
+ Recursively normalize all lists inside a dict or list.
+ All lists are sorted to ensure consistent ordering for comparison.
+ """
+ if isinstance(node, dict):
+ for k, v in node.items():
+ if isinstance(v, list):
+ if all(not isinstance(i, (dict, list)) for i in v):
+ node[k] = sorted(v)
+ else:
+ for item in v:
+ self._normalize_lists(item)
+ elif isinstance(v, dict):
+ self._normalize_lists(v)
+ elif isinstance(node, list):
+ for item in node:
+ self._normalize_lists(item)
+
+ def _extract_named_leafs(self, data, parent_name=None, prefix_key=None):
+ results = []
+
+ if prefix_key == "real_server" and isinstance(data, dict):
+ for d, server_data in data.items():
+ if not isinstance(server_data, dict):
+ continue
+
+ address = server_data.get("address")
+ if not address:
+ continue
+
+ for k, v in server_data.items():
+ if k == "address":
+ continue
+
+ results.append(
+ {
+ "name": parent_name,
+ "real_server": {
+ "address": address,
+ k: v,
+ },
+ },
+ )
+ return results
+
+ if isinstance(data, dict):
+ current_name = data.get("name", parent_name)
+
+ for k, v in data.items():
+ if k == "name":
+ continue
+
+ results.extend(
+ self._extract_named_leafs(v, current_name, k),
+ )
+
+ return results
+
+ return [
+ {
+ "name": parent_name,
+ prefix_key: data,
+ },
+ ]
+
+ def _prune_stubs(self, w, h, path=""):
+ wc = {}
+ hc = self._remove_defaults(h)
+
+ if not self._remove_defaults(w) and remove_empties(hc):
+ self.commands = ["delete high-availability"]
+ return {}, {}, path
+
+ for k, wg in (self._remove_defaults(w) or {}).items():
+ next_path = f"{path} {k}".strip()
+ stub = self._cli_path(next_path)
+ hg = remove_empties(hc).get(k)
+
+ if hg is None:
+ continue
+
+ if not isinstance(wg, (dict, list)):
+ self.commands.append(f"delete high-availability {stub}")
+ hc.pop(k, None)
+ wc.pop(k, None)
+ continue
+
+ if not wg:
+ self.commands.append(f"delete high-availability {stub}")
+ hc.pop(k, None)
+ wc.pop(k, None)
+ continue
+
+ if isinstance(wg, list) and isinstance(hg, dict):
+ for item in wg:
+ name = item.get("name")
+ if not name:
+ continue
+
+ if name in hg:
+ self.commands.append(
+ f"delete high-availability {stub} {name}",
+ )
+
+ hg.pop(name, None)
+
+ if hg:
+ hc[k] = hg
+ else:
+ hc.pop(k, None)
+
+ if self._remove_defaults(wg):
+ wc[k] = wg
+ else:
+ wc.pop(k, None)
+
+ continue
+
+ if isinstance(wg, dict) and isinstance(hg, dict):
+ wi, hi, p = self._prune_stubs(wg, hg, next_path)
+
+ if wi:
+ wc[k] = wi
+
+ if hi:
+ hc[k] = hi
+ else:
+ hc.pop(k, None)
+
+ return wc, hc, path
+
+ def _remove_defaults(self, data):
+ """Strip None and False from config dicts, but preserve "disabled".
+
+ False is the argspec default for boolean flags (disable, no_preempt,
+ rfc3768_compatibility) and carries no config intent — stripping it
+ prevents spurious `delete` commands for fields already at their
+ default state.
+
+ "disabled" is an explicit user choice for snmp and must be preserved
+ so that _prune_stubs can act on it. The original code stripped it,
+ which made `snmp: disabled` invisible to the deleted-state logic.
+ """
+ if isinstance(data, dict):
+ cleaned = {}
+ for k, v in data.items():
+ if v is None or v is False:
+ continue
+ v = self._remove_defaults(v)
+ cleaned[k] = v
+ return cleaned
+ return data
+
+ def _cli_path(self, path):
+ token_map = {
+ "groups": "group",
+ "sync_groups": "sync-group",
+ "virtual_servers": "virtual-server",
+ }
+
+ parts = []
+ for p in path.split():
+ p = token_map.get(p, p)
+ parts.append(p.replace("_", "-"))
+
+ return " ".join(parts)
+
+ def _diff_w_h(self, w, h):
+
+ NAMED_OBJECT_KEYS = {
+ "groups",
+ "sync_groups",
+ "virtual_servers",
+ "global_parameters",
+ }
+
+ if not isinstance(w, dict) or not isinstance(h, dict):
+ return w
+
+ for key in w.keys() & h.keys():
+ wv = w[key]
+ hv = h[key]
+
+ if key in NAMED_OBJECT_KEYS and isinstance(wv, dict) and isinstance(hv, dict):
+ for name in wv.keys() & hv.keys():
+ if wv[name] != hv[name] and isinstance(wv[name], (dict, list)):
+ wv[name] = {}
+ elif wv[name] != hv[name]:
+ wv[name] = None
+ continue
+ self._diff_w_h(wv, hv)
+ return w
diff --git a/plugins/module_utils/network/vyos/facts/facts.py b/plugins/module_utils/network/vyos/facts/facts.py
index 24c444dd..7f7bad8a 100644
--- a/plugins/module_utils/network/vyos/facts/facts.py
+++ b/plugins/module_utils/network/vyos/facts/facts.py
@@ -30,6 +30,9 @@ from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.firew
from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.firewall_rules.firewall_rules import (
Firewall_rulesFacts,
)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.ha.ha import (
+ HaFacts,
+)
from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.hostname.hostname import (
HostnameFacts,
)
@@ -94,6 +97,7 @@ FACT_RESOURCE_SUBSETS = dict(
firewall_rules=Firewall_rulesFacts,
firewall_global=Firewall_globalFacts,
firewall_interfaces=Firewall_interfacesFacts,
+ ha=HaFacts,
ospfv3=Ospfv3Facts,
ospfv2=Ospfv2Facts,
ospf_interfaces=Ospf_interfacesFacts,
diff --git a/plugins/module_utils/network/vyos/facts/ha/__init__.py b/plugins/module_utils/network/vyos/facts/ha/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/plugins/module_utils/network/vyos/facts/ha/__init__.py
diff --git a/plugins/module_utils/network/vyos/facts/ha/ha.py b/plugins/module_utils/network/vyos/facts/ha/ha.py
new file mode 100644
index 00000000..fc559783
--- /dev/null
+++ b/plugins/module_utils/network/vyos/facts/ha/ha.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+# Copyright 2021 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
+
+"""
+The vyos_ha fact class
+It is in this file the configuration is collected from the device
+for a given resource, parsed, and the facts tree is populated
+based on the configuration.
+"""
+
+import re
+
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common import utils
+
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.argspec.ha.ha import (
+ HaArgs,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.rm_templates.ha import (
+ HaTemplate,
+)
+
+
+class HaFacts(object):
+ """The vyos_ha facts class"""
+
+ def __init__(self, module, subspec="config", options="options"):
+ self._module = module
+ self.argument_spec = HaArgs.argument_spec
+
+ def get_config(self, connection):
+ return connection.get('show configuration commands | match "set high-availability"')
+
+ def get_config_set(self, data, connection):
+ """Classify config lines into per-object buckets for isolated parsing.
+
+ Each bucket is parsed by a single HaTemplate instance so that facts
+ from different objects (groups, sync-groups, virtual-servers) never
+ bleed into each other.
+
+ Keys are namespaced to avoid collisions between a VRRP group and a
+ sync-group that share the same name (e.g. both named "g1").
+ An elif chain ensures each line lands in exactly one bucket.
+ """
+ config_dict = {}
+ for config_line in data.splitlines():
+ vrrp_disable = re.search(r"set high-availability disable", config_line)
+ vrrp_snmp = re.search(r"set high-availability vrrp snmp", config_line)
+ vrrp_gp = re.search(
+ r"set high-availability vrrp global-parameters (\S+).*",
+ config_line,
+ )
+ vrrp_grp = re.search(r"set high-availability vrrp group (\S+).*", config_line)
+ vrrp_sg = re.search(r"set high-availability vrrp sync-group (\S+).*", config_line)
+ vrrp_vsrv = re.search(r"set high-availability virtual-server (\S+).*", config_line)
+
+ if vrrp_disable:
+ config_dict.setdefault("disable", []).append(config_line)
+ elif vrrp_snmp:
+ config_dict.setdefault("vrrp", []).append(config_line)
+ elif vrrp_gp:
+ config_dict.setdefault("global_parameters", []).append(config_line)
+ elif vrrp_grp:
+ key = "vrrp_group_{0}".format(vrrp_grp.group(1))
+ config_dict.setdefault(key, []).append(config_line)
+ elif vrrp_sg:
+ key = "vrrp_sg_{0}".format(vrrp_sg.group(1))
+ config_dict.setdefault(key, []).append(config_line)
+ elif vrrp_vsrv:
+ config_dict.setdefault(vrrp_vsrv.group(1), []).append(config_line)
+
+ return list(config_dict.values())
+
+ def deep_merge(self, dest, src):
+ for key, value in src.items():
+ if key in dest and isinstance(dest[key], dict) and isinstance(value, dict):
+ self.deep_merge(dest[key], value)
+ else:
+ dest[key] = value
+ return dest
+
+ def populate_facts(self, connection, ansible_facts, data=None):
+ """Populate the facts for vrrp network resource
+
+ :param connection: the device connection
+ :param ansible_facts: Facts dictionary
+ :param data: previously collected conf
+
+ :rtype: dictionary
+ :returns: facts
+ """
+ facts = {}
+ objs = {}
+
+ if not data:
+ data = self.get_config(connection)
+ resources = self.get_config_set(data, connection)
+ vrrp_facts = {"disable": False, "virtual_servers": {}, "vrrp": {}}
+ for resource in resources:
+ vrrp_parser = HaTemplate(
+ lines=resource,
+ module=self._module,
+ )
+ objs = vrrp_parser.parse()
+ if "disable" in objs:
+ vrrp_facts["disable"] = objs["disable"]
+
+ for section in ("virtual_servers", "vrrp"):
+ if section in objs:
+ for name, data in objs[section].items():
+ if not isinstance(data, dict):
+ vrrp_facts[section][name] = data
+ continue
+ existing = vrrp_facts[section].get(name, {})
+ vrrp_facts[section][name] = self.deep_merge(existing, data)
+
+ ansible_facts["ansible_network_resources"].pop("ha", None)
+
+ vrrp_facts = self.normalize_config(vrrp_facts)
+
+ validate_parser = HaTemplate(lines=[], module=self._module)
+ params = utils.remove_empties(
+ validate_parser.validate_config(
+ self.argument_spec,
+ {"config": vrrp_facts},
+ redact=True,
+ ),
+ )
+
+ facts["ha"] = params.get("config", {})
+ ansible_facts["ansible_network_resources"].update(facts)
+ return ansible_facts
+
+ def normalize_config(self, config):
+ if not config:
+ return config
+
+ if isinstance(config.get("virtual_servers"), dict):
+ config["virtual_servers"] = list(config["virtual_servers"].values())
+
+ vrrp = config.get("vrrp", {})
+
+ if isinstance(vrrp.get("groups"), dict):
+ vrrp["groups"] = list(vrrp["groups"].values())
+
+ if isinstance(vrrp.get("sync_groups"), dict):
+ vrrp["sync_groups"] = list(vrrp["sync_groups"].values())
+
+ # Normalize real_server inside each virtual_server
+ for vs in config.get("virtual_servers", []):
+ if isinstance(vs.get("real_server"), dict):
+ vs["real_server"] = list(vs["real_server"].values())
+
+ for group in vrrp.get("groups", []):
+ if isinstance(group.get("address"), list):
+ group["address"] = sorted(group["address"])
+
+ if isinstance(group.get("excluded_address"), list):
+ group["excluded_address"] = sorted(group["excluded_address"])
+
+ if isinstance(group.get("track", {}).get("interface"), list):
+ group["track"]["interface"] = sorted(group["track"]["interface"])
+
+ for sg in vrrp.get("sync_groups", []):
+ if isinstance(sg.get("member"), list):
+ sg["member"] = sorted(sg["member"])
+
+ return config
diff --git a/plugins/module_utils/network/vyos/rm_templates/ha.py b/plugins/module_utils/network/vyos/rm_templates/ha.py
new file mode 100644
index 00000000..300b14a7
--- /dev/null
+++ b/plugins/module_utils/network/vyos/rm_templates/ha.py
@@ -0,0 +1,1011 @@
+# -*- coding: utf-8 -*-
+# Copyright 2021 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
+
+"""
+The Ha parser templates file. This contains
+a list of parser definitions and associated functions that
+facilitates both facts gathering and native command generation for
+the given network resource.
+"""
+
+import re
+
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.network_template import (
+ NetworkTemplate,
+)
+
+
+def _tmplt_vsrvs(config_data):
+ config_data = config_data["virtual_servers"]
+ command = []
+
+ cmd = "high-availability virtual-server {name}".format(**config_data)
+ for key, value in config_data.items():
+ if key == "name" or isinstance(value, dict) or value is None:
+ continue
+ else:
+ command.append(f"{cmd} {key.replace('_', '-')} {value}")
+
+ return command
+
+
+def _tmplt_vsrvs_rsrv(config_data):
+ config_data = config_data["virtual_servers"]
+ command = []
+ cmd = "high-availability virtual-server {name}".format(**config_data)
+ config_data = config_data["real_server"]
+ address = config_data["address"]
+ for key, value in config_data.items():
+ if key == "address" or value is None:
+ continue
+ if value is not None and key == "health_check_script":
+ command.append(cmd + " real-server " + address + " health-check script " + value)
+ else:
+ command.append(cmd + " real-server " + f"{address} {key.replace('_', '-')} {value}")
+ return command
+
+
+def _tmplt_vrrp_sgroup_hc(config_data):
+ config_data = config_data["vrrp"]["sync_groups"]
+ command = []
+ cmd = "high-availability vrrp sync-group {name}".format(**config_data)
+ config_data = config_data["health_check"]
+ for key, value in config_data.items():
+ if value is not None:
+ command.append(cmd + " health-check " + f"{key.replace('_', '-')} {value}")
+ return command
+
+
+def _tmplt_vrrp_sgroup_ts(config_data):
+ config_data = config_data["vrrp"]["sync_groups"]
+ command = []
+ cmd = "high-availability vrrp sync-group {name}".format(**config_data)
+ config_data = config_data["transition_script"]
+ for key, value in config_data.items():
+ if value is not None:
+ command.append(cmd + " transition-script " + f"{key.replace('_', '-')} {value}")
+ return command
+
+
+def _tmplt_vrrp_gp(config_data):
+ config_data = config_data["vrrp"]["global_parameters"]
+ command = []
+
+ cmd = "high-availability vrrp global-parameters".format(**config_data)
+ for key, value in config_data.items():
+ if isinstance(value, dict) or value is None:
+ continue
+ else:
+ command.append(f"{cmd} {key.replace('_', '-')} {value}")
+ return command
+
+
+def _tmplt_vrrp_gp_garp(config_data):
+ config_data = config_data["vrrp"]["global_parameters"]["garp"]
+ command = []
+ cmd = "high-availability vrrp global-parameters garp"
+
+ for key, value in config_data.items():
+ if value is None:
+ continue
+ command.append(f"{cmd} {key.replace('_', '-')} {value}")
+
+ return command
+
+
+def _tmplt_vrrp_group(config_data):
+ config_data = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**config_data)
+
+ for key, value in config_data.items():
+ if (
+ key == "name"
+ or isinstance(value, dict)
+ or isinstance(value, list)
+ or isinstance(value, bool)
+ or value is None
+ ):
+ continue
+ else:
+ if key == "description":
+ value = f"'{value}'"
+ command.append(f"{cmd} {key.replace('_', '-')} {value}")
+ return command
+
+
+def _tmplt_vrrp_group_bool(config_data):
+ config_data = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**config_data)
+
+ for key, value in config_data.items():
+ if key != "name" and value is not None:
+ command.append(f"{cmd} {key.replace('_', '-')}")
+ return command
+
+
+def _tmplt_vrrp_group_garp(config_data):
+ config_data = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**config_data)
+ config_data = config_data["garp"]
+ for key, value in config_data.items():
+ if value is not None:
+ command.append(cmd + " garp " + f"{key.replace('_', '-')} {value}")
+ return command
+
+
+def _tmplt_vrrp_group_auth(config_data):
+ config_data = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**config_data)
+ config_data = config_data["authentication"]
+ for key, value in config_data.items():
+ if value is not None:
+ command.append(cmd + " authentication " + f"{key.replace('_', '-')} {value}")
+ return command
+
+
+def _tmplt_vrrp_group_ts(config_data):
+ config_data = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**config_data)
+ config_data = config_data["transition_script"]
+ for key, value in config_data.items():
+ if value is not None:
+ command.append(cmd + " transition-script " + f"{key.replace('_', '-')} {value}")
+ return command
+
+
+def _tmplt_vrrp_sgroup_member(config_data):
+ sgroup = config_data["vrrp"]["sync_groups"]
+ command = []
+ cmd = "high-availability vrrp sync-group {name}".format(**sgroup)
+ members = sgroup.get("member", [])
+ for member in members:
+ if member is None:
+ continue
+ command.append(f"{cmd} member {member}")
+ return command
+
+
+def _tmplt_vrrp_group_exaddress(config_data):
+ group = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**group)
+ exaddresses = group.get("excluded_address", [])
+ for exaddress in exaddresses:
+ if exaddress is None:
+ continue
+ command.append(f"{cmd} excluded-address {exaddress}")
+ return command
+
+
+def _tmplt_vrrp_group_address(config_data):
+ group = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**group)
+ addresses = group.get("address", [])
+ for address in addresses:
+ if address is None:
+ continue
+ command.append(f"{cmd} address {address}")
+ return command
+
+
+def _tmplt_vrrp_group_hc(config_data):
+ config_data = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**config_data)
+ config_data = config_data["health_check"]
+ for key, value in config_data.items():
+ if value is not None:
+ command.append(cmd + " health-check " + f"{key.replace('_', '-')} {value}")
+ return command
+
+
+def _tmplt_vrrp_group_track_list(config_data):
+ config_data = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**config_data)
+ config_data = config_data["track"]
+ for key, value in config_data.items():
+ if isinstance(value, list) and value is not None and key != "name":
+ for item in value:
+ command.append(cmd + " track " + f"{key.replace('_', '-')} {item}")
+ return command
+
+
+def _tmplt_vrrp_group_track_bool(config_data):
+ config_data = config_data["vrrp"]["groups"]
+ command = []
+ cmd = "high-availability vrrp group {name}".format(**config_data)
+ config_data = config_data["track"]
+ for key, value in config_data.items():
+ if key != "name" and value is not None:
+ command.append(cmd + " track " + f"{key.replace('_', '-')}")
+ return command
+
+
+class HaTemplate(NetworkTemplate):
+ def __init__(self, lines=None, module=None):
+ prefix = {"set": "set", "remove": "delete"}
+ super(HaTemplate, self).__init__(
+ lines=lines,
+ tmplt=self,
+ prefix=prefix,
+ module=module,
+ )
+
+ # fmt: off
+ PARSERS = [
+ {
+ "name": "disable",
+ "getval": re.compile(
+ r"""
+ ^set
+ \shigh-availability
+ \s(?P<disable>disable)
+ $""",
+ re.VERBOSE,
+ ),
+ "setval": "high-availability disable",
+ "result": {
+ "disable": "{{ True if disable is defined else False }}",
+ },
+ },
+ {
+ "name": "virtual_servers.address",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ (?:\s+address\s+(?P<address>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "address": "{{ address if address is defined else None }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.algorithm",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ (?:\s+algorithm\s+(?P<algorithm>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "algorithm": "{{ algorithm if algorithm is defined else None }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.delay_loop",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ (?:\s+delay-loop\s+(?P<delay_loop>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "delay_loop": "{{ delay_loop if delay_loop is defined else None }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.forward_method",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ (?:\s+forward-method\s+(?P<forward_method>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "forward_method": "{{ forward_method if forward_method is defined else None }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.fwmark",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ (?:\s+fwmark\s+(?P<fwmark>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "fwmark": "{{ fwmark if fwmark is defined else None }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.persistence_timeout",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ (?:\s+persistence-timeout\s+(?P<persistence_timeout>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "persistence_timeout": "{{ persistence_timeout if persistence_timeout is defined else None }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.port",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ (?:\s+port\s+(?P<port>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "port": "{{ port if port is defined else None }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.protocol",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ (?:\s+protocol\s+(?P<protocol>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "protocol": "{{ protocol if protocol is defined else None }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.real_server.port",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ \sreal-server
+ \s+(?P<address>\S+)
+ (?:\s+port\s+(?P<port>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs_rsrv,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "real_server": {
+ "{{ address }}": {
+ "address": "{{ address }}",
+ "port": "{{ port if port is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.real_server.health_check_script",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ \sreal-server
+ \s+(?P<address>\S+)
+ (?:\s+health-check\sscript\s+(?P<hcscript>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs_rsrv,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "real_server": {
+ "{{ address }}": {
+ "address": "{{ address }}",
+ "health_check_script": "{{ hcscript if hcscript is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "virtual_servers.real_server.connection_timeout",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svirtual-server
+ \s+(?P<name>\S+)
+ \sreal-server
+ \s+(?P<address>\S+)
+ (?:\s+connection-timeout\s+(?P<cont>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vsrvs_rsrv,
+ "result": {
+ "virtual_servers": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "real_server": {
+ "{{ address }}": {
+ "address": "{{ address }}",
+ "connection_timeout": "{{ cont if cont is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.sync_groups.member",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\ssync-group
+ \s+(?P<sgname>\S+)
+ \smember
+ \s+(?P<member>\S+)
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_sgroup_member,
+ "result": {
+ "vrrp": {
+ "sync_groups": {
+ "{{ sgname }}": {
+ "name": "{{ sgname }}",
+ "member": [
+ "{{ member }}",
+ ],
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.sync_groups.health_check",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\ssync-group
+ \s+(?P<sgname>\S+)
+ \shealth-check
+ (?:\s+failure-count\s+(?P<failure_count>\S+))?
+ (?:\s+interval\s+(?P<int>\S+))?
+ (?:\s+ping\s+(?P<ping>\S+))?
+ (?:\s+script\s+(?P<script>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_sgroup_hc,
+ "result": {
+ "vrrp": {
+ "sync_groups": {
+ "{{ sgname }}": {
+ "name": "{{ sgname }}",
+ "health_check": {
+ "failure_count": "{{ failure_count if failure_count is defined else None }}",
+ "interval": "{{ int if int is defined else None }}",
+ "ping": "{{ ping if ping is defined else None }}",
+ "script": "{{ script if script is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.sync_groups.transition_script",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\ssync-group
+ \s+(?P<sgname>\S+)
+ \stransition-script
+ (?:\s+backup\s+(?P<backup>\S+))?
+ (?:\s+fault\s+(?P<fault>\S+))?
+ (?:\s+master\s+(?P<master>\S+))?
+ (?:\s+stop\s+(?P<stop>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_sgroup_ts,
+ "result": {
+ "vrrp": {
+ "sync_groups": {
+ "{{ sgname }}": {
+ "name": "{{ sgname }}",
+ "transition_script": {
+ "backup": "{{ backup if backup is defined else None }}",
+ "fault": "{{ fault if fault is defined else None }}",
+ "master": "{{ master if master is defined else None }}",
+ "stop": "{{ stop if stop is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.global_parameters.garp",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sglobal-parameters
+ \s+garp
+ (?:\s+interval\s+(?P<interval>\S+))?
+ (?:\s+master-delay\s+(?P<master_delay>\S+))?
+ (?:\s+master-refresh\s+(?P<master_refresh>\S+))?
+ (?:\s+master-refresh-repeat\s+(?P<master_refresh_repeat>\S+))?
+ (?:\s+master-repeat\s+(?P<master_repeat>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_gp_garp,
+ "result": {
+ "vrrp": {
+ "global_parameters": {
+ "garp": {
+ "interval": "{{ interval if interval is defined else None }}",
+ "master_delay": "{{ master_delay if master_delay is defined else None }}",
+ "master_refresh": "{{ master_refresh if master_refresh is defined else None }}",
+ "master_refresh_repeat": "{{ master_refresh_repeat if master_refresh_repeat is defined else None }}",
+ "master_repeat": "{{ master_repeat if master_repeat is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.global_parameters",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sglobal-parameters
+ (?=\s+(?:startup-delay|version)\s)
+ (?:\s+startup-delay\s+(?P<startup_delay>\S+))?
+ (?:\s+version\s+(?P<version>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_gp,
+ "result": {
+ "vrrp": {
+ "global_parameters": {
+ "startup_delay": "{{ startup_delay if startup_delay is defined else None }}",
+ "version": "{{ version if version is defined else None }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sgroup
+ \s+(?P<gname>\S+)
+ (?:\s+description\s+(?P<description>'.+?'|\S+))?
+ (?:\s+advertise-interval\s+(?P<advertise_interval>\S+))?
+ (?:\s+hello-source-address\s+(?P<hello_source>\S+))?
+ (?:\s+interface\s+(?P<interface>\S+))?
+ (?:\s+peer-address\s+(?P<peer_address>\S+))?
+ (?:\s+preempt-delay\s+(?P<preempt_delay>\S+))?
+ (?:\s+priority\s+(?P<priority>\S+))?
+ (?:\s+vrid\s+(?P<vrid>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "name": "{{ gname }}",
+ "description": "{{ description | replace(\"'\", \"\") if description is defined else None }}",
+ "advertise_interval": "{{ advertise_interval if advertise_interval is defined else None }}",
+ "hello_source_address": "{{ hello_source if hello_source is defined else None }}",
+ "interface": "{{ interface if interface is defined else None }}",
+ "peer_address": "{{ peer_address if peer_address is defined else None }}",
+ "preempt_delay": "{{ preempt_delay if preempt_delay is defined else None }}",
+ "priority": "{{ priority if priority is defined else None }}",
+ "vrid": "{{ vrid if vrid is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.excluded_address",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sgroup
+ \s+(?P<gname>\S+)
+ \sexcluded-address
+ \s+(?P<excluded_address>.*)
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_exaddress,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "name": "{{ gname }}",
+ "excluded_address": [
+ "{{ excluded_address | replace(\"'\", \"\") }}",
+ ],
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.address",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sgroup
+ \s+(?P<gname>\S+)
+ \saddress
+ \s+(?P<address>.*)
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_address,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "name": "{{ gname }}",
+ "address": [
+ "{{ address | replace(\"'\", \"\") }}",
+ ],
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.garp",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sgroup
+ \s+(?P<gname>\S+)
+ \s+garp
+ (?:\s+interval\s+(?P<interval>\S+))?
+ (?:\s+master-delay\s+(?P<master_delay>\S+))?
+ (?:\s+master-refresh\s+(?P<master_refresh>\S+))?
+ (?:\s+master-refresh-repeat\s+(?P<master_refresh_repeat>\S+))?
+ (?:\s+master-repeat\s+(?P<master_repeat>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_garp,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "name": "{{ gname }}",
+ "garp": {
+ "interval": "{{ interval if interval is defined else None }}",
+ "master_delay": "{{ master_delay if master_delay is defined else None }}",
+ "master_refresh": "{{ master_refresh if master_refresh is defined else None }}",
+ "master_refresh_repeat": "{{ master_refresh_repeat if master_refresh_repeat is defined else None }}",
+ "master_repeat": "{{ master_repeat if master_repeat is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.authentication",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sgroup
+ \s+(?P<gname>\S+)
+ \s+authentication
+ (?:\s+password\s+(?P<password>\S+))?
+ (?:\s+type\s+(?P<type>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_auth,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "name": "{{ gname }}",
+ "authentication": {
+ "password": "{{ password if password is defined else None }}",
+ "type": "{{ type if type is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.transition_script",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sgroup
+ \s+(?P<gname>\S+)
+ \stransition-script
+ (?:\s+backup\s+(?P<backup>\S+))?
+ (?:\s+fault\s+(?P<fault>\S+))?
+ (?:\s+master\s+(?P<master>\S+))?
+ (?:\s+stop\s+(?P<stop>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_ts,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "name": "{{ gname }}",
+ "transition_script": {
+ "backup": "{{ backup if backup is defined else None }}",
+ "fault": "{{ fault if fault is defined else None }}",
+ "master": "{{ master if master is defined else None }}",
+ "stop": "{{ stop if stop is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.health_check",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sgroup
+ \s+(?P<gname>\S+)
+ \shealth-check
+ (?:\s+failure-count\s+(?P<failure_count>\S+))?
+ (?:\s+interval\s+(?P<int>\S+))?
+ (?:\s+ping\s+(?P<ping>\S+))?
+ (?:\s+script\s+(?P<script>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_hc,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "name": "{{ gname }}",
+ "health_check": {
+ "failure_count": "{{ failure_count if failure_count is defined else None }}",
+ "interval": "{{ int if int is defined else None }}",
+ "ping": "{{ ping if ping is defined else None }}",
+ "script": "{{ script if script is defined else None }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.track.interface",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sgroup
+ \s+(?P<gname>\S+)
+ \strack
+ (?:\s+interface\s+(?P<interface>\S+))?
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_track_list,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "name": "{{ gname }}",
+ "track": {
+ "interface": "{{ [interface.strip(\"'\")] if interface is defined else [] }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.snmp",
+ "getval": re.compile(
+ r"""
+ ^set
+ \shigh-availability
+ \svrrp
+ \s(?P<snmp>snmp)
+ $""",
+ re.VERBOSE,
+ ),
+ "setval": "high-availability vrrp snmp",
+ "result": {
+ "vrrp": {
+ "snmp": "{{ 'enabled' if snmp is defined else 'disabled' }}",
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.disable",
+ "getval": re.compile(
+ r"""
+ ^set
+ \shigh-availability\svrrp\sgroup
+ \s(?P<gname>\S+)
+ \s(?P<disable>disable)
+ $""",
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_bool,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "disable": "{{ True if disable is defined else False }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.no_preempt",
+ "getval": re.compile(
+ r"""
+ ^set
+ \shigh-availability\svrrp\sgroup
+ \s(?P<gname>\S+)
+ \s(?P<no_preempt>no-preempt)
+ $""",
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_bool,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "no_preempt": "{{ True if no_preempt is defined else False }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.rfc3768_compatibility",
+ "getval": re.compile(
+ r"""
+ ^set
+ \shigh-availability\svrrp\sgroup
+ \s(?P<gname>\S+)
+ \s(?P<rfc3768_compatibility>rfc3768-compatibility)
+ $""",
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_bool,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "rfc3768_compatibility": "{{ True if rfc3768_compatibility is defined else False }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "vrrp.groups.track.exclude_vrrp_interface",
+ "getval": re.compile(
+ r"""
+ ^set\shigh-availability\svrrp\sgroup
+ \s+(?P<gname>\S+)
+ \strack
+ \s(?P<exclude_vrrp_inter>exclude-vrrp-interface)
+ $
+ """,
+ re.VERBOSE,
+ ),
+ "setval": _tmplt_vrrp_group_track_bool,
+ "result": {
+ "vrrp": {
+ "groups": {
+ "{{ gname }}": {
+ "name": "{{ gname }}",
+ "track": {
+ "exclude_vrrp_interface": "{{ True if exclude_vrrp_inter is defined else False }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ ]
+ # fmt: on
diff --git a/plugins/module_utils/network/vyos/utils/utils.py b/plugins/module_utils/network/vyos/utils/utils.py
index 78445c02..df0a72ca 100644
--- a/plugins/module_utils/network/vyos/utils/utils.py
+++ b/plugins/module_utils/network/vyos/utils/utils.py
@@ -288,12 +288,29 @@ def in_target_not_none(h, key):
def combine(a, b, recursive=False, list_merge="replace"):
- """
- Merge two dictionaries (shallow or deep).
- :param a: dict
- :param b: dict
- :param recursive: bool, deep merge
- :param list_merge: str, only 'replace' is supported (default Ansible behavior)
+ """Merge dict ``b`` into dict ``a``, returning a new dict.
+
+ :param a: Base dictionary.
+ :param b: Dictionary whose values take precedence over ``a``.
+ :param recursive: When True, nested dicts are merged recursively rather
+ than replaced wholesale.
+ :param list_merge: Controls how list values are combined when the same key
+ exists in both dicts. Supported modes:
+
+ - ``"replace"`` *(default)* — ``b``'s list replaces ``a``'s list.
+ - ``"append"`` — ``b``'s list is appended to ``a``'s list (duplicates
+ kept).
+ - ``"prepend"`` — ``b``'s list is prepended to ``a``'s list
+ (duplicates kept).
+ - ``"append_rp"`` — like ``"append"`` but duplicates are removed,
+ preserving the first occurrence (rp = remove-preserve).
+ - ``"prepend_rp"`` — like ``"prepend"`` but duplicates are removed,
+ preserving the first occurrence.
+
+ Passing any other value raises ``ValueError``.
+ :returns: New merged dict.
+ :raises ValueError: If either argument is not a dict, or if an
+ unsupported ``list_merge`` mode is given.
"""
if not isinstance(a, dict) or not isinstance(b, dict):
raise ValueError("combine expects two dictionaries")
@@ -301,8 +318,29 @@ def combine(a, b, recursive=False, list_merge="replace"):
result = a.copy()
for k, v in b.items():
- if recursive and k in result and isinstance(result[k], dict) and isinstance(v, dict):
- result[k] = combine(result[k], v, recursive=True, list_merge=list_merge)
+ if k in result:
+ # dict merge
+ if recursive and isinstance(result[k], dict) and isinstance(v, dict):
+ result[k] = combine(result[k], v, recursive=True, list_merge=list_merge)
+
+ # list merge
+ elif isinstance(result[k], list) and isinstance(v, list):
+ if list_merge == "replace":
+ result[k] = v
+ elif list_merge == "append":
+ result[k] = result[k] + v
+ elif list_merge == "prepend":
+ result[k] = v + result[k]
+ elif list_merge == "append_rp":
+ result[k] = list(dict.fromkeys(result[k] + v))
+ elif list_merge == "prepend_rp":
+ result[k] = list(dict.fromkeys(v + result[k]))
+ else:
+ raise ValueError(f"Unsupported list_merge mode: {list_merge}")
+
+ # everything else
+ else:
+ result[k] = v
else:
result[k] = v