Skip to content
LinkState
Go back

Git as audit trail, SoT as live model

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

DirectionTriggerSemanticsTypical Use
Git → NetBox/NautobotMerge 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 → GitChange 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:

  1. Read the file from the working tree.
  2. Normalize keys to match the API (e.g., site → slug or numeric ID).
  3. Lookup the existing object via a unique identifier (name + parent scope or slug).
  4. 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).
  5. 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:

  1. Simultaneous PR merges that modify the same object in Git.
  2. NetBox/Nautobot changes made via UI/API while a Git‑triggered sync is in flight.
  3. 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:

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:

  1. Checkout the target branch (e.g., main).
  2. 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-state with the new HEAD SHA.
  3. Push any NetBox‑to‑Git changes back to a separate netbox-sync branch (or directly to main if 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:

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


Share this post on:

Previous Post
Rolling next-hop-self without creating hidden blackholes
Next Post
Diagnosing Next-Hop Recursion Failure Three Ways