From 16eee47f5e184e9e5c9afa8abe7534e9416f1caa Mon Sep 17 00:00:00 2001 From: Sergei Volkov Date: Wed, 10 Jun 2026 15:14:00 +0300 Subject: [PATCH] net, evpn: add source provider migration test (#4817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ##### What this PR does / why we need it: Add source provider migration test for EVPN. - Add VNI-aware teardown for L2 endpoints: clean up VLAN/VNI bridge mappings, allowing re-deployment with different parameters in the same test session. - Add `macAddress` field to `Interface` dataclass for explicit MAC assignment. - Add explicit MAC support to `deploy_evpn_l2_endpoint`. - Add `test_source_provider_migration`: emulates migration of an external L2 workload into the cluster as a CUDN VM, preserving its IP and MAC addresses. The old endpoint is torn down, a VM takes over the original identity, then a new L2 endpoint is deployed with different IPs to verify connectivity. ##### Which issue(s) this PR fixes: ##### Special notes for reviewer: - `test_source_provider_migration` uses `@pytest.mark.order("last")` because it tears down the shared L2 endpoint, which would break other tests if run earlier. - IPv6 static IP fix: `random_ipv6_address()` generates addresses with a random 7th hextet, but OVN IPAM caps /64 allocation to 65536 addresses. Replaced with a low-offset hardcoded address. Only affects IPs requested via `network.kubevirt.io/addresses`; external netns endpoints are unaffected. The known bug: https://redhat.atlassian.net/browse/OCPBUGS-86810 - The new L2 endpoint is created as a test step rather than a fixture. This is a known limitation — currently only one L2 endpoint can exist at a time. A follow-up will reorganise fixtures to separate VLAN lifecycle from endpoints, enabling independent concurrent endpoints. ##### jira-ticket: https://redhat.atlassian.net/browse/CNV-89431 ## Summary by CodeRabbit * **New Features** * VM interfaces can include an explicit MAC address; L2 endpoints accept and apply a provided MAC. * **Tests** * EVPN connectivity tests now perform a full source-provider migration end-to-end and run last. * IPv4/IPv6 endpoint addressing is deterministic for repeatable runs. * Tests inject endpoint MACs into VM templates and add a fixture to provision VMs attached to external L2 endpoints. * **Bug Fixes** * L2 endpoint teardown now fully cleans bridge/VNI state. Signed-off-by: Sergei Volkov --- libs/vm/spec.py | 1 + tests/network/bgp/evpn/conftest.py | 44 ++++++++++++++++--- tests/network/bgp/evpn/libevpn.py | 36 ++++++++++++--- .../bgp/evpn/test_evpn_connectivity.py | 42 ++++++++++++++++-- 4 files changed, 108 insertions(+), 15 deletions(-) diff --git a/libs/vm/spec.py b/libs/vm/spec.py index 75f5f9a973..c11a21a9d5 100644 --- a/libs/vm/spec.py +++ b/libs/vm/spec.py @@ -77,6 +77,7 @@ class Interface: passtBinding: dict[Any, Any] | None = None # noqa: N815 binding: NetBinding | None = None state: str | None = None + macAddress: str | None = None # noqa: N815 @dataclass diff --git a/tests/network/bgp/evpn/conftest.py b/tests/network/bgp/evpn/conftest.py index 250aee4daf..35bb94a9e2 100644 --- a/tests/network/bgp/evpn/conftest.py +++ b/tests/network/bgp/evpn/conftest.py @@ -11,11 +11,16 @@ from ocp_resources.resource import ResourceEditor from ocp_resources.vtep import VTEP -from libs.net.ip import random_ipv4_address, random_ipv6_address +from libs.net.ip import random_ipv4_address from libs.net.traffic_generator import TcpServer -from libs.net.udn import UDN_BINDING_DEFAULT_PLUGIN_NAME, create_udn_namespace +from libs.net.udn import UDN_BINDING_DEFAULT_PLUGIN_NAME, create_udn_namespace, udn_primary_network +from libs.vm.affinity import new_pod_anti_affinity +from libs.vm.factory import base_vmspec, fedora_vm +from libs.vm.spec import Devices, Metadata from libs.vm.vm import BaseVirtualMachine from tests.network.bgp.evpn.libevpn import ( + CUDN_EVPN_SUBNET_IPV6, + EVPN_CUDN_NET_SEED, EndpointTcpClient, EvpnEndpoint, cudn_evpn_subnets, @@ -41,8 +46,9 @@ EVPN_ADVERTISE_LABEL: Final[dict] = {"advertise": "evpn"} APP_EVPN_CUDN_LABEL: Final[dict] = {**EVPN_ADVERTISE_LABEL, "app": "cudn-evpn"} CUDN_EVPN_BGP_LABEL: Final[dict] = {"cudn-bgp": "evpn"} -EXTERNAL_L2_ENDPOINT_IPV4: Final[str] = f"{random_ipv4_address(net_seed=5, host_address=250)}/24" -EXTERNAL_L2_ENDPOINT_IPV6: Final[str] = f"{random_ipv6_address(net_seed=5, host_address=250)}/64" +EXTERNAL_L2_ENDPOINT_IPV4: Final[str] = f"{random_ipv4_address(net_seed=EVPN_CUDN_NET_SEED, host_address=250)}/24" +EXTERNAL_L2_ENDPOINT_IPV6: Final[str] = f"{ipaddress.ip_network(CUDN_EVPN_SUBNET_IPV6, strict=False)[250]}/64" +EXTERNAL_L2_ENDPOINT_MAC: Final[str] = "02:00:05:00:fa:00" EXTERNAL_L3_ENDPOINT_IPV4: Final[str] = "192.168.100.100/24" EXTERNAL_L3_ENDPOINT_IPV6: Final[str] = "fd01:1234:5678::64/64" EXTERNAL_L3_GATEWAY_IPV4: Final[str] = "192.168.100.1/24" @@ -233,9 +239,10 @@ def external_l2_endpoint( pod=frr_external_pod.pod, vni=EVPN_MAC_VRF_VNI, endpoint_ips=[EXTERNAL_L2_ENDPOINT_IPV4, EXTERNAL_L2_ENDPOINT_IPV6], + mac_address=EXTERNAL_L2_ENDPOINT_MAC, ) yield endpoint - teardown_evpn_l2_endpoint(pod=frr_external_pod.pod) + teardown_evpn_l2_endpoint(pod=frr_external_pod.pod, vni=EVPN_MAC_VRF_VNI) @pytest.fixture(scope="module") @@ -269,3 +276,30 @@ def evpn_routed_l3_active_connections( ) -> Generator[list[tuple[EndpointTcpClient, TcpServer]]]: with evpn_workloads_active_connections(endpoint=external_l3_endpoint, vm=vm_evpn_target) as connections: yield connections + + +@pytest.fixture() +def vm_source_provider( + external_l2_endpoint: EvpnEndpoint, + namespace_evpn: Namespace, + cudn_evpn_layer2: libcudn.ClusterUserDefinedNetwork, + admin_client: DynamicClient, + frr_external_pod: ExternalFrrPodInfo, +) -> Generator[BaseVirtualMachine]: + spec = base_vmspec() + network_name = "udn-network" + iface, network = udn_primary_network(name=network_name, binding=UDN_BINDING_DEFAULT_PLUGIN_NAME) + iface.macAddress = external_l2_endpoint.mac_address + spec.template.spec.domain.devices = Devices(interfaces=[iface]) + spec.template.spec.networks = [network] + spec.template.metadata = Metadata( + labels=EXTERNAL_FRR_POD_LABEL, + annotations={"network.kubevirt.io/addresses": json.dumps({network_name: external_l2_endpoint.ip_addresses})}, + ) + label, *_ = EXTERNAL_FRR_POD_LABEL.items() + spec.template.spec.affinity = new_pod_anti_affinity( + label=label, namespaces=[frr_external_pod.pod.namespace, namespace_evpn.name] + ) + + with fedora_vm(namespace=namespace_evpn.name, name="vm-source-provider", client=admin_client, spec=spec) as vm: + yield vm diff --git a/tests/network/bgp/evpn/libevpn.py b/tests/network/bgp/evpn/libevpn.py index 709d3258f0..dab2bbdb9c 100644 --- a/tests/network/bgp/evpn/libevpn.py +++ b/tests/network/bgp/evpn/libevpn.py @@ -24,8 +24,9 @@ LOGGER = logging.getLogger(__name__) -CUDN_EVPN_SUBNET_IPV4: str = f"{random_ipv4_address(net_seed=5, host_address=0)}/24" -CUDN_EVPN_SUBNET_IPV6: str = f"{random_ipv6_address(net_seed=5, host_address=0)}/64" +EVPN_CUDN_NET_SEED: int = 5 +CUDN_EVPN_SUBNET_IPV4: str = f"{random_ipv4_address(net_seed=EVPN_CUDN_NET_SEED, host_address=0)}/24" +CUDN_EVPN_SUBNET_IPV6: str = f"{random_ipv6_address(net_seed=EVPN_CUDN_NET_SEED, host_address=0)}/64" _BRIDGE_NAME: str = "br0" _VXLAN_NAME: str = "vxlan0" @@ -51,6 +52,7 @@ class EvpnEndpoint: pod: Pod ip_addresses: list[str] netns_name: str + mac_address: str | None = None class EndpointTcpClient(PodTcpClient): @@ -143,6 +145,7 @@ def deploy_evpn_l2_endpoint( pod: Pod, vni: int, endpoint_ips: list[str], + mac_address: str | None = None, ) -> EvpnEndpoint: """Creates a stretched L2 endpoint on the shared SVD bridge. @@ -155,32 +158,46 @@ def deploy_evpn_l2_endpoint( pod: The FRR pod hosting the endpoint. vni: MAC-VRF VNI (must match CUDN's macVRF VNI). endpoint_ips: IPs with prefix length (e.g. ["10.0.5.250/24", "fd00::fa/64"]). + mac_address: Explicit MAC for the endpoint interface (locally-administered). Returns: EvpnEndpoint. """ - commands = _build_l2_endpoint_commands(vni=vni, endpoint_ips=endpoint_ips) + commands = _build_l2_endpoint_commands(vni=vni, endpoint_ips=endpoint_ips, mac_address=mac_address) for command in commands: pod.execute(command=shlex.split(command), container=NET_TOOLS_CONTAINER_NAME) bare_ips = [ip.split("/")[0] for ip in endpoint_ips] LOGGER.info(f"EVPN L2 endpoint deployed: {bare_ips} in namespace {_L2_ENDPOINT_NETNS}") - return EvpnEndpoint(pod=pod, ip_addresses=bare_ips, netns_name=_L2_ENDPOINT_NETNS) + return EvpnEndpoint(pod=pod, ip_addresses=bare_ips, netns_name=_L2_ENDPOINT_NETNS, mac_address=mac_address) -def teardown_evpn_l2_endpoint(pod: Pod) -> None: - """Removes the EVPN L2 endpoint (netns, veth) from the FRR pod.""" +def teardown_evpn_l2_endpoint(pod: Pod, vni: int) -> None: + """Removes the EVPN L2 endpoint (netns, veth, VLAN/VNI mappings) from the FRR pod. + + Args: + pod: The FRR pod hosting the endpoint. + vni: MAC-VRF VNI used during deployment. + """ for cmd in [ f"ip netns delete {_L2_ENDPOINT_NETNS}", f"ip link delete {_L2_VETH_POD_SIDE}", + f"bridge vlan del dev {_VXLAN_NAME} vid {_L2_VID} tunnel_info id {vni}", + f"bridge vni del dev {_VXLAN_NAME} vni {vni}", + f"bridge vlan del dev {_VXLAN_NAME} vid {_L2_VID}", + f"bridge vlan del dev {_BRIDGE_NAME} vid {_L2_VID} self", ]: pod.execute(command=shlex.split(cmd), container=NET_TOOLS_CONTAINER_NAME, ignore_rc=True) LOGGER.info(f"EVPN L2 endpoint removed: namespace={_L2_ENDPOINT_NETNS}") -def _build_l2_endpoint_commands(vni: int, endpoint_ips: list[str]) -> list[str]: +def _build_l2_endpoint_commands( + vni: int, + endpoint_ips: list[str], + mac_address: str | None = None, +) -> list[str]: return [ f"bridge vlan add dev {_BRIDGE_NAME} vid {_L2_VID} self", f"bridge vlan add dev {_VXLAN_NAME} vid {_L2_VID}", @@ -193,6 +210,11 @@ def _build_l2_endpoint_commands(vni: int, endpoint_ips: list[str]) -> list[str]: f"ip netns add {_L2_ENDPOINT_NETNS}", f"ip link set {_L2_VETH_EP_SIDE} netns {_L2_ENDPOINT_NETNS}", *(f"ip netns exec {_L2_ENDPOINT_NETNS} ip addr add {ip} dev {_L2_VETH_EP_SIDE}" for ip in endpoint_ips), + *( + [f"ip netns exec {_L2_ENDPOINT_NETNS} ip link set dev {_L2_VETH_EP_SIDE} address {mac_address}"] + if mac_address + else [] + ), f"ip netns exec {_L2_ENDPOINT_NETNS} ip link set {_L2_VETH_EP_SIDE} up", f"ip netns exec {_L2_ENDPOINT_NETNS} ip link set lo up", ] diff --git a/tests/network/bgp/evpn/test_evpn_connectivity.py b/tests/network/bgp/evpn/test_evpn_connectivity.py index 282a6ae7f8..b8f92238a6 100644 --- a/tests/network/bgp/evpn/test_evpn_connectivity.py +++ b/tests/network/bgp/evpn/test_evpn_connectivity.py @@ -20,11 +20,21 @@ import pytest +from libs.net.ip import random_ipv4_address, random_ipv6_address from libs.net.traffic_generator import active_tcp_connections, is_tcp_connection from libs.net.vmspec import lookup_primary_network -from tests.network.bgp.evpn.libevpn import assert_evpn_workloads_connectivity, evpn_workloads_active_connections +from tests.network.bgp.evpn.libevpn import ( + EVPN_CUDN_NET_SEED, + assert_evpn_workloads_connectivity, + deploy_evpn_l2_endpoint, + evpn_workloads_active_connections, + teardown_evpn_l2_endpoint, +) from utilities.virt import migrate_vm_and_verify +_L2_ENDPOINT_IPV4: str = f"{random_ipv4_address(net_seed=EVPN_CUDN_NET_SEED, host_address=249)}/24" +_L2_ENDPOINT_IPV6: str = f"{random_ipv6_address(net_seed=EVPN_CUDN_NET_SEED, host_address=249)}/64" + pytestmark = [ pytest.mark.bgp, pytest.mark.ipv4, @@ -175,7 +185,15 @@ def test_connectivity_after_udn_vm_cold_reboot( @pytest.mark.polarion("CNV-15233") -def test_source_provider_migration(): +@pytest.mark.order("last") +def test_source_provider_migration( + external_l3_endpoint, + cudn_evpn_layer2, + vm_source_provider, + vm_evpn_target, + frr_external_pod, + subtests, +): """ Scenario emulates a migration of an external workload (Source Provider) into the OCP cluster as a CUDN VM, while preserving its IP and MAC addresses, and maintaining connectivity. @@ -184,6 +202,7 @@ def test_source_provider_migration(): - External Source Provider L2 and L3 endpoints. - Running connectivity reference VM with a primary EVPN-enabled CUDN. - TCP connectivity exists between the connectivity reference VM and the external L2 and L3 endpoints. + Precondition is verified in preceding tests. Steps: 1. Shut down/remove the external L2 endpoint. @@ -193,6 +212,23 @@ def test_source_provider_migration(): Expected: - New connections are established after new UDN VM deployment. """ + mac_vrf_vni = cudn_evpn_layer2.instance.spec.network.evpn.macVRF.vni + + teardown_evpn_l2_endpoint(pod=frr_external_pod.pod, vni=mac_vrf_vni) + vm_source_provider.start(wait=True) + vm_source_provider.wait_for_agent_connected() -test_source_provider_migration.__test__ = False + new_l2_endpoint = deploy_evpn_l2_endpoint( + pod=frr_external_pod.pod, + vni=mac_vrf_vni, + endpoint_ips=[_L2_ENDPOINT_IPV4, _L2_ENDPOINT_IPV6], + ) + + assert_evpn_workloads_connectivity( + target_vm=vm_evpn_target, + ref_vm=vm_source_provider, + l2_endpoint=new_l2_endpoint, + l3_endpoint=external_l3_endpoint, + subtests=subtests, + )