Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1bf6d25
Add BIRD EVPN/VXLAN configuration templates.
jbemmel Jun 12, 2026
17853b7
Fix BIRD EVPN/VXLAN container startup and BGP session handling.
jbemmel Jun 13, 2026
c855257
Fix BIRD EVPN route reflection over EBGP sessions.
jbemmel Jun 13, 2026
c62264e
Inline BIRD source-build entrypoint and drop build context copy.
jbemmel Jun 13, 2026
209eb66
Remove unused EXPOSE from BIRD source-build Dockerfile.
jbemmel Jun 13, 2026
ce10613
Move BIRD VXLAN interface setup from vlan.j2 to vxlan.j2.
jbemmel Jun 13, 2026
f5345c2
Split BIRD VXLAN shell setup from daemon configuration.
jbemmel Jun 13, 2026
c0b88e7
Rename BIRD VXLAN daemon template to vxlan.mod.j2.
jbemmel Jun 13, 2026
991a9b4
Fix BIRD EVPN containerlab startup and VXLAN interface ordering.
jbemmel Jun 14, 2026
e1dff3a
Fix duplicate BIRD BGP protocol names in IBGP-over-EBGP labs.
jbemmel Jun 14, 2026
e68e756
Revert FRR VLAN bridge MAC address setup change.
jbemmel Jun 14, 2026
ebe51df
Simplify BIRD container entrypoint to wait for netlab initial only.
jbemmel Jun 14, 2026
621c9f0
Remove redundant runtime directory creation from BIRD image.
jbemmel Jun 14, 2026
e3db717
Inline netlab config done marker lookup in mark_config_done.
jbemmel Jun 14, 2026
9ca1373
Stop tracking local BIRD source patches in git.
jbemmel Jun 14, 2026
62ba689
Mark clab config done only after all deploy scripts succeed.
jbemmel Jun 14, 2026
584641a
Revert .gitignore change for local BIRD patches.
jbemmel Jun 14, 2026
fe39d5b
Fix ruff I001 import formatting in clab configs.
jbemmel Jun 14, 2026
ea25b48
Extract gateway data-plane setup into a shared FRR template.
jbemmel Jun 15, 2026
7c1ac05
Move BIRD config-done marker into a container deploy script.
jbemmel Jun 15, 2026
10502ae
Remove interface wait loop from BIRD initial config template.
jbemmel Jun 15, 2026
ff02050
Add config-done wait entrypoint to all BIRD Dockerfiles.
jbemmel Jun 16, 2026
0706caf
Configure BIRD clab data plane from the host netns.
jbemmel Jun 16, 2026
d71130d
Rename BIRD clab initial template to bird-clab.j2.
jbemmel Jun 16, 2026
3f711e8
Remove invalid fallback logic hack
jbemmel Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions netsim/ansible/templates/gateway/frr.j2
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,6 @@ if [ ! -e /sys/class/net/{{ v_if }} ]; then
ip link set dev {{ v_if }} up
fi
{% endfor %}
{% if interfaces | selectattr('gateway.protocol', 'defined') | selectattr('gateway.protocol', 'equalto', 'vrrp') | list %}
Comment thread
jbemmel marked this conversation as resolved.
Outdated
{% include 'frr.vrrp-config.j2' %}
{% endif %}
9 changes: 0 additions & 9 deletions netsim/ansible/templates/initial/bird-clab.j2

This file was deleted.

32 changes: 32 additions & 0 deletions netsim/ansible/templates/initial/bird.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/sh
#
# Initial configuration for BIRD containers (docker exec / sh mode).
# Do not use the -clab suffix here: that convention is for netns execution on the host.
#
set -x
set -e
{% if 'vxlan' in module|default([]) %}
Comment thread
jbemmel marked this conversation as resolved.
Outdated
#
# containerlab interfaces might not be ready when netlab initial executes this
# script via docker exec
#
waited=0
while [ "$waited" -lt "${NETLAB_INTERFACE_WAIT:-30}" ]; do
if [ -d /sys/class/net/eth1 ]; then
break
fi
sleep 1
waited=$((waited + 1))
done
if [ ! -d /sys/class/net/eth1 ]; then
echo "Timeout waiting for containerlab interfaces (eth1)" >&2
exit 1
fi
{% endif %}
{% include 'linux/vanilla.j2' +%}
#
{% if loopback.ipv6 is defined %}
set +e
ip addr add fe80::1/64 dev lo scope link
{% endif %}
exit 0
2 changes: 2 additions & 0 deletions netsim/ansible/templates/vxlan/frr.j2
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ set -e # Exit immediately when any command fails
{% set use_evpn = vxlan.flooding|default('') == 'evpn' %}

{% macro create_vxlan_interface(vni,br_name,vrf=None,mtu=1500) %}
if [ ! -e /sys/devices/virtual/net/vxlan{{ vni }} ]; then
Comment thread
jbemmel marked this conversation as resolved.
ip link add vxlan{{ vni }} type vxlan \
id {{ vni }} \
dstport 4789 \
local {{ vxlan.vtep }} {{ 'nolearning' if use_evpn else '' }}
fi
#
# Add it to the VLAN bridge (create if needed for l3 vnis); disable STP
if [ ! -e /sys/devices/virtual/net/{{ br_name}} ]; then
Expand Down
16 changes: 16 additions & 0 deletions netsim/daemons/bird.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
---
description: BIRD Internet Routing Daemon
parent: linux

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why exactly do you need this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'linux' is the implicit default so it's not strictly needed

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'linux' is the implicit default so it's not strictly needed

So why did you add it?

role: router

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And why have you changed the default role?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #3499 as to what triggered it - I agree it would be better to fix that specific test

It could make sense that a "Routing Daemon" would have a default "router" role - but we can leave it out

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although it's not defined (precisely) anywhere in the documentation, the daemon devices were designed to provide control-plane-only functionality (for example, BGP RR). That's also the most common BIRD use case (IXP BGP route server)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although it's not defined (precisely) anywhere in the documentation, the daemon devices were designed to provide control-plane-only functionality (for example, BGP RR). That's also the most common BIRD use case (IXP BGP route server)

ok, so in my mind that is a "router" role more than a "host" role. Looking at the codebase, I now realize that Linux daemons are explicitly exempted from requiring a loopback interface - is that the more common way BIRD gets deployed?

group_vars:
netlab_import_map:
bgp: RTS_BGP
ospf: RTS_OSPF
connected: RTS_DEVICE
static: RTS_STATIC_DEVICE,RTS_STATIC
netlab_initial: always

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this relevant only with configuration reload?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially modeled the Bird config after frr, hence this got copied - but you are right that it currently isn't used

packages:
bird: bird
daemon_config:
Expand All @@ -16,12 +19,16 @@ daemon_config:
bgp@policy: /etc/bird/bgp.policy.conf
ospf@areas: /etc/bird/ospf.areas.conf
ospf: /etc/bird/ospf.mod.conf
vxlan: /etc/bird/vxlan.mod.conf
evpn: /etc/bird/evpn.mod.conf
routing: /etc/bird/routing.mod.conf
clab:
node_config: null # Do not inherit linux :ns scripts; use netlab_config_mode: sh
Comment thread
jbemmel marked this conversation as resolved.
Outdated
group_vars:
netlab_show_command: [ birdc, 'show $@' ]
docker_shell: bash -il
netlab_config_mode: sh
netlab_config_done: /var/run/netlab-config-done
image: netlab/bird:latest
build: True
sw_version: 2.19.1
Expand Down Expand Up @@ -69,6 +76,15 @@ features:
areas: true
routing:
static.discard: true
evpn:
transport: [ vxlan ]
vxlan:
vtep6: true
vlan:
model: router
svi_interface_name: vlan{vlan}
gateway:
protocol: [ anycast ]
dhcp: false
initial:
ipv4:
Expand Down
27 changes: 23 additions & 4 deletions netsim/daemons/bird/Dockerfile.v2_from_src.j2
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ LABEL description="BIRD Internet Routing Daemon v2 built from source ({{ _sw_ver
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y \
iproute2 \
iputils-ping \
net-tools \
procps \
libreadline8 \
libssh-4 \
&& rm -rf /var/lib/apt/lists/*
Expand All @@ -57,8 +61,23 @@ COPY --from=builder /usr/sbin/bird /usr/sbin/bird
COPY --from=builder /usr/sbin/birdcl /usr/sbin/birdcl
COPY --from=builder /etc/bird /etc/bird

# Create runtime directory for control sockets
RUN mkdir -p /var/run
# Wait for netlab initial to execute bound /etc/config scripts, then start BIRD.
Comment thread
jbemmel marked this conversation as resolved.
RUN cat >/entrypoint.sh <<'ENTRYPOINT'
#!/bin/bash
set -e

# Run BIRD in the foreground with the default config path
CMD ["/usr/sbin/bird", "-f", "-c", "/etc/bird/bird.conf"]
marker="${NETLAB_CONFIG_DONE:-/var/run/netlab-config-done}"
timeout="${NETLAB_CONFIG_WAIT:-120}"

while [ ! -f "$marker" ] && [ "$timeout" -gt 0 ]; do
sleep 1
timeout=$((timeout - 1))
done

[ -f "$marker" ] || { echo "bird: timeout waiting for ${marker}" >&2; exit 1; }

exec bird -f -c /etc/bird/bird.conf -d
ENTRYPOINT
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
24 changes: 23 additions & 1 deletion netsim/daemons/bird/bgp.j2
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
router id {{ bgp.router_id }};
{% endif %}

{% for n in bgp.neighbors|default([]) if n.evpn|default(false) %}
{% if loop.first %}
evpn table evpntab;
{% endif %}
{% endfor %}

{% for ngb in bgp.neighbors|default([]) if ngb.default_originate|default(False) %}
{% if loop.first %}
{% for _af in ['ipv4','ipv6'] if _af in af %}
Expand Down Expand Up @@ -39,6 +45,9 @@ function bgp_prefixes( bool originate_default ) {
if source ~ [ {{ netlab_import_map[proto] }} ]
then accept "{{ proto }} route:", net;
{% endfor %}
{% elif 'ospf' in module|default([]) %}
Comment thread
jbemmel marked this conversation as resolved.
Outdated
if source ~ [ RTS_OSPF ]
then accept "ospf route:", net;
{% endif %}
{% for pfx_af in ['ipv4','ipv6'] %}
{% for pfx in bgp.advertise|default([]) if pfx_af in pfx %}
Expand Down Expand Up @@ -79,7 +88,7 @@ function bgp_export_{{ ntype }}( bool originate_default; bool rem_private_as ) {

{% for n in bgp.neighbors %}
{% for af in [ 'ipv4','ipv6' ] if af in n and n[af] is string %}
protocol bgp bgp_{{ n.name }}_{{ af }} {
protocol bgp bgp_{{ n.name }}_{{ n.type }}_{{ af }} {
{% set loopback = loopback|default({}) %}
{% set local_ip = loopback[af]|default('') %}
local {{ local_ip.split('/')[0] if n.type == 'ibgp' else '' }} as {{ n.local_as|default(bgp.as) }};
Expand Down Expand Up @@ -149,6 +158,19 @@ protocol bgp bgp_{{ n.name }}_{{ af }} {
};
{% endif %}
{% endfor %}
{% if n.evpn|default('') == af %}
evpn {
table evpntab;
import all;
export all;
{% if n.type == 'ebgp' %}
gateway recursive;
{% if bgp.rr|default('') %}
next hop keep;
{% endif %}
{% endif %}
};
{% endif %}
}
{% endfor %}
{% endfor %}
2 changes: 1 addition & 1 deletion netsim/daemons/bird/bird.j2
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% set module = module|default([]) %}
log "/var/log/bird" all;
log stderr all;

{% include 'protocols.j2' %}

Expand Down
52 changes: 52 additions & 0 deletions netsim/daemons/bird/evpn.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{% macro bird_ec(rt) %}(rt, {{ rt.split(':')[0] }}, {{ rt.split(':')[1] }}){% endmacro %}

{% if vxlan is defined and vxlan.flooding|default('') == 'evpn' %}
{% for vname in vxlan.vlans|default([]) if vlans[vname].vni is defined and 'evpn' in vlans[vname] %}
{% set vlan = vlans[vname] %}
protocol bridge bridge_{{ vname }} {
eth { table eth_{{ vname }}; export all; };
bridge device "vlan{{ vlan.id }}";
};

protocol evpn evpn_{{ vname }} {
eth { table eth_{{ vname }}; };
evpn { table evpntab; import all; export all; };
rd {{ vlan.evpn.rd }};
{% for rt in vlan.evpn.import|default([]) %}
import target {{ bird_ec(rt) }};
{% endfor %}
{% for rt in vlan.evpn.export|default([]) %}
export target {{ bird_ec(rt) }};
{% endfor %}
vni {{ vlan.vni }};
vid {{ vlan.id }};
encapsulation vxlan {
tunnel device "vxlan{{ vlan.vni }}";
router address {{ vxlan.vtep }};
};
};
{% endfor %}
{% endif %}

{% for vname,vdata in vrfs|default({})|dictsort %}
{% if 'evpn' in vdata and vdata.evpn.transit_vni is defined %}
eth table eth_vrf_{{ vname }};

protocol evpn evpn_vrf_{{ vname }} {
eth { table eth_vrf_{{ vname }}; };
evpn { table evpntab; import all; export all; };
rd {{ vdata.rd }};
{% for rt in vdata.import|default([]) %}
import target {{ bird_ec(rt) }};
{% endfor %}
{% for rt in vdata.export|default([]) %}
export target {{ bird_ec(rt) }};
{% endfor %}
vni {{ vdata.evpn.transit_vni }};
encapsulation vxlan {
tunnel device "vxlan{{ vdata.evpn.transit_vni }}";
router address {{ vxlan.vtep }};
};
};
{% endif %}
{% endfor %}
1 change: 1 addition & 0 deletions netsim/daemons/bird/gateway.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% include 'gateway/frr.j2' +%}
4 changes: 4 additions & 0 deletions netsim/daemons/bird/vlan.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% include 'vlan/frr.j2' +%}
{% if 'vxlan' in module|default([]) %}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need this dirty hack???

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because daemons have 2 types of template files: Bash scripts and config file snippets

vxlan.j2 is already mapped as a config snippet, so it cannot also be a Bash script. This was a workaround to get both without core changes

If we move the logic from vxlan.j2 into evpn.j2, it frees up that file to include vxlan/frr.j2 instead and we can remove this

{% include 'vxlan/frr.j2' +%}
{% endif %}
5 changes: 5 additions & 0 deletions netsim/daemons/bird/vxlan.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% if vxlan.flooding|default('') == 'evpn' %}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this thing generates content only when EVPN is enabled, don't you think it belongs to the EVPN config?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does fit better in evpn.j2, which already has the check

{% for vname in vxlan.vlans|default([]) if vlans[vname].vni is defined and 'evpn' in vlans[vname] %}
eth table eth_{{ vname }};
{% endfor %}
{% endif %}
2 changes: 1 addition & 1 deletion netsim/providers/clab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def deploy_node_config(self, node: Box, topology: Box, deploy_list: list) -> Non
if not cfg_files: # No node files => no config to deploy here
return
node_name = self.get_node_name(node.name,topology) # ... get container/namespace name
configs.deploy_container_config(node,node_name,deploy_list)
configs.deploy_container_config(node,node_name,deploy_list,topology)

def capture_command(self, node: Box, topology: Box, args: argparse.Namespace) -> list:
cmd = strings.string_to_list(topology.defaults.netlab.capture.command)
Expand Down
25 changes: 23 additions & 2 deletions netsim/providers/clab/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@
from ...utils import log, strings
from . import utils


Comment thread
jbemmel marked this conversation as resolved.
Outdated
def mark_config_done(node: Box, node_name: str, topology: Box) -> None:
marker = devices.get_node_group_var(node,'netlab_config_done',topology.defaults)
if not marker:
return

status = external_commands.run_command(
cmd=['docker','exec',node_name,'touch',marker],
ignore_errors=True,
return_exitcode=True)
if status != 0:
log.error(
f'Cannot create netlab configuration done marker on node {node.name}',
category=log.FatalError,
more_data=f'Executed command: docker exec {node_name} touch {marker}',
skip_header=True,
module='initial')

'''
add_default_config_mode: if the netlab_config_mode is set, add configured modules to _node_config dictionary
'''
Expand Down Expand Up @@ -97,7 +115,7 @@ def generate_startup_config(n: Box) -> None:
log.status_created()
print(f"startup configuration for {n.name}",flush=True)

def deploy_container_config(node: Box, node_name: str, deploy_list: list) -> None:
def deploy_container_config(node: Box, node_name: str, deploy_list: list, topology: Box) -> None:
for cfg_item in node.clab.config_templates: # Go through configuration files (we know they exist)
mod_name = cfg_item.source # Get module name
f_type = cfg_item.get('mode',None)
Expand Down Expand Up @@ -163,4 +181,7 @@ def deploy_container_config(node: Box, node_name: str, deploy_list: list) -> No
skip_header=True,
module='initial')
append_to_list(node._deploy,'failed',mod_name)
break
break

if '_deploy.failed' not in node and '_deploy.success' in node:
mark_config_done(node,node_name,topology)