Skip to content

Commit 85cf9d6

Browse files
authored
Static (topology) 'tc' functionality for clab and libvirt (#2626)
Implements #1387
1 parent 0ea856a commit 85cf9d6

File tree

8 files changed

+170
-25
lines changed

8 files changed

+170
-25
lines changed

docs/links.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,23 @@ A lab device could be a networking device or a host[^HOST]. Links with attached
442442

443443
[^NOPASS]: To turn a link with hosts attached into a transit link, set link **role** to **lan** (or any other role).
444444

445+
(links-netem)=
446+
## Link Impairment
447+
448+
_netlab_ can configure the Linux **netem** queuing discipline to introduce link impairment on point-to-point or LAN links[^tc]. The **tc** link- or interface attribute allows you to configure these QoS parameters:
449+
450+
* **tc.delay** -- transmission delay specified in milliseconds (`ms`) or seconds (`s`)
451+
* **tc.jitter** -- jitter (using the same units as **tc.delay**)
452+
* **tc.loss** -- loss percentage (0..100)
453+
* **tc.corrupt** -- packet corruption percentage
454+
* **tc.duplicate** -- packet duplication percentage
455+
* **tc.reorder** -- packet reordering percentage
456+
* **tc.rate** -- rate throttling (in kbps). Delays packets to emulate a fixed link speed
457+
458+
[^tc]: This feature works with *[clab](lab-clab)* and *[libvirt](lab-libvirt)* providers. *libvirt* point-to-point links are converted to LAN links (using a Linux bridge), which means that you cannot use **tc** together with link aggregation on inter-VM links.
459+
460+
While you could configure **tc** parameters on individual interfaces, the **netem** queuing discipline applies them only to outgoing traffic. You should therefore configure **tc** parameters on links to ensure the same parameters are applied to all interfaces connected to the link.
461+
445462
(links-bridge)=
446463
## Bridge Names
447464

netsim/cli/up.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,8 @@ def run_up(cli_args: typing.List[str]) -> None:
373373
recreate_secondary_config(topology,p_provider,s_provider)
374374
start_provider_lab(topology,p_provider,s_provider)
375375

376+
providers.execute_tc_commands(topology)
377+
376378
try:
377379
if args.reload:
378380
reload_saved_config(args,topology)

netsim/defaults/attributes.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ link: # Global link attributes
6565
onlink: bool
6666
role: id
6767
pool: id
68+
tc:
69+
delay: time
70+
jitter: time
71+
loss: { type: float, min_value: 0, max_value: 100 }
72+
corrupt: { type: float, min_value: 0, max_value: 100 }
73+
duplicate: { type: float, min_value: 0, max_value: 100 }
74+
reorder: { type: float, min_value: 0, max_value: 100 }
75+
rate: { type: int, min_value: 1 }
6876
type: { type: str, valid_values: [ lan, p2p, stub, loopback, tunnel, vlan_member ] }
6977
unnumbered: bool
7078
interfaces:

netsim/providers/__init__.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,13 @@ def get_lab_status(self) -> Box:
233233
def get_node_name(self, node: str, topology: Box) -> str:
234234
return node
235235

236+
def set_tc(self, node: Box, topology: Box, intf: Box) -> None:
237+
n_provider = devices.get_provider(node,topology.defaults)
238+
log.warning(
239+
text=f'tc is not supported (node {node.name}, link {intf.name})',
240+
module=n_provider,
241+
flag=f'{n_provider}.tc')
242+
236243
"""
237244
Generic provider pre-transform processing: Mark multi-provider links
238245
"""
@@ -329,10 +336,10 @@ def execute(hook: str, topology: Box) -> None:
329336
"""
330337
Execute a node-level provider hook
331338
"""
332-
def execute_node(hook: str, node: Box, topology: Box) -> typing.Any:
339+
def execute_node(hook: str, node: Box, topology: Box, **kwargs: typing.Any) -> typing.Any:
333340
node_provider = devices.get_provider(node,topology.defaults)
334341
p_module = get_provider_module(topology,node_provider)
335-
return p_module.call(hook,node,topology)
342+
return p_module.call(hook,node,topology,**kwargs)
336343

337344
"""
338345
Mark all nodes and links with relevant provider(s)
@@ -439,3 +446,51 @@ def validate_mgmt_ip(
439446
f'Management {af} address of node {node.name} ({n_mgmt[af]}) is not part of the management subnet',
440447
category=log.IncorrectValue,
441448
module=provider)
449+
450+
"""
451+
Execute tc commands
452+
"""
453+
def execute_tc_commands(topology: Box) -> None:
454+
for ndata in topology.nodes.values():
455+
for intf in ndata.interfaces:
456+
if 'tc' not in intf:
457+
continue
458+
execute_node('set_tc',node=ndata,topology=topology,intf=intf)
459+
460+
"""
461+
Apply tc netem parameters to the specified interface
462+
"""
463+
NETEM_KW_MAP = {
464+
'delay': ' delay {delay}ms {jitter}ms'
465+
}
466+
467+
NETEM_SIMPLE_KW = [ 'loss', 'corrupt', 'duplicate', 'reorder', 'rate' ]
468+
469+
def tc_netem_set(intf: str, tc_data: Box, pfx: str = '') -> typing.Union[str,bool]:
470+
global NETEM_KW_MAP,NETEM_SIMPLE_KW
471+
from ..cli import external_commands
472+
473+
netem_params = ''
474+
if 'jitter' in tc_data and 'delay' not in tc_data: # Delay and jitter have to be specified
475+
tc_data.delay = 0 # ... in a single netem parameter
476+
if 'delay' in tc_data and 'jitter' not in tc_data: # ... so we have to ensure both of them
477+
tc_data.jitter = 0 # ... are set at the same time
478+
479+
for kw in tc_data:
480+
if kw in NETEM_SIMPLE_KW:
481+
netem_params += f' {kw} {tc_data[kw]}'
482+
elif kw in NETEM_KW_MAP:
483+
netem_params += NETEM_KW_MAP[kw].format(**tc_data)
484+
485+
if 'sudo' not in pfx:
486+
pfx = 'sudo '+pfx
487+
qdisc = external_commands.run_command(
488+
cmd=pfx + f' tc qdisc show dev {intf}',
489+
ignore_errors=True,return_stdout=True,check_result=True)
490+
if isinstance(qdisc,str) and 'noqueue' not in qdisc:
491+
external_commands.run_command(
492+
cmd=pfx + f' tc qdisc del dev {intf} root',
493+
ignore_errors=True,return_stdout=True,check_result=True)
494+
495+
status = external_commands.run_command(pfx + f' tc qdisc add dev {intf} root netem'+netem_params)
496+
return netem_params if status else False

netsim/providers/clab.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from ..data import append_to_list, filemaps, get_empty_box
1414
from ..data.types import must_be_dict
1515
from ..utils import linuxbridge, log, strings
16-
from . import _Provider, get_provider_forwarded_ports, node_add_forwarded_ports, validate_mgmt_ip
16+
from . import _Provider, get_provider_forwarded_ports, node_add_forwarded_ports, tc_netem_set, validate_mgmt_ip
1717

1818

1919
def list_bridges( topology: Box ) -> typing.Set[str]:
@@ -278,3 +278,17 @@ def capture_command(self, node: Box, topology: Box, args: argparse.Namespace) ->
278278
cmd = strings.eval_format_list(cmd,{'intf': args.intf})
279279
node_name = self.get_node_name(node.name,topology)
280280
return strings.string_to_list(f'sudo ip netns exec {node_name}') + cmd
281+
282+
def set_tc(self, node: Box, topology: Box, intf: Box) -> None:
283+
c_name = self.get_node_name(node.name,topology)
284+
c_intf = intf.get('clab.name',intf.ifname)
285+
netns = 'sudo ip netns exec ' + c_name
286+
status = tc_netem_set(intf=c_intf,tc_data=intf.tc,pfx=netns)
287+
if status:
288+
log.info(text=f'Traffic control on {node.name} {intf.ifname}:{status}')
289+
else:
290+
log.error(
291+
text=f'Failed to deploy tc policy on {node.name} (container {c_name}) interface {c_intf}',
292+
module='clab',
293+
skip_header=True,
294+
category=log.ErrorAbort)

netsim/providers/libvirt.py

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from ..data import get_box, get_empty_box, types
1919
from ..utils import files as _files
2020
from ..utils import linuxbridge, log, strings
21-
from . import _Provider, get_provider_forwarded_ports, node_add_forwarded_ports, validate_mgmt_ip
21+
from . import _Provider, get_provider_forwarded_ports, node_add_forwarded_ports, tc_netem_set, validate_mgmt_ip
2222

2323
LIBVIRT_MANAGEMENT_NETWORK_NAME = "vagrant-libvirt"
2424
LIBVIRT_MANAGEMENT_BRIDGE_NAME = "libvirt-mgmt"
@@ -284,8 +284,22 @@ def pre_transform(self, topology: Box) -> None:
284284
if not 'public' in l.libvirt: # ... but no 'public' libvirt attr
285285
l.libvirt.public = 'bridge' # ... default mode is bridge (MACVTAP)
286286

287+
"""
288+
The libvirt links could be modeled as P2P links (using UDP tunnels) or
289+
LAN links using a Linux bridge. It's better to use the UDP tunnels, but
290+
we must us the Linux bridge if:
291+
292+
* The link type is 'lan' or 'stub' (set/used elsewhere, also includes
293+
hosts connected to links)
294+
* The libvirt.provider attribute is set (multi-provider links or external
295+
connectivity)
296+
* The system defaults say P2P links should be modeled as bridges
297+
(used for traffic capture)
298+
* The link or any of the interfaces has the 'tc' parameter
299+
"""
287300
must_be_lan = l.get('libvirt.provider',None) and 'vlan' not in l.type
288301
must_be_lan = must_be_lan or (p2p_bridge and l.get('type','p2p') == 'p2p')
302+
must_be_lan = must_be_lan or 'tc' in l or [ intf for intf in l.interfaces if 'tc' in intf ]
289303
if must_be_lan:
290304
l.type = 'lan'
291305
if not 'bridge' in l:
@@ -482,37 +496,76 @@ def validate_node_image(self, node: Box, topology: Box) -> None:
482496
f"'vagrant box add <url>' command to add it, or use this recipe to build it:",
483497
dp_data.build ])
484498

485-
def capture_command(self, node: Box, topology: Box, args: argparse.Namespace) -> typing.Optional[list]:
486-
intf = [ intf for intf in node.interfaces if intf.ifname == args.intf ][0]
487-
if intf.get('libvirt.type',None) == 'tunnel':
499+
def get_linux_intf(
500+
self,
501+
node: Box,
502+
topology: Box,
503+
ifname: str,
504+
op: str,
505+
hint: str,
506+
exit_on_error: bool = True) -> typing.Optional[str]:
507+
508+
intf = [ intf for intf in node.interfaces if intf.ifname == ifname ][0]
509+
if intf.get('libvirt.type',None) == 'tunnel' or 'bridge' not in intf:
488510
log.error(
489-
f'Cannot perform packet capture on libvirt point-to-point links',
511+
f'Cannot perform {op} on libvirt point-to-point links',
490512
category=log.FatalError,
491513
module='libvirt',
492514
skip_header=True,
493-
exit_on_error=True,
494-
hint='capture')
515+
exit_on_error=exit_on_error,
516+
hint=hint)
517+
return None
495518

496519
domiflist = external_commands.run_command(
497520
['virsh','domiflist',f'{topology.name}_{node.name}'],
498521
check_result=True,
499522
return_stdout=True)
500523
if not isinstance(domiflist,str):
524+
log.error(
525+
f'Cannot get the list of libvirt interface for node {node.name}',
526+
category=log.FatalError,
527+
module='libvirt',
528+
skip_header=True,
529+
exit_on_error=exit_on_error)
501530
return None
502531

503532
for intf_line in domiflist.split('\n'):
504533
intf_data = strings.string_to_list(intf_line)
505534
if len(intf_data) != 5:
506535
continue
507536
if intf_data[2] == intf.bridge:
508-
cmd = strings.string_to_list(topology.defaults.netlab.capture.command)
509-
cmd = strings.eval_format_list(cmd,{'intf': intf_data[0]})
510-
return ['sudo'] + cmd
511-
537+
return intf_data[0]
538+
512539
log.error(
513540
f'Cannot find the interface on node {node.name} attached to libvirt network {intf.bridge}',
514541
category=log.FatalError,
515542
module='libvirt',
516543
skip_header=True,
517-
exit_on_error=True)
544+
exit_on_error=exit_on_error)
518545
return None
546+
547+
def capture_command(self, node: Box, topology: Box, args: argparse.Namespace) -> typing.Optional[list]:
548+
ifname = self.get_linux_intf(node,topology,args.intf,op='packet capture',hint='capture')
549+
if not ifname:
550+
return None
551+
552+
cmd = strings.string_to_list(topology.defaults.netlab.capture.command)
553+
cmd = strings.eval_format_list(cmd,{'intf': ifname})
554+
return ['sudo'] + cmd
555+
556+
def set_tc(self, node: Box, topology: Box, intf: Box) -> None:
557+
vm_intf = self.get_linux_intf(
558+
node,topology,ifname=intf.ifname,
559+
op='traffic control',hint='tc',exit_on_error=False)
560+
if not vm_intf:
561+
return
562+
563+
status = tc_netem_set(intf=vm_intf,tc_data=intf.tc)
564+
if status:
565+
log.info(text=f'Traffic control on {node.name} {intf.ifname}:{status}')
566+
else:
567+
log.error(
568+
text=f'Failed to deploy tc policy on {node.name} interface {intf.ifname} (Linux interface {vm_intf})',
569+
module='libvirt',
570+
skip_header=True,
571+
category=log.ErrorAbort)

tests/topology/expected/link-bw.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ input:
44
links:
55
- _linkname: links[1]
66
bandwidth: 100000
7+
bridge: input_1
78
interfaces:
89
- ifindex: 1
910
ifname: GigabitEthernet0/1
@@ -25,7 +26,7 @@ links:
2526
jitter: 13
2627
loss: 0.3
2728
rate: 2000
28-
type: p2p
29+
type: lan
2930
name: input
3031
nodes:
3132
e1:
@@ -37,6 +38,7 @@ nodes:
3738
id: 1
3839
interfaces:
3940
- bandwidth: 100000
41+
bridge: input_1
4042
ifindex: 1
4143
ifname: GigabitEthernet0/1
4244
ipv4: 192.168.23.1/24
@@ -53,7 +55,7 @@ nodes:
5355
jitter: 13
5456
loss: 0.3
5557
rate: 2000
56-
type: p2p
58+
type: lan
5759
loopback:
5860
ifindex: 0
5961
ifname: Loopback0
@@ -76,6 +78,7 @@ nodes:
7678
id: 2
7779
interfaces:
7880
- bandwidth: 100000
81+
bridge: input_1
7982
ifindex: 1
8083
ifname: GigabitEthernet0/1
8184
ipv4: 192.168.23.2/24
@@ -92,7 +95,7 @@ nodes:
9295
jitter: 13
9396
loss: 0.3
9497
rate: 2000
95-
type: p2p
98+
type: lan
9699
loopback:
97100
ifindex: 0
98101
ifname: Loopback0

tests/topology/input/link-bw.yml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
defaults:
22
device: iosv
33
inventory: dump
4-
attributes:
5-
link:
6-
tc:
7-
delay: time
8-
jitter: time
9-
loss: { type: float, min_value: 0, max_value: 100 }
10-
rate: { type: int, min_value: 1 }
114

125
nodes:
136
e1:

0 commit comments

Comments
 (0)