Docs
Reference

Design structure

The parts of a design, how its values are read, and how its objects match NetBox's.

A design is a YAML mapping with these keys:

name: Site Foundation
description: A site in a country, with a /16 supernet for its subnets.
netbox_versions: ["4.6.*", "4.7.*"]
variables:
  site_name: {type: netbox_new, model: Site, field: name, description: The site's name}
  status: active
infra:
- infra_type: Site
  name: '{{ variables.site_name }}'
  slug: '{{ variables.site_name | slugify }}'
  status: '{{ variables.status }}'
KeyRequiredHolds
nameYesThe design's title. Other designs refer to it by its catalogue name, not this
descriptionNoWhat the design builds
netbox_versionsYesThe NetBox versions the design works with
variablesYesThe design's inputs and computed values; {} for none. See Variables
instance_keyNoWhich variable identifies an instance of the design, when a deployment holds it more than once: a rack design's rack_name. See Deployments
infraYesThe objects the design builds
assertNoWhat must hold once the plan is applied. See What must hold

The same structure is published as a JSON Schema, design.schema.json, for editors and tools that write designs. An editor with YAML schema support reads it from a comment at the top of the file:

# yaml-language-server: $schema=https://netboxlabs.com/docs/designs/reference/design.schema.json
name: Site Foundation

The schema covers the file's structure: the keys above, what an infra item is, how nbd_loop and design: are written. It doesn't cover what each object type's fields are, which depends on the type and on the NetBox version: see Object types. The catalogue checks the structure when a design is added or changed, and refuses one that's malformed with the same message nbd validate prints, naming the item. The Add and Edit dialogs also read the design against the newest NetBox that Designs knows and list, under the YAML, any object type or field that NetBox doesn't have; the plan checks the same against the target's own version.

netbox_versions

Designs refuses to plan a design against a NetBox whose version it doesn't list, and the catalogue refuses a design without the key. The value is "*", for a design that works with any version, or a list of version specifiers:

DeclarationMatches
"*"Any NetBox version
["4.7.*"]Any 4.7 release
["4.6.*", "4.7.*"]Any 4.6 or 4.7 release
[">=4.6,<4.8"]4.6.0 up to, but not including, 4.8.0
["4.7.0"]Exactly 4.7.0

Objects

Each item in infra is an object. Its infra_type names the object type as NetBox's data model does, such as Site or DeviceType, and its other keys are the object's fields, as NetBox names them, such as site or device_type. Object types lists the types.

An object can hold other objects in a list under a key that isn't one of its fields, such as a region's regions, a site's racks, a rack's devices and cables, or a device's modules. Each item in the list is an object of its own, and inside it {{ parent }} refers to the object that holds it:

- infra_type: Site
  name: DC1
  slug: dc1
  status: active
  racks:
  - infra_type: Rack
    name: RACK-01
    site: '{{ parent }}'
    status: active

Fields and empty values

A design owns only the fields it writes. A field you leave out is left alone in NetBox: it's never reported as drift and never written, even when NetBox gives it a default.

You writeIt means
description: "Uplink"Set the field
description: ""Clear a text field
region:Null, because an empty YAML value is null. Clears a field NetBox allows to be null, such as a reference
The field left outLeave the field alone
serial: '{{ variables.serial }}', where serial is an optional variable (required: false, or $USER_OPTIONAL) you didn't giveLeft out, as if the line weren't there

The render refuses a value NetBox wouldn't keep as written, before a plan exists, and says what to write instead. The rules come from the data model of your NetBox's version:

  • A null NetBox refuses, in most text fields: description: with nothing after it. Write "" to clear the field, or remove the line.
  • A blank NetBox refuses, such as name: "".
  • A field NetBox drops without another set. NetBox removes an interface's untagged_vlan unless its mode is set, and a rack's outer_unit unless an outer dimension is.
  • A value NetBox stores in another form. NetBox trims spaces from the ends of text, lower-cases an IP address's dns_name, stores an empty VRF rd or asset tag as null, and writes MAC addresses in upper-case pairs joined by colons. Write the form the message gives. A comments: | block ends in a newline NetBox strips: use |-.
  • A value outside a bound NetBox enforces. A name longer than its field holds (100 characters for most), a VLAN vid above 4094, a device position above 100.5, a slug with a space in it, a power feed voltage of 0. The message gives the bound: NetBox 4.7.2 requires vid to be at most 4094; 5000 is not.
  • A value of the wrong type for its field. Text in a whole-number field (vid: abc, u_height: probe, one slip in a variable's text box), a fraction in one, maybe where NetBox wants true or false, soon for a date. The message names the type: NetBox 4.7.2 requires u_height to be a whole number; 'probe' is not. What NetBox itself reads is accepted: "42" is a whole number, yes and 0 are booleans, a date is YYYY-MM-DD.

A type from the Device Type Library isn't refused this way, because you can't edit it. Its values arrive as NetBox would store them.

References

A field that refers to another object accepts:

  • Text, matched against that object's key, then its name, then its slug: site: dc1, region: Latvia. When the object's key is one field, as a site's, a region's or a tenant's is, the text is resolved when you plan, against the objects in NetBox and in the design, and text that matches nothing, or more than one object, refuses the plan. When the key has several fields, as a device's name and site do, the text is resolved when you apply.
  • The object that holds this one, or one of its fields: site: '{{ parent }}', site: '{{ parent.site }}'.
  • A mapping of its fields: device: {name: SW-01}. The fields needn't be the object's key: group: {slug: branch-offices} names a site group, whose key is its name and parent, by its slug. A mapping that gives less than the key is resolved when you plan, against the objects in NetBox and in the design, and one that matches nothing, or more than one object, refuses the plan.
  • A function that finds it: see Functions.

A field that holds a list of references, such as tags or a VLAN group's scope-less vid_ranges, takes a list of any of these, and stays a field of the object rather than becoming children under it. An item written as an object, with an infra_type, is created by the design and referred to by the field:

- infra_type: Site
  name: Acme APJ - Dunedin
  slug: acme-apj-dunedin
  status: active
  tags:
  - acme-apj
  - {slug: branch-office}
  - {infra_type: Tag, name: Dunedin, slug: dunedin}

Fields that hold one of several types

Some fields hold an object of any of several types. An address's interface holds an interface, a VM interface or an FHRP group; a circuit termination's termination holds a site, a location, a region, a site group or a provider network; a cable's termination_a and termination_b hold any of the port types a cable can end on.

The plan reads which type a mapping means from its fields and from what NetBox and the design hold: interface: {name: xe-0/0/0, device: {name: RTR-01}} can only be an interface, and termination: {slug: hong-kong} is the site when a site has that slug and nothing else does. When a mapping could mean objects of two types, the plan is refused, naming both, and you say which you mean with _type:

- infra_type: CircuitTermination
  circuit: {cid: ACME-HKG-01}
  term_side: A
  termination: {slug: acme-apj-hong-kong, _type: Site}

_type takes the type's name as infra_type does. A _type the field can't hold refuses the render.

A cable ends on an interface, a front port, a rear port, a console port, a console server port, a power port, a power outlet, a power feed or a circuit termination. Its ends are its identity, so each end is a reference to a port the design or NetBox has, by function or by mapping:

- infra_type: Cable
  status: connected
  termination_a: "{{ nbd.lookup.endpoint('srv-01', 'PowerPort', 'psu1') }}"
  termination_b: {name: feed-a, power_panel: {name: panel-1, site: {slug: dc1}}}

An end may land on several ports at once, as a breakout cable's does. Write the end as a list of ports; the order doesn't matter, and a cable NetBox holds with the same ports on each end is the same cable whichever order it lists them in:

- infra_type: Cable
  status: connected
  termination_a: "{{ nbd.lookup.endpoint('spine-01', 'Interface', 'Ethernet1/1') }}"
  termination_b:
    - "{{ nbd.lookup.endpoint('leaf-01', 'Interface', 'Ethernet1') }}"
    - "{{ nbd.lookup.endpoint('leaf-01', 'Interface', 'Ethernet2') }}"
    - "{{ nbd.lookup.endpoint('leaf-01', 'Interface', 'Ethernet3') }}"
    - "{{ nbd.lookup.endpoint('leaf-01', 'Interface', 'Ethernet4') }}"

The ports on an end are part of the cable's identity: add one or take one away and the plan sees another cable, applied as a delete and a create, as any change to a cable's terminations is (see Limitations). A port takes one cable, so a design that puts a port on a second cable is refused at plan time with the cable that holds it.

A front port is mapped to the rear port a cable path continues through on its rear_ports, one row per position: the position on the front port, the rear port by name on the same device, and the position on it. A path traces through the device along these rows, as NetBox draws it; a front port with none is a dead end. A device type from the library brings its own.

- infra_type: FrontPort
  name: "1"
  type: 8p8c
  device: "{{ parent }}"
  rear_ports:
    - {position: 1, rear_port: {name: "1", device: "{{ parent }}"}, rear_port_position: 1}

A design that says nothing about a front port's rear_ports leaves its mappings alone; rear_ports: [] removes them. A rear port's front_ports are the same mappings seen from the other side, and are read only.

A device's primary IP address

NetBox keeps a device's primary IPv4 and IPv6 on the device. A design says it on the address instead: primary: true on an IPAddress that sits on one of the device's interfaces makes it the device's primary IPv4 or IPv6, by the address family, when the address is created:

- infra_type: IPAddress
  address: "10.10.1.10/24"
  status: active
  interface: "{{ nbd.lookup.endpoint('R1-COMPUTE-01', 'Interface', 'iDRAC9') }}"
  primary: true

primary is a design-time instruction, not a fact Designs compares afterwards: the plan does not read a device's primary IP back from NetBox, so a primary IP changed by hand is not drift and is not restored, and an address that already exists is not made primary by adding primary: true to it later. Set it where the address is created, which for a device's management address is the device's own design. The tutorial's compute rack leaves it out so its first plans stay small to read; add it to the management address when you want the device reachable by name in NetBox.

Custom fields

An object's custom field values go under custom_fields, as NetBox's API names them. Keys are the custom fields' names in your NetBox; values follow each field's type. Text, integer, decimal, boolean, date, datetime, URL, JSON and select fields take plain values; a multi-select takes a list of them; an object field takes a reference, written as any reference is, and a multi-object field a list of references.

- infra_type: Device
  name: "{{ rack }}-leaf-01"
  site: {slug: "{{ site }}"}
  custom_fields:
    grid_pod: "{{ grid_pod }}"
    asn: "{{ asn }}"
    environment: prod
    maintainer: {username: netops}
    peer_sites: [{slug: dc2}, {slug: dc3}]

A design owns a custom field one key at a time, the way it owns any field. A key you set is compared with what NetBox holds and written when it differs; a key you never mention is left alone, whoever set it, and is never in a plan. Two deployments setting different keys on one object coexist; two setting the same key overwrite each other, as with any field. Set a key to null (tier: with nothing after it) to clear it. An object's values are read from NetBox along with everything else, so an adopted object's custom fields show in its plan's before, and a value someone changed by hand is drift.

The plan is refused before review, naming the object and the field, when a design sets a custom field your NetBox doesn't define for that object type (the message lists the ones it does), or a value the field won't take: a choice outside its set, text in a number field, a number outside its minimum or maximum, text that doesn't match its pattern. NetBox would refuse the same at apply, after the review. A custom field your NetBox requires with no default is refused the same way when a design creates an object of its type without setting it: set it, or give the field a default in NetBox, or make it optional. One a design leaves to NetBox that has a default gets the default, as it would from the NetBox UI. The definitions are read from your NetBox with everything else, so a field added, changed or deleted there is known to the next plan.

custom_field_data, the name of NetBox's own column, is refused: write custom_fields.

What must hold

A design says what it builds in infra. In assert it says what must be true of the result, in its own words: every leaf has two uplinks; this VLAN id is the pod's alone. A plan whose result breaks an assertion is refused with the message the design gives.

assert:
  - name: two uplinks per leaf
    for_each: {type: Device, where: {role: leaf, site: "{{ variables.site }}"}}
    count: {type: Interface, where: {device: "{{ each }}", lag: present}}
    min: 2
    message: "{{ each.name }} has {{ count }} interfaces in a LAG; a leaf's uplinks are two"
  - name: the VLAN id is free
    count: {type: VLAN, where: {vid: "{{ variables.vlan_id }}", site: "{{ variables.site }}"}}
    max: 1
    message: "VLAN {{ variables.vlan_id }} is in use at {{ variables.site }} already"

Each entry counts the objects of one type and says what the count must be.

KeyRequiredMeaning
countYesWhat is counted: a type, as infra_type names it, and a where the objects must match
is, min, maxOne ofWhat the count must be: exactly is, or between min and max, either alone
messageYesWhat the refusal says. It may use {{ count }}, {{ variables.<name> }} and, under for_each, {{ each }} and {{ each.<field> }}
for_eachNoCheck once per object of this type matching this where. Each one is each in count and message
nameNoHow the refusal names the assertion
severityNoerror, the default, refuses the plan. warning shows on the plan and lets it proceed

The count is over the result: the objects the design builds, and the objects NetBox already has that the design doesn't redeclare. A design that adds a second uplink to a leaf that already has one counts two.

where matches fields by value. A reference matches by the referenced object's name or slug, or by {{ each }} under for_each. present and absent match a reference that is set or not: lag: present is an interface in a LAG. The values of where and the words of message take only {{ variables.<name> }}, {{ each }}, {{ each.<field> }} and {{ count }}; the design's other expressions don't apply there.

A type or field an assertion names that NetBox doesn't have is refused when the design is added, with the nearest name offered.

Matching objects NetBox already has

A plan matches each object in the design with the object NetBox has under the same key. Each type's key is in Object types.

  • A key field NetBox requires can be left out. device: {name: SW-01} leaves out the device's site, and matches the one SW-01 whatever its site. When two sites each have an SW-01, the plan is refused, naming both. Add the site.
  • A key field NetBox allows to be empty means empty when left out. A prefix without vrf is the global prefix, and a VLAN without group is the ungrouped one, which is also what NetBox stores when it creates them.
  • A device needs a name. Only its id tells one unnamed device from another, so the render refuses a design that declares one.

On this page