Skip to content

NetBox Sync BGP Communities Task¤

task API name: sync_bgp_community

The NetBox Sync BGP Communities task collects named BGP communities from live devices and reconciles them with NetBox. Route-target (rt) communities are stored as IPAM route targets. Other community types are stored as NetBox BGP plugin community objects when the plugin is installed.

Objects are matched by community value. The task creates missing objects and extends their optional community-name custom field with missing live names, but it never deletes NetBox objects.

How It Works¤

  1. Resolve explicit device names and optional Nornir host filters.
  2. Confirm the selected devices exist in NetBox.
  3. Run Nornir parse_ttp with the TTP Templates bgp_communities getter.
  4. Group records by community value and aggregate the sorted, unique live community-set names.
  5. Compare live values with NetBox route targets and BGP plugin communities.
  6. Return the diff or apply bulk create and update operations.

If several devices use different names for the same value, the configured custom field stores the names as a comma-separated string. For example, 65000:100 named CUSTOMER_EXPORT on one device and BLUE_EXPORT on another is stored as:

BLUE_EXPORT, CUSTOMER_EXPORT

Existing custom-field values are preserved. New live names are appended only when they are missing.

Inputs¤

Parameter Required Default Description
devices Conditional None NetBox device names from which to collect communities
Nornir filters Conditional None Host filters such as FB, FC, FG, FL, or FR; may be combined with devices
instance No Worker default NetBox instance to target
branch No None NetBox Branching plugin branch to use
dry_run No False Return the calculated diff without writing to NetBox
with_approval No False Preview the diff and request approval before writing
timeout No 600 Timeout in seconds for host resolution and live parsing
community_name_field No community_name Custom field used to store live community-set names; set to False to disable name synchronization

At least one explicit device or Nornir host filter must select a device. Device names from both sources are combined and deduplicated.

Prerequisites¤

  • Selected devices must exist in NetBox.
  • The Nornir worker must support the TTP Templates bgp_communities getter for the device platform.
  • The NetBox BGP plugin is required to store non-route-target communities.
  • To synchronize community-set names, the configured custom field must exist and be assigned to ipam.routetarget, netbox_bgp.community, or both.

The custom field is optional. If it does not exist, the task emits a warning and continues creating community objects without updating community names. If the BGP plugin is unavailable, the task emits a warning and synchronizes route targets only.

Live Data¤

The getter returns one record for each concrete community value:

- value: 65000:100
  type: standard
  name: CUSTOMER_EXPORT
- value: 65000:200
  type: rt
  name: TENANT_BLUE

Records must contain non-empty string values for value, type, and name. Malformed worker, device, or record data is reported in the task errors.

Execution Modes¤

Dry run — dry_run=True returns the prepared diff and performs no NetBox writes.

With approval — with_approval=True presents the prepared diff for review before applying it. A declined review returns the diff with status="skipped" and dry_run=True. When both options are enabled, dry-run behavior takes precedence and no approval prompt is shown.

Live run — The default mode creates and updates NetBox objects. The prepared plan remains available in the top-level diff field.

Output¤

Results are separated into route_targets and communities scopes. The communities scope is omitted when the NetBox BGP plugin is unavailable.

Dry-run output uses create, update, delete, and in_sync actions:

{
  "route_targets": {
    "create": ["65000:200"],
    "update": {},
    "delete": [],
    "in_sync": []
  },
  "communities": {
    "create": ["65000:100"],
    "update": {},
    "delete": [],
    "in_sync": []
  }
}

Live-run output uses created, updated, deleted, and in_sync:

{
  "route_targets": {
    "created": ["65000:200"],
    "updated": [],
    "deleted": [],
    "in_sync": []
  },
  "communities": {
    "created": ["65000:100"],
    "updated": [],
    "deleted": [],
    "in_sync": []
  }
}

Deletion lists are always empty because community objects may be shared beyond the selected device scope.

Branching Support¤

Pass branch=<name> to read and write objects through a NetBox Branching plugin branch instead of the main database. The task creates the branch if it does not exist and waits for it to become ready.

Examples¤

Preview communities collected from explicit devices:

nf# netbox sync bgp-communities devices fn-ceos-lf-1 fn-ceos-lf-2 dry-run

Select devices with a Nornir filter and use a different custom field:

nf# netbox sync bgp-communities FC leaf community-name-field community_aliases

Create objects without synchronizing community-set names:

nf# netbox sync bgp-communities devices edge-1 community-name-field false

Preview and request approval before applying changes:

nf# netbox sync bgp-communities devices edge-1 with-approval

Synchronize into a NetBox branch:

nf# netbox sync bgp-communities devices edge-1 branch community-review
from norfab.core.nfapi import NorFab

with NorFab(inventory="./inventory.yaml") as nf:
    client = nf.make_client()
    result = client.run_job(
        "netbox",
        "sync_bgp_community",
        workers="any",
        kwargs={
            "devices": ["edge-1", "edge-2"],
            "dry_run": True,
            "community_name_field": "community_aliases",
        },
    )
    print(result)

The same task can use Nornir filters without an explicit device list:

from norfab.core.nfapi import NorFab

nf = NorFab(inventory="./inventory.yaml")
nf.start()
try:
    client = nf.make_client()
    result = client.run_job(
        "netbox",
        "sync_bgp_community",
        workers="any",
        kwargs={"FR": "edge-[12]", "community_name_field": False},
    )
    print(result)
finally:
    nf.destroy()

Notes and Gotchas¤

  • The NetBox BGP plugin must accept each non-route-target value returned by the getter. Unsupported values produce NetBox API validation errors.
  • The selected devices define the complete live alias set written to the custom field. Use the full intended device scope for globally shared values.
  • NetBox-only objects are retained because a scoped live query cannot prove that a globally shared community is stale.
  • with_approval cannot be combined with the NFCLI nowait option.

Troubleshooting¤

If a device has no live result, confirm its platform supports the bgp_communities getter and that the Nornir worker can reach it. Also confirm that every explicitly selected device exists in NetBox.

For custom-field warnings, confirm the field name and its object assignments. Use a long-text field so it can hold multiple comma-separated community names.

NORFAB NetBox Sync BGP Communities Command Shell Reference¤

nf# man tree netbox.sync.bgp-communities
root
└── netbox:    NetBox service
    └── sync:    Synchronize data with NetBox
        └── bgp-communities:    Sync live BGP communities with NetBox
            ├── timeout:    Job timeout, default 600
            ├── workers:    Filter worker to target, default 'any'
            ├── verbose-result:    Control output details, default 'False'
            ├── nowait:    Do not wait for job completion, default 'False'
            ├── instance:    NetBox instance name to target
            ├── branch:    NetBox Branching plugin branch name to use
            ├── dry-run:    Return the diff without writing to NetBox
            ├── with-approval:    Preview changes and ask for approval
            ├── devices:    NetBox devices from which to collect communities
            ├── community-name-field:    Custom field for live community-set names
            ├── FO:    Filter hosts using a Filter Object
            ├── FB:    Filter hosts by name using glob patterns
            ├── FH:    Filter hosts by hostname
            ├── FC:    Filter hosts by name containment
            ├── FR:    Filter hosts by name using regular expressions
            ├── FG:    Filter hosts by group
            ├── FP:    Filter hosts by hostname using an IP prefix
            ├── FL:    Filter hosts by name list
            ├── FM:    Filter hosts by platform
            ├── FX:    Exclude hosts by name
            └── FN:    Negate the host filter match
nf#

Python API Reference¤

Synchronize live BGP communities with NetBox.

Route-target communities are stored as IPAM route targets. Every other community type is stored as a NetBox BGP plugin community. Objects are keyed by community value and are never deleted. When the configured custom field exists, it stores the sorted, comma-separated live community-set names observed for each value.

Parameters:

Name Type Description Default
job Job

NorFab job object.

required
instance Union[None, str]

NetBox instance name. Uses the default instance when omitted.

None
dry_run bool

Return the calculated diff without writing to NetBox.

False
with_approval bool

Ask for approval before applying the prepared diff.

False
timeout int

Timeout in seconds for Nornir host resolution and parsing.

600
devices Union[None, list]

Explicit NetBox and Nornir device names.

None
branch Union[None, str]

NetBox Branching plugin branch name.

None
community_name_field Union[str, bool]

Optional custom field for community-set names.

'community_name'
**kwargs Any

Nornir FFun host filters.

{}

Returns:

Name Type Description
Result Result

Route-target and BGP community synchronization actions.

Source code in norfab\workers\netbox_worker\bgp_community_tasks.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
@Task(
    fastapi={"methods": ["POST"], "schema": NetboxFastApiArgs.model_json_schema()},
    input=SyncBgpCommunityInput,
    output=SyncBgpCommunityResult,
    mcp={
        "annotations": {
            "title": "Sync BGP Communities",
            "readOnlyHint": False,
            "destructiveHint": True,
            "idempotentHint": True,
            "openWorldHint": True,
        }
    },
)
def sync_bgp_community(
    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: Union[None, str] = None,
    community_name_field: Union[str, bool] = "community_name",
    **kwargs: Any,
) -> Result:
    """Synchronize live BGP communities with NetBox.

    Route-target communities are stored as IPAM route targets. Every other
    community type is stored as a NetBox BGP plugin community. Objects are
    keyed by community value and are never deleted. When the configured
    custom field exists, it stores the sorted, comma-separated live
    community-set names observed for each value.

    Args:
        job: NorFab job object.
        instance: NetBox instance name. Uses the default instance when omitted.
        dry_run: Return the calculated diff without writing to NetBox.
        with_approval: Ask for approval before applying the prepared diff.
        timeout: Timeout in seconds for Nornir host resolution and parsing.
        devices: Explicit NetBox and Nornir device names.
        branch: NetBox Branching plugin branch name.
        community_name_field: Optional custom field for community-set names.
        **kwargs: Nornir FFun host filters.

    Returns:
        Result: Route-target and BGP community synchronization actions.
    """
    devices = list(devices or [])
    instance = instance or self.default_instance
    ret = Result(
        task=f"{self.name}:sync_bgp_community",
        result={},
        resources=[instance],
        dry_run=dry_run,
        diff={},
    )

    msg = (
        f"starting BGP community sync using NetBox instance '{instance}' for "
        f"{len(devices)} explicit device(s), dry_run={dry_run}"
    )
    job.event(msg)
    log.info(f"{self.name} - {msg}")

    msg = f"validating NetBox BGP plugin for '{instance}'"
    job.event(msg)
    log.info(f"{self.name} - {msg}")
    has_bgp_plugin = self.has_plugin("netbox_bgp", instance)
    if not has_bgp_plugin:
        msg = (
            f"netbox instance '{instance}' has no BGP plugin installed; "
            "syncing route targets only"
        )
        job.event(msg, severity="WARNING")
        log.warning(f"{self.name} - {msg}")

    nb = self._get_pynetbox(instance, branch=branch, job=job)

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

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

    if community_name_field and not nb.extras.custom_fields.get(
        name=community_name_field
    ):
        msg = (
            f"custom field '{community_name_field}' not found in NetBox; "
            "community name synchronization disabled"
        )
        job.event(msg, severity="WARNING")
        log.warning(f"{self.name} - {msg}")
        community_name_field = False

    msg = f"collecting live BGP communities from {len(devices)} device(s)"
    job.event(msg)
    log.info(f"{self.name} - {msg}")
    parse_data = self.client.run_job(
        "nornir",
        "parse_ttp",
        kwargs={"get": "bgp_communities", "FL": devices},
        workers="all",
        timeout=timeout,
    )

    # Build one live state dictionary for DeepDiff. Route targets always
    # participate; plugin communities are included only when available.
    normalised_live = {"route_targets": {}}
    if has_bgp_plugin:
        normalised_live["communities"] = {}
    result_devices = set()
    failed_devices = set()
    parsed_count = 0
    for worker_name, worker_data in parse_data.items():
        if worker_data.get("failed"):
            msg = f"worker '{worker_name}' failed to collect BGP communities"
            job.event(msg, severity="ERROR")
            log.error(f"{self.name} - {msg}")
            ret.errors.append(msg)
            continue
        resources_failed = worker_data.get("resources_failed") or []
        if resources_failed:
            failed_devices.update(resources_failed)
            msg = (
                f"{worker_name} failed to fetch BGP community data from devices "
                f"{', '.join(sorted(resources_failed))}"
            )
            job.event(msg, severity="ERROR")
            log.error(f"{self.name} - {msg}")
            ret.errors.append(msg)

        worker_result = worker_data.get("result")
        if not isinstance(worker_result, dict):
            msg = f"worker '{worker_name}' returned malformed community data"
            job.event(msg, severity="ERROR")
            log.error(f"{self.name} - {msg}")
            ret.errors.append(msg)
            continue
        for device_name, records in worker_result.items():
            if device_name not in netbox_devices:
                continue
            result_devices.add(device_name)
            if not isinstance(records, list):
                msg = f"device '{device_name}' community result is not a list"
                job.event(msg, severity="ERROR")
                log.error(f"{self.name} - {msg}")
                ret.errors.append(msg)
                continue
            for record in records:
                if not isinstance(record, dict) or not all(
                    isinstance(record.get(field), str) and record[field].strip()
                    for field in ("value", "type", "name")
                ):
                    msg = f"device '{device_name}' returned malformed community record"
                    job.event(msg, severity="ERROR")
                    log.error(f"{self.name} - {msg}")
                    ret.errors.append(msg)
                    continue
                value = record["value"].strip()
                if record["type"].strip().lower() == "rt":
                    scope = "route_targets"
                elif has_bgp_plugin:
                    scope = "communities"
                else:
                    continue
                normalised_live[scope].setdefault(value, set()).add(
                    record["name"].strip()
                )
                parsed_count += 1

    for device_name in devices:
        if device_name not in result_devices and device_name not in failed_devices:
            msg = f"device '{device_name}' is missing a live community result"
            job.event(msg, severity="ERROR")
            log.error(f"{self.name} - {msg}")
            ret.errors.append(msg)
    if not result_devices:
        ret.failed = True
        return ret
    msg = (
        f"parsed {parsed_count} live community record(s) from "
        f"{len(result_devices)} device(s)"
    )
    job.event(msg)
    log.info(f"{self.name} - {msg}")

    for scope in normalised_live:
        for sname, names in sorted(normalised_live[scope].items()):
            normalised_live[scope][sname] = (
                {community_name_field: ", ".join(sorted(names))}
                if community_name_field
                else {}
            )

    # Fetch matching NetBox objects directly, keyed the same way as live
    # data so DeepDiff sees only create, update, and in-sync candidates.
    normalised_nb = {"route_targets": {}}
    nb_community_objects = {"route_targets": {}}

    route_target_snames = list(normalised_live["route_targets"])
    nb_communities_result = (
        self.bulk_filter(
            nb.ipam.route_targets,
            name=route_target_snames,
            fields="id,name,custom_fields",
        )
        if route_target_snames
        else []
    )
    for nb_community in nb_communities_result:
        sname = str(nb_community.name)
        normalised_nb["route_targets"][sname] = {}
        if community_name_field:
            custom_fields = nb_community.custom_fields or {}
            current_value = str(custom_fields.get(community_name_field, ""))
            normalised_nb["route_targets"][sname] = {
                community_name_field: current_value
            }
            normalised_live["route_targets"][sname] = (
                normalise_community_name_field(
                    current_value,
                    community_name_field,
                    normalised_live["route_targets"][sname].get(
                        community_name_field, ""
                    ),
                )
            )
        nb_community_objects["route_targets"][sname] = nb_community

    if has_bgp_plugin:
        normalised_nb["communities"] = {}
        nb_community_objects["communities"] = {}
        community_snames = list(normalised_live["communities"])
        nb_communities_result = (
            self.bulk_filter(
                nb.plugins.bgp.community,
                value=community_snames,
                fields="id,value,custom_fields",
            )
            if community_snames
            else []
        )
        for nb_community in nb_communities_result:
            sname = str(nb_community.value)
            normalised_nb["communities"][sname] = {}
            if community_name_field:
                custom_fields = nb_community.custom_fields or {}
                current_value = str(custom_fields.get(community_name_field, ""))
                normalised_nb["communities"][sname] = {
                    community_name_field: current_value
                }
                normalised_live["communities"][sname] = (
                    normalise_community_name_field(
                        current_value,
                        community_name_field,
                        normalised_live["communities"][sname].get(
                            community_name_field, ""
                        ),
                    )
                )
            nb_community_objects["communities"][sname] = nb_community

    communities_diff = self.make_diff(normalised_live, normalised_nb)
    full_diff = {}
    for scope, actions in sorted(communities_diff.items()):
        actions["delete"] = []
        full_diff[scope] = {
            "create": sorted(actions["create"]),
            "update": {
                sname: actions["update"][sname]
                for sname in sorted(actions["update"])
            },
            "delete": [],
            "in_sync": sorted(actions["in_sync"]),
        }

    create_count = sum(len(actions["create"]) for actions in full_diff.values())
    update_count = sum(len(actions["update"]) for actions in full_diff.values())
    in_sync_count = sum(len(actions["in_sync"]) for actions in full_diff.values())
    msg = (
        "bgp community sync diff complete: "
        f"{create_count} create, {update_count} update, "
        f"{in_sync_count} in sync"
    )
    job.event(msg)
    log.info(f"{self.name} - {msg}")

    if dry_run:
        ret.result = full_diff
        ret.dry_run = True
        return ret
    if with_approval and not review_sync_task_result(
        job, "BGP community sync", full_diff
    ):
        ret.status = "skipped"
        ret.result = full_diff
        ret.dry_run = True
        ret.messages.append("review declined; changes were not applied")
        return ret

    # Apply route targets and plugin communities separately to keep the
    # NetBox API writes explicit.
    ret.diff = full_diff
    ret.result = {
        scope: {
            "created": [],
            "updated": [],
            "deleted": [],
            "in_sync": actions["in_sync"],
        }
        for scope, actions in full_diff.items()
    }
    create_snames = full_diff["route_targets"]["create"]
    create_payloads = []
    for sname in create_snames:
        payload = {"name": sname}
        if community_name_field:
            payload["custom_fields"] = {
                community_name_field: normalised_live["route_targets"][sname][
                    community_name_field
                ]
            }
        create_payloads.append(payload)
    if create_payloads:
        msg = f"creating {len(create_payloads)} route target(s) in NetBox"
        job.event(msg)
        log.info(f"{self.name} - {msg}")
        nb.ipam.route_targets.create(create_payloads)
        ret.result["route_targets"]["created"].extend(create_snames)

    update_snames = list(full_diff["route_targets"]["update"])
    update_payloads = [
        {
            "id": nb_community_objects["route_targets"][sname].id,
            "custom_fields": {
                community_name_field: normalised_live["route_targets"][sname][
                    community_name_field
                ]
            },
        }
        for sname in update_snames
        if community_name_field
    ]
    if update_payloads:
        msg = f"updating {len(update_payloads)} route target(s) in NetBox"
        job.event(msg)
        log.info(f"{self.name} - {msg}")
        nb.ipam.route_targets.update(update_payloads)
        ret.result["route_targets"]["updated"].extend(update_snames)

    if has_bgp_plugin:
        create_snames = full_diff["communities"]["create"]
        create_payloads = []
        for sname in create_snames:
            payload = {"value": sname}
            if community_name_field:
                payload["custom_fields"] = {
                    community_name_field: normalised_live["communities"][sname][
                        community_name_field
                    ]
                }
            create_payloads.append(payload)
        if create_payloads:
            msg = (
                f"creating {len(create_payloads)} BGP community object(s) in NetBox"
            )
            job.event(msg)
            log.info(f"{self.name} - {msg}")
            nb.plugins.bgp.community.create(create_payloads)
            ret.result["communities"]["created"].extend(create_snames)

        update_snames = list(full_diff["communities"]["update"])
        update_payloads = [
            {
                "id": nb_community_objects["communities"][sname].id,
                "custom_fields": {
                    community_name_field: normalised_live["communities"][sname][
                        community_name_field
                    ]
                },
            }
            for sname in update_snames
            if community_name_field
        ]
        if update_payloads:
            msg = (
                f"updating {len(update_payloads)} BGP community object(s) in NetBox"
            )
            job.event(msg)
            log.info(f"{self.name} - {msg}")
            nb.plugins.bgp.community.update(update_payloads)
            ret.result["communities"]["updated"].extend(update_snames)

    msg = (
        f"bgp community sync complete: {create_count} created, "
        f"{update_count} updated, {in_sync_count} in sync"
    )
    job.event(msg)
    log.info(f"{self.name} - {msg}")
    return ret