diff options
| author | omnom62 <75066712+omnom62@users.noreply.github.com> | 2026-08-22 01:04:18 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-21 10:04:18 -0500 |
| commit | cb738721c15ac01f49f66144dae80eb478331f77 (patch) | |
| tree | 52f870ed414506aaa5cd6efa7ae6513d807e194b /plugins/modules | |
| parent | 885b9462480712210ddeea9ca4a4b7a52e9ef587 (diff) | |
| download | rest.vyos-cb738721c15ac01f49f66144dae80eb478331f77.tar.gz rest.vyos-cb738721c15ac01f49f66144dae80eb478331f77.zip | |
T8989: wave 2 modules: interfaces, l3_interfaces, lag_interfaces, lldp_interfaces, ospfv2/3, ospf_interfaces
* T8989: vyos_interfaces
* T8989: vyos_interfaces changelog
* T8989: vyos_interfaces changelog
* T8989: changelog typo
* T8989: vyos_l3_interfaces module
* T8989: vyos_l3_interfaces module SIT and UAT
* T8989: vyos_l3_interfaces doc
* T8989: lag_interfaces
* T8989: lag interfaces
* T8989: lldp_interfaces
* T8989: lldp_interfaces module
* T8989: SIT updated
* T8989: SIT updated
* T8989: SIT updated
* T8989: typo fixes
* T8989: SIT and UAT updates
* T8989: ospf_v3 module
* T8989: ospf_v3 module
* T8989: vyos_ospf_interfaces
* T8989: vyos_ospf_interfaces SIT
* T8989: vyos_ospf_interfaces SIT
* T8989: vyos_ospfv3 SIT and UAT
---------
Co-authored-by: John Estabrook <jestabro@vyos.io>
Diffstat (limited to 'plugins/modules')
| -rw-r--r-- | plugins/modules/vyos_banner.py | 2 | ||||
| -rw-r--r-- | plugins/modules/vyos_interfaces.py | 334 | ||||
| -rw-r--r-- | plugins/modules/vyos_l3_interfaces.py | 467 | ||||
| -rw-r--r-- | plugins/modules/vyos_lag_interfaces.py | 431 | ||||
| -rw-r--r-- | plugins/modules/vyos_lldp_interfaces.py | 365 | ||||
| -rw-r--r-- | plugins/modules/vyos_logging_global.py | 8 | ||||
| -rw-r--r-- | plugins/modules/vyos_ospf_interfaces.py | 539 | ||||
| -rw-r--r-- | plugins/modules/vyos_ospfv2.py | 955 | ||||
| -rw-r--r-- | plugins/modules/vyos_ospfv3.py | 360 | ||||
| -rw-r--r-- | plugins/modules/vyos_route_maps.py | 15 | ||||
| -rw-r--r-- | plugins/modules/vyos_snmp_server.py | 10 |
11 files changed, 3471 insertions, 15 deletions
diff --git a/plugins/modules/vyos_banner.py b/plugins/modules/vyos_banner.py index 304fa20..bcda26d 100644 --- a/plugins/modules/vyos_banner.py +++ b/plugins/modules/vyos_banner.py @@ -267,7 +267,7 @@ def main(): try: if state in ("merged", "replaced"): - if state == "merged" and before.get("text") == desired_text: + if before.get("text") == desired_text: module.exit_json(changed=False, before=before, commands=[]) client.configure_set(path, _encode_banner(desired_text)) commands.append("set {p} '...'".format(p=" ".join(path))) diff --git a/plugins/modules/vyos_interfaces.py b/plugins/modules/vyos_interfaces.py new file mode 100644 index 0000000..6ed45d5 --- /dev/null +++ b/plugins/modules/vyos_interfaces.py @@ -0,0 +1,334 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_interfaces +short_description: Manage interface configuration on VyOS devices via REST API. +description: + - Manages L2 interface configuration (description, MTU, speed, duplex, enabled) + on VyOS devices using the HTTPS REST API. + - IP address configuration is handled by M(vyos.rest.vyos_l3_interfaces). +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of interface configurations. + type: list + elements: dict + suboptions: + name: + description: Full interface name (e.g. eth0, bond0, lo). + type: str + required: true + description: + description: Interface description. + type: str + enabled: + description: Whether the interface is enabled. False sets the disable flag. + type: bool + default: true + mtu: + description: Interface MTU. + type: int + duplex: + description: Interface duplex setting. + type: str + choices: [auto, full, half] + speed: + description: Interface speed setting. + type: str + choices: [auto, "10", "100", "1000", "2500", "10000"] + state: + description: + - C(merged) - Merge config with existing interface settings. + - C(replaced) - Replace config for listed interfaces. + - C(overridden) - Replace config for all interfaces. + - C(deleted) - Remove listed interface config or all interface config. + - C(gathered) - Read interface config from device without changes. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged +seealso: + - module: vyos.vyos.vyos_interfaces + - module: vyos.rest.vyos_l3_interfaces +""" + +EXAMPLES = r""" +- name: Merge interface configuration + vyos.rest.vyos_interfaces: + config: + - name: eth0 + description: Management interface + mtu: 1500 + enabled: true + state: merged + +- name: Disable an interface + vyos.rest.vyos_interfaces: + config: + - name: eth1 + enabled: false + state: merged + +- name: Delete interface description + vyos.rest.vyos_interfaces: + config: + - name: eth0 + state: deleted + +- name: Gather current interface configuration + vyos.rest.vyos_interfaces: + state: gathered +""" + +RETURN = r""" +before: + description: Interface configuration before this module ran. + returned: always + type: list +after: + description: Interface configuration after this module ran. + returned: when changed + type: list +commands: + description: List of API command tuples sent to the device. + returned: always + type: list +gathered: + description: Current interface configuration as structured data. + returned: when state is gathered + type: list +saved: + description: Whether the config was saved after changes. + returned: when changes are applied + type: bool +response: + description: Raw API response. + returned: when changes are applied + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +# Interface name prefix → API type key +_IFACE_TYPE = { + "eth": "ethernet", + "bond": "bonding", + "lo": "loopback", + "tun": "tunnel", + "wg": "wireguard", + "vti": "vti", + "dum": "dummy", + "vtun": "openvpn", + "ppp": "pppoe", + "wlan": "wireless", + "br": "bridge", +} + +# L2 fields managed by this module — excludes address, hw-id etc. +_L2_FIELDS = ["description", "mtu", "duplex", "speed"] + + +def _iface_type(name): + for prefix, itype in _IFACE_TYPE.items(): + if name.startswith(prefix): + return itype + return "ethernet" + + +def _iface_base(name): + return ["interfaces", _iface_type(name), name] + + +def get_running_config(vyos): + raw = vyos.get_config(["interfaces"]) + if not raw or not isinstance(raw, dict): + return [] + + result = [] + for itype, ifaces in sorted(raw.items()): + if not isinstance(ifaces, dict): + continue + for iname, idata in sorted(ifaces.items()): + idata = idata or {} + entry = {"name": iname} + if idata.get("description"): + entry["description"] = idata["description"] + if "mtu" in idata: + entry["mtu"] = int(idata["mtu"]) + if "duplex" in idata: + entry["duplex"] = idata["duplex"] + if "speed" in idata: + entry["speed"] = idata["speed"] + entry["enabled"] = "disable" not in idata + result.append(entry) + + return result + + +def _normalize(config): + """Convert argspec list to dict keyed by interface name.""" + return {entry["name"]: entry for entry in (config or [])} + + +def _iface_cmds(name, want, have): + """Generate set/delete commands to bring have → want for one interface.""" + cmds = [] + base = _iface_base(name) + have = have or {} + + # description + want_desc = want.get("description") + have_desc = have.get("description") + if want_desc is not None and want_desc != have_desc: + cmds.append(("set", base + ["description", want_desc])) + elif want_desc is None and have_desc is not None: + cmds.append(("delete", base + ["description"])) + + # mtu + want_mtu = want.get("mtu") + have_mtu = have.get("mtu") + if want_mtu is not None and want_mtu != have_mtu: + cmds.append(("set", base + ["mtu", str(want_mtu)])) + + # duplex + want_duplex = want.get("duplex") + have_duplex = have.get("duplex") + if want_duplex is not None and want_duplex != have_duplex: + cmds.append(("set", base + ["duplex", want_duplex])) + + # speed + want_speed = want.get("speed") + have_speed = have.get("speed") + if want_speed is not None and want_speed != have_speed: + cmds.append(("set", base + ["speed", want_speed])) + + # enabled / disable flag + want_enabled = want.get("enabled", True) + have_enabled = have.get("enabled", True) + if not want_enabled and have_enabled: + cmds.append(("set", base + ["disable"])) + elif want_enabled and not have_enabled: + cmds.append(("delete", base + ["disable"])) + + return cmds + + +def _delete_iface_config(name, have): + """Generate delete commands to remove L2 config from an interface.""" + cmds = [] + base = _iface_base(name) + have = have or {} + + for field in _L2_FIELDS: + if field in have: + cmds.append(("delete", base + [field])) + if not have.get("enabled", True): + cmds.append(("delete", base + ["disable"])) + + return cmds + + +def build_commands(config, have_raw, state): + cmds = [] + have_map = _normalize(have_raw) + + if state == "deleted": + if not config: + for name, have in have_map.items(): + cmds += _delete_iface_config(name, have) + else: + for entry in config: + name = entry["name"] + cmds += _delete_iface_config(name, have_map.get(name, {})) + return cmds + + want_map = _normalize(config) + + if state == "overridden": + # delete L2 config from interfaces not in want + for name in set(have_map) - set(want_map): + cmds += _delete_iface_config(name, have_map[name]) + + for name, want in want_map.items(): + have = have_map.get(name, {}) + + if state == "replaced": + # pre-check — only act if something differs + test_cmds = _iface_cmds(name, want, have) + if not test_cmds: + continue + # delete L2 fields then rebuild + cmds += _delete_iface_config(name, have) + have = {} + + cmds += _iface_cmds(name, want, have if state != "replaced" else {}) + + return cmds + + +ARGUMENT_SPEC = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + description=dict(type="str"), + enabled=dict(type="bool", default=True), + mtu=dict(type="int"), + duplex=dict(type="str", choices=["auto", "full", "half"]), + speed=dict(type="str", choices=["auto", "10", "100", "1000", "2500", "10000"]), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), +) + + +def main(): + module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or [] + + have = get_running_config(vyos) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + commands = build_commands(config, have, state) + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + module.exit_json( + changed=True, + before=have, + after=get_running_config(vyos), + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_l3_interfaces.py b/plugins/modules/vyos_l3_interfaces.py new file mode 100644 index 0000000..9b33973 --- /dev/null +++ b/plugins/modules/vyos_l3_interfaces.py @@ -0,0 +1,467 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_l3_interfaces +short_description: Manage L3 interface configuration on VyOS devices via REST API. +description: + - Manages IPv4 and IPv6 address configuration on VyOS interfaces using the + HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_l3_interfaces) but uses the HTTP API instead of CLI. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of L3 interface configurations. + type: list + elements: dict + suboptions: + name: + description: Full name of the interface, e.g. eth0, lo. + type: str + required: true + ipv4: + description: List of IPv4 addresses of the interface. + type: list + elements: dict + suboptions: + address: + description: + - IPv4 address in CIDR notation or C(dhcp). + type: str + ipv6: + description: List of IPv6 addresses of the interface. + type: list + elements: dict + suboptions: + address: + description: + - IPv6 address in CIDR notation, C(dhcpv6), or C(auto-config). + type: str + vifs: + description: List of virtual sub-interfaces (VLANs). + type: list + elements: dict + suboptions: + vlan_id: + description: VLAN identifier. + type: int + required: true + ipv4: + description: List of IPv4 addresses of the VIF. + type: list + elements: dict + suboptions: + address: + description: IPv4 address in CIDR notation or C(dhcp). + type: str + ipv6: + description: List of IPv6 addresses of the VIF. + type: list + elements: dict + suboptions: + address: + description: IPv6 address in CIDR notation, C(dhcpv6), or C(auto-config). + type: str + running_config: + description: Used only with state C(parsed). + type: str + state: + description: + - C(merged) - Add addresses without removing existing ones. + - C(replaced) - Replace addresses for listed interfaces. + - C(overridden) - Replace addresses for all interfaces. + - C(deleted) - Remove listed or all interface addresses. + - C(gathered) - Read interface addresses from device without changes. + - C(rendered) - Return commands for provided config without connecting. + - C(parsed) - Parse running_config into structured data. + type: str + choices: [merged, replaced, overridden, deleted, gathered, rendered, parsed] + default: merged +seealso: + - module: vyos.vyos.vyos_l3_interfaces + - module: vyos.rest.vyos_interfaces +""" + +EXAMPLES = r""" +- name: Merge L3 interface configuration + vyos.rest.vyos_l3_interfaces: + config: + - name: eth0 + ipv4: + - address: 192.0.2.1/24 + - address: dhcp + - name: lo + ipv4: + - address: 10.0.0.1/32 + ipv6: + - address: 2001:db8::1/128 + state: merged + +- name: Add VLAN subinterface addresses + vyos.rest.vyos_l3_interfaces: + config: + - name: eth0 + vifs: + - vlan_id: 100 + ipv4: + - address: 192.0.2.100/24 + state: merged + +- name: Delete all interface addresses + vyos.rest.vyos_l3_interfaces: + state: deleted + +- name: Gather current L3 interface configuration + vyos.rest.vyos_l3_interfaces: + state: gathered +""" + +RETURN = r""" +before: + description: L3 interface configuration before this module ran. + returned: always + type: list +after: + description: L3 interface configuration after this module ran. + returned: when changed + type: list +commands: + description: List of API command tuples sent to the device. + returned: always + type: list +gathered: + description: Current L3 interface configuration as structured data. + returned: when state is gathered + type: list +rendered: + description: Commands for the provided config (state=rendered). + returned: when state is rendered + type: list +parsed: + description: Structured data parsed from running_config (state=parsed). + returned: when state is parsed + type: list +saved: + description: Whether the config was saved after changes. + returned: when changes are applied + type: bool +response: + description: Raw API response. + returned: when changes are applied + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +_IFACE_TYPE = { + "eth": "ethernet", + "bond": "bonding", + "lo": "loopback", + "tun": "tunnel", + "wg": "wireguard", + "vti": "vti", + "dum": "dummy", + "vtun": "openvpn", + "br": "bridge", +} + + +def _iface_type(name): + for prefix, itype in _IFACE_TYPE.items(): + if name.startswith(prefix): + return itype + return "ethernet" + + +def _iface_base(name): + return ["interfaces", _iface_type(name), name] + + +def _addr_list(raw): + """Normalize address field — string or list → sorted list.""" + if not raw: + return [] + if isinstance(raw, str): + return [raw] + if isinstance(raw, list): + return sorted(raw) + return [] + + +def _split_addresses(addresses): + """Split address list into ipv4 and ipv6 lists.""" + ipv4 = [] + ipv6 = [] + for addr in addresses: + if addr in ("dhcp", "dhcpv6"): + if addr == "dhcp": + ipv4.append(addr) + else: + ipv6.append(addr) + elif ":" in addr: + ipv6.append(addr) + else: + ipv4.append(addr) + return sorted(ipv4), sorted(ipv6) + + +def _parse_iface(name, idata): + """Parse raw API interface data into argspec format.""" + idata = idata or {} + entry = {"name": name} + + addrs = _addr_list(idata.get("address")) + if addrs: + ipv4, ipv6 = _split_addresses(addrs) + if ipv4: + entry["ipv4"] = [{"address": a} for a in ipv4] + if ipv6: + entry["ipv6"] = [{"address": a} for a in ipv6] + + vif_data = idata.get("vif") or {} + if isinstance(vif_data, dict) and vif_data: + vifs = [] + for vlan_id, vdata in sorted(vif_data.items(), key=lambda x: int(x[0])): + vdata = vdata or {} + vif = {"vlan_id": int(vlan_id)} + vaddrs = _addr_list(vdata.get("address")) + if vaddrs: + vipv4, vipv6 = _split_addresses(vaddrs) + if vipv4: + vif["ipv4"] = [{"address": a} for a in vipv4] + if vipv6: + vif["ipv6"] = [{"address": a} for a in vipv6] + vifs.append(vif) + if vifs: + entry["vifs"] = vifs + + return entry + + +def get_running_config(vyos): + raw = vyos.get_config(["interfaces"]) + if not raw or not isinstance(raw, dict): + return [] + + result = [] + for itype, ifaces in sorted(raw.items()): + if not isinstance(ifaces, dict): + continue + for iname, idata in sorted(ifaces.items()): + entry = _parse_iface(iname, idata) + # only include if there's at least one address or vif + if entry.get("ipv4") or entry.get("ipv6") or entry.get("vifs"): + result.append(entry) + + return result + + +def _normalize(config): + """Convert argspec list to dict keyed by interface name.""" + result = {} + for entry in config or []: + name = entry["name"] + ipv4 = sorted([a["address"] for a in (entry.get("ipv4") or [])]) + ipv6 = sorted([a["address"] for a in (entry.get("ipv6") or [])]) + vifs = {} + for vif in entry.get("vifs") or []: + vid = vif["vlan_id"] + vipv4 = sorted([a["address"] for a in (vif.get("ipv4") or [])]) + vipv6 = sorted([a["address"] for a in (vif.get("ipv6") or [])]) + vifs[vid] = {"ipv4": vipv4, "ipv6": vipv6} + result[name] = {"ipv4": ipv4, "ipv6": ipv6, "vifs": vifs} + return result + + +def _addr_cmds(base, want_addrs, have_addrs, state): + """Generate set/delete commands for address lists.""" + cmds = [] + want_set = set(want_addrs) + have_set = set(have_addrs) + + for addr in want_set - have_set: + cmds.append(("set", base + ["address", addr])) + + if state in ("replaced", "deleted", "overridden"): + for addr in have_set - want_set: + cmds.append(("delete", base + ["address", addr])) + + return cmds + + +def _vif_cmds(iface_base, want_vifs, have_vifs, state): + """Generate commands for VIF subinterfaces.""" + cmds = [] + + if state in ("replaced", "overridden"): + for vid in set(have_vifs) - set(want_vifs): + cmds.append(("delete", iface_base + ["vif", str(vid)])) + + for vid, want_vif in want_vifs.items(): + have_vif = have_vifs.get(vid, {"ipv4": [], "ipv6": []}) + vif_base = iface_base + ["vif", str(vid)] + cmds += _addr_cmds(vif_base, want_vif["ipv4"], have_vif["ipv4"], state) + cmds += _addr_cmds(vif_base, want_vif["ipv6"], have_vif["ipv6"], state) + + return cmds + + +def build_commands(config, have_raw, state): + cmds = [] + have_map = _normalize(have_raw) + + if state == "deleted": + if not config: + for name, have in have_map.items(): + base = _iface_base(name) + for addr in have["ipv4"] + have["ipv6"]: + cmds.append(("delete", base + ["address", addr])) + for vid in have["vifs"]: + cmds.append(("delete", base + ["vif", str(vid)])) + else: + want_map = _normalize(config) + for name, want in want_map.items(): + have = have_map.get(name, {"ipv4": [], "ipv6": [], "vifs": {}}) + base = _iface_base(name) + if not want["ipv4"] and not want["ipv6"] and not want["vifs"]: + # delete all addresses for this interface + for addr in have["ipv4"] + have["ipv6"]: + cmds.append(("delete", base + ["address", addr])) + for vid in have["vifs"]: + cmds.append(("delete", base + ["vif", str(vid)])) + else: + for addr in want["ipv4"] + want["ipv6"]: + if addr in have["ipv4"] + have["ipv6"]: + cmds.append(("delete", base + ["address", addr])) + for vid in want["vifs"]: + if vid in have["vifs"]: + cmds.append(("delete", base + ["vif", str(vid)])) + return cmds + + want_map = _normalize(config) + + if state == "overridden": + for name in set(have_map) - set(want_map): + have = have_map[name] + base = _iface_base(name) + for addr in have["ipv4"] + have["ipv6"]: + cmds.append(("delete", base + ["address", addr])) + for vid in have["vifs"]: + cmds.append(("delete", base + ["vif", str(vid)])) + + for name, want in want_map.items(): + have = have_map.get(name, {"ipv4": [], "ipv6": [], "vifs": {}}) + base = _iface_base(name) + cmds += _addr_cmds(base, want["ipv4"], have["ipv4"], state) + cmds += _addr_cmds(base, want["ipv6"], have["ipv6"], state) + cmds += _vif_cmds(base, want["vifs"], have["vifs"], state) + + return cmds + + +ARGUMENT_SPEC = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + ipv4=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str")), + ), + ipv6=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str")), + ), + vifs=dict( + type="list", + elements="dict", + options=dict( + vlan_id=dict(type="int", required=True), + ipv4=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str")), + ), + ipv6=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str")), + ), + ), + ), + ), + ), + running_config=dict(type="str"), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered", "rendered", "parsed"], + ), +) + + +def main(): + module = AnsibleModule( + argument_spec=ARGUMENT_SPEC, + mutually_exclusive=[["config", "running_config"]], + required_if=[ + ("state", "rendered", ["config"]), + ("state", "parsed", ["running_config"]), + ], + supports_check_mode=True, + ) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or [] + + if state == "parsed": + # parsed is offline — just return empty for now + module.exit_json(parsed=[]) + + if state == "rendered": + # build commands without connecting + cmds = build_commands(config, [], "merged") + module.exit_json(rendered=cmds, commands=cmds) + + have = get_running_config(vyos) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + commands = build_commands(config, have, state) + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + module.exit_json( + changed=True, + before=have, + after=get_running_config(vyos), + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_lag_interfaces.py b/plugins/modules/vyos_lag_interfaces.py new file mode 100644 index 0000000..6855419 --- /dev/null +++ b/plugins/modules/vyos_lag_interfaces.py @@ -0,0 +1,431 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_lag_interfaces +short_description: Manage LAG interface configuration on VyOS devices via REST API. +description: + - Manages Link Aggregation Group (LAG/bonding) interface configuration on VyOS + devices using the HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_lag_interfaces) but uses the HTTP API instead of CLI. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of LAG interface configurations. + type: list + elements: dict + suboptions: + name: + description: Name of the LAG interface (e.g. bond0). + type: str + required: true + mode: + description: LAG bonding mode. + type: str + choices: + - 802.3ad + - active-backup + - broadcast + - round-robin + - transmit-load-balance + - adaptive-load-balance + - xor-hash + members: + description: List of member interfaces. + type: list + elements: dict + suboptions: + member: + description: Name of the member interface. + type: str + primary: + description: Primary interface for active-backup mode. + type: str + hash_policy: + description: Transmit hash policy. + type: str + choices: + - layer2 + - layer2+3 + - layer3+4 + arp_monitor: + description: ARP link monitoring parameters. + type: dict + suboptions: + interval: + description: ARP monitoring interval in milliseconds. + type: int + target: + description: IP addresses to use for ARP monitoring. + type: list + elements: str + running_config: + description: Used only with state C(parsed). + type: str + state: + description: + - C(merged) - Merge config with existing LAG settings. + - C(replaced) - Replace config for listed LAG interfaces. + - C(overridden) - Replace config for all LAG interfaces. + - C(deleted) - Remove listed or all LAG interface config. + - C(gathered) - Read LAG config from device without changes. + - C(rendered) - Return commands for provided config without connecting. + - C(parsed) - Parse running_config into structured data. + type: str + choices: [merged, replaced, overridden, deleted, gathered, rendered, parsed] + default: merged +seealso: + - module: vyos.vyos.vyos_lag_interfaces +""" + +EXAMPLES = r""" +- name: Merge LAG interface configuration + vyos.rest.vyos_lag_interfaces: + config: + - name: bond0 + mode: 802.3ad + hash_policy: layer2 + members: + - member: eth1 + - member: eth2 + state: merged + +- name: Delete all LAG interfaces + vyos.rest.vyos_lag_interfaces: + state: deleted + +- name: Gather current LAG configuration + vyos.rest.vyos_lag_interfaces: + state: gathered +""" + +RETURN = r""" +before: + description: LAG configuration before this module ran. + returned: always + type: list +after: + description: LAG configuration after this module ran. + returned: when changed + type: list +commands: + description: List of API command tuples sent to the device. + returned: always + type: list +gathered: + description: Current LAG configuration as structured data. + returned: when state is gathered + type: list +rendered: + description: Commands for provided config (state=rendered). + returned: when state is rendered + type: list +parsed: + description: Structured data parsed from running_config (state=parsed). + returned: when state is parsed + type: list +saved: + description: Whether the config was saved after changes. + returned: when changes are applied + type: bool +response: + description: Raw API response. + returned: when changes are applied + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +_BASE = ["interfaces", "bonding"] + + +def _bond_base(name): + return _BASE + [name] + + +def get_running_config(vyos): + raw = vyos.get_config(_BASE) + if not raw or not isinstance(raw, dict): + return [] + + if len(raw) == 1 and "bonding" in raw: + raw = raw["bonding"] + + result = [] + for name, data in sorted(raw.items()): + if name in ("bonding", "ethernet", "loopback"): + continue + data = data or {} + entry = {"name": name} + + if data.get("mode"): + entry["mode"] = data["mode"] + if data.get("primary"): + entry["primary"] = data["primary"] + if "hash-policy" in data: + entry["hash_policy"] = data["hash-policy"] + + member_data = data.get("member", {}) + if isinstance(member_data, dict): + iface_data = member_data.get("interface", {}) + if isinstance(iface_data, dict) and iface_data: + entry["members"] = [{"member": m} for m in sorted(iface_data.keys())] + elif isinstance(iface_data, str): + entry["members"] = [{"member": iface_data}] + + arp = data.get("arp-monitor", {}) + if isinstance(arp, dict) and arp: + arp_entry = {} + if "interval" in arp: + arp_entry["interval"] = int(arp["interval"]) + target_data = arp.get("target", {}) + if isinstance(target_data, dict): + arp_entry["target"] = sorted(target_data.keys()) + elif isinstance(target_data, str): + arp_entry["target"] = [target_data] + if arp_entry: + entry["arp_monitor"] = arp_entry + + result.append(entry) + + return result + + +def _normalize(config): + result = {} + for entry in config or []: + name = entry["name"] + result[name] = { + "mode": entry.get("mode"), + "primary": entry.get("primary"), + "hash_policy": entry.get("hash_policy"), + "members": sorted([m["member"] for m in (entry.get("members") or [])]), + "arp_interval": (entry.get("arp_monitor") or {}).get("interval"), + "arp_targets": sorted((entry.get("arp_monitor") or {}).get("target") or []), + } + return result + + +def _bond_cmds(name, want, have): + cmds = [] + base = _bond_base(name) + have = have or {} + + if want.get("mode") and want["mode"] != have.get("mode"): + cmds.append(("set", base + ["mode", want["mode"]])) + + if want.get("primary") and want["primary"] != have.get("primary"): + cmds.append(("set", base + ["primary", want["primary"]])) + + if want.get("hash_policy") and want["hash_policy"] != have.get("hash_policy"): + cmds.append(("set", base + ["hash-policy", want["hash_policy"]])) + + want_members = set(want.get("members") or []) + have_members = set(have.get("members") or []) + for m in want_members - have_members: + cmds.append(("set", base + ["member", "interface", m])) + + want_interval = want.get("arp_interval") + have_interval = have.get("arp_interval") + if want_interval is not None and want_interval != have_interval: + cmds.append(("set", base + ["arp-monitor", "interval", str(want_interval)])) + + want_targets = set(want.get("arp_targets") or []) + have_targets = set(have.get("arp_targets") or []) + for t in want_targets - have_targets: + cmds.append(("set", base + ["arp-monitor", "target", t])) + + return cmds + + +def _delete_bond_cmds(name, have, want=None): + """Generate delete commands for a bond — full delete or selective.""" + cmds = [] + base = _bond_base(name) + have = have or {} + want = want or {} + + if not want: + # full delete + cmds.append(("delete", base)) + return cmds + + # selective — only delete what want specifies + if want.get("mode") and have.get("mode"): + cmds.append(("delete", base + ["mode"])) + if want.get("primary") and have.get("primary"): + cmds.append(("delete", base + ["primary"])) + if want.get("hash_policy") and have.get("hash_policy"): + cmds.append(("delete", base + ["hash-policy"])) + for m in set(want.get("members") or []) & set(have.get("members") or []): + cmds.append(("delete", base + ["member", "interface", m])) + if want.get("arp_interval") and have.get("arp_interval"): + cmds.append(("delete", base + ["arp-monitor", "interval"])) + for t in set(want.get("arp_targets") or []) & set(have.get("arp_targets") or []): + cmds.append(("delete", base + ["arp-monitor", "target", t])) + + return cmds + + +def build_commands(config, have_raw, state): + cmds = [] + have_map = _normalize(have_raw) + + if state == "deleted": + if not config: + for name in have_map: + cmds.append(("delete", _bond_base(name))) + else: + want_map = _normalize(config) + for name, want in want_map.items(): + have = have_map.get(name, {}) + if not any( + [ + want.get("mode"), + want.get("primary"), + want.get("hash_policy"), + want.get("members"), + want.get("arp_interval"), + want.get("arp_targets"), + ], + ): + # delete entire bond + if name in have_map: + cmds.append(("delete", _bond_base(name))) + else: + cmds += _delete_bond_cmds(name, have, want) + return cmds + + want_map = _normalize(config) + + if state == "overridden": + for name in set(have_map) - set(want_map): + cmds.append(("delete", _bond_base(name))) + + for name, want in want_map.items(): + have = have_map.get(name, {}) + + if state == "replaced" and name in have_map: + test_cmds = _bond_cmds(name, want, have) + # check for extra members/targets in have not in want + extra_members = set(have.get("members") or []) - set(want.get("members") or []) + extra_targets = set(have.get("arp_targets") or []) - set(want.get("arp_targets") or []) + have_fields = {k: v for k, v in have.items() if v} + want_fields = {k: v for k, v in want.items() if v} + if test_cmds or extra_members or extra_targets or have_fields != want_fields: + cmds.append(("delete", _bond_base(name))) + have = {} + else: + continue + + cmds += _bond_cmds(name, want, have) + + return cmds + + +ARGUMENT_SPEC = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + mode=dict( + type="str", + choices=[ + "802.3ad", + "active-backup", + "broadcast", + "round-robin", + "transmit-load-balance", + "adaptive-load-balance", + "xor-hash", + ], + ), + members=dict( + type="list", + elements="dict", + options=dict(member=dict(type="str")), + ), + primary=dict(type="str"), + hash_policy=dict( + type="str", + choices=["layer2", "layer2+3", "layer3+4"], + ), + arp_monitor=dict( + type="dict", + options=dict( + interval=dict(type="int"), + target=dict(type="list", elements="str"), + ), + ), + ), + ), + running_config=dict(type="str"), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered", "rendered", "parsed"], + ), +) + + +def main(): + module = AnsibleModule( + argument_spec=ARGUMENT_SPEC, + mutually_exclusive=[["config", "running_config"]], + required_if=[ + ("state", "rendered", ["config"]), + ("state", "parsed", ["running_config"]), + ], + supports_check_mode=True, + ) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or [] + + if state == "parsed": + module.exit_json(parsed=[]) + + if state == "rendered": + cmds = build_commands(config, [], "merged") + module.exit_json(rendered=cmds, commands=cmds) + + have = get_running_config(vyos) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + commands = build_commands(config, have, state) + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + module.exit_json( + changed=True, + before=have, + after=get_running_config(vyos), + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_lldp_interfaces.py b/plugins/modules/vyos_lldp_interfaces.py new file mode 100644 index 0000000..aa80b2f --- /dev/null +++ b/plugins/modules/vyos_lldp_interfaces.py @@ -0,0 +1,365 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_lldp_interfaces +short_description: Manage LLDP interface configuration on VyOS devices via REST API. +description: + - Manages per-interface LLDP configuration on VyOS devices using the HTTPS REST API. + - Targets VyOS 1.5+ where LLDP interface mode replaces the legacy disable flag. + - For the disable flag used in VyOS 1.3/1.4, see C(vyos.vyos.vyos_lldp_interfaces). +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of LLDP interface configurations. + type: list + elements: dict + suboptions: + name: + description: Name of the interface. + type: str + required: true + mode: + description: + - LLDP administrative mode for this interface. + - C(rx-tx) sends and receives LLDP frames (default). + - C(disable) disables LLDP on this interface. + - C(rx) receives only. + - C(tx) transmits only. + type: str + choices: [disable, rx-tx, rx, tx] + location: + description: LLDP-MED location data. + type: dict + suboptions: + elin: + description: Emergency Call Service ELIN number (10-25 digits). + type: str + coordinate_based: + description: Coordinate-based location. + type: dict + suboptions: + latitude: + description: Latitude (e.g. 33.524449N). + type: str + required: true + longitude: + description: Longitude (e.g. 22.267255E). + type: str + required: true + altitude: + description: Altitude in meters. + type: int + datum: + description: Coordinate datum type. + type: str + choices: [WGS84, NAD83, MLLW] + running_config: + description: Used only with state C(parsed). + type: str + state: + description: + - C(merged) - Merge config with existing LLDP interface settings. + - C(replaced) - Replace config for listed interfaces. + - C(overridden) - Replace config for all LLDP interfaces. + - C(deleted) - Remove listed or all LLDP interface config. + - C(gathered) - Read LLDP interface config from device without changes. + - C(rendered) - Return commands for provided config without connecting. + - C(parsed) - Parse running_config into structured data. + type: str + choices: [merged, replaced, overridden, deleted, gathered, rendered, parsed] + default: merged +notes: + - Targets VyOS 1.5+ exclusively. The C(mode) parameter replaces the C(enable) + boolean used in C(vyos.vyos.vyos_lldp_interfaces) for VyOS 1.3/1.4. +seealso: + - module: vyos.vyos.vyos_lldp_interfaces + - module: vyos.rest.vyos_lldp_global +""" + +EXAMPLES = r""" +- name: Merge LLDP interface configuration + vyos.rest.vyos_lldp_interfaces: + config: + - name: eth0 + mode: disable + location: + elin: "1234567890" + - name: eth1 + location: + coordinate_based: + latitude: "33.524449N" + longitude: "22.267255E" + altitude: 2200 + datum: WGS84 + state: merged + +- name: Delete all LLDP interface configuration + vyos.rest.vyos_lldp_interfaces: + state: deleted + +- name: Gather current LLDP interface configuration + vyos.rest.vyos_lldp_interfaces: + state: gathered +""" + +RETURN = r""" +before: + description: LLDP interface configuration before this module ran. + returned: always + type: list +after: + description: LLDP interface configuration after this module ran. + returned: when changed + type: list +commands: + description: List of API command tuples sent to the device. + returned: always + type: list +gathered: + description: Current LLDP interface configuration as structured data. + returned: when state is gathered + type: list +rendered: + description: Commands for provided config (state=rendered). + returned: when state is rendered + type: list +parsed: + description: Structured data parsed from running_config (state=parsed). + returned: when state is parsed + type: list +saved: + description: Whether the config was saved after changes. + returned: when changes are applied + type: bool +response: + description: Raw API response. + returned: when changes are applied + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +_BASE = ["service", "lldp", "interface"] + + +def _iface_base(name): + return _BASE + [name] + + +def get_running_config(vyos): + raw = vyos.get_config(["service", "lldp"]) + if not raw or not isinstance(raw, dict): + return [] + + iface_data = raw.get("interface") or {} + if not isinstance(iface_data, dict): + return [] + + result = [] + for name, data in sorted(iface_data.items()): + data = data or {} + entry = {"name": name} + + if data.get("mode"): + entry["mode"] = data["mode"] + + loc_data = data.get("location") or {} + if isinstance(loc_data, dict) and loc_data: + loc = {} + if "elin" in loc_data: + loc["elin"] = loc_data["elin"] + cb = loc_data.get("coordinate-based") or {} + if isinstance(cb, dict) and cb: + coord = {} + if "latitude" in cb: + coord["latitude"] = cb["latitude"] + if "longitude" in cb: + coord["longitude"] = cb["longitude"] + if "altitude" in cb: + coord["altitude"] = int(cb["altitude"]) + if "datum" in cb: + coord["datum"] = cb["datum"] + if coord: + loc["coordinate_based"] = coord + if loc: + entry["location"] = loc + + result.append(entry) + + return result + + +def _normalize(config): + result = {} + for entry in config or []: + name = entry["name"] + loc = entry.get("location") or {} + cb = loc.get("coordinate_based") or {} + result[name] = { + "mode": entry.get("mode"), + "elin": loc.get("elin"), + "latitude": cb.get("latitude"), + "longitude": cb.get("longitude"), + "altitude": cb.get("altitude"), + "datum": cb.get("datum"), + } + return result + + +def _iface_cmds(name, want, have): + cmds = [] + base = _iface_base(name) + have = have or {} + + if want.get("mode") and want["mode"] != have.get("mode"): + cmds.append(("set", base + ["mode", want["mode"]])) + elif not want.get("mode") and have.get("mode"): + cmds.append(("delete", base + ["mode"])) + + loc_base = base + ["location"] + if want.get("elin") and want["elin"] != have.get("elin"): + cmds.append(("set", loc_base + ["elin", want["elin"]])) + + cb_base = loc_base + ["coordinate-based"] + if want.get("latitude") and want["latitude"] != have.get("latitude"): + cmds.append(("set", cb_base + ["latitude", want["latitude"]])) + if want.get("longitude") and want["longitude"] != have.get("longitude"): + cmds.append(("set", cb_base + ["longitude", want["longitude"]])) + if want.get("altitude") is not None and want["altitude"] != have.get("altitude"): + cmds.append(("set", cb_base + ["altitude", str(want["altitude"])])) + if want.get("datum") and want["datum"] != have.get("datum"): + cmds.append(("set", cb_base + ["datum", want["datum"]])) + + return cmds + + +def build_commands(config, have_raw, state): + cmds = [] + have_map = _normalize(have_raw) + + if state == "deleted": + if not config: + for name in have_map: + cmds.append(("delete", _iface_base(name))) + else: + want_map = _normalize(config) + for name in want_map: + if name in have_map: + cmds.append(("delete", _iface_base(name))) + return cmds + + want_map = _normalize(config) + + if state == "overridden": + for name in set(have_map) - set(want_map): + cmds.append(("delete", _iface_base(name))) + + for name, want in want_map.items(): + have = have_map.get(name, {}) + + if state == "replaced" and name in have_map: + test_cmds = _iface_cmds(name, want, have) + if not test_cmds: + continue + cmds.append(("delete", _iface_base(name))) + have = {} + + cmds += _iface_cmds(name, want, have) + + return cmds + + +ARGUMENT_SPEC = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + mode=dict(type="str", choices=["disable", "rx-tx", "rx", "tx"]), + location=dict( + type="dict", + options=dict( + elin=dict(type="str"), + coordinate_based=dict( + type="dict", + options=dict( + latitude=dict(type="str", required=True), + longitude=dict(type="str", required=True), + altitude=dict(type="int"), + datum=dict(type="str", choices=["WGS84", "NAD83", "MLLW"]), + ), + ), + ), + ), + ), + ), + running_config=dict(type="str"), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered", "rendered", "parsed"], + ), +) + + +def main(): + module = AnsibleModule( + argument_spec=ARGUMENT_SPEC, + mutually_exclusive=[["config", "running_config"]], + required_if=[ + ("state", "rendered", ["config"]), + ("state", "parsed", ["running_config"]), + ], + supports_check_mode=True, + ) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or [] + + if state == "parsed": + module.exit_json(parsed=[]) + + if state == "rendered": + cmds = build_commands(config, [], "merged") + module.exit_json(rendered=cmds, commands=cmds) + + have = get_running_config(vyos) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + commands = build_commands(config, have, state) + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + module.exit_json( + changed=True, + before=have, + after=get_running_config(vyos), + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_logging_global.py b/plugins/modules/vyos_logging_global.py index 09fdd6b..f18ba3f 100644 --- a/plugins/modules/vyos_logging_global.py +++ b/plugins/modules/vyos_logging_global.py @@ -305,7 +305,7 @@ def normalize_running(raw): for f, data in raw.get("console", {}).get("facility", {}).items(): result["console"]["facilities"][f] = data.get("level") - g = raw.get("global", {}) + g = raw.get("local", {}) for f, data in g.get("facility", {}).items(): result["global"]["facilities"][f] = data.get("level") if "archive" in g: @@ -315,7 +315,7 @@ def normalize_running(raw): if "preserve-fqdn" in g: result["global"]["preserve_fqdn"] = True - for host, data in raw.get("host", {}).items(): + for host, data in raw.get("remote", {}).items(): h = {"port": data.get("port"), "facilities": {}} for f, fd in data.get("facility", {}).items(): h["facilities"][f] = { @@ -402,7 +402,7 @@ def build_commands(want, have, state): ) cmds += diff_facilities( - ["system", "syslog", "global"], + ["system", "syslog", "local"], want["global"]["facilities"], have["global"]["facilities"], state, @@ -416,7 +416,7 @@ def build_commands(want, have, state): ) cmds += diff_map( - ["system", "syslog", "host"], + ["system", "syslog", "remote"], want["hosts"], have["hosts"], state, diff --git a/plugins/modules/vyos_ospf_interfaces.py b/plugins/modules/vyos_ospf_interfaces.py new file mode 100644 index 0000000..5388ef0 --- /dev/null +++ b/plugins/modules/vyos_ospf_interfaces.py @@ -0,0 +1,539 @@ +#!/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_ospf_interfaces +short_description: Manage OSPF interface configuration on VyOS devices using REST API +description: + - Manages OSPF and OSPFv3 interface configuration on VyOS devices via the REST API. + - IPv4 OSPF maps to C(protocols ospf interface). + - IPv6 OSPFv3 maps to C(protocols ospfv3 interface). + - Uses REST API (C(connection=httpapi)) instead of CLI. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of OSPF interface configurations. + type: list + elements: dict + suboptions: + name: + description: Interface name. + type: str + required: true + address_family: + description: OSPF settings per address family. + type: list + elements: dict + suboptions: + afi: + description: Address family identifier. + type: str + choices: [ipv4, ipv6] + required: true + authentication: + description: Authentication settings (IPv4 only). + type: dict + suboptions: + plaintext_password: + description: Plaintext password. + type: str + md5_key: + description: MD5 authentication key. + type: dict + suboptions: + key_id: + description: MD5 key ID. + type: int + key: + description: MD5 key string. + type: str + bandwidth: + description: Interface bandwidth in kbps (IPv4 only). + type: int + cost: + description: Interface cost metric. + type: int + dead_interval: + description: Dead router detection interval in seconds. + type: int + hello_interval: + description: Hello packet interval in seconds. + type: int + ifmtu: + description: Interface MTU (IPv6 only). + type: int + instance: + description: OSPFv3 instance ID (IPv6 only). + type: str + mtu_ignore: + description: Disable MTU check (IPv4 only). + type: bool + network: + description: Network type (IPv4 only). + type: str + choices: [broadcast, non-broadcast, point-to-multipoint, point-to-point] + passive: + description: Disable adjacency formation (IPv6 only). + type: bool + priority: + description: Interface priority. + type: int + retransmit_interval: + description: LSA retransmit interval in seconds. + type: int + transmit_delay: + description: LSA transmit delay in seconds. + type: int + state: + description: + - Desired state of the OSPF interface configuration. + - C(merged) adds or updates without removing existing config. + - C(replaced) replaces per-interface OSPF config for named interfaces. + - C(overridden) replaces all OSPF interface config. + - C(deleted) removes OSPF interface config. + - C(gathered) returns current configuration as structured data. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged +notes: + - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin. + - C(ansible_network_os) must be set to C(vyos.rest.vyos). +""" + +EXAMPLES = r""" +- name: Merge OSPF interface configuration + vyos.rest.vyos_ospf_interfaces: + config: + - name: eth1 + address_family: + - afi: ipv4 + cost: 100 + transmit_delay: 50 + priority: 26 + - afi: ipv6 + dead_interval: 39 + passive: true + state: merged + +- name: Delete OSPF interface configuration + vyos.rest.vyos_ospf_interfaces: + config: + - name: eth1 + state: deleted + +- name: Delete all OSPF interface configuration + vyos.rest.vyos_ospf_interfaces: + state: deleted + +- name: Gather current OSPF interface configuration + vyos.rest.vyos_ospf_interfaces: + state: gathered +""" + +RETURN = r""" +before: + description: OSPF interface configuration before this module ran. + returned: always + type: list +after: + description: OSPF interface configuration after this module ran. + returned: when changed + type: list +commands: + description: List of API command tuples sent to the device. + returned: always + type: list +gathered: + description: Current OSPF interface configuration as structured data. + returned: when state is gathered + type: list +saved: + description: Whether the config was saved after changes. + returned: when changes are applied + type: bool +response: + description: Raw API response. + returned: when changes are applied + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +_BASE4 = ["protocols", "ospf", "interface"] +_BASE6 = ["protocols", "ospfv3", "interface"] + +# IPv4 scalar fields: argspec_key -> api_key +_IPV4_SCALARS = { + "bandwidth": "bandwidth", + "cost": "cost", + "dead_interval": "dead-interval", + "hello_interval": "hello-interval", + "mtu_ignore": "mtu-ignore", + "network": "network", + "priority": "priority", + "retransmit_interval": "retransmit-interval", + "transmit_delay": "transmit-delay", +} + +# IPv6 scalar fields +_IPV6_SCALARS = { + "cost": "cost", + "dead_interval": "dead-interval", + "hello_interval": "hello-interval", + "ifmtu": "ifmtu", + "instance": "instance-id", + "passive": "passive", + "priority": "priority", + "retransmit_interval": "retransmit-interval", + "transmit_delay": "transmit-delay", +} + +# bool fields that use presence (no value) +_IPV4_BOOL_PRESENCE = {"mtu_ignore"} +_IPV6_BOOL_PRESENCE = {"passive"} + + +def _parse_ipv4_iface(data): + af = {"afi": "ipv4"} + data = data or {} + for arg_key, api_key in _IPV4_SCALARS.items(): + if api_key in data: + if arg_key in _IPV4_BOOL_PRESENCE: + af[arg_key] = True + else: + val = data[api_key] + if arg_key in ( + "bandwidth", + "cost", + "dead_interval", + "hello_interval", + "priority", + "retransmit_interval", + "transmit_delay", + ): + try: + af[arg_key] = int(val) + except (TypeError, ValueError): + af[arg_key] = val + else: + af[arg_key] = val + # authentication + auth = data.get("authentication", {}) + if auth: + auth_entry = {} + if "plaintext-password" in auth: + auth_entry["plaintext_password"] = auth["plaintext-password"] + md5 = auth.get("md5", {}) + if md5: + key_id_data = md5.get("key-id", {}) + if key_id_data: + key_id = list(key_id_data.keys())[0] + md5_key = key_id_data[key_id].get("md5-key") + auth_entry["md5_key"] = {"key_id": int(key_id), "key": md5_key} + if auth_entry: + af["authentication"] = auth_entry + return af + + +def _parse_ipv6_iface(data): + af = {"afi": "ipv6"} + data = data or {} + for arg_key, api_key in _IPV6_SCALARS.items(): + if api_key in data: + if arg_key in _IPV6_BOOL_PRESENCE: + af[arg_key] = True + else: + val = data[api_key] + if arg_key in ( + "cost", + "dead_interval", + "hello_interval", + "ifmtu", + "priority", + "retransmit_interval", + "transmit_delay", + ): + try: + af[arg_key] = int(val) + except (TypeError, ValueError): + af[arg_key] = val + else: + af[arg_key] = val + return af + + +def get_running_config(vyos): + raw4 = vyos.get_config(_BASE4) or {} + raw4 = raw4.get("interface", raw4) + raw6 = vyos.get_config(_BASE6) or {} + raw6 = raw6.get("interface", raw6) + + ifaces = {} + + for iface_name, data in raw4.items(): + if iface_name not in ifaces: + ifaces[iface_name] = {"name": iface_name, "address_family": []} + af = _parse_ipv4_iface(data) + if len(af) > 1: # more than just afi key + ifaces[iface_name]["address_family"].append(af) + + for iface_name, data in raw6.items(): + if iface_name not in ifaces: + ifaces[iface_name] = {"name": iface_name, "address_family": []} + af = _parse_ipv6_iface(data) + if len(af) > 1: + ifaces[iface_name]["address_family"].append(af) + + result = sorted(ifaces.values(), key=lambda x: x["name"]) + # Remove empty address_family lists + for iface in result: + if not iface["address_family"]: + del iface["address_family"] + return result + + +def _ipv4_af_cmds(iface_name, af, have_af, op="set"): + cmds = [] + base = _BASE4 + [iface_name] + have_af = have_af or {} + + for arg_key, api_key in _IPV4_SCALARS.items(): + want_val = af.get(arg_key) + have_val = have_af.get(arg_key) + if want_val is not None and want_val != have_val: + if arg_key in _IPV4_BOOL_PRESENCE: + cmds.append(("set", base + [api_key])) + else: + cmds.append(("set", base + [api_key, str(want_val)])) + elif op == "replace" and have_val is not None and want_val != have_val: + cmds.append(("delete", base + [api_key])) + + # authentication + want_auth = af.get("authentication") or {} + have_auth = have_af.get("authentication") or {} + if want_auth.get("plaintext_password") and want_auth["plaintext_password"] != have_auth.get( + "plaintext_password", + ): + cmds.append( + ( + "set", + base + + [ + "authentication", + "plaintext-password", + want_auth["plaintext_password"], + ], + ), + ) + md5 = want_auth.get("md5_key") or {} + if md5 and md5 != have_auth.get("md5_key"): + cmds.append( + ( + "set", + base + + [ + "authentication", + "md5", + "key-id", + str(md5["key_id"]), + "md5-key", + md5["key"], + ], + ), + ) + + return cmds + + +def _ipv6_af_cmds(iface_name, af, have_af, op="set"): + cmds = [] + base = _BASE6 + [iface_name] + have_af = have_af or {} + + for arg_key, api_key in _IPV6_SCALARS.items(): + want_val = af.get(arg_key) + have_val = have_af.get(arg_key) + if want_val is not None and want_val != have_val: + if arg_key in _IPV6_BOOL_PRESENCE: + cmds.append(("set", base + [api_key])) + else: + cmds.append(("set", base + [api_key, str(want_val)])) + elif op == "replace" and have_val is not None and want_val != have_val: + cmds.append(("delete", base + [api_key])) + + return cmds + + +def build_commands(config, have_raw, state): + cmds = [] + have_map = {e["name"]: e for e in have_raw} + + if state == "deleted": + if not config: + # delete all + if have_raw: + for iface in have_raw: + name = iface["name"] + afs = {af["afi"] for af in iface.get("address_family", [])} + if "ipv4" in afs: + cmds.append(("delete", _BASE4 + [name])) + if "ipv6" in afs: + cmds.append(("delete", _BASE6 + [name])) + else: + for entry in config: + name = entry["name"] + if name in have_map: + have_afs = {af["afi"] for af in have_map[name].get("address_family", [])} + want_afis = {af["afi"] for af in (entry.get("address_family") or [])} + if not want_afis: + # delete all AFIs for this interface + if "ipv4" in have_afs: + cmds.append(("delete", _BASE4 + [name])) + if "ipv6" in have_afs: + cmds.append(("delete", _BASE6 + [name])) + else: + if "ipv4" in want_afis and "ipv4" in have_afs: + cmds.append(("delete", _BASE4 + [name])) + if "ipv6" in want_afis and "ipv6" in have_afs: + cmds.append(("delete", _BASE6 + [name])) + return cmds + + if state == "overridden": + want_names = {e["name"] for e in config} + for name, have_iface in have_map.items(): + if name not in want_names: + have_afs = {af["afi"] for af in have_iface.get("address_family", [])} + if "ipv4" in have_afs: + cmds.append(("delete", _BASE4 + [name])) + if "ipv6" in have_afs: + cmds.append(("delete", _BASE6 + [name])) + + for entry in config: + name = entry["name"] + have_iface = have_map.get(name, {}) + have_af_map = {af["afi"]: af for af in have_iface.get("address_family", [])} + + for af in entry.get("address_family", []): + afi = af["afi"] + have_af = have_af_map.get(afi, {}) + + if state == "replaced": + af_clean = {k: v for k, v in af.items() if v is not None} + if have_af and have_af != af_clean: + if afi == "ipv4": + cmds.append(("delete", _BASE4 + [name])) + have_af = {} + else: + cmds.append(("delete", _BASE6 + [name])) + have_af = {} + elif have_af == af_clean: + continue # already matches — idempotent + + if afi == "ipv4": + cmds += _ipv4_af_cmds(name, af, have_af) + else: + cmds += _ipv6_af_cmds(name, af, have_af) + + return cmds + + +ARGUMENT_SPEC = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + address_family=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", choices=["ipv4", "ipv6"], required=True), + authentication=dict( + type="dict", + options=dict( + plaintext_password=dict(type="str", no_log=True), + md5_key=dict( + type="dict", + no_log=True, + options=dict( + key_id=dict(type="int"), + key=dict(type="str", no_log=True), + ), + ), + ), + ), + bandwidth=dict(type="int"), + cost=dict(type="int"), + dead_interval=dict(type="int"), + hello_interval=dict(type="int"), + ifmtu=dict(type="int"), + instance=dict(type="str"), + mtu_ignore=dict(type="bool"), + network=dict( + type="str", + choices=[ + "broadcast", + "non-broadcast", + "point-to-multipoint", + "point-to-point", + ], + ), + passive=dict(type="bool"), + priority=dict(type="int"), + retransmit_interval=dict(type="int"), + transmit_delay=dict(type="int"), + ), + ), + ), + ), + state=dict( + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), +) + + +def main(): + module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or [] + + have = get_running_config(vyos) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + commands = build_commands(config, have, state) + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + module.exit_json( + changed=True, + before=have, + after=get_running_config(vyos), + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_ospfv2.py b/plugins/modules/vyos_ospfv2.py new file mode 100644 index 0000000..5ac333d --- /dev/null +++ b/plugins/modules/vyos_ospfv2.py @@ -0,0 +1,955 @@ +#!/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_ospfv2 +short_description: Manage OSPFv2 configuration on VyOS devices using REST API +description: + - Manages OSPFv2 configuration on VyOS devices via the REST API. + - Uses REST API (C(connection=httpapi)) instead of CLI. + - 'In VyOS 1.5+, passive interfaces use per-interface config rather than passive-interface.' +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: OSPFv2 configuration. + type: dict + suboptions: + areas: + description: OSPFv2 areas. + type: list + elements: dict + suboptions: + area_id: + description: Area ID. + type: str + required: true + area_type: + description: Area type. + type: dict + suboptions: + normal: + description: Normal area. + type: bool + nssa: + description: NSSA area. + type: dict + suboptions: + set: + description: Enable NSSA. + type: bool + default_cost: + description: Default cost for NSSA. + type: int + no_summary: + description: Do not inject inter-area routes. + type: bool + translate: + description: NSSA-ABR translate setting. + type: str + choices: [always, candidate, never] + stub: + description: Stub area. + type: dict + suboptions: + set: + description: Enable stub. + type: bool + default_cost: + description: Default cost for stub. + type: int + no_summary: + description: Do not inject inter-area routes. + type: bool + authentication: + description: Area authentication type. + type: str + choices: [plaintext-password, md5] + network: + description: Networks in this area. + type: list + elements: dict + suboptions: + address: + description: Network address. + type: str + required: true + range: + description: Area ranges. + type: list + elements: dict + suboptions: + address: + description: Range address. + type: str + required: true + cost: + description: Cost for this range. + type: int + not_advertise: + description: Do not advertise this range. + type: bool + substitute: + description: Substitute prefix. + type: str + shortcut: + description: Shortcut mode. + type: str + choices: [default, disable, enable] + auto_cost: + description: Auto-cost reference bandwidth. + type: dict + suboptions: + reference_bandwidth: + description: Reference bandwidth in Mbps. + type: int + default_information: + description: Default route distribution. + type: dict + suboptions: + originate: + description: Originate default route. + type: dict + suboptions: + always: + description: Always advertise default route. + type: bool + metric: + description: Metric for default route. + type: int + metric_type: + description: Metric type. + type: int + route_map: + description: Route map. + type: str + default_metric: + description: Default metric for redistributed routes. + type: int + distance: + description: Administrative distances. + type: dict + suboptions: + global: + description: Global OSPFv2 distance. + type: int + ospf: + description: Per-route-type distances. + type: dict + suboptions: + external: + description: External route distance. + type: int + inter_area: + description: Inter-area route distance. + type: int + intra_area: + description: Intra-area route distance. + type: int + log_adjacency_changes: + description: Log adjacency changes. + type: str + choices: [detail] + neighbor: + description: OSPF neighbors. + type: list + elements: dict + suboptions: + neighbor_id: + description: Neighbor IP. + type: str + required: true + poll_interval: + description: Poll interval. + type: int + priority: + description: Neighbor priority. + type: int + parameters: + description: OSPFv2 parameters. + type: dict + suboptions: + abr_type: + description: ABR type. + type: str + choices: [cisco, ibm, shortcut, standard] + opaque_lsa: + description: Enable opaque LSA. + type: bool + rfc1583_compatibility: + description: Enable RFC1583 compatibility. + type: bool + router_id: + description: Router ID. + type: str + passive_interface: + description: > + Passive interfaces (VyOS 1.5+: configured via + C(protocols ospf interface <name> passive)). + type: list + elements: str + redistribute: + description: Route redistribution. + type: list + elements: dict + suboptions: + route_type: + description: Protocol to redistribute. + type: str + choices: [bgp, connected, kernel, rip, static] + metric: + description: Metric. + type: int + metric_type: + description: Metric type. + type: int + route_map: + description: Route map. + type: str + state: + description: + - Desired state. + - C(merged) adds/updates without removing existing config. + - C(replaced) replaces the entire OSPFv2 configuration. + - C(deleted) removes OSPFv2 configuration. + - C(gathered) returns current configuration as structured data. + type: str + choices: [merged, replaced, deleted, gathered] + default: merged +notes: + - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin. + - C(ansible_network_os) must be set to C(vyos.rest.vyos). + - VyOS 1.5+ uses per-interface passive configuration rather than + the global C(passive-interface) command used in VyOS 1.4. +""" + +EXAMPLES = r""" +- name: Merge OSPFv2 configuration + vyos.rest.vyos_ospfv2: + config: + parameters: + router_id: 192.0.1.1 + abr_type: cisco + auto_cost: + reference_bandwidth: 2 + areas: + - area_id: "2" + area_type: + normal: true + network: + - address: 192.0.2.0/24 + - area_id: "3" + area_type: + nssa: + set: true + - area_id: "4" + area_type: + stub: + default_cost: 20 + range: + - address: 192.0.3.0/24 + cost: 10 + redistribute: + - route_type: bgp + metric: 10 + passive_interface: + - eth1 + state: merged + +- name: Delete all OSPFv2 configuration + vyos.rest.vyos_ospfv2: + state: deleted + +- name: Gather current OSPFv2 configuration + vyos.rest.vyos_ospfv2: + state: gathered +""" + +RETURN = r""" +before: + description: OSPFv2 configuration before this module ran. + returned: always + type: dict +after: + description: OSPFv2 configuration after this module ran. + returned: when changed + type: dict +commands: + description: List of API command tuples sent to the device. + returned: always + type: list +gathered: + description: Current OSPFv2 configuration as structured data. + returned: when state is gathered + type: dict +saved: + description: Whether the config was saved after changes. + returned: when changes are applied + type: bool +response: + description: Raw API response. + returned: when changes are applied + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +_BASE = ["protocols", "ospf"] + + +def _parse_areas(raw_areas): + if not raw_areas or not isinstance(raw_areas, dict): + return [] + areas = [] + for area_id, data in sorted(raw_areas.items()): + area = {"area_id": area_id} + data = data or {} + + # area-type + at = data.get("area-type", {}) + if at: + area_type = {} + if "normal" in at: + area_type["normal"] = True + if "nssa" in at: + nssa_data = at["nssa"] or {} + nssa = {"set": True} + if "default-cost" in nssa_data: + nssa["default_cost"] = int(nssa_data["default-cost"]) + if "no-summary" in nssa_data: + nssa["no_summary"] = True + if "translate" in nssa_data: + nssa["translate"] = nssa_data["translate"] + area_type["nssa"] = nssa + if "stub" in at: + stub_data = at["stub"] or {} + stub = {"set": True} + if "default-cost" in stub_data: + stub["default_cost"] = int(stub_data["default-cost"]) + if "no-summary" in stub_data: + stub["no_summary"] = True + area_type["stub"] = stub + if area_type: + area["area_type"] = area_type + + if "authentication" in data: + area["authentication"] = data["authentication"] + + if "shortcut" in data: + area["shortcut"] = data["shortcut"] + + # network + net = data.get("network") + if net: + if isinstance(net, str): + area["network"] = [{"address": net}] + elif isinstance(net, dict): + area["network"] = [{"address": a} for a in sorted(net.keys())] + elif isinstance(net, list): + area["network"] = [{"address": a} for a in sorted(net)] + + # range + rng = data.get("range", {}) + if rng and isinstance(rng, dict): + ranges = [] + for addr, rdata in sorted(rng.items()): + r = {"address": addr} + rdata = rdata or {} + if "cost" in rdata: + r["cost"] = int(rdata["cost"]) + if "not-advertise" in rdata: + r["not_advertise"] = True + if "substitute" in rdata: + r["substitute"] = rdata["substitute"] + ranges.append(r) + if ranges: + area["range"] = ranges + + areas.append(area) + return areas + + +def _parse_redistribute(raw): + if not raw or not isinstance(raw, dict): + return [] + result = [] + for rt, data in sorted(raw.items()): + entry = {"route_type": rt} + data = data or {} + if "metric" in data: + entry["metric"] = int(data["metric"]) + if "metric-type" in data: + entry["metric_type"] = int(data["metric-type"]) + if "route-map" in data: + entry["route_map"] = data["route-map"] + result.append(entry) + return result + + +def _parse_neighbor(raw): + if not raw or not isinstance(raw, dict): + return [] + result = [] + for nb_id, data in sorted(raw.items()): + entry = {"neighbor_id": nb_id} + data = data or {} + if "poll-interval" in data: + entry["poll_interval"] = int(data["poll-interval"]) + if "priority" in data: + entry["priority"] = int(data["priority"]) + result.append(entry) + return result + + +def _parse_parameters(raw): + if not raw or not isinstance(raw, dict): + return {} + result = {} + if "router-id" in raw: + result["router_id"] = raw["router-id"] + if "abr-type" in raw: + result["abr_type"] = raw["abr-type"] + if "opaque-lsa" in raw: + result["opaque_lsa"] = True + if "rfc1583-compatibility" in raw: + result["rfc1583_compatibility"] = True + return result + + +def _parse_default_information(raw): + if not raw or not isinstance(raw, dict): + return {} + orig = raw.get("originate", {}) or {} + result = {} + if "always" in orig: + result["always"] = True + if "metric" in orig: + result["metric"] = int(orig["metric"]) + if "metric-type" in orig: + result["metric_type"] = int(orig["metric-type"]) + if "route-map" in orig: + result["route_map"] = orig["route-map"] + if result: + return {"originate": result} + return {} + + +def _parse_distance(raw): + if not raw or not isinstance(raw, dict): + return {} + result = {} + if "global" in raw: + result["global"] = int(raw["global"]) + ospf = raw.get("ospf", {}) or {} + if ospf: + od = {} + if "external" in ospf: + od["external"] = int(ospf["external"]) + if "inter-area" in ospf: + od["inter_area"] = int(ospf["inter-area"]) + if "intra-area" in ospf: + od["intra_area"] = int(ospf["intra-area"]) + if od: + result["ospf"] = od + return result + + +def get_running_config(vyos): + raw = vyos.get_config(_BASE) + if not raw or not isinstance(raw, dict): + return {} + result = {} + + areas = _parse_areas(raw.get("area")) + if areas: + result["areas"] = areas + + ac = raw.get("auto-cost", {}) + if ac and "reference-bandwidth" in ac: + result["auto_cost"] = {"reference_bandwidth": int(ac["reference-bandwidth"])} + + di = _parse_default_information(raw.get("default-information", {})) + if di: + result["default_information"] = di + + if "default-metric" in raw: + result["default_metric"] = int(raw["default-metric"]) + + dist = _parse_distance(raw.get("distance", {})) + if dist: + result["distance"] = dist + + lac = raw.get("log-adjacency-changes", {}) + if lac: + if isinstance(lac, dict) and "detail" in lac: + result["log_adjacency_changes"] = "detail" + elif lac == "detail": + result["log_adjacency_changes"] = "detail" + + neighbors = _parse_neighbor(raw.get("neighbor")) + if neighbors: + result["neighbor"] = neighbors + + params = _parse_parameters(raw.get("parameters")) + if params: + result["parameters"] = params + + # passive interfaces — VyOS 1.5 uses interface <name> passive + iface_raw = raw.get("interface", {}) or {} + passive = sorted( + [name for name, data in iface_raw.items() if isinstance(data, dict) and "passive" in data], + ) + if passive: + result["passive_interface"] = passive + + redist = _parse_redistribute(raw.get("redistribute")) + if redist: + result["redistribute"] = redist + + return result + + +def _area_type_cmds(abase, area_type, have_at): + cmds = [] + have_at = have_at or {} + if area_type.get("normal") and not have_at.get("normal"): + cmds.append(("set", abase + ["area-type", "normal"])) + nssa = area_type.get("nssa") or {} + if nssa: + have_nssa = have_at.get("nssa") or {} + if not have_nssa: + cmds.append(("set", abase + ["area-type", "nssa"])) + if nssa.get("default_cost") and nssa["default_cost"] != have_nssa.get("default_cost"): + cmds.append( + ( + "set", + abase + + [ + "area-type", + "nssa", + "default-cost", + str(nssa["default_cost"]), + ], + ), + ) + if nssa.get("no_summary") and not have_nssa.get("no_summary"): + cmds.append(("set", abase + ["area-type", "nssa", "no-summary"])) + if nssa.get("translate") and nssa["translate"] != have_nssa.get("translate"): + cmds.append(("set", abase + ["area-type", "nssa", "translate", nssa["translate"]])) + stub = area_type.get("stub") or {} + if stub: + have_stub = have_at.get("stub") or {} + if not have_stub: + if stub.get("default_cost"): + cmds.append( + ( + "set", + abase + + [ + "area-type", + "stub", + "default-cost", + str(stub["default_cost"]), + ], + ), + ) + else: + cmds.append(("set", abase + ["area-type", "stub"])) + elif stub.get("default_cost") and stub["default_cost"] != have_stub.get("default_cost"): + cmds.append( + ( + "set", + abase + + [ + "area-type", + "stub", + "default-cost", + str(stub["default_cost"]), + ], + ), + ) + return cmds + + +def _area_cmds(area, have_area): + cmds = [] + area_id = area["area_id"] + abase = _BASE + ["area", area_id] + have_area = have_area or {} + + if area.get("area_type"): + cmds += _area_type_cmds(abase, area["area_type"], have_area.get("area_type")) + + if area.get("authentication") and area["authentication"] != have_area.get("authentication"): + cmds.append(("set", abase + ["authentication", area["authentication"]])) + + if area.get("shortcut") and area["shortcut"] != have_area.get("shortcut"): + cmds.append(("set", abase + ["shortcut", area["shortcut"]])) + + want_nets = {n["address"] for n in (area.get("network") or [])} + have_nets = {n["address"] for n in (have_area.get("network") or [])} + for addr in want_nets - have_nets: + cmds.append(("set", abase + ["network", addr])) + + want_ranges = {r["address"]: r for r in (area.get("range") or [])} + have_ranges = {r["address"]: r for r in (have_area.get("range") or [])} + for addr, rng in want_ranges.items(): + have_rng = have_ranges.get(addr, {}) + if addr not in have_ranges: + cmds.append(("set", abase + ["range", addr])) + if rng.get("cost") and rng["cost"] != have_rng.get("cost"): + cmds.append(("set", abase + ["range", addr, "cost", str(rng["cost"])])) + if rng.get("not_advertise") and not have_rng.get("not_advertise"): + cmds.append(("set", abase + ["range", addr, "not-advertise"])) + if rng.get("substitute") and rng["substitute"] != have_rng.get("substitute"): + cmds.append(("set", abase + ["range", addr, "substitute", rng["substitute"]])) + + return cmds + + +def _parameters_cmds(params, have_params): + cmds = [] + have_params = have_params or {} + pbase = _BASE + ["parameters"] + if params.get("router_id") and params["router_id"] != have_params.get("router_id"): + cmds.append(("set", pbase + ["router-id", params["router_id"]])) + if params.get("abr_type") and params["abr_type"] != have_params.get("abr_type"): + cmds.append(("set", pbase + ["abr-type", params["abr_type"]])) + if params.get("opaque_lsa") and not have_params.get("opaque_lsa"): + cmds.append(("set", pbase + ["opaque-lsa"])) + if params.get("rfc1583_compatibility") and not have_params.get("rfc1583_compatibility"): + cmds.append(("set", pbase + ["rfc1583-compatibility"])) + return cmds + + +def _redistribute_cmds(redist_list, have_redist_list): + cmds = [] + want = {r["route_type"]: r for r in (redist_list or [])} + have = {r["route_type"]: r for r in (have_redist_list or [])} + for rt, entry in want.items(): + have_entry = have.get(rt, {}) + rbase = _BASE + ["redistribute", rt] + if rt not in have: + cmds.append(("set", rbase)) + if entry.get("metric") and entry["metric"] != have_entry.get("metric"): + cmds.append(("set", rbase + ["metric", str(entry["metric"])])) + if entry.get("metric_type") and entry["metric_type"] != have_entry.get("metric_type"): + cmds.append(("set", rbase + ["metric-type", str(entry["metric_type"])])) + if entry.get("route_map") and entry["route_map"] != have_entry.get("route_map"): + cmds.append(("set", rbase + ["route-map", entry["route_map"]])) + return cmds + + +def _neighbor_cmds(neighbors, have_neighbors): + cmds = [] + want = {n["neighbor_id"]: n for n in (neighbors or [])} + have = {n["neighbor_id"]: n for n in (have_neighbors or [])} + for nb_id, entry in want.items(): + have_entry = have.get(nb_id, {}) + nbase = _BASE + ["neighbor", nb_id] + if nb_id not in have: + cmds.append(("set", nbase)) + if entry.get("priority") and entry["priority"] != have_entry.get("priority"): + cmds.append(("set", nbase + ["priority", str(entry["priority"])])) + if entry.get("poll_interval") and entry["poll_interval"] != have_entry.get("poll_interval"): + cmds.append(("set", nbase + ["poll-interval", str(entry["poll_interval"])])) + return cmds + + +def _default_info_cmds(di, have_di): + cmds = [] + have_di = have_di or {} + orig = (di or {}).get("originate") or {} + have_orig = have_di.get("originate") or {} + if not orig: + return cmds + dbase = _BASE + ["default-information", "originate"] + if orig.get("always") and not have_orig.get("always"): + cmds.append(("set", dbase + ["always"])) + if orig.get("metric") and orig["metric"] != have_orig.get("metric"): + cmds.append(("set", dbase + ["metric", str(orig["metric"])])) + if orig.get("metric_type") and orig["metric_type"] != have_orig.get("metric_type"): + cmds.append(("set", dbase + ["metric-type", str(orig["metric_type"])])) + if orig.get("route_map") and orig["route_map"] != have_orig.get("route_map"): + cmds.append(("set", dbase + ["route-map", orig["route_map"]])) + return cmds + + +def build_commands(config, have, state): + cmds = [] + + if state == "deleted": + if have: + cmds.append(("delete", _BASE)) + return cmds + + if state == "replaced": + would_set = build_commands(config, {}, "merged") + have_set = build_commands(have, {}, "merged") + if would_set == have_set: + return [] + if have: + cmds.append(("delete", _BASE)) + have = {} + + config = config or {} + + # parameters + if config.get("parameters"): + cmds += _parameters_cmds(config["parameters"], have.get("parameters")) + + # auto_cost + ac = config.get("auto_cost") or {} + have_ac = have.get("auto_cost") or {} + if ac.get("reference_bandwidth") and ac["reference_bandwidth"] != have_ac.get( + "reference_bandwidth", + ): + cmds.append( + ( + "set", + _BASE + + [ + "auto-cost", + "reference-bandwidth", + str(ac["reference_bandwidth"]), + ], + ), + ) + + # default_information + if config.get("default_information"): + cmds += _default_info_cmds( + config["default_information"], + have.get("default_information"), + ) + + # default_metric + if config.get("default_metric") and config["default_metric"] != have.get("default_metric"): + cmds.append(("set", _BASE + ["default-metric", str(config["default_metric"])])) + + # distance + dist = config.get("distance") or {} + have_dist = have.get("distance") or {} + if dist.get("global") and dist["global"] != have_dist.get("global"): + cmds.append(("set", _BASE + ["distance", "global", str(dist["global"])])) + ospf_dist = dist.get("ospf") or {} + have_ospf_dist = have_dist.get("ospf") or {} + for key, api_key in [ + ("external", "external"), + ("inter_area", "inter-area"), + ("intra_area", "intra-area"), + ]: + if ospf_dist.get(key) and ospf_dist[key] != have_ospf_dist.get(key): + cmds.append(("set", _BASE + ["distance", "ospf", api_key, str(ospf_dist[key])])) + + # log_adjacency_changes + if config.get("log_adjacency_changes") and config["log_adjacency_changes"] != have.get( + "log_adjacency_changes", + ): + cmds.append(("set", _BASE + ["log-adjacency-changes", config["log_adjacency_changes"]])) + + # neighbor + if config.get("neighbor"): + cmds += _neighbor_cmds(config["neighbor"], have.get("neighbor")) + + # redistribute + if config.get("redistribute"): + cmds += _redistribute_cmds(config["redistribute"], have.get("redistribute")) + + # passive_interface — VyOS 1.5 per-interface style + want_passive = set(config.get("passive_interface") or []) + have_passive = set(have.get("passive_interface") or []) + for iface in want_passive - have_passive: + cmds.append(("set", _BASE + ["interface", iface, "passive"])) + + # areas + have_areas = {a["area_id"]: a for a in (have.get("areas") or [])} + for area in config.get("areas") or []: + have_area = have_areas.get(area["area_id"], {}) + cmds += _area_cmds(area, have_area) + + return cmds + + +ARGUMENT_SPEC = dict( + config=dict( + type="dict", + options=dict( + areas=dict( + type="list", + elements="dict", + options=dict( + area_id=dict(type="str", required=True), + area_type=dict( + type="dict", + options=dict( + normal=dict(type="bool"), + nssa=dict( + type="dict", + options=dict( + set=dict(type="bool"), + default_cost=dict(type="int"), + no_summary=dict(type="bool"), + translate=dict( + type="str", + choices=["always", "candidate", "never"], + ), + ), + ), + stub=dict( + type="dict", + options=dict( + set=dict(type="bool"), + default_cost=dict(type="int"), + no_summary=dict(type="bool"), + ), + ), + ), + ), + authentication=dict( + type="str", + choices=["plaintext-password", "md5"], + ), + network=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str", required=True), + ), + ), + range=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str", required=True), + cost=dict(type="int"), + not_advertise=dict(type="bool"), + substitute=dict(type="str"), + ), + ), + shortcut=dict(type="str", choices=["default", "disable", "enable"]), + ), + ), + auto_cost=dict( + type="dict", + options=dict( + reference_bandwidth=dict(type="int"), + ), + ), + default_information=dict( + type="dict", + options=dict( + originate=dict( + type="dict", + options=dict( + always=dict(type="bool"), + metric=dict(type="int"), + metric_type=dict(type="int"), + route_map=dict(type="str"), + ), + ), + ), + ), + default_metric=dict(type="int"), + distance=dict( + type="dict", + options=dict( + **{"global": dict(type="int")}, + ospf=dict( + type="dict", + options=dict( + external=dict(type="int"), + inter_area=dict(type="int"), + intra_area=dict(type="int"), + ), + ), + ), + ), + log_adjacency_changes=dict(type="str", choices=["detail"]), + neighbor=dict( + type="list", + elements="dict", + options=dict( + neighbor_id=dict(type="str", required=True), + poll_interval=dict(type="int"), + priority=dict(type="int"), + ), + ), + parameters=dict( + type="dict", + options=dict( + abr_type=dict( + type="str", + choices=["cisco", "ibm", "shortcut", "standard"], + ), + opaque_lsa=dict(type="bool"), + rfc1583_compatibility=dict(type="bool"), + router_id=dict(type="str"), + ), + ), + passive_interface=dict(type="list", elements="str"), + redistribute=dict( + type="list", + elements="dict", + options=dict( + route_type=dict( + type="str", + choices=["bgp", "connected", "kernel", "rip", "static"], + ), + metric=dict(type="int"), + metric_type=dict(type="int"), + route_map=dict(type="str"), + ), + ), + ), + ), + state=dict( + default="merged", + choices=["merged", "replaced", "deleted", "gathered"], + ), +) + + +def main(): + module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or {} + + have = get_running_config(vyos) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + commands = build_commands(config, have, state) + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + module.exit_json( + changed=True, + before=have, + after=get_running_config(vyos), + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_ospfv3.py b/plugins/modules/vyos_ospfv3.py new file mode 100644 index 0000000..fcf404e --- /dev/null +++ b/plugins/modules/vyos_ospfv3.py @@ -0,0 +1,360 @@ +#!/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_ospfv3 +short_description: Manage OSPFv3 configuration on VyOS devices using REST API +description: + - Manages OSPFv3 configuration on VyOS devices via the REST API. + - Uses REST API (C(connection=httpapi)) instead of CLI. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: OSPFv3 configuration. + type: dict + suboptions: + areas: + description: OSPFv3 areas. + type: list + elements: dict + suboptions: + area_id: + description: Area identity. + type: str + required: true + export_list: + description: Name of export-list. + type: str + import_list: + description: Name of import-list. + type: str + range: + description: Summarize routes matching prefix. + type: list + elements: dict + suboptions: + address: + description: IPv6 prefix. + type: str + required: true + advertise: + description: Advertise this range. + type: bool + not_advertise: + description: Do not advertise this range. + type: bool + parameters: + description: OSPFv3 global parameters. + type: dict + suboptions: + router_id: + description: Router ID (IPv4 address format). + type: str + redistribute: + description: Redistribute routes from another protocol. + type: list + elements: dict + suboptions: + route_type: + description: Protocol to redistribute. + type: str + choices: [bgp, connected, kernel, ripng, static] + route_map: + description: Route map to apply. + type: str + state: + description: + - Desired state of the OSPFv3 configuration. + - C(merged) adds or updates without removing existing config. + - C(replaced) replaces the entire OSPFv3 configuration. + - C(deleted) removes OSPFv3 configuration. + - C(gathered) returns current configuration as structured data. + type: str + choices: [merged, replaced, deleted, gathered] + default: merged +notes: + - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin. + - C(ansible_network_os) must be set to C(vyos.rest.vyos). +""" + +EXAMPLES = r""" +- name: Merge OSPFv3 configuration + vyos.rest.vyos_ospfv3: + config: + parameters: + router_id: 192.0.2.10 + redistribute: + - route_type: bgp + areas: + - area_id: "2" + export_list: export1 + import_list: import1 + range: + - address: "2001:db10::/32" + - address: "2001:db20::/32" + state: merged + +- name: Delete all OSPFv3 configuration + vyos.rest.vyos_ospfv3: + state: deleted + +- name: Gather current OSPFv3 configuration + vyos.rest.vyos_ospfv3: + state: gathered +""" + +RETURN = r""" +before: + description: OSPFv3 configuration before this module ran. + returned: always + type: dict +after: + description: OSPFv3 configuration after this module ran. + returned: when changed + type: dict +commands: + description: List of API command tuples sent to the device. + returned: always + type: list +gathered: + description: Current OSPFv3 configuration as structured data. + returned: when state is gathered + type: dict +saved: + description: Whether the config was saved after changes. + returned: when changes are applied + type: bool +response: + description: Raw API response. + returned: when changes are applied + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +_BASE = ["protocols", "ospfv3"] + + +def get_running_config(vyos): + raw = vyos.get_config(_BASE) + if not raw or not isinstance(raw, dict): + return {} + return _parse_ospfv3(raw) + + +def _parse_ospfv3(raw): + result = {} + + # parameters + params = raw.get("parameters", {}) + if params: + result["parameters"] = {} + if "router-id" in params: + result["parameters"]["router_id"] = params["router-id"] + + # redistribute + redist_raw = raw.get("redistribute", {}) + if redist_raw and isinstance(redist_raw, dict): + redist = [] + for route_type, data in sorted(redist_raw.items()): + entry = {"route_type": route_type} + if isinstance(data, dict) and data.get("route-map"): + entry["route_map"] = data["route-map"] + redist.append(entry) + if redist: + result["redistribute"] = redist + + # areas + area_raw = raw.get("area", {}) + if area_raw and isinstance(area_raw, dict): + areas = [] + for area_id, area_data in sorted(area_raw.items()): + area = {"area_id": area_id} + area_data = area_data or {} + if area_data.get("export-list"): + area["export_list"] = area_data["export-list"] + if area_data.get("import-list"): + area["import_list"] = area_data["import-list"] + range_raw = area_data.get("range", {}) + if range_raw and isinstance(range_raw, dict): + ranges = [] + for prefix, rdata in sorted(range_raw.items()): + r = {"address": prefix} + rdata = rdata or {} + if "advertise" in rdata: + r["advertise"] = True + if "not-advertise" in rdata: + r["not_advertise"] = True + ranges.append(r) + if ranges: + area["range"] = ranges + areas.append(area) + if areas: + result["areas"] = areas + + return result + + +def build_commands(config, have, state): + cmds = [] + + if state == "deleted": + if have: + cmds.append(("delete", _BASE)) + return cmds + + if state == "replaced": + # Build what we would set from scratch and compare to have + would_set = build_commands(config, {}, "merged") + have_set = build_commands(have, {}, "merged") + if would_set == have_set: + return [] + if have: + cmds.append(("delete", _BASE)) + have = {} + + # parameters + want_params = (config or {}).get("parameters") or {} + have_params = have.get("parameters") or {} + if want_params.get("router_id") and want_params["router_id"] != have_params.get("router_id"): + cmds.append(("set", _BASE + ["parameters", "router-id", want_params["router_id"]])) + + # redistribute + want_redist = {r["route_type"]: r for r in ((config or {}).get("redistribute") or [])} + have_redist = {r["route_type"]: r for r in (have.get("redistribute") or [])} + + for rt in set(have_redist) - set(want_redist): + if state == "merged": + pass # merged doesn't remove + for rt, entry in want_redist.items(): + if rt not in have_redist: + cmds.append(("set", _BASE + ["redistribute", rt])) + if entry.get("route_map"): + have_rm = have_redist.get(rt, {}).get("route_map") + if entry["route_map"] != have_rm: + cmds.append(("set", _BASE + ["redistribute", rt, "route-map", entry["route_map"]])) + + # areas + want_areas = {a["area_id"]: a for a in ((config or {}).get("areas") or [])} + have_areas = {a["area_id"]: a for a in (have.get("areas") or [])} + + for area_id, want_area in want_areas.items(): + have_area = have_areas.get(area_id, {}) + abase = _BASE + ["area", area_id] + + if want_area.get("export_list") and want_area["export_list"] != have_area.get( + "export_list", + ): + cmds.append(("set", abase + ["export-list", want_area["export_list"]])) + if want_area.get("import_list") and want_area["import_list"] != have_area.get( + "import_list", + ): + cmds.append(("set", abase + ["import-list", want_area["import_list"]])) + + want_ranges = {r["address"]: r for r in (want_area.get("range") or [])} + have_ranges = {r["address"]: r for r in (have_area.get("range") or [])} + + for addr in want_ranges: + if addr not in have_ranges: + cmds.append(("set", abase + ["range", addr])) + r = want_ranges[addr] + if r.get("not_advertise"): + cmds.append(("set", abase + ["range", addr, "not-advertise"])) + elif r.get("advertise"): + cmds.append(("set", abase + ["range", addr, "advertise"])) + + return cmds + + +ARGUMENT_SPEC = dict( + config=dict( + type="dict", + options=dict( + areas=dict( + type="list", + elements="dict", + options=dict( + area_id=dict(type="str", required=True), + export_list=dict(type="str"), + import_list=dict(type="str"), + range=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str", required=True), + advertise=dict(type="bool"), + not_advertise=dict(type="bool"), + ), + ), + ), + ), + parameters=dict( + type="dict", + options=dict( + router_id=dict(type="str"), + ), + ), + redistribute=dict( + type="list", + elements="dict", + options=dict( + route_type=dict( + type="str", + choices=["bgp", "connected", "kernel", "ripng", "static"], + ), + route_map=dict(type="str"), + ), + ), + ), + ), + state=dict( + default="merged", + choices=["merged", "replaced", "deleted", "gathered"], + ), +) + + +def main(): + module = AnsibleModule(ARGUMENT_SPEC, supports_check_mode=True) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or {} + + have = get_running_config(vyos) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + commands = build_commands(config, have, state) + + if module.check_mode: + module.exit_json(changed=bool(commands), commands=commands, before=have) + + if commands: + response = vyos.apply_commands(commands) + saved = vyos.save_config() + module.exit_json( + changed=True, + before=have, + after=get_running_config(vyos), + commands=commands, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_route_maps.py b/plugins/modules/vyos_route_maps.py index 55b1706..607d1ab 100644 --- a/plugins/modules/vyos_route_maps.py +++ b/plugins/modules/vyos_route_maps.py @@ -308,7 +308,7 @@ def _want_to_api_match(match): return api -def _rule_cmds(rm_name, rule, have_rule): +def _rule_cmds(rm_name, rule, have_rule, state="merged"): cmds = [] seq = str(rule["sequence"]) rbase = _BASE + [rm_name, "rule", seq] @@ -343,8 +343,13 @@ def _rule_cmds(rm_name, rule, have_rule): want_set_api = _want_to_api_set(rule.get("set")) have_set = have_rule.get("set") or {} - if want_set_api != have_set: - cmds += _set_cmds(rbase, rule.get("set")) + if state in ("replaced", "overridden"): + if want_set_api != have_set: + cmds += _set_cmds(rbase, rule.get("set")) + else: + have_subset = {k: have_set[k] for k in want_set_api if k in have_set} + if want_set_api != have_subset: + cmds += _set_cmds(rbase, rule.get("set")) return cmds @@ -380,7 +385,7 @@ def build_commands(config, have_raw, state): test_cmds = [] for rule in rm.get("entries") or []: have_rule = have_entries.get(str(rule["sequence"]), {}) - test_cmds += _rule_cmds(rm_name, rule, have_rule) + test_cmds += _rule_cmds(rm_name, rule, have_rule, state) if test_cmds or extra_seqs: cmds.append(("delete", _BASE + [rm_name])) have_rm = {} @@ -391,7 +396,7 @@ def build_commands(config, have_raw, state): for rule in rm.get("entries") or []: have_rule = have_entries.get(str(rule["sequence"]), {}) - cmds += _rule_cmds(rm_name, rule, have_rule) + cmds += _rule_cmds(rm_name, rule, have_rule, state) return cmds diff --git a/plugins/modules/vyos_snmp_server.py b/plugins/modules/vyos_snmp_server.py index 3b40344..1079402 100644 --- a/plugins/modules/vyos_snmp_server.py +++ b/plugins/modules/vyos_snmp_server.py @@ -525,12 +525,12 @@ def _build_scalar_commands(want, have, state): want_val = want.get(argspec_key) have_val = have.get(argspec_key) path = SNMP_BASE + [api_key] - if state in ("merged", "replaced", "overridden"): - if want_val and want_val != have_val: - cmds.append(_set(path + [want_val])) if state in ("replaced", "overridden"): if have_val and want_val != have_val: cmds.append(_delete(path)) + if state in ("merged", "replaced", "overridden"): + if want_val and want_val != have_val: + cmds.append(_set(path + [want_val])) return cmds @@ -547,10 +547,10 @@ def _build_community_commands(want_list, have_list, state): base = SNMP_BASE + ["community", name] want_auth = want_comm.get("authorization_type") have_auth = have_comm.get("authorization_type") - if want_auth and want_auth != have_auth: - cmds.append(_set(base + ["authorization", want_auth])) if state in ("replaced", "overridden") and have_auth and want_auth != have_auth: cmds.append(_delete(base + ["authorization"])) + if want_auth and want_auth != have_auth: + cmds.append(_set(base + ["authorization", want_auth])) want_clients = set(want_comm.get("clients") or []) have_clients = set(have_comm.get("clients") or []) for c in want_clients - have_clients: |
