diff --git a/libs/vm/spec.py b/libs/vm/spec.py index 24fd13fdbe..e20b5f9ff4 100644 --- a/libs/vm/spec.py +++ b/libs/vm/spec.py @@ -31,6 +31,7 @@ class VMISpec: volumes: list[Volume] | None = None terminationGracePeriodSeconds: int | None = None # noqa: N815 affinity: Affinity | None = None + nodeSelector: dict[str, str] | None = None # noqa: N815 @dataclass diff --git a/pytest.ini b/pytest.ini index c19714291c..f991324bad 100644 --- a/pytest.ini +++ b/pytest.ini @@ -72,6 +72,7 @@ markers = rwx_default_storage: Tests that require RWX storage descheduler: Tests that require kube-descheduler on nodes remote_cluster: Tests that require a remote cluster + mixed_os_nodes: Tests that require a dual-stream cluster with both RHCOS 9 and RHCOS 10 worker nodes ## Required operators mtv: Tests that require the MTV operator to be installed diff --git a/tests/network/conftest.py b/tests/network/conftest.py index dd5617e262..762047d751 100644 --- a/tests/network/conftest.py +++ b/tests/network/conftest.py @@ -15,6 +15,11 @@ from timeout_sampler import TimeoutExpiredError from libs.net.cluster import ipv4_supported_cluster, ipv6_supported_cluster +from tests.network.libs.nodes import ( + RHCOS_9_VERSION_PREFIX, + RHCOS_10_VERSION_PREFIX, + node_by_rhcos_version, +) from tests.network.utils import get_vlan_index_number from utilities.constants import ( CLUSTER, @@ -321,3 +326,13 @@ def _verify_mtv_installed(): message="Network cluster verification failed", admin_client=admin_client, ) + + +@pytest.fixture(scope="module") +def rhcos9_node(workers): + return node_by_rhcos_version(workers=workers, rhcos_version_prefix=RHCOS_9_VERSION_PREFIX) + + +@pytest.fixture(scope="module") +def rhcos10_node(workers): + return node_by_rhcos_version(workers=workers, rhcos_version_prefix=RHCOS_10_VERSION_PREFIX) diff --git a/tests/network/libs/nodes.py b/tests/network/libs/nodes.py new file mode 100644 index 0000000000..3064d484be --- /dev/null +++ b/tests/network/libs/nodes.py @@ -0,0 +1,41 @@ +from typing import Final + +from ocp_resources.node import Node +from ocp_resources.resource import ResourceEditor + +from libs.vm.vm import BaseVirtualMachine + +HOSTNAME_LABEL: Final[str] = "kubernetes.io/hostname" +RHCOS_9_VERSION_PREFIX: Final[str] = "Red Hat Enterprise Linux CoreOS 9" +RHCOS_10_VERSION_PREFIX: Final[str] = "Red Hat Enterprise Linux CoreOS 10" + + +def node_by_rhcos_version(workers: list[Node], rhcos_version_prefix: str) -> Node: + """Return the first worker node whose OS image starts with the given RHCOS version prefix. + + Args: + workers: List of worker nodes to search. + rhcos_version_prefix: Expected prefix of the node osImage field (e.g. "Red Hat Enterprise Linux CoreOS 9"). + + Returns: + The first matching Node. + + Raises: + ValueError: If no worker node matches the prefix. + """ + for node in workers: + if node.instance.status.nodeInfo.osImage.startswith(rhcos_version_prefix): + return node + raise ValueError(f"No worker node found with RHCOS version prefix: {rhcos_version_prefix!r}") + + +def update_vm_node_selector(vm: BaseVirtualMachine, node: Node) -> None: + """Patch the VM spec to pin it to the given node via nodeSelector. + + Args: + vm: VirtualMachine to update. + node: Target worker node. + """ + ResourceEditor( + patches={vm: {"spec": {"template": {"spec": {"nodeSelector": {HOSTNAME_LABEL: node.hostname}}}}}} + ).update() diff --git a/tests/network/libs/vm_factory.py b/tests/network/libs/vm_factory.py index b4e12b23d7..c5d6aef2e3 100644 --- a/tests/network/libs/vm_factory.py +++ b/tests/network/libs/vm_factory.py @@ -1,11 +1,13 @@ """This module provides various virtual machine configurations with a focus on network setups.""" from kubernetes.dynamic import DynamicClient +from ocp_resources.node import Node from libs.net.udn import udn_primary_network from libs.vm.affinity import new_pod_anti_affinity from libs.vm.factory import base_vmspec, fedora_vm from libs.vm.vm import BaseVirtualMachine +from tests.network.libs.nodes import HOSTNAME_LABEL def udn_vm( @@ -15,12 +17,33 @@ def udn_vm( binding: str, template_labels: dict | None = None, anti_affinity_namespaces: list[str] | None = None, + node: Node | None = None, ) -> BaseVirtualMachine: + """Create a Fedora VM connected to a primary UDN using the specified binding. + + When node is provided the VM is pinned to that node via nodeSelector and no + anti-affinity is applied. When template_labels are provided without a node, + pod anti-affinity is used for scheduling. + + Args: + namespace_name: Namespace in which the VM will be created. + name: Name of the VM. + client: Kubernetes dynamic client. + binding: UDN binding plugin name (e.g. UDN_BINDING_DEFAULT_PLUGIN_NAME). + template_labels: Optional labels to add to the VM pod template, also used as anti-affinity key. + anti_affinity_namespaces: Optional namespaces to scope the pod anti-affinity rule. + node: If provided, pins the VM to this node via nodeSelector (takes precedence over anti-affinity). + + Returns: + Configured BaseVirtualMachine object (not yet started). + """ spec = base_vmspec() iface, network = udn_primary_network(name="udn-primary", binding=binding) spec.template.spec.domain.devices.interfaces = [iface] # type: ignore spec.template.spec.networks = [network] - if template_labels: + if node is not None: + spec.template.spec.nodeSelector = {HOSTNAME_LABEL: node.hostname} + elif template_labels: spec.template.metadata.labels = spec.template.metadata.labels or {} # type: ignore spec.template.metadata.labels.update(template_labels) # type: ignore # Use the first label key and first value as the anti-affinity label to use: diff --git a/tests/network/user_defined_network/rhel9_rhel10_cluster/conftest.py b/tests/network/user_defined_network/rhel9_rhel10_cluster/conftest.py new file mode 100644 index 0000000000..06ac12f7d9 --- /dev/null +++ b/tests/network/user_defined_network/rhel9_rhel10_cluster/conftest.py @@ -0,0 +1,83 @@ +from collections.abc import Generator + +import pytest +from kubernetes.dynamic import DynamicClient +from ocp_resources.namespace import Namespace +from ocp_resources.node import Node +from ocp_resources.user_defined_network import Layer2UserDefinedNetwork + +from libs.net.ip import filter_link_local_addresses +from libs.net.traffic_generator import active_tcp_connections +from libs.net.udn import UDN_BINDING_DEFAULT_PLUGIN_NAME +from libs.net.vmspec import lookup_iface_status +from libs.vm.vm import BaseVirtualMachine +from tests.network.libs.vm_factory import udn_vm + +_UDN_PRIMARY_IFACE_NAME = "udn-primary" + + +@pytest.fixture(scope="module") +def udn_server_vm( + admin_client: DynamicClient, + udn_namespace: Namespace, + namespaced_layer2_user_defined_network: Layer2UserDefinedNetwork, + rhcos9_node: Node, +) -> Generator[BaseVirtualMachine]: + with udn_vm( + namespace_name=udn_namespace.name, + name="server-vm", + client=admin_client, + binding=UDN_BINDING_DEFAULT_PLUGIN_NAME, + node=rhcos9_node, + ) as vm: + vm.start(wait=True) + vm.wait_for_agent_connected() + lookup_iface_status( + vm=vm, + iface_name=_UDN_PRIMARY_IFACE_NAME, + predicate=lambda iface: ( + "guest-agent" in iface["infoSource"] + and bool(filter_link_local_addresses(ip_addresses=iface.get("ipAddresses", []))) + ), + ) + yield vm + + +@pytest.fixture(scope="module") +def udn_client_vm( + admin_client: DynamicClient, + udn_namespace: Namespace, + namespaced_layer2_user_defined_network: Layer2UserDefinedNetwork, + rhcos9_node: Node, +) -> Generator[BaseVirtualMachine]: + with udn_vm( + namespace_name=udn_namespace.name, + name="client-vm", + client=admin_client, + binding=UDN_BINDING_DEFAULT_PLUGIN_NAME, + node=rhcos9_node, + ) as vm: + vm.start(wait=True) + vm.wait_for_agent_connected() + lookup_iface_status( + vm=vm, + iface_name=_UDN_PRIMARY_IFACE_NAME, + predicate=lambda iface: ( + "guest-agent" in iface["infoSource"] + and bool(filter_link_local_addresses(ip_addresses=iface.get("ipAddresses", []))) + ), + ) + yield vm + + +@pytest.fixture(scope="module") +def udn_active_tcp_connection( + udn_client_vm: BaseVirtualMachine, + udn_server_vm: BaseVirtualMachine, +) -> Generator: + with active_tcp_connections( + client_vm=udn_client_vm, + server_vm=udn_server_vm, + iface_name=_UDN_PRIMARY_IFACE_NAME, + ) as connections: + yield connections diff --git a/tests/network/user_defined_network/rhel9_rhel10_cluster/test_connectivity.py b/tests/network/user_defined_network/rhel9_rhel10_cluster/test_connectivity.py index fb75b56a1f..26d7ce27db 100644 --- a/tests/network/user_defined_network/rhel9_rhel10_cluster/test_connectivity.py +++ b/tests/network/user_defined_network/rhel9_rhel10_cluster/test_connectivity.py @@ -10,9 +10,15 @@ import pytest -__test__ = False +from libs.net.traffic_generator import is_tcp_connection +from tests.network.libs.nodes import update_vm_node_selector +from utilities.virt import migrate_vm_and_verify +@pytest.mark.mixed_os_nodes +@pytest.mark.ipv4 +# Incremental: the second test depends on the server VM already being on RHCOS 10 +# as a side effect of the first test's migration. @pytest.mark.incremental class TestConnectivity: """ @@ -23,7 +29,12 @@ class TestConnectivity: """ @pytest.mark.polarion("CNV-15952") - def test_connectivity_preserved_during_server_migration_to_rhcos10(self): + def test_connectivity_preserved_during_server_migration_to_rhcos10( + self, + udn_server_vm, + rhcos10_node, + udn_active_tcp_connection, + ): """ Test that an active TCP connection over a primary UDN is preserved when the server VM migrates from an RHCOS 9 node to an RHCOS 10 node. @@ -39,9 +50,20 @@ def test_connectivity_preserved_during_server_migration_to_rhcos10(self): Expected: - The active TCP connection from the client VM to the server VM is preserved during the migration """ + update_vm_node_selector(vm=udn_server_vm, node=rhcos10_node) + migrate_vm_and_verify(vm=udn_server_vm) + for client, server in udn_active_tcp_connection: + assert is_tcp_connection(server=server, client=client), ( + f"TCP connection lost after migrating {udn_server_vm.name} to RHCOS 10 node" + ) @pytest.mark.polarion("CNV-15965") - def test_connectivity_preserved_during_server_migration_to_rhcos9(self): + def test_connectivity_preserved_during_server_migration_to_rhcos9( + self, + udn_server_vm, + rhcos9_node, + udn_active_tcp_connection, + ): """ Test that an active TCP connection over a primary UDN is preserved when the server VM migrates from an RHCOS 10 node to an RHCOS 9 node. @@ -57,3 +79,9 @@ def test_connectivity_preserved_during_server_migration_to_rhcos9(self): Expected: - The active TCP connection from the client VM to the server VM is preserved during the migration """ + update_vm_node_selector(vm=udn_server_vm, node=rhcos9_node) + migrate_vm_and_verify(vm=udn_server_vm) + for client, server in udn_active_tcp_connection: + assert is_tcp_connection(server=server, client=client), ( + f"TCP connection lost after migrating {udn_server_vm.name} back to RHCOS 9 node" + )