diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index 2b343aa6..2b9a02a4 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -1,205 +1,204 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
#
# Per-repo CodeRabbit override for vyos/vyos.vyos (Ansible network collection).
#
# Most behavior is inherited from the org-level central baseline at
# https://github.com/vyos/coderabbit/blob/production/.coderabbit.yaml.
# This file keeps only what's distinct to this repo:
# - Ansible-collection-specific `path_instructions` (15 entries).
# - Two `path_filters` entries that aren't in the central list.
# - `base_branches: [main]` — central uses VyOS release-train names; this
# repo is an Ansible collection that lives on `main`.
# - `knowledge_base.jira` scoped to VD.
#
# Migrated from standalone (591-line rich config from T8584, sha
# 38eae56fb991b63e0c89245e4ef5fc130d3f5c8b) to centralized inheritance
# mode under T8851 on 2026-05-24. The standalone config preceded
# `vyos/coderabbit` central-config introduction (2026-05-12).
inheritance: true
reviews:
auto_review:
base_branches:
- main
path_filters:
# Repo-specific filters that aren't in the central baseline. The
# central already filters `!**/__pycache__/**`, `!**/*.pyc`,
# `!**/*.egg-info/**`, `!**/.venv/**`, `!**/.worktrees/**` — so they
# are not repeated here.
- - '!changelogs/changelog.yaml'
- - '!.collections/**'
+ - "!changelogs/changelog.yaml"
+ - "!.collections/**"
path_instructions:
# ── Global PR hygiene ──────────────────────────────────────────────
- path: "**"
instructions: |
This is the vyos.vyos Ansible network collection (namespace=vyos, name=vyos,
version=6.0.0). PR titles must follow the format `T{id}: description` referencing
a Phorge task at vyos.dev. Every PR must include a changelog fragment in
changelogs/fragments/ (YAML, valid keys: major_changes, minor_changes,
breaking_changes, deprecated_features, removed_features, security_fixes,
bugfixes, known_issues, doc_changes, trivial; plus release_summary as a
prelude section). Style: black line-length=100, isort
profile=black line_length=100, flake8 max-line-length=120. Do not suggest
88-char wrapping.
# ── Module entry points ────────────────────────────────────────────
- path: "plugins/modules/vyos_*.py"
instructions: |
Module entry points. Each file must contain three YAML triple-string blocks:
DOCUMENTATION, EXAMPLES, and RETURN — this is Ansible's documentation contract,
not Python docstrings. Verify:
- DOCUMENTATION includes: module, author, short_description, description,
version_added, extends_documentation_fragment (vyos.vyos.vyos), options with
types and descriptions, and a notes section listing tested VyOS versions.
- EXAMPLES has at least one working task per supported state.
- RETURN documents all return keys with description, returned, type, and sample.
- The module wires argspec, config, and facts classes correctly.
- State choices include the full set where applicable: merged, replaced,
overridden, deleted, gathered, parsed, rendered.
Do not add Python-style docstrings (def-level) to these files — the YAML blocks
are the canonical documentation.
# ── Argspec (auto-generated) ───────────────────────────────────────
- path: "plugins/module_utils/network/vyos/argspec/**"
instructions: |
Auto-generated by the Ansible resource module builder. These files carry a
"DO NOT EDIT" warning header. Do not suggest modifications to auto-generated
argspec files — changes will be overwritten. If the schema needs updating,
the resource module builder must regenerate it. Only flag issues if the
argument_spec dict has obvious type mismatches or missing required fields
that would cause runtime failures.
# ── Config classes ─────────────────────────────────────────────────
- path: "plugins/module_utils/network/vyos/config/**"
instructions: |
Config builders extending ansible.netcommon ConfigBase or ResourceModule.
These generate VyOS CLI commands from desired state. Verify:
- execute_module() handles all declared states correctly.
- set_config() and _set_config() process gathered facts and desired config
without data loss.
- Command generation produces valid VyOS CLI syntax (set/delete prefixes,
proper quoting of values with spaces).
- No silent swallowing of unknown keys — unknown config should raise or warn.
- Methods that compare current vs desired state handle empty/None gracefully.
Some older config files have auto-generated headers — do not restructure those.
# ── Facts classes ──────────────────────────────────────────────────
- path: "plugins/module_utils/network/vyos/facts/**"
instructions: |
Facts classes parse raw VyOS CLI output into structured dicts. Verify:
- Regex patterns handle edge cases (missing fields, empty values, quoted strings).
- populate() returns a clean dict even when device output is incomplete.
- get_device_data() uses the correct show command for the resource.
- facts/facts.py FACT_RESOURCE_SUBSETS and FACT_LEGACY_SUBSETS stay in sync
with available fact classes.
- Legacy facts (facts/legacy/) use run_commands(); resource facts use
get_resource_connection().
# ── RM Templates ───────────────────────────────────────────────────
- path: "plugins/module_utils/network/vyos/rm_templates/*.py"
instructions: |
Parser templates mapping structured data to VyOS CLI commands and vice versa.
Files with a `_14` suffix target VyOS 1.4+ behavior — do not suggest merging
them with the base version. Verify:
- _tmplt_* helper functions produce syntactically valid VyOS commands.
- Regex patterns in PARSERS list correctly capture all variations of the CLI
output (quoted values, optional fields, nested hierarchies).
- New templates include both set and delete command generation.
- compval/getval paths match the argspec structure.
# ── Cliconf plugin ─────────────────────────────────────────────────
- path: "plugins/cliconf/vyos.py"
instructions: |
Low-level CLI abstraction for VyOS. Handles configure mode, commit, diff,
command execution. Changes here affect all modules. Verify:
- edit_config() enters configure mode and commits correctly.
- get_diff() returns accurate before/after config diffs.
- Error handling catches VyOS-specific error patterns (commit failures,
invalid commands).
- __rpc__ list matches actually implemented methods.
# ── Terminal plugin ────────────────────────────────────────────────
- path: "plugins/terminal/vyos.py"
instructions: |
Terminal prompt detection and initialization. Changes affect connection
reliability. Verify regex patterns against actual VyOS prompt formats
(configure mode, operational mode, different shell variants). Do not
remove existing patterns without testing against all supported VyOS versions.
# ── Action plugin ──────────────────────────────────────────────────
- path: "plugins/action/vyos.py"
instructions: |
Auto-proxies all modules to the device. Must validate network_cli connection
type. Symlinks from each module name point here. Keep minimal — logic belongs
in config classes, not the action plugin.
# ── Changelog fragments ────────────────────────────────────────────
- path: "changelogs/fragments/*.{yaml,yml}"
instructions: |
Changelog fragments for ansible-changelog. Valid top-level keys:
major_changes, minor_changes, breaking_changes, deprecated_features,
removed_features, security_fixes, bugfixes, known_issues, doc_changes,
trivial. release_summary is a prelude section (one per release).
Fragment filename should be descriptive (e.g., fix-bgp-neighbor-timers.yml).
Use `trivial` for tooling/housekeeping. Entries should be complete sentences.
# ── CI workflows ───────────────────────────────────────────────────
- path: ".github/workflows/**"
instructions: |
CI pipeline: tests.yml (main CI with changelog, build, lint, sanity, unit jobs),
codecoverage.yml, release.yml (Galaxy + Automation Hub publish), check_label.yaml,
cla-check.yml. Changes to release.yml or ah_token_refresh.yml affect publishing
credentials — review with extra care. Do not remove the `all_green` aggregation
job from tests.yml.
# ── Unit tests ─────────────────────────────────────────────────────
- path: "tests/unit/**"
instructions: |
Unit tests use pytest + unittest.TestCase via TestVyosModule base class.
Key patterns:
- All test classes inherit TestVyosModule (from vyos_module.py).
- setUp() creates and starts mock patches; tearDown() stops them.
- execute_module(failed, changed, commands, sort) is the primary assertion method.
- load_fixtures() is overridden per test class to wire mock return values.
- Fixture files (.cfg) go in tests/unit/modules/network/vyos/fixtures/.
- Use load_fixture(name) to read fixtures — never inline raw config strings.
- set_module_args(dict(...)) configures module input before execution.
Style: black line-length=100, assertions via self.assertEqual / self.assertIn /
execute_module kwargs. pytest-xdist runs tests in parallel (-n 2).
# ── Test fixtures ──────────────────────────────────────────────────
- path: "tests/unit/modules/network/vyos/fixtures/**"
instructions: |
Raw VyOS CLI output files (.cfg). These are loaded by load_fixture() and
cached in memory. Format is VyOS `set ...` configuration syntax or show
command output. Fixture filenames follow the pattern:
vyos_{module}_config.cfg (base) or vyos_{module}_config_v14.cfg (VyOS 1.4+).
New fixtures must be syntactically valid VyOS config. Do not add JSON fixtures
unless the test explicitly requires JSON parsing.
# ── Collection metadata ────────────────────────────────────────────
- path: "galaxy.yml"
instructions: |
Collection metadata. namespace=vyos, name=vyos. Version bumps must be
coordinated with release process. Dependency on ansible.netcommon>=2.5.1
is required. Do not add unnecessary dependencies.
- path: "meta/runtime.yml"
instructions: |
Module redirects and tombstones. Adding a new module requires a redirect
entry (short name → FQCN). Tombstoned modules (logging, vyos_logging) must
not be un-tombstoned. requires_ansible must stay >=2.15.0 unless explicitly
bumping minimum version.
-
knowledge_base:
jira:
# `auto` activates Jira context lookups when this repo lives on an
# org with an Atlassian OAuth grant attached (VyOS-Networks); on the
# public vyos source it self-disables.
usage: auto
project_keys:
- VD
diff --git a/docs/vyos.vyos.vyos_config_module.rst b/docs/vyos.vyos.vyos_config_module.rst
index e2be25b9..b5b9f8b9 100644
--- a/docs/vyos.vyos.vyos_config_module.rst
+++ b/docs/vyos.vyos.vyos_config_module.rst
@@ -1,437 +1,458 @@
.. _vyos.vyos.vyos_config_module:
*********************
vyos.vyos.vyos_config
*********************
**Manage VyOS configuration on remote device**
Version added: 1.0.0
.. contents::
:local:
:depth: 1
Synopsis
--------
- This module provides configuration file management of VyOS devices. It provides arguments for managing both the configuration file and state of the active configuration. All configuration statements are based on `set` and `delete` commands in the device configuration.
Parameters
----------
.. raw:: html
| Parameter |
Choices/Defaults |
Comments |
+
+ |
+
+ allow_password_change
+
+
+ string
+
+ |
+
+ Choices:
+ - all
+ plaintext ←
+ - encrypted
+ - none
+
+ |
+
+ The allow_password_change argument specifies whether any configuration lines which would change a user's password should be filtered out. By default only plaintext password changes are allowed and any encrypted-password keys are filtered out. In order to allow all password updates, both plaintext and encrypted, set this argument to all.
+ |
+
|
backup
boolean
|
|
The backup argument will backup the current devices active configuration to the Ansible control host prior to making any changes. If the backup_options value is not given, the backup file will be located in the backup folder in the playbook root directory or role root directory, if playbook is part of an ansible role. If the directory does not exist, it is created.
|
|
backup_options
dictionary
|
|
This is a dict object containing configurable options related to backup file path. The value of this option is read only when backup is set to yes, if backup is set to no this option will be silently ignored.
|
|
dir_path
path
|
|
This option provides the path ending with directory name in which the backup configuration file will be stored. If the directory does not exist it will be first created and the filename is either the value of filename or default filename as described in filename options description. If the path value is not given in that case a backup directory will be created in the current working directory and backup configuration will be copied in filename within backup directory.
|
|
filename
string
|
|
The filename to be used to store the backup configuration. If the filename is not given it will be generated based on the hostname, current time and date in format defined by <hostname>_config.<current-date>@<current-time>
|
|
comment
string
|
Default:
"configured by vyos_config"
|
Allows a commit description to be specified to be included when the configuration is committed. If the configuration is not changed or committed, this argument is ignored.
|
|
config
string
|
|
The config argument specifies the base configuration to use to compare against the desired configuration. If this value is not specified, the module will automatically retrieve the current active configuration from the remote device. The configuration lines in the option value should be similar to how it will appear if present in the running-configuration of the device including indentation to ensure idempotency and correct diff.
|
|
confirm
string
|
Choices:
- automatic
- manual
none ←
|
The confirm argument will tell vyos to revert to the previous configuration if not explicitly confirmed after applying the new config. When set to automatic this module will automatically confirm the configuration, if the current session remains working with the new config. When set to manual, this module does not issue the confirmation itself.
|
|
confirm_timeout
integer
|
Default:
10
|
Minutes to wait for confirmation before reverting the configuration. Does not apply when confirm is set to none .
|
|
lines
list
/ elements=string
|
|
The ordered set of commands that should be configured in the section. The commands must be the exact same commands as found in the device running-config as found in the device running-config to ensure idempotency and correct diff. Be sure to note the configuration command syntax as some commands are automatically modified by the device config parser.
|
|
match
string
|
|
The match argument controls the method used to match against the current active configuration. By default, the desired config is matched against the active config and the deltas are loaded. If the match argument is set to none the active configuration is ignored and the configuration is always loaded.
|
|
save
boolean
|
|
The save argument controls whether or not changes made to the active configuration are saved to disk. This is independent of committing the config. When set to True, the active configuration is saved.
|
|
src
path
|
|
The src argument specifies the path to the source config file to load. The source config file can either be in bracket format or set format. The source file can include Jinja2 template variables. The configuration lines in the source file should be similar to how it will appear if present in the running-configuration of the device including indentation to ensure idempotency and correct diff.
|
Notes
-----
.. note::
- Tested against VyOS 1.3.8, 1.4.2, the upcoming 1.5, and the rolling release of spring 2025.
- This module works with connection ``ansible.netcommon.network_cli``. See `the VyOS OS Platform Options <../network/user_guide/platform_vyos.html>`_.
- To ensure idempotency and correct diff the configuration lines in the relevant module options should be similar to how they appear if present in the running configuration on device including the indentation.
- For more information on using Ansible to manage network devices see the :ref:`Ansible Network Guide `
Examples
--------
.. code-block:: yaml
- name: configure the remote device
vyos.vyos.vyos_config:
lines:
- set system host-name {{ inventory_hostname }}
- set service lldp
- delete service dhcp-server
- name: backup and load from file
vyos.vyos.vyos_config:
src: vyos.cfg
backup: true
- name: render a Jinja2 template onto the VyOS router
vyos.vyos.vyos_config:
src: vyos_template.j2
- name: revert after ten minutes, if connection is lost
vyos.vyos.vyos_config:
src: vyos_template.j2
confirm: automatic
- name: for idempotency, use full-form commands
vyos.vyos.vyos_config:
lines:
# - set int eth eth2 description 'OUTSIDE'
- set interface ethernet eth2 description 'OUTSIDE'
- name: configurable backup path
vyos.vyos.vyos_config:
backup: true
backup_options:
filename: backup.cfg
dir_path: /home/user
Return Values
-------------
Common return values are documented `here `_, the following are the fields unique to this module:
.. raw:: html
| Key |
Returned |
Description |
|
backup_path
string
|
when backup is yes |
The full path to the backup file
Sample:
/playbooks/ansible/backup/vyos_config.2016-07-16@22:28:34
|
|
commands
list
|
always |
The list of configuration commands sent to the device
Sample:
['...', '...']
|
|
date
string
|
when backup is yes |
The date extracted from the backup file name
Sample:
2016-07-16
|
|
filename
string
|
when backup is yes and filename is not specified in backup options |
The name of the backup file
Sample:
vyos_config.2016-07-16@22:28:34
|
|
filtered
list
|
always |
The list of configuration commands removed to avoid a load failure
Sample:
['...', '...']
|
|
shortname
string
|
when backup is yes and filename is not specified in backup options |
The full path to the backup file excluding the timestamp
Sample:
/playbooks/ansible/backup/vyos_config
|
|
time
string
|
when backup is yes |
The time extracted from the backup file name
Sample:
22:28:34
|
Status
------
Authors
~~~~~~~
- Nathaniel Case (@Qalthos)
diff --git a/plugins/module_utils/network/vyos/config/ha/ha.py b/plugins/module_utils/network/vyos/config/ha/ha.py
index 9657b29d..9592f9ee 100644
--- a/plugins/module_utils/network/vyos/config/ha/ha.py
+++ b/plugins/module_utils/network/vyos/config/ha/ha.py
@@ -1,648 +1,653 @@
#
# -*- 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",
]
+ # Validate once at construction time rather than on every parse()
+ # and get_parser() call. get_os_version() triggers a device
+ # round-trip; calling it 20+ times per module execution is wasteful.
+ 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 parse(self):
- """override parse to check template"""
- self._validate_template()
- return super().parse()
-
- def get_parser(self, name):
- """get_parsers"""
- self._validate_template()
- return super().get_parser(name)
-
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 = {}
haved = {}
wantd = deepcopy(self.want)
haved = deepcopy(self.have)
for entry in wantd, haved:
self._vrrp_groups_list_to_dict(entry)
self._virtual_servers_list_to_dict(entry)
self._vrrp_sync_groups_list_to_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 = sorted(list(dict.fromkeys(self.commands)))
def _compare_vsrvs(self, want, have):
"""Compare virtual servers of VRRP"""
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",
]
hlist = self._extract_named_leafs(have)
wlist = self._extract_named_leafs(want)
if self.state == "rendered":
hlist = []
if self.state in ["replaced", "deleted"]:
for hdict in hlist:
wdict = self._find_matching_vsrv(hdict, wlist)
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 wdict in wlist:
hdict = self._find_matching_vsrv(wdict, hlist)
self.compare(
parsers=vs_parsers,
want={"virtual_servers": wdict},
have={"virtual_servers": hdict},
)
def _compare_vrrp(self, want, have):
"""Compare the instances of VRRP"""
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")
hlist = self._extract_leaf_items(have)
wlist = self._extract_leaf_items(want)
if self.state == "rendered":
hlist = []
if self.state in ["replaced", "deleted"]:
for hdict in hlist:
wdict = self._find_matching_vrrp(hdict, wlist)
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 wdict in wlist:
hdict = self._find_matching_vrrp(wdict, hlist)
self.compare(parsers=vrrp_parsers, want={"vrrp": wdict}, have={"vrrp": hdict})
def _vrrp_groups_list_to_dict(self, data):
vrrp = data.get("vrrp", {})
groups = vrrp.get("groups")
if not groups:
return data
if isinstance(groups, dict):
return data
if isinstance(groups, list):
new_groups = {}
for item in groups:
name = item.get("name")
if not name:
continue
new_groups[name] = item
data["vrrp"]["groups"] = new_groups
return data
return data
def _vrrp_sync_groups_list_to_dict(self, data):
vrrp = data.get("vrrp", {})
groups = vrrp.get("sync_groups")
if not groups:
return data
if isinstance(groups, dict):
return data
if isinstance(groups, list):
new_groups = {}
for item in groups:
name = item.get("name")
if not name:
continue
new_groups[name] = item
data["vrrp"]["sync_groups"] = new_groups
return data
return data
def _virtual_servers_list_to_dict(self, data):
vss = data.get("virtual_servers")
if not vss:
return data
if isinstance(vss, dict):
for vs in vss.items():
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
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
return data
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 _find_matching_vsrv(self, want_item, have_list):
def build_sig(item):
if not isinstance(item, dict):
return None
name = item.get("name")
if not name:
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", name, addr, k)
return ("real_server", name, addr, None)
for k in item:
if k != "name":
return ("attr", name, k)
return None
sig_want = build_sig(want_item)
for obj in have_list:
if build_sig(obj) == sig_want:
return obj
return {}
def _find_matching_vrrp(self, want_item, have_list):
def build_sig(item):
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)
sig_want = build_sig(want_item)
for obj in have_list:
if build_sig(obj) == sig_want:
return obj
return {}
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 in [None, False, "disabled"]:
+ 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/ha/ha.py b/plugins/module_utils/network/vyos/facts/ha/ha.py
index 7a15ea0e..26a1f34e 100644
--- a/plugins/module_utils/network/vyos/facts/ha/ha.py
+++ b/plugins/module_utils/network/vyos/facts/ha/ha.py
@@ -1,164 +1,183 @@
# -*- 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):
- """To classify the configurations based on high availability sections"""
+ """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_grp = re.search(r"set high-availability vrrp group (\S+).*", config_line)
+ 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)
- vrrp_disable = re.search(r"set high-availability disable", config_line)
- vrrp_snmp = re.search(r"set high-availability vrrp snmp", config_line)
if vrrp_disable:
config_dict.setdefault("disable", []).append(config_line)
- if vrrp_snmp:
+ elif vrrp_snmp:
config_dict.setdefault("vrrp", []).append(config_line)
- if vrrp_gp:
+ elif vrrp_gp:
config_dict.setdefault("global_parameters", []).append(config_line)
- if vrrp_vsrv:
+ elif vrrp_grp:
+ # Namespace with prefix to avoid collision with sync-groups
+ # that share the same name.
+ 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)
- if vrrp_sg:
- config_dict.setdefault(vrrp_sg.group(1), []).append(config_line)
- if vrrp_grp:
- config_dict.setdefault(vrrp_grp.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)
+
+ # normalize_config must run before validate_config so that
+ # virtual_servers, groups, and sync_groups are already lists.
+ # validate_config cannot coerce a keyed dict to a list and will
+ # fail_json if it receives one.
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"] = self.normalize_config(params.get("config", []))
+ 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
- # Normalize virtual_servers
+ # Normalize virtual_servers dict → list. This conversion is safe to
+ # call repeatedly (already-a-list case is a no-op) but should only
+ # be needed once — after validate_config has run.
if isinstance(config.get("virtual_servers"), dict):
config["virtual_servers"] = list(config["virtual_servers"].values())
- # Normalize vrrp
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())
- vrrp = config.get("vrrp", {})
-
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
index ce673668..870ec5cf 100644
--- a/plugins/module_utils/network/vyos/rm_templates/ha.py
+++ b/plugins/module_utils/network/vyos/rm_templates/ha.py
@@ -1,1010 +1,1024 @@
# -*- 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(?Pdisable)
$""",
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\S+)
(?:\s+address\s+(?P\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\S+)
(?:\s+algorithm\s+(?P\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\S+)
(?:\s+delay-loop\s+(?P\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\S+)
(?:\s+forward-method\s+(?P\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\S+)
(?:\s+fwmark\s+(?P\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\S+)
(?:\s+persistence-timeout\s+(?P\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\S+)
(?:\s+port\s+(?P\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\S+)
(?:\s+protocol\s+(?P\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\S+)
\sreal-server
\s+(?P\S+)
(?:\s+port\s+(?P\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\S+)
\sreal-server
\s+(?P\S+)
(?:\s+health-check\sscript\s+(?P\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\S+)
\sreal-server
\s+(?P\S+)
(?:\s+connection-timeout\s+(?P\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\S+)
\smember
\s+(?P\S+)
$
""",
re.VERBOSE,
),
"setval": _tmplt_vrrp_sgroup_member,
"result": {
"vrrp": {
"sync_groups": {
"{{ sgname }}": {
"name": "{{ sgname }}",
"member": [
"{{ member }}",
],
},
},
},
},
},
+ # vrrp.sync_groups.health_check — all sub-fields are independently
+ # optional. VyOS emits one field per line; placing ? inside each
+ # group (not on a separate line) ensures every single-field line
+ # matches regardless of which field is present.
{
"name": "vrrp.sync_groups.health_check",
"getval": re.compile(
r"""
^set\shigh-availability\svrrp\ssync-group
\s+(?P\S+)
\shealth-check
- (?:\s+failure-count\s+(?P\S+))
- ?(?:\s+interval\s+(?P\S+))
- ?(?:\s+ping\s+(?P\S+))
- ?(?:\s+script\s+(?P