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 TransformSRLpygnmi → deploy_config.py fetches the artifact, diffs it against the live network-instance state on each leaf over gNMI, and pushes only the delta.
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.
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.
Node — A
Nodein Infrahub represents a model, in SQL terms equivalent to a table. AnAttributerepresents a column: a value directly associated with aNode, with akindsuch asTextorNumber. EveryNodemust be defined within aNamespace, which acts as a separation layer so multipleNodes can share a name without conflict.
Generic — Lets you define shared attributes and relationships across different node types. A
Genericcan’t exist on its own, it must be implemented by at least oneNode, much like an abstract class in Java or Python.CoreArtifactTargetbelow 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.
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]]
.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()