Skip to main content

max / alloy

16.2 KB · 398 lines History Blame Raw
1 #!/usr/bin/env python3
2 """Walk `alloy install` to the end, in a VM, without a person at the keyboard.
3
4 The installer is a ratatui program with no headless mode and no answer file, so
5 a scripted install has to answer the six questions the way a person would. What
6 it must not do is answer them by sending scancodes at the qemu monitor and
7 screendumping to find out what happened: reading the screen as TEXT is what
8 makes each step's completion a fact rather than a delay, so every wait below is
9 for a title the installer only draws once it is on that step, and a slow
10 install and a stuck one look different. Pictures cannot answer that.
11
12 ## Two ways in
13
14 `--via ssh` (the default) uses the medium's own headless flow:
15 `alloy-installer-ssh.service` creates the `installer` account on the live medium
16 only, and the sshd drop-in makes that account's session BE `alloy install` under
17 a real tty. It is the route a person installing a screenless machine takes, so
18 it is the one worth exercising.
19
20 It could not install anything until 2026-08-26. `alloy install` does not
21 escalate, and that session landed unprivileged:
22
23 error: Installing to disk: Querying root privilege: This command must be
24 executed as the root user
25 wipefs: error: /dev/vda: probing initialization failed: Permission denied
26
27 Found by running this script (GO alloy `2cf04f20`), and fixed by running the
28 wizard under `run0` with a polkit grant for the one action run0 asks for. The
29 Containerfile asserts the three files still agree; if that assertion ever fails,
30 this is the route that stops working.
31
32 `--via serial` boots GRUB's debug entry instead, which carries `alloy.debug` and
33 so starts `alloy-debug-shell@ttyS0`: a root bash on the guest's serial console.
34 It was the default while the ssh route was broken, and it is worth keeping for
35 the case that comes back, and for a medium built without a baked pubkey.
36
37 The answers are the ones a regression test wants and not the ones a person
38 would pick:
39
40 encryption OFF The unlock path is a whole second thing to go wrong, and
41 nothing downstream of here touches LUKS. `--encrypt` turns
42 it back on for a run that means to exercise it.
43 a pubkey The installed machine has to be reachable afterwards or
44 nothing can be asserted about it.
45
46 Use:
47
48 install_drive.py --key ~/.ssh/id_ed25519
49 install_drive.py --via serial --pubkey "$(cat ~/.ssh/id_ed25519.pub)"
50
51 Exits 0 when the install reports success, 1 when it reports failure, and 3 when
52 the run could not get far enough to have a verdict. The screen is printed on
53 every path, because a failed install's own error is the thing worth reading and
54 it is on it.
55 """
56
57 import argparse
58 import os
59 import re
60 import sys
61 import time
62
63 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
64 from tui import Session, SocketSession, Timeout # noqa: E402
65 from vm import Monitor # noqa: E402
66
67 SCRATCH = os.environ.get(
68 "VM_STATE", os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
69 )
70
71 ENTER = "\r"
72 TAB = "\t"
73 SPACE = " "
74
75
76 def ssh_argv(key, user, port):
77 return [
78 "ssh", "-tt",
79 # The medium is rebuilt constantly and always answers on the same
80 # forwarded port, so a known_hosts entry here is a guaranteed false
81 # alarm rather than a check.
82 "-o", "StrictHostKeyChecking=no",
83 "-o", "UserKnownHostsFile=/dev/null",
84 "-o", "LogLevel=ERROR",
85 "-o", "ConnectTimeout=10",
86 "-p", str(port), "-i", key, "%s@127.0.0.1" % user,
87 ]
88
89
90 def connect_ssh(key, port, timeout, first_step=r"step \d+ of 6"):
91 """Keep trying until the medium's sshd is up, or give up saying so.
92
93 A live ISO takes tens of seconds to reach multi-user, and the account this
94 logs in as is created by a unit inside that boot, so early attempts are
95 expected to fail. What is not expected is all of them failing, which is
96 what the deadline turns into a message.
97
98 `first_step` is what counts as the wizard having drawn itself. It is a
99 pattern rather than `step 1 of 6` because a medium carrying an answer sheet
100 opens on the first question it cannot answer, which is not step 1; a caller
101 testing that asserts the number itself once the screen is up."""
102 deadline = time.time() + timeout
103 last = ""
104 while time.time() < deadline:
105 s = Session(ssh_argv(key, "installer", port),
106 log_path=os.path.join(SCRATCH, "install-drive.raw"))
107 try:
108 s.wait_for(first_step, min(45.0, max(5.0, deadline - time.time())))
109 return s
110 except Timeout as e:
111 last = e.screen
112 s.close()
113 time.sleep(3)
114 raise SystemExit(
115 "error: the installer never drew its first step on port %d.\n"
116 "The medium has to carry a baked pubkey matching the key given here -- "
117 "build it with `--build-arg ALLOY_SSH_KEY=\"$(cat <key>.pub)\"` -- and "
118 "alloy-installer-ssh.service only creates the account when the kernel "
119 "command line carries `alloy.installer`, which only the ISO's GRUB "
120 "entries set.\nThe last thing the session showed:\n\n%s\n" % (port, last)
121 )
122
123
124 # One GRUB menu row: the box border, then the mark, then the title. Keyed on
125 # the titles this ISO writes rather than on a row number, and the mark is
126 # matched AFTER the border on purpose. `line.strip()` is not enough: it leaves
127 # the `\u2502` in front, so `startswith("*")` is false on the selected row and a
128 # navigator built on it walks to the bottom of the menu and stays there.
129 GRUB_ROW = re.compile(r"^[^\w*]*(\*?)\s*((?:Install Alloy|Initramfs shell).*?)\s*$")
130
131
132 def grub_menu(s):
133 """The menu as `(selected, title)` pairs, or an empty list if it is not up."""
134 rows = []
135 for line in s.screen.text().splitlines():
136 hit = GRUB_ROW.match(line)
137 if hit:
138 rows.append((hit.group(1) == "*", hit.group(2)))
139 return rows
140
141
142 def grub_select(s, entry, timeout=120.0):
143 """Stop GRUB's countdown, move to the entry whose title contains `entry`,
144 and boot it.
145
146 GRUB draws its menu on the serial console (`make-iso.sh` puts
147 `console=ttyS0` on every entry) and marks the current row. So which entry
148 is selected is readable, and this navigates by reading rather than by
149 counting keystrokes from an assumed starting row.
150
151 That is the fix for two of the things build/vmtest/README.md records as
152 costing an afternoon: the countdown expiring before the harness is ready,
153 and pressing return without knowing what is highlighted. Any keypress stops
154 the countdown, so the loop below holds the menu open by pressing one, and
155 nothing is committed until the mark is where it should be.
156 """
157 monitor = Monitor()
158 end = time.time() + timeout
159 # Down-then-up is a pair that leaves the selection where it found it, which
160 # is what makes holding the menu open safe to do before it has been read.
161 while not grub_menu(s):
162 if time.time() >= end:
163 raise SystemExit("error: GRUB's menu never appeared on the serial console.\n\n%s\n"
164 % s.screen.text())
165 monitor.sendkey("down")
166 monitor.sendkey("up")
167 s.pump(0.3)
168
169 titles = [title for _, title in grub_menu(s)]
170 want = next((i for i, title in enumerate(titles) if entry in title), None)
171 if want is None:
172 raise SystemExit("error: no GRUB entry matching %r. The menu reads:\n %s\n"
173 % (entry, "\n ".join(titles)))
174
175 for _ in range(len(titles) * 3 + 6):
176 marks = [i for i, (sel, _) in enumerate(grub_menu(s)) if sel]
177 if marks == [want]:
178 break
179 if not marks:
180 # The mark is repainted a moment after the move; wait for it rather
181 # than pressing again, which is how a navigator overshoots.
182 s.pump(0.4)
183 continue
184 monitor.sendkey("down" if marks[0] < want else "up")
185 s.pump(0.4)
186 else:
187 raise SystemExit("error: could not put GRUB's mark on %r.\n\n%s\n"
188 % (entry, s.screen.text()))
189
190 monitor.sendkey("ret")
191 s.pump(1.0)
192
193
194 # Split so the echoed command line does not contain the string being waited
195 # for. Without that, the wait is satisfied by the terminal echoing the command
196 # back before the shell has run it, and the next thing typed lands in a shell
197 # that is not ready.
198 READY = "VMTEST-SHELL-UP"
199 READY_CMD = 'echo VMTEST-SHELL"-"UP\n'
200
201
202 def connect_serial(rows, cols, timeout):
203 """Boot the debug entry, take its root shell, and start the installer in it.
204
205 The debug entry rather than the default one, and root rather than the ssh
206 account, because the ssh account cannot install: see the module docstring.
207 `alloy-debug-shell@ttyS0` is gated on `alloy.debug`, which only these GRUB
208 entries set, so this reaches nothing on an installed machine."""
209 s = SocketSession(os.path.join(SCRATCH, "serial.sock"), rows=rows, cols=cols,
210 log_path=os.path.join(SCRATCH, "install-drive.raw"))
211 grub_select(s, "root shell on tty9")
212
213 # The shell arrives some tens of seconds into the boot, and prompts differ,
214 # so this asks rather than matching a prompt.
215 end = time.time() + timeout
216 while True:
217 s.send("\n" + READY_CMD, settle=1.0)
218 if READY in s.screen.text():
219 break
220 if time.time() >= end:
221 raise SystemExit("error: no root shell on the serial console.\n\n%s\n"
222 % s.screen.text())
223
224 # The size is the guest's, not an ioctl from this end: a serial console has
225 # no window size to inherit and defaults to 80x24, which is narrower than
226 # the installer's own 80-column budget leaves room for.
227 s.send("stty rows %d cols %d; export TERM=xterm-256color\n" % (rows, cols), settle=1.0)
228 s.send("clear; alloy install\n", settle=2.0)
229 s.wait_for(r"step 1 of 6", 60)
230 return s
231
232
233 MARKER = MARKER = "\u25b6" # alloy_tui::selection::MARKER, the selected-row gutter mark.
234
235
236 def selected_row(s):
237 """The name on the row the cursor is on, read off the gutter marker.
238
239 AlloyList draws `MARKER` on the selected row and a space on every other, so
240 which row is selected is on the screen rather than something to be counted.
241 That is what makes the walk below self-correcting: it checks where it
242 landed instead of assuming a starting index and an ordering."""
243 hit = re.search(re.escape(MARKER) + r"\s+(\S+)", s.screen.text())
244 return hit.group(1) if hit else None
245
246
247 def pick_disk(s, want, limit=40):
248 """Put the cursor on `want`.
249
250 `k` to the top first, because the cursor clamps rather than wrapping
251 (alloy_tui::Cursor::move_by) so a long enough run lands on row 0 from
252 anywhere, and because AlloyList derives its scroll offset from the
253 selection — at the top, what is on screen starts at the first disk.
254
255 Then down one row at a time, reading the marker after each. Stepping and
256 checking rather than jumping to a counted index is what keeps this honest
257 about a list longer than the pane."""
258 s.send("k" * limit, settle=0.8)
259 seen = []
260 for _ in range(limit):
261 here = selected_row(s)
262 if here is None:
263 raise SystemExit(
264 "error: the disk step is showing no selected row.\n\n%s\n" % s.screen.text()
265 )
266 if here == want:
267 return here
268 if seen and here == seen[-1]:
269 break # the bottom: the cursor clamped and stopped moving
270 seen.append(here)
271 s.send("j", settle=0.4)
272 raise SystemExit(
273 "error: no disk called %r on the disk step. The rows it stepped through: %s\n\n%s\n"
274 % (want, ", ".join(seen) or "(none)", s.screen.text())
275 )
276
277
278 def drive(s, args):
279 # 1 of 6, the disk.
280 pick_disk(s, args.disk)
281 s.send(ENTER)
282
283 # 2 of 6, the machine's name. Enter submits from the name field; the
284 # timezone checkbox below it has a default and is left at it.
285 s.wait_for(r"step 2 of 6", 30)
286 s.send(args.hostname)
287 s.send(ENTER)
288
289 # 3 of 6, the account. Tab moves between the four fields, and the pubkey is
290 # the last of them.
291 s.wait_for(r"step 3 of 6", 30)
292 s.send(args.user)
293 s.send(TAB)
294 s.send(args.password)
295 s.send(TAB)
296 s.send(args.password)
297 s.send(TAB)
298 s.send(args.pubkey)
299 s.send(ENTER)
300
301 # 4 of 6, encryption. The checkbox has focus and defaults to on, so a space
302 # here is what turns it off.
303 s.wait_for(r"step 4 of 6", 30)
304 if not args.encrypt:
305 s.send(SPACE)
306 else:
307 s.send(TAB)
308 s.send(args.passphrase)
309 s.send(TAB)
310 s.send(args.passphrase)
311 s.send(ENTER)
312
313 # 5 of 6, the review. It no longer installs; it advances.
314 s.wait_for(r"step 5 of 6", 30)
315 s.send(ENTER)
316
317 # 6 of 6, the credits, which is the step that acts. The first Enter raises
318 # the erase confirmation and the second answers it — the footer says
319 # `enter install` and means it two keystrokes from now.
320 s.wait_for(r"step 6 of 6", 30)
321 s.send(ENTER)
322 # The modal's own sentence, not its footer. `enter confirm` is what any
323 # modal's footer reads, so waiting on that would also be satisfied by a
324 # different one — and on this screen the next Enter erases a disk.
325 s.wait_for(r"Erase .* and install Alloy", 30)
326 s.send(ENTER)
327
328
329 def wait_for_verdict(s, seconds):
330 """Wait for the run to finish, and say which way.
331
332 Read off the footer, which is the installer's own statement about its
333 state: the run screen offers `esc done` only once the sequence is over, and
334 adds `r reboot` only when it succeeded (InstallView::hints). So the two
335 outcomes are distinguishable without matching on any message text."""
336 s.wait_for(r"esc\s+done", seconds)
337 # One more frame, so a `reboot` hint drawn in the same repaint as `done`
338 # is not missed by reading between the two writes.
339 s.pump(1.0)
340 return "reboot" in s.screen.text()
341
342
343 def main():
344 p = argparse.ArgumentParser(description="drive `alloy install` to the end")
345 p.add_argument("--via", choices=("ssh", "serial"), default="ssh",
346 help="ssh: the medium's own headless installer account (the default; "
347 "needs a medium whose baked pubkey matches --key). serial: GRUB's "
348 "debug entry and its root shell")
349 p.add_argument("--rows", type=int, default=40)
350 p.add_argument("--cols", type=int, default=120)
351 p.add_argument("--key", default=os.path.expanduser("~/.ssh/id_ed25519"),
352 help="--via ssh: private key matching the pubkey baked into the medium")
353 p.add_argument("--pubkey", default=None,
354 help="public key for the account being created (default: --key + .pub)")
355 p.add_argument("--port", type=int, default=2222)
356 p.add_argument("--disk", default="vda")
357 p.add_argument("--hostname", default="alloytest")
358 p.add_argument("--user", default="tester")
359 p.add_argument("--password", default="alloytest")
360 p.add_argument("--encrypt", action="store_true",
361 help="leave encryption on, and answer its passphrase")
362 p.add_argument("--passphrase", default="alloytestalloytest")
363 p.add_argument("--connect-timeout", type=float, default=300.0)
364 p.add_argument("--install-timeout", type=float, default=1800.0)
365 args = p.parse_args()
366
367 if args.pubkey is None:
368 path = args.key + ".pub"
369 if not os.path.exists(path):
370 raise SystemExit("error: no %s; pass --pubkey" % path)
371 args.pubkey = open(path).read().strip()
372
373 if args.via == "ssh":
374 s = connect_ssh(args.key, args.port, args.connect_timeout, r"step 1 of 6")
375 else:
376 s = connect_serial(args.rows, args.cols, args.connect_timeout)
377 try:
378 drive(s, args)
379 print("==> installing; this is the long part", flush=True)
380 ok = wait_for_verdict(s, args.install_timeout)
381 print(s.screen.text())
382 if not ok:
383 print("\nerror: the installer finished without offering a reboot, "
384 "which is how it says the install failed", file=sys.stderr)
385 return 1
386 return 0
387 except Timeout as e:
388 print("error: %s" % e, file=sys.stderr)
389 return 3
390 finally:
391 # Left running, the ForceCommand session would keep the installer alive
392 # on a medium the scenario is about to power off.
393 s.close()
394
395
396 if __name__ == "__main__":
397 sys.exit(main())
398