diff --git a/conftest.py b/conftest.py index 03ec108ea..f4cfed129 100644 --- a/conftest.py +++ b/conftest.py @@ -432,10 +432,9 @@ def vm_ref(request): if ref is None: # get default VM from test if there's one marker = request.node.get_closest_marker("default_vm") - default_vm = marker.args[0] if marker is not None else None - if default_vm is not None: - logging.info(">> No VM specified on CLI. Using default: %s." % default_vm) - ref = default_vm + if marker is not None: + ref = marker.args[0] + logging.info(">> No VM specified on CLI. Using default: %s.", ref) else: # global default logging.info(">> No VM specified on CLI, and no default found in test definition. Using global default.") diff --git a/data.py-dist b/data.py-dist index 8399d8ed1..c32267c07 100644 --- a/data.py-dist +++ b/data.py-dist @@ -1,12 +1,13 @@ # Configuration file, to be adapted to one's needs -from typing import Any, Dict, TYPE_CHECKING +from __future__ import annotations import legacycrypt as crypt # type: ignore import os +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: - from lib.typing import IsoImageDef + from lib.typing import SimpleAnswerfileDict, IsoImageDef # Default user and password to connect to a host through XAPI # Note: this won't be used for SSH. @@ -21,7 +22,7 @@ def hash_password(password): HOST_DEFAULT_PASSWORD_HASH = hash_password(HOST_DEFAULT_PASSWORD) -# Public key for a private key available to the test runner +# Public keys for a private keys available to the test runner TEST_SSH_PUBKEY = """ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMnN/wVdQqHA8KsndfrLS7fktH/IEgxoa533efuXR6rw XCP-ng CI ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKz9uQOoxq6Q0SQ0XTzQHhDolvuo/7EyrDZsYQbRELhcPJG8MT/o5u3HyJFhIP2+HqBSXXgmqRPJUkwz9wUwb2sUwf44qZm/pyPUWOoxyVtrDXzokU/uiaNKUMhbnfaXMz6Ogovtjua63qld2+ZRXnIgrVtYKtYBeu/qKGVSnf4FTOUKl1w3uKkr59IUwwAO8ay3wVnxXIHI/iJgq6JBgQNHbn3C/SpYU++nqL9G7dMyqGD36QPFuqH/cayL8TjNZ67TgAzsPX8OvmRSqjrv3KFbeSlpS/R4enHkSemhgfc8Z2f49tE7qxWZ6x4Uyp5E6ur37FsRf/tEtKIUJGMRXN XCP-ng CI @@ -37,7 +38,7 @@ OBJECTS_NAME_PREFIX = None # skip_xo_config allows to not touch XO's configuration regarding the host # Else the default behaviour is to add the host to XO servers at the beginning # of the testing session and remove it at the end. -HOSTS: Dict[str, Dict[str, Any]] = { +HOSTS: dict[str, dict[str, Any]] = { # "10.0.0.1": {"user": "root", "password": ""}, # "testhost1": {"user": "root", "password": "", 'skip_xo_config': True}, } @@ -106,8 +107,25 @@ OTHER_GUEST_TOOLS = { }, } +# IP addresses for hosts to be installed +# NOTE: do NOT set an IP for host1, it is assumed to use DEFAULT +HOSTS_IP_CONFIG: dict[str, dict[str, str]] = { + 'HOSTS': { +# 'DEFAULT': '192.16.0.1', +# 'host2': '192.16.0.2', +# "DEFAULT_v6": '2001:db8::1:1/32', +# "host_v6": '2001:db8::1:2/32', + }, +# 'NETMASK': '255.255.0.0', +# 'GATEWAY': '192.16.0.254', +# 'DNS': '192.16.0.254', + +# 'GATEWAY_v6': '2001:db8::1', +# 'DNS_v6': '2001:db8::2', +} + # Tools -TOOLS: Dict[str, str] = { +TOOLS: dict[str, str] = { # "iso-remaster": "/home/user/src/xcpng/xcp/scripts/iso-remaster/iso-remaster.sh", } @@ -127,7 +145,7 @@ ISO_IMAGES_CACHE = "/home/user/iso" # for local-only ISO with things like "locally-built/my.iso" or "xs/8.3.iso". # If 'net-only' is set to 'True' only source of type URL will be possible. # By default the parameter is set to False. -ISO_IMAGES: Dict[str, "IsoImageDef"] = { +ISO_IMAGES: dict[str, "IsoImageDef"] = { '83nightly': {'path': os.environ.get("XCPNG83_NIGHTLY", "http://unconfigured.iso"), 'unsigned': True}, @@ -182,25 +200,25 @@ DEFAULT_SR = 'default' CACHE_IMPORTED_VM = False # Default NFS device config: -NFS_DEVICE_CONFIG: Dict[str, Dict[str, str]] = { +NFS_DEVICE_CONFIG: dict[str, dict[str, str]] = { # 'server': '10.0.0.2', # URL/Hostname of NFS server # 'serverpath': '/path/to/shared/mount' # Path to shared mountpoint } # Default NFS4+ only device config: -NFS4_DEVICE_CONFIG: Dict[str, Dict[str, str]] = { +NFS4_DEVICE_CONFIG: dict[str, dict[str, str]] = { # 'server': '10.0.0.2', # URL/Hostname of NFS server # 'serverpath': '/path_to_shared_mount' # Path to shared mountpoint # 'nfsversion': '4.1' } # Default NFS ISO device config: -NFS_ISO_DEVICE_CONFIG: Dict[str, Dict[str, str]] = { +NFS_ISO_DEVICE_CONFIG: dict[str, dict[str, str]] = { # 'location': '10.0.0.2:/path/to/shared/mount' # URL/Hostname of NFS server and path to shared mountpoint } # Default CIFS ISO device config: -CIFS_ISO_DEVICE_CONFIG: Dict[str, Dict[str, str]] = { +CIFS_ISO_DEVICE_CONFIG: dict[str, dict[str, str]] = { # 'location': r'\\10.0.0.2\', # 'username': '', # 'cifspassword': '', @@ -208,25 +226,25 @@ CIFS_ISO_DEVICE_CONFIG: Dict[str, Dict[str, str]] = { # 'vers': '<1.0> or <3.0>' } -CEPHFS_DEVICE_CONFIG: Dict[str, Dict[str, str]] = { +CEPHFS_DEVICE_CONFIG: dict[str, dict[str, str]] = { # 'server': '10.0.0.2', # 'serverpath': '/vms' } -MOOSEFS_DEVICE_CONFIG: Dict[str, Dict[str, str]] = { +MOOSEFS_DEVICE_CONFIG: dict[str, dict[str, str]] = { # 'masterhost': 'mfsmaster', # 'masterport': '9421', # 'rootpath': '/vms' } -LVMOISCSI_DEVICE_CONFIG: Dict[str, Dict[str, str]] = { +LVMOISCSI_DEVICE_CONFIG: dict[str, dict[str, str]] = { # 'target': '192.168.1.1', # 'port': '3260', # 'targetIQN': 'target.example', # 'SCSIid': 'id' } -BASE_ANSWERFILES = dict( +BASE_ANSWERFILES: dict[str, "SimpleAnswerfileDict"] = dict( INSTALL={ "TAG": "installation", "CONTENTS": ( @@ -248,16 +266,7 @@ BASE_ANSWERFILES = dict( }, ) -IMAGE_EQUIVS: Dict[str, str] = { +IMAGE_EQUIVS: dict[str, str] = { # 'install.test::Nested::install[bios-830-ext]-vm1-607cea0c825a4d578fa5fab56978627d8b2e28bb': # 'install.test::Nested::install[bios-830-ext]-vm1-addb4ead4da49856e1d2fb3ddf4e31027c6b693b', } - -# compatibility settings for older tests -DEFAULT_NFS_DEVICE_CONFIG = NFS_DEVICE_CONFIG -DEFAULT_NFS4_DEVICE_CONFIG = NFS4_DEVICE_CONFIG -DEFAULT_NFS_ISO_DEVICE_CONFIG = NFS_ISO_DEVICE_CONFIG -DEFAULT_CIFS_ISO_DEVICE_CONFIG = CIFS_ISO_DEVICE_CONFIG -DEFAULT_CEPHFS_DEVICE_CONFIG = CEPHFS_DEVICE_CONFIG -DEFAULT_MOOSEFS_DEVICE_CONFIG = MOOSEFS_DEVICE_CONFIG -DEFAULT_LVMOISCSI_DEVICE_CONFIG = LVMOISCSI_DEVICE_CONFIG diff --git a/lib/installer.py b/lib/installer.py index b2e00e3bf..ddfa1a38c 100644 --- a/lib/installer.py +++ b/lib/installer.py @@ -1,66 +1,96 @@ +from __future__ import annotations + import logging import time import xml.etree.ElementTree as ET +from typing import cast, Optional, Sequence, Union from lib.commands import ssh, SSHCommandFailed from lib.common import wait_for +from lib.typing import AnswerfileDict, Self, SimpleAnswerfileDict class AnswerFile: - def __init__(self, kind, /): + def __init__(self, kind: str, /) -> None: from data import BASE_ANSWERFILES - defn = BASE_ANSWERFILES[kind] + defn: SimpleAnswerfileDict = BASE_ANSWERFILES[kind] self.defn = self._normalize_structure(defn) - def write_xml(self, filename): + def write_xml(self, filename: str) -> None: etree = ET.ElementTree(self._defn_to_xml_et(self.defn)) etree.write(filename) # chainable mutators for lambdas - def top_append(self, *defs): + def top_append(self, *defs: Union[SimpleAnswerfileDict, None, ValueError]) -> Self: + assert not isinstance(self.defn['CONTENTS'], str), "a toplevel CONTENTS must be a list" for defn in defs: + if defn is None: + continue self.defn['CONTENTS'].append(self._normalize_structure(defn)) return self - def top_setattr(self, attrs): + def top_setattr(self, attrs: "dict[str, str]") -> Self: assert 'CONTENTS' not in attrs - self.defn.update(attrs) + self.defn.update(cast(AnswerfileDict, attrs)) return self # makes a mutable deep copy of all `contents` @staticmethod - def _normalize_structure(defn): - assert isinstance(defn, dict) - assert 'TAG' in defn - defn = dict(defn) - if 'CONTENTS' not in defn: - defn['CONTENTS'] = [] - if not isinstance(defn['CONTENTS'], str): - defn['CONTENTS'] = [AnswerFile._normalize_structure(item) - for item in defn['CONTENTS']] - return defn + def _normalize_structure(defn: Union[SimpleAnswerfileDict, ValueError]) -> AnswerfileDict: + assert isinstance(defn, dict), f"{defn!r} is not a dict" + assert 'TAG' in defn, f"{defn} has no TAG" + + # type mutation through nearly-shallow copy + new_defn: AnswerfileDict = { + 'TAG': defn['TAG'], + 'CONTENTS': [], + } + for key, value in defn.items(): + if key == 'CONTENTS': + if isinstance(value, str): + new_defn['CONTENTS'] = value + else: + value_as_sequence: Sequence["SimpleAnswerfileDict"] + if isinstance(value, Sequence): + value_as_sequence = value + else: + value_as_sequence = ( + cast(SimpleAnswerfileDict, value), + ) + new_defn['CONTENTS'] = [ + AnswerFile._normalize_structure(item) + for item in value_as_sequence + if item is not None + ] + elif key == 'TAG': + pass # already copied + else: + new_defn[key] = value # type: ignore[literal-required] + + return new_defn # convert to a ElementTree.Element tree suitable for further # modification before we serialize it to XML @staticmethod - def _defn_to_xml_et(defn, /, *, parent=None): + def _defn_to_xml_et(defn: AnswerfileDict, /, *, parent: Optional[ET.Element] = None) -> ET.Element: assert isinstance(defn, dict) - defn = dict(defn) - name = defn.pop('TAG') + defn_copy = dict(defn) + name = defn_copy.pop('TAG') assert isinstance(name, str) - contents = defn.pop('CONTENTS', ()) + contents = cast(Union[str, "list[AnswerfileDict]"], defn_copy.pop('CONTENTS', [])) assert isinstance(contents, (str, list)) - element = ET.Element(name, **defn) + defn_filtered = cast("dict[str, str]", defn_copy) + element = ET.Element(name, {}, **defn_filtered) if parent is not None: parent.append(element) if isinstance(contents, str): element.text = contents else: - for contents in contents: - AnswerFile._defn_to_xml_et(contents, parent=element) + for content in contents: + AnswerFile._defn_to_xml_et(content, parent=element) return element -def poweroff(ip): +def poweroff(ip: str) -> None: try: ssh(ip, ["poweroff"]) except SSHCommandFailed as e: @@ -71,7 +101,7 @@ def poweroff(ip): else: raise -def monitor_install(*, ip): +def monitor_install(*, ip: str) -> None: # wait for "yum install" phase to finish wait_for(lambda: ssh(ip, ["grep", "'DISPATCH: NEW PHASE: Completing installation'", @@ -95,7 +125,7 @@ def monitor_install(*, ip): ).returncode == 1, "Wait for installer to terminate") -def monitor_upgrade(*, ip): +def monitor_upgrade(*, ip: str) -> None: # wait for "yum install" phase to start wait_for(lambda: ssh(ip, ["grep", "'DISPATCH: NEW PHASE: Reading package information'", @@ -128,7 +158,7 @@ def monitor_upgrade(*, ip): ).returncode == 1, "Wait for installer to terminate") -def monitor_restore(*, ip): +def monitor_restore(*, ip: str) -> None: # wait for "yum install" phase to start wait_for(lambda: ssh(ip, ["grep", "'Restoring backup'", diff --git a/lib/pxe.py b/lib/pxe.py index 4e3491aef..eb94a45c1 100644 --- a/lib/pxe.py +++ b/lib/pxe.py @@ -1,9 +1,11 @@ +from __future__ import annotations + from lib.commands import ssh, scp from data import ARP_SERVER, PXE_CONFIG_SERVER PXE_CONFIG_DIR = "/pxe/configs/custom" -def generate_boot_conf(directory, installer, action): +def generate_boot_conf(directory: str, installer: str, action: str) -> None: # in case of restore, we disable the text ui from the installer completely, # to workaround a bug that leaves us stuck on a confirmation dialog at the end of the operation. rt = 'rt=1' if action == 'restore' else '' @@ -15,7 +17,7 @@ def generate_boot_conf(directory, installer, action): {rt} """) -def server_push_config(mac_address, tmp_local_path): +def server_push_config(mac_address: str, tmp_local_path: str) -> None: assert mac_address remote_dir = f'{PXE_CONFIG_DIR}/{mac_address}/' server_remove_config(mac_address) @@ -23,17 +25,17 @@ def server_push_config(mac_address, tmp_local_path): scp(PXE_CONFIG_SERVER, f'{tmp_local_path}/boot.conf', remote_dir) scp(PXE_CONFIG_SERVER, f'{tmp_local_path}/answerfile.xml', remote_dir) -def server_remove_config(mac_address): +def server_remove_config(mac_address: str) -> None: assert mac_address # protection against deleting the whole parent dir! remote_dir = f'{PXE_CONFIG_DIR}/{mac_address}/' ssh(PXE_CONFIG_SERVER, ['rm', '-rf', remote_dir]) -def server_remove_bootconf(mac_address): +def server_remove_bootconf(mac_address: str) -> None: assert mac_address distant_file = f'{PXE_CONFIG_DIR}/{mac_address}/boot.conf' ssh(PXE_CONFIG_SERVER, ['rm', '-rf', distant_file]) -def arp_addresses_for(mac_address): +def arp_addresses_for(mac_address: str) -> list[str]: output = ssh( ARP_SERVER, ['ip', 'neigh', 'show', 'nud', 'reachable', diff --git a/lib/typing.py b/lib/typing.py index 6b2a7d491..1c9001dd7 100644 --- a/lib/typing.py +++ b/lib/typing.py @@ -1,5 +1,12 @@ -from typing import TypedDict -from typing_extensions import NotRequired +from __future__ import annotations + +import sys +from typing import Sequence, TypedDict, Union + +if sys.version_info >= (3, 11): + from typing import NotRequired, Self +else: + from typing_extensions import NotRequired, Self IsoImageDef = TypedDict('IsoImageDef', {'path': str, @@ -7,3 +14,28 @@ 'net-only': NotRequired[bool], 'unsigned': NotRequired[bool], }) + + +# Dict-based description of an Answerfile object to be built. +AnswerfileDict = TypedDict('AnswerfileDict', { + 'TAG': str, + 'CONTENTS': Union[str, "list[AnswerfileDict]"], +}) + +# Simplified version of AnswerfileDict for user input. +# - does not require to write 0 or 1 subelement as a list +SimpleAnswerfileDict = TypedDict('SimpleAnswerfileDict', { + 'TAG': str, + 'CONTENTS': NotRequired[Union[str, "SimpleAnswerfileDict", Sequence["SimpleAnswerfileDict"]]], + + # No way to allow arbitrary fields in addition? This conveys the + # field's type, but allows them in places we wouldn't want them, + # and forces every XML attribute we use to appear here. + 'device': NotRequired[str], + 'guest-storage': NotRequired[str], + 'mode': NotRequired[str], + 'name': NotRequired[str], + 'proto': NotRequired[str], + 'protov6': NotRequired[str], + 'type': NotRequired[str], +}) diff --git a/tests/install/conftest.py b/tests/install/conftest.py index 03357c613..1c17700c2 100644 --- a/tests/install/conftest.py +++ b/tests/install/conftest.py @@ -68,13 +68,6 @@ def answerfile(request): answerfile_def = callable_marker(marker.args[0], request) assert isinstance(answerfile_def, AnswerFile) - answerfile_def.top_append( - dict(TAG="admin-interface", - name="eth0", - proto="dhcp", - ), - ) - yield answerfile_def @@ -103,9 +96,17 @@ def installer_iso(request): ) @pytest.fixture(scope='function') -def install_disk(request): +def system_disks_names(request): firmware = request.getfixturevalue("firmware") - yield {"uefi": "nvme0n1", "bios": "sda"}[firmware] + system_disk_config = request.getfixturevalue("system_disk_config") + yield ( + ({"uefi": "nvme0n1", "bios": "sda"}[firmware],) + + ( + {"raid1": {"uefi": "nvme0n2", "bios": "sdb"}[firmware], + "disk": (), + }[system_disk_config], + ) + ) # Remasters the ISO sepecified by `installer_iso` mark, with: # - network and ssh support activated, and .ssh/authorized_key so tests can @@ -120,10 +121,12 @@ def install_disk(request): # in contexts where the same IP is reused by successively different MACs # (when cloning VMs from cache) @pytest.fixture(scope='function') -def remastered_iso(installer_iso, answerfile): +def remastered_iso(request, installer_iso, answerfile): iso_file = installer_iso['iso'] unsigned = installer_iso['unsigned'] + install_iface = request.getfixturevalue("install_iface") + assert "iso-remaster" in TOOLS iso_remaster = TOOLS["iso-remaster"] assert os.access(iso_remaster, os.X_OK) @@ -160,6 +163,9 @@ def remastered_iso(installer_iso, answerfile): test ! -e "{answerfile_xml}" || cp "{answerfile_xml}" "$INSTALLIMG/root/answerfile.xml" +HOSTINSTALLER=$HOME/src/xs/host-installer +make -C "$HOSTINSTALLER" DESTDIR="$INSTALLIMG" XS_MPATH_CONF="$HOME/src/xapi/sm/multipath/multipath.conf" + mkdir -p "$INSTALLIMG/usr/local/sbin" cat > "$INSTALLIMG/usr/local/sbin/test-pingpxe.sh" << 'EOF' #! /bin/bash @@ -179,13 +185,18 @@ def remastered_iso(installer_iso, answerfile): test "$eth_mac" = "$br_mac" fi -if [ $(readlink "/bin/ping") = busybox ]; then +if [ "$(readlink /bin/ping)" = busybox ]; then # XS before 7.0 PINGARGS="" else PINGARGS="-c1" fi +# detect lack of ipv4 both in installer and in installed host +if grep -q -w network_config=none /proc/cmdline || grep -q "^MODE='none'\\$" /etc/firstboot.d/data/management.conf; then + PINGARGS+=" -6" +fi + ping $PINGARGS "$1" EOF chmod +x "$INSTALLIMG/usr/local/sbin/test-pingpxe.sh" @@ -246,6 +257,11 @@ def remastered_iso(installer_iso, answerfile): set -ex ISODIR="$1" SED_COMMANDS=(-e "s@/vmlinuz@/vmlinuz network_device=all sshpassword={passwd} atexit=shell@") +case "{install_iface}" in +ipv4dhcp) ;; +ipv6dhcp) SED_COMMANDS+=(-e "s@/vmlinuz@/vmlinuz network_config=none network_config6=dhcp@") ;; +*) echo >&2 "ERROR unhandled install_iface '{install_iface}'"; exit 1 ;; +esac test ! -e "{answerfile_xml}" || SED_COMMANDS+=(-e "s@/vmlinuz@/vmlinuz install answerfile=file:///root/answerfile.xml@") # assuming *gpgcheck only appear within unsigned ISO diff --git a/tests/install/test-sequences/inst+upg+rst.lst b/tests/install/test-sequences/inst+upg+rst.lst index c23878e4c..cc2eeec9d 100644 --- a/tests/install/test-sequences/inst+upg+rst.lst +++ b/tests/install/test-sequences/inst+upg+rst.lst @@ -1,2 +1,2 @@ -tests/install/test.py::TestNested::test_restore[uefi-83nightly-83nightly-83nightly-iso-ext] -tests/install/test.py::TestNested::test_boot_rst[uefi-83nightly-83nightly-83nightly-iso-ext] +tests/install/test.py::TestNested::test_restore[uefi-83nightly-83nightly-83nightly-disk-iso-ext-ipv4dhcp] +tests/install/test.py::TestNested::test_boot_rst[uefi-83nightly-83nightly-83nightly-disk-iso-ext-ipv4dhcp] diff --git a/tests/install/test-sequences/inst+upg.lst b/tests/install/test-sequences/inst+upg.lst index 100e53593..b2f67d52b 100644 --- a/tests/install/test-sequences/inst+upg.lst +++ b/tests/install/test-sequences/inst+upg.lst @@ -1,2 +1,2 @@ -tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly-host1-iso-ext] -tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly-host1-iso-ext] +tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly-host1-disk-iso-ext-ipv4dhcp] +tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly-host1-disk-iso-ext-ipv4dhcp] diff --git a/tests/install/test-sequences/inst.lst b/tests/install/test-sequences/inst.lst index 9b92eea31..854cd9c52 100644 --- a/tests/install/test-sequences/inst.lst +++ b/tests/install/test-sequences/inst.lst @@ -1,3 +1,3 @@ -tests/install/test.py::TestNested::test_install[uefi-83nightly-iso-ext] -tests/install/test.py::TestNested::test_tune_firstboot[None-uefi-83nightly-host1-iso-ext] -tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1-iso-ext] +tests/install/test.py::TestNested::test_install[uefi-83nightly-disk-iso-ext-ipv4dhcp] +tests/install/test.py::TestNested::test_tune_firstboot[None-uefi-83nightly-host1-disk-iso-ext-ipv4dhcp] +tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1-disk-iso-ext-ipv4dhcp] diff --git a/tests/install/test.py b/tests/install/test.py index daacab631..16a70679e 100644 --- a/tests/install/test.py +++ b/tests/install/test.py @@ -1,6 +1,7 @@ import logging import pytest from uuid import uuid4 +from typing import cast from lib import commands, installer, pxe from lib.common import safe_split, wait_for @@ -9,8 +10,9 @@ from lib.pool import Pool from lib.vdi import VDI -from data import ISO_IMAGES, NETWORKS +from data import HOSTS_IP_CONFIG, ISO_IMAGES, NETWORKS assert "MGMT" in NETWORKS +assert "HOSTS" in HOSTS_IP_CONFIG # Requirements: # - one XCP-ng host capable of nested virt, with an ISO SR, and a default SR @@ -26,22 +28,26 @@ def helper_vm_with_plugged_disk(running_vm, create_vms): all_vdis = [VDI(uuid, host=host_vm.host) for uuid in host_vm.vdi_uuids()] disk_vdis = [vdi for vdi in all_vdis if not vdi.readonly()] - vdi, = disk_vdis - vbd = helper_vm.create_vbd("1", vdi.uuid) + vbds = [helper_vm.create_vbd(str(n + 1), vdi.uuid) for n, vdi in enumerate(disk_vdis)] try: - vbd.plug() + for vbd in vbds: + vbd.plug() yield helper_vm finally: - vbd.unplug() - vbd.destroy() + for vbd in reversed(vbds): + vbd.unplug() + vbd.destroy() @pytest.mark.dependency() class TestNested: + @pytest.mark.parametrize("admin_iface", ("ipv4dhcp", "ipv4static", "ipv6static", "ipv6ac", "ipv6dhcp")) @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm")) @pytest.mark.parametrize("package_source", ("iso", "net")) + @pytest.mark.parametrize("system_disk_config", ("disk", "raid1")) + @pytest.mark.parametrize("install_iface", ("ipv4dhcp", "ipv6dhcp")) @pytest.mark.parametrize("iso_version", ( "83nightly", "830net", "830", @@ -53,7 +59,7 @@ class TestNested: )) @pytest.mark.parametrize("firmware", ("uefi", "bios")) @pytest.mark.vm_definitions( - lambda firmware: dict( + lambda firmware, system_disk_config: dict( name="vm1", template="Other install media", params=( @@ -73,31 +79,74 @@ class TestNested: ), "bios": (), }[firmware], - vdis=[dict(name="vm1 system disk", size="100GiB", device="xvda", userdevice="0")], + vdis=([dict(name="vm1 system disk", size="100GiB", device="xvda", userdevice="0")] + + ([dict(name="vm1 system disk mirror", size="100GiB", device="xvdb", userdevice="1")] + if system_disk_config == "raid1" else []) + ), cd_vbd=dict(device="xvdd", userdevice="3"), vifs=[dict(index=0, network_name=NETWORKS["MGMT"])], )) @pytest.mark.answerfile( - lambda install_disk, local_sr, package_source, iso_version: AnswerFile("INSTALL") + lambda system_disks_names, local_sr, package_source, system_disk_config, iso_version, + admin_iface: AnswerFile("INSTALL") .top_setattr({} if local_sr == "nosr" else {"sr-type": local_sr}) .top_append( - {"TAG": "source", "type": "local"} if package_source == "iso" - else {"TAG": "source", "type": "url", - "CONTENTS": ISO_IMAGES[iso_version]['net-url']} if package_source == "net" - else {}, + {"iso": {"TAG": "source", "type": "local"}, + "net": {"TAG": "source", "type": "url", + "CONTENTS": ISO_IMAGES[iso_version]['net-url']}, + }[package_source], + + {"raid1": {"TAG": "raid", "device": "md127", + "CONTENTS": [ + {"TAG": "disk", "CONTENTS": diskname} for diskname in system_disks_names + ]}, + "disk": None, + }[system_disk_config], + + {"TAG": "admin-interface", "name": "eth0", + "proto": ("dhcp" if admin_iface == "ipv4dhcp" + else "static" if admin_iface == "ipv4static" + else "none"), + "protov6": ("dhcp" if admin_iface == "ipv6dhcp" + else "autoconf" if admin_iface == "ipv6ac" + else "static" if admin_iface == "ipv6static" + else "none"), + "CONTENTS": ((( + {"TAG": "ipaddr", "CONTENTS": cast(str, HOSTS_IP_CONFIG['HOSTS']['DEFAULT'])}, + {"TAG": "subnet", "CONTENTS": cast(str, HOSTS_IP_CONFIG['NETMASK'])}, + {"TAG": 'gateway', "CONTENTS": cast(str, HOSTS_IP_CONFIG['GATEWAY'])}, + ) if admin_iface == "ipv4static" + else ()) + + (( + {"TAG": "ipv6", "CONTENTS": cast(str, HOSTS_IP_CONFIG['HOSTS']['DEFAULT_v6'])}, + {"TAG": "gatewayv6", "CONTENTS": cast(str, HOSTS_IP_CONFIG['GATEWAY_v6'])}, + ) if admin_iface == "ipv6static" + else ())), + }, + {"TAG": "name-server", "CONTENTS": cast(str, HOSTS_IP_CONFIG['DNS'])} if admin_iface == "ipv4static" + else None, + {"TAG": "name-server", "CONTENTS": cast(str, HOSTS_IP_CONFIG['DNS_v6'])} if admin_iface == "ipv6static" + else None, + {"TAG": "primary-disk", "guest-storage": "no" if local_sr == "nosr" else "yes", - "CONTENTS": install_disk}, + "CONTENTS": {"disk": system_disks_names[0], + "raid1": "md127", + }[system_disk_config], + }, )) - def test_install(self, vm_booted_with_installer, install_disk, - firmware, iso_version, package_source, local_sr): + def test_install(self, vm_booted_with_installer, system_disks_names, + firmware, iso_version, install_iface, package_source, system_disk_config, local_sr, admin_iface): host_vm = vm_booted_with_installer installer.monitor_install(ip=host_vm.ip) @pytest.mark.usefixtures("xcpng_chained") + @pytest.mark.parametrize("admin_iface", ("ipv4dhcp", "ipv4static", "ipv6static", "ipv6ac", "ipv6dhcp")) @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm")) @pytest.mark.parametrize("package_source", ("iso", "net")) + @pytest.mark.parametrize("system_disk_config", ("disk", "raid1")) @pytest.mark.parametrize("machine", ("host1", "host2")) + @pytest.mark.parametrize("install_iface", ("ipv4dhcp", "ipv6dhcp")) @pytest.mark.parametrize("version", ( "83nightly", "830net", "830", @@ -110,19 +159,43 @@ def test_install(self, vm_booted_with_installer, install_disk, )) @pytest.mark.parametrize("firmware", ("uefi", "bios")) @pytest.mark.continuation_of( - lambda version, firmware, local_sr, package_source: [dict( + lambda version, firmware, install_iface, local_sr, admin_iface, package_source, system_disk_config: [dict( vm="vm1", - image_test=f"TestNested::test_install[{firmware}-{version}-{package_source}-{local_sr}]")]) - @pytest.mark.small_vm + image_test=(f"TestNested::test_install[{firmware}-{version}-{install_iface}-{system_disk_config}" + f"-{package_source}-{local_sr}-{admin_iface}]"))]) def test_tune_firstboot(self, create_vms, helper_vm_with_plugged_disk, - firmware, version, machine, local_sr, package_source): + firmware, version, install_iface, machine, local_sr, admin_iface, package_source, system_disk_config): helper_vm = helper_vm_with_plugged_disk - helper_vm.ssh(["mount /dev/xvdb1 /mnt"]) + if system_disk_config == "disk": + helper_vm.ssh(["mount /dev/xvdb1 /mnt"]) + elif system_disk_config == "raid1": + # FIXME helper VM has to be an Alpine, that should not be a random vm_ref + helper_vm.ssh(["apk add mdadm"]) + helper_vm.ssh(["mdadm -A /dev/md/127 -N localhost:127"]) + helper_vm.ssh(["mount /dev/md127p1 /mnt"]) + else: + raise ValueError(f"unhandled system_disk_config {system_disk_config!r}") + try: # hostname logging.info("Setting hostname to %r", machine) helper_vm.ssh(["echo > /mnt/etc/hostname", machine]) + if admin_iface == "ipv4static" and machine in HOSTS_IP_CONFIG['HOSTS']: + # static management IPv4 if not the default set during install + ip = HOSTS_IP_CONFIG['HOSTS'][machine] + logging.info("Changing IP to %s", ip) + + helper_vm.ssh([f"sed -i s/^IP=.*/IP='{ip}'/", + "/mnt/etc/firstboot.d/data/management.conf"]) + machine_v6 = f"{machine}_v6" + if admin_iface == "ipv6static" and machine_v6 in HOSTS_IP_CONFIG['HOSTS']: + # static management IPv6 if not the default set during install + ip = HOSTS_IP_CONFIG['HOSTS'][machine_v6] + logging.info("Changing IP to %s", ip) + + helper_vm.ssh([f"sed -i s/^IPv6=.*/IPv6='{ip}'/", + "/mnt/etc/firstboot.d/data/management.conf"]) # UUIDs logging.info("Randomizing UUIDs") helper_vm.ssh( @@ -132,9 +205,9 @@ def test_tune_firstboot(self, create_vms, helper_vm_with_plugged_disk, '/mnt/etc/xensource-inventory']) helper_vm.ssh(["grep UUID /mnt/etc/xensource-inventory"]) finally: - helper_vm.ssh(["umount /dev/xvdb1"]) + helper_vm.ssh(["umount /mnt"]) - def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=False): + def _test_firstboot(self, create_vms, mode, admin_iface, *, machine='DEFAULT', is_restore=False): host_vm = create_vms[0] vif = host_vm.vifs()[0] mac_address = vif.param_get('MAC') @@ -175,14 +248,26 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal host_vm.start() wait_for(host_vm.is_running, "Wait for host VM running") - # catch host-vm IP address - wait_for(lambda: pxe.arp_addresses_for(mac_address), - "Wait for DHCP server to see Host VM in ARP tables", - timeout_secs=10 * 60) - ips = pxe.arp_addresses_for(mac_address) - logging.info("Host VM has IPs %s", ips) - assert len(ips) == 1 - host_vm.ip = ips[0] + machine_v6 = f"{machine}_v6" + if admin_iface in ("ipv4dhcp", "ipv6dhcp", "ipv6ac"): + # catch host-vm IP address + wait_for(lambda: pxe.arp_addresses_for(mac_address), + "Wait for ARP_SERVER to see Host VM in ARP tables", + timeout_secs=10 * 60) + ips = pxe.arp_addresses_for(mac_address) + logging.info("Host VM has IPs %s", ips) + assert len(ips) == 1 + host_vm.ip = ips[0] + elif admin_iface == "ipv4static": + host_vm.ip = HOSTS_IP_CONFIG['HOSTS'].get(machine, + HOSTS_IP_CONFIG['HOSTS']['DEFAULT']) + logging.info("Expecting host VM to have IP %s", host_vm.ip) + elif admin_iface == "ipv6static": + host_vm.ip, prefix_len = HOSTS_IP_CONFIG['HOSTS'].get( + machine_v6, HOSTS_IP_CONFIG['HOSTS']['DEFAULT_v6']).split('/') + logging.info("Expecting host VM to have IP %s", host_vm.ip) + else: + raise ValueError(f"admin_iface {admin_iface!r}") wait_for( lambda: commands.local_cmd( @@ -286,9 +371,12 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal raise @pytest.mark.usefixtures("xcpng_chained") + @pytest.mark.parametrize("admin_iface", ("ipv4dhcp", "ipv4static", "ipv6static", "ipv6ac", "ipv6dhcp")) @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm")) @pytest.mark.parametrize("package_source", ("iso", "net")) + @pytest.mark.parametrize("system_disk_config", ("disk", "raid1")) @pytest.mark.parametrize("machine", ("host1", "host2")) + @pytest.mark.parametrize("install_iface", ("ipv4dhcp", "ipv6dhcp")) @pytest.mark.parametrize("version", ( "83nightly", "830net", "830", @@ -301,18 +389,22 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal )) @pytest.mark.parametrize("firmware", ("uefi", "bios")) @pytest.mark.continuation_of( - lambda firmware, version, machine, local_sr, package_source: [ + lambda firmware, version, install_iface, machine, local_sr, admin_iface, package_source, system_disk_config: [ dict(vm="vm1", image_test=("TestNested::test_tune_firstboot" - f"[None-{firmware}-{version}-{machine}-{package_source}-{local_sr}]"))]) + f"[None-{firmware}-{version}-{install_iface}-{machine}-{system_disk_config}" + f"-{package_source}-{local_sr}-{admin_iface}]"))]) def test_boot_inst(self, create_vms, - firmware, version, machine, package_source, local_sr): - self._test_firstboot(create_vms, version, machine=machine) + firmware, version, install_iface, machine, package_source, system_disk_config, local_sr, admin_iface): + self._test_firstboot(create_vms, version, admin_iface, machine=machine) @pytest.mark.usefixtures("xcpng_chained") + @pytest.mark.parametrize("admin_iface", ("ipv4dhcp", "ipv4static", "ipv6static")) @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm")) @pytest.mark.parametrize("package_source", ("iso", "net")) + @pytest.mark.parametrize("system_disk_config", ("disk", "raid1")) @pytest.mark.parametrize("machine", ("host1", "host2")) + @pytest.mark.parametrize("install_iface", ("ipv4dhcp", "ipv6dhcp")) @pytest.mark.parametrize(("orig_version", "iso_version"), [ ("83nightly", "83nightly"), ("830", "83nightly"), @@ -328,27 +420,35 @@ def test_boot_inst(self, create_vms, ]) @pytest.mark.parametrize("firmware", ("uefi", "bios")) @pytest.mark.continuation_of( - lambda firmware, orig_version, machine, package_source, local_sr: [dict( + lambda firmware, orig_version, install_iface, machine, system_disk_config, package_source, local_sr, admin_iface: [dict( vm="vm1", - image_test=f"TestNested::test_boot_inst[{firmware}-{orig_version}-{machine}-{package_source}-{local_sr}]")]) + image_test=(f"TestNested::test_boot_inst[{firmware}-{orig_version}-{install_iface}-{machine}-{system_disk_config}" + f"-{package_source}-{local_sr}-{admin_iface}]"))]) @pytest.mark.answerfile( - lambda install_disk, package_source, iso_version: AnswerFile("UPGRADE").top_append( - {"TAG": "source", "type": "local"} if package_source == "iso" - else {"TAG": "source", "type": "url", - "CONTENTS": ISO_IMAGES[iso_version]['net-url']} if package_source == "net" - else {}, + lambda system_disks_names, package_source, system_disk_config, iso_version: + AnswerFile("UPGRADE").top_append( + {"iso": {"TAG": "source", "type": "local"}, + "net": {"TAG": "source", "type": "url", + "CONTENTS": ISO_IMAGES[iso_version]['net-url']}, + }[package_source], {"TAG": "existing-installation", - "CONTENTS": install_disk}, + "CONTENTS": {"disk": system_disks_names[0], + "raid1": "md127", + }[system_disk_config]}, )) - def test_upgrade(self, vm_booted_with_installer, install_disk, - firmware, orig_version, iso_version, machine, package_source, local_sr): + def test_upgrade(self, vm_booted_with_installer, system_disks_names, + firmware, orig_version, iso_version, install_iface, machine, package_source, + system_disk_config, local_sr, admin_iface): host_vm = vm_booted_with_installer installer.monitor_upgrade(ip=host_vm.ip) @pytest.mark.usefixtures("xcpng_chained") + @pytest.mark.parametrize("admin_iface", ("ipv4dhcp", "ipv4static", "ipv6static")) @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm")) @pytest.mark.parametrize("package_source", ("iso", "net")) + @pytest.mark.parametrize("system_disk_config", ("disk", "raid1")) @pytest.mark.parametrize("machine", ("host1", "host2")) + @pytest.mark.parametrize("install_iface", ("ipv4dhcp", "ipv6dhcp")) @pytest.mark.parametrize("mode", ( "83nightly-83nightly", "830-83nightly", @@ -364,16 +464,20 @@ def test_upgrade(self, vm_booted_with_installer, install_disk, )) @pytest.mark.parametrize("firmware", ("uefi", "bios")) @pytest.mark.continuation_of( - lambda firmware, mode, machine, package_source, local_sr: [dict( + lambda firmware, mode, install_iface, machine, system_disk_config, package_source, local_sr, admin_iface: [dict( vm="vm1", - image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{machine}-{package_source}-{local_sr}]"))]) + image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{install_iface}-{machine}-{system_disk_config}" + f"-{package_source}-{local_sr}-{admin_iface}]"))]) def test_boot_upg(self, create_vms, - firmware, mode, machine, package_source, local_sr): - self._test_firstboot(create_vms, mode, machine=machine) + firmware, mode, install_iface, machine, package_source, system_disk_config, local_sr, admin_iface): + self._test_firstboot(create_vms, mode, admin_iface, machine=machine) @pytest.mark.usefixtures("xcpng_chained") + @pytest.mark.parametrize("admin_iface", ("ipv4dhcp", "ipv4static", "ipv6static")) @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm")) @pytest.mark.parametrize("package_source", ("iso", "net")) + @pytest.mark.parametrize("system_disk_config", ("disk", "raid1")) + @pytest.mark.parametrize("install_iface", ("ipv4dhcp", "ipv6dhcp")) @pytest.mark.parametrize(("orig_version", "iso_version"), [ ("83nightly-83nightly", "83nightly"), ("830-83nightly", "83nightly"), @@ -389,22 +493,29 @@ def test_boot_upg(self, create_vms, ]) @pytest.mark.parametrize("firmware", ("uefi", "bios")) @pytest.mark.continuation_of( - lambda firmware, orig_version, local_sr, package_source: [dict( + lambda firmware, orig_version, install_iface, local_sr, admin_iface, system_disk_config, package_source: [dict( vm="vm1", - image_test=f"TestNested::test_boot_upg[{firmware}-{orig_version}-host1-{package_source}-{local_sr}]")]) + image_test=(f"TestNested::test_boot_upg[{firmware}-{orig_version}-{install_iface}-host1-{system_disk_config}" + f"-{package_source}-{local_sr}-{admin_iface}]"))]) @pytest.mark.answerfile( - lambda install_disk: AnswerFile("RESTORE").top_append( + lambda system_disks_names, system_disk_config: AnswerFile("RESTORE").top_append( {"TAG": "backup-disk", - "CONTENTS": install_disk}, + "CONTENTS": {"disk": system_disks_names[0], + "raid1": "md127", + }[system_disk_config]}, )) - def test_restore(self, vm_booted_with_installer, install_disk, - firmware, orig_version, iso_version, package_source, local_sr): + def test_restore(self, vm_booted_with_installer, system_disks_names, + firmware, orig_version, iso_version, install_iface, package_source, + system_disk_config, local_sr, admin_iface): host_vm = vm_booted_with_installer installer.monitor_restore(ip=host_vm.ip) @pytest.mark.usefixtures("xcpng_chained") + @pytest.mark.parametrize("admin_iface", ("ipv4dhcp", "ipv4static", "ipv6static")) @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm")) @pytest.mark.parametrize("package_source", ("iso", "net")) + @pytest.mark.parametrize("system_disk_config", ("disk", "raid1")) + @pytest.mark.parametrize("install_iface", ("ipv4dhcp", "ipv6dhcp")) @pytest.mark.parametrize("mode", ( "83nightly-83nightly-83nightly", "830-83nightly-83nightly", @@ -420,9 +531,10 @@ def test_restore(self, vm_booted_with_installer, install_disk, )) @pytest.mark.parametrize("firmware", ("uefi", "bios")) @pytest.mark.continuation_of( - lambda firmware, mode, package_source, local_sr: [dict( + lambda firmware, mode, install_iface, system_disk_config, package_source, local_sr, admin_iface: [dict( vm="vm1", - image_test=(f"TestNested::test_restore[{firmware}-{mode}-{package_source}-{local_sr}]"))]) + image_test=(f"TestNested::test_restore[{firmware}-{mode}-{install_iface}-{system_disk_config}" + f"-{package_source}-{local_sr}-{admin_iface}]"))]) def test_boot_rst(self, create_vms, - firmware, mode, package_source, local_sr): - self._test_firstboot(create_vms, mode, is_restore=True) + firmware, mode, install_iface, package_source, system_disk_config, local_sr, admin_iface): + self._test_firstboot(create_vms, mode, admin_iface, is_restore=True) diff --git a/tests/install/test_fixtures.py b/tests/install/test_fixtures.py index 0d6247693..87e03cfc6 100644 --- a/tests/install/test_fixtures.py +++ b/tests/install/test_fixtures.py @@ -5,7 +5,7 @@ # test the answerfile fixture can run on 2 parametrized instances # of the test in one run -@pytest.mark.answerfile(lambda: AnswerFile("INSTALL").top_append( +@pytest.mark.answerfile(lambda: AnswerFile("INSTALL").top_append( # type: ignore[call-arg] {"TAG": "source", "type": "local"}, {"TAG": "primary-disk", "CONTENTS": "nvme0n1"}, ))