Skip to content

Template Assembly

sat edited this page Aug 25, 2026 · 5 revisions

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.

Hierarchical and sort

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.

Hierarchical assembly

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.

Structured File Generation Pattern

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

Basic Example Pattern

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 merging

Template Embedding Syntax

Hierarchical 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:

  1. Self-templates: Resolved first within same object
  2. Child templates: Collected from related objects
  3. Format application: FormatStyle applied during merge
  4. Final embedding: Result embedded at specified position

Template Definition Requirements

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 template

Group 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.

Aggregation: one block per interface, gathered by the node

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

Sort assembly

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.

Priority

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.

Gathering more than one group

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.

Anchors

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.

Choosing between hierarchical and sort

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

Hybrid assembly

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

File output

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.

File Output Process

  1. Template Assembly Phase:

    • Hierarchical: Templates embed other templates via {{ .self_template_name }}
    • Sort: Templates contribute config blocks to groups, then sorter templates collect them
  2. 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 }}

File Template Examples

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

Cross-approach template usage

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

Bidirectional Usage Examples

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.

See Also

Clone this wiki locally