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.

Route targets are matched by community value. BGP plugin communities are matched by value and live community-set name so NetBox can hold several standard communities with the same value. The task creates missing objects and extends their optional community-name custom field with missing live names. An optional multi-object custom field associates each community with the devices on which it was observed. The task never deletes NetBox objects or associations.

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 route targets by community value and BGP plugin communities by community value plus live community-set name.
  5. Compare live route targets and BGP plugin communities with matching NetBox objects.
  6. Return the diff or apply bulk create and update operations.

For BGP plugin communities, existing NetBox objects are matched in this order: same value with the live name contained in the configured community-name custom field, same value with a description matching the live name, then an unlabeled same-value object. If the community-name custom field is disabled or missing, the task starts with the description match.

If several route-target records use different names for the same value, the configured custom field stores the names as a comma-separated string. For example, 65000:200 named TENANT_BLUE on one device and VPN_BLUE on another is stored as:

TENANT_BLUE, VPN_BLUE

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
device_custom_field No devices Multi-object custom field related to dcim.device that stores associated devices

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.
  • To associate devices, device_custom_field must name a multi-object custom field assigned to the community models and related to dcim.device.

The community-name custom field is optional. If it does not exist, the task emits a warning and continues creating community objects without updating community names. A missing device custom field is ignored. 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::BLUE_EXPORT"],
    "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::BLUE_EXPORT"],
    "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.

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¤

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. Route targets are keyed by community value. BGP plugin communities are keyed by community value and live community-set name, allowing NetBox to hold several standard communities with the same value. Objects are never deleted. When the configured custom field exists, it stores the sorted, comma-separated live community-set names observed for each object. The selected device custom field records devices on which each community was observed.

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'
device_custom_field str

Community custom field containing associated devices.

'devices'
**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
 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
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
@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",
    device_custom_field: str = "devices",
    batch_size: int = 1000,
    **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. Route
    targets are keyed by community value. BGP plugin communities are keyed
    by community value and live community-set name, allowing NetBox to hold
    several standard communities with the same value. Objects are never
    deleted. When the configured custom field exists, it stores the sorted,
    comma-separated live community-set names observed for each object. The
    selected device custom field records devices on which each community was
    observed.

    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.
        device_custom_field: Community custom field containing associated devices.
        **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=SyncActionSummaryMap().model_dump(),
        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: device
        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

    if not nb.extras.custom_fields.get(name=device_custom_field):
        device_custom_field = None

    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,
    )

    # Collect live records once. Route targets are always reconciled; other
    # community types wait for the BGP plugin-specific flow below.
    route_target_observations = {}
    bgp_community_observations = []
    result_devices = set()
    failed_devices = set()
    parsed_count = 0
    for worker_name, worker_data in parse_data.items():
        resources_failed = worker_data.get("resources_failed") or []
        if resources_failed:
            failed_devices.update(resources_failed)
            ret.resources_failed = sorted(
                set(ret.resources_failed) | set(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)
        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
        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")
                ):
                    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()
                # netbox bgp plugin does not accept communites with no ':' in the value
                if ":" not in value:
                    continue
                name = (
                    record.get("name", "").strip()
                    if isinstance(record.get("name", ""), str)
                    else ""
                )
                if record["type"].strip().lower() == "rt":
                    route_target_observations.setdefault(value, []).append(
                        {"device": device_name, "name": name}
                    )
                    parsed_count += 1
                else:
                    bgp_community_observations.append(
                        {"device": device_name, "value": value, "name": name}
                    )
                    if has_bgp_plugin:
                        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}")

    # Route targets are unique by NetBox IPAM name, so value-only matching
    # is safe here and remains separate from plugin community matching.
    msg = "matching live route targets with NetBox"
    job.event(msg)
    log.info(f"{self.name} - Matching live route targets with NetBox")
    normalised_live = {"route_targets": {}}
    normalised_nb = {"route_targets": {}}
    nb_community_objects = {"route_targets": {}}
    community_key_data = {}

    for sname, records in sorted(route_target_observations.items()):
        live_names = sorted(
            {record["name"] for record in records if record["name"]}
        )
        normalised_live["route_targets"][sname] = (
            {community_name_field: ", ".join(live_names)}
            if community_name_field and live_names
            else {}
        )
        if device_custom_field:
            normalised_live["route_targets"][sname][device_custom_field] = sorted(
                {netbox_devices[record["device"]].id for record in records}
            )

    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) or "")
            normalised_nb["route_targets"][sname] = {
                community_name_field: current_value
            }
            normalised_live["route_targets"][sname].update(
                normalise_community_name_field(
                    current_value,
                    community_name_field,
                    normalised_live["route_targets"][sname].get(
                        community_name_field, ""
                    ),
                )
            )
        if device_custom_field:
            custom_fields = nb_community.custom_fields or {}
            current_devices = [
                device["id"]
                for device in (custom_fields.get(device_custom_field) or [])
            ]
            normalised_nb["route_targets"][sname][
                device_custom_field
            ] = current_devices
            normalised_live["route_targets"][sname][device_custom_field] = sorted(
                set(current_devices)
                | set(normalised_live["route_targets"][sname][device_custom_field])
            )
        nb_community_objects["route_targets"][sname] = nb_community

    if has_bgp_plugin:
        # Standard BGP communities may share the same value, so keep the
        # live name in the sync key and match NetBox from most-specific to
        # least-specific identity.
        msg = "matching live BGP plugin communities with NetBox"
        job.event(msg)
        log.info(f"{self.name} - Matching live BGP plugin communities with NetBox")
        normalised_live["communities"] = {}
        normalised_nb["communities"] = {}
        nb_community_objects["communities"] = {}

        for record in bgp_community_observations:
            sname = f"{record['value']}::{record['name'] or NO_COMMUNITY_NAME}"
            community_key_data[sname] = {
                "value": record["value"],
                "name": record["name"],
            }
            normalised_live["communities"].setdefault(sname, {})
            if community_name_field and record["name"]:
                normalised_live["communities"][sname][community_name_field] = (
                    record["name"]
                )
            if device_custom_field:
                device_ids = normalised_live["communities"][sname].setdefault(
                    device_custom_field, []
                )
                device_id = netbox_devices[record["device"]].id
                if device_id not in device_ids:
                    device_ids.append(device_id)

        for sname in normalised_live["communities"]:
            if device_custom_field:
                normalised_live["communities"][sname][device_custom_field] = sorted(
                    normalised_live["communities"][sname][device_custom_field]
                )

        community_values = sorted(
            {data["value"] for data in community_key_data.values()}
        )
        nb_communities_result = (
            self.bulk_filter(
                nb.plugins.bgp.community,
                value=community_values,
                fields="id,value,description,custom_fields",
            )
            if community_values
            else []
        )
        nb_communities_by_value = {}
        for nb_community in nb_communities_result:
            nb_communities_by_value.setdefault(str(nb_community.value), []).append(
                nb_community
            )

        for sname, data in sorted(community_key_data.items()):
            nb_community = None
            candidates = nb_communities_by_value.get(data["value"], [])
            name = data["name"]
            # Prefer explicit community-name custom-field containment,
            # then description, then an unlabeled same-value object.
            if community_name_field and name:
                for candidate in candidates:
                    custom_fields = candidate.custom_fields or {}
                    current_value = str(
                        custom_fields.get(community_name_field) or ""
                    )
                    current_names = {
                        current_name.strip()
                        for current_name in current_value.split(",")
                        if current_name.strip()
                    }
                    if name in current_names:
                        nb_community = candidate
                        break
            if nb_community is None and name:
                for candidate in candidates:
                    current_description = str(
                        getattr(candidate, "description", "") or ""
                    ).strip()
                    if current_description == name:
                        nb_community = candidate
                        break
            if nb_community is None and not name and candidates:
                nb_community = candidates[0]
            if nb_community is None and name:
                for candidate in candidates:
                    custom_fields = candidate.custom_fields or {}
                    current_names = (
                        {
                            current_name.strip()
                            for current_name in str(
                                custom_fields.get(community_name_field) or ""
                            ).split(",")
                            if current_name.strip()
                        }
                        if community_name_field
                        else set()
                    )
                    current_description = str(
                        getattr(candidate, "description", "") or ""
                    ).strip()
                    if not current_names and not current_description:
                        nb_community = candidate
                        break
            if nb_community is None:
                continue
            normalised_nb["communities"][sname] = {}
            if community_name_field:
                custom_fields = nb_community.custom_fields or {}
                current_value = str(
                    custom_fields.get(community_name_field) or ""
                )
                normalised_nb["communities"][sname] = {
                    community_name_field: current_value
                }
                normalised_live["communities"][sname].update(
                    normalise_community_name_field(
                        current_value,
                        community_name_field,
                        normalised_live["communities"][sname].get(
                            community_name_field, ""
                        ),
                    )
                )
            if device_custom_field:
                custom_fields = nb_community.custom_fields or {}
                current_devices = [
                    device["id"]
                    for device in (custom_fields.get(device_custom_field) or [])
                ]
                normalised_nb["communities"][sname][
                    device_custom_field
                ] = current_devices
                normalised_live["communities"][sname][device_custom_field] = sorted(
                    set(current_devices)
                    | set(
                        normalised_live["communities"][sname][device_custom_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}")

    ret.diff = full_diff
    ret.result = {
        scope: SyncActionSummary(in_sync=actions["in_sync"]).model_dump()
        for scope, actions in full_diff.items()
    }
    if dry_run:
        ret.result = full_diff
        ret.dry_run = True
        return ret
    if not sync_diff_has_changes(full_diff):
        job.event("no BGP community sync changes required")
        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.
    create_snames = full_diff["route_targets"]["create"]
    create_items = []
    for sname in create_snames:
        payload = {"name": sname}
        if normalised_live["route_targets"][sname]:
            payload["custom_fields"] = normalised_live["route_targets"][sname]
        create_items.append((sname, payload))
    if create_items:
        msg = f"creating {len(create_items)} route target(s) in NetBox"
        job.event(msg)
        log.info(f"{self.name} - {msg}")
        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 route target batch {batch_number}/{total_batches} ({len(batch)} target(s))"
            job.event(msg)
            log.info(msg)
            try:
                nb.ipam.route_targets.create([payload for _, payload in batch])
            except Exception as exc:
                msg = f"failed to create route target batch {batch_number}/{total_batches}: {exc}"
                job.event(msg, severity="ERROR")
                log.error(msg)
                ret.errors.append(msg)
                ret.failed = True
                return ret
            ret.result["route_targets"]["created"].extend(name for name, _ in batch)

    update_items = [
        (
            sname,
            {
                "id": nb_community_objects["route_targets"][sname].id,
                "custom_fields": normalised_live["route_targets"][sname],
            },
        )
        for sname in full_diff["route_targets"]["update"]
    ]
    if update_items:
        msg = f"updating {len(update_items)} route target(s) in NetBox"
        job.event(msg)
        log.info(f"{self.name} - {msg}")
        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 route target batch {batch_number}/{total_batches} ({len(batch)} target(s))"
            job.event(msg)
            log.info(msg)
            try:
                nb.ipam.route_targets.update([payload for _, payload in batch])
            except Exception as exc:
                msg = f"failed to update route target batch {batch_number}/{total_batches}: {exc}"
                job.event(msg, severity="ERROR")
                log.error(msg)
                ret.errors.append(msg)
                ret.failed = True
                return ret
            ret.result["route_targets"]["updated"].extend(name for name, _ in batch)

    if has_bgp_plugin:
        create_snames = full_diff["communities"]["create"]
        create_items = []
        for sname in create_snames:
            community_data = community_key_data[sname]
            payload = {"value": community_data["value"]}
            if community_data["name"]:
                payload["description"] = community_data["name"]
            if normalised_live["communities"][sname]:
                payload["custom_fields"] = normalised_live["communities"][sname]
            create_items.append((sname, payload))
        if create_items:
            msg = f"creating {len(create_items)} BGP community object(s) in NetBox"
            job.event(msg)
            log.info(f"{self.name} - {msg}")
            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 BGP community batch {batch_number}/{total_batches} ({len(batch)} community object(s))"
                job.event(msg)
                log.info(msg)
                try:
                    nb.plugins.bgp.community.create(
                        [payload for _, payload in batch]
                    )
                except Exception as exc:
                    msg = f"failed to create BGP community batch {batch_number}/{total_batches}: {exc}"
                    job.event(msg, severity="ERROR")
                    log.error(msg)
                    ret.errors.append(msg)
                    ret.failed = True
                    return ret
                ret.result["communities"]["created"].extend(
                    name for name, _ in batch
                )

        update_items = [
            (
                sname,
                {
                    "id": nb_community_objects["communities"][sname].id,
                    "custom_fields": normalised_live["communities"][sname],
                },
            )
            for sname in full_diff["communities"]["update"]
        ]
        if update_items:
            msg = f"updating {len(update_items)} BGP community object(s) in NetBox"
            job.event(msg)
            log.info(f"{self.name} - {msg}")
            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 BGP community batch {batch_number}/{total_batches} ({len(batch)} community object(s))"
                job.event(msg)
                log.info(msg)
                try:
                    nb.plugins.bgp.community.update(
                        [payload for _, payload in batch]
                    )
                except Exception as exc:
                    msg = f"failed to update BGP community batch {batch_number}/{total_batches}: {exc}"
                    job.event(msg, severity="ERROR")
                    log.error(msg)
                    ret.errors.append(msg)
                    ret.failed = True
                    return ret
                ret.result["communities"]["updated"].extend(
                    name for name, _ in batch
                )

    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