Skip to content

Template System

sat edited this page Aug 25, 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 writing one of those templates: what it can reach, and how to reach the objects around it. Putting the blocks together into a file is Template Assembly.

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

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)

Namespace inspection with 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.

Object parameters and namespace formation

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: an object's own parameters

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: cross-object parameters

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.

Namespace prefixes

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 the other objects of a class named in classmembers (the referring object itself is not one of them unless include_self: true)

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.

Template syntax

Basic syntax

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

config:
  - group: 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.

Referential objects: neighbors and members

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 objects

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 objects

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 }}"

The object doing the referring is not among its own members. It belongs to the class it names whenever the two are the same, and a line written per member would then name the object itself — a router listing itself as its own peer. Say include_self: true where iterating over every member including this one is what you want.

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 }})"

Namespace prefix reference

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, or something about the machine it sits on
{{ .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.

A group's value, read from a node on it

Whatever is true of the machine is true for the nodes placed on it, and {{ .group_* }} is how a node's template reads it. This is the usual way to get an environment-specific value — the address of a machine's own interface, a VLAN its switch was given — into the output of the nodes that sit there, without any node knowing which machine it landed on.

# The value belongs to the machine, so it is written on the machine:
#   subgraph host1 { label = "worker; inter_ip=192.168.101.2"; ... }

nodeclass:
  - name: platform_sw
    deploy: platform
    config:
      - name: attach_entry
        template: ["{{ .group_inter_ip }} {{ .name }}"]

file:
  - name: attach.txt
    scope: group        # one per machine

groupclass:
  - name: worker
    config:
      - file: attach.txt
        template: ["{{ .nodes_attach_entry }}"]

One file per machine, each naming that machine's own value:

host1/attach.txt : 192.168.101.2 br1
host2/attach.txt : 192.168.101.3 br2

The value is written once, in the DOT file, and reaches both the configuration and the instructions for wiring the machine up — so there are not two places to keep in step.

Assembling the blocks into a file

Everything above is about one block: what it can read, and what it says. A device's configuration is many of them, and what decides where each one lands — and which file it reaches — is a question of its own: Template Assembly.

Advanced template features

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

Composing a value from several parameters

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, worker_deploy and the other machine-side ones — 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

How it is combined

Field Meaning
depends Names of blocks on the same object that must be rendered first. Usually unnecessary: a template that embeds {{ .self_x }}, or merges self_x with blocks:, has already said it needs x first, and dot2net reads that. Write it only for a reference no reading of the text can find — a name built while rendering, {{ index . "self_x" }}
blocks.before / blocks.after Merge the named blocks into this one's output, before or after its own text
placed.before / placed.after Put this block before or after the anchors named, within the column it is gathered into
anchor Label this block so others can be placed relative to it
priority Order among blocks of the same group. Smaller comes first
style / sort_group / sort_groups / group See Template Assembly
format / formats The FormatStyle applied to the block's own text, and to the file it is written to
namespace_format / namespace_formats The FormatStyle applied when the block is registered under its name:, for another template to embed. Falls back to format
assembly_format / assembly_formats The FormatStyle applied to the result of assembling — the blocks a sort gathers, or the ones blocks: merges

Template variable reference

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 }}

Best practices

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.

Common patterns

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 }}"

      - group: startup        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 }}"

Troubleshooting templates

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
  • Verify all referenced variables exist in scope — a name dot2net did not put there fails the build, which is what {{ if }} and {{ range }} run into (see Variable Access Patterns for why {{ if }} and {{ range }} run into exactly this)

4. Priority and Dependency Issues

Problem: Configuration blocks appear in wrong order

Solutions:

  • Use priority in group templates for ordering
  • Embed the block you need ({{ .self_x }}): the order follows from the reference, so depends is rarely what is missing
  • 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

Examples from real topologies

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