Skip to content

Commit 53d6095

Browse files
authored
Merge pull request #81 from chadell/example-peeringdb
Example 5: PeeringDB to Nautobot example
2 parents 32bad81 + afbf048 commit 53d6095

File tree

6 files changed

+425
-0
lines changed

6 files changed

+425
-0
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Example 5 - PeeringDB to Nautobot synchronisation
2+
3+
## Context
4+
5+
The goal of this example is to synchronize some data from [PeeringDB](https://www.peeringdb.com/), that as the name suggests is a DB where peering entities define their facilities and presence to facilitate peering, towards [Nautobot Demo](https://demo.nautobot.com/) that is a always on demo service for [Nautobot](https://nautobot.readthedocs.io/), an open source Source of Truth.
6+
7+
In Peering DB there is a model that defines a `Facility` and you can get information about the actual data center and the city where it is placed. In Nautobot, this information could be mapped to the `Region` and `Site` models, where `Region` can define hierarchy. For instance, Barcelona is in Spain and Spain is in Europe, and all of them are `Regions`. And, finally, the actual datacenter will refer to the `Region` where it is placed.
8+
9+
Because of the nature of the demo, we will focus on syncing from PeeringDB to Nautobot (we can assume that PeeringDB is the authoritative System of Record) and we will skip the `delete` part of the `diffsync` library.
10+
11+
We have 3 files:
12+
13+
- `models.py`: defines the reference models that we will use: `RegionMode` and `SiteModel`
14+
- `adapter_peeringdb.py`: defines the PeeringDB adapter to translate via `load()` the data from PeeringDB into the reference models commented above. Notice that we don't define CRUD methods because we will sync from it (no to it)
15+
- `adapter_nautobot.py`: deifnes the Nautobot adapter with the `load()` and the CRUD methods
16+
17+
> The source code for this example is in Github in the [examples/05-nautobot-peeringdb/](https://github.com/networktocode/diffsync/tree/main/examples/05-nautobot-peeringdb) directory.
18+
19+
## Install dependencies
20+
21+
```bash
22+
python3 -m venv .venv
23+
source .venv/bin/activate
24+
pip3 install -r requirements.txt
25+
```
26+
27+
## Run it interactively
28+
29+
```python
30+
from IPython import embed
31+
embed(colors="neutral")
32+
33+
# Import Adapters
34+
from diffsync.enum import DiffSyncFlags
35+
36+
from adapter_nautobot import NautobotRemote
37+
from adapter_peeringdb import PeeringDB
38+
39+
# Initialize PeeringDB adapter, using CATNIX id for demonstration
40+
peeringdb = PeeringDB(ix_id=62)
41+
42+
# Initialize Nautobot adapter, pointing to the demo instance (it's also the default settings)
43+
nautobot = NautobotRemote(
44+
url="https://demo.nautobot.com",
45+
token="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
46+
)
47+
48+
# Load PeeringDB info into the adapter
49+
peeringdb.load()
50+
51+
# We can check the data that has been imported, some as `site` and some as `region` (with the parent relationships)
52+
peeringdb.dict()
53+
54+
# Load Nautobot info into the adapter
55+
nautobot.load()
56+
57+
# Let's diffsync do it's magic
58+
diff = nautobot.diff_from(peeringdb)
59+
60+
# Quick summary of the expected changes (remember that delete ones are dry-run)
61+
diff.summary()
62+
63+
# Execute the synchronization
64+
nautobot.sync_from(peeringdb, flags=DiffSyncFlags.SKIP_UNMATCHED_DST)
65+
66+
```
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Diffsync adapter class for Nautobot."""
2+
# pylint: disable=import-error,no-name-in-module
3+
import os
4+
import requests
5+
from models import RegionModel, SiteModel
6+
from diffsync import DiffSync
7+
8+
9+
NAUTOBOT_URL = os.getenv("NAUTOBOT_URL", "https://demo.nautobot.com")
10+
NAUTOBOT_TOKEN = os.getenv("NAUTOBOT_TOKEN", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
11+
12+
13+
class RegionNautobotModel(RegionModel):
14+
"""Implementation of Region create/update/delete methods for updating remote Nautobot data."""
15+
16+
@classmethod
17+
def create(cls, diffsync, ids, attrs):
18+
"""Create a new Region record in remote Nautobot.
19+
20+
Args:
21+
diffsync (NautobotRemote): DiffSync adapter owning this Region
22+
ids (dict): Initial values for this model's _identifiers
23+
attrs (dict): Initial values for this model's _attributes
24+
"""
25+
data = {
26+
"name": ids["name"],
27+
"slug": attrs["slug"],
28+
}
29+
if attrs["description"]:
30+
data["description"] = attrs["description"]
31+
if attrs["parent_name"]:
32+
data["parent"] = str(diffsync.get(diffsync.region, attrs["parent_name"]).pk)
33+
diffsync.post("/api/dcim/regions/", data)
34+
return super().create(diffsync, ids=ids, attrs=attrs)
35+
36+
def update(self, attrs):
37+
"""Update an existing Region record in remote Nautobot.
38+
39+
Args:
40+
attrs (dict): Updated values for this record's _attributes
41+
"""
42+
data = {}
43+
if "slug" in attrs:
44+
data["slug"] = attrs["slug"]
45+
if "description" in attrs:
46+
data["description"] = attrs["description"]
47+
if "parent_name" in attrs:
48+
if attrs["parent_name"]:
49+
data["parent"] = str(self.get(self.region, attrs["parent_name"]).pk)
50+
else:
51+
data["parent"] = None
52+
self.diffsync.patch(f"/api/dcim/regions/{self.pk}/", data)
53+
return super().update(attrs)
54+
55+
def delete(self): # pylint: disable= useless-super-delegation
56+
"""Delete an existing Region record from remote Nautobot."""
57+
# self.diffsync.delete(f"/api/dcim/regions/{self.pk}/")
58+
return super().delete()
59+
60+
61+
class SiteNautobotModel(SiteModel):
62+
"""Implementation of Site create/update/delete methods for updating remote Nautobot data."""
63+
64+
@classmethod
65+
def create(cls, diffsync, ids, attrs):
66+
"""Create a new Site in remote Nautobot.
67+
68+
Args:
69+
diffsync (NautobotRemote): DiffSync adapter owning this Site
70+
ids (dict): Initial values for this model's _identifiers
71+
attrs (dict): Initial values for this model's _attributes
72+
"""
73+
diffsync.post(
74+
"/api/dcim/sites/",
75+
{
76+
"name": ids["name"],
77+
"slug": attrs["slug"],
78+
"description": attrs["description"],
79+
"status": attrs["status_slug"],
80+
"region": {"name": attrs["region_name"]} if attrs["region_name"] else None,
81+
"latitude": attrs["latitude"],
82+
"longitude": attrs["longitude"],
83+
},
84+
)
85+
return super().create(diffsync, ids=ids, attrs=attrs)
86+
87+
def update(self, attrs):
88+
"""Update an existing Site record in remote Nautobot.
89+
90+
Args:
91+
attrs (dict): Updated values for this record's _attributes
92+
"""
93+
data = {}
94+
if "slug" in attrs:
95+
data["slug"] = attrs["slug"]
96+
if "description" in attrs:
97+
data["description"] = attrs["description"]
98+
if "status_slug" in attrs:
99+
data["status"] = attrs["status_slug"]
100+
if "region_name" in attrs:
101+
if attrs["region_name"]:
102+
data["region"] = {"name": attrs["region_name"]}
103+
else:
104+
data["region"] = None
105+
if "latitude" in attrs:
106+
data["latitude"] = attrs["latitude"]
107+
if "longitude" in attrs:
108+
data["longitude"] = attrs["longitude"]
109+
self.diffsync.patch(f"/api/dcim/sites/{self.pk}/", data)
110+
return super().update(attrs)
111+
112+
def delete(self): # pylint: disable= useless-super-delegation
113+
"""Delete an existing Site record from remote Nautobot."""
114+
# self.diffsync.delete(f"/api/dcim/sites/{self.pk}/")
115+
return super().delete()
116+
117+
118+
class NautobotRemote(DiffSync):
119+
"""DiffSync adapter class for loading data from a remote Nautobot instance using Python requests."""
120+
121+
# Model classes used by this adapter class
122+
region = RegionNautobotModel
123+
site = SiteNautobotModel
124+
125+
# Top-level class labels, i.e. those classes that are handled directly rather than as children of other models
126+
top_level = ("region", "site")
127+
128+
def __init__(self, *args, url=NAUTOBOT_URL, token=NAUTOBOT_TOKEN, **kwargs):
129+
"""Instantiate this class, but do not load data immediately from the remote system.
130+
131+
Args:
132+
url (str): URL of the remote Nautobot system
133+
token (str): REST API authentication token
134+
job (Job): The running Job instance that owns this DiffSync adapter instance
135+
"""
136+
super().__init__(*args, **kwargs)
137+
if not url or not token:
138+
raise ValueError("Both url and token must be specified!")
139+
self.url = url
140+
self.token = token
141+
self.headers = {
142+
"Accept": "application/json",
143+
"Authorization": f"Token {self.token}",
144+
}
145+
146+
def load(self):
147+
"""Load Region and Site data from the remote Nautobot instance."""
148+
region_data = requests.get(f"{self.url}/api/dcim/regions/", headers=self.headers, params={"limit": 0}).json()
149+
regions = region_data["results"]
150+
while region_data["next"]:
151+
region_data = requests.get(region_data["next"], headers=self.headers, params={"limit": 0}).json()
152+
regions.extend(region_data["results"])
153+
154+
for region_entry in regions:
155+
region = self.region(
156+
name=region_entry["name"],
157+
slug=region_entry["slug"],
158+
description=region_entry["description"] or None,
159+
parent_name=region_entry["parent"]["name"] if region_entry["parent"] else None,
160+
pk=region_entry["id"],
161+
)
162+
self.add(region)
163+
164+
site_data = requests.get(f"{self.url}/api/dcim/sites/", headers=self.headers, params={"limit": 0}).json()
165+
sites = site_data["results"]
166+
while site_data["next"]:
167+
site_data = requests.get(site_data["next"], headers=self.headers, params={"limit": 0}).json()
168+
sites.extend(site_data["results"])
169+
170+
for site_entry in sites:
171+
site = self.site(
172+
name=site_entry["name"],
173+
slug=site_entry["slug"],
174+
status_slug=site_entry["status"]["value"] if site_entry["status"] else "active",
175+
region_name=site_entry["region"]["name"] if site_entry["region"] else None,
176+
description=site_entry["description"],
177+
longitude=site_entry["longitude"],
178+
latitude=site_entry["latitude"],
179+
pk=site_entry["id"],
180+
)
181+
self.add(site)
182+
183+
def post(self, path, data):
184+
"""Send an appropriately constructed HTTP POST request."""
185+
response = requests.post(f"{self.url}{path}", headers=self.headers, json=data)
186+
response.raise_for_status()
187+
return response
188+
189+
def patch(self, path, data):
190+
"""Send an appropriately constructed HTTP PATCH request."""
191+
response = requests.patch(f"{self.url}{path}", headers=self.headers, json=data)
192+
response.raise_for_status()
193+
return response
194+
195+
def delete(self, path):
196+
"""Send an appropriately constructed HTTP DELETE request."""
197+
response = requests.delete(f"{self.url}{path}", headers=self.headers)
198+
response.raise_for_status()
199+
return response
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Diffsync adapter class for PeeringDB."""
2+
# pylint: disable=import-error,no-name-in-module
3+
import requests
4+
from slugify import slugify
5+
import pycountry
6+
from models import RegionModel, SiteModel
7+
from diffsync import DiffSync
8+
from diffsync.exceptions import ObjectNotFound
9+
10+
11+
PEERINGDB_URL = "https://peeringdb.com/"
12+
13+
14+
class PeeringDB(DiffSync):
15+
"""DiffSync adapter using requests to communicate with PeeringDB."""
16+
17+
# Model classes used by this adapter class
18+
region = RegionModel
19+
site = SiteModel
20+
21+
# Top-level class labels, i.e. those classes that are handled directly rather than as children of other models
22+
top_level = ("region", "site")
23+
24+
def __init__(self, *args, ix_id, **kwargs):
25+
"""Initialize the PeeringDB adapter."""
26+
super().__init__(*args, **kwargs)
27+
self.ix_id = ix_id
28+
29+
def load(self):
30+
"""Load data via from PeeringDB."""
31+
ix_data = requests.get(f"{PEERINGDB_URL}/api/ix/{self.ix_id}").json()
32+
33+
for fac in ix_data["data"][0]["fac_set"]:
34+
# PeeringDB has no Region entity, so we must avoid duplicates
35+
try:
36+
self.get(self.region, fac["city"])
37+
except ObjectNotFound:
38+
# Use pycountry to translate the country code (like "DE") to a country name (like "Germany")
39+
parent_name = pycountry.countries.get(alpha_2=fac["country"]).name
40+
# Add the country as a parent region if not already added
41+
try:
42+
self.get(self.region, parent_name)
43+
except ObjectNotFound:
44+
parent_region = self.region(
45+
name=parent_name,
46+
slug=slugify(parent_name),
47+
)
48+
self.add(parent_region)
49+
50+
region = self.region(
51+
name=fac["city"],
52+
slug=slugify(fac["city"]),
53+
parent_name=parent_name,
54+
)
55+
self.add(region)
56+
57+
site = self.site(
58+
name=fac["name"],
59+
slug=slugify(fac["name"]),
60+
status_slug="active",
61+
region_name=fac["city"],
62+
description=fac["notes"],
63+
longitude=fac["longitude"],
64+
latitude=fac["latitude"],
65+
pk=fac["id"],
66+
)
67+
self.add(site)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Main.py."""
2+
3+
# Import Adapters
4+
from adapter_nautobot import NautobotRemote
5+
from adapter_peeringdb import PeeringDB
6+
7+
from diffsync.enum import DiffSyncFlags
8+
9+
10+
# Initialize PeeringDB adapter, using CATNIX id for demonstration
11+
peeringdb = PeeringDB(ix_id=62)
12+
13+
# Initialize Nautobot adapter, pointing to the demo instance (it's also the default settings)
14+
nautobot = NautobotRemote(url="https://demo.nautobot.com", token="a" * 40) # nosec
15+
16+
# Load PeeringDB info into the adapter
17+
peeringdb.load()
18+
19+
# We can check the data that has been imported, some as `site` and some as `region` (with the parent relationships)
20+
peeringdb.dict()
21+
22+
# Load Nautobot info into the adapter
23+
nautobot.load()
24+
25+
# Let's diffsync do it's magic
26+
diff = nautobot.diff_from(peeringdb)
27+
28+
# Quick summary of the expected changes (remember that delete ones are dry-run)
29+
diff.summary()
30+
31+
# Execute the synchronization
32+
nautobot.sync_from(peeringdb, flags=DiffSyncFlags.SKIP_UNMATCHED_DST)

0 commit comments

Comments
 (0)