Skip to main content

Transforms

Developer Guide

Documents the transform implementations. To view artifacts as an operator, see Viewing Artifacts.

Overview

Transforms convert Infrahub data into usable outputs (configs, documentation, computed attributes). They run on-demand when artifacts are accessed.

Transform types

  1. Python Transforms - Complex logic, external library calls (PyAVD)
  2. Jinja2 Transforms - Template-based text generation

Every transform this repository registers is a Python transform. One of them, containerlab_topology, renders its output through a Jinja2 template it loads itself; the jinja2_transforms: block of .infrahub.yml is unused here.

Transform architecture

┌──────────────────┐ ┌────────────────────┐ ┌─────────────────┐
│ GraphQL Query │ ──▶ │ Transform Class │ ──▶ │ Artifact │
│ (*.gql) │ │ (*Transform) │ │ (config/doc) │
└──────────────────┘ └────────────────────┘ └─────────────────┘

Python transforms

ComputedInterfaceDescription

File: transforms/computed_interface_description.py

Purpose: Generate human-readable interface descriptions

Input: DcimInterface Output: String like "→ remote-device:Ethernet1"

class ComputedInterfaceDescription(InfrahubTransform):
async def transform(self, data):
interface = data["DcimInterface"]["edges"][0]["node"]
link = interface.get("link")
if not link:
return ""

# Find the remote end
remote = link["interface_a"] if link["interface_b"]["id"] == interface["id"] else link["interface_b"]
return f"→ {remote['device']['name']['value']}:{remote['name']['value']}"

CablingPlan

File: transforms/cabling_plan.py

Purpose: Generate CSV cabling documentation for a fabric

Input: NetworkFabric Output: CSV file with all connections

Source Device,Source Interface,Destination Device,Destination Interface,Link Type
spine-A1-1,Ethernet1,super-spine-A-1,Ethernet1,uplink
leaf-A1-01-1,Ethernet49,spine-A1-1,Ethernet1,uplink

AvdEosConfigTransform

File: transforms/avd_eos_config.py

Purpose: Convert AVD structured config to EOS CLI

Input: DcimDevice (with AvdArtifact) Output: EOS CLI configuration

class AvdEosConfigTransform(InfrahubTransform):
async def transform(self, data):
device = data["DcimDevice"]["edges"][0]["node"]
artifact = device["avd_artifact"]["node"]

if not artifact["structured_config_identifier"]["value"]:
return "! No structured config available"

config = await self.client.object_store.get(
identifier=artifact["structured_config_identifier"]["value"]
)
return pyavd.get_device_config(json.loads(config))

AvdFabricDocTransform

File: transforms/avd_fabric_doc.py

Purpose: Generate fabric-wide documentation

Input: NetworkFabric Output: Markdown documentation

class AvdFabricDocTransform(InfrahubTransform):
async def transform(self, data):
# Collect all device hostvars and structured configs
all_hostvars = {}
all_structured = {}

for device in devices:
hostvars = await self.client.object_store.get(...)
structured = await self.client.object_store.get(...)
all_hostvars[device.name] = hostvars
all_structured[device.name] = structured

avd_facts = pyavd.get_avd_facts(all_hostvars)
return pyavd.get_fabric_documentation(
avd_facts, all_structured, fabric_name
)

AvdDeviceDocTransform

File: transforms/avd_device_doc.py

Purpose: Generate per-device documentation

Input: DcimDevice Output: Markdown documentation for single device

AvdAntaCatalogTransform

File: transforms/avd_anta_catalog.py

Purpose: Render a per-device ANTA test catalog from the stored structured config

Input: DcimDevice Output: YAML catalog, or a one-line marker comment

Unlike EOS config rendering, catalog generation needs fabric-wide data, so the transform gathers every sibling device's structured config in the same fabric into one AVDFabricData before calling pyavd.get_device_test_catalog().

It is gated by the fabric's anta_enabled flag. When ANTA is disabled — or the device has no fabric, or no structured config — the transform returns a marker comment instead of a catalog, so the artifact renders successfully and says why it is empty:

# ANTA disabled for fabric Fabric-L3LS-Multi-Domain

ContainerLabTopology

File: transforms/containerlab_topology.py

Purpose: Render a ContainerLab topology file for a whole fabric

Input: NetworkFabric Output: YAML topology (topology.clab.yml shape)

The transform uses two queries — containerlab_topology for the fabric's devices and containerlab_link_endpoints to resolve link endpoints in batches — and renders through the transforms/templates/containerlab_topology.j2 template. Node kinds, container images, and the interface-mapping bind come from schema attributes (DcimPlatform.containerlab_os, DcimPlatform.containerlab_image, DcimDeviceType.containerlab_interface_mapping), so changing the cEOS version is a data change rather than a code change.

Nodes and links are emitted in a stable sorted order, so two renders of unchanged data are byte-identical. See the ContainerLab page for the full artifact shape, the role-selection rules, and how to deploy the topology.

CVWorkspaceSubmissionWebhookPayload

File: transforms/cv_workspace_submission_webhook.py

Purpose: Build the JSON body for the CloudVision workspace-submission CoreCustomWebhook

Input: CloudvisionWorkspace Output: JSON object — check name, proposed-change ID, and one entry per linked workspace with its ID, status, URL, and fabric name

This transform has no artifact definition: it is referenced by the webhook rather than rendered to the object store. See Checks for how it fits the CloudVision validation pipeline.

Query classes

Each transform has Pydantic models for type-safe query parsing. These *_query.py files are generated, not hand-written — regenerate them whenever the .gql query or the schema changes:

uv run infrahubctl graphql generate-return-types transforms/computed_interface_description.gql

This reads schema.graphql at the repo root (refresh with uv run infrahubctl graphql export-schema --destination schema.graphql when needed) and emits the matching *_query.py next to the query file.

Shape of a typical generated class:

# transforms/computed_interface_description_query.py (generated)

class InterfaceLink(BaseModel):
interface_a: InterfaceNode
interface_b: InterfaceNode

class InterfaceNode(BaseModel):
id: str
name: ValueWrapper[str]
device: DeviceRef
link: InterfaceLink | None

Artifacts

Artifacts are the output files generated by transforms.

Artifact definitions

Defined in .infrahub.yml:

artifact_definitions:
- name: cabling_plan
targets: fabrics
transformation: cabling_plan
content_type: text/csv

- name: avd_eos_configuration
targets: avd_devices
transformation: avd_eos_config
content_type: text/plain

- name: avd_fabric_documentation
targets: fabrics
transformation: avd_fabric_doc
content_type: text/markdown

- name: avd_device_documentation
targets: avd_devices
transformation: avd_device_doc
content_type: text/markdown

- name: avd_anta_catalog
targets: avd_devices
transformation: avd_anta_catalog
content_type: application/yaml

- name: containerlab_topology
targets: fabrics
transformation: containerlab_topology
content_type: application/yaml

cv_workspace_submission_webhook_payload is deliberately absent from this block — it renders a webhook body, not an artifact.

Viewing artifacts

  1. Navigate to target object in Infrahub UI
  2. Click Artifacts tab
  3. Select artifact to view/download

Regenerating artifacts

Artifacts regenerate automatically when:

  • Underlying data changes
  • Transform code changes
  • Manually triggered via UI

Configuration

Transforms are registered in .infrahub.yml:

python_transforms:
- name: computed_interface_description
class_name: ComputedInterfaceDescription
file_path: "./transforms/computed_interface_description.py"

- name: cabling_plan
class_name: CablingPlan
file_path: "./transforms/cabling_plan.py"

- name: avd_eos_config
class_name: AvdEosConfigTransform
file_path: "./transforms/avd_eos_config.py"

- name: avd_fabric_doc
class_name: AvdFabricDocTransform
file_path: "./transforms/avd_fabric_doc.py"

- name: avd_device_doc
class_name: AvdDeviceDocTransform
file_path: "./transforms/avd_device_doc.py"

- name: avd_anta_catalog
class_name: AvdAntaCatalogTransform
file_path: "./transforms/avd_anta_catalog.py"

- name: containerlab_topology
class_name: ContainerLabTopology
file_path: "./transforms/containerlab_topology.py"

- name: cv_workspace_submission_webhook_payload
class_name: CVWorkspaceSubmissionWebhookPayload
file_path: "./transforms/cv_workspace_submission_webhook.py"
convert_query_response: false

File structure

transforms/
├── computed_interface_description.py # Interface description
├── computed_interface_description.gql # Interface query
├── computed_interface_description_query.py # Pydantic models
├── cabling_plan.py # Cabling plan CSV
├── fabric_cabling_plan.gql # Fabric cabling query
├── fabric_cabling_plan_query.py # Pydantic models
├── avd_eos_config.py # EOS config transform
├── avd_device_config.gql # Device config query
├── avd_device_config_query.py # Pydantic models
├── avd_fabric_doc.py # Fabric documentation
├── avd_device_doc.py # Device documentation
├── avd_fabric_devices.gql # Fabric devices query
├── avd_fabric_devices_query.py # Pydantic models
├── avd_anta_catalog.py # ANTA catalog transform
├── avd_anta_catalog.gql # ANTA catalog query
├── avd_anta_catalog_query.py # Pydantic models
├── containerlab_topology.py # ContainerLab topology transform
├── containerlab_topology.gql # Fabric device/link query
├── containerlab_topology_query.py # Pydantic models
├── containerlab_link_endpoints.gql # Batched link-endpoint query
├── containerlab_link_endpoints_query.py # Pydantic models
├── cv_workspace_submission_webhook.py # CloudVision webhook payload
├── cv_workspace_submission_webhook.gql # Workspace query
├── cv_workspace_submission_webhook_query.py # Pydantic models
└── templates/
└── containerlab_topology.j2 # ContainerLab topology template

Creating new transforms

Python transform

  1. Create transform class:
# transforms/my_transform.py
from infrahub_sdk.transforms import InfrahubTransform

class MyTransform(InfrahubTransform):
query = "my_query"

async def transform(self, data):
# Process data
return "output"
  1. Create GraphQL query:
# transforms/my_query.gql
query MyQuery($device_id: String!) {
DcimDevice(ids: [$device_id]) {
edges {
node {
name { value }
}
}
}
}
  1. Register in .infrahub.yml:
queries:
- name: my_query
file_path: "./transforms/my_query.gql"

python_transforms:
- name: my_transform
class_name: MyTransform
file_path: "./transforms/my_transform.py"

artifact_definitions:
- name: my_artifact
targets: devices
transformation: my_transform

Jinja2 transform

  1. Create template:
{# transforms/templates/my_template.j2 #}
Output for {{ node.name.value }}
  1. Register in .infrahub.yml:
jinja2_transforms:
- name: my_jinja_transform
template_path: "./transforms/templates/my_template.j2"
query: my_query

Source