nautobot to netbox, there and back again
if you’ve ever tried to move data from one dcim tool into another, you already know my pain. maybe you get a csv export if you’re lucky, in the source system’s model. of course the target wants its own, as well it should. maybe the two look like they mostly agree, but there are always fault lines, and they always cause unnecessary friction.
in this blog post, we do a small but real migration from nautobot to
netbox that does the translation properly, with alembic’s map step doing the interpreter
work.
a tale of two models
despite originating from similar backgrounds, nautobot and netbox describe the same fabric differently, and the differences aren’t just cosmetic. over time, the two systems have diverged quite significantly!
- nautobot 2.x organizes locations as
dcim.location, keyed by a human name, with no slug. netbox usesdcim.site, and the slug is required. - nautobot models status as a reference to an
extras.statusobject. netbox wants a status string. - a device points at
locationin one andsitein the other.
none of those are particularly bad in isolation. a simple python script would probably be able to translate. but what i want to emphasize is that a migration here is not export-then-import. there’s a translation step, and if you don’t know both models you end up with garbage data in your target system.
in alembic, the data model and its flow are explicit.
import (nautobot to ir), map (ir to ir), then
plan and apply (ir to netbox). the only step
that needs our attention is map.
we will go through all of them anyway, for completeness’ sake, and to show what a real migration between real systems could look like.
import: observe nautobot as it is
alembic import -o nautobot-ir.yaml \
--backend-config backend-nautobot.yaml \
-f schema-nautobot.yaml-f is an inventory whose schema declares
the nautobot-shaped types to observe. import writes what it sees to
nautobot-ir.yaml. trimmed to one location and one
device:
- uid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
type: dcim.location
key: { name: "Frankfurt DC1" }
attrs:
status: 99999999-9999-9999-9999-999999999999 # ref to extras.status
- uid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
type: dcim.device
key: { name: leaf01 }
attrs:
status: 99999999-9999-9999-9999-999999999999
role: leaf
location: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaastatus is a uid pointing at an
extras.status object, not the word active.
that’s nautobot’s model and we take it at face value for now. the job
then is to know how to say the same thing in netbox’s.
map: rewrite into netbox’s vocabulary
map is a pure ir-to-ir transform. rules match objects by
type, reshape them through ${...} templates, and follow
references with lookups. two rules are enough for this
migration.
rules:
# dcim.location -> dcim.site: derive the slug netbox requires from the
# location's human name, and resolve the status reference to a string.
- name: locations-to-sites
match: "dcim.location"
lookups:
status_name: { ref: "${attrs.status}", get: "key.name" }
emit:
type: dcim.site
key:
slug: "${key.name|slug}"
attrs:
name: "${key.name}"
slug: "${key.name|slug}"
status: "${lookup.status_name|lower}"
# devices keep their type; the `location` relation becomes `site`.
- name: devices
match: "dcim.device"
lookups:
status_name: { ref: "${attrs.status}", get: "key.name" }
emit:
type: dcim.device
key:
name: "${key.name}"
attrs:
status: "${lookup.status_name|lower}"
role: "${attrs.role}"
# this ref is rewritten to the mapped target site
site: "${attrs.location}"given this translation layer as a spec file, we can execute:
alembic map -f nautobot-ir.yaml --spec map-nautobot-to-netbox.yaml -o netbox-ir.jsonthe output is in netbox’s vocabulary. Frankfurt DC1 is
now a site with slug frankfurt-dc1, its status is the
string active, and the device’s site ref
points at the new site:
{ "type": "dcim.site",
"key": { "slug": "frankfurt-dc1" },
"attrs": { "name": "Frankfurt DC1", "slug": "frankfurt-dc1", "status": "active" } }
{ "type": "dcim.device",
"key": { "name": "leaf01" },
"attrs": { "role": "leaf", "site": "<new dcim.site uid>", "status": "active" } }then it’s the usual read-only-then-write finish:
alembic plan -f netbox-ir.json -o plan.json --backend-config backend-netbox.yaml
# check that everything looks right
alembic apply -p plan.json --backend-config backend-netbox.yamland we’re done!
fault lines & safety
a rename that changes an object’s identity is where migrations tend to break. why doesn’t this one?
two things:
- the ref rewires itself. deriving the slug changes
the key, and therefore the uid, of every site. the device still lands on
the right site because
maprewrites references in a second pass, after every object’s new identity is known. you don’t thread the new uid through by hand,mapsees thatlocations-to-sitesis a 1:1 rename and follows it. nothing to do on your end, alembic handles it for you. - references become values. nautobot’s
statusref is resolved by thelookupsblock, which follows it to the status object and reads its name; thelowertransform normalizesActivetoactive. a reference-valued field comes out as the plain string netbox expects. this is a simple transform once you know where to pull data from.
ids are recomputed from the target identity (dcim.site
plus slug), so they’re stable across runs, and netbox assigns its own
backend ids on apply. this means we also know an object’s provenance
from the source system if we ever need it, since we retain every
system’s ids at every step.
in the end, every migration is a translation between two vocabularies for the same model. in alembic, we write it down once as a map we can read, diff, and run again, because that’s the power of being in control of the model.
this and many more examples live in the repository, and the cli can
be installed with cargo install alembic-cli or downloaded
from the
releases page.