Skip to content

Commit 8357a71

Browse files
authored
net: Introduce dual-stream linux bridge tests (#4772)
##### Short description: Dual-stream clusters run worker nodes with a mix of RHCOS 9 and RHCOS 10. Live migration across OS versions can expose compatibility issues: kernel bridge drivers, OVN flow tables, and binding plugins all behave differently between RHCOS releases, that would only manifest during cross-OS migration and not in homogeneous OS nodes clusters. ##### More details: STD: #4569 ##### What this PR does / why we need it: This PR adds the shared infrastructure layer needed by all network migration tests in this series, plus Linux bridge connectivity tests. The shared layer registers the mixed_os_nodes marker to gate tests to compatible clusters, and introduces helpers to direct a running VM to a specific OS node before triggering migration. The Linux bridge tests verify that an active TCP connection over a secondary Linux bridge network survives live migration of the server VM in both directions (RHCOS 9 -> 10 and back). The client VM remains on RHCOS 9 throughout, so the test isolates the impact of the server-side OS change on the forwarding path. ##### Which issue(s) this PR fixes: - ##### Special notes for reviewer: This is the first PR in a series. Localnet, primary network, and primary UDN are covered in separate PRs that depend on the same infra commits. ##### jira-ticket: https://redhat.atlassian.net/browse/CNV-86194 <!-- 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 testing framework for virtual machine migrations between RHEL9 and RHEL10 worker nodes. * Enhanced L2 bridge network configuration and testing across mixed-OS clusters. * **Tests** * Implemented comprehensive test fixtures for L2 bridge integration testing. * Added automated network connectivity verification during VM migrations across operating system versions. [![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/4772) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents c603ae0 + 562fcbf commit 8357a71

15 files changed

Lines changed: 338 additions & 132 deletions

File tree

libs/net/ip.py

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,48 @@
1515
ICMP_HEADER_SIZE: Final[int] = 8
1616

1717

18+
def random_cidr_addresses_by_family(net_seed: int, host_address: int) -> list[str]:
19+
"""Return CIDR-formatted addresses for each IP family supported by the cluster.
20+
21+
This library uses /24 for IPv4 and /64 for IPv6 VM subnets, matching the
22+
subnet definitions in this module. VMs with the same net_seed share the
23+
same subnet, allowing direct L2 communication without routing. Only
24+
families supported by the cluster are included.
25+
26+
Args:
27+
net_seed: Index into the cached pool of random network prefixes.
28+
host_address: Host portion of the address — must be unique per VM in the test.
29+
30+
Returns:
31+
List of CIDR strings (e.g. ["192.168.1.1/24", "fd00::1/64"]).
32+
"""
33+
return [
34+
f"{ip}/64" if ipaddress.ip_address(ip).version == 6 else f"{ip}/24"
35+
for ip in random_ip_addresses_by_family(net_seed=net_seed, host_address=host_address)
36+
]
37+
38+
39+
def random_ip_addresses_by_family(
40+
net_seed: int,
41+
host_address: int,
42+
) -> list[str]:
43+
"""Generate IP addresses for each IP family supported by the cluster network stack.
44+
45+
Args:
46+
net_seed: Seed index for selecting the random network portion of the address.
47+
host_address: Host portion of the address, used to place VMs on the same subnet.
48+
49+
Returns:
50+
List of IP address strings, one per IP family supported by the cluster.
51+
"""
52+
ips = []
53+
if ipv4_supported_cluster():
54+
ips.append(random_ipv4_address(net_seed=net_seed, host_address=host_address))
55+
if ipv6_supported_cluster():
56+
ips.append(random_ipv6_address(net_seed=net_seed, host_address=host_address))
57+
return ips
58+
59+
1860
def random_ipv4_address(net_seed: int, host_address: int) -> str:
1961
"""Construct a random IPv4 address using a cached list of random third octets.
2062
@@ -81,27 +123,6 @@ def _random_hextets(count: int) -> list[int]:
81123
return random.sample(range(1, 0xFFFE), count)
82124

83125

84-
def random_ip_addresses_by_family(
85-
net_seed: int,
86-
host_address: int,
87-
) -> list[str]:
88-
"""Generate IP addresses for each IP family supported by the cluster network stack.
89-
90-
Args:
91-
net_seed: Seed index for selecting the random network portion of the address.
92-
host_address: Host portion of the address, used to place VMs on the same subnet.
93-
94-
Returns:
95-
List of IP address strings, one per IP family supported by the cluster.
96-
"""
97-
ips = []
98-
if ipv4_supported_cluster():
99-
ips.append(random_ipv4_address(net_seed=net_seed, host_address=host_address))
100-
if ipv6_supported_cluster():
101-
ips.append(random_ipv6_address(net_seed=net_seed, host_address=host_address))
102-
return ips
103-
104-
105126
def filter_link_local_addresses(ip_addresses: list[str]) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
106127
"""
107128
Filter out link-local IP addresses from a list of IP address strings.

libs/vm/affinity.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
Affinity,
77
LabelSelector,
88
LabelSelectorRequirement,
9+
NodeAffinity,
10+
NodeSelectorTerm,
11+
NodeSelectorTerms,
912
PodAffinity,
1013
PodAffinityTerm,
1114
PodAntiAffinity,
@@ -80,3 +83,26 @@ def new_pod_affinity(label: tuple[str, str], namespaces: list[str] | None = None
8083
]
8184
)
8285
)
86+
87+
88+
def new_node_affinity(key: str, exists: bool) -> Affinity:
89+
"""Create a node affinity based on whether target nodes must carry the given label.
90+
91+
Args:
92+
key: Node role label key to match.
93+
exists: If True, target nodes must carry the label (Exists operator).
94+
If False, target nodes must NOT carry the label (DoesNotExist operator).
95+
96+
Returns:
97+
Affinity object with nodeAffinity configured.
98+
"""
99+
operator = "Exists" if exists else "DoesNotExist"
100+
return Affinity(
101+
nodeAffinity=NodeAffinity(
102+
requiredDuringSchedulingIgnoredDuringExecution=NodeSelectorTerms(
103+
nodeSelectorTerms=[
104+
NodeSelectorTerm(matchExpressions=[LabelSelectorRequirement(key=key, operator=operator)])
105+
]
106+
)
107+
)
108+
)

libs/vm/oper.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import logging
2+
3+
from kubernetes.client import ApiException
4+
5+
from libs.vm.vm import BaseVirtualMachine
6+
7+
LOGGER = logging.getLogger(__name__)
8+
9+
10+
def run_vms(vms: tuple[BaseVirtualMachine, ...]) -> tuple[BaseVirtualMachine, ...]:
11+
"""Start all VMs in parallel then wait for each to be ready and agent-connected.
12+
13+
Args:
14+
vms: VMs to start.
15+
16+
Returns:
17+
The same tuple of VMs, all running with guest agent connected.
18+
"""
19+
for vm in vms:
20+
try:
21+
vm.start() # type: ignore[no-untyped-call]
22+
except ApiException as vm_exception:
23+
if "VM is already running" in vm_exception.body:
24+
LOGGER.warning(f"VM {vm.name} is already running")
25+
continue
26+
for vm in vms:
27+
vm.wait_for_ready_status(status=True) # type: ignore[no-untyped-call]
28+
vm.wait_for_agent_connected()
29+
return vms

libs/vm/spec.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@ class Multus:
100100
class Affinity:
101101
podAntiAffinity: PodAntiAffinity | None = None # noqa: N815
102102
podAffinity: PodAffinity | None = None # noqa: N815
103+
nodeAffinity: NodeAffinity | None = None # noqa: N815
104+
105+
106+
@dataclass
107+
class NodeAffinity:
108+
requiredDuringSchedulingIgnoredDuringExecution: NodeSelectorTerms # noqa: N815
109+
110+
111+
@dataclass
112+
class NodeSelectorTerms:
113+
nodeSelectorTerms: list[NodeSelectorTerm] # noqa: N815
114+
115+
116+
@dataclass
117+
class NodeSelectorTerm:
118+
matchExpressions: list[LabelSelectorRequirement] # noqa: N815
103119

104120

105121
@dataclass
@@ -129,7 +145,7 @@ class LabelSelector:
129145
class LabelSelectorRequirement:
130146
operator: str
131147
key: str
132-
values: list[str]
148+
values: list[str] | None = None
133149

134150

135151
@dataclass

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ markers =
7272
rwx_default_storage: Tests that require RWX storage
7373
descheduler: Tests that require kube-descheduler on nodes
7474
remote_cluster: Tests that require a remote cluster
75+
mixed_os_nodes: Tests that require a dual-stream cluster with both RHCOS 9 and RHCOS 10 worker nodes
7576

7677
## Required operators
7778
mtv: Tests that require the MTV operator to be installed

tests/network/l2_bridge/bandwidth/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
BANDWIDTH_RATE_BPS,
2222
BANDWIDTH_SECONDARY_IFACE_NAME,
2323
GUEST_2ND_IFACE_NAME,
24-
secondary_network_vm,
2524
)
25+
from tests.network.l2_bridge.libl2bridge import secondary_network_vm
2626

2727
_NAD_NAME: Final[str] = "br-bw-test-nad"
2828

Lines changed: 1 addition & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
import json
22
from typing import Final
33

4-
from kubernetes.dynamic import DynamicClient
5-
6-
from libs.net.cluster import ipv4_supported_cluster, ipv6_supported_cluster
74
from libs.net.traffic_generator import IPERF_SERVER_PORT, TcpServer
8-
from libs.vm.factory import base_vmspec, fedora_vm
9-
from libs.vm.spec import CloudInitNoCloud, Interface, Multus, Network
10-
from libs.vm.vm import BaseVirtualMachine, add_volume_disk, cloudinitdisk_storage
11-
from tests.network.libs import cloudinit
5+
from libs.vm.vm import BaseVirtualMachine
126

137
BANDWIDTH_SECONDARY_IFACE_NAME: Final[str] = "secondary"
148
BANDWIDTH_RATE_BPS: Final[int] = 10_000_000 # 10 Mbps
@@ -84,64 +78,3 @@ def assert_bidir_throughput_within_limit(
8478
f"Measured {direction} throughput {throughput_bps:.0f} bps exceeds "
8579
f"configured limit {rate_bps} bps for {server_ip}"
8680
)
87-
88-
89-
def secondary_network_vm(
90-
namespace: str,
91-
name: str,
92-
client: DynamicClient,
93-
nad_name: str,
94-
secondary_iface_name: str,
95-
secondary_iface_addresses: list[str],
96-
) -> BaseVirtualMachine:
97-
"""Create a Fedora VM with a masquerade primary interface and a secondary Linux bridge interface.
98-
99-
Args:
100-
namespace: Namespace to deploy the VM in.
101-
name: VM name.
102-
client: Kubernetes dynamic client.
103-
nad_name: NetworkAttachmentDefinition name for the secondary interface.
104-
secondary_iface_name: Name of the secondary network interface in the VM spec.
105-
secondary_iface_addresses: CIDR addresses to assign to the secondary interface via cloud-init.
106-
"""
107-
spec = base_vmspec()
108-
spec.template.spec.domain.devices.interfaces = [ # type: ignore
109-
Interface(name="default", masquerade={}),
110-
Interface(name=secondary_iface_name, bridge={}),
111-
]
112-
spec.template.spec.networks = [
113-
Network(name="default", pod={}),
114-
Network(name=secondary_iface_name, multus=Multus(networkName=nad_name)),
115-
]
116-
117-
ethernets = {}
118-
primary = _masquerade_iface_cloud_init()
119-
if primary:
120-
ethernets["eth0"] = primary
121-
ethernets["eth1"] = cloudinit.EthernetDevice(addresses=secondary_iface_addresses)
122-
123-
userdata = cloudinit.UserData(users=[])
124-
disk, volume = cloudinitdisk_storage(
125-
data=CloudInitNoCloud(
126-
networkData=cloudinit.asyaml(no_cloud=cloudinit.NetworkData(ethernets=ethernets)),
127-
userData=cloudinit.format_cloud_config(userdata=userdata),
128-
)
129-
)
130-
spec.template.spec = add_volume_disk(vmi_spec=spec.template.spec, volume=volume, disk=disk)
131-
return fedora_vm(namespace=namespace, name=name, client=client, spec=spec)
132-
133-
134-
def _masquerade_iface_cloud_init() -> cloudinit.EthernetDevice | None:
135-
"""Return cloud-init ethernet config for a masquerade (primary) interface.
136-
137-
Returns:
138-
EthernetDevice with static IPv6 and optional DHCP4, or None if IPv6 is not supported.
139-
"""
140-
if not ipv6_supported_cluster():
141-
return None
142-
return cloudinit.EthernetDevice(
143-
addresses=["fd10:0:2::2/120"],
144-
gateway6="fd10:0:2::1",
145-
dhcp4=ipv4_supported_cluster(),
146-
dhcp6=False,
147-
)

tests/network/l2_bridge/conftest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import tests.network.libs.nodenetworkconfigurationpolicy as libnncp
99
from libs.net.ip import random_ipv4_address
10+
from libs.net.netattachdef import CNIPluginBridgeConfig, NetConfig, NetworkAttachmentDefinition
1011
from tests.network.l2_bridge.libl2bridge import DHCP_INTERFACE_NAME, bridge_attached_vm
1112
from tests.network.libs.dhcpd import (
1213
DHCP_IP_RANGE_END,
@@ -313,3 +314,22 @@ def bridge_nncp(
313314
) as nncp_br:
314315
nncp_br.wait_for_status_success()
315316
yield nncp_br
317+
318+
319+
@pytest.fixture(scope="class")
320+
def bridge_nad(
321+
admin_client: DynamicClient,
322+
namespace,
323+
bridge_nncp: libnncp.NodeNetworkConfigurationPolicy,
324+
) -> Generator[NetworkAttachmentDefinition]:
325+
config = NetConfig(
326+
name="test-bridge-network",
327+
plugins=[CNIPluginBridgeConfig(bridge=bridge_nncp.desired_state_spec.interfaces[0].name)], # type: ignore
328+
)
329+
with NetworkAttachmentDefinition(
330+
name="test-bridge-network",
331+
namespace=namespace.name,
332+
config=config,
333+
client=admin_client,
334+
) as nad:
335+
yield nad

0 commit comments

Comments
 (0)