Skip to content

Commit 5e9c2fc

Browse files
authored
net: NAD live update VM connectivity tests (#4976)
##### What this PR does / why we need it: Implements two NAD live-update tests for Linux bridge: - `test_connectivity`: after iface-1 is moved from VLAN-A to VLAN-B, verifies the under-test VM gains TCP connectivity on VLAN-B and loses it on VLAN-A. - `test_two_networks`: applies a single atomic patch swapping both secondary networks simultaneously and verifies each interface reaches its new VLAN. Adds bind_dev support to TcpServer and VMTcpClient (--bind-dev / SO_BINDTODEVICE) to force iperf3 traffic through a specific interface, bypassing ECMP routing ambiguity when two secondary interfaces share the same subnet. Adds poll_tcp_connectivity to tests/network/libs/connectivity.py: a retrying TCP connectivity probe used by the baseline connectivity fixture and the tests. ##### Which issue(s) this PR fixes: - ##### Special notes for reviewer: Depends on #4962 - only review the last 3 commits ##### jira-ticket: https://redhat.atlassian.net/browse/CNV-80573 <!-- 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** * Enhanced traffic generator tools to support binding network traffic to specific guest network interfaces. * Added TCP connectivity polling capabilities for network testing scenarios. * **Tests** * Refactored VLAN reference-change tests with improved fixture-driven coverage. * Introduced new test helpers and fixtures for validating multi-interface network connectivity. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/RedHatQE/openshift-virtualization-tests/pull/4976?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents c64efa3 + 4696502 commit 5e9c2fc

5 files changed

Lines changed: 241 additions & 10 deletions

File tree

libs/net/traffic_generator.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,22 @@ class TcpServer:
5353
vm (BaseVirtualMachine): The virtual machine where the server runs.
5454
port (int): The port on which the server listens for client connections.
5555
bind_ip (str): The IP address to bind the server to (optional).
56+
bind_dev (str): Guest network device to bind the server socket to via SO_BINDTODEVICE
57+
(e.g. "eth1"). Forces responses out this interface, bypassing ECMP routing.
5658
"""
5759

5860
def __init__(
5961
self,
6062
vm: BaseVirtualMachine,
6163
port: int,
6264
bind_ip: str | None = None,
65+
bind_dev: str | None = None,
6366
):
6467
self._vm = vm
6568
self._port = port
6669
self._cmd = f"{_IPERF_BIN} --server --port {self._port} --one-off"
6770
self._cmd += f" --bind {bind_ip}" if bind_ip else ""
71+
self._cmd += f" --bind-dev {bind_dev}" if bind_dev else ""
6872

6973
def __enter__(self) -> "TcpServer":
7074
self._vm.console(
@@ -100,6 +104,8 @@ class VMTcpClient(BaseTcpClient):
100104
server_port (int): The port on which the server listens for connections.
101105
maximum_segment_size (int): Define explicitly the TCP payload size (in bytes).
102106
Default value is 0 (do not change mss).
107+
bind_dev (str): Guest network device to bind the client socket to via SO_BINDTODEVICE
108+
(e.g. "eth1"). Forces traffic out this interface, bypassing ECMP routing.
103109
"""
104110

105111
def __init__(
@@ -108,9 +114,11 @@ def __init__(
108114
server_ip: str,
109115
server_port: int,
110116
maximum_segment_size: int = 0,
117+
bind_dev: str | None = None,
111118
):
112119
super().__init__(server_ip=server_ip, server_port=server_port)
113120
self._vm = vm
121+
self._cmd += f" --bind-dev {bind_dev}" if bind_dev else ""
114122
self._cmd += f" --set-mss {maximum_segment_size}" if maximum_segment_size else ""
115123

116124
def __enter__(self) -> "VMTcpClient":

tests/network/l2_bridge/nad_ref_change/conftest.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@
44
from kubernetes.dynamic import DynamicClient
55
from ocp_resources.namespace import Namespace
66

7-
from libs.net.ip import random_cidr_addresses_by_family
7+
from libs.net.ip import filter_link_local_addresses, random_cidr_addresses_by_family
88
from libs.net.netattachdef import CNIPluginBridgeConfig, NetConfig, NetworkAttachmentDefinition
9-
from libs.net.vmspec import wait_for_ifaces_status
9+
from libs.net.vmspec import lookup_iface_status, wait_for_ifaces_status
1010
from libs.vm.vm import BaseVirtualMachine
1111
from tests.network.l2_bridge.libl2bridge import LINUX_BRIDGE_IFACE_NAME_1, LINUX_BRIDGE_IFACE_NAME_2
1212
from tests.network.l2_bridge.nad_ref_change.lib_helpers import (
13+
GUEST_IFACE_1,
14+
GUEST_IFACE_2,
1315
NET_SEED,
1416
two_secondary_bridge_vm,
1517
)
1618
from tests.network.libs import nodenetworkconfigurationpolicy as libnncp
17-
from tests.network.libs.connectivity import ARP_ISOLATION_SYSCTL_CMD
19+
from tests.network.libs.connectivity import ARP_ISOLATION_SYSCTL_CMD, poll_tcp_connectivity
1820

1921

2022
@pytest.fixture(scope="module")
@@ -55,6 +57,36 @@ def bridge_nad_b(
5557
yield nad
5658

5759

60+
@pytest.fixture(scope="module")
61+
def ref_vm(
62+
namespace: Namespace,
63+
unprivileged_client: DynamicClient,
64+
bridge_nad_a: NetworkAttachmentDefinition,
65+
bridge_nad_b: NetworkAttachmentDefinition,
66+
) -> Generator[BaseVirtualMachine]:
67+
iface_a_ips = random_cidr_addresses_by_family(net_seed=NET_SEED, host_address=1)
68+
iface_b_ips = random_cidr_addresses_by_family(net_seed=NET_SEED, host_address=2)
69+
with two_secondary_bridge_vm(
70+
namespace=namespace.name,
71+
name="ref-vm",
72+
client=unprivileged_client,
73+
nad_names=[bridge_nad_a.name, bridge_nad_b.name],
74+
ip_addresses=[iface_a_ips, iface_b_ips],
75+
iface_names=[LINUX_BRIDGE_IFACE_NAME_1, LINUX_BRIDGE_IFACE_NAME_2],
76+
runcmd=ARP_ISOLATION_SYSCTL_CMD,
77+
) as vm:
78+
vm.start(wait=True)
79+
vm.wait_for_agent_connected()
80+
wait_for_ifaces_status(
81+
vm=vm,
82+
ip_addresses_by_spec_net_name={
83+
LINUX_BRIDGE_IFACE_NAME_1: [addr.split("/")[0] for addr in iface_a_ips],
84+
LINUX_BRIDGE_IFACE_NAME_2: [addr.split("/")[0] for addr in iface_b_ips],
85+
},
86+
)
87+
yield vm
88+
89+
5890
@pytest.fixture(scope="class")
5991
def under_test_vm_two_ifaces(
6092
namespace: Namespace,
@@ -82,3 +114,34 @@ def under_test_vm_two_ifaces(
82114
},
83115
)
84116
yield vm
117+
118+
119+
@pytest.fixture(scope="class")
120+
def baseline_connectivity(
121+
under_test_vm_two_ifaces: BaseVirtualMachine,
122+
ref_vm: BaseVirtualMachine,
123+
) -> None:
124+
"""Verify baseline connectivity before the NAD reference change.
125+
126+
Asserts that the under-test VM can reach the reference VM on VLAN-A (iface-1)
127+
and cannot reach it on VLAN-B (iface-2) before any NAD update is applied.
128+
"""
129+
for server_ip in filter_link_local_addresses(
130+
ip_addresses=lookup_iface_status(vm=ref_vm, iface_name=LINUX_BRIDGE_IFACE_NAME_1).ipAddresses
131+
):
132+
poll_tcp_connectivity(
133+
client_vm=under_test_vm_two_ifaces,
134+
server_vm=ref_vm,
135+
server_ip=str(server_ip),
136+
server_bind_dev=GUEST_IFACE_1,
137+
)
138+
for server_ip in filter_link_local_addresses(
139+
ip_addresses=lookup_iface_status(vm=ref_vm, iface_name=LINUX_BRIDGE_IFACE_NAME_2).ipAddresses
140+
):
141+
poll_tcp_connectivity(
142+
client_vm=under_test_vm_two_ifaces,
143+
server_vm=ref_vm,
144+
server_ip=str(server_ip),
145+
server_bind_dev=GUEST_IFACE_2,
146+
expect_connectivity=False,
147+
)

tests/network/l2_bridge/nad_ref_change/lib_helpers.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from libs.vm.vm import BaseVirtualMachine, add_volume_disk, cloudinitdisk_storage
1616
from tests.network.libs import cloudinit
1717
from tests.network.libs.cloudinit import primary_iface_cloud_init
18+
from tests.network.libs.connectivity import poll_tcp_connectivity
1819

1920
NET_SEED: Final[int] = 0
2021

@@ -23,6 +24,57 @@
2324
GUEST_IFACE_2: Final[str] = "eth2"
2425

2526

27+
def assert_connectivity(
28+
client_vm: BaseVirtualMachine,
29+
server_vm: BaseVirtualMachine,
30+
server_ip: str,
31+
server_bind_dev: str,
32+
client_bind_dev: str,
33+
) -> None:
34+
"""Assert TCP connectivity from client to server for a single IP address.
35+
36+
Args:
37+
client_vm: VM initiating the connection.
38+
server_vm: VM accepting the connection.
39+
server_ip: IP address to connect to.
40+
server_bind_dev: Guest device to bind the iperf3 server to (bypasses ECMP).
41+
client_bind_dev: Guest device to bind the iperf3 client to (bypasses ECMP).
42+
"""
43+
poll_tcp_connectivity(
44+
client_vm=client_vm,
45+
server_vm=server_vm,
46+
server_ip=server_ip,
47+
client_bind_dev=client_bind_dev,
48+
server_bind_dev=server_bind_dev,
49+
)
50+
51+
52+
def assert_no_connectivity(
53+
client_vm: BaseVirtualMachine,
54+
server_vm: BaseVirtualMachine,
55+
server_ip: str,
56+
server_bind_dev: str,
57+
client_bind_dev: str,
58+
) -> None:
59+
"""Assert no TCP connectivity from client to server for a single IP address.
60+
61+
Args:
62+
client_vm: VM initiating the connection.
63+
server_vm: VM accepting the connection.
64+
server_ip: IP address to connect to.
65+
server_bind_dev: Guest device to bind the iperf3 server to (bypasses ECMP).
66+
client_bind_dev: Guest device to bind the iperf3 client to (bypasses ECMP).
67+
"""
68+
poll_tcp_connectivity(
69+
client_vm=client_vm,
70+
server_vm=server_vm,
71+
server_ip=server_ip,
72+
client_bind_dev=client_bind_dev,
73+
server_bind_dev=server_bind_dev,
74+
expect_connectivity=False,
75+
)
76+
77+
2678
def update_nad_references(vm: BaseVirtualMachine, nad_name_by_net: dict[str, str]) -> None:
2779
"""Update secondary network NAD references and wait for the change to be fully applied.
2880

tests/network/l2_bridge/nad_ref_change/test_nad_ref_change.py

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@
1313

1414
import pytest
1515

16+
from libs.net.ip import filter_link_local_addresses
1617
from libs.net.vmspec import lookup_iface_status
17-
from tests.network.l2_bridge.libl2bridge import LINUX_BRIDGE_IFACE_NAME_1
18+
from tests.network.l2_bridge.libl2bridge import LINUX_BRIDGE_IFACE_NAME_1, LINUX_BRIDGE_IFACE_NAME_2
1819
from tests.network.l2_bridge.nad_ref_change.lib_helpers import (
20+
GUEST_IFACE_1,
21+
GUEST_IFACE_2,
22+
assert_connectivity,
23+
assert_no_connectivity,
1924
update_nad_references,
2025
)
2126

2227

28+
@pytest.mark.usefixtures("baseline_connectivity")
2329
@pytest.mark.incremental
2430
class TestRunningVMLinuxBridgeVlanChange:
2531
"""
@@ -80,7 +86,7 @@ def test_vm_state_iface_info_preserved(
8086
8187
Expected:
8288
- Under-test VM remains running after the NAD reference change
83-
- Guest first secondary interface MAC address, name, and IP addresses are the same before and after the
89+
- All guest first secondary interface status fields are identical before and after the
8490
NAD reference change
8591
"""
8692
iface_before = lookup_iface_status(vm=under_test_vm_two_ifaces, iface_name=LINUX_BRIDGE_IFACE_NAME_1)
@@ -95,7 +101,14 @@ def test_vm_state_iface_info_preserved(
95101
)
96102

97103
@pytest.mark.polarion("CNV-15972")
98-
def test_connectivity(self):
104+
def test_connectivity(
105+
self,
106+
subtests,
107+
under_test_vm_two_ifaces,
108+
ref_vm,
109+
bridge_nad_a,
110+
bridge_nad_b,
111+
):
99112
"""
100113
Test that the under-test VM has TCP connectivity to the reference VM on the new NAD-VLAN-B and no TCP
101114
connectivity on the old NAD-VLAN-A after the NAD reference change.
@@ -112,11 +125,38 @@ def test_connectivity(self):
112125
- Under-test VM eventually has TCP connectivity to the reference VM on NAD-VLAN-B
113126
- Under-test VM has no TCP connectivity to the reference VM on NAD-VLAN-A
114127
"""
115-
116-
test_connectivity.__test__ = False
128+
for server_ip in filter_link_local_addresses(
129+
ip_addresses=lookup_iface_status(vm=ref_vm, iface_name=LINUX_BRIDGE_IFACE_NAME_2).ipAddresses
130+
):
131+
with subtests.test(msg=f"IPv{server_ip.version} on {bridge_nad_b.name}"):
132+
assert_connectivity(
133+
client_vm=under_test_vm_two_ifaces,
134+
server_vm=ref_vm,
135+
server_ip=str(server_ip),
136+
server_bind_dev=GUEST_IFACE_2,
137+
client_bind_dev=GUEST_IFACE_1,
138+
)
139+
for server_ip in filter_link_local_addresses(
140+
ip_addresses=lookup_iface_status(vm=ref_vm, iface_name=LINUX_BRIDGE_IFACE_NAME_1).ipAddresses
141+
):
142+
with subtests.test(msg=f"IPv{server_ip.version} on {bridge_nad_a.name}"):
143+
assert_no_connectivity(
144+
client_vm=under_test_vm_two_ifaces,
145+
server_vm=ref_vm,
146+
server_ip=str(server_ip),
147+
server_bind_dev=GUEST_IFACE_1,
148+
client_bind_dev=GUEST_IFACE_1,
149+
)
117150

118151
@pytest.mark.polarion("CNV-15946")
119-
def test_two_networks(self):
152+
def test_two_networks(
153+
self,
154+
subtests,
155+
under_test_vm_two_ifaces,
156+
ref_vm,
157+
bridge_nad_a,
158+
bridge_nad_b,
159+
):
120160
"""
121161
Test that both secondary Linux bridge networks on a running VM can have their VLANs
122162
changed in a single patch, with each network switching to a different VLAN.
@@ -140,8 +180,36 @@ def test_two_networks(self):
140180
- Under-test VM first secondary network eventually has TCP connectivity to the reference VM on NAD-VLAN-A
141181
- Under-test VM second secondary network eventually has TCP connectivity to the reference VM on NAD-VLAN-B
142182
"""
183+
update_nad_references(
184+
vm=under_test_vm_two_ifaces,
185+
nad_name_by_net={
186+
LINUX_BRIDGE_IFACE_NAME_1: bridge_nad_a.name,
187+
LINUX_BRIDGE_IFACE_NAME_2: bridge_nad_b.name,
188+
},
189+
)
143190

144-
test_two_networks.__test__ = False
191+
for server_ip in filter_link_local_addresses(
192+
ip_addresses=lookup_iface_status(vm=ref_vm, iface_name=LINUX_BRIDGE_IFACE_NAME_1).ipAddresses
193+
):
194+
with subtests.test(msg=f"IPv{server_ip.version} on {bridge_nad_a.name}"):
195+
assert_connectivity(
196+
client_vm=under_test_vm_two_ifaces,
197+
server_vm=ref_vm,
198+
server_ip=str(server_ip),
199+
server_bind_dev=GUEST_IFACE_1,
200+
client_bind_dev=GUEST_IFACE_1,
201+
)
202+
for server_ip in filter_link_local_addresses(
203+
ip_addresses=lookup_iface_status(vm=ref_vm, iface_name=LINUX_BRIDGE_IFACE_NAME_2).ipAddresses
204+
):
205+
with subtests.test(msg=f"IPv{server_ip.version} on {bridge_nad_b.name}"):
206+
assert_connectivity(
207+
client_vm=under_test_vm_two_ifaces,
208+
server_vm=ref_vm,
209+
server_ip=str(server_ip),
210+
server_bind_dev=GUEST_IFACE_2,
211+
client_bind_dev=GUEST_IFACE_2,
212+
)
145213

146214

147215
@pytest.mark.polarion("CNV-15947")

tests/network/libs/connectivity.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import ipaddress
22
from typing import Final
33

4+
from timeout_sampler import TimeoutExpiredError, retry
5+
6+
from libs.net.traffic_generator import IPERF_SERVER_PORT, TcpServer, VMTcpClient
7+
from libs.vm.vm import BaseVirtualMachine
8+
49
ARP_ISOLATION_SYSCTL_CMD: Final[list[str]] = [
510
# Only answer ARP for the IP assigned to the receiving interface —
611
# prevents eth1 from responding to ARP for eth2's IP when queried from the same VLAN.
@@ -26,3 +31,38 @@ def build_ping_command(dst_ip: str, count: int, timeout: int) -> str:
2631
ip = ipaddress.ip_address(address=dst_ip)
2732
ping_ipv6_flag = " -6" if ip.version == 6 else ""
2833
return f"ping{ping_ipv6_flag} {dst_ip} -c {count} -w {timeout}"
34+
35+
36+
@retry(wait_timeout=60, sleep=5, exceptions_dict={})
37+
def poll_tcp_connectivity(
38+
client_vm: BaseVirtualMachine,
39+
server_vm: BaseVirtualMachine,
40+
server_ip: str,
41+
client_bind_dev: str | None = None,
42+
server_bind_dev: str | None = None,
43+
expect_connectivity: bool = True,
44+
) -> bool:
45+
"""Poll TCP connectivity (or its absence) between two VMs, retrying until the expected state is reached.
46+
47+
Args:
48+
client_vm: VM initiating the TCP connection.
49+
server_vm: VM running the iperf3 server.
50+
server_ip: IP address the server binds to.
51+
client_bind_dev: Guest network device name to force the client out (e.g. "eth1").
52+
Bypasses ECMP routing when both secondary interfaces share the same subnet.
53+
server_bind_dev: Guest network device name to force the server responses out (e.g. "eth1").
54+
Bypasses ECMP routing on the server VM when it has multiple secondary interfaces.
55+
expect_connectivity: When True polls until connectivity exists; when False polls until it does not.
56+
57+
Returns:
58+
True when the observed reachability matches expect_connectivity.
59+
"""
60+
try:
61+
with TcpServer(vm=server_vm, port=IPERF_SERVER_PORT, bind_ip=server_ip, bind_dev=server_bind_dev):
62+
with VMTcpClient(
63+
vm=client_vm, server_ip=server_ip, server_port=IPERF_SERVER_PORT, bind_dev=client_bind_dev
64+
):
65+
reachable = True
66+
except TimeoutExpiredError:
67+
reachable = False
68+
return reachable if expect_connectivity else not reachable

0 commit comments

Comments
 (0)