-
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"
- group: startup 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: a template is referenceable only if it was defined with
name:
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:when templates are collected and merged by Sort assembly
They are not alternatives — one template may carry both. name: and
group: are two ways of consuming a block, not two kinds of block: the
template is rendered once, and what it produces is both available under
{{ .self_<name> }} / {{ .interfaces_<name> }} and gathered into the group.
Write both when the same text belongs in two places.
interfaceclass:
- name: default
config:
- name: addr # embedded where the node asks for it by name
group: summary.txt # and collected into the summary file as well
template:
- "{{ .name }} {{ .ip_addr }}/{{ .ip_plen }}"Each destination gets the block once. It appears twice in one file only if that
file asks for it twice — by embedding {{ .interfaces_addr }} in the very
template that sorts the group.
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.
A group nothing sorts is an error. A block written into a name no sorter gathers would be generated and then dropped, and the only sign of it was a section missing from a file. Mistype either name and dot2net says so, listing the groups that are sorted.
A block's blank first and last lines are not kept. A template read from a file ends with that file's line terminator, and a block written with a blank line in front of it meant to stand clear of what came before; kept, they become empty lines between blocks — an empty command in a script, an empty entry in a list of them.
Blocks are ordered by priority, smaller first, and the default is 0. Blocks
of equal priority come out in the order their classes are declared in the
YAML — but do not lean on that: a module's classes are added after the
topology's, so a tie puts the topology's block first, which is the opposite of
what a hook wants. Where the order between two blocks carries meaning, say it.
A sorter may name several groups and put them all in one column:
- file: "ospf.conf"
style: sort
sort_groups: ["ospf_interfaces", "ospf_local"]The blocks of every group named are ordered together, so a group is where blocks are written from rather than a section of the result. Who may write a block and where the block ends up are different questions: a group only its own writer knows the name of and a group anything may write into can feed one file. This is how a module keeps its own commands out of another platform's files while the topology writes into a shared name — see the hooks.
A sorter may also carry group: itself, and then what it gathered becomes one
block of that other column.
A number is a poor contract between a topology and a module: it holds only while both agree what the numbers mean, and the module cannot move its own blocks afterwards without breaking the topology. So a block may carry a label, and others say where they go by naming it:
- group: worker_deploy
anchor: deploy_command # this block is the fixed point
template: ["..."]
- group: worker_deploy
placed:
after: [deploy_command] # this one goes after it
template: ["..."]Several anchors on either side are ordinary — a column can carry more than one thing worth sitting next to — and the block goes after all of the first and before all of the second.
A label is not a name. A name: makes a namespace parameter and has to be
unique on the object; a label is read only within the column it appears in, so
several columns can carry the same one. That is what lets a topology write
after: [worker_deploy] without knowing which platform's script is being
written.
Two things look alike here and only one is silent:
- A label no config template carries anywhere is a typo, and is refused when the config is read.
- A label that exists but was not generated for this object is nothing to sit next to, so the block keeps its place. A block cannot know which objects its anchor is generated for, and the same silence is what lets one line be written for three platforms.
Blocks placed in a circle are reported, as is a block that gives both
priority: and placed:, and one that says placed: while writing into no
group at all.
placed: and blocks: read alike and are told apart by voice: blocks:
lists what is put around this one, placed: says where this one is put.
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 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 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