Skip to content

Commit e96a7af

Browse files
committed
Add example 5 with Nautobot to PeeringDB example
1 parent b59a362 commit e96a7af

File tree

4 files changed

+377
-0
lines changed

4 files changed

+377
-0
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# playground-diffsync
2+
3+
## Description
4+
5+
This repository is just a set of examples around [diffsync](https://github.com/networktocode/diffsync) in order to understand how it works and how you could use it.
6+
7+
## Install dependencies
8+
9+
```bash
10+
python3 -m venv .venv
11+
source .venv/bin/activate
12+
pip3 install -r requirements.txt
13+
```
14+
15+
## Context
16+
17+
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.
18+
19+
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.
20+
21+
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.
22+
23+
## Show me the code
24+
25+
We have 3 files:
26+
27+
- `models.py`: defines the reference models that we will use: `RegionMode` and `SiteModel`
28+
- `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)
29+
- `adapter_nautobot.py`: deifnes the Nautobot adapter with the `load()` and the CRUD methods
30+
31+
```python
32+
from IPython import embed
33+
embed(colors="neutral")
34+
35+
# Import Adapaters
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)
65+
```
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""Diffsync adapter class for Nautobot."""
2+
import os
3+
import requests
4+
from diffsync import DiffSync
5+
6+
from .models import RegionModel, SiteModel
7+
8+
NAUTOBOT_URL = os.getenv("NAUTOBOT_URL", "https://demo.nautobot.com")
9+
NAUTOBOT_TOKEN = os.getenv("NAUTOBOT_TOKEN", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
10+
11+
12+
class RegionNautobotModel(RegionModel):
13+
"""Implementation of Region create/update/delete methods for updating remote Nautobot data."""
14+
15+
@classmethod
16+
def create(cls, diffsync, ids, attrs):
17+
"""Create a new Region record in remote Nautobot.
18+
Args:
19+
diffsync (NautobotRemote): DiffSync adapter owning this Region
20+
ids (dict): Initial values for this model's _identifiers
21+
attrs (dict): Initial values for this model's _attributes
22+
"""
23+
data = {
24+
"name": ids["name"],
25+
"slug": attrs["slug"],
26+
}
27+
if attrs["description"]:
28+
data["description"] = attrs["description"]
29+
if attrs["parent_name"]:
30+
data["parent"] = str(diffsync.get(diffsync.region, attrs["parent_name"]).pk)
31+
diffsync.post("/api/dcim/regions/", data)
32+
return super().create(diffsync, ids=ids, attrs=attrs)
33+
34+
def update(self, attrs):
35+
"""Update an existing Region record in remote Nautobot.
36+
Args:
37+
attrs (dict): Updated values for this record's _attributes
38+
"""
39+
data = {}
40+
if "slug" in attrs:
41+
data["slug"] = attrs["slug"]
42+
if "description" in attrs:
43+
data["description"] = attrs["description"]
44+
if "parent_name" in attrs:
45+
if attrs["parent_name"]:
46+
data["parent"] = {"name": attrs["parent_name"]}
47+
else:
48+
data["parent"] = None
49+
self.diffsync.patch(f"/api/dcim/regions/{self.pk}/", data)
50+
return super().update(attrs)
51+
52+
def delete(self):
53+
"""Delete an existing Region record from remote Nautobot."""
54+
# self.diffsync.delete(f"/api/dcim/regions/{self.pk}/")
55+
return super().delete()
56+
57+
58+
class SiteNautobotModel(SiteModel):
59+
"""Implementation of Site create/update/delete methods for updating remote Nautobot data."""
60+
61+
@classmethod
62+
def create(cls, diffsync, ids, attrs):
63+
"""Create a new Site in remote Nautobot.
64+
Args:
65+
diffsync (NautobotRemote): DiffSync adapter owning this Site
66+
ids (dict): Initial values for this model's _identifiers
67+
attrs (dict): Initial values for this model's _attributes
68+
"""
69+
diffsync.post(
70+
"/api/dcim/sites/",
71+
{
72+
"name": ids["name"],
73+
"slug": attrs["slug"],
74+
"description": attrs["description"],
75+
"status": attrs["status_slug"],
76+
"region": {"name": attrs["region_name"]} if attrs["region_name"] else None,
77+
"latitude": attrs["latitude"],
78+
"longitude": attrs["longitude"],
79+
},
80+
)
81+
return super().create(diffsync, ids=ids, attrs=attrs)
82+
83+
def update(self, attrs):
84+
"""Update an existing Site record in remote Nautobot.
85+
Args:
86+
attrs (dict): Updated values for this record's _attributes
87+
"""
88+
data = {}
89+
if "slug" in attrs:
90+
data["slug"] = attrs["slug"]
91+
if "description" in attrs:
92+
data["description"] = attrs["description"]
93+
if "status_slug" in attrs:
94+
data["status"] = attrs["status_slug"]
95+
if "region_name" in attrs:
96+
if attrs["region_name"]:
97+
data["region"] = {"name": attrs["region_name"]}
98+
else:
99+
data["region"] = None
100+
if "latitude" in attrs:
101+
data["latitude"] = attrs["latitude"]
102+
if "longitude" in attrs:
103+
data["longitude"] = attrs["longitude"]
104+
self.diffsync.patch(f"/api/dcim/sites/{self.pk}/", data)
105+
return super().update(attrs)
106+
107+
def delete(self):
108+
"""Delete an existing Site record from remote Nautobot."""
109+
# self.diffsync.delete(f"/api/dcim/sites/{self.pk}/")
110+
return super().delete()
111+
112+
113+
class NautobotRemote(DiffSync):
114+
"""DiffSync adapter class for loading data from a remote Nautobot instance using Python requests.
115+
In a more realistic example, you'd probably use PyNautobot here instead of raw requests,
116+
but we didn't want to add PyNautobot as a dependency of this plugin just to make an example more realistic.
117+
"""
118+
119+
# Model classes used by this adapter class
120+
region = RegionNautobotModel
121+
site = SiteNautobotModel
122+
123+
# Top-level class labels, i.e. those classes that are handled directly rather than as children of other models
124+
top_level = ("region", "site")
125+
126+
def __init__(self, *args, url=NAUTOBOT_URL, token=NAUTOBOT_TOKEN, **kwargs):
127+
"""Instantiate this class, but do not load data immediately from the remote system.
128+
Args:
129+
url (str): URL of the remote Nautobot system
130+
token (str): REST API authentication token
131+
job (Job): The running Job instance that owns this DiffSync adapter instance
132+
"""
133+
super().__init__(*args, **kwargs)
134+
if not url or not token:
135+
raise ValueError("Both url and token must be specified!")
136+
self.url = url
137+
self.token = token
138+
self.headers = {
139+
"Accept": "application/json",
140+
"Authorization": f"Token {self.token}",
141+
}
142+
143+
def load(self):
144+
"""Load Region and Site data from the remote Nautobot instance."""
145+
# To keep the example simple, we disable REST API pagination of results.
146+
# In a real job, especially when expecting a large number of results,
147+
# we'd want to handle pagination, or use something like PyNautobot.
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+
# self.job.log_debug(message=f"Loaded {region} from remote Nautobot instance")
164+
165+
site_data = requests.get(f"{self.url}/api/dcim/sites/", headers=self.headers, params={"limit": 0}).json()
166+
sites = site_data["results"]
167+
while site_data["next"]:
168+
site_data = requests.get(site_data["next"], headers=self.headers, params={"limit": 0}).json()
169+
sites.extend(site_data["results"])
170+
171+
for site_entry in sites:
172+
site = self.site(
173+
name=site_entry["name"],
174+
slug=site_entry["slug"],
175+
status_slug=site_entry["status"]["value"] if site_entry["status"] else "active",
176+
region_name=site_entry["region"]["name"] if site_entry["region"] else None,
177+
description=site_entry["description"],
178+
longitude=site_entry["longitude"],
179+
latitude=site_entry["latitude"],
180+
pk=site_entry["id"],
181+
)
182+
self.add(site)
183+
# self.job.log_debug(message=f"Loaded {site} from remote Nautobot instance")
184+
185+
def post(self, path, data):
186+
"""Send an appropriately constructed HTTP POST request."""
187+
response = requests.post(f"{self.url}{path}", headers=self.headers, json=data)
188+
response.raise_for_status()
189+
return response
190+
191+
def patch(self, path, data):
192+
"""Send an appropriately constructed HTTP PATCH request."""
193+
response = requests.patch(f"{self.url}{path}", headers=self.headers, json=data)
194+
response.raise_for_status()
195+
return response
196+
197+
def delete(self, path):
198+
"""Send an appropriately constructed HTTP DELETE request."""
199+
response = requests.delete(f"{self.url}{path}", headers=self.headers)
200+
response.raise_for_status()
201+
return response
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Diffsync adapter class for PeeringDB."""
2+
import requests
3+
from slugify import slugify
4+
import pycountry
5+
6+
from diffsync import DiffSync
7+
from diffsync.exceptions import ObjectNotFound
8+
9+
from .models import RegionModel, SiteModel
10+
11+
PEERINGDB_URL = "https://peeringdb.com/"
12+
13+
14+
class PeeringDB(DiffSync):
15+
"""DiffSync adapter using pysnow 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+
region = self.region(
39+
name=fac["city"],
40+
slug=slugify(fac["city"]),
41+
parent_name=pycountry.countries.get(alpha_2=fac["country"]).name,
42+
)
43+
self.add(region)
44+
45+
site = self.site(
46+
name=fac["name"],
47+
slug=slugify(fac["name"]),
48+
status_slug="active",
49+
region_name=fac["city"],
50+
description=fac["notes"],
51+
longitude=fac["longitude"],
52+
latitude=fac["latitude"],
53+
pk=fac["id"],
54+
)
55+
self.add(site)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""DiffSyncModel subclasses for Nautobot-PeeringDB data sync."""
2+
from typing import Optional, Union
3+
from uuid import UUID
4+
5+
from diffsync import DiffSyncModel
6+
7+
8+
class RegionModel(DiffSyncModel):
9+
"""Shared data model representing a Region."""
10+
11+
# Metadata about this model
12+
_modelname = "region"
13+
_identifiers = ("name",)
14+
_attributes = (
15+
"slug",
16+
"description",
17+
"parent_name",
18+
)
19+
20+
# Data type declarations for all identifiers and attributes
21+
name: str
22+
slug: str
23+
description: Optional[str]
24+
parent_name: Optional[str] # may be None
25+
26+
# Not in _attributes or _identifiers, hence not included in diff calculations
27+
pk: Optional[UUID]
28+
29+
30+
class SiteModel(DiffSyncModel):
31+
"""Shared data model representing a Site in either of the local or remote Nautobot instances."""
32+
33+
# Metadata about this model
34+
_modelname = "site"
35+
_identifiers = ("name",)
36+
# To keep this example simple, we don't include **all** attributes of a Site here. But you could!
37+
_attributes = (
38+
"slug",
39+
"status_slug",
40+
"region_name",
41+
"description",
42+
"latitude",
43+
"longitude",
44+
)
45+
46+
# Data type declarations for all identifiers and attributes
47+
name: str
48+
slug: str
49+
status_slug: str
50+
region_name: Optional[str] # may be None
51+
description: Optional[str]
52+
latitude: Optional[float]
53+
longitude: Optional[float]
54+
55+
# Not in _attributes or _identifiers, hence not included in diff calculations
56+
pk: Optional[Union[UUID, int]]

0 commit comments

Comments
 (0)