Skip to content

Template System

sat edited this page Aug 15, 2026 · 23 revisions

Template System

One template, written once, becomes every device's configuration. You write what a router of a given kind needs — router ospf, an address on an interface, a BGP session to a neighbour — with the parts that differ per device left as {{ }}. dot2net fills them in from the topology, so adding a router means adding a line to the DOT file and nothing else.

This page is about the filling in: what a template can reach, how to reach the objects around it, and how many small blocks become one file.

Overview

Templates are Go text/template, used for {{ }} substitution only.

if and range are not how you write conditions and loops here. A parameter that is missing is an error rather than an empty value, so a conditional fails on exactly the objects it was written for, and there is nothing for range to walk. Both needs are answered another way, and both are worth knowing before you start:

You want Write
The same block for every interface of a node, gathered into one place aggregation{{ .interfaces_<name> }}
A block that appears only for the objects that have something to say required_params
A value from a neighbouring object, not this one a cross-object prefix{{ .node_* }}, {{ .conn_* }}, {{ .opp_* }}
One block per neighbour, or per member of a class neighbor and member objects

What a template can see: the namespace

Every object — node, interface, connection, group — carries a set of named values, and a template attached to that object can read any of them. That set is its namespace, and knowing what is in it is most of writing a template.

It holds four kinds of thing:

  • the object's own{{ .name }}, {{ .ip_addr }}
  • the objects it belongs to{{ .node_name }}, {{ .group_as }}
  • the objects it touches{{ .opp_ip_addr }} (the far end), {{ .conn_vlan_id }} (the connection)
  • objects generated for it{{ .n_node_as }} (a neighbour), {{ .m_ipv4_net }} (a class member)

Do not guess what is in it: dot2net params

Rather than writing a name and finding out at build time whether it exists, ask. dot2net params prints the namespace of every object as dot2net actually built it.

Draft, run dot2net params, write against what it printed, then dot2net build. Filtering options are in the Command Reference.

Where the values come from

A namespace is built in two stages: first each object collects what is its own, then it gains what its neighbours have. Reading them in that order is the fastest way to predict what a template can use — and to know which file to edit when a value you want is missing.

Stage 1: what an object holds by itself

Five sources, and each is somewhere different:

Source Where it comes from Edit it in
Automatic dot2net assigns it from the topology nowhere — it follows the graph
Address an address policy hands it out layer: / policy:
DOT label you wrote it on the node or edge the DOT file
Class value a class's values: the YAML
Parameter rule a param_rule computes it the YAML

1. Automatic Parameters (Generated by dot2net)

These parameters are automatically assigned by dot2net based on object structure and naming rules:

Node examples:

{{ .name }}          // Node name (e.g., "r1", "r2")

Interface examples:

{{ .name }}          // Interface name (e.g., "eth0", "eth1")
{{ .node_name }}     // Parent node name (e.g., "r1")
{{ .opp_name }}      // Opposite interface name (e.g., "eth0")
{{ .opp_node_name }} // Opposite node name (e.g., "r2")

Connection examples:

{{ .name }}          // Connection name (e.g., "r1--r2", "vlan_trunk0")
{{ .conn_id }}       // Connection ID (e.g., "0", "1")

2. IP Address Related Parameters (Calculated by address policies)

These parameters are generated based on layer policies and automatic IP assignment:

Interface IP parameters:

{{ .ip_addr }}       // IP address (e.g., "10.0.0.1", "fc00::1")
{{ .ip_plen }}       // Prefix length (e.g., "24", "64")
{{ .ip_net }}        // Network address (e.g., "10.0.0.0/24")
{{ .opp_ip_addr }}   // Opposite interface IP (e.g., "10.0.0.2")

Node IP parameters:

{{ .ip_loopback }}   // Loopback IP (e.g., "10.255.0.1")
{{ .ipv4_loopback }} // IPv4 loopback (dual-stack topologies)
{{ .ipv6_loopback }} // IPv6 loopback (dual-stack topologies)

3. DOT File Parameters (User-specified in topology)

These parameters come from DOT file labels, including Value Labels and custom attributes:

Value Labels (name=value syntax):

r1 [xlabel="router"; stub_network="192.168.1.0/24"];
r2 -> r3 [label="trunk"; vlan="100"];

Resulting parameters:

{{ .stub_network }}  // "192.168.1.0/24" (from DOT Value Label)
{{ .vlan }}          // "100" (from DOT edge label)

Place Labels (@name syntax):

r1 [xlabel="router"; @region="us-west"];
r2 [xlabel="router"; @region="us-east"];

Cross-references:

{{ .region }}        // "us-west" (for r1), "us-east" (for r2)

4. YAML Configuration Parameters (User-defined class properties)

These parameters come from class definitions in YAML configuration files:

From NodeClass values:

nodeclass:
  - name: router
    values:
      kind: linux
      image: quay.io/frrouting/frr:8.5.4
      mgmt_ip: dhcp

Resulting parameters:

{{ .kind }}          // "linux"
{{ .image }}         // "quay.io/frrouting/frr:8.5.4"
{{ .mgmt_ip }}       // "dhcp"

From Group parameters:

groupclass:
  - name: as65001
    params: [as]
    values:
      as: 65001
      region: us-west

Resulting parameters:

{{ .group_as }}      // "65001" (inherited from group)
{{ .group_region }}  // "us-west" (inherited from group)

5. Policy-Driven Parameters (Generated by parameter rules)

These parameters are automatically generated based on parameter rules and assignment policies:

Parameter rules definition:

param_rule:
  - name: vlan_id
    min: 100
    max: 199
    assign: segment
    layer: switching

Resulting parameters:

{{ .vlan_id }}       // "100", "101", "102"... (auto-assigned by segment)
{{ .conn_vlan_id }}  // VLAN ID from connection (cross-reference)

AS number assignment:

param_rule:
  - name: as
    min: 65000
    max: 65535

Resulting parameters:

{{ .as }}            // "65000", "65001"... (auto-assigned to groups)
{{ .group_as }}      // AS number inherited from group
{{ .opp_group_as }}  // Opposite node's AS number

Object-Specific Parameter Examples

Node Object Parameters:

{{ .name }}          // Auto: Node name (e.g., "r1")
{{ .ip_loopback }}   // IP: Loopback address (e.g., "10.255.0.1")
{{ .kind }}          // YAML: Container type (e.g., "linux")
{{ .image }}         // YAML: Container image (e.g., "quay.io/frrouting/frr:8.5.4")
{{ .group_as }}      // YAML: AS number from group (e.g., "65001")

Interface Object Parameters:

{{ .name }}          // Auto: Interface name (e.g., "eth0")
{{ .ip_addr }}       // IP: Interface IP (e.g., "10.0.0.1")
{{ .ip_plen }}       // IP: Prefix length (e.g., "24")
{{ .vlan_tag }}      // DOT: VLAN tag from Value Label

Connection Object Parameters:

{{ .name }}          // Auto: Connection name (e.g., "vlan_trunk0")
{{ .conn_id }}       // Auto: Connection ID (e.g., "0")
{{ .vlan_id }}       // Policy: Auto-assigned VLAN (e.g., "100")

Group Object Parameters:

{{ .name }}          // Auto: Group name (e.g., "as65001")
{{ .as }}            // Policy: Auto-assigned AS number (e.g., "65001")
{{ .region }}        // YAML: User-defined region (e.g., "us-west")

Stage 2: what an object gains from the objects around it

Most configuration needs a value the object does not own. An address on an interface needs the prefix length the segment agreed on; a BGP session needs the far end's AS number; a network statement needs the subnet the connection was given. Writing those by hand is what topology-driven configuration exists to avoid, so dot2net puts them in the namespace too, under a prefix that says where each came from.

The prefixes, and what each is for

Direct Relationships (always available):

  • Interface → Node: node_ prefix accesses parent node parameters
  • Interface → Connection: conn_ prefix accesses connection parameters
  • Interface → Opposite Interface: opp_ prefix accesses peer interface parameters

Iterative Relationships (generated dynamically):

  • Neighbor Objects: n_ prefix for adjacent interface parameters (requires neighbors definition)
  • Member Objects: m_ prefix for same-class object parameters (requires classmembers definition)

Common prefix examples:

{{ .node_name }}      // Parent node name
{{ .node_image }}     // Parent node container image
{{ .conn_name }}      // Connection name
{{ .conn_vlan_id }}   // Connection VLAN ID
{{ .opp_ip_addr }}    // Opposite interface IP
{{ .opp_node_name }}  // Opposite node name
{{ .n_ip_addr }}      // Neighbor interface IP (in neighbor templates)
{{ .m_ipv4_net }}     // Member network (in member templates)

Complete Namespace Example

For interface r1.eth0 in a BGP topology, the complete namespace combines:

Stage 1 (Individual parameters):

name: eth0              // Auto: Interface name
ip_addr: 10.0.0.1      // IP: Interface address
ip_plen: 24            // IP: Prefix length

Stage 2 (Cross-object additions):

# From parent node (node_ prefix)
node_name: r1          // Parent node name
node_image: quay.io/frrouting/frr:8.5.4  // Parent node image
node_group_as: 65001   // Parent node's group AS

# From connection (conn_ prefix)
conn_name: r1--r2      // Connection name
conn_vlan_id: 100      // Connection VLAN (if applicable)

# From opposite interface (opp_ prefix)
opp_name: eth0         // Opposite interface name
opp_ip_addr: 10.0.0.2  // Opposite interface IP
opp_node_name: r2      // Opposite node name
opp_node_group_as: 65000  // Opposite node's AS

Final result: Rich namespace enabling complex BGP neighbor configuration:

template:
  - "interface {{ .name }}"
  - "ip address {{ .ip_addr }}/{{ .ip_plen }}"
  - "router bgp {{ .node_group_as }}"
  - "neighbor {{ .opp_ip_addr }} remote-as {{ .opp_node_group_as }}"

Real Example: Complete Namespace Formation

From topologies/basic_bgp/, interface r1.eth0 demonstrates the complete 2-stage namespace formation:

Stage 1 - Individual object parameters:

interface:r1.eth0
  # Automatic parameters
  name: eth0

  # IP-related parameters
  ip_addr: 10.0.0.1
  ip_plen: 24
  ip_net: 10.0.0.0/24

Stage 2 - Cross-object relationship additions:

interface:r1.eth0
  # From parent node (node_ prefix)
  node_name: r1
  node_kind: linux
  node_image: quay.io/frrouting/frr:8.5.4
  node_group_as: 65001

  # From opposite interface (opp_ prefix)
  opp_name: eth0
  opp_ip_addr: 10.0.0.2
  opp_node_name: r2
  opp_node_group_as: 65000

  # From connection (conn_ prefix - if applicable)
  conn_name: r1--r2

This rich namespace enables the BGP interface template to access all necessary information for complete neighbor configuration.

Writing the template itself

The whole of the syntax

There is very little to learn: text, with {{ .name }} where a value goes.

config:
  - name: startup
    template:
      - "hostname {{ .name }}"
      - "ip address {{ .ip_addr }}/{{ .ip_plen }}"

Variable Access Patterns

Direct property access:

{{ .name }}          // Object name
{{ .ip_addr }}       // IP address
{{ .ip_plen }}       // IP prefix length
{{ .image }}         // Container image (nodes)

Conditional rendering:

{{ if .loopback }}          {{/* does not work - see below */}}
loopback {{ .ip_loopback }}
{{ end }}

if and range do not work here, and the reason is worth knowing.

Every parameter is a string, and a parameter that was never set is missing, not empty. A template that reads a missing key fails the build:

map has no entry for key "loopback"

So {{ if .loopback }} builds on a node that has a loopback and fails on the node that does not — the one case a conditional exists for. And {{ range .interfaces }} fails always: there is no list to walk, only strings.

What to write instead:

Instead of Write
a loop over an object's children an aggregation: {{ .interfaces_<name> }} gathers what every interface produced
a conditional on a parameter required_params on the config entry — the whole block is skipped when the parameter is absent
a conditional on a role a class. Objects that carry it get the block; objects that do not, do not

This is not a limitation dot2net works around — it is what deterministic templating means. A template that cannot branch produces the same text for the same object every time, and what varies is decided by the model rather than by logic embedded in the text.

Passing {{ }} through to another tool

A generated file is sometimes itself a template for another tool — TENTOU's infra.yml holds {{ip.r1.eth0}}, and Ansible and Helm use the same marks. The braces then have to survive dot2net untouched.

What happens if you just write them:

What you write What happens
{{ip.r1.eth0}} An error while the config is read (function "ip" not defined). You find out
{{ .name }}, meant literally Filled in without a word — it becomes r1. No error, no warning
the same in a sourcefile: Also filled in. Reading from a file is not a way past this

The second row is the dangerous one: the file is generated, looks right, and carries a value dot2net chose where the downstream tool was meant to choose one.

Two ways to write it out:

- '{{ printf "{{ip.%s.eth0}}" .name }}'      # easier to read
- '{{"{{"}}ip.{{ .name }}.eth0{{"}}"}}'      # the plain form

Or take the marks away from dot2net for that file entirely:

config:
  - file: infra.yml
    delimiters: ["[[", "]]"]     # dot2net reads [[ ]], and {{ }} passes through
    template:
      - "bindip: '{{ip.r1.eth0}}'"
      - "name: [[ .name ]]"

And for a file dot2net has nothing to fill in at all, raw: true hands the source file through as it is. See ConfigTemplate Field Reference.

This is a sharp edge, not a feature: whether a literal {{ }} survives depends on the author noticing. delimiters and raw exist because escaping by hand is easy to get wrong.

One line per neighbour: referential objects

Some configuration has one line per other device, and you do not know how many there are. A BGP section needs a neighbor line for each peer; a static route needs one line per destination; a route reflector needs one per client. The count follows from the graph, and it changes when the graph does — which is exactly what you must not have to write out by hand.

Two mechanisms cover it, differing in what they count:

You want one block per Use Reached with
Adjacent device on a layer — whoever ends up next to this one neighbor {{ .n_* }}
Object carrying a given class — whoever is in this set, adjacent or not member {{ .m_* }}

Both produce a block per object found, each with the writing object's whole namespace plus the found object's values under the prefix. Both produce nothing when nothing is found, so a router with no peers writes no neighbor lines rather than an empty section.

Neighbor: one block per adjacent interface

Adjacency is per layer, so a topology carrying IPv4 and IPv6 on the same wires counts them separately.

Definition Syntax

interfaceclass:
  - name: ospf_interface
    neighbors:
      - layer: ip  # Network layer for adjacency
        config:
          - name: static_routes
            node: router  # Target node class for config block
            template:
              - "ipv6 route {{ .n_node_stubnet }} {{ .n_ip_addr }}"

Behavior

  1. For each interface with class ospf_interface
  2. Find adjacent interfaces in the ip layer
  3. Generate one config block per adjacent interface
  4. Add to target node (specified by node: router)

Real Example from ospf6_topo1

interfaceclass:
  - name: to_stub
    neighbors:
      - layer: ip
        config:
          - group: staticd.conf
            node: router
            template:
              - "ipv6 route {{ .n_node_stubnet }} {{ .n_ip_addr }}"
              - "!"

Result: For each to_stub interface, generates static routes pointing to neighbor stub networks.

Member: one block per object carrying a class

Where a neighbor follows the wires, a member follows a class: it finds every object that carries the named one, whether or not it is next to the writer. That is what a BGP speaker advertising its own networks needs — the networks are whichever interfaces the topology marked as advertised, and they are not adjacent to anything.

Definition Syntax

interfaceclass:
  - name: bgp_peer
    classmembers:
      - interface: advertised_networks  # Target class name
        config:
          - name: network_advertisement
            template:
              - "  network {{ .m_ipv4_net }}"

Behavior

  1. For each interface with class bgp_peer
  2. Find all interfaces with class advertised_networks
  3. Generate one config block per found interface
  4. Merge into parent object's configuration

Real Example from bgp_features

interfaceclass:
  - name: ibgp
    config:
      - name: ibgp_afconf
        node: bgp
        template:
          - "{{ .neighbors_ipv4_ibgp_afconf_nb }}"
          - "{{ .members_interface_adv_ibgp_afconf_adv }}"

    classmembers:
      - interface: adv
        config:
          - name: ibgp_afconf_adv
            template:
              - "  network {{ .m_ipv4_net }}"  # advertised network

Result: For each ibgp interface, includes network advertisements from all adv class interfaces.

Namespace Inheritance Pattern

Neighbor Namespace

Neighbor Object Namespace = Parent Interface Namespace + Neighbor-specific Parameters

Example: Interface r1.eth0 with neighbor r2.eth0

  • Inherited: {{ .name }} = r1.eth0, {{ .ip_addr }} = 10.0.0.1
  • Neighbor-specific: {{ .n_name }} = r2.eth0, {{ .n_ip_addr }} = 10.0.0.2

Member Namespace

Member Object Namespace = Parent Object Namespace + Member-specific Parameters

Example: Interface r1.eth0 with member r3.adv0

  • Inherited: {{ .name }} = r1.eth0, {{ .node_as }} = 65001
  • Member-specific: {{ .m_name }} = r3.adv0, {{ .m_ipv4_net }} = 192.168.3.0/24

Advanced Examples

Complex BGP Configuration

interfaceclass:
  - name: ibgp_peer
    config:
      - name: bgp_base
        node: bgp
        template:
          - "router bgp {{ .node_as }}"
          - "{{ .neighbors_ipv4_ibgp_neighbor_config }}"
          - "{{ .members_interface_adv_network_config }}"

    # Generate neighbor configurations
    neighbors:
      - layer: ipv4
        config:
          - name: neighbor_config
            node: bgp
            template:
              - " neighbor {{ .n_node_ipv4_loopback }} remote-as {{ .n_node_as }}"
              - " neighbor {{ .n_node_ipv4_loopback }} update-source lo"
              - " neighbor {{ .n_node_ipv4_loopback }} description {{ .n_node_name }}"

    # Include advertised networks from other interfaces
    classmembers:
      - interface: adv
        config:
          - name: network_config
            template:
              - "  network {{ .m_ipv4_net }}"

Configuration flow:

  1. Base template sets up BGP router and references neighbor/member configs
  2. Neighbor templates generate one neighbor statement per adjacent router
  3. Member templates generate one network statement per advertising interface
  4. Final config combines all generated blocks into complete BGP configuration

Multi-Layer Neighbor Configuration

interfaceclass:
  - name: dual_stack
    neighbors:
      - layer: ipv4
        config:
          - name: ipv4_neighbor
            template:
              - "neighbor {{ .n_ipv4_addr }} description IPv4-{{ .n_node_name }}"

      - layer: ipv6
        config:
          - name: ipv6_neighbor
            template:
              - "neighbor {{ .n_ipv6_addr }} description IPv6-{{ .n_node_name }}"

Result: Generates separate neighbor configurations for both IPv4 and IPv6 layers.

Best Practices for Referential Objects

1. Use Descriptive Names

# Good
neighbors:
  - layer: ip
    config:
      - name: ospf_neighbor_hello
      - name: static_route_to_stub

# Avoid
neighbors:
  - layer: ip
    config:
      - name: config1
      - name: template

2. Layer-Specific Configurations

# Use different layers for different protocols
neighbors:
  - layer: ipv4
    config:
      - name: bgp_ipv4_neighbor
  - layer: ipv6
    config:
      - name: bgp_ipv6_neighbor

3. Members that only sometimes contribute

A member that should produce nothing unless a parameter is set says so with required_params. The whole block is skipped when the parameter is absent — which a conditional could not do, since reading a missing parameter fails the build.

classmembers:
  - interface: advertised_routes
    config:
      - name: route_advertisement
        required_params: [m_advertise]
        template:
          - "  network {{ .m_ipv4_net }}"

4. Combine with Group Templates

interfaceclass:
  - name: ospf_interface
    neighbors:
      - layer: ip
        config:
          - group: ospf_neighbors  # Collect all neighbor configs
            template:
              - "neighbor {{ .n_ip_addr }} area {{ .n_ospf_area }}"

# Later processed by sorter template
nodeclass:
  - name: router
    config:
      - file: ospf.conf
        style: sort
        sort_group: ospf_neighbors

Cross-Object Reference Examples

BGP Neighbor Configuration

interfaceclass:
  - name: bgp_interface
    config:
      - name: frr_cmds
        template:
          - "interface {{ .name }}"
          - "ip address {{ .ip_addr }}/{{ .ip_plen }}"
          - "router bgp {{ .node_group_as }}"
          - "neighbor {{ .opp_ip_addr }} remote-as {{ .opp_node_group_as }}"

Variable breakdown:

  • {{ .node_group_as }} - Parent node's AS number from group
  • {{ .opp_ip_addr }} - Opposite interface IP address
  • {{ .opp_node_group_as }} - Opposite node's AS number

VLAN Trunk Configuration

connectionclass:
  - name: vlan_trunk
    prefix: "vlan_trunk"
    params: [vlan_id]

interfaceclass:
  - name: trunk_port
    config:
      - name: switch_config
        template:
          - "interface {{ .name }}"
          - "switchport mode trunk"
          - "switchport trunk allowed vlan {{ .conn_vlan_id }}"
          - "description {{ .conn_name }} (VLAN {{ .conn_vlan_id }})"

The prefixes, in one table

When a template needs a value it does not own, the prefix says where to look. The whole vocabulary:

Written Reads
{{ .name }} the object's own — its name, address, or anything a class gave it
{{ .node_* }} the node this interface belongs to
{{ .conn_* }} the connection this interface sits on
{{ .opp_* }} the interface at the far end
{{ .opp_node_* }} the node at the far end
{{ .group_* }} a group the object belongs to — an AS number, an OSPF area
{{ .n_* }} a neighbour, inside its block
{{ .m_* }} a class member, inside its block

Values reach an object before it is asked for them: a group's parameters are there for its nodes, a node's for its interfaces, and a connection's for both of its ends. Nothing has to be passed along by hand.

Many small blocks, one file

A device's configuration is written in pieces and has to come out as one file. The address lines belong to the interfaces, the OSPF section belongs to the node, the network statements belong to whichever interfaces the topology marked — and frr.conf has to hold all of it, in an order FRR accepts.

There are two ways to put the pieces together, and the choice is about who decides where a block lands:

Who decides the position Reach for it when
Hierarchical the parent template, by naming the block where it wants it the file has a shape — sections in a fixed order, blocks that must sit inside one of them
Sort the blocks themselves, by naming a group they belong to the file is a list, and what matters is that everything of one kind ends up together

They compose: a node template can name an aggregated group in one place and embed a specific block in another. Most real topologies do both.

Hierarchical: the parent says where each block goes

A template names the blocks it wants, at the point it wants them, so the file's shape lives in one readable place and the order is fixed by construction rather than by hoping.

Structured File Generation Pattern

Hierarchical assembly is particularly effective for generating structured configuration files that require multiple coordinated sections:

# NetworkClass orchestrates the entire file structure
networkclass:
  - name: infrastructure_config
    config:
      # Individual section templates
      - name: file_header
        template:
          - "# Configuration Header"
          - "version: {{ .version }}"

      # Organize network-level sections
      - name: networks_section
        template:
          - "networks:"
          - "{{ .segments_ip_network_config }}"

      # Organize device-level sections
      - name: devices_section
        template:
          - "devices:"
          - "{{ .nodes_device_config }}"

      # Final file assembly with precise ordering
      - file: config.yaml
        template:
          - "{{ .self_file_header }}"
          - "{{ .self_networks_section }}"
          - "{{ .self_devices_section }}"

Key concepts of this pattern:

  • NetworkClass orchestration: Single control point for file structure
  • Section-based organization: Each major section has dedicated templates
  • Cross-object integration: Different object types contribute to different sections
  • Template embedding: {{ .self_section_name }} provides precise positioning
  • Child object collection: {{ .segments_<layer>_<name> }}, {{ .nodes_template_name }} gather contributions

Basic Example Pattern

nodeclass:
  - name: router
    config:
      - name: frr_cmds
        template:
          - "hostname {{ .name }}"
          - "ip forwarding"

      - name: startup
        depends: ["frr_cmds"]
        template:
          - "/usr/lib/frr/frr start"
          - "{{ .self_frr_cmds }}"        # Same object template embedding
          - "{{ .interfaces_frr_cmds }}"  # Child object template merging

Template Embedding Syntax

Hierarchical templates use specific syntax patterns for embedding other templates:

Self-Reference Embedding:

- "{{ .self_template_name }}"    # Embed another template from same object
  • Embeds templates defined in the same class using name: attribute
  • Maintains exact positioning control
  • Enables modular template composition
  • Important: Template must be defined with name: (not group:) to be referenceable

Child Object Embedding:

- "{{ .interfaces_template_name }}"  # Embed template from all interfaces
- "{{ .segments_ip_network_entry }}"    # Embed template from all segments
- "{{ .nodes_node_entry }}"          # Embed template from all nodes
  • Collects templates from child objects
  • Automatically merges all matching templates
  • Uses object-specific FileFormat for merging

Object Type Prefixes:

  • interfaces_ - Collects from all interfaces of parent object
  • segments_ - Collects from all network segments
  • nodes_ - Collects from all nodes
  • connections_ - Collects from all connections
  • groups_ - Collects from all groups

Template Resolution Process:

  1. Self-templates: Resolved first within same object
  2. Child templates: Collected from related objects
  3. Format application: FileFormat applied during merge
  4. Final embedding: Result embedded at specified position

Template Definition Requirements

For Hierarchical assembly, templates must be defined with appropriate attributes:

Named Templates (Hierarchical):

config:
  - name: "template_name"    # Required for template embedding
    template:
      - "configuration content"

  - name: "main_config"
    template:
      - "{{ .self_template_name }}"  # Can reference above template

Group Templates (Sort):

config:
  - group: "group_name"      # Used for sort-based collection
    template:
      - "configuration content"

Key distinction:

  • Use name: when templates need to be referenced in Hierarchical assembly
  • Use group: only for Sort assembly where templates are collected and merged

Sort: the blocks say which pile they belong to

Each block names a group, and everything in that group is gathered and ordered by priority. Nothing has to know how many contributors there are, which is what makes it right for the repetitive parts: every interface adds its line to the same pile, and adding an interface adds a line.

Example pattern:

# Multiple interfaces contribute to group
interfaceclass:
  - name: ospf_interface
    config:
      - group: "ospf_interfaces"
        priority: 10
        template:
          - "interface {{ .name }}"
          - "ip ospf area 0"

# Node processes collected group
nodeclass:
  - name: router
    config:
      - file: "ospf.conf"
        style: sort
        sort_group: "ospf_interfaces"
        template:
          - "router ospf"

The blocks collected in ospf_interfaces are merged after this one by the sort style itself — there is nothing to walk and no loop to write. That is the whole point of the style: the template says what it contributes, and the assembly is dot2net's job.

Which one

Ask what fixes the order. If it is the file's format — this section before that one, this line inside that block — the parent knows it, so name the blocks from the parent: hierarchical. If the order among the blocks does not matter and what matters is that they all arrive, let them name a group: sort.

Hierarchical Sort
Order comes from the parent template's text the group and its priorities
A new contributor has to be named somewhere just appears
Reads well when the file has a shape the file has a list
When it goes wrong a block is in the wrong place, visibly a block is missing from a pile, quietly

Using both

Most files need both, and they nest without ceremony: a parent names the groups it wants, and the contributors fill them.

nodeclass:
  - name: advanced_router
    config:
      # Hierarchical for main structure
      - name: main_config
        depends: ["base_config"]
        template:
          - "{{ .self_base_config }}"
          - "# OSPF Configuration"
          - "{{ .self_ospf_sorted_config }}"
          - "# BGP Configuration"
          - "{{ .self_bgp_sorted_config }}"

      # Sort for aggregating interface contributions
      - name: ospf_sorted_config
        style: sort
        sort_group: "ospf_interfaces"

      - name: bgp_sorted_config
        style: sort
        sort_group: "bgp_interfaces"

Template merging behavior:

  • {{ .self_template_name }}: Embeds same-object template at exact position
  • {{ .interfaces_template_name }}: Merges all interface template results using the specified FileFormat

File output mechanism:

Assembling is not writing: a block still needs a file to land in

Both approaches produce text. Neither of them writes anything. What puts text on disk is a config entry naming a file:, on the node or the network — so a block that is assembled perfectly and named by no file simply does not appear, which is the usual reason for output that is missing rather than wrong.

File Output Process

  1. Template Assembly Phase:

    • Hierarchical: Templates embed other templates via {{ .self_template_name }}
    • Sort: Templates contribute config blocks to groups, then sorter templates collect them
  2. File Output Phase:

    • NetworkClass/NodeClass file template reads assembled content and writes to target files
    • Required: file: attribute specifying target file path
    • Content source: References assembled templates via {{ .self_template_name }}

File Template Examples

Hierarchical file output:

nodeclass:
  - name: router
    config:
      - name: main_config  # Assembly phase
        template: ["router bgp {{ .group_as }}"]

      - file: "bgp.conf"   # File output phase
        template: ["{{ .self_main_config }}"]

Sort file output:

nodeclass:
  - name: router
    config:
      - name: collected_config  # Assembly phase
        style: sort
        sort_group: "bgp_config"

      - file: "bgp.conf"        # File output phase
        template: ["{{ .self_collected_config }}"]

Both directions

The two mix either way round: an aggregated group can be embedded at a chosen point, and a block that was placed by a parent can also contribute to a pile.

Sort blocks embedded in Hierarchical templates:

nodeclass:
  - name: router
    config:
      # Hierarchical main structure
      - name: main_config
        depends: ["base_config"]
        template:
          - "{{ .self_base_config }}"
          - "# Interface configurations (collected via Sort)"
          - "{{ .self_interface_aggregation }}"
          - "# Static configuration"
          - "no ip forwarding"

      # Sort approach for collecting interface configs
      - name: interface_aggregation
        style: sort
        sort_group: "interface_configs"

      # Final file output (required for actual file generation)
      - file: "router.conf"
        template:
          - "{{ .self_main_config }}"

Hierarchical blocks used in Sort templates:

interfaceclass:
  - name: complex_interface
    config:
      # Hierarchical for interface-specific structure
      - name: base_interface
        template:
          - "interface {{ .name }}"
          - "{{ .self_protocol_config }}"

      - name: protocol_config
        template:
          - "ip address {{ .ip_addr }}/{{ .ip_plen }}"
          - "ip ospf area 0"

      # Contribute to node-level Sort group
      - group: "all_interfaces"
        template:
          - "{{ .self_base_interface }}"

Bidirectional Usage Examples

Complete bidirectional topology where both approaches complement each other:

# Interface uses Hierarchical for structure, contributes to Sort groups
interfaceclass:
  - name: bgp_interface
    config:
      # Hierarchical assembly of interface-specific config
      - name: interface_base
        template:
          - "interface {{ .name }}"
          - "{{ .self_interface_address }}"
          - "{{ .self_interface_routing }}"

      - name: interface_address
        template: ["ip address {{ .ip_addr }}/{{ .ip_plen }}"]

      - name: interface_routing
        template: ["ip ospf area {{ .group_ospf_area }}"]

      # Contribute hierarchical result to node-level Sort group
      - group: "interface_configs"
        template: ["{{ .self_interface_base }}"]

# Node uses Sort to collect interfaces, embeds in Hierarchical structure
nodeclass:
  - name: bgp_router
    config:
      # Sort collection of all interface configs
      - name: all_interfaces
        style: sort
        sort_group: "interface_configs"

      # Hierarchical main structure embedding Sort result
      - name: main_config
        template:
          - "{{ .self_router_header }}"
          - "# Interface configurations (collected via Sort)"
          - "{{ .self_all_interfaces }}"
          - "{{ .self_routing_protocols }}"

      - name: router_header
        template: ["hostname {{ .name }}"]

      - name: routing_protocols
        template:
          - "router bgp {{ .group_as }}"
          - "bgp router-id {{ .ip_loopback }}"

      # Final file output combining both approaches
      - file: "router.conf"
        template: ["{{ .self_main_config }}"]

This demonstrates how Sort collection (interface configs) can be embedded within Hierarchical structure (main config), and conversely how Hierarchical assembly (interface structure) can contribute to Sort groups for node-level aggregation.

For detailed configuration syntax and implementation examples, see YAML Configuration - Template Types. For template merging and FileFormat details, see YAML Configuration - File Formats.

Three things that come up once a topology grows

1. Output that appears only sometimes

Two blocks, each written for a parameter that not every node has. required_params decides whether each one is produced; the template itself never asks.

nodeclass:
  - name: router
    config:
      - name: ospf_config
        required_params: [group_ospf_enabled]
        template:
          - "router ospf"
          - "router-id {{ .ip_loopback }}"
      - name: bgp_config
        required_params: [group_as]
        template:
          - "router bgp {{ .group_as }}"
          - "bgp router-id {{ .ip_loopback }}"

A class does the same thing when the condition is a role rather than a value: give the routers that speak BGP a bgp_router class and put the block there.

2. One block per interface, gathered by the node

The interface class writes the block for one interface, and says it only applies where the VLAN id exists. The node's template gathers them all.

interfaceclass:
  - name: access_port
    config:
      - name: vlan_entry
        required_params: [conn_vlan_id]
        template:
          - "vlan {{ .conn_vlan_id }}"
          - "name VLAN_{{ .conn_vlan_id }}"

nodeclass:
  - name: switch
    config:
      - name: vlan_config
        template:
          - "{{ .interfaces_vlan_entry }}"

3. Complex Parameter Composition

interfaceclass:
  - name: bgp_peer
    config:
      - name: bgp_config
        template:
          - "router bgp {{ .node_group_as }}"
          - "neighbor {{ .opp_ip_addr }} remote-as {{ .opp_node_group_as }}"
          - "neighbor {{ .opp_ip_addr }} description {{ .opp_node_name }}_{{ .opp_name }}"

The line that should appear only for an iBGP session belongs in a class of its own — one attached to the interfaces whose two ends share an AS. Comparing the two values in the template would put the decision in the text, where dot2net cannot see it.

ConfigTemplate Field Reference

Every entry under a class's config: is a ConfigTemplate. These are its fields.

What it produces

Field Meaning
file Write the rendered text to this file definition. An entry with file produces a file
name Make the rendered text available to other templates as {{ .self_<name> }}, and to a parent as {{ .nodes_<name> }}, {{ .interfaces_<name> }} and so on. An entry with name produces a block, not a file

A name that dot2net owns — startup, teardown — is a hook: a module reads it and puts it where its own platform runs such things. See Module System.

Where the text comes from

Field Meaning
template The lines themselves
sourcefile A file to read them from. Naming both template and sourcefile is rejected: write two entries and order them with blocks:
raw Hand the source file through as read, without expanding it. For a file that is material rather than a template — one dot2net has nothing to fill in, and that may carry {{ of its own meant for whoever reads it later
delimiters Replace {{ and }} for this template alone, e.g. delimiters: ["[[", "]]"]. For generating a file that is itself a template for another tool: the downstream syntax passes through untouched while dot2net's own values are still filled in
config:
  - file: daemons
    sourcefile: ./daemons
    raw: true                 # FRR's daemons file, handed through as it is
  - file: playbook.yml
    delimiters: ["[[", "]]"]
    template:
      - "  host: {{ ansible_host }}"     # left for Ansible
      - "  name: [[ .name ]]"            # filled in by dot2net

When it applies

Field Meaning
node / nodes Only for interfaces or connections of nodes carrying these classes
neighbor_node / neighbor_nodes Only when the neighbour is of these classes
required_params Only when every named parameter exists and is not empty. This is how a section is left out without an if in the template
required_link Only where the connection is deploy: link — wiring the platform actually lays. Written on the templates that ask a platform to make a link, so that a connection built by the configuration instead (a tunnel, an overlay) produces no wiring for the platform to do. Works on interface-scoped and connection-scoped templates alike, since both describe the same wire
empty Produce an empty file or block rather than nothing when the conditions are not met

How it is combined

Field Meaning
depends Names of blocks on the same object that must be rendered first
blocks.before / blocks.after Place this block before or after named blocks in the file
priority Order among blocks of the same group. Smaller comes first
style / sort_group / group See Many small blocks, one file
format / formats The FormatStyle applied when the block is registered in a namespace
assembly_format / assembly_formats The FormatStyle applied when blocks are assembled into a file

Every variable, by object

What follows is the full list, for looking things up. dot2net params on your own topology is the better answer while writing — these are what exists in principle, not what exists in yours.

Node Template Variables

Variable Description Example
{{ .name }} Node name r1
{{ .image }} Container image quay.io/frrouting/frr:8.5.4
{{ .kind }} Node type linux
{{ .ip_loopback }} Loopback IP 10.255.0.1
{{ .group_<param> }} Group parameter {{ .group_as }}

Interface Template Variables

Variable Description Example
{{ .name }} Interface name eth0
{{ .ip_addr }} IP address 10.0.0.1
{{ .ip_plen }} Prefix length 24
{{ .node_name }} Parent node name r1
{{ .conn_name }} Connection name r1--r2
{{ .opp_ip_addr }} Opposite IP 10.0.0.2

Connection Template Variables

Variable Description Example
{{ .name }} Connection name vlan_trunk0
{{ .vlan_id }} VLAN ID 100
{{ .conn_id }} Connection ID 0

Neighbor Template Variables

Variable Description Example
{{ .n_name }} Neighbor interface name eth0
{{ .n_ip_addr }} Neighbor IP address 10.0.0.2
{{ .n_node_name }} Neighbor node name r2
{{ .n_node_as }} Neighbor node AS number 65002
{{ .n_<param> }} Any neighbor parameter {{ .n_ospf_area }}

Member Template Variables

Variable Description Example
{{ .m_name }} Member object name adv0
{{ .m_ip_addr }} Member IP address 192.168.1.1
{{ .m_ipv4_net }} Member network 192.168.1.0/24
{{ .m_<param> }} Any member parameter {{ .m_advertise }}

Habits worth having

1. Look the parameters up rather than remembering them

Use dot2net params to discover available variables and validate template references as you develop.

Why this matters:

  • Prevents template errors: Avoid referencing non-existent variables
  • Discovers available parameters: Find useful variables you might not know about
  • Validates cross-references: Confirm that opp_, node_, conn_ references work
  • Shows actual values: See computed IPs, names, and derived parameters

See Command Reference for detailed usage and filtering examples.

2. Use Descriptive Template Names

# Good
- name: bgp_neighbor_config
- name: ospf_interface_setup
- name: vlan_trunk_config

# Avoid
- name: config1
- name: template

3. Leverage Cross-Object References

# Leverage connection information in interface templates
interfaceclass:
  - name: trunk_port
    config:
      - name: vlan_config
        template:
          - "interface {{ .name }}"
          - "description {{ .conn_name }} (VLAN {{ .conn_vlan_id }})"
          - "switchport trunk allowed vlan {{ .conn_vlan_id }}"

4. Use Group Templates for Repetitive Config

# Collect interface configs for later processing
interfaceclass:
  - name: default
    config:
      - group: "interface_configs"
        template:
          - "interface {{ .name }}"
          - "ip address {{ .ip_addr }}/{{ .ip_plen }}"

# Process all interfaces in node template
nodeclass:
  - name: router
    config:
      - file: "interfaces.conf"
        style: sort
        sort_group: "interface_configs"

5. Let required_params decide, not the template

- name: routing_protocols
  required_params: [group_ospf_area]
  template:
    - "router ospf"
    - "router-id {{ .ip_loopback }}"
    - "network {{ .ip_net }} area {{ .group_ospf_area }}"

The block is produced for nodes in a group that has an OSPF area, and not produced for the others. Written as {{ if .group_ospf_area }}, it would instead fail the build for the others.

Whole shapes to copy

Three configurations that come up constantly, written out end to end. Copying one of these is usually faster than assembling it from the pieces above.

1. FRR Configuration Pattern

nodeclass:
  - name: frr_router
    config:
      - name: frr_cmds
        template:
          - "ip forwarding"
          - "{{ .self_router_protocol }}"

      - name: startup
        depends: ["frr_cmds"]
        format: FRRVtyshCLI
        template:
          - "{{ .self_frr_cmds }}"
          - "{{ .interfaces_frr_cmds }}"

2. Containerlab Integration Pattern

You do not write this one. The containerlab module produces topo.yaml, including the bind list, from the files your topology declares — see Module: Containerlab. It is shown here because the shape is worth recognising: the list of binds is an aggregation the module assembles, not a loop in a template.

# what the module's own template looks like, in outline
      - name: clab_topo
        template:
          - "{{ .name }}:"
          - "  kind: {{ .kind }}"
          - "  image: {{ .image }}"
          - "  binds:"
          - "{{ .values_clab_bind_entry }}"    # one entry per file, gathered

3. VLAN Configuration Pattern

connectionclass:
  - name: vlan_connection
    prefix: "vlan"
    params: [vlan_id]

interfaceclass:
  - name: vlan_interface
    config:
      - name: vlan_setup
        template:
          - "interface {{ .name }}"
          - "switchport access vlan {{ .conn_vlan_id }}"
          - "description VLAN_{{ .conn_vlan_id }}_{{ .conn_name }}"

When the build stops

Every message below names the object and the template it came from, so the first move is always to read the whole line rather than the first half.

1. Variable Not Found Errors

Problem: template: executing template: map has no entry for key "xyz"

Solutions:

  • Check parameter name spelling
  • Verify parameter is defined in class params list
  • Ensure cross-object reference uses correct prefix (node_, conn_, opp_)

2. Missing Cross-Object References

Problem: Connection or node parameters not accessible

Solutions:

  • Verify objects are properly connected in DOT file
  • Check that referenced object has the required parameters
  • Ensure parameter rules are defined for custom parameters

3. Template Execution Errors

Problem: Template syntax errors during execution

Solutions:

  • Validate Go template syntax
  • Check for unmatched {{ if }} / {{ end }} pairs
  • Verify all referenced variables exist in scope

4. Priority and Dependency Issues

Problem: Configuration blocks appear in wrong order

Solutions:

  • Use priority in group templates for ordering
  • Use depends in named templates for dependencies
  • Check sorter template sort_group matches group template names

5. Neighbor/Member Reference Issues

Problem: Neighbor or member references not working

Solutions:

  • For neighbors: Verify interfaces are connected in the specified layer
  • For members: Ensure target class objects actually exist in the network
  • Namespace: Check that n_ or m_ prefix is used correctly
  • Layer mismatch: Verify neighbor layer matches connection layer
  • Class existence: Confirm referenced classes are defined and assigned

Problem: No neighbor/member config blocks generated

Solutions:

  • Check that adjacent interfaces exist for neighbor references
  • Verify that member class objects are present in the network model
  • Ensure layer specification matches actual network connections
  • Confirm that referential objects have proper class assignments

The same thing, in topologies you can run

These are not written for this page: they are the shipped topologies, and dot2net build in their directory produces the output described.

Basic BGP Configuration

From topologies/basic_bgp/input.yaml:

nodeclass:
  - name: default
    config:
      - name: frr_cmds
        template:
          - "router bgp {{ .group_as }}"
          - "bgp router-id {{ .ip_loopback }}"

interfaceclass:
  - name: default
    config:
      - name: frr_cmds
        template:
          - "int {{ .name }}"
          - "ip addr {{ .ip_addr }}/{{ .ip_plen }}"
          - "router bgp {{ .group_as }}"
          - "neighbor {{ .opp_ip_addr }} remote-as {{ .opp_group_as }}"

Key features:

  • {{ .group_as }} - AS number from group class
  • {{ .opp_ip_addr }} - Opposite interface IP
  • {{ .opp_group_as }} - Opposite node's group AS number

One value, read from both ends

From example/param_share/input.yaml. A rule assigns a VLAN id per segment and a name per connection; an interface asks for both and reads them as its own:

param_rule:
  - name: vlan
    assign: segment      # everything on one medium gets the same value
    layer: ip
    min: 100
    max: 1001
  - name: cname
    assign: connection   # both ends of one link get the same value

interfaceclass:
  - name: default
    params: [vlan, cname]
    config:
      - group: params.txt
        template:
          - "connection name {{ .cname }} for vlan {{ .vlan }} ({{ .node_name }}.{{ .name }})"

Both interfaces of a link produce the same cname, and every interface on a segment the same vlan — without either end being told what the other got:

connection name conn0 for vlan 100 (r1.eth0)
connection name conn0 for vlan 100 (r2.eth0)

Key features:

  • params: — what this class asks to be given
  • {{ .node_name }} — the parent node, through the node_ prefix

This template system provides the foundation for dot2net's flexible and powerful configuration generation capabilities, enabling complex network configurations through simple, reusable templates.

Clone this wiki locally