How to Build a BGP Automation Pipeline with NetBox, Ansible, and Python for ISP-Scale Routers
BGP automation at ISP scale is less about “generating configs” and more about building a safe, repeatable pipeline: authoritative source-of-truth, validation gates, idempotent config rendering, controlled rollout, and post-deploy verification with rollback. This tutorial walks through a practical architecture using NetBox + Python + Ansible that can support large router fleets, multi-AS designs, and strict convergence requirements.
Assumptions, target topology and constraints for ISP networks (scale, convergence targets, multi-AS designs)
Target environment (typical ISP-scale reality)
- Routers: 100–5,000+ devices, multiple vendors/OSes (e.g., IOS XR, Junos, SR OS).
- BGP roles:
- Edge/Peering: IX, PNIs, transit providers.
- Core: iBGP (RRs), labeled-unicast/L3VPN, or BGP-free core with LDP/SR.
- Access/BNG: may run eBGP to aggregation or iBGP to RRs.
- Routing domains:
- Single AS with confederations, or multi-AS (regional ASNs, M&A, regulatory).
- Multiple VRFs (internet, services, wholesale, CGNAT, etc.).
- Scale constraints:
- Thousands of neighbors, tens/hundreds of thousands of prefixes.
- Large policy surface area (communities, RPKI, max-prefix, dampening decisions).
- Operational constraints:
- Convergence targets: e.g., “< 2 seconds for edge session failover” or “< 30 seconds for backbone incidents” (depends on design).
- Must avoid global churn: RR changes can ripple across the network.
- Change windows and safe rollout: canary → small batch → wider batches.
Topology patterns that influence automation design
- Route Reflector clusters: clients grouped by region/site; avoid single blast radius.
- Multi-AS:
- Inter-AS boundaries with distinct import/export policy and hygiene (prefix limits, communities).
- Some neighbors are “shared” across ASNs (policy differs by AS).
- eBGP underlay (common in modern DC + some ISP backbones):
- Session count grows quickly; templating and validation become mandatory.
Design goals for the pipeline
- NetBox = source of truth for intent (neighbors, ASNs, prefixes, policies, roles).
- Python = deterministic render and diff for idempotence.
- Ansible = orchestration with staged rollout, concurrency control, and safe pushing.
- Validation gates before and after deploy: preflight checks + post-deploy checks + auto-remediation.
Designing the NetBox data model for BGP at scale: prefixes, RIRs, ASNs, peering roles and custom fields
NetBox’s stock objects cover a lot (ASNs, prefixes, sites, devices, interfaces). At ISP scale, the difference is how you model policy intent and peering relationships so that generation is predictable and “safe by default”.
Core NetBox objects to use
- ASN objects:
- Store your own ASNs and external peer ASNs.
- Tag by role (transit, peer, customer, internal, confed member).
- Prefix objects:
- Customer allocations, infrastructure, aggregates, blackhole ranges.
- Tag/role to drive route-maps/policy-lists (e.g.,
customer-announces,bogons,blackhole). - RIRs:
- Useful for documentation and governance; not directly required for config, but helps keep data clean.
- Tenancy/VRFs:
- Use VRFs for policy separation (internet vs. services).
- Use Tenant to represent external parties (IX, transit provider, customer).
Model “BGP neighbor intent” explicitly
NetBox does not have a first-class “BGP neighbor” object by default. Common approaches:
1. NetBox BGP plugin (if available/approved in your environment), or
2. Use Custom Objects (if supported), or
3. Use IP Address + Custom Fields + Tags on interfaces to represent neighbor relationships.
A practical pattern without heavy customization:
- Represent each BGP neighbor as an IP Address on a “logical peering interface” (or a subinterface).
- Attach custom fields to the IP Address (or Interface) that describe BGP intent.
Recommended custom fields (minimum viable)
Attach to the object you’ll query as “neighbor endpoint” (often ipam.IPAddress or dcim.Interface):
bgp_enabled(bool)bgp_local_as(int) — allow per-device override if multi-ASbgp_remote_as(int)bgp_session_type(choice:ebgp,ibgp)bgp_address_family(choice/multi:ipv4,ipv6,vpnv4,vpnv6,labeled-unicast)bgp_peer_role(choice:transit,ix_peer,pni_peer,customer,rr_client,rr_peer)bgp_import_policy/bgp_export_policy(string keys mapped to templates)bgp_max_prefix(int) +bgp_max_prefix_teardown(bool)bgp_bfd(bool)bgp_password_ref(string) — reference a secret path (don’t store the secret in NetBox)bgp_description(string)bgp_multihop_ttl(int, optional)bgp_update_source(string, optional; loopback, interface name)
Policy indirection is key: store policy identifiers, not giant route-policy bodies. Let Python/Jinja expand policy from a controlled library.
Use tags to drive behavior
Tags are extremely effective at scale:
- NO_EXPORT_DEFAULT → do not advertise default route
- BLACKHOLE → enable RTBH community handling
- RPKI_ENFORCE → strict RPKI (drop invalid)
- GRACEFUL_SHUTDOWN → apply RFC 8326 behavior
- CANARY → include in initial rollout batch
Data quality rules (enforce them early)
Treat NetBox like a database with contracts:
- Remote AS must exist and be valid (asn object exists).
- Neighbor IP must be in a “peering” prefix role.
- For eBGP: remote IP must be reachable within the intended VRF and interface.
- For iBGP RR: enforce cluster-ID conventions and client group membership.
Python-driven config generation: Jinja2 templates, data validation (pydantic/schemas), and idempotent diff outputs
Python is the glue: query NetBox, validate intent, render configs, and output diffs that are stable and reviewable.
Pipeline flow (high level)
- Fetch device + interface + IP + ASN + prefix data from NetBox (REST or GraphQL).
- Normalize into a vendor-neutral internal model.
- Validate with Pydantic (or JSON schema) to catch incomplete or contradictory intent.
- Render with Jinja2 templates per platform.
- Compute idempotent diffs against last rendered or live config (depending on your approach).
- Emit artifacts:
- per-device candidate config
- per-device diff
- a “change manifest” (neighbors added/removed, policy changes)
Why schemas matter at ISP scale
Validation prevents “one bad neighbor record” from taking down a batch:
- Missing remote_as → fail fast
- eBGP with session_type=ibgp mismatch → fail fast
- max_prefix missing for transit → fail policy gate
- IPv6 neighbor with IPv4-only AFI → fail fast
Example: NetBox → model → render (Python)
Below is a compact example showing Pydantic validation and Jinja2 rendering. In production, split into modules and add richer normalization.
from typing import Literal, Optional, List
from pydantic import BaseModel, Field, IPvAnyAddress, ValidationError, field_validator
from jinja2 import Environment, FileSystemLoader
import requests
class BgpNeighbor(BaseModel):
neighbor_ip: IPvAnyAddress
remote_as: int = Field(gt=0, lt=4294967296)
session_type: Literal["ebgp", "ibgp"]
afi: List[Literal["ipv4", "ipv6"]]
description: str
bfd: bool = True
max_prefix: Optional[int] = Field(default=None, gt=0)
@field_validator("max_prefix")
@classmethod
def require_max_prefix_for_ebgp(cls, v, info):
# Example policy: require max-prefix on all eBGP sessions
if info.data.get("session_type") == "ebgp" and v is None:
raise ValueError("max_prefix is required for eBGP neighbors")
return v
class DeviceBgpIntent(BaseModel):
hostname: str
local_as: int = Field(gt=0, lt=4294967296)
router_id: str
neighbors: List[BgpNeighbor]
def netbox_get(url, token, params=None):
r = requests.get(url, headers={"Authorization": f"Token {token}"}, params=params, timeout=30)
r.raise_for_status()
return r.json()
def build_intent_from_netbox(netbox_url, token, device_name) -> DeviceBgpIntent:
# Example: fetch device and a pre-modeled list of neighbor IPs with custom fields
dev = netbox_get(f"{netbox_url}/api/dcim/devices/", token, params={"name": device_name})["results"][0]
ips = netbox_get(f"{netbox_url}/api/ipam/ip-addresses/", token, params={"device": device_name, "tag": "bgp-neighbor"})["results"]
neighbors = []
for ip in ips:
cf = ip.get("custom_fields", {})
neighbors.append(BgpNeighbor(
neighbor_ip=ip["address"].split("/")[0],
remote_as=int(cf["bgp_remote_as"]),
session_type=cf["bgp_session_type"],
afi=cf["bgp_address_family"],
description=cf.get("bgp_description") or f"to {cf.get('bgp_peer_role','peer')}",
bfd=bool(cf.get("bgp_bfd", True)),
max_prefix=cf.get("bgp_max_prefix"),
))
return DeviceBgpIntent(
hostname=dev["name"],
local_as=int(dev["custom_fields"]["bgp_local_as"]),
router_id=dev["custom_fields"]["router_id"],
neighbors=neighbors
)
def render_junos_bgp(intent: DeviceBgpIntent) -> str:
env = Environment(loader=FileSystemLoader("templates"), trim_blocks=True, lstrip_blocks=True)
tmpl = env.get_template("junos_bgp.j2")
return tmpl.render(d=intent)
if __name__ == "__main__":
NETBOX_URL = "https://netbox.example.net"
TOKEN = "REDACTED"
device = "edge01"
try:
intent = build_intent_from_netbox(NETBOX_URL, TOKEN, device)
except (KeyError, ValidationError) as e:
raise SystemExit(f"Intent validation failed: {e}")
config = render_junos_bgp(intent)
print(config)
Idempotent diffs: stable output matters
Make diffs predictable to support review and safe deployment:
- Sort neighbors by IP/ASN.
- Render deterministic policy blocks (stable ordering).
- Normalize whitespace and optional fields.
- Prefer declarative “replace this stanza” where platform supports it, or generate platform-native set commands.
A common pattern:
- Python renders a candidate config file per device.
- Ansible loads it and does a check/diff using platform modules.
- Deploy only if diff is within expected scope (guardrails).
Safe orchestration with Ansible: staged playbooks, dry-run with check_mode, transactional pushes and concurrency controls
Ansible is your rollout engine. For ISP-scale BGP automation, the key is reducing blast radius with stages, limits, and guardrails.
Playbook structure (recommended)
- Stage 0: Preflight
- Confirm device reachable
- Confirm NetBox intent exists
- Confirm maintenance window / change ticket presence (if enforced)
- Stage 1: Dry-run / check mode
- Gather facts
- Load candidate config
- Show diff
- Fail if unexpected deletions or neighbor count changes exceed threshold
- Stage 2: Canary deploy
- Run on small tagged subset (e.g.,
CANARY) - Validate BGP health
- Stage 3: Batch deploy
- Roll out by region/site/role
- Concurrency controls and rate limiting
- Stage 4: Post-check and closeout
- Confirm sessions established, prefix counts within bounds
- Publish results/metrics
Concurrency controls
At scale, limit concurrency to avoid:
- Route churn (many peers bouncing at once)
- Control plane CPU spikes (commits and policy recompute)
- NOC overload (alert storms)
Use:
- serial: to cap batch size
- throttle: for specific tasks
- Separate inventory groups per POP/role
Example: staged playbook snippet with check mode and serial
This example uses network_cli and vendor modules as placeholders; adapt to your platform modules (e.g., junipernetworks.junos, cisco.iosxr, nokia.sros, arista.eos).
---
- name: BGP automation - staged rollout
hosts: edge_routers
connection: network_cli
gather_facts: false
serial: 10 # batch size for safety
vars:
candidate_dir: "rendered/{{ inventory_hostname }}"
candidate_file: "{{ candidate_dir }}/bgp.conf"
pre_tasks:
- name: Ensure candidate config exists (render step happens earlier in CI)
ansible.builtin.stat:
path: "{{ candidate_file }}"
register: cand
- name: Fail if no candidate config
ansible.builtin.fail:
msg: "Missing candidate config: {{ candidate_file }}"
when: not cand.stat.exists
tasks:
- name: Dry-run (check mode) - load candidate and show diff
# Example module; replace with platform equivalent
ansible.netcommon.cli_config:
config: "{{ lookup('ansible.builtin.file', candidate_file) }}"
diff: true
check_mode: true
register: dryrun
- name: Guardrail - fail if diff is empty (optional) or too large
ansible.builtin.fail:
msg: "Diff too large or unexpected; review required"
when: dryrun.diff is defined and (dryrun.diff | length) > 50000
- name: Deploy - load candidate
ansible.netcommon.cli_config:
config: "{{ lookup('ansible.builtin.file', candidate_file) }}"
diff: true
register: deploy
- name: Post-check - verify BGP sessions summary (CLI example)
ansible.netcommon.cli_command:
command: "show bgp summary"
register: bgp_sum
- name: Fail if BGP summary indicates broken sessions (placeholder regex)
ansible.builtin.fail:
msg: "BGP validation failed on {{ inventory_hostname }}"
when: bgp_sum.stdout[0] is search("Idle|Active")
Transactional pushes and rollback
Different platforms have different transaction semantics:
- Junos: candidate config + commit confirmed is ideal for automated rollback.
- IOS XR: commit replace + rollback points.
- SR OS: candidate/commit with rollback checkpoints (varies by release).
Practical approach:
- Use “commit confirmed” when available:
- If post-check passes, confirm commit.
- If not, let it auto-rollback or explicitly rollback.
- Store rollback artifacts:
- Before-change snapshot (or show configuration | display set)
- Post-change snapshot
- Commit ID
Guardrails worth implementing
- Neighbor count change threshold (e.g., fail if adding/removing > N sessions in one batch).
- Require max-prefix on all eBGP neighbors.
- Require RPKI policy on transit/IX roles (if that’s a standard).
- Refuse to touch route reflector policies without explicit override.
Post-deploy validation and auto-remediation: BGP session checks, baseline telemetry, automated rollback and alerting
Post-deploy validation is not a single command. It should combine:
- protocol state checks
- prefix counters
- control-plane health signals
- telemetry baseline comparisons
Validation checklist (per device)
BGP session state
- All expected neighbors are Established.
- No unexpected new neighbors.
- Session flap count not increasing rapidly.
Prefix/policy sanity
- Received prefixes within expected range (and below max-prefix).
- Advertised prefixes match intent (per peer role).
- RPKI invalids handled as expected (drop/lowpref based on policy).
- No sudden loss of critical routes (default, core aggregates, customer supernets).
Control plane health
- CPU and memory not spiking
- BGP process not restarting
- Commit time within normal bounds
Baseline telemetry (practical minimum)
Pick a small set of time-series metrics to compare before/after:
- bgp_session_up by neighbor
- bgp_prefix_received / bgp_prefix_advertised
- Interface errors on peering links
- Router CPU (control plane)
If you already have streaming telemetry, export summary metrics per change batch.
Auto-remediation patterns
Automation should remediate only what it can do safely:
- If a commit causes sessions to drop and does not recover within a bounded time:
- rollback to last known good commit
- drain traffic if applicable (depends on design)
- alert with device + neighbor + diff summary
Rollback strategy
- Prefer platform-native rollback (commit confirmed / rollback point).
- Avoid “push previous full config” unless you’re certain about drift; use known commits.
Alerting
- Send a structured event with:
- device, batch, timestamp
- commit ID
- neighbors impacted
- diff excerpt (redacted)
- validation results
Key takeaways
- NetBox should store intent, not raw vendor config: model neighbor relationships, roles, and policy identifiers.
- Python adds schema validation and deterministic rendering, which prevents bad source-of-truth data from becoming outages.
- Ansible provides safe rollout mechanics: check mode, serial batching, and platform-aware transactional commits.
- Post-deploy validation must include session state + prefix counts + baseline telemetry; rollback should be automatic when possible.
- Guardrails (max-prefix, neighbor delta limits, canaries) are what make bgp automation operationally safe.
Common pitfalls
- Storing secrets in NetBox custom fields: store references/IDs and fetch secrets from a vault system at runtime.
- Underspecified policy intent: “import_policy=peer” is meaningless unless it maps to a controlled policy library with clear semantics.
- Non-deterministic rendering: unsorted lists and inconsistent whitespace create noisy diffs and reduce trust in the pipeline.
- Rolling out too broadly: changing 200 edge routers at once can cause route churn and alert floods; batch by site/role.
- No max-prefix discipline: max-prefix is one of the simplest, highest-value safety controls for eBGP at scale.
- Validating only ‘Established’: a session can be up while policy is wrong; always validate prefix counters and critical advertisements.
- Ignoring multi-AS nuances: local-as overrides, confed IDs, and per-AS policy differences need first-class modeling, not ad hoc exceptions.
If you want to extend this pipeline further, the next practical steps are: (1) introduce GraphQL queries for efficient NetBox reads, (2) add per-role policy unit tests (e.g., “transit must never accept martians”), and (3) integrate a change approval gate based on rendered diffs and neighbor-impact manifests.