Skip to content

Sync VRFs¤

The sync_vrfs task reconciles VRFs from live devices with global NetBox VRF objects. A VRF name is its identity. The task synchronizes its description and adds live import and export route targets to the existing NetBox associations. Route-target and device associations are additive.

The task creates missing NetBox route targets before it creates or updates VRFs. Route distinguishers and import/export route policies returned by the TTP getter are not stored in NetBox.

Inputs¤

Input Default Description
instance Worker default NetBox instance name.
dry_run False Calculate the diff without writing to NetBox.
with_approval False Present the prepared plan for approval before writing. Ignored during dry-run.
timeout 600 Timeout in seconds for host resolution and TTP parsing.
devices None Explicit NetBox and Nornir device names.
branch None NetBox Branching plugin branch name.
device_custom_field devices Multi-object VRF custom field related to dcim.device.
Nornir filters None FO, FB, FH, FC, FR, FG, FP, FL, FM, FX, and FN.

Provide devices or at least one Nornir host filter.

NetBox VRF Device Custom Field¤

By default, the task uses a VRF custom field named devices. Configure it as a multi-object field assigned to ipam.vrf and related to dcim.device. Use device_custom_field to select another field with the same configuration.

The task adds devices on which the VRF was observed and preserves existing associations. It does not remove devices during a scoped run. If the configured field exists but has no value on an existing VRF, it is treated as an empty association list. If the configured field does not exist, the task continues without device association changes.

For example, when device_custom_field="vrf_devices" is requested but NetBox does not have a custom field named vrf_devices, VRFs and route targets are still synchronized. The task does not fail and does not add a device custom field value to the VRF.

Live data¤

The task runs Nornir parse_ttp with get="vrfs", which returns:

- name: TENANT_A
  description: Tenant A services
  rd: 65000:201
  rt_import:
    - 65000:201
    - 65000:301
  rt_export:
    - 65000:201
    - 65000:302
  route_policy_import: TENANT_A_IMPORT
  route_policy_export: TENANT_A_EXPORT

Only name, description, rt_import, and rt_export are used. Description text and route-target values are used without string normalization, and null descriptions become an empty string.

For each VRF, devices are ordered alphabetically. The first non-empty description in that order becomes the NetBox description; the description is empty when no device supplies one. Different descriptions from multiple devices are resolved by this rule and are not reported as conflicts.

Import and export route-target lists from every reporting device are concatenated to form the live aggregate. The shared make_diff method compares that aggregate with NetBox and ignores route-target order. If it finds an import or export route-target difference for an existing VRF, the task combines the current NetBox list with live targets that are not already in that list and places the combined list in the update. For a new VRF, or an existing VRF with an empty target set, the live aggregate populates the association. Route-target differences are not reported as errors, and all reporting devices are added to the device custom field.

Multi-device aggregation example¤

Assume the selected devices return the following records for the same VRF:

- name: TENANT_A
  description:
  rt_import:
    - 65000:100
    - 65000:200
  rt_export:
    - 65000:100
- name: TENANT_A
  description: Tenant A services
  rt_import:
    - 65000:100
    - 65000:300
  rt_export:
    - 65000:100
    - 65000:400

The task processes edge-a before edge-b because device names are sorted. The empty description from edge-a is skipped, so Tenant A services from edge-b becomes the desired description. It concatenates the live lists as follows:

TENANT_A:
  description: Tenant A services
  import_targets:
    - 65000:100
    - 65000:200
    - 65000:100
    - 65000:300
  export_targets:
    - 65000:100
    - 65000:100
    - 65000:400
  devices:
    - edge-a
    - edge-b

The task does not sort or deduplicate these aggregate lists. NetBox stores VRF route targets as object relationships, so the resulting VRF is associated with import targets 65000:100, 65000:200, and 65000:300, and export targets 65000:100 and 65000:400. Repeated live references do not create duplicate route-target objects or relationships.

If NetBox initially associates TENANT_A with import target 65000:999 and export targets 65000:100 and 65000:999, the task produces this state:

NetBox item Result
TENANT_A description Changed to Tenant A services.
Import associations Extended to 65000:999, 65000:100, 65000:200, and 65000:300.
Export associations Extended to 65000:100, 65000:999, and 65000:400.
Missing route-target objects Created before the VRF is updated; existing objects are reused.
Existing 65000:999 associations Retained on TENANT_A.
Route-target object 65000:999 Retained in NetBox.
Device associations edge-a and edge-b are added; devices already recorded on the VRF are retained.

The shared make_diff method initially detects the difference between the raw live aggregate and the current NetBox VRF. The task then replaces the route-target value in that update plan with the combined list. An order-only difference does not update the VRF. A missing live membership extends the existing relationship and does not add an error to the result. A NetBox-only membership is retained in the merged update.

Because the comparison uses the raw live aggregate before the merge, a NetBox-only route target remains a difference on later runs. The task can report the VRF as updated again, but the NetBox-only association remains attached and is never removed.

Output¤

Results use one global scope. Dry-run returns the standard sync diff:

{
  "global": {
    "create": ["TENANT_A"],
    "update": {
      "TENANT_B": {
        "export_targets": {
          "old_value": ["65000:999"],
          "new_value": ["65000:999", "65000:202"]
        }
      }
    },
    "delete": [],
    "in_sync": ["CONTROL_PLANE"]
  }
}

Live runs use completed-action verbs. The prepared plan remains available in the top-level diff field:

{
  "global": {
    "created": ["TENANT_A"],
    "updated": ["TENANT_B"],
    "deleted": [],
    "in_sync": ["CONTROL_PLANE"]
  }
}

Deletions¤

The task does not delete VRFs, route-target objects, route-target associations, or device associations. A selected device set does not prove that existing NetBox data is stale, so delete and deleted remain empty.

Condition Action
Live VRF is missing from NetBox Create the VRF.
Live route target is missing from NetBox Create the route-target object and associate it with the VRF.
NetBox VRF association is absent from the live aggregate Keep the existing association on the VRF.
Existing route-target object is not reported by live data Keep the route-target object.
NetBox VRF is absent from the selected devices Keep the NetBox VRF.

Branches¤

The task obtains its NetBox client with the requested branch. Reads and writes therefore remain inside that branch. The NetBox Branching plugin must be installed and configured for branch use.

Examples¤

Preview VRF changes:

nf# netbox sync vrfs devices fn-ceos-lf-1 dry-run

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

nf# netbox sync vrfs FC leaf device-custom-field vrf_devices
from norfab.core.nfapi import NorFab

with NorFab(inventory="./inventory.yaml") as nf:
    client = nf.make_client()
    result = client.run_job(
        "netbox",
        "sync_vrfs",
        workers="any",
        kwargs={
            "devices": ["fn-ceos-lf-1", "fn-ceos-lf-2"],
            "dry_run": True,
            "device_custom_field": "devices",
        },
    )
    print(result)

Troubleshooting¤

Missing parser data¤

Confirm the device platform is supported by the TTP vrfs getter and that the Nornir worker can run the getter's command. Missing or malformed results are reported as errors; valid results from other devices and workers still proceed.

Live VRF aggregation¤

Every selected device contributes its import and export route targets. Check the selected device set when the aggregate contains an unexpected target. For descriptions, the first non-empty value in sorted device-name order is used.

Device custom field¤

Confirm that the field is assigned to ipam.vrf, uses the multi-object type, and relates to dcim.device. The field name must match device_custom_field exactly.

NetBox write failures¤

Confirm route-target values are valid NetBox route-target names and that the custom field accepts device object IDs. Correct the validation error and rerun the dry-run before applying changes.

Task command shell reference¤

nf# man tree netbox.sync.vrfs

R - required field, M - supports multiline input, D - dynamic key

root
└── netbox:    Netbox service
    └── sync:    Sync Netbox data
        └── vrfs:    Sync live VRF configuration with NetBox
            ├── instance:    Netbox instance name to target
            ├── dry-run:    Calculate the VRF diff without writing to NetBox, default 'False'
            ├── branch:    NetBox branching plugin branch name to use
            ├── FO:    Filter hosts using Filter Object
            ├── FB:    Filter hosts by name using Glob Patterns
            ├── FH:    Filter hosts by hostname
            ├── FC:    Filter hosts containment of pattern in name
            ├── FR:    Filter hosts by name using Regular Expressions
            ├── FG:    Filter hosts by group
            ├── FP:    Filter hosts by hostname using IP Prefix
            ├── FL:    Filter hosts by names list
            ├── FM:    Filter hosts by platform
            ├── FX:    Filter hosts excluding them by name
            ├── FN:    Negate the match
            ├── devices:    List of NetBox devices to collect VRFs from
            ├── timeout:    Job timeout
            ├── with-approval:    Preview VRF changes and ask for review before writing to NetBox, default 'False'
            ├── device-custom-field:    VRF custom field that stores associated NetBox devices, default 'devices'
            ├── workers:    Filter worker to target, default 'any'
            ├── verbose-result:    Control output details, default 'False'
            └── nowait:    Do not wait for job to complete, default 'False'
nf#

Python API reference¤

Synchronize live VRFs and their route targets with NetBox.

VRFs have global scope and are identified by name. Descriptions are synchronized, while live import/export route targets extend the existing NetBox associations. Route distinguishers and route policies returned by the parser are not stored. The selected VRF custom field records devices on which each VRF 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
device_custom_field str

VRF custom field containing associated devices.

'devices'
**kwargs Any

Nornir FFun host filters.

{}

Returns:

Name Type Description
Result Result

Global VRF synchronization actions.

Source code in norfab\workers\netbox_worker\vrf_tasks.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 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
@Task(
    fastapi={"methods": ["POST"], "schema": NetboxFastApiArgs.model_json_schema()},
    input=SyncVrfsInput,
    output=SyncVrfsResult,
    mcp={
        "annotations": {
            "title": "Sync VRFs",
            "readOnlyHint": False,
            "destructiveHint": True,
            "idempotentHint": True,
            "openWorldHint": True,
        }
    },
)
def sync_vrfs(
    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,
    device_custom_field: str = "devices",
    **kwargs: Any,
) -> Result:
    """Synchronize live VRFs and their route targets with NetBox.

    VRFs have global scope and are identified by name. Descriptions are
    synchronized, while live import/export route targets extend the
    existing NetBox associations. Route distinguishers and route policies
    returned by the parser are not stored. The selected VRF custom field
    records devices on which each VRF 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.
        device_custom_field: VRF custom field containing associated devices.
        **kwargs: Nornir FFun host filters.

    Returns:
        Result: Global VRF synchronization actions.
    """
    devices = list(devices or [])
    instance = instance or self.default_instance
    ret = Result(
        task=f"{self.name}:sync_vrfs",
        result={},
        resources=[instance],
        dry_run=dry_run,
        diff={},
    )

    job.event(
        f"starting VRF sync using NetBox instance '{instance}' for "
        f"{len(devices)} explicit device(s)"
    )
    log.info(f"{self.name} - Sync VRFs: instance '{instance}', 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")
        log.error(f"{self.name} - Sync VRFs: {msg}")
        ret.errors.append(msg)
        ret.failed = True
        return ret

    nb_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 nb_devices]:
        msg = f"device '{device_name}' not found in NetBox"
        job.event(msg, severity="ERROR")
        log.error(f"{self.name} - Sync VRFs: {msg}")
        ret.errors.append(msg)
    devices = [name for name in devices if name in nb_devices]
    if not devices:
        ret.failed = True
        return ret
    job.event(f"validated {len(devices)} NetBox device(s)")

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

    job.event(f"collecting live VRFs from {len(devices)} device(s)")
    log.info(f"{self.name} - Sync VRFs: collecting from {len(devices)} device(s)")
    parse_data = self.client.run_job(
        "nornir",
        "parse_ttp",
        kwargs={"get": "vrfs", "FL": devices},
        workers="all",
        timeout=timeout,
    )
    job.event(f"received VRF data from {len(parse_data)} Nornir worker(s)")
    observations = {}
    result_devices = set()
    parsed_count = 0
    for worker_name, worker_data in parse_data.items():
        if worker_data["failed"]:
            msg = f"worker '{worker_name}' failed to collect live VRF data"
            job.event(msg, severity="ERROR")
            log.error(f"{self.name} - Sync VRFs: {msg}")
            ret.errors.append(msg)
            continue
        for device_name, records in worker_data["result"].items():
            if device_name not in nb_devices:
                continue
            result_devices.add(device_name)
            parsed_count += len(records)
            for record in records:
                observations.setdefault(record["name"], []).append(
                    {
                        "device": device_name,
                        "description": record["description"] or "",
                        "import_targets": record["rt_import"],
                        "export_targets": record["rt_export"],
                    }
                )

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

    live_vrfs = {}
    for vrf_name in sorted(observations):
        records = sorted(observations[vrf_name], key=lambda item: item["device"])
        live_vrfs[vrf_name] = {
            "description": next(
                (
                    record["description"]
                    for record in records
                    if record["description"]
                ),
                "",
            ),
            "import_targets": [
                target for record in records for target in record["import_targets"]
            ],
            "export_targets": [
                target for record in records for target in record["export_targets"]
            ],
        }
        if device_custom_field:
            live_vrfs[vrf_name][device_custom_field] = sorted(
                {nb_devices[record["device"]].id for record in records}
            )

    job.event("loading global NetBox VRFs")
    netbox_vrfs = {}
    netbox_objects = {}
    vrf_names = list(live_vrfs)
    netbox_vrfs_records = (
        self.bulk_filter(
            nb.ipam.vrfs,
            name=vrf_names,
            fields="id,name,description,import_targets,export_targets,custom_fields",
        )
        if vrf_names
        else []
    )
    for vrf in netbox_vrfs_records:
        current = {
            "description": vrf.description,
            "import_targets": [target.name for target in vrf.import_targets],
            "export_targets": [target.name for target in vrf.export_targets],
        }
        if device_custom_field:
            current[device_custom_field] = [
                device["id"]
                for device in (vrf.custom_fields[device_custom_field] or [])
            ]
            live_vrfs[vrf.name][device_custom_field] = sorted(
                set(current[device_custom_field])
                | set(live_vrfs[vrf.name][device_custom_field])
            )
        netbox_vrfs[vrf.name] = current
        netbox_objects[vrf.name] = vrf
    job.event(f"loaded {len(netbox_vrfs)} matching NetBox VRF(s)")

    job.event("calculating VRF sync diff")
    vrf_diff = self.make_diff(
        {"vrfs": live_vrfs},
        {"vrfs": netbox_vrfs},
    )["vrfs"]
    for vrf_name, changes in vrf_diff["update"].items():
        for field in ("import_targets", "export_targets"):
            if field in changes:
                live_vrfs[vrf_name][field] = netbox_vrfs[vrf_name][field] + [
                    target
                    for target in live_vrfs[vrf_name][field]
                    if target not in netbox_vrfs[vrf_name][field]
                ]
                changes[field]["new_value"] = live_vrfs[vrf_name][field]
    vrf_diff["delete"] = []
    full_diff = {"global": vrf_diff}
    create_names = vrf_diff["create"]
    update = vrf_diff["update"]
    in_sync = vrf_diff["in_sync"]
    job.event(
        "vrf sync diff complete: "
        f"{len(create_names)} create, {len(update)} update, "
        f"{len(in_sync)} in sync"
    )

    if dry_run:
        job.event("dry-run requested, returning VRF sync diff without changes")
        log.info(f"{self.name} - Sync VRFs: dry-run complete")
        ret.result = full_diff
        ret.dry_run = True
        return ret
    if with_approval:
        job.event("requesting approval for the prepared VRF sync plan")
    if with_approval and not review_sync_task_result(job, "VRF sync", full_diff):
        ret.status = "skipped"
        ret.result = full_diff
        ret.dry_run = True
        ret.messages.append("review declined; changes were not applied")
        log.info(f"{self.name} - Sync VRFs: approval declined")
        return ret

    route_target_names = list(
        dict.fromkeys(
            target
            for vrf in live_vrfs.values()
            for field in ("import_targets", "export_targets")
            for target in vrf[field]
        )
    )
    route_targets = (
        {
            target.name: target
            for target in self.bulk_filter(
                nb.ipam.route_targets,
                name=route_target_names,
                fields="id,name",
            )
        }
        if route_target_names
        else {}
    )
    missing_route_targets = [
        name for name in route_target_names if name not in route_targets
    ]
    if missing_route_targets:
        created_targets = nb.ipam.route_targets.create(
            [{"name": name} for name in missing_route_targets]
        )
        for target in created_targets:
            route_targets[target.name] = target
        job.event(f"created {len(created_targets)} NetBox route target(s)")

    ret.diff = full_diff
    ret.result = {
        "global": {
            "created": [],
            "updated": [],
            "deleted": [],
            "in_sync": in_sync,
        }
    }
    create_payloads = []
    for vrf_name in create_names:
        desired = live_vrfs[vrf_name]
        payload = {
            "name": vrf_name,
            "description": desired["description"],
            "import_targets": [
                route_targets[name].id for name in desired["import_targets"]
            ],
            "export_targets": [
                route_targets[name].id for name in desired["export_targets"]
            ],
        }
        if device_custom_field:
            payload["custom_fields"] = {
                device_custom_field: desired[device_custom_field]
            }
        create_payloads.append(payload)
    if create_payloads:
        nb.ipam.vrfs.create(create_payloads)
        ret.result["global"]["created"].extend(create_names)
        job.event(f"created {len(create_payloads)} NetBox VRF(s)")

    update_payloads = []
    for vrf_name in sorted(update):
        desired = live_vrfs[vrf_name]
        payload = {"id": netbox_objects[vrf_name].id}
        for field in update[vrf_name]:
            if field == "description":
                payload[field] = desired[field]
            elif field in ("import_targets", "export_targets"):
                payload[field] = [route_targets[name].id for name in desired[field]]
            elif field == device_custom_field:
                payload["custom_fields"] = {
                    device_custom_field: desired[device_custom_field]
                }
        update_payloads.append(payload)
    if update_payloads:
        nb.ipam.vrfs.update(update_payloads)
        ret.result["global"]["updated"].extend(sorted(update))
        job.event(f"updated {len(update_payloads)} NetBox VRF(s)")

    job.event("vrf sync complete")
    log.info(
        f"{self.name} - Sync VRFs complete: {len(create_names)} created, "
        f"{len(update)} updated, {len(in_sync)} in sync"
    )
    return ret