Skip to content

Netbox Sync Device Prefixes Task¤

task api name: sync_device_prefixes

The task derives IPv4 and IPv6 networks from live device interface addresses and reconciles those prefixes with NetBox. It is independent from sync_device_ip: it does not require NetBox interfaces and does not create or update IP address records.

How It Works¤

  1. Resolve the selected devices and validate that they exist in NetBox.
  2. Run Nornir parse_ttp with get="interfaces" for the selected devices.
  3. Apply interface, prefix, and ignore-range filters.
  4. Convert each address to its canonical network, for example 10.0.0.1/31 to 10.0.0.0/31.
  5. Deduplicate observations, compare them with NetBox, and create missing or update out-of-sync prefixes.

NetBox interface presence is never checked. A prefix can therefore be synchronized before its source interface exists in NetBox.

Output¤

The result is global because the same prefix can be reported by several devices:

{
    "created": ["10.0.0.0/31"],
    "updated": ["10.0.1.0/31"],
    "in_sync": ["2001:db8::1/128"]
}

The top-level diff field always contains a global reconciliation plan:

{
    "global": {
        "create": [],
        "update": {},
        "delete": [],
        "in_sync": ["2001:db8::1/128"]
    }
}

The task does not delete prefixes, so delete is always empty.

Dry-run returns the same structure without writing to NetBox. With with_approval=True, the prepared result is shown for approval before any prefixes are written.

VRF Handling¤

ignore_vrf=True is the default:

  • Prefixes are matched by canonical prefix alone.
  • Existing VRF associations are preserved.
  • New prefixes are created without a VRF.

With ignore_vrf=False:

  • The live interface VRF is resolved by name; a missing VRF is created.
  • Prefixes are matched by canonical prefix and VRF.
  • If the same prefix exists in another VRF, that object is preserved and a new prefix is created in the live VRF.
  • global and default interface VRFs are treated as the global table.

Site Handling¤

ignore_site=True is the default:

  • Device sites are not written to prefixes.
  • Existing prefix scope/site associations are preserved.

With ignore_site=False:

  • New prefixes are associated with the reporting device's site.
  • Existing matching prefixes are updated when their site differs.
  • When several devices report the same prefix identity, the site from the alphabetically first device name is authoritative.

Prefix identity is the prefix alone when VRFs are ignored, otherwise it is the prefix and resolved VRF together.

Filtering¤

Interface filters are applied to live data before prefix derivation. Prefix filters are applied to the derived canonical prefixes:

  • filter_by_name — interface-name glob, such as Loopback*.
  • filter_by_description — interface-description glob.
  • filter_by_prefix — include prefixes contained within one IPv4 or IPv6 network.
  • ignore_ranges — exclude derived prefixes fully contained within any supplied IPv4 or IPv6 network. A narrower ignored network does not exclude a broader live prefix containing it.

The default ignored ranges are:

127.0.0.0/8
224.0.0.0/24
fe80::/10
ff02::/16
::ffff:0:0/96
::1/128

Filters combine using intersection.

For example, ignore_ranges="10.3.15.33/32" does not exclude the live prefix 10.3.15.32/30, while ignore_ranges="10.3.15.0/24" does.

Deletion Behavior¤

The task never deletes prefixes. A prefix absent from the selected live device data is left unchanged in NetBox.

Branching Support¤

Pass branch=<name> to create or update prefixes in a NetBox Branching Plugin branch. The task creates the branch when it does not already exist.

Bulk Request Batching¤

List-based NetBox writes are sent as sequential requests containing at most batch_size objects. The default is 1000; set any integer greater than zero to tune the request size. Each batch emits matching progress event and log messages. If a request fails, the task stops and returns the results recorded for earlier successful batches.

Examples¤

Synchronize prefixes from one device:

nf#netbox sync prefixes devices ceos-spine-1

Preview prefixes derived from loopbacks:

nf#netbox sync prefixes devices ceos-spine-1 filter-by-name "Loopback*" dry-run

Associate prefixes with live VRFs and device sites:

nf#netbox sync prefixes devices ceos-spine-1 ignore-vrf false ignore-site false

Select devices with a Nornir filter:

nf#netbox sync prefixes FC spine
result = client.run_job(
    "netbox",
    "sync_device_prefixes",
    workers="any",
    kwargs={
        "devices": ["ceos-spine-1", "ceos-spine-2"],
        "dry_run": True,
    },
)
result = client.run_job(
    "netbox",
    "sync_device_prefixes",
    workers="any",
    kwargs={
        "devices": ["ceos-spine-1"],
        "ignore_vrf": False,
        "ignore_site": False,
        "filter_by_prefix": "10.0.0.0/8",
    },
)

NORFAB Netbox Sync Device Prefixes Command Shell Reference¤

nf# man tree netbox.sync.prefixes
root
└── netbox
    └── sync
        └── prefixes
            ├── timeout
            ├── workers
            ├── verbose-result
            ├── progress
            ├── instance
            ├── dry-run
            ├── with-approval
            ├── devices
            ├── ignore-ranges
            ├── ignore-vrf
            ├── ignore-site
            ├── filter-by-name
            ├── filter-by-description
            ├── filter-by-prefix
            ├── branch
            └── Nornir host filters

Python API Reference¤

Synchronize prefixes derived from live interface addresses into NetBox.

Prefixes are collected from the Nornir TTP interfaces getter without requiring corresponding NetBox interfaces. Existing prefixes are matched by prefix alone when ignore_vrf=True and by prefix plus VRF otherwise. Site association is left unchanged by default. When ignore_site=False, the site of the alphabetically first device reporting a prefix is used.

Parameters:

Name Type Description Default
job Job

NorFab Job object containing relevant metadata.

required
instance Union[None, str]

NetBox instance name.

None
dry_run bool

Return the reconciliation plan without writing to NetBox.

False
with_approval bool

Preview changes and ask for review before applying them.

False
timeout int

Timeout in seconds for Nornir parsing and host resolution.

600
devices Union[None, list]

Explicit device names to collect prefixes from.

None
branch str

NetBox Branching plugin branch name.

None
ignore_ranges Union[None, str, list]

Exclude derived prefixes fully contained in these ranges.

None
ignore_vrf bool

Ignore live VRFs and preserve existing prefix VRFs.

True
ignore_site bool

Ignore device sites and preserve existing prefix scopes.

True
filter_by_name Union[None, str]

Interface-name glob filter.

None
filter_by_description Union[None, str]

Interface-description glob filter.

None
filter_by_prefix Union[None, str]

Include prefixes within this network only.

None
**kwargs Any

Nornir host filter arguments.

{}

Returns:

Name Type Description
Result Result

Global created, updated, and in_sync prefix lists. Result.diff contains a global reconciliation plan with create, update, delete, and in_sync actions; delete is always empty because this task does not delete prefixes.

Source code in norfab\workers\netbox_worker\ip_tasks.py
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
@Task(
    fastapi={"methods": ["POST"], "schema": NetboxFastApiArgs.model_json_schema()},
    input=SyncDevicePrefixesInput,
    output=SyncDevicePrefixesResult,
    mcp={
        "annotations": {
            "title": "Sync Device Prefixes",
            "readOnlyHint": False,
            "destructiveHint": True,
            "idempotentHint": True,
            "openWorldHint": True,
        }
    },
)
def sync_device_prefixes(
    self,
    job: Job,
    instance: Union[None, str] = None,
    dry_run: bool = False,
    with_approval: bool = False,
    timeout: int = 600,
    devices: Union[None, list] = None,
    branch: str = None,
    ignore_ranges: Union[None, str, list] = None,
    ignore_vrf: bool = True,
    ignore_site: bool = True,
    filter_by_name: Union[None, str] = None,
    filter_by_description: Union[None, str] = None,
    filter_by_prefix: Union[None, str] = None,
    batch_size: int = 1000,
    **kwargs: Any,
) -> Result:
    """Synchronize prefixes derived from live interface addresses into NetBox.

    Prefixes are collected from the Nornir TTP ``interfaces`` getter without
    requiring corresponding NetBox interfaces. Existing prefixes are matched
    by prefix alone when ``ignore_vrf=True`` and by prefix plus VRF otherwise.
    Site association is left unchanged by default. When ``ignore_site=False``,
    the site of the alphabetically first device reporting a prefix is used.

    Args:
        job: NorFab Job object containing relevant metadata.
        instance: NetBox instance name.
        dry_run: Return the reconciliation plan without writing to NetBox.
        with_approval: Preview changes and ask for review before applying them.
        timeout: Timeout in seconds for Nornir parsing and host resolution.
        devices: Explicit device names to collect prefixes from.
        branch: NetBox Branching plugin branch name.
        ignore_ranges: Exclude derived prefixes fully contained in these ranges.
        ignore_vrf: Ignore live VRFs and preserve existing prefix VRFs.
        ignore_site: Ignore device sites and preserve existing prefix scopes.
        filter_by_name: Interface-name glob filter.
        filter_by_description: Interface-description glob filter.
        filter_by_prefix: Include prefixes within this network only.
        **kwargs: Nornir host filter arguments.

    Returns:
        Result: Global ``created``, ``updated``, and ``in_sync`` prefix lists.
            ``Result.diff`` contains a ``global`` reconciliation plan with
            ``create``, ``update``, ``delete``, and ``in_sync`` actions;
            ``delete`` is always empty because this task does not delete prefixes.
    """
    devices = list(devices or [])
    instance = instance or self.default_instance
    ret = Result(
        task=f"{self.name}:sync_device_prefixes",
        result=SyncActionSummary().model_dump(),
        resources=[instance],
        diff={
            "global": {
                "create": [],
                "update": {},
                "delete": [],
                "in_sync": [],
            }
        },
        dry_run=dry_run,
    )
    nb = self._get_pynetbox(instance, branch=branch, job=job)

    if kwargs:
        job.event("resolving devices from Nornir filters")
        devices.extend(self.get_nornir_hosts(kwargs, timeout))
    devices = sorted(set(devices))
    if not devices:
        msg = "no devices specified"
        job.event(msg, severity="ERROR")
        ret.errors.append(msg)
        ret.failed = True
        return ret

    nb_devices = {
        device.name: device.site.id
        for device in self.bulk_filter(
            nb.dcim.devices,
            name=devices,
            fields="name,site",
        )
    }
    for device_name in [name for name in devices if name not in nb_devices]:
        msg = f"{device_name} - device not found in Netbox"
        log.error(msg)
        job.event(msg, severity="ERROR")
        ret.errors.append(msg)
    devices = [name for name in devices if name in nb_devices]
    if not devices:
        ret.failed = True
        return ret

    normalised_live_all = collect_live_interface_ip_data(
        client=self.client,
        job=job,
        ret=ret,
        devices=devices,
        timeout=timeout,
        filter_by_name=filter_by_name,
        filter_by_description=filter_by_description,
    )

    job.event("collecting live prefix candidates")
    ignore_ranges = ignore_ranges or DEFAULT_IGNORE_RANGES
    if isinstance(ignore_ranges, str):
        ignore_ranges = [ignore_ranges]
    ignore_nets = [
        ipaddress.ip_network(str(prefix), strict=False) for prefix in ignore_ranges
    ]
    filter_prefix_net = (
        ipaddress.ip_network(filter_by_prefix, strict=False)
        if filter_by_prefix
        else None
    )
    desired_prefixes = {}
    for device_name in sorted(normalised_live_all):
        if device_name not in nb_devices:
            continue
        site_id = nb_devices[device_name]
        for interface_data in normalised_live_all[device_name].values():
            vrf_name = interface_data["vrf"]
            if ignore_vrf:
                vrf_name = None
            elif vrf_name and vrf_name.lower() in ["global", "default"]:
                vrf_name = None
            for address_data in (interface_data.get("ipv4_addresses") or []) + (
                interface_data.get("ipv6_addresses") or []
            ):
                address = address_data["ip"]
                prefix_net = ipaddress.ip_interface(str(address)).network
                if any(
                    prefix_net.version == ignore_net.version
                    and prefix_net.subnet_of(ignore_net)
                    for ignore_net in ignore_nets
                ):
                    continue
                if filter_prefix_net and (
                    prefix_net.version != filter_prefix_net.version
                    or not prefix_net.subnet_of(filter_prefix_net)
                ):
                    continue
                prefix = str(prefix_net)
                key = prefix if ignore_vrf else (prefix, vrf_name)
                desired_prefixes.setdefault(
                    key,
                    {"prefix": prefix, "vrf": vrf_name, "site": site_id},
                )

    if not desired_prefixes:
        job.event("no prefixes found in live data")
        return ret
    job.event(f"collected {len(desired_prefixes)} unique prefix candidate(s)")

    nb_prefixes = self.bulk_filter(
        nb.ipam.prefixes,
        prefix=sorted({item["prefix"] for item in desired_prefixes.values()}),
        fields="id,prefix,vrf,scope_type,scope_id",
    )
    create_prefixes = {}
    update_prefixes = {}
    update_prefixes_diff = {}
    for key, desired in desired_prefixes.items():
        matching = [
            prefix
            for prefix in nb_prefixes
            if prefix.prefix == desired["prefix"]
            and (
                ignore_vrf
                or (prefix.vrf.name if prefix.vrf else None) == desired["vrf"]
            )
        ]
        if not matching:
            payload = {"prefix": desired["prefix"]}
            if not ignore_site:
                payload.update(
                    {"scope_type": "dcim.site", "scope_id": desired["site"]}
                )
            create_prefixes[key] = payload
            continue

        current = min(matching, key=lambda prefix: prefix.id)
        current_site = (
            getattr(current, "scope_id", None)
            if getattr(current, "scope_type", None) == "dcim.site"
            else None
        )
        if not ignore_site and current_site != desired["site"]:
            update_prefixes[key] = {
                "id": current.id,
                "scope_type": "dcim.site",
                "scope_id": desired["site"],
            }
            update_prefixes_diff[key] = {
                "site": {
                    "old_value": current_site,
                    "new_value": desired["site"],
                }
            }
        else:
            ret.result["in_sync"].append(desired["prefix"])

    ret.diff = {
        "global": {
            "create": sorted(
                {desired_prefixes[key]["prefix"] for key in create_prefixes}
            ),
            "update": {
                desired_prefixes[key]["prefix"]: update_prefixes_diff[key]
                for key in sorted(
                    update_prefixes_diff,
                    key=lambda item: desired_prefixes[item]["prefix"],
                )
            },
            "delete": [],
            "in_sync": sorted(set(ret.result["in_sync"])),
        }
    }

    preview = copy.deepcopy(ret.result)
    preview["created"].extend(
        desired_prefixes[key]["prefix"] for key in create_prefixes
    )
    preview["updated"].extend(
        desired_prefixes[key]["prefix"] for key in update_prefixes
    )
    for action in preview:
        preview[action] = sorted(set(preview[action]))
    if dry_run:
        ret.result = preview
        ret.dry_run = True
        return ret
    if not sync_diff_has_changes(ret.diff):
        job.event("no prefix sync changes required")
        return ret
    if with_approval:
        if not review_sync_task_result(job, "prefix sync", preview):
            ret.status = "skipped"
            ret.result = preview
            ret.dry_run = True
            ret.messages.append("review declined; changes were not applied")
            return ret
    vrf_ids = {}
    for key in list(create_prefixes):
        vrf_name = desired_prefixes[key]["vrf"]
        if vrf_name is None:
            continue
        if vrf_name not in vrf_ids:
            vrf_ids[vrf_name] = resolve_vrf(vrf_name, nb, job, ret, self.name)
        if vrf_ids[vrf_name] is None:
            create_prefixes.pop(key)
            continue
        create_prefixes[key]["vrf"] = vrf_ids[vrf_name]

    if update_prefixes:
        update_items = list(update_prefixes.items())
        total_batches = (len(update_items) + batch_size - 1) // batch_size
        for batch_start in range(0, len(update_items), batch_size):
            batch = update_items[batch_start : batch_start + batch_size]
            batch_number = batch_start // batch_size + 1
            msg = f"updating prefix batch {batch_number}/{total_batches} ({len(batch)} prefix(es))"
            job.event(msg)
            log.info(msg)
            try:
                nb.ipam.prefixes.update([payload for _, payload in batch])
            except Exception as exc:
                msg = f"failed to update prefix batch {batch_number}/{total_batches}: {exc}"
                ret.errors.append(msg)
                ret.failed = True
                log.error(msg)
                job.event(msg, severity="ERROR")
                return ret
            ret.result["updated"].extend(
                desired_prefixes[key]["prefix"] for key, _ in batch
            )
        job.event(f"updated {len(update_prefixes)} prefix(es)")
    if create_prefixes:
        create_items = list(create_prefixes.items())
        total_batches = (len(create_items) + batch_size - 1) // batch_size
        for batch_start in range(0, len(create_items), batch_size):
            batch = create_items[batch_start : batch_start + batch_size]
            batch_number = batch_start // batch_size + 1
            msg = f"creating prefix batch {batch_number}/{total_batches} ({len(batch)} prefix(es))"
            job.event(msg)
            log.info(msg)
            try:
                nb.ipam.prefixes.create([payload for _, payload in batch])
            except Exception as exc:
                msg = f"failed to create prefix batch {batch_number}/{total_batches}: {exc}"
                ret.errors.append(msg)
                ret.failed = True
                log.error(msg)
                job.event(msg, severity="ERROR")
                return ret
            ret.result["created"].extend(
                desired_prefixes[key]["prefix"] for key, _ in batch
            )
        job.event(f"created {len(create_prefixes)} prefix(es)")

    for action in ret.result:
        ret.result[action] = sorted(set(ret.result[action]))

    job.event("device prefix sync complete")
    return ret