diff --git a/docs/admin/release_notes/version_0.9.md b/docs/admin/release_notes/version_0.9.md new file mode 100644 index 00000000..9c175a68 --- /dev/null +++ b/docs/admin/release_notes/version_0.9.md @@ -0,0 +1,17 @@ +# v0.9 Release Notes + +## Release Overview + +This version introduces `PeerGroupAddressFamily` and `PeerEndpointAddressFamily` data models to provide for more granular configuration modeling. + +!!! warning + This version **removes** the `import_policy`, `export_policy`, and `multipath` attributes from the `PeerGroupTemplate`, `PeerGroup`, and `PeerEndpoint` models, as these are generally address-family-specific configuration attributes and are modeled as such now. No data migration is provided at this time (as there is no way to identify **which** AFI-SAFI any existing policy/multipath configs should be migrated to), and upgrading to this version will therefore necessarily result in data loss if you had previously populated these model fields. Back up your configuration or record this data in some other format before upgrading if appropriate. + +### Added + +- [#26](https://github.com/nautobot/nautobot-plugin-bgp-models/issues/26) - Adds `PeerGroupAddressFamily` and `PeerEndpointAddressFamily` data models. +- [#132](https://github.com/nautobot/nautobot-plugin-bgp-models/pull/132) - Adds `extra_attributes` support to the `AddressFamily` model. + +### Removed + +- [#132](https://github.com/nautobot/nautobot-plugin-bgp-models/pull/132) - Removes `import_policy`, `export_policy`, and `multipath` attributes from `PeerGroupTemplate`, `PeerGroup`, and `PeerEndpoint` models. Use the equivalent fields on `PeerGroupAddressFamily` and `PeerEndpointAddressFamily` instead. diff --git a/docs/dev/models.md b/docs/dev/models.md index 2d9d79a0..c1ede3e4 100644 --- a/docs/dev/models.md +++ b/docs/dev/models.md @@ -3,13 +3,15 @@ This plugin adds the following data models to Nautobot: - AutonomousSystem +- PeeringRole - BGPRoutingInstance -- PeerEndpoint -- PeerGroup -- PeerGroupTemplate - AddressFamily +- PeerGroupTemplate +- PeerGroup +- PeerGroupAddressFamily +- PeerEndpoint +- PeerEndpointAddressFamily - Peering -- PeeringRole A key motivation behind this design is the idea that the Source of Truth should take a network-wide view of the BGP configuration rather than a per-device view. This especially applies to the data models for autonomous systems (ASNs), BGP peerings, and network-wide templates (Peer Groups). @@ -31,6 +33,10 @@ The data models introduced by the BGP plugin support the following Nautobot feat This model represents a network-wide description of a BGP autonomous system (AS). It has fields including the actual AS number (ASN), a description field, foreign key (FK) to a Nautobot `Provider` object, and a FK to a Nautobot `Status` object. +### PeeringRole + +This model operates similarly to Nautobot’s `Status` and `Tag` models, in that instances of this model describe various valid values for the `Role` field used by `PeerGroup` and `Peering` records. Similar to those models, this model has fields including a unique name, unique slug, and HTML color code. + ### BGPRoutingInstance This model represents a device specific BGP process. It has a mandatory FK to a Nautobot `Device`, mandatory FK to a `AutonomousSystem` and following fields: @@ -53,14 +59,24 @@ Example of the extra attributes: Extra Attributes are available for following models: -- `PeerEndpoint` -- `PeerGroup` -- `PeerGroupTemplate` - `BGPRoutingInstance` +- `AddressFamily` +- `PeerGroupTemplate` +- `PeerGroup` +- `PeerGroupAddressFamily` +- `PeerEndpoint` +- `PeerEndpointAddressFamily` -### PeeringRole +### AddressFamily -This model operates similarly to Nautobot’s `Status` and `Tag` models, in that instances of this model describe various valid values for the `Role` field used by `PeerGroup` and `Peering` records. Similar to those models, this model has fields including a unique name, unique slug, and HTML color code. +This model represents configuration of a BGP address-family (AFI-SAFI). AddressFamily aims to represent a device specific Address Family instance. + +It has a locally unique AFI (address family identifier) field, optional VRF field (FK to Nautobot `VRF`) and following fields: + +- Import Policy (optional, string) +- Export Policy (optional, string) + +(*) The network-wide modeling of AddressFamilies will be implemented in the future with `AddressFamilyTemplate` model similar to the `PeerGroupTemplate`. ### PeerGroupTemplate @@ -70,8 +86,6 @@ This model represents a network-wide configuration for `PeerGroups`. `PeerGroupT - Role (optional, FK to `PeeringRole`) - Description (string) - Enabled (bool) -- Import Policy (optional, string) -- Export Policy (optional, string) - Secret (optional, FK to Nautobot `Secret`) - Extra Attributes (optional, JSON) @@ -85,9 +99,15 @@ This model represents a common configuration for a group of functionally related - Role (optional, FK to `PeeringRole`) - Description (string) - Enabled (bool) +- Secret (optional, FK to Nautobot `Secret`) +- Extra Attributes (optional, JSON) + +### PeerGroupAddressFamily + +This model represents address-family-specific configuration of a PeerGroup. It has a mandatory FK to a `PeerGroup` and a mandatory `afi_safi` field, and additional fields including + - Import Policy (optional, string) - Export Policy (optional, string) -- Secret (optional, FK to Nautobot `Secret`) - Extra Attributes (optional, JSON) ### PeerEndpoint @@ -107,13 +127,9 @@ Note that in the case of an external peering (connection with an ISP or Transit - Role (optional, FK to `PeeringRole`) - Description (string) - Enabled (bool) -- Import Policy (optional, string) -- Export Policy (optional, string) - Secret (optional, FK to Nautobot `Secret`) - Extra Attributes (optional, JSON) -The device-specific `PeerEndpoint` custom modeling will be implemented in the future with `PeerEndpointContext` and `PeerGroupContext` models. - #### PeerEndpoint Local-IP To ease the data presentation and consumption, `PeerEndpoint` provides a property named `local_ip`. @@ -127,16 +143,13 @@ As Source-IP and Source-Interface could be defined at multiple inheritance level 3. `PeerEndpoint`'s `source_interface` attribute (if exists) 4. `PeerGroup`'s `source_interface` attribute (if exists) -### AddressFamily - -This model represents configuration of a BGP address-family (AFI-SAFI). AddressFamily aims to represent a device specific Address Family instance. +### PeerEndpointAddressFamily -It has a locally unique AFI (address family identifier) field, optional VRF field (FK to Nautobot `VRF`) and following fields: +This model represents address-family-specific configuration of a device's PeerEndpoint. It has a mandatory FK to a `PeerEndpoint` and a mandatory `afi_safi` field, and additional keys including: - Import Policy (optional, string) - Export Policy (optional, string) - -(*) The network-wide modeling of AddressFamilies will be implemented in the future with `AddressFamilyTemplate` model similar to the `PeerGroupTemplate`. +- Extra Attributes (optional, JSON) ### Peering @@ -201,3 +214,18 @@ Following is the complete documentation of the field inheritance hierarchy. Mode | export_policy | PeerGroupTemplate | | import_policy | PeerGroupTemplate | | role | PeerGroupTemplate | + +**PeerGroupAddressFamily**: + +| **Attribute** | **Inheritance from model** | +| ------------- | -------------------------- | +| extra_attributes | AddressFamily (same `afi_safi` only) | + +**PeerEndpointAddressFamily**: + +| **Attribute** | **Inheritance from model** | +| ------------- | -------------------------- | +| extra_attributes | PeerGroupAddressFamily (same `afi_safi` only) → AddressFamily (same `afi_safi` only) | +| import_policy | PeerGroupAddressFamily (same `afi_safi` only) | +| export_policy | PeerGroupAddressFamily (same `afi_safi` only) | +| multipath | PeerGroupAddressFamily (same `afi_safi` only) | diff --git a/docs/user/app_overview.md b/docs/user/app_overview.md index a7647732..6f883cc6 100644 --- a/docs/user/app_overview.md +++ b/docs/user/app_overview.md @@ -11,13 +11,15 @@ An app for [Nautobot](https://github.com/nautobot/nautobot), extending the core This application adds the following new data models into Nautobot: -- **BGP Routing Instance**: device-specific BGP process - **Autonomous System**: network-wide description of a BGP autonomous system (AS) +- **Peering Role**: describes the valid options for PeerGroup, PeerGroupTemplate, and/or Peering roles +- **BGP Routing Instance**: device-specific BGP process +- **Address Family**: device-specific configuration of a BGP address family (AFI-SAFI) with an optional VRF - **Peer Group Template**: network-wide template for Peer Group objects - **Peer Group**: device-specific configuration for a group of functionally related BGP peers -- **Address Family**: device-specific configuration of a BGP address family (AFI-SAFI) +- **Peer Group Address Family**: peer-group-specific configuration of a BGP address-family (AFI-SAFI) - **Peering and Peer Endpoints**: A BGP Peering is represented by a Peering object and two endpoints, each representing the configuration of one side of the BGP peering. A Peer Endpoint must be associated with a BGP Routing Instance. -- **Peering Role**: describes the valid options for PeerGroup, PeerGroupTemplate, and/or Peering roles +- **Peer Endpoint Address Family**: peer-specific configuration of a BGP address-family (AFI-SAFI) With these new models, it's now possible to populate the Source of Truth (SoT) with any BGP peerings, internal or external, regardless of whether both endpoints are fully defined in the Source of Truth. @@ -37,13 +39,15 @@ Network Admins who need to model their BGP internal and external peerings inside This plugin adds the following data models to Nautobot: - AutonomousSystem +- PeeringRole - BGPRoutingInstance -- PeerEndpoint -- PeerGroup -- PeerGroupTemplate - AddressFamily +- PeerGroupTemplate +- PeerGroup +- PeerGroupAddressFamily +- PeerEndpoint +- PeerEndpointAddressFamily - Peering -- PeeringRole The data models introduced by the BGP plugin support the following Nautobot features: diff --git a/docs/user/cisco_use_case.md b/docs/user/cisco_use_case.md index 40e2c1c5..3a4ab4e4 100644 --- a/docs/user/cisco_use_case.md +++ b/docs/user/cisco_use_case.md @@ -22,13 +22,16 @@ query ($device_id: ID!) { peer_groups { name extra_attributes - template { + peergroup_template { autonomous_system { asn } + extra_attributes + } + address_families { + afi_safi import_policy export_policy - extra_attributes } } endpoints { @@ -85,23 +88,29 @@ An example data returned from Nautobot is presented below. { "name": "EDGE-to-LEAF", "extra_attributes": null, - "template": { + "peergroup_template": { "autonomous_system": null, - "import_policy": "BGP-LEAF-IN", - "export_policy": "BGP-LEAF-OUT", - "extra_attributes": { - "next-hop-self": true, - "send-community": true - }, + "extra_attributes": {}, "role": { "slug": "peer" } - } + }, + "address_families": [ + { + "afi_safi": "IPV4_UNICAST", + "import_policy": "BGP-LEAF-IN", + "export_policy": "BGP-LEAF-OUT", + "extra_attributes": { + "next-hop-self": true, + "send-community": true, + } + } + ] }, { "name": "EDGE-to-TRANSIT", "extra_attributes": null, - "template": { + "peergroup_template": { "autonomous_system": null, "import_policy": "BGP-TRANSIT-IN", "export_policy": "BGP-TRANSIT-OUT", @@ -111,7 +120,15 @@ An example data returned from Nautobot is presented below. "role": { "slug": "customer" } - } + }, + "address_families": [ + { + "afi_safi": "IPV4_UNICAST", + "import_policy": "BGP-TRANSIT-IN", + "export_policy": "BGP-TRANSIT-OUT", + "extra_attributes": {} + } + ] } ], "endpoints": [ @@ -318,16 +335,16 @@ Following snippet represents an example Cisco BGP Configuration Template: router bgp {{ data.device.bgp_routing_instances.0.autonomous_system.asn }} {%- for peer_group in data.device.bgp_routing_instances.0.peer_groups %} neighbor {{ peer_group.name }} peer-group - neighbor {{ peer_group.name }} route-map {{ peer_group.template.import_policy }} in - neighbor {{ peer_group.name }} route-map {{ peer_group.template.export_policy }} out -{%- if "next-hop-self" in peer_group.template.extra_attributes %} + neighbor {{ peer_group.name }} route-map {{ peer_group.address_families.0.import_policy }} in + neighbor {{ peer_group.name }} route-map {{ peer_group.address_families.0.export_policy }} out +{%- if "next-hop-self" in peer_group.address_families.0.extra_attributes %} neighbor {{ peer_group.name }} next-hop-self {%- endif %} -{%- if "send-community" in peer_group.template.extra_attributes %} +{%- if "send-community" in peer_group.address_families.0.extra_attributes %} neighbor {{ peer_group.name }} send-community {%- endif %} -{%- if "ttl_security_hops" in peer_group.template.extra_attributes %} - neighbor {{ peer_group.name }} ttl-security hops {{ peer_group.template.extra_attributes.ttl_security_hops }} +{%- if "ttl_security_hops" in peer_group.peergroup_template.extra_attributes %} + neighbor {{ peer_group.name }} ttl-security hops {{ peer_group.peergroup_template.extra_attributes.ttl_security_hops }} {%- endif %} {%- endfor %} ! @@ -352,7 +369,7 @@ router bgp {{ data.device.bgp_routing_instances.0.autonomous_system.asn }} ## Rendering Cisco Jinja2 BGP Configuration Template with the data retrieved from GraphQL -Following snippet represents an example Cisco BGP Renderer Configuration: +Following snippet represents an example Cisco BGP rendered configuration: ```text ! diff --git a/docs/user/juniper_use_case.md b/docs/user/juniper_use_case.md index b3e67e53..dffd6bcc 100644 --- a/docs/user/juniper_use_case.md +++ b/docs/user/juniper_use_case.md @@ -22,10 +22,17 @@ query ($device_id: ID!) { peer_groups { name extra_attributes - template { + peergroup_template { autonomous_system { asn } + role { + slug + } + extra_attributes + } + address_families { + afi_safi import_policy export_policy extra_attributes @@ -85,33 +92,45 @@ An example data returned from Nautobot is presented below. { "name": "EDGE-to-LEAF", "extra_attributes": null, - "template": { + "peergroup_template": { "autonomous_system": null, - "import_policy": "BGP-LEAF-IN", - "export_policy": "BGP-LEAF-OUT", - "extra_attributes": { - "next-hop-self": true, - "send-community": true - }, "role": { "slug": "peer" } - } + "extra_attributes": {} + }, + "address_families": [ + { + "afi_safi": "IPV4_UNICAST", + "import_policy": "BGP-LEAF-IN", + "export_policy": "BGP-LEAF-OUT", + "extra_attributes": { + "next-hop-self": true, + "send-community": true + } + } + ] }, { "name": "EDGE-to-TRANSIT", "extra_attributes": null, - "template": { + "peergroup_template": { "autonomous_system": null, - "import_policy": "BGP-TRANSIT-IN", - "export_policy": "BGP-TRANSIT-OUT", "extra_attributes": { "ttl_security_hops": 1 }, "role": { "slug": "customer" } - } + }, + "address_families": [ + { + "afi_safi": "IPV4_UNICAST", + "import_policy": "BGP-TRANSIT-IN", + "export_policy": "BGP-TRANSIT-OUT", + "extra_attributes": {} + } + ] } ], "endpoints": [ @@ -319,14 +338,14 @@ set routing-options autonomous-system {{ data.device.bgp_routing_instances.0.aut # Configure Groups {%- for peer_group in data.device.bgp_routing_instances.0.peer_groups %} -{%- if peer_group.template.role.slug == "peer" %} +{%- if peer_group.peergroup_template.role.slug == "peer" %} set protocols bgp group {{ peer_group.name }} type internal {%- endif %} -{%- if peer_group.template.role.slug == "customer" %} +{%- if peer_group.peergroup_template.role.slug == "customer" %} set protocols bgp group {{ peer_group.name }} type external {%- endif %} -set protocols bgp group {{ peer_group.name }} import {{ peer_group.template.import_policy }} -set protocols bgp group {{ peer_group.name }} export {{ peer_group.template.export_policy }} +set protocols bgp group {{ peer_group.name }} import {{ peer_group.address_families.0.import_policy }} +set protocols bgp group {{ peer_group.name }} export {{ peer_group.address_families.0.export_policy }} {%- endfor %} # Configure Peers diff --git a/mkdocs.yml b/mkdocs.yml index 0595a805..95007b13 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,6 +110,7 @@ nav: - "admin/release_notes/index.md" - v0.7: "admin/release_notes/version_0.7.md" - v0.8: "admin/release_notes/version_0.8.md" + - v0.9: "admin/release_notes/version_0.9.md" - Developer Guide: - BGP Data Models: "dev/models.md" - Extending the App: "dev/extending.md" diff --git a/nautobot_bgp_models/api/nested_serializers.py b/nautobot_bgp_models/api/nested_serializers.py index ed628c3c..048adac5 100644 --- a/nautobot_bgp_models/api/nested_serializers.py +++ b/nautobot_bgp_models/api/nested_serializers.py @@ -7,14 +7,16 @@ from nautobot_bgp_models import models __all__ = ( + "NestedAddressFamilySerializer", "NestedAutonomousSystemSerializer", + "NestedBGPRoutingInstanceSerializer", + "NestedPeerEndpointAddressFamilySerializer", + "NestedPeerEndpointSerializer", "NestedPeeringRoleSerializer", + "NestedPeeringSerializer", + "NestedPeerGroupAddressFamilySerializer", "NestedPeerGroupSerializer", "NestedPeerGroupTemplateSerializer", - "NestedPeerEndpointSerializer", - "NestedPeeringSerializer", - "NestedAddressFamilySerializer", - "NestedBGPRoutingInstanceSerializer", ) @@ -98,3 +100,27 @@ class NestedAddressFamilySerializer(WritableNestedSerializer): class Meta: model = models.AddressFamily fields = ["id", "url", "afi_safi"] + + +class NestedPeerGroupAddressFamilySerializer(WritableNestedSerializer): + """Nested/brief serializer for PeerGroupAddressFamily.""" + + url = serializers.HyperlinkedIdentityField( + view_name="plugins-api:nautobot_bgp_models-api:peergroupaddressfamily-detail" + ) + + class Meta: + model = models.PeerGroupAddressFamily + fields = ["id", "url"] + + +class NestedPeerEndpointAddressFamilySerializer(WritableNestedSerializer): + """Nested/brief serializer for PeerEndpointAddressFamily.""" + + url = serializers.HyperlinkedIdentityField( + view_name="plugins-api:nautobot_bgp_models-api:peerendpointaddressfamily-detail" + ) + + class Meta: + model = models.PeerEndpointAddressFamily + fields = ["id", "url"] diff --git a/nautobot_bgp_models/api/serializers.py b/nautobot_bgp_models/api/serializers.py index e8cc2099..c3b997c3 100644 --- a/nautobot_bgp_models/api/serializers.py +++ b/nautobot_bgp_models/api/serializers.py @@ -1,6 +1,6 @@ """REST API serializers for nautobot_bgp_models models.""" -from rest_framework import serializers +from rest_framework import serializers, validators from nautobot.dcim.api.serializers import NestedDeviceSerializer, NestedInterfaceSerializer from nautobot.ipam.api.serializers import NestedVRFSerializer, NestedIPAddressSerializer @@ -95,8 +95,6 @@ class Meta: "description", "enabled", "autonomous_system", - "import_policy", - "export_policy", "extra_attributes", "secret", ] @@ -117,6 +115,8 @@ class PeerGroupSerializer( autonomous_system = NestedAutonomousSystemSerializer(required=False, allow_null=True) # noqa: F405 + vrf = NestedVRFSerializer(required=False, allow_null=True) + peergroup_template = NestedPeerGroupTemplateSerializer(required=False, allow_null=True) # noqa: F405 secret = NestedSecretSerializer(required=False, allow_null=True) @@ -133,13 +133,24 @@ class Meta: "enabled", "autonomous_system", "routing_instance", + "vrf", "peergroup_template", "secret", "extra_attributes", "role", - "import_policy", - "export_policy", ] + validators = [] + + def validate(self, data): + """Custom validation logic to handle unique-together with a nullable field.""" + if data.get("vrf"): + validator = validators.UniqueTogetherValidator( + queryset=models.PeerGroup.objects.all(), fields=("routing_instance", "name", "vrf") + ) + validator(data, self) + + super().validate(data) + return data class PeerEndpointSerializer( @@ -171,8 +182,6 @@ class Meta: "autonomous_system", "peer_group", "peer", - "import_policy", - "export_policy", "peering", "secret", "tags", @@ -247,7 +256,7 @@ class Meta: ] -class AddressFamilySerializer(NautobotModelSerializer): +class AddressFamilySerializer(NautobotModelSerializer, ExtraAttributesSerializerMixin): """REST API serializer for AddressFamily records.""" url = serializers.HyperlinkedIdentityField(view_name="plugins-api:nautobot_bgp_models-api:addressfamily-detail") @@ -264,6 +273,51 @@ class Meta: "afi_safi", "routing_instance", "vrf", + "extra_attributes", + ] + + +class PeerGroupAddressFamilySerializer(NautobotModelSerializer, ExtraAttributesSerializerMixin): + """REST API serializer for PeerGroupAddressFamily records.""" + + url = serializers.HyperlinkedIdentityField( + view_name="plugins-api:nautobot_bgp_models-api:peergroupaddressfamily-detail" + ) + + peer_group = NestedPeerGroupSerializer(required=True) # noqa: F405 + + class Meta: + model = models.PeerGroupAddressFamily + fields = [ + "id", + "url", + "afi_safi", + "peer_group", + "import_policy", "export_policy", + "multipath", + "extra_attributes", + ] + + +class PeerEndpointAddressFamilySerializer(NautobotModelSerializer, ExtraAttributesSerializerMixin): + """REST API serializer for PeerEndpointAddressFamily records.""" + + url = serializers.HyperlinkedIdentityField( + view_name="plugins-api:nautobot_bgp_models-api:peerendpointaddressfamily-detail" + ) + + peer_endpoint = NestedPeerEndpointSerializer(required=True) # noqa: F405 + + class Meta: + model = models.PeerEndpointAddressFamily + fields = [ + "id", + "url", + "afi_safi", + "peer_endpoint", "import_policy", + "export_policy", + "multipath", + "extra_attributes", ] diff --git a/nautobot_bgp_models/api/urls.py b/nautobot_bgp_models/api/urls.py index f645bafe..19b8ac56 100644 --- a/nautobot_bgp_models/api/urls.py +++ b/nautobot_bgp_models/api/urls.py @@ -14,6 +14,8 @@ router.register("peer-endpoints", views.PeerEndpointViewSet) router.register("peerings", views.PeeringViewSet) router.register("address-families", views.AddressFamilyViewSet) +router.register("peer-group-address-families", views.PeerGroupAddressFamilyViewSet) +router.register("peer-endpoint-address-families", views.PeerEndpointAddressFamilyViewSet) router.register("routing-instances", views.BGPRoutingInstanceViewSet) urlpatterns = router.urls diff --git a/nautobot_bgp_models/api/views.py b/nautobot_bgp_models/api/views.py index 77b0523e..01316a3b 100644 --- a/nautobot_bgp_models/api/views.py +++ b/nautobot_bgp_models/api/views.py @@ -113,3 +113,19 @@ class AddressFamilyViewSet(InheritableFieldsViewSetMixin, PluginModelViewSet): queryset = models.AddressFamily.objects.all() serializer_class = serializers.AddressFamilySerializer filterset_class = filters.AddressFamilyFilterSet + + +class PeerGroupAddressFamilyViewSet(InheritableFieldsViewSetMixin, PluginModelViewSet): + """REST API viewset for PeerGroupAddressFamily records.""" + + queryset = models.PeerGroupAddressFamily.objects.all() + serializer_class = serializers.PeerGroupAddressFamilySerializer + filterset_class = filters.PeerGroupAddressFamilyFilterSet + + +class PeerEndpointAddressFamilyViewSet(InheritableFieldsViewSetMixin, PluginModelViewSet): + """REST API viewset for PeerEndpointAddressFamily records.""" + + queryset = models.PeerEndpointAddressFamily.objects.all() + serializer_class = serializers.PeerEndpointAddressFamilySerializer + filterset_class = filters.PeerEndpointAddressFamilyFilterSet diff --git a/nautobot_bgp_models/choices.py b/nautobot_bgp_models/choices.py index e4ba9f32..ddfbb481 100644 --- a/nautobot_bgp_models/choices.py +++ b/nautobot_bgp_models/choices.py @@ -7,9 +7,11 @@ class AFISAFIChoices(ChoiceSet): """Choices for the "afi_safi" field on the AddressFamily model.""" AFI_IPV4_UNICAST = "ipv4_unicast" + AFI_IPV4_LABELED_UNICAST = "ipv4_labeled_unicast" AFI_IPV4_MULTICAST = "ipv4_multicast" AFI_IPV6_UNICAST = "ipv6_unicast" + AFI_IPV6_LABELED_UNICAST = "ipv6_labeled_unicast" AFI_IPV6_MULTICAST = "ipv6_multicast" AFI_IPV4_FLOWSPEC = "ipv4_flowspec" @@ -26,9 +28,11 @@ class AFISAFIChoices(ChoiceSet): CHOICES = ( (AFI_IPV4_UNICAST, "IPv4 Unicast"), + (AFI_IPV4_LABELED_UNICAST, "IPv4 Labeled Unicast"), (AFI_IPV4_MULTICAST, "IPv4 Multicast"), (AFI_IPV4_FLOWSPEC, "IPv4 Flowspec"), (AFI_IPV6_UNICAST, "IPv6 Unicast"), + (AFI_IPV6_LABELED_UNICAST, "IPv6 Labeled Unicast"), (AFI_IPV6_MULTICAST, "IPv6 Multicast"), (AFI_IPV6_FLOWSPEC, "IPv6 Flowspec"), (AFI_VPNV4_UNICAST, "VPNv4 Unicast"), diff --git a/nautobot_bgp_models/dolt_compat.py b/nautobot_bgp_models/dolt_compat.py index 6d9a7466..03361303 100644 --- a/nautobot_bgp_models/dolt_compat.py +++ b/nautobot_bgp_models/dolt_compat.py @@ -20,8 +20,10 @@ "autonomoussystem": tables.AutonomousSystemTable, "peeringrole": tables.PeeringRoleTable, "peergroup": tables.PeerGroupTable, + "peergroupaddressfamily": tables.PeerGroupAddressFamilyTable, "peergrouptemplate": tables.PeerGroupTemplateTable, "peerendpoint": tables.PeerEndpointTable, + "peerendpointaddressfamily": tables.PeerEndpointAddressFamilyTable, "peering": tables.PeeringTable, "addressfamily": tables.AddressFamilyTable, } diff --git a/nautobot_bgp_models/filters.py b/nautobot_bgp_models/filters.py index dce24768..99af6e27 100644 --- a/nautobot_bgp_models/filters.py +++ b/nautobot_bgp_models/filters.py @@ -121,6 +121,13 @@ class PeerGroupFilterSet(BaseFilterSet): label="BGP Routing Instance ID", ) + vrf = django_filters.ModelMultipleChoiceFilter( + field_name="vrf__name", + queryset=VRF.objects.all(), + to_field_name="name", + label="VRF (name)", + ) + role = django_filters.ModelMultipleChoiceFilter( field_name="role__slug", queryset=models.PeeringRole.objects.all(), @@ -271,3 +278,71 @@ class Meta: "afi_safi", "vrf", ] + + +class PeerGroupAddressFamilyFilterSet(BaseFilterSet, CreatedUpdatedFilterSet, CustomFieldModelFilterSet): + """Filtering of PeerGroupAddressFamily records.""" + + q = django_filters.CharFilter( + method="search", + label="Search", + ) + + afi_safi = django_filters.MultipleChoiceFilter(choices=choices.AFISAFIChoices) + + peer_group = django_filters.ModelMultipleChoiceFilter( + label="Peer Group (ID)", + queryset=models.PeerGroup.objects.all(), + ) + + class Meta: + model = models.PeerGroupAddressFamily + fields = [ + "id", + "afi_safi", + "peer_group", + ] + + def search(self, queryset, name, value): # pylint: disable=unused-argument + """Free-text search method implementation.""" + if not value.strip(): + return queryset + return queryset.filter( + Q(afi_safi__icontains=value) + | Q(peer_group__name__icontains=value) + | Q(peer_group__description__icontains=value) + ).distinct() + + +class PeerEndpointAddressFamilyFilterSet(BaseFilterSet, CreatedUpdatedFilterSet, CustomFieldModelFilterSet): + """Filtering of PeerEndpointAddressFamily records.""" + + q = django_filters.CharFilter( + method="search", + label="Search", + ) + + afi_safi = django_filters.MultipleChoiceFilter(choices=choices.AFISAFIChoices) + + peer_endpoint = django_filters.ModelMultipleChoiceFilter( + label="Peer Endpoint (ID)", + queryset=models.PeerEndpoint.objects.all(), + ) + + class Meta: + model = models.PeerEndpointAddressFamily + fields = [ + "id", + "afi_safi", + "peer_endpoint", + ] + + def search(self, queryset, name, value): # pylint: disable=unused-argument + """Free-text search method implementation.""" + if not value.strip(): + return queryset + return queryset.filter( + Q(afi_safi__icontains=value) + | Q(peer_endpoint__routing_instance__device__name__iexact=value) + | Q(peer_endpoint__description__icontains=value) + ).distinct() diff --git a/nautobot_bgp_models/forms.py b/nautobot_bgp_models/forms.py index 63b62b32..8e497e76 100644 --- a/nautobot_bgp_models/forms.py +++ b/nautobot_bgp_models/forms.py @@ -249,13 +249,6 @@ class Meta: class PeerGroupForm(NautobotModelForm): """Form for creating/updating PeerGroup records.""" - def __init__(self, *args, **kwargs): - """Init.""" - super().__init__(*args, **kwargs) - - if self.initial.get("routing_instance"): - self.fields["routing_instance"].disabled = True - routing_instance = DynamicModelChoiceField( queryset=models.BGPRoutingInstance.objects.all(), required=True, @@ -263,11 +256,20 @@ def __init__(self, *args, **kwargs): help_text="Specify related Routing Instance (Device)", ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label="VRF", + ) + source_ip = DynamicModelChoiceField( queryset=IPAddress.objects.all(), required=False, label="Source IP Address", - query_params={"nautobot_bgp_models_ips_bgp_routing_instance": "$routing_instance"}, + query_params={ + "nautobot_bgp_models_ips_bgp_routing_instance": "$routing_instance", + "vrf": "$vrf", + }, ) source_interface = DynamicModelChoiceField( @@ -294,6 +296,7 @@ class Meta: fields = ( "routing_instance", "name", + "vrf", "peergroup_template", "description", "enabled", @@ -301,8 +304,6 @@ class Meta: "source_ip", "source_interface", "autonomous_system", - "import_policy", - "export_policy", "secret", "extra_attributes", ) @@ -343,8 +344,6 @@ class Meta: "enabled", "role", "autonomous_system", - "import_policy", - "export_policy", "extra_attributes", ) @@ -382,6 +381,8 @@ class PeerGroupFilterForm(NautobotFilterForm): queryset=models.AutonomousSystem.objects.all(), to_field_name="asn", required=False ) + vrf = DynamicModelMultipleChoiceField(queryset=VRF.objects.all(), required=False) + class PeerGroupTemplateFilterForm(NautobotFilterForm): """Form for filtering PeerGroupTemplate records in combination with PeerGroupTemplateFilterSet.""" @@ -435,6 +436,12 @@ class PeerGroupCSVForm(CustomFieldModelCSVForm): required=False, ) + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + to_field_name="name", + required=False, + ) + autonomous_system = CSVModelChoiceField( queryset=models.AutonomousSystem.objects.all(), to_field_name="asn", @@ -540,8 +547,6 @@ class Meta: "source_interface", "autonomous_system", "peer_group", - "import_policy", - "export_policy", "secret", "extra_attributes", ) @@ -636,26 +641,20 @@ class AddressFamilyForm(NautobotModelForm): label="VRF", ) - multipath = forms.NullBooleanField(required=False, widget=utilities_forms.BulkEditNullBooleanSelect()) - class Meta: model = models.AddressFamily fields = ( "routing_instance", "afi_safi", "vrf", - "import_policy", - "export_policy", - "multipath", + "extra_attributes", ) class AddressFamilyBulkEditForm(NautobotBulkEditForm): """Form for bulk-editing multiple AddressFamily records.""" - pk = forms.ModelMultipleChoiceField( - queryset=models.AutonomousSystem.objects.all(), widget=forms.MultipleHiddenInput() - ) + pk = forms.ModelMultipleChoiceField(queryset=models.AddressFamily.objects.all(), widget=forms.MultipleHiddenInput()) class Meta: nullable_fields = [] @@ -684,3 +683,137 @@ class AddressFamilyCSVForm(CustomFieldModelCSVForm): class Meta: model = models.AddressFamily fields = models.AddressFamily.csv_headers + + +class PeerGroupAddressFamilyForm(NautobotModelForm): + """Form for creating/updating PeerGroupAddressFamily records.""" + + peer_group = DynamicModelChoiceField( + queryset=models.PeerGroup.objects.all(), + required=True, + label="BGP Peer Group", + ) + + afi_safi = forms.ChoiceField( + label="AFI-SAFI", + choices=choices.AFISAFIChoices, + required=False, + widget=utilities_forms.StaticSelect2(), + ) + + multipath = forms.NullBooleanField(required=False, widget=utilities_forms.BulkEditNullBooleanSelect()) + + class Meta: + model = models.PeerGroupAddressFamily + fields = ( + "peer_group", + "afi_safi", + "import_policy", + "export_policy", + "multipath", + "extra_attributes", + ) + + +class PeerGroupAddressFamilyBulkEditForm(NautobotBulkEditForm): + """Form for bulk-editing multiple PeerGroupAddressFamily records.""" + + pk = forms.ModelMultipleChoiceField( + queryset=models.PeerGroupAddressFamily.objects.all(), widget=forms.MultipleHiddenInput() + ) + import_policy = forms.CharField(max_length=100, required=False) + export_policy = forms.CharField(max_length=100, required=False) + multipath = forms.NullBooleanField(required=False, widget=utilities_forms.BulkEditNullBooleanSelect()) + + class Meta: + nullable_fields = ["import_policy", "export_policy", "multipath"] + + +class PeerGroupAddressFamilyFilterForm(NautobotFilterForm): + """Form for filtering PeerGroupAddressFamily records in combination with PeerGroupAddressFamilyFilterSet.""" + + model = models.PeerGroupAddressFamily + + peer_group = DynamicModelMultipleChoiceField(queryset=models.PeerGroup.objects.all(), required=False) + + afi_safi = forms.MultipleChoiceField( + label="AFI-SAFI", + choices=choices.AFISAFIChoices, + required=False, + widget=utilities_forms.StaticSelect2Multiple(), + ) + + +class PeerGroupAddressFamilyCSVForm(CustomFieldModelCSVForm): + """Form for importing PeerGroupAddressFamily from CSV data.""" + + class Meta: + model = models.PeerGroupAddressFamily + fields = models.PeerGroupAddressFamily.csv_headers + + +class PeerEndpointAddressFamilyForm(NautobotModelForm): + """Form for creating/updating PeerEndpointAddressFamily records.""" + + peer_endpoint = DynamicModelChoiceField( + queryset=models.PeerEndpoint.objects.all(), + required=True, + label="BGP Peer Endpoint", + ) + + afi_safi = forms.ChoiceField( + label="AFI-SAFI", + choices=choices.AFISAFIChoices, + required=False, + widget=utilities_forms.StaticSelect2(), + ) + + multipath = forms.NullBooleanField(required=False, widget=utilities_forms.BulkEditNullBooleanSelect()) + + class Meta: + model = models.PeerGroupAddressFamily + fields = ( + "peer_endpoint", + "afi_safi", + "import_policy", + "export_policy", + "multipath", + "extra_attributes", + ) + + +class PeerEndpointAddressFamilyBulkEditForm(NautobotBulkEditForm): + """Form for bulk-editing multiple PeerEndpointAddressFamily records.""" + + pk = forms.ModelMultipleChoiceField( + queryset=models.PeerEndpointAddressFamily.objects.all(), widget=forms.MultipleHiddenInput() + ) + import_policy = forms.CharField(max_length=100, required=False) + export_policy = forms.CharField(max_length=100, required=False) + multipath = forms.NullBooleanField(required=False, widget=utilities_forms.BulkEditNullBooleanSelect()) + + class Meta: + nullable_fields = ["import_policy", "export_policy", "multipath"] + + +class PeerEndpointAddressFamilyFilterForm(NautobotFilterForm): + """Form for filtering PeerEndpointAddressFamily records in combination with PeerEndpointAddressFamilyFilterSet.""" + + model = models.PeerEndpointAddressFamily + + peer_endpoint = DynamicModelMultipleChoiceField(queryset=models.PeerEndpoint.objects.all(), required=False) + + afi_safi = forms.MultipleChoiceField( + label="AFI-SAFI", + choices=choices.AFISAFIChoices, + required=False, + widget=utilities_forms.StaticSelect2Multiple(), + ) + + +class PeerEndpointAddressFamilyCSVForm(CustomFieldModelCSVForm): + """Form for importing PeerEndpointAddressFamily from CSV data.""" + + class Meta: + model = models.PeerEndpointAddressFamily + fields = models.PeerEndpointAddressFamily.csv_headers diff --git a/nautobot_bgp_models/migrations/0003_peergroupaddressfamily_peerendpointaddressfamily.py b/nautobot_bgp_models/migrations/0003_peergroupaddressfamily_peerendpointaddressfamily.py new file mode 100644 index 00000000..1f65b366 --- /dev/null +++ b/nautobot_bgp_models/migrations/0003_peergroupaddressfamily_peerendpointaddressfamily.py @@ -0,0 +1,173 @@ +# pylint: disable=missing-module-docstring,missing-function-docstring,missing-class-docstring,invalid-name +# Generated by Django 3.2.16 on 2023-09-18 19:03 +import uuid + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import nautobot.extras.models.mixins + + +class Migration(migrations.Migration): + dependencies = [ + ("ipam", "0008_prefix_vlan_vlangroup_location"), + ("nautobot_bgp_models", "0002_viewsets_migration"), + ] + + operations = [ + migrations.RemoveField( + model_name="addressfamily", + name="export_policy", + ), + migrations.RemoveField( + model_name="addressfamily", + name="import_policy", + ), + migrations.RemoveField( + model_name="addressfamily", + name="multipath", + ), + migrations.RemoveField( + model_name="peerendpoint", + name="export_policy", + ), + migrations.RemoveField( + model_name="peerendpoint", + name="import_policy", + ), + migrations.RemoveField( + model_name="peergroup", + name="export_policy", + ), + migrations.RemoveField( + model_name="peergroup", + name="import_policy", + ), + migrations.RemoveField( + model_name="peergrouptemplate", + name="export_policy", + ), + migrations.RemoveField( + model_name="peergrouptemplate", + name="import_policy", + ), + migrations.AddField( + model_name="addressfamily", + name="extra_attributes", + field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + migrations.AddField( + model_name="peergroup", + name="vrf", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="peer_groups", + to="ipam.vrf", + ), + ), + migrations.AlterField( + model_name="addressfamily", + name="vrf", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="address_families", + to="ipam.vrf", + ), + ), + migrations.AlterUniqueTogether( + name="peergroup", + unique_together={("name", "routing_instance", "vrf")}, + ), + migrations.CreateModel( + name="PeerGroupAddressFamily", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True + ), + ), + ("created", models.DateField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "_custom_field_data", + models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + ( + "extra_attributes", + models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + ("afi_safi", models.CharField(max_length=64)), + ("import_policy", models.CharField(blank=True, default="", max_length=100)), + ("export_policy", models.CharField(blank=True, default="", max_length=100)), + ("multipath", models.BooleanField(blank=True, null=True)), + ( + "peer_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="address_families", + to="nautobot_bgp_models.peergroup", + ), + ), + ], + options={ + "verbose_name": "BGP peer-group address family", + "verbose_name_plural": "BGP Peer-Group Address Families", + "ordering": ["-peer_group"], + "unique_together": {("peer_group", "afi_safi")}, + }, + bases=( + models.Model, + nautobot.extras.models.mixins.DynamicGroupMixin, + nautobot.extras.models.mixins.NotesMixin, + ), + ), + migrations.CreateModel( + name="PeerEndpointAddressFamily", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True + ), + ), + ("created", models.DateField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ( + "_custom_field_data", + models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + ( + "extra_attributes", + models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + ("afi_safi", models.CharField(max_length=64)), + ("import_policy", models.CharField(blank=True, default="", max_length=100)), + ("export_policy", models.CharField(blank=True, default="", max_length=100)), + ("multipath", models.BooleanField(blank=True, null=True)), + ( + "peer_endpoint", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="address_families", + to="nautobot_bgp_models.peerendpoint", + ), + ), + ], + options={ + "verbose_name": "BGP peer-endpoint address family", + "verbose_name_plural": "BGP Peer-Endpoint Address Families", + "ordering": ["-peer_endpoint"], + "unique_together": {("peer_endpoint", "afi_safi")}, + }, + bases=( + models.Model, + nautobot.extras.models.mixins.DynamicGroupMixin, + nautobot.extras.models.mixins.NotesMixin, + ), + ), + ] diff --git a/nautobot_bgp_models/models.py b/nautobot_bgp_models/models.py index 260adaf0..c44e30b4 100644 --- a/nautobot_bgp_models/models.py +++ b/nautobot_bgp_models/models.py @@ -1,5 +1,4 @@ -"""Django model definitions for nautobot_bgp_models.""" - +"""BGP data models.""" import functools from collections import OrderedDict @@ -294,10 +293,6 @@ class PeerGroupTemplate(PrimaryModel, BGPExtraAttributesMixin): on_delete=models.PROTECT, ) - import_policy = models.CharField(max_length=100, default="", blank=True) - - export_policy = models.CharField(max_length=100, default="", blank=True) - secret = models.ForeignKey( to="extras.Secret", on_delete=models.PROTECT, @@ -305,14 +300,12 @@ class PeerGroupTemplate(PrimaryModel, BGPExtraAttributesMixin): blank=True, null=True, ) - csv_headers = ["name", "import_policy", "export_policy", "autonomous_system", "enabled", "role"] + csv_headers = ["name", "autonomous_system", "enabled", "role"] def to_csv(self): """Render a PeerGroupTemplate record to CSV fields.""" return ( self.name, - self.import_policy, - self.export_policy, self.autonomous_system.asn if self.autonomous_system else None, self.enabled, self.role.name if self.role else None, @@ -347,8 +340,6 @@ class PeerGroup(PrimaryModel, InheritanceMixin, BGPExtraAttributesMixin): "autonomous_system": ["peergroup_template", "routing_instance"], "description": ["peergroup_template"], "enabled": ["peergroup_template"], - "export_policy": ["peergroup_template"], - "import_policy": ["peergroup_template"], "role": ["peergroup_template"], } @@ -363,6 +354,15 @@ class PeerGroup(PrimaryModel, InheritanceMixin, BGPExtraAttributesMixin): to=PeeringRole, on_delete=models.PROTECT, related_name="peer_groups", blank=True, null=True ) + vrf = models.ForeignKey( + to="ipam.VRF", + verbose_name="VRF", + related_name="peer_groups", + blank=True, + null=True, + on_delete=models.PROTECT, + ) + description = models.CharField(max_length=200, blank=True) enabled = models.BooleanField(default=True) @@ -400,10 +400,6 @@ class PeerGroup(PrimaryModel, InheritanceMixin, BGPExtraAttributesMixin): verbose_name="Source Interface", ) - import_policy = models.CharField(max_length=100, default="", blank=True) - - export_policy = models.CharField(max_length=100, default="", blank=True) - secret = models.ForeignKey( to="extras.Secret", on_delete=models.PROTECT, @@ -416,8 +412,7 @@ class PeerGroup(PrimaryModel, InheritanceMixin, BGPExtraAttributesMixin): "name", "routing_instance", "autonomous_system", - "import_policy", - "export_policy", + "vrf", "source_interface", "source_ip", "peergroup_template", @@ -431,8 +426,7 @@ def to_csv(self): self.name, self.routing_instance.pk, self.autonomous_system.asn if self.autonomous_system else None, - self.import_policy, - self.export_policy, + self.vrf.name if self.vrf else None, self.source_interface.name if self.source_interface else None, self.source_ip.address if self.source_ip else None, self.peergroup_template.name if self.peergroup_template else None, @@ -442,14 +436,16 @@ def to_csv(self): def __str__(self): """String.""" - return f"{self.name}" + if self.vrf: + return f"{self.name} (VRF {self.vrf}) - {self.routing_instance.device}" + return f"{self.name} - {self.routing_instance.device}" def get_absolute_url(self): """Get the URL for detailed view of a single PeerGroup.""" return reverse("plugins:nautobot_bgp_models:peergroup", args=[self.pk]) class Meta: - unique_together = [("name", "routing_instance")] + unique_together = [("name", "routing_instance", "vrf")] verbose_name = "BGP Peer Group" def clean(self): @@ -458,14 +454,43 @@ def clean(self): if self.source_ip and self.source_interface: raise ValidationError("Can not set both IP and Update source options") - # Ensure source_interface interface has 1 IP Address assigned - if self.source_interface and self.source_interface.ip_addresses.count() != 1: - raise ValidationError("Source Interface must have only 1 IP Address assigned.") - - # Ensure IP related to the routing instance - if self.routing_instance and self.source_ip: + if self.source_interface: + # Ensure source_interface interface has 1 IP Address assigned + if self.source_interface.ip_addresses.count() != 1: + raise ValidationError("Source Interface must have only 1 IP Address assigned.") + # Ensure VRF membership + if self.source_interface.ip_addresses.all().first().vrf != self.vrf: + raise ValidationError( + f"VRF mismatch between PeerGroup VRF ({self.vrf}) " + f"and selected source interface VRF ({self.source_interface.ip_addresses.all().first().vrf})" + ) + + if self.source_ip: + # Ensure IP related to the routing instance if self.source_ip not in IPAddress.objects.filter(interface__device_id=self.routing_instance.device.id): raise ValidationError("Group IP not associated with Routing Instance") + # Ensure VRF membership + if self.source_ip.vrf != self.vrf: + raise ValidationError( + f"VRF mismatch between PeerGroup VRF ({self.vrf}) and selected source IP VRF ({self.source_ip.vrf})" + ) + + if self.present_in_database: + original = self.__class__.objects.get(id=self.id) + if self.vrf != original.vrf and self.endpoints.exists(): + raise ValidationError("Cannot change VRF of PeerGroup that has existing PeerEndpoints in this VRF.") + + def validate_unique(self, exclude=None): + """Validate uniqueness, handling NULL != NULL for VRF foreign key.""" + if ( + self.vrf is None + and self.__class__.objects.exclude(id=self.id) + .filter(routing_instance=self.routing_instance, name=self.name, vrf__isnull=True) + .exists() + ): + raise ValidationError(f"Duplicate Peer Group name for {self.routing_instance}") + + super().validate_unique(exclude) @extras_features( @@ -485,8 +510,6 @@ class PeerEndpoint(PrimaryModel, InheritanceMixin, BGPExtraAttributesMixin): "autonomous_system": ["peer_group", "peer_group.peergroup_template", "routing_instance"], "description": ["peer_group", "peer_group.peergroup_template"], "enabled": ["peer_group", "peer_group.peergroup_template"], - "export_policy": ["peer_group", "peer_group.peergroup_template"], - "import_policy": ["peer_group", "peer_group.peergroup_template"], "source_ip": ["peer_group"], "source_interface": ["peer_group"], "role": ["peer_group.role", "peer_group.peergroup_template.role"], @@ -589,10 +612,6 @@ def local_ip(self): return None - import_policy = models.CharField(max_length=100, default="", blank=True) - - export_policy = models.CharField(max_length=100, default="", blank=True) - secret = models.ForeignKey( to="extras.Secret", on_delete=models.PROTECT, @@ -603,10 +622,11 @@ def local_ip(self): def __str__(self): """String.""" + asn, _, _ = self.get_inherited_field(field_name="autonomous_system") if self.routing_instance and self.routing_instance.device: - return f"{self.routing_instance.device}" + return f"{self.routing_instance.device} {self.local_ip} ({asn})" - return f"{self.local_ip} ({self.autonomous_system})" + return f"{self.local_ip} ({asn})" def get_absolute_url(self): """Get the URL for detailed view of a single PeerEndpoint.""" @@ -646,6 +666,14 @@ def clean(self): elif not self.routing_instance and local_ip_value.interface.exists(): raise ValidationError("Must specify Routing Instance for this IP Address") + # Enforce peer group VRF membership + if self.peer_group is not None: + if local_ip_value.vrf != self.peer_group.vrf: + raise ValidationError( + f"VRF mismatch between {local_ip_value} (VRF {local_ip_value.vrf}) " + f"and peer-group {self.peer_group.name} (VRF {self.peer_group.vrf})" + ) + @extras_features( "custom_fields", @@ -715,14 +743,17 @@ def validate_peers(self): "relationships", "webhooks", ) -class AddressFamily(OrganizationalModel): - """Address-family (AFI-SAFI) model.""" +class AddressFamily(OrganizationalModel, BGPExtraAttributesMixin): + """Address-family (AFI-SAFI) model for the RoutingInstance and VRF levels of configuration.""" + + extra_attributes_inheritance = [] afi_safi = models.CharField(max_length=64, choices=AFISAFIChoices, verbose_name="AFI-SAFI") vrf = models.ForeignKey( to="ipam.VRF", verbose_name="VRF", + related_name="address_families", blank=True, null=True, on_delete=models.PROTECT, @@ -735,12 +766,6 @@ class AddressFamily(OrganizationalModel): verbose_name="BGP Routing Instance", ) - import_policy = models.CharField(max_length=100, default="", blank=True) - - export_policy = models.CharField(max_length=100, default="", blank=True) - - multipath = models.BooleanField(blank=True, null=True) - class Meta: ordering = ["-routing_instance", "-vrf"] verbose_name = "BGP address family" @@ -750,9 +775,6 @@ class Meta: "routing_instance", "vrf", "afi_safi", - "import_policy", - "export_policy", - "multipath", ] def to_csv(self): @@ -761,9 +783,6 @@ def to_csv(self): self.routing_instance.pk, self.vrf.name if self.vrf else None, self.afi_safi, - self.import_policy, - self.export_policy, - self.multipath, ) def __str__(self): @@ -796,3 +815,164 @@ def validate_unique(self, exclude=None): raise ValidationError("Duplicate Address Family") super().validate_unique(exclude) + + +@extras_features( + "custom_fields", + "custom_links", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) +class PeerGroupAddressFamily(OrganizationalModel, InheritanceMixin, BGPExtraAttributesMixin): + """Address-family (AFI-SAFI) model for PeerGroup-specific configuration.""" + + @property + def parent_address_family(self): + """The routing-instance AddressFamily (if any) that this PeerGroupAddressFamily inherits from.""" + try: + return self.peer_group.routing_instance.address_families.get( + vrf=self.peer_group.vrf, afi_safi=self.afi_safi + ) + except AddressFamily.DoesNotExist: + return None + + extra_attributes_inheritance = ["parent_address_family"] + + property_inheritance = {} # no non-extra-attributes properties inherited from AddressFamily at this time + + afi_safi = models.CharField(max_length=64, choices=AFISAFIChoices, verbose_name="AFI-SAFI") + + peer_group = models.ForeignKey( + to=PeerGroup, + on_delete=models.CASCADE, + related_name="address_families", + ) + + import_policy = models.CharField(max_length=100, default="", blank=True) + + export_policy = models.CharField(max_length=100, default="", blank=True) + + multipath = models.BooleanField(blank=True, null=True) + + class Meta: + ordering = ["-peer_group"] + unique_together = ["peer_group", "afi_safi"] + verbose_name = "BGP peer-group address family" + verbose_name_plural = "BGP Peer-Group Address Families" + + csv_headers = [ + "peer_group", + "afi_safi", + "import_policy", + "export_policy", + "multipath", + ] + + def to_csv(self): + """Return a list of values for use in CSV export.""" + return ( + str(self.peer_group), + self.afi_safi, + self.import_policy, + self.export_policy, + str(self.multipath), + ) + + def __str__(self): + """String representation.""" + return f"{self.afi_safi} AF - {self.peer_group}" + + def get_absolute_url(self): + """Absolute URL of a record.""" + return reverse("plugins:nautobot_bgp_models:peergroupaddressfamily", args=[self.pk]) + + +@extras_features( + "custom_fields", + "custom_links", + "custom_validators", + "export_templates", + "graphql", + "relationships", + "webhooks", +) +class PeerEndpointAddressFamily(OrganizationalModel, InheritanceMixin, BGPExtraAttributesMixin): + """Address-family (AFI-SAFI) model for PeerEndpoint-specific configuration.""" + + @property + def parent_peer_group_address_family(self): + """The PeerGroupAddressFamily (if any) that this PeerEndpointAddressFamily inherits from.""" + try: + parent_pg = self.peer_endpoint.peer_group + if parent_pg is not None: + return parent_pg.address_families.get(afi_safi=self.afi_safi) + except PeerGroupAddressFamily.DoesNotExist: + pass + return None + + @property + def parent_address_family(self): + """The routing-instance AddressFamily (if any) that this PeerEndpointAddressFamily inherits from.""" + try: + return self.peer_endpoint.routing_instance.address_families.get( + vrf=self.peer_endpoint.local_ip.vrf, afi_safi=self.afi_safi + ) + except AddressFamily.DoesNotExist: + return None + + extra_attributes_inheritance = ["parent_peer_group_address_family", "parent_address_family"] + + property_inheritance = { + "import_policy": ["parent_peer_group_address_family"], + "export_policy": ["parent_peer_group_address_family"], + "multipath": ["parent_peer_group_address_family"], + } + + afi_safi = models.CharField(max_length=64, choices=AFISAFIChoices, verbose_name="AFI-SAFI") + + peer_endpoint = models.ForeignKey( + to=PeerEndpoint, + on_delete=models.CASCADE, + related_name="address_families", + ) + + import_policy = models.CharField(max_length=100, default="", blank=True) + + export_policy = models.CharField(max_length=100, default="", blank=True) + + multipath = models.BooleanField(blank=True, null=True) + + class Meta: + ordering = ["-peer_endpoint"] + unique_together = ["peer_endpoint", "afi_safi"] + verbose_name = "BGP peer-endpoint address family" + verbose_name_plural = "BGP Peer-Endpoint Address Families" + + csv_headers = [ + "peer_endpoint", + "afi_safi", + "import_policy", + "export_policy", + "multipath", + ] + + def to_csv(self): + """Return a list of values for use in CSV export.""" + return ( + str(self.peer_endpoint), + self.afi_safi, + self.import_policy, + self.export_policy, + str(self.multipath), + ) + + def __str__(self): + """String representation.""" + return f"{self.afi_safi} AF - {self.peer_endpoint}" + + def get_absolute_url(self): + """Absolute URL of a record.""" + return reverse("plugins:nautobot_bgp_models:peerendpointaddressfamily", args=[self.pk]) diff --git a/nautobot_bgp_models/navigation.py b/nautobot_bgp_models/navigation.py index cc901a43..f83fdbd1 100644 --- a/nautobot_bgp_models/navigation.py +++ b/nautobot_bgp_models/navigation.py @@ -77,6 +77,21 @@ ), ), ), + NavMenuItem( + link="plugins:nautobot_bgp_models:addressfamily_list", + name="Address-families (AFI-SAFI)", + permissions=["nautobot_bgp_models.view_addressfamily"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_bgp_models:addressfamily_add", + permissions=["nautobot_bgp_models.add_addressfamily"], + ), + NavMenuImportButton( + link="plugins:nautobot_bgp_models:addressfamily_import", + permissions=["nautobot_bgp_models.add_addressfamily"], + ), + ), + ), NavMenuItem( link="plugins:nautobot_bgp_models:peergroup_list", name="Peer Groups", @@ -93,17 +108,17 @@ ), ), NavMenuItem( - link="plugins:nautobot_bgp_models:addressfamily_list", - name="Address-families (AFI-SAFI)", - permissions=["nautobot_bgp_models.view_addressfamily"], + link="plugins:nautobot_bgp_models:peergroupaddressfamily_list", + name="Peer Group Address-families (AFI-SAFI)", + permissions=["nautobot_bgp_models.view_peergroupaddressfamily"], buttons=( NavMenuAddButton( - link="plugins:nautobot_bgp_models:addressfamily_add", - permissions=["nautobot_bgp_models.add_addressfamily"], + link="plugins:nautobot_bgp_models:peergroupaddressfamily_add", + permissions=["nautobot_bgp_models.add_peergroupaddressfamily"], ), NavMenuImportButton( - link="plugins:nautobot_bgp_models:addressfamily_import", - permissions=["nautobot_bgp_models.add_addressfamily"], + link="plugins:nautobot_bgp_models:peergroupaddressfamily_import", + permissions=["nautobot_bgp_models.add_peergroupaddressfamily"], ), ), ), @@ -124,6 +139,17 @@ ), ), ), + NavMenuItem( + link="plugins:nautobot_bgp_models:peerendpointaddressfamily_list", + name="Peer Endpoint Address-families (AFI-SAFI)", + permissions=["nautobot_bgp_models.view_peerendpointaddressfamily"], + buttons=( + NavMenuAddButton( + link="plugins:nautobot_bgp_models:peerendpointaddressfamily_add", + permissions=["nautobot_bgp_models.add_peerendpointaddressfamily"], + ), + ), + ), ), ), ), diff --git a/nautobot_bgp_models/tables.py b/nautobot_bgp_models/tables.py index c2c4a8b8..e163df51 100644 --- a/nautobot_bgp_models/tables.py +++ b/nautobot_bgp_models/tables.py @@ -86,6 +86,7 @@ class PeerGroupTable(BaseTable): name = tables.LinkColumn() peergroup_template = tables.LinkColumn() routing_instance = tables.LinkColumn() + vrf = tables.LinkColumn() enabled = BooleanColumn() role = ColoredLabelColumn() autonomous_system = tables.LinkColumn() @@ -102,11 +103,10 @@ class Meta(BaseTable.Meta): "name", "peergroup_template", "routing_instance", + "vrf", "enabled", "role", "autonomous_system", - "import_policy", - "export_policy", "source_ip", "source_interface", "secret", @@ -116,11 +116,10 @@ class Meta(BaseTable.Meta): "name", "peergroup_template", "routing_instance", + "vrf", "enabled", "role", "autonomous_system", - "import_policy", - "export_policy", "actions", ) @@ -144,8 +143,6 @@ class Meta(BaseTable.Meta): "enabled", "role", "autonomous_system", - "import_policy", - "export_policy", "secret", ) default_columns = ( @@ -154,8 +151,6 @@ class Meta(BaseTable.Meta): "enabled", "role", "autonomous_system", - "import_policy", - "export_policy", "secret", # "actions", ) @@ -194,8 +189,6 @@ class Meta(BaseTable.Meta): "peering", "vrf", "peer_group", - "import_policy", - "export_policy", ) default_columns = ( "pk", @@ -210,8 +203,6 @@ class Meta(BaseTable.Meta): "peering", "vrf", "peer_group", - "import_policy", - "export_policy", ) @@ -270,9 +261,6 @@ class Meta(BaseTable.Meta): "routing_instance", "afi_safi", "vrf", - "import_policy", - "export_policy", - "multipath", ) default_columns = ( "pk", @@ -280,6 +268,75 @@ class Meta(BaseTable.Meta): "routing_instance", "afi_safi", "vrf", + "actions", + ) + + +class PeerGroupAddressFamilyTable(BaseTable): + """Table representation of PeerGroupAddressFamily records.""" + + pk = ToggleColumn() + peer_group_address_family = tables.LinkColumn( + viewname="plugins:nautobot_bgp_models:peergroupaddressfamily", + args=[A("pk")], + text=str, + ) + peer_group = tables.LinkColumn() + afi_safi = tables.Column() + actions = ButtonsColumn(model=models.PeerGroupAddressFamily) + + class Meta(BaseTable.Meta): + model = models.PeerGroupAddressFamily + fields = ( + "pk", + "peer_group_address_family", + "peer_group", + "afi_safi", + "import_policy", + "export_policy", + "multipath", + ) + default_columns = ( + "pk", + "peer_group_address_family", + "peer_group", + "afi_safi", + "import_policy", + "export_policy", + "multipath", + "actions", + ) + + +class PeerEndpointAddressFamilyTable(BaseTable): + """Table representation of PeerEndpointAddressFamily records.""" + + pk = ToggleColumn() + peer_endpoint_address_family = tables.LinkColumn( + viewname="plugins:nautobot_bgp_models:peerendpointaddressfamily", + args=[A("pk")], + text=str, + ) + peer_endpoint = tables.LinkColumn() + afi_safi = tables.Column() + actions = ButtonsColumn(model=models.PeerEndpointAddressFamily) + + class Meta(BaseTable.Meta): + model = models.PeerEndpointAddressFamily + fields = ( + "pk", + "peer_endpoint_address_family", + "peer_endpoint", + "afi_safi", + "import_policy", + "export_policy", + "multipath", + ) + default_columns = ( + "pk", + "peer_endpoint_address_family", + "peer_endpoint", + "afi_safi", "import_policy", "export_policy", "multipath", diff --git a/nautobot_bgp_models/templates/nautobot_bgp_models/addressfamily_retrieve.html b/nautobot_bgp_models/templates/nautobot_bgp_models/addressfamily_retrieve.html index 9d697fb8..b4527b56 100644 --- a/nautobot_bgp_models/templates/nautobot_bgp_models/addressfamily_retrieve.html +++ b/nautobot_bgp_models/templates/nautobot_bgp_models/addressfamily_retrieve.html @@ -38,31 +38,12 @@ None {% endif %} - - Multipath - - {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object_fields.multipath %} - - {% endblock content_left_page %} -{% block content_right_page %} -
-
Policy
- - - - - - - - - -
Import Policy - {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object_fields.import_policy %} -
Export Policy - {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object_fields.export_policy %} -
-
-{% endblock content_right_page %} + +{% block extra_nav_tabs %} + +{% endblock extra_nav_tabs %} diff --git a/nautobot_bgp_models/templates/nautobot_bgp_models/bgproutinginstance_retrieve.html b/nautobot_bgp_models/templates/nautobot_bgp_models/bgproutinginstance_retrieve.html index 2acae33e..6822f62d 100644 --- a/nautobot_bgp_models/templates/nautobot_bgp_models/bgproutinginstance_retrieve.html +++ b/nautobot_bgp_models/templates/nautobot_bgp_models/bgproutinginstance_retrieve.html @@ -34,6 +34,44 @@ {% endblock content_left_page %} +{% block content_right_page %} +
+
+ Address-Families +
+ + {% for af in object.address_families.all %} + + + + {% endfor %} +
{{ af.afi_safi }}
+ +
+
+
+ Peer Groups +
+ + {% for pg in object.peer_groups.all %} + + + + {% endfor %} +
{{ pg.name }}
+ +
+{% endblock content_right_page %} {% block extra_nav_tabs %} +{% endblock extra_nav_tabs %} diff --git a/nautobot_bgp_models/templates/nautobot_bgp_models/peergroup_retrieve.html b/nautobot_bgp_models/templates/nautobot_bgp_models/peergroup_retrieve.html index 0d1c5c9c..c24b9803 100644 --- a/nautobot_bgp_models/templates/nautobot_bgp_models/peergroup_retrieve.html +++ b/nautobot_bgp_models/templates/nautobot_bgp_models/peergroup_retrieve.html @@ -24,6 +24,14 @@ Routing Instance {{ object.routing_instance }} + + VRF + {% if object.vrf %} + {{ object.vrf }} + {% else %} + None + {% endif %} +
@@ -39,8 +47,6 @@
-{% endblock content_left_page %} -{% block content_right_page %}
Authentication @@ -74,40 +80,55 @@ Description - {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object_fields.description %} + {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object.fields_inherited.description %} Enabled - {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object_fields.enabled %} + {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object.fields_inherited.enabled %} Autonomous System - {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object_fields.autonomous_system %} + {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object.fields_inherited.autonomous_system %}
+{% endblock content_left_page %} +{% block content_right_page %}
- Policy + Peer Group Address-Families
+ {% for af in object.address_families.all %} - - + + {% endfor %} +
Import Policy - {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object_fields.import_policy %} - {{ af.afi_safi }}
+ +
+
+
+ Peerings In This Group +
+ + {% for endpoint in object.endpoints.all %} - - + + + + {% endfor %}
Export Policy - {% include "nautobot_bgp_models/inc/inheritable_property.html" with property=object_fields.export_policy %} - {{ endpoint }}peered to{{ endpoint.peer }}
{% endblock content_right_page %} diff --git a/nautobot_bgp_models/templates/nautobot_bgp_models/peergroupaddressfamily_retrieve.html b/nautobot_bgp_models/templates/nautobot_bgp_models/peergroupaddressfamily_retrieve.html new file mode 100644 index 00000000..04c6873f --- /dev/null +++ b/nautobot_bgp_models/templates/nautobot_bgp_models/peergroupaddressfamily_retrieve.html @@ -0,0 +1,68 @@ +{% extends 'generic/object_detail.html' %} +{% load helpers %} + +{% block content_left_page %} +
+
+ BGP Peer-Group Address-Family +
+ + + + {% if object.peer_group.routing_instance and object.peer_group.routing_instance.device %} + + {% else %} + + {% endif %} + + + + + + + + + +
Device{{ object.peer_group.routing_instance.device }}None
Routing Instance{{ object.peer_group.routing_instance }}
Peer Group + + {{ object.peer_group }} + +
+
+
+
+ Attributes +
+ + + + + + + + + +
AFI-SAFI{{ object.afi_safi }}
Multipath{{ object.multipath | placeholder }}
+
+{% endblock content_left_page %} +{% block content_right_page %} +
+
Policy
+ + + + + + + + + +
Import Policy{{ object.import_policy | placeholder }}
Export Policy{{ object.export_policy | placeholder }}
+
+{% endblock content_right_page %} + +{% block extra_nav_tabs %} + +{% endblock extra_nav_tabs %} diff --git a/nautobot_bgp_models/templates/nautobot_bgp_models/peergrouptemplate_retrieve.html b/nautobot_bgp_models/templates/nautobot_bgp_models/peergrouptemplate_retrieve.html index ab85212a..4819ea59 100644 --- a/nautobot_bgp_models/templates/nautobot_bgp_models/peergrouptemplate_retrieve.html +++ b/nautobot_bgp_models/templates/nautobot_bgp_models/peergrouptemplate_retrieve.html @@ -52,27 +52,6 @@
{% endblock content_left_page %} -{% block content_right_page %} -
-
- Policy -
- - - - - - - - - -
Import Policy - {% include "nautobot_bgp_models/inc/native_property.html" with property=object.import_policy %} -
Export Policy - {% include "nautobot_bgp_models/inc/native_property.html" with property=object.export_policy %} -
-
-{% endblock content_right_page %} {% block extra_nav_tabs %}