Add LLDP cable exporter for NetBox REST API import
Creates cables from LLDP neighbor data by collecting LLDP from all inventory devices, validating both endpoints against the NetBox inventory (devices + interfaces), deduplicating bidirectional links, and importing via the NetBox REST API. Handles interface name normalization across vendors (NOS space-delimited names, abbreviated LLDP names, etc.). First run: 30 cables created across Cisco IOS, IOS-XR, Brocade ICX/VDX, and CML lab routers. Idempotent on re-run (skips already-cabled interfaces). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d70cd8975c
commit
1445e06f34
575
collectors/cable_exporter.py
Normal file
575
collectors/cable_exporter.py
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""LLDP Cable Exporter — collect LLDP neighbors and create cables in NetBox.
|
||||||
|
|
||||||
|
Connects to devices via SSH (NAPALM or Netmiko), collects LLDP neighbor data,
|
||||||
|
validates both endpoints against the NetBox inventory, deduplicates bidirectional
|
||||||
|
links, and creates cables via the NetBox REST API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python collectors/cable_exporter.py -i collectors/inventory.yaml --dry-run
|
||||||
|
python collectors/cable_exporter.py -i collectors/inventory.yaml
|
||||||
|
python collectors/cable_exporter.py -i collectors/inventory.yaml --csv-only
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Import shared utilities from network_collector
|
||||||
|
from network_collector import (
|
||||||
|
NETMIKO_ONLY_DRIVERS,
|
||||||
|
_netmiko_parse_lldp,
|
||||||
|
connect_device,
|
||||||
|
connect_netmiko,
|
||||||
|
load_inventory,
|
||||||
|
merge_device_config,
|
||||||
|
normalize_interface_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger("cable-exporter")
|
||||||
|
|
||||||
|
# Interface types that cannot be cable endpoints in NetBox
|
||||||
|
UNCABLEABLE_TYPES = {"virtual", "lag"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NetBox API helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class NetBoxClient:
|
||||||
|
"""Thin wrapper around the NetBox REST API."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, token: str):
|
||||||
|
self.base = url.rstrip("/")
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
def _get_all(self, endpoint: str, params: dict | None = None) -> list[dict]:
|
||||||
|
"""Paginate through all results for an endpoint."""
|
||||||
|
results = []
|
||||||
|
url = f"{self.base}{endpoint}"
|
||||||
|
p = dict(params or {})
|
||||||
|
p.setdefault("limit", 250)
|
||||||
|
while url:
|
||||||
|
resp = self.session.get(url, params=p)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
results.extend(data.get("results", []))
|
||||||
|
url = data.get("next")
|
||||||
|
p = {} # next URL already has params
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_devices(self, site: str | None = None) -> list[dict]:
|
||||||
|
params = {}
|
||||||
|
if site:
|
||||||
|
params["site"] = site
|
||||||
|
return self._get_all("/api/dcim/devices/", params)
|
||||||
|
|
||||||
|
def get_interfaces(self, device_id: int) -> list[dict]:
|
||||||
|
return self._get_all("/api/dcim/interfaces/", {"device_id": device_id})
|
||||||
|
|
||||||
|
def get_cables(self) -> list[dict]:
|
||||||
|
return self._get_all("/api/dcim/cables/")
|
||||||
|
|
||||||
|
def ensure_tag(self, name: str, slug: str | None = None) -> dict:
|
||||||
|
"""Create tag if it doesn't exist, return tag dict."""
|
||||||
|
slug = slug or name
|
||||||
|
existing = self._get_all("/api/extras/tags/", {"name": name})
|
||||||
|
if existing:
|
||||||
|
return existing[0]
|
||||||
|
resp = self.session.post(
|
||||||
|
f"{self.base}/api/extras/tags/",
|
||||||
|
json={"name": name, "slug": slug},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def create_cable(self, a_iface_id: int, b_iface_id: int,
|
||||||
|
status: str = "connected", tag_ids: list[int] | None = None) -> dict:
|
||||||
|
"""Create a cable between two interface IDs."""
|
||||||
|
payload = {
|
||||||
|
"a_terminations": [{"object_type": "dcim.interface", "object_id": a_iface_id}],
|
||||||
|
"b_terminations": [{"object_type": "dcim.interface", "object_id": b_iface_id}],
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if tag_ids:
|
||||||
|
payload["tags"] = [{"id": tid} for tid in tag_ids]
|
||||||
|
resp = self.session.post(f"{self.base}/api/dcim/cables/", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NetBox inventory cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_netbox_cache(nb: NetBoxClient, site: str) -> dict:
|
||||||
|
"""Build lookup tables from NetBox API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"devices": {device_name: device_id},
|
||||||
|
"interfaces": {(device_id, iface_name): {"id": N, "type": "..."}},
|
||||||
|
"cabled_ifaces": set of interface IDs that already have cables,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
cache = {"devices": {}, "interfaces": {}, "cabled_ifaces": set()}
|
||||||
|
|
||||||
|
# Load devices at target site
|
||||||
|
devices = nb.get_devices(site=site)
|
||||||
|
log.info(" Loaded %d devices from site '%s'", len(devices), site)
|
||||||
|
for dev in devices:
|
||||||
|
name = dev["name"]
|
||||||
|
if name in cache["devices"]:
|
||||||
|
log.debug(" Duplicate device name '%s' at site '%s', keeping first", name, site)
|
||||||
|
continue
|
||||||
|
cache["devices"][name] = dev["id"]
|
||||||
|
|
||||||
|
# Load interfaces for each device
|
||||||
|
iface_count = 0
|
||||||
|
for dev_name, dev_id in cache["devices"].items():
|
||||||
|
ifaces = nb.get_interfaces(dev_id)
|
||||||
|
for iface in ifaces:
|
||||||
|
iface_type = iface["type"]["value"] if iface.get("type") else "other"
|
||||||
|
cache["interfaces"][(dev_id, iface["name"])] = {
|
||||||
|
"id": iface["id"],
|
||||||
|
"type": iface_type,
|
||||||
|
}
|
||||||
|
iface_count += 1
|
||||||
|
log.info(" Loaded %d interfaces across %d devices", iface_count, len(cache["devices"]))
|
||||||
|
|
||||||
|
# Load existing cables to find already-cabled interfaces
|
||||||
|
cables = nb.get_cables()
|
||||||
|
for cable in cables:
|
||||||
|
for term in cable.get("a_terminations", []):
|
||||||
|
obj = term.get("object")
|
||||||
|
if obj:
|
||||||
|
cache["cabled_ifaces"].add(obj["id"])
|
||||||
|
for term in cable.get("b_terminations", []):
|
||||||
|
obj = term.get("object")
|
||||||
|
if obj:
|
||||||
|
cache["cabled_ifaces"].add(obj["id"])
|
||||||
|
log.info(" Found %d existing cables (%d cabled interfaces)",
|
||||||
|
len(cables), len(cache["cabled_ifaces"]))
|
||||||
|
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLDP collection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def collect_lldp_from_device(device_cfg: dict) -> dict:
|
||||||
|
"""Connect to a single device and collect only LLDP neighbors.
|
||||||
|
|
||||||
|
Returns NAPALM-format lldp_neighbors dict or empty dict on failure.
|
||||||
|
"""
|
||||||
|
host = device_cfg["host"]
|
||||||
|
driver = device_cfg["driver"]
|
||||||
|
username = device_cfg["username"]
|
||||||
|
password = device_cfg["password"]
|
||||||
|
secret = device_cfg.get("secret", "")
|
||||||
|
timeout = device_cfg.get("timeout", 60)
|
||||||
|
use_netmiko = driver in NETMIKO_ONLY_DRIVERS
|
||||||
|
|
||||||
|
lldp = {}
|
||||||
|
|
||||||
|
if use_netmiko:
|
||||||
|
log.debug(" Using Netmiko fallback for %s (driver=%s)", host, driver)
|
||||||
|
try:
|
||||||
|
conn = connect_netmiko(host, driver, username, password, secret, timeout)
|
||||||
|
lldp = _netmiko_parse_lldp(conn, driver)
|
||||||
|
conn.disconnect()
|
||||||
|
except Exception as exc:
|
||||||
|
log.error(" Netmiko LLDP collection failed for %s: %s", host, exc)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dev = connect_device(host, driver, username, password, secret, timeout)
|
||||||
|
try:
|
||||||
|
lldp = dev.get_lldp_neighbors_detail()
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning(" NAPALM get_lldp_neighbors_detail() failed for %s: %s", host, exc)
|
||||||
|
dev.close()
|
||||||
|
except Exception as exc:
|
||||||
|
log.error(" NAPALM connection failed for %s: %s", host, exc)
|
||||||
|
|
||||||
|
total = sum(len(v) for v in lldp.values())
|
||||||
|
if total:
|
||||||
|
log.info(" %s: %d LLDP neighbors", host, total)
|
||||||
|
return lldp
|
||||||
|
|
||||||
|
|
||||||
|
def collect_all_lldp(inventory: dict) -> dict:
|
||||||
|
"""Collect LLDP from all devices in inventory.
|
||||||
|
|
||||||
|
Returns {device_hostname: lldp_neighbors_dict} where hostname is the
|
||||||
|
inventory 'host' field (IP or name). We'll map to NetBox device names later.
|
||||||
|
"""
|
||||||
|
defaults = inventory.get("defaults", {})
|
||||||
|
all_lldp = {}
|
||||||
|
devices = inventory["devices"]
|
||||||
|
|
||||||
|
for entry in devices:
|
||||||
|
cfg = merge_device_config(entry, defaults)
|
||||||
|
host = cfg["host"]
|
||||||
|
log.info("Collecting LLDP from %s (driver=%s)...", host, cfg["driver"])
|
||||||
|
lldp = collect_lldp_from_device(cfg)
|
||||||
|
if lldp:
|
||||||
|
# Store with the inventory host key — we'll resolve to NetBox name later
|
||||||
|
all_lldp[host] = {"lldp": lldp, "config": cfg}
|
||||||
|
|
||||||
|
return all_lldp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLDP → Cable matching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_ABBREV_TO_LONG = {
|
||||||
|
"Gi": "GigabitEthernet", "Te": "TenGigabitEthernet",
|
||||||
|
"Fa": "FastEthernet", "Et": "Ethernet",
|
||||||
|
"Fo": "FortyGigabitEthernet", "Hu": "HundredGigE",
|
||||||
|
"Lo": "Loopback", "Vl": "Vlan", "Po": "Port-channel",
|
||||||
|
"Mg": "MgmtEth", "Tu": "Tunnel", "Se": "Serial",
|
||||||
|
}
|
||||||
|
_LONG_TO_ABBREV = {v: k for k, v in _ABBREV_TO_LONG.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_interface(iface_cache: dict, dev_id: int, iface_name: str) -> dict | None:
|
||||||
|
"""Look up an interface with fallback name variations.
|
||||||
|
|
||||||
|
Tries multiple naming conventions:
|
||||||
|
1. Exact match
|
||||||
|
2. With/without space after type prefix
|
||||||
|
3. Abbreviated ↔ long form (Te ↔ TenGigabitEthernet)
|
||||||
|
"""
|
||||||
|
# Exact match
|
||||||
|
result = iface_cache.get((dev_id, iface_name))
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
m = re.match(r"^([A-Za-z]+)\s*(\d.*)$", iface_name)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
prefix, rest = m.group(1), m.group(2)
|
||||||
|
|
||||||
|
# Try with/without space
|
||||||
|
for fmt in [f"{prefix} {rest}", f"{prefix}{rest}"]:
|
||||||
|
result = iface_cache.get((dev_id, fmt))
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Try long form ↔ abbreviated form
|
||||||
|
alt_prefix = _LONG_TO_ABBREV.get(prefix) or _ABBREV_TO_LONG.get(prefix)
|
||||||
|
if alt_prefix:
|
||||||
|
for fmt in [f"{alt_prefix} {rest}", f"{alt_prefix}{rest}"]:
|
||||||
|
result = iface_cache.get((dev_id, fmt))
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_cable_list(all_lldp: dict, cache: dict, nb: NetBoxClient) -> list[dict]:
|
||||||
|
"""Build validated cable list from LLDP data + NetBox cache.
|
||||||
|
|
||||||
|
Returns list of cable dicts:
|
||||||
|
[{"a_device": str, "a_iface": str, "a_iface_id": int,
|
||||||
|
"b_device": str, "b_iface": str, "b_iface_id": int,
|
||||||
|
"skip_reason": str | None}]
|
||||||
|
"""
|
||||||
|
device_cache = cache["devices"]
|
||||||
|
iface_cache = cache["interfaces"]
|
||||||
|
cabled_ifaces = cache["cabled_ifaces"]
|
||||||
|
|
||||||
|
# Build IP → device_name mapping by querying NetBox for primary IPs
|
||||||
|
ip_to_device = _build_ip_to_device_map(nb, device_cache)
|
||||||
|
|
||||||
|
# Build hostname → device_name (case-insensitive) for LLDP remote_system_name
|
||||||
|
# Include both full names and short names (stripped FQDN) for matching
|
||||||
|
name_lower = {}
|
||||||
|
for name in device_cache:
|
||||||
|
name_lower[name.lower()] = name
|
||||||
|
# Also index short name (strip FQDN)
|
||||||
|
if "." in name:
|
||||||
|
short = name.split(".")[0].lower()
|
||||||
|
if short not in name_lower:
|
||||||
|
name_lower[short] = name
|
||||||
|
|
||||||
|
seen_links = set() # for bidirectional dedup
|
||||||
|
cables = []
|
||||||
|
stats = {"total_pairs": 0, "matched": 0, "skipped_device": 0,
|
||||||
|
"skipped_iface": 0, "skipped_type": 0, "skipped_cabled": 0, "skipped_dup": 0}
|
||||||
|
|
||||||
|
for host, data in all_lldp.items():
|
||||||
|
lldp = data["lldp"]
|
||||||
|
# Resolve local device name from IP
|
||||||
|
local_name = ip_to_device.get(host)
|
||||||
|
if not local_name:
|
||||||
|
log.warning(" %s: not found in NetBox, skipping all its neighbors", host)
|
||||||
|
continue
|
||||||
|
local_dev_id = device_cache[local_name]
|
||||||
|
|
||||||
|
for local_iface_raw, neighbors in lldp.items():
|
||||||
|
for neighbor in neighbors:
|
||||||
|
stats["total_pairs"] += 1
|
||||||
|
|
||||||
|
# -- Resolve remote device --
|
||||||
|
remote_sys = neighbor.get("remote_system_name", "").strip()
|
||||||
|
# Strip FQDN
|
||||||
|
if "." in remote_sys:
|
||||||
|
remote_sys = remote_sys.split(".")[0]
|
||||||
|
# Strip IOS-XR RP prefix
|
||||||
|
if ":" in remote_sys:
|
||||||
|
remote_sys = remote_sys.rsplit(":", 1)[-1]
|
||||||
|
|
||||||
|
remote_name = name_lower.get(remote_sys.lower())
|
||||||
|
if not remote_name:
|
||||||
|
# Try remote_system_description as fallback
|
||||||
|
desc = neighbor.get("remote_system_description", "").strip()
|
||||||
|
if desc:
|
||||||
|
short = desc.split(".")[0] if "." in desc else desc
|
||||||
|
remote_name = name_lower.get(short.lower())
|
||||||
|
if not remote_name:
|
||||||
|
stats["skipped_device"] += 1
|
||||||
|
log.debug(" Skip: remote device '%s' not in NetBox", remote_sys)
|
||||||
|
continue
|
||||||
|
remote_dev_id = device_cache[remote_name]
|
||||||
|
|
||||||
|
# -- Resolve local interface --
|
||||||
|
local_iface = normalize_interface_name(local_iface_raw.strip())
|
||||||
|
local_iface_info = lookup_interface(iface_cache, local_dev_id, local_iface)
|
||||||
|
if not local_iface_info:
|
||||||
|
stats["skipped_iface"] += 1
|
||||||
|
log.debug(" Skip: %s:%s not found in NetBox", local_name, local_iface)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -- Resolve remote interface --
|
||||||
|
remote_port_raw = (neighbor.get("remote_port", "")
|
||||||
|
or neighbor.get("remote_port_description", "")).strip()
|
||||||
|
remote_iface = normalize_interface_name(remote_port_raw)
|
||||||
|
remote_iface_info = lookup_interface(iface_cache, remote_dev_id, remote_iface)
|
||||||
|
if not remote_iface_info:
|
||||||
|
stats["skipped_iface"] += 1
|
||||||
|
log.debug(" Skip: %s:%s not found in NetBox", remote_name, remote_iface)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -- Check interface types (no LAG / virtual) --
|
||||||
|
if local_iface_info["type"] in UNCABLEABLE_TYPES:
|
||||||
|
stats["skipped_type"] += 1
|
||||||
|
log.debug(" Skip: %s:%s is type '%s' (uncableable)",
|
||||||
|
local_name, local_iface, local_iface_info["type"])
|
||||||
|
continue
|
||||||
|
if remote_iface_info["type"] in UNCABLEABLE_TYPES:
|
||||||
|
stats["skipped_type"] += 1
|
||||||
|
log.debug(" Skip: %s:%s is type '%s' (uncableable)",
|
||||||
|
remote_name, remote_iface, remote_iface_info["type"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -- Check if already cabled --
|
||||||
|
a_id = local_iface_info["id"]
|
||||||
|
b_id = remote_iface_info["id"]
|
||||||
|
if a_id in cabled_ifaces or b_id in cabled_ifaces:
|
||||||
|
stats["skipped_cabled"] += 1
|
||||||
|
log.debug(" Skip: %s:%s or %s:%s already cabled",
|
||||||
|
local_name, local_iface, remote_name, remote_iface)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# -- Bidirectional dedup --
|
||||||
|
link_key = tuple(sorted([a_id, b_id]))
|
||||||
|
if link_key in seen_links:
|
||||||
|
stats["skipped_dup"] += 1
|
||||||
|
continue
|
||||||
|
seen_links.add(link_key)
|
||||||
|
|
||||||
|
cables.append({
|
||||||
|
"a_device": local_name,
|
||||||
|
"a_iface": local_iface,
|
||||||
|
"a_iface_id": a_id,
|
||||||
|
"b_device": remote_name,
|
||||||
|
"b_iface": remote_iface,
|
||||||
|
"b_iface_id": b_id,
|
||||||
|
})
|
||||||
|
stats["matched"] += 1
|
||||||
|
|
||||||
|
log.info("Cable matching summary:")
|
||||||
|
log.info(" Total LLDP pairs: %d", stats["total_pairs"])
|
||||||
|
log.info(" Matched cables: %d", stats["matched"])
|
||||||
|
log.info(" Skipped (device not found): %d", stats["skipped_device"])
|
||||||
|
log.info(" Skipped (interface not found): %d", stats["skipped_iface"])
|
||||||
|
log.info(" Skipped (uncableable type): %d", stats["skipped_type"])
|
||||||
|
log.info(" Skipped (already cabled): %d", stats["skipped_cabled"])
|
||||||
|
log.info(" Skipped (bidirectional dup): %d", stats["skipped_dup"])
|
||||||
|
|
||||||
|
return cables
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ip_to_device_map(nb: NetBoxClient, device_cache: dict) -> dict:
|
||||||
|
"""Build {ip_address: device_name} mapping from NetBox interface IPs.
|
||||||
|
|
||||||
|
This lets us map inventory hosts (which use IPs) to NetBox device names.
|
||||||
|
"""
|
||||||
|
ip_to_device = {}
|
||||||
|
# Query all IP addresses and map to their assigned device
|
||||||
|
ips = nb._get_all("/api/ipam/ip-addresses/", {"limit": 250})
|
||||||
|
for ip_entry in ips:
|
||||||
|
addr = ip_entry.get("address", "") # "10.100.0.100/24"
|
||||||
|
ip_only = addr.split("/")[0] if "/" in addr else addr
|
||||||
|
assigned = ip_entry.get("assigned_object")
|
||||||
|
if assigned and assigned.get("device"):
|
||||||
|
dev_name = assigned["device"]["name"]
|
||||||
|
if dev_name in device_cache:
|
||||||
|
ip_to_device[ip_only] = dev_name
|
||||||
|
log.info(" Built IP→device map: %d IPs mapped", len(ip_to_device))
|
||||||
|
return ip_to_device
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(cables: list[dict], output_path: str, site: str):
|
||||||
|
"""Write cables to CSV in NetBox bulk import format."""
|
||||||
|
with open(output_path, "w", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow([
|
||||||
|
"side_a_device", "side_a_type", "side_a_name",
|
||||||
|
"side_b_device", "side_b_type", "side_b_name",
|
||||||
|
"side_a_site", "side_b_site", "status", "tags",
|
||||||
|
])
|
||||||
|
for cable in cables:
|
||||||
|
writer.writerow([
|
||||||
|
cable["a_device"], "dcim.interface", cable["a_iface"],
|
||||||
|
cable["b_device"], "dcim.interface", cable["b_iface"],
|
||||||
|
site, site, "connected", "lldp-discovered",
|
||||||
|
])
|
||||||
|
log.info("CSV written to %s (%d cables)", output_path, len(cables))
|
||||||
|
|
||||||
|
|
||||||
|
def create_cables_via_api(nb: NetBoxClient, cables: list[dict], tag_id: int | None) -> int:
|
||||||
|
"""Create cables via NetBox REST API. Returns count of successfully created cables."""
|
||||||
|
created = 0
|
||||||
|
tag_ids = [tag_id] if tag_id else None
|
||||||
|
for cable in cables:
|
||||||
|
try:
|
||||||
|
nb.create_cable(cable["a_iface_id"], cable["b_iface_id"], tag_ids=tag_ids)
|
||||||
|
log.info(" Created: %s:%s <-> %s:%s",
|
||||||
|
cable["a_device"], cable["a_iface"],
|
||||||
|
cable["b_device"], cable["b_iface"])
|
||||||
|
created += 1
|
||||||
|
except requests.HTTPError as exc:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = exc.response.json()
|
||||||
|
except Exception:
|
||||||
|
body = exc.response.text[:200]
|
||||||
|
log.error(" Failed: %s:%s <-> %s:%s — %s",
|
||||||
|
cable["a_device"], cable["a_iface"],
|
||||||
|
cable["b_device"], cable["b_iface"], body)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="LLDP Cable Exporter for NetBox")
|
||||||
|
parser.add_argument("-i", "--inventory", required=True, help="Path to inventory.yaml")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Show cables without creating them")
|
||||||
|
parser.add_argument("--csv-only", action="store_true", help="Only generate CSV, don't call API")
|
||||||
|
parser.add_argument("--output", default="cables_export.csv", help="CSV output path")
|
||||||
|
parser.add_argument("--site", default="main", help="NetBox site to target (default: main)")
|
||||||
|
parser.add_argument("--log-level", default="INFO", help="Log level (DEBUG/INFO/WARNING)")
|
||||||
|
parser.add_argument("--env-file", default=".env", help="Path to .env file")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, args.log_level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load environment
|
||||||
|
env_path = Path(args.env_file)
|
||||||
|
if env_path.exists():
|
||||||
|
load_dotenv(env_path)
|
||||||
|
else:
|
||||||
|
log.warning("Env file %s not found, using environment variables", args.env_file)
|
||||||
|
|
||||||
|
netbox_url = os.environ.get("NETBOX_API_URL")
|
||||||
|
netbox_token = os.environ.get("NETBOX_API_TOKEN")
|
||||||
|
if not netbox_url or not netbox_token:
|
||||||
|
log.error("NETBOX_API_URL and NETBOX_API_TOKEN must be set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Load inventory
|
||||||
|
inventory = load_inventory(args.inventory)
|
||||||
|
device_count = len(inventory.get("devices", []))
|
||||||
|
log.info("Loaded %d devices from inventory", device_count)
|
||||||
|
|
||||||
|
# Connect to NetBox API
|
||||||
|
nb = NetBoxClient(netbox_url, netbox_token)
|
||||||
|
log.info("Building NetBox inventory cache (site=%s)...", args.site)
|
||||||
|
cache = build_netbox_cache(nb, args.site)
|
||||||
|
|
||||||
|
# Collect LLDP from all devices
|
||||||
|
log.info("Collecting LLDP neighbors from devices...")
|
||||||
|
all_lldp = collect_all_lldp(inventory)
|
||||||
|
connected = len(all_lldp)
|
||||||
|
log.info("LLDP collected from %d/%d devices", connected, device_count)
|
||||||
|
|
||||||
|
# Match and validate cables
|
||||||
|
log.info("Matching LLDP data against NetBox inventory...")
|
||||||
|
cables = build_cable_list(all_lldp, cache, nb)
|
||||||
|
|
||||||
|
if not cables:
|
||||||
|
log.info("No valid cables to create.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Output
|
||||||
|
if args.dry_run:
|
||||||
|
log.info("DRY RUN — %d cables would be created:", len(cables))
|
||||||
|
for c in cables:
|
||||||
|
log.info(" %s:%s <-> %s:%s", c["a_device"], c["a_iface"],
|
||||||
|
c["b_device"], c["b_iface"])
|
||||||
|
write_csv(cables, args.output, args.site)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Write CSV
|
||||||
|
write_csv(cables, args.output, args.site)
|
||||||
|
|
||||||
|
if args.csv_only:
|
||||||
|
log.info("CSV-only mode — skipping API import")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure tag exists
|
||||||
|
tag = nb.ensure_tag("lldp-discovered")
|
||||||
|
tag_id = tag["id"]
|
||||||
|
|
||||||
|
# Create cables via API
|
||||||
|
log.info("Creating %d cables via NetBox API...", len(cables))
|
||||||
|
created = create_cables_via_api(nb, cables, tag_id)
|
||||||
|
log.info("Done! Created %d/%d cables.", created, len(cables))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
34
collectors/cables_export.csv
Normal file
34
collectors/cables_export.csv
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name,side_a_site,side_b_site,status,tags
|
||||||
|
4351-01,dcim.interface,GigabitEthernet0/0/0,C3850-04,dcim.interface,GigabitEthernet1/0/10,main,main,connected,lldp-discovered
|
||||||
|
4351-01,dcim.interface,GigabitEthernet0,C3850-04,dcim.interface,GigabitEthernet1/0/2,main,main,connected,lldp-discovered
|
||||||
|
2960CX-01,dcim.interface,GigabitEthernet0/10,C3850-04,dcim.interface,GigabitEthernet1/0/3,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/1,CML-R9K-01,dcim.interface,GigabitEthernet0/0/0/1,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/2,CML-R9K-01,dcim.interface,GigabitEthernet0/0/0/2,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/4,CML-R9K-04,dcim.interface,GigabitEthernet0/0/0/4,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/5,CML-R9K-04,dcim.interface,GigabitEthernet0/0/0/5,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/6,CML-R9K-05,dcim.interface,GigabitEthernet0/0/0/6,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/7,CML-R9K-05,dcim.interface,GigabitEthernet0/0/0/5,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/12,CML-R9K-02,dcim.interface,GigabitEthernet0/0/0/12,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/13,CML-R9K-03,dcim.interface,GigabitEthernet0/0/0/13,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/14,CML-R9K-02,dcim.interface,GigabitEthernet0/0/0/14,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/15,CML-R9K-CORE-02,dcim.interface,GigabitEthernet0/0/0/0,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-02,dcim.interface,GigabitEthernet0/0/0/1,CML-R9K-CORE-01,dcim.interface,GigabitEthernet0/0/0/16,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-02,dcim.interface,GigabitEthernet0/0/0/4,CML-R9K-04,dcim.interface,GigabitEthernet0/0/0/2,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-02,dcim.interface,GigabitEthernet0/0/0/5,CML-R9k-06,dcim.interface,GigabitEthernet0/0/0/5,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-02,dcim.interface,GigabitEthernet0/0/0/6,CML-R9k-06,dcim.interface,GigabitEthernet0/0/0/6,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-02,dcim.interface,GigabitEthernet0/0/0/7,CML-R9K-05,dcim.interface,GigabitEthernet0/0/0/7,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-CORE-02,dcim.interface,GigabitEthernet0/0/0/8,CML-R9K-05,dcim.interface,GigabitEthernet0/0/0/8,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-03,dcim.interface,GigabitEthernet0/0/0/0,ebgppeer,dcim.interface,GigabitEthernet2,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-03,dcim.interface,GigabitEthernet0/0/0/3,CML-R9K-05,dcim.interface,GigabitEthernet0/0/0/3,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-04,dcim.interface,GigabitEthernet0/0/0/0,CML-R9k-06,dcim.interface,GigabitEthernet0/0/0/0,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-04,dcim.interface,GigabitEthernet0/0/0/1,CML-R9k-06,dcim.interface,GigabitEthernet0/0/0/1,main,main,connected,lldp-discovered
|
||||||
|
CML-R9K-05,dcim.interface,GigabitEthernet0/0/0/4,CML-R9K-03,dcim.interface,GigabitEthernet0/0/0/4,main,main,connected,lldp-discovered
|
||||||
|
ebgppeer,dcim.interface,GigabitEthernet1,CML-MLS-MGMT.apodacalab.com,dcim.interface,Ethernet1/0,main,main,connected,lldp-discovered
|
||||||
|
Brocade40G-01,dcim.interface,1/1/1,im7248-2-dac,dcim.interface,eth0,main,main,connected,lldp-discovered
|
||||||
|
Brocade40G-01,dcim.interface,1/2/1,Brocade-VDX-6940-01,dcim.interface,FortyGigabitEthernet 1/0/3,main,main,connected,lldp-discovered
|
||||||
|
Brocade40G-02,dcim.interface,1/2/1,Brocade-VDX-6940-01,dcim.interface,FortyGigabitEthernet 1/0/2,main,main,connected,lldp-discovered
|
||||||
|
Brocade40g-Core,dcim.interface,1/2/1,Brocade-VDX-6940-01,dcim.interface,FortyGigabitEthernet 1/0/1,main,main,connected,lldp-discovered
|
||||||
|
Brocade-VDX-6940-01,dcim.interface,FortyGigabitEthernet1/0/1,Brocade40g-Core,dcim.interface,40GigabitEthernet1/2/1,main,main,connected,lldp-discovered
|
||||||
|
Brocade-VDX-6940-01,dcim.interface,FortyGigabitEthernet1/0/2,Brocade40G-02,dcim.interface,40GigabitEthernet1/2/1,main,main,connected,lldp-discovered
|
||||||
|
Brocade-VDX-6940-01,dcim.interface,FortyGigabitEthernet1/0/3,Brocade40G-01,dcim.interface,40GigabitEthernet1/2/1,main,main,connected,lldp-discovered
|
||||||
|
Brocade-VDX-6940-01,dcim.interface,FortyGigabitEthernet1/0/33,proxmox3,dcim.interface,ens2,main,main,connected,lldp-discovered
|
||||||
|
Loading…
x
Reference in New Issue
Block a user