-
Notifications
You must be signed in to change notification settings - Fork 1
YAML Configuration
This page explains the YAML configuration system in dot2net, including class definitions, template syntax, and configuration patterns.
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.
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 itKey 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
- Default (
-
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 subgraphlabelused only as a caption)
-
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.
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: truein global settings, uses NodeClass prefix
| 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.
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...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.- 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
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 filenameWhere 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 resultTwo-Phase Processing:
-
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
-
-
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):
-
lineprefix→format_lineprefix -
linesuffix→format_linesuffix -
lineseparator→format_lineseparator -
blockprefix→format_blockprefix -
blocksuffix→format_blocksuffix -
blockseparator→merge_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 resultProcessing 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.afterfeatures that merge child configs
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/24Key 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 }}"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 own — mgmt_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 givenWithout 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: eth0This creates an eth0 management interface on each node, addressed from
10.255.255.0/24.
containerlab keeps
eth0for 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 thaneth0, through an interface classprefix; dot2net says so while generating rather than letting containerlab refuse at deploy time.
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 fileKey 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 whenassign: "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)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 outputComplete 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 generationLoad 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 helpersSettings 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. |
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: trueused to mean "not deployed" as well. It no longer does — writedeploy: nonefor that. Until this migration is over, a class that setsvirtual: truewithout also naming adeployis 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 itWhether 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.
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: winsWhat "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 |
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]-
baseclasses apply to every object of that type, and sit below anything the object names itself: a class written on the object wins. -
defaultclasses 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.
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.
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 }}"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, aswitches: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
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), ornone. 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
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), ornone -
prefix- Automatic naming prefix (generates names likevlan_trunk0,vlan_trunk1) -
params- Parameter rules for automatic value assignment - Connection templates use
{{ .name }}for self-reference
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 likeseg0,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)
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 nodeploy: 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
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_hostgraph {
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 aspec.yaml, Kathara alab.confin 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
workergroups 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: falseturns 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.
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.
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
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 positionCharacteristics:
- 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
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.
Accumulate configuration blocks for later processing:
config:
- group: "ospf6d.conf"
priority: -1 # Order control via priority only
template:
- "log file /var/log/frr.log"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
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 twoDependency Requirements:
-
Cross-object embedding (Parent-Child relationships):
# NodeClass template embedding InterfaceClass templates template: - "interface configuration:" - "{{ .interfaces_config }}" # No depends needed - hierarchy is clear
-
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
-
dependsis 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
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_paramsexist 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_*).
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:
-
dependscontrols processing order (template A must be evaluated before template B) -
blocks.before/aftercontrols output order (template A's output appears before/after template B's output) - Both can be used together when needed
Templates have access to a symmetric parameter namespace:
-
{{ .name }}- Object name -
{{ .ip_addr }}- IP address -
{{ .ip_plen }}- IP prefix length -
{{ .vlan_id }}- VLAN identifier
Templates can reference related objects with consistent prefixes:
template:
- "interface {{ .name }}"
- " description Connected via {{ .conn_name }}"
- " vlan {{ .conn_vlan_id }}"template:
- "interface {{ .name }}"
- " description Interface on {{ .node_name }}"
- " ip address {{ .ip_addr }}/{{ .ip_plen }}"template:
- "connection {{ .name }}"
- " type vlan_trunk"
- " vlan {{ .vlan_id }}"-
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 usenode_prefix
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.
Network → Node → Connection → Interface → Group → Segment
Critical: Connection parameters must be assigned before Interface parameters to enable cross-object references.
-
Dependency Resolution:
reorderConfigTemplatesresolves template dependencies - Group Processing: Group templates accumulate configuration blocks
- Sorter Processing: Sorter templates merge and output final configurations
- Priority Control: Lower priority values (-3, -2, -1) process first
- Interface namespace includes connection parameters with
conn_prefix - Interface namespace includes node parameters with
node_prefix - References are established during parameter assignment phase
-
Nodes that shape the model without being deployed:
deploy: none - Modular Extension: Use multiple classes for additional functionality
- Clear Naming: Use descriptive class names that reflect their purpose
-
Deterministic Templates: Avoid control syntax (
for,if) - use parameter symmetry instead -
Consistent Prefixing: Use
conn_andnode_prefixes for cross-object references - Priority Management: Use negative priorities for foundational configurations
- Automatic Assignment: Leverage parameter rules for consistent value assignment
- Manual Override: Use direct value assignment in DOT files when needed
- Cross-Object Consistency: Maintain consistent naming across related objects
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"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.
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".
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
Multiple object types can reference other objects in the same class through classmembers:
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"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 }}"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 }}"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 totruewhere iterating over every member including this one is what you want -
config- Template blocks that execute for each referenced object