Skip to content

Commit 9e25afa

Browse files
azhivovkclaude
andcommitted
net: extend shared infrastructure for dual-stream cluster tests
Dual-stream clusters (RHCOS 9 + RHCOS 10 worker nodes) require VM factory functions to support node pinning so tests can control which node a VM starts on and verify migration between OS versions. Without this, existing factories always use anti-affinity scheduling, making deterministic migration direction impossible. - Add mixed_os_nodes marker to pytest.ini to select tests only on compatible clusters - Add rhcos9_node/rhcos10_node fixtures to network conftest - Extend localnet_vm and udn_vm with optional node param for nodeSelector - Add update_vm_node_selector helper and HOSTNAME_LABEL to nodes lib Signed-off-by: Asia Khromov <azhivovk@redhat.com> Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 267f965 commit 9e25afa

7 files changed

Lines changed: 109 additions & 3 deletions

File tree

libs/net/ip.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,23 @@ def have_same_ip_families(
128128
expected_ips: list[ipaddress.IPv4Address | ipaddress.IPv6Address],
129129
) -> bool:
130130
return {ip.version for ip in actual_ips} == {ip.version for ip in expected_ips}
131+
132+
133+
def cidr_addresses_by_family(net_seed: int, host_address: int) -> list[str]:
134+
"""Return CIDR-formatted addresses for each IP family supported by the cluster.
135+
136+
IPv4 addresses use a /24 prefix; IPv6 addresses use /64. Only families
137+
supported by the cluster are included. Suitable for any VM interface
138+
configuration that needs per-family addresses (primary, secondary, UDN, etc.).
139+
140+
Args:
141+
net_seed: Index into the cached pool of random network prefixes.
142+
host_address: Host portion of the address — must be unique per VM in the test.
143+
144+
Returns:
145+
List of CIDR strings (e.g. ["192.168.1.1/24", "fd00::1/64"]).
146+
"""
147+
return [
148+
f"{ip}/64" if ipaddress.ip_address(ip).version == 6 else f"{ip}/24"
149+
for ip in random_ip_addresses_by_family(net_seed=net_seed, host_address=host_address)
150+
]

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

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/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
from timeout_sampler import TimeoutExpiredError
1616

1717
from libs.net.cluster import ipv4_supported_cluster, ipv6_supported_cluster
18+
from tests.network.libs.nodes import (
19+
RHCOS_9_VERSION_PREFIX,
20+
RHCOS_10_VERSION_PREFIX,
21+
node_by_rhcos_version,
22+
)
1823
from tests.network.utils import get_vlan_index_number
1924
from utilities.constants import (
2025
CLUSTER,
@@ -321,3 +326,13 @@ def _verify_mtv_installed():
321326
message="Network cluster verification failed",
322327
admin_client=admin_client,
323328
)
329+
330+
331+
@pytest.fixture(scope="module")
332+
def rhcos9_node(workers):
333+
return node_by_rhcos_version(workers=workers, rhcos_version_prefix=RHCOS_9_VERSION_PREFIX)
334+
335+
336+
@pytest.fixture(scope="module")
337+
def rhcos10_node(workers):
338+
return node_by_rhcos_version(workers=workers, rhcos_version_prefix=RHCOS_10_VERSION_PREFIX)

tests/network/libs/nodes.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import Final
2+
3+
from ocp_resources.node import Node
4+
from ocp_resources.resource import ResourceEditor
5+
6+
from libs.vm.vm import BaseVirtualMachine
7+
8+
HOSTNAME_LABEL: Final[str] = "kubernetes.io/hostname"
9+
RHCOS_9_VERSION_PREFIX: Final[str] = "Red Hat Enterprise Linux CoreOS 9"
10+
RHCOS_10_VERSION_PREFIX: Final[str] = "Red Hat Enterprise Linux CoreOS 10"
11+
12+
13+
def node_by_rhcos_version(workers: list[Node], rhcos_version_prefix: str) -> Node:
14+
"""Return the first worker node whose OS image starts with the given RHCOS version prefix.
15+
16+
Args:
17+
workers: List of worker nodes to search.
18+
rhcos_version_prefix: Expected prefix of the node osImage field (e.g. "Red Hat Enterprise Linux CoreOS 9").
19+
20+
Returns:
21+
The first matching Node.
22+
23+
Raises:
24+
ValueError: If no worker node matches the prefix.
25+
"""
26+
for node in workers:
27+
if node.instance.status.nodeInfo.osImage.startswith(rhcos_version_prefix):
28+
return node
29+
raise ValueError(f"No worker node found with RHCOS version prefix: {rhcos_version_prefix!r}")
30+
31+
32+
def update_vm_node_selector(vm: BaseVirtualMachine, node: Node) -> None:
33+
"""Patch the VM spec to pin it to the given node via nodeSelector.
34+
35+
Args:
36+
vm: VirtualMachine to update.
37+
node: Target worker node.
38+
"""
39+
ResourceEditor(
40+
patches={vm: {"spec": {"template": {"spec": {"nodeSelector": {HOSTNAME_LABEL: node.hostname}}}}}}
41+
).update()

tests/network/libs/vm_factory.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""This module provides various virtual machine configurations with a focus on network setups."""
22

33
from kubernetes.dynamic import DynamicClient
4+
from ocp_resources.node import Node
45

56
from libs.net.udn import udn_primary_network
67
from libs.vm.affinity import new_pod_anti_affinity
78
from libs.vm.factory import base_vmspec, fedora_vm
89
from libs.vm.vm import BaseVirtualMachine
10+
from tests.network.libs.nodes import HOSTNAME_LABEL
911

1012

1113
def udn_vm(
@@ -15,12 +17,33 @@ def udn_vm(
1517
binding: str,
1618
template_labels: dict | None = None,
1719
anti_affinity_namespaces: list[str] | None = None,
20+
node: Node | None = None,
1821
) -> BaseVirtualMachine:
22+
"""Create a Fedora VM connected to a primary UDN using the specified binding.
23+
24+
When node is provided the VM is pinned to that node via nodeSelector and no
25+
anti-affinity is applied. When template_labels are provided without a node,
26+
pod anti-affinity is used for scheduling.
27+
28+
Args:
29+
namespace_name: Namespace in which the VM will be created.
30+
name: Name of the VM.
31+
client: Kubernetes dynamic client.
32+
binding: UDN binding plugin name (e.g. UDN_BINDING_DEFAULT_PLUGIN_NAME).
33+
template_labels: Optional labels to add to the VM pod template, also used as anti-affinity key.
34+
anti_affinity_namespaces: Optional namespaces to scope the pod anti-affinity rule.
35+
node: If provided, pins the VM to this node via nodeSelector (takes precedence over anti-affinity).
36+
37+
Returns:
38+
Configured BaseVirtualMachine object (not yet started).
39+
"""
1940
spec = base_vmspec()
2041
iface, network = udn_primary_network(name="udn-primary", binding=binding)
2142
spec.template.spec.domain.devices.interfaces = [iface] # type: ignore
2243
spec.template.spec.networks = [network]
23-
if template_labels:
44+
if node is not None:
45+
spec.template.spec.nodeSelector = {HOSTNAME_LABEL: node.hostname}
46+
elif template_labels:
2447
spec.template.metadata.labels = spec.template.metadata.labels or {} # type: ignore
2548
spec.template.metadata.labels.update(template_labels) # type: ignore
2649
# Use the first label key and first value as the anti-affinity label to use:

tests/network/localnet/liblocalnet.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from kubernetes.client import ApiException
88
from kubernetes.dynamic import DynamicClient
9+
from ocp_resources.node import Node
910

1011
from libs.net.cluster import ipv4_supported_cluster, ipv6_supported_cluster
1112
from libs.vm.affinity import new_pod_anti_affinity
@@ -16,6 +17,7 @@
1617
from tests.network.libs import cluster_user_defined_network as libcudn
1718
from tests.network.libs import nodenetworkconfigurationpolicy as libnncp
1819
from tests.network.libs.label_selector import LabelSelector
20+
from tests.network.libs.nodes import HOSTNAME_LABEL
1921
from utilities.constants import OVS_BRIDGE, WORKER_NODE_LABEL_KEY
2022

2123
LOCALNET_BR_EX_NETWORK = "localnet-br-ex-network"
@@ -80,6 +82,7 @@ def localnet_vm(
8082
interfaces: list[Interface],
8183
network_data: cloudinit.NetworkData | None = None,
8284
affinity: Affinity | None = None,
85+
node: Node | None = None,
8386
) -> BaseVirtualMachine:
8487
"""
8588
Create a Fedora-based Virtual Machine connected to localnet network(s).
@@ -98,8 +101,8 @@ def localnet_vm(
98101
Each Interface should have a name matching a Network, and additional configuration and state.
99102
network_data (cloudinit.NetworkData | None): Cloud-init NetworkData object containing the network
100103
configuration for the VM interfaces. If None, no network configuration is applied via cloud-init.
101-
affinity (Affinity | None): Optional Affinity object for VM scheduling. Controls the VM scheduling
102-
location. If None, no affinity constraints are applied.
104+
affinity (Affinity | None): Optional Affinity object for VM scheduling. If None, no affinity is applied.
105+
node (Node | None): If provided, pins the VM to this node via nodeSelector.
103106
104107
Returns:
105108
BaseVirtualMachine: The configured VM object ready for creation.
@@ -141,6 +144,8 @@ def localnet_vm(
141144
)
142145
vmi_spec = add_volume_disk(vmi_spec=vmi_spec, volume=volume, disk=disk)
143146

147+
if node is not None:
148+
vmi_spec.nodeSelector = {HOSTNAME_LABEL: node.hostname}
144149
if affinity is not None:
145150
vmi_spec.affinity = copy.deepcopy(affinity)
146151

0 commit comments

Comments
 (0)