44
55import hashlib
66import logging
7+ import sys
78import tempfile
89import time
910from pathlib import Path
1011from uuid import uuid4
1112
1213import paramiko
14+ import pyte
1315
1416from lib .common import Defer , wait_for
1517from 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 ()
0 commit comments