feat: CARP addresses as virtual IPs, with their holder in the reading (4.10.0)
CI / test (3.10) (push) Successful in 27s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 27s
CI / test (3.13) (push) Successful in 25s
CI / test (3.10) (pull_request) Successful in 26s
CI / test (3.11) (pull_request) Successful in 24s
CI / test (3.12) (pull_request) Successful in 26s
CI / test (3.13) (pull_request) Successful in 26s

A firewall pair's CARP addresses are a third virtual IP mechanism. Unlike
vip-manager and keepalived, a CARP reading says who holds the address: a
firewall's interface list need not show it, but CARP's state names the MASTER.

- VirtualIpsDict gains carp (NotRequired), VirtualIpDict gains holds
  (NotRequired; None leaves the holder to the interfaces).
- carp_virtual_ip() shapes one address bound to a VHID: MASTER holds, BACKUP
  stands by, INIT and DISABLED neither run nor hold, an unknown state says
  nothing. The same for OPNsense and pfSense; a driver supplies the fields.

netOrk #1121.
This commit is contained in:
christianmanivong committed 2026-10-16 11:06:43 +02:00
1 parent fde6d717a8
commit 4f3b88db0e
6 files changed
+195 -9

No files matched your search

+8
View File
@@ -128,6 +128,14 @@ A mechanism it could not read completely is `None` ("did not look"), a host with
`[]`. The command is about 4 kB, so a driver sends it on an exec channel
(`_run_virtual_ips_command(command, privileged=)`), not typed into a shell.
A firewall's CARP addresses are a third mechanism, `carp`, which a firewall driver reads
from its own API and reports under the reading's `carp` key (`vip_manager` and
`keepalived` then None). `carp_virtual_ip(...)` turns one address bound to a VHID into the
entry -- the same for OPNsense and pfSense. Unlike the other two, a CARP entry says who
holds the address (`holds`): every member carries the address in its configuration, and a
firewall's interface list need not show it, but CARP's state names the MASTER. A state it
does not know leaves `holds` None, and the caller falls back on the interfaces.
`SystemIdentityMixin` (`get_system_identity`) reads a host's SMBIOS identity -- system UUID,
product serial and board serial from `/sys/class/dmi/id` -- which is what ties a server to
its BMC (`ManagementControllerDriver.get_hardware_inventory` reports the same values from
+2
View File
@@ -134,6 +134,7 @@ from napalm_device_types.system_identity import (
from napalm_device_types.virtual_ips import (
VIRTUAL_IPS_COMMAND,
VirtualIpsMixin,
carp_virtual_ip,
parse_virtual_ips,
)
from napalm_device_types.residential_gateway import ResidentialGatewayDriver
@@ -221,6 +222,7 @@ __all__ = [
"SshHardeningResult",
"plan_ssh_hardening",
"parse_virtual_ips",
"carp_virtual_ip",
"add_lag_interfaces",
"driver_supports_ping",
"normalize_cidr",
+21 -8
View File
@@ -404,29 +404,42 @@ class VirtualIpDict(TypedDict):
"""An address the host's HA configuration lets float between machines.
Declared, not observed: whether the host holds the address right now is in
its interface addresses, not here. Only these fields leave the host -- never
a password, an etcd endpoint or a notify script.
its interface addresses, not here -- unless the mechanism says so itself.
CARP does (``holds``): a firewall's interface list need not show the
address at all, but CARP's state names the MASTER. Only these fields leave
the host -- never a password, an etcd endpoint or a notify script.
"""
mechanism: str # "vip-manager" | "keepalived"
instance: str # the vrrp_instance name, or the vip-manager unit without ".service"
mechanism: str # "vip-manager" | "keepalived" | "carp"
#: the vrrp_instance name, the vip-manager unit without ".service", or the
#: CARP address's interface description
instance: str
address: str # normalised: "10.7.224.10", "fd00::10"
prefix_length: Optional[int] # None where the configuration gives none
interface: Optional[str]
group_key: Optional[str] # vip-manager's trigger-key, or "vrid:<n>" for keepalived
running: Optional[bool] # the unit is active
#: vip-manager's trigger-key, "vrid:<n>" for keepalived, "vhid:<n>" for CARP
group_key: Optional[str]
running: Optional[bool] # the unit is active; for CARP: in MASTER or BACKUP
#: The host holds the address now, by the mechanism's own state (CARP's
#: MASTER). Absent or None: the interface addresses tell.
holds: NotRequired[Optional[bool]]
class VirtualIpsDict(TypedDict):
"""What ``VirtualIpsMixin.get_virtual_ips`` read, per mechanism.
"""What a driver's ``get_virtual_ips`` read, per mechanism.
A list is what was looked at -- ``[]`` a host without that mechanism. None is
"did not look": a configuration it could not read, an include it could not
follow, a host without systemd. A consumer keeps what it knew for a None.
follow, a host without systemd, a mechanism the driver does not read (a
firewall's reading has ``vip_manager`` and ``keepalived`` None). A consumer
keeps what it knew for a None, and for a missing ``carp``.
"""
vip_manager: Optional[List[VirtualIpDict]]
keepalived: Optional[List[VirtualIpDict]]
#: A firewall's CARP addresses (not read by ``VirtualIpsMixin``, which reads Linux
#: hosts): every address bound to a VHID, the CARP address and IP aliases on it.
carp: NotRequired[Optional[List[VirtualIpDict]]]
class PortForwardDict(TypedDict):
+52
View File
@@ -42,6 +42,11 @@ caller keeps what it knew. ``[]`` is a host that has none.
script goes to the host as one ``sh -c`` argument; when the privileged call
brings no report back, it runs again without privilege and reads what it can.
**CARP.** A firewall's CARP addresses come from its own API, not from this
command, so :func:`carp_virtual_ip` only shapes one entry: what is the same for
every CARP firewall is the meaning of the state. CARP's MASTER holds the address
(``holds``), which the interface list of a firewall need not show.
**An exec channel, not a terminal.** With its awk program the command is about
4 kB, past the line an interactive shell reliably takes. A driver sends it the
way it runs any command that needs no PTY -- napalm-linux's ``run_command``.
@@ -332,6 +337,53 @@ _READERS: Dict[str, Tuple[str, Callable[[str, List[_Record]], List[VirtualIpDict
}
#: CARP's state of a VHID on one member -> (running, holds).
_CARP_STATES: Dict[str, Tuple[bool, bool]] = {
"MASTER": (True, True),
"BACKUP": (True, False),
"INIT": (False, False),
"DISABLED": (False, False),
}
def carp_virtual_ip(
*,
address: str,
prefix_length: Optional[int],
vhid: object,
state: Optional[str],
instance: str,
interface: Optional[str],
) -> Optional[VirtualIpDict]:
"""One address bound to a CARP VHID, as a virtual IP; None for what is no address.
Who holds it comes from CARP's *state* on this member, not from its
interfaces: MASTER holds it, BACKUP stands by, INIT and DISABLED neither run
nor hold. A state this does not know says nothing (``running`` and ``holds``
None), and the caller falls back on the interfaces. *address* may carry its
prefix (``"10.0.0.1/24"``), which then wins over *prefix_length*.
"""
parsed = _address(address.strip()) if address else None
if parsed is None:
return None
normalised, written = parsed
prefix = written if written is not None else prefix_length
if prefix is not None and not 0 <= prefix <= ipaddress.ip_address(normalised).max_prefixlen:
prefix = None
vhid_text = str(vhid).strip() if vhid is not None else ""
running, holds = _CARP_STATES.get((state or "").strip().upper(), (None, None))
return {
"mechanism": "carp",
"instance": instance or f"vhid {vhid_text}",
"address": normalised,
"prefix_length": prefix,
"interface": interface,
"group_key": f"vhid:{vhid_text}" if vhid_text.isdigit() else None,
"running": running,
"holds": holds,
}
def _order(entry: VirtualIpDict) -> Tuple[int, int, str]:
address = ipaddress.ip_address(entry["address"])
return address.version, int(address), entry["instance"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "napalm-device-types"
version = "4.9.0"
version = "4.10.0"
description = "Abstract device-type base classes for NAPALM drivers"
readme = "README.md"
requires-python = ">=3.10"
+111
View File
@@ -0,0 +1,111 @@
"""carp_virtual_ip: one CARP address as a virtual IP, with its holder in the reading.
CARP differs from vip-manager and keepalived in what the caller can see. The
address is configured on every member, and the interface list of a firewall need
not show it at all -- so the interfaces cannot say who holds it. CARP's own state
can: the member in MASTER holds it, a BACKUP stands by. Turning that state into a
virtual IP is the same for every CARP firewall (OPNsense, pfSense), so it lives
here; a driver supplies the fields from its API (netOrk #1121).
"""
from __future__ import annotations
import pytest
from napalm_device_types.virtual_ips import carp_virtual_ip
def _entry(**overrides):
fields = {
"address": "172.22.8.1",
"prefix_length": 24,
"vhid": "108",
"state": "MASTER",
"instance": "MGMT",
"interface": "vtnet0",
}
fields.update(overrides)
return carp_virtual_ip(**fields)
def test_a_master_holds_the_address():
assert _entry() == {
"mechanism": "carp",
"instance": "MGMT",
"address": "172.22.8.1",
"prefix_length": 24,
"interface": "vtnet0",
"group_key": "vhid:108",
"running": True,
"holds": True,
}
def test_a_backup_stands_by():
entry = _entry(state="BACKUP")
assert entry is not None
assert (entry["running"], entry["holds"]) == (True, False)
@pytest.mark.parametrize("state", ["INIT", "DISABLED"])
def test_a_member_whose_carp_is_not_up_neither_runs_nor_holds(state):
entry = _entry(state=state)
assert entry is not None
assert (entry["running"], entry["holds"]) == (False, False)
@pytest.mark.parametrize("state", [None, "", "SOMETHING-NEW"])
def test_an_unknown_state_says_nothing_about_the_holder(state):
"""None leaves the holder to the interfaces, as for the other mechanisms."""
entry = _entry(state=state)
assert entry is not None
assert (entry["running"], entry["holds"]) == (None, None)
def test_the_state_is_read_case_blind():
entry = _entry(state=" master ")
assert entry is not None
assert entry["holds"] is True
def test_the_address_is_normalised():
entry = _entry(address="FD00::0001", prefix_length=64)
assert entry is not None
assert entry["address"] == "fd00::1"
def test_an_address_written_with_its_prefix_takes_that_prefix():
entry = _entry(address="10.42.100.1/24", prefix_length=None)
assert entry is not None
assert (entry["address"], entry["prefix_length"]) == ("10.42.100.1", 24)
@pytest.mark.parametrize("address", ["", "not-an-address", "10.0.0.300"])
def test_what_is_no_address_is_no_virtual_ip(address):
assert _entry(address=address) is None
@pytest.mark.parametrize("prefix", [33, -1])
def test_an_impossible_prefix_is_dropped(prefix):
entry = _entry(prefix_length=prefix)
assert entry is not None
assert entry["prefix_length"] is None
@pytest.mark.parametrize("vhid", [None, "", "abc"])
def test_without_a_numeric_vhid_there_is_no_group(vhid):
entry = _entry(vhid=vhid)
assert entry is not None
assert entry["group_key"] is None
def test_a_vhid_given_as_a_number_groups_the_same_way():
entry = _entry(vhid=108)
assert entry is not None
assert entry["group_key"] == "vhid:108"
def test_an_empty_instance_falls_back_to_the_vhid():
entry = _entry(instance="")
assert entry is not None
assert entry["instance"] == "vhid 108"