Skip to content

Commit

Permalink
Initial implementation for Netbox v4
Browse files Browse the repository at this point in the history
  • Loading branch information
johannwagner committed Oct 21, 2024
1 parent 9d62bc7 commit a160b4f
Show file tree
Hide file tree
Showing 2 changed files with 232 additions and 20 deletions.
209 changes: 209 additions & 0 deletions cosmo/graphqlclient.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,44 @@
import json
from string import Template
from urllib.parse import urlencode

import requests


class GraphqlClient:
def __init__(self, url, token):
self.url = url
self.token = token

v = self.query_version()

if v == "3.x":
self.child_client = GraphqlClientV3(url, token)
elif v == "4.x":
self.child_client = GraphqlClientV4(url, token)
else:
raise Exception("Unknown Version")

def query_version(self):
r = requests.get(
f"{self.url}/api/status/",
headers={
"Authorization": f"Token {self.token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
if r.status_code != 200:
raise Exception("Error querying api: " + r.text)

json = r.json()
return "3.x" if str(json['netbox-version']).startswith("wc_3") else "4.x"

def get_data(self, device_config):
return self.child_client.get_data(device_config)


class GraphqlBase:
def __init__(self, url, token):
self.url = url
self.token = token
Expand All @@ -24,6 +58,35 @@ def query(self, query):

return r.json()

def query_rest(self, path, queries):
q = urlencode(queries, doseq=True)
url = f"{self.url}/{path}?{q}"

return_array = list()

while url is not None:
r = requests.get(
url,
headers={
"Authorization": f"Token {self.token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)

if r.status_code != 200:
raise Exception("Error querying api: " + r.text)

data = r.json()

url = data['next']
return_array.extend(data['results'])

return return_array


class GraphqlClientV3(GraphqlBase):

def get_data(self, device_config):
query_template = Template(
"""
Expand Down Expand Up @@ -173,3 +236,149 @@ def get_data(self, device_config):
r = self.query(query)

return r['data']


class GraphqlClientV4(GraphqlBase):

def get_data(self, device_config):
query_template = Template(
"""
query {
device_list(filters: {
name: { in_list: $device_array },
}) {
id
name
serial
device_type {
slug
}
platform {
manufacturer {
slug
}
slug
}
primary_ip4 {
address
}
interfaces {
id
name
enabled
type
mode
mtu
mac_address
description
vrf {
id
}
lag {
id
}
ip_addresses {
address
}
untagged_vlan {
id
name
vid
}
tagged_vlans {
id
name
vid
}
tags {
name
slug
}
parent {
id
}
connected_endpoints {
... on InterfaceType {
name
device {
primary_ip4 {
address
}
interfaces {
ip_addresses {
address
}
}
}
}
}
custom_fields
}
}
vrf_list {
id
name
description
rd
export_targets {
name
}
import_targets {
name
}
}
l2vpn_list (filters: {name: {starts_with: "WAN: "}}) {
id
name
type
identifier
terminations {
id
assigned_object {
__typename
... on VLANType {
id
}
... on InterfaceType {
id
device {
name
interfaces (filters:{type: {exact: "virtual"}}) {
ip_addresses {
address
}
parent {
name
type
}
vrf {
id
}
}
}
}
}
}
}
}"""
)

device_list = device_config['router'] + device_config['switch']
query = query_template.substitute(
device_array=json.dumps(device_list)
)

r = self.query(query)
graphql_data = r['data']

static_routes = self.query_rest("/api/plugins/routing/staticroutes/", {"device": device_list})

for d in r['data']['device_list']:

device_static_routes = list(filter(lambda sr: str(sr['device']['id']) == d['id'], static_routes))
d['staticroute_set'] = device_static_routes

return graphql_data
43 changes: 23 additions & 20 deletions cosmo/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def process_l2vpn_terminations(l2vpn_list):
for l2vpn in l2vpn_list:
if not l2vpn["name"].startswith("WAN: "):
continue
if l2vpn['type'] == "VPWS" and len(l2vpn['terminations']) != 2:
if l2vpn['type'].lower() == "vpws" and len(l2vpn['terminations']) != 2:
warnings.warn(
f"VPWS circuits are only allowed to have two terminations. {l2vpn['name']} has {len(l2vpn['terminations'])} terminations, ignoring...")
continue
Expand All @@ -111,7 +111,7 @@ def process_l2vpn_terminations(l2vpn_list):
"VLANType", "InterfaceType"]:
warnings.warn(f"Found unsupported L2VPN termination in {l2vpn['name']}, ignoring...")
continue
if l2vpn['type'] == "VPWS" and termination['assigned_object']['__typename'] != "InterfaceType":
if l2vpn['type'].lower() == "vpws" and termination['assigned_object']['__typename'] != "InterfaceType":
warnings.warn(
f"Found non-interface termination in L2VPN {l2vpn['name']}, ignoring... VPWS only supports interace terminations.")
continue
Expand Down Expand Up @@ -172,7 +172,8 @@ def _get_unit(self, iface):

for ip in iface["ip_addresses"]:
ipa = ipaddress.ip_interface(ip["address"])
is_secondary = ip.get("role", None) == "SECONDARY"
role = ip.get("role", None)
is_secondary = role and role.lower() == "secondary"

# abort if a private IP is used on a unit without a VRF
# we use !is_global instead of is_private since the latter ignores 100.64/10
Expand Down Expand Up @@ -250,7 +251,7 @@ def _get_unit(self, iface):
unit_stub["mtu"] = iface["mtu"]

interface_vlan_id = None
if iface["mode"] == "ACCESS":
if iface["mode"] and iface["mode"].lower() == "access":
if not iface.get("untagged_vlan"):
warnings.warn(
f"Interface {iface['name']} on device {self.device['name']} is mode ACCESS but has no untagged vlan, skipping"
Expand All @@ -268,9 +269,9 @@ def _get_unit(self, iface):

l2vpn = l2vpn_vlan_attached or l2vpn_interface_attached
if l2vpn:
if l2vpn['type'] in ["VPWS", "EVPL"] and unit_stub.get('vlan'):
if l2vpn['type'].lower() in ["vpws", "evpl"] and unit_stub.get('vlan'):
unit_stub["encapsulation"] = "vlan-ccc"
elif l2vpn['type'] in ["MPLS_EVPN", "VXLAN_EVPN"]:
elif l2vpn['type'].lower() in ["mpls_evpn", "vxlan_evpn"]:
unit_stub["encapsulation"] = "vlan-bridge"

# We need to collect the used L2VPNs for rendering those afterwards in other places within the configuration.
Expand Down Expand Up @@ -423,15 +424,15 @@ def serialize(self):
case _:
warnings.warn(f"FEC mode {fec} on interface {interface['name']} is not known, ignoring")

if interface.get("type") == "LAG":
if interface.get("type", '').lower() == "lag":
interface_stub["type"] = "lag"
elif interface.get("type") == "LOOPBACK":
elif interface.get("type", '').lower() == "loopback":
interface_stub["type"] = "loopback"
elif interface.get("type") == "VIRTUAL":
elif interface.get("type", '').lower() == "virtual":
interface_stub["type"] = "virtual"
elif tags.has_key("access"):
interface_stub["type"] = "access"
elif "BASE" in interface.get("type", ""):
elif "base" in interface.get("type", "").lower():
interface_stub["type"] = "physical"

if tags.has_key("access"):
Expand Down Expand Up @@ -467,7 +468,7 @@ def serialize(self):
continue

l2vpn = self.l2vpn_interface_terminations.get(si["id"])
if len(sub_interfaces) == 1 and l2vpn and l2vpn['type'] in ["VPWS", "EPL", "EVPL"] \
if len(sub_interfaces) == 1 and l2vpn and l2vpn['type'].lower() in ["vpws", "epl", "evpl"] \
and not si.get('vlan') and not si.get('custom_fields', {}).get("outer_tag", None):
interface_stub["encapsulation"] = "ethernet-ccc"

Expand Down Expand Up @@ -520,7 +521,7 @@ def serialize(self):
router_id = next(iter(interfaces[self.lo_interface]["units"][0]["families"]["inet"]["address"].keys())).split("/")[0]

for _, l2vpn in self.l2vpns.items():
if l2vpn['type'] == "VXLAN_EVPN":
if l2vpn['type'].lower() == "vxlan_evpn":
self.routing_instances[l2vpn["name"].replace("WAN: ", "")] = {
"bridge_domains": [
{
Expand All @@ -545,7 +546,7 @@ def serialize(self):
"route_distinguisher": "9136:" + str(l2vpn["identifier"]),
"vrf_target": "target:1:" + str(l2vpn["identifier"]),
}
elif l2vpn['type'] == "MPLS_EVPN":
elif l2vpn['type'].lower() == "mpls_evpn":
self.routing_instances[l2vpn["name"].replace("WAN: ", "")] = {
"interfaces": [
i["name"] for i in l2vpn["interfaces"]
Expand All @@ -558,8 +559,8 @@ def serialize(self):
"route_distinguisher": "9136:" + str(l2vpn["identifier"]),
"vrf_target": "target:1:" + str(l2vpn["identifier"]),
}
elif l2vpn['type'] == "VPWS":
l2vpn_interfaces = {};
elif l2vpn['type'].lower() == "vpws":
l2vpn_interfaces = {}
for i in l2vpn["interfaces"]:
id_local = int(i['id'])

Expand Down Expand Up @@ -589,8 +590,8 @@ def serialize(self):
"route_distinguisher": "9136:" + str(l2vpn["identifier"]),
"vrf_target": "target:1:" + str(l2vpn["identifier"]),
}
elif l2vpn['type'] == "EPL" or l2vpn['type'] == "EVPL":
l2vpn_interfaces = {};
elif l2vpn['type'].lower() == "epl" or l2vpn['type'].lower() == "evpl":
l2vpn_interfaces = {}
for i in l2vpn["interfaces"]:
for termination in l2vpn["terminations"]:
if termination["assigned_object"]["id"] == i["id"]:
Expand All @@ -613,7 +614,9 @@ def serialize(self):
# We pick a `virtual` interface which is not in a VRF and which parent is a `loopback`
# Then we pick the first IPv4 address.
for a in remote_interfaces:
if a["vrf"] == None and a["parent"] and a["parent"]["type"] == "LOOPBACK" and a["parent"]["name"].startswith("lo"):
if a["vrf"] is None and a["parent"] and \
a["parent"]["type"] and a["parent"]["type"].lower() == "loopback" and \
a["parent"]["name"].startswith("lo"):
for ip in a['ip_addresses']:
ipa = ipaddress.ip_interface(ip["address"])
if ipa.version == 4:
Expand All @@ -628,7 +631,7 @@ def serialize(self):

l2circuits[l2vpn["name"].replace("WAN: ", "")] = {
"interfaces": l2vpn_interfaces,
"description": f"{l2vpn['type']}: " + l2vpn["name"].replace("WAN: ", "") + " via " + remote_device,
"description": f"{l2vpn['type'].upper()}: " + l2vpn["name"].replace("WAN: ", "") + " via " + remote_device,
}

for _, l3vpn in self.l3vpns.items():
Expand Down Expand Up @@ -689,7 +692,7 @@ def serialize(self):

interface_stub["mtu"] = interface["mtu"] if interface["mtu"] else 10000

if interface["type"] == "LAG":
if interface["type"] and interface['type'].lower() == "lag":
interface_stub["bond_mode"] = "802.3ad"
interface_stub["bond_slaves"] = sorted([i["name"] for i in self.device["interfaces"] if i["lag"] and i["lag"]["id"] == interface["id"]])

Expand Down

0 comments on commit a160b4f

Please sign in to comment.