How to automate BGP peer provisioning for ISP networks with NetBox, Ansible, and Python

How to automate BGP peer provisioning for ISP networks with NetBox, Ansible, and Python

Automating bgp peer provisioning in an ISP network is less about “pushing config” and more about building a reliable pipeline: a clean source of truth, deterministic data transformation, strong validation (policy and routing security), idempotent configuration rendering, and post-deploy verification with drift detection.

This tutorial walks through a practical, advanced workflow using NetBox for inventory, Python for transformation and validation, and Ansible + Jinja for idempotent configuration across router platforms.

Inventory and data model: model ASN, peers, policies and transport in NetBox and export canonical datasets

What to model (minimum viable but scalable)

You want a model that supports:
- Multiple network roles (transit, IX, private peer, downstream/customer)
- Per-peer policy deltas (communities, max-prefix, allow-as-in, multihop, GTSM/TTL security)
- Transport details (IPv4/IPv6 endpoints, update-source/interface, VRF, BFD, MD5/TCP-AO)
- Attach policy objects (prefix-lists, route-maps, as-path filters)
- Routing security inputs (RPKI ROAs, IRR as-set expansion)

Suggested NetBox modeling approach

NetBox doesn’t have a native “BGP neighbor” object, so model with a combination of standard objects plus custom fields, tags, and/or a small plugin. A common approach:

Core objects
- Tenant: external party (peer/transit/customer)
- ASN (IPAM → ASNs): local ASN(s) + peer ASNs
- Site/Region: PoP / metro / region
- Device: routers
- Interface: peering interfaces / loopbacks
- IP addresses: peering IPs, loopbacks, subinterfaces
- VRFs: per customer / per service / internet / management separation
- Circuit (optional): for physical transport tracking

Custom fields / tags (practical)
Add custom fields on Device, Interface, ASN, or IP Address:
- bgp_role: transit|ix|private_peer|customer
- bgp_policy_in, bgp_policy_out: canonical policy names
- max_prefix_v4, max_prefix_v6
- session_type: ebgp|ibgp
- multihop: integer, 0 means directly connected
- local_address: explicit update-source (interface name or IP)
- password: reference name for secrets lookup (not the secret itself)
- graceful_restart, bfd, gtsm
- allow_as_in, remove_private_as, next_hop_self (where applicable)
- peer_group: to deduplicate config across many peers

Where to attach the “neighbor” definition
Two patterns work well:

1) IP Address as the neighbor “handle”
Store neighbor IP as an IP Address object and attach custom fields to it:
- Pros: neighbor endpoint is explicit and tied to VRF + interface
- Cons: more custom-field usage; needs discipline

2) Virtual Machine / Device representing “Peer endpoint” (less common for ISP peers)
Not recommended unless peers are internal routers.

A pragmatic compromise:
- Define each peering endpoint as an IP Address on an interface (yours).
- Define the remote neighbor IP as a custom field peer_ip on that same IP Address record, or store it as a second IP Address object with a tag like bgp-remote (depends on your process).

Export canonical datasets

Avoid letting Ansible scrape NetBox ad hoc. Instead:
- Export a canonical dataset per router (or per site/role), version it, and use it as the input to config generation.
- This gives you reproducibility, change review, and the ability to re-run automation without NetBox availability.

Common export shapes:
- routers/<hostname>.yml (neighbors, policies, address-families, vrfs)
- policies/*.yml (route-maps, prefix-lists, as-path sets, community-sets)
- peerdb/*.yml (peer definitions shared across routers)

Use NetBox REST API or GraphQL. REST is simpler and widely supported.

Transform and validate: Python scripts to convert NetBox objects into Ansible variables and enforce policy checks (AS-path, prefix-lists, ROA/IRR validation)

Why a transformation layer matters

NetBox data is relational and “inventory-ish.” Router configuration needs:
- Flattened, explicit objects (neighbor + AFI/SAFI policy bindings)
- Deterministic ordering (stable diffs)
- Defaults and inheritance (peer-group, role-based defaults)
- Guardrails (don’t deploy unsafe/invalid sessions)

Treat Python as the compiler from “inventory” to “intended state.”

Validation checklist (high-value in ISP bgp automation)

At minimum, enforce:

Transport + session sanity
- Neighbor IP version matches AFI being enabled
- eBGP TTL/multihop rules are consistent
- update-source is required for multihop or loopback-based sessions
- VRF exists and interface/IP is in that VRF
- Max-prefix thresholds defined per role (transit vs peer vs customer)

Policy correctness
- Must have inbound and outbound policy for eBGP neighbors (except tightly controlled internal cases)
- AS-path filter presence for customers (or explicit rationale if not)
- Prefix-list generation source present (IRR/RPKI/manual)

Routing security
- RPKI ROA validation (preferably using local validator via RTR, plus offline checks)
- IRR as-set expansion for customers/peers where appropriate
- “Reject RPKI invalid” policy is consistent with network policy (or at least tag exceptions explicitly)

Example: Python transformation + basic checks

Below is a simplified example that:
- Pulls neighbors for a router (assume you already resolved from NetBox)
- Validates required fields
- Emits an Ansible vars file

python
#!/usr/bin/env python3
import ipaddress
import sys
import yaml
from dataclasses import dataclass, asdict

@dataclass(frozen=True)
class Neighbor:
    name: str
    peer_asn: int
    neighbor_ip: str
    vrf: str
    afi: list[str]          # ["ipv4", "ipv6"]
    policy_in: str
    policy_out: str
    max_prefix: dict        # {"ipv4": 1000000, "ipv6": 250000}
    multihop: int = 0
    update_source: str | None = None
    bfd: bool = False
    gtsm: int | None = None # e.g. 1

def validate_neighbor(n: Neighbor) -> None:
    ip = ipaddress.ip_address(n.neighbor_ip)

    if not n.policy_in or not n.policy_out:
        raise ValueError(f"{n.name}: missing policy_in/policy_out")

    if n.multihop and not n.update_source:
        raise ValueError(f"{n.name}: multihop requires update_source")

    # AFI consistency
    if ip.version == 4 and "ipv4" not in n.afi:
        raise ValueError(f"{n.name}: neighbor is IPv4 but ipv4 AFI not enabled")
    if ip.version == 6 and "ipv6" not in n.afi:
        raise ValueError(f"{n.name}: neighbor is IPv6 but ipv6 AFI not enabled")

    for afi in n.afi:
        if afi not in n.max_prefix:
            raise ValueError(f"{n.name}: missing max_prefix for {afi}")

def emit_vars(router: str, neighbors: list[Neighbor]) -> dict:
    # Stable ordering for diff friendliness
    neighbors_sorted = sorted(neighbors, key=lambda x: (x.vrf, x.neighbor_ip, x.peer_asn))
    return {
        "router": router,
        "bgp_neighbors": [asdict(n) for n in neighbors_sorted]
    }

def main():
    # In practice, replace this with NetBox API calls and mapping logic.
    router = sys.argv[1] if len(sys.argv) > 1 else "r1.example"
    neighbors = [
        Neighbor(
            name="IXP-RS1",
            peer_asn=65001,
            neighbor_ip="192.0.2.10",
            vrf="default",
            afi=["ipv4"],
            policy_in="RM-IX-IN",
            policy_out="RM-IX-OUT",
            max_prefix={"ipv4": 200000},
            multihop=0,
            update_source=None,
            bfd=True,
            gtsm=1,
        )
    ]

    for n in neighbors:
        validate_neighbor(n)

    vars_doc = emit_vars(router, neighbors)
    print(yaml.safe_dump(vars_doc, sort_keys=False))

if __name__ == "__main__":
    main()

Where to plug ROA/IRR checks

The transformation stage is the best place to:
- Build prefix-lists from IRR as-sets (e.g., via bgpq4 or IRRd)
- Pull ROA data (RPKI validator API or local cache)
- Produce policy artifacts:
- PL-CUST-AS64500-V4
- PL-CUST-AS64500-V6
- ASPATH-CUST-AS64500
- Fail the pipeline if required artifacts can’t be built (or require an explicit override tag)

A common pattern is:
- Generate policy inputs (prefixes, as-path regexes) in Python
- Render platform syntax via Jinja templates per NOS

Idempotent configuration: Ansible playbook patterns and Jinja templates for Cisco and other router platforms to provision BGP neighbors and policies

Design principles for bgp config automation

  • Idempotent inputs: same data → same rendered config
  • Feature boundaries: separate “policy objects” from “neighbor bindings”
  • Minimize per-neighbor repetition: use peer-groups / templates wherever possible
  • Predictable diffs: sorted lists, consistent naming, stable formatting
  • Platform abstraction: keep a normalized data model and render per NOS

Example: Ansible task flow (network_cli + config + commit-like behavior)

The exact modules differ by platform (IOS XE, IOS XR, NX-OS, Junos, Arista EOS). The pattern stays consistent:

1) Generate candidate config (Jinja)
2) Push candidate with diff
3) Commit / write memory / confirm
4) Run verification tasks

yaml
---
- name: Provision BGP peers safely
  hosts: edge_routers
  gather_facts: no
  vars_files:
    - "generated/{{ inventory_hostname }}.yml"

  tasks:
    - name: Render BGP neighbor configuration
      ansible.builtin.template:
        src: "templates/{{ ansible_network_os }}/bgp_neighbors.j2"
        dest: "rendered/{{ inventory_hostname }}-bgp.cfg"

    - name: Load candidate config (IOS XE example)
      when: ansible_network_os == "cisco.ios.ios"
      cisco.ios.ios_config:
        src: "rendered/{{ inventory_hostname }}-bgp.cfg"
        diff_against: running
        save_when: modified

    - name: Load candidate config (IOS XR example)
      when: ansible_network_os == "cisco.iosxr.iosxr"
      cisco.iosxr.iosxr_config:
        src: "rendered/{{ inventory_hostname }}-bgp.cfg"
        diff_against: running
        # In XR you often prefer commit confirmed flows; see rollout section.

    - name: Verify BGP sessions are established (basic)
      ansible.netcommon.cli_command:
        command: "show bgp summary"
      register: bgp_summary

    - name: Save show output for audit
      ansible.builtin.copy:
        content: "{{ bgp_summary.stdout }}\n"
        dest: "artifacts/{{ inventory_hostname }}-show-bgp-summary.txt"

Jinja template example (conceptual, IOS XE-ish)

Render neighbors from bgp_neighbors with AFI-specific policy bindings:

jinja
router bgp {{ local_asn }}
 bgp log-neighbor-changes
{% for n in bgp_neighbors %}
 neighbor {{ n.neighbor_ip }} remote-as {{ n.peer_asn }}
{% if n.update_source %}
 neighbor {{ n.neighbor_ip }} update-source {{ n.update_source }}
{% endif %}
{% if n.multihop|int > 0 %}
 neighbor {{ n.neighbor_ip }} ebgp-multihop {{ n.multihop }}
{% endif %}
{% if n.bfd %}
 neighbor {{ n.neighbor_ip }} bfd
{% endif %}

 address-family ipv4
{% if "ipv4" in n.afi %}
  neighbor {{ n.neighbor_ip }} activate
  neighbor {{ n.neighbor_ip }} route-map {{ n.policy_in }} in
  neighbor {{ n.neighbor_ip }} route-map {{ n.policy_out }} out
  neighbor {{ n.neighbor_ip }} maximum-prefix {{ n.max_prefix.ipv4 | default(n.max_prefix["ipv4"]) }}
{% endif %}
 exit-address-family

 address-family ipv6
{% if "ipv6" in n.afi %}
  neighbor {{ n.neighbor_ip }} activate
  neighbor {{ n.neighbor_ip }} route-map {{ n.policy_in }} in
  neighbor {{ n.neighbor_ip }} route-map {{ n.policy_out }} out
  neighbor {{ n.neighbor_ip }} maximum-prefix {{ n.max_prefix["ipv6"] }}
{% endif %}
 exit-address-family
{% endfor %}

Notes for real deployments:
- Use peer-groups (IOS XE/NX-OS) or neighbor-groups (IOS XR) for role-based defaults.
- Separate policy definitions (prefix-lists, route-maps, community-lists) into their own templates/tasks so neighbors reference pre-existing objects.
- Normalize differences per NOS (e.g., route-policy in XR, policy-options in Junos).

Safe rollout and rollback: staging, dry-run verifications, canary peers, transaction logging and automated rollback strategies

Staging pipeline: “intended state” before touching routers

A robust rollout separates build-time checks from deploy-time actions:

1) Build (offline)
- Export from NetBox → canonical dataset
- Transform + validate (Python)
- Generate device configs (Ansible template render)
- Run static lint checks (YAML/Jinja), unit tests for policy generation

2) Plan
- Produce diffs against current running config (where supported)
- Human review for high-risk changes (transit sessions, large peers)

3) Deploy
- Canary first (one router, one region, or one peer-group)
- Roll forward gradually

Dry-run / diff practices

  • Use --check where modules support it (varies by NOS).
  • Always capture diffs (diff_against: running).
  • Store rendered configs and diffs as build artifacts with timestamps and a change ID.

Canary peers

Choose canaries that minimize blast radius but maximize signal:
- A route-server session at an IX (if policy is conservative)
- A low-volume private peer
- A single customer in each major policy class

Canary criteria:
- Representative policy features (communities, max-prefix, RPKI behavior)
- Clear success metrics (session up, route counts within expected range)

Rollback strategies by platform

Rollback is platform-dependent; plan it before automating:

  • IOS XR / Junos: leverage commit history / rollback.
  • Prefer commit confirmed where possible, and confirm only after verification passes.
  • IOS XE / NX-OS / EOS: rely on:
  • Replace-style config (if supported and safe)
  • “Archive” / checkpoint features
  • Explicit “undo” template (generated from intended state transitions)
  • Automatic rollback by re-applying last-known-good rendered config

Transaction logging (non-negotiable)

For each change, log:
- Input dataset hash (NetBox export version, Git commit, etc.)
- Rendered config hash
- Device diffs
- Pre/post “show” outputs (BGP summary, route counts, prefix received/advertised)
- Result of validation checks

This makes rollback and incident analysis far faster.

Post-deploy verification and reconciliation: automated tests (BGP session, route counts, prefix validation), NetBox reconciliation loop and alerting for drift

What to verify after deploying bgp changes

Go beyond “session established”:

Session health
- BGP state = Established for each neighbor
- Flap count / last reset reason
- Negotiated capabilities (4-byte ASN, graceful restart, add-path, etc.)

Route-volume sanity
- Prefixes received within expected bounds (max-prefix minus headroom)
- Prefixes advertised match policy (often near-zero for peers, higher for customers)
- Unexpected route leaks (e.g., customer sending full table)

Policy and security
- RPKI invalids are rejected (or tagged) according to policy
- IRR-based filters match expected expansions
- Bogon and martian filters active on ingress/egress where required

Lightweight verification examples

A common approach is:
- Run commands via Ansible cli_command
- Parse with TextFSM/pyATS/Genie, or simple regex for targeted assertions
- Fail the pipeline if checks fail, triggering rollback (where supported)

Examples of checks:
- show bgp ipv4 unicast summary
- show bgp ipv6 unicast summary
- show bgp neighbor <ip> received-routes (careful: expensive on some platforms)
- show route-policy / show route-map presence checks

Reconciliation loop with NetBox

Decide what NetBox represents:
- Source of truth for intent (recommended): NetBox is authoritative; devices are enforced to match.
- Source of partial truth: some attributes discovered from devices and written back (use caution to avoid feedback loops).

A practical reconciliation loop:
1) Collect operational state (neighbors, session state, counts)
2) Compare to intended dataset (not directly to NetBox raw)
3) If drift is detected:
- Alert (ticket/notification)
- Optionally open a PR/update to NetBox or block further changes until resolved

Drift alerting

Alert on:
- Neighbor exists on router but not in intended state
- Intended neighbor missing on router
- Policy objects missing
- Significant deviation in route counts (sudden spikes/drops)
- RPKI invalid acceptance unexpectedly increased

Common pitfalls

  • Letting NetBox data leak directly into templates: it tends to create fragile templates and inconsistent diffs. Compile NetBox into a normalized, versioned dataset first.
  • No explicit policy bindings: “default permit” mistakes are common. Require policy_in and policy_out for eBGP neighbors unless an exception is explicitly tagged and reviewed.
  • Ignoring per-AFI differences: IPv4 and IPv6 often have different max-prefix, different prefix filters, and different ROA coverage.
  • Overusing per-neighbor config: without peer-groups/role defaults, templates become unmaintainable and changes become riskier.
  • No rollback plan: “just re-run Ansible” is not rollback. Use commit-confirmed/rollback features where available, and keep last-known-good artifacts.
  • Verification that’s too shallow: session up doesn’t mean policy is correct. Include route count sanity and prefix validation checks.
  • RPKI/IRR ambiguity: if IRR expansion fails or ROA data is unavailable, decide whether to block changes or deploy with a safe default (often “fail closed” for customers, “fail safe” with strict limits for peers, depending on policy).

Key takeaways

  • Treat bgp peer provisioning as a pipeline: NetBox (intent) → Python (compile + validate) → Ansible/Jinja (render + apply) → verification + drift control.
  • Build a normalized canonical dataset per router and version it for deterministic renders and auditable changes.
  • Put policy and routing security validation (AS-path, prefix-lists, ROA/IRR) in the transformation stage so unsafe changes never reach devices.
  • Use idempotent templates with peer-groups/role defaults to keep configs consistent across platforms.
  • Roll out safely with canaries, diffs, transaction logs, and real rollback mechanisms, then verify beyond “Established” with route-volume and prefix validation checks.