Skip to content

Commit 31951a4

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

6 files changed

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

0 commit comments

Comments
 (0)