diff --git a/cosmo/cperoutervisitor.py b/cosmo/cperoutervisitor.py deleted file mode 100644 index ff3b97ae..00000000 --- a/cosmo/cperoutervisitor.py +++ /dev/null @@ -1,57 +0,0 @@ -from multimethod import multimethod as singledispatchmethod -from ipaddress import IPv6Network, IPv4Network - -from cosmo.netbox_types import IPAddressType, DeviceType -from cosmo.visitors import AbstractNoopNetboxTypesVisitor - - -class CpeRouterIPVisitor(AbstractNoopNetboxTypesVisitor): - def __init__(self, ip_networks) -> None: - super().__init__() - - self.ip_networks = ip_networks - - @singledispatchmethod - def accept(self, o): - return super().accept(o) - - @accept.register - def _(self, o: IPAddressType): - ipo = o.getIPInterfaceObject() - - for ipn in self.ip_networks: - if o.getIPInterfaceObject() in ipn: - return ipo - - return None - - -class CpeRouterExporterVisitor(AbstractNoopNetboxTypesVisitor): - """ - This visitor creates a list of networks which are exported from the router - via unnumbered bgp. We allow all configured IP networks on a CPE to be - exported. By definition the primary IP is our management IP and this IP - should not be allowed to be exported via BGP from the router. - """ - - def __init__(self, forbidden_networks: list[IPv6Network | IPv4Network]): - self.forbidden_networks = forbidden_networks - - @singledispatchmethod - def accept(self, o): - return super().accept(o) - - @accept.register - def _(self, o: IPAddressType): - primary_ip4 = o.getParent(DeviceType)["primary_ip4"] - if primary_ip4 and primary_ip4.getIPAddress() == o.getIPAddress(): - return # skip, they're not allowed to export their mgmt addr - ip_interface = o.getIPInterfaceObject() - if any( - map( - lambda forbidden_network: ip_interface in forbidden_network, - self.forbidden_networks, - ) - ): - return # skip, they're not allowed to export our transfer nets - return type(ip_interface), ip_interface.network.with_prefixlen diff --git a/cosmo/netbox_autodescribable_mixin.py b/cosmo/netbox_autodescribable_mixin.py index f833786c..eeff24cc 100644 --- a/cosmo/netbox_autodescribable_mixin.py +++ b/cosmo/netbox_autodescribable_mixin.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from .autodesc import AbstractComposableAutoDescription + from cosmo.autodesc import AbstractComposableAutoDescription class AutoDescribableMixin(MutableMapping, metaclass=ABCMeta): diff --git a/cosmo/serializer.py b/cosmo/serializer.py index 0c52f208..1e11800b 100644 --- a/cosmo/serializer.py +++ b/cosmo/serializer.py @@ -3,7 +3,7 @@ from deepmerge import Merger -from cosmo.autodescvisitor import MutatingAutoDescVisitor +from cosmo.visitors.autodesc import MutatingAutoDescVisitor from cosmo.common import ( deepsort, DeviceSerializationError, @@ -13,16 +13,25 @@ ) from cosmo.features import features from cosmo.log import error -from cosmo.netbox_types import DeviceType, CosmoLoopbackType, AbstractNetboxType -from cosmo.loopbacks import LoopbackHelper +from cosmo.netbox_types import AbstractNetboxType +from cosmo.visitors.helpers.loopbacks import LoopbackHelper from cosmo.netbox_types import DeviceType, CosmoLoopbackType -from cosmo.switchvisitor import SwitchDeviceExporterVisitor -from cosmo.routervisitor import RouterDeviceExporterVisitor +from cosmo.visitors.router_bgpcpe import RouterBgpCpeExporterVisitor +from cosmo.visitors.router_l2vpn import ( + RouterL2VPNValidatorVisitor, + RouterL2VPNExporterVisitor, +) +from cosmo.visitors.switch import SwitchDeviceExporterVisitor +from cosmo.visitors.router import RouterDeviceExporterVisitor + +# serializer function type +S = Callable[[CosmoOutputType, AbstractNetboxType], None] class AbstractSerializer(metaclass=ABCMeta): def __init__(self, device): self.device = DeviceType(device) + self.serializers: list[S] = [] @staticmethod def getMerger(): @@ -39,6 +48,16 @@ def getMerger(): ) return merger + def exportTemplateMethod( + self, accept: Callable[[AbstractNetboxType], CosmoOutputType] + ) -> S: + def export(device_stub: CosmoOutputType, value: AbstractNetboxType): + new = accept(value) + if new: + device_stub = self.getMerger().merge(device_stub, new) + + return export + @staticmethod def autoDescPreprocess(_: CosmoOutputType, value: AbstractNetboxType): if not features.featureIsEnabled("interface-auto-descriptions"): @@ -67,6 +86,14 @@ def processErrors(self, latest_errors): str(first_error), on=self.device ) from first_error + def serialize(self) -> CosmoOutputType | Never: + device_stub: CosmoOutputType = {} + latest_errors: list[AbstractRecoverableError] = [] + for s in self.serializers: + latest_errors.extend(self.walk(device_stub, s)) + self.processErrors(latest_errors) + return deepsort(device_stub) + class RouterSerializer(AbstractSerializer): def __init__(self, device, l2vpn_list, loopbacks, cosmo_config): @@ -87,44 +114,50 @@ def __init__(self, device, l2vpn_list, loopbacks, cosmo_config): for (key, loopback) in self.loopbacks.items() } loopback_helper = LoopbackHelper(loopbacks) + # main visitor self.router_device_export_visitor = RouterDeviceExporterVisitor( loopbacks=loopback_helper, cosmo_config=cosmo_config ) if self.allow_private_ips: self.router_device_export_visitor.allowPrivateIPs() + # supplementary chainable visitors + self.router_bgpcpe_export_visitor = RouterBgpCpeExporterVisitor( + cosmo_config=cosmo_config, + ) + self.l2vpn_validator = RouterL2VPNValidatorVisitor( + cosmo_config=cosmo_config, + loopbacks=loopback_helper, + ) + self.l2vpn_exporter = RouterL2VPNExporterVisitor( + cosmo_config=cosmo_config, + loopbacks=loopback_helper, + ) + + self.serializers.extend( + [ + self.autoDescPreprocess, + self.exportTemplateMethod(self.l2vpn_validator.accept), + self.exportTemplateMethod(self.router_device_export_visitor.accept), + self.exportTemplateMethod(self.l2vpn_exporter.accept), + self.exportTemplateMethod(self.router_bgpcpe_export_visitor.accept), + ] + ) def allowPrivateIPs(self): self.router_device_export_visitor.allowPrivateIPs() return self - def routerExport(self, device_stub: CosmoOutputType, value: AbstractNetboxType): - new = self.router_device_export_visitor.accept(value) - if new: - device_stub = self.getMerger().merge(device_stub, new) - - def serialize(self) -> CosmoOutputType | Never: - device_stub: CosmoOutputType = {} - latest_errors: list[AbstractRecoverableError] = [] - latest_errors.extend(self.walk(device_stub, self.autoDescPreprocess)) - latest_errors.extend(self.walk(device_stub, self.routerExport)) - self.processErrors(latest_errors) - return deepsort(device_stub) - class SwitchSerializer(AbstractSerializer): def __init__(self, device, cosmo_config): super().__init__(device) self._cosmo_config = cosmo_config - - def switchExport(self, device_stub: CosmoOutputType, value: AbstractNetboxType): - new = SwitchDeviceExporterVisitor(cosmo_config=self._cosmo_config).accept(value) - if new: - device_stub = self.getMerger().merge(device_stub, new) - - def serialize(self) -> CosmoOutputType | Never: - device_stub: CosmoOutputType = {} - latest_errors: list[AbstractRecoverableError] = [] - latest_errors.extend(self.walk(device_stub, self.autoDescPreprocess)) - latest_errors.extend(self.walk(device_stub, self.switchExport)) - self.processErrors(latest_errors) - return deepsort(device_stub) + self.switch_device_export_visitor = SwitchDeviceExporterVisitor( + cosmo_config=cosmo_config + ) + self.serializers.extend( + [ + self.autoDescPreprocess, + self.exportTemplateMethod(self.switch_device_export_visitor.accept), + ] + ) diff --git a/cosmo/visitors.py b/cosmo/visitors.py deleted file mode 100644 index b6cd2de2..00000000 --- a/cosmo/visitors.py +++ /dev/null @@ -1,9 +0,0 @@ -import abc - - -class AbstractNoopNetboxTypesVisitor(abc.ABC): - def accept(self, o): - # use raise NotImplementedError(f"unsupported type {o}") - # when you're adding new types and you want to check your - # visitor gets everything - return diff --git a/cosmo/visitors/__init__.py b/cosmo/visitors/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cosmo/abstractroutervisitor.py b/cosmo/visitors/abc.py similarity index 52% rename from cosmo/abstractroutervisitor.py rename to cosmo/visitors/abc.py index 63fc9882..be969798 100644 --- a/cosmo/abstractroutervisitor.py +++ b/cosmo/visitors/abc.py @@ -1,7 +1,13 @@ +import abc from abc import ABC -from cosmo.netbox_types import CosmoLoopbackType -from cosmo.visitors import AbstractNoopNetboxTypesVisitor + +class AbstractNoopNetboxTypesVisitor(abc.ABC): + def accept(self, o): + # use raise NotImplementedError(f"unsupported type {o}") + # when you're adding new types and you want to check your + # visitor gets everything + return class AbstractRouterExporterVisitor(AbstractNoopNetboxTypesVisitor, ABC): diff --git a/cosmo/autodescvisitor.py b/cosmo/visitors/autodesc.py similarity index 96% rename from cosmo/autodescvisitor.py rename to cosmo/visitors/autodesc.py index d1fe0deb..289dc102 100644 --- a/cosmo/autodescvisitor.py +++ b/cosmo/visitors/autodesc.py @@ -8,7 +8,7 @@ ) from cosmo.common import head, strictly_decreasing, AutoDescriptionError from cosmo.netbox_types import InterfaceType -from cosmo.visitors import AbstractNoopNetboxTypesVisitor +from cosmo.visitors.abc import AbstractNoopNetboxTypesVisitor class MutatingAutoDescVisitor(AbstractNoopNetboxTypesVisitor): diff --git a/cosmo/visitors/helpers/__init__.py b/cosmo/visitors/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cosmo/loopbacks.py b/cosmo/visitors/helpers/loopbacks.py similarity index 100% rename from cosmo/loopbacks.py rename to cosmo/visitors/helpers/loopbacks.py diff --git a/cosmo/vrfhelper.py b/cosmo/visitors/helpers/vrf.py similarity index 100% rename from cosmo/vrfhelper.py rename to cosmo/visitors/helpers/vrf.py diff --git a/cosmo/l2vpnhelpertypes.py b/cosmo/visitors/l2vpnhelpertypes.py similarity index 98% rename from cosmo/l2vpnhelpertypes.py rename to cosmo/visitors/l2vpnhelpertypes.py index 5c1aec53..ae5ea682 100644 --- a/cosmo/l2vpnhelpertypes.py +++ b/cosmo/visitors/l2vpnhelpertypes.py @@ -3,17 +3,16 @@ from multimethod import multimethod as singledispatchmethod from typing import NoReturn -from cosmo.abstractroutervisitor import AbstractRouterExporterVisitor +from cosmo.visitors.abc import AbstractRouterExporterVisitor from cosmo.common import ( head, CosmoOutputType, L2VPNSerializationError, - DeviceSerializationError, ) from cosmo.config.cosmo_config import CosmoConfig -from cosmo.loopbacks import LoopbackHelper +from cosmo.visitors.helpers.loopbacks import LoopbackHelper from cosmo.manufacturers import ManufacturerFactoryFromDevice -from cosmo.vrfhelper import TVRFHelpers +from cosmo.visitors.helpers.vrf import TVRFHelpers from cosmo.log import warn from cosmo.netbox_types import ( InterfaceType, @@ -21,7 +20,6 @@ AbstractNetboxType, DeviceType, L2VPNType, - CosmoLoopbackType, L2VPNTerminationType, ) diff --git a/cosmo/routervisitor.py b/cosmo/visitors/router.py similarity index 96% rename from cosmo/routervisitor.py rename to cosmo/visitors/router.py index d573bd10..c23063ee 100644 --- a/cosmo/routervisitor.py +++ b/cosmo/visitors/router.py @@ -8,20 +8,18 @@ from cosmo.config.cosmo_config import CosmoConfig from cosmo.autodesc import AbstractComposableAutoDescription from cosmo.log import warn -from cosmo.abstractroutervisitor import AbstractRouterExporterVisitor +from cosmo.visitors.abc import AbstractRouterExporterVisitor from cosmo.common import ( InterfaceSerializationError, head, StaticRouteSerializationError, APP_NAME, - DeviceSerializationError, ) -from cosmo.loopbacks import LoopbackHelper -from cosmo.vrfhelper import TVRFHelpers +from cosmo.visitors.helpers.loopbacks import LoopbackHelper +from cosmo.visitors.helpers.vrf import TVRFHelpers from cosmo.manufacturers import ManufacturerFactoryFromDevice, AbstractManufacturer -from cosmo.routerbgpcpevisitor import RouterBgpCpeExporterVisitor from cosmo.features import features -from cosmo.routerl2vpnvisitor import ( +from cosmo.visitors.router_l2vpn import ( RouterL2VPNValidatorVisitor, RouterL2VPNExporterVisitor, ) @@ -46,16 +44,6 @@ class RouterDeviceExporterVisitor(AbstractRouterExporterVisitor, TVRFHelpers): def __init__(self, loopbacks: LoopbackHelper, cosmo_config: CosmoConfig): self._cosmo_config = cosmo_config self.asn = self._cosmo_config["asn"] - # Note: I have to use composition since singledispatchmethod does not work well with inheritance - self.l2vpn_exporter = RouterL2VPNExporterVisitor( - loopbacks=loopbacks, cosmo_config=self._cosmo_config - ) - self.l2vpn_validator = RouterL2VPNValidatorVisitor( - loopbacks=loopbacks, cosmo_config=self._cosmo_config - ) - self.bgpcpe_exporter = RouterBgpCpeExporterVisitor( - cosmo_config=self._cosmo_config - ) self.loopbacks = loopbacks self.allow_private_ips = features.featureIsEnabled( "allow-private-ips-default-vrf" @@ -76,10 +64,6 @@ def disallowPrivateIPs(self): def accept(self, o): return super().accept(o) - @accept.register - def _(self, o: L2VPNType): - return self.l2vpn_validator.accept(o) - @accept.register def _(self, o: DeviceType): if not o.isCompositeRoot(): # not root, do not process! @@ -346,7 +330,7 @@ def _(self, o: InterfaceType): # guard: do not process VLAN interface info return if o.hasParentAboveWithType(L2VPNTerminationType): - return self.l2vpn_exporter.accept(o) + return None # guard: will be processed by L2VPN exporter # interface in interface is lag info if o.hasParentAboveWithType(InterfaceType): if o.isUnderKeyNameForParentAboveWithType("lag", InterfaceType): @@ -515,7 +499,7 @@ def processUntaggedVLAN(self, o: VLANType): @accept.register def _(self, o: VLANType): if o.hasParentAboveWithType(L2VPNTerminationType): - return self.l2vpn_exporter.accept(o) + return None # will be processed by L2VPN Exporter parent_interface = o.getParent(InterfaceType) if ( parent_interface @@ -849,8 +833,3 @@ def _(self, o: TagType): warn(f"{APP_NAME} doesn't know this bgp tag.", o) case _: warn(f"{APP_NAME} doesn't know this tag.", o) - - @accept.register - def _(self, o: List[TagType]): - if "bgp:cpe" in o: - return self.bgpcpe_exporter.accept(o) diff --git a/cosmo/routerbgpcpevisitor.py b/cosmo/visitors/router_bgpcpe.py similarity index 84% rename from cosmo/routerbgpcpevisitor.py rename to cosmo/visitors/router_bgpcpe.py index cf45a829..347b54ea 100644 --- a/cosmo/routerbgpcpevisitor.py +++ b/cosmo/visitors/router_bgpcpe.py @@ -2,12 +2,14 @@ from typing import List, NoReturn, TypeGuard from multimethod import multimethod as singledispatchmethod -from ipaddress import IPv4Interface, IPv6Interface +from ipaddress import IPv4Interface, IPv6Interface, IPv6Network, IPv4Network from cosmo.common import head, CosmoOutputType, InterfaceSerializationError from cosmo.config.cosmo_config import CosmoConfig -from cosmo.cperoutervisitor import CpeRouterExporterVisitor, CpeRouterIPVisitor -from cosmo.abstractroutervisitor import AbstractRouterExporterVisitor +from cosmo.visitors.abc import ( + AbstractRouterExporterVisitor, + AbstractNoopNetboxTypesVisitor, +) from cosmo.features import features from cosmo.log import warn from cosmo.manufacturers import ManufacturerFactoryFromDevice @@ -17,9 +19,62 @@ DeviceType, VRFType, AbstractNetboxType, + IPAddressType, ) +class CpeRouterIPVisitor(AbstractNoopNetboxTypesVisitor): + def __init__(self, ip_networks) -> None: + super().__init__() + + self.ip_networks = ip_networks + + @singledispatchmethod + def accept(self, o): + return super().accept(o) + + @accept.register + def _(self, o: IPAddressType): + ipo = o.getIPInterfaceObject() + + for ipn in self.ip_networks: + if o.getIPInterfaceObject() in ipn: + return ipo + + return None + + +class CpeRouterExporterVisitor(AbstractNoopNetboxTypesVisitor): + """ + This visitor creates a list of networks which are exported from the router + via unnumbered bgp. We allow all configured IP networks on a CPE to be + exported. By definition the primary IP is our management IP and this IP + should not be allowed to be exported via BGP from the router. + """ + + def __init__(self, forbidden_networks: list[IPv6Network | IPv4Network]): + self.forbidden_networks = forbidden_networks + + @singledispatchmethod + def accept(self, o): + return super().accept(o) + + @accept.register + def _(self, o: IPAddressType): + primary_ip4 = o.getParent(DeviceType)["primary_ip4"] + if primary_ip4 and primary_ip4.getIPAddress() == o.getIPAddress(): + return # skip, they're not allowed to export their mgmt addr + ip_interface = o.getIPInterfaceObject() + if any( + map( + lambda forbidden_network: ip_interface in forbidden_network, + self.forbidden_networks, + ) + ): + return # skip, they're not allowed to export our transfer nets + return type(ip_interface), ip_interface.network.with_prefixlen + + class AbstractBgpCpeExporter(metaclass=ABCMeta): def __init__(self, cosmo_config: CosmoConfig): self._cosmo_config = cosmo_config diff --git a/cosmo/routerl2vpnvisitor.py b/cosmo/visitors/router_l2vpn.py similarity index 89% rename from cosmo/routerl2vpnvisitor.py rename to cosmo/visitors/router_l2vpn.py index 52442fa3..0d7ad743 100644 --- a/cosmo/routerl2vpnvisitor.py +++ b/cosmo/visitors/router_l2vpn.py @@ -1,21 +1,19 @@ -import ipaddress from multimethod import multimethod as singledispatchmethod -from cosmo.abstractroutervisitor import AbstractRouterExporterVisitor -from cosmo.common import head, L2VPNSerializationError +from cosmo.visitors.abc import AbstractRouterExporterVisitor +from cosmo.common import L2VPNSerializationError from cosmo.config.cosmo_config import CosmoConfig -from cosmo.l2vpnhelpertypes import ( +from cosmo.visitors.l2vpnhelpertypes import ( L2VpnVisitorClassFactoryFromL2VpnTypeObject, AbstractL2VpnTypeTerminationVisitor, ) -from cosmo.loopbacks import LoopbackHelper +from cosmo.visitors.helpers.loopbacks import LoopbackHelper from cosmo.netbox_types import ( L2VPNType, InterfaceType, VLANType, - CosmoLoopbackType, - L2VPNTerminationType, DeviceType, + L2VPNTerminationType, ) @@ -81,6 +79,8 @@ def accept(self, o): @accept.register def _(self, o: InterfaceType): + if not o.hasParentAboveWithType(L2VPNTerminationType): + return None # guard l2vpn_type = self.getL2VpnTypeTerminationObjectFrom(o.getParent(L2VPNType)) # guard: processed l2vpn should have at least 1 termination belonging # to current device. @@ -89,6 +89,8 @@ def _(self, o: InterfaceType): @accept.register def _(self, o: VLANType): + if not o.hasParentAboveWithType(L2VPNTerminationType): + return None # guard l2vpn_type = self.getL2VpnTypeTerminationObjectFrom(o.getParent(L2VPNType)) # guard: processed l2vpn should have at least 1 termination belonging # to current device. if no termination passes the test, then l2vpn diff --git a/cosmo/switchvisitor.py b/cosmo/visitors/switch.py similarity index 97% rename from cosmo/switchvisitor.py rename to cosmo/visitors/switch.py index caf4b41e..06d71c8e 100644 --- a/cosmo/switchvisitor.py +++ b/cosmo/visitors/switch.py @@ -4,9 +4,8 @@ from cosmo.config.cosmo_config import CosmoConfig from cosmo.autodesc import AbstractComposableAutoDescription -from cosmo.common import APP_NAME from cosmo.log import warn -from cosmo.manufacturers import AbstractManufacturer, ManufacturerFactoryFromDevice +from cosmo.manufacturers import ManufacturerFactoryFromDevice from cosmo.netbox_types import ( IPAddressType, DeviceType, @@ -14,7 +13,7 @@ VLANType, TagType, ) -from cosmo.visitors import AbstractNoopNetboxTypesVisitor +from .abc import AbstractNoopNetboxTypesVisitor class SwitchDeviceExporterVisitor(AbstractNoopNetboxTypesVisitor):