Skip to content

Commit ea9bf9f

Browse files
committed
Use pyte for emulating a vt100 terminal and logging the install dialogs
Signed-off-by: Vincent Michel <vincent.michel@vates.tech>
1 parent 31951a4 commit ea9bf9f

4 files changed

Lines changed: 210 additions & 55 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ dependencies = [
1818
"requests",
1919
"ipdb",
2020
"paramiko",
21+
"pyte",
2122
]
2223

2324
[dependency-groups]

requirements/base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ pytest-dependency
1111
requests
1212
ipdb
1313
paramiko
14+
pyte

tests/install/with_tui.py

Lines changed: 194 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
import hashlib
66
import logging
7+
import sys
78
import tempfile
89
import time
910
from pathlib import Path
1011
from uuid import uuid4
1112

1213
import paramiko
14+
import pyte
1315

1416
from lib.common import Defer, wait_for
1517
from lib.host import Host
@@ -132,23 +134,30 @@ def missing_host_key(self, client, hostname, key):
132134
transport = client.get_transport()
133135
assert transport is not None
134136
channel = transport.open_session()
135-
channel.get_pty(term='vt100', width=80, height=24)
137+
channel.get_pty(term='linux', width=80, height=24)
136138
channel.exec_command(f"xl console -t serial {dom_id}".encode())
137139
channel.settimeout(30.0)
138140
stdout = channel.makefile('rb', -1)
139141

142+
screen = pyte.Screen(columns=80, lines=24)
143+
stream = pyte.ByteStream(screen)
144+
140145
# Wait for grub to finish
141146
for line in stdout:
142147
if b"Booting `install'" in line:
143148
break
144149

145150
# Wait for TUI to appear
146151
for line in stdout:
152+
assert isinstance(line, bytes)
153+
154+
# Start feeding the terminal emulator from here
155+
stream.feed(line)
156+
147157
if b"Welcome to XCP-ng" in line:
148-
logging.info(f"Entering TUI: {line}")
158+
logging.info(f"Entering TUI: {line!r}")
149159
break
150-
assert isinstance(line, bytes)
151-
if b"\x1b" in line:
160+
elif b"\x1b" in line:
152161
logging.info(f"! {line!r}")
153162
else:
154163
decoded = line.decode(errors="ignore").rstrip()
@@ -157,60 +166,190 @@ def missing_host_key(self, client, hostname, key):
157166
# Maybe some data already got extracted in the stdout buffer
158167
extra_data = getattr(stdout, "_rbuffer")
159168
assert isinstance(extra_data, bytes)
169+
stream.feed(extra_data)
170+
171+
def wait_for_dialog(
172+
title: str,
173+
discriminant: str = "",
174+
delay: float = 1.0,
175+
) -> None:
176+
wrapped_title = f"─┤ {title} ├─"
177+
logging.info(f"Wait for {title!r} dialog title")
178+
while not (
179+
any(wrapped_title in line for line in screen.display)
180+
and any(discriminant in line for line in screen.display)
181+
):
182+
stream.feed(channel.recv(1024))
183+
logging.info(f"Wait for {title!r} dialog to stabilize")
184+
time.sleep(delay)
185+
while channel.recv_ready():
186+
stream.feed(channel.recv(1024))
187+
logging.info(f"{title!r} dialog reached\n{show_screen(screen)}")
188+
189+
def send_tab(n: int = 1, delay: float = 1.0) -> None:
190+
channel.send(b"\t" * n)
191+
time.sleep(delay)
192+
while channel.recv_ready():
193+
stream.feed(channel.recv(1024))
194+
logging.info(f"Selection updated with {n} tab(s)\n{show_screen(screen)}")
195+
196+
def send_up(n: int = 1, delay: float = 1.0) -> None:
197+
channel.send(b"\x1b[A" * n)
198+
time.sleep(delay)
199+
while channel.recv_ready():
200+
stream.feed(channel.recv(1024))
201+
logging.info(f"Selection updated with {n} up(s)\n{show_screen(screen)}")
202+
203+
def send_password(n: int = 1, delay: float = 1.0) -> None:
204+
channel.send(f"{HOST_DEFAULT_PASSWORD}\t".encode() * 2)
205+
time.sleep(delay)
206+
while channel.recv_ready():
207+
stream.feed(channel.recv(1024))
208+
logging.info(f"Selection updated with {n} password(s)\n{show_screen(screen)}")
209+
210+
def send_pagedown(n: int = 1, delay: float = 1.0) -> None:
211+
channel.send(b"\x1b[6~" * n)
212+
time.sleep(delay)
213+
while channel.recv_ready():
214+
stream.feed(channel.recv(1024))
215+
logging.info(f"Selection updated with {n} pagedown(s)\n{show_screen(screen)}")
216+
217+
def validate(expected_selection: str | None = None, expected_validation: str = "Ok") -> None:
218+
highlighted = get_highlighted(screen)
219+
if expected_selection is None:
220+
validation, = highlighted
221+
else:
222+
selected, validation = highlighted
223+
assert expected_selection in selected
224+
assert validation == f" {expected_validation} "
225+
if expected_selection is None:
226+
logging.info(f"Validate dialog using {expected_validation!r}")
227+
else:
228+
logging.info(f"Validate {expected_selection!r} selection using {expected_validation!r}")
229+
channel.send(b"\r")
230+
231+
wait_for_dialog("Select Keymap")
232+
send_tab()
233+
validate(expected_selection="[qwerty] us")
234+
235+
wait_for_dialog("Welcome to XCP-ng Setup")
236+
validate()
237+
238+
wait_for_dialog("End User Agreement")
239+
send_tab()
240+
validate(expected_validation="Accept EUA")
241+
242+
wait_for_dialog("Select Primary Disk")
243+
send_tab()
244+
validate(expected_selection="nvme0n1")
245+
246+
wait_for_dialog("Virtual Machine Storage")
247+
send_tab()
248+
validate(expected_selection="nvme0n1")
249+
250+
wait_for_dialog("Virtual Machine Storage Type")
251+
send_tab(n=2)
252+
validate()
253+
254+
wait_for_dialog("Select Installation Source")
255+
send_tab()
256+
validate(expected_selection="Local media")
257+
258+
wait_for_dialog("Verify Installation Source")
259+
send_up()
260+
send_tab()
261+
validate(expected_selection="Skip verification")
262+
263+
wait_for_dialog("Set Password")
264+
send_password(n=2)
265+
validate()
266+
267+
wait_for_dialog("Networking", discriminant="IPv4")
268+
send_tab(n=3)
269+
validate()
270+
271+
wait_for_dialog("Networking", discriminant="DHCP")
272+
send_tab(n=3)
273+
validate()
274+
275+
wait_for_dialog("Hostname and DNS Configuration")
276+
send_tab(n=5)
277+
validate()
278+
279+
wait_for_dialog("Select Time Zone", discriminant="Africa")
280+
send_pagedown()
281+
send_tab()
282+
validate(expected_selection="Europe")
283+
284+
wait_for_dialog("Select Time Zone", discriminant="Amsterdam")
285+
send_pagedown(n=4)
286+
send_tab()
287+
validate(expected_selection="Paris")
288+
289+
wait_for_dialog("System Time")
290+
send_tab()
291+
validate(expected_selection="Use DHCP NTP servers")
160292

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")
293+
wait_for_dialog("Confirm Installation")
294+
send_tab()
295+
validate(expected_validation="Install XCP-ng")
193296

194297
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")
298+
wait_for_dialog("Installation Complete")
299+
300+
def show_screen(screen: pyte.Screen) -> str:
301+
ANSI_RESET = "\033[0m"
302+
ANSI_BOLD = "\033[1m"
303+
ANSI_REVERSE = "\033[7m"
304+
305+
columns = screen.columns
306+
307+
# 1. Draw the top border
308+
result = ["┌" + "─" * columns + "┐"]
309+
310+
# 2. Draw each row with left and right borders
311+
for row_idx in range(screen.lines):
312+
row = screen.buffer[row_idx]
313+
314+
# Start with the left border wall
315+
row_str = "│"
316+
317+
for col_idx in range(columns):
318+
char = row[col_idx]
319+
320+
fmt = ""
321+
if char.bold:
322+
fmt += ANSI_BOLD
323+
if char.reverse:
324+
fmt += ANSI_REVERSE
325+
326+
if fmt:
327+
row_str += f"{fmt}{char.data}{ANSI_RESET}"
328+
else:
329+
row_str += char.data
330+
331+
# Cap the line with the right border wall
332+
row_str += "│"
333+
334+
result.append(row_str)
335+
336+
# 3. Draw the bottom border
337+
result.append("└" + "─" * columns + "┘")
338+
return "\n".join(result)
339+
340+
def get_highlighted(screen: pyte.Screen) -> list[str]:
341+
highlighted = []
342+
for i in range(screen.lines):
343+
row = screen.buffer[i]
344+
previously_reversed = False
345+
for j in range(screen.columns):
346+
char = row[j]
347+
if char.reverse and not previously_reversed:
348+
highlighted.append("")
349+
if char.reverse:
350+
highlighted[-1] += char.data
351+
previously_reversed = char.reverse
352+
return highlighted
214353

215354

216355
@pytest.mark.dependency()

uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)