diff options
Diffstat (limited to 'plugins/modules/vyos_ha.py')
| -rw-r--r-- | plugins/modules/vyos_ha.py | 783 |
1 files changed, 783 insertions, 0 deletions
diff --git a/plugins/modules/vyos_ha.py b/plugins/modules/vyos_ha.py new file mode 100644 index 0000000..ff1705f --- /dev/null +++ b/plugins/modules/vyos_ha.py @@ -0,0 +1,783 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +module: vyos_ha +short_description: Manage VRRP and load balancer configuration on VyOS via REST API +description: +- Manages VRRP groups, global VRRP parameters, sync-groups, virtual servers, and LVS + real servers on VyOS devices via the REST API. +- Uses REST API (C(connection=httpapi)) instead of CLI. +- Targets VyOS 1.4+. +version_added: 1.0.0 +author: +- Evgeny Molotkov (@omnom62) +options: + config: + description: High-availability configuration. + type: dict + suboptions: + disable: + description: Disable all high-availability configuration. + type: bool + default: false + virtual_servers: + description: List of load balancer virtual server definitions. + type: list + elements: dict + suboptions: + name: + type: str + required: true + description: Name. + address: + type: str + description: Address. + algorithm: + type: str + description: Algorithm. + delay_loop: + type: int + description: Delay loop. + forward_method: + type: str + choices: + - direct + - nat + description: Forward method. + fwmark: + type: int + description: Fwmark. + persistence_timeout: + type: int + description: Persistence timeout. + port: + type: int + description: Port. + protocol: + type: str + choices: + - tcp + - udp + description: Protocol. + real_server: + type: list + elements: dict + suboptions: + address: + type: str + required: true + description: Address. + port: + type: int + description: Port. + connection_timeout: + type: int + description: Connection timeout. + health_check_script: + type: str + description: Health check script. + description: Real server. + vrrp: + description: VRRP configuration. + type: dict + suboptions: + global_parameters: + type: dict + suboptions: + garp: + type: dict + suboptions: + interval: + type: int + description: Interval. + master_delay: + type: int + description: Master delay. + master_refresh: + type: int + description: Master refresh. + master_refresh_repeat: + type: int + description: Master refresh repeat. + master_repeat: + type: int + description: Master repeat. + description: Garp. + startup_delay: + type: int + description: Startup delay. + version: + type: str + description: Version. + description: Global parameters. + groups: + type: list + elements: dict + suboptions: + name: + type: str + required: true + description: Name. + address: + type: list + elements: str + description: Address. + advertise_interval: + type: int + description: Advertise interval. + authentication: + type: dict + suboptions: + password: + type: str + description: Password. + type: + type: str + description: Type. + description: Authentication. + description: + type: str + description: Description. + disable: + type: bool + default: false + description: Disable. + excluded_address: + type: list + elements: str + description: Excluded address. + garp: + type: dict + suboptions: + interval: + type: int + description: Interval. + master_delay: + type: int + description: Master delay. + master_refresh: + type: int + description: Master refresh. + master_refresh_repeat: + type: int + description: Master refresh repeat. + master_repeat: + type: int + description: Master repeat. + description: Garp. + health_check: + type: dict + suboptions: + failure_count: + type: int + description: Failure count. + interval: + type: int + description: Interval. + ping: + type: str + description: Ping. + script: + type: str + description: Script. + description: Health check. + hello_source_address: + type: str + description: Hello source address. + interface: + type: str + description: Interface. + no_preempt: + type: bool + default: false + description: No preempt. + peer_address: + type: str + description: Peer address. + preempt_delay: + type: int + description: Preempt delay. + priority: + type: int + description: Priority. + rfc3768_compatibility: + type: bool + default: false + description: Rfc3768 compatibility. + track: + type: dict + suboptions: + exclude_vrrp_interface: + type: bool + description: Exclude vrrp interface. + interface: + type: list + elements: str + description: Interface. + description: Track. + transition_script: + type: dict + suboptions: + backup: + type: str + description: Backup. + fault: + type: str + description: Fault. + master: + type: str + description: Master. + stop: + type: str + description: Stop. + description: Transition script. + vrid: + type: int + description: Vrid. + description: Groups. + snmp: + type: str + choices: + - enabled + - disabled + description: Snmp. + sync_groups: + type: list + elements: dict + suboptions: + name: + type: str + required: true + description: Name. + health_check: + type: dict + suboptions: + failure_count: + type: int + description: Failure count. + interval: + type: int + description: Interval. + ping: + type: str + description: Ping. + script: + type: str + description: Script. + description: Health check. + member: + type: list + elements: str + description: Member. + transition_script: + type: dict + suboptions: + backup: + type: str + description: Backup. + fault: + type: str + description: Fault. + master: + type: str + description: Master. + stop: + type: str + description: Stop. + description: Transition script. + description: Sync groups. + state: + description: Desired end state of the configuration. + type: str + choices: + - merged + - replaced + - overridden + - deleted + - gathered + default: merged + +""" + +EXAMPLES = r""" +- name: Merge VRRP configuration + vyos.rest.vyos_ha: + config: + vrrp: + global_parameters: + startup_delay: 30 + groups: + - name: g1 + interface: eth0 + vrid: 20 + priority: 100 + address: + - 192.168.1.100/24 + sync_groups: + - name: sg1 + member: [g1] + snmp: enabled + state: merged + +- name: Delete all HA configuration + vyos.rest.vyos_ha: + state: deleted + +- name: Gather current HA configuration + vyos.rest.vyos_ha: + state: gathered +""" + +RETURN = r""" +before: + description: HA configuration before this module ran. + returned: always + type: dict +after: + description: HA configuration after this module ran. + returned: when changed + type: dict +commands: + description: List of API commands sent to the device. + returned: always + type: list +gathered: + description: Current HA configuration as structured data. + returned: when state is gathered + type: dict +saved: + description: Whether the config was saved after changes. + returned: when changed + type: bool +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import ( + VyOSModule, + autoclean, + cast_by_spec, + dict_op, + from_device, + normalize_have, + to_tag_dict, +) + + +_BASE = ["high-availability"] + +ARGUMENT_SPEC = dict( + config=dict( + type="dict", + options=dict( + disable=dict(type="bool", default=False), + virtual_servers=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + address=dict(type="str"), + algorithm=dict(type="str"), + delay_loop=dict(type="int"), + forward_method=dict(type="str", choices=["direct", "nat"]), + fwmark=dict(type="int"), + persistence_timeout=dict(type="int"), + port=dict(type="int"), + protocol=dict(type="str", choices=["tcp", "udp"]), + real_server=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str", required=True), + port=dict(type="int"), + connection_timeout=dict(type="int"), + health_check_script=dict(type="str"), + ), + ), + ), + ), + vrrp=dict( + type="dict", + options=dict( + global_parameters=dict( + type="dict", + options=dict( + garp=dict( + type="dict", + options=dict( + interval=dict(type="int"), + master_delay=dict(type="int"), + master_refresh=dict(type="int"), + master_refresh_repeat=dict(type="int"), + master_repeat=dict(type="int"), + ), + ), + startup_delay=dict(type="int"), + version=dict(type="str"), + ), + ), + groups=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + address=dict(type="list", elements="str"), + advertise_interval=dict(type="int"), + authentication=dict( + type="dict", + options=dict( + password=dict(type="str", no_log=True), + type=dict(type="str"), + ), + ), + description=dict(type="str"), + disable=dict(type="bool", default=False), + excluded_address=dict(type="list", elements="str"), + garp=dict( + type="dict", + options=dict( + interval=dict(type="int"), + master_delay=dict(type="int"), + master_refresh=dict(type="int"), + master_refresh_repeat=dict(type="int"), + master_repeat=dict(type="int"), + ), + ), + health_check=dict( + type="dict", + options=dict( + failure_count=dict(type="int"), + interval=dict(type="int"), + ping=dict(type="str"), + script=dict(type="str"), + ), + ), + hello_source_address=dict(type="str"), + interface=dict(type="str"), + no_preempt=dict(type="bool", default=False), + peer_address=dict(type="str"), + preempt_delay=dict(type="int"), + priority=dict(type="int"), + rfc3768_compatibility=dict(type="bool", default=False), + track=dict( + type="dict", + options=dict( + exclude_vrrp_interface=dict(type="bool"), + interface=dict(type="list", elements="str"), + ), + ), + transition_script=dict( + type="dict", + options=dict( + backup=dict(type="str"), + fault=dict(type="str"), + master=dict(type="str"), + stop=dict(type="str"), + ), + ), + vrid=dict(type="int"), + ), + ), + snmp=dict(type="str", choices=["enabled", "disabled"]), + sync_groups=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + health_check=dict( + type="dict", + options=dict( + failure_count=dict(type="int"), + interval=dict(type="int"), + ping=dict(type="str"), + script=dict(type="str"), + ), + ), + member=dict(type="list", elements="str"), + transition_script=dict( + type="dict", + options=dict( + backup=dict(type="str"), + fault=dict(type="str"), + master=dict(type="str"), + stop=dict(type="str"), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), +) + +_TOP_OPTIONS = ARGUMENT_SPEC["config"]["options"] +_VS_OPTIONS = _TOP_OPTIONS["virtual_servers"]["options"] +_RS_OPTIONS = _VS_OPTIONS["real_server"]["options"] +_VRRP_OPTIONS = _TOP_OPTIONS["vrrp"]["options"] +_GROUP_OPTIONS = _VRRP_OPTIONS["groups"]["options"] +_SYNC_GROUP_OPTIONS = _VRRP_OPTIONS["sync_groups"]["options"] + +# Tag nodes VyOS's REST API can collapse to a bare string/list for a +# single entry with no other config. Split by section because "address" +# means two different things depending on where it appears -- confirmed +# against vyos-1x: vrrp.group.<name>.address is a genuine tagNode (VRRP +# virtual IPs, each with real child structure), but virtual-server. +# <name>.address is a flat scalar string (the load-balancer's own bind +# address). A single blanket key-name-based coercion across the whole +# raw tree would wrongly reshape the latter into a tag-node dict -- +# exactly the class of bug this split avoids. +_VS_TAG_KEYS = {"virtual-server", "real-server"} +_VRRP_TAG_KEYS = {"group", "sync-group", "address", "excluded-address"} + +# track.interface and sync-group.member are NOT included above -- +# confirmed <leafNode><multi/>, i.e. plain multi-value leaves, not tag +# nodes; dict_op's own native list handling (which already corrects for +# the same single-value-collapse quirk) applies to them directly, no +# reshaping needed. + + +# --------------------------------------------------------------------------- +# Structural adapters — the only genuine exceptions, confirmed against +# vyos-1x schema, not assumed: +# 1. Named-object lists (virtual_servers, real_server, groups, +# sync_groups): argspec uses [{name: "x", ...}], device uses +# {"x": {...}}. +# 2. address / excluded_address (VRRP group virtual IPs): genuine +# tagNodes (each has real child structure) -> {"a": {}, "b": {}}. +# 3. snmp: argspec "enabled"/"disabled" string <-> device presence node +# (present) / absent. "disabled" has no device-side representation at +# all -- see the explicit delete in build_commands(). +# 4. health_check_script: argspec flat field <-> device nested under +# health-check.script. +# +# Everything else -- including track.interface and sync_group.member, +# both plain multi-value leaves despite superficially looking like the +# same shape as address/excluded_address -- flows through autoclean/ +# from_device untouched. +# --------------------------------------------------------------------------- + + +def _real_server_to_device(rs): + entry = autoclean( + {k: v for k, v in rs.items() if k not in ("address", "health_check_script")}, + ) + if rs.get("health_check_script"): + entry["health-check"] = {"script": rs["health_check_script"]} + return entry + + +def _real_server_from_device(addr, data): + data = dict(data or {}) + hc = data.pop("health-check", None) or {} + entry = {"address": addr, **from_device(data)} + if hc.get("script"): + entry["health_check_script"] = hc["script"] + cast_by_spec(entry, _RS_OPTIONS) + return entry + + +def _virtual_server_to_device(vs): + entry = autoclean({k: v for k, v in vs.items() if k not in ("name", "real_server")}) + if vs.get("real_server"): + entry["real-server"] = { + rs["address"]: _real_server_to_device(rs) for rs in vs["real_server"] + } + return entry + + +def _virtual_server_from_device(name, data): + data = dict(data or {}) + rs_raw = data.pop("real-server", None) or {} + entry = {"name": name, **from_device(data)} + cast_by_spec(entry, _VS_OPTIONS) + if rs_raw: + entry["real_server"] = [ + _real_server_from_device(addr, rdata) for addr, rdata in sorted(rs_raw.items()) + ] + return entry + + +def _group_to_device(grp): + entry = autoclean( + {k: v for k, v in grp.items() if k not in ("name", "address", "excluded_address")}, + ) + if grp.get("address"): + entry["address"] = {a: {} for a in grp["address"]} + if grp.get("excluded_address"): + entry["excluded-address"] = {a: {} for a in grp["excluded_address"]} + return entry + + +def _group_from_device(name, data): + data = dict(data or {}) + addr_raw = data.pop("address", None) + excl_raw = data.pop("excluded-address", None) + entry = {"name": name, **from_device(data)} + cast_by_spec(entry, _GROUP_OPTIONS) + if addr_raw: + entry["address"] = sorted(to_tag_dict(addr_raw).keys()) + if excl_raw: + entry["excluded_address"] = sorted(to_tag_dict(excl_raw).keys()) + return entry + + +def _sync_group_from_device(name, data): + entry = {"name": name, **from_device(data or {})} + cast_by_spec(entry, _SYNC_GROUP_OPTIONS) + return entry + + +def _want_to_device(config): + if not config: + return {} + want = autoclean({k: v for k, v in config.items() if k not in ("virtual_servers", "vrrp")}) + + if config.get("virtual_servers"): + want["virtual-server"] = { + vs["name"]: _virtual_server_to_device(vs) for vs in config["virtual_servers"] + } + + vrrp = config.get("vrrp") or {} + if vrrp: + vrrp_dev = autoclean( + {k: v for k, v in vrrp.items() if k not in ("groups", "sync_groups", "snmp")}, + ) + # snmp: "enabled" -> presence node; "disabled" has no device-side + # form at all (handled via an explicit delete in build_commands). + if vrrp.get("snmp") == "enabled": + vrrp_dev["snmp"] = {} + if vrrp.get("groups"): + vrrp_dev["group"] = {g["name"]: _group_to_device(g) for g in vrrp["groups"]} + if vrrp.get("sync_groups"): + vrrp_dev["sync-group"] = { + sg["name"]: autoclean({k: v for k, v in sg.items() if k != "name"}) + for sg in vrrp["sync_groups"] + } + if vrrp_dev: + want["vrrp"] = vrrp_dev + + return want + + +def get_running_config(vyos): + return vyos.get_config(_BASE) or {} + + +def _device_to_argspec(raw): + if not raw: + return {} + result = from_device({k: v for k, v in raw.items() if k not in ("virtual-server", "vrrp")}) + cast_by_spec(result, _TOP_OPTIONS) + + vs_raw = raw.get("virtual-server") or {} + if vs_raw: + result["virtual_servers"] = [ + _virtual_server_from_device(name, data) for name, data in sorted(vs_raw.items()) + ] + + vrrp_raw = raw.get("vrrp") or {} + if vrrp_raw: + vrrp_arg = from_device( + {k: v for k, v in vrrp_raw.items() if k not in ("group", "sync-group", "snmp")}, + ) + cast_by_spec(vrrp_arg, _VRRP_OPTIONS) + if "snmp" in vrrp_raw: + vrrp_arg["snmp"] = "enabled" + + grp_raw = vrrp_raw.get("group") or {} + if grp_raw: + vrrp_arg["groups"] = [ + _group_from_device(name, data) for name, data in sorted(grp_raw.items()) + ] + + sg_raw = vrrp_raw.get("sync-group") or {} + if sg_raw: + vrrp_arg["sync_groups"] = [ + _sync_group_from_device(name, data) for name, data in sorted(sg_raw.items()) + ] + + if vrrp_arg: + result["vrrp"] = vrrp_arg + + return result + + +def build_commands(config, raw_have, state): + raw_have = raw_have or {} + config = config or {} + + if state == "deleted": + return [("delete", _BASE)] if raw_have else [] + + want = _want_to_device(config) + norm_have = {k: v for k, v in raw_have.items() if k not in ("virtual-server", "vrrp")} + if raw_have.get("virtual-server"): + norm_have["virtual-server"] = normalize_have(raw_have, _VS_TAG_KEYS)["virtual-server"] + if raw_have.get("vrrp"): + norm_have["vrrp"] = normalize_have(raw_have, _VRRP_TAG_KEYS)["vrrp"] + + commands = [] + if state == "overridden": + commands += dict_op(want, norm_have, _BASE, op="purge") + elif state == "replaced": + for section, section_want in want.items(): + section_have = norm_have.get(section, {}) + commands += dict_op(section_want, section_have, _BASE + [section], op="purge") + commands += dict_op(want, norm_have, _BASE, op="set") + + # snmp "disabled" has no device-side value to compare against -- + # it's the absence of the presence node, which dict_op's set/purge + # logic can't express as a "delete" on its own. Handled explicitly. + if (config.get("vrrp") or {}).get("snmp") == "disabled": + if "snmp" in (raw_have.get("vrrp") or {}): + commands.append(("delete", _BASE + ["vrrp", "snmp"])) + + return commands + + +def main(): + module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or {} + + raw_have = get_running_config(vyos) + have = _device_to_argspec(raw_have) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + commands = build_commands(config, raw_have, state) + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + after = _device_to_argspec(get_running_config(vyos)) + module.exit_json( + changed=True, + before=have, + after=after, + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() |
