Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/containers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
paths: &triggerpaths
- "ansible/**"
- "containers/ansible/**"
- "containers/diff-nautobot-understack/**"
- "containers/dnsmasq/**"
- "containers/ironic-nautobot-client/**"
- "containers/ironic-vnc-client/**"
Expand All @@ -30,6 +31,8 @@ jobs:
container:
- name: ansible
target: prod
- name: diff-nautobot-understack
target: prod
- name: dnsmasq
target: prod
- name: ironic-nautobot-client
Expand Down
32 changes: 32 additions & 0 deletions containers/diff-nautobot-understack/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
FROM python:3.12-slim AS builder

RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/root/.cache/pip pip install 'wheel==0.43.0'
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# copy in the code
COPY python/diff-nautobot-understack /tmp/understack/python/diff-nautobot-understack

# Set version for setuptools-scm since .git is not available in build context
ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0

# install our requirements and our packages
RUN --mount=type=cache,target=/root/.cache/uv \
uv venv /opt/venv --clear && \
uv pip install \
--python /opt/venv/bin/python \
/tmp/understack/python/diff-nautobot-understack

FROM python:3.12-slim AS prod
LABEL org.opencontainers.image.description="Diff Nautobot Understack - Compare OpenStack and Nautobot data"
ENV PATH="/opt/venv/bin:${PATH}"

RUN groupadd --system --gid 10001 appgroup && \
useradd --system --create-home --uid 10001 --gid appgroup appuser

COPY --from=builder --link /opt/venv /opt/venv

USER appuser
32 changes: 32 additions & 0 deletions docs/operator-guide/openstack-nautobot-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,38 @@ The following events trigger Nautobot updates:
When Nautobot gets out of sync with OpenStack (e.g., after database restore,
missed events, or manual changes), you can perform a bulk resync.

### Dry-Run (Diff Preview)

Before running a resync, you can preview what changes would be made using the
diff workflow. This compares OpenStack and Nautobot data without making changes:

```bash
argo -n argo-events submit --from workflowtemplate/diff-nautobot
```

The diff workflow compares:

- Keystone projects ↔ Nautobot tenants
- Neutron networks ↔ Nautobot UCVNIs
- Neutron subnets ↔ Nautobot prefixes
- Ironic nodes ↔ Nautobot devices

You can also run the diff CLI directly:

```bash
# Compare all projects
uc-diff projects

# Compare all networks
uc-diff network

# Compare all subnets
uc-diff subnets

# Compare all devices
uc-diff devices
```

### Resync Order

The resync workflow runs three steps sequentially in dependency order:
Expand Down
32 changes: 32 additions & 0 deletions python/diff-nautobot-understack/diff_nautobot_understack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rich.console import Console
from rich.table import Table

from diff_nautobot_understack.device.main import ironic_nodes_diff_from_nautobot_devices
from diff_nautobot_understack.network.main import (
openstack_network_diff_from_ucvni_network,
)
Expand All @@ -17,6 +18,9 @@
openstack_project_diff_from_nautobot_tenant,
)
from diff_nautobot_understack.settings import app_settings as settings
from diff_nautobot_understack.subnet.main import (
openstack_subnets_diff_from_nautobot_prefixes,
)

required_env_vars = ["NAUTOBOT_TOKEN", "NAUTOBOT_URL", "OS_CLOUD"]

Expand All @@ -29,6 +33,8 @@
diff_outputs = {
"project": {"title": "Project Diff", "id_column_name": "Project ID"},
"network": {"title": "Network Diff", "id_column_name": "Network ID"},
"subnet": {"title": "Subnet Diff", "id_column_name": "Subnet ID"},
"device": {"title": "Device Diff", "id_column_name": "Device ID"},
}


Expand Down Expand Up @@ -108,6 +114,32 @@ def network(
display_output(diff_result, "network", output_format)


@app.command()
def subnets(
debug: bool = typer.Option(False, "--debug", "-v", help="Enable debug mode"),
output_format: str = typer.Option(
"json", "--format", help="Available formats: json, table, human"
),
):
"""OpenStack subnets ⟹ Nautobot prefixes"""
settings.debug = debug
diff_result = openstack_subnets_diff_from_nautobot_prefixes()
display_output(diff_result, "subnet", output_format)


@app.command()
def devices(
debug: bool = typer.Option(False, "--debug", "-v", help="Enable debug mode"),
output_format: str = typer.Option(
"json", "--format", help="Available formats: json, table, human"
),
):
"""Ironic nodes ⟹ Nautobot devices"""
settings.debug = debug
diff_result = ironic_nodes_diff_from_nautobot_devices()
display_output(diff_result, "device", output_format)


def check_env_vars(required_vars):
missing_vars = [var for var in required_vars if var not in os.environ]

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Device diff adapters (Ironic nodes <-> Nautobot devices)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Device adapters
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from diffsync import Adapter

from diff_nautobot_understack.clients.openstack import API
from diff_nautobot_understack.device import models

# Map Ironic provision states to Nautobot statuses
PROVISION_STATE_MAP = {
"active": "Active",
"enroll": "Planned",
"available": "Available",
"deploy failed": "Quarantine",
"error": "Quarantine",
"rescue": "Quarantine",
"rescue failed": "Quarantine",
"unrescueing": "Quarantine",
"manageable": "Staged",
"inspecting": "Provisioning",
"deploying": "Provisioning",
"cleaning": "Quarantine",
"clean failed": "Quarantine",
"deleting": "Decommissioning",
}


class Nodes(Adapter):
"""Adapter for Ironic baremetal nodes."""

device = models.DeviceModel

top_level = ["device"]
type = "IronicNode"

def __init__(self, **kwargs):
super().__init__(**kwargs)
openstack_api = API()
self.cloud = openstack_api.cloud_connection

def load(self):
for node in self.cloud.baremetal.nodes():
# Map provision state to Nautobot status
status = PROVISION_STATE_MAP.get(node.provision_state)

self.add(
self.device(
id=node.id,
name=node.name,
status=status,
tenant_id=node.lessee,
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from diffsync import Adapter

from diff_nautobot_understack.clients.nautobot import API
from diff_nautobot_understack.device import models


class Devices(Adapter):
"""Adapter for Nautobot devices."""

device = models.DeviceModel

top_level = ["device"]
type = "NautobotDevice"

def __init__(self, **kwargs):
super().__init__(**kwargs)
self.api_client = API()

def load(self):
# Filter by role=server to only get baremetal devices
url = "/api/dcim/devices/?role=server"
devices_response = self.api_client.make_api_request(url, paginated=True)

for device in devices_response:
device_id = device.get("id")
if not device_id:
continue

# Get status name
status = device.get("status", {})
status_name = status.get("name") if status else None

# Get tenant ID
tenant = device.get("tenant", {})
tenant_id = tenant.get("id") if tenant else None

self.add(
self.device(
id=device_id,
name=device.get("name"),
status=status_name,
tenant_id=tenant_id,
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from diffsync.diff import Diff
from diffsync.enum import DiffSyncFlags

from diff_nautobot_understack.device.adapters.ironic_node import Nodes
from diff_nautobot_understack.device.adapters.nautobot_device import Devices


def ironic_nodes_diff_from_nautobot_devices() -> Diff:
"""Compare all Ironic nodes with Nautobot devices."""
ironic_nodes = Nodes()
ironic_nodes.load()

nautobot_devices = Devices()
nautobot_devices.load()

return nautobot_devices.diff_from(
ironic_nodes, flags=DiffSyncFlags.CONTINUE_ON_FAILURE
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from diffsync import DiffSyncModel


class DeviceModel(DiffSyncModel):
"""Model for comparing Ironic nodes with Nautobot devices."""

_modelname = "device"
_identifiers = ("id",)
_attributes = (
"name",
"status",
"tenant_id",
)

id: str
name: str | None = None
status: str | None = None
tenant_id: str | None = None
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ def load(self):
self.network(
id=network.id,
name=network.name,
status=network.status.lower(),
provider_physical_network=network.provider_physical_network,
vni_id=network.provider_segmentation_id,
tenant_id=network.project_id,
ucvni_id=network.provider_segmentation_id,
)
)
Loading
Loading