Network automation: Project

Dec 5, 2025

Notes and artifacts from my network automation project: containerlab for the lab infrastructure, Infrahub as the source of truth and schema, and Nornir + pygnmi for pushing the actual config to the SR Linux devices.

#network #automation #python

Source code: https://github.com/GoniMcColly/Infrahub_NetAut_Project

Full loop: a change lands in Infrahub (schema, VRF, interface) → the pygnmi-Artifact gets re-rendered by TransformSRLpygnmideploy_config.py fetches the artifact, diffs it against the live network-instance state on each leaf over gNMI, and pushes only the delta.

Setup

infrahubctl.toml

server_address="http://localhost:8000"
api_token="06438eb2-8019...c-0941b1f1d1ec"
# bring up Infrahub + Prefect (docker-compose.infrahub.yaml + .prefect.yaml + .override.yaml for the custom worker image)
make setup

# python deps
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

# point Infrahub at this repo so it picks up .infrahub.yml
# (schemas, queries, transforms, objects all get auto-discovered from there)
infrahubctl repository add "makolli-infrahub-project" "https://gitlab.ost.ch/agron.makolli/makolli-infrahub-project.git" --read-only --username <user> --password <token>

requirements.txt also pulls in the custom infrahub-worker package (setup/infrahub-worker), which ships a couple of protocol helpers and the NETCONF transform used below. nornir_pygnmi (the gnmi_set/gnmi_get tasks) isn’t pinned there, so it needs installing separately.

Topology

lab/clos.clab.yml — 2 spines (ixrd3l) + 3 leaves (ixrd2l), all nokia_srlinux, with 4 Linux clients hanging off the leaves:

name: clos

topology:
  kinds:
    nokia_srlinux:
      image: ghcr.io/nokia/srlinux
      binds:
        - ./config/__clabNodeName__.cfg:/tmp/base.cfg:ro
      exec:
        - sr_cli source /tmp/base.cfg
    linux:
      image: ghcr.io/hellt/network-multitool
  nodes:
    spine01:
      kind: nokia_srlinux
      type: ixrd3l
    spine02:
      kind: nokia_srlinux
      type: ixrd3l
    leaf01:
      kind: nokia_srlinux
      type: ixrd2l
    leaf02:
      kind: nokia_srlinux
      type: ixrd2l
    leaf03:
      kind: nokia_srlinux
      type: ixrd2l
    client01:
      kind: linux
      exec:
        - ip addr add 172.16.0.2/24 dev eth1
        - ip route add 172.16.0.0/12 via 172.16.0.1 dev eth1
    # client02-04 follow the same pattern, one /24 per leaf

  links:
    - endpoints: ["leaf01:e1-1", "spine01:e1-1"]
    - endpoints: ["leaf01:e1-2", "spine02:e1-1"]
    - endpoints: ["leaf02:e1-1", "spine01:e1-2"]
    - endpoints: ["leaf02:e1-2", "spine02:e1-2"]
    - endpoints: ["leaf03:e1-1", "spine01:e1-3"]
    - endpoints: ["leaf03:e1-2", "spine02:e1-3"]
    - endpoints: ["client01:eth1", "leaf01:e1-3"]
      ipv4: ["172.16.0.2/24"]
    - endpoints: ["client02:eth1", "leaf02:e1-3"]
    - endpoints: ["client03:eth1", "leaf03:e1-3"]
    - endpoints: ["client04:eth1", "leaf03:e1-4"]

Each nokia_srlinux node boots from a base config in lab/config/<node>.cfg, so the lab starts from a known state instead of an empty switch.

Infrahub schema

Node — A Node in Infrahub represents a model, in SQL terms equivalent to a table. An Attribute represents a column: a value directly associated with a Node, with a kind such as Text or Number. Every Node must be defined within a Namespace, which acts as a separation layer so multiple Nodes can share a name without conflict.

Generic — Lets you define shared attributes and relationships across different node types. A Generic can’t exist on its own, it must be implemented by at least one Node, much like an abstract class in Java or Python. CoreArtifactTarget below is one of these — inheriting from it is what makes a node eligible as an artifact target.

Extensions — Let you add relationships or attributes to nodes from a separate file, so schema files stay self-contained and pluggable.

infrahub/schemas/schema.yaml defines the core inventory of devices and their interfaces:

nodes:
  - name: Device
    namespace: Network
    inherit_from: ["CoreArtifactTarget"]
    attributes:
      - name: name
        kind: Text
        unique: true
      - name: platform
        kind: Text
        optional: false
      - name: status
        kind: Dropdown
        choices:
          - name: active
          - name: maintenance
          - name: offline
    relationships:
      - name: interfaces
        identifier: "device__interface"
        cardinality: many
        peer: NetworkInterface
        kind: Component

  - name: Interface
    namespace: Network
    uniqueness_constraints:
      - ["device", "name__value"]
    attributes:
      - name: name
        kind: Text
      - name: mode
        kind: Dropdown
        choices:
          - name: access
          - name: trunk
          - name: routed
          - name: fabric   # IPv6 underlay port
    relationships:
      - name: device
        peer: NetworkDevice
        identifier: "device__interface"
        cardinality: one
        kind: Parent
      - name: ip_address
        peer: IpamIPAddress
        cardinality: one
        optional: true
        kind: Attribute

The same file also defines Vlan, IPPrefix/IPAddress (built on Infrahub’s built-in IPAM types) and a Global node used purely as the target for the topology-wide artifacts further down.

infrahub/schemas/vrf.yaml is the piece that makes the VXLAN/EVPN multi-tenancy possible. Vrf node plus an extension that hangs a vrf relationship off every NetworkInterface:

nodes:
  - name: Vrf
    namespace: Network
    attributes:
      - name: name
        kind: Text       # e.g. "tenant-1"
        unique: true
      - name: vni
        kind: Number      # e.g. 100
      - name: evi
        kind: Number      # e.g. 1
      - name: route_target
        kind: Text        # e.g. "target:65535:1"
      - name: ecmp
        kind: Number
        default_value: 8
      - name: vxlan_tunnel_name
        kind: Text
        default_value: "vxlan1"
    relationships:
      - name: interface
        cardinality: many
        peer: NetworkInterface
        optional: true
        kind: Attribute

extensions:
  nodes:
    - kind: NetworkInterface
      relationships:
        - name: vrf
          peer: NetworkVrf
          cardinality: many
          optional: true
          kind: Attribute

Initial inventory data (the artifact target groups CiscoSwitche, SRLinuxRouter, GlobalArtifactTarget) is loaded as plain data objects from infrahub/objects/*.yml, referenced via the objects: key in .infrahub.yml. No manual clicking through the UI to create them.

Branches

Standard workflow: branch off, push a schema or data change against the branch, then open a proposed change to diff and merge into main.

infrahubctl branch create <branch-name>
infrahubctl <cmd> --branch <branch-name>
infrahubctl schema load <file.yaml> --branch <branch>

![[Pasted image 20251123134501.png]]

Artifacts & transforms (pygnmi)

.infrahub.yml ties everything together: the queries, the Python transforms, and which artifact gets generated for which device group:

queries:
  - name: GetVrfInterfaces
    file_path: "infrahub/queries/GetVrfInterfaces.gql"

schemas:
  - "infrahub/schemas/schema.yaml"
  - "infrahub/schemas/vrf.yaml"

python_transforms:
  - name: TransformSRLpygnmi
    class_name: TransformSRLpygnmi
    file_path: "infrahub/transforms/srl_pygnmi.py"
    convert_query_response: False

artifact_definitions:
  - name: "SRL_pygnmi-Artifact"
    artifact_name: "pygnmi-Artifact"
    parameters:
      name: "name__value"
    content_type: "application/json"
    targets: "SRLinuxRouter"
    transformation: "TransformSRLpygnmi"

It also registers a couple of bonus transforms not central to the deployment path: TransformTopologyMarkdown/SVGGraphviz/SVGD2 render the topology for docs, TransformContainerlabTopology regenerates a containerlab file straight from Infrahub’s inventory, and TransformSRLNetconf is a NETCONF-based alternative to the gNMI path below.

GetVrfInterfaces.gql pulls a device’s interfaces and their VRFs (VNI, EVI, route target, ECMP, VXLAN tunnel) out of Infrahub:

query GetVrfInterfaces($name: String!) {
  NetworkDevice(name__value: $name) {
    edges {
      node {
        name { value }
        interfaces {
          edges {
            node {
              name { value }
              ip_address { node { address { value } } }
              vrf {
                edges {
                  node {
                    name { value }
                    vni { value }
                    evi { value }
                    route_target { value }
                    ecmp { value }
                    vxlan_tunnel_name { value }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

infrahub/transforms/srl_pygnmi.py turns that into the SR Linux native JSON: one interface/subinterface entry per VRF membership (the VNI doubles as the subinterface index to keep it unique), a tunnel-interface with one vxlan-interface mapping per VNI, and a network-instance per VRF with EVPN/BGP-VPN wired up:

from infrahub_sdk.transforms import InfrahubTransform

class TransformSRLpygnmi(InfrahubTransform):
    query = "GetVrfInterfaces"

    async def transform(self, data):
        if not data["NetworkDevice"]["edges"]:
            return {"error": "Device not found"}

        device_node = data["NetworkDevice"]["edges"][0]["node"]
        interfaces_map = {}
        tunnel_interfaces_conf = [{"name": "vxlan1", "vxlan-interface": []}]
        network_instances_conf = []
        processed_vrfs = {}

        for edge in device_node["interfaces"]["edges"]:
            intf = edge["node"]
            intf_name = intf["name"]["value"]

            ip_address = None
            if intf["ip_address"] and intf["ip_address"]["node"]:
                ip_address = intf["ip_address"]["node"]["address"]["value"]
            if not ip_address:
                continue

            for vrf_edge in intf["vrf"]["edges"]:
                vrf_node = vrf_edge["node"]
                vrf_name = vrf_node["name"]["value"]
                vni = vrf_node["vni"]["value"]
                evi = vrf_node["evi"]["value"]
                rt = vrf_node["route_target"]["value"]

                # subinterface index = VNI, keeps it unique across VRFs
                sub_id = vni
                full_intf_name = f"{intf_name}.{sub_id}"

                if intf_name not in interfaces_map:
                    interfaces_map[intf_name] = {"name": intf_name, "subinterface": []}
                interfaces_map[intf_name]["subinterface"].append({
                    "index": sub_id,
                    "admin-state": "enable",
                    "ipv4": {"admin-state": "enable", "address": [{"ip-prefix": ip_address}]},
                })

                if vrf_name not in processed_vrfs:
                    tunnel_interfaces_conf[0]["vxlan-interface"].append({
                        "index": vni, "type": "routed", "ingress": {"vni": vni}
                    })
                    new_vrf = {
                        "name": vrf_name,
                        "type": "ip-vrf",
                        "admin-state": "enable",
                        "interface": [],
                        "vxlan-interface": [{"name": f"vxlan1.{vni}"}],
                        "protocols": {
                            "bgp-evpn": {"bgp-instance": [{
                                "id": 1, "admin-state": "enable",
                                "vxlan-interface": f"vxlan1.{vni}", "evi": evi, "ecmp": 8,
                            }]},
                            "bgp-vpn": {"bgp-instance": [{
                                "id": 1,
                                "route-target": {"export-rt": f"target:{rt}", "import-rt": f"target:{rt}"},
                            }]},
                        },
                    }
                    network_instances_conf.append(new_vrf)
                    processed_vrfs[vrf_name] = new_vrf

                processed_vrfs[vrf_name]["interface"].append({"name": full_intf_name})

        return {
            "interface": list(interfaces_map.values()),
            "tunnel-interface": tunnel_interfaces_conf,
            "network-instance": network_instances_conf,
        }

On the deployment side, Nornir uses nornir_infrahub’s InfrahubInventory plugin to build its host list straight from Infrahub data instead of a static inventory file:

from nornir import InitNornir
from nornir.core.inventory import ConnectionOptions
from nornir_infrahub.plugins.tasks import get_artifact
from nornir_pygnmi.tasks import gnmi_set, gnmi_get
from nornir.core.filter import F

def deploy_config(task):
    # read what's currently configured on the switch
    get_res = task.run(task=gnmi_get,
        path=["/network-instance/name", "/network-instance/type"], encoding="json_ietf")
    raw_val = get_res.result["notification"][0]["update"][0]["val"]
    all_instances = raw_val.get("srl_nokia-network-instance:network-instance", [])
    switch_vrfs = {i["name"] for i in all_instances if i["name"] not in ["mgmt", "default"]}

    # desired state comes straight from the Infrahub artifact
    artifact = task.run(task=get_artifact, artifact="pygnmi-Artifact")
    if not artifact.result:
        return "Skipped: Empty Artifact"
    artifact_vrfs = {vrf["name"] for vrf in artifact.result["network-instance"]}

    # diff: anything on the switch but not in the artifact gets deleted
    vrfs_to_delete = switch_vrfs - artifact_vrfs
    delete_paths = [f"/network-instance[name={name}]" for name in vrfs_to_delete]

    task.run(task=gnmi_set, update=[("/", artifact.result)],
        delete=delete_paths, encoding="json_ietf")

def main():
    nr = InitNornir(inventory={
        "plugin": "InfrahubInventory",
        "options": {
            "address": "http://<infrahub-host>:8000",
            "token": "<api-token>",
            "host_node": {"kind": "NetworkDevice"},
            "schema_mappings": [
                {"name": "hostname", "mapping": "primary_address.address"},
                {"name": "platform", "mapping": "platform.nornir_platform"},
            ],
        },
    })
    nr.inventory.defaults.username = "admin"
    nr.inventory.defaults.password = "<device-password>"
    nr.inventory.defaults.port = 57400
    nr.inventory.defaults.connection_options["gnmi"] = ConnectionOptions(
        port=57400, extras={"insecure": True, "skip_verify": True, "encoding": "json_ietf"}
    )

    # containerlab prefixes hostnames, e.g. leaf01 -> clab-clos-leaf01
    for name, host in nr.inventory.hosts.items():
        host.hostname = f"clab-clos-{name}"

    # VRFs only live on the leaves, not the spines
    target_devices = nr.filter(F(name__contains="leaf"))
    result = target_devices.run(task=deploy_config)

if __name__ == "__main__":
    main()