feat(dhcp): implement subnet get/apply/commit against Kea DHCPv4
Part of netork#85. Fills in the three device-specific methods the new DhcpServerMixin subnet layer expects. searchSubnet only carries uuid/subnet/description, so get_dhcp_subnets follows each row with getSubnet for the option data. That is one request per subnet; a firewall serves a handful, so the round trips cost less than the reconfigure they help avoid. An option Kea does not carry is omitted rather than reported as empty, because the generic diff reads an absent key as "not managed" — reporting [] would make every unmanaged option look like a pending change. apply_dhcp_subnet honours the mixin's partial-update contract: on an update it reads the subnet's current options first and replaces only the named ones. Without that, managing domain_search alone would blank the routers Kea autocollected and strand every client on that VLAN without a gateway. Setting any option also forces option_data_autocollect off — left on, Kea keeps re-filling routers/DNS/NTP and the next diff sees a change again, which is a reconfigure loop rather than a converged state. OPNsense renders repeatable option fields as comma-separated strings in some versions and as a selection map in others, for the same logical field. Both shapes are accepted rather than pinning the driver to one release. Pools are a newline-separated text block. A subnet whose detail fetch fails is skipped with a log line instead of aborting, same rule as get_dhcp_reservations: one broken record must not make the whole inventory unreadable. 16 new tests. Not yet verified against a live device — no reachable OPNsense at the time of writing, same caveat the reservation support shipped with.
This commit is contained in:
@@ -2127,3 +2127,237 @@ class TestCommitDhcpReservations:
|
||||
|
||||
assert calls == ["/api/kea/service/reconfigure"]
|
||||
assert result == {"success": True}
|
||||
|
||||
|
||||
# ── Kea DHCPv4 subnets ────────────────────────────────────────────────────────
|
||||
|
||||
KEA_SUBNET_SEARCH_RESPONSE = {
|
||||
"rows": [
|
||||
{"uuid": "sub-home", "subnet": "10.10.20.0/24", "description": "Home"},
|
||||
{"uuid": "sub-guest", "subnet": "10.10.40.0/24", "description": "Guest"},
|
||||
]
|
||||
}
|
||||
|
||||
# getSubnet wraps the record and, unlike searchSubnet, carries the options.
|
||||
# OPNsense renders AsList fields as comma-separated strings.
|
||||
KEA_SUBNET_DETAIL_RESPONSE = {
|
||||
"subnet": {
|
||||
"subnet": "10.10.20.0/24",
|
||||
"description": "Home",
|
||||
"pools": "10.10.20.100-10.10.20.200",
|
||||
"match-client-id": "1",
|
||||
"option_data": {
|
||||
"routers": "10.10.20.1",
|
||||
"domain_name_servers": "10.10.20.1,10.10.20.2",
|
||||
"domain_name": "home.local",
|
||||
"domain_search": "home.local,office.local",
|
||||
"ntp_servers": "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestGetDhcpSubnets:
|
||||
def _wire(self, driver, detail=None):
|
||||
driver._get = lambda path: (
|
||||
KEA_SUBNET_SEARCH_RESPONSE
|
||||
if "searchSubnet" in path
|
||||
else (detail or KEA_SUBNET_DETAIL_RESPONSE)
|
||||
)
|
||||
|
||||
def test_maps_kea_subnet_to_vendor_neutral_dict(self, driver):
|
||||
self._wire(driver)
|
||||
|
||||
result = driver.get_dhcp_subnets()
|
||||
|
||||
assert result[0] == {
|
||||
"uuid": "sub-home",
|
||||
"subnet": "10.10.20.0/24",
|
||||
"description": "Home",
|
||||
"pools": ["10.10.20.100-10.10.20.200"],
|
||||
"option_data": {
|
||||
"routers": ["10.10.20.1"],
|
||||
"domain_name_servers": ["10.10.20.1", "10.10.20.2"],
|
||||
"domain_name": "home.local",
|
||||
"domain_search": ["home.local", "office.local"],
|
||||
},
|
||||
"match_client_id": True,
|
||||
}
|
||||
|
||||
def test_fetches_detail_per_subnet(self, driver):
|
||||
paths = []
|
||||
driver._get = lambda path: (
|
||||
paths.append(path)
|
||||
or (KEA_SUBNET_SEARCH_RESPONSE if "searchSubnet" in path else KEA_SUBNET_DETAIL_RESPONSE)
|
||||
)
|
||||
|
||||
driver.get_dhcp_subnets()
|
||||
|
||||
assert "/api/kea/dhcpv4/getSubnet/sub-home" in paths
|
||||
assert "/api/kea/dhcpv4/getSubnet/sub-guest" in paths
|
||||
|
||||
def test_empty_option_is_omitted_not_reported_as_empty(self, driver):
|
||||
"""An option Kea does not carry must read as "unset", not as []."""
|
||||
self._wire(driver)
|
||||
result = driver.get_dhcp_subnets()
|
||||
assert "ntp_servers" not in result[0]["option_data"]
|
||||
|
||||
def test_multiline_pools_become_a_list(self, driver):
|
||||
detail = {
|
||||
"subnet": {
|
||||
"subnet": "10.10.20.0/24",
|
||||
"description": "",
|
||||
"pools": "10.10.20.100-10.10.20.150\n10.10.20.180-10.10.20.200",
|
||||
"option_data": {},
|
||||
}
|
||||
}
|
||||
self._wire(driver, detail)
|
||||
assert driver.get_dhcp_subnets()[0]["pools"] == [
|
||||
"10.10.20.100-10.10.20.150",
|
||||
"10.10.20.180-10.10.20.200",
|
||||
]
|
||||
|
||||
def test_selection_map_shape_is_accepted(self, driver):
|
||||
"""Some OPNsense versions render list fields as a selection map."""
|
||||
detail = {
|
||||
"subnet": {
|
||||
"subnet": "10.10.20.0/24",
|
||||
"description": "",
|
||||
"pools": "",
|
||||
"option_data": {
|
||||
"domain_search": {
|
||||
"home.local": {"value": "home.local", "selected": 1},
|
||||
"office.local": {"value": "office.local", "selected": 0},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
self._wire(driver, detail)
|
||||
assert driver.get_dhcp_subnets()[0]["option_data"]["domain_search"] == ["home.local"]
|
||||
|
||||
def test_a_subnet_whose_detail_fails_is_skipped_not_fatal(self, driver):
|
||||
def _get(path):
|
||||
if "searchSubnet" in path:
|
||||
return KEA_SUBNET_SEARCH_RESPONSE
|
||||
if "sub-home" in path:
|
||||
raise RuntimeError("boom")
|
||||
return KEA_SUBNET_DETAIL_RESPONSE
|
||||
|
||||
driver._get = _get
|
||||
result = driver.get_dhcp_subnets()
|
||||
assert [s["uuid"] for s in result] == ["sub-guest"]
|
||||
|
||||
def test_missing_plugin_raises(self, driver):
|
||||
def _get(path):
|
||||
raise RuntimeError("404")
|
||||
|
||||
driver._get = _get
|
||||
with pytest.raises(RuntimeError, match="Kea DHCPv4 plugin unavailable"):
|
||||
driver.get_dhcp_subnets()
|
||||
|
||||
|
||||
class TestApplyDhcpSubnet:
|
||||
def test_update_uses_set_endpoint_with_uuid(self, driver):
|
||||
calls = []
|
||||
driver._get = lambda path: KEA_SUBNET_DETAIL_RESPONSE
|
||||
driver._post = lambda path, data=None: calls.append((path, data)) or {"result": "saved"}
|
||||
|
||||
driver.apply_dhcp_subnet(
|
||||
{"subnet": "10.10.20.0/24", "option_data": {"domain_search": ["a.local"]}},
|
||||
uuid="sub-home",
|
||||
)
|
||||
|
||||
assert calls[0][0] == "/api/kea/dhcpv4/setSubnet/sub-home"
|
||||
|
||||
def test_create_uses_add_endpoint(self, driver):
|
||||
calls = []
|
||||
driver._post = lambda path, data=None: calls.append((path, data)) or {"result": "saved"}
|
||||
|
||||
driver.apply_dhcp_subnet({"subnet": "10.10.99.0/24", "option_data": {}})
|
||||
|
||||
assert calls[0][0] == "/api/kea/dhcpv4/addSubnet"
|
||||
|
||||
def test_unnamed_options_are_preserved_on_update(self, driver):
|
||||
"""The whole point of the partial-update contract.
|
||||
|
||||
Kea autocollects routers/domain_name_servers. Setting only
|
||||
domain_search must not blank them, or every managed subnet loses its
|
||||
gateway the first time netOrk touches it.
|
||||
"""
|
||||
calls = []
|
||||
driver._get = lambda path: KEA_SUBNET_DETAIL_RESPONSE
|
||||
driver._post = lambda path, data=None: calls.append((path, data)) or {"result": "saved"}
|
||||
|
||||
driver.apply_dhcp_subnet(
|
||||
{"subnet": "10.10.20.0/24", "option_data": {"domain_search": ["new.local"]}},
|
||||
uuid="sub-home",
|
||||
)
|
||||
|
||||
sent = calls[0][1]["subnet"]["option_data"]
|
||||
assert sent["domain_search"] == "new.local"
|
||||
assert sent["routers"] == "10.10.20.1"
|
||||
assert sent["domain_name_servers"] == "10.10.20.1,10.10.20.2"
|
||||
|
||||
def test_lists_are_serialised_comma_separated(self, driver):
|
||||
calls = []
|
||||
driver._post = lambda path, data=None: calls.append((path, data)) or {"result": "saved"}
|
||||
|
||||
driver.apply_dhcp_subnet(
|
||||
{
|
||||
"subnet": "10.10.99.0/24",
|
||||
"option_data": {"domain_search": ["a.local", "b.local"]},
|
||||
}
|
||||
)
|
||||
|
||||
assert calls[0][1]["subnet"]["option_data"]["domain_search"] == "a.local,b.local"
|
||||
|
||||
def test_pools_are_serialised_newline_separated(self, driver):
|
||||
calls = []
|
||||
driver._post = lambda path, data=None: calls.append((path, data)) or {"result": "saved"}
|
||||
|
||||
driver.apply_dhcp_subnet(
|
||||
{
|
||||
"subnet": "10.10.99.0/24",
|
||||
"pools": ["10.10.99.10-10.10.99.20", "10.10.99.30-10.10.99.40"],
|
||||
"option_data": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert (
|
||||
calls[0][1]["subnet"]["pools"]
|
||||
== "10.10.99.10-10.10.99.20\n10.10.99.30-10.10.99.40"
|
||||
)
|
||||
|
||||
def test_explicit_option_data_disables_autocollect(self, driver):
|
||||
"""Kea would otherwise re-fill routers/DNS/NTP and fight the desired state."""
|
||||
calls = []
|
||||
driver._post = lambda path, data=None: calls.append((path, data)) or {"result": "saved"}
|
||||
|
||||
driver.apply_dhcp_subnet(
|
||||
{"subnet": "10.10.99.0/24", "option_data": {"domain_search": ["a.local"]}}
|
||||
)
|
||||
|
||||
assert calls[0][1]["subnet"]["option_data_autocollect"] == "0"
|
||||
|
||||
def test_no_options_leaves_autocollect_alone(self, driver):
|
||||
calls = []
|
||||
driver._post = lambda path, data=None: calls.append((path, data)) or {"result": "saved"}
|
||||
|
||||
driver.apply_dhcp_subnet({"subnet": "10.10.99.0/24", "option_data": {}})
|
||||
|
||||
assert "option_data_autocollect" not in calls[0][1]["subnet"]
|
||||
|
||||
def test_rejection_raises(self, driver):
|
||||
driver._post = lambda path, data=None: {"result": "failed", "validations": {"x": "bad"}}
|
||||
|
||||
with pytest.raises(RuntimeError, match="10.10.99.0/24"):
|
||||
driver.apply_dhcp_subnet({"subnet": "10.10.99.0/24", "option_data": {}})
|
||||
|
||||
|
||||
class TestCommitDhcpSubnets:
|
||||
def test_reconfigures_kea(self, driver):
|
||||
calls = []
|
||||
driver._post = lambda path, data=None: calls.append(path) or {}
|
||||
|
||||
assert driver.commit_dhcp_subnets() == {"success": True}
|
||||
assert calls == ["/api/kea/service/reconfigure"]
|
||||
|
||||
Reference in New Issue
Block a user