Skip to content

Commit 96be67a

Browse files
committed
net, tests, stuntime: Add OVN localnet migration stuntime scenario
Implement the initial stuntime scenario to serve as a baseline for future performance testing. For now, the global stuntime threshold is set to a 5s placeholder. Once we finish automating the remaining scenarios and have the baseline data to calibrate our expectations, we’ll replace this with a more precise, data-driven value. Technical changes: - Annotate get_node_selector_dict to satisfy strict mypy checks (disallow_untyped_calls) for the new localnet migration fixture. - Stuntime scenarios require two VMs on the same node. To support this, anti-affinity is now configurable; it remains enabled by default to avoid impacting existing callers. Signed-off-by: Anat Wax <awax@redhat.com> Assisted by: Cursor
1 parent 9ec6aeb commit 96be67a

File tree

8 files changed

+586
-33
lines changed

8 files changed

+586
-33
lines changed

libs/vm/spec.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class VMISpec:
3131
volumes: list[Volume] | None = None
3232
terminationGracePeriodSeconds: int | None = None # noqa: N815
3333
affinity: Affinity | None = None
34+
nodeSelector: dict[str, str] | None = None # noqa: N815
3435

3536

3637
@dataclass

libs/vm/vm.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,17 @@ def update_template_annotations(self, template_annotations: dict[str, str]) -> N
114114
}
115115
ResourceEditor(patches=patches).update()
116116

117+
def update_template_node_selector(self, node_selector: dict[str, str] | None) -> None:
118+
"""Update the VM template node selector.
119+
120+
Args:
121+
node_selector: Node selector dictionary to apply to the VM template spec.
122+
Set to None to clear the node selector.
123+
"""
124+
self._spec.template.spec.nodeSelector = node_selector
125+
patches = {self: {"spec": {"template": {"spec": {"nodeSelector": node_selector}}}}}
126+
ResourceEditor(patches=patches).update()
127+
117128
@property
118129
def template_spec(self) -> VMISpec:
119130
return self._spec.template.spec

tests/network/l2_bridge/migration_stuntime/test_migration_stuntime.py

Lines changed: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
Stuntime is defined as the connectivity gap from last successful reply before loss
77
to first successful reply after recovery.
88
9-
Stuntime is measured using ICMP ping from client to server in 0.1s intervals, using ping -D so each
10-
log line includes a timestamp for gap calculation.
9+
Stuntime is measured using ICMP ping from client to server in 0.1s intervals.
1110
The under-test VMs are configured on a Linux bridge secondary network, with a single interface,
1211
on which IPv4/IPv6 static addresses will be defined according to the environment the test runs on.
1312
@@ -23,11 +22,6 @@
2322
__test__ = False
2423

2524
"""
26-
Parametrize:
27-
- ip_family:
28-
- ipv4 [Markers: ipv4]
29-
- ipv6 [Markers: ipv6]
30-
3125
Preconditions:
3226
- Shared under-test server VM on Linux bridge secondary network, for the IP family from ip_family parametrization.
3327
- Shared under-test client VM on Linux bridge secondary network, for that same IP family,
@@ -37,7 +31,28 @@
3731

3832
@pytest.mark.incremental
3933
class TestMigrationStuntime:
40-
@pytest.mark.polarion("CNV-15252")
34+
@pytest.mark.parametrize(
35+
"stuntime_active_ping",
36+
[
37+
pytest.param(
38+
4,
39+
id="ipv4",
40+
marks=[
41+
pytest.mark.polarion("CNV-15252"),
42+
pytest.mark.ipv4,
43+
],
44+
),
45+
pytest.param(
46+
6,
47+
id="ipv6",
48+
marks=[
49+
pytest.mark.polarion("CNV-15267"),
50+
pytest.mark.ipv6,
51+
],
52+
),
53+
],
54+
indirect=True,
55+
)
4156
def test_client_migrates_off_server_node(self):
4257
"""
4358
Test that measured stuntime does not exceed the global threshold when the client
@@ -59,7 +74,28 @@ def test_client_migrates_off_server_node(self):
5974
- Measured stuntime does not exceed the global threshold.
6075
"""
6176

62-
@pytest.mark.polarion("CNV-15253")
77+
@pytest.mark.parametrize(
78+
"stuntime_active_ping",
79+
[
80+
pytest.param(
81+
4,
82+
id="ipv4",
83+
marks=[
84+
pytest.mark.polarion("CNV-15253"),
85+
pytest.mark.ipv4,
86+
],
87+
),
88+
pytest.param(
89+
6,
90+
id="ipv6",
91+
marks=[
92+
pytest.mark.polarion("CNV-15268"),
93+
pytest.mark.ipv6,
94+
],
95+
),
96+
],
97+
indirect=True,
98+
)
6399
def test_client_migrates_between_non_server_nodes(self):
64100
"""
65101
Test that measured stuntime does not exceed the global threshold when the client VM migrates between nodes
@@ -81,7 +117,28 @@ def test_client_migrates_between_non_server_nodes(self):
81117
- Measured stuntime does not exceed the global threshold.
82118
"""
83119

84-
@pytest.mark.polarion("CNV-15254")
120+
@pytest.mark.parametrize(
121+
"stuntime_active_ping",
122+
[
123+
pytest.param(
124+
4,
125+
id="ipv4",
126+
marks=[
127+
pytest.mark.polarion("CNV-15254"),
128+
pytest.mark.ipv4,
129+
],
130+
),
131+
pytest.param(
132+
6,
133+
id="ipv6",
134+
marks=[
135+
pytest.mark.polarion("CNV-15278"),
136+
pytest.mark.ipv6,
137+
],
138+
),
139+
],
140+
indirect=True,
141+
)
85142
def test_client_migrates_to_server_node(self):
86143
"""
87144
Test that measured stuntime does not exceed the global threshold when the client VM migrates
@@ -103,7 +160,28 @@ def test_client_migrates_to_server_node(self):
103160
- Measured stuntime does not exceed the global threshold.
104161
"""
105162

106-
@pytest.mark.polarion("CNV-15255")
163+
@pytest.mark.parametrize(
164+
"stuntime_active_ping",
165+
[
166+
pytest.param(
167+
4,
168+
id="ipv4",
169+
marks=[
170+
pytest.mark.polarion("CNV-15255"),
171+
pytest.mark.ipv4,
172+
],
173+
),
174+
pytest.param(
175+
6,
176+
id="ipv6",
177+
marks=[
178+
pytest.mark.polarion("CNV-15269"),
179+
pytest.mark.ipv6,
180+
],
181+
),
182+
],
183+
indirect=True,
184+
)
107185
def test_server_migrates_off_client_node(self):
108186
"""
109187
Test that measured stuntime does not exceed the global threshold when the server
@@ -125,7 +203,28 @@ def test_server_migrates_off_client_node(self):
125203
- Measured stuntime does not exceed the global threshold.
126204
"""
127205

128-
@pytest.mark.polarion("CNV-15256")
206+
@pytest.mark.parametrize(
207+
"stuntime_active_ping",
208+
[
209+
pytest.param(
210+
4,
211+
id="ipv4",
212+
marks=[
213+
pytest.mark.polarion("CNV-15256"),
214+
pytest.mark.ipv4,
215+
],
216+
),
217+
pytest.param(
218+
6,
219+
id="ipv6",
220+
marks=[
221+
pytest.mark.polarion("CNV-15270"),
222+
pytest.mark.ipv6,
223+
],
224+
),
225+
],
226+
indirect=True,
227+
)
129228
def test_server_migrates_between_non_client_nodes(self):
130229
"""
131230
Test that measured stuntime does not exceed the global threshold when the server VM migrates between nodes
@@ -147,7 +246,28 @@ def test_server_migrates_between_non_client_nodes(self):
147246
- Measured stuntime does not exceed the global threshold.
148247
"""
149248

150-
@pytest.mark.polarion("CNV-15257")
249+
@pytest.mark.parametrize(
250+
"stuntime_active_ping",
251+
[
252+
pytest.param(
253+
4,
254+
id="ipv4",
255+
marks=[
256+
pytest.mark.polarion("CNV-15257"),
257+
pytest.mark.ipv4,
258+
],
259+
),
260+
pytest.param(
261+
6,
262+
id="ipv6",
263+
marks=[
264+
pytest.mark.polarion("CNV-15271"),
265+
pytest.mark.ipv6,
266+
],
267+
),
268+
],
269+
indirect=True,
270+
)
151271
def test_server_migrates_to_client_node(self):
152272
"""
153273
Test that measured stuntime does not exceed the global threshold when the server VM migrates from a node

tests/network/localnet/liblocalnet.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import contextlib
22
import logging
33
import uuid
4-
from typing import Final, Generator
4+
from collections.abc import Generator
5+
from typing import Final
56

67
from kubernetes.client import ApiException
78
from kubernetes.dynamic import DynamicClient
@@ -77,6 +78,8 @@ def localnet_vm(
7778
networks: list[Network],
7879
interfaces: list[Interface],
7980
network_data: cloudinit.NetworkData | None = None,
81+
pod_anti_affinity: bool = True,
82+
node_selector: dict[str, str] | None = None,
8083
) -> BaseVirtualMachine:
8184
"""
8285
Create a Fedora-based Virtual Machine connected to localnet network(s).
@@ -95,6 +98,9 @@ def localnet_vm(
9598
Each Interface should have a name matching a Network, and additional configuration and state.
9699
network_data (cloudinit.NetworkData | None): Cloud-init NetworkData object containing the network
97100
configuration for the VM interfaces. If None, no network configuration is applied via cloud-init.
101+
pod_anti_affinity (bool): When True (default), prevent this VM from being scheduled on the same node
102+
as other VMs with the localnet test label.
103+
node_selector (dict[str, str] | None): Optional VMI nodeSelector (e.g. pin to a worker hostname).
98104
99105
Returns:
100106
BaseVirtualMachine: The configured VM object ready for creation.
@@ -136,8 +142,12 @@ def localnet_vm(
136142
)
137143
vmi_spec = add_volume_disk(vmi_spec=vmi_spec, volume=volume, disk=disk)
138144

139-
vmi_spec.affinity = new_pod_anti_affinity(label=next(iter(LOCALNET_TEST_LABEL.items())))
140-
vmi_spec.affinity.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution[0].namespaceSelector = {}
145+
if pod_anti_affinity:
146+
vmi_spec.affinity = new_pod_anti_affinity(label=next(iter(LOCALNET_TEST_LABEL.items())))
147+
vmi_spec.affinity.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution[0].namespaceSelector = {}
148+
149+
if node_selector is not None:
150+
vmi_spec.nodeSelector = node_selector
141151

142152
return fedora_vm(namespace=namespace, name=name, client=client, spec=spec)
143153

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import logging
2+
from collections.abc import Generator
3+
4+
import pytest
5+
from kubernetes.dynamic import DynamicClient
6+
from ocp_resources.namespace import Namespace
7+
8+
from libs.net.vmspec import lookup_iface_status_ip
9+
from libs.vm.spec import Interface, Multus, Network
10+
from libs.vm.vm import BaseVirtualMachine
11+
from tests.network.libs import cloudinit
12+
from tests.network.libs import cluster_user_defined_network as libcudn
13+
from tests.network.localnet.liblocalnet import (
14+
GUEST_1ST_IFACE_NAME,
15+
LOCALNET_OVS_BRIDGE_INTERFACE,
16+
ip_addresses_from_pool,
17+
libnncp,
18+
localnet_vm,
19+
run_vms,
20+
)
21+
from tests.network.localnet.migration_stuntime import libstuntime
22+
from utilities.infra import get_node_selector_dict
23+
24+
LOGGER = logging.getLogger(__name__)
25+
26+
27+
@pytest.fixture()
28+
def localnet_stuntime_server_vm(
29+
unprivileged_client: DynamicClient,
30+
nncp_localnet_on_secondary_node_nic: libnncp.NodeNetworkConfigurationPolicy,
31+
cudn_localnet_ovs_bridge: libcudn.ClusterUserDefinedNetwork,
32+
namespace_localnet_1: Namespace,
33+
ipv4_localnet_address_pool: Generator[str],
34+
ipv6_localnet_address_pool: Generator[str],
35+
) -> Generator[BaseVirtualMachine]:
36+
"""Fedora VM on OVS localnet acting as ping server for stuntime tests."""
37+
with localnet_vm(
38+
namespace=namespace_localnet_1.name,
39+
name="localnet-stuntime-server",
40+
client=unprivileged_client,
41+
networks=[
42+
Network(name=LOCALNET_OVS_BRIDGE_INTERFACE, multus=Multus(networkName=cudn_localnet_ovs_bridge.name))
43+
],
44+
interfaces=[Interface(name=LOCALNET_OVS_BRIDGE_INTERFACE, bridge={})],
45+
network_data=cloudinit.NetworkData(
46+
ethernets={
47+
GUEST_1ST_IFACE_NAME: cloudinit.EthernetDevice(
48+
addresses=ip_addresses_from_pool(
49+
ipv4_pool=ipv4_localnet_address_pool,
50+
ipv6_pool=ipv6_localnet_address_pool,
51+
)
52+
)
53+
}
54+
),
55+
pod_anti_affinity=False,
56+
) as server_vm:
57+
run_vms(vms=(server_vm,))
58+
yield server_vm
59+
60+
61+
@pytest.fixture()
62+
def localnet_stuntime_client_vm(
63+
unprivileged_client: DynamicClient,
64+
cudn_localnet_ovs_bridge: libcudn.ClusterUserDefinedNetwork,
65+
namespace_localnet_1: Namespace,
66+
ipv4_localnet_address_pool: Generator[str],
67+
ipv6_localnet_address_pool: Generator[str],
68+
localnet_stuntime_server_vm: BaseVirtualMachine,
69+
) -> Generator[BaseVirtualMachine]:
70+
"""Fedora VM on OVS localnet acting as ping client, initially on same node as server."""
71+
server_node_name = localnet_stuntime_server_vm.vmi.node.name
72+
with localnet_vm(
73+
namespace=namespace_localnet_1.name,
74+
name="localnet-stuntime-client",
75+
client=unprivileged_client,
76+
networks=[
77+
Network(name=LOCALNET_OVS_BRIDGE_INTERFACE, multus=Multus(networkName=cudn_localnet_ovs_bridge.name))
78+
],
79+
interfaces=[Interface(name=LOCALNET_OVS_BRIDGE_INTERFACE, bridge={})],
80+
network_data=cloudinit.NetworkData(
81+
ethernets={
82+
GUEST_1ST_IFACE_NAME: cloudinit.EthernetDevice(
83+
addresses=ip_addresses_from_pool(
84+
ipv4_pool=ipv4_localnet_address_pool,
85+
ipv6_pool=ipv6_localnet_address_pool,
86+
)
87+
)
88+
}
89+
),
90+
pod_anti_affinity=False,
91+
node_selector=get_node_selector_dict(node_selector=server_node_name),
92+
) as client_vm:
93+
run_vms(vms=(client_vm,))
94+
# Clear node selector to allow migration to any node
95+
client_vm.update_template_node_selector(node_selector=None)
96+
yield client_vm
97+
98+
99+
@pytest.fixture()
100+
def stuntime_active_ping(
101+
request: pytest.FixtureRequest,
102+
localnet_stuntime_server_vm: BaseVirtualMachine,
103+
localnet_stuntime_client_vm: BaseVirtualMachine,
104+
) -> Generator:
105+
"""Active ping from client to server for stuntime measurement.
106+
107+
Ping starts as a precondition before test runs.
108+
Test must call stop_and_get_summary() to get results.
109+
Context manager guarantees cleanup even if test fails.
110+
111+
Requires indirect parametrization with ip_family parameter.
112+
"""
113+
ip_family = request.param
114+
server_ip = str(
115+
lookup_iface_status_ip(
116+
vm=localnet_stuntime_server_vm,
117+
iface_name=LOCALNET_OVS_BRIDGE_INTERFACE,
118+
ip_family=ip_family,
119+
)
120+
)
121+
122+
with libstuntime.continuous_ping(source_vm=localnet_stuntime_client_vm, destination_ip=server_ip) as ping:
123+
yield ping

0 commit comments

Comments
 (0)