Skip to content

Commit 53dbdca

Browse files
committed
Add few bond tests
- add some tests for Bond - add new abstractions (Network and Bond) with fixtures Signed-off-by: Sebastien Marie <semarie@kapouay.eu.org>
1 parent 4f858ff commit 53dbdca

File tree

7 files changed

+338
-2
lines changed

7 files changed

+338
-2
lines changed

jobs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@
468468
BROKEN_TESTS = [
469469
# not really broken but has complex prerequisites (3 NICs on 3 different networks)
470470
"tests/migration/test_host_evacuate.py::TestHostEvacuateWithNetwork",
471+
# not really broken but has complex prerequisites (3 NICs)
472+
"tests/network/test_bond.py::test_bond",
471473
# running quicktest on zfsvol generates dangling TAP devices that are hard to
472474
# cleanup. Bug needs to be fixed before enabling quicktest on zfsvol.
473475
"tests/storage/zfsvol/test_zfsvol_sr.py::TestZfsvolVm::test_quicktest",

lib/bond.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from lib.common import _param_add, _param_clear, _param_get, _param_remove, _param_set, safe_split
6+
from lib.host import Host
7+
from lib.network import Network
8+
from lib.pif import PIF
9+
10+
from typing import Optional
11+
12+
class Bond:
13+
xe_prefix = "bond"
14+
15+
def __init__(self, host: Host, uuid: str):
16+
self.host = host
17+
self.uuid = uuid
18+
19+
def param_get(self, param_name, key=None, accept_unknown_key=False):
20+
return _param_get(self.host, Bond.xe_prefix, self.uuid, param_name, key, accept_unknown_key)
21+
22+
def param_set(self, param_name, value, key=None):
23+
_param_set(self.host, Bond.xe_prefix, self.uuid, param_name, value, key)
24+
25+
def param_add(self, param_name, value, key=None):
26+
_param_add(self.host, Bond.xe_prefix, self.uuid, param_name, value, key)
27+
28+
def param_clear(self, param_name):
29+
_param_clear(self.host, Bond.xe_prefix, self.uuid, param_name)
30+
31+
def param_remove(self, param_name, key, accept_unknown_key=False):
32+
_param_remove(self.host, Bond.xe_prefix, self.uuid, param_name, key, accept_unknown_key)
33+
34+
@staticmethod
35+
def create(host: Host, network: Network, pifs: list[PIF], mode: Optional[str] = None) -> Bond:
36+
args: dict[str, str | bool] = {
37+
'network-uuid': network.uuid,
38+
'pif-uuids': ','.join([pif.uuid for pif in pifs]),
39+
}
40+
41+
if mode is not None:
42+
args['mode'] = mode
43+
44+
uuid = host.xe("bond-create", args, minimal=True)
45+
logging.info(f"New Bond: {uuid}")
46+
47+
return Bond(host, uuid)
48+
49+
def destroy(self):
50+
logging.info(f"Destroying bond: {self.uuid}")
51+
self.host.xe('bond-destroy', {'uuid': self.uuid})
52+
53+
def master(self) -> PIF:
54+
uuid = self.param_get('master')
55+
assert uuid is not None, "no master on Bond"
56+
return PIF(uuid, self.host)
57+
58+
def slaves(self) -> list[str]:
59+
return safe_split(self.param_get('slaves'))
60+
61+
def mode(self) -> Optional[str]:
62+
return self.param_get("mode")

lib/host.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,3 +767,13 @@ def lvs(self, vgName: Optional[str] = None, ignore_MGT: bool = True) -> List[str
767767
continue
768768
ret.append(line.strip())
769769
return ret
770+
771+
def PIFs(self, device: Optional[str] = None) -> list[pif.PIF]:
772+
args: Dict[str, str | bool] = {
773+
"host-uuid": self.uuid,
774+
}
775+
776+
if device is not None:
777+
args["device"] = device
778+
779+
return [pif.PIF(uuid, self) for uuid in safe_split(self.xe("pif-list", args, minimal=True))]

lib/network.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from lib.common import _param_add, _param_clear, _param_get, _param_remove, _param_set, safe_split
6+
from lib.host import Host
7+
8+
from typing import Optional
9+
10+
class Network:
11+
xe_prefix = "network"
12+
13+
def __init__(self, host: Host, uuid: str):
14+
self.host = host
15+
self.uuid = uuid
16+
17+
def param_get(self, param_name, key=None, accept_unknown_key=False):
18+
return _param_get(self.host, Network.xe_prefix, self.uuid, param_name, key, accept_unknown_key)
19+
20+
def param_set(self, param_name, value, key=None):
21+
_param_set(self.host, Network.xe_prefix, self.uuid, param_name, value, key)
22+
23+
def param_add(self, param_name, value, key=None):
24+
_param_add(self.host, Network.xe_prefix, self.uuid, param_name, value, key)
25+
26+
def param_clear(self, param_name):
27+
_param_clear(self.host, Network.xe_prefix, self.uuid, param_name)
28+
29+
def param_remove(self, param_name, key, accept_unknown_key=False):
30+
_param_remove(self.host, Network.xe_prefix, self.uuid, param_name, key, accept_unknown_key)
31+
32+
@staticmethod
33+
def create(host: Host, label: str, description: Optional[str] = None) -> Network:
34+
args: dict[str, str | bool] = {
35+
'name-label': label,
36+
}
37+
38+
if description is not None:
39+
args['name-description'] = description
40+
41+
logging.info(f"Creating network '{label}'")
42+
uuid = host.xe("network-create", args, minimal=True)
43+
logging.info(f"New Network: {uuid}")
44+
45+
return Network(host, uuid)
46+
47+
def destroy(self):
48+
logging.info(f"Destroying network '{self.param_get('name-label')}': {self.uuid}")
49+
self.host.xe('network-destroy', {'uuid': self.uuid})
50+
51+
def PIF_uuids(self) -> list[str]:
52+
return safe_split(self.param_get('PIF-uuids'), '; ')
53+
54+
def VIF_uuids(self) -> list[str]:
55+
return safe_split(self.param_get('VIF-uuids'), '; ')
56+
57+
def is_private(self) -> bool:
58+
return len(self.PIF_uuids()) == 0
59+
60+
def managed(self) -> bool:
61+
return self.param_get('managed') == 'true'
62+
63+
def MTU(self) -> int:
64+
return int(self.param_get('MTU') or '0')

lib/pif.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
from __future__ import annotations
2+
13
from lib.common import _param_add, _param_clear, _param_get, _param_remove, _param_set
24

5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from lib.host import Host
9+
310
class PIF:
411
xe_prefix = "pif"
512

6-
def __init__(self, uuid, host):
13+
def __init__(self, uuid: str, host: Host):
714
self.uuid = uuid
815
self.host = host
916

@@ -26,3 +33,32 @@ def param_add(self, param_name, value, key=None):
2633
def param_clear(self, param_name):
2734
_param_clear(self.host, self.xe_prefix, self.uuid,
2835
param_name)
36+
37+
def is_managed(self) -> bool:
38+
return self.param_get("managed") == "true"
39+
40+
def is_physical(self) -> bool:
41+
return self.param_get("physical") == "true"
42+
43+
def is_currently_attached(self) -> bool:
44+
return self.param_get("currently-attached") == "true"
45+
46+
def is_management(self) -> bool:
47+
return self.param_get("management") == "true"
48+
49+
def network_uuid(self) -> str:
50+
uuid = self.param_get("network-uuid")
51+
assert uuid is not None, "unexpected PIF without network-uuid"
52+
return uuid
53+
54+
def reconfigure_ip(self, mode: str) -> None:
55+
self.host.xe("pif-reconfigure-ip", {
56+
"uuid": self.uuid,
57+
"mode": mode,
58+
})
59+
60+
def reconfigure_ipv6(self, mode: str) -> None:
61+
self.host.xe("pif-reconfigure-ipv6", {
62+
"uuid": self.uuid,
63+
"mode": mode,
64+
})

tests/network/conftest.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
import pytest
22

3+
from lib.host import Host
4+
from lib.network import Network
5+
6+
from typing import Generator
7+
38
@pytest.fixture(scope='package')
4-
def host_no_sdn_controller(host):
9+
def host_no_sdn_controller(host: Host):
510
""" An XCP-ng with no SDN controller. """
611
if host.xe('sdn-controller-list', minimal=True):
712
pytest.skip("This test requires an XCP-ng with no SDN controller")
13+
14+
15+
@pytest.fixture(scope='module')
16+
def empty_network(host: Host) -> Generator[Network, None, None]:
17+
try:
18+
net = Network.create(host, label="empty_network for tests")
19+
yield net
20+
finally:
21+
net.destroy()

tests/network/test_bond.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import pytest
2+
3+
import logging
4+
from contextlib import contextmanager
5+
6+
from lib.bond import Bond
7+
from lib.host import Host
8+
from lib.network import Network
9+
from lib.vm import VM
10+
11+
# Requirements:
12+
# From --hosts parameter:
13+
# - host(A1): first XCP-ng host, with at least 3 NICs (1 management, 2 unused), with tcpdump
14+
# From --vm parameter
15+
# - A VM to import (alpine expected)
16+
17+
@contextmanager
18+
def managed_bond(host: Host, network: Network, devices: list[str], mode: str):
19+
pifs = []
20+
for name in devices:
21+
[pif] = host.PIFs(device=name)
22+
pifs.append(pif)
23+
24+
bond = Bond.create(host, network, pifs, mode=mode)
25+
try:
26+
yield bond
27+
finally:
28+
bond.destroy()
29+
30+
@pytest.mark.small_vm
31+
class TestNetwork:
32+
@pytest.mark.no_vm
33+
def test_basic(self, host: Host, empty_network: Network):
34+
assert len(empty_network.PIF_uuids()) == 0, "unexpected number of PIFs in network"
35+
assert len(empty_network.VIF_uuids()) == 0, "unexpected number of VIFs in network"
36+
assert empty_network.is_private(), "empty_network isn't private"
37+
assert empty_network.MTU() == 1500, "unexpected MTU"
38+
39+
def test_private_network(self, host: Host, empty_network: Network, imported_vm: VM):
40+
network = empty_network
41+
42+
try:
43+
vm1 = imported_vm.clone()
44+
vm2 = imported_vm.clone()
45+
46+
vif_1_1 = vm1.create_vif(1, network_uuid=network.uuid)
47+
vif_2_1 = vm2.create_vif(1, network_uuid=network.uuid)
48+
49+
assert len(vm1.vifs()) == 2, "VM1 should have 2 NICs"
50+
assert len(vm2.vifs()) == 2, "VM2 should have 2 NICs"
51+
assert len(network.VIF_uuids()) == 2, "unexpected number of VIFs in network"
52+
53+
vm1.start()
54+
vm2.start()
55+
56+
vm1.wait_for_vm_running_and_ssh_up()
57+
vm2.wait_for_vm_running_and_ssh_up()
58+
59+
logging.info("Configuring local address on private network")
60+
vm1.ssh(f"ifconfig eth{vif_1_1.param_get('device')} inet 169.254.1.1 broadcast 169.254.0.0 up")
61+
vm2.ssh(f"ifconfig eth{vif_2_1.param_get('device')} inet 169.254.2.1 broadcast 169.254.0.0 up")
62+
63+
logging.info("Ping VMs")
64+
assert vm1.ssh_with_result(["ping", "-c3", "-w5", "169.254.2.1"]).returncode == 0
65+
assert vm2.ssh_with_result(["ping", "-c3", "-w5", "169.254.1.1"]).returncode == 0
66+
67+
finally:
68+
# VIFs are destroyed by VM.destroy()
69+
vm2.destroy()
70+
vm1.destroy()
71+
72+
def _wait_for_packet(host: Host | VM, interface: str, sfilter: str, timeout: int = 30) -> None:
73+
ret = host.ssh_with_result(f"timeout {timeout} tcpdump -i {interface} -n -c1 '{sfilter}'")
74+
if ret.returncode != 0:
75+
pytest.fail(f"tcpdump error: code={ret.returncode} stdout={ret.stdout}")
76+
return None
77+
78+
@pytest.mark.complex_prerequisites
79+
@pytest.mark.small_vm
80+
@pytest.mark.parametrize("mode", ["lacp", "active-backup", "balance-slb"])
81+
def test_bond(host: Host, empty_network: Network, imported_vm: VM, mode):
82+
network = empty_network
83+
assert len(network.PIF_uuids()) == 0
84+
85+
assert host.ssh_with_result("which tcpdump").returncode == 0, \
86+
"host should have tcpdump installed"
87+
88+
# expect host with eth1 and eth2 NICs free for use
89+
with managed_bond(host, network, ["eth1", "eth2"], mode) as bond:
90+
logging.info(f"Bond = {bond.uuid} mode={bond.mode()} slaves={bond.slaves()}")
91+
92+
# disable LACP fallback (make Bond to require LACP negociation first)
93+
if mode == "lacp":
94+
bond.param_set("properties", key="lacp-fallback-ab", value=False)
95+
96+
# check bond0 on the host
97+
ret = host.ssh_with_result("ovs-appctl bond/show bond0")
98+
if ret.returncode != 0:
99+
pytest.fail(f"ovs-appctl bond/show failed: exitcode={ret.returncode} stdout={ret.stdout}")
100+
101+
if mode == 'lacp':
102+
bond_mode = 'balance-tcp'
103+
else:
104+
bond_mode = mode
105+
106+
if f"bond_mode: {bond_mode}" not in ret.stdout:
107+
pytest.fail(f"unexpected bond_mode: {mode}: stdout={ret.stdout}")
108+
109+
if mode == 'lacp':
110+
if "lacp_fallback_ab: false" not in ret.stdout:
111+
pytest.fail(f"unexpected lacp_fallback_ab: stdout={ret.stdout}")
112+
elif "lacp_status: configured" not in ret.stdout:
113+
pytest.fail(f"unexpected lacp_status: stdout={ret.stdout}")
114+
else:
115+
if "lacp_status: off" not in ret.stdout:
116+
pytest.fail(f"unexpected lacp_status: stdout={ret.stdout}")
117+
118+
try:
119+
# on the VM, add a new NIC using the bond
120+
vm = imported_vm.clone()
121+
vm.create_vif(1, network_uuid=bond.master().network_uuid())
122+
vm.start()
123+
vm.wait_for_vm_running_and_ssh_up()
124+
125+
vm.ssh("apk add tcpdump")
126+
vm.ssh("ip link set eth1 up")
127+
128+
if mode == 'lacp':
129+
# we are checking if we are seeing LACP packet on VM side.
130+
#
131+
# OpenvSwitch will send LACP negociation on host.eth1 and host.eth2
132+
# to etablish LACP link. As we don't have other side, it will keep
133+
# sending them.
134+
# On VM side, we could see such packets. So we are checking the VM.eth1
135+
# is properly connected to the Bond just created (but we don't check that
136+
# OpenvSwitch is properly setup)
137+
logging.info("Waiting for LACP packet")
138+
_wait_for_packet(vm, "eth1", "ether proto 0x8809")
139+
140+
else:
141+
# just check if we see a packet on the host side.
142+
# theVM kernel is expected to send IPv6 packet for Router Solicitation
143+
# as the VM interface is UP.
144+
logging.info("Waiting for some IPv6 packet")
145+
_wait_for_packet(host, "eth1", "ether proto 0x86dd")
146+
147+
finally:
148+
vm.destroy()

0 commit comments

Comments
 (0)