-
Notifications
You must be signed in to change notification settings - Fork 1
Template System
This page provides comprehensive documentation of dot2net's template system, including syntax, variable embedding, cross-object references, and advanced features for generating network configuration files.
dot2net uses Go's standard text/template engine to generate configuration files from network models. The template system enables dynamic configuration generation based on network topology, automatic parameter assignment, and cross-object relationships.
Key Features:
-
Go template syntax for
{{ }}expressions. Not forifandrange, though: a parameter that is missing is an error, not an empty value, so a conditional fails on exactly the objects it was written for. dot2net answers both needs another way — aggregation in place of a loop,required_paramsin place of a conditional - Cross-object references: Access parameters from related network objects
- Parameter inheritance: Automatic propagation of parameters between objects
- Template types: Support for named, group, and sorter templates
- Format flexibility: Generate any configuration format (FRR, Juniper, Cisco, etc.)
In dot2net, each network object (node, interface, connection, etc.) has a parameter namespace containing all variables accessible within templates. This namespace includes:
-
Self parameters: Object's own properties (
{{ .name }},{{ .ip_addr }}) -
Inherited parameters: Properties from parent/related objects (
{{ .node_name }},{{ .group_as }}) -
Cross-object references: Parameters from connected objects (
{{ .opp_ip_addr }},{{ .conn_vlan_id }}) -
Referential parameters: Parameters from neighbor/member objects (
{{ .n_node_as }},{{ .m_ipv4_net }})
When writing templates, use the dot2net params command to discover available variables and validate your template references. This command shows the actual parameter namespace for each object after dot2net processes your configuration.
Template development workflow:
- Draft initial templates based on your configuration goals
-
Check available variables using
dot2net params - Refine templates using discovered parameters
- Iterate configuration and templates as needed
-
Test final result with
dot2net build
See the Command Reference for detailed usage examples and filtering options.
Each network object (node, interface, connection, group) has its own set of parameters before considering relationships with other objects. Understanding these base parameters helps you predict what will be available in templates.
Each object's base parameters come from five sources:
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")
The complete namespace for each object is formed by combining its individual parameters with parameters from related objects through cross-object references. This creates a rich parameter space that enables complex template logic.
When dot2net processes the network model, it adds relationship-based parameters to each object's namespace using 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 (requiresneighborsdefinition) -
Member Objects:
m_prefix for same-class object parameters (requiresclassmembersdefinition)
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.
Templates use Go's text/template syntax with double curly braces:
config:
- name: 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.
Neighbor and Member objects are special referential objects that generate multiple configuration blocks dynamically based on network topology and class relationships.
Key characteristics:
- Multiple instances: One config block generated per related object
- Namespace inheritance: Inherit parent object's full namespace
-
Extended parameters: Add object-specific parameters with
n_orm_prefixes - Conditional generation: Only created when relevant objects exist
Neighbor objects iterate over adjacent interfaces within a specific network layer:
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.
Member objects iterate over objects of the same class as specified in classmembers:
interfaceclass:
- name: bgp_peer
classmembers:
- interface: advertised_networks # Target class name
config:
- name: network_advertisement
template:
- " network {{ .m_ipv4_net }}"-
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 }})"Parameters flow through the network model hierarchy:
Group → Node → Interface
↘ ↗
Connection
-
{{ .name }}- Object's own name -
{{ .ip_addr }}- Object's IP address -
{{ .<custom_param> }}- Object's custom parameters
-
{{ .node_<param> }}- Parent node parameter (from interface) -
{{ .conn_<param> }}- Connection parameter (from interface) -
{{ .opp_<param> }}- Opposite interface parameter
-
{{ .opp_node_<param> }}- Opposite node parameter -
{{ .group_<param> }}- Group parameter (inherited)
- Object's direct parameters
- Inherited parameters (group → node → interface)
- Cross-object references (node_, conn_, opp_)
- Default values (if specified in parameter rules)
dot2net provides two fundamental approaches for assembling configuration templates, each with distinct advantages for different topologies.
Concept: Templates are embedded directly within other templates using explicit dependency relationships and precise positioning control.
Characteristics:
- Explicit structure: Parent-child relationships are clearly defined
- Precise control: Exact embedding positions are specified
- Strict dependencies: Template dependencies are explicitly managed
- Deterministic output: Configuration order is predictable
When to use:
- Complex dependency relationships between templates
- When exact positioning of configuration blocks is critical
- For structured, hierarchical configuration formats
- When template relationships need to be clearly visible
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
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 mergingHierarchical 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:(notgroup:) 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:
- Self-templates: Resolved first within same object
- Child templates: Collected from related objects
- Format application: FileFormat applied during merge
- Final embedding: Result embedded at specified position
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 templateGroup 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
Concept: Configuration blocks are collected in groups and then merged with automatic ordering based on priority values.
Characteristics:
- Flexible collection: Templates contribute to shared groups
- Automatic ordering: Priority-based sorting handles sequence
- Distributed generation: Multiple objects can contribute to same configuration
- Dynamic aggregation: Final structure emerges from individual contributions
When to use:
- Repetitive configuration patterns across multiple objects
- When configuration blocks should be aggregated and sorted
- For topologies where exact positioning is less critical than logical grouping
- When multiple objects need to contribute to shared configuration sections
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.
| Aspect | Hierarchical | Sort |
|---|---|---|
| Control | Precise positioning | Priority-based ordering |
| Complexity | Explicit dependencies | Automatic aggregation |
| Scalability | Manual relationship management | Dynamic collection |
| Use Cases | Structured configs, complex dependencies | Repetitive patterns, aggregation |
| Debugging | Clear template relationships | Group-based troubleshooting |
Complex topologies often benefit from combining both approaches:
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:
Both Hierarchical and Sort approaches are template assembly methods that prepare configuration content. Actual file generation requires a file template with a file: attribute at the Network or Node level.
-
Template Assembly Phase:
-
Hierarchical: Templates embed other templates via
{{ .self_template_name }} - Sort: Templates contribute config blocks to groups, then sorter templates collect them
-
Hierarchical: Templates embed other templates via
-
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 }}
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 }}"]The two approaches can be combined bidirectionally, enabling flexible template organization:
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 }}"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.
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.
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 }}"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 — 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 |
empty |
Produce an empty file or block rather than nothing when the conditions are not met |
| 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 Template Assembly Approaches |
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 |
| 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.
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 }}"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 }}"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
- Check for unmatched
{{ if }}/{{ end }}pairs - Verify all referenced variables exist in scope
Problem: Configuration blocks appear in wrong order
Solutions:
- Use
priorityin group templates for ordering - Use
dependsin named templates for dependencies - 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
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.