Introduction
Git records every change as an immutable commit, making it an ideal journal of intent for network automation. Each commit (or pull‑request) represents a proposed change to topology, configuration, or IP‑address plan. Git does not store live state; it stores only the desired state that must be reviewed, approved, and applied to the source of truth.
NetBox/Nautobot is a structured operational model of the network. Its data model includes Sites, Racks, Devices, Interfaces, IPAddresses, VLANs, Circuits, etc., with defined fields, relationships, and validation rules. The REST (and GraphQL in Nautobot) API lets external systems read and write this model transactionally, while an internal change‑log records who changed what and when—providing the source of truth for the live network state.
Designing the Integration Architecture
Sync Semantics
| Direction | Trigger | Semantics | Typical Use |
|---|---|---|---|
| Git → NetBox/Nautobot | Merge of a pull‑request (or push to a protected branch) | Commit(s) in the PR are interpreted as a desired‑state diff. The sync engine applies the diff via the API, creating, updating, or deleting objects as needed. | Operators propose changes via Git; after review, the change is enacted in the live model. |
| NetBox/Nautobot → Git | Change committed inside NetBox/Nautobot (UI, API, or automated job) | The sync engine reads the change‑log (or webhook payload) and commits the inverse diff to the Git repository, preserving a journal of what actually happened. | Auditing, drift detection, and rollback via Git history. |
Both directions are idempotent: re‑applying the same commit or change‑log entry produces no further modifications. Git is the source of proposals; NetBox/Nautobot is the source of truth. Divergence triggers a conflict‑resolution workflow (see below).
Data Model Mapping
Because Git stores arbitrary files, we serialize NetBox/Nautobot objects as YAML (or JSON) files in a directory‑per‑resource‑type hierarchy that mirrors the API endpoints:
netbox/
├── sites/
│ ├── site-a.yml
│ └── site-b.yml
├── devices/
│ ├── router01.yml
│ └── switch02.yml
├── ipam/
│ ├── prefixes/
│ │ └── 10.0.0.0_24.yml
│ └── ip-addresses/
│ └── 10.0.0.5_32.yml
└── vlans/
└── vlan10.yml
Each file contains a dictionary matching the fields expected by the NetBox/Nautobot create/update endpoint (excluding read‑only fields like id, url, created, last_updated). The sync engine processes each file as follows:
- Read the file from the working tree.
- Normalize keys to match the API (e.g.,
site→ slug or numeric ID). - Lookup the existing object via a unique identifier (
name+ parent scope orslug). - Compute a diff:
- Object missing → CREATE
- Fields differ → PATCH/PUT
- File absent but object exists → DELETE only if a
delete:flag is present (to avoid accidental deletions).
- Apply the change via the API, checking HTTP 200/201 responses.
The reverse direction (NetBox/Nautobot → Git) serializes each changed object back to its canonical file path, overwriting the existing file or creating a new one. Deletions are recorded by removing the file and adding a deleted: true marker in a separate .netbox-deletions.log if an audit trail of removed objects is desired.
Conflict Handling
Concurrent updates can arise in three scenarios:
- Simultaneous PR merges that modify the same object in Git.
- NetBox/Nautobot changes made via UI/API while a Git‑triggered sync is in flight.
- Hybrid case: a PR modifies an object while a NetBox user edits the same object via the UI.
Our strategy combines optimistic locking with manual review:
- Version field – NetBox/Nautobot exposes a
last_updatedtimestamp via the API, which we use as a proxy for a version counter. Before applying a Git‑derived change, the sync engine fetches the current object, compares itslast_updatedto the timestamp recorded in the Git commit (stored in a custom comment or a.metafile). If timestamps diverge beyond a small tolerance, the change is considered stale and the sync aborts, flagging a conflict. - Three‑way merge – For YAML files we attempt a line‑based three‑way merge using the base commit (the commit before the PR), the PR commit, and the current NetBox‑derived version (exported to a temporary branch). If the merge succeeds automatically, we proceed; otherwise we mark the file as conflicted.
- Conflict resolution workflow – When a conflict is detected:
- The sync engine writes a conflict report to a
conflicts/directory in the repo, detailing the opposing versions. - A PR is automatically opened (or a comment added to the existing PR) requesting human review.
- No changes are applied to NetBox/Nautobot until the conflict is resolved manually (by editing the Git file, adjusting the NetBox object via UI, or instructing the sync engine to favor one side).
- The sync engine writes a conflict report to a
This approach ensures no silent overwrites and retains operator authority over the live model.
Implementing the Integration
Automated Syncing via Forge Webhooks / CI/CD
Git hooks run locally; for a centralized workflow we rely on the forge’s webhook system (GitHub, GitLab, Gitea) or a CI/CD pipeline that triggers on push/merge events. The pipeline performs:
- Checkout the target branch (e.g.,
main). - Run a sync script (
sync_git_to_netbox.py) that:- Computes the diff between the previous known good state (tagged or stored in
.sync-state) and the current HEAD. - Applies the diff to NetBox/Nautobot via the API.
- Updates
.sync-statewith the new HEAD SHA.
- Computes the diff between the previous known good state (tagged or stored in
- Push any NetBox‑to‑Git changes back to a separate
netbox-syncbranch (or directly tomainif using a pull‑request model) using a bot account.
Example GitHub Actions Workflow
name: Git → NetBox Sync
on:
push:
branches: [ main ]
pull_request:
types: [ closed ]
branches: [ main ]
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # needed for full history
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pynetbox requests PyYAML
- name: Run sync script
env:
NETBOX_URL: ${{ secrets.NETBOX_URL }}
NETBOX_TOKEN: ${{ secrets.NETBOX_TOKEN }}
GIT_AUTHOR_NAME: netbox-sync-bot
GIT_AUTHOR_EMAIL: bot@example.com
run: |
python sync_git_to_netbox.py \
--repo . \
--branch main \
--state-file .sync-state
- name: Push NetBox → Git changes
if: success()
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "netbox-sync-bot"
git config user.email "bot@example.com"
git add netbox/
git commit -m "Sync NetBox changes from $(date +%Y-%m-%d)" || echo "No changes to commit"
git push origin HEAD:main
NetBox/Nautobot API Integration
NetBox/Nautobot’s REST API follows a conventional pattern:
- List:
GET /api/dcim/devices/?limit=0 - Retrieve:
GET /api/dcim/devices/<id>/ - Create:
POST /api/dcim/devices/with JSON payload. - Update:
PATCH /api/dcim/devices/<id>/(preferred) orPUT. - Delete:
DELETE /api/dcim/devices/<id>/.
The API returns a last_updated field (ISO‑8601 timestamp) used for optimistic locking. Authentication is via a token header: Authorization: Token <TOKEN>.
Minimal Python Client (using pynetbox)
import pynetbox
import yaml
import os
import sys
nb = pynetbox.api(
os.getenv("NETBOX_URL"),
token=os.getenv("NETBOX_TOKEN"),
threading=True
)
def sync_file_to_nb(path):
rel = os.path.relpath(path, start="netbox")
parts = rel.split(os.sep)
if len(parts) != 2:
return # ignore unexpected files
collection, filename = parts
obj_name = os.path.splitext(filename)[0]
with open(path, "r") as f:
data = yaml.safe_load(f)
# Map collection to nb endpoint
endpoint = getattr(nb, collection.replace("/", "_"))
try:
existing = endpoint.get(name=obj_name)
except pynetbox.core.query.RequestError:
existing = None
if existing:
# optimistic check: compare last_updated
git_mtime = os.path.getmtime(path)
nb_mtime = existing.last_updated.timestamp()
if abs(git_mtime - nb_mtime) > 1: # 1‑second tolerance
raise ConflictError(f"{collection}/{obj_name} changed out of band")
# PATCH only changed fields
patch = {k: v for k, v in data.items() if getattr(existing, k, None) != v}
if patch:
endpoint.patch(existing.id, patch)
else:
endpoint.create(data)
if __name__ == "__main__":
for root, _, files in os.walk("netbox"):
for f in files:
if f.endswith(".yml") or f.endswith(".yaml"):
try:
sync_file_to_nb(os.path.join(root, f))
except ConflictError as e:
print(f"CONFLICT: {e}", file=sys.stderr)
sys.exit(1)
Full Sync Script (Git → NetBox and NetBox → Git)
#!/usr/bin/env python3
import os, sys, yaml, hashlib
from datetime import datetime, timezone
import git
import pynetbox
REPO_PATH = os.getenv("GIT_REPO", ".")
NETBOX_URL = os.getenv("NETBOX_URL")
NETBOX_TOKEN = os.getenv("NETBOX_TOKEN")
STATE_FILE = os.path.join(REPO_PATH, ".sync-state")
CONFLICT_DIR = os.path.join(REPO_PATH, "conflicts")
nb = pynetbox.api(NETBOX_URL, token=NETBOX_TOKEN)
def git_hash(path):
h = hashlib.sha1()
with open(path, "rb") as f:
while chunk := f.read(8192):
h.update(chunk)
return h.hexdigest()
def load_state():
if not os.path.isfile(STATE_FILE):
return {}
with open(STATE_FILE) as f:
return yaml.safe_load(f) or {}
def save_state(state):
with open(STATE_FILE, "w") as f:
yaml.dump(state, f)
def apply_git_to_netbox(repo, base_commit, head_commit):
# Compute changed YAML files between base and head
diff = repo.git.diff(base_commit, head_commit, "--name-only", "--diff-filter=AM").splitlines()
for rel_path in diff:
if not rel_path.endswith((".yml", ".yaml")) or not rel_path.startswith("netbox/"):
continue
abs_path = os.path.join(repo.working_tree_dir, rel_path)
try:
sync_file_to_nb(abs_path) # reuse function from snippet above
except ConflictError as e:
os.makedirs(CONFLICT_DIR, exist_ok=True)
conflict_file = os.path.join(CONFLICT_DIR, f"{rel_path.replace('/', '_')}.conflict")
with open(conflict_file, "w") as cf:
cf.write(str(e))
print(f"Conflict detected: {e}", file=sys.stderr)
sys.exit(1)
def push_netbox_changes(repo):
repo.git.add("netbox/")
# Commit only if there are changes
if repo.is_dirty(untracked_files=True):
repo.index.commit(f"Sync NetBox changes {datetime.now(timezone.utc).isoformat()}")
origin = repo.remote(name="origin")
origin.push(refspec="HEAD:main")
else:
print("No NetBox‑derived changes to commit.")
if __name__ == "__main__":
repo = git.Repo(REPO_PATH)
state = load_state()
base = state.get("last_synced_sha", repo.head.commit.parents[0] if repo.head.commit.parents else repo.head.commit)
head = repo.head.commit
apply_git_to_netbox(repo, base, head)
push_netbox_changes(repo)
# Update state file
save_state({"last_synced_sha": head.hexsha})
Summary
- Git serves as the immutable journal of intent (proposals).
- NetBox/Nautobot remains the structured source of truth for the live network state.
- A bidirectional sync engine translates commits to API operations and vice‑versa, using optimistic locking (
last_updated) and three‑way YAML merges to detect and surface conflicts. - Automation is achieved via forge webhooks or CI/CD pipelines, with a small Python script handling the diff, apply, and push steps.
- Conflict reports are written to a
conflicts/directory and require manual resolution, ensuring operators retain final authority over the live model.