From 7cfca28e5857b480920c22ae12557f55ca2a8a19 Mon Sep 17 00:00:00 2001 From: omnom62 <75066712+omnom62@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:34:13 +1000 Subject: T8989: vyos vrf module (#39) * T8989: vyos_vrf module * T8989: vyos_vrf module * T8989: vyos_vrf module unit tests * T8989: vyos_vrf module integration tests * T8989: vyos_vrf module integration tests * T8989: vyos_vrf module changelog * T8989: fix pylint disallowed-name issues * T8989: fix pylint disallowed-name issues * T8989: fix lint issues for vrf * T8989: vrf AI comment fixed * T8989: vrf AI comment fixed --------- Co-authored-by: Daniil Baturin --- README.md | 1 + changelogs/fragments/t8989_vrf.yml | 3 + docs/vyos.rest.vyos_vrf_module.rst | 861 ++++++++++++++++ plugins/module_utils/vyos.py | 21 + plugins/modules/vyos_vrf.py | 1028 ++++++++++++++++++++ tests/integration/targets/vyos_vrf/aliases | 1 + .../targets/vyos_vrf/defaults/main.yaml | 3 + .../targets/vyos_vrf/tasks/httpapi.yaml | 21 + tests/integration/targets/vyos_vrf/tasks/main.yaml | 5 + .../vyos_vrf/tests/httpapi/_populate_config.yaml | 23 + .../vyos_vrf/tests/httpapi/_remove_config.yaml | 4 + .../targets/vyos_vrf/tests/httpapi/deleted.yaml | 50 + .../targets/vyos_vrf/tests/httpapi/gathered.yaml | 27 + .../targets/vyos_vrf/tests/httpapi/merged.yaml | 59 ++ .../vyos_vrf/tests/httpapi/merged_protocols.yaml | 117 +++ .../targets/vyos_vrf/tests/httpapi/overridden.yaml | 50 + .../targets/vyos_vrf/tests/httpapi/replaced.yaml | 52 + .../targets/vyos_vrf/tests/httpapi/rtt.yaml | 82 ++ tests/integration/targets/vyos_vrf/vars/main.yaml | 2 + tests/unit/fixtures/vrf_running.json | 42 + tests/unit/modules/test_vyos_vrf.py | 369 +++++++ 21 files changed, 2821 insertions(+) create mode 100644 changelogs/fragments/t8989_vrf.yml create mode 100644 docs/vyos.rest.vyos_vrf_module.rst create mode 100644 plugins/modules/vyos_vrf.py create mode 100644 tests/integration/targets/vyos_vrf/aliases create mode 100644 tests/integration/targets/vyos_vrf/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_vrf/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_vrf/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_vrf/tests/httpapi/_populate_config.yaml create mode 100644 tests/integration/targets/vyos_vrf/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_vrf/tests/httpapi/deleted.yaml create mode 100644 tests/integration/targets/vyos_vrf/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_vrf/tests/httpapi/merged.yaml create mode 100644 tests/integration/targets/vyos_vrf/tests/httpapi/merged_protocols.yaml create mode 100644 tests/integration/targets/vyos_vrf/tests/httpapi/overridden.yaml create mode 100644 tests/integration/targets/vyos_vrf/tests/httpapi/replaced.yaml create mode 100644 tests/integration/targets/vyos_vrf/tests/httpapi/rtt.yaml create mode 100644 tests/integration/targets/vyos_vrf/vars/main.yaml create mode 100644 tests/unit/fixtures/vrf_running.json create mode 100644 tests/unit/modules/test_vyos_vrf.py diff --git a/README.md b/README.md index 5d33329..d0c88d2 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ Name | Description [vyos.rest.vyos_system](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_system_module.rst)|Manage system settings on VyOS devices using REST API [vyos.rest.vyos_user](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_user_module.rst)|Manage user accounts on VyOS devices using REST API [vyos.rest.vyos_vlan](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_vlan_module.rst)|Manage VLAN (vif) configuration on VyOS devices using REST API +[vyos.rest.vyos_vrf](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_vrf_module.rst)|Manage VRF configuration on VyOS devices using REST API diff --git a/changelogs/fragments/t8989_vrf.yml b/changelogs/fragments/t8989_vrf.yml new file mode 100644 index 0000000..613bebd --- /dev/null +++ b/changelogs/fragments/t8989_vrf.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - vyos_vrf - Add new module and tests. diff --git a/docs/vyos.rest.vyos_vrf_module.rst b/docs/vyos.rest.vyos_vrf_module.rst new file mode 100644 index 0000000..92a7292 --- /dev/null +++ b/docs/vyos.rest.vyos_vrf_module.rst @@ -0,0 +1,861 @@ +.. _vyos.rest.vyos_vrf_module: + + +****************** +vyos.rest.vyos_vrf +****************** + +**Manage VRF configuration on VyOS devices using REST API** + + +Version added: 1.0.0 + +.. contents:: + :local: + :depth: 1 + + +Synopsis +-------- +- Manages Virtual Routing and Forwarding (VRF) instances on VyOS devices via the REST API. +- Supports merged, replaced, overridden, deleted, and gathered states. +- Protocol configuration within VRFs (BGP, OSPFv2, static routes) is managed inline with focused scope (core fields only). + + + + +Parameters +---------- + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsComments
+
+ config + +
+ dictionary +
+
+ +
VRF configuration.
+
+
+ bind_to_all + +
+ boolean +
+
+
    Choices: +
  • no
  • +
  • yes
  • +
+
+
Enable binding services to all VRFs.
+
Whether omitting this option preserves the current device setting depends on state. With merged, and with deleted when specific instances are named, omission leaves it untouched. With replaced or overridden, omission deletes an existing setting, since those states replace everything not explicitly present in config. With deleted and no instances given, the entire VRF configuration (including this setting) is removed.
+
Only set this explicitly (true or false) when you want this module to manage it under merged or deleted with named instances.
+
+
+ instances + +
+ list + / elements=dictionary +
+
+ +
List of VRF instances.
+
+
+ address_family + +
+ list + / elements=dictionary +
+
+ +
Address family configuration.
+
+
+ afi + +
+ string + / required +
+
+
    Choices: +
  • ipv4
  • +
  • ipv6
  • +
+
+
Address family identifier.
+
+
+ disable_forwarding + +
+ boolean +
+
+
    Choices: +
  • no ←
  • +
  • yes
  • +
+
+
Disable IP forwarding for this address family.
+
+
+ nht_no_resolve_via_default + +
+ boolean +
+
+
    Choices: +
  • no ←
  • +
  • yes
  • +
+
+
Disable next-hop resolution via default route.
+
+
+ route_maps + +
+ list + / elements=dictionary +
+
+ +
Route maps applied per protocol.
+
+
+ protocol + +
+ string + / required +
+
+
    Choices: +
  • any
  • +
  • babel
  • +
  • bgp
  • +
  • eigrp
  • +
  • isis
  • +
  • ospf
  • +
  • rip
  • +
  • static
  • +
+
+
Protocol to which the route map applies.
+
+
+ rm_name + +
+ string + / required +
+
+ +
Route map name.
+
+
+ description + +
+ string +
+
+ +
VRF description.
+
+
+ disable + +
+ boolean +
+
+
    Choices: +
  • no ←
  • +
  • yes
  • +
+
+
Administratively disable this VRF.
+
+
+ name + +
+ string + / required +
+
+ +
VRF instance name.
+
+
+ protocols + +
+ dictionary +
+
+ +
Protocol configuration within this VRF instance.
+
+
+ bgp + +
+ dictionary +
+
+ +
BGP protocol configuration (core fields only).
+
+
+ neighbor + +
+ list + / elements=dictionary +
+
+ +
BGP neighbors.
+
+
+ address + +
+ string + / required +
+
+ +
Neighbor IP address.
+
+
+ description + +
+ string +
+
+ +
Neighbor description.
+
+
+ remote_as + +
+ integer +
+
+ +
Neighbor AS number.
+
+
+ system_as + +
+ integer +
+
+ +
BGP autonomous system number.
+
+
+ ospf + +
+ dictionary +
+
+ +
OSPFv2 protocol configuration (core fields only).
+
+
+ areas + +
+ list + / elements=dictionary +
+
+ +
OSPF areas.
+
+
+ area_id + +
+ string + / required +
+
+ +
OSPF area identifier.
+
+
+ networks + +
+ list + / elements=string +
+
+ +
Networks in this area.
+
+
+ parameters + +
+ dictionary +
+
+ +
OSPF parameters.
+
+
+ router_id + +
+ string +
+
+ +
OSPF router ID.
+
+
+ static + +
+ dictionary +
+
+ +
Static routes configuration.
+
+
+ routes + +
+ list + / elements=dictionary +
+
+ +
Static routes.
+
+
+ dest + +
+ string + / required +
+
+ +
Destination prefix.
+
+
+ next_hops + +
+ list + / elements=string +
+
+ +
Next-hop IP addresses.
+
+
+ table_id + +
+ integer +
+
+ +
Routing table ID associated with this VRF.
+
The device enforces the valid range and rejects an invalid value with its own error message.
+
VyOS does not support changing an existing VRF's table ID in place -- it must be deleted and recreated. state=merged and state=replaced fail with a clear error if this differs from the current device value, rather than silently deleting and recreating the VRF. Use state=overridden (which deletes and recreates the VRF, re-applying every other desired field, since it already replaces everything to match config), or explicitly run state=deleted followed by state=merged/replaced as separate tasks.
+
+
+ vni + +
+ integer +
+
+ +
Virtual Network Identifier.
+
+
+ state + +
+ string +
+
+
    Choices: +
  • merged ←
  • +
  • replaced
  • +
  • overridden
  • +
  • deleted
  • +
  • gathered
  • +
+
+
Desired state of the VRF configuration.
+
+
+ + + + +Examples +-------- + +.. code-block:: yaml + + - name: Merge VRF instances + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: Red VRF + table_id: 101 + vni: 501 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.1 + static: + routes: + - dest: 192.168.10.0/24 + next_hops: + - 10.0.0.254 + state: merged + + - name: Delete specific VRF + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + state: deleted + + - name: Delete all VRF configuration + vyos.rest.vyos_vrf: + state: deleted + + - name: Gather current VRF configuration + vyos.rest.vyos_vrf: + state: gathered + + + +Return Values +------------- +Common return values are documented `here `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyReturnedDescription
+
+ after + +
+ dictionary +
+
when changed +
VRF configuration after this module ran.
+
+
+
+ before + +
+ dictionary +
+
state is not gathered +
VRF configuration before this module ran.
+
+
+
+ commands + +
+ list +
+
state is not gathered +
List of API command tuples sent to the device, or that would be sent (in check mode).
+
+
+
+ gathered + +
+ dictionary +
+
when state is gathered +
Current VRF configuration as structured data.
+
+
+
+ saved + +
+ boolean +
+
when changed +
Whether the config was saved after changes.
+
+
+

+ + +Status +------ + + +Authors +~~~~~~~ + +- VyOS Community (@vyos) diff --git a/plugins/module_utils/vyos.py b/plugins/module_utils/vyos.py index b7a519e..382394b 100644 --- a/plugins/module_utils/vyos.py +++ b/plugins/module_utils/vyos.py @@ -383,3 +383,24 @@ class VyOSModule: return True except VyOSRestError: return False + + +def import_module_plugin(name): + """Import a sibling plugin from plugins/modules/ by name. + + Ansible's AnsiballZ does not add plugins/modules to sys.path, so + collection modules cannot be imported via the standard import system. + This utility resolves the module file relative to this module_utils + directory and loads it with importlib. + """ + import importlib.util + import os + + modules_dir = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "modules"), + ) + module_path = os.path.join(modules_dir, "{0}.py".format(name)) + spec = importlib.util.spec_from_file_location(name, module_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod diff --git a/plugins/modules/vyos_vrf.py b/plugins/modules/vyos_vrf.py new file mode 100644 index 0000000..3aba832 --- /dev/null +++ b/plugins/modules/vyos_vrf.py @@ -0,0 +1,1028 @@ +#!/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_vrf +short_description: Manage VRF configuration on VyOS devices using REST API +description: + - Manages Virtual Routing and Forwarding (VRF) instances on VyOS devices + via the REST API. + - Supports merged, replaced, overridden, deleted, and gathered states. + - Protocol configuration within VRFs (BGP, OSPFv2, static routes) is + managed inline with focused scope (core fields only). +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: VRF configuration. + type: dict + suboptions: + bind_to_all: + description: + - Enable binding services to all VRFs. + - >- + Whether omitting this option preserves the current device + setting depends on C(state). With C(merged), and with + C(deleted) when specific C(instances) are named, omission + leaves it untouched. With C(replaced) or C(overridden), + omission deletes an existing setting, since those states + replace everything not explicitly present in C(config). + With C(deleted) and no C(instances) given, the entire VRF + configuration (including this setting) is removed. + - Only set this explicitly (C(true) or C(false)) when you want + this module to manage it under C(merged) or C(deleted) with + named instances. + type: bool + instances: + description: List of VRF instances. + type: list + elements: dict + suboptions: + name: + description: VRF instance name. + type: str + required: true + description: + description: VRF description. + type: str + disable: + description: Administratively disable this VRF. + type: bool + default: false + table_id: + description: + - Routing table ID associated with this VRF. + - The device enforces the valid range and rejects an invalid + value with its own error message. + - VyOS does not support changing an existing VRF's table ID + in place -- it must be deleted and recreated. C(state=merged) + and C(state=replaced) fail with a clear error if this + differs from the current device value, rather than + silently deleting and recreating the VRF. Use + C(state=overridden) (which deletes and recreates the VRF, + re-applying every other desired field, since it already + replaces everything to match C(config)), or explicitly run + C(state=deleted) followed by C(state=merged)/C(replaced) + as separate tasks. + type: int + vni: + description: Virtual Network Identifier. + type: int + address_family: + description: Address family configuration. + type: list + elements: dict + suboptions: + afi: + description: Address family identifier. + type: str + required: true + choices: [ipv4, ipv6] + disable_forwarding: + description: Disable IP forwarding for this address family. + type: bool + default: false + nht_no_resolve_via_default: + description: Disable next-hop resolution via default route. + type: bool + default: false + route_maps: + description: Route maps applied per protocol. + type: list + elements: dict + suboptions: + protocol: + description: Protocol to which the route map applies. + type: str + required: true + choices: [any, babel, bgp, eigrp, isis, ospf, rip, static] + rm_name: + description: Route map name. + type: str + required: true + protocols: + description: Protocol configuration within this VRF instance. + type: dict + suboptions: + bgp: + description: BGP protocol configuration (core fields only). + type: dict + suboptions: + system_as: + description: BGP autonomous system number. + type: int + neighbor: + description: BGP neighbors. + type: list + elements: dict + suboptions: + address: + description: Neighbor IP address. + type: str + required: true + remote_as: + description: Neighbor AS number. + type: int + description: + description: Neighbor description. + type: str + ospf: + description: OSPFv2 protocol configuration (core fields only). + type: dict + suboptions: + areas: + description: OSPF areas. + type: list + elements: dict + suboptions: + area_id: + description: OSPF area identifier. + type: str + required: true + networks: + description: Networks in this area. + type: list + elements: str + parameters: + description: OSPF parameters. + type: dict + suboptions: + router_id: + description: OSPF router ID. + type: str + static: + description: Static routes configuration. + type: dict + suboptions: + routes: + description: Static routes. + type: list + elements: dict + suboptions: + dest: + description: Destination prefix. + type: str + required: true + next_hops: + description: Next-hop IP addresses. + type: list + elements: str + state: + description: Desired state of the VRF configuration. + type: str + default: merged + choices: [merged, replaced, overridden, deleted, gathered] +""" + +EXAMPLES = r""" +- name: Merge VRF instances + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: Red VRF + table_id: 101 + vni: 501 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.1 + static: + routes: + - dest: 192.168.10.0/24 + next_hops: + - 10.0.0.254 + state: merged + +- name: Delete specific VRF + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + state: deleted + +- name: Delete all VRF configuration + vyos.rest.vyos_vrf: + state: deleted + +- name: Gather current VRF configuration + vyos.rest.vyos_vrf: + state: gathered +""" + +RETURN = r""" +before: + description: VRF configuration before this module ran. + returned: state is not gathered + type: dict +after: + description: VRF configuration after this module ran. + returned: when changed + type: dict +commands: + description: List of API command tuples sent to the device, or that + would be sent (in check mode). + returned: state is not gathered + type: list +gathered: + description: Current VRF configuration as structured data. + returned: when state is gathered + type: dict +saved: + description: Whether the config was saved after changes. + returned: when changed + type: bool +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import ( + VyOSModule, + autoclean, + cast_by_spec, + dict_op, + from_device, + to_tag_dict, +) + + +_BASE = ["vrf"] + +# --------------------------------------------------------------------------- +# Field name renames: argspec key -> device key. +# Only entries that cannot be derived by mechanical snake_case <-> kebab-case +# conversion, or where the device uses a completely different name. +# +# table_id -> table argspec uses _id suffix, device does not +# system_as -> system-as hyphen in middle (mechanical would also work, +# declared here for explicitness) +# remote_as -> remote-as same +# rm_name -> route-map completely different device leaf name +# next_hops -> next-hop plural vs singular + hyphen +# areas -> area device uses singular tag-node name +# routes -> route device uses singular tag-node name +# --------------------------------------------------------------------------- +_DEVICE_RENAMES = { + "table_id": "table", + "system_as": "system-as", + "remote_as": "remote-as", + "rm_name": "route-map", + "next_hops": "next-hop", + "areas": "area", + "routes": "route", + "bind_to_all": "bind-to-all", + "router_id": "router-id", +} + + +# --------------------------------------------------------------------------- +# Generic helpers — same pattern as vyos_snmp_server +# --------------------------------------------------------------------------- + + +def _derive_key_field(options_spec): + """Derive the tag-node key field — the one required=True suboption.""" + required = [k for k, spec in options_spec.items() if spec.get("required")] + if len(required) != 1: + raise ValueError( + "expected exactly one required suboption, found: {0}".format(required), + ) + return required[0] + + +def _keyed_list_to_device(items, key_field, entry_transform=None): + """Convert argspec list of dicts to device tag-node dict keyed by key_field.""" + result = {} + for item in items or []: + if not item.get(key_field): + continue + rest = {k: v for k, v in item.items() if k != key_field} + entry = entry_transform(rest) if entry_transform else autoclean(rest) + result[item[key_field]] = entry + return result + + +def _keyed_list_from_device(raw, key_field, entry_transform=None): + """Convert device tag-node dict to argspec list of dicts with key_field.""" + return [ + ( + {key_field: key, **entry_transform(data or {})} + if entry_transform + else {key_field: key, **from_device(data or {})} + ) + for key, data in sorted(to_tag_dict(raw).items()) + ] + + +# --------------------------------------------------------------------------- +# Structural entry overrides: sections where the device layout requires +# something beyond a field rename. Each entry: +# argspec_key -> (to_device_fn, from_device_fn) +# The to/from functions receive/return the entry dict WITHOUT the key field. +# --------------------------------------------------------------------------- + +# neighbor entries: fields use _DEVICE_RENAMES (remote_as -> remote-as), +# so we need _spec_to_device recursion, not plain autoclean. +# Defined after _spec_to_device is declared — see _ENTRY_OVERRIDES assignment. + + +# area entries: networks is a plain sorted list leaf, not a tag node. +def _area_to_device(rest): + d = {} + if rest.get("networks"): + d["network"] = sorted(rest["networks"]) + return d + + +def _area_from_device(raw): + raw = raw or {} + entry = {} + networks_raw = raw.get("network") + if networks_raw: + if isinstance(networks_raw, str): + entry["networks"] = [networks_raw] + elif isinstance(networks_raw, list): + entry["networks"] = sorted(networks_raw) + elif isinstance(networks_raw, dict): + entry["networks"] = sorted(networks_raw.keys()) + return entry + + +# route entries: next-hop is a tag node keyed by address, value is presence {}. +def _route_to_device(rest): + d = {} + if rest.get("next_hops"): + d["next-hop"] = {nh: {} for nh in rest["next_hops"]} + return d + + +def _route_from_device(raw): + raw = raw or {} + entry = {} + next_hops_raw = raw.get("next-hop") or {} + if isinstance(next_hops_raw, dict): + nhs = sorted(next_hops_raw.keys()) + if nhs: + entry["next_hops"] = nhs + elif isinstance(next_hops_raw, str): + entry["next_hops"] = [next_hops_raw] + return entry + + +# --------------------------------------------------------------------------- +# Generic recursive walkers — driven by ARGUMENT_SPEC + _DEVICE_RENAMES +# + _ENTRY_OVERRIDES. Same pattern as vyos_snmp_server. +# --------------------------------------------------------------------------- + + +def _spec_to_device(value, options_spec): + """Recursively convert argspec dict to device dict.""" + if not isinstance(value, dict): + return value + result = {} + for arg_key, sub_spec in options_spec.items(): + val = value.get(arg_key) + if val is None or val is False: + continue + device_key = _DEVICE_RENAMES.get(arg_key, arg_key) + sub_type = sub_spec.get("type") + sub_options = sub_spec.get("options") + if sub_type == "dict" and sub_options: + converted = _spec_to_device(val, sub_options) + if converted: + result[device_key] = converted + elif sub_type == "list" and sub_options: + key_field = _derive_key_field(sub_options) + entry_to, entry_from_unused = _ENTRY_OVERRIDES.get(arg_key, (None, None)) + entry_transform = entry_to or ( + lambda rest, spec=sub_options: _spec_to_device(rest, spec) + ) + result[device_key] = _keyed_list_to_device(val, key_field, entry_transform) + elif val is True: + result[device_key] = {} + elif sub_type == "list": + result[device_key] = list(val) + else: + result[device_key] = val + return result + + +def _device_to_spec(raw, options_spec): + """Recursively convert device dict to argspec dict.""" + if not raw or not isinstance(raw, dict): + return {} + have_idx = {k.replace("-", "_"): k for k in raw} + result = {} + for arg_key, sub_spec in options_spec.items(): + device_key = _DEVICE_RENAMES.get(arg_key, arg_key) + orig_key = device_key if device_key in raw else have_idx.get(arg_key) + if orig_key is None: + continue + raw_val = raw[orig_key] + sub_type = sub_spec.get("type") + sub_options = sub_spec.get("options") + if sub_type == "dict" and sub_options: + converted = _device_to_spec(raw_val, sub_options) + if converted: + result[arg_key] = converted + elif sub_type == "list" and sub_options: + key_field = _derive_key_field(sub_options) + entry_to_unused, entry_from = _ENTRY_OVERRIDES.get(arg_key, (None, None)) + entry_transform = entry_from or (lambda d, spec=sub_options: _device_to_spec(d, spec)) + entries = _keyed_list_from_device(raw_val, key_field, entry_transform) + if entries: + result[arg_key] = entries + elif sub_type == "list": + if raw_val: + result[arg_key] = sorted(to_tag_dict(raw_val).keys()) + elif isinstance(raw_val, dict) and not raw_val: + result[arg_key] = True + else: + result[arg_key] = raw_val + return result + + +# Entry overrides — defined after _spec_to_device so neighbor can use it. +# neighbor uses _spec_to_device recursion to apply _DEVICE_RENAMES +# (remote_as -> remote-as) inside each neighbor entry. +_ENTRY_OVERRIDES = { + "areas": (_area_to_device, _area_from_device), + "routes": (_route_to_device, _route_from_device), +} +# neighbor: use generic _spec_to_device with neighbor sub-options. +# Cannot declare inline above because _spec_to_device not yet defined. +# Assigned after ARGUMENT_SPEC is defined (see bottom of file). + + +# --------------------------------------------------------------------------- +# VRF address_family — bespoke because device uses ip/ipv6 as keys +# (not a standard tag node with argspec-named keys) +# nht_no_resolve_via_default maps to nested nht.no-resolve-via-default +# --------------------------------------------------------------------------- + +_AFI_TO_DEVICE = {"ipv4": "ip", "ipv6": "ipv6"} +_AFI_FROM_DEVICE = {"ip": "ipv4", "ipv6": "ipv6"} + + +def _af_to_device(af): + """Single address_family entry -> device ip/ipv6 subtree.""" + d = {} + if af.get("disable_forwarding"): + d["disable-forwarding"] = {} + if af.get("nht_no_resolve_via_default"): + d["nht"] = {"no-resolve-via-default": {}} + for rm in af.get("route_maps") or []: + proto = rm.get("protocol") + rm_name = rm.get("rm_name") + if proto and rm_name: + d.setdefault("protocol", {})[proto] = {"route-map": rm_name} + return d + + +def _af_from_device(afi_key, raw): + """Device ip/ipv6 subtree -> single address_family entry.""" + raw = raw or {} + entry = {"afi": _AFI_FROM_DEVICE.get(afi_key, afi_key)} + if "disable-forwarding" in raw: + entry["disable_forwarding"] = True + nht = raw.get("nht") or {} + if isinstance(nht, dict) and "no-resolve-via-default" in nht: + entry["nht_no_resolve_via_default"] = True + proto_raw = raw.get("protocol") or {} + if isinstance(proto_raw, dict): + rms = [ + {"protocol": p, "rm_name": (d or {}).get("route-map")} + for p, d in proto_raw.items() + if (d or {}).get("route-map") + ] + if rms: + entry["route_maps"] = rms + return entry + + +# --------------------------------------------------------------------------- +# VRF instance converters +# --------------------------------------------------------------------------- + + +def _instance_to_device(inst): + """argspec instance -> device VRF entry (non-protocol fields).""" + d = _spec_to_device( + {k: v for k, v in inst.items() if k not in ("name", "address_family", "protocols")}, + _INSTANCE_OPTIONS, + ) + for af in inst.get("address_family") or []: + afi = af.get("afi") + if afi: + dev_key = _AFI_TO_DEVICE.get(afi, afi) + af_data = _af_to_device(af) + if af_data: + d[dev_key] = af_data + return d + + +def _instance_from_device(name, raw): + """Device VRF entry -> argspec instance (non-protocol fields).""" + raw_base = {k: v for k, v in raw.items() if k not in ("ip", "ipv6", "protocols")} + inst = {"name": name} + d = _device_to_spec(raw_base, _INSTANCE_OPTIONS) + cast_by_spec(d, _INSTANCE_OPTIONS) + inst.update({k: v for k, v in d.items() if k not in ("address_family", "protocols")}) + afs = [_af_from_device(key, raw[key]) for key in ("ip", "ipv6") if key in raw and raw[key]] + if afs: + inst["address_family"] = afs + return inst + + +# --------------------------------------------------------------------------- +# Top-level config converters +# --------------------------------------------------------------------------- + + +def _config_to_device(config): + """Top-level argspec config -> device dict.""" + config = config or {} + result = _spec_to_device( + {k: v for k, v in config.items() if k != "instances"}, + _TOP_OPTIONS, + ) + name_dict = { + inst["name"]: _instance_to_device(inst) + for inst in config.get("instances") or [] + if inst.get("name") + } + if name_dict: + result["name"] = name_dict + return result + + +def _device_to_argspec(raw): + """Device raw config -> argspec (non-protocol fields).""" + raw = raw or {} + result = _device_to_spec( + {k: v for k, v in raw.items() if k != "name"}, + _TOP_OPTIONS, + ) + name_raw = raw.get("name") or {} + if isinstance(name_raw, dict): + instances = [ + _instance_from_device(vrf_name, vrf_data or {}) + for vrf_name, vrf_data in sorted(name_raw.items()) + ] + if instances: + result["instances"] = instances + return result + + +# --------------------------------------------------------------------------- +# Protocol converters — generic walkers with protocol sub-specs +# --------------------------------------------------------------------------- + + +def _routes_to_device(routes): + """Split routes by address family -- VyOS requires IPv4 static + routes under "route" and IPv6 under "route6" as genuinely separate + device subtrees (confirmed against VyOS's own interface-definitions + schema: static-route.xml.i and static-route6.xml.i are distinct + includes, not a single shared "route" path for both families). + """ + device = {} + for entry in routes or []: + dest = entry.get("dest") + if not dest: + continue + container = "route6" if ":" in dest else "route" + route_device = _route_to_device({k: v for k, v in entry.items() if k != "dest"}) + device.setdefault(container, {})[dest] = route_device + return device + + +def _routes_from_device(raw): + """Inverse of _routes_to_device -- merge the route and route6 + device subtrees back into a single argspec routes list.""" + entries = [] + for container in ("route", "route6"): + raw_container = (raw or {}).get(container) + if raw_container: + entries += _keyed_list_from_device(raw_container, "dest", _route_from_device) + return sorted(entries, key=lambda e: e["dest"]) + + +def _proto_to_device(proto_config, proto_key): + """argspec protocol config -> device protocol dict.""" + if proto_key == "static": + return _routes_to_device((proto_config or {}).get("routes")) + result = _spec_to_device(proto_config or {}, _PROTO_OPTIONS[proto_key]["options"]) + return result + + +def _proto_from_device(raw, proto_key): + """Device protocol dict -> argspec protocol config.""" + if proto_key == "static": + routes = _routes_from_device(raw) + return {"routes": routes} if routes else {} + result = _device_to_spec(raw or {}, _PROTO_OPTIONS[proto_key]["options"]) + cast_by_spec(result, _PROTO_OPTIONS[proto_key]["options"]) + return result + + +# --------------------------------------------------------------------------- +# Protocol dispatch — single source of truth. +# Adding a new protocol: one entry in _PROTO_HANDLERS + argspec only. +# --------------------------------------------------------------------------- + +_PROTO_HANDLERS = ["bgp", "ospf", "static"] + +# Tag-node containers per protocol (device key name) — used for placeholder seeding. +_PROTO_TAG_CONTAINERS = { + "bgp": ["neighbor"], + "ospf": ["area"], + "static": ["route", "route6"], +} + + +def _protocols_from_device(raw_vrf): + """Extract and convert all protocol configs from raw VRF device data.""" + proto_raw = (raw_vrf or {}).get("protocols") or {} + result = {} + for proto_key in _PROTO_HANDLERS: + if proto_raw.get(proto_key): + converted = _proto_from_device(proto_raw[proto_key], proto_key) + if converted: + result[proto_key] = converted + return result or None + + +def _seed_tag_node_placeholders(want, have, proto_key): + """Seed empty placeholders for new tag-node entries so dict_op uses + verbatim keys rather than guessing a kebab-case translation.""" + for container in _PROTO_TAG_CONTAINERS.get(proto_key, []): + want_entries = want.get(container) or {} + if isinstance(want_entries, dict): + have.setdefault(container, {}) + for entry_key in want_entries: + have[container].setdefault(entry_key, {}) + + +def _protocol_commands(vrf_name, protocols, raw_proto, state): + """Generate protocol commands for a VRF instance.""" + cmds = [] + for proto_key in _PROTO_HANDLERS: + want_proto = (protocols or {}).get(proto_key) + raw_have_proto = (raw_proto or {}).get(proto_key) or {} + + if want_proto is None and state not in ("overridden", "replaced"): + continue + + if want_proto is None: + if raw_have_proto: + cmds.append(("delete", _BASE + ["name", vrf_name, "protocols", proto_key])) + continue + + proto_base = _BASE + ["name", vrf_name, "protocols", proto_key] + want = _proto_to_device(want_proto, proto_key) + norm_have = _proto_to_device( + _proto_from_device(raw_have_proto, proto_key), + proto_key, + ) + _seed_tag_node_placeholders(want, norm_have, proto_key) + + if state in ("overridden", "replaced"): + cmds += dict_op(want, norm_have, proto_base, op="purge") + cmds += dict_op(want, norm_have, proto_base, op="set") + return cmds + + +# --------------------------------------------------------------------------- +# Running config +# --------------------------------------------------------------------------- + + +def get_running_config(vyos): + try: + return vyos.get_config(_BASE) or {} + except Exception as exc: + if "Configuration under specified path is empty" in str(exc): + return {} + raise + + +# --------------------------------------------------------------------------- +# Build commands +# --------------------------------------------------------------------------- + + +def build_commands(config, raw_have, state): + raw_have = raw_have or {} + config = config or {} + cmds = [] + + if state == "deleted": + instances = config.get("instances") or [] + if not instances: + return [("delete", _BASE)] if raw_have else [] + for inst in instances: + vrf_name = inst.get("name") + if vrf_name and (raw_have.get("name") or {}).get(vrf_name) is not None: + cmds.append(("delete", _BASE + ["name", vrf_name])) + if "bind_to_all" in config and config["bind_to_all"] is False and "bind-to-all" in raw_have: + cmds.append(("delete", _BASE + ["bind-to-all"])) + return cmds + + want = _config_to_device(config) + norm_have = _config_to_device(_device_to_argspec(raw_have)) + + # Seed placeholders for new VRF instances (verbatim tag-node keys) + for vrf_name in want.get("name") or {}: + norm_have.setdefault("name", {}).setdefault(vrf_name, {}) + + # VyOS's routing table ID cannot be modified in place once assigned -- + # confirmed via VyOS's own official documentation ("A routing table ID + # can not be modified once it is assigned. It can only be changed by + # deleting and re-adding the VRF instance") and its source + # (ConfigError: "VRF ... table id modification not possible!"). This + # module never does that destructive delete-and-recreate silently + # under merged/replaced -- only overridden's own explicit contract + # ("replace everything to match want, destructively if needed") + # covers it; merged/replaced fail loudly instead, so a table_id + # change is always something the user explicitly asked for via the + # right state, not a surprise this module decided on their behalf. + recreated_vrfs = set() + for vrf_name, vrf_want in (want.get("name") or {}).items(): + vrf_have = (norm_have.get("name") or {}).get(vrf_name) or {} + want_table = vrf_want.get("table") + have_table = vrf_have.get("table") + if want_table is None or have_table is None or str(want_table) == str(have_table): + continue + if state == "overridden": + cmds.append(("delete", _BASE + ["name", vrf_name])) + norm_have["name"][vrf_name] = {} + recreated_vrfs.add(vrf_name) + else: + raise ValueError( + "VRF '{name}': table_id cannot be changed in place under " + "state={state} -- VyOS does not support modifying an " + "existing VRF's routing table. Use state=overridden, or " + "explicitly remove and recreate it with separate " + "state=deleted and state=merged/replaced tasks.".format( + name=vrf_name, + state=state, + ), + ) + + if state == "overridden": + cmds += dict_op(want, norm_have, _BASE, op="purge") + elif state == "replaced": + for vrf_name, vrf_want in (want.get("name") or {}).items(): + vrf_have = (norm_have.get("name") or {}).get(vrf_name) or {} + cmds += dict_op(vrf_want, vrf_have, _BASE + ["name", vrf_name], op="purge") + if "bind-to-all" not in want and "bind-to-all" in norm_have: + cmds.append(("delete", _BASE + ["bind-to-all"])) + elif config.get("bind_to_all") is False and "bind-to-all" in norm_have: + # merged never runs a purge pass, so _spec_to_device's blanket + # "val is False: continue" skip means an explicit bind_to_all: + # false is otherwise indistinguishable from omission by the + # time dict_op sees "want" -- confirmed real bug: the device's + # bind-to-all setting silently stayed enabled with no delete + # command generated and changed=false reported. + cmds.append(("delete", _BASE + ["bind-to-all"])) + + cmds += dict_op(want, norm_have, _BASE, op="set") + + # Protocol commands per VRF instance + for inst in config.get("instances") or []: + vrf_name = inst.get("name") + if not vrf_name: + continue + protocols = inst.get("protocols") or {} + if vrf_name in recreated_vrfs: + raw_vrf = {} + else: + raw_vrf = (raw_have.get("name") or {}).get(vrf_name) or {} + raw_proto = raw_vrf.get("protocols") or {} + if protocols or state in ("overridden", "replaced") or vrf_name in recreated_vrfs: + cmds += _protocol_commands(vrf_name, protocols, raw_proto, state) + + return cmds + + +# --------------------------------------------------------------------------- +# Enrich have/after with protocol data +# --------------------------------------------------------------------------- + + +def _enrich_with_protocols(instances, raw_have): + """Add protocol data to each instance dict in-place.""" + for inst in instances or []: + raw_vrf = (raw_have.get("name") or {}).get(inst["name"]) or {} + protocols = _protocols_from_device(raw_vrf) + if protocols: + inst["protocols"] = protocols + + +# --------------------------------------------------------------------------- +# ARGUMENT_SPEC +# --------------------------------------------------------------------------- + +ARGUMENT_SPEC = dict( + config=dict( + type="dict", + options=dict( + bind_to_all=dict(type="bool"), + instances=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + description=dict(type="str"), + disable=dict(type="bool", default=False), + table_id=dict(type="int"), + vni=dict(type="int"), + address_family=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), + disable_forwarding=dict(type="bool", default=False), + nht_no_resolve_via_default=dict(type="bool", default=False), + route_maps=dict( + type="list", + elements="dict", + options=dict( + protocol=dict( + type="str", + required=True, + choices=[ + "any", + "babel", + "bgp", + "eigrp", + "isis", + "ospf", + "rip", + "static", + ], + ), + rm_name=dict(type="str", required=True), + ), + ), + ), + ), + protocols=dict( + type="dict", + options=dict( + bgp=dict( + type="dict", + options=dict( + system_as=dict(type="int"), + neighbor=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str", required=True), + remote_as=dict(type="int"), + description=dict(type="str"), + ), + ), + ), + ), + ospf=dict( + type="dict", + options=dict( + areas=dict( + type="list", + elements="dict", + options=dict( + area_id=dict(type="str", required=True), + networks=dict(type="list", elements="str"), + ), + ), + parameters=dict( + type="dict", + options=dict( + router_id=dict(type="str"), + ), + ), + ), + ), + static=dict( + type="dict", + options=dict( + routes=dict( + type="list", + elements="dict", + options=dict( + dest=dict(type="str", required=True), + next_hops=dict(type="list", elements="str"), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), +) + + +def _init_specs(): + """Initialize module-level spec references and entry overrides. + Called once at import time via _init_specs(). Avoids module-level + subscript expressions that confuse ansible-doc's AST walker. + """ + top = ARGUMENT_SPEC["config"]["options"] + instance_opts = top["instances"]["options"] + proto_opts = instance_opts["protocols"]["options"] + neighbor_opts = proto_opts["bgp"]["options"]["neighbor"]["options"] + + global _TOP_OPTIONS, _INSTANCE_OPTIONS, _PROTO_OPTIONS + + _TOP_OPTIONS = top + _INSTANCE_OPTIONS = instance_opts + _PROTO_OPTIONS = proto_opts + + def _neighbor_entry_to_device(rest): + return _spec_to_device(rest, neighbor_opts) + + def _neighbor_entry_from_device(d): + return _device_to_spec(d, neighbor_opts) + + _ENTRY_OVERRIDES["neighbor"] = (_neighbor_entry_to_device, _neighbor_entry_from_device) + + +_TOP_OPTIONS = {} +_INSTANCE_OPTIONS = {} +_PROTO_OPTIONS = {} +_init_specs() + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + + +def main(): + module = AnsibleModule(argument_spec=ARGUMENT_SPEC, supports_check_mode=True) + vyos = VyOSModule(module) + state = module.params["state"] + config = module.params.get("config") or {} + + raw_have = get_running_config(vyos) + have = _device_to_argspec(raw_have) + _enrich_with_protocols(have.get("instances"), raw_have) + + if state == "gathered": + module.exit_json(changed=False, gathered=have) + + try: + cmds = build_commands(config, raw_have, state) + except ValueError as exc: + module.fail_json(msg=str(exc)) + + if module.check_mode: + module.exit_json(changed=bool(cmds), commands=cmds, before=have) + + if cmds: + response = vyos.apply_commands(cmds) + saved = vyos.save_config() + raw_after = get_running_config(vyos) + after = _device_to_argspec(raw_after) + _enrich_with_protocols(after.get("instances"), raw_after) + module.exit_json( + changed=True, + before=have, + after=after, + commands=cmds, + saved=saved, + response=response, + ) + + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/vyos_vrf/aliases b/tests/integration/targets/vyos_vrf/aliases new file mode 100644 index 0000000..cc0afef --- /dev/null +++ b/tests/integration/targets/vyos_vrf/aliases @@ -0,0 +1 @@ +network/vyos diff --git a/tests/integration/targets/vyos_vrf/defaults/main.yaml b/tests/integration/targets/vyos_vrf/defaults/main.yaml new file mode 100644 index 0000000..164afea --- /dev/null +++ b/tests/integration/targets/vyos_vrf/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_vrf/tasks/httpapi.yaml b/tests/integration/targets/vyos_vrf/tasks/httpapi.yaml new file mode 100644 index 0000000..0ed3e42 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tasks/httpapi.yaml @@ -0,0 +1,21 @@ +--- +- name: Collect all httpapi test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/httpapi" + patterns: "{{ testcase }}.yaml" + use_regex: true + register: test_cases + delegate_to: localhost + +- name: Set test_items + ansible.builtin.set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list | sort }}" + +- name: Run test case (connection=httpapi) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + vars: + ansible_connection: ansible.netcommon.httpapi + ansible_network_os: vyos.rest.vyos + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/vyos_vrf/tasks/main.yaml b/tests/integration/targets/vyos_vrf/tasks/main.yaml new file mode 100644 index 0000000..b1f6193 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tasks/main.yaml @@ -0,0 +1,5 @@ +--- +- name: Run httpapi tests + ansible.builtin.include_tasks: httpapi.yaml + tags: + - httpapi diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/_populate_config.yaml new file mode 100644 index 0000000..69222b6 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/_populate_config.yaml @@ -0,0 +1,23 @@ +--- +- name: Populate VRF configuration + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red + table_id: 101 + vni: 501 + disable: true + - name: vrf2 + description: blah2 + table_id: 102 + vni: 102 + address_family: + - afi: ipv4 + disable_forwarding: true + nht_no_resolve_via_default: true + - afi: ipv6 + disable_forwarding: true + nht_no_resolve_via_default: true + state: merged diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/_remove_config.yaml new file mode 100644 index 0000000..da63347 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/_remove_config.yaml @@ -0,0 +1,4 @@ +--- +- name: Remove VRF configuration + vyos.rest.vyos_vrf: + state: deleted diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/deleted.yaml new file mode 100644 index 0000000..22eb43f --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/deleted.yaml @@ -0,0 +1,50 @@ +--- +- debug: + msg: START vyos_vrf deleted integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Delete specific VRF + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + state: deleted + + - assert: + that: + - result.changed == true + + - name: Gather and verify vrf1 deleted + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances | length == 1 + - result.gathered.instances[0].name == "vrf2" + + - name: Delete all VRF configuration + register: result + vyos.rest.vyos_vrf: + state: deleted + + - assert: + that: + - result.changed == true + + - name: Delete all VRF (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + state: deleted + + - assert: + that: + - result.changed == false + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/gathered.yaml new file mode 100644 index 0000000..83f543d --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/gathered.yaml @@ -0,0 +1,27 @@ +--- +- debug: + msg: START vyos_vrf gathered integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Gather VRF configuration + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.changed == false + - result.gathered.bind_to_all == true + - result.gathered.instances | length == 2 + - result.gathered.instances[0].name == "vrf1" + - result.gathered.instances[0].table_id == 101 + - result.gathered.instances[0].vni == 501 + - result.gathered.instances[0].disable == true + - result.gathered.instances[1].name == "vrf2" + - result.gathered.instances[1].table_id == 102 + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/merged.yaml new file mode 100644 index 0000000..2655ce3 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/merged.yaml @@ -0,0 +1,59 @@ +--- +- debug: + msg: START vyos_vrf merged integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Merge VRF configuration + register: result + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red + table_id: 101 + vni: 501 + state: merged + + - assert: + that: + - result.changed == true + - result.after.instances | length == 1 + - result.after.instances[0].name == "vrf1" + - result.after.instances[0].table_id == 101 + + - name: Merge VRF configuration (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red + table_id: 101 + vni: 501 + state: merged + + - assert: + that: + - result.changed == false + - result.commands == [] + + - name: Merge second VRF instance + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf2 + table_id: 102 + state: merged + + - assert: + that: + - result.changed == true + - result.after.instances | length == 2 + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/merged_protocols.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/merged_protocols.yaml new file mode 100644 index 0000000..f600f84 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/merged_protocols.yaml @@ -0,0 +1,117 @@ +--- +- debug: + msg: START vyos_vrf merged protocols integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Merge VRF with BGP + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + table_id: 101 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + description: peer1 + state: merged + + - assert: + that: + - result.changed == true + + - name: Merge VRF with BGP (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + table_id: 101 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + description: peer1 + state: merged + + - assert: + that: + - result.changed == false + - result.commands == [] + + - name: Merge VRF with OSPF and static routes + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + table_id: 101 + protocols: + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.1 + static: + routes: + - dest: 192.168.10.0/24 + next_hops: + - 10.0.0.254 + state: merged + + - assert: + that: + - result.changed == true + + - name: Gather and verify protocols + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances[0].protocols.bgp.system_as == 65001 + - result.gathered.instances[0].protocols.ospf.areas | length == 1 + - result.gathered.instances[0].protocols.static.routes | length == 1 + + - name: Change an already-set OSPF router_id + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + table_id: 101 + protocols: + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.99 + state: merged + + - assert: + that: + - result.changed == true + + - name: Gather and verify router_id was actually changed + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances[0].protocols.ospf.parameters.router_id == "10.0.0.99" + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/overridden.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/overridden.yaml new file mode 100644 index 0000000..da72aac --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/overridden.yaml @@ -0,0 +1,50 @@ +--- +- debug: + msg: START vyos_vrf overridden integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Override with single VRF + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf3 + table_id: 200 + vni: 2000 + state: overridden + + - assert: + that: + - result.changed == true + + - name: Gather and verify override + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances | length == 1 + - result.gathered.instances[0].name == "vrf3" + - result.gathered.instances[0].table_id == 200 + + - name: Override (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf3 + table_id: 200 + vni: 2000 + state: overridden + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/replaced.yaml new file mode 100644 index 0000000..53714fc --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/replaced.yaml @@ -0,0 +1,52 @@ +--- +- debug: + msg: START vyos_vrf replaced integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Replace vrf1 configuration + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + description: updated + table_id: 101 + vni: 999 + state: replaced + + - assert: + that: + - result.changed == true + + - name: Gather and verify replacement + register: result + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - result.gathered.instances[0].vni == 999 + - result.gathered.instances[0].description == "updated" + - result.gathered.instances[0].disable is not defined or result.gathered.instances[0].disable == false + + - name: Replace vrf1 (IDEMPOTENT) + register: result + vyos.rest.vyos_vrf: + config: + instances: + - name: vrf1 + description: updated + table_id: 101 + vni: 999 + state: replaced + + - assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/tests/httpapi/rtt.yaml b/tests/integration/targets/vyos_vrf/tests/httpapi/rtt.yaml new file mode 100644 index 0000000..9d16bc6 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/tests/httpapi/rtt.yaml @@ -0,0 +1,82 @@ +--- +- debug: + msg: START vyos_vrf round trip integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: RTT - Apply base configuration + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red + table_id: 101 + vni: 501 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + ospf: + areas: + - area_id: "0" + networks: + - 10.0.0.0/24 + parameters: + router_id: 10.0.0.1 + static: + routes: + - dest: 192.168.10.0/24 + next_hops: + - 10.0.0.254 + state: merged + + - name: RTT - Gather + register: gathered + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - gathered.gathered.bind_to_all == true + - gathered.gathered.instances[0].name == "vrf1" + - gathered.gathered.instances[0].table_id == 101 + - gathered.gathered.instances[0].protocols.bgp.system_as == 65001 + - gathered.gathered.instances[0].protocols.ospf.parameters.router_id == "10.0.0.1" + - gathered.gathered.instances[0].protocols.static.routes[0].dest == "192.168.10.0/24" + + - name: RTT - Modify description and add BGP neighbor + vyos.rest.vyos_vrf: + config: + bind_to_all: true + instances: + - name: vrf1 + description: red-updated + table_id: 101 + vni: 501 + protocols: + bgp: + system_as: 65001 + neighbor: + - address: 10.0.0.1 + remote_as: 65002 + - address: 10.0.0.2 + remote_as: 65003 + state: replaced + + - name: RTT - Gather after modify + register: gathered2 + vyos.rest.vyos_vrf: + state: gathered + + - assert: + that: + - gathered2.gathered.instances[0].description == "red-updated" + - gathered2.gathered.instances[0].protocols.bgp.neighbor | length == 2 + - gathered2.gathered.instances[0].protocols.ospf is not defined + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_vrf/vars/main.yaml b/tests/integration/targets/vyos_vrf/vars/main.yaml new file mode 100644 index 0000000..4303881 --- /dev/null +++ b/tests/integration/targets/vyos_vrf/vars/main.yaml @@ -0,0 +1,2 @@ +--- +# only common vars here diff --git a/tests/unit/fixtures/vrf_running.json b/tests/unit/fixtures/vrf_running.json new file mode 100644 index 0000000..602c292 --- /dev/null +++ b/tests/unit/fixtures/vrf_running.json @@ -0,0 +1,42 @@ +{ + "bind-to-all": {}, + "name": { + "vrf1": { + "description": "red", + "disable": {}, + "table": "101", + "vni": "501", + "protocols": { + "bgp": { + "system-as": "65001", + "neighbor": { + "10.0.0.1": { "remote-as": "65002", "description": "peer1" } + } + }, + "ospf": { + "area": { "0": { "network": ["10.0.0.0/24", "172.16.0.0/24"] } }, + "parameters": { "router-id": "10.0.0.1" } + }, + "static": { + "route": { + "192.168.10.0/24": { "next-hop": { "10.0.0.254": {} } } + } + } + } + }, + "vrf2": { + "description": "blah2", + "disable": {}, + "table": "102", + "vni": "102", + "ip": { + "disable-forwarding": {}, + "nht": { "no-resolve-via-default": {} } + }, + "ipv6": { + "disable-forwarding": {}, + "nht": { "no-resolve-via-default": {} } + } + } + } +} diff --git a/tests/unit/modules/test_vyos_vrf.py b/tests/unit/modules/test_vyos_vrf.py new file mode 100644 index 0000000..e17bcd1 --- /dev/null +++ b/tests/unit/modules/test_vyos_vrf.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import unittest + +from ansible_collections.vyos.rest.plugins.modules.vyos_vrf import ( + _device_to_argspec, + _proto_from_device, + _proto_to_device, + _protocols_from_device, + build_commands, +) + +from .base import load_fixture + + +_RAW_HAVE = load_fixture("vrf_running.json") + + +class TestDeviceToArgspec(unittest.TestCase): + def setUp(self): + self.result = _device_to_argspec(_RAW_HAVE) + + def test_bind_to_all(self): + self.assertTrue(self.result["bind_to_all"]) + + def test_instances_count(self): + self.assertEqual(len(self.result["instances"]), 2) + + def test_vrf1_properties(self): + vrf1 = next(i for i in self.result["instances"] if i["name"] == "vrf1") + self.assertEqual(vrf1["description"], "red") + self.assertTrue(vrf1["disable"]) + self.assertEqual(vrf1["table_id"], 101) + self.assertEqual(vrf1["vni"], 501) + + def test_vrf2_address_family(self): + vrf2 = next(i for i in self.result["instances"] if i["name"] == "vrf2") + afis = {af["afi"]: af for af in vrf2["address_family"]} + self.assertIn("ipv4", afis) + self.assertTrue(afis["ipv4"]["disable_forwarding"]) + self.assertTrue(afis["ipv4"]["nht_no_resolve_via_default"]) + self.assertIn("ipv6", afis) + self.assertTrue(afis["ipv6"]["disable_forwarding"]) + self.assertTrue(afis["ipv6"]["nht_no_resolve_via_default"]) + + def test_empty_input(self): + self.assertEqual(_device_to_argspec({}), {}) + self.assertEqual(_device_to_argspec(None), {}) + + +class TestBgpFromDevice(unittest.TestCase): + def setUp(self): + self.raw = _RAW_HAVE["name"]["vrf1"]["protocols"]["bgp"] + self.result = _proto_from_device(self.raw, "bgp") + + def test_system_as(self): + self.assertEqual(self.result["system_as"], 65001) + + def test_neighbor_list(self): + self.assertEqual(len(self.result["neighbor"]), 1) + n = self.result["neighbor"][0] + self.assertEqual(n["address"], "10.0.0.1") + self.assertEqual(n["remote_as"], 65002) + self.assertEqual(n["description"], "peer1") + + def test_empty_input(self): + self.assertEqual(_proto_from_device({}, "bgp"), {}) + self.assertEqual(_proto_from_device(None, "bgp"), {}) + + +class TestBgpToDevice(unittest.TestCase): + def test_system_as_to_device(self): + result = _proto_to_device({"system_as": 65001}, "bgp") + self.assertIn("system-as", result) + self.assertEqual(result["system-as"], 65001) + + def test_neighbor_to_device(self): + result = _proto_to_device( + { + "system_as": 65001, + "neighbor": [{"address": "10.0.0.1", "remote_as": 65002}], + }, + "bgp", + ) + self.assertIn("neighbor", result) + self.assertIn("10.0.0.1", result["neighbor"]) + self.assertEqual(result["neighbor"]["10.0.0.1"]["remote-as"], 65002) + + def test_idempotent(self): + want = _proto_from_device(_RAW_HAVE["name"]["vrf1"]["protocols"]["bgp"], "bgp") + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"bgp": want}}]}, + _RAW_HAVE, + "merged", + ) + bgp_cmds = [c for c in cmds if "bgp" in str(c)] + self.assertEqual(bgp_cmds, []) + + def test_add_neighbor(self): + want_bgp = { + "system_as": 65001, + "neighbor": [ + {"address": "10.0.0.1", "remote_as": 65002, "description": "peer1"}, + {"address": "10.0.0.2", "remote_as": 65003}, + ], + } + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"bgp": want_bgp}}]}, + _RAW_HAVE, + "merged", + ) + paths = [c[1] for c in cmds] + self.assertIn( + [ + "vrf", + "name", + "vrf1", + "protocols", + "bgp", + "neighbor", + "10.0.0.2", + "remote-as", + "65003", + ], + paths, + ) + self.assertNotIn( + [ + "vrf", + "name", + "vrf1", + "protocols", + "bgp", + "neighbor", + "10.0.0.1", + "remote-as", + "65002", + ], + paths, + ) + + +class TestOspfFromDevice(unittest.TestCase): + def setUp(self): + self.raw = _RAW_HAVE["name"]["vrf1"]["protocols"]["ospf"] + self.result = _proto_from_device(self.raw, "ospf") + + def test_areas(self): + self.assertEqual(len(self.result["areas"]), 1) + area = self.result["areas"][0] + self.assertEqual(area["area_id"], "0") + self.assertIn("10.0.0.0/24", area["networks"]) + self.assertIn("172.16.0.0/24", area["networks"]) + + def test_parameters(self): + self.assertEqual(self.result["parameters"]["router_id"], "10.0.0.1") + + def test_empty_input(self): + self.assertEqual(_proto_from_device({}, "ospf"), {}) + + +class TestOspfBuildCommands(unittest.TestCase): + def test_idempotent(self): + want = _proto_from_device(_RAW_HAVE["name"]["vrf1"]["protocols"]["ospf"], "ospf") + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"ospf": want}}]}, + _RAW_HAVE, + "merged", + ) + ospf_cmds = [c for c in cmds if "ospf" in str(c)] + self.assertEqual(ospf_cmds, []) + + def test_add_network(self): + want_ospf = { + "areas": [ + {"area_id": "0", "networks": ["10.0.0.0/24", "172.16.0.0/24", "192.168.0.0/24"]}, + ], + } + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"ospf": want_ospf}}]}, + _RAW_HAVE, + "merged", + ) + paths = [c[1] for c in cmds] + self.assertIn( + ["vrf", "name", "vrf1", "protocols", "ospf", "area", "0", "network", "192.168.0.0/24"], + paths, + ) + + def test_add_area(self): + want_ospf = { + "areas": [ + {"area_id": "0", "networks": ["10.0.0.0/24", "172.16.0.0/24"]}, + {"area_id": "1", "networks": ["10.1.0.0/24"]}, + ], + } + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"ospf": want_ospf}}]}, + _RAW_HAVE, + "merged", + ) + paths = [c[1] for c in cmds] + self.assertIn( + ["vrf", "name", "vrf1", "protocols", "ospf", "area", "1", "network", "10.1.0.0/24"], + paths, + ) + + def test_change_router_id(self): + """Regression test: confirmed bug where router_id was missing + from _DEVICE_RENAMES. A brand-new router_id happened to work + via dict_op's own fallback conversion, and an unchanged value + happened to stay idempotent since both sides of the comparison + shared the same (wrong) key -- only *changing* an existing + router_id actually exposed the corrupted "router_id" (no + hyphen) device path, which VyOS would reject.""" + want_ospf = { + "areas": [ + {"area_id": "0", "networks": ["10.0.0.0/24", "172.16.0.0/24"]}, + ], + "parameters": {"router_id": "10.0.0.99"}, + } + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"ospf": want_ospf}}]}, + _RAW_HAVE, + "merged", + ) + self.assertIn( + ( + "set", + [ + "vrf", + "name", + "vrf1", + "protocols", + "ospf", + "parameters", + "router-id", + "10.0.0.99", + ], + ), + cmds, + ) + paths = [c[1] for c in cmds] + self.assertFalse( + any("router_id" in p for p in paths), + "router_id (underscore) must never appear in a device path", + ) + + +class TestStaticFromDevice(unittest.TestCase): + def setUp(self): + self.raw = _RAW_HAVE["name"]["vrf1"]["protocols"]["static"] + self.result = _proto_from_device(self.raw, "static") + + def test_routes(self): + self.assertEqual(len(self.result["routes"]), 1) + route = self.result["routes"][0] + self.assertEqual(route["dest"], "192.168.10.0/24") + self.assertIn("10.0.0.254", route["next_hops"]) + + def test_empty_input(self): + self.assertEqual(_proto_from_device({}, "static"), {}) + + +class TestStaticBuildCommands(unittest.TestCase): + def test_idempotent(self): + want = _proto_from_device(_RAW_HAVE["name"]["vrf1"]["protocols"]["static"], "static") + cmds = build_commands( + {"instances": [{"name": "vrf1", "table_id": 101, "protocols": {"static": want}}]}, + _RAW_HAVE, + "merged", + ) + static_cmds = [c for c in cmds if "static" in str(c)] + self.assertEqual(static_cmds, []) + + def test_add_route(self): + want_static = { + "routes": [ + {"dest": "192.168.10.0/24", "next_hops": ["10.0.0.254"]}, + {"dest": "192.168.20.0/24", "next_hops": ["10.0.0.254"]}, + ], + } + cmds = build_commands( + { + "instances": [ + {"name": "vrf1", "table_id": 101, "protocols": {"static": want_static}}, + ], + }, + _RAW_HAVE, + "merged", + ) + paths = [c[1] for c in cmds] + self.assertIn( + [ + "vrf", + "name", + "vrf1", + "protocols", + "static", + "route", + "192.168.20.0/24", + "next-hop", + "10.0.0.254", + ], + paths, + ) + + +class TestProtocolsFromDevice(unittest.TestCase): + def test_all_protocols(self): + raw_vrf = _RAW_HAVE["name"]["vrf1"] + result = _protocols_from_device(raw_vrf) + self.assertIn("bgp", result) + self.assertIn("ospf", result) + self.assertIn("static", result) + + def test_no_protocols(self): + raw_vrf = _RAW_HAVE["name"]["vrf2"] + result = _protocols_from_device(raw_vrf) + self.assertIsNone(result) + + +class TestBuildCommands(unittest.TestCase): + def test_merged_new_vrf(self): + config = {"instances": [{"name": "vrf3", "table_id": 200, "vni": 2000}]} + cmds = build_commands(config, _RAW_HAVE, "merged") + paths = [c[1] for c in cmds] + self.assertIn(["vrf", "name", "vrf3", "table", "200"], paths) + self.assertIn(["vrf", "name", "vrf3", "vni", "2000"], paths) + + def test_merged_idempotent(self): + config = { + "bind_to_all": True, + "instances": [ + {"name": "vrf1", "description": "red", "table_id": 101, "vni": 501}, + ], + } + cmds = build_commands(config, _RAW_HAVE, "merged") + self.assertEqual(cmds, []) + + def test_deleted_specific_vrf(self): + config = {"instances": [{"name": "vrf1"}]} + cmds = build_commands(config, _RAW_HAVE, "deleted") + self.assertIn(("delete", ["vrf", "name", "vrf1"]), cmds) + self.assertNotIn(("delete", ["vrf", "name", "vrf2"]), cmds) + + def test_deleted_all(self): + cmds = build_commands({}, _RAW_HAVE, "deleted") + self.assertIn(("delete", ["vrf"]), cmds) + + def test_overridden_removes_extra_vrf(self): + config = {"instances": [{"name": "vrf1", "table_id": 101}]} + cmds = build_commands(config, _RAW_HAVE, "overridden") + paths = [c[1] for c in cmds] + self.assertIn(["vrf", "name", "vrf2"], paths) + + def test_merged_does_not_delete_unreferenced_vrf(self): + config = {"instances": [{"name": "vrf1", "table_id": 101}]} + cmds = build_commands(config, _RAW_HAVE, "merged") + paths = [c[1] for c in cmds] + self.assertNotIn(["vrf", "name", "vrf2"], paths) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3