feat(dhcp): add generic subnet diff/apply to DhcpServerMixin

Part of netork#85. Reservations were the only DHCP desired state the mixin
knew about; this adds the layer above them — the ranges a device serves and
the options it publishes with them.

The identity is the CIDR, matched rather than compared, the way `mac` is for
a reservation. normalize_cidr deliberately does not rewrite the network
address: turning 10.10.20.5/24 into 10.10.20.0/24 would make a typo silently
match a real subnet and then apply that caller's pools and options to it.

option_data is compared per option, and only over the options the caller
named. An absent key means "not managed", not "should be empty" — without
that rule a caller managing only domain_search would diff against every
option the server autocollects (routers, domain_name_servers, ntp_servers)
and reconfigure the DHCP daemon on every single run.

Neither diff deletes. For subnets that is not merely conservative: removing
one takes DHCP down for a whole VLAN, and the diff cannot tell "no longer
wanted" from "was never this caller's to describe".

commit_dhcp_subnets is separate from commit_dhcp_reservations even where a
driver implements both with the same call — the two desired-state sets are
applied independently, and a caller that changed only subnets should not
have to know which reload the vendor happens to share.

19 new tests against an in-memory fake; no vendor driver needed.
This commit is contained in:
2026-08-20 07:21:40 +07:00
parent b8977cdaa5
commit b53cf4d1f4
4 changed files with 479 additions and 9 deletions
+2 -1
View File
@@ -36,7 +36,7 @@ Also provided:
from napalm_device_types.base import DeviceTypeDriver, FingerprintRule, PortSpec
from napalm_device_types.access_point import AccessPointDriver
from napalm_device_types.config_lifecycle import ConfigLifecycleMixin
from napalm_device_types.dhcp import DhcpServerMixin, normalize_mac
from napalm_device_types.dhcp import DhcpServerMixin, normalize_cidr, normalize_mac
from napalm_device_types.firewall import FirewallDriver
from napalm_device_types.hypervisor import HypervisorDriver
from napalm_device_types.os import OSDriver
@@ -60,5 +60,6 @@ __all__ = [
"StorageDriver",
"SwitchDriver",
"driver_supports_ping",
"normalize_cidr",
"normalize_mac",
]
+195 -8
View File
@@ -1,16 +1,24 @@
# -*- coding: utf-8 -*-
"""Static DHCP reservation management, shared by firewalls and gateways.
"""DHCP desired-state management, shared by firewalls and gateways.
Covers two independent desired-state sets:
* **reservations** -- static MAC -> IP bindings, matched on the MAC;
* **subnets** -- the served ranges and their per-subnet DHCP options,
matched on the CIDR.
Both :class:`~napalm_device_types.firewall.FirewallDriver` and
:class:`~napalm_device_types.residential_gateway.ResidentialGatewayDriver`
mix this in, because both device types commonly run the DHCP server for
their networks.
Only ``get_dhcp_reservations``/``apply_dhcp_reservation``/
``commit_dhcp_reservations`` are device-specific and must be implemented by
a concrete driver; the diff and the apply loop are vendor-neutral algorithms
and live here -- see README.md "Design principle: generic vs. device-specific
logic".
Only the six get/apply/commit methods are device-specific and must be
implemented by a concrete driver; the diffs and the apply loops are
vendor-neutral algorithms and live here -- see README.md "Design principle:
generic vs. device-specific logic".
Neither diff ever deletes. For subnets that is not just caution: removing one
takes DHCP down for an entire VLAN.
"""
from __future__ import annotations
@@ -22,6 +30,9 @@ from napalm_device_types.models import (
DhcpReservationDiffDict,
DhcpReservationDict,
DhcpReservationUpdateDict,
DhcpSubnetDiffDict,
DhcpSubnetDict,
DhcpSubnetUpdateDict,
)
# `mac` is the identity, so it is matched rather than compared. `uuid` is
@@ -33,6 +44,15 @@ _DHCP_RESERVATION_COMPARE_FIELDS = (
"subnet",
)
# `subnet` (the CIDR) is the identity, so it is matched rather than compared.
# `uuid` is assigned by the device and never part of the desired state.
_DHCP_SUBNET_COMPARE_FIELDS = (
"description",
"pools",
"option_data",
"match_client_id",
)
_HEX_ONLY = re.compile(r"[^0-9a-f]")
@@ -58,11 +78,43 @@ def normalize_mac(mac: Optional[str]) -> str:
return ":".join(hex_digits[i : i + 2] for i in range(0, 12, 2))
def normalize_cidr(cidr: Optional[str]) -> str:
"""Reduces a subnet CIDR to a canonical string for matching.
Only whitespace and case are normalised -- deliberately not the network
address itself. Rewriting ``10.10.20.5/24`` to ``10.10.20.0/24`` would
make a caller's typo silently match a real subnet and then apply that
caller's pools and options to it.
"""
return (cidr or "").strip().lower()
def _subnet_field_differs(
field: str, live: DhcpSubnetDict, desired: DhcpSubnetDict
) -> bool:
"""Compares one subnet field, with `option_data` handled specially.
For `option_data` only the options `desired` actually names are compared;
see ``diff_dhcp_subnets`` for why an unmentioned option must not count as
a difference.
"""
if field != "option_data":
return live.get(field) != desired.get(field)
desired_options = desired.get("option_data") or {}
live_options = live.get("option_data") or {}
return any(
live_options.get(option) != value for option, value in desired_options.items()
)
class DhcpServerMixin:
"""Mixin providing static DHCP reservation read/diff/apply.
"""Mixin providing DHCP reservation and subnet read/diff/apply.
Concrete drivers **must** provide ``get_dhcp_reservations()``,
``apply_dhcp_reservation()`` and ``commit_dhcp_reservations()``.
``apply_dhcp_reservation()`` and ``commit_dhcp_reservations()``; drivers
that also manage subnets provide ``get_dhcp_subnets()``,
``apply_dhcp_subnet()`` and ``commit_dhcp_subnets()``.
"""
# ------------------------------------------------------------------
@@ -101,6 +153,53 @@ class DhcpServerMixin:
"""
raise NotImplementedError
def get_dhcp_subnets(self) -> List[DhcpSubnetDict]:
"""
Returns every DHCPv4 subnet the device serves, with its pools and
per-subnet options.
:raises NotImplementedError: If the driver does not support reading
DHCP subnets.
"""
raise NotImplementedError
def apply_dhcp_subnet(
self, subnet: DhcpSubnetDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
"""
Creates or updates a single DHCPv4 subnet on the device.
Implementations must treat ``subnet["option_data"]`` as a partial
update: an option the caller did not name is left as the device has
it. Managing `domain_search` alone is the common case, and it must
not silently drop the `routers` the server autocollected.
:param subnet: The desired subnet state, vendor-neutral.
:param uuid: If given, update the existing subnet with this ID
in-place. If ``None``, create a new one.
:raises NotImplementedError: If the driver does not support writing
DHCP subnets.
:raises RuntimeError: If the device rejects the write.
:returns: A dict with at least ``{"success": bool}``.
"""
raise NotImplementedError
def commit_dhcp_subnets(self) -> Dict[str, Any]:
"""
Applies pending subnet changes (e.g. Kea's ``service/reconfigure``).
Separate from ``commit_dhcp_reservations`` even where a driver
implements both with the same call: the two desired-state sets are
applied independently, and a caller that changed only subnets should
not have to know which reload the vendor happens to share.
:raises NotImplementedError: If the driver does not support this.
:returns: A dict with at least ``{"success": bool}``.
"""
raise NotImplementedError
def commit_dhcp_reservations(self) -> Dict[str, Any]:
"""
Applies pending reservation changes (e.g. Kea's ``service/reconfigure``,
@@ -209,3 +308,91 @@ class DhcpServerMixin:
f"[commit] applied {len(diff['add'])} add(s), "
f"{len(diff['update'])} update(s)"
)
def diff_dhcp_subnets(self, desired: List[DhcpSubnetDict]) -> DhcpSubnetDiffDict:
"""
Compares `desired` against the device's current subnets and returns
what would need to change to reach that state.
Matches on the CIDR. A desired subnet with no live counterpart becomes
an "add"; a live one whose CIDR matches but whose other fields differ
becomes an "update".
`option_data` is compared **per option**, and only over the options
the caller named. An option the device carries but `desired` does not
mention is left out of the comparison entirely, because absent means
"not managed" rather than "should be empty". Without that rule a
caller managing only `domain_search` would diff against every option
Kea autocollects (`routers`, `domain_name_servers`, `ntp_servers`) and
reconfigure the DHCP daemon on every single run.
Live subnets with no matching desired entry are **not** reported for
deletion, and more emphatically than for reservations: removing a
subnet takes DHCP down for a whole VLAN, and this method cannot tell
"no longer wanted" from "was never netOrk's to describe".
:param desired: The complete desired subnet set.
:returns: ``{"add": [...], "update": [{"uuid", "subnet",
"changed_fields"}, ...]}``.
"""
live_by_cidr: Dict[str, DhcpSubnetDict] = {
normalize_cidr(live.get("subnet")): live for live in self.get_dhcp_subnets()
}
add: List[DhcpSubnetDict] = []
update: List[DhcpSubnetUpdateDict] = []
for desired_subnet in desired:
live = live_by_cidr.get(normalize_cidr(desired_subnet.get("subnet")))
if live is None:
add.append(desired_subnet)
continue
changed_fields = [
field
for field in _DHCP_SUBNET_COMPARE_FIELDS
if _subnet_field_differs(field, live, desired_subnet)
]
if changed_fields:
update.append(
{
"uuid": live["uuid"],
"subnet": desired_subnet,
"changed_fields": changed_fields,
}
)
return {"add": add, "update": update}
def apply_dhcp_subnetset(self, desired: List[DhcpSubnetDict]) -> Iterator[str]:
"""
Computes the diff against `desired` and applies it, yielding one
human-readable progress line per change, then commits.
As with ``apply_dhcp_reservationset``, an empty diff does **not**
commit: reconfiguring the DHCP daemon is too expensive for a no-op.
:param desired: The complete desired subnet set.
:yields: Progress lines, one per applied add/update, plus a final
commit line.
"""
diff = self.diff_dhcp_subnets(desired)
for subnet in diff["add"]:
self.apply_dhcp_subnet(subnet)
yield f"[add] {subnet['subnet']}"
for entry in diff["update"]:
self.apply_dhcp_subnet(entry["subnet"], uuid=entry["uuid"])
fields = ", ".join(entry["changed_fields"])
yield f"[update] {entry['subnet']['subnet']} ({fields})"
if not diff["add"] and not diff["update"]:
yield "[commit] no changes"
return
self.commit_dhcp_subnets()
yield (
f"[commit] applied {len(diff['add'])} add(s), "
f"{len(diff['update'])} update(s)"
)
+54
View File
@@ -387,6 +387,60 @@ class DhcpReservationDiffDict(TypedDict):
update: List[DhcpReservationUpdateDict]
class DhcpOptionDataDict(TypedDict, total=False):
"""DHCPv4 options carried by a subnet, by their RFC/Kea names.
Only options netOrk actually models are listed. `domain_search` (option
119) is the reason this type exists: it is the one option that cannot be
expressed anywhere else in the stack, and no DHCP server autocollects it.
Every field is optional and an absent key means "do not manage this
option" -- distinct from an empty list, which means "manage it, and the
desired value is empty". A driver must preserve options it was not given.
"""
routers: List[str]
domain_name_servers: List[str]
domain_name: str
domain_search: List[str]
ntp_servers: List[str]
class DhcpSubnetDict(TypedDict):
"""A DHCPv4 subnet served by the device, in vendor-neutral form.
`subnet` (the CIDR) is the stable matching key, the way `mac` is for a
reservation. Renaming is not a thing a subnet does; changing its CIDR
makes it a different subnet.
`pools` are address ranges in ``"start-end"`` form, the shape both Kea
and ISC DHCP use.
`option_data` carries the per-subnet DHCP options. Servers that
autocollect some of them (Kea fills `routers`, `domain_name_servers` and
`ntp_servers` when ``option_data_autocollect`` is on) still never
autocollect `domain_name` or `domain_search`.
"""
uuid: str
subnet: str
description: str
pools: List[str]
option_data: DhcpOptionDataDict
match_client_id: bool
class DhcpSubnetUpdateDict(TypedDict):
uuid: str
subnet: DhcpSubnetDict
changed_fields: List[str]
class DhcpSubnetDiffDict(TypedDict):
add: List[DhcpSubnetDict]
update: List[DhcpSubnetUpdateDict]
class VPNTunnelDict(TypedDict):
type: str
local_endpoint: str