-
Notifications
You must be signed in to change notification settings - Fork 1
Template Assembly
A device's configuration is written in pieces, and something has to decide
where each piece lands. The address lines belong to the interfaces, the OSPF
section belongs to the node, the network statements belong to whichever
interfaces the topology marked — and frr.conf has to hold all of it, in an
order FRR accepts.
Writing one of those pieces is Template System. This page is the next step: putting them together, and getting the result into a file.
There are two, and the choice is about who decides where a block lands:
| Who decides the position | Reach for it when | |
|---|---|---|
| Hierarchical | the parent template, by naming the block where it wants it | the file has a shape — sections in a fixed order, blocks that must sit inside one of them |
| Sort | the blocks themselves, by naming a group they belong to | the file is a list, and what matters is that everything of one kind ends up together |
They compose: a node template can name an aggregated group in one place and embed a specific block in another. Most real topologies do both.
A template names the blocks it wants, at the point it wants them, so the file's shape lives in one readable place and the order is fixed by construction rather than by hoping.
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 FormatStyle 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: FormatStyle 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
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 }}"Each block names a group, and everything in that group is gathered and ordered by priority. Nothing has to know how many contributors there are, which is what makes it right for the repetitive parts: every interface adds its line to the same pile, and adding an interface adds a line.
Example pattern:
# Multiple interfaces contribute to group
interfaceclass:
- name: ospf_interface
config:
- group: "ospf_interfaces"
priority: 10
template:
- "interface {{ .name }}"
- "ip ospf area 0"
# Node processes collected group
nodeclass:
- name: router
config:
- file: "ospf.conf"
style: sort
sort_group: "ospf_interfaces"
template:
- "router ospf"The blocks collected in ospf_interfaces are merged after this one by the sort
style itself — there is nothing to walk and no loop to write. That is the whole
point of the style: the template says what it contributes, and the assembly is
dot2net's job.
Ask what fixes the order. If it is the file's format — this section before that one, this line inside that block — the parent knows it, so name the blocks from the parent: hierarchical. If the order among the blocks does not matter and what matters is that they all arrive, let them name a group: sort.
| Hierarchical | Sort | |
|---|---|---|
| Order comes from | the parent template's text | the group and its priorities |
| A new contributor | has to be named somewhere | just appears |
| Reads well when | the file has a shape | the file has a list |
| When it goes wrong | a block is in the wrong place, visibly | a block is missing from a pile, quietly |
Most files need both, and they nest without ceremony: a parent names the groups it wants, and the contributors fill them.
nodeclass:
- name: advanced_router
config:
# Hierarchical for main structure
- name: main_config
depends: ["base_config"]
template:
- "{{ .self_base_config }}"
- "# OSPF Configuration"
- "{{ .self_ospf_sorted_config }}"
- "# BGP Configuration"
- "{{ .self_bgp_sorted_config }}"
# Sort for aggregating interface contributions
- name: ospf_sorted_config
style: sort
sort_group: "ospf_interfaces"
- name: bgp_sorted_config
style: sort
sort_group: "bgp_interfaces"Template merging behavior:
-
{{ .self_template_name }}: Embeds same-object template at exact position -
{{ .interfaces_template_name }}: Merges all interface template results using the specified FormatStyle
Both approaches produce text. Neither of them writes anything. What puts text
on disk is a config entry naming a file:, on the node or the network — so a
block that is assembled perfectly and named by no file simply does not appear,
which is the usual reason for output that is missing rather than wrong.
-
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 mix either way round: an aggregated group can be embedded at a chosen point, and a block that was placed by a parent can also contribute to a pile.
Sort blocks embedded in Hierarchical templates:
nodeclass:
- name: router
config:
# Hierarchical main structure
- name: main_config
depends: ["base_config"]
template:
- "{{ .self_base_config }}"
- "# Interface configurations (collected via Sort)"
- "{{ .self_interface_aggregation }}"
- "# Static configuration"
- "no ip forwarding"
# Sort approach for collecting interface configs
- name: interface_aggregation
style: sort
sort_group: "interface_configs"
# Final file output (required for actual file generation)
- file: "router.conf"
template:
- "{{ .self_main_config }}"Hierarchical blocks used in Sort templates:
interfaceclass:
- name: complex_interface
config:
# Hierarchical for interface-specific structure
- name: base_interface
template:
- "interface {{ .name }}"
- "{{ .self_protocol_config }}"
- name: protocol_config
template:
- "ip address {{ .ip_addr }}/{{ .ip_plen }}"
- "ip ospf area 0"
# Contribute to node-level Sort group
- group: "all_interfaces"
template:
- "{{ .self_base_interface }}"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.
The punctuation between blocks — the ! FRR wants, the indentation containerlab
wants — is not written here: it belongs to the file's
FormatStyle.
- Template System - writing one block: syntax, namespace, neighbors and members
- YAML Configuration - the fields these mechanisms are declared with
-
File Output -
path,provide, and how a file reaches a container - FormatStyle Design - the punctuation between blocks, kept out of the templates