Template and Plugin Expansion Walkthrough

View as Markdown

This walkthrough shows how the render engine selects entrypoint templates, expands Jinja inheritance, and loads external template plugins. It is intended for external users who need to build their own template plugins while reusing the Config Manager template engine.

Entrypoint Selection

For each device, the selected DCIM provider loads one RenderData payload with device, location, and optional plugin data. The renderer then selects every entrypoint below this logical path:

<normalized-platform>/<normalized-role>/<intended-firmware>/entrypoint/

The platform, role, and intended-firmware values come from provider-supplied render data and are normalized by lower-casing and replacing whitespace with hyphens. The bundled Nautobot provider currently maps intended firmware from its configuration context; that is not a requirement for another provider.

For a device with platform Cumulus Linux, role Storage Leaf, and intended firmware 5.16.1, the renderer looks under:

cumulus-linux/storage-leaf/5.16.1/entrypoint/

Each matching .j2 file renders one output file. The render service stores the output by stripping the path and .j2 suffix, so entrypoint/startup.yaml.j2 becomes startup.yaml.

Built-In Expansion Example

A Cumulus storage leaf startup entrypoint starts as a thin version-specific composition file:

1{% extends "cumulus-linux/storage-leaf/base/startup.yaml.j2" %}
2
3{% block service %}
4{% include "cumulus-linux/role_common/5.16.1/include/service.j2" %}
5{% endblock service %}
6
7{% block qos %}
8{% include "cumulus-linux/storage-leaf/5.16.1/include/qos.j2" %}
9{% endblock qos %}
10
11{% block router %}
12{% include "cumulus-linux/storage-leaf/5.16.1/include/router.j2" %}
13{% endblock router %}
14
15{% block interfaces %}
16{% include "cumulus-linux/storage-leaf/5.16.1/include/interface.j2" %}
17{% endblock interfaces %}
18
19{% block system %}
20{% include "cumulus-linux/role_common/5.16.1/include/system.j2" %}
21{% endblock system %}

The role base inherits from broader common behavior:

1{% extends "cumulus-linux/superpod-common/base/startup.yaml.j2" %}
2
3{% block acl %}
4{% include "cumulus-linux/storage-leaf/include/acl.j2" %}
5{% endblock acl %}
6
7{% block bridge %}
8{% include "cumulus-linux/storage-leaf/include/bridge.j2" %}
9{% endblock bridge %}
10
11{% block interfaces %}
12{% include "cumulus-linux/storage-leaf/include/interface.j2" %}
13{% endblock interfaces %}
14
15{% block router %}
16{% include "cumulus-linux/storage-leaf/include/router.j2" %}
17{% endblock router %}
18
19{% block vrf %}
20{% include "cumulus-linux/storage-leaf/include/vrf.j2" %}
21{% endblock vrf %}

The common base defines the output skeleton and required blocks:

1{% block header %}
2- set:
3{% endblock header %}
4
5{% block acl %}{% endblock acl %}
6{% block bridge %}{% endblock bridge %}
7{% block evpn %}{% endblock evpn %}
8{% block interfaces required %}{% endblock interfaces %}
9{% block nve %}{% endblock nve %}
10{% block qos %}{% endblock qos %}
11{% block router required %}{% endblock router %}
12
13{% block service %}
14{% include "cumulus-linux/superpod-common/include/service.j2" %}
15{% endblock service %}
16
17{% block system %}
18{% include "cumulus-linux/superpod-common/include/system.j2" %}
19{% endblock system %}
20
21{% block vrf required %}{% endblock vrf %}

The final render is the merged result of those layers:

  1. The version-specific entrypoint selects the firmware-specific overrides.
  2. The role base supplies role-level blocks and includes.
  3. The common base supplies the file skeleton and default blocks.
  4. Jinja replaces each include with the included template output.
  5. Filters convert provider-supplied device, location, and plugin data into the values used by the templates.

Plugin Discovery

Template plugins are normal Python packages. A plugin registers an entry point in the nv_config_manager_templates.plugins group:

1[project]
2name = "example-config-manager-templates"
3version = "0.1.0"
4dependencies = ["nv-config-manager-templates"]
5
6[project.entry-points."nv_config_manager_templates.plugins"]
7example = "example_config_manager_templates"

The plugin module can provide any combination of templates, filters, or provider-neutral render-data requirements:

1from pathlib import Path
2from typing import Any
3
4from example_config_manager_templates.filters import get_custom_filters as _filters
5
6
7def get_template_paths() -> list[Path]:
8 return [Path(__file__).parent / "templates"]
9
10
11def get_custom_filters() -> dict[str, Any]:
12 return _filters()
13
14
15def get_render_data_requirements() -> dict[str, object]:
16 return {}

At renderer startup, Config Manager:

  1. Discovers installed entry points from nv_config_manager_templates.plugins.
  2. Calls plugin hooks such as get_template_paths(), get_custom_filters(), and get_render_data_requirements().
  3. Adds plugin template paths before the built-in package templates.
  4. Loads built-in filters.
  5. Loads plugin filters that do not conflict with an existing filter name.
  6. Collects provider-neutral plugin data requirements. The selected DCIM provider owns obtaining and normalizing matching data before it invokes the renderer.

Because plugin template paths are loaded before the built-in templates, a plugin can add new logical paths or intentionally shadow a built-in template path. Shadowing should be explicit and covered by render tests. Prefer extends and small block overrides when the plugin only needs to adjust part of a built-in template.

Plugin Template Layout

A plugin template root uses the same logical layout as the built-in templates:

src/example_config_manager_templates/templates/
cumulus-linux/
example-storage-leaf/
base/
startup.yaml.j2
include/
qos.j2
5.16.1/
entrypoint/
startup.yaml.j2
include/
qos.j2

For a new device role, create a role path that matches the normalized role name. For example, role Example Storage Leaf resolves to example-storage-leaf.

An entrypoint can reuse a built-in base template and override only the blocks that differ:

1{% extends "cumulus-linux/storage-leaf/base/startup.yaml.j2" %}
2
3{% block qos %}
4{% include "cumulus-linux/example-storage-leaf/5.16.1/include/qos.j2" %}
5{% endblock qos %}

If the plugin needs shared behavior for multiple plugin-owned roles, use plugin-owned common role names instead of broad names that look built-in:

cumulus-linux/example-common/base/startup.yaml.j2
cumulus-linux/example-storage-leaf/base/startup.yaml.j2
cumulus-linux/example-border-leaf/base/startup.yaml.j2

Plugin Data and Filters

The render context always includes:

device_data
location_data

When the selected provider supplies plugin data, the context can also include:

plugin_data

Plugin data is keyed by requirement name. Templates should not traverse raw provider response paths directly. Instead, expose stable domain concepts through plugin filters:

1def fabric_policy(plugin_data: dict, policy_name: str) -> dict:
2 policy_data = plugin_data.get("fabric_policy") or {}
3 ...

Then call that filter from the template:

1{% set policy = plugin_data|fabric_policy("default") %}

This keeps provider model changes isolated to Python filters instead of spreading response-shape assumptions across templates.

Local Plugin Testing

Install the plugin into the same Python environment as nv-config-manager-templates, then use template-cli for local iteration.

Create a provider configuration file, for example example-provider.toml:

1[provider]
2name = "example"
3
4[provider.settings]
5endpoint = "https://dcim.example"
6token = "<token>"

Cache the complete provider-supplied render data:

$uv run template-cli cache-query \
> --provider-config example-provider.toml \
> --device-name sw01 \
> --output-render-data-file tests/resources/render-data/sw01.json

Render from cached data:

$uv run template-cli render \
> --cached-render-data tests/resources/render-data/sw01.json \
> --entrypoint startup.yaml.j2

Without --vault, template-cli render disables Vault lookups and returns placeholder secret values. Use this mode for render tests, not for production device configuration.

For regression coverage, add a portable RenderData cache and expected rendered output for every plugin role and entrypoint. Template tests do not accept split or native DCIM cache formats.

The template library does not know a provider’s REST or GraphQL APIs. When a plugin needs extension data, declare get_render_data_requirements() and work with the provider author to populate the matching plugin_data value. See Contribute a DCIM Provider for the provider-side contract.

Deployment

External deployments load template plugins through the installer or Helm chart, not through template-cli.

With the installer, add plugin directories or plugin tarballs on the Template Plugins screen. The installer stages that content into the render service template plugin PVC and restarts render workloads only when the staged template content changes.

For Helm or GitOps-managed deployments, set renderService.templatePlugins.enabled and provide plugin source images when needed. A plugin source image can provide:

/plugin-wheels/*.whl
/plugin-source/pyproject.toml

The render service installs those plugin wheels or source packages into its runtime environment so their nv_config_manager_templates.plugins entry points are discoverable.

Rendered configuration metadata records both the engine package version and installed plugin package versions:

engine=nv-config-manager-templates:<engine-version>;plugins=<plugin>:<plugin-version>

That version vector lets Config Manager compare rendered configurations against the exact template engine and plugin set that produced them.