-
Notifications
You must be signed in to change notification settings - Fork 1
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.
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 |
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)
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.
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.
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 |
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")
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)
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)
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: dhcpResulting 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-westResulting parameters:
{{ .group_as }} // "65001" (inherited from group)
{{ .group_region }} // "us-west" (inherited from group)
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: switchingResulting 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: 65535Resulting parameters:
{{ .as }} // "65000", "65001"... (auto-assigned to groups)
{{ .group_as }} // AS number inherited from group
{{ .opp_group_as }} // Opposite node's AS number
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")
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.
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 (requiresneighborsdefinition) -
Member Objects:
m_prefix for the other objects of a class named inclassmembers(the referring object itself is not one of them unlessinclude_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)
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 }}"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.
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 }}"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.
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 formOr 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.delimitersandrawexist because escaping by hand is easy to get wrong.
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.
Adjacency is per layer, so a topology carrying IPv4 and IPv6 on the same wires counts them separately.
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 }}"-
For each interface with class
ospf_interface -
Find adjacent interfaces in the
iplayer - Generate one config block per adjacent interface
-
Add to target node (specified by
node: router)
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.
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.
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.
-
For each interface with class
bgp_peer -
Find all interfaces with class
advertised_networks - Generate one config block per found interface
- Merge into parent object's configuration
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 networkResult: For each ibgp interface, includes network advertisements from all adv class interfaces.
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 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
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:
- Base template sets up BGP router and references neighbor/member configs
- Neighbor templates generate one neighbor statement per adjacent router
- Member templates generate one network statement per advertising interface
- Final config combines all generated blocks into complete BGP 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.
# Good
neighbors:
- layer: ip
config:
- name: ospf_neighbor_hello
- name: static_route_to_stub
# Avoid
neighbors:
- layer: ip
config:
- name: config1
- name: template# Use different layers for different protocols
neighbors:
- layer: ipv4
config:
- name: bgp_ipv4_neighbor
- layer: ipv6
config:
- name: bgp_ipv6_neighborA 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 }}"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_neighborsinterfaceclass:
- 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
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 }})"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.
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.
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.
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.
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.
Every entry under a class's config: is a ConfigTemplate. These are its fields.
| 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.
| 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| 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 |
| 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 |
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.
| 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 }} |
| 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 |
| Variable | Description | Example |
|---|---|---|
{{ .name }} |
Connection name | vlan_trunk0 |
{{ .vlan_id }} |
VLAN ID | 100 |
{{ .conn_id }} |
Connection ID | 0 |
| 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 }} |
| 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 }} |
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.
# Good
- name: bgp_neighbor_config
- name: ospf_interface_setup
- name: vlan_trunk_config
# Avoid
- name: config1
- name: template# 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 }}"# 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"- 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.
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.
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 }}"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, gatheredconnectionclass:
- 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 }}"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.
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_)
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
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)
Problem: Configuration blocks appear in wrong order
Solutions:
- Use
priorityin group templates for ordering - Embed the block you need (
{{ .self_x }}): the order follows from the reference, sodependsis rarely what is missing - Check sorter template
sort_groupmatches group template names
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_orm_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
These are not written for this page: they are the shipped topologies, and
dot2net build in their directory produces the output described.
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
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 thenode_prefix
This template system provides the foundation for dot2net's flexible and powerful configuration generation capabilities, enabling complex network configurations through simple, reusable templates.