ContainerLab
ContainerLab runs a fabric as containers — Arista cEOS nodes for the network devices, plain Linux containers for the servers. The repository renders a topology file for any modelled fabric as an Infrahub artifact, so a virtual replica of the fabric can be brought up from the same source of truth that produces the EOS configs.
Two flows exist, and they are separate on purpose:
- The committed lab in
lab/— a two-DC topology checked into the repository, booting from AVD renders that are also checked in. This is the day-to-day lab; seelab/README.md. - The generated topology — the
ContainerLab Topologyartifact described here, rendered per fabric from Infrahub data. Use this for fabrics the committed lab does not cover.
The ContainerLab topology artifact
| Property | Value |
|---|---|
| Artifact name | ContainerLab Topology |
| Attached to | Each NetworkFabric (target group fabrics) |
| Content type | application/yaml |
| Transform | containerlab_topology |
The artifact is fabric-scoped: one topology per fabric, containing every device the fabric owns
through its pods and racks. The rendered name: is the fabric name, so the management network and
container names are derived from it (clab-<fabric-name>-mgmt).
Find it on the fabric's Artifacts tab in the Infrahub UI, the same way as the fabric documentation — see Viewing Artifacts.
To preview a render locally without going through a proposed change:
# COLUMNS is set because infrahubctl prints via Rich, which wraps long lines at the
# terminal width — irrelevant to the server-rendered artifact, but not to a local capture.
COLUMNS=500 uv run infrahubctl transform containerlab_topology name=Fabric-L3LS-Multi-Domain
What ends up in the topology
---
name: Fabric-L3LS-Multi-Domain
mgmt:
network: clab-Fabric-L3LS-Multi-Domain-mgmt
ipv4-subnet: 10.0.6.0/24
topology:
kinds:
arista_ceos:
image: arista/ceos:4.36.0.1F
startup-config: configs/__clabNodeName__.cfg
linux:
image: lab-server
nodes:
spine-infrahub-dc1-1:
kind: arista_ceos
mgmt-ipv4: 10.0.6.11
binds:
- configs/eos-intf-mapping/DCS-7050CX3-32S.json:/mnt/flash/EosIntfMapping.json:ro
dc1-server:
kind: linux
mgmt-ipv4: 10.0.6.100
binds:
- configs/servers/dc1-server-netplan.yaml:/etc/netplan/netplan.yaml
links:
- endpoints: ["leaf-infrahub-dc1-1:eth49_1", "spine-infrahub-dc1-1:eth1_1"]
Nodes and links are emitted in a stable sorted order, and endpoints are ordered within each link, so two renders of unchanged data are byte-identical.
startup-config is set only on kinds whose nodes are network devices — the linux kind has none,
because servers boot from their netplan bind. The path is configs/__clabNodeName__.cfg, the
directory the deploy playbook writes fetched EOS configs into.
Which devices are included
Devices are selected by DcimDevice.role. Included: super_spine, spine, leaf,
border_leaf, l2leaf, l2spine, l3spine. ComputePhysicalServer members of the fabric are
included as Linux nodes.
The p, pe, and rr roles are deliberately excluded. They belong to the ISIS-LDP fabric,
whose interface naming has not been validated against ContainerLab, so admitting them would be
speculative. Each excluded device is logged as a warning during the render rather than dropped
silently, so Fabric-ISIS-LDP renders without those devices and says so in the transform log.
A link is only emitted when it resolves to exactly two endpoints and both endpoints belong to
devices present in nodes.
Kind, image, and interface mapping come from the schema
Nothing about node identity is hardcoded in the transform. Three schema attributes drive it:
| Attribute | Node | Drives |
|---|---|---|
DcimPlatform.containerlab_os | kind: on each node | arista_ceos, linux |
DcimPlatform.containerlab_image | image: on each kinds entry | arista/ceos:4.36.0.1F, lab-server |
DcimDeviceType.containerlab_interface_mapping | the EosIntfMapping.json bind | DCS-7050CX3-32S.json |
All three are optional Text attributes. A device whose platform has no containerlab_os cannot
be rendered as a node; a device type with no containerlab_interface_mapping gets no
mapping bind (the binds key is omitted entirely when a node has nothing to bind).
containerlab_interface_mapping holds a filename only, not a path, and not the file contents.
The file itself lives in lab/configs/eos-intf-mapping/ and is resolved relative to the topology
file at deploy time. Note the filenames intentionally differ from the device type's part_number
(DCS-7050CX3-32S.json for part number DCS-7050CX3-32C) — the attribute exists precisely so the
mapping filename does not have to be derived from anything else.
Seed values live in objects/common/03_device_type.yml.
Interface names and why the mapping bind matters
Link endpoints are translated from EOS names to the Linux interface names cEOS exposes to ContainerLab:
| EOS name | ContainerLab name |
|---|---|
Ethernet5 | eth5 |
Ethernet1/1 | eth1_1 |
Ethernet49/1 | eth49_1 |
The rule is Ethernet<N>[/<M>] → eth<N>[_<M>]: strip the Ethernet prefix and replace / with
_.
For plain Ethernet<N> interfaces that is all cEOS needs — it maps ethN to EthernetN by
default. Breakout names are the problem. A generated config that says interface Ethernet1/1 does
not attach to anything if cEOS has decided that eth1_1 is Ethernet1_1, or has not created the
interface at all. EosIntfMapping.json is the file that tells cEOS which container interface
corresponds to which EOS interface name, per device type. It is mounted read-only:
binds:
- configs/eos-intf-mapping/DCS-7050SX3-48YC8.json:/mnt/flash/EosIntfMapping.json:ro
On a fabric whose spines use Ethernet<N>/1 uplinks and whose leaves use Ethernet49-50/1, the
mapping bind is what makes the AVD-rendered config match the interfaces that actually exist. To
confirm it took effect after a deploy:
docker exec clab-<topology>-<a-spine> Cli -c "show interfaces status" | head -20
Expect Ethernet1/1-style names. Seeing eth1_1 instead means the bind is missing, or points at a
file that is not on disk next to the topology.
Server nodes carry a netplan bind instead, mounted at /etc/netplan/netplan.yaml. The source
filename is derived by convention from the device name (configs/servers/<device-name>-netplan.yaml);
netplan contents are not generated from Infrahub.
Deploying with Ansible
ansible/deploy_clab.yml fetches the topology artifact and each device's AVD EOS Configuration
artifact from Infrahub, stages them plus every committed bind source onto a ContainerLab host, and
runs containerlab deploy there. It lives in ansible/ because that directory is also the
Semaphore playbook repository.
The playbook is two plays, because the machine that talks to Infrahub is not necessarily the machine that runs the lab:
| Play | Hosts | Does |
|---|---|---|
| 1 | localhost | Resolves the fabric to a node ID, fetches the topology and per-device config artifacts, asserts each returned a body |
| 2 | clab_hosts | Stages the topology, configs, and bind sources onto the lab host, validates every bind and startup-config path exists there, then deploys |
Nothing is written to the controller's filesystem — the artifacts are copied straight to the lab host, so no shared filesystem is assumed.
Point clab_hosts at whichever host runs ContainerLab, in ansible/inventory_clab.yml. It defaults
to localhost with ansible_connection: local, so a single-machine setup works unchanged. This
inventory must be passed explicitly, because ansible/ansible.cfg pins inventory to the dynamic
Infrahub plugin.
Required environment:
export INFRAHUB_ADDRESS=http://localhost:8000
export INFRAHUB_API_TOKEN=<token>
The playbook uses the opsmill.infrahub collection. Install it from the repository root:
ansible-galaxy collection install -r ansible/galaxy-requirements.yml
The collection's plugins run on the Ansible controller and import infrahub-sdk directly, so
infrahub-sdk must be importable by the controller's Python — otherwise they fail with
infrahub_sdk must be installed to use this plugin. lab/pyproject.toml provides it, which is why
the wrapper below runs through uv run from lab/.
Then:
cd lab
make deploy-from-infrahub FABRIC=Fabric-L3LS-Multi-Domain
Or invoke it directly, noting the explicit inventory:
uv run ansible-playbook -i ../ansible/inventory_clab.yml ../ansible/deploy_clab.yml \
-e fabric=Fabric-L3LS-Multi-Domain
To stage and validate without touching the lab, skip the deployment tasks:
... --skip-tags deploy
Useful variables:
| Variable | Default | Purpose |
|---|---|---|
fabric | required | Which fabric to deploy |
clab_host_group | clab_hosts | Inventory group running ContainerLab |
clab_staging_dir | /opt/containerlab/<fabric> | Where files are staged on the lab host |
clab_dir | <repo>/lab | Where committed bind sources are read from on the controller |
Verify:
containerlab inspect --topo topology.clab.yml
docker ps --format '{{.Names}}' | grep clab- | wc -l
Running it from Semaphore
invoke init-semaphore registers a Fetch ContainerLab Files template. It runs the same playbook
with --skip-tags deploy, so it fetches the artifacts, stages every file the topology references,
and validates them — but does not deploy. That is deliberate: the Semaphore container has no
containerlab binary and no Docker socket, so an unskipped run can only ever fail on the
containerlab check.
The pulled files land on the Docker host, in lab/clab-staging:
lab/clab-staging/