Skip to content

Commit 18b8e12

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 2ce14c9 commit 18b8e12

File tree

7 files changed

+348
-2
lines changed

7 files changed

+348
-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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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.pif import PIF
7+
8+
from typing import TYPE_CHECKING, Optional
9+
10+
if TYPE_CHECKING:
11+
from lib.host import Host
12+
13+
class Bond:
14+
xe_prefix = "bond"
15+
16+
def __init__(self, host: Host, uuid: str):
17+
self.host = host
18+
self.uuid = uuid
19+
20+
def param_get(self, param_name, key=None, accept_unknown_key=False):
21+
return _param_get(self.host, Bond.xe_prefix, self.uuid, param_name, key, accept_unknown_key)
22+
23+
def param_set(self, param_name, value, key=None):
24+
_param_set(self.host, Bond.xe_prefix, self.uuid, param_name, value, key)
25+
26+
def param_add(self, param_name, value, key=None):
27+
_param_add(self.host, Bond.xe_prefix, self.uuid, param_name, value, key)
28+
29+
def param_clear(self, param_name):
30+
_param_clear(self.host, Bond.xe_prefix, self.uuid, param_name)
31+
32+
def param_remove(self, param_name, key, accept_unknown_key=False):
33+
_param_remove(self.host, Bond.xe_prefix, self.uuid, param_name, key, accept_unknown_key)
34+
35+
def destroy(self):
36+
logging.info(f"Destroying bond: {self.uuid}")
37+
self.host.xe('bond-destroy', {'uuid': self.uuid})
38+
39+
def master(self) -> PIF:
40+
uuid = self.param_get('master')
41+
assert uuid is not None, "no master on Bond"
42+
return PIF(uuid, self.host)
43+
44+
def slaves(self) -> list[str]:
45+
return safe_split(self.param_get('slaves'))
46+
47+
def mode(self) -> Optional[str]:
48+
return self.param_get("mode")

lib/host.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
if TYPE_CHECKING:
1717
from lib.pool import Pool
1818

19+
from lib.bond import Bond
1920
from lib.common import (
2021
DiskDevName,
2122
_param_add,
@@ -32,6 +33,7 @@
3233
wait_for_not,
3334
)
3435
from lib.netutil import wrap_ip
36+
from lib.network import Network
3537
from lib.pif import PIF
3638
from lib.sr import SR
3739
from lib.vdi import VDI
@@ -768,3 +770,41 @@ def lvs(self, vgName: Optional[str] = None, ignore_MGT: bool = True) -> List[str
768770
continue
769771
ret.append(line.strip())
770772
return ret
773+
774+
def pifs(self, device: Optional[str] = None) -> list[PIF]:
775+
args: Dict[str, str | bool] = {
776+
"host-uuid": self.uuid,
777+
}
778+
779+
if device is not None:
780+
args["device"] = device
781+
782+
return [PIF(uuid, self) for uuid in safe_split(self.xe("pif-list", args, minimal=True))]
783+
784+
def bond_create(self, network: Network, pifs: list[PIF], mode: Optional[str] = None) -> Bond:
785+
args: dict[str, str | bool] = {
786+
'network-uuid': network.uuid,
787+
'pif-uuids': ','.join([pif.uuid for pif in pifs]),
788+
}
789+
790+
if mode is not None:
791+
args['mode'] = mode
792+
793+
uuid = self.xe("bond-create", args, minimal=True)
794+
logging.info(f"New Bond: {uuid}")
795+
796+
return Bond(self, uuid)
797+
798+
def network_create(self, label: str, description: Optional[str] = None) -> Network:
799+
args: dict[str, str | bool] = {
800+
'name-label': label,
801+
}
802+
803+
if description is not None:
804+
args['name-description'] = description
805+
806+
logging.info(f"Creating network '{label}'")
807+
uuid = self.xe("network-create", args, minimal=True)
808+
logging.info(f"New Network: {uuid}")
809+
810+
return Network(self, uuid)

lib/network.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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+
7+
from typing import TYPE_CHECKING
8+
9+
if TYPE_CHECKING:
10+
from lib.host import Host
11+
12+
class Network:
13+
xe_prefix = "network"
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, Network.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, Network.xe_prefix, self.uuid, param_name, value, key)
24+
25+
def param_add(self, param_name, value, key=None):
26+
_param_add(self.host, Network.xe_prefix, self.uuid, param_name, value, key)
27+
28+
def param_clear(self, param_name):
29+
_param_clear(self.host, Network.xe_prefix, self.uuid, param_name)
30+
31+
def param_remove(self, param_name, key, accept_unknown_key=False):
32+
_param_remove(self.host, Network.xe_prefix, self.uuid, param_name, key, accept_unknown_key)
33+
34+
def destroy(self):
35+
logging.info(f"Destroying network '{self.param_get('name-label')}': {self.uuid}")
36+
self.host.xe('network-destroy', {'uuid': self.uuid})
37+
38+
def PIF_uuids(self) -> list[str]:
39+
return safe_split(self.param_get('PIF-uuids'), '; ')
40+
41+
def VIF_uuids(self) -> list[str]:
42+
return safe_split(self.param_get('VIF-uuids'), '; ')
43+
44+
def is_private(self) -> bool:
45+
return len(self.PIF_uuids()) == 0
46+
47+
def managed(self) -> bool:
48+
return self.param_get('managed') == 'true'
49+
50+
def MTU(self) -> int:
51+
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: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,46 @@
11
import pytest
22

3+
import logging
4+
5+
from lib.host import Host
6+
from lib.network import Network
7+
8+
from typing import Generator
9+
310
@pytest.fixture(scope='package')
4-
def host_no_sdn_controller(host):
11+
def host_no_sdn_controller(host: Host):
512
""" An XCP-ng with no SDN controller. """
613
if host.xe('sdn-controller-list', minimal=True):
714
pytest.skip("This test requires an XCP-ng with no SDN controller")
15+
16+
17+
@pytest.fixture(scope='module')
18+
def empty_network(host: Host) -> Generator[Network, None, None]:
19+
try:
20+
net = host.network_create(label="empty_network for tests")
21+
yield net
22+
finally:
23+
net.destroy()
24+
25+
@pytest.fixture(params=[])
26+
def bond_devices(request: pytest.FixtureRequest) -> list[str]:
27+
return request.param
28+
29+
@pytest.fixture(params=["lacp"])
30+
def bond_mode(request: pytest.FixtureRequest) -> str:
31+
return request.param
32+
33+
@pytest.fixture
34+
def bond(host: Host, empty_network: Network, bond_devices: list[str], bond_mode: str):
35+
pifs = []
36+
logging.info(f"bond: resolve PIFs on {host.hostname_or_ip} using \
37+
{[(pif.network_uuid(), pif.param_get('device')) for pif in host.pifs()]}")
38+
for name in bond_devices:
39+
[pif] = host.pifs(device=name)
40+
pifs.append(pif)
41+
42+
bond = host.bond_create(empty_network, pifs, mode=bond_mode)
43+
try:
44+
yield bond
45+
finally:
46+
bond.destroy()

tests/network/test_bond.py

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

0 commit comments

Comments
 (0)