Skip to content

YAML Configuration

sat edited this page Aug 15, 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" node0, node1, node2

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

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:
      - name: 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.

virtual: what is not deployed

virtual: true says the object shapes the topology without being deployed. What that means depends on what carries it, and each is decided on its own:

On a What it means
node class Nothing is deployed for the node — and no link to it is either, since there is nothing to connect. It still holds parameters and still shapes the topology, which is what makes it useful for laying out addresses. deploy: none is the fuller spelling
interface class The interface is not written into the platform's file. It does not remove the link it sits on — whether a link is emitted follows from the connection, not from this
connection class The connection is not written as a link. This is what a shared medium modelled without a real link uses

Changed in 0.8.0. A virtual interface used to take its connection with it, so marking an interface virtual silently removed the link. It no longer does. A topology that relied on that gets the link — say it on the connection instead.

Whether a platform is told to make a link is a property of the template that writes it (required_link), not of virtual. See ConfigTemplate Field Reference.

virtual: false claims nothing at all, which is how the boolean has always behaved: it does not override a class that says deploy: none. Writing both virtual: true and a deploy that contradicts it is an error.

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. The exception is a hook name, where a module's part and the topology's are merged on purpose
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
    • virtual: true is the older spelling of deploy: none and still works
  • 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:

  • 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:

  • 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  # a group that shapes parameters without being deployed
    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 - Exclude from final output if true
  • 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:

  • containerlab writes one topo.yaml per machine, and TiNET one spec.yaml. Each holds the nodes of that machine and the links it can wire itself; a link that leaves a machine appears in neither, since neither 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
  • Kathara cannot express this at all and says so: a lab.conf describes one machine

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 System — Template Assembly Approaches.

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 Required for same-object template embedding (e.g., {{ .self_xxx }})

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"
    depends: ["base_config"]  # Required for same-object template dependencies
    template:
      - "advanced settings"
      - "{{ .base_config }}"  # Template embedding

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"
        depends: ["base"]  # Required - processing order matters
        template: ["{{ .base }}", "extended settings"]

Purpose:

  • Essential for Hierarchical style: All template relationships require named templates
  • depends required only for same-object template dependencies (reorderConfigTemplates processing)
  • 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

Comparison with template if-statements:

Approach Pros Cons
required_params Declarative, no template logic Block-level only
{{ if .param }} Fine-grained control Template becomes complex

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. Virtual Nodes: Use virtual: true for topology modeling without deployment
  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  # Exclude current interface from iteration
        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 to include the current object in iteration (default: true)
  • config - Template blocks that execute for each referenced object

Clone this wiki locally