feat(firewall): add generic diff/apply mechanism for firewall rules
FirewallRuleDict/FirewallRuleDiffDict (models.py) plus three abstract methods (get_firewall_rules/apply_firewall_rule/commit_firewall_rules) concrete drivers implement, and two concrete methods every driver gets for free: diff_firewall_rules() matches desired vs. live rules by description and reports add/update (never delete -- a firewall may carry manually-created rules a caller's desired set was never meant to describe); apply_firewall_ruleset() orchestrates applying the diff and yields progress lines, meant for streaming to a caller. This is the generic reconciliation engine NetOrk's Firewall Profile feature needs against OPNsense -- kept here instead of in napalm-opnsense since the matching/comparison/orchestration logic is identical for any firewall vendor that implements the three abstract methods.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""Tests for FirewallDriver's generic diff/apply mechanism.
|
||||
|
||||
diff_firewall_rules/apply_firewall_ruleset are concrete methods on the base
|
||||
class (not overridden by concrete drivers) — they only depend on the three
|
||||
abstract methods (get_firewall_rules/apply_firewall_rule/commit_firewall_rules),
|
||||
so a fake in-memory driver is enough to exercise them fully; no real device
|
||||
or vendor driver needed. See README.md "Design principle: generic vs.
|
||||
device-specific logic" for why this logic lives here and not in a vendor
|
||||
driver.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
from napalm_device_types import FirewallDriver
|
||||
from napalm_device_types.models import FirewallRuleDict
|
||||
|
||||
|
||||
def _rule(**overrides: Any) -> FirewallRuleDict:
|
||||
base: FirewallRuleDict = {
|
||||
"uuid": "",
|
||||
"description": "allow_mgmt_to_fw_gui",
|
||||
"action": "pass",
|
||||
"interface": "lan",
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"source_net": "MGMT_NET",
|
||||
"source_port": "",
|
||||
"destination_net": "(self)",
|
||||
"destination_port": "https",
|
||||
"enabled": True,
|
||||
"quick": True,
|
||||
"log": False,
|
||||
}
|
||||
base.update(overrides) # type: ignore[typeddict-item]
|
||||
return base
|
||||
|
||||
|
||||
class _FakeFirewall(FirewallDriver):
|
||||
"""In-memory fake — no network, no OPNsense/vendor specifics."""
|
||||
|
||||
def __init__(self, live_rules: Optional[List[FirewallRuleDict]] = None) -> None:
|
||||
self.live_rules = live_rules or []
|
||||
self.applied: List[Dict[str, Any]] = []
|
||||
self.committed = False
|
||||
|
||||
def get_firewall_rules(self) -> List[FirewallRuleDict]:
|
||||
return self.live_rules
|
||||
|
||||
def apply_firewall_rule(
|
||||
self, rule: FirewallRuleDict, *, uuid: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
self.applied.append({"rule": rule, "uuid": uuid})
|
||||
return {"success": True}
|
||||
|
||||
def commit_firewall_rules(self) -> Dict[str, Any]:
|
||||
self.committed = True
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class TestAbstractContract:
|
||||
def test_get_firewall_rules_raises_not_implemented_by_default(self):
|
||||
class _Bare(FirewallDriver):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
_Bare().get_firewall_rules()
|
||||
|
||||
def test_apply_firewall_rule_raises_not_implemented_by_default(self):
|
||||
class _Bare(FirewallDriver):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
_Bare().apply_firewall_rule(_rule())
|
||||
|
||||
def test_commit_firewall_rules_raises_not_implemented_by_default(self):
|
||||
class _Bare(FirewallDriver):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
_Bare().commit_firewall_rules()
|
||||
|
||||
|
||||
class TestDiffFirewallRules:
|
||||
def test_desired_rule_missing_live_is_an_add(self):
|
||||
driver = _FakeFirewall(live_rules=[])
|
||||
diff = driver.diff_firewall_rules([_rule()])
|
||||
|
||||
assert len(diff["add"]) == 1
|
||||
assert diff["add"][0]["description"] == "allow_mgmt_to_fw_gui"
|
||||
assert diff["update"] == []
|
||||
|
||||
def test_matching_rule_with_changed_field_is_an_update(self):
|
||||
live = _rule(uuid="abc-123", action="block")
|
||||
driver = _FakeFirewall(live_rules=[live])
|
||||
diff = driver.diff_firewall_rules([_rule(action="pass")])
|
||||
|
||||
assert diff["add"] == []
|
||||
assert len(diff["update"]) == 1
|
||||
update = diff["update"][0]
|
||||
assert update["uuid"] == "abc-123"
|
||||
assert update["changed_fields"] == ["action"]
|
||||
assert update["rule"]["action"] == "pass"
|
||||
|
||||
def test_identical_rule_produces_no_diff(self):
|
||||
live = _rule(uuid="abc-123")
|
||||
driver = _FakeFirewall(live_rules=[live])
|
||||
diff = driver.diff_firewall_rules([_rule()])
|
||||
|
||||
assert diff["add"] == []
|
||||
assert diff["update"] == []
|
||||
|
||||
def test_live_rule_not_in_desired_is_never_deleted(self):
|
||||
"""v1 never deletes -- rules present live but absent from `desired`
|
||||
are simply ignored, not reported for removal."""
|
||||
live = _rule(uuid="abc-123", description="some_unmanaged_rule")
|
||||
driver = _FakeFirewall(live_rules=[live])
|
||||
diff = driver.diff_firewall_rules([])
|
||||
|
||||
assert diff == {"add": [], "update": []}
|
||||
assert "delete" not in diff
|
||||
|
||||
def test_multiple_changed_fields_all_reported(self):
|
||||
live = _rule(uuid="abc-123", action="block", protocol="udp", log=True)
|
||||
driver = _FakeFirewall(live_rules=[live])
|
||||
diff = driver.diff_firewall_rules([_rule(action="pass", protocol="tcp", log=False)])
|
||||
|
||||
assert set(diff["update"][0]["changed_fields"]) == {"action", "protocol", "log"}
|
||||
|
||||
|
||||
class TestApplyFirewallRuleset:
|
||||
def test_adds_are_applied_with_no_uuid(self):
|
||||
driver = _FakeFirewall(live_rules=[])
|
||||
list(driver.apply_firewall_ruleset([_rule()]))
|
||||
|
||||
assert len(driver.applied) == 1
|
||||
assert driver.applied[0]["uuid"] is None
|
||||
assert driver.applied[0]["rule"]["description"] == "allow_mgmt_to_fw_gui"
|
||||
|
||||
def test_updates_are_applied_with_existing_uuid(self):
|
||||
live = _rule(uuid="abc-123", action="block")
|
||||
driver = _FakeFirewall(live_rules=[live])
|
||||
list(driver.apply_firewall_ruleset([_rule(action="pass")]))
|
||||
|
||||
assert len(driver.applied) == 1
|
||||
assert driver.applied[0]["uuid"] == "abc-123"
|
||||
|
||||
def test_commits_after_applying(self):
|
||||
driver = _FakeFirewall(live_rules=[])
|
||||
list(driver.apply_firewall_ruleset([_rule()]))
|
||||
|
||||
assert driver.committed is True
|
||||
|
||||
def test_yields_a_progress_line_per_change(self):
|
||||
driver = _FakeFirewall(live_rules=[])
|
||||
lines = list(driver.apply_firewall_ruleset([_rule(), _rule(description="second_rule")]))
|
||||
|
||||
assert len(lines) >= 2
|
||||
assert all(isinstance(line, str) for line in lines)
|
||||
|
||||
def test_no_changes_still_commits_but_applies_nothing(self):
|
||||
live = _rule(uuid="abc-123")
|
||||
driver = _FakeFirewall(live_rules=[live])
|
||||
list(driver.apply_firewall_ruleset([_rule()]))
|
||||
|
||||
assert driver.applied == []
|
||||
assert driver.committed is True
|
||||
Reference in New Issue
Block a user