|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import pytest |
| 4 | + |
| 5 | +import hashlib |
| 6 | +import logging |
| 7 | +import tempfile |
| 8 | +import time |
| 9 | +from pathlib import Path |
| 10 | +from uuid import uuid4 |
| 11 | + |
| 12 | +import paramiko |
| 13 | + |
| 14 | +from lib.common import Defer, wait_for |
| 15 | +from lib.host import Host |
| 16 | +from lib.vm import VM |
| 17 | + |
| 18 | +from .test import helper_vm_with_plugged_disk |
| 19 | + |
| 20 | +from typing import Generator |
| 21 | + |
| 22 | +def sha256(path: Path) -> str: |
| 23 | + with path.open("rb") as f: |
| 24 | + return hashlib.file_digest(f, "sha256").hexdigest() |
| 25 | + |
| 26 | + |
| 27 | +def vm_definition(firmware: str) -> dict: |
| 28 | + from data import NETWORKS |
| 29 | + |
| 30 | + return dict( |
| 31 | + name="vm1", |
| 32 | + template="Other install media", |
| 33 | + params=( |
| 34 | + # dict(param_name="", value=""), |
| 35 | + dict(param_name="memory-static-max", value="4GiB"), |
| 36 | + dict(param_name="memory-dynamic-max", value="4GiB"), |
| 37 | + dict(param_name="memory-dynamic-min", value="4GiB"), |
| 38 | + dict(param_name="VCPUs-max", value="2"), |
| 39 | + dict(param_name="VCPUs-at-startup", value="2"), |
| 40 | + dict(param_name="platform", key="exp-nested-hvm", value="true"), # FIXME < 8.3 host? |
| 41 | + dict(param_name="platform", key="nested-virt", value="true"), # FIXME >= 8.3 host? |
| 42 | + dict(param_name="HVM-boot-params", key="order", value="dc"), |
| 43 | + ) + { |
| 44 | + "uefi": ( |
| 45 | + dict(param_name="HVM-boot-params", key="firmware", value="uefi"), |
| 46 | + dict(param_name="platform", key="device-model", value="qemu-upstream-uefi"), |
| 47 | + ), |
| 48 | + "bios": (), |
| 49 | + }[firmware], |
| 50 | + vdis=[ |
| 51 | + dict(name="vm1 system disk", size="100GiB", device="xvda", userdevice="0"), |
| 52 | + dict(name="vm1 extra disk", size="50GiB", device="xvdb", userdevice="1") |
| 53 | + ], |
| 54 | + cd_vbd=dict(device="xvdd", userdevice="3"), |
| 55 | + vifs=[dict(index=0, network_name=NETWORKS["MGMT"])], |
| 56 | + ) |
| 57 | + |
| 58 | +@pytest.fixture(scope='function') |
| 59 | +def remote_installer_iso(host: Host, installer_iso: dict[str, str | bool]) -> Generator[str]: |
| 60 | + from data import OBJECTS_NAME_PREFIX |
| 61 | + |
| 62 | + assert isinstance(installer_iso['iso'], str) |
| 63 | + base_iso_file = Path(installer_iso['iso']) |
| 64 | + base_iso_name = base_iso_file.stem |
| 65 | + base_iso_hash = sha256(base_iso_file)[:8] |
| 66 | + base_iso_key = f"{base_iso_name}-{base_iso_hash}" |
| 67 | + remote_filename = f"{OBJECTS_NAME_PREFIX}tests-install-cache-{base_iso_key}.iso" |
| 68 | + |
| 69 | + iso_sr = host.pool.get_iso_sr() |
| 70 | + if not host.xe( |
| 71 | + "vdi-list", {"sr-uuid": iso_sr.uuid, "name-label": remote_filename}, |
| 72 | + minimal=True, |
| 73 | + ): |
| 74 | + mountpoint = f"/run/sr-mount/{iso_sr.uuid}" |
| 75 | + destination = f"{mountpoint}/{remote_filename}" |
| 76 | + host.pool.push_iso(str(base_iso_file), destination) |
| 77 | + |
| 78 | + yield remote_filename |
| 79 | + |
| 80 | +@pytest.fixture |
| 81 | +def vm_booted_with_original_installer( |
| 82 | + host: Host, create_vms: list[VM], remote_installer_iso: str |
| 83 | +) -> Generator[VM, None, None]: |
| 84 | + |
| 85 | + host_vm, = create_vms # one single VM |
| 86 | + |
| 87 | + vif = host_vm.vifs()[0] |
| 88 | + mac_address = vif.param_get('MAC') |
| 89 | + assert mac_address is not None |
| 90 | + logging.info("Host VM has MAC %s", mac_address) |
| 91 | + |
| 92 | + host_vm.insert_cd(remote_installer_iso) |
| 93 | + host_vm.start() |
| 94 | + wait_for(host_vm.is_running, "Wait for host VM running") |
| 95 | + |
| 96 | + yield host_vm |
| 97 | + |
| 98 | + logging.info("Shutting down Host VM") |
| 99 | + host_vm.shutdown(force=True) |
| 100 | + |
| 101 | + host_vm.eject_cd() |
| 102 | + |
| 103 | +@pytest.mark.dependency() |
| 104 | +@pytest.mark.parametrize("local_sr", ("ext",)) # TODO: "nosr" and "lvm" |
| 105 | +@pytest.mark.parametrize("package_source", ("iso",)) # TODO: "net" |
| 106 | +@pytest.mark.parametrize("iso_version", ("83nightly",)) # TODO: support other ISOs? |
| 107 | +@pytest.mark.parametrize("firmware", ("uefi",)) # TODO: "bios" |
| 108 | +@pytest.mark.vm_definitions(lambda firmware: vm_definition(firmware)) |
| 109 | +def test_install_with_tui( |
| 110 | + vm_booted_with_original_installer: VM, |
| 111 | + firmware: str, iso_version: str, package_source: str, local_sr: str, |
| 112 | + defer: Defer, |
| 113 | +): |
| 114 | + from data import HOST_DEFAULT_PASSWORD |
| 115 | + |
| 116 | + vm = vm_booted_with_original_installer |
| 117 | + residence_host = vm.get_residence_host() |
| 118 | + dom_id = residence_host.xe( |
| 119 | + 'vm-param-get', |
| 120 | + {'uuid': vm.uuid, 'param-name': 'dom-id'}, |
| 121 | + ) |
| 122 | + |
| 123 | + class IgnorePolicy(paramiko.MissingHostKeyPolicy): |
| 124 | + def missing_host_key(self, client, hostname, key): |
| 125 | + pass |
| 126 | + |
| 127 | + client = paramiko.SSHClient() |
| 128 | + defer(client.close) |
| 129 | + client.set_missing_host_key_policy(IgnorePolicy()) |
| 130 | + logging.info(f"Connecting to {residence_host.hostname_or_ip}") |
| 131 | + client.connect(residence_host.hostname_or_ip, username='root') |
| 132 | + transport = client.get_transport() |
| 133 | + assert transport is not None |
| 134 | + channel = transport.open_session() |
| 135 | + channel.get_pty(term='vt100', width=80, height=24) |
| 136 | + command = f"xl console -t serial {dom_id}" |
| 137 | + logging.info(f"Connecting to serial line with {command!r}") |
| 138 | + channel.exec_command(command.encode()) |
| 139 | + channel.settimeout(30.0) |
| 140 | + stdout = channel.makefile('rb', -1) |
| 141 | + |
| 142 | + # Wait for grub to finish |
| 143 | + for line in stdout: |
| 144 | + if b"Booting `install'" in line: |
| 145 | + break |
| 146 | + |
| 147 | + # Wait for TUI to appear |
| 148 | + for line in stdout: |
| 149 | + if b"Welcome to XCP-ng" in line: |
| 150 | + logging.info(f"Entering TUI: {line}") |
| 151 | + break |
| 152 | + assert isinstance(line, bytes) |
| 153 | + if b"\x1b" in line: |
| 154 | + logging.info(f"! {line!r}") |
| 155 | + else: |
| 156 | + decoded = line.decode(errors="ignore").rstrip() |
| 157 | + logging.info(f"> {decoded}") |
| 158 | + |
| 159 | + # Maybe some data already got extracted in the stdout buffer |
| 160 | + extra_data = getattr(stdout, "_rbuffer") |
| 161 | + assert isinstance(extra_data, bytes) |
| 162 | + |
| 163 | + _select_keymap_dialog = wait_for_dialog(channel, b"Select Keymap") |
| 164 | + channel.send(b"\t\r") # Validate US |
| 165 | + _welcome_dialog = wait_for_dialog(channel, b"Welcome to XCP-ng Setup") |
| 166 | + channel.send(b"\r") # Do not reboot, continue |
| 167 | + _end_user_agreement_dialog = wait_for_dialog(channel, b"End User Agreement") |
| 168 | + channel.send(b"\t\r") # Accept the end user agreement |
| 169 | + _select_primary_disk_dialog = wait_for_dialog(channel, b"Select Primary Disk") |
| 170 | + channel.send(b"\t\r") # Select first disk |
| 171 | + _virtual_machine_storage_dialog = wait_for_dialog(channel, b"Virtual Machine Storage") |
| 172 | + channel.send(b"\t\r") # Select first disk |
| 173 | + _virtual_machine_storage_type_dialog = wait_for_dialog(channel, b"Virtual Machine Storage Type") |
| 174 | + channel.send(b"\t\t\r") # Select EXT |
| 175 | + _select_installation_source_dialog = wait_for_dialog(channel, b"Select Installation Source") |
| 176 | + channel.send(b"\t\r") # Select Local Media |
| 177 | + _verify_installation_source_dialog = wait_for_dialog(channel, b"Verify Installation Source") |
| 178 | + channel.send(b"\x1b[A\t\r") # Skip the verification |
| 179 | + _set_password_dialog = wait_for_dialog(channel, b"Set Password") |
| 180 | + channel.send(f"{HOST_DEFAULT_PASSWORD}\t{HOST_DEFAULT_PASSWORD}\t\r".encode()) # Type root password |
| 181 | + _networking_1_dialog = wait_for_dialog(channel, b"Networking") |
| 182 | + channel.send(b"\t\t\t\r") # IPv4 |
| 183 | + _networking_2_dialog = wait_for_dialog(channel, b"Networking") |
| 184 | + channel.send(b"\t\t\t\r") # DHCP |
| 185 | + _hostname_and_dns_configuration_dialog = wait_for_dialog(channel, b"Hostname and DNS Configuration") |
| 186 | + channel.send(b"\t\t\t\t\t\r") # Random hostname and DNS set by DHCP |
| 187 | + _select_time_zone_1_dialog = wait_for_dialog(channel, b"Select Time Zone") |
| 188 | + channel.send(b"\x1b[6~\r") # Page down to select Europe |
| 189 | + _select_time_zone_2_dialog = wait_for_dialog(channel, b"Select Time Zone") |
| 190 | + channel.send(b"\x1b[6~\x1b[6~\x1b[6~\x1b[6~\r") # 4 Page down to select Paris |
| 191 | + _system_time_dialog = wait_for_dialog(channel, b"System Time") |
| 192 | + channel.send(b"\t\r") |
| 193 | + _confirm_installation_dialog = wait_for_dialog(channel, b"Confirm Installation") |
| 194 | + channel.send(b"\t\r") |
| 195 | + |
| 196 | + channel.settimeout(600) |
| 197 | + _installation_complete_dialog = wait_for_dialog(channel, b"Installation Complete") |
| 198 | + |
| 199 | + |
| 200 | +def wait_for_dialog( |
| 201 | + channel: paramiko.Channel, title: bytes, |
| 202 | + dialog: bytes = b"", delay: float = 1.0, |
| 203 | +) -> bytes: |
| 204 | + logging.info(f"Wait for {title!r} dialog title") |
| 205 | + while title not in dialog: |
| 206 | + dialog += channel.recv(1024) |
| 207 | + logging.info(f"Wait for {title!r} dialog to stabilize") |
| 208 | + time.sleep(delay) |
| 209 | + while channel.recv_ready(): |
| 210 | + dialog += channel.recv(1024) |
| 211 | + return dialog |
| 212 | + |
| 213 | +def show_dialog(dialog: bytes) -> None: |
| 214 | + """Helper to use when debugging the dialogs""" |
| 215 | + print("\x1b[2J\x1b[H" + dialog.decode() + "\x1b[24H\n") |
| 216 | + |
| 217 | + |
| 218 | +@pytest.mark.dependency() |
| 219 | +@pytest.mark.usefixtures("xcpng_chained") |
| 220 | +@pytest.mark.parametrize("local_sr", ("ext",)) |
| 221 | +@pytest.mark.parametrize("package_source", ("iso",)) |
| 222 | +@pytest.mark.parametrize("machine", ("host1", "host2")) |
| 223 | +@pytest.mark.parametrize("version", ("83nightly",)) |
| 224 | +@pytest.mark.parametrize("firmware", ("uefi",)) |
| 225 | +@pytest.mark.continuation_of.with_args( |
| 226 | + lambda version, firmware, local_sr, package_source: [dict( |
| 227 | + vm="vm1", |
| 228 | + image_test=f"test_install_with_tui[{firmware}-{version}-{package_source}-{local_sr}]")]) |
| 229 | +@pytest.mark.small_vm |
| 230 | +def test_tune_firstboot(create_vms: list[VM], helper_vm_with_plugged_disk: VM, |
| 231 | + firmware: str, version: str, machine: str, local_sr: str, package_source: str) -> None: |
| 232 | + from data import TEST_SSH_PUBKEY |
| 233 | + |
| 234 | + helper_vm = helper_vm_with_plugged_disk |
| 235 | + |
| 236 | + helper_vm.ssh("mount /dev/xvdb1 /mnt") |
| 237 | + try: |
| 238 | + # hostname |
| 239 | + logging.info("Setting hostname to %r", machine) |
| 240 | + helper_vm.ssh(f'echo {machine} > /mnt/etc/hostname') |
| 241 | + # UUIDs |
| 242 | + logging.info("Randomizing UUIDs") |
| 243 | + helper_vm.ssh( |
| 244 | + f'''sed -i -e "/^INSTALLATION_UUID=/ s/.*/INSTALLATION_UUID='{uuid4()}'/" -e "/^CONTROL_DOMAIN_UUID=/ s/.*/CONTROL_DOMAIN_UUID='{uuid4()}'/" /mnt/etc/xensource-inventory''' # noqa |
| 245 | + ) |
| 246 | + helper_vm.ssh("grep UUID /mnt/etc/xensource-inventory") |
| 247 | + logging.info("Add the CI SSH key") |
| 248 | + helper_vm.ssh(f'echo "{TEST_SSH_PUBKEY}" >> /mnt/root/.ssh/authorized_keys') |
| 249 | + logging.info("Configure the test-pingpxe service") |
| 250 | + configure_pingpxe_service(helper_vm) |
| 251 | + finally: |
| 252 | + helper_vm.ssh("umount /dev/xvdb1") |
| 253 | + |
| 254 | + |
| 255 | +def configure_pingpxe_service(helper_vm: VM): |
| 256 | + from data import ARP_SERVER |
| 257 | + |
| 258 | + # Copy test-pingpxe script |
| 259 | + pingpxe_path = Path(__file__).parent / "test-pingpxe.sh" |
| 260 | + assert pingpxe_path.exists() |
| 261 | + helper_vm.scp(str(pingpxe_path.absolute()), "/mnt/usr/local/sbin/test-pingpxe.sh") |
| 262 | + |
| 263 | + # Copy test-pingpxe service |
| 264 | + service_destination = "/mnt/etc/systemd/system/test-pingpxe.service" |
| 265 | + with tempfile.NamedTemporaryFile("w") as f: |
| 266 | + f.write(f"""\ |
| 267 | +[Unit] |
| 268 | +Description=Ping pxe server to populate its ARP table |
| 269 | +After=network-online.target |
| 270 | +[Service] |
| 271 | +Type=oneshot |
| 272 | +ExecStart=/bin/sh -c 'while ! /usr/local/sbin/test-pingpxe.sh "{ARP_SERVER}"; do sleep 1 ; done' |
| 273 | +[Install] |
| 274 | +WantedBy=default.target |
| 275 | +""") |
| 276 | + f.flush() |
| 277 | + helper_vm.scp(f.name, service_destination) |
| 278 | + |
| 279 | + # Enable test-pingpxe service |
| 280 | + helper_vm.ssh( |
| 281 | + f"ln -s {service_destination} /mnt/etc/systemd/system/default.target.wants/test-pingpxe.service" |
| 282 | + ) |
| 283 | + |
| 284 | + |
| 285 | +@pytest.mark.dependency() |
| 286 | +@pytest.mark.usefixtures("xcpng_chained") |
| 287 | +@pytest.mark.parametrize("local_sr", ("ext",)) |
| 288 | +@pytest.mark.parametrize("package_source", ("iso",)) |
| 289 | +@pytest.mark.parametrize("machine", ("host1", "host2")) |
| 290 | +@pytest.mark.parametrize("version", ("83nightly",)) |
| 291 | +@pytest.mark.parametrize("firmware", ("uefi",)) |
| 292 | +@pytest.mark.continuation_of.with_args( |
| 293 | + lambda firmware, version, machine, local_sr, package_source: [ |
| 294 | + dict(vm="vm1", |
| 295 | + image_test=("test_tune_firstboot" |
| 296 | + f"[None-{firmware}-{version}-{machine}-{package_source}-{local_sr}]"))]) |
| 297 | +def test_boot_inst(create_vms: list[VM], |
| 298 | + firmware: str, version: str, machine: str, package_source: str, local_sr: str) -> None: |
| 299 | + from .test import TestNested |
| 300 | + |
| 301 | + test_firstboot = getattr(TestNested(), "_test_firstboot") |
| 302 | + test_firstboot(create_vms, version, machine=machine) |
0 commit comments