Skip to content
LinkState
Go back

Catching cable swaps before the first test packet

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:

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).

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

SymptomLikely CauseDiagnostic Step
Empty LLDP neighbor tablelldpd not running or missingdocker exec <cid> ps | grep lldpd
Neighbor shows wrong port nameInterface alias mismatch in topologyCompare ethX in YAML with ip link show inside container
Chassis ID is 00:00:00:00:00:00LLDP agent not initialized fast enoughAdd a sleep 5 after starting lldpd or use a healthcheck
Multiple neighbors on same portAccidental loop or duplicate link definitionVerify 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:

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

SymptomLikely CauseDiagnostic Step
Interface appears as eth0 despite eth10 in YAMLContainer image already occupies eth0‑eth9; kernel renames higher indicesdocker 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 interfacesVerify endpoint refers to a data‑plane interface; use extra-host-config to rename if needed
No interface appears at allNode failed to start or crashed before interface creationcontainerlab 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

SymptomLikely CauseDiagnostic Step
Interface shows state DOWNveth pair not moved into container namespace or missing NET_ADMINVerify privileged: true or cap_add: [NET_ADMIN]; inspect docker inspect <cid> for CapAdd
Interface shows carrier OFF despite UPMis‑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 rapidlyRace condition where interface is brought up before veth peer is readyAdd a sleep 2 or healthcheck loop in an exec block after container start
No interface visible at allContainer crashed on start; image missing required toolsCheck container logs: docker logs <cid>; ensure image includes iproute2

Implementing Preflight Gates in Containerlab

Gate Workflow

  1. Deploy the lab with containerlab deploy -t <file> --retrieve.
  2. Inspect the rendered topology (containerlab inspect -o json).
  3. For each link, derive the intended LLDP neighbor, interface name, and link‑state expectations.
  4. Query each container via docker exec to collect the observed LLDP table (lldpctl show neighbors -f json), interface list (ip -brief link show), and carrier state (ip -details link show).
  5. Compare intended vs. observed, ignoring variable fields such as MAC addresses.
  6. Exit with a non‑zero status on any mismatch, causing the CI/CD pipeline to halt.

The gate defines a clear transaction boundary:

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

Share this post on:

Previous Post
Fault-injecting leaf-spine fabrics before production does
Next Post
Pre-merge verification gates for copilot changes