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
#!/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
---
- 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:
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
--checkwhere 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_inandpolicy_outfor 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.