Introduction to Preflight Gates
Preflight gates are automated checks that run after containerlab deploy creates the containers but before any user‑defined configuration is applied. They compare the intended state (derived from the topology file) with the observed state (reported by the runtime) for three key networking primitives:
- LLDP neighbor discovery – verifies wiring
- Interface naming – confirms that the alias declared in the topology matches the interface seen inside the container
- Link‑state – ensures both ends of a link report
carrierandstate UP
If any mismatch is detected, the gate aborts the job, preventing hours of fruitless protocol debugging caused by a mis‑patched cable, wrong container image, or incorrect interface mapping.
Expected‑versus‑Observed LLDP Evidence
Understanding LLDP in Preflight Gates
LLDP (Link Layer Discovery Protocol) advertises a device’s identity and port identifiers to directly connected neighbors. In Containerlab each node can run an LLDP agent (e.g., lldpd) that populates the kernel’s LLDP tables (lldpctl show neighbors).
- Intended LLDP – built from the topology: each link defines two endpoints, each with a node name and an interface alias.
- Observed LLDP – queried from each node after the container starts but before user config is applied.
A mismatch indicates a wrong patch, missing container, or mis‑mapped interface alias.
Configuring LLDP in Containerlab
LLDP is not enabled by default; add it via an exec block for a kind: linux node:
name: lldp-preflight
topology:
nodes:
leaf1:
kind: linux
image: alpine:latest
exec:
- command: apk add --no-cache lldpd
- command: rc-service lldpd start
leaf2:
kind: linux
image: alpine:latest
exec:
- command: apk add --no-cache lldpd
- command: rc-service lldpd start
links:
- endpoints: ["leaf1:eth1", "leaf2:eth1"]
Verifying LLDP with CLI
# Deploy and inspect the lab
containerlab deploy -t lldp-preflight.yml --retrieve
containerlab inspect -t lldp-preflight.yml -o json > rendered.json
# Extract container IDs
LEAF1_ID=$(jq -r '.nodes[] | select(.name=="leaf1") | .container_id' rendered.json)
LEAF2_ID=$(jq -r '.nodes[] | select(.name=="leaf2") | .container_id' rendered.json)
# Query LLDP neighbors (JSON output)
docker exec "$LEAF1_ID" lldpctl show neighbors -f json
docker exec "$LEAF2_ID" lldpctl show neighbors -f json
The expected JSON for leaf1 contains a single neighbor whose chassis_id matches leaf2 and whose port_id matches eth1. Any deviation triggers a gate failure.
Common LLDP Issues
| Symptom | Likely Cause | Diagnostic Step |
|---|---|---|
| Empty LLDP neighbor table | lldpd not running or missing | docker exec <cid> ps | grep lldpd |
| Neighbor shows wrong port name | Interface alias mismatch in topology | Compare ethX in YAML with ip link show inside container |
Chassis ID is 00:00:00:00:00:00 | LLDP agent not initialized fast enough | Add a sleep 5 after starting lldpd or use a healthcheck |
| Multiple neighbors on same port | Accidental loop or duplicate link definition | Verify links: section for duplicate endpoints |
Interface Naming Evidence
How Containerlab Assigns Interface Names
The <node>:<iface> syntax in the endpoints field makes <iface> the interface alias inside the container. The actual kernel name may differ if:
- The container image already uses
eth0‑eth9 for other purposes. - The node kind (e.g.,
vrnetlab) reserves certain indices. - The user overrides names via
extra-host-configor customip link set name.
A preflight gate validates that the observed name matches the intended alias after the container boots but before user config is applied.
Configuring Interface Naming
name: intf-naming
topology:
nodes:
leaf1:
kind: linux
image: alpine:latest
leaf2:
kind: linux
image: alpine:latest
links:
- endpoints: ["leaf1:eth10", "leaf2:eth20"] # intentional high numbers
Verifying Interface Names with CLI
containerlab deploy -t intf-naming.yml --retrieve
containerlab inspect -t intf-naming.yml -o json > rendered.json
LEAF1_ID=$(jq -r '.nodes[] | select(.name=="leaf1") | .container_id' rendered.json)
LEAF2_ID=$(jq -r '.nodes[] | select(.name=="leaf2") | .container_id' rendered.json)
docker exec "$LEAF1_ID" ip -brief link show | grep -E 'eth10|lo'
docker exec "$LEAF2_ID" ip -brief link show | grep -E 'eth20|lo'
Expected output (example):
eth10 UP 00:11:22:33:44:55 <BROADCAST,MULTICAST,UP,LOWER_UP>
lo UNKNOWN 00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP>
If the interface appears as eth1 instead of eth10, the naming gate fails.
Common Interface‑Naming Issues
| Symptom | Likely Cause | Diagnostic Step |
|---|---|---|
Interface appears as eth0 despite eth10 in YAML | Container image already occupies eth0‑eth9; kernel renames higher indices | docker exec <cid> ip link show; adjust offset or use vrnetlab with proper offset |
Interface name contains unexpected prefix (e.g., netveth) | Using kind: bridge or default Docker veth pairing; Containerlab rewrites only data‑plane interfaces | Verify endpoint refers to a data‑plane interface; use extra-host-config to rename if needed |
| No interface appears at all | Node failed to start or crashed before interface creation | containerlab inspect -t ... to check node state; examine docker logs <cid> |
Link‑State Evidence
Role of Link‑State in Preflight Gates
Link‑state reflects the operational state of a layer‑2 link: whether the interface reports carrier (link‑up) and can send/receive frames. In Containerlab a link is considered up when both ends show state UP and carrier is detected. The gate uses this as a physical‑layer sanity check; if the intended topology declares a link but one or both ends are down, the gate aborts, preventing higher‑layer protocols from attempting adjacencies over a dead link.
Influencing Link‑State
Link‑state emerges from the veth pairs Containerlab creates. To ensure interfaces come up automatically, grant the container sufficient privileges:
name: linkstate-check
topology:
nodes:
leaf1:
kind: linux
image: alpine:latest
privileged: true
leaf2:
kind: linux
image: alpine:latest
privileged: true
links:
- endpoints: ["leaf1:eth1", "leaf2:eth1"]
Verifying Link‑State with CLI
containerlab deploy -t linkstate-check.yml --retrieve
containerlab inspect -t linkstate-check.yml -o json > rendered.json
LEAF1_ID=$(jq -r '.nodes[] | select(.name=="leaf1") | .container_id' rendered.json)
LEAF2_ID=$(jq -r '.nodes[] | select(.name=="leaf2") | .container_id' rendered.json)
docker exec "$LEAF1_ID" ip -details link show eth1 | grep -E 'state|carrier'
docker exec "$LEAF2_ID" ip -details link show eth1 | grep -E 'state|carrier'
Expected lines (example):
state UP mode DEFAULT group default qlen 1000
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
If either side shows state DOWN or carrier OFF, the link‑state gate fails.
Common Link‑State Issues
| Symptom | Likely Cause | Diagnostic Step |
|---|---|---|
Interface shows state DOWN | veth pair not moved into container namespace or missing NET_ADMIN | Verify privileged: true or cap_add: [NET_ADMIN]; inspect docker inspect <cid> for CapAdd |
Interface shows carrier OFF despite UP | Mis‑matched veth peers (one end in wrong namespace) | Use docker exec <cid> ethtool eth1 to see Link detected: yes/no; check peer with docker exec <othercid> ethtool eth1 |
| Link flaps rapidly | Race condition where interface is brought up before veth peer is ready | Add a sleep 2 or healthcheck loop in an exec block after container start |
| No interface visible at all | Container crashed on start; image missing required tools | Check container logs: docker logs <cid>; ensure image includes iproute2 |
Implementing Preflight Gates in Containerlab
Gate Workflow
- Deploy the lab with
containerlab deploy -t <file> --retrieve. - Inspect the rendered topology (
containerlab inspect -o json). - For each link, derive the intended LLDP neighbor, interface name, and link‑state expectations.
- Query each container via
docker execto collect the observed LLDP table (lldpctl show neighbors -f json), interface list (ip -brief link show), and carrier state (ip -details link show). - Compare intended vs. observed, ignoring variable fields such as MAC addresses.
- Exit with a non‑zero status on any mismatch, causing the CI/CD pipeline to halt.
The gate defines a clear transaction boundary:
- Precondition – containers are running, no user config applied.
- Commit – gate success → proceed to apply user config.
- Rollback – gate failure → abort further steps; optionally run
containerlab destroy. - Blast radius – limited to the lab; no production traffic affected.
- Operator intervention – gate prints a human‑readable diff and stops, allowing inspection of wiring or image before proceeding.
Example Preflight Gate Script (preflight.sh)
#!/usr/bin/env bash
set -euo pipefail
# -------------------------------------------------
# Usage: ./preflight.sh <topology-file.yml>
# -------------------------------------------------
TOPOLOGY_FILE="${1:-topology.yml}"
LAB_NAME=$(basename "${TOPOLOGY_FILE}" .yml)
echo "[$(date)] Deploying lab ${LAB_NAME} for preflight validation..."
containerlab deploy -t "${TOPOLOGY_FILE}" --retrieve
# Grab rendered topology in JSON
containerlab inspect -t "${TOPOLOGY_FILE}" -o json > rendered.json
# Helper: get LLDP neighbors from a container (returns {} on failure)
get_lldp() {
local cid=$1
docker exec "$cid" lldpctl show neighbors -f json 2>/dev/null || echo '{}'
}
# Helper: get interface state (returns "state,carrier")
get_iface_state() {
local cid=$1 iface=$2
docker exec "$cid" ip -details link show "$iface" 2>/dev/null | awk '
/state/ {state=$2}
/carrier/ {carrier=$2}
END {print state "," carrier}'
}
FAILURES=0
# Iterate over each link in the rendered topology
jq -c '.links[]' rendered.json | while read -r link; do
NODE_A=$(echo "$link" | jq -r '.endpoints[0]' | cut -d: -f1)
IFACE_A=$(echo "$link" | jq -r '.endpoints[0]' | cut -d: -f2)
NODE_B=$(echo "$link" | jq -r '.endpoints[1]' | cut -d: -f1)
IFACE_B=$(echo "$link" | jq -r '.endpoints[1]' | cut -d: -f2)
CID_A=$(jq -r --arg n "$NODE_A" '.nodes[] | select(.name==$n) | .container_id' rendered.json)
CID_B=$(jq -r --arg n "$NODE_B" '.nodes[] | select(.name==$n) | .container_id' rendered.json)
# ----- LLDP check -----
LLDP_A=$(get_lldp "$CID_A")
LLDP_B=$(get_lldp "$CID_B")
# Expect each side to see the other as a single neighbor
EXPECTED_A=$(jq -n --arg ch "$CID_B" --arg if "$IFACE_B" '{neighbors:[{chassis_id:$ch,port_id:$if}]}')
EXPECTED_B=$(jq -n --arg ch "$CID_A" --arg if "$IFACE_A" '{neighbors:[{chassis_id:$ch,port_id:$if}]}')
if [[ "$LLDP_A" != "$EXPECTED_A" ]]; then
echo "❌ LLDP mismatch on $NODE_A:$IFACE_A"
echo " Expected: $EXPECTED_A"
echo " Observed: $LLDP_A"
((FAILURES++))
fi
if [[ "$LLDP_B" != "$EXPECTED_B" ]]; then
echo "❌ LLDP mismatch on $NODE_B:$IFACE_B"
echo " Expected: $EXPECTED_B"
echo " Observed: $LLDP_B"
((FAILURES++))
fi
# ----- Interface naming check -----
# Observe the interface name as seen inside the container
OBS_A=$(docker exec "$CID_A" ip -brief link show | awk -v iface="$IFACE_A" '$1==iface {print $1; exit}')
OBS_B=$(docker exec "$CID_B" ip -brief link show | awk -v iface="$IFACE_B" '$1==iface {print $1; exit}')
if [[ -z "$OBS_A" || "$OBS_A" != "$IFACE_A" ]]; then
echo "❌ Interface naming mismatch on $NODE_A"
echo " Expected: $IFACE_A"
echo " Observed: ${OBS_A:-<none>}"
((FAILURES++))
fi
if [[ -z "$OBS_B" || "$OBS_B" != "$IFACE_B" ]]; then
echo "❌ Interface naming mismatch on $NODE_B"
echo " Expected: $IFACE_B"
echo " Observed: ${OBS_B:-<none>}"
((FAILURES++))
fi
# ----- Link‑state check -----
STATE_A=$(get_iface_state "$CID_A" "$IFACE_A")
STATE_B=$(get_iface_state "$CID_B" "$IFACE_B")
if [[ "$STATE_A" != "UP,ON" ]]; then
echo "❌ Link‑state down on $NODE_A:$IFACE_A – state/carrier: $STATE_A"
((FAILURES++))
fi
if [[ "$STATE_B" != "UP,ON" ]]; then
echo "❌ Link‑state down on $NODE_B:$IFACE_B – state/carrier: $STATE_B"
((FAILURES++))
fi
done
if (( FAILURES > 0 )); then
echo "❌ Preflight gate failed with $FAILURES error(s). Aborting."
exit 1
else
echo "✅ Preflight gate passed – all LLDP, naming, and