CI / test (3.10) (push) Successful in 30s
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 30s
CI / test (3.10) (pull_request) Successful in 29s
CI / test (3.11) (pull_request) Successful in 28s
CI / test (3.12) (pull_request) Successful in 30s
get_virtual_ips() reads every address bound to a CARP VHID -- the CARP address and IP aliases on its VHID alike -- from /api/interfaces/overview/export, with this node's CARP state, so the reading says who holds each address. interfaces/vip_settings would list the same addresses with each VHID's CARP password; it is not read. vip_manager and keepalived are Linux mechanisms and are None here. Needs napalm-device-types 4.10.0 (carp_virtual_ip). netOrk #1121.
131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
"""The firewall's CARP addresses, with who holds each (netOrk #1121).
|
|
|
|
``/api/interfaces/overview/export`` -- the source ``get_interfaces_ip`` reads --
|
|
lists every address on an interface, and an address bound to a CARP VHID carries
|
|
the VHID and this node's CARP state. That covers both the CARP address itself and
|
|
an IP alias bound to its VHID: both float with the VHID's MASTER. An address
|
|
without a VHID stays on this node and is no virtual IP.
|
|
|
|
The overview has no CARP password in it; ``interfaces/vip_settings`` would, which
|
|
is why it is not read. The fixture is the shape of a real OPNsense 26.7 answer,
|
|
cut down, with documentation addresses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from napalm_opnsense.opnsense import OPNsenseDriver
|
|
from napalm_opnsense.virtual_ips import carp_addresses
|
|
|
|
OVERVIEW = json.loads(
|
|
(Path(__file__).parent / "fixtures" / "interfaces_overview_carp.json").read_text()
|
|
)
|
|
|
|
|
|
def _driver(overview=OVERVIEW) -> OPNsenseDriver:
|
|
with patch("napalm_opnsense.opnsense.requests.Session"):
|
|
drv = OPNsenseDriver(hostname="fw", username="k", password="s")
|
|
drv.session = MagicMock()
|
|
|
|
def get(path: str):
|
|
if path == "/api/interfaces/overview/export":
|
|
if isinstance(overview, Exception):
|
|
raise overview
|
|
return overview
|
|
raise AssertionError(f"unexpected GET {path}")
|
|
|
|
drv._get = get # type: ignore[method-assign]
|
|
return drv
|
|
|
|
|
|
def _by_address(entries) -> dict:
|
|
return {e["address"]: e for e in entries}
|
|
|
|
|
|
class TestCarpAddresses:
|
|
def test_every_address_bound_to_a_vhid_is_a_virtual_ip(self):
|
|
assert [e["address"] for e in carp_addresses(OVERVIEW)] == [
|
|
"192.0.2.1",
|
|
"198.51.100.1",
|
|
"198.51.100.140",
|
|
"203.0.113.1",
|
|
"2001:db8:8::1",
|
|
]
|
|
|
|
def test_an_address_without_a_vhid_stays_on_this_node(self):
|
|
addresses = {e["address"] for e in carp_addresses(OVERVIEW)}
|
|
assert not addresses & {"192.0.2.2", "192.0.2.140", "127.0.0.1", "fe80::1"}
|
|
|
|
def test_the_carp_address_carries_its_interface_and_state(self):
|
|
assert _by_address(carp_addresses(OVERVIEW))["192.0.2.1"] == {
|
|
"mechanism": "carp",
|
|
"instance": "MGMT",
|
|
"address": "192.0.2.1",
|
|
"prefix_length": 24,
|
|
"interface": "em0",
|
|
"group_key": "vhid:108",
|
|
"running": True,
|
|
"holds": True,
|
|
}
|
|
|
|
def test_an_alias_on_a_vhid_follows_that_vhid(self):
|
|
alias = _by_address(carp_addresses(OVERVIEW))["198.51.100.140"]
|
|
assert (alias["group_key"], alias["prefix_length"], alias["holds"]) == (
|
|
"vhid:140",
|
|
32,
|
|
True,
|
|
)
|
|
|
|
def test_a_vhid_not_up_neither_runs_nor_holds(self):
|
|
entry = _by_address(carp_addresses(OVERVIEW))["203.0.113.1"]
|
|
assert (entry["running"], entry["holds"]) == (False, False)
|
|
|
|
def test_an_interface_without_a_description_is_named_by_its_identifier(self):
|
|
assert _by_address(carp_addresses(OVERVIEW))["203.0.113.1"]["instance"] == "opt9"
|
|
|
|
def test_on_a_backup_node_nothing_is_held(self):
|
|
backup = json.loads(json.dumps(OVERVIEW).replace('"MASTER"', '"BACKUP"'))
|
|
entries = carp_addresses(backup)
|
|
assert entries and all(e["holds"] is False for e in entries)
|
|
|
|
def test_the_answer_wrapped_in_an_object_reads_the_same(self):
|
|
assert carp_addresses({"interfaces": OVERVIEW}) == carp_addresses(OVERVIEW)
|
|
|
|
@pytest.mark.parametrize("answer", [[], {}, {"interfaces": None}])
|
|
def test_a_firewall_without_interfaces_has_none(self, answer):
|
|
assert carp_addresses(answer) == []
|
|
|
|
@pytest.mark.parametrize("answer", ["nonsense", None, 42])
|
|
def test_an_answer_of_another_shape_is_refused(self, answer):
|
|
with pytest.raises(ValueError):
|
|
carp_addresses(answer)
|
|
|
|
def test_odd_entries_are_skipped(self):
|
|
overview = [
|
|
{"identifier": "opt1", "ipv4": ["192.0.2.9/24", {"vhid": "1"}, None]},
|
|
"not-an-interface",
|
|
{"identifier": "opt2", "ipv4": [{"ipaddr": "bogus", "vhid": "2"}]},
|
|
]
|
|
assert carp_addresses(overview) == []
|
|
|
|
|
|
class TestGetVirtualIps:
|
|
def test_a_firewall_reports_carp_and_does_not_look_for_the_linux_mechanisms(self):
|
|
reading = _driver().get_virtual_ips()
|
|
assert reading["vip_manager"] is None
|
|
assert reading["keepalived"] is None
|
|
assert len(reading["carp"]) == 5
|
|
|
|
def test_a_firewall_without_carp_reports_an_empty_list(self):
|
|
reading = _driver(overview=[OVERVIEW[3], OVERVIEW[4]]).get_virtual_ips()
|
|
assert reading["carp"] == []
|
|
|
|
def test_a_failed_request_is_raised_not_reported_as_none_declared(self):
|
|
with pytest.raises(RuntimeError):
|
|
_driver(overview=RuntimeError("API down")).get_virtual_ips()
|