From 555f8436f14f359c8d10536b041462486590477f Mon Sep 17 00:00:00 2001 From: Damien Arnodo Date: Thu, 8 Jan 2026 07:53:37 +0000 Subject: [PATCH 1/5] docs: Add NetBox data model documentation for fabric intent Documents how EVPN-VXLAN fabric configuration is represented in NetBox using native models and the BGP plugin. Includes: - Model mapping tables (native + BGP plugin) - IP addressing scheme - BGP session configuration - VXLAN/EVPN L2VPN setup - MLAG custom fields requirements - VRF configuration - Data retrieval examples with pynetbox Relates to #5 --- docs/netbox-data-model.md | 295 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/netbox-data-model.md diff --git a/docs/netbox-data-model.md b/docs/netbox-data-model.md new file mode 100644 index 0000000..199c23a --- /dev/null +++ b/docs/netbox-data-model.md @@ -0,0 +1,295 @@ +# NetBox Data Model for Fabric Orchestrator + +This document describes how fabric intent is represented in NetBox for the fabric-orchestrator to consume. + +## Overview + +The fabric-orchestrator uses NetBox as the **source of truth** for EVPN-VXLAN fabric configuration. Instead of using ConfigContexts, we leverage NetBox's native data models plus the [NetBox BGP Plugin](https://github.com/netbox-community/netbox-bgp) for comprehensive fabric representation. + +### Requirements + +- NetBox 4.4.x +- NetBox BGP Plugin v0.17.x + +--- + +## Data Model Summary + +### Native NetBox Models + +| Feature | NetBox Model | API Endpoint | +|---------|--------------|--------------| +| **Devices** | `dcim.Device` | `/api/dcim/devices/` | +| **Interfaces** | `dcim.Interface` | `/api/dcim/interfaces/` | +| **LAGs/Port-Channels** | `dcim.Interface` (type=LAG) | `/api/dcim/interfaces/` | +| **VLANs** | `ipam.VLAN` | `/api/ipam/vlans/` | +| **VLAN Groups** | `ipam.VLANGroup` | `/api/ipam/vlan-groups/` | +| **VRFs** | `ipam.VRF` | `/api/ipam/vrfs/` | +| **Route Targets** | `ipam.RouteTarget` | `/api/ipam/route-targets/` | +| **IP Addresses** | `ipam.IPAddress` | `/api/ipam/ip-addresses/` | +| **Prefixes** | `ipam.Prefix` | `/api/ipam/prefixes/` | +| **ASNs** | `ipam.ASN` | `/api/ipam/asns/` | +| **L2VPN (EVPN)** | `vpn.L2VPN` | `/api/vpn/l2vpns/` | +| **L2VPN Terminations** | `vpn.L2VPNTermination` | `/api/vpn/l2vpn-terminations/` | + +### NetBox BGP Plugin Models + +| Feature | Plugin Model | API Endpoint | +|---------|--------------|--------------| +| **BGP Sessions** | `netbox_bgp.BGPSession` | `/api/plugins/bgp/sessions/` | +| **BGP Peer Groups** | `netbox_bgp.PeerGroup` | `/api/plugins/bgp/peer-groups/` | +| **BGP Communities** | `netbox_bgp.Community` | `/api/plugins/bgp/communities/` | +| **Routing Policies** | `netbox_bgp.RoutingPolicy` | `/api/plugins/bgp/routing-policies/` | +| **Prefix Lists** | `netbox_bgp.PrefixList` | `/api/plugins/bgp/prefix-lists/` | +| **AS Path Lists** | `netbox_bgp.ASPathList` | `/api/plugins/bgp/as-path-lists/` | + +--- + +## Fabric Component Mapping + +### Devices and Roles + +| Fabric Role | NetBox Device Role | Description | +|-------------|-------------------|-------------| +| Spine | `spine` | Spine switches (AS 65000) | +| Leaf | `leaf` | Leaf switches (AS 65001-65004) | + +**Example Device Setup:** +- `spine1`, `spine2` - Device Role: spine +- `leaf1` through `leaf8` - Device Role: leaf + +### Interfaces + +| Interface Type | NetBox Interface Type | Usage | +|----------------|----------------------|-------| +| Physical uplinks | `1000base-t` / `10gbase-x-sfpp` | Spine-Leaf connections | +| Loopback0 | `virtual` | BGP Router-ID | +| Loopback1 | `virtual` | VTEP source (shared by MLAG pair) | +| Port-Channel | `lag` | MLAG peer-link, host bonds | +| Vxlan1 | `virtual` | VXLAN tunnel interface | +| SVI | `virtual` | VLAN interfaces (Vlan40, etc.) | + +### IP Addressing Scheme + +Based on the reference topology: + +| Purpose | Prefix | Example | +|---------|--------|---------| +| Spine-Leaf P2P (Spine1) | `10.0.1.0/24` | `10.0.1.0/31`, `10.0.1.2/31`, ... | +| Spine-Leaf P2P (Spine2) | `10.0.2.0/24` | `10.0.2.0/31`, `10.0.2.2/31`, ... | +| MLAG iBGP Peer | `10.0.3.0/24` | `10.0.3.0/31` per MLAG pair | +| MLAG Peer VLAN 4090 | `10.0.199.0/24` | `/31` per MLAG pair | +| Loopback0 (Router-ID) | `10.0.250.0/24` | Spine: `.1-.2`, Leaf: `.11-.18` | +| Loopback1 (VTEP) | `10.0.255.0/24` | `.11-.14` (shared per MLAG pair) | + +--- + +## BGP Configuration + +### ASN Assignments + +| Device(s) | ASN | Type | +|-----------|-----|------| +| spine1, spine2 | 65000 | eBGP | +| leaf1, leaf2 | 65001 | iBGP pair | +| leaf3, leaf4 | 65002 | iBGP pair | +| leaf5, leaf6 | 65003 | iBGP pair | +| leaf7, leaf8 | 65004 | iBGP pair | + +### BGP Sessions (Plugin) + +#### Underlay eBGP Sessions + +Each leaf has eBGP sessions to both spines over point-to-point interfaces: + +| Local Device | Local IP | Remote Device | Remote IP | Remote ASN | +|--------------|----------|---------------|-----------|------------| +| leaf1 | 10.0.1.1 | spine1 | 10.0.1.0 | 65000 | +| leaf1 | 10.0.2.1 | spine2 | 10.0.2.0 | 65000 | + +#### Underlay iBGP Sessions + +Each MLAG pair has an iBGP session for redundancy: + +| Local Device | Local IP | Remote Device | Remote IP | Notes | +|--------------|----------|---------------|-----------|-------| +| leaf1 | 10.0.3.0 | leaf2 | 10.0.3.1 | next-hop-self | + +#### Overlay EVPN Sessions + +EVPN sessions use loopback addresses with `ebgp-multihop 3`: + +| Local Device | Local IP (Lo0) | Remote Device | Remote IP (Lo0) | AFI/SAFI | +|--------------|----------------|---------------|-----------------|----------| +| leaf1 | 10.0.250.11 | spine1 | 10.0.250.1 | L2VPN EVPN | +| leaf1 | 10.0.250.11 | spine2 | 10.0.250.2 | L2VPN EVPN | + +### BGP Peer Groups (Plugin) + +| Peer Group | Purpose | Key Settings | +|------------|---------|--------------| +| `underlay` | eBGP to spines | `remote-as 65000`, `maximum-routes 12000` | +| `underlay_ibgp` | iBGP to MLAG peer | `next-hop-self` | +| `evpn` | EVPN overlay | `update-source Loopback0`, `ebgp-multihop 3`, `send-community extended` | + +--- + +## VXLAN / EVPN Configuration + +### L2VPN for EVPN VNI Mappings + +NetBox's L2VPN model represents EVPN instances: + +| L2VPN Name | Type | Identifier (VNI) | Description | +|------------|------|------------------|-------------| +| `VLAN40-L2VNI` | EVPN | 100040 | L2 VXLAN for VLAN 40 | +| `VRF-gold` | EVPN | 100001 | L3 VNI for VRF gold | + +### L2VPN Terminations + +Link VLANs to L2VPNs on specific devices: + +| L2VPN | Device | Interface/VLAN | +|-------|--------|----------------| +| `VLAN40-L2VNI` | leaf1 | VLAN 40 | +| `VLAN40-L2VNI` | leaf2 | VLAN 40 | +| `VLAN40-L2VNI` | leaf5 | VLAN 40 | +| `VLAN40-L2VNI` | leaf6 | VLAN 40 | + +### Route Targets + +| VRF/VLAN | Import RT | Export RT | +|----------|-----------|-----------| +| VLAN 40 | `40:100040` | `40:100040` | +| VRF gold | `1:100001` | `1:100001` | + +--- + +## MLAG Configuration + +MLAG configuration requires Custom Fields since NetBox doesn't have native MLAG support. + +### Custom Fields Required + +| Field Name | Object Type | Type | Description | +|------------|-------------|------|-------------| +| `mlag_domain_id` | Device | Text | MLAG domain identifier | +| `mlag_peer_address` | Device | Text (IP) | MLAG peer IP address | +| `mlag_local_address` | Device | Text (IP) | MLAG local IP address | +| `mlag_peer_link` | Interface | Boolean | Marks interface as MLAG peer-link | +| `mlag_virtual_mac` | Device | Text | Shared virtual-router MAC | + +### MLAG Pair Configuration + +| Device | Domain ID | Local IP | Peer IP | Virtual MAC | +|--------|-----------|----------|---------|-------------| +| leaf1 | leafs | 10.0.199.254 | 10.0.199.255 | c001.cafe.babe | +| leaf2 | leafs | 10.0.199.255 | 10.0.199.254 | c001.cafe.babe | + +### MLAG-Related VLANs + +| VLAN | Name | Purpose | Trunk Group | +|------|------|---------|-------------| +| 4090 | mlag-peer | MLAG peer communication | mlag-peer | +| 4091 | mlag-ibgp | iBGP between MLAG peers | mlag-peer | + +--- + +## VRF Configuration + +### VRF Definition + +| VRF Name | RD | Import RT | Export RT | L3 VNI | +|----------|-------|-----------|-----------|--------| +| gold | `10.0.250.X:1` | `1:100001` | `1:100001` | 100001 | + +> Note: RD uses device's Loopback0 IP for uniqueness + +### VRF Interface Assignment + +SVIs are assigned to VRFs in NetBox: + +| Interface | VRF | IP Address | Virtual IP | +|-----------|-----|------------|------------| +| Vlan34 (leaf3) | gold | 10.34.34.2/24 | 10.34.34.1 | +| Vlan34 (leaf4) | gold | 10.34.34.3/24 | 10.34.34.1 | +| Vlan78 (leaf7) | gold | 10.78.78.2/24 | 10.78.78.1 | +| Vlan78 (leaf8) | gold | 10.78.78.3/24 | 10.78.78.1 | + +--- + +## Data Retrieval Strategy + +The fabric-orchestrator retrieves intent from NetBox using pynetbox: + +```python +import pynetbox + +nb = pynetbox.api('http://netbox.example.com', token='your-token') + +# Get all leaf devices +leaves = nb.dcim.devices.filter(role='leaf') + +# Get BGP sessions for a device +bgp_sessions = nb.plugins.bgp.sessions.filter(device='leaf1') + +# Get L2VPNs (EVPN) +l2vpns = nb.vpn.l2vpns.filter(type='evpn') + +# Get VLAN-to-VNI mappings via L2VPN terminations +terminations = nb.vpn.l2vpn_terminations.filter(l2vpn='VLAN40-L2VNI') +``` + +--- + +## Relationship Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ NetBox │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌─────────────┐ │ +│ │ Device │────▶│ Interface │────▶│ IP Address │ │ +│ │ (leaf1) │ │ (Loopback0) │ │(10.0.250.11)│ │ +│ └──────────┘ └──────────────┘ └─────────────┘ │ +│ │ │ +│ │ ASN assignment │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ ASN │ │ +│ │ (65001) │ │ +│ └──────────┘ │ +│ │ +│ ┌──────────────┐ ┌─────────────────┐ │ +│ │ BGP Session │────▶│ Peer Group │ │ +│ │ (plugin) │ │ (plugin) │ │ +│ └──────────────┘ └─────────────────┘ │ +│ │ +│ ┌──────────┐ ┌─────────────────────┐ ┌──────────┐ │ +│ │ VLAN │────▶│ L2VPN Termination │────▶│ L2VPN │ │ +│ │ (40) │ │ │ │ (EVPN) │ │ +│ └──────────┘ └─────────────────────┘ └──────────┘ │ +│ │ │ +│ │ VNI │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ 100040 │ │ +│ └──────────┘ │ +│ │ +│ ┌──────────┐ ┌─────────────────┐ │ +│ │ VRF │────▶│ Route Target │ │ +│ │ (gold) │ │ (1:100001) │ │ +│ └──────────┘ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Next Steps + +1. **Custom Fields Setup** - Create MLAG-related custom fields in NetBox +2. **Data Population** - Enter fabric topology data into NetBox +3. **NetBox Client** - Implement `src/netbox/client.py` using pynetbox +4. **Validation** - Verify data retrieval matches expected fabric config -- 2.52.0 From 4799c4cbf2cfb62317c664cc93acb714f10aedcb Mon Sep 17 00:00:00 2001 From: darnodo Date: Thu, 8 Jan 2026 12:57:50 +0100 Subject: [PATCH 2/5] feat(netbox): Add NetBox API client for fabric intent retrieval Implements FabricNetBoxClient using pynetbox to fetch: - Device and interface data - BGP sessions and peer groups (plugin) - L2VPN/EVPN VNI mappings - VRF and route target configuration - MLAG custom fields Relates to #5 --- pyproject.toml | 1 + src/netbox/__init__.py | 36 ++ src/netbox/client.py | 755 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 792 insertions(+) create mode 100644 src/netbox/__init__.py create mode 100644 src/netbox/client.py diff --git a/pyproject.toml b/pyproject.toml index ab99bfe..ac4f87b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "click>=8.1.0", "pygnmi>=0.8.0", + "pynetbox>=7.0.0", "rich>=13.0.0", ] diff --git a/src/netbox/__init__.py b/src/netbox/__init__.py new file mode 100644 index 0000000..cf75abf --- /dev/null +++ b/src/netbox/__init__.py @@ -0,0 +1,36 @@ +""" +NetBox Client Module for Fabric Orchestrator. + +This module provides a NetBox API client for fetching fabric intent data +using pynetbox. + +Usage: + from src.netbox import FabricNetBoxClient + + client = FabricNetBoxClient( + url="http://netbox.local", + token="your-token" + ) + + # Get all leaf devices + leaves = client.get_leaf_devices() + + # Get complete intent for a device + intent = client.get_device_intent("leaf1") +""" + +from .client import ( + FabricNetBoxClient, + NetBoxAPIError, + NetBoxConnectionError, + NetBoxError, + NetBoxNotFoundError, +) + +__all__ = [ + "FabricNetBoxClient", + "NetBoxError", + "NetBoxConnectionError", + "NetBoxNotFoundError", + "NetBoxAPIError", +] \ No newline at end of file diff --git a/src/netbox/client.py b/src/netbox/client.py new file mode 100644 index 0000000..fceee26 --- /dev/null +++ b/src/netbox/client.py @@ -0,0 +1,755 @@ +""" +NetBox API Client for Fabric Orchestrator. + +This module provides a client for fetching fabric intent data from NetBox +using pynetbox. It retrieves device, interface, BGP, EVPN, and VRF configuration +to determine the desired network state. + +Usage: + from src.netbox import FabricNetBoxClient + + client = FabricNetBoxClient( + url="http://netbox.local", + token="your-token" + ) + + # Get all leaf devices + leaves = client.get_leaf_devices() + + # Get complete intent for a device + intent = client.get_device_intent("leaf1") +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +import pynetbox +from pynetbox.core.query import RequestError + +# ============================================================================= +# Exceptions +# ============================================================================= + + +class NetBoxError(Exception): + """Base exception for NetBox operations.""" + + pass + + +class NetBoxConnectionError(NetBoxError): + """Raised when connection to NetBox fails.""" + + pass + + +class NetBoxNotFoundError(NetBoxError): + """Raised when requested resource is not found.""" + + pass + + +class NetBoxAPIError(NetBoxError): + """Raised when NetBox API returns an error.""" + + pass + + +# ============================================================================= +# Logger +# ============================================================================= + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# NetBox Client +# ============================================================================= + + +class FabricNetBoxClient: + """ + NetBox API client for fabric intent retrieval. + + Provides methods to fetch device, interface, BGP, EVPN, and VRF + configuration from NetBox to determine desired network state. + + Supports configuration via environment variables: + - NETBOX_URL: NetBox server URL + - NETBOX_TOKEN: API token for authentication + + Example: + client = FabricNetBoxClient( + url="http://netbox.local", + token="your-token" + ) + leaves = client.get_leaf_devices() + """ + + def __init__( + self, + url: str | None = None, + token: str | None = None, + threading: bool = False, + ): + """ + Initialize NetBox client. + + Args: + url: NetBox server URL (or NETBOX_URL env var) + token: API token (or NETBOX_TOKEN env var) + threading: Enable threading for requests (default: False) + + Raises: + NetBoxConnectionError: If URL or token is not provided + """ + self.url = url or os.environ.get("NETBOX_URL") + self.token = token or os.environ.get("NETBOX_TOKEN") + + if not self.url: + raise NetBoxConnectionError( + "NetBox URL not provided. Set NETBOX_URL environment variable or pass url parameter." + ) + if not self.token: + raise NetBoxConnectionError( + "NetBox token not provided. Set NETBOX_TOKEN environment variable or pass token parameter." + ) + + logger.debug(f"Connecting to NetBox at {self.url}") + + try: + self._api = pynetbox.api(self.url, token=self.token, threading=threading) + # Test connection by fetching API status + self._api.status() + logger.info(f"Successfully connected to NetBox at {self.url}") + except Exception as e: + raise NetBoxConnectionError(f"Failed to connect to NetBox at {self.url}: {e}") from e + + # ========================================================================= + # Device Methods + # ========================================================================= + + def get_device(self, name: str) -> dict[str, Any]: + """ + Get a device by name. + + Args: + name: Device name + + Returns: + Device data as dictionary + + Raises: + NetBoxNotFoundError: If device not found + """ + logger.debug(f"Fetching device: {name}") + try: + device = self._api.dcim.devices.get(name=name) + if not device: + raise NetBoxNotFoundError(f"Device not found: {name}") + return dict(device) + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch device {name}: {e}") from e + + def get_devices_by_role(self, role: str) -> list[dict[str, Any]]: + """ + Get all devices with a specific role. + + Args: + role: Device role slug (e.g., 'leaf', 'spine') + + Returns: + List of device dictionaries + """ + logger.debug(f"Fetching devices with role: {role}") + try: + devices = self._api.dcim.devices.filter(role=role) + return [dict(d) for d in devices] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch devices with role {role}: {e}") from e + + def get_leaf_devices(self) -> list[dict[str, Any]]: + """ + Get all leaf devices. + + Returns: + List of leaf device dictionaries + """ + return self.get_devices_by_role("leaf") + + def get_spine_devices(self) -> list[dict[str, Any]]: + """ + Get all spine devices. + + Returns: + List of spine device dictionaries + """ + return self.get_devices_by_role("spine") + + def get_device_asn(self, device_name: str) -> int | None: + """ + Get ASN assigned to a device. + + Args: + device_name: Device name + + Returns: + ASN number or None if not assigned + """ + logger.debug(f"Fetching ASN for device: {device_name}") + try: + asns = self._api.ipam.asns.filter(device=device_name) + asn_list = list(asns) + if asn_list: + return asn_list[0].asn + return None + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch ASN for {device_name}: {e}") from e + + # ========================================================================= + # Interface Methods + # ========================================================================= + + def get_device_interfaces( + self, device_name: str, interface_type: str | None = None + ) -> list[dict[str, Any]]: + """ + Get interfaces for a device. + + Args: + device_name: Device name + interface_type: Optional interface type filter (e.g., 'virtual', 'lag', '1000base-t') + + Returns: + List of interface dictionaries + """ + logger.debug(f"Fetching interfaces for device: {device_name}, type: {interface_type}") + try: + filters = {"device": device_name} + if interface_type: + filters["type"] = interface_type + interfaces = self._api.dcim.interfaces.filter(**filters) + return [dict(i) for i in interfaces] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch interfaces for {device_name}: {e}") from e + + def get_device_loopbacks(self, device_name: str) -> list[dict[str, Any]]: + """ + Get loopback interfaces for a device. + + Args: + device_name: Device name + + Returns: + List of loopback interface dictionaries + """ + logger.debug(f"Fetching loopbacks for device: {device_name}") + try: + interfaces = self._api.dcim.interfaces.filter(device=device_name, type="virtual") + loopbacks = [dict(i) for i in interfaces if i.name.lower().startswith("loopback")] + return loopbacks + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch loopbacks for {device_name}: {e}") from e + + def get_device_lags(self, device_name: str) -> list[dict[str, Any]]: + """ + Get LAG/Port-Channel interfaces for a device. + + Args: + device_name: Device name + + Returns: + List of LAG interface dictionaries + """ + logger.debug(f"Fetching LAGs for device: {device_name}") + return self.get_device_interfaces(device_name, interface_type="lag") + + def get_device_svis(self, device_name: str) -> list[dict[str, Any]]: + """ + Get SVI (VLAN) interfaces for a device. + + Args: + device_name: Device name + + Returns: + List of SVI interface dictionaries + """ + logger.debug(f"Fetching SVIs for device: {device_name}") + try: + interfaces = self._api.dcim.interfaces.filter(device=device_name, type="virtual") + svis = [dict(i) for i in interfaces if i.name.lower().startswith("vlan")] + return svis + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch SVIs for {device_name}: {e}") from e + + # ========================================================================= + # IP Address Methods + # ========================================================================= + + def get_interface_ips( + self, device_name: str, interface_name: str + ) -> list[dict[str, Any]]: + """ + Get IP addresses assigned to an interface. + + Args: + device_name: Device name + interface_name: Interface name + + Returns: + List of IP address dictionaries + """ + logger.debug(f"Fetching IPs for {device_name}:{interface_name}") + try: + ips = self._api.ipam.ip_addresses.filter( + device=device_name, interface=interface_name + ) + return [dict(ip) for ip in ips] + except RequestError as e: + raise NetBoxAPIError( + f"Failed to fetch IPs for {device_name}:{interface_name}: {e}" + ) from e + + def get_device_ips(self, device_name: str) -> list[dict[str, Any]]: + """ + Get all IP addresses for a device. + + Args: + device_name: Device name + + Returns: + List of IP address dictionaries with interface info + """ + logger.debug(f"Fetching all IPs for device: {device_name}") + try: + ips = self._api.ipam.ip_addresses.filter(device=device_name) + return [dict(ip) for ip in ips] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch IPs for {device_name}: {e}") from e + + # ========================================================================= + # BGP Methods (Plugin) + # ========================================================================= + + def get_bgp_sessions(self, device_name: str | None = None) -> list[dict[str, Any]]: + """ + Get BGP sessions from NetBox BGP plugin. + + Args: + device_name: Optional device name filter + + Returns: + List of BGP session dictionaries + """ + logger.debug(f"Fetching BGP sessions for device: {device_name or 'all'}") + try: + if device_name: + sessions = self._api.plugins.bgp.sessions.filter(device=device_name) + else: + sessions = self._api.plugins.bgp.sessions.all() + return [dict(s) for s in sessions] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch BGP sessions: {e}") from e + except AttributeError: + logger.warning("NetBox BGP plugin not available") + return [] + + def get_bgp_peer_groups(self, device_name: str | None = None) -> list[dict[str, Any]]: + """ + Get BGP peer groups from NetBox BGP plugin. + + Args: + device_name: Optional device name filter + + Returns: + List of BGP peer group dictionaries + """ + logger.debug(f"Fetching BGP peer groups for device: {device_name or 'all'}") + try: + if device_name: + peer_groups = self._api.plugins.bgp.peer_groups.filter(device=device_name) + else: + peer_groups = self._api.plugins.bgp.peer_groups.all() + return [dict(pg) for pg in peer_groups] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch BGP peer groups: {e}") from e + except AttributeError: + logger.warning("NetBox BGP plugin not available") + return [] + + # ========================================================================= + # VLAN Methods + # ========================================================================= + + def get_vlans(self, site: str | None = None) -> list[dict[str, Any]]: + """ + Get VLANs. + + Args: + site: Optional site filter + + Returns: + List of VLAN dictionaries + """ + logger.debug(f"Fetching VLANs for site: {site or 'all'}") + try: + if site: + vlans = self._api.ipam.vlans.filter(site=site) + else: + vlans = self._api.ipam.vlans.all() + return [dict(v) for v in vlans] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch VLANs: {e}") from e + + def get_vlan(self, vlan_id: int, site: str | None = None) -> dict[str, Any] | None: + """ + Get a specific VLAN by ID. + + Args: + vlan_id: VLAN ID + site: Optional site filter + + Returns: + VLAN dictionary or None if not found + """ + logger.debug(f"Fetching VLAN {vlan_id}") + try: + filters = {"vid": vlan_id} + if site: + filters["site"] = site + vlans = self._api.ipam.vlans.filter(**filters) + vlan_list = list(vlans) + if vlan_list: + return dict(vlan_list[0]) + return None + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch VLAN {vlan_id}: {e}") from e + + # ========================================================================= + # L2VPN/EVPN Methods + # ========================================================================= + + def get_l2vpns(self, l2vpn_type: str = "evpn") -> list[dict[str, Any]]: + """ + Get L2VPNs (EVPN instances). + + Args: + l2vpn_type: L2VPN type filter (default: 'evpn') + + Returns: + List of L2VPN dictionaries with VNI identifiers + """ + logger.debug(f"Fetching L2VPNs of type: {l2vpn_type}") + try: + l2vpns = self._api.vpn.l2vpns.filter(type=l2vpn_type) + return [dict(l2vpn) for l2vpn in l2vpns] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch L2VPNs: {e}") from e + + def get_l2vpn_terminations( + self, device_name: str | None = None, l2vpn_name: str | None = None + ) -> list[dict[str, Any]]: + """ + Get L2VPN terminations (VLAN-to-device mappings). + + Args: + device_name: Optional device name filter + l2vpn_name: Optional L2VPN name filter + + Returns: + List of L2VPN termination dictionaries + """ + logger.debug( + f"Fetching L2VPN terminations for device: {device_name or 'all'}, " + f"l2vpn: {l2vpn_name or 'all'}" + ) + try: + filters = {} + if l2vpn_name: + filters["l2vpn"] = l2vpn_name + if device_name: + filters["device"] = device_name + + if filters: + terminations = self._api.vpn.l2vpn_terminations.filter(**filters) + else: + terminations = self._api.vpn.l2vpn_terminations.all() + return [dict(t) for t in terminations] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch L2VPN terminations: {e}") from e + + def get_vni_for_vlan(self, vlan_id: int) -> int | None: + """ + Get VNI for a VLAN from L2VPN configuration. + + Args: + vlan_id: VLAN ID + + Returns: + VNI number or None if not mapped + """ + logger.debug(f"Fetching VNI for VLAN {vlan_id}") + try: + # Get L2VPN terminations for this VLAN + terminations = self._api.vpn.l2vpn_terminations.filter(vlan_id=vlan_id) + term_list = list(terminations) + if term_list: + l2vpn = term_list[0].l2vpn + if l2vpn and hasattr(l2vpn, "identifier"): + return l2vpn.identifier + return None + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch VNI for VLAN {vlan_id}: {e}") from e + + # ========================================================================= + # VRF Methods + # ========================================================================= + + def get_vrfs(self) -> list[dict[str, Any]]: + """ + Get all VRFs with route targets. + + Returns: + List of VRF dictionaries including import/export route targets + """ + logger.debug("Fetching all VRFs") + try: + vrfs = self._api.ipam.vrfs.all() + return [dict(v) for v in vrfs] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch VRFs: {e}") from e + + def get_vrf(self, name: str) -> dict[str, Any] | None: + """ + Get a VRF by name. + + Args: + name: VRF name + + Returns: + VRF dictionary or None if not found + """ + logger.debug(f"Fetching VRF: {name}") + try: + vrf = self._api.ipam.vrfs.get(name=name) + if vrf: + return dict(vrf) + return None + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch VRF {name}: {e}") from e + + def get_device_vrf_interfaces( + self, device_name: str, vrf_name: str + ) -> list[dict[str, Any]]: + """ + Get interfaces assigned to a VRF on a device. + + Args: + device_name: Device name + vrf_name: VRF name + + Returns: + List of interface dictionaries assigned to the VRF + """ + logger.debug(f"Fetching VRF {vrf_name} interfaces for device: {device_name}") + try: + interfaces = self._api.dcim.interfaces.filter(device=device_name, vrf=vrf_name) + return [dict(i) for i in interfaces] + except RequestError as e: + raise NetBoxAPIError( + f"Failed to fetch VRF interfaces for {device_name}/{vrf_name}: {e}" + ) from e + + def get_route_targets(self) -> list[dict[str, Any]]: + """ + Get all route targets. + + Returns: + List of route target dictionaries + """ + logger.debug("Fetching all route targets") + try: + rts = self._api.ipam.route_targets.all() + return [dict(rt) for rt in rts] + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch route targets: {e}") from e + + # ========================================================================= + # MLAG Methods (Custom Fields) + # ========================================================================= + + def get_device_mlag_config(self, device_name: str) -> dict[str, Any]: + """ + Get MLAG configuration from device custom fields. + + Custom fields expected: + - mlag_domain_id: MLAG domain identifier + - mlag_peer_address: MLAG peer IP address + - mlag_local_address: MLAG local IP address + - mlag_virtual_mac: Shared virtual-router MAC + + Args: + device_name: Device name + + Returns: + MLAG configuration dictionary + """ + logger.debug(f"Fetching MLAG config for device: {device_name}") + device = self.get_device(device_name) + custom_fields = device.get("custom_fields", {}) or {} + + return { + "domain_id": custom_fields.get("mlag_domain_id"), + "peer_address": custom_fields.get("mlag_peer_address"), + "local_address": custom_fields.get("mlag_local_address"), + "virtual_mac": custom_fields.get("mlag_virtual_mac"), + } + + def get_mlag_peer_link(self, device_name: str) -> dict[str, Any] | None: + """ + Get MLAG peer-link interface for a device. + + Looks for interface with mlag_peer_link custom field set to True. + + Args: + device_name: Device name + + Returns: + Peer-link interface dictionary or None + """ + logger.debug(f"Fetching MLAG peer-link for device: {device_name}") + try: + interfaces = self._api.dcim.interfaces.filter(device=device_name) + for iface in interfaces: + cf = iface.custom_fields or {} + if cf.get("mlag_peer_link"): + return dict(iface) + return None + except RequestError as e: + raise NetBoxAPIError( + f"Failed to fetch MLAG peer-link for {device_name}: {e}" + ) from e + + # ========================================================================= + # Aggregated Intent Methods + # ========================================================================= + + def get_device_intent(self, device_name: str) -> dict[str, Any]: + """ + Get complete fabric intent for a single device. + + Aggregates all relevant configuration data for a device including: + - Device info and custom fields + - Interfaces (physical, loopback, LAG, SVI) + - IP addresses + - BGP sessions and peer groups + - VLAN assignments + - L2VPN/EVPN terminations + - VRF assignments + - MLAG configuration + + Args: + device_name: Device name + + Returns: + Complete device intent dictionary + """ + logger.info(f"Building complete intent for device: {device_name}") + + # Get device info + device = self.get_device(device_name) + + # Get interfaces + all_interfaces = self.get_device_interfaces(device_name) + loopbacks = self.get_device_loopbacks(device_name) + lags = self.get_device_lags(device_name) + svis = self.get_device_svis(device_name) + + # Get IP addresses + ip_addresses = self.get_device_ips(device_name) + + # Get ASN + asn = self.get_device_asn(device_name) + + # Get BGP configuration + bgp_sessions = self.get_bgp_sessions(device_name) + bgp_peer_groups = self.get_bgp_peer_groups(device_name) + + # Get L2VPN terminations for this device + l2vpn_terminations = self.get_l2vpn_terminations(device_name=device_name) + + # Get MLAG configuration + mlag_config = self.get_device_mlag_config(device_name) + mlag_peer_link = self.get_mlag_peer_link(device_name) + + # Build intent dictionary + intent = { + "device": { + "name": device.get("name"), + "role": device.get("role", {}).get("slug") if device.get("role") else None, + "platform": ( + device.get("platform", {}).get("slug") if device.get("platform") else None + ), + "site": device.get("site", {}).get("slug") if device.get("site") else None, + "custom_fields": device.get("custom_fields", {}), + }, + "asn": asn, + "interfaces": { + "all": all_interfaces, + "loopbacks": loopbacks, + "lags": lags, + "svis": svis, + }, + "ip_addresses": ip_addresses, + "bgp": { + "sessions": bgp_sessions, + "peer_groups": bgp_peer_groups, + }, + "l2vpn_terminations": l2vpn_terminations, + "mlag": { + "config": mlag_config, + "peer_link": mlag_peer_link, + }, + } + + logger.debug(f"Built intent for {device_name} with {len(all_interfaces)} interfaces") + return intent + + def get_fabric_intent(self) -> dict[str, Any]: + """ + Get complete fabric intent for all devices. + + Returns: + Dictionary with fabric-wide intent including: + - All devices with their intent + - Fabric-wide VLANs + - Fabric-wide L2VPNs + - Fabric-wide VRFs + """ + logger.info("Building complete fabric intent") + + # Get all devices + spine_devices = self.get_spine_devices() + leaf_devices = self.get_leaf_devices() + + # Get fabric-wide configuration + vlans = self.get_vlans() + l2vpns = self.get_l2vpns() + vrfs = self.get_vrfs() + + # Build device intents + device_intents = {} + for device in spine_devices + leaf_devices: + name = device.get("name") + if name: + device_intents[name] = self.get_device_intent(name) + + return { + "devices": device_intents, + "vlans": vlans, + "l2vpns": l2vpns, + "vrfs": vrfs, + } -- 2.52.0 From b1c46686d2f01893c4551ca78f715ac5ad06d9ec Mon Sep 17 00:00:00 2001 From: darnodo Date: Thu, 8 Jan 2026 13:02:39 +0100 Subject: [PATCH 3/5] chore: add missing newline and update dependencies - Fix missing newline at end of __init__.py - Add certifi and charset-normalizer packages to uv.lock - Update lock file with HTTP-related dependencies --- src/netbox/__init__.py | 2 +- uv.lock | 123 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/netbox/__init__.py b/src/netbox/__init__.py index cf75abf..5b9aae4 100644 --- a/src/netbox/__init__.py +++ b/src/netbox/__init__.py @@ -33,4 +33,4 @@ __all__ = [ "NetBoxConnectionError", "NetBoxNotFoundError", "NetBoxAPIError", -] \ No newline at end of file +] diff --git a/uv.lock b/uv.lock index 525b554..46f38fe 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -59,6 +68,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -152,6 +218,7 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "pygnmi" }, + { name = "pynetbox" }, { name = "rich" }, ] @@ -164,6 +231,7 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.1.0" }, { name = "pygnmi", specifier = ">=0.8.0" }, + { name = "pynetbox", specifier = ">=7.0.0" }, { name = "rich", specifier = ">=13.0.0" }, ] @@ -211,6 +279,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, ] +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -232,6 +309,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + [[package]] name = "protobuf" version = "6.33.2" @@ -280,6 +366,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/d8/1958e627d66f5f0c8dbdb1962850bc9e256fc65129ceb0212c0cb3e0d121/pygnmi-0.8.15-py3-none-any.whl", hash = "sha256:e72859070c7dc3618b578e48c76521db6d627b64a92b496ab886f3ff93a1a396", size = 35561, upload-time = "2025-03-10T22:15:32.981Z" }, ] +[[package]] +name = "pynetbox" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/0b/695021a23c373991d07c1e4cb510287a521318cfc4b29f68ebbecb19fcd2/pynetbox-7.5.0.tar.gz", hash = "sha256:780064c800fb8c079c9828df472203146442ed3dd0b522a28a501204eb00c066", size = 73850, upload-time = "2025-05-20T16:03:03.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b7/a24bc58f0e27f0cd847bf14ffbe9722604f5abe3ec9c12dd8f89cb965be8/pynetbox-7.5.0-py3-none-any.whl", hash = "sha256:ab755a0020c0abb09b4d24c8f8ba89df26f04fa56c35de73302e29a39352f031", size = 35808, upload-time = "2025-05-20T16:03:02.445Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -327,3 +441,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] -- 2.52.0 From 2f828f43d60e3a42f366a4f21f6373ae6429e833 Mon Sep 17 00:00:00 2001 From: Damien Arnodo Date: Thu, 8 Jan 2026 12:33:17 +0000 Subject: [PATCH 4/5] docs: add complete custom fields reference and API compatibility notes - Add comprehensive custom fields table for Device, Interface, VRF, and IP Address - Include API examples for custom field creation - Document working vs non-working API filters for NetBox 4.4 - Add workarounds for filters that don't exist (ASN by device, L2VPN terminations by device) - Update VRF interface assignment to show IP-based VRF membership - Add virtual_ip custom field for anycast gateway support --- docs/netbox-data-model.md | 230 +++++++++++++++++++++++++++----------- 1 file changed, 163 insertions(+), 67 deletions(-) diff --git a/docs/netbox-data-model.md b/docs/netbox-data-model.md index 199c23a..20c08b5 100644 --- a/docs/netbox-data-model.md +++ b/docs/netbox-data-model.md @@ -45,6 +45,74 @@ The fabric-orchestrator uses NetBox as the **source of truth** for EVPN-VXLAN fa --- +## Custom Fields Reference + +NetBox doesn't natively support all fabric-specific configurations. The following custom fields must be created to support MLAG, ASN assignment, and VRF extensions. + +### Complete Custom Fields List + +#### Device Custom Fields + +| Field Name | Label | Type | Required | Description | +|------------|-------|------|----------|-------------| +| `asn` | ASN | Integer | No | BGP Autonomous System Number assigned to this device | +| `mlag_domain_id` | MLAG Domain ID | Text | No | MLAG domain identifier (e.g., "leafs") | +| `mlag_peer_address` | MLAG Peer Address | Text | No | MLAG peer IP address | +| `mlag_local_address` | MLAG Local Address | Text | No | MLAG local IP address | +| `mlag_virtual_mac` | MLAG Virtual MAC | Text | No | Shared virtual-router MAC (e.g., "c001.cafe.babe") | + +#### Interface Custom Fields + +| Field Name | Label | Type | Required | Description | +|------------|-------|------|----------|-------------| +| `mlag_peer_link` | MLAG Peer Link | Boolean | No | Marks interface as MLAG peer-link | +| `mlag_id` | MLAG ID | Integer | No | MLAG port-channel ID for host-facing LAGs | + +#### VRF Custom Fields + +| Field Name | Label | Type | Required | Description | +|------------|-------|------|----------|-------------| +| `l3vni` | L3 VNI | Integer | No | Layer 3 VNI for EVPN symmetric IRB | +| `vrf_vlan` | VRF VLAN | Integer | No | VLAN ID used for L3 VNI SVI | + +#### IP Address Custom Fields + +| Field Name | Label | Type | Required | Description | +|------------|-------|------|----------|-------------| +| `virtual_ip` | Virtual IP | Boolean | No | Marks IP as anycast/virtual IP (shared across MLAG pair) | + +### Custom Field Creation via API + +```bash +# Example: Create ASN custom field on Device +curl -X POST "https://netbox.example.com/api/extras/custom-fields/" \ + -H "Authorization: Token $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "content_types": ["dcim.device"], + "name": "asn", + "label": "ASN", + "type": "integer", + "required": false, + "description": "BGP Autonomous System Number assigned to this device" + }' + +# Example: Create MLAG Peer Link custom field on Interface +curl -X POST "https://netbox.example.com/api/extras/custom-fields/" \ + -H "Authorization: Token $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "content_types": ["dcim.interface"], + "name": "mlag_peer_link", + "label": "MLAG Peer Link", + "type": "boolean", + "required": false, + "description": "Marks interface as MLAG peer-link" + }' +``` + +--- + ## Fabric Component Mapping ### Devices and Roles @@ -88,13 +156,13 @@ Based on the reference topology: ### ASN Assignments -| Device(s) | ASN | Type | -|-----------|-----|------| -| spine1, spine2 | 65000 | eBGP | -| leaf1, leaf2 | 65001 | iBGP pair | -| leaf3, leaf4 | 65002 | iBGP pair | -| leaf5, leaf6 | 65003 | iBGP pair | -| leaf7, leaf8 | 65004 | iBGP pair | +| Device(s) | ASN | Type | Custom Field | +|-----------|-----|------|--------------| +| spine1, spine2 | 65000 | eBGP | `asn: 65000` | +| leaf1, leaf2 | 65001 | iBGP pair | `asn: 65001` | +| leaf3, leaf4 | 65002 | iBGP pair | `asn: 65002` | +| leaf5, leaf6 | 65003 | iBGP pair | `asn: 65003` | +| leaf7, leaf8 | 65004 | iBGP pair | `asn: 65004` | ### BGP Sessions (Plugin) @@ -167,17 +235,7 @@ Link VLANs to L2VPNs on specific devices: ## MLAG Configuration -MLAG configuration requires Custom Fields since NetBox doesn't have native MLAG support. - -### Custom Fields Required - -| Field Name | Object Type | Type | Description | -|------------|-------------|------|-------------| -| `mlag_domain_id` | Device | Text | MLAG domain identifier | -| `mlag_peer_address` | Device | Text (IP) | MLAG peer IP address | -| `mlag_local_address` | Device | Text (IP) | MLAG local IP address | -| `mlag_peer_link` | Interface | Boolean | Marks interface as MLAG peer-link | -| `mlag_virtual_mac` | Device | Text | Shared virtual-router MAC | +MLAG configuration uses Custom Fields since NetBox doesn't have native MLAG support. ### MLAG Pair Configuration @@ -199,22 +257,24 @@ MLAG configuration requires Custom Fields since NetBox doesn't have native MLAG ### VRF Definition -| VRF Name | RD | Import RT | Export RT | L3 VNI | -|----------|-------|-----------|-----------|--------| +| VRF Name | RD | Import RT | Export RT | L3 VNI (custom field) | +|----------|-------|-----------|-----------|----------------------| | gold | `10.0.250.X:1` | `1:100001` | `1:100001` | 100001 | > Note: RD uses device's Loopback0 IP for uniqueness ### VRF Interface Assignment -SVIs are assigned to VRFs in NetBox: +SVIs are assigned to VRFs in NetBox. VRF membership is tracked via IP addresses assigned to interfaces: -| Interface | VRF | IP Address | Virtual IP | -|-----------|-----|------------|------------| -| Vlan34 (leaf3) | gold | 10.34.34.2/24 | 10.34.34.1 | -| Vlan34 (leaf4) | gold | 10.34.34.3/24 | 10.34.34.1 | -| Vlan78 (leaf7) | gold | 10.78.78.2/24 | 10.78.78.1 | -| Vlan78 (leaf8) | gold | 10.78.78.3/24 | 10.78.78.1 | +| Interface | VRF | IP Address | Virtual IP (custom field) | +|-----------|-----|------------|---------------------------| +| Vlan34 (leaf3) | gold | 10.34.34.2/24 | No | +| Vlan34 (leaf4) | gold | 10.34.34.3/24 | No | +| Vlan34 (leaf3) | gold | 10.34.34.1/24 | Yes (anycast) | +| Vlan78 (leaf7) | gold | 10.78.78.2/24 | No | +| Vlan78 (leaf8) | gold | 10.78.78.3/24 | No | +| Vlan78 (leaf7) | gold | 10.78.78.1/24 | Yes (anycast) | --- @@ -230,6 +290,10 @@ nb = pynetbox.api('http://netbox.example.com', token='your-token') # Get all leaf devices leaves = nb.dcim.devices.filter(role='leaf') +# Get device ASN from custom field +device = nb.dcim.devices.get(name='leaf1') +asn = device.custom_fields.get('asn') + # Get BGP sessions for a device bgp_sessions = nb.plugins.bgp.sessions.filter(device='leaf1') @@ -238,6 +302,10 @@ l2vpns = nb.vpn.l2vpns.filter(type='evpn') # Get VLAN-to-VNI mappings via L2VPN terminations terminations = nb.vpn.l2vpn_terminations.filter(l2vpn='VLAN40-L2VNI') + +# Get VRF with L3 VNI +vrf = nb.ipam.vrfs.get(name='gold') +l3vni = vrf.custom_fields.get('l3vni') ``` --- @@ -245,51 +313,79 @@ terminations = nb.vpn.l2vpn_terminations.filter(l2vpn='VLAN40-L2VNI') ## Relationship Diagram ``` -┌─────────────────────────────────────────────────────────────────┐ -│ NetBox │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────────┐ ┌─────────────┐ │ -│ │ Device │────▶│ Interface │────▶│ IP Address │ │ -│ │ (leaf1) │ │ (Loopback0) │ │(10.0.250.11)│ │ -│ └──────────┘ └──────────────┘ └─────────────┘ │ -│ │ │ -│ │ ASN assignment │ -│ ▼ │ -│ ┌──────────┐ │ -│ │ ASN │ │ -│ │ (65001) │ │ -│ └──────────┘ │ -│ │ -│ ┌──────────────┐ ┌─────────────────┐ │ -│ │ BGP Session │────▶│ Peer Group │ │ -│ │ (plugin) │ │ (plugin) │ │ -│ └──────────────┘ └─────────────────┘ │ -│ │ -│ ┌──────────┐ ┌─────────────────────┐ ┌──────────┐ │ -│ │ VLAN │────▶│ L2VPN Termination │────▶│ L2VPN │ │ -│ │ (40) │ │ │ │ (EVPN) │ │ -│ └──────────┘ └─────────────────────┘ └──────────┘ │ -│ │ │ -│ │ VNI │ -│ ▼ │ -│ ┌──────────┐ │ -│ │ 100040 │ │ -│ └──────────┘ │ -│ │ -│ ┌──────────┐ ┌─────────────────┐ │ -│ │ VRF │────▶│ Route Target │ │ -│ │ (gold) │ │ (1:100001) │ │ -│ └──────────┘ └─────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ NetBox │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────┐ ┌────────────────┐ ┌───────────────┐ │ +│ │ Device │─────│ Interface │─────│ IP Address │ │ +│ │ (leaf1) │ │ (Loopback0) │ │(10.0.250.11) │ │ +│ │ cf:asn │ │ cf:mlag_peer_ │ │ cf:virtual_ip │ │ +│ └────────────┘ │ link │ └───────────────┘ │ +│ │ └────────────────┘ │ +│ │ │ +│ │ custom_fields │ +│ ▼ │ +│ ┌────────────┐ │ +│ │ cf:asn │ │ +│ │ (65001) │ │ +│ └────────────┘ │ +│ │ +│ ┌──────────────────┐ ┌───────────────────┐ │ +│ │ BGP Session │─────│ Peer Group │ │ +│ │ (plugin) │ │ (plugin) │ │ +│ └──────────────────┘ └───────────────────┘ │ +│ │ +│ ┌────────────┐ ┌───────────────────────┐ ┌────────────┐ │ +│ │ VLAN │─────│ L2VPN Termination │─────│ L2VPN │ │ +│ │ (40) │ │ │ │ (EVPN) │ │ +│ └────────────┘ └───────────────────────┘ └────────────┘ │ +│ │ │ +│ │ identifier │ +│ ▼ │ +│ ┌────────────┐ │ +│ │ 100040 │ │ +│ │ (VNI) │ │ +│ └────────────┘ │ +│ │ +│ ┌────────────┐ ┌───────────────────┐ │ +│ │ VRF │─────│ Route Target │ │ +│ │ (gold) │ │ (1:100001) │ │ +│ │ cf:l3vni │ └───────────────────┘ │ +│ └────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` --- +## API Filter Compatibility Notes + +### Working Filters (NetBox 4.4) + +| Endpoint | Filter | Example | +|----------|--------|---------| +| `/api/dcim/devices/` | `role`, `name`, `site` | `?role=leaf` | +| `/api/dcim/interfaces/` | `device`, `type`, `name` | `?device=leaf1&type=virtual` | +| `/api/ipam/ip-addresses/` | `device`, `interface`, `vrf` | `?device=leaf1&vrf=gold` | +| `/api/ipam/vlans/` | `vid`, `site`, `group` | `?vid=40` | +| `/api/vpn/l2vpns/` | `type`, `name` | `?type=evpn` | +| `/api/vpn/l2vpn-terminations/` | `l2vpn`, `vlan_id` | `?l2vpn=VLAN40-L2VNI` | +| `/api/plugins/bgp/sessions/` | `device`, `peer_group` | `?device=leaf1` | + +### Non-Working Filters (Require Workarounds) + +| Endpoint | Invalid Filter | Workaround | +|----------|----------------|------------| +| `/api/ipam/asns/` | `device` | Use custom field `asn` on Device | +| `/api/vpn/l2vpn-terminations/` | `device` | Filter by `vlan_id` then correlate | +| `/api/dcim/interfaces/` | `vrf` | Query IPs with VRF filter, get interface IDs | + +--- + ## Next Steps -1. **Custom Fields Setup** - Create MLAG-related custom fields in NetBox +1. **Custom Fields Setup** - Create all custom fields listed above in NetBox 2. **Data Population** - Enter fabric topology data into NetBox -3. **NetBox Client** - Implement `src/netbox/client.py` using pynetbox +3. **NetBox Client** - Update `src/netbox/client.py` to use custom fields 4. **Validation** - Verify data retrieval matches expected fabric config -- 2.52.0 From ac763aa37676c645915293b521d59cc390861ff5 Mon Sep 17 00:00:00 2001 From: darnodo Date: Fri, 9 Jan 2026 10:24:57 +0100 Subject: [PATCH 5/5] fix(netbox): adapt client for NetBox 4.4 API compatibility - Bump pynetbox dependency to >=7.5.0 - Change get_device_asn to read ASN from device custom field instead of filtering ASNs by device (unsupported in NetBox 4.4) - Remove device_name filter from get_l2vpn_terminations as L2VPN terminations cannot be filtered by device in NetBox 4.4 - Refactor get_vrf_interfaces to query IP addresses with VRF filter and extract associated interfaces (VRF filtering on interfaces unsupported) - Add get_interface_mlag_id method to read MLAG ID from interface custom field --- pyproject.toml | 2 +- src/netbox/client.py | 136 +++++++++++++++++++++++++++++++++++++------ uv.lock | 2 +- 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ac4f87b..8ce4d94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.12" dependencies = [ "click>=8.1.0", "pygnmi>=0.8.0", - "pynetbox>=7.0.0", + "pynetbox>=7.5.0", "rich>=13.0.0", ] diff --git a/src/netbox/client.py b/src/netbox/client.py index fceee26..27f1d83 100644 --- a/src/netbox/client.py +++ b/src/netbox/client.py @@ -191,7 +191,10 @@ class FabricNetBoxClient: def get_device_asn(self, device_name: str) -> int | None: """ - Get ASN assigned to a device. + Get ASN assigned to a device from custom field. + + Note: NetBox 4.4 does not support filtering ASNs by device. + ASN is stored as a custom field on the Device object. Args: device_name: Device name @@ -200,14 +203,8 @@ class FabricNetBoxClient: ASN number or None if not assigned """ logger.debug(f"Fetching ASN for device: {device_name}") - try: - asns = self._api.ipam.asns.filter(device=device_name) - asn_list = list(asns) - if asn_list: - return asn_list[0].asn - return None - except RequestError as e: - raise NetBoxAPIError(f"Failed to fetch ASN for {device_name}: {e}") from e + device = self.get_device(device_name) + return device.get("custom_fields", {}).get("asn") # ========================================================================= # Interface Methods @@ -455,23 +452,27 @@ class FabricNetBoxClient: """ Get L2VPN terminations (VLAN-to-device mappings). + Note: NetBox 4.4 does not support filtering L2VPN terminations by device. + L2VPN terminations link to VLANs, not devices directly. + The device_name parameter is accepted for API compatibility but ignored. + Args: - device_name: Optional device name filter + device_name: Ignored - not supported by NetBox API l2vpn_name: Optional L2VPN name filter Returns: List of L2VPN termination dictionaries """ - logger.debug( - f"Fetching L2VPN terminations for device: {device_name or 'all'}, " - f"l2vpn: {l2vpn_name or 'all'}" - ) + if device_name: + logger.warning( + f"device_name filter '{device_name}' ignored - " + "L2VPN terminations cannot be filtered by device in NetBox 4.4" + ) + logger.debug(f"Fetching L2VPN terminations for l2vpn: {l2vpn_name or 'all'}") try: filters = {} if l2vpn_name: filters["l2vpn"] = l2vpn_name - if device_name: - filters["device"] = device_name if filters: terminations = self._api.vpn.l2vpn_terminations.filter(**filters) @@ -547,6 +548,10 @@ class FabricNetBoxClient: """ Get interfaces assigned to a VRF on a device. + Note: NetBox 4.4 does not support filtering interfaces by VRF directly. + VRF is assigned to IP addresses, not interfaces. This method queries + IP addresses with VRF filter and extracts the associated interfaces. + Args: device_name: Device name vrf_name: VRF name @@ -556,8 +561,25 @@ class FabricNetBoxClient: """ logger.debug(f"Fetching VRF {vrf_name} interfaces for device: {device_name}") try: - interfaces = self._api.dcim.interfaces.filter(device=device_name, vrf=vrf_name) - return [dict(i) for i in interfaces] + # Get IPs in this VRF for this device + ips = self._api.ipam.ip_addresses.filter(device=device_name, vrf=vrf_name) + + # Extract unique interface IDs + interface_ids = set() + for ip in ips: + if ip.assigned_object_type == "dcim.interface" and ip.assigned_object_id: + interface_ids.add(ip.assigned_object_id) + + # Fetch interfaces + if not interface_ids: + return [] + + interfaces = [] + for iid in interface_ids: + iface = self._api.dcim.interfaces.get(id=iid) + if iface: + interfaces.append(dict(iface)) + return interfaces except RequestError as e: raise NetBoxAPIError( f"Failed to fetch VRF interfaces for {device_name}/{vrf_name}: {e}" @@ -633,6 +655,84 @@ class FabricNetBoxClient: f"Failed to fetch MLAG peer-link for {device_name}: {e}" ) from e + def get_interface_mlag_id( + self, device_name: str, interface_name: str + ) -> int | None: + """ + Get MLAG ID from interface custom field. + + Args: + device_name: Device name + interface_name: Interface name + + Returns: + MLAG ID or None if not assigned + """ + logger.debug(f"Fetching MLAG ID for {device_name}:{interface_name}") + try: + iface = self._api.dcim.interfaces.get(device=device_name, name=interface_name) + if iface: + cf = iface.custom_fields or {} + return cf.get("mlag_id") + return None + except RequestError as e: + raise NetBoxAPIError( + f"Failed to fetch MLAG ID for {device_name}:{interface_name}: {e}" + ) from e + + # ========================================================================= + # VRF Custom Field Methods + # ========================================================================= + + def get_vrf_l3vni(self, vrf_name: str) -> int | None: + """ + Get L3 VNI from VRF custom field. + + Args: + vrf_name: VRF name + + Returns: + L3 VNI number or None if not assigned + """ + logger.debug(f"Fetching L3 VNI for VRF: {vrf_name}") + vrf = self.get_vrf(vrf_name) + if vrf: + return vrf.get("custom_fields", {}).get("l3vni") + return None + + # ========================================================================= + # Virtual IP Methods (Custom Fields) + # ========================================================================= + + def get_virtual_ips(self, device_name: str | None = None) -> list[dict[str, Any]]: + """ + Get IP addresses marked as virtual/anycast IPs. + + Retrieves IPs where the custom field `virtual_ip` is True. + These are typically anycast IPs shared across an MLAG pair. + + Args: + device_name: Optional device name filter + + Returns: + List of virtual IP address dictionaries + """ + logger.debug(f"Fetching virtual IPs for device: {device_name or 'all'}") + try: + if device_name: + ips = self._api.ipam.ip_addresses.filter(device=device_name) + else: + ips = self._api.ipam.ip_addresses.all() + + virtual_ips = [] + for ip in ips: + cf = ip.custom_fields or {} + if cf.get("virtual_ip"): + virtual_ips.append(dict(ip)) + return virtual_ips + except RequestError as e: + raise NetBoxAPIError(f"Failed to fetch virtual IPs: {e}") from e + # ========================================================================= # Aggregated Intent Methods # ========================================================================= diff --git a/uv.lock b/uv.lock index 46f38fe..bebdcdf 100644 --- a/uv.lock +++ b/uv.lock @@ -231,7 +231,7 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.1.0" }, { name = "pygnmi", specifier = ">=0.8.0" }, - { name = "pynetbox", specifier = ">=7.0.0" }, + { name = "pynetbox", specifier = ">=7.5.0" }, { name = "rich", specifier = ">=13.0.0" }, ] -- 2.52.0