Skip to content

Commit 45d1fc4

Browse files
authored
net: Introduce bandwidth throttling test (#4299)
Add bandwidth throttling test for Multus secondary interface via bridge CNI. Users are interested to apply bandwidth limitations on ingress and egress traffic passing to and from the VM interfaces. Implements test coverage for SUPPORTEX-29574 https://redhat.atlassian.net/browse/SUPPORTEX-29574 In order to cover the BW rating limits, a Network Attachment Definition with the bridge CNI and the Bandwidth CNI is defined and linked to a secondary VM network. The test covers all available IP families, per the deployed cluster. An existing NNCP is used to configure the node network. Some new helpers are similar to existing helpers from `vmi_interfaces_stability` module and will be adjusted in a follow-up to focus this PR on the BW test. ##### jira-ticket: https://redhat.atlassian.net/browse/CNV-82064 <!-- full-ticket-url needs to be provided. This would add a link to the pull request to the jira and close it when the pull request is merged If the task is not tracked by a Jira ticket, just write "NONE". --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added bandwidth shaping and rate limiting capabilities for secondary network interfaces. * Introduced dual-stack IPv4/IPv6 networking support with automatic address configuration and cloud-init integration. * Enhanced VM interface status monitoring and verification functionality. * **Tests** * Added comprehensive test suite for bandwidth limit enforcement on secondary network interfaces. * Implemented supporting test infrastructure and helpers for network configuration validation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 03bc740 + fe46d8f commit 45d1fc4

File tree

9 files changed

+441
-33
lines changed

9 files changed

+441
-33
lines changed

libs/net/ip.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,31 @@ def _random_hextets(count: int) -> list[int]:
7979
return random.sample(range(1, 0xFFFE), count)
8080

8181

82+
def random_ip_addresses_by_family(
83+
ipv4_supported: bool,
84+
ipv6_supported: bool,
85+
net_seed: int,
86+
host_address: int,
87+
) -> list[str]:
88+
"""Generate IP addresses for each supported IP family.
89+
90+
Args:
91+
ipv4_supported: Whether IPv4 is supported.
92+
ipv6_supported: Whether IPv6 is supported.
93+
net_seed: Seed index for selecting the random network portion of the address.
94+
host_address: Host portion of the address, used to place VMs on the same subnet.
95+
96+
Returns:
97+
List of IP address strings, one per supported IP family.
98+
"""
99+
ips = []
100+
if ipv4_supported:
101+
ips.append(random_ipv4_address(net_seed=net_seed, host_address=host_address))
102+
if ipv6_supported:
103+
ips.append(random_ipv6_address(net_seed=net_seed, host_address=host_address))
104+
return ips
105+
106+
82107
def filter_link_local_addresses(ip_addresses: list[str]) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
83108
"""
84109
Filter out link-local IP addresses from a list of IP address strings.

libs/net/netattachdef.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ class Topology(Enum):
8484
LOCALNET = "localnet"
8585

8686

87+
@dataclass
88+
class CNIPluginBandwidthConfig(CNIPluginConfig):
89+
"""
90+
CNI Bandwidth Plugin
91+
Ref: https://www.cni.dev/plugins/current/meta/bandwidth/
92+
"""
93+
94+
type: str = field(default="bandwidth", init=False)
95+
ingressRate: int # noqa: N815
96+
ingressBurst: int # noqa: N815
97+
egressRate: int # noqa: N815
98+
egressBurst: int # noqa: N815
99+
100+
87101
@dataclass
88102
class CNIPluginMacvlanConfig(CNIPluginConfig):
89103
"""

libs/net/vmspec.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,30 @@ def _lookup_first_ip_address(
163163
ip_family: int,
164164
) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
165165
return next((ip for ip_addr in ip_addresses if (ip := ipaddress.ip_address(ip_addr)).version == ip_family), None)
166+
167+
168+
def wait_for_ifaces_status(
169+
vm: BaseVirtualMachine,
170+
ip_addresses_by_spec_net_name: dict[str, list[str]],
171+
) -> None:
172+
"""Wait for all VM interfaces to be ready.
173+
174+
Args:
175+
vm: The virtual machine to wait for.
176+
ip_addresses_by_spec_net_name: Mapping of spec network name to its expected IP addresses.
177+
Primary (masquerade) interfaces are detected automatically from the VM spec.
178+
"""
179+
for iface in vm.template_spec.domain.devices.interfaces: # type: ignore
180+
if iface.masquerade is not None:
181+
lookup_iface_status(vm=vm, iface_name=iface.name)
182+
else:
183+
lookup_iface_status(
184+
vm=vm,
185+
iface_name=iface.name,
186+
predicate=lambda iface_status: (
187+
"guest-agent" in iface_status["infoSource"]
188+
and all(
189+
ip in iface_status.get("ipAddresses", []) for ip in ip_addresses_by_spec_net_name[iface.name]
190+
)
191+
),
192+
)

libs/vm/vm.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,17 @@
1111
from ocp_resources.virtual_machine_instance import VirtualMachineInstance
1212
from pytest_testconfig import config as py_config
1313

14-
from libs.vm.spec import CloudInitNoCloud, ContainerDisk, Devices, Disk, Metadata, SpecDisk, VMISpec, VMSpec, Volume
14+
from libs.vm.spec import (
15+
CloudInitNoCloud,
16+
ContainerDisk,
17+
Devices,
18+
Disk,
19+
Metadata,
20+
SpecDisk,
21+
VMISpec,
22+
VMSpec,
23+
Volume,
24+
)
1525
from tests.network.libs import cloudinit
1626
from utilities import infra
1727
from utilities.constants import CLOUD_INIT_DISK_NAME
@@ -104,6 +114,10 @@ def update_template_annotations(self, template_annotations: dict[str, str]) -> N
104114
}
105115
ResourceEditor(patches=patches).update()
106116

117+
@property
118+
def template_spec(self) -> VMISpec:
119+
return self._spec.template.spec
120+
107121
@property
108122
def cloud_init_network_data(self) -> cloudinit.NetworkData:
109123
"""Return the parsed cloud-init network data configured for this VM.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import ipaddress
2+
from collections.abc import Generator
3+
from ipaddress import ip_interface
4+
from typing import Final
5+
6+
import pytest
7+
from kubernetes.dynamic import DynamicClient
8+
from ocp_resources.namespace import Namespace
9+
10+
import tests.network.libs.nodenetworkconfigurationpolicy as libnncp
11+
from libs.net.ip import random_ip_addresses_by_family
12+
from libs.net.netattachdef import (
13+
CNIPluginBandwidthConfig,
14+
CNIPluginBridgeConfig,
15+
NetConfig,
16+
NetworkAttachmentDefinition,
17+
)
18+
from libs.net.vmspec import wait_for_ifaces_status
19+
from libs.vm.vm import BaseVirtualMachine
20+
from tests.network.l2_bridge.bandwidth.lib_helpers import (
21+
BANDWIDTH_RATE_BPS,
22+
BANDWIDTH_SECONDARY_IFACE_NAME,
23+
GUEST_2ND_IFACE_NAME,
24+
secondary_network_vm,
25+
)
26+
27+
_NAD_NAME: Final[str] = "br-bw-test-nad"
28+
29+
30+
@pytest.fixture(scope="module")
31+
def bandwidth_nad(
32+
admin_client: DynamicClient,
33+
namespace: Namespace,
34+
bridge_nncp: libnncp.NodeNetworkConfigurationPolicy,
35+
) -> Generator[NetworkAttachmentDefinition]:
36+
config = NetConfig(
37+
name=_NAD_NAME,
38+
plugins=[
39+
CNIPluginBridgeConfig(
40+
bridge=bridge_nncp.desired_state_spec.interfaces[0].name # type: ignore
41+
),
42+
CNIPluginBandwidthConfig(
43+
ingressRate=BANDWIDTH_RATE_BPS,
44+
ingressBurst=BANDWIDTH_RATE_BPS,
45+
egressRate=BANDWIDTH_RATE_BPS,
46+
egressBurst=BANDWIDTH_RATE_BPS,
47+
),
48+
],
49+
)
50+
with NetworkAttachmentDefinition(
51+
name=_NAD_NAME,
52+
namespace=namespace.name,
53+
config=config,
54+
client=admin_client,
55+
) as bw_nad:
56+
yield bw_nad
57+
58+
59+
@pytest.fixture(scope="module")
60+
def server_vm(
61+
ipv4_supported_cluster: bool,
62+
ipv6_supported_cluster: bool,
63+
unprivileged_client: DynamicClient,
64+
namespace: Namespace,
65+
bandwidth_nad: NetworkAttachmentDefinition,
66+
) -> Generator[BaseVirtualMachine]:
67+
addresses = [
68+
f"{ip}/64" if ipaddress.ip_address(ip).version == 6 else f"{ip}/24"
69+
for ip in random_ip_addresses_by_family(
70+
ipv4_supported=ipv4_supported_cluster,
71+
ipv6_supported=ipv6_supported_cluster,
72+
net_seed=0,
73+
host_address=1,
74+
)
75+
]
76+
with secondary_network_vm(
77+
namespace=namespace.name,
78+
name="bw-server-vm",
79+
client=unprivileged_client,
80+
nad_name=bandwidth_nad.name,
81+
secondary_iface_name=BANDWIDTH_SECONDARY_IFACE_NAME,
82+
secondary_iface_addresses=addresses,
83+
ipv4_supported=ipv4_supported_cluster,
84+
ipv6_supported=ipv6_supported_cluster,
85+
) as vm:
86+
vm.start(wait=True)
87+
vm.wait_for_agent_connected()
88+
wait_for_ifaces_status(
89+
vm=vm,
90+
ip_addresses_by_spec_net_name={
91+
BANDWIDTH_SECONDARY_IFACE_NAME: [
92+
str(ip_interface(addr).ip)
93+
for addr in vm.cloud_init_network_data.ethernets[GUEST_2ND_IFACE_NAME].addresses
94+
]
95+
},
96+
)
97+
yield vm
98+
99+
100+
@pytest.fixture(scope="module")
101+
def client_vm(
102+
ipv4_supported_cluster: bool,
103+
ipv6_supported_cluster: bool,
104+
unprivileged_client: DynamicClient,
105+
namespace: Namespace,
106+
bandwidth_nad: NetworkAttachmentDefinition,
107+
) -> Generator[BaseVirtualMachine]:
108+
addresses = [
109+
f"{ip}/64" if ipaddress.ip_address(ip).version == 6 else f"{ip}/24"
110+
for ip in random_ip_addresses_by_family(
111+
ipv4_supported=ipv4_supported_cluster,
112+
ipv6_supported=ipv6_supported_cluster,
113+
net_seed=0,
114+
host_address=2,
115+
)
116+
]
117+
with secondary_network_vm(
118+
namespace=namespace.name,
119+
name="bw-client-vm",
120+
client=unprivileged_client,
121+
nad_name=bandwidth_nad.name,
122+
secondary_iface_name=BANDWIDTH_SECONDARY_IFACE_NAME,
123+
secondary_iface_addresses=addresses,
124+
ipv4_supported=ipv4_supported_cluster,
125+
ipv6_supported=ipv6_supported_cluster,
126+
) as vm:
127+
vm.start(wait=True)
128+
vm.wait_for_agent_connected()
129+
wait_for_ifaces_status(
130+
vm=vm,
131+
ip_addresses_by_spec_net_name={
132+
BANDWIDTH_SECONDARY_IFACE_NAME: [
133+
str(ip_interface(addr).ip)
134+
for addr in vm.cloud_init_network_data.ethernets[GUEST_2ND_IFACE_NAME].addresses
135+
]
136+
},
137+
)
138+
yield vm

0 commit comments

Comments
 (0)