Skip to content

Commit a5ada05

Browse files
Merge pull request #624 from rackerlabs/puc-740-diffsync
feat: PUC-740: comparing data from Openstack and Nautobot
2 parents 1ebf07d + 5abdb7b commit a5ada05

22 files changed

+1637
-0
lines changed
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Nautobot vs OpenStack Comparison Tool
2+
3+
This tool compares data between Nautobot and OpenStack.
4+
5+
## Currently Supported Comparisons
6+
7+
| OpenStack | Nautobot |
8+
|-----------|-----------------------|
9+
| Project | Tenant |
10+
| Network | UCVNI & Namespace |
11+
12+
13+
---
14+
15+
## Setup Instructions
16+
17+
1. cd python/diff-nautobot-understack
18+
2. `poetry install`
19+
1. poetry will handle the creation of this virtual environment for you. It'll use .venv in the project if you configure it to do so locally on your machine with `poetry config virtualenvs.in-project true`.
20+
2. user can create a shell with poetry shell and then when they exit it will clean up auth variables. Or they can run source .venv/bin/activate or poetry run <commands-below>
21+
22+
3. Export environment variables (or add them to a .env file):
23+
1. export NAUTOBOT_URL=https://nautobot.url.here
24+
2. users should browse to https://nautobot.url.here/user/api-tokens/ and generate an API token
25+
3. export NAUTOBOT_TOKEN= <generated token from above step>
26+
4. export OS_CLOUD=uc-dev-infra
27+
5. export OS_CLIENT_CONFIG_FILE=./my_clouds.yaml (set this if it is in any other location not [defined here](https://opendev.org/openstack/openstacksdk#getting-started))
28+
29+
30+
- Below are some example commands
31+
```
32+
uc-diff --help
33+
uc-diff project undercloud -v
34+
uc-diff network
35+
```

python/diff-nautobot-understack/diff_nautobot_understack/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import os
2+
import sys
3+
4+
import typer
5+
from diffsync.diff import Diff
6+
from rich import print
7+
from rich.console import Console
8+
from rich.table import Table
9+
10+
from diff_nautobot_understack.network.main import (
11+
openstack_network_diff_from_ucvni_network,
12+
)
13+
from diff_nautobot_understack.project.main import (
14+
openstack_project_diff_from_nautobot_tenant,
15+
)
16+
from diff_nautobot_understack.settings import app_settings as settings
17+
18+
required_env_vars = ["NAUTOBOT_TOKEN", "NAUTOBOT_URL", "OS_CLOUD"]
19+
20+
21+
app = typer.Typer(
22+
name="diff",
23+
add_completion=False,
24+
help="compare data between Openstack and Nautobot.",
25+
)
26+
diff_outputs = {
27+
"project": {"title": "Project Diff", "id_column_name": "Project ID"},
28+
"network": {"title": "Network Diff", "id_column_name": "Network ID"},
29+
}
30+
31+
32+
def display_output(
33+
diff_result: Diff, diff_output: str, output_format: str | None = None
34+
):
35+
print(diff_result.summary())
36+
__output_format = (
37+
output_format if output_format is not None else settings.output_format
38+
)
39+
if __output_format == "table":
40+
diff_output_props = diff_outputs.get(diff_output)
41+
tabular_output(
42+
diff_result.dict().get(diff_output, {}),
43+
diff_output_props.get("title"),
44+
diff_output_props.get("id_column_name"),
45+
)
46+
else:
47+
print(diff_result.dict())
48+
49+
50+
def tabular_output(diffs, title, id_column_name):
51+
table = Table(title=title, show_lines=True)
52+
53+
table.add_column(id_column_name, style="cyan", no_wrap=True)
54+
table.add_column("Change Type", style="magenta")
55+
table.add_column("Details", style="yellow")
56+
57+
for diff_id, changes in diffs.items():
58+
for change_type, details in changes.items():
59+
table.add_row(diff_id, change_type, str(details))
60+
61+
console = Console()
62+
console.print(table)
63+
64+
65+
@app.command()
66+
def project(
67+
name: str,
68+
debug: bool = typer.Option(False, "--debug", "-v", help="Enable debug mode"),
69+
output_format: str = typer.Option(
70+
"json", "--format", help="Available formats json, table"
71+
),
72+
):
73+
"""Nautobot tenants ⟹ Openstack projects"""
74+
settings.debug = debug
75+
diff_result = openstack_project_diff_from_nautobot_tenant(os_project=name)
76+
display_output(diff_result, "project", output_format)
77+
78+
79+
@app.command()
80+
def network(
81+
debug: bool = typer.Option(False, "--debug", "-v", help="Enable debug mode"),
82+
output_format: str = typer.Option(
83+
"json", "--format", help="Available formats json, table"
84+
),
85+
):
86+
"""Nautobot ucvnis ⟹ Openstack networks"""
87+
settings.debug = debug
88+
diff_result = openstack_network_diff_from_ucvni_network()
89+
display_output(diff_result, "network", output_format)
90+
91+
92+
def check_env_vars(required_vars):
93+
missing_vars = [var for var in required_vars if var not in os.environ]
94+
95+
if missing_vars:
96+
print(f"Error: Missing environment variables: {', '.join(missing_vars)}")
97+
sys.exit(1)
98+
else:
99+
print("All required environment variables are set.")
100+
101+
102+
check_env_vars(required_env_vars)

python/diff-nautobot-understack/diff_nautobot_understack/clients/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import inspect
2+
import logging
3+
from urllib.parse import urljoin
4+
5+
import requests
6+
7+
from diff_nautobot_understack.settings import app_settings as settings
8+
9+
10+
class API:
11+
CALLER_FRAME = 1
12+
13+
def __init__(self):
14+
self.base_url = settings.nautobot_url
15+
self.s = requests.Session()
16+
self.token = settings.nautobot_token
17+
self.s.headers.update({"Authorization": f"Token {self.token}"})
18+
19+
def make_api_request(
20+
self, url: str, payload: dict | None = None, paginated: bool = False
21+
) -> dict | list:
22+
endpoint_url = urljoin(self.base_url, url)
23+
caller_function = inspect.stack()[self.CALLER_FRAME].function
24+
25+
logging.debug(
26+
"%(caller_function)s payload: %(payload)s",
27+
{"payload": payload, "caller_function": caller_function},
28+
)
29+
30+
if paginated:
31+
return self._fetch_paginated_data(endpoint_url, payload, caller_function)
32+
else:
33+
resp = self.s.get(endpoint_url, timeout=10, json=payload)
34+
return self._process_response(resp, caller_function)
35+
36+
def _fetch_paginated_data(
37+
self, endpoint_url: str, payload: dict | None, caller_function: str
38+
) -> list:
39+
response_items = []
40+
url = endpoint_url
41+
42+
while url is not None:
43+
resp = self.s.get(url, timeout=10, json=payload)
44+
resp_data = self._process_response(resp, caller_function)
45+
46+
response_items.extend(resp_data.get("results", []))
47+
url = resp_data.get("next")
48+
49+
return response_items
50+
51+
def _process_response(self, resp, caller_function: str) -> dict:
52+
if resp.content:
53+
resp_data = resp.json()
54+
else:
55+
resp_data = {"status_code": resp.status_code}
56+
57+
logging.debug(
58+
"%(caller_function)s resp: %(resp)s",
59+
{"resp": resp_data, "caller_function": caller_function},
60+
)
61+
62+
self._log_and_raise_for_status(resp)
63+
return resp_data
64+
65+
def _log_and_raise_for_status(self, resp):
66+
try:
67+
resp.raise_for_status()
68+
except Exception as e:
69+
logging.error(f"HTTP error occurred: {e}")
70+
raise
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import openstack
2+
3+
from diff_nautobot_understack.settings import app_settings as settings
4+
5+
6+
class API:
7+
def __init__(self):
8+
cloud_name = settings.os_cloud
9+
debug = settings.debug
10+
11+
openstack.enable_logging(debug=debug)
12+
self.cloud_connection = openstack.connect(cloud=cloud_name)

python/diff-nautobot-understack/diff_nautobot_understack/network/__init__.py

Whitespace-only changes.

python/diff-nautobot-understack/diff_nautobot_understack/network/adapters/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from diffsync import Adapter
2+
3+
from diff_nautobot_understack.clients.openstack import API
4+
from diff_nautobot_understack.network import models
5+
6+
7+
class Network(Adapter):
8+
network = models.NetworkModel
9+
10+
top_level = ["network"]
11+
type = "OpenstackNetwork"
12+
13+
def __init__(self, **kwargs):
14+
super().__init__(**kwargs)
15+
openstack_api = API()
16+
self.cloud = openstack_api.cloud_connection
17+
18+
def load(self):
19+
for network in self.cloud.network.networks():
20+
self.add(
21+
self.network(
22+
id=network.id,
23+
name=network.name,
24+
status=network.status.lower(),
25+
provider_physical_network=network.provider_physical_network,
26+
vni_id=network.provider_segmentation_id,
27+
)
28+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from diffsync import Adapter
2+
from pydantic import BaseModel
3+
4+
from diff_nautobot_understack.clients.nautobot import API
5+
from diff_nautobot_understack.network import models
6+
7+
8+
class UcvniDetails(BaseModel):
9+
id: str
10+
name: str
11+
status: str
12+
ucvni_id: int
13+
ucvni_group: str
14+
vlan_group: str
15+
vlan_id: int
16+
17+
18+
class NautobotError(Exception):
19+
message = "Nautobot error"
20+
21+
22+
class Network(Adapter):
23+
CALLER_FRAME = 1
24+
network = models.NetworkModel
25+
26+
top_level = ["network"]
27+
type = "UCVNI"
28+
29+
def __init__(self, **kwargs):
30+
super().__init__(**kwargs)
31+
self.api_client = API()
32+
33+
def load(self):
34+
ucvni_data: list[UcvniDetails] = self.ucvni_get()
35+
for ucvni_item in ucvni_data:
36+
network = self.network(
37+
id=ucvni_item.id,
38+
name=ucvni_item.name,
39+
vni_id=ucvni_item.vlan_id,
40+
provider_physical_network=ucvni_item.vlan_group,
41+
status=ucvni_item.status,
42+
)
43+
self.add(network)
44+
45+
def ucvni_get(
46+
self,
47+
) -> list[UcvniDetails]:
48+
ucvni_detail_list: list[UcvniDetails] = []
49+
50+
url = "/api/plugins/undercloud-vni/ucvnis/?include=relationship"
51+
52+
ucvnis_response = self.api_client.make_api_request(
53+
f"{url}/?include=relationships", paginated=True
54+
)
55+
56+
for ucvni_item in ucvnis_response:
57+
ucvni_group = self.api_client.make_api_request(
58+
url=ucvni_item.get("ucvni_group", {}).get("url")
59+
)
60+
status = self.api_client.make_api_request(
61+
url=ucvni_item.get("status", {}).get("url")
62+
)
63+
vlan_uuid_objects = (
64+
ucvni_item.get("relationships", {})
65+
.get("ucvni_vlans", {})
66+
.get("destination")
67+
.get("objects")
68+
)
69+
vlan_details = self.get_vlan_details(vlan_uuid_objects)
70+
vlan_group, vlan_ids = next(iter(vlan_details.items()))
71+
ucvni_details = UcvniDetails(
72+
id=ucvni_item.get("id"),
73+
name=ucvni_item.get("name"),
74+
ucvni_id=ucvni_item.get("ucvni_id"),
75+
ucvni_group=ucvni_group.get("name"),
76+
status=status.get("name").lower(),
77+
vlan_group=vlan_group,
78+
vlan_id=int(vlan_ids[0]),
79+
)
80+
ucvni_detail_list.append(ucvni_details)
81+
return ucvni_detail_list
82+
83+
def get_vlan_details(self, vlan_uuid_objects):
84+
vlan_uuids = [vlan_uuid_object["id"] for vlan_uuid_object in vlan_uuid_objects]
85+
vlan_details = {}
86+
87+
vlan_uuids_query_params = "&".join(f"id={value}" for value in vlan_uuids)
88+
vlan_url = f"/api/ipam/vlans/?{vlan_uuids_query_params}"
89+
90+
vlans_response = self.api_client.make_api_request(url=vlan_url, paginated=True)
91+
92+
for vlan_response in vlans_response:
93+
vlan_group_url = vlan_response.get("vlan_group", {}).get("url")
94+
95+
if vlan_group_url:
96+
vlan_group_response = self.api_client.make_api_request(
97+
url=vlan_group_url
98+
)
99+
vlan_group_name = vlan_group_response.get("name")
100+
vlan_id = vlan_response.get("vid")
101+
102+
if vlan_group_name:
103+
vlan_details.setdefault(vlan_group_name, []).append(vlan_id)
104+
105+
return vlan_details
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from diffsync.diff import Diff
2+
from diffsync.enum import DiffSyncFlags
3+
from rich import print
4+
5+
from diff_nautobot_understack.network.adapters.openstack_network import (
6+
Network as OpenstackNetwork,
7+
)
8+
from diff_nautobot_understack.network.adapters.ucvni import Network as UcvniNetwork
9+
10+
11+
def openstack_network_diff_from_ucvni_network() -> Diff:
12+
openstack_network = OpenstackNetwork()
13+
try:
14+
openstack_network.load()
15+
except Exception:
16+
print("Error retrieving networks from Openstack")
17+
ucvni_network = UcvniNetwork()
18+
try:
19+
ucvni_network.load()
20+
except Exception:
21+
print("Error retrieving ucvnis from Nautobot")
22+
ucvni_network_destination_openstack_source = ucvni_network.diff_from(
23+
openstack_network, flags=DiffSyncFlags.CONTINUE_ON_FAILURE
24+
)
25+
return ucvni_network_destination_openstack_source

0 commit comments

Comments
 (0)