Skip to content

YAML Configuration

sat edited this page Aug 25, 2026 · 33 revisions

YAML Configuration

This page explains the YAML configuration system in dot2net, including class definitions, template syntax, and configuration patterns.

Overview

YAML configuration files define the generalized configuration part of topology-driven configuration. While DOT files describe "what" the network topology is, YAML files describe "how" each type of device should be configured.

Top-Level Configuration

Global Settings

Global settings control project-wide behavior:

global:
  path: local  # "local" = files in config dir, "default" = working dir
  nodeautoname: true  # Enable automatic node renaming
  max_address_count: 65536  # Cap on addresses/prefixes enumerated per pool
  ignore_undefined_class: false  # Skip (true) or error (false) on undefined class labels
  output_group_class: worker     # One output directory per group of this class
  aggregate_crossing_links: true # Replace a segment reaching across machines with bridges
  split_module_output: false     # Put each module's own files in a directory named after it

Key attributes:

  • path: File path specification for config files referenced in YAML

    • "local": Paths relative to input.yaml directory
    • "default": Paths relative to current working directory (shell execution location)
  • nodeautoname: Automatic node renaming

    • true: Ignore DOT file node names, generate names using NodeClass prefix settings
    • false: Use DOT file node names as-is
    • Uses same mechanism as automatic interface naming
  • max_address_count: Upper bound on how many addresses/prefixes are enumerated when an address pool is expanded fully (loopback / segment / reservation handling). Bounds internal work so an oversized (e.g. IPv6) pool cannot blow up memory.

    • Default (0, negative, or unset): 65536 (comfortably fits a /16)
    • Increase it if a single pool legitimately needs more than 65536 entries
  • ignore_undefined_class: How a class label that matches no defined class is handled

    • false (default): it is an error (catches typos in class names)
    • true: it is silently skipped — useful when a label is meant for display rather than as a class (e.g. a subgraph label used only as a caption)

Automatic Object Naming

Names you do not have to write, and cannot get wrong. An interface has to be called something, and the name has to match between the device's configuration and the platform's file. dot2net assigns it, so adding a link never means renumbering by hand — and never means two places disagreeing about eth1. Interfaces, connections and segments are named this way always; nodes only if you ask, since a node's name is usually worth choosing yourself.

Supported Object Types

Always Enabled:

  • Interfaces: All interfaces are automatically named using InterfaceClass prefix
  • Connections: All connections are automatically named using ConnectionClass prefix
  • Network Segments: All detected network segments are automatically named using SegmentClass prefix

Configurable:

  • Nodes: When nodeautoname: true in global settings, uses NodeClass prefix

Default Prefixes

Object Type Default Prefix Example Names
Interface "eth" eth0, eth1, eth2
Connection "conn" conn0, conn1, conn2
Segment "seg" seg0, seg1, seg2
Node "node" node1, node2, node3

Interfaces, connections and segments are numbered from 0 and nodes from 1: eth0 is the first interface everywhere, and there is no node0.

A prefix other than eth on an interface shuts Kathara out. Kathara reads an interface's name from its position in lab.conf, so the interfaces it lays are eth0, eth1, ... and any other name is rejected — see Module: Kathara. The other object types are dot2net's own names and no platform sees them, so their prefixes are free. An interface the node builds for itself (deploy: logical) never reaches lab.conf either, which is why a tunnel end may carry a prefix of its own.

example/naming shows all of this in one small topology.

Custom Prefix Configuration

You can customize naming prefixes in class definitions using the prefix attribute:

# Custom interface naming
interfaceclass:
  - name: mgmt_interface
    prefix: "mgmt"  # Generates: mgmt0, mgmt1, mgmt2...

# Custom connection naming
connectionclass:
  - name: vlan_trunk
    prefix: "trunk"  # Generates: trunk0, trunk1, trunk2...

# Custom segment naming
segmentclass:
  - name: network_segment
    prefix: "mgmt"  # Generates: mgmt0, mgmt1, mgmt2...

# Custom node naming (when nodeautoname: true)
nodeclass:
  - name: router
    prefix: "rtr"  # Generates: rtr0, rtr1, rtr2...

Template Name References

All automatically named objects can reference their assigned names in templates using {{ .name }}:

# Connection template using auto-assigned name
connectionclass:
  - name: vlan_connection
    prefix: "vlan"
    config:
      - name: connection_setup
        template:
          - "# Connection: {{ .name }}"  # Outputs: vlan0, vlan1, etc.

# Segment template using auto-assigned name
segmentclass:
  - name: network_segment
    prefix: "seg"
    config:
      - name: network_entry
        template:
          - "- name: {{ .name }}"  # Outputs: seg0, seg1, etc.

Benefits

  • Consistency: Sequential numbering ensures predictable naming patterns
  • Template compatibility: All objects support {{ .name }} template references
  • Cross-object references: Reliable names enable stable inter-object relationships
  • Configuration portability: Generated names work consistently across environments

Files Configuration

Says what files exist and where each one goes. A template produces text; this is what turns that text into a file at a path — inside the container, at the lab root, one per node or one for the whole lab. A config entry then names the file it writes to. See File Output for the full set of fields.

file:
  - name: frr.conf
    path: /etc/frr/frr.conf  # Target path on nodes
    scope: node  # "node" (per-node, default), "group" (per group) or "network" (one file)
    format: frr_format  # Reference to format definition
    output: node  # "node" (subdirectory) or "root" (lab root)
    name_prefix: ""  # Prefix for output filename
    name_suffix: ""  # Suffix for output filename

File Formats (FormatStyle)

Where the punctuation lives, so templates hold only content. FRR wants a ! between blocks, containerlab wants each command indented under a YAML list, TiNET wants them joined a third way. Written into the templates, that punctuation would be repeated in every one of them and would have to change everywhere at once. A format is named once and applied where blocks are made and where they are joined. See FormatStyle Design.

format:
  - name: frr_format
    # Format Phase (applied during config block generation)
    format_lineprefix: " "
    format_linesuffix: ""
    format_lineseparator: "\n"
    format_blockprefix: ""
    format_blocksuffix: "!"
    # Merge Phase (applied when combining multiple blocks)
    merge_blockseparator: "\n"
    merge_resultprefix: ""     # Optional: wrap entire merged result
    merge_resultsuffix: ""     # Optional: wrap entire merged result

Two-Phase Processing:

  1. Format Phase - Individual config block formatting:

    • format_lineprefix: Added before each line
    • format_linesuffix: Added after each line
    • format_lineseparator: Used to join lines (default: "\n")
    • format_blockprefix: Added before entire config block
    • format_blocksuffix: Added after entire config block
  2. Merge Phase - Combining multiple formatted blocks:

    • merge_blockseparator: Used to join multiple config blocks (default: "\n")
    • merge_resultprefix: Added before the entire merged result (optional)
    • merge_resultsuffix: Added after the entire merged result (optional)

Backward Compatibility (v0.6.x):

  • lineprefixformat_lineprefix
  • linesuffixformat_linesuffix
  • lineseparatorformat_lineseparator
  • blockprefixformat_blockprefix
  • blocksuffixformat_blocksuffix
  • blockseparatormerge_blockseparator

Real-world examples:

# FRR vtysh command format
format:
  - name: frr_vtysh
    format_lineseparator: "\" -c \""
    format_blockprefix: "vtysh -c \"conf t\" -c \""
    format_blocksuffix: "\""

# Containerlab YAML array format
format:
  - name: clab_yaml
    merge_blockseparator: ", "

# Containerlab command list format
format:
  - name: clab_cmd
    format_lineprefix: "      - "
    merge_blockseparator: "\n"

Complete example demonstrating all attributes:

format:
  - name: comprehensive_format
    # Format Phase
    format_lineprefix: "  "           # Indent each line
    format_linesuffix: ";"            # Add semicolon to each line
    format_lineseparator: " \\\n"     # Line continuation with backslash
    format_blockprefix: "START {\n"   # Block opening
    format_blocksuffix: "\n} END"     # Block closing
    # Merge Phase
    merge_blockseparator: "\n---\n"   # Separator between blocks
    merge_resultprefix: "# BEGIN\n"   # Wrap entire result
    merge_resultsuffix: "\n# END"     # Wrap entire result

Processing flow example:

Input template: ["router ospf", "network 10.0.1.0/24 area 0"]

↓ Format Phase - formatConfigLines (comprehensive_format)
Step 1 - Apply line formatting (format_lineprefix/suffix/separator):
  "  router ospf; \\\n  network 10.0.1.0/24 area 0;"

↓ Format Phase - formatSingleConfigBlock
Step 2 - Apply block formatting (format_blockprefix/suffix):
  "START {\n  router ospf; \\\n  network 10.0.1.0/24 area 0;\n} END"

↓ Merge Phase - mergeConfigBlocks (multiple blocks)
Step 3 - Join blocks with merge_blockseparator:
  "Block1\n---\nBlock2\n---\nBlock3"

↓ Merge Phase - wrap result (merge_resultprefix/suffix)
Step 4 - Wrap entire result:
  "# BEGIN\nBlock1\n---\nBlock2\n---\nBlock3\n# END"

When Merge Phase is used:

  • Sorter templates (style: sort) that combine multiple config blocks
  • blocks.before / blocks.after features that merge child configs

Layers Configuration

Lets one topology carry more than one address family at a time. A network running IPv4 and IPv6, or an overlay on top of an underlay, needs two independent sets of addresses over the same links. A layer is one such set, with its own pool and its own policy, and a template asks for the one it means. With a single layer there would be nothing to say which address a template wanted.

layer:
  - name: ip
    default_connect: true  # Default layer for connections
    policy:
      - name: ip
        range: 10.0.0.0/16
        prefix: 24
      - name: lo
        type: loopback
        range: 10.0.255.0/24

Key attributes:

  • name: Layer identifier (used in template variables)
  • default_connect: Default connection layer for IP segment discovery
    • true: ConnectionClasses without explicit layer specification are considered connected on this layer
    • false: Not used for default connections
    • Used by DefaultConnectionLayer() function for segment exploration
  • policy: Array of IP address policies for this layer

IP Policy attributes:

  • name: Policy identifier
  • type: Policy type ("ip" for normal interfaces, "loopback" for loopback interfaces)
  • range: IP address range (CIDR notation)
  • prefix: Default prefix length for subnets

Generated template variables: Each layer automatically provides template variables:

  • {{ .{name}_addr }} - IP address
  • {{ .{name}_net }} - Network address
  • {{ .{name}_plen }} - Prefix length
  • {{ .{name}_protocol }} - Protocol identifier
  • {{ .{name}_loopback }} - Loopback address

Example usage:

# Template can use:
template:
  - "interface {{ .name }}"
  - " ip address {{ .ip_addr }}/{{ .ip_plen }}"
  - " description Connected to {{ .ip_net }}"

Management Layer

For reaching a device without going through the network being tested. A lab whose routing is broken is exactly the lab you need to log into, so the management addresses must not come out of the same pool as the topology's own. Optional: a lab that is only ever driven through its platform's exec does not need one.

mgmt_layer:
  name: mgmt                    # Layer name identifier
  range: 192.168.1.0/24         # IP address range for management interfaces
  gateway: 192.168.1.1          # External gateway (reserved from auto-assignment)
  interface_name: mgmt0         # Management interface name (default: "mgmt0")

Key attributes:

  • name: Management layer identifier (used in template variables)
  • range: IP address range for automatic assignment to management interfaces
  • gateway: External gateway address (automatically reserved from assignment pool)
  • interface_name: Name for management interface on each node (default: "mgmt0")

Behavior:

  • Independent of topology: Management interfaces are automatically created on every node
  • Separate IP space: Uses dedicated IP range independent of main network layers
  • External connectivity: Designed for out-of-band management (Containerlab external networks)
  • Automatic creation: Management interfaces added during addSpecialInterfaces() processing

Generated template variables:

  • {{ .{name}_addr }} - Management IP address
  • {{ .{name}_net }} - Management network address
  • {{ .{name}_plen }} - Management prefix length

Giving the management interface a class of its ownmgmt_interfaceclass on a node class. The interface is created without an edge, so it carries no class unless one is named, and a lab that has to configure it (an address on the interface, a route through the gateway) needs one:

nodeclass:
  - name: router
    mgmt_interfaceclass: mgmt_iface     # the class the mgmt interface is given

Without it the interface exists and is addressed, but nothing is written for it.

Usage with Containerlab: Management layer integrates with Containerlab's external network connectivity for out-of-band device management.

Example:

mgmt_layer:
  name: mgmt
  range: 10.255.255.0/24
  gateway: 10.255.255.1
  interface_name: eth0

This creates an eth0 management interface on each node, addressed from 10.255.255.0/24.

containerlab keeps eth0 for its management interface, and since 0.8.0 it is not attached by default — see module_config. A topology that turns it back on has to name its data interfaces something other than eth0, through an interface class prefix; dot2net says so while generating rather than letting containerlab refuse at deploy time.

Parameter Rules

For values dot2net does not know about, assigned the way addresses are. A VLAN id per segment, an AS number per group, a router id per node: each has to be unique, has to stay put when the graph changes around it, and would otherwise be typed into the DOT file by hand and kept unique by the author. A rule says where the values come from and what they are assigned per.

The fields are summarised here; what each mode does, and how a rule interacts with the addresses dot2net assigns on its own, is Parameter Generation.

param_rule:
  - name: vlan_id
    assign: segment  # "object" (default), "segment", "connection"
    layer: ip        # Required when assign: "segment"
    type: integer    # "integer" (default) or "file"
    min: 100
    max: 1001
    header: vlan     # Prefix for generated values
    footer: ""       # Suffix for generated values
  - name: conn_id
    assign: connection
    header: conn
    min: 0
  - name: hostname_list
    type: file
    sourcefile: ./hostnames.txt  # Read values from file

Key attributes:

  • name: Parameter rule identifier (must match parameter names in class definitions)
  • assign: Assignment scope
    • "object" (default): Individual assignment per node/interface
    • "segment": Same value for interfaces in the same network segment
    • "connection": Same value for interfaces connected by the same connection
  • layer: Protocol layer specification (required when assign: "segment")
  • type: Parameter generation type
    • "integer" (default): Generate numeric sequences
    • "file": Read values from external file

Integer type attributes:

  • min: Starting value (default: 0)
  • max: Maximum value (optional, for validation)
  • header: Prefix string (default: "")
  • footer: Suffix string (default: "")
  • Generation pattern: {header}{min+i}{footer}

File type attributes:

  • sourcefile: Path to source file (relative to config file)
  • Format: One value per line, read sequentially

Assignment behavior examples:

# Example: VLAN assignment per segment
param_rule:
  - name: vlan_id
    assign: segment
    layer: ip
    min: 100
    header: vlan
# Result: vlan100, vlan101, vlan102... (same value for all interfaces in each segment)

# Example: Connection ID per connection
param_rule:
  - name: conn_id
    assign: connection
    header: conn
    min: 0
# Result: conn0, conn1, conn2... (same value for both ends of each connection)

# Example: Individual interface numbering
param_rule:
  - name: if_num
    assign: object  # or omit (default)
    min: 1
# Result: 1, 2, 3... (unique value per interface)

Value Class (Attach Mode)

The Value class allows attaching multiple parameter sets to a single object — the mechanism that replaces a loop in a template. See Attach Mode for how the values are generated. This is useful for topologies like:

  • Multiple VLAN configurations per switch
  • Multiple static routes per router
  • Dynamic file mount configurations

Basic Structure:

param_rule:
  - name: vlan_ids
    mode: attach           # Required for Value class
    source:
      type: range          # "range", "sequence", "list", or "file"
      start: 100
      end: 103
    param_format:
      vlan_id: "{{ .value }}"
    config:
      - name: vlan_entry
        template:
          - "vlan {{ .vlan_id }}"

Mode:

  • distribute (default): One value per object (legacy behavior)
  • attach: Multiple Values attached to one object

Source Types:

Type Description Fields
range Numbers counted off one per object start, end
sequence The same, put into a format string — for a name or an identifier built around a number start, end, format
list Values written out, handed out in order values (array)
file Values read from a file, for a list too long or too external to write in the topology file

Template Reference:

In class config templates, reference Values using values_ prefix:

nodeclass:
  - name: switch
    params: [vlan_ids]    # Reference the param_rule
    config:
      - group: startup
        template:
          - "! VLAN Configuration"
          - "{{ .values_vlan_entry }}"  # Reference formatted Value output

Complete Example:

# Define param_rule with attach mode
param_rule:
  - name: static_routes
    mode: attach
    source:
      type: list
      values:
        - { network: "10.0.0.0/8", gateway: "192.168.1.1" }
        - { network: "172.16.0.0/12", gateway: "192.168.1.2" }
    config:
      - name: route_entry
        template:
          - "ip route {{ .network }} {{ .gateway }}"

# Reference in node class
nodeclass:
  - name: router
    params: [static_routes]
    config:
      - file: router.conf
        template:
          - "! Static Routes"
          - "{{ .values_route_entry }}"

Module Generators:

Modules can provide generators for dynamic Value creation:

# Containerlab module provides clab.filemounts generator
# TiNET module provides tinet.filemounts generator
# These are used internally for bind mount generation

Module Configuration

Load external modules. For details on how modules work, see Module System.

module:
  - tinet        # TiNET spec.yaml generation
  - containerlab # Containerlab topo.yaml generation
  - kathara      # Kathara lab.conf generation
  - frr          # FRR configuration helpers

Settings that belong to one platform rather than to the topology go under module_config, in a section named after the module. global: is for what every platform shares; what a module offers is its own, and a section naming a module the topology does not load is rejected, since it does nothing and is nearly always a typo.

module_config:
  containerlab:
    management_network: false   # default: no management network at all
    generate_scripts: false     # write containerlab.sh beside the lab
  kathara:
    mount_dirs: [/etc/frr]      # directories dot2net supplies entirely
    generate_scripts: false
  tinet:
    generate_scripts: false
Setting Meaning
containerlab.management_network Put containerlab's management network back. It is off by default: a second path between every pair of nodes is one a reachability test can pass through without anyone noticing. Turning it on means naming the data interfaces something other than eth0.
<module>.generate_scripts Write an entry point script (containerlab.sh, tinet.sh, kathara.sh) that deploys, destroys, collects and execs. See Command Reference.
kathara.mount_dirs Directories inside a device that dot2net supplies entirely. See Module: Kathara.

deploy and virtual

Not everything you draw is a container, and not everything you draw is wired. Four things a topology regularly needs, and what each is written as:

You want Write
A switch the platform provides — a Linux or OVS bridge, not a container of yours deploy: platform on the node class
A node that exists only to shape the addressing, deployed nowhere deploy: none on the node class — and no wire is laid to it either, since nothing reaches an end that is not there
A VXLAN overlay or a GRE tunnel: two ends that are real, with no wire between them deploy: logical on the connection class — see topologies/bgp_evpn_vxlan_topo1

example/deploy_and_virtual is all four in one small topology, with the generated files beside it: a site where the LAN is the platform's, the upstream router is someone else's, and an appliance arrives configured. | A node or an interface that is deployed like any other, but whose configuration you write yourself elsewhere | virtual: true |

The first three answer one question and the last answers another, and the two are asked separately.

deploy — what is this object materialised as? The values a class may choose from are the forms that kind of object can take:

On a Values
node class container (the default: a container of its own) · platform (a facility the platform provides itself, such as a bridge) · none
interface class link (an end of wiring the platform lays) · logical (a device the node's own configuration builds — a bridge, a dummy, a VRF) · none
connection class link (a wire the platform lays) · logical (a tunnel or overlay the configuration builds, reaching the same two ends without any wiring) · none
group class none of them: deploy does not exist on a group. A group is a scope, and neither the platform nor the configuration puts anything in place for one

A node has no logical because configuration runs inside a node, and there is nothing further in for it to build the node from. An interface and a connection have one platform form rather than two, because the only way a platform puts wiring in place is by laying a link.

An interface takes the form of the connection it sits on, so the usual case needs nothing written, and writing one that disagrees with the connection is an error: an end of a wire is there because the wire is. Write it on an interface class for an interface that has no connection to take it from — one the platform supplies without an edge ever being drawn, such as a management interface, is a link, which is also what an interface with no connection and no class falls back to.

virtual: true — do not write this object's configuration. That is all it says. It means the same thing on all four kinds of class, and it says nothing about whether the object is there: a node marked virtual still gets its container, and an interface marked virtual is still wired.

The two are asked separately, but they are not symmetric. deploy: none writes no configuration either — an object that is nothing has nothing for a configuration to describe, and a link to it is not laid, so the interface at its far end produces nothing either. The other way round is the one that does not follow: deployed by nobody, configured by us — generating a configuration file for a real router that dot2net does not start — cannot be expressed in 0.8.0. It needs a form meaning "it exists, we just do not put it there", which the values above do not have.

Changed in 0.8.0, and it is a breaking change. virtual: true used to mean "not deployed" as well. It no longer does — write deploy: none for that. Until this migration is over, a class that sets virtual: true without also naming a deploy is rejected with an error, so a topology written for 0.7.x stops rather than coming up in a shape you did not ask for. See Upgrading to 0.8.

The two combine, which is what the single flag could not express:

nodeclass:
  - name: stock_image
    deploy: container     # started like any other node
    virtual: true         # but dot2net writes no configuration into it

connectionclass:
  - name: vxlan
    deploy: logical       # the platform lays no wire; the configuration builds it

Whether a platform is told to make a link is a property of the template that writes it (required_link), and that template is written only where the connection is a link. See ConfigTemplate Field Reference.

virtual: false claims nothing at all, which is how the boolean has always behaved. deploy is the way to say something out loud and have it win.

When several classes apply to one object

An object usually carries more than one class: what the DOT file names, what class_policy adds, what a class pulls in with use:, and what a module attaches. They are combined rather than one overriding the rest, and where two of them say different things, the stronger one wins.

Weakest to strongest:

Where it comes from
1. module classes a module attaches, and classes pulled in with use: from a module
2. base classes named in class_policy.<type>.base
3. the author's own classes named in the DOT file, and class_policy.<type>.default
4. value labels in the DOT file r1 [image=...] — applied before any class, so they beat all of them

Modules are the weakest on purpose: they supply defaults, and what the author writes has to win over them. A module that must force a value does not win silently — it checks and reports, which is why asking Kathara for an interface prefix other than eth is an error rather than a setting quietly ignored.

Two classes of the same strength saying different things is an error, not a race. There is nothing to say which of them the author meant.

class_policy:
  node:
    base: [common]

nodeclass:
  - name: common
    values: {image: quay.io/frrouting/frr:8.5.4}   # weaker
  - name: edge
    values: {image: quay.io/frrouting/frr:9.0.0}   # written on the node: wins

What "wins" means depends on the field:

Field How they combine
values, params, collect added together. Every class contributes; two of the same strength setting the same key differently is the error above
deploy, prefix, and other single-valued fields the strongest wins. A class that says nothing makes no claim, so it never conflicts with anything
config added together, and names must not collide at all. Two classes defining a config entry of the same name is an error whatever their strength: a template is not a value to be overridden, and a topology cannot replace a module's. Writing into one group from two classes is ordinary, though — that is what a group is for, and it is how a module and a topology both reach a hook
use attaches the named class. It is not inheritance: the attached class stands beside this one, keeping the strength it came with — so a module's class stays weaker than what you write

Class Policy

Two class names used to be magic: all applied to every object, and default applied to objects with no class label. class_policy says the same thing where it can be seen, and frees both names for ordinary use.

class_policy:
  node:
    base: [common]       # applies to every node
    default: [router]    # applies to a node that carries no class label at all
  interface:
    default: [ip]
  group:
    default: [worker]
  • base classes apply to every object of that type, and sit below anything the object names itself: a class written on the object wins.
  • default classes stand in for a class the author did not write, so they count as the author's own.

The old names still work, with a warning, and will be removed in a later release.

Class System

A class is how you say something once and have it reach every object it applies to. Write the OSPF configuration for "a router" and every node labelled router gets it; add a router to the DOT file and it gets it too, with its own addresses filled in. Without classes there is nowhere to put configuration except on each object by name, which is the coupling topology-driven configuration exists to undo.

There is one class per kind of object, because what you want to say differs by kind. The sections below say what each is for.

NetworkClass Definition

Use it for something the lab has one of. An inventory of every node, a summary of the addresses handed out, a platform's own deployment file — anything whose content comes from the network as a whole rather than from one device. It is the only class that can aggregate over every node and every group.

networkclass:
  - name: main_network
    values:
      project: "example_network"
      version: "1.0"
    config:
      - file: network_info.txt
        template:
          - "# Network: {{ .project }} v{{ .version }}"

NodeClass Definition

Use it for what a device is. A router's routing configuration, the image it runs on, the files it needs — everything that follows from the role a device plays rather than from any one of its links. Most of what a topology writes ends up here.

nodeclass:
  - name: router
    use: [frrLogFile]          # pull in another class, module-provided or your own
    deploy: container          # container (default) | platform | none
    values:
      kind: linux
      image: quay.io/frrouting/frr:8.5.4
    interface_policy: [ip]
    params: [lo]
    collect: ["/var/log/frr.log"]   # copy out before the lab is destroyed
    config:
      - file: frr.conf
        template:
          - "router ospf"
          - " ospf router-id {{ .ip_loopback }}"

Key attributes:

  • use - Attach another class to whatever carries this one. It attaches, rather than overriding: the two classes' fields are combined by the ordinary rules, and a module's value loses to yours. See Module System
  • deploy - What the platform puts in place for this node:
    • container (the default) - a container of its own
    • platform - something the platform provides rather than deploys: a bridge on containerlab, a switches: entry on TiNET, a collision domain on Kathara
    • none - nothing. The node shapes the topology and is not deployed, and neither is any link to it
  • values - Default parameter values
  • interface_policy - IP assignment policies for interfaces
  • params - Parameter rules for automatic assignment
  • collect - Files to copy out of the node before the lab is destroyed. Each is a template. See File Output
  • config - Configuration template blocks

InterfaceClass Definition

Use it for what one end of a link needs. An address, the OSPF line that names the interface, a VLAN mode — anything written once per interface and repeated for every interface a node has. A node template then aggregates them with {{ .interfaces_<name> }} instead of looping.

interfaceclass:
  - name: vlan
    layers: [ip]  # Restrict to specific layers
    params: [vlan]
    config:
      - group: params.txt
        priority: -1  # Processing priority (lower = earlier)
        template:
          - "vlan {{ .vlan }} for {{ .node_name }}.{{ .name }}"

Key attributes:

  • deploy - What the interface is materialised as: link (an end of wiring the platform lays), logical (a device the node's own configuration builds), or none. Left out, it follows the connection the interface sits on, which is what you want unless the interface has no connection
  • layers - Specify which protocol layers this class applies to
  • priority - Control processing order within groups
  • group - Accumulate configuration blocks for later merging
  • neighbors - Reference adjacent interfaces for iterative configuration
  • classmembers - Reference objects in the same class for member-based configuration

ConnectionClass Definition

Use it when the two ends of a link have to agree. A VLAN id, a link cost, a shared identifier: put it on the connection and both interfaces read it through {{ .conn_<param> }}, so the two ends cannot drift apart. Written on each interface separately, they can.

connectionclass:
  - name: vlan_conn
    prefix: "vlan_trunk"  # Auto-naming prefix for connections
    params: [conn_id, vlan_id]  # Auto-assigned by parameter rules
    config:
      - group: network_config.txt
        priority: -2
        template:
          - "# VLAN Connection: {{ .name }} (ID: {{ .conn_id }}, VLAN: {{ .vlan_id }})"
          - "connection {{ .name }} type vlan_trunk vlan {{ .vlan_id }}"

Key attributes:

  • deploy - What the connection is materialised as: link (the default: a wire the platform lays), logical (a tunnel or overlay the configuration builds, reaching the same two ends without any wiring), or none
  • prefix - Automatic naming prefix (generates names like vlan_trunk0, vlan_trunk1)
  • params - Parameter rules for automatic value assignment
  • Connection templates use {{ .name }} for self-reference

SegmentClass Definition

Use it when more than two interfaces share one medium. A connection has two ends, so it cannot carry a value that everything on a switch must agree on — the VLAN id of a shared segment, say. A segment is what dot2net finds by following links through the switches between them, and a segment class is how you configure what it finds.

segmentclass:
  - name: network_segment
    layer: ip
    prefix: "net"  # Auto-naming prefix for segments
    params: [segment_id]  # Auto-assigned by parameter rules
    config:
      - name: network_entry
        template:
          - "- name: {{ .name }}"  # Auto-assigned segment name
          - "  vlan: auto"
          - "  nodes:"
          - "{{ .interfaces_segment_nodes }}"

Key attributes:

  • layer - Protocol layer for this segment type
  • prefix - Automatic naming prefix (generates names like seg0, seg1)
  • params - Parameter rules for automatic value assignment
  • Segments are automatically detected from network topology
  • Segment templates use {{ .name }} for self-reference
  • Used with relational class labels (segment#class_name)

GroupClass Definition

Use it for something a set of nodes shares. An AS number, an OSPF area, the machine a set of containers is deployed to. A group is drawn as a subgraph, and a group class configures it — including aggregating over its own members only, which is what makes a file per machine possible.

groupclass:
  - name: backbone_area
    virtual: false  # claims nothing; a group is never deployed anyway
    params: [ospf_area_id]  # Parameter rules for automatic assignment
    values:
      area_type: "backbone"
      priority: "high"
    config:
      - file: area_config.txt
        template:
          - "# OSPF Area {{ .ospf_area_id }} ({{ .area_type }})"
          - "area {{ .ospf_area_id }} authentication"

Key attributes:

  • virtual - Do not write this group's own configuration. A group has no deploy: nothing is ever put in place for one
  • params - Parameter rules for automatic assignment
  • values - Default parameter values
  • boundary_crossing_connection_class - A connection class for the links that leave a group of this class. Which links those are follows from the topology, so no edge carries an annotation saying it a second time

Placing nodes on machines: the worker group class

A lab larger than one machine is drawn as one graph, and the machines are subgraphs of a group class named worker — the one name dot2net reserves for a placement unit.

global:
  output_group_class: worker   # one output directory per machine

groupclass:
  - name: worker
    boundary_crossing_connection_class: crosses_host
graph {
  subgraph host1 { xlabel="worker"; r1; r2; }
  subgraph host2 { xlabel="worker"; r3; r4; }
  r2 -- r3;                     # this link leaves a machine
}

What follows from naming it:

  • Each platform writes one deployment file per machine — containerlab a topo.yaml, TiNET a spec.yaml, Kathara a lab.conf in a directory of its own. Each holds the nodes of that machine and the links it can wire itself; a link that leaves a machine appears in none of them, since no such file describes anything outside its own machine
  • A node in two worker groups is rejected — it cannot be in two places
  • A shared segment reaching across machines is replaced with one bridge per machine, linked to each other, so the segment costs one link leaving a machine instead of one per member on the far side. Each such link costs a VLAN from a finite pool, which is what makes the difference worth having. global.aggregate_crossing_links: false turns it off
  • On Kathara, joining the machines needs its bridge plugin. Its default collision domain driver puts no bridge on the host, so there is nothing for a cross-machine link to attach to — see Module: Kathara

The links that leave a machine are yours to make — that is what boundary_crossing_connection_class is for. topologies/ospf_multihost writes them into a file for whoever wires the machines together.

Template Description Styles

Two answers to the same question: how do the pieces of a file end up in the right order? A device's configuration is written in pieces — one per interface, one per neighbor, one for the node itself — and something has to decide where each piece lands. Naming the place explicitly is one way; sorting them into groups is the other. Which to pick is Template Assembly.

Overview: ConfigTemplate Coordination

ConfigTemplates can be coordinated in several ways:

Mechanism Purpose Description
Hierarchical Template embedding Embed child templates via {{ .self_xxx }} or {{ .interfaces_xxx }}
Sort Group aggregation Collect templates into groups, then merge with sorting
blocks.before/after Output ordering Control position of blocks relative to other templates
required_params Conditional output Skip entire block if parameters are missing
depends Processing order Worked out from what the template reads ({{ .self_xxx }}, and the blocks it merges). Written only for a reference no reading of the text can find

Typical combinations:

  • Hierarchical + blocks: Use Hierarchical for embedding, blocks for cross-class ordering (e.g., interface configs before router section)
  • Sort + blocks: Aggregate by priority, with additional ordering constraints
  • Any style + required_params: Add conditional output to optional configuration sections

Hierarchical Style (Standard)

Use it when you want to see, in the template, where the pieces go. The enclosing template names the hole — {{ .interfaces_ospf_net }} — and the pieces fill it. What the file looks like is readable from the template alone, which is why it is the default. The cost is that the enclosing template has to know the names of the pieces.

config:
  - file: ospfd.conf
    template:
      - "router ospf"
      - " ospf router-id {{ .RouterID }}"
      - " {{ .interfaces_ospf_network }}"  # Embedded template position

Characteristics:

  • Strict ordering: Template embedding positions must be explicitly defined
  • Precise relationships: Template dependencies are strictly managed
  • Complex control: Supports intricate dependency relationships

Advantages:

  • Strict order control
  • Clear template relationships
  • Accurate expression of complex dependencies

Disadvantages:

  • More complex to write
  • Requires detailed position specification

Sort Style

Use it when the pieces come from classes that do not know about each other. A BGP configuration assembled from several unrelated classes has no single template that could name them all. Instead each piece says which group it belongs to and where it sorts within it, and the file is assembled from the groups. The order is no longer visible in one place — that is what you trade.

Group Templates

Accumulate configuration blocks for later processing:

config:
  - group: "ospf6d.conf"
    priority: -1  # Order control via priority only
    template:
      - "log file /var/log/frr.log"

Sorter Templates

Merge and output accumulated group configurations:

config:
  - file: "ospf6d.conf"
    style: sort
    sort_group: "ospf6d.conf"  # References group name
    template:
      - "router ospf6"
      - " ospf6 router-id {{ .RouterID }}"

Characteristics:

  • Simple addition: Just add templates to defined groups
  • Loose management: Template relationships are loosely controlled
  • Priority-based ordering: Order control through priority values only

Advantages:

  • Simple description
  • Easy template addition
  • Flexible management approach

Disadvantages:

  • Difficult to express strict dependencies
  • Not suitable for complex order control

Named Templates (Hierarchical Mechanism)

How a template refers to another object's output. Give a config entry a name, and other objects can embed what it produced: {{ .interfaces_<name> }} for every interface of this node, {{ .self_<name> }} for another entry on the same object. This is what replaces a loop — the aggregation is the loop, and it is dot2net that runs it.

config:
  - name: "base_config"
    template:
      - "base settings"
  - name: "advanced_config"
    template:
      - "advanced settings"
      - "{{ .self_base_config }}"  # Template embedding: this is what orders the two

Dependency Requirements:

  1. Cross-object embedding (Parent-Child relationships):

    # NodeClass template embedding InterfaceClass templates
    template:
      - "interface configuration:"
      - "{{ .interfaces_config }}"  # No depends needed - hierarchy is clear
  2. Same-object embedding (Same-level templates):

    # Templates within the same object class
    config:
      - name: "base"
        template: ["base settings"]
      - name: "extended"
        template: ["{{ .self_base }}", "extended settings"]

    The embedding is what orders the two: a template that reads another's output needs it rendered first, and dot2net takes that from the reference. depends: used to be written here as well, saying the same thing a second time.

Purpose:

  • Essential for Hierarchical style: All template relationships require named templates
  • depends is rarely needed: the order is read from {{ .self_x }} and from the blocks a template merges. Write it only for a reference no reading of the text can find — a name built while rendering, {{ index . "self_x" }}
  • Parent-child class relationships have implicit dependencies through object hierarchy

Conditional Template Output (required_params)

The required_params field allows templates to be conditionally skipped when specified parameters are missing or empty:

nodeclass:
  - name: router
    config:
      # Always output
      - name: base_config
        template: ["hostname {{ .name }}"]

      # Only output if 'mem' parameter exists and is non-empty
      - name: memory_config
        required_params: [mem]
        template: ["memory {{ .mem }}"]

      # Only output if both 'cpus' AND 'sysctl' exist
      - name: resource_config
        required_params: [cpus, sysctl]
        template:
          - "cpus {{ .cpus }}"
          - "sysctl {{ .sysctl }}"

Behavior:

  • If all parameters in required_params exist and are non-empty → template is processed
  • If any parameter is missing or empty → entire template block is skipped
  • Empty string values are treated as "not exists"

Use cases:

  • Optional parameters (mem, cpus, sysctl) in container configs
  • Module-provided templates that depend on user-defined parameters
  • Avoiding if-statements in templates for cleaner, declarative configuration

There is no template-side alternative. {{ if .param }} is not a finer-grained version of this — a parameter that was never set is missing rather than empty, so the template fails the build on exactly the object the condition was written for. required_params is how a block says "only where this exists"; see Template System.

Note: required_params checks both regular parameters and relative parameters (e.g., self_startup, values_*).

Config Block Ordering (blocks.before and blocks.after)

The blocks field controls where a template's output is positioned relative to other templates:

nodeclass:
  - name: router
    config:
      # Main router config - other blocks position relative to this
      - name: router_base
        file: frr.conf
        template:
          - "router ospf"
          - " router-id {{ .ip_loopback }}"

interfaceclass:
  - name: default
    config:
      # Interface config should appear BEFORE router_base in frr.conf
      - name: interface_config
        file: frr.conf
        blocks:
          before: [router_base]
        template:
          - "interface {{ .name }}"
          - " ip address {{ .ip_addr }}/{{ .ip_plen }}"

      # Network statements should appear AFTER router_base
      - name: ospf_network
        file: frr.conf
        blocks:
          after: [router_base]
        template:
          - " network {{ .ip_net }} area 0"

Result in frr.conf:

interface eth0
 ip address 10.0.0.1/24
interface eth1
 ip address 10.0.0.5/24
router ospf
 router-id 10.255.0.1
 network 10.0.0.0/24 area 0
 network 10.0.0.4/24 area 0

Behavior:

  • blocks.before: [name1, name2] - This template's output appears before the listed templates
  • blocks.after: [name1, name2] - This template's output appears after the listed templates
  • Multiple templates can be listed; ordering is determined by dependency resolution
  • Works across different object classes (e.g., InterfaceClass referencing NodeClass templates)

Use cases:

  • FRR/Quagga configs where interface definitions must precede router sections
  • Any config format requiring specific section ordering
  • Separating logical config sections while maintaining correct output order

Relationship with depends:

  • depends controls processing order (template A must be evaluated before template B)
  • blocks.before/after controls output order (template A's output appears before/after template B's output)
  • Both can be used together when needed

Template Variables and Cross-Object References

Basic Parameters

Templates have access to a symmetric parameter namespace:

  • {{ .name }} - Object name
  • {{ .ip_addr }} - IP address
  • {{ .ip_plen }} - IP prefix length
  • {{ .vlan_id }} - VLAN identifier

Cross-Object References (New Specification)

Templates can reference related objects with consistent prefixes:

Interface Templates Referencing Connections

template:
  - "interface {{ .name }}"
  - " description Connected via {{ .conn_name }}"
  - " vlan {{ .conn_vlan_id }}"

Interface Templates Referencing Nodes

template:
  - "interface {{ .name }}"
  - " description Interface on {{ .node_name }}"
  - " ip address {{ .ip_addr }}/{{ .ip_plen }}"

Connection Templates (Self-Reference)

template:
  - "connection {{ .name }}"
  - " type vlan_trunk"
  - " vlan {{ .vlan_id }}"

Variable Naming Rules

  • Connection templates: Use {{ .name }} for self-reference
  • Interface templates: Use {{ .conn_name }} for connection reference
  • Consistent prefixing: All connection references use conn_ prefix, node references use node_ prefix

Configuration Processing Flow

Read this when a template gets a value you did not expect. The order below is what decides which parameters exist by the time your template runs, and a template that reads something assigned later than itself sees nothing. depends is how a config entry says it must come after another.

1. Parameter Assignment Order

Network → Node → Connection → Interface → Group → Segment

Critical: Connection parameters must be assigned before Interface parameters to enable cross-object references.

2. Template Processing Order

  1. Dependency Resolution: reorderConfigTemplates resolves template dependencies
  2. Group Processing: Group templates accumulate configuration blocks
  3. Sorter Processing: Sorter templates merge and output final configurations
  4. Priority Control: Lower priority values (-3, -2, -1) process first

3. Cross-Object Reference Setup

  • Interface namespace includes connection parameters with conn_ prefix
  • Interface namespace includes node parameters with node_ prefix
  • References are established during parameter assignment phase

Best Practices

Class Design

  1. Nodes that shape the model without being deployed: deploy: none
  2. Modular Extension: Use multiple classes for additional functionality
  3. Clear Naming: Use descriptive class names that reflect their purpose

Template Design

  1. Deterministic Templates: Avoid control syntax (for, if) - use parameter symmetry instead
  2. Consistent Prefixing: Use conn_ and node_ prefixes for cross-object references
  3. Priority Management: Use negative priorities for foundational configurations

Parameter Management

  1. Automatic Assignment: Leverage parameter rules for consistent value assignment
  2. Manual Override: Use direct value assignment in DOT files when needed
  3. Cross-Object Consistency: Maintain consistent naming across related objects

Example: Complete VLAN Configuration

YAML Configuration

nodeclass:
  - name: router
    values:
      kind: linux
      image: quay.io/frrouting/frr:8.5.4
    params: [lo]

connectionclass:
  - name: vlan_conn
    prefix: "vlan_trunk"
    params: [conn_id, vlan_id]
    config:
      - group: network_config.txt
        priority: -2
        template:
          - "# Connection: {{ .name }} VLAN {{ .vlan_id }}"

interfaceclass:
  - name: trunk_interface
    config:
      - group: interface_config.txt
        priority: -1
        template:
          - "interface {{ .name }}"
          - " description Trunk to {{ .opp_node_name }} via {{ .conn_name }}"
          - " switchport mode trunk"
          - " switchport trunk allowed vlan {{ .conn_vlan_id }}"

segmentclass:
  - name: trunk_segment
    layer: ip
    config:
      - group: network_config.txt
        priority: -3
        template:
          - "# Trunk Segment: {{ .segment_name }} with {{ .segment_interface_count }} interfaces"

DOT Usage

digraph {
    r1 [xlabel="router"];
    r2 [xlabel="router"];

    r1 -> r2 [
        dir="none",
        class="vlan_conn; segment#trunk_segment",
        taillabel="trunk_interface",
        headlabel="trunk_interface"
    ];
}

This configuration demonstrates the complete workflow from class definition to cross-object references, showcasing dot2net's powerful template and parameter system.

Advanced Object References

These exist so that "one line per neighbor" can be written as one line. A BGP router needs a neighbor statement for each peer, and a template cannot loop. So dot2net makes an object per peer, the template is written for one of them, and the node gathers what they produced. Same for members of a class.

Which to use: neighbors when the set is "whatever is on the other end of my links", members when it is "everything carrying this class".

Neighbor References (Interface-only)

Interfaces can reference adjacent interfaces through neighbors configuration for iterative template processing:

interfaceclass:
  - name: ospf_interface
    neighbors:
      - layer: ip  # Process neighbors on IP layer
        config:
          - name: neighbor_config
            template:
              - "# Neighbor: {{ .opp_node_name }} via {{ .conn_name }}"
              - "neighbor {{ .opp_ip_addr }} area {{ .ospf_area }}"

Key features:

  • Interface-specific: Only InterfaceClass supports neighbors
  • Layer-based: Process neighbors on specific protocol layers
  • Automatic iteration: Template executes for each adjacent interface
  • Neighbor parameters: Access {{ .opp_* }} parameters for opposite interface

Class Member References (Multi-object)

Multiple object types can reference other objects in the same class through classmembers:

Node Class Members

nodeclass:
  - name: router_cluster
    classmembers:
      - nodes: ["router"]  # Reference other nodes with class "router"
        config:
          - name: cluster_peers
            template:
              - "# Cluster peer: {{ .name }} at {{ .ip_loopback }}"
              - "peer {{ .ip_loopback }} cluster-member"

Interface Class Members

interfaceclass:
  - name: trunk_ports
    classmembers:
      - interfaces: ["trunk_interface"]  # Reference interfaces in same class
        include_self: false  # the default; the peers are the other interfaces
        config:
          - name: trunk_aggregation
            template:
              - "# Trunk peer: {{ .node_name }}.{{ .name }}"
              - "trunk-peer {{ .node_name }} interface {{ .name }}"

Connection Class Members

connectionclass:
  - name: vlan_connection
    classmembers:
      - connections: ["vlan_conn"]  # Reference connections in same class
        config:
          - name: vlan_coordination
            template:
              - "# VLAN peer connection: {{ .name }} VLAN {{ .vlan_id }}"

Segment Class Members

segmentclass:
  - name: network_segment
    classmembers:
      - nodes: ["router"]  # Reference nodes within the segment
        config:
          - name: segment_routing
            template:
              - "# Segment node: {{ .name }} in segment {{ .segment_name }}"
              - "segment-id {{ .segment_id }} node {{ .name }}"

Supported object types for classmembers:

  • NodeClass: Can reference nodes, interfaces, connections
  • InterfaceClass: Can reference nodes, interfaces, connections
  • ConnectionClass: Can reference nodes, interfaces, connections
  • SegmentClass: Can reference nodes, interfaces, connections

Key attributes:

  • nodes / interfaces / connections - Specify which object classes to reference
  • node / interface / connection - Single class name (alternative syntax)
  • include_self - Whether the referring object is itself among the members (default: false). The object doing the referring belongs to the class it names, so without this a node writing a line per peer writes one naming itself. Set it to true where iterating over every member including this one is what you want
  • config - Template blocks that execute for each referenced object

Clone this wiki locally