From d997d88bc1f07a9ad641c5c6e7db2a27deacefa3 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Sun, 14 Jun 2026 21:27:08 +1000 Subject: [PATCH 1/5] T8989 static_routes --- README.md | 13 +- plugins/modules/vyos_static_routes.py | 386 +++++++++++++++++ .../targets/vyos_static_routes/aliases | 1 + .../vyos_static_routes/defaults/main.yaml | 3 + .../vyos_static_routes/tasks/httpapi.yaml | 21 + .../vyos_static_routes/tasks/main.yaml | 5 + .../vyos_static_routes/tests/httpapi.yaml | 21 + .../tests/httpapi/_populate_config.yaml | 19 + .../tests/httpapi/_remove_config.yaml | 5 + .../tests/httpapi/deleted.yaml | 28 ++ .../tests/httpapi/gathered.yaml | 20 + .../tests/httpapi/merged.yaml | 37 ++ .../tests/httpapi/replaced.yaml | 35 ++ .../targets/vyos_static_routes/vars/main.yaml | 1 + .../unit/fixtures/static_routes_running.json | 21 + tests/unit/modules/test_vyos_static_routes.py | 398 ++++++++++++++++++ 16 files changed, 1006 insertions(+), 8 deletions(-) create mode 100644 plugins/modules/vyos_static_routes.py create mode 100644 tests/integration/targets/vyos_static_routes/aliases create mode 100644 tests/integration/targets/vyos_static_routes/defaults/main.yaml create mode 100644 tests/integration/targets/vyos_static_routes/tasks/httpapi.yaml create mode 100644 tests/integration/targets/vyos_static_routes/tasks/main.yaml create mode 100644 tests/integration/targets/vyos_static_routes/tests/httpapi.yaml create mode 100644 tests/integration/targets/vyos_static_routes/tests/httpapi/_populate_config.yaml create mode 100644 tests/integration/targets/vyos_static_routes/tests/httpapi/_remove_config.yaml create mode 100644 tests/integration/targets/vyos_static_routes/tests/httpapi/deleted.yaml create mode 100644 tests/integration/targets/vyos_static_routes/tests/httpapi/gathered.yaml create mode 100644 tests/integration/targets/vyos_static_routes/tests/httpapi/merged.yaml create mode 100644 tests/integration/targets/vyos_static_routes/tests/httpapi/replaced.yaml create mode 100644 tests/integration/targets/vyos_static_routes/vars/main.yaml create mode 100644 tests/unit/fixtures/static_routes_running.json create mode 100644 tests/unit/modules/test_vyos_static_routes.py diff --git a/README.md b/README.md index e056af4..629577c 100644 --- a/README.md +++ b/README.md @@ -74,20 +74,17 @@ Name | Description [vyos.rest.vyos](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_httpapi.rst)|HttpApi plugin for VyOS REST API ### Modules - -Modules marked ⚠️ are not yet available in this release. - Name | Description --- | --- [vyos.rest.vyos_banner](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_banner_module.rst)|Manage multiline banners on VyOS devices via REST API. [vyos.rest.vyos_configure](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_configure_module.rst)|Send raw set/delete commands to a VyOS device via REST API. [vyos.rest.vyos_hostname](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_hostname_module.rst)|Manage the system hostname on a VyOS device via the REST API. [vyos.rest.vyos_lldp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_lldp_global_module.rst)|Manage LLDP global configuration on VyOS via REST API. -[vyos.rest.vyos_logging_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_logging_global_module.rst)|Manage syslog configuration on VyOS devices using REST API. -[vyos.rest.vyos_ntp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ntp_global_module.rst)|Manage NTP configuration on VyOS devices using REST API. -[vyos.rest.vyos_prefix_lists](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_prefix_lists_module.rst)|⚠️ Manage prefix-list configuration on VyOS devices using REST API. *(not yet available)* -[vyos.rest.vyos_route_maps](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_route_maps_module.rst)|Manage route-map configuration on VyOS devices using REST API. -[vyos.rest.vyos_snmp_server](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_snmp_server_module.rst)|Manage SNMP server configuration on VyOS devices using REST API. +[vyos.rest.vyos_logging_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_logging_global_module.rst)|Manage syslog configuration on VyOS devices using REST API +[vyos.rest.vyos_ntp_global](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_ntp_global_module.rst)|Manage NTP configuration on VyOS devices using REST API +[vyos.rest.vyos_route_maps](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_route_maps_module.rst)|Manage route-map configuration on VyOS devices using REST API +[vyos.rest.vyos_snmp_server](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_snmp_server_module.rst)|Manage SNMP server configuration on VyOS devices using REST API +[vyos.rest.vyos_static_routes](https://github.com/vyos/vyos.rest/blob/main/docs/vyos.rest.vyos_static_routes_module.rst)|Manage static routes on VyOS devices via REST API. diff --git a/plugins/modules/vyos_static_routes.py b/plugins/modules/vyos_static_routes.py new file mode 100644 index 0000000..20ccf55 --- /dev/null +++ b/plugins/modules/vyos_static_routes.py @@ -0,0 +1,386 @@ +#!/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_static_routes +short_description: Manage static routes on VyOS devices via REST API. +description: + - Manages IPv4 and IPv6 static routes on VyOS devices using the HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_static_routes) but uses the HTTP API instead of CLI. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of static route configurations grouped by address family. + type: list + elements: dict + suboptions: + afi: + description: Address family indicator. + type: str + choices: [ipv4, ipv6] + required: true + routes: + description: List of static route entries. + type: list + elements: dict + suboptions: + dest: + description: Destination prefix in CIDR notation. + type: str + required: true + blackhole_config: + description: Blackhole route configuration. + type: dict + suboptions: + distance: + description: Administrative distance (1-255). + type: int + type: + description: Blackhole type. + type: str + next_hops: + description: List of next-hop addresses. + type: list + elements: dict + suboptions: + forward_router_address: + description: Next-hop IP address. + type: str + required: true + admin_distance: + description: Administrative distance for this next-hop. + type: int + enabled: + description: Whether this next-hop is enabled. + type: bool + default: true + interface: + description: Outgoing interface name. + type: str + state: + description: + - C(merged) - Add routes without removing existing ones. + - C(replaced) - Replace routes for listed destinations. + - C(overridden) - Replace the entire static route table. + - C(deleted) - Remove listed or all static routes. + - C(gathered) - Read static routes from device without changes. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged +seealso: + - module: vyos.vyos.vyos_static_routes +""" + +EXAMPLES = r""" +- name: Merge IPv4 and IPv6 static routes + vyos.rest.vyos_static_routes: + config: + - afi: ipv4 + routes: + - dest: 192.0.2.0/24 + next_hops: + - forward_router_address: 10.0.0.1 + - dest: 203.0.113.0/24 + blackhole_config: + distance: 200 + - afi: ipv6 + routes: + - dest: 2001:db8::/32 + next_hops: + - forward_router_address: 2001:db8::1 + state: merged + +- name: Delete all static routes + vyos.rest.vyos_static_routes: + state: deleted + +- name: Gather current static routes + vyos.rest.vyos_static_routes: + state: gathered +""" + +RETURN = r""" +before: + description: Static route configuration before this module ran. + returned: always + type: list +after: + description: Static route 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 static route 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 + + +_ROUTE_KEY = {"ipv4": "route", "ipv6": "route6"} +_BASE = ["protocols", "static"] + + +def get_running_config(vyos): + raw = vyos.get_config(_BASE) + if not raw or not isinstance(raw, dict): + return [] + + result = [] + for afi, route_key in [("ipv4", "route"), ("ipv6", "route6")]: + routes_data = raw.get(route_key) or {} + if not isinstance(routes_data, dict): + continue + + routes = [] + for dest, rdata in sorted(routes_data.items()): + rdata = rdata or {} + route = {"dest": dest} + + if "blackhole" in rdata: + bh = rdata["blackhole"] + bc = {} + if isinstance(bh, dict) and "distance" in bh: + bc["distance"] = int(bh["distance"]) + route["blackhole_config"] = bc + + nh_data = rdata.get("next-hop") or {} + if isinstance(nh_data, dict) and nh_data: + next_hops = [] + for nh_addr, nh_opts in sorted(nh_data.items()): + nh = {"forward_router_address": nh_addr} + nh_opts = nh_opts or {} + if "distance" in nh_opts: + nh["admin_distance"] = int(nh_opts["distance"]) + if "disable" in nh_opts: + nh["enabled"] = False + if "interface" in nh_opts: + nh["interface"] = nh_opts["interface"] + next_hops.append(nh) + route["next_hops"] = next_hops + + routes.append(route) + + if routes: + result.append({"afi": afi, "routes": routes}) + + return result + + +def _normalize(config): + """Convert argspec list to nested dict for diffing. + { + "ipv4": {"192.0.2.0/24": {"next_hops": {...}, "blackhole_config": {...}}}, + "ipv6": {...} + } + """ + result = {"ipv4": {}, "ipv6": {}} + for entry in config or []: + afi = entry.get("afi") + if afi not in result: + continue + for route in entry.get("routes") or []: + dest = route["dest"] + result[afi][dest] = { + "blackhole_config": route.get("blackhole_config"), + "next_hops": { + nh["forward_router_address"]: nh for nh in (route.get("next_hops") or []) + }, + } + return result + + +def _route_cmds(afi, dest, want_route, have_route): + """Generate set commands for a single route.""" + cmds = [] + base = _BASE + [_ROUTE_KEY[afi], dest] + have_route = have_route or {} + + # blackhole + if want_route.get("blackhole_config") is not None: + bh_base = base + ["blackhole"] + if "blackhole_config" not in have_route: + cmds.append(("set", bh_base)) + dist = (want_route["blackhole_config"] or {}).get("distance") + have_dist = (have_route.get("blackhole_config") or {}).get("distance") + if dist is not None and dist != have_dist: + cmds.append(("set", bh_base + ["distance", str(dist)])) + + # next-hops + want_nhs = want_route.get("next_hops") or {} + have_nhs = have_route.get("next_hops") or {} + + for nh_addr, want_nh in want_nhs.items(): + nh_base = base + ["next-hop", nh_addr] + have_nh = have_nhs.get(nh_addr, {}) + + if nh_addr not in have_nhs: + cmds.append(("set", nh_base)) + + dist = want_nh.get("admin_distance") + have_dist = have_nh.get("admin_distance") + if dist is not None and dist != have_dist: + cmds.append(("set", nh_base + ["distance", str(dist)])) + + enabled = want_nh.get("enabled", True) + have_enabled = have_nh.get("enabled", True) + if not enabled and have_enabled: + cmds.append(("set", nh_base + ["disable"])) + elif enabled and not have_enabled: + cmds.append(("delete", nh_base + ["disable"])) + + iface = want_nh.get("interface") + have_iface = have_nh.get("interface") + if iface and iface != have_iface: + cmds.append(("set", nh_base + ["interface", iface])) + + return cmds + + +def build_commands(config, have_raw, state): + cmds = [] + + if state == "deleted": + if not config: + for afi, route_key in _ROUTE_KEY.items(): + if any(e.get("afi") == afi for e in have_raw): + cmds.append(("delete", _BASE + [route_key])) + else: + for entry in config: + afi = entry.get("afi") + route_key = _ROUTE_KEY[afi] + for route in entry.get("routes") or []: + cmds.append(("delete", _BASE + [route_key, route["dest"]])) + return cmds + + want = _normalize(config) + have = _normalize(have_raw) + + for afi, route_key in _ROUTE_KEY.items(): + want_afi = want.get(afi, {}) + have_afi = have.get(afi, {}) + + if state == "overridden": + for dest in set(have_afi) - set(want_afi): + cmds.append(("delete", _BASE + [route_key, dest])) + + for dest, want_route in want_afi.items(): + have_route = have_afi.get(dest, {}) + + if state == "replaced" and dest in have_afi: + # check if anything differs before deleting + test_cmds = _route_cmds(afi, dest, want_route, have_route) + want_nhs = set(want_route.get("next_hops") or {}) + have_nhs = set(have_route.get("next_hops") or {}) + extra_nhs = have_nhs - want_nhs + want_bh = want_route.get("blackhole_config") + have_bh = have_route.get("blackhole_config") + have_bh_now = have_bh is not None + want_bh_now = want_bh is not None + if test_cmds or extra_nhs or have_bh_now != want_bh_now: + cmds.append(("delete", _BASE + [route_key, dest])) + have_route = {} + else: + continue # already matches — idempotent + + cmds += _route_cmds(afi, dest, want_route, have_route) + + return cmds + + +ARGUMENT_SPEC = dict( + config=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), + routes=dict( + type="list", + elements="dict", + options=dict( + dest=dict(type="str", required=True), + blackhole_config=dict( + type="dict", + options=dict( + distance=dict(type="int"), + type=dict(type="str"), + ), + ), + next_hops=dict( + type="list", + elements="dict", + options=dict( + forward_router_address=dict(type="str", required=True), + admin_distance=dict(type="int"), + enabled=dict(type="bool", default=True), + interface=dict(type="str"), + ), + ), + ), + ), + ), + ), + 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/tests/integration/targets/vyos_static_routes/aliases b/tests/integration/targets/vyos_static_routes/aliases new file mode 100644 index 0000000..cc0afef --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/aliases @@ -0,0 +1 @@ +network/vyos diff --git a/tests/integration/targets/vyos_static_routes/defaults/main.yaml b/tests/integration/targets/vyos_static_routes/defaults/main.yaml new file mode 100644 index 0000000..164afea --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +testcase: "[^_].*" +test_items: [] diff --git a/tests/integration/targets/vyos_static_routes/tasks/httpapi.yaml b/tests/integration/targets/vyos_static_routes/tasks/httpapi.yaml new file mode 100644 index 0000000..4147e6d --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/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 }}" + +- 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_static_routes/tasks/main.yaml b/tests/integration/targets/vyos_static_routes/tasks/main.yaml new file mode 100644 index 0000000..b1f6193 --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/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_static_routes/tests/httpapi.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi.yaml new file mode 100644 index 0000000..4147e6d --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/tests/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 }}" + +- 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_static_routes/tests/httpapi/_populate_config.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/_populate_config.yaml new file mode 100644 index 0000000..e257075 --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/_populate_config.yaml @@ -0,0 +1,19 @@ +--- +- name: Populate static_routes config for testing + vyos.rest.vyos_static_routes: + config: + - afi: ipv4 + routes: + - dest: 192.0.2.0/24 + next_hops: + - forward_router_address: 10.0.0.1 + - dest: 203.0.113.0/24 + blackhole_config: + distance: 200 + - afi: ipv6 + routes: + - dest: 2001:db8::/32 + next_hops: + - forward_router_address: 2001:db8::1 + state: merged + ignore_errors: true diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/_remove_config.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/_remove_config.yaml new file mode 100644 index 0000000..dd02da6 --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/_remove_config.yaml @@ -0,0 +1,5 @@ +--- +- name: Remove pre-existing static_routes config + vyos.rest.vyos_static_routes: + state: deleted + ignore_errors: true diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/deleted.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/deleted.yaml new file mode 100644 index 0000000..b02e256 --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/deleted.yaml @@ -0,0 +1,28 @@ +--- +- debug: + msg: START vyos_static_routes deleted integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Delete all static_routes configuration + register: result + vyos.rest.vyos_static_routes: &id001 + state: deleted + + - assert: + that: + - result.changed == true + + - name: Delete static_routes configuration (IDEMPOTENT) + register: result + vyos.rest.vyos_static_routes: *id001 + + - name: Assert idempotent + assert: + that: + - result.changed == false + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/gathered.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/gathered.yaml new file mode 100644 index 0000000..c6d5e59 --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/gathered.yaml @@ -0,0 +1,20 @@ +--- +- debug: + msg: START vyos_static_routes gathered integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Gather static_routes configuration + register: result + vyos.rest.vyos_static_routes: + state: gathered + + - assert: + that: + - result.changed == false + - result.gathered | selectattr('afi', 'eq', 'ipv4') | list | length > 0 + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/merged.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/merged.yaml new file mode 100644 index 0000000..6cf6a01 --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/merged.yaml @@ -0,0 +1,37 @@ +--- +- debug: + msg: START vyos_static_routes merged integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml + +- block: + - name: Merge static_routes configuration + register: result + vyos.rest.vyos_static_routes: &id001 + config: + - afi: ipv4 + routes: + - dest: 192.0.2.0/24 + next_hops: + - forward_router_address: 10.0.0.1 + - dest: 203.0.113.0/24 + blackhole_config: + distance: 200 + state: merged + + - assert: + that: + - result.changed == true + + - name: Merge static_routes configuration (IDEMPOTENT) + register: result + vyos.rest.vyos_static_routes: *id001 + + - name: Assert idempotent + assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_static_routes/tests/httpapi/replaced.yaml b/tests/integration/targets/vyos_static_routes/tests/httpapi/replaced.yaml new file mode 100644 index 0000000..a27e637 --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/tests/httpapi/replaced.yaml @@ -0,0 +1,35 @@ +--- +- debug: + msg: START vyos_static_routes replaced integration tests on connection={{ ansible_connection }} + +- include_tasks: _remove_config.yaml +- include_tasks: _populate_config.yaml + +- block: + - name: Replace static_routes configuration + register: result + vyos.rest.vyos_static_routes: &id001 + config: + - afi: ipv4 + routes: + - dest: 192.0.2.0/24 + next_hops: + - forward_router_address: 10.0.0.2 + state: replaced + + - assert: + that: + - result.changed == true + + - name: Replace static_routes configuration (IDEMPOTENT) + register: result + vyos.rest.vyos_static_routes: *id001 + + - name: Assert idempotent + assert: + that: + - result.changed == false + - result.commands == [] + + always: + - include_tasks: _remove_config.yaml diff --git a/tests/integration/targets/vyos_static_routes/vars/main.yaml b/tests/integration/targets/vyos_static_routes/vars/main.yaml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/tests/integration/targets/vyos_static_routes/vars/main.yaml @@ -0,0 +1 @@ +--- diff --git a/tests/unit/fixtures/static_routes_running.json b/tests/unit/fixtures/static_routes_running.json new file mode 100644 index 0000000..46cf700 --- /dev/null +++ b/tests/unit/fixtures/static_routes_running.json @@ -0,0 +1,21 @@ +{ + "route": { + "192.0.2.0/24": { + "next-hop": { + "10.0.0.1": {} + } + }, + "203.0.113.0/24": { + "blackhole": { + "distance": "200" + } + } + }, + "route6": { + "2001:db8::/32": { + "next-hop": { + "2001:db8::1": {} + } + } + } +} diff --git a/tests/unit/modules/test_vyos_static_routes.py b/tests/unit/modules/test_vyos_static_routes.py new file mode 100644 index 0000000..f75e4d1 --- /dev/null +++ b/tests/unit/modules/test_vyos_static_routes.py @@ -0,0 +1,398 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +import json +import os +import unittest + +from unittest.mock import MagicMock + +from ansible_collections.vyos.rest.plugins.modules.vyos_static_routes import ( + _normalize, + _route_cmds, + build_commands, + get_running_config, +) + + +def load_fixture(filename): + fixtures_dir = os.path.join(os.path.dirname(__file__), "..", "fixtures") + path = os.path.join(fixtures_dir, filename) + with open(path) as f: + return json.load(f) + + +class VyOSModuleTestCase(unittest.TestCase): + def setUp(self): + self.mock_vyos = MagicMock() + self.mock_vyos.get_config = MagicMock(return_value={}) + + def set_running_config(self, data): + self.mock_vyos.get_config.return_value = data + + +class TestVyOSStaticRoutesGetRunningFixture(VyOSModuleTestCase): + + def setUp(self): + super().setUp() + self.fixture = load_fixture("static_routes_running.json") + + def test_fixture_parses_ipv4(self): + self.set_running_config(self.fixture) + result = get_running_config(self.mock_vyos) + ipv4 = next(e for e in result if e["afi"] == "ipv4") + dests = [r["dest"] for r in ipv4["routes"]] + self.assertIn("192.0.2.0/24", dests) + self.assertIn("203.0.113.0/24", dests) + + def test_fixture_parses_ipv4_next_hop(self): + self.set_running_config(self.fixture) + result = get_running_config(self.mock_vyos) + ipv4 = next(e for e in result if e["afi"] == "ipv4") + route = next(r for r in ipv4["routes"] if r["dest"] == "192.0.2.0/24") + self.assertEqual(route["next_hops"][0]["forward_router_address"], "10.0.0.1") + + def test_fixture_parses_blackhole_distance(self): + self.set_running_config(self.fixture) + result = get_running_config(self.mock_vyos) + ipv4 = next(e for e in result if e["afi"] == "ipv4") + route = next(r for r in ipv4["routes"] if r["dest"] == "203.0.113.0/24") + self.assertEqual(route["blackhole_config"]["distance"], 200) + + def test_fixture_parses_ipv6(self): + self.set_running_config(self.fixture) + result = get_running_config(self.mock_vyos) + ipv6 = next(e for e in result if e["afi"] == "ipv6") + self.assertEqual(ipv6["routes"][0]["dest"], "2001:db8::/32") + + +class TestVyOSStaticRoutesGetRunning(VyOSModuleTestCase): + + def test_empty_returns_empty_list(self): + self.set_running_config({}) + result = get_running_config(self.mock_vyos) + self.assertEqual(result, []) + + def test_parses_ipv4_next_hop(self): + self.set_running_config( + { + "route": { + "192.0.2.0/24": { + "next-hop": {"10.0.0.1": {}}, + }, + }, + }, + ) + result = get_running_config(self.mock_vyos) + ipv4 = next(e for e in result if e["afi"] == "ipv4") + route = ipv4["routes"][0] + self.assertEqual(route["dest"], "192.0.2.0/24") + self.assertEqual(route["next_hops"][0]["forward_router_address"], "10.0.0.1") + + def test_parses_ipv4_blackhole(self): + self.set_running_config( + { + "route": { + "203.0.113.0/24": {"blackhole": {}}, + }, + }, + ) + result = get_running_config(self.mock_vyos) + ipv4 = next(e for e in result if e["afi"] == "ipv4") + route = ipv4["routes"][0] + self.assertIn("blackhole_config", route) + + def test_parses_ipv4_blackhole_distance(self): + self.set_running_config( + { + "route": { + "203.0.113.0/24": {"blackhole": {"distance": "200"}}, + }, + }, + ) + result = get_running_config(self.mock_vyos) + ipv4 = next(e for e in result if e["afi"] == "ipv4") + route = ipv4["routes"][0] + self.assertEqual(route["blackhole_config"]["distance"], 200) + + def test_parses_ipv6_next_hop(self): + self.set_running_config( + { + "route6": { + "2001:db8::/32": { + "next-hop": {"2001:db8::1": {}}, + }, + }, + }, + ) + result = get_running_config(self.mock_vyos) + ipv6 = next(e for e in result if e["afi"] == "ipv6") + route = ipv6["routes"][0] + self.assertEqual(route["dest"], "2001:db8::/32") + self.assertEqual(route["next_hops"][0]["forward_router_address"], "2001:db8::1") + + def test_parses_next_hop_admin_distance(self): + self.set_running_config( + { + "route": { + "192.0.2.0/24": { + "next-hop": {"10.0.0.1": {"distance": "10"}}, + }, + }, + }, + ) + result = get_running_config(self.mock_vyos) + ipv4 = next(e for e in result if e["afi"] == "ipv4") + nh = ipv4["routes"][0]["next_hops"][0] + self.assertEqual(nh["admin_distance"], 10) + + def test_parses_disabled_next_hop(self): + self.set_running_config( + { + "route": { + "192.0.2.0/24": { + "next-hop": {"10.0.0.1": {"disable": {}}}, + }, + }, + }, + ) + result = get_running_config(self.mock_vyos) + ipv4 = next(e for e in result if e["afi"] == "ipv4") + nh = ipv4["routes"][0]["next_hops"][0] + self.assertFalse(nh["enabled"]) + + def test_no_ipv4_entry_when_only_ipv6(self): + self.set_running_config( + { + "route6": { + "2001:db8::/32": {"next-hop": {"2001:db8::1": {}}}, + }, + }, + ) + result = get_running_config(self.mock_vyos) + afis = [e["afi"] for e in result] + self.assertNotIn("ipv4", afis) + self.assertIn("ipv6", afis) + + +class TestVyOSStaticRoutesNormalize(unittest.TestCase): + + def test_normalize_ipv4_next_hop(self): + config = [ + { + "afi": "ipv4", + "routes": [ + { + "dest": "192.0.2.0/24", + "next_hops": [{"forward_router_address": "10.0.0.1"}], + }, + ], + }, + ] + result = _normalize(config) + self.assertIn("192.0.2.0/24", result["ipv4"]) + self.assertIn("10.0.0.1", result["ipv4"]["192.0.2.0/24"]["next_hops"]) + + def test_normalize_blackhole(self): + config = [ + { + "afi": "ipv4", + "routes": [ + { + "dest": "203.0.113.0/24", + "blackhole_config": {"distance": 200}, + }, + ], + }, + ] + result = _normalize(config) + route = result["ipv4"]["203.0.113.0/24"] + self.assertEqual(route["blackhole_config"]["distance"], 200) + + def test_normalize_empty_config(self): + result = _normalize([]) + self.assertEqual(result["ipv4"], {}) + self.assertEqual(result["ipv6"], {}) + + def test_normalize_unknown_afi_ignored(self): + config = [{"afi": "ipv99", "routes": []}] + result = _normalize(config) + self.assertNotIn("ipv99", result) + + +class TestVyOSStaticRoutesRouteCmds(unittest.TestCase): + + def test_new_next_hop(self): + want = {"next_hops": {"10.0.0.1": {"forward_router_address": "10.0.0.1"}}} + cmds = _route_cmds("ipv4", "192.0.2.0/24", want, {}) + paths = [c[1] for c in cmds] + self.assertIn( + ["protocols", "static", "route", "192.0.2.0/24", "next-hop", "10.0.0.1"], + paths, + ) + + def test_new_blackhole(self): + want = {"blackhole_config": {}} + cmds = _route_cmds("ipv4", "192.0.2.0/24", want, {}) + paths = [c[1] for c in cmds] + self.assertIn( + ["protocols", "static", "route", "192.0.2.0/24", "blackhole"], + paths, + ) + + def test_blackhole_distance(self): + want = {"blackhole_config": {"distance": 200}} + cmds = _route_cmds("ipv4", "192.0.2.0/24", want, {}) + paths = [c[1] for c in cmds] + self.assertIn( + ["protocols", "static", "route", "192.0.2.0/24", "blackhole", "distance", "200"], + paths, + ) + + def test_idempotent_next_hop(self): + want = {"next_hops": {"10.0.0.1": {"forward_router_address": "10.0.0.1"}}} + have = {"next_hops": {"10.0.0.1": {"forward_router_address": "10.0.0.1"}}} + cmds = _route_cmds("ipv4", "192.0.2.0/24", want, have) + self.assertEqual(cmds, []) + + def test_admin_distance_added(self): + want = { + "next_hops": { + "10.0.0.1": { + "forward_router_address": "10.0.0.1", + "admin_distance": 10, + }, + }, + } + cmds = _route_cmds("ipv4", "192.0.2.0/24", want, {}) + paths = [c[1] for c in cmds] + self.assertIn( + [ + "protocols", + "static", + "route", + "192.0.2.0/24", + "next-hop", + "10.0.0.1", + "distance", + "10", + ], + paths, + ) + + def test_disable_next_hop(self): + want = { + "next_hops": { + "10.0.0.1": { + "forward_router_address": "10.0.0.1", + "enabled": False, + }, + }, + } + have = { + "next_hops": { + "10.0.0.1": { + "forward_router_address": "10.0.0.1", + "enabled": True, + }, + }, + } + cmds = _route_cmds("ipv4", "192.0.2.0/24", want, have) + ops = [(c[0], c[1][-1]) for c in cmds] + self.assertIn(("set", "disable"), ops) + + +class TestVyOSStaticRoutesBuildCommands(unittest.TestCase): + + def _have_ipv4(self): + return [ + { + "afi": "ipv4", + "routes": [ + { + "dest": "192.0.2.0/24", + "next_hops": [{"forward_router_address": "10.0.0.1"}], + }, + ], + }, + ] + + def test_merged_adds_new_route(self): + config = [ + { + "afi": "ipv4", + "routes": [ + {"dest": "10.0.0.0/8", "next_hops": [{"forward_router_address": "1.2.3.4"}]}, + ], + }, + ] + cmds = build_commands(config, [], "merged") + paths = [c[1] for c in cmds] + self.assertIn( + ["protocols", "static", "route", "10.0.0.0/8", "next-hop", "1.2.3.4"], + paths, + ) + + def test_merged_idempotent(self): + cmds = build_commands(self._have_ipv4(), self._have_ipv4(), "merged") + self.assertEqual(cmds, []) + + def test_deleted_no_config_removes_all(self): + cmds = build_commands([], self._have_ipv4(), "deleted") + self.assertIn(("delete", ["protocols", "static", "route"]), cmds) + + def test_deleted_with_config_removes_specific(self): + config = [{"afi": "ipv4", "routes": [{"dest": "192.0.2.0/24"}]}] + cmds = build_commands(config, self._have_ipv4(), "deleted") + self.assertIn( + ("delete", ["protocols", "static", "route", "192.0.2.0/24"]), + cmds, + ) + + def test_deleted_idempotent_when_empty(self): + cmds = build_commands([], [], "deleted") + self.assertEqual(cmds, []) + + def test_replaced_idempotent(self): + cmds = build_commands(self._have_ipv4(), self._have_ipv4(), "replaced") + self.assertEqual(cmds, []) + + def test_replaced_deletes_then_rebuilds(self): + config = [ + { + "afi": "ipv4", + "routes": [ + {"dest": "192.0.2.0/24", "next_hops": [{"forward_router_address": "9.9.9.9"}]}, + ], + }, + ] + cmds = build_commands(config, self._have_ipv4(), "replaced") + delete_idx = next( + i + for i, c in enumerate(cmds) + if c == ("delete", ["protocols", "static", "route", "192.0.2.0/24"]) + ) + set_idx = next(i for i, c in enumerate(cmds) if c[0] == "set" and "9.9.9.9" in c[1]) + self.assertLess(delete_idx, set_idx) + + def test_overridden_removes_extra_routes(self): + config = [ + { + "afi": "ipv4", + "routes": [ + {"dest": "10.0.0.0/8", "next_hops": [{"forward_router_address": "1.2.3.4"}]}, + ], + }, + ] + cmds = build_commands(config, self._have_ipv4(), "overridden") + self.assertIn( + ("delete", ["protocols", "static", "route", "192.0.2.0/24"]), + cmds, + ) + + +if __name__ == "__main__": + unittest.main() From 935b24602f2fadc297e85274a9c5faaa235e830f Mon Sep 17 00:00:00 2001 From: omnom62 Date: Sun, 14 Jun 2026 21:27:34 +1000 Subject: [PATCH 2/5] T8989 static_routes --- docs/vyos.rest.vyos_static_routes_module.rst | 447 +++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 docs/vyos.rest.vyos_static_routes_module.rst diff --git a/docs/vyos.rest.vyos_static_routes_module.rst b/docs/vyos.rest.vyos_static_routes_module.rst new file mode 100644 index 0000000..a2687b9 --- /dev/null +++ b/docs/vyos.rest.vyos_static_routes_module.rst @@ -0,0 +1,447 @@ +.. _vyos.rest.vyos_static_routes_module: + + +**************************** +vyos.rest.vyos_static_routes +**************************** + +**Manage static routes on VyOS devices via REST API.** + + +Version added: 1.0.0 + +.. contents:: + :local: + :depth: 1 + + +Synopsis +-------- +- Manages IPv4 and IPv6 static routes on VyOS devices using the HTTPS REST API. +- Mirrors ``vyos.vyos.vyos_static_routes`` but uses the HTTP API instead of CLI. + + + + +Parameters +---------- + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterChoices/DefaultsComments
+
+ config + +
+ list + / elements=dictionary +
+
+ +
List of static route configurations grouped by address family.
+
+
+ afi + +
+ string + / required +
+
+
    Choices: +
  • ipv4
  • +
  • ipv6
  • +
+
+
Address family indicator.
+
+
+ routes + +
+ list + / elements=dictionary +
+
+ +
List of static route entries.
+
+
+ blackhole_config + +
+ dictionary +
+
+ +
Blackhole route configuration.
+
+
+ distance + +
+ integer +
+
+ +
Administrative distance (1-255).
+
+
+ type + +
+ string +
+
+ +
Blackhole type.
+
+
+ dest + +
+ string + / required +
+
+ +
Destination prefix in CIDR notation.
+
+
+ next_hops + +
+ list + / elements=dictionary +
+
+ +
List of next-hop addresses.
+
+
+ admin_distance + +
+ integer +
+
+ +
Administrative distance for this next-hop.
+
+
+ enabled + +
+ boolean +
+
+
    Choices: +
  • no
  • +
  • yes ←
  • +
+
+
Whether this next-hop is enabled.
+
+
+ forward_router_address + +
+ string + / required +
+
+ +
Next-hop IP address.
+
+
+ interface + +
+ string +
+
+ +
Outgoing interface name.
+
+
+ state + +
+ string +
+
+
    Choices: +
  • merged ←
  • +
  • replaced
  • +
  • overridden
  • +
  • deleted
  • +
  • gathered
  • +
+
+
merged - Add routes without removing existing ones.
+
replaced - Replace routes for listed destinations.
+
overridden - Replace the entire static route table.
+
deleted - Remove listed or all static routes.
+
gathered - Read static routes from device without changes.
+
+
+ + + +See Also +-------- + +.. seealso:: + + :ref:`vyos.vyos.vyos_static_routes_module` + The official documentation on the **vyos.vyos.vyos_static_routes** module. + + +Examples +-------- + +.. code-block:: yaml + + - name: Merge IPv4 and IPv6 static routes + vyos.rest.vyos_static_routes: + config: + - afi: ipv4 + routes: + - dest: 192.0.2.0/24 + next_hops: + - forward_router_address: 10.0.0.1 + - dest: 203.0.113.0/24 + blackhole_config: + distance: 200 + - afi: ipv6 + routes: + - dest: 2001:db8::/32 + next_hops: + - forward_router_address: 2001:db8::1 + state: merged + + - name: Delete all static routes + vyos.rest.vyos_static_routes: + state: deleted + + - name: Gather current static routes + vyos.rest.vyos_static_routes: + state: gathered + + + +Return Values +------------- +Common return values are documented `here `_, the following are the fields unique to this module: + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyReturnedDescription
+
+ after + +
+ list +
+
when changed +
Static route configuration after this module ran.
+
+
+
+ before + +
+ list +
+
always +
Static route configuration before this module ran.
+
+
+
+ commands + +
+ list +
+
always +
List of API command tuples sent to the device.
+
+
+
+ gathered + +
+ list +
+
when state is gathered +
Current static route configuration as structured data.
+
+
+
+ response + +
+ dictionary +
+
when changes are applied +
Raw API response.
+
+
+
+ saved + +
+ boolean +
+
when changes are applied +
Whether the config was saved after changes.
+
+
+

+ + +Status +------ + + +Authors +~~~~~~~ + +- VyOS Community (@vyos) From b8bdf6e8bbeb774293f3b0d8bb4177c6c4f974e0 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Sun, 14 Jun 2026 21:30:14 +1000 Subject: [PATCH 3/5] T8989 added changelog fragment for static routes module --- changelogs/fragments/t8989_static_routes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelogs/fragments/t8989_static_routes diff --git a/changelogs/fragments/t8989_static_routes b/changelogs/fragments/t8989_static_routes new file mode 100644 index 0000000..7c5a929 --- /dev/null +++ b/changelogs/fragments/t8989_static_routes @@ -0,0 +1,3 @@ +--- +minor_change: + - vyos_statc_routes - Added static_routes module, documenatation and tests.. From 923d847494c5fbf83cac84e34d7f62ae9b584345 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Sun, 14 Jun 2026 21:34:36 +1000 Subject: [PATCH 4/5] t8989 static route changelog --- .../fragments/{t8989_static_routes => t8989_static_routes.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelogs/fragments/{t8989_static_routes => t8989_static_routes.yml} (100%) diff --git a/changelogs/fragments/t8989_static_routes b/changelogs/fragments/t8989_static_routes.yml similarity index 100% rename from changelogs/fragments/t8989_static_routes rename to changelogs/fragments/t8989_static_routes.yml From 994544c67bcd044152e73239024b982df189080e Mon Sep 17 00:00:00 2001 From: omnom62 Date: Sun, 14 Jun 2026 21:36:31 +1000 Subject: [PATCH 5/5] t8989 static_routes module --- changelogs/fragments/t8989_static_routes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelogs/fragments/t8989_static_routes.yml b/changelogs/fragments/t8989_static_routes.yml index 7c5a929..ef877d8 100644 --- a/changelogs/fragments/t8989_static_routes.yml +++ b/changelogs/fragments/t8989_static_routes.yml @@ -1,3 +1,3 @@ --- -minor_change: +minor_changes: - vyos_statc_routes - Added static_routes module, documenatation and tests..