Skip to content

Commit 295de61

Browse files
committed
[PoC] Auto-install with TUI
Signed-off-by: Vincent Michel <vincent.michel@vates.tech>
1 parent 4580df1 commit 295de61

6 files changed

Lines changed: 476 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"pytest-dependency",
1818
"requests",
1919
"ipdb",
20+
"paramiko",
2021
]
2122

2223
[dependency-groups]
@@ -38,6 +39,7 @@ dev = [
3839
"prek",
3940
"autopep8",
4041
"types-passlib",
42+
"types-paramiko",
4143
]
4244

4345
[tool.pyright]

requirements/base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ pytest>=9.1.1
1010
pytest-dependency
1111
requests
1212
ipdb
13+
paramiko

requirements/dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ zizmor
1616
prek
1717
autopep8
1818
types-passlib
19+
types-paramiko
1920
-r base.txt

tests/install/test-pingpxe.sh

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#! /bin/bash
2+
set -eE
3+
set -o pipefail
4+
5+
ether_of () {
6+
ifconfig "$1" | grep ether | sed 's/.*ether \\([^ ]*\\).*/\\1/'
7+
}
8+
9+
# on installed system, avoid xapi-project/xen-api#5799
10+
if ! [ -e /opt/xensource/installer ]; then
11+
eth_mac=$(ether_of eth0)
12+
br_mac=$(ether_of xenbr0)
13+
14+
# wait for bridge MAC to be fixed
15+
test "$eth_mac" = "$br_mac"
16+
fi
17+
18+
if [ "$(readlink /bin/ping)" = busybox ]; then
19+
# XS before 7.0
20+
PINGARGS=""
21+
else
22+
PINGARGS="-c1"
23+
fi
24+
25+
ping $PINGARGS "$1"

tests/install/with_tui.py

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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

Comments
 (0)